├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── 10_bug_report.yml │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── actionlint-matcher.json ├── dependabot.yml └── workflows │ ├── benchmark-ci.yml │ ├── benchmark.yml │ ├── build.yml │ ├── codeql.yml │ ├── container-scan.yml │ ├── dependency-review.yml │ ├── lint.yml │ ├── npm-audit-fix.yml │ └── spectral.yml ├── .gitignore ├── .markdownlint.json ├── .spectral.yaml ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── .vsconfig ├── API.ruleset ├── API.slnx ├── Directory.Build.props ├── Directory.Packages.props ├── LICENSE ├── NuGet.config ├── README.md ├── SECURITY.md ├── benchmark-crank.ps1 ├── benchmark.ps1 ├── benchmark.yml ├── build.ps1 ├── crank.ps1 ├── crank.yml ├── global.json ├── src └── API │ ├── .npmrc │ ├── .prettierignore │ ├── API.csproj │ ├── ApiBuilder.cs │ ├── ApiModule.cs │ ├── ApplicationJsonSerializerContext.cs │ ├── ApplicationTelemetry.cs │ ├── Extensions │ ├── HttpRequestExtensions.cs │ ├── ILoggingBuilderExtensions.cs │ ├── IServiceCollectionExtensions.cs │ ├── ResultsExtensions.cs │ └── TelemetryExtensions.cs │ ├── GitHubModule.cs │ ├── GitMetadata.cs │ ├── Middleware │ ├── CustomHttpHeadersMiddleware.cs │ └── PyroscopeK6Middleware.cs │ ├── Models │ ├── GitHubAccessToken.cs │ ├── GitHubDeviceCode.cs │ ├── GuidResponse.cs │ ├── HashRequest.cs │ ├── HashResponse.cs │ ├── LayoutModel.cs │ ├── MachineKeyResponse.cs │ ├── MetaModel.cs │ └── TimeResponse.cs │ ├── OpenApi │ ├── AddApiInfo.cs │ └── ProblemDetailsExampleProvider.cs │ ├── Options │ ├── AnalyticsOptions.cs │ ├── ApiCorsOptions.cs │ ├── ApiOptions.cs │ ├── AuthorOptions.cs │ ├── AuthorSocialMediaOptions.cs │ ├── DocumentationOptions.cs │ ├── ExternalLinksOptions.cs │ ├── LicenseOptions.cs │ ├── MetadataOptions.cs │ ├── ReportOptions.cs │ └── SiteOptions.cs │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Slices │ ├── Docs.cshtml │ ├── Error.cshtml │ ├── Home.cshtml │ ├── _Footer.cshtml │ ├── _Layout.cshtml │ ├── _Links.cshtml │ ├── _Meta.cshtml │ ├── _Navbar.cshtml │ ├── _Scripts.cshtml │ ├── _Styles.cshtml │ └── _ViewImports.cshtml │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── assets │ ├── scripts │ │ └── main.ts │ └── styles │ │ └── main.css │ ├── eslint.config.js │ ├── package-lock.json │ ├── package.json │ ├── tsconfig.json │ ├── webpack.config.cjs │ └── wwwroot │ ├── BingSiteAuth.xml │ ├── browserconfig.xml │ ├── error.html │ ├── favicon.ico │ ├── googled1107923138d0b79.html │ ├── gss.xsl │ ├── humans.txt │ ├── keybase.txt │ ├── manifest.webmanifest │ ├── robots.txt │ ├── robots933456.txt │ └── sitemap.xml ├── startvs.cmd ├── startvscode.cmd ├── stylecop.json └── tests ├── API.Benchmarks ├── API.Benchmarks.csproj ├── ApiBenchmarks.cs ├── ApiServer.cs ├── Program.cs └── Properties │ └── launchSettings.json └── API.Tests ├── API.Tests.csproj ├── CategoryAttribute.cs ├── EndToEnd ├── ApiCollection.cs ├── ApiFixture.cs ├── ApiTests.cs ├── EndToEndTest.cs └── ResourceTests.cs └── Integration ├── GitHubTests.cs ├── IntegrationTest.cs ├── OpenApiTests.Json_Schema_Is_Correct.verified.txt ├── OpenApiTests.Yaml_Schema_Is_Correct.verified.txt ├── OpenApiTests.cs ├── ResourceTests.cs ├── TestServerCollection.cs ├── TestServerFixture.cs ├── TimeTests.cs └── ToolsTests.cs /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/vscode/devcontainers/universal:latest@sha256:8b30c9dcb2e9e39ec850171def409cfb34ef0c951ba7b6fe3e9996518642903d 2 | 3 | # Suppress an apt-key warning about standard out not being a terminal. Use in this script is safe. 4 | ENV APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn 5 | 6 | # Install Google Chrome 7 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - 8 | RUN echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | sudo tee /etc/apt/sources.list.d/google-chrome.list 9 | RUN sudo apt-get update 10 | RUN sudo apt-get --yes install google-chrome-stable 2>&1 11 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "C# (.NET)", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | "customizations": { 7 | "vscode": { 8 | "extensions": [ 9 | "editorconfig.editorconfig", 10 | "ms-dotnettools.csharp", 11 | "ms-vscode.PowerShell" 12 | ] 13 | } 14 | }, 15 | "forwardPorts": [ 5000 ], 16 | "portsAttributes":{ 17 | "5000": { 18 | "label": "API", 19 | "onAutoForward": "openBrowserOnce", 20 | "protocol": "http" 21 | } 22 | }, 23 | "postCreateCommand": "./build.ps1 -SkipTests", 24 | "remoteEnv": { 25 | "PATH": "/root/.dotnet/tools:${containerWorkspaceFolder}/.dotnet:${containerEnv:PATH}" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = crlf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{config,csproj,css,js,json,props,ruleset,slnx,targets,ts,vsconfig,xml,yml}] 12 | indent_size = 2 13 | 14 | [*.{received,verified}.{txt,xml,json}] 15 | charset = utf-8-bom 16 | end_of_line = lf 17 | indent_size = unset 18 | indent_style = unset 19 | insert_final_newline = false 20 | tab_width = unset 21 | trim_trailing_whitespace = false 22 | 23 | [*.{cs,ts}] 24 | file_header_template = Copyright (c) Martin Costello, 2016. All rights reserved.\nLicensed under the MIT license. See the LICENSE file in the project root for full license information. 25 | 26 | [*.{cs,csx,vb,vbx}] 27 | charset = utf-8-bom 28 | 29 | [*.sln] 30 | charset = unset 31 | end_of_line = unset 32 | indent_size = unset 33 | indent_style = unset 34 | insert_final_newline = unset 35 | trim_trailing_whitespace = unset 36 | 37 | [*.cs] 38 | 39 | dotnet_analyzer_diagnostic.category-Style.severity = warning 40 | 41 | dotnet_diagnostic.IDE0005.severity = error 42 | dotnet_diagnostic.IDE0045.severity = silent 43 | dotnet_diagnostic.IDE0046.severity = silent 44 | dotnet_diagnostic.IDE0058.severity = silent 45 | dotnet_diagnostic.IDE0072.severity = silent 46 | dotnet_diagnostic.IDE0079.severity = silent 47 | 48 | # HACK Workaround for https://github.com/dotnet/runtime/issues/100474 49 | dotnet_diagnostic.IL2026.severity = silent 50 | dotnet_diagnostic.IL3050.severity = silent 51 | 52 | dotnet_sort_system_directives_first = true 53 | 54 | dotnet_style_qualification_for_field = false:suggestion 55 | dotnet_style_qualification_for_property = false:suggestion 56 | dotnet_style_qualification_for_method = false:suggestion 57 | dotnet_style_qualification_for_event = false:suggestion 58 | 59 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion 60 | dotnet_style_predefined_type_for_member_access = true:suggestion 61 | 62 | dotnet_style_coalesce_expression = true:suggestion 63 | dotnet_style_collection_initializer = true:suggestion 64 | dotnet_style_explicit_tuple_names = true:suggestion 65 | dotnet_style_null_propagation = true:suggestion 66 | dotnet_style_object_initializer = true:suggestion 67 | 68 | csharp_style_var_for_built_in_types = false:suggestion 69 | csharp_style_var_when_type_is_apparent = true:suggestion 70 | csharp_style_var_elsewhere = true:suggestion 71 | 72 | csharp_style_expression_bodied_constructors = false:none 73 | csharp_style_expression_bodied_methods = false:none 74 | csharp_style_expression_bodied_operators = false:none 75 | 76 | csharp_style_expression_bodied_accessors = true:none 77 | csharp_style_expression_bodied_indexers = true:none 78 | csharp_style_expression_bodied_properties = true:none 79 | 80 | csharp_style_expression_bodied_local_functions = when_on_single_line 81 | 82 | csharp_style_conditional_delegate_call = true:suggestion 83 | csharp_style_inlined_variable_declaration = true:suggestion 84 | csharp_style_namespace_declarations = file_scoped 85 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 86 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 87 | csharp_style_throw_expression = true:suggestion 88 | 89 | csharp_new_line_before_catch = true 90 | csharp_new_line_before_else = true 91 | csharp_new_line_before_finally = true 92 | csharp_new_line_before_open_brace = all 93 | csharp_new_line_before_members_in_anonymous_types = true 94 | csharp_new_line_before_members_in_object_initializers = true 95 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh eol=lf 2 | package-lock.json eol=lf 3 | *.verified.json text eol=lf working-tree-encoding=UTF-8 4 | *.verified.txt text eol=lf working-tree-encoding=UTF-8 5 | *.verified.xml text eol=lf working-tree-encoding=UTF-8 6 | -------------------------------------------------------------------------------- /.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/api/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: textarea 13 | attributes: 14 | label: Describe the bug 15 | description: A clear and concise description of what the bug is. 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Expected behaviour 21 | description: A clear and concise description of what you expected to happen. 22 | validations: 23 | required: false 24 | - type: textarea 25 | attributes: 26 | label: Actual behaviour 27 | description: What actually happens. 28 | validations: 29 | required: false 30 | - type: textarea 31 | attributes: 32 | label: Steps to reproduce 33 | description: | 34 | Provide a link to a [minimalistic project which reproduces this issue (repro)](https://stackoverflow.com/help/mcve) hosted in a **public** GitHub repository. 35 | 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. 36 | 37 | This issue will be closed if: 38 | - The behaviour you're reporting cannot be easily reproduced. 39 | - The issue is a duplicate of an existing issue. 40 | - The behaviour you're reporting is by design. 41 | validations: 42 | required: false 43 | - type: textarea 44 | attributes: 45 | label: Exception(s) (if any) 46 | description: Include any exception(s) and/or stack trace(s) you get when facing this issue. 47 | render: text 48 | validations: 49 | required: false 50 | - type: input 51 | attributes: 52 | label: .NET Version 53 | description: | 54 | Run `dotnet --version` to get the .NET SDK version you're using. 55 | Alternatively, which target framework(s) (e.g. `net9.0`) does the project you're using the package with target? 56 | placeholder: 9.0.100 57 | validations: 58 | required: false 59 | - type: textarea 60 | attributes: 61 | label: Anything else? 62 | description: | 63 | Links? References? Anything that will give us more context about the issue you are encountering is useful. 64 | 65 | 💡Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 66 | validations: 67 | required: false 68 | -------------------------------------------------------------------------------- /.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: npm 10 | directory: "/src/API" 11 | groups: 12 | babel: 13 | patterns: 14 | - "@babel/*" 15 | typescript-eslint: 16 | patterns: 17 | - "@typescript-eslint/*" 18 | schedule: 19 | interval: daily 20 | time: "05:30" 21 | timezone: Europe/London 22 | open-pull-requests-limit: 99 23 | - package-ecosystem: nuget 24 | directory: "/" 25 | groups: 26 | Microsoft.OpenApi: 27 | patterns: 28 | - Microsoft.OpenApi* 29 | OpenTelemetry: 30 | patterns: 31 | - OpenTelemetry* 32 | Pyroscope: 33 | patterns: 34 | - Pyroscope* 35 | xunit: 36 | patterns: 37 | - Verify.Xunit* 38 | - xunit* 39 | schedule: 40 | interval: daily 41 | time: "05:30" 42 | timezone: Europe/London 43 | open-pull-requests-limit: 99 44 | -------------------------------------------------------------------------------- /.github/workflows/benchmark-ci.yml: -------------------------------------------------------------------------------- 1 | name: benchmark-ci 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 | 24 | jobs: 25 | benchmark: 26 | runs-on: ubuntu-latest 27 | 28 | permissions: 29 | contents: read 30 | 31 | steps: 32 | 33 | - name: Checkout code 34 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 35 | with: 36 | filter: 'tree:0' 37 | show-progress: false 38 | 39 | - name: Setup .NET SDK 40 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 41 | 42 | - name: Setup Node 43 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 44 | with: 45 | node-version: '22' 46 | 47 | - name: Run benchmarks 48 | shell: pwsh 49 | run: ./benchmark.ps1 50 | 51 | - name: Publish BenchmarkDotNet artifacts 52 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 53 | if: ${{ !cancelled() }} 54 | with: 55 | name: artifacts 56 | path: ./BenchmarkDotNet.Artifacts/results/* 57 | if-no-files-found: error 58 | 59 | - name: Get repository name 60 | id: get-repo-name 61 | shell: pwsh 62 | run: | 63 | $repoName = ${env:GITHUB_REPOSITORY}.Split("/")[-1] 64 | "repo-name=${repoName}" >> ${env:GITHUB_OUTPUT} 65 | 66 | - name: Publish results 67 | uses: martincostello/benchmarkdotnet-results-publisher@abcb3ce3975e1e86f06f2c04e3a4059ccdb91cc1 # v1.0.2 68 | with: 69 | branch: ${{ github.ref_name }} 70 | comment-on-threshold: true 71 | name: API 72 | output-file-path: '${{ steps.get-repo-name.outputs.repo-name }}/data.json' 73 | repo: '${{ github.repository_owner }}/benchmarks' 74 | repo-token: ${{ secrets.BENCHMARKS_TOKEN }} 75 | 76 | - name: Output summary 77 | shell: pwsh 78 | env: 79 | REPO_NAME: ${{ steps.get-repo-name.outputs.repo-name }} 80 | run: | 81 | $summary += "`n`n" 82 | $summary += "View benchmark results history [here](https://benchmarks.martincostello.com/?repo=${env:REPO_NAME}&branch=${env:GITHUB_REF_NAME})." 83 | $summary >> ${env:GITHUB_STEP_SUMMARY} 84 | -------------------------------------------------------------------------------- /.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 | issue_comment: 12 | types: [ created ] 13 | 14 | permissions: {} 15 | 16 | jobs: 17 | benchmark: 18 | name: benchmark 19 | runs-on: ubuntu-latest 20 | if: | 21 | github.event.repository.fork == false && 22 | github.event.issue.pull_request != '' && 23 | startsWith(github.event.comment.body, '/benchmark') 24 | 25 | permissions: 26 | contents: read 27 | pull-requests: write 28 | statuses: write 29 | 30 | steps: 31 | 32 | - name: Parse comment 33 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 34 | id: parse-comment 35 | with: 36 | result-encoding: string 37 | script: | 38 | const availableBenchmarks = { 39 | "guid": "Runs the GUID benchmark", 40 | "openapi": "Runs the OpenAPI benchmark", 41 | "root": "Runs the root benchmark", 42 | "time": "Runs the time benchmark", 43 | }; 44 | 45 | const owner = context.payload.repository.owner.login; 46 | const repo = context.payload.repository.name; 47 | const username = context.payload.comment.user.login; 48 | const issue_number = context.issue.number; 49 | 50 | try { 51 | await github.rest.repos.checkCollaborator({ 52 | owner, 53 | repo, 54 | username, 55 | }); 56 | } catch (error) { 57 | const message = `@${username} You are not a repository collaborator; benchmarking is not allowed.`; 58 | await github.rest.issues.createComment({ 59 | owner, 60 | repo, 61 | issue_number, 62 | body: message, 63 | }); 64 | throw new Error(message); 65 | } 66 | 67 | core.info(`Verified ${username} is a repo collaborator.`); 68 | 69 | if (context.eventName !== "issue_comment") { 70 | throw "Error: This action only works on issue_comment events."; 71 | } 72 | 73 | // Extract the benchmark arguments from the comment 74 | const regex = /\/benchmark ([a-zA-Z\d\/\.\-\_]+)/; 75 | const arguments = regex.exec(context.payload.comment.body); 76 | 77 | // Generate help text with all available commands 78 | if (arguments == null || arguments.length < 2 || !availableBenchmarks.hasOwnProperty(arguments[1])) { 79 | let body = 'The `/benchmark` command accepts these values:\n'; 80 | for (const key in availableBenchmarks) { 81 | body += `- \`/benchmark ${key}\`: ${availableBenchmarks[key]}\n`; 82 | } 83 | 84 | await github.rest.issues.createComment({ 85 | issue_number, 86 | owner, 87 | repo, 88 | body, 89 | }); 90 | 91 | throw new Error('Error: Invalid arguments, workflow stopped.'); 92 | } 93 | 94 | const benchmark = arguments[1]; 95 | const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`; 96 | 97 | core.info(`Benchmark: ${benchmark}`); 98 | 99 | await github.rest.issues.createComment({ 100 | owner, 101 | repo, 102 | issue_number, 103 | body: `Started [${benchmark} benchmark](${workflowUrl}). :hourglass:`, 104 | }); 105 | 106 | const { data: pull } = await github.rest.pulls.get({ 107 | owner, 108 | repo, 109 | pull_number: issue_number, 110 | }); 111 | const sha = pull.head.sha; 112 | 113 | await github.rest.repos.createCommitStatus({ 114 | owner, 115 | repo, 116 | sha, 117 | state: 'pending', 118 | target_url: workflowUrl, 119 | description: `Benchmark ${benchmark} started...`, 120 | context: `benchmarks / ${benchmark.toLowerCase()}`, 121 | }); 122 | 123 | core.setOutput('benchmark', benchmark); 124 | core.setOutput('commit-sha', sha); 125 | 126 | - name: Checkout code 127 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 128 | with: 129 | filter: 'tree:0' 130 | show-progress: false 131 | 132 | - name: Setup .NET SDK for crank 133 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 134 | with: 135 | dotnet-version: 8.0.x 136 | 137 | - name: Setup .NET SDK for app 138 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 139 | 140 | - name: Setup Node 141 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 142 | with: 143 | node-version: '22' 144 | 145 | - name: Install crank 146 | shell: pwsh 147 | env: 148 | CRANK_VERSION: '0.2.0-*' 149 | run: | 150 | dotnet tool install --global Microsoft.Crank.Agent --version ${env:CRANK_VERSION} 151 | dotnet tool install --global Microsoft.Crank.Controller --version ${env:CRANK_VERSION} 152 | dotnet tool install --global Microsoft.Crank.PullRequestBot --version ${env:CRANK_VERSION} 153 | 154 | - name: Run crank 155 | shell: pwsh 156 | env: 157 | ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 158 | BENCHMARK_NAME: ${{ steps.parse-comment.outputs.benchmark }} 159 | PULL_REQUEST_ID: ${{ github.event.issue.number }} 160 | run: | 161 | ./benchmark-crank.ps1 ` 162 | -Benchmark ${env:BENCHMARK_NAME} ` 163 | -Repository "${env:GITHUB_SERVER_URL}/${env:GITHUB_REPOSITORY}" ` 164 | -PullRequestId ${env:PULL_REQUEST_ID} ` 165 | -AccessToken ${env:ACCESS_TOKEN} ` 166 | -PublishResults 167 | 168 | - name: Post result comment 169 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 170 | if: ${{ !cancelled() }} 171 | env: 172 | BENCHMARK_NAME: ${{ steps.parse-comment.outputs.benchmark }} 173 | COMMIT_SHA: ${{ steps.parse-comment.outputs.commit-sha }} 174 | OUTCOME: ${{ job.status }} 175 | with: 176 | script: | 177 | const owner = context.repo.owner; 178 | const repo = context.repo.repo; 179 | 180 | const benchmark = process.env.BENCHMARK_NAME; 181 | const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`; 182 | 183 | const succeeded = process.env.OUTCOME === 'success'; 184 | const outcome = succeeded ? 'succeeded' : 'failed'; 185 | const emoji = succeeded ? ':white_check_mark:' : ':x:'; 186 | const state = succeeded ? 'success' : 'failure'; 187 | 188 | await github.rest.issues.createComment({ 189 | owner, 190 | repo, 191 | issue_number: context.issue.number, 192 | body: `Benchmark [${benchmark}](${workflowUrl}) ${outcome} ${emoji}`, 193 | }); 194 | 195 | await github.rest.repos.createCommitStatus({ 196 | owner, 197 | repo, 198 | sha: process.env.COMMIT_SHA, 199 | state, 200 | target_url: workflowUrl, 201 | description: `Benchmark ${benchmark} ${outcome}.`, 202 | context: `benchmarks / ${benchmark.toLowerCase()}`, 203 | }); 204 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: codeql 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 * * 1' 13 | workflow_dispatch: 14 | 15 | permissions: {} 16 | 17 | jobs: 18 | analysis: 19 | runs-on: ubuntu-latest 20 | 21 | permissions: 22 | actions: read 23 | contents: read 24 | security-events: write 25 | 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | language: [ 'actions', 'csharp', 'javascript' ] 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | with: 35 | filter: 'tree:0' 36 | show-progress: false 37 | 38 | - name: Initialize CodeQL 39 | uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 40 | with: 41 | build-mode: none 42 | languages: ${{ matrix.language }} 43 | queries: security-and-quality 44 | 45 | - name: Perform CodeQL Analysis 46 | uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 47 | with: 48 | category: '/language:${{ matrix.language }}' 49 | 50 | codeql: 51 | if: ${{ !cancelled() }} 52 | needs: [ analysis ] 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - name: Report status 57 | shell: bash 58 | env: 59 | SCAN_SUCCESS: ${{ !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} 60 | run: | 61 | if [ "${SCAN_SUCCESS}" == "true" ] 62 | then 63 | echo 'CodeQL analysis successful ✅' 64 | else 65 | echo 'CodeQL analysis failed ❌' 66 | exit 1 67 | fi 68 | -------------------------------------------------------------------------------- /.github/workflows/container-scan.yml: -------------------------------------------------------------------------------- 1 | name: container-scan 2 | 3 | on: 4 | schedule: 5 | - cron: '0 2 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: {} 9 | 10 | env: 11 | FORCE_COLOR: 3 12 | TERM: xterm 13 | 14 | jobs: 15 | scan-image: 16 | runs-on: ubuntu-latest 17 | if: github.event.repository.fork == false 18 | 19 | concurrency: 20 | group: ${{ github.workflow }} 21 | cancel-in-progress: false 22 | 23 | permissions: 24 | contents: read 25 | security-events: write 26 | 27 | steps: 28 | 29 | - name: Checkout code 30 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 31 | with: 32 | filter: 'tree:0' 33 | show-progress: false 34 | 35 | - name: Configure Trivy 36 | id: configure 37 | shell: pwsh 38 | run: | 39 | $registry = "${env:GITHUB_REPOSITORY_OWNER}.azurecr.io" 40 | $image = "${registry}/${env:GITHUB_REPOSITORY}:latest".ToLowerInvariant() 41 | "container-image=${image}" >> ${env:GITHUB_OUTPUT} 42 | 43 | - name: Run Trivy (SARIF) 44 | uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 # 0.31.0 45 | env: 46 | TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db:2 47 | TRIVY_USERNAME: ${{ secrets.TRIVY_USERNAME }} 48 | TRIVY_PASSWORD: ${{ secrets.TRIVY_PASSWORD }} 49 | with: 50 | format: sarif 51 | ignore-unfixed: true 52 | image-ref: ${{ steps.configure.outputs.container-image }} 53 | limit-severities-for-sarif: true 54 | output: trivy.sarif 55 | severity: CRITICAL,HIGH 56 | 57 | - name: Upload Trivy scan results 58 | uses: github/codeql-action/upload-sarif@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 59 | if: ${{ !cancelled() }} 60 | with: 61 | sarif_file: trivy.sarif 62 | 63 | - name: Run Trivy (JSON) 64 | uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 # 0.31.0 65 | env: 66 | TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db:2 67 | TRIVY_USERNAME: ${{ secrets.TRIVY_USERNAME }} 68 | TRIVY_PASSWORD: ${{ secrets.TRIVY_PASSWORD }} 69 | with: 70 | image-ref: ${{ steps.configure.outputs.container-image }} 71 | format: json 72 | ignore-unfixed: true 73 | output: trivy.json 74 | severity: CRITICAL,HIGH 75 | 76 | - name: Check for vulnerabilities 77 | id: check-for-vulnerabilities 78 | shell: pwsh 79 | run: | 80 | $report = Get-Content ./trivy.json | Out-String | ConvertFrom-Json 81 | 82 | $vulnerabilities = @() 83 | $hasVulnerabilities = $false 84 | 85 | foreach ($target in $report.Results) { 86 | foreach ($vulnerability in $target.Vulnerabilities) { 87 | $vulnerabilities += $vulnerability 88 | # Ignore vulnerabilities in the .NET application itself as a rebuild of the container won't fix these 89 | if ($target.Type -ne "dotnet-core") { 90 | $hasVulnerabilities = $true 91 | } 92 | } 93 | } 94 | 95 | "has-vulnerabilities=${hasVulnerabilities}".ToLowerInvariant() >> ${env:GITHUB_OUTPUT} 96 | 97 | $report = @( 98 | "# Container Image Vulnerability Report", 99 | "" 100 | ) 101 | 102 | if ($vulnerabilities.Length -eq 0) { 103 | $report += ":closed_lock_with_key: No vulnerabilities found." 104 | } else { 105 | $report += "| Library | Vulnerability | Severity | Status | Installed Version | Fixed Version | Title |" 106 | $report += "|:--------|:--------------|:---------|:-------|:------------------|:--------------|:------|" 107 | 108 | foreach ($vulnerability in $vulnerabilities) { 109 | $title = $vulnerability.Title 110 | if ([string]::IsNullOrEmpty($title)) { 111 | $title = $vulnerability.Description 112 | } 113 | 114 | $fixedVersion = $vulnerability.FixedVersion 115 | if ([string]::IsNullOrEmpty($fixedVersion)) { 116 | $fixedVersion = "N/A" 117 | } 118 | 119 | $report += "| $($vulnerability.PkgName) | $($vulnerability.VulnerabilityID) | $($vulnerability.Severity) | $($vulnerability.Status) | $($vulnerability.InstalledVersion) | ${fixedVersion} | [${title}]($($vulnerability.PrimaryURL)) |" 120 | } 121 | } 122 | 123 | $report += "" 124 | $report += "" 125 | 126 | ($report -Join "`n") >> ${env:GITHUB_STEP_SUMMARY} 127 | 128 | - name: Rebuild if any vulnerabilities found 129 | if: | 130 | github.event_name == 'schedule' && 131 | steps.check-for-vulnerabilities.outputs.has-vulnerabilities == 'true' 132 | env: 133 | GH_TOKEN: ${{ secrets.COSTELLOBOT_TOKEN }} 134 | run: gh workflow run build.yml 135 | -------------------------------------------------------------------------------- /.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 | 12 | jobs: 13 | dependency-review: 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: read 18 | 19 | steps: 20 | 21 | - name: Checkout code 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | with: 24 | filter: 'tree:0' 25 | show-progress: false 26 | 27 | - name: Review dependencies 28 | uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 29 | with: 30 | allow-licenses: 'Apache-2.0,BSD-3-Clause,MIT' 31 | -------------------------------------------------------------------------------- /.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 | 19 | env: 20 | FORCE_COLOR: 3 21 | TERM: xterm 22 | 23 | jobs: 24 | lint: 25 | runs-on: ubuntu-latest 26 | 27 | permissions: 28 | contents: read 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 | treat-warnings-as-errors: true 57 | 58 | - name: Lint PowerShell scripts 59 | shell: pwsh 60 | run: | 61 | $settings = @{ 62 | IncludeDefaultRules = $true 63 | Severity = @("Error", "Warning") 64 | } 65 | $issues = Invoke-ScriptAnalyzer -Path ${env:GITHUB_WORKSPACE} -Recurse -ReportSummary -Settings $settings 66 | foreach ($issue in $issues) { 67 | $severity = $issue.Severity.ToString() 68 | $level = $severity.Contains("Error") ? "error" : $severity.Contains("Warning") ? "warning" : "notice" 69 | Write-Output "::${level} file=$($issue.ScriptName),line=$($issue.Line),title=PSScriptAnalyzer::$($issue.Message)" 70 | } 71 | if ($issues.Count -gt 0) { 72 | exit 1 73 | } 74 | -------------------------------------------------------------------------------- /.github/workflows/npm-audit-fix.yml: -------------------------------------------------------------------------------- 1 | name: npm-audit-fix 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: {} 7 | 8 | jobs: 9 | regenerate: 10 | runs-on: [ ubuntu-latest ] 11 | 12 | concurrency: 13 | group: ${{ github.workflow }} 14 | cancel-in-progress: false 15 | 16 | steps: 17 | 18 | - name: Checkout code 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | with: 21 | filter: 'tree:0' 22 | show-progress: false 23 | token: ${{ secrets.COSTELLOBOT_TOKEN }} 24 | 25 | - name: Setup Node 26 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 27 | with: 28 | node-version: '20.x' 29 | 30 | - name: Install packages 31 | run: npm ci 32 | working-directory: ./src/API 33 | 34 | - name: Apply audit fixes 35 | run: npm audit fix 36 | working-directory: ./src/API 37 | 38 | - name: Push changes to GitHub 39 | id: push-changes 40 | shell: pwsh 41 | env: 42 | GIT_COMMIT_USER_EMAIL: ${{ vars.GIT_COMMIT_USER_EMAIL }} 43 | GIT_COMMIT_USER_NAME: ${{ vars.GIT_COMMIT_USER_NAME }} 44 | run: | 45 | $gitStatus = (git status --porcelain) 46 | 47 | if ([string]::IsNullOrEmpty($gitStatus)) { 48 | Write-Output "No changes to commit." 49 | exit 0 50 | } 51 | 52 | $branchName = "npm-audit-fix" 53 | 54 | git config user.email ${env:GIT_COMMIT_USER_EMAIL} | Out-Null 55 | git config user.name ${env:GIT_COMMIT_USER_NAME} | Out-Null 56 | git fetch origin --no-tags | Out-Null 57 | git rev-parse --verify --quiet "remotes/origin/${branchName}" | Out-Null 58 | 59 | if ($LASTEXITCODE -eq 0) { 60 | Write-Output "Branch ${branchName} already exists." 61 | exit 0 62 | } 63 | 64 | git checkout -b $branchName 65 | git add . 66 | git commit -m "Run npm audit fix`n`nRun ``npm audit fix`` to pick up latest fixes." 67 | 68 | if ($LASTEXITCODE -ne 0) { 69 | Write-Output "No changes to commit." 70 | exit 0 71 | } 72 | 73 | git push -u origin $branchName 74 | 75 | "branch-name=${branchName}" >> $env:GITHUB_OUTPUT 76 | "regenerated=true" >> $env:GITHUB_OUTPUT 77 | 78 | - name: Create pull request 79 | if: steps.push-changes.outputs.regenerated == 'true' 80 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 81 | env: 82 | BASE_BRANCH: ${{ github.event.repository.default_branch }} 83 | HEAD_BRANCH: ${{ steps.push-changes.outputs.branch-name }} 84 | with: 85 | github-token: ${{ secrets.COSTELLOBOT_TOKEN }} 86 | script: | 87 | const { repo, owner } = context.repo; 88 | const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`; 89 | 90 | const { data: pr } = await github.rest.pulls.create({ 91 | title: 'Run npm audit fix', 92 | owner, 93 | repo, 94 | head: process.env.HEAD_BRANCH, 95 | base: process.env.BASE_BRANCH, 96 | body: [ 97 | 'Run `npm audit fix` to pick up latest fixes.', 98 | '', 99 | `This pull request was generated by [GitHub Actions](${workflowUrl}).` 100 | ].join('\n') 101 | }); 102 | 103 | core.notice(`Created pull request ${owner}/${repo}#${pr.number}: ${pr.html_url}`); 104 | -------------------------------------------------------------------------------- /.github/workflows/spectral.yml: -------------------------------------------------------------------------------- 1 | name: spectral 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - dotnet-vnext 8 | - dotnet-nightly 9 | workflow_dispatch: 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | spectral: 15 | runs-on: ubuntu-latest 16 | 17 | permissions: 18 | contents: read 19 | 20 | steps: 21 | 22 | - name: Checkout code 23 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 24 | with: 25 | filter: 'tree:0' 26 | show-progress: false 27 | 28 | - name: Setup .NET SDK 29 | uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 30 | 31 | - name: Setup Node 32 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 33 | with: 34 | node-version: '22' 35 | 36 | - name: Install Spectral 37 | run: npm install --global @stoplight/spectral-cli 38 | 39 | - name: Generate OpenAPI document 40 | run: dotnet build ./src/API 41 | 42 | - name: Run Spectral 43 | run: spectral lint "./artifacts/openapi/*" --fail-severity warn --format github-actions 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dotnet 3 | .metadata 4 | .pyroscope 5 | .settings 6 | .vs 7 | _Chutzpah* 8 | _ReSharper* 9 | _UpgradeReport_Files/ 10 | artifacts/ 11 | Backup*/ 12 | BenchmarkDotNet.Artifacts/ 13 | bin 14 | Bin 15 | BuildOutput 16 | coverage 17 | coverage* 18 | junit.xml 19 | MSBuild_Logs/ 20 | node_modules 21 | obj 22 | packages 23 | project.lock.json 24 | src/API/wwwroot/assets 25 | src/API/wwwroot/openapi 26 | src/API/wwwroot/lib/* 27 | TestResults 28 | UpgradeLog*.htm 29 | UpgradeLog*.XML 30 | PublishProfiles 31 | *.binlog 32 | *.coverage 33 | *.DotSettings 34 | *.GhostDoc.xml 35 | *.log 36 | *.nupkg 37 | !.packages/*.nupkg 38 | *.opensdf 39 | *.[Pp]ublish.xml 40 | *.publishproj 41 | *.pubxml 42 | *.received.* 43 | *.sdf 44 | *.sln.cache 45 | *.sln.docstates 46 | *.sln.ide 47 | *.suo 48 | *.user 49 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, 3 | "MD040": false 4 | } 5 | -------------------------------------------------------------------------------- /.spectral.yaml: -------------------------------------------------------------------------------- 1 | extends: ['spectral:oas'] 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "davidanson.vscode-markdownlint", 4 | "editorconfig.editorconfig", 5 | "github.vscode-github-actions", 6 | "ms-dotnettools.csharp", 7 | "ms-vscode.PowerShell" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Launch API", 6 | "type": "coreclr", 7 | "request": "launch", 8 | "preLaunchTask": "build", 9 | "program": "${workspaceFolder}/src/API/bin/Debug/net9.0/API.dll", 10 | "args": [], 11 | "cwd": "${workspaceFolder}/src/API", 12 | "stopAtEntry": false, 13 | "internalConsoleOptions": "openOnSessionStart", 14 | "serverReadyAction": { 15 | "action": "openExternally", 16 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 17 | }, 18 | "env": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | }, 21 | "sourceFileMap": { 22 | "/Views": "${workspaceFolder}/src/API/Views" 23 | } 24 | }, 25 | { 26 | "name": "Run tests", 27 | "type": "coreclr", 28 | "request": "launch", 29 | "preLaunchTask": "build", 30 | "program": "dotnet", 31 | "args": [ 32 | "test" 33 | ], 34 | "cwd": "${workspaceFolder}/tests/API.Tests", 35 | "console": "internalConsole", 36 | "stopAtEntry": false, 37 | "internalConsoleOptions": "openOnSessionStart" 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "command": "dotnet", 4 | "args": [], 5 | "tasks": [ 6 | { 7 | "label": "build", 8 | "type": "shell", 9 | "command": "dotnet", 10 | "args": [ 11 | "build", 12 | "${workspaceRoot}/src/API/API.csproj" 13 | ], 14 | "problemMatcher": "$msCompile", 15 | "group": "build" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vsconfig: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "components": [ 4 | "Microsoft.VisualStudio.Component.CoreEditor", 5 | "Microsoft.VisualStudio.Workload.CoreEditor", 6 | "Microsoft.NetCore.Component.Runtime.9.0", 7 | "Microsoft.NetCore.Component.SDK", 8 | "Microsoft.VisualStudio.Component.Roslyn.Compiler", 9 | "Microsoft.VisualStudio.Component.Roslyn.LanguageServices", 10 | "Microsoft.VisualStudio.ComponentGroup.WebToolsExtensions", 11 | "Microsoft.VisualStudio.Component.IISExpress", 12 | "Microsoft.VisualStudio.Component.WebDeploy" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /API.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 | -------------------------------------------------------------------------------- /API.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 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | Martin Costello 4 | true 5 | $(MSBuildThisFileDirectory)API.ruleset 6 | false 7 | false 8 | true 9 | true 10 | latest 11 | true 12 | 9.0.$([MSBuild]::ValueOrDefault('$(GITHUB_RUN_NUMBER)', '0')) 13 | 14 | 15 | true 16 | $(NoWarn);419;1570;1573;1574;1584;1591;SA0001;SA1602 17 | 18 | 19 | -------------------------------------------------------------------------------- /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 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Martin Costello 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Martin Costello's API 2 | 3 | [![Build status](https://github.com/martincostello/api/actions/workflows/build.yml/badge.svg?branch=main&event=push)](https://github.com/martincostello/api/actions/workflows/build.yml?query=branch%3Amain+event%3Apush) 4 | [![codecov](https://codecov.io/gh/martincostello/api/branch/main/graph/badge.svg)](https://codecov.io/gh/martincostello/api) 5 | 6 | ## Overview 7 | 8 | Source code for building and deploying [api.martincostello.com](https://api.martincostello.com/). 9 | 10 | ## Feedback 11 | 12 | Any feedback or issues can be added to the issues for this project in [GitHub](https://github.com/martincostello/api/issues). 13 | 14 | ## Repository 15 | 16 | The repository is hosted in [GitHub](https://github.com/martincostello/api): 17 | 18 | ## License 19 | 20 | This project is licensed under the [MIT](https://github.com/martincostello/api/blob/main/LICENSE) license. 21 | 22 | ## Building and Testing 23 | 24 | To build and test the website run the following command using PowerShell Core: 25 | 26 | ```powershell 27 | .\build.ps1 28 | ``` 29 | -------------------------------------------------------------------------------- /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](https://github.com/martincostello/api/security/advisories). 6 | 7 | Further details can be found in the [GitHub documentation](https://docs.github.com/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability). 8 | -------------------------------------------------------------------------------- /benchmark-crank.ps1: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env pwsh 2 | 3 | #Requires -PSEdition Core 4 | #Requires -Version 7 5 | 6 | param( 7 | [Parameter(Mandatory = $true)][string] $PullRequestId, 8 | [Parameter(Mandatory = $false)][string] $AccessToken, 9 | [Parameter(Mandatory = $false)][string] $Benchmark = "root", 10 | [Parameter(Mandatory = $false)][string] $CrankPath = "", 11 | [Parameter(Mandatory = $false)][string] $Repository = "https://github.com/martincostello/api", 12 | [Parameter(Mandatory = $false)][switch] $PublishResults 13 | ) 14 | 15 | $crankAgent = "crank-agent" 16 | $crankPR = "crank-pr" 17 | 18 | if (-Not [string]::IsNullOrEmpty($CrankPath)) { 19 | $crankAgent = Join-Path $CrankPath $crankAgent 20 | $crankPR = Join-Path $CrankPath $crankPR 21 | } 22 | 23 | if ($IsWindows) { 24 | $agent = Start-Process -FilePath $crankAgent -WindowStyle Hidden -PassThru 25 | } else { 26 | $agent = Start-Process -FilePath $crankAgent -PassThru 27 | } 28 | 29 | Start-Sleep -Seconds 2 30 | 31 | $repoPath = $PSScriptRoot 32 | $components = "api" 33 | $config = Join-Path $repoPath "benchmark.yml" 34 | $profiles = "local" 35 | 36 | $additionalArgs = @() 37 | 38 | if (-Not [string]::IsNullOrEmpty($AccessToken)) { 39 | $additionalArgs += "--access-token" 40 | $additionalArgs += $AccessToken 41 | 42 | if ($PublishResults) { 43 | $additionalArgs += "--publish-results" 44 | $additionalArgs += "true" 45 | } 46 | } 47 | 48 | try { 49 | & $crankPR ` 50 | --benchmarks $Benchmark ` 51 | --components $components ` 52 | --config $config ` 53 | --profiles $profiles ` 54 | --pull-request $PullRequestId ` 55 | --repository $Repository ` 56 | $additionalArgs 57 | } 58 | finally { 59 | Stop-Process -InputObject $agent -Force | Out-Null 60 | } 61 | 62 | if ($LASTEXITCODE -ne 0) { 63 | throw "crank-pr failed with exit code $LASTEXITCODE" 64 | } 65 | -------------------------------------------------------------------------------- /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 | $InformationPreference = "Continue" 13 | $ProgressPreference = "SilentlyContinue" 14 | 15 | $solutionPath = $PSScriptRoot 16 | $sdkFile = Join-Path $solutionPath "global.json" 17 | 18 | $dotnetVersion = (Get-Content $sdkFile | Out-String | ConvertFrom-Json).sdk.version 19 | 20 | $installDotNetSdk = $false 21 | 22 | if (($null -eq (Get-Command "dotnet" -ErrorAction SilentlyContinue)) -and ($null -eq (Get-Command "dotnet.exe" -ErrorAction SilentlyContinue))) { 23 | Write-Information "The .NET SDK is not installed." 24 | $installDotNetSdk = $true 25 | } 26 | else { 27 | Try { 28 | $installedDotNetVersion = (dotnet --version 2>&1 | Out-String).Trim() 29 | } 30 | Catch { 31 | $installedDotNetVersion = "?" 32 | } 33 | 34 | if ($installedDotNetVersion -ne $dotnetVersion) { 35 | Write-Information "The required version of the .NET SDK is not installed. Expected $dotnetVersion." 36 | $installDotNetSdk = $true 37 | } 38 | } 39 | 40 | if ($installDotNetSdk -eq $true) { 41 | ${env:DOTNET_INSTALL_DIR} = Join-Path $PSScriptRoot ".dotnet" 42 | $sdkPath = Join-Path ${env:DOTNET_INSTALL_DIR} "sdk" $dotnetVersion 43 | 44 | if (!(Test-Path $sdkPath)) { 45 | if (!(Test-Path ${env:DOTNET_INSTALL_DIR})) { 46 | mkdir ${env:DOTNET_INSTALL_DIR} | Out-Null 47 | } 48 | $installScript = Join-Path ${env:DOTNET_INSTALL_DIR} "install.ps1" 49 | [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor "Tls12" 50 | Invoke-WebRequest "https://dot.net/v1/dotnet-install.ps1" -OutFile $installScript -UseBasicParsing 51 | & $installScript -JsonFile $sdkFile -InstallDir ${env:DOTNET_INSTALL_DIR} -NoPath 52 | } 53 | 54 | ${env:PATH} = "${env:DOTNET_INSTALL_DIR};${env:PATH}" 55 | $dotnet = Join-Path ${env:DOTNET_INSTALL_DIR} "dotnet" 56 | } 57 | else { 58 | $dotnet = "dotnet" 59 | } 60 | 61 | $benchmarks = (Join-Path $solutionPath "tests" "API.Benchmarks" "API.Benchmarks.csproj") 62 | 63 | Write-Information "Running benchmarks..." 64 | 65 | $additionalArgs = @() 66 | 67 | if (-Not [string]::IsNullOrEmpty($Filter)) { 68 | $additionalArgs += "--filter" 69 | $additionalArgs += $Filter 70 | } 71 | 72 | if (-Not [string]::IsNullOrEmpty($Job)) { 73 | $additionalArgs += "--job" 74 | $additionalArgs += $Job 75 | } 76 | 77 | if (-Not [string]::IsNullOrEmpty(${env:GITHUB_SHA})) { 78 | $additionalArgs += "--exporters" 79 | $additionalArgs += "json" 80 | } 81 | 82 | & $dotnet run --project $benchmarks --configuration "Release" -- $additionalArgs 83 | -------------------------------------------------------------------------------- /benchmark.yml: -------------------------------------------------------------------------------- 1 | components: 2 | api: 3 | script: | 4 | pwsh build.ps1 -SkipTests 5 | arguments: "" 6 | 7 | defaults: --config ./crank.yml 8 | 9 | profiles: 10 | local: 11 | description: Local 12 | arguments: --profile local 13 | 14 | benchmarks: 15 | root: 16 | description: Root 17 | arguments: --config ./crank.yml --scenario root 18 | guid: 19 | description: GUID 20 | arguments: --config ./crank.yml --scenario guid 21 | openapi: 22 | description: OpenAPI 23 | arguments: --config ./crank.yml --scenario openapi 24 | time: 25 | description: Time 26 | arguments: --config ./crank.yml --scenario time 27 | -------------------------------------------------------------------------------- /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 | $InformationPreference = "Continue" 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-Information "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-Information "The required version of the .NET SDK is not installed. Expected $dotnetVersion." 35 | $installDotNetSdk = $true 36 | } 37 | } 38 | 39 | if ($installDotNetSdk -eq $true) { 40 | 41 | ${env:DOTNET_INSTALL_DIR} = Join-Path $PSScriptRoot ".dotnet" 42 | $sdkPath = Join-Path ${env:DOTNET_INSTALL_DIR} "sdk" $dotnetVersion 43 | 44 | if (!(Test-Path $sdkPath)) { 45 | if (!(Test-Path ${env:DOTNET_INSTALL_DIR})) { 46 | mkdir ${env:DOTNET_INSTALL_DIR} | Out-Null 47 | } 48 | [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor "Tls12" 49 | if (($PSVersionTable.PSVersion.Major -ge 6) -And !$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 | function DotNetTest { 73 | param() 74 | 75 | $additionalArgs = @() 76 | 77 | if (-Not [string]::IsNullOrEmpty(${env:GITHUB_SHA})) { 78 | $additionalArgs += "--logger:GitHubActions;report-warnings=false" 79 | $additionalArgs += "--logger:junit;LogFilePath=junit.xml" 80 | } 81 | 82 | & $dotnet test --configuration "Release" $additionalArgs -- RunConfiguration.TestSessionTimeout=1200000 83 | 84 | if ($LASTEXITCODE -ne 0) { 85 | throw "dotnet test failed with exit code $LASTEXITCODE" 86 | } 87 | } 88 | 89 | function DotNetPublish { 90 | param([string]$Project) 91 | 92 | & $dotnet publish $Project 93 | 94 | if ($LASTEXITCODE -ne 0) { 95 | throw "dotnet publish failed with exit code $LASTEXITCODE" 96 | } 97 | } 98 | 99 | $publishProjects = @( 100 | (Join-Path $solutionPath "src" "API" "API.csproj") 101 | ) 102 | 103 | Write-Information "Publishing solution..." 104 | ForEach ($project in $publishProjects) { 105 | DotNetPublish $project 106 | } 107 | 108 | if (-Not $SkipTests) { 109 | Write-Information "Testing solution..." 110 | DotNetTest 111 | } 112 | -------------------------------------------------------------------------------- /crank.ps1: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env pwsh 2 | 3 | #Requires -PSEdition Core 4 | #Requires -Version 7 5 | 6 | param( 7 | [Parameter(Mandatory = $false)][string] $Scenario = "root", 8 | [Parameter(Mandatory = $false)][string] $Profile = "local", 9 | [Parameter(Mandatory = $false)][string] $BranchOrCommitOrTag = "" 10 | ) 11 | 12 | $additionalArgs = @() 13 | 14 | if (![string]::IsNullOrEmpty($BranchOrCommitOrTag)) { 15 | $additionalArgs += "--application.source.branchOrCommit" 16 | $additionalArgs += $BranchOrCommitOrTag 17 | } 18 | 19 | $repoPath = $PSScriptRoot 20 | $config = Join-Path $repoPath "crank.yml" 21 | 22 | if ($Windows) { 23 | $agent = Start-Process -FilePath "crank-agent" -PassThru -WindowStyle Hidden 24 | } else { 25 | $agent = Start-Process -FilePath "crank-agent" -PassThru 26 | } 27 | 28 | Start-Sleep -Seconds 2 29 | 30 | try { 31 | crank --config $config --scenario $Scenario --profile $Profile $additionalArgs 32 | } 33 | finally { 34 | Stop-Process -InputObject $agent -Force | Out-Null 35 | } 36 | 37 | if ($LASTEXITCODE -ne 0) { 38 | throw "crank failed with exit code $LASTEXITCODE" 39 | } 40 | -------------------------------------------------------------------------------- /crank.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - https://raw.githubusercontent.com/dotnet/crank/main/src/Microsoft.Crank.Jobs.Bombardier/bombardier.yml 3 | 4 | jobs: 5 | bombardier: 6 | channel: current 7 | server: 8 | source: 9 | repository: https://github.com/martincostello/api 10 | branchOrCommit: main 11 | project: src/API/API.csproj 12 | channel: current 13 | readyStateText: Application started. 14 | 15 | scenarios: 16 | root: 17 | application: 18 | job: server 19 | framework: net9.0 20 | load: 21 | job: bombardier 22 | framework: net9.0 23 | variables: 24 | serverPort: 5000 25 | path: / 26 | guid: 27 | application: 28 | job: server 29 | framework: net9.0 30 | load: 31 | job: bombardier 32 | framework: net9.0 33 | variables: 34 | serverPort: 5000 35 | path: /tools/guid 36 | openapi: 37 | application: 38 | job: server 39 | framework: net9.0 40 | load: 41 | job: bombardier 42 | framework: net9.0 43 | variables: 44 | serverPort: 5000 45 | path: /openapi/api.json 46 | time: 47 | application: 48 | job: server 49 | framework: net9.0 50 | load: 51 | job: bombardier 52 | framework: net9.0 53 | variables: 54 | serverPort: 5000 55 | path: /time 56 | 57 | profiles: 58 | local: 59 | variables: 60 | serverAddress: localhost 61 | jobs: 62 | application: 63 | endpoints: 64 | - http://localhost:5010 65 | load: 66 | endpoints: 67 | - http://localhost:5010 68 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/API/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | -------------------------------------------------------------------------------- /src/API/.prettierignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | node_modules/ 3 | obj/ 4 | wwwroot/ 5 | -------------------------------------------------------------------------------- /src/API/API.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | noble-chiseled 4 | martincostello/api 5 | Martin Costello's API 6 | true 7 | true 8 | true 9 | true 10 | $([System.IO.Path]::Combine($(ArtifactsPath), 'openapi')) 11 | true 12 | Exe 13 | api.martincostello.com 14 | true 15 | true 16 | MartinCostello.Api 17 | net9.0 18 | true 19 | latest 20 | api.martincostello.com 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 | 64 | 65 | false 66 | 67 | 68 | -------------------------------------------------------------------------------- /src/API/ApplicationJsonSerializerContext.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Diagnostics.CodeAnalysis; 5 | using System.Text.Json.Nodes; 6 | using System.Text.Json.Serialization; 7 | using MartinCostello.Api.Models; 8 | using Microsoft.AspNetCore.Mvc; 9 | 10 | namespace MartinCostello.Api; 11 | 12 | [ExcludeFromCodeCoverage] 13 | [JsonSerializable(typeof(bool?))] 14 | [JsonSerializable(typeof(GitHubAccessToken))] 15 | [JsonSerializable(typeof(GitHubDeviceCode))] 16 | [JsonSerializable(typeof(GuidResponse))] 17 | [JsonSerializable(typeof(HashRequest))] 18 | [JsonSerializable(typeof(HashResponse))] 19 | [JsonSerializable(typeof(JsonObject))] 20 | [JsonSerializable(typeof(MachineKeyResponse))] 21 | [JsonSerializable(typeof(ProblemDetails))] 22 | [JsonSerializable(typeof(TimeResponse))] 23 | [JsonSourceGenerationOptions( 24 | NumberHandling = JsonNumberHandling.Strict, 25 | PropertyNameCaseInsensitive = false, 26 | PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, 27 | WriteIndented = true)] 28 | public sealed partial class ApplicationJsonSerializerContext : JsonSerializerContext; 29 | -------------------------------------------------------------------------------- /src/API/ApplicationTelemetry.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Diagnostics; 5 | using OpenTelemetry; 6 | using OpenTelemetry.Resources; 7 | using Pyroscope; 8 | 9 | namespace MartinCostello.Api; 10 | 11 | /// 12 | /// A class containing telemetry information for the API. 13 | /// 14 | public static class ApplicationTelemetry 15 | { 16 | /// 17 | /// The name of the service. 18 | /// 19 | public static readonly string ServiceName = "API"; 20 | 21 | /// 22 | /// The version of the service. 23 | /// 24 | public static readonly string ServiceVersion = GitMetadata.Version.Split('+')[0]; 25 | 26 | /// 27 | /// The custom activity source for the service. 28 | /// 29 | public static readonly ActivitySource ActivitySource = new(ServiceName, ServiceVersion); 30 | 31 | /// 32 | /// Gets the to use for telemetry. 33 | /// 34 | public static ResourceBuilder ResourceBuilder { get; } = ResourceBuilder.CreateDefault() 35 | .AddService(ServiceName, serviceVersion: ServiceVersion) 36 | .AddAzureAppServiceDetector() 37 | .AddContainerDetector() 38 | .AddOperatingSystemDetector() 39 | .AddProcessRuntimeDetector(); 40 | 41 | /// 42 | /// Returns whether an OTLP collector is configured. 43 | /// 44 | /// 45 | /// if OTLP is configured; otherwise . 46 | /// 47 | internal static bool IsOtlpCollectorConfigured() 48 | => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT")); 49 | 50 | /// 51 | /// Returns whether Pyroscope is configured. 52 | /// 53 | /// 54 | /// if Pyroscope is configured; otherwise . 55 | /// 56 | internal static bool IsPyroscopeConfigured() 57 | => Environment.GetEnvironmentVariable("PYROSCOPE_PROFILING_ENABLED") is "1"; 58 | 59 | /// 60 | /// Profiles the specified delegate with the labels from the current span's baggage, if any. 61 | /// 62 | /// The type of the state. 63 | /// The state to pass to the operation. 64 | /// The operation to profile. 65 | /// 66 | /// A representing the asynchronous operation. 67 | /// 68 | [StackTraceHidden] 69 | internal static async Task ProfileAsync(T state, Func operation) 70 | { 71 | if (ExtractK6Baggage() is not { Count: > 0 } baggage) 72 | { 73 | await operation(state); 74 | return; 75 | } 76 | 77 | try 78 | { 79 | Profiler.Instance.ClearDynamicTags(); 80 | 81 | foreach ((string key, string value) in baggage) 82 | { 83 | Profiler.Instance.SetDynamicTag(key, value); 84 | } 85 | 86 | await operation(state); 87 | } 88 | finally 89 | { 90 | Profiler.Instance.ClearDynamicTags(); 91 | } 92 | } 93 | 94 | private static Dictionary? ExtractK6Baggage() 95 | { 96 | // Based on https://github.com/grafana/pyroscope-go/blob/8fff2bccb5ed5611fdb09fdbd9a727367ab35f39/x/k6/baggage.go 97 | if (Baggage.GetBaggage() is not { Count: > 0 } baggage) 98 | { 99 | return null; 100 | } 101 | 102 | Dictionary? labels = null; 103 | 104 | foreach ((string key, string? value) in baggage.Where((p) => p.Key.StartsWith("k6.", StringComparison.Ordinal))) 105 | { 106 | if (value is { Length: > 0 }) 107 | { 108 | string label = key.Replace('.', '_'); 109 | 110 | // See https://grafana.com/docs/k6/latest/javascript-api/jslib/http-instrumentation-pyroscope/#about-baggage-header 111 | labels ??= new(3); 112 | labels[label] = value; 113 | } 114 | } 115 | 116 | return labels; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/API/Extensions/HttpRequestExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Api.Options; 5 | 6 | namespace MartinCostello.Api.Extensions; 7 | 8 | /// 9 | /// A class containing extension methods for the class. This class cannot be inherited. 10 | /// 11 | public static class HttpRequestExtensions 12 | { 13 | /// 14 | /// Returns the canonical URI for the specified HTTP request with the optional path. 15 | /// 16 | /// The HTTP request to get the canonical URI from. 17 | /// The optional path to get the canonical URI for. 18 | /// 19 | /// The canonical URI to use for the specified HTTP request. 20 | /// 21 | public static string Canonical(this HttpRequest request, string? path = null) 22 | { 23 | string host = request.Host.ToString(); 24 | string[] hostSplit = host.Split(':'); 25 | 26 | var builder = new UriBuilder() 27 | { 28 | Host = hostSplit[0], 29 | }; 30 | 31 | if (hostSplit.Length > 1) 32 | { 33 | builder.Port = int.Parse(hostSplit[1], CultureInfo.InvariantCulture); 34 | } 35 | 36 | builder.Path = path ?? request.Path; 37 | builder.Query = string.Empty; 38 | builder.Scheme = "https"; 39 | 40 | string canonicalUri = builder.Uri.AbsoluteUri.ToLowerInvariant(); 41 | 42 | if (!canonicalUri.EndsWith('/')) 43 | { 44 | canonicalUri += "/"; 45 | } 46 | 47 | return canonicalUri; 48 | } 49 | 50 | /// 51 | /// Converts a virtual (relative) path to an CDN absolute URI, if configured. 52 | /// 53 | /// The . 54 | /// The virtual path of the content. 55 | /// The current site configuration. 56 | /// The CDN absolute URI, if configured; otherwise the application absolute URI. 57 | public static string CdnContent(this HttpRequest value, string contentPath, SiteOptions options) 58 | { 59 | var cdn = options?.ExternalLinks?.Cdn; 60 | 61 | // Prefer empty images to a NullReferenceException 62 | if (cdn == null) 63 | { 64 | return string.Empty; 65 | } 66 | 67 | return $"{cdn}{value.Content(contentPath)}"; 68 | } 69 | 70 | /// 71 | /// Converts a virtual (relative) path to a relative URI. 72 | /// 73 | /// The . 74 | /// The virtual path of the content. 75 | /// Whether to append a version to the URL. 76 | /// The relatve URI to the content. 77 | public static string? Content(this HttpRequest request, string? contentPath, bool appendVersion = true) 78 | { 79 | string? result = string.Empty; 80 | 81 | if (!string.IsNullOrEmpty(contentPath)) 82 | { 83 | if (contentPath[0] == '~') 84 | { 85 | var segment = new PathString(contentPath[1..]); 86 | var applicationPath = request.PathBase; 87 | 88 | var path = applicationPath.Add(segment); 89 | result = path.Value; 90 | } 91 | else 92 | { 93 | result = contentPath; 94 | } 95 | } 96 | 97 | if (appendVersion) 98 | { 99 | result += $"?v={GitMetadata.Commit}"; 100 | } 101 | 102 | return result; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/API/Extensions/ILoggingBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using OpenTelemetry.Logs; 5 | 6 | namespace MartinCostello.Api.Extensions; 7 | 8 | /// 9 | /// A class containing extension methods for the interface. This class cannot be inherited. 10 | /// 11 | public static class ILoggingBuilderExtensions 12 | { 13 | /// 14 | /// Adds OpenTelemetry logging to the specified . 15 | /// 16 | /// The to configure. 17 | /// 18 | /// The value of . 19 | /// 20 | public static ILoggingBuilder AddTelemetry(this ILoggingBuilder builder) 21 | { 22 | return builder.AddOpenTelemetry((options) => 23 | { 24 | options.IncludeFormattedMessage = true; 25 | options.IncludeScopes = true; 26 | 27 | options.SetResourceBuilder(ApplicationTelemetry.ResourceBuilder); 28 | 29 | if (ApplicationTelemetry.IsOtlpCollectorConfigured()) 30 | { 31 | options.AddOtlpExporter(); 32 | } 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/API/Extensions/IServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Api.OpenApi; 5 | using MartinCostello.Api.Options; 6 | using MartinCostello.OpenApi; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace MartinCostello.Api.Extensions; 11 | 12 | /// 13 | /// A class containing extension methods for the interface. This class cannot be inherited. 14 | /// 15 | public static class IServiceCollectionExtensions 16 | { 17 | /// 18 | /// Adds OpenAPI documentation to the services. 19 | /// 20 | /// The to add OpenAPI documentation to. 21 | /// 22 | /// The value specified by . 23 | /// 24 | public static IServiceCollection AddOpenApiDocumentation(this IServiceCollection services) 25 | { 26 | services.AddHttpContextAccessor(); 27 | services.AddSingleton, PostConfigureOpenApiExtensionsOptions>(); 28 | 29 | const string DocumentName = "api"; 30 | 31 | services.AddOpenApi(DocumentName, (options) => 32 | { 33 | options.AddDocumentTransformer(); 34 | }); 35 | 36 | services.AddOpenApiExtensions(DocumentName, (options) => 37 | { 38 | options.AddExamples = true; 39 | options.AddServerUrls = true; 40 | options.SerializationContexts.Add(ApplicationJsonSerializerContext.Default); 41 | 42 | options.AddExample(); 43 | options.AddXmlComments(); 44 | }); 45 | 46 | return services; 47 | } 48 | 49 | private sealed class PostConfigureOpenApiExtensionsOptions(IOptionsMonitor monitor) : IPostConfigureOptions 50 | { 51 | public void PostConfigure(string? name, OpenApiExtensionsOptions options) 52 | => options.DefaultServerUrl = $"https://{monitor.CurrentValue.Metadata!.Domain}"; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/API/Extensions/ResultsExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Http.HttpResults; 5 | 6 | #pragma warning disable IDE0130 7 | namespace Microsoft.AspNetCore.Http; 8 | 9 | /// 10 | /// A class containing extension methods for the interface. This class cannot be inherited. 11 | /// 12 | internal static class ResultsExtensions 13 | { 14 | /// 15 | /// Returns an representing an invalid API request. 16 | /// 17 | /// The being extended. 18 | /// The error detail. 19 | /// 20 | /// The representing the response. 21 | /// 22 | public static ProblemHttpResult InvalidRequest(this IResultExtensions resultExtensions, string detail) 23 | { 24 | ArgumentNullException.ThrowIfNull(resultExtensions); 25 | return TypedResults.Problem(detail, statusCode: StatusCodes.Status400BadRequest); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/API/Extensions/TelemetryExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using OpenTelemetry.Instrumentation.Http; 5 | using OpenTelemetry.Metrics; 6 | using OpenTelemetry.Trace; 7 | 8 | namespace MartinCostello.Api.Extensions; 9 | 10 | /// 11 | /// A class containing telemetry-related extension methods for . 12 | /// 13 | public static class TelemetryExtensions 14 | { 15 | /// 16 | /// Adds telemetry services to the specified . 17 | /// 18 | /// The to configure telemetry for. 19 | /// The current . 20 | public static void AddTelemetry(this IServiceCollection services, IWebHostEnvironment environment) 21 | { 22 | ArgumentNullException.ThrowIfNull(services); 23 | 24 | services 25 | .AddOpenTelemetry() 26 | .WithMetrics((builder) => 27 | { 28 | builder.SetResourceBuilder(ApplicationTelemetry.ResourceBuilder) 29 | .AddAspNetCoreInstrumentation() 30 | .AddHttpClientInstrumentation() 31 | .AddProcessInstrumentation() 32 | .AddMeter("System.Runtime"); 33 | 34 | if (ApplicationTelemetry.IsOtlpCollectorConfigured()) 35 | { 36 | builder.AddOtlpExporter(); 37 | } 38 | }) 39 | .WithTracing((builder) => 40 | { 41 | builder.SetResourceBuilder(ApplicationTelemetry.ResourceBuilder) 42 | .AddAspNetCoreInstrumentation() 43 | .AddHttpClientInstrumentation() 44 | .AddSource(ApplicationTelemetry.ServiceName); 45 | 46 | if (environment.IsDevelopment()) 47 | { 48 | builder.SetSampler(new AlwaysOnSampler()); 49 | } 50 | 51 | if (ApplicationTelemetry.IsOtlpCollectorConfigured()) 52 | { 53 | builder.AddOtlpExporter(); 54 | } 55 | 56 | if (ApplicationTelemetry.IsPyroscopeConfigured()) 57 | { 58 | builder.AddProcessor(new Pyroscope.OpenTelemetry.PyroscopeSpanProcessor()); 59 | } 60 | }); 61 | 62 | services.AddOptions() 63 | .Configure((options, _) => options.RecordException = true); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/API/GitHubModule.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.AspNetCore.WebUtilities; 6 | 7 | namespace MartinCostello.Api; 8 | 9 | #pragma warning disable CA2234 10 | 11 | /// 12 | /// A class that configures the GitHub endpoints. 13 | /// 14 | public static class GitHubModule 15 | { 16 | /// 17 | /// Maps the GitHub endpoints. 18 | /// 19 | /// The to use. 20 | /// 21 | /// The specified by . 22 | /// 23 | public static IEndpointRouteBuilder MapGitHubEndpoints(this IEndpointRouteBuilder builder) 24 | { 25 | var group = builder.MapGroup("github") 26 | .RequireCors("DefaultCorsPolicy") 27 | .ExcludeFromDescription(); 28 | 29 | // See https://docs.github.com/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#step-1-app-requests-the-device-and-user-verification-codes-from-github 30 | group.MapPost("login/device/code", async ( 31 | [FromServices] HttpClient client, 32 | [FromQuery(Name = "client_id")] string clientId, 33 | [FromQuery] string scope, 34 | CancellationToken cancellationToken) => 35 | { 36 | var parameters = new Dictionary(2) 37 | { 38 | ["client_id"] = clientId, 39 | ["scope"] = scope, 40 | }; 41 | 42 | string requestUri = QueryHelpers.AddQueryString("https://github.com/login/device/code", parameters); 43 | 44 | client.DefaultRequestHeaders.Add("Accept", "application/json"); 45 | 46 | var response = await client.PostAsync(requestUri, null, cancellationToken); 47 | response.EnsureSuccessStatusCode(); 48 | 49 | var jsonTypeInfo = ApplicationJsonSerializerContext.Default.GitHubDeviceCode; 50 | 51 | var deviceCode = await response.Content.ReadFromJsonAsync( 52 | jsonTypeInfo, 53 | cancellationToken); 54 | 55 | return Results.Json(deviceCode, jsonTypeInfo); 56 | }); 57 | 58 | // See https://docs.github.com/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#step-3-app-polls-github-to-check-if-the-user-authorized-the-device 59 | group.MapPost("login/oauth/access_token", static async ( 60 | [FromServices] HttpClient client, 61 | [FromQuery(Name = "client_id")] string clientId, 62 | [FromQuery(Name = "device_code")] string deviceCode, 63 | [FromQuery(Name = "grant_type")] string grantType, 64 | CancellationToken cancellationToken) => 65 | { 66 | var parameters = new Dictionary(3) 67 | { 68 | ["client_id"] = clientId, 69 | ["device_code"] = deviceCode, 70 | ["grant_type"] = grantType, 71 | }; 72 | 73 | string requestUri = QueryHelpers.AddQueryString("https://github.com/login/oauth/access_token", parameters); 74 | 75 | client.DefaultRequestHeaders.Add("Accept", "application/json"); 76 | 77 | var response = await client.PostAsync(requestUri, null, cancellationToken); 78 | response.EnsureSuccessStatusCode(); 79 | 80 | var jsonTypeInfo = ApplicationJsonSerializerContext.Default.GitHubAccessToken; 81 | 82 | var accessToken = await response.Content.ReadFromJsonAsync( 83 | jsonTypeInfo, 84 | cancellationToken); 85 | 86 | return Results.Json(accessToken, jsonTypeInfo); 87 | }); 88 | 89 | return builder; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/API/GitMetadata.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Reflection; 5 | 6 | namespace MartinCostello.Api; 7 | 8 | /// 9 | /// A class containing Git metadata for the assembly. This class cannot be inherited. 10 | /// 11 | public static class GitMetadata 12 | { 13 | /// 14 | /// Gets the SHA for the Git branch the assembly was compiled from. 15 | /// 16 | public static string Branch { get; } = GetMetadataValue("CommitBranch", "Unknown"); 17 | 18 | /// 19 | /// Gets the SHA for the Git commit the assembly was compiled from. 20 | /// 21 | public static string Commit { get; } = GetMetadataValue("CommitHash", "HEAD"); 22 | 23 | /// 24 | /// Gets the Id for the GitHub Actions run the assembly was compiled and deployed from. 25 | /// 26 | public static string DeployId { get; } = GetMetadataValue("BuildId", "Unknown"); 27 | 28 | /// 29 | /// Gets the timestamp the assembly was compiled at. 30 | /// 31 | public static DateTime Timestamp { get; } = DateTime.Parse(GetMetadataValue("BuildTimestamp", DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal); 32 | 33 | /// 34 | /// Gets the informational version of the assembly. 35 | /// 36 | public static string Version { get; } = typeof(GitMetadata).Assembly.GetCustomAttribute()!.InformationalVersion; 37 | 38 | /// 39 | /// Gets the specified metadata value. 40 | /// 41 | /// The name of the metadata value to retrieve. 42 | /// The default value if the metadata is not found. 43 | /// 44 | /// A containing the Git SHA-1 for the revision of the application. 45 | /// 46 | private static string GetMetadataValue(string name, string defaultValue) 47 | { 48 | return typeof(GitMetadata).Assembly 49 | .GetCustomAttributes() 50 | .Where((p) => string.Equals(p.Key, name, StringComparison.Ordinal)) 51 | .Select((p) => p.Value) 52 | .FirstOrDefault() ?? defaultValue; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/API/Middleware/PyroscopeK6Middleware.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Middleware; 5 | 6 | /// 7 | /// A class representing middleware for adding profiler labels for Pyroscope from k6. This class cannot be inherited. 8 | /// 9 | internal sealed class PyroscopeK6Middleware(RequestDelegate next) 10 | { 11 | private readonly RequestDelegate _next = next; 12 | 13 | /// 14 | /// Invokes the middleware asynchronously. 15 | /// 16 | /// The current HTTP context. 17 | /// 18 | /// A representing the actions performed by the middleware. 19 | /// 20 | [System.Diagnostics.StackTraceHidden] 21 | public Task InvokeAsync(HttpContext context) => 22 | ApplicationTelemetry.ProfileAsync((_next, context), static (state) => state._next(state.context)); 23 | } 24 | -------------------------------------------------------------------------------- /src/API/Models/GitHubAccessToken.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json.Serialization; 5 | 6 | namespace MartinCostello.Api.Models; 7 | 8 | /// 9 | /// A class representing a GitHub access token. This class cannot be inherited. 10 | /// 11 | public sealed class GitHubAccessToken 12 | { 13 | /// 14 | /// Gets or sets the error code, if unsuccessful. 15 | /// 16 | [JsonPropertyName("error")] 17 | public string? Error { get; set; } 18 | 19 | /// 20 | /// Gets or sets the access token. 21 | /// 22 | [JsonPropertyName("access_token")] 23 | public string? AccessToken { get; set; } 24 | 25 | /// 26 | /// Gets or sets the type of the access token. 27 | /// 28 | [JsonPropertyName("token_type")] 29 | public string? TokenType { get; set; } 30 | 31 | /// 32 | /// Gets or sets the space-separated scopes(s) associated with the access token. 33 | /// 34 | [JsonPropertyName("scope")] 35 | public string? Scopes { get; set; } 36 | } 37 | -------------------------------------------------------------------------------- /src/API/Models/GitHubDeviceCode.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Text.Json.Serialization; 5 | 6 | namespace MartinCostello.Api.Models; 7 | 8 | /// 9 | /// A class representing a GitHub device code. This class cannot be inherited. 10 | /// 11 | public sealed class GitHubDeviceCode 12 | { 13 | /// 14 | /// Gets or sets the device code. 15 | /// 16 | [JsonPropertyName("device_code")] 17 | public string DeviceCode { get; set; } = default!; 18 | 19 | /// 20 | /// Gets or sets the user code. 21 | /// 22 | [JsonPropertyName("user_code")] 23 | public string UserCode { get; set; } = default!; 24 | 25 | /// 26 | /// Gets or sets the verification URL. 27 | /// 28 | [JsonPropertyName("verification_uri")] 29 | public string VerificationUrl { get; set; } = default!; 30 | 31 | /// 32 | /// Gets or sets the number of seconds before the device and user codes expire. 33 | /// 34 | [JsonPropertyName("expires_in")] 35 | public int ExpiresInSeconds { get; set; } 36 | 37 | /// 38 | /// Gets or sets the minimum number of seconds that must pass before re-requesting an access token. 39 | /// 40 | [JsonPropertyName("interval")] 41 | public int RefreshIntervalInSeconds { get; set; } 42 | } 43 | -------------------------------------------------------------------------------- /src/API/Models/GuidResponse.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT 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 MartinCostello.Api.Models; 8 | 9 | /// 10 | /// Represents the response from the /tools/guid API resource. 11 | /// 12 | [OpenApiExample] 13 | public sealed class GuidResponse : IExampleProvider 14 | { 15 | /// 16 | /// Gets or sets the generated GUID value. 17 | /// 18 | [JsonPropertyName("guid")] 19 | #pragma warning disable CA1720 20 | public string Guid { get; set; } = string.Empty; 21 | #pragma warning restore CA1720 22 | 23 | /// 24 | public static GuidResponse GenerateExample() 25 | { 26 | return new() 27 | { 28 | Guid = new("6bc55a07-3d3e-4d52-8701-362a1187772d"), 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/API/Models/HashRequest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.ComponentModel.DataAnnotations; 5 | using System.Text.Json.Serialization; 6 | using MartinCostello.OpenApi; 7 | 8 | namespace MartinCostello.Api.Models; 9 | 10 | /// 11 | /// Represents a request to the /tools/hash API resource. 12 | /// 13 | [OpenApiExample] 14 | public sealed class HashRequest : IExampleProvider 15 | { 16 | /// 17 | /// Gets or sets the name of the hash algorithm to use. 18 | /// 19 | [JsonPropertyName("algorithm")] 20 | [Required] 21 | public string Algorithm { get; set; } = string.Empty; 22 | 23 | /// 24 | /// Gets or sets the format in which to return the hash. 25 | /// 26 | [JsonPropertyName("format")] 27 | [Required] 28 | public string Format { get; set; } = string.Empty; 29 | 30 | /// 31 | /// Gets or sets the plaintext value to generate the hash from. 32 | /// 33 | [JsonPropertyName("plaintext")] 34 | [Required] 35 | public string Plaintext { get; set; } = string.Empty; 36 | 37 | /// 38 | public static HashRequest GenerateExample() 39 | { 40 | return new() 41 | { 42 | Algorithm = "sha256", 43 | Format = "base64", 44 | Plaintext = "The quick brown fox jumped over the lazy dog", 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/API/Models/HashResponse.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT 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 MartinCostello.Api.Models; 8 | 9 | /// 10 | /// Represents the response from the /tools/hash API resource. 11 | /// 12 | [OpenApiExample] 13 | public sealed class HashResponse : IExampleProvider 14 | { 15 | /// 16 | /// Gets or sets a string containing the generated hash value in the requested format. 17 | /// 18 | [JsonPropertyName("hash")] 19 | public string Hash { get; set; } = string.Empty; 20 | 21 | /// 22 | public static HashResponse GenerateExample() 23 | { 24 | return new() 25 | { 26 | Hash = "fTi1zSWiuvha07tbkxE4PmcaihQuswKzJNSl+6h0jGk=", 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/API/Models/LayoutModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Models; 5 | 6 | /// 7 | /// A class representing the page layout model. This class cannot be inherited. 8 | /// 9 | /// The page title. 10 | public sealed class LayoutModel(string title) 11 | { 12 | /// 13 | /// Gets the page title. 14 | /// 15 | public string Title { get; } = title; 16 | 17 | /// 18 | /// Gets or sets the page description. 19 | /// 20 | public string? Description { get; set; } 21 | 22 | /// 23 | /// Gets or sets the ROBOTS directive to use. 24 | /// 25 | public string? Robots { get; set; } 26 | } 27 | -------------------------------------------------------------------------------- /src/API/Models/MachineKeyResponse.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT 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 MartinCostello.Api.Models; 8 | 9 | /// 10 | /// Represents the response from the /tools/machinekey API resource. 11 | /// 12 | [OpenApiExample] 13 | public sealed class MachineKeyResponse : IExampleProvider 14 | { 15 | /// 16 | /// Gets or sets a string containing the decryption key. 17 | /// 18 | [JsonPropertyName("decryptionKey")] 19 | public string DecryptionKey { get; set; } = string.Empty; 20 | 21 | /// 22 | /// Gets or sets a string containing the validation key. 23 | /// 24 | [JsonPropertyName("validationKey")] 25 | public string ValidationKey { get; set; } = string.Empty; 26 | 27 | /// 28 | /// Gets or sets a string containing the machineKey XML configuration element. 29 | /// 30 | [JsonPropertyName("machineKeyXml")] 31 | public string MachineKeyXml { get; set; } = string.Empty; 32 | 33 | /// 34 | public static MachineKeyResponse GenerateExample() 35 | { 36 | return new() 37 | { 38 | DecryptionKey = "2EA72C07DEEF522B4686C39BDF83E70A96BA92EE1D960029821FCA2E4CD9FB72", 39 | ValidationKey = "0A7A92827A74B9B4D2A21918814D8E4A9150BB5ADDB284533BDB50E44ADA6A4BCCFF637A5CB692816EE304121A1BCAA5A6D96BE31A213DEE0BAAEF102A391E8F", 40 | MachineKeyXml = @"", 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/API/Models/MetaModel.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Api.Options; 5 | 6 | namespace MartinCostello.Api.Models; 7 | 8 | /// 9 | /// A class representing the view model for page metadata. This class cannot be inherited. 10 | /// 11 | public sealed class MetaModel 12 | { 13 | /// 14 | /// Gets or sets the author. 15 | /// 16 | public string? Author { get; set; } 17 | 18 | /// 19 | /// Gets or sets the canonical URI. 20 | /// 21 | public string? CanonicalUri { get; set; } 22 | 23 | /// 24 | /// Gets or sets the description. 25 | /// 26 | public string? Description { get; set; } 27 | 28 | /// 29 | /// Gets or sets the Facebook profile Id. 30 | /// 31 | public string? Facebook { get; set; } 32 | 33 | /// 34 | /// Gets or sets the host name. 35 | /// 36 | public string? HostName { get; set; } 37 | 38 | /// 39 | /// Gets or sets the image URI. 40 | /// 41 | public string? ImageUri { get; set; } 42 | 43 | /// 44 | /// Gets or sets the image alternate text. 45 | /// 46 | public string? ImageAltText { get; set; } 47 | 48 | /// 49 | /// Gets or sets the page keywords. 50 | /// 51 | public string? Keywords { get; set; } 52 | 53 | /// 54 | /// Gets or sets the robots value. 55 | /// 56 | public string? Robots { get; set; } 57 | 58 | /// 59 | /// Gets or sets the site name. 60 | /// 61 | public string? SiteName { get; set; } 62 | 63 | /// 64 | /// Gets or sets the site type. 65 | /// 66 | public string? SiteType { get; set; } 67 | 68 | /// 69 | /// Gets or sets the page title. 70 | /// 71 | public string? Title { get; set; } 72 | 73 | /// 74 | /// Gets or sets the Twitter card type. 75 | /// 76 | public string? TwitterCard { get; set; } 77 | 78 | /// 79 | /// Gets or sets the Twitter handle. 80 | /// 81 | public string? TwitterHandle { get; set; } 82 | 83 | /// 84 | /// Creates a new instance of . 85 | /// 86 | /// The options to use. 87 | /// The optional canonical URI of the page. 88 | /// The optional page description. 89 | /// The optional robots value. 90 | /// The optional page title. 91 | /// 92 | /// The created instance of . 93 | /// 94 | public static MetaModel Create( 95 | MetadataOptions? options, 96 | string? canonicalUri = null, 97 | string? description = null, 98 | string? robots = null, 99 | string? title = null) 100 | { 101 | options ??= new MetadataOptions(); 102 | 103 | return new MetaModel() 104 | { 105 | Author = options.Author?.Name, 106 | CanonicalUri = canonicalUri ?? string.Empty, 107 | Description = description ?? options.Description, 108 | Facebook = options.Author?.SocialMedia?.Facebook, 109 | HostName = options.Domain, 110 | Keywords = options.Keywords ?? "martin,costello,api", 111 | Robots = robots ?? options.Robots, 112 | SiteName = options.Name ?? "api.martincostello.com", 113 | SiteType = options.Type ?? "website", 114 | Title = title + " - " + options.Name, 115 | TwitterCard = "summary", 116 | TwitterHandle = options.Author?.SocialMedia?.Twitter, 117 | }; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/API/Models/TimeResponse.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT 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 MartinCostello.Api.Models; 8 | 9 | /// 10 | /// Represents the response from the /time API resource. 11 | /// 12 | [OpenApiExample] 13 | public sealed class TimeResponse : IExampleProvider 14 | { 15 | /// 16 | /// Gets or sets the timestamp for the response for which the times are generated. 17 | /// 18 | [JsonPropertyName("timestamp")] 19 | public DateTimeOffset Timestamp { get; set; } 20 | 21 | /// 22 | /// Gets or sets the current UTC date and time in RFC1123 format. 23 | /// 24 | [JsonPropertyName("rfc1123")] 25 | public string Rfc1123 { get; set; } = string.Empty; 26 | 27 | /// 28 | /// Gets or sets the number of seconds since the UNIX epoch. 29 | /// 30 | [JsonPropertyName("unix")] 31 | public long Unix { get; set; } 32 | 33 | /// 34 | /// Gets or sets the current UTC date and time in universal sortable format. 35 | /// 36 | [JsonPropertyName("universalSortable")] 37 | public string UniversalSortable { get; set; } = string.Empty; 38 | 39 | /// 40 | /// Gets or sets the current UTC date and time in universal full format. 41 | /// 42 | [JsonPropertyName("universalFull")] 43 | public string UniversalFull { get; set; } = string.Empty; 44 | 45 | /// 46 | public static TimeResponse GenerateExample() 47 | { 48 | return new() 49 | { 50 | Timestamp = new(2016, 6, 3, 18, 44, 14, TimeSpan.Zero), 51 | Rfc1123 = "Fri, 03 Jun 2016 18:44:14 GMT", 52 | UniversalFull = "Friday, 03 June 2016 18:44:14", 53 | UniversalSortable = "2016-06-03 18:44:14Z", 54 | Unix = 1464979454, 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/API/OpenApi/AddApiInfo.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Api.Options; 5 | using Microsoft.AspNetCore.OpenApi; 6 | using Microsoft.Extensions.Options; 7 | using Microsoft.OpenApi.Models; 8 | 9 | namespace MartinCostello.Api.OpenApi; 10 | 11 | /// 12 | /// A class that adds API information. This class cannot be inherited. 13 | /// 14 | internal sealed class AddApiInfo(IOptions options) : IOpenApiDocumentTransformer 15 | { 16 | /// 17 | public Task TransformAsync( 18 | OpenApiDocument document, 19 | OpenApiDocumentTransformerContext context, 20 | CancellationToken cancellationToken) 21 | { 22 | ConfigureInfo(document.Info); 23 | 24 | return Task.CompletedTask; 25 | } 26 | 27 | private void ConfigureInfo(OpenApiInfo info) 28 | { 29 | var siteOptions = options.Value; 30 | 31 | info.Description = siteOptions.Metadata?.Description; 32 | info.Title = siteOptions.Metadata?.Name; 33 | info.Version = string.Empty; 34 | 35 | info.Contact = new() 36 | { 37 | Name = siteOptions.Metadata?.Author?.Name, 38 | }; 39 | 40 | if (siteOptions.Metadata?.Author?.Website is { } contactUrl) 41 | { 42 | info.Contact.Url = new(contactUrl); 43 | } 44 | 45 | info.License = new() 46 | { 47 | Name = siteOptions.Api?.License?.Name, 48 | }; 49 | 50 | if (siteOptions.Api?.License?.Url is { } licenseUrl) 51 | { 52 | info.License.Url = new(licenseUrl); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/API/OpenApi/ProblemDetailsExampleProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT 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 MartinCostello.Api.OpenApi; 8 | 9 | /// 10 | /// A class representing an example provider for . 11 | /// 12 | internal sealed 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 | -------------------------------------------------------------------------------- /src/API/Options/AnalyticsOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Options; 5 | 6 | /// 7 | /// A class representing the analytics options for the site. This class cannot be inherited. 8 | /// 9 | public sealed class AnalyticsOptions 10 | { 11 | /// 12 | /// Gets or sets the Google property Id. 13 | /// 14 | public string Google { get; set; } = string.Empty; 15 | } 16 | -------------------------------------------------------------------------------- /src/API/Options/ApiCorsOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Options; 5 | 6 | /// 7 | /// A class representing the CORS options for the API. This class cannot be inherited. 8 | /// 9 | public sealed class ApiCorsOptions 10 | { 11 | /// 12 | /// Gets or sets a value indicating whether to allow any origin. 13 | /// 14 | public bool AllowAnyOrigin { get; set; } 15 | 16 | /// 17 | /// Gets or sets the names of the HTTP response headers exposed. 18 | /// 19 | public string[] ExposedHeaders { get; set; } = []; 20 | 21 | /// 22 | /// Gets or sets the names of the allowed HTTP request headers. 23 | /// 24 | public string[] Headers { get; set; } = []; 25 | 26 | /// 27 | /// Gets or sets the allowed HTTP methods. 28 | /// 29 | public string[] Methods { get; set; } = []; 30 | 31 | /// 32 | /// Gets or sets the allowed CORS origins. 33 | /// 34 | public string[] Origins { get; set; } = []; 35 | } 36 | -------------------------------------------------------------------------------- /src/API/Options/ApiOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Options; 5 | 6 | /// 7 | /// A class representing the API options for the site. This class cannot be inherited. 8 | /// 9 | public sealed class ApiOptions 10 | { 11 | /// 12 | /// Gets or sets the CORS options for the API. 13 | /// 14 | public ApiCorsOptions? Cors { get; set; } 15 | 16 | /// 17 | /// Gets or sets the documentation options for the API. 18 | /// 19 | public DocumentationOptions? Documentation { get; set; } 20 | 21 | /// 22 | /// Gets or sets the license options for the API. 23 | /// 24 | public LicenseOptions? License { get; set; } 25 | } 26 | -------------------------------------------------------------------------------- /src/API/Options/AuthorOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Options; 5 | 6 | /// 7 | /// A class representing the author options for the site. This class cannot be inherited. 8 | /// 9 | public sealed class AuthorOptions 10 | { 11 | /// 12 | /// Gets or sets the email address of the author. 13 | /// 14 | public string Email { get; set; } = string.Empty; 15 | 16 | /// 17 | /// Gets or sets the name of the author. 18 | /// 19 | public string Name { get; set; } = string.Empty; 20 | 21 | /// 22 | /// Gets or sets the social media options. 23 | /// 24 | public AuthorSocialMediaOptions? SocialMedia { get; set; } 25 | 26 | /// 27 | /// Gets or sets the URL of the author's website. 28 | /// 29 | public string Website { get; set; } = string.Empty; 30 | } 31 | -------------------------------------------------------------------------------- /src/API/Options/AuthorSocialMediaOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Options; 5 | 6 | /// 7 | /// A class representing the social media options for the site author. This class cannot be inherited. 8 | /// 9 | public sealed class AuthorSocialMediaOptions 10 | { 11 | /// 12 | /// Gets or sets the Facebook profile Id of the author. 13 | /// 14 | public string Facebook { get; set; } = string.Empty; 15 | 16 | /// 17 | /// Gets or sets the Twitter handle of the author. 18 | /// 19 | public string Twitter { get; set; } = string.Empty; 20 | } 21 | -------------------------------------------------------------------------------- /src/API/Options/DocumentationOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Options; 5 | 6 | /// 7 | /// A class representing the documentation options for the site. This class cannot be inherited. 8 | /// 9 | public sealed class DocumentationOptions 10 | { 11 | /// 12 | /// Gets or sets the relative path to the location of the documentation. 13 | /// 14 | public string Location { get; set; } = string.Empty; 15 | } 16 | -------------------------------------------------------------------------------- /src/API/Options/ExternalLinksOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Options; 5 | 6 | /// 7 | /// A class representing the external link options for the site. This class cannot be inherited. 8 | /// 9 | public sealed class ExternalLinksOptions 10 | { 11 | /// 12 | /// Gets or sets the URI of the blog. 13 | /// 14 | public Uri? Blog { get; set; } 15 | 16 | /// 17 | /// Gets or sets the URI of the CDN. 18 | /// 19 | public Uri? Cdn { get; set; } 20 | 21 | /// 22 | /// Gets or sets the options for the URIs to use for reports. 23 | /// 24 | public ReportOptions? Reports { get; set; } 25 | } 26 | -------------------------------------------------------------------------------- /src/API/Options/LicenseOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Options; 5 | 6 | /// 7 | /// A class representing the license options for the API. This class cannot be inherited. 8 | /// 9 | public sealed class LicenseOptions 10 | { 11 | /// 12 | /// Gets or sets the license name. 13 | /// 14 | public string Name { get; set; } = string.Empty; 15 | 16 | /// 17 | /// Gets or sets the license URL. 18 | /// 19 | public string Url { get; set; } = string.Empty; 20 | } 21 | -------------------------------------------------------------------------------- /src/API/Options/MetadataOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Options; 5 | 6 | /// 7 | /// A class representing the metadata options for the site. This class cannot be inherited. 8 | /// 9 | public sealed class MetadataOptions 10 | { 11 | /// 12 | /// Gets or sets the author options. 13 | /// 14 | public AuthorOptions? Author { get; set; } 15 | 16 | /// 17 | /// Gets or sets the site description. 18 | /// 19 | public string Description { get; set; } = string.Empty; 20 | 21 | /// 22 | /// Gets or sets the domain. 23 | /// 24 | public string Domain { get; set; } = string.Empty; 25 | 26 | /// 27 | /// Gets or sets the keywords. 28 | /// 29 | public string Keywords { get; set; } = string.Empty; 30 | 31 | /// 32 | /// Gets or sets the site name. 33 | /// 34 | public string Name { get; set; } = string.Empty; 35 | 36 | /// 37 | /// Gets or sets the URL of the site's repository. 38 | /// 39 | public string Repository { get; set; } = string.Empty; 40 | 41 | /// 42 | /// Gets or sets the robots value. 43 | /// 44 | public string Robots { get; set; } = string.Empty; 45 | 46 | /// 47 | /// Gets or sets the site type. 48 | /// 49 | public string Type { get; set; } = string.Empty; 50 | } 51 | -------------------------------------------------------------------------------- /src/API/Options/ReportOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Options; 5 | 6 | /// 7 | /// A class representing the options for reports. This class cannot be inherited. 8 | /// 9 | public sealed class ReportOptions 10 | { 11 | /// 12 | /// Gets or sets the URI to use for Content-Security-Policy. 13 | /// 14 | public Uri? ContentSecurityPolicy { get; set; } 15 | 16 | /// 17 | /// Gets or sets the URI to use for Content-Security-Policy-Report-Only. 18 | /// 19 | public Uri? ContentSecurityPolicyReportOnly { get; set; } 20 | } 21 | -------------------------------------------------------------------------------- /src/API/Options/SiteOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Options; 5 | 6 | /// 7 | /// A class representing the site configuration. This class cannot be inherited. 8 | /// 9 | public sealed class SiteOptions 10 | { 11 | /// 12 | /// Gets or sets the analytics options for the site. 13 | /// 14 | public AnalyticsOptions? Analytics { get; set; } 15 | 16 | /// 17 | /// Gets or sets the API options for the site. 18 | /// 19 | public ApiOptions? Api { get; set; } 20 | 21 | /// 22 | /// Gets or sets the external link options for the site. 23 | /// 24 | public ExternalLinksOptions? ExternalLinks { get; set; } 25 | 26 | /// 27 | /// Gets or sets the metadata options for the site. 28 | /// 29 | public MetadataOptions? Metadata { get; set; } 30 | } 31 | -------------------------------------------------------------------------------- /src/API/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using MartinCostello.Api; 5 | 6 | var builder = WebApplication.CreateBuilder(args); 7 | 8 | ApiBuilder.Configure(builder).Run(); 9 | -------------------------------------------------------------------------------- /src/API/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "API": { 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 | -------------------------------------------------------------------------------- /src/API/Slices/Docs.cshtml: -------------------------------------------------------------------------------- 1 | @implements IUsesLayout<_Layout, LayoutModel> 2 | 3 |

@(LayoutModel.Title)

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 | @functions { 34 | public LayoutModel LayoutModel { get; } = new("API Documentation") 35 | { 36 | Description = "Documentation for the resources in api.martincostello.com.", 37 | }; 38 | 39 | protected override Task ExecuteSectionAsync(string name) 40 | { 41 | if (name is "links") 42 | { 43 | 44 | } 45 | else if (name is "scripts") 46 | { 47 | 48 | 49 | } 50 | else if (name is "styles") 51 | { 52 | 53 | 54 | } 55 | 56 | return Task.CompletedTask; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/API/Slices/Error.cshtml: -------------------------------------------------------------------------------- 1 | @implements IUsesLayout<_Layout, LayoutModel> 2 | @{ 3 | var statusCode = HttpContext!.Response.StatusCode; 4 | } 5 |

Error.

6 |

@(Microsoft.AspNetCore.WebUtilities.ReasonPhrases.GetReasonPhrase(statusCode))

7 |

HTTP status code @(statusCode).

8 | 9 | @functions { 10 | public LayoutModel LayoutModel { get; } = new("Error") 11 | { 12 | Description = string.Empty, 13 | Robots = "NOINDEX", 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/API/Slices/Home.cshtml: -------------------------------------------------------------------------------- 1 | @implements IUsesLayout<_Layout, LayoutModel> 2 | @{ 3 | var options = Options.Value; 4 | } 5 |
6 |
7 |

Martin Costello's API

8 |

9 | This website is an exercise in the use of ASP.NET Core @(Environment.Version.ToString(1)) for a website and REST API. 10 |

11 |
12 |
13 |
14 |

15 | This website is hosted in Microsoft Azure 16 | and the source code can be found on GitHub. 17 |

18 |

19 | It is currently running @(System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription). 20 |

21 |
22 |
23 |
24 |

Website

25 |

My main website.

26 |

Visit website »

27 |
28 |
29 |

Blog

30 |

I occasionally blog about topics related to .NET development.

31 |

Visit blog »

32 |
33 |
34 | @functions { 35 | public LayoutModel LayoutModel { get; } = new("Home Page"); 36 | } 37 | -------------------------------------------------------------------------------- /src/API/Slices/_Footer.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | var options = Options.Value; 3 | } 4 |
5 | 23 | -------------------------------------------------------------------------------- /src/API/Slices/_Layout.cshtml: -------------------------------------------------------------------------------- 1 | @inherits RazorLayoutSlice 2 | @inject IOptions Options 3 | 4 | @{ 5 | var options = Options.Value; 6 | var meta = MetaModel.Create( 7 | options.Metadata, 8 | canonicalUri: HttpContext!.Request.Canonical(), 9 | description: Model.Description, 10 | title: Model.Title, 11 | robots: Model.Robots); 12 | } 13 | 14 | 15 | 16 | @(await RenderPartialAsync<_Meta, MetaModel>(meta)) 17 | @(await RenderPartialAsync<_Links, string?>(meta.CanonicalUri)) 18 | @await RenderSectionAsync("links") 19 | @await RenderSectionAsync("styles") 20 | 28 | 29 | 30 | @(await RenderPartialAsync<_Navbar>()) 31 |
32 | @await RenderBodyAsync() 33 | @(await RenderPartialAsync<_Footer>()) 34 |
35 | @(await RenderPartialAsync<_Styles>()) 36 | @(await RenderPartialAsync<_Scripts>()) 37 | @await RenderSectionAsync("scripts") 38 | 39 | 42 | 43 | -------------------------------------------------------------------------------- /src/API/Slices/_Links.cshtml: -------------------------------------------------------------------------------- 1 | @inherits RazorSlice 2 | @{ 3 | var options = Options.Value; 4 | var request = HttpContext!.Request; 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 | -------------------------------------------------------------------------------- /src/API/Slices/_Meta.cshtml: -------------------------------------------------------------------------------- 1 | @inherits RazorSlice 2 | @inject IWebHostEnvironment Hosting 3 | 4 | @(Model.Title) 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 | -------------------------------------------------------------------------------- /src/API/Slices/_Navbar.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | var options = Options.Value; 3 | var baseUri = new Uri(options.Metadata?.Author?.Website ?? string.Empty); 4 | var aboutLink = new Uri(baseUri, "home/about/"); 5 | } 6 | 27 | -------------------------------------------------------------------------------- /src/API/Slices/_Scripts.cshtml: -------------------------------------------------------------------------------- 1 | 2 | @{ 3 | var analyticsId = Options.Value.Analytics?.Google; 4 | } 5 | @if (!string.IsNullOrWhiteSpace(analyticsId)) 6 | { 7 | 8 | } 9 | 10 | -------------------------------------------------------------------------------- /src/API/Slices/_Styles.cshtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/API/Slices/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | @inherits RazorSliceHttpResult 2 | 3 | @using System.Globalization 4 | @using Microsoft.AspNetCore.Razor 5 | @using Microsoft.AspNetCore.Http.HttpResults 6 | @using Microsoft.Extensions.Configuration 7 | @using Microsoft.Extensions.Hosting 8 | @using Microsoft.Extensions.Options 9 | @using MartinCostello.Api 10 | @using MartinCostello.Api.Extensions 11 | @using MartinCostello.Api.Models 12 | @using MartinCostello.Api.Options 13 | @using MartinCostello.Api.Slices 14 | @using RazorSlices 15 | 16 | @tagHelperPrefix __disable_tagHelpers__: 17 | @removeTagHelper *, Microsoft.AspNetCore.Mvc.Razor 18 | 19 | @inject IOptions Options 20 | -------------------------------------------------------------------------------- /src/API/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/API/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager": "Error", 7 | "Microsoft.AspNetCore.DataProtection.Repositories.EphemeralXmlRepository": "Error", 8 | "Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository": "Error", 9 | "Microsoft.Hosting.Lifetime": "Information", 10 | "System": "Warning" 11 | } 12 | }, 13 | "Site": { 14 | "Analytics": { 15 | "Google": "G-81R468LM0B" 16 | }, 17 | "Api": { 18 | "Cors": { 19 | "ExposedHeaders": [ 20 | "Retry-After", 21 | "X-Rate-Limit-Limit", 22 | "X-Rate-Limit-Remaining", 23 | "X-Rate-Limit-Reset", 24 | "X-Request-Duration", 25 | "X-Request-Id" 26 | ], 27 | "Headers": [ 28 | "Content-Type" 29 | ], 30 | "Methods": [ 31 | "GET", 32 | "POST" 33 | ], 34 | "Origins": [ 35 | "https://localhost:5001", 36 | "https://localhost:50001", 37 | "https://martincostello.com", 38 | "https://benchmarks.martincostello.com", 39 | "https://dev.martincostello.com" 40 | ] 41 | }, 42 | "Documentation": { 43 | "Location": "docs" 44 | }, 45 | "License": { 46 | "Name": "This API is licensed under the MIT License.", 47 | "Url": "https://github.com/martincostello/api/blob/main/LICENSE" 48 | } 49 | }, 50 | "ExternalLinks": { 51 | "Blog": "https://blog.martincostello.com/", 52 | "Cdn": "https://cdn.martincostello.com/", 53 | "Reports": { 54 | "ContentSecurityPolicy": "", 55 | "ContentSecurityPolicyReportOnly": "" 56 | } 57 | }, 58 | "Metadata": { 59 | "Author": { 60 | "Email": "martin@martincostello.com", 61 | "Name": "Martin Costello", 62 | "SocialMedia": { 63 | "Facebook": "10100867762061905", 64 | "Twitter": "@martin_costello" 65 | }, 66 | "Website": "https://martincostello.com/" 67 | }, 68 | "Description": "Martin Costello's API", 69 | "Domain": "api.martincostello.com", 70 | "Keywords": "martin,costello,api", 71 | "Name": "api.martincostello.com", 72 | "Repository": "https://github.com/martincostello/api", 73 | "Robots": "INDEX", 74 | "Type": "website" 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/API/assets/scripts/main.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | import moment from 'moment'; 5 | 6 | document.addEventListener('DOMContentLoaded', () => { 7 | const trackingId = document.querySelector('meta[name="google-analytics"]').getAttribute('content'); 8 | if (trackingId && 'dataLayer' in window) { 9 | const dataLayer = (window.dataLayer as any[]) || []; 10 | const gtag = (...args: any[]) => { 11 | dataLayer.push(args); 12 | }; 13 | gtag('js', new Date()); 14 | gtag('config', trackingId); 15 | } 16 | 17 | const element = document.getElementById('build-date'); 18 | if (element) { 19 | const timestamp = element.getAttribute('data-timestamp'); 20 | const format = element.getAttribute('data-format'); 21 | 22 | const value = moment(timestamp, format); 23 | if (value.isValid()) { 24 | const text: string = value.fromNow(); 25 | element.textContent = `(${text})`; 26 | } 27 | } 28 | 29 | if ('SwaggerUIBundle' in window && 'SwaggerUIStandalonePreset' in window) { 30 | const swaggerUIBundle = window['SwaggerUIBundle'] as any; 31 | const swaggerUIStandalonePreset = window['SwaggerUIStandalonePreset'] as any; 32 | const url = document.querySelector('link[rel="swagger"]').getAttribute('href'); 33 | const ui: any = swaggerUIBundle({ 34 | url: url, 35 | /* eslint-disable @typescript-eslint/naming-convention */ 36 | dom_id: '#swagger-ui', 37 | deepLinking: true, 38 | presets: [swaggerUIBundle.presets.apis, swaggerUIStandalonePreset], 39 | plugins: [ 40 | swaggerUIBundle.plugins.DownloadUrl, 41 | (): any => { 42 | return { 43 | components: { 44 | /* eslint-disable @typescript-eslint/naming-convention */ 45 | Topbar: (): any => null, 46 | }, 47 | }; 48 | }, 49 | ], 50 | layout: 'StandaloneLayout', 51 | booleanValues: ['false', 'true'], 52 | defaultModelRendering: 'schema', 53 | displayRequestDuration: true, 54 | jsonEditor: true, 55 | showRequestHeaders: true, 56 | supportedSubmitMethods: ['get', 'post'], 57 | tryItOutEnabled: true, 58 | validatorUrl: null, 59 | responseInterceptor: (response: any): any => { 60 | delete response.headers['content-security-policy']; 61 | delete response.headers['content-security-policy-report-only']; 62 | delete response.headers['cross-origin-embedder-policy']; 63 | delete response.headers['cross-origin-opener-policy']; 64 | delete response.headers['cross-origin-resource-policy']; 65 | delete response.headers['permissions-policy']; 66 | }, 67 | }); 68 | 69 | (window as any).ui = ui; 70 | } 71 | }); 72 | -------------------------------------------------------------------------------- /src/API/assets/styles/main.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) Martin Costello, 2016. All rights reserved. 3 | Licensed under the MIT license. See the LICENSE file in the project root for full license information. 4 | */ 5 | 6 | /* stylelint-disable-next-line media-feature-name-no-vendor-prefix */ 7 | @media (-webkit-min-device-pixel-ratio: 1.25), (resolution >= 120dpi) { 8 | html { 9 | font-size: 16px; 10 | } 11 | 12 | body { 13 | font-size: 1rem; 14 | } 15 | } 16 | 17 | a, 18 | .url, 19 | .swagger-ui .info a { 20 | color: #0f7661; 21 | } 22 | 23 | .body-content { 24 | padding-top: 5rem; 25 | } 26 | 27 | /* stylelint-disable-next-line selector-class-pattern */ 28 | .response-col_status { 29 | width: unset; 30 | } 31 | 32 | .scheme-container { 33 | display: none; 34 | } 35 | 36 | .swagger-ui .opblock.opblock-get .opblock-summary-method { 37 | background: #0170df; 38 | } 39 | 40 | .swagger-ui .opblock.opblock-post .opblock-summary-method { 41 | background: #227751; 42 | } 43 | 44 | .swagger-ui-svg { 45 | height: 0; 46 | position: absolute; 47 | width: 0; 48 | } 49 | 50 | /* stylelint-disable-next-line selector-class-pattern */ 51 | .parameters-col_name { 52 | width: 20%; 53 | } 54 | -------------------------------------------------------------------------------- /src/API/eslint.config.js: -------------------------------------------------------------------------------- 1 | import stylistic from "@stylistic/eslint-plugin"; 2 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 3 | import globals from "globals"; 4 | import tsParser from "@typescript-eslint/parser"; 5 | import path from "node:path"; 6 | import { fileURLToPath } from "node:url"; 7 | import js from "@eslint/js"; 8 | import { FlatCompat } from "@eslint/eslintrc"; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all 16 | }); 17 | 18 | export default [...compat.extends("prettier"), { 19 | files: ['**/*.cjs', '**/*.js', '**/*.ts'], 20 | ignores: [ 21 | 'bin/*', 22 | 'coverage/*', 23 | 'node_modules/*', 24 | 'obj/*', 25 | 'wwwroot/*' 26 | ], 27 | plugins: { 28 | "@stylistic": stylistic, 29 | "@typescript-eslint": typescriptEslint, 30 | }, 31 | languageOptions: { 32 | globals: { 33 | ...globals.browser, 34 | ...globals.node, 35 | }, 36 | parser: tsParser, 37 | ecmaVersion: 5, 38 | sourceType: "module", 39 | parserOptions: { 40 | project: "./tsconfig.json", 41 | }, 42 | }, 43 | rules: { 44 | "@stylistic/indent": "error", 45 | "@stylistic/member-delimiter-style": "error", 46 | "@stylistic/quotes": ["error", "single"], 47 | "@stylistic/semi": ["error", "always"], 48 | "@stylistic/type-annotation-spacing": "error", 49 | "@typescript-eslint/naming-convention": "error", 50 | "@typescript-eslint/prefer-namespace-keyword": "error", 51 | "brace-style": ["error", "1tbs"], 52 | eqeqeq: ["error", "smart"], 53 | "id-blacklist": [ 54 | "error", 55 | "any", 56 | "Number", 57 | "number", 58 | "String", 59 | "string", 60 | "Boolean", 61 | "boolean", 62 | "Undefined", 63 | "undefined", 64 | ], 65 | "id-match": "error", 66 | "no-eval": "error", 67 | "no-redeclare": "error", 68 | "no-trailing-spaces": "error", 69 | "no-underscore-dangle": "error", 70 | "no-var": "error", 71 | "spaced-comment": ["error", "always", { 72 | markers: ["/"], 73 | }], 74 | }, 75 | }]; 76 | -------------------------------------------------------------------------------- /src/API/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "private": true, 4 | "type": "module", 5 | "version": "9.0.0", 6 | "description": "Martin Costello's API", 7 | "scripts": { 8 | "build": "npm run compile && npm run format && npm run lint", 9 | "compile": "webpack", 10 | "format": "prettier --write assets/**/*.ts && stylelint --fix lax assets/*/*.css", 11 | "format-check": "prettier --check assets/**/*.ts && stylelint assets/*/*.css", 12 | "lint": "eslint assets", 13 | "watch": "webpack --watch" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/martincostello/api.git" 18 | }, 19 | "author": "martincostello", 20 | "license": "Apache-2.0", 21 | "devDependencies": { 22 | "@babel/core": "^7.27.4", 23 | "@babel/preset-env": "^7.27.2", 24 | "@stylistic/eslint-plugin": "^4.4.1", 25 | "@typescript-eslint/eslint-plugin": "^8.34.0", 26 | "@typescript-eslint/parser": "^8.32.1", 27 | "css-loader": "^7.1.2", 28 | "css-minimizer-webpack-plugin": "^7.0.2", 29 | "eslint": "^9.28.0", 30 | "eslint-config-prettier": "^10.1.5", 31 | "globals": "^16.2.0", 32 | "mini-css-extract-plugin": "^2.9.2", 33 | "moment": "^2.30.1", 34 | "prettier": "^3.5.3", 35 | "style-loader": "^4.0.0", 36 | "stylelint": "^16.20.0", 37 | "stylelint-config-standard": "^38.0.0", 38 | "ts-loader": "^9.5.2", 39 | "tsify": "^5.0.4", 40 | "typescript": "^5.8.3", 41 | "webpack": "^5.99.9", 42 | "webpack-cli": "^6.0.1", 43 | "webpack-remove-empty-scripts": "^1.1.1" 44 | }, 45 | "prettier": { 46 | "arrowParens": "always", 47 | "bracketSpacing": true, 48 | "endOfLine": "auto", 49 | "printWidth": 140, 50 | "quoteProps": "consistent", 51 | "semi": true, 52 | "singleQuote": true, 53 | "tabWidth": 4, 54 | "trailingComma": "es5", 55 | "useTabs": false 56 | }, 57 | "stylelint": { 58 | "extends": [ 59 | "stylelint-config-standard" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/API/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "inlineSources": true, 5 | "moduleResolution": "Node", 6 | "noEmitOnError": true, 7 | "noImplicitAny": true, 8 | "noImplicitOverride": true, 9 | "noImplicitThis": true, 10 | "outDir": "./wwwroot/assets/js", 11 | "removeComments": false, 12 | "sourceMap": true, 13 | "target": "ES2015" 14 | }, 15 | "exclude": [ 16 | "node_modules", 17 | "wwwroot" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/API/webpack.config.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const cssMinimizerPlugin = require('css-minimizer-webpack-plugin'); 4 | const miniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const removeEmptyScriptsPlugin = require('webpack-remove-empty-scripts'); 6 | 7 | module.exports = { 8 | devtool: 'source-map', 9 | entry: { 10 | css: path.resolve(__dirname, './assets/styles/main.css'), 11 | js: path.resolve(__dirname, './assets/scripts/main.ts'), 12 | }, 13 | mode: 'production', 14 | module: { 15 | rules: [ 16 | { 17 | test: /.css$/, 18 | use: [ 19 | miniCssExtractPlugin.loader, 20 | { loader: 'css-loader', options: { sourceMap: true } }, 21 | ], 22 | }, 23 | { 24 | test: /\.tsx?$/, 25 | use: 'ts-loader', 26 | exclude: /node_modules/, 27 | }, 28 | ], 29 | }, 30 | optimization: { 31 | minimize: true, 32 | minimizer: [ 33 | '...', 34 | new cssMinimizerPlugin(), 35 | ], 36 | }, 37 | output: { 38 | clean: true, 39 | filename: '[name]/main.js', 40 | path: path.resolve(__dirname, 'wwwroot', 'assets'), 41 | }, 42 | plugins: [ 43 | new miniCssExtractPlugin({ 44 | filename: '[name]/main.css' 45 | }), 46 | new removeEmptyScriptsPlugin(), 47 | new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /en-gb/), 48 | ], 49 | resolve: { 50 | extensions: ['.css', '.tsx', '.ts', '.js'], 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /src/API/wwwroot/BingSiteAuth.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | D6C2E7551C902F1A396D8564C6452930 4 | 5 | -------------------------------------------------------------------------------- /src/API/wwwroot/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | #2c3e50 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/API/wwwroot/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Error - api.martincostello.com 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 23 | 42 |
43 |

Error

44 |

An error occurred while processing your request.

45 |
46 |
47 |

48 | © Martin Costello 2019 49 |

50 |
51 |
52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/API/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martincostello/api/26ceb252c650afaa6502f25c3eac52f522c7f3f0/src/API/wwwroot/favicon.ico -------------------------------------------------------------------------------- /src/API/wwwroot/googled1107923138d0b79.html: -------------------------------------------------------------------------------- 1 | google-site-verification: googled1107923138d0b79.html 2 | -------------------------------------------------------------------------------- /src/API/wwwroot/humans.txt: -------------------------------------------------------------------------------- 1 | This website is maintained by Martin Costello. 2 | 3 | Bluesky: @martincostello.com 4 | From: London, UK 5 | -------------------------------------------------------------------------------- /src/API/wwwroot/keybase.txt: -------------------------------------------------------------------------------- 1 | ================================================================== 2 | https://keybase.io/martincostello 3 | -------------------------------------------------------------------- 4 | 5 | I hereby claim: 6 | 7 | * I am an admin of https://api.martincostello.com 8 | * I am martincostello (https://keybase.io/martincostello) on keybase. 9 | * I have a public key with fingerprint 09E6 0E06 CD38 314B 9F25 1C62 74A5 B223 F73B 2AF3 10 | 11 | To do so, I am signing this object: 12 | 13 | { 14 | "body": { 15 | "key": { 16 | "eldest_kid": "0101627969ba73e541a5f3d945416ea5f448053207380d6b5e64665461d9fce3f4ef0a", 17 | "fingerprint": "09e60e06cd38314b9f251c6274a5b223f73b2af3", 18 | "host": "keybase.io", 19 | "key_id": "74a5b223f73b2af3", 20 | "kid": "0101627969ba73e541a5f3d945416ea5f448053207380d6b5e64665461d9fce3f4ef0a", 21 | "uid": "db5898b36f1d1c6b7cb496a98dd61919", 22 | "username": "martincostello" 23 | }, 24 | "service": { 25 | "hostname": "api.martincostello.com", 26 | "protocol": "https:" 27 | }, 28 | "type": "web_service_binding", 29 | "version": 1 30 | }, 31 | "ctime": 1458916367, 32 | "expire_in": 157680000, 33 | "prev": "6796195a58f1b080d845065541216f513c5f2dd2421826e36f3ff921bfcff8dc", 34 | "seqno": 8, 35 | "tag": "signature" 36 | } 37 | 38 | which yields the signature: 39 | 40 | -----BEGIN PGP MESSAGE----- 41 | Version: Keybase OpenPGP v2.0.51 42 | Comment: https://keybase.io/crypto 43 | 44 | yMIuAnicrVJbSBVBGD7mrSwxFM0gMZfwQU13dnfm7B4tQ19EjSAkgqTDXmZ007N7 45 | Ome9pxaWPiRFJJUlhJVaISFoEmUXy4gUirKHsNuhwoJILNEIM5uVegh6bF7mn5nv 46 | +/5vPv6RyGBHRFBgcaLpSZv/cdDYcE+FY8dsYVQdo5haDeOqY8rw0obLNey33GW6 47 | xrgYFrAAcU4JSYrs5DEUgAwJr0kCrRCmtSCILOQ51smLrIYUiJGAEBQQ0CSiYp4I 48 | mLAyk8YQ3SjBPq9PNyxbVsKIxSxSNV7kgaBIhINApY0EGSocxxMnr3Ay4Smx1PTb 49 | DGpOkf04XTfpHT24l+z9A/+ffVcsyWkKFCVR4REBGvWpOFVFkJAsiZqGgAQkG+jH 50 | PkP2YIr2yD5LN1RqHJeXm0x9GkPfKnUV2/Ha//mNk716+t/YdNX0UC2vz7RM1Syn 51 | mFLL8vpdtoZV47VJVVhx/5ZzK7qh0VwpoxL7/LppMC5Akaql2/pAoKYB4pEzjcHV 52 | Xt2H3bqNgE4ksnTZfXAllUQ0JyBBGYoEKCzNQxQgiyCNigOIQMCrkHCaxgkcEDmE 53 | aQo8IRIHFKISImoqY39wr2EyLpHalEuopF8vMWSrwoeZ+rt3ikMcQRGOsNBl9rw5 54 | Ilas/jOF8XvCFxIa8m+u+tTcsRC+nnhiBzwj/WOMFNd05nJ10cfZ/O6UtsbaREfX 55 | e+HYl/Yj0Z65ksCJw5uSatsi4kKfZ7SPox/zqQenh3KdH/IWW4YyEkOywlLPHiiG 56 | Vc3k9f7t51KvZ10tXg5vGVrkjZ6pLQ/y57wDfdMtR3uPM+dPJiXP34si7ROTiYf6 57 | t/V+ffY0sHX4W8/gu+DOvvhJ1BjZ171zumBl18XOoheTebtIV33ChjeLySn3Cxze 58 | fRunMnsbLszEDlfGxMmOU4HxdWtyZwIJ6NXu6MFrqRmelx2ns1VrdLF0NHvzpYcT 59 | M59zlNaekJju1iuZQ4/yKtY23/5Z+P1tSlrO1C8PU0nM 60 | =CBFb 61 | -----END PGP MESSAGE----- 62 | 63 | And finally, I am proving ownership of this host by posting or 64 | appending to this document. 65 | 66 | View my publicly-auditable identity here: https://keybase.io/martincostello 67 | 68 | ================================================================== 69 | -------------------------------------------------------------------------------- /src/API/wwwroot/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api.martincostello.com", 3 | "short_name": "API", 4 | "description": "Martin Costello's API", 5 | "start_url": "/", 6 | "background_color": "#ffffff", 7 | "theme_color": "#2c3e50", 8 | "display": "minimal-ui", 9 | "lang": "en-GB", 10 | "dir": "ltr", 11 | "icons": [ 12 | { 13 | "src": "https://cdn.martincostello.com/android-chrome-36x36.png?v=3", 14 | "sizes": "36x36", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "https://cdn.martincostello.com/android-chrome-48x48.png?v=3", 19 | "sizes": "48x48", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "https://cdn.martincostello.com/android-chrome-72x72.png?v=3", 24 | "sizes": "72x72", 25 | "type": "image/png" 26 | }, 27 | { 28 | "src": "https://cdn.martincostello.com/android-chrome-96x96.png?v=3", 29 | "sizes": "96x96", 30 | "type": "image/png" 31 | }, 32 | { 33 | "src": "https://cdn.martincostello.com/android-chrome-144x144.png?v=3", 34 | "sizes": "144x144", 35 | "type": "image/png" 36 | }, 37 | { 38 | "src": "https://cdn.martincostello.com/android-chrome-192x192.png?v=3", 39 | "sizes": "192x192", 40 | "type": "image/png" 41 | }, 42 | { 43 | "src": "https://cdn.martincostello.com/android-chrome-512x512.png?v=3", 44 | "sizes": "512x512", 45 | "type": "image/png" 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /src/API/wwwroot/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /api 3 | 4 | Sitemap: https://api.martincostello.com/sitemap.xml 5 | -------------------------------------------------------------------------------- /src/API/wwwroot/robots933456.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /api 3 | 4 | Sitemap: https://api.martincostello.com/sitemap.xml 5 | -------------------------------------------------------------------------------- /src/API/wwwroot/sitemap.xml: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | https://api.martincostello.com/ 6 | 7 | 8 | -------------------------------------------------------------------------------- /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=%~dp0API.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://martincostello.com/", 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": true, 10 | "documentPrivateElements": false, 11 | "documentPrivateFields": false, 12 | "fileNamingConvention": "metadata", 13 | "xmlHeader": false, 14 | "variables": { 15 | "licenseFile": "LICENSE", 16 | "licenseName": "MIT", 17 | "ownerName": "Martin Costello", 18 | "year": "2016" 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/API.Benchmarks/API.Benchmarks.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Benchmarks for the API. 4 | $(NoWarn);CA2007;CA2234;SA1600 5 | false 6 | false 7 | Exe 8 | MartinCostello.API.Benchmarks 9 | net9.0 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/API.Benchmarks/ApiBenchmarks.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Net.Http.Json; 5 | using BenchmarkDotNet.Attributes; 6 | using BenchmarkDotNet.Diagnosers; 7 | 8 | namespace MartinCostello.Api.Benchmarks; 9 | 10 | [EventPipeProfiler(EventPipeProfile.CpuSampling)] 11 | [MemoryDiagnoser] 12 | public class ApiBenchmarks : IAsyncDisposable 13 | { 14 | private ApiServer? _app = new(); 15 | private HttpClient? _client; 16 | private bool _disposed; 17 | 18 | [GlobalSetup] 19 | public async Task StartServer() 20 | { 21 | if (_app is { } app) 22 | { 23 | await app.StartAsync(); 24 | _client = app.CreateHttpClient(); 25 | } 26 | } 27 | 28 | [GlobalCleanup] 29 | public async Task StopServer() 30 | { 31 | if (_app is { } app) 32 | { 33 | await app.StopAsync(); 34 | _app = null; 35 | } 36 | } 37 | 38 | [Benchmark] 39 | public async Task Root() 40 | => await _client!.GetByteArrayAsync("/"); 41 | 42 | [Benchmark] 43 | public async Task Version() 44 | => await _client!.GetByteArrayAsync("/version"); 45 | 46 | [Benchmark] 47 | public async Task Hash() 48 | { 49 | var body = new { algorithm = "sha1", Format = "base64", plaintext = "Hello, world!" }; 50 | 51 | using var response = await _client!.PostAsJsonAsync("/tools/hash", body); 52 | 53 | response.EnsureSuccessStatusCode(); 54 | 55 | return await response!.Content!.ReadAsByteArrayAsync(); 56 | } 57 | 58 | [Benchmark] 59 | public async Task Time() 60 | => await _client!.GetByteArrayAsync("/time"); 61 | 62 | [Benchmark] 63 | public async Task OpenApi() 64 | => await _client!.GetByteArrayAsync("/openapi/api.json"); 65 | 66 | public async ValueTask DisposeAsync() 67 | { 68 | GC.SuppressFinalize(this); 69 | 70 | if (!_disposed) 71 | { 72 | _client?.Dispose(); 73 | _client = null; 74 | 75 | if (_app is not null) 76 | { 77 | await _app.DisposeAsync(); 78 | _app = null; 79 | } 80 | } 81 | 82 | _disposed = true; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/API.Benchmarks/ApiServer.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT 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 | 11 | namespace MartinCostello.Api.Benchmarks; 12 | 13 | internal sealed class ApiServer : IAsyncDisposable 14 | { 15 | private WebApplication? _app; 16 | private Uri? _baseAddress; 17 | private bool _disposed; 18 | 19 | public ApiServer() 20 | { 21 | var builder = WebApplication.CreateBuilder([$"--contentRoot={GetContentRoot()}"]); 22 | 23 | builder.Logging.ClearProviders(); 24 | builder.WebHost.UseUrls("https://127.0.0.1:0"); 25 | 26 | _app = ApiBuilder.Configure(builder); 27 | } 28 | 29 | public HttpClient CreateHttpClient() 30 | { 31 | #pragma warning disable CA2000 32 | var handler = new HttpClientHandler() 33 | { 34 | ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, 35 | }; 36 | #pragma warning restore CA2000 37 | 38 | #pragma warning disable CA5400 39 | return new(handler, disposeHandler: true) { BaseAddress = _baseAddress }; 40 | #pragma warning restore CA5400 41 | } 42 | 43 | public async Task StartAsync() 44 | { 45 | if (_app is { } app) 46 | { 47 | await app.StartAsync(); 48 | 49 | var server = app.Services.GetRequiredService(); 50 | var addresses = server.Features.Get(); 51 | 52 | _baseAddress = addresses!.Addresses 53 | .Select((p) => new Uri(p)) 54 | .Last(); 55 | } 56 | } 57 | 58 | public async Task StopAsync() 59 | { 60 | if (_app is { } app) 61 | { 62 | await app.StopAsync(); 63 | _app = null; 64 | } 65 | } 66 | 67 | public async ValueTask DisposeAsync() 68 | { 69 | GC.SuppressFinalize(this); 70 | 71 | if (!_disposed && _app is not null) 72 | { 73 | await _app.DisposeAsync(); 74 | } 75 | 76 | _disposed = true; 77 | } 78 | 79 | private static string GetContentRoot() 80 | { 81 | string contentRoot = string.Empty; 82 | var directoryInfo = new DirectoryInfo(Path.GetDirectoryName(typeof(ApiBenchmarks).Assembly.Location)!); 83 | 84 | do 85 | { 86 | string? solutionPath = Directory.EnumerateFiles(directoryInfo.FullName, "API.slnx").FirstOrDefault(); 87 | 88 | if (solutionPath is not null) 89 | { 90 | contentRoot = Path.GetFullPath(Path.Combine(directoryInfo.FullName, "src", "API")); 91 | break; 92 | } 93 | 94 | directoryInfo = directoryInfo.Parent; 95 | } 96 | while (directoryInfo is not null); 97 | 98 | return contentRoot; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/API.Benchmarks/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using BenchmarkDotNet.Running; 5 | using MartinCostello.Api.Benchmarks; 6 | 7 | if (args.SequenceEqual(["--test"])) 8 | { 9 | await using var benchmark = new ApiBenchmarks(); 10 | await benchmark.StartServer(); 11 | 12 | try 13 | { 14 | _ = await benchmark.Root(); 15 | _ = await benchmark.Version(); 16 | _ = await benchmark.Hash(); 17 | _ = await benchmark.Time(); 18 | _ = await benchmark.OpenApi(); 19 | } 20 | finally 21 | { 22 | await benchmark.StopServer(); 23 | } 24 | } 25 | else 26 | { 27 | BenchmarkRunner.Run(args: args); 28 | } 29 | -------------------------------------------------------------------------------- /tests/API.Benchmarks/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "API.Benchmarks": { 4 | "commandName": "Project", 5 | "commandLineArgs": "" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/API.Tests/API.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Tests for Martin Costello's API 4 | false 5 | $(NoWarn);SA1601 6 | Exe 7 | MartinCostello.Api 8 | net9.0 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | true 33 | 85,58,90 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /tests/API.Tests/CategoryAttribute.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using Xunit.v3; 5 | 6 | namespace MartinCostello.Api; 7 | 8 | [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] 9 | public sealed class CategoryAttribute(string category) : Attribute, ITraitAttribute 10 | { 11 | public string Category { get; } = category; 12 | 13 | public IReadOnlyCollection> GetTraits() 14 | => [new("Category", Category)]; 15 | } 16 | -------------------------------------------------------------------------------- /tests/API.Tests/EndToEnd/ApiCollection.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.EndToEnd; 5 | 6 | [CollectionDefinition(Name)] 7 | public sealed class ApiCollection : ICollectionFixture 8 | { 9 | public const string Name = "API collection"; 10 | } 11 | -------------------------------------------------------------------------------- /tests/API.Tests/EndToEnd/ApiFixture.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Net.Http.Headers; 5 | 6 | namespace MartinCostello.Api.EndToEnd; 7 | 8 | public sealed class ApiFixture 9 | { 10 | private const string WebsiteUrl = "WEBSITE_URL"; 11 | 12 | public ApiFixture() 13 | { 14 | string url = Environment.GetEnvironmentVariable(WebsiteUrl) ?? string.Empty; 15 | 16 | if (Uri.TryCreate(url, UriKind.Absolute, out var address)) 17 | { 18 | ServerAddress = address; 19 | } 20 | } 21 | 22 | public Uri? ServerAddress { get; } 23 | 24 | public HttpClient CreateClient() 25 | { 26 | Assert.SkipWhen(ServerAddress is null, $"The {WebsiteUrl} environment variable is not set or is not a valid absolute URI."); 27 | 28 | var client = new HttpClient() 29 | { 30 | BaseAddress = ServerAddress, 31 | }; 32 | 33 | client.DefaultRequestHeaders.UserAgent.Add( 34 | new ProductInfoHeaderValue( 35 | "MartinCostello.Api.Tests", 36 | "1.0.0+" + GitMetadata.Commit)); 37 | 38 | return client; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/API.Tests/EndToEnd/ApiTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Net.Http.Json; 5 | using System.Text.Json; 6 | using System.Text.Json.Nodes; 7 | using System.Text.Json.Serialization; 8 | using System.Xml.Linq; 9 | using Microsoft.AspNetCore.WebUtilities; 10 | 11 | namespace MartinCostello.Api.EndToEnd; 12 | 13 | public partial class ApiTests(ApiFixture fixture) : EndToEndTest(fixture) 14 | { 15 | [Fact] 16 | public async Task Can_Get_Time() 17 | { 18 | // Arrange 19 | var tolerance = TimeSpan.FromSeconds(5); 20 | var utcNow = DateTimeOffset.UtcNow; 21 | using var client = Fixture.CreateClient(); 22 | 23 | // Act 24 | using var response = await client.GetFromJsonAsync("/time", AppJsonSerializerContext.Default.JsonDocument, CancellationToken); 25 | 26 | // Assert 27 | response.ShouldNotBeNull(); 28 | response.RootElement.GetProperty("timestamp").GetDateTimeOffset().ShouldBe(utcNow, tolerance); 29 | 30 | DateTimeOffset.TryParse(response.RootElement.GetProperty("rfc1123").GetString(), out var actual).ShouldBeTrue(); 31 | actual.ShouldBe(utcNow, TimeSpan.FromSeconds(5), "rfc1123 is not a valid DateTimeOffset."); 32 | 33 | DateTimeOffset.TryParse(response.RootElement.GetProperty("universalSortable").GetString(), out actual).ShouldBeTrue(); 34 | actual.ShouldBe(utcNow, TimeSpan.FromSeconds(5), "universalSortable is not a valid DateTimeOffset."); 35 | 36 | DateTimeOffset.TryParse(response.RootElement.GetProperty("universalFull").GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out actual).ShouldBeTrue(); 37 | actual.ShouldBe(utcNow, TimeSpan.FromSeconds(5), "universalFull is not a valid DateTimeOffset."); 38 | 39 | long unix = response.RootElement.GetProperty("unix").GetInt64(); 40 | unix.ShouldBeGreaterThan(DateTimeOffset.UnixEpoch.ToUnixTimeSeconds()); 41 | DateTimeOffset.FromUnixTimeSeconds(unix).ShouldBe(utcNow, TimeSpan.FromSeconds(5), "The value of unix is incorrect."); 42 | } 43 | 44 | [Fact] 45 | public async Task Can_Generate_Guid() 46 | { 47 | // Arrange 48 | using var client = Fixture.CreateClient(); 49 | 50 | // Act 51 | using var response = await client.GetFromJsonAsync("/tools/guid", AppJsonSerializerContext.Default.JsonDocument, CancellationToken); 52 | 53 | // Assert 54 | response.ShouldNotBeNull(); 55 | response.RootElement.GetProperty("guid").GetGuid().ShouldNotBe(Guid.Empty); 56 | } 57 | 58 | [Fact] 59 | public async Task Can_Generate_Machine_Key() 60 | { 61 | // Arrange 62 | var parameters = new Dictionary() 63 | { 64 | ["decryptionAlgorithm"] = "AES-256", 65 | ["validationAlgorithm"] = "SHA1", 66 | }; 67 | 68 | using var client = Fixture.CreateClient(); 69 | 70 | string requestUri = QueryHelpers.AddQueryString("/tools/machinekey", parameters); 71 | 72 | // Act 73 | using var response = await client.GetFromJsonAsync(requestUri, AppJsonSerializerContext.Default.JsonDocument, CancellationToken); 74 | 75 | // Assert 76 | response.ShouldNotBeNull(); 77 | response.RootElement.GetProperty("decryptionKey").GetString().ShouldNotBeNullOrWhiteSpace(); 78 | response.RootElement.GetProperty("validationKey").GetString().ShouldNotBeNullOrWhiteSpace(); 79 | response.RootElement.GetProperty("machineKeyXml").GetString().ShouldNotBeNullOrWhiteSpace(); 80 | 81 | var element = XElement.Parse(response.RootElement.GetProperty("machineKeyXml").GetString()!); 82 | 83 | element.Name.ShouldBe("machineKey"); 84 | element.Attribute("decryption")!.Value.ShouldBe("AES"); 85 | element.Attribute("decryptionKey")!.Value.ShouldBe(response.RootElement.GetProperty("decryptionKey").GetString()); 86 | element.Attribute("validation")!.Value.ShouldBe("SHA1"); 87 | element.Attribute("validationKey")!.Value.ShouldBe(response.RootElement.GetProperty("validationKey").GetString()); 88 | } 89 | 90 | [Theory] 91 | [InlineData("md5", "hexadecimal", "martincostello.com", "e6c3105bdb8e6466f9db1dab47a85131")] 92 | [InlineData("sha1", "hexadecimal", "martincostello.com", "7fbd8e8cf806e5282af895396f5268483bf6af1b")] 93 | [InlineData("sha256", "hexadecimal", "martincostello.com", "3b8143aa8119eaf0910aef5cade45dd0e6bb7b70e8d1c8c057bf3fc125248642")] 94 | [InlineData("sha384", "hexadecimal", "martincostello.com", "5c0e892a9348c184df255f46ab7282eb5792d552c896eb6893d90f36c7202540a9942c80ce5812616d29c08331c60510")] 95 | [InlineData("sha512", "hexadecimal", "martincostello.com", "3be0167275455dcf1e34f8818d48b7ae4a61fb8549153f42d0d035464fdccee97022d663549eb249d4796956e4016ad83d5e64ba766fb751c8fb2c03b2b4eb9a")] 96 | public async Task Can_Generate_Hash(string algorithm, string format, string plaintext, string expected) 97 | { 98 | // Arrange 99 | var request = new JsonObject() 100 | { 101 | ["algorithm"] = algorithm, 102 | ["format"] = format, 103 | ["plaintext"] = plaintext, 104 | }; 105 | 106 | using var client = Fixture.CreateClient(); 107 | 108 | // Act 109 | using var response = await client.PostAsJsonAsync("/tools/hash", request, AppJsonSerializerContext.Default.JsonObject, CancellationToken); 110 | 111 | // Assert 112 | response.EnsureSuccessStatusCode(); 113 | 114 | using var result = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(CancellationToken), cancellationToken: CancellationToken); 115 | 116 | result.RootElement.GetProperty("hash").GetString().ShouldBe(expected); 117 | } 118 | 119 | [JsonSerializable(typeof(JsonDocument))] 120 | [JsonSerializable(typeof(JsonObject))] 121 | private sealed partial class AppJsonSerializerContext : JsonSerializerContext; 122 | } 123 | -------------------------------------------------------------------------------- /tests/API.Tests/EndToEnd/EndToEndTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.EndToEnd; 5 | 6 | [Category("EndToEnd")] 7 | [Collection] 8 | public abstract class EndToEndTest(ApiFixture fixture) 9 | { 10 | protected virtual CancellationToken CancellationToken => TestContext.Current.CancellationToken; 11 | 12 | protected ApiFixture Fixture { get; } = fixture; 13 | } 14 | -------------------------------------------------------------------------------- /tests/API.Tests/EndToEnd/ResourceTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Net; 5 | using System.Net.Mime; 6 | 7 | namespace MartinCostello.Api.EndToEnd; 8 | 9 | public class ResourceTests(ApiFixture fixture) : EndToEndTest(fixture) 10 | { 11 | [Theory] 12 | [InlineData("/", MediaTypeNames.Text.Html)] 13 | [InlineData("/assets/css/main.css", "text/css")] 14 | [InlineData("/assets/css/main.css.map", MediaTypeNames.Text.Plain)] 15 | [InlineData("/assets/js/main.js", "text/javascript")] 16 | [InlineData("/assets/js/main.js.map", MediaTypeNames.Text.Plain)] 17 | [InlineData("BingSiteAuth.xml", MediaTypeNames.Text.Xml)] 18 | [InlineData("browserconfig.xml", MediaTypeNames.Text.Xml)] 19 | [InlineData("/docs", MediaTypeNames.Text.Html)] 20 | [InlineData("/error.html", MediaTypeNames.Text.Html)] 21 | [InlineData("/favicon.ico", "image/x-icon")] 22 | [InlineData("/googled1107923138d0b79.html", MediaTypeNames.Text.Html)] 23 | [InlineData("/gss.xsl", MediaTypeNames.Text.Xml)] 24 | [InlineData("/humans.txt", MediaTypeNames.Text.Plain)] 25 | [InlineData("/keybase.txt", MediaTypeNames.Text.Plain)] 26 | [InlineData("/openapi/api.json", MediaTypeNames.Application.Json)] 27 | [InlineData("/openapi/api.yaml", "application/yaml")] 28 | [InlineData("/robots.txt", MediaTypeNames.Text.Plain)] 29 | [InlineData("/robots933456.txt", MediaTypeNames.Text.Plain)] 30 | [InlineData("/sitemap.xml", MediaTypeNames.Text.Xml)] 31 | [InlineData("/time", MediaTypeNames.Application.Json)] 32 | [InlineData("/tools/guid", MediaTypeNames.Application.Json)] 33 | [InlineData("/tools/machinekey?decryptionAlgorithm=3DES&validationAlgorithm=3DES", MediaTypeNames.Application.Json)] 34 | [InlineData("/version", MediaTypeNames.Application.Json)] 35 | public async Task Can_Load_Resource_As_Get(string requestUri, string contentType) 36 | { 37 | // Arrange 38 | using var client = Fixture.CreateClient(); 39 | 40 | // Act 41 | using var response = await client.GetAsync(requestUri, CancellationToken); 42 | 43 | // Assert 44 | response.StatusCode.ShouldBe(HttpStatusCode.OK); 45 | response.Content.ShouldNotBeNull(); 46 | response.Content!.Headers.ContentType?.MediaType?.ShouldBe(contentType); 47 | } 48 | 49 | [Fact] 50 | public async Task Response_Headers_Contains_Expected_Headers() 51 | { 52 | // Arrange 53 | string[] expectedHeaders = 54 | [ 55 | "content-security-policy", 56 | "Cross-Origin-Embedder-Policy", 57 | "Cross-Origin-Opener-Policy", 58 | "Cross-Origin-Resource-Policy", 59 | "Permissions-Policy", 60 | "Referrer-Policy", 61 | "X-Content-Type-Options", 62 | "X-Download-Options", 63 | "X-Frame-Options", 64 | "X-Instance", 65 | "X-Request-Id", 66 | "X-Revision", 67 | "X-XSS-Protection", 68 | ]; 69 | 70 | using var client = Fixture.CreateClient(); 71 | 72 | // Act 73 | using var response = await client.GetAsync("/", CancellationToken); 74 | 75 | // Assert 76 | foreach (string expected in expectedHeaders) 77 | { 78 | response.Headers.Contains(expected).ShouldBeTrue($"The '{expected}' response header was not found."); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/API.Tests/Integration/GitHubTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Net.Http.Json; 5 | using JustEat.HttpClientInterception; 6 | 7 | namespace MartinCostello.Api.Integration; 8 | 9 | /// 10 | /// A class containing tests for the /github endpoints. 11 | /// 12 | /// 13 | /// Initializes a new instance of the class. 14 | /// 15 | /// The fixture to use. 16 | /// The test output helper to use. 17 | [Collection] 18 | public class GitHubTests(TestServerFixture fixture, ITestOutputHelper outputHelper) : IntegrationTest(fixture, outputHelper) 19 | { 20 | [Fact] 21 | public async Task GitHub_Can_Get_Device_Code() 22 | { 23 | // Arrange 24 | var builder = new HttpRequestInterceptionBuilder() 25 | .ForPost() 26 | .ForUrl("https://github.com/login/device/code?client_id=dkd73mfo9ASgjsfnhJD8&scope=public_repo") 27 | .WithJsonContent( 28 | new() 29 | { 30 | DeviceCode = "3584d83530557fdd1f46af8289938c8ef79f9dc5", 31 | ExpiresInSeconds = 900, 32 | RefreshIntervalInSeconds = 5, 33 | UserCode = "WDJB-MJHT", 34 | VerificationUrl = "https://github.com/login/device", 35 | }, 36 | ApplicationJsonSerializerContext.Default.GitHubDeviceCode); 37 | 38 | builder.RegisterWith(Fixture.Interceptor); 39 | 40 | using var client = Fixture.CreateClient(); 41 | 42 | // Act 43 | var actual = await client.PostAsync( 44 | "/github/login/device/code?client_id=dkd73mfo9ASgjsfnhJD8&scope=public_repo", 45 | null, 46 | CancellationToken); 47 | 48 | actual.EnsureSuccessStatusCode(); 49 | 50 | var deviceCode = await actual.Content.ReadFromJsonAsync( 51 | ApplicationJsonSerializerContext.Default.GitHubDeviceCode, 52 | CancellationToken); 53 | 54 | // Assert 55 | deviceCode.ShouldNotBeNull(); 56 | deviceCode.DeviceCode.ShouldBe("3584d83530557fdd1f46af8289938c8ef79f9dc5"); 57 | deviceCode.ExpiresInSeconds.ShouldBe(900); 58 | deviceCode.RefreshIntervalInSeconds.ShouldBe(5); 59 | deviceCode.UserCode.ShouldBe("WDJB-MJHT"); 60 | deviceCode.VerificationUrl.ShouldBe("https://github.com/login/device"); 61 | } 62 | 63 | [Fact] 64 | public async Task GitHub_Can_Get_Access_Code() 65 | { 66 | // Arrange 67 | var builder = new HttpRequestInterceptionBuilder() 68 | .ForPost() 69 | .ForUrl("https://github.com/login/oauth/access_token?client_id=dkd73mfo9ASgjsfnhJD8&device_code=3584d83530557fdd1f46af8289938c8ef79f9dc5&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code") 70 | .WithJsonContent( 71 | new() 72 | { 73 | AccessToken = "not_a_real_token", 74 | TokenType = "bearer", 75 | Scopes = "public_repo", 76 | }, 77 | ApplicationJsonSerializerContext.Default.GitHubAccessToken); 78 | 79 | builder.RegisterWith(Fixture.Interceptor); 80 | 81 | using var client = Fixture.CreateClient(); 82 | 83 | // Act 84 | var actual = await client.PostAsync( 85 | "/github/login/oauth/access_token?client_id=dkd73mfo9ASgjsfnhJD8&device_code=3584d83530557fdd1f46af8289938c8ef79f9dc5&grant_type=urn:ietf:params:oauth:grant-type:device_code", 86 | null, 87 | CancellationToken); 88 | 89 | actual.EnsureSuccessStatusCode(); 90 | 91 | var deviceCode = await actual.Content.ReadFromJsonAsync( 92 | ApplicationJsonSerializerContext.Default.GitHubAccessToken, 93 | CancellationToken); 94 | 95 | // Assert 96 | deviceCode.ShouldNotBeNull(); 97 | deviceCode.AccessToken.ShouldBe("not_a_real_token"); 98 | deviceCode.Error.ShouldBeNull(); 99 | deviceCode.TokenType.ShouldBe("bearer"); 100 | deviceCode.Scopes.ShouldBe("public_repo"); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/API.Tests/Integration/IntegrationTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Integration; 5 | 6 | /// 7 | /// The base class for integration tests. 8 | /// 9 | [Category("Integration")] 10 | [Collection] 11 | public abstract class IntegrationTest : IDisposable 12 | { 13 | private bool _disposed; 14 | 15 | /// 16 | /// Initializes a new instance of the class. 17 | /// 18 | /// The fixture to use. 19 | /// The test output helper to use. 20 | protected IntegrationTest(TestServerFixture fixture, ITestOutputHelper outputHelper) 21 | { 22 | Fixture = fixture; 23 | Fixture.OutputHelper = outputHelper; 24 | } 25 | 26 | /// 27 | /// Finalizes an instance of the class. 28 | /// 29 | ~IntegrationTest() 30 | { 31 | Dispose(false); 32 | } 33 | 34 | /// 35 | /// Gets the to use. 36 | /// 37 | protected virtual CancellationToken CancellationToken => TestContext.Current.CancellationToken; 38 | 39 | /// 40 | /// Gets the to use. 41 | /// 42 | protected TestServerFixture Fixture { get; } 43 | 44 | /// 45 | public void Dispose() 46 | { 47 | Dispose(true); 48 | GC.SuppressFinalize(this); 49 | } 50 | 51 | /// 52 | /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. 53 | /// 54 | /// 55 | /// to release both managed and unmanaged resources; 56 | /// to release only unmanaged resources. 57 | /// 58 | protected virtual void Dispose(bool disposing) 59 | { 60 | if (!_disposed) 61 | { 62 | if (disposing && Fixture != null) 63 | { 64 | Fixture.OutputHelper = null; 65 | } 66 | 67 | _disposed = true; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/API.Tests/Integration/OpenApiTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using Microsoft.OpenApi.Extensions; 5 | using Microsoft.OpenApi.Readers; 6 | using Microsoft.OpenApi.Validations; 7 | 8 | namespace MartinCostello.Api.Integration; 9 | 10 | [Collection] 11 | public class OpenApiTests(TestServerFixture fixture, ITestOutputHelper outputHelper) : IntegrationTest(fixture, outputHelper) 12 | { 13 | [Fact] 14 | public async Task Json_Schema_Is_Correct() 15 | { 16 | // Arrange 17 | var settings = new VerifySettings(); 18 | settings.DontScrubDateTimes(); 19 | settings.DontScrubGuids(); 20 | 21 | using var client = Fixture.CreateClient(); 22 | 23 | // Act 24 | string actual = await client.GetStringAsync("/openapi/api.json", CancellationToken); 25 | 26 | // Assert 27 | await VerifyJson(actual, settings); 28 | } 29 | 30 | [Fact] 31 | public async Task Yaml_Schema_Is_Correct() 32 | { 33 | // Arrange 34 | var settings = new VerifySettings(); 35 | settings.DontScrubDateTimes(); 36 | settings.DontScrubGuids(); 37 | 38 | using var client = Fixture.CreateClient(); 39 | 40 | // Act 41 | string actual = await client.GetStringAsync("/openapi/api.yaml", CancellationToken); 42 | 43 | // Assert 44 | await Verify(actual, settings); 45 | } 46 | 47 | [Theory] 48 | [InlineData("/openapi/api.json")] 49 | [InlineData("/openapi/api.yaml")] 50 | public async Task Schema_Has_No_Validation_Warnings(string requestUrl) 51 | { 52 | // Arrange 53 | var ruleSet = ValidationRuleSet.GetDefaultRuleSet(); 54 | 55 | // HACK Workaround for https://github.com/microsoft/OpenAPI.NET/issues/1738 56 | ruleSet.Remove("MediaTypeMismatchedDataType"); 57 | 58 | using var client = Fixture.CreateClient(); 59 | 60 | // Act 61 | using var schema = await client.GetStreamAsync(requestUrl, CancellationToken); 62 | 63 | // Assert 64 | var reader = new OpenApiStreamReader(); 65 | var actual = await reader.ReadAsync(schema, CancellationToken); 66 | 67 | actual.OpenApiDiagnostic.Errors.ShouldBeEmpty(); 68 | 69 | var errors = actual.OpenApiDocument.Validate(ruleSet); 70 | errors.ShouldBeEmpty(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/API.Tests/Integration/ResourceTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Net; 5 | using System.Net.Mime; 6 | using Microsoft.AspNetCore.Mvc.Testing; 7 | 8 | namespace MartinCostello.Api.Integration; 9 | 10 | /// 11 | /// A class containing tests for loading resources in the website. 12 | /// 13 | /// 14 | /// Initializes a new instance of the class. 15 | /// 16 | /// The fixture to use. 17 | /// The test output helper to use. 18 | [Collection] 19 | public class ResourceTests(TestServerFixture fixture, ITestOutputHelper outputHelper) : IntegrationTest(fixture, outputHelper) 20 | { 21 | [Theory] 22 | [InlineData("/", MediaTypeNames.Text.Html)] 23 | [InlineData("/assets/css/main.css", "text/css")] 24 | [InlineData("/assets/css/main.css.map", MediaTypeNames.Text.Plain)] 25 | [InlineData("/assets/js/main.js", "text/javascript")] 26 | [InlineData("/assets/js/main.js.map", MediaTypeNames.Text.Plain)] 27 | [InlineData("BingSiteAuth.xml", MediaTypeNames.Text.Xml)] 28 | [InlineData("browserconfig.xml", MediaTypeNames.Text.Xml)] 29 | [InlineData("/docs", MediaTypeNames.Text.Html)] 30 | [InlineData("/error.html", MediaTypeNames.Text.Html)] 31 | [InlineData("/favicon.ico", "image/x-icon")] 32 | [InlineData("/googled1107923138d0b79.html", MediaTypeNames.Text.Html)] 33 | [InlineData("/gss.xsl", MediaTypeNames.Text.Xml)] 34 | [InlineData("/humans.txt", MediaTypeNames.Text.Plain)] 35 | [InlineData("/keybase.txt", MediaTypeNames.Text.Plain)] 36 | [InlineData("/openapi/api.json", MediaTypeNames.Application.Json)] 37 | [InlineData("/openapi/api.yaml", "application/yaml")] 38 | [InlineData("/robots.txt", MediaTypeNames.Text.Plain)] 39 | [InlineData("/robots933456.txt", MediaTypeNames.Text.Plain)] 40 | [InlineData("/sitemap.xml", MediaTypeNames.Text.Xml)] 41 | [InlineData("/time", MediaTypeNames.Application.Json)] 42 | [InlineData("/tools/guid", MediaTypeNames.Application.Json)] 43 | [InlineData("/tools/guid?uppercase=false", MediaTypeNames.Application.Json)] 44 | [InlineData("/tools/guid?uppercase=true", MediaTypeNames.Application.Json)] 45 | [InlineData("/tools/machinekey?decryptionAlgorithm=3DES&validationAlgorithm=3DES", MediaTypeNames.Application.Json)] 46 | [InlineData("/version", MediaTypeNames.Application.Json)] 47 | public async Task Can_Load_Resource_As_Get(string requestUri, string contentType) 48 | { 49 | // Arrange 50 | using var client = Fixture.CreateClient(); 51 | 52 | // Act 53 | using var response = await client.GetAsync(requestUri, CancellationToken); 54 | 55 | // Assert 56 | response.StatusCode.ShouldBe(HttpStatusCode.OK); 57 | response.Content.ShouldNotBeNull(); 58 | response.Content!.Headers.ContentType?.MediaType?.ShouldBe(contentType); 59 | } 60 | 61 | [Theory] 62 | [InlineData("/", MediaTypeNames.Text.Html)] 63 | public async Task Can_Load_Resource_As_Head(string requestUri, string contentType) 64 | { 65 | // Arrange 66 | using var client = Fixture.CreateClient(); 67 | using var message = new HttpRequestMessage(HttpMethod.Head, requestUri); 68 | 69 | // Act 70 | using var response = await client.SendAsync(message, CancellationToken); 71 | 72 | // Assert 73 | response.StatusCode.ShouldBe(HttpStatusCode.OK); 74 | response.Content.ShouldNotBeNull(); 75 | response.Content!.Headers.ContentType?.MediaType?.ShouldBe(contentType); 76 | } 77 | 78 | [Theory] 79 | [InlineData("GET", "/time")] 80 | [InlineData("GET", "/tools/guid")] 81 | [InlineData("POST", "/tools/hash")] 82 | [InlineData("GET", "/tools/machinekey?decryptionAlgorithm=3DES&validationAlgorithm=3DES")] 83 | public async Task Can_Load_Resource_With_Cors(string requestMethod, string requestUri) 84 | { 85 | // Arrange 86 | using var client = Fixture.CreateClient(); 87 | 88 | client.DefaultRequestHeaders.Add("Access-Control-Request-Method", requestMethod); 89 | client.DefaultRequestHeaders.Add("Origin", "https://localhost:50001"); 90 | 91 | using var message = new HttpRequestMessage(HttpMethod.Options, requestUri); 92 | 93 | // Act 94 | using var response = await client.SendAsync(message, CancellationToken); 95 | 96 | // Assert 97 | response.StatusCode.ShouldBe(HttpStatusCode.NoContent); 98 | response.Headers.Contains("Access-Control-Allow-Methods").ShouldBeTrue(); 99 | response.Headers.Contains("Access-Control-Allow-Origin").ShouldBeTrue(); 100 | response.Headers.Contains("Access-Control-Allow-Headers").ShouldBeTrue(); 101 | } 102 | 103 | [Fact] 104 | public async Task Response_Headers_Contains_Expected_Headers() 105 | { 106 | // Arrange 107 | string[] expectedHeaders = 108 | [ 109 | "content-security-policy", 110 | "Cross-Origin-Embedder-Policy", 111 | "Cross-Origin-Opener-Policy", 112 | "Cross-Origin-Resource-Policy", 113 | "Permissions-Policy", 114 | "Referrer-Policy", 115 | "X-Content-Type-Options", 116 | "X-Download-Options", 117 | "X-Frame-Options", 118 | "X-Instance", 119 | "X-Request-Id", 120 | "X-Revision", 121 | "X-XSS-Protection", 122 | ]; 123 | 124 | using var client = Fixture.CreateClient(); 125 | 126 | // Act 127 | using var response = await client.GetAsync("/", CancellationToken); 128 | 129 | // Assert 130 | foreach (string expected in expectedHeaders) 131 | { 132 | response.Headers.Contains(expected).ShouldBeTrue($"The '{expected}' response header was not found."); 133 | } 134 | } 135 | 136 | [Theory] 137 | [InlineData("/foo", HttpStatusCode.NotFound)] 138 | [InlineData("/error", HttpStatusCode.InternalServerError)] 139 | [InlineData("/error?id=399", HttpStatusCode.InternalServerError)] 140 | [InlineData("/error?id=400", HttpStatusCode.BadRequest)] 141 | [InlineData("/error?id=600", HttpStatusCode.InternalServerError)] 142 | public async Task Can_Load_Resource(string requestUri, HttpStatusCode expected) 143 | { 144 | // Arrange 145 | using var client = Fixture.CreateClient(new WebApplicationFactoryClientOptions() { AllowAutoRedirect = false }); 146 | 147 | // Act 148 | using var response = await client.GetAsync(requestUri, CancellationToken); 149 | 150 | // Assert 151 | response.StatusCode.ShouldBe(expected, $"Incorrect status code for {requestUri}"); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /tests/API.Tests/Integration/TestServerCollection.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | namespace MartinCostello.Api.Integration; 5 | 6 | /// 7 | /// A class representing the collection fixture for a test server. This class cannot be inherited. 8 | /// 9 | [CollectionDefinition(Name)] 10 | public sealed class TestServerCollection : ICollectionFixture 11 | { 12 | /// 13 | /// The name of the test fixture. 14 | /// 15 | public const string Name = "Test server collection"; 16 | } 17 | -------------------------------------------------------------------------------- /tests/API.Tests/Integration/TestServerFixture.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using JustEat.HttpClientInterception; 5 | using MartinCostello.Logging.XUnit; 6 | using Microsoft.AspNetCore.Hosting; 7 | using Microsoft.AspNetCore.Mvc.Testing; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Http; 10 | using Microsoft.Extensions.Logging; 11 | using Microsoft.Extensions.Time.Testing; 12 | 13 | namespace MartinCostello.Api.Integration; 14 | 15 | /// 16 | /// A class representing a factory for creating instances of the application. 17 | /// 18 | public class TestServerFixture : WebApplicationFactory, ITestOutputHelperAccessor 19 | { 20 | /// 21 | /// Initializes a new instance of the class. 22 | /// 23 | public TestServerFixture() 24 | : base() 25 | { 26 | ClientOptions.AllowAutoRedirect = false; 27 | ClientOptions.BaseAddress = new Uri("https://localhost"); 28 | } 29 | 30 | /// 31 | /// Gets the in use. 32 | /// 33 | public HttpClientInterceptorOptions Interceptor { get; } = new HttpClientInterceptorOptions().ThrowsOnMissingRegistration(); 34 | 35 | /// 36 | public ITestOutputHelper? OutputHelper { get; set; } 37 | 38 | /// 39 | protected override void ConfigureWebHost(IWebHostBuilder builder) 40 | { 41 | builder.ConfigureLogging((loggingBuilder) => loggingBuilder.ClearProviders().AddXUnit(this)); 42 | builder.ConfigureServices((services) => 43 | { 44 | var utcNow = new DateTimeOffset(2016, 05, 24, 12, 34, 56, TimeSpan.Zero); 45 | var timeProvider = new FakeTimeProvider(utcNow); 46 | 47 | services.AddSingleton(timeProvider); 48 | services.AddSingleton((_) => new HttpRequestInterceptionFilter(Interceptor)); 49 | }); 50 | } 51 | 52 | private sealed class HttpRequestInterceptionFilter(HttpClientInterceptorOptions options) : IHttpMessageHandlerBuilderFilter 53 | { 54 | public Action Configure(Action next) 55 | { 56 | return (builder) => 57 | { 58 | next(builder); 59 | builder.AdditionalHandlers.Add(options.CreateHttpMessageHandler()); 60 | }; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/API.Tests/Integration/TimeTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Net.Http.Json; 5 | 6 | namespace MartinCostello.Api.Integration; 7 | 8 | /// 9 | /// A class containing tests for the /time endpoint. 10 | /// 11 | /// 12 | /// Initializes a new instance of the class. 13 | /// 14 | /// The fixture to use. 15 | /// The test output helper to use. 16 | [Collection] 17 | public class TimeTests(TestServerFixture fixture, ITestOutputHelper outputHelper) : IntegrationTest(fixture, outputHelper) 18 | { 19 | [Fact] 20 | public async Task Time_Get_Returns_Correct_Response() 21 | { 22 | // Arrange 23 | using var client = Fixture.CreateClient(); 24 | 25 | // Act 26 | var actual = await client.GetFromJsonAsync("/time", ApplicationJsonSerializerContext.Default.TimeResponse, CancellationToken); 27 | 28 | // Assert 29 | actual.ShouldNotBeNull(); 30 | actual!.Timestamp.ShouldBe(new DateTimeOffset(2016, 05, 24, 12, 34, 56, TimeSpan.Zero)); 31 | actual.Rfc1123.ShouldBe("Tue, 24 May 2016 12:34:56 GMT"); 32 | actual.UniversalFull.ShouldBe("Tuesday, 24 May 2016 12:34:56"); 33 | actual.UniversalSortable.ShouldBe("2016-05-24 12:34:56Z"); 34 | actual.Unix.ShouldBe(1464093296); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/API.Tests/Integration/ToolsTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Martin Costello, 2016. All rights reserved. 2 | // Licensed under the MIT license. See the LICENSE file in the project root for full license information. 3 | 4 | using System.Net; 5 | using System.Net.Http.Json; 6 | using System.Xml.Linq; 7 | using MartinCostello.Api.Models; 8 | 9 | namespace MartinCostello.Api.Integration; 10 | 11 | /// 12 | /// A class containing tests for the /tools/* endpoints. 13 | /// 14 | /// 15 | /// Initializes a new instance of the class. 16 | /// 17 | /// The fixture to use. 18 | /// The test output helper to use. 19 | [Collection] 20 | public class ToolsTests(TestServerFixture fixture, ITestOutputHelper outputHelper) : IntegrationTest(fixture, outputHelper) 21 | { 22 | [Theory] 23 | [InlineData("MD5", "Hexadecimal", "", "d41d8cd98f00b204e9800998ecf8427e")] 24 | [InlineData("SHA1", "Hexadecimal", "", "da39a3ee5e6b4b0d3255bfef95601890afd80709")] 25 | [InlineData("SHA256", "Hexadecimal", "", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")] 26 | [InlineData("SHA384", "Hexadecimal", "", "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b")] 27 | [InlineData("SHA512", "HEXADECIMAL", "", "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e")] 28 | [InlineData("MD5", "Base64", "", "1B2M2Y8AsgTpgAmY7PhCfg==")] 29 | [InlineData("SHA1", "Base64", "", "2jmj7l5rSw0yVb/vlWAYkK/YBwk=")] 30 | [InlineData("SHA256", "Base64", "", "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=")] 31 | [InlineData("SHA384", "BASE64", "", "OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb")] 32 | [InlineData("SHA512", "base64", "", "z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg==")] 33 | [InlineData("md5", "hexadecimal", "martincostello.com", "e6c3105bdb8e6466f9db1dab47a85131")] 34 | [InlineData("sha1", "hexadecimal", "martincostello.com", "7fbd8e8cf806e5282af895396f5268483bf6af1b")] 35 | [InlineData("sha256", "hexadecimal", "martincostello.com", "3b8143aa8119eaf0910aef5cade45dd0e6bb7b70e8d1c8c057bf3fc125248642")] 36 | [InlineData("sha384", "hexadecimal", "martincostello.com", "5c0e892a9348c184df255f46ab7282eb5792d552c896eb6893d90f36c7202540a9942c80ce5812616d29c08331c60510")] 37 | [InlineData("sha512", "hexadecimal", "martincostello.com", "3be0167275455dcf1e34f8818d48b7ae4a61fb8549153f42d0d035464fdccee97022d663549eb249d4796956e4016ad83d5e64ba766fb751c8fb2c03b2b4eb9a")] 38 | public async Task Tools_Post_Hash_Returns_Correct_Response(string algorithm, string format, string plaintext, string expected) 39 | { 40 | // Arrange 41 | var request = new HashRequest() 42 | { 43 | Algorithm = algorithm, 44 | Format = format, 45 | Plaintext = plaintext, 46 | }; 47 | 48 | using var client = Fixture.CreateClient(); 49 | 50 | // Act 51 | using var response = await client.PostAsJsonAsync("/tools/hash", request, ApplicationJsonSerializerContext.Default.HashRequest, CancellationToken); 52 | 53 | // Assert 54 | response.EnsureSuccessStatusCode(); 55 | 56 | var actual = await response.Content.ReadFromJsonAsync(ApplicationJsonSerializerContext.Default.HashResponse, CancellationToken); 57 | 58 | actual.ShouldNotBeNull(); 59 | actual.Hash.ShouldBe(expected); 60 | } 61 | 62 | [Fact] 63 | public async Task Tools_Get_Machine_Key_Returns_Correct_Response() 64 | { 65 | // Arrange 66 | using var client = Fixture.CreateClient(); 67 | 68 | // Act 69 | var actual = await client.GetFromJsonAsync( 70 | "/tools/machinekey?decryptionAlgorithm=AES-256&validationAlgorithm=SHA1", 71 | ApplicationJsonSerializerContext.Default.MachineKeyResponse, 72 | CancellationToken); 73 | 74 | // Assert 75 | actual.ShouldNotBeNull(); 76 | actual.ShouldNotBeNull(); 77 | actual.DecryptionKey.ShouldNotBeNullOrWhiteSpace(); 78 | actual.MachineKeyXml.ShouldNotBeNullOrWhiteSpace(); 79 | actual.ValidationKey.ShouldNotBeNullOrWhiteSpace(); 80 | 81 | var element = XElement.Parse(actual.MachineKeyXml); 82 | 83 | element.Name.ShouldBe("machineKey"); 84 | element.Attribute("decryption")!.Value.ShouldBe("AES"); 85 | element.Attribute("decryptionKey")!.Value.ShouldBe(actual.DecryptionKey); 86 | element.Attribute("validation")!.Value.ShouldBe("SHA1"); 87 | element.Attribute("validationKey")!.Value.ShouldBe(actual.ValidationKey); 88 | } 89 | 90 | [Theory] 91 | [InlineData("foo")] 92 | public async Task Tools_Get_Guid_Returns_Correct_Response_For_Invalid_Parameters( 93 | string format) 94 | { 95 | // Arrange 96 | using var client = Fixture.CreateClient(); 97 | 98 | // Act 99 | using var response = await client.GetAsync( 100 | $"/tools/guid?format={format}", 101 | CancellationToken); 102 | 103 | // Assert 104 | response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); 105 | } 106 | 107 | [Theory] 108 | [InlineData("", "SHA1")] 109 | [InlineData("foo", "SHA1")] 110 | [InlineData("AES-256", "")] 111 | [InlineData("AES-256", "foo")] 112 | public async Task Tools_Get_Machine_Key_Returns_Correct_Response_For_Invalid_Parameters( 113 | string decryptionAlgorithm, 114 | string validationAlgorithm) 115 | { 116 | // Arrange 117 | using var client = Fixture.CreateClient(); 118 | 119 | // Act 120 | using var response = await client.GetAsync( 121 | $"/tools/machinekey?decryptionAlgorithm={decryptionAlgorithm}&validationAlgorithm={validationAlgorithm}", 122 | CancellationToken); 123 | 124 | // Assert 125 | response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); 126 | } 127 | 128 | [Theory] 129 | [InlineData("", "Hexadecimal", "")] 130 | [InlineData("foo", "Hexadecimal", "")] 131 | [InlineData("MD5", "", "")] 132 | [InlineData("MD5", "foo", "")] 133 | public async Task Tools_Post_Hash_Returns_Correct_Response_For_Invalid_Parameters(string algorithm, string format, string plaintext) 134 | { 135 | // Arrange 136 | var request = new HashRequest() 137 | { 138 | Algorithm = algorithm, 139 | Format = format, 140 | Plaintext = plaintext, 141 | }; 142 | 143 | using var client = Fixture.CreateClient(); 144 | 145 | // Act 146 | using var response = await client.PostAsJsonAsync( 147 | "/tools/hash", 148 | request, 149 | ApplicationJsonSerializerContext.Default.HashRequest, 150 | CancellationToken); 151 | 152 | // Assert 153 | response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); 154 | } 155 | } 156 | --------------------------------------------------------------------------------