diff --git a/.github/workflows/pr-gatechecks.yml b/.github/workflows/pr-gatechecks.yml new file mode 100644 index 0000000..839e348 --- /dev/null +++ b/.github/workflows/pr-gatechecks.yml @@ -0,0 +1,107 @@ +name: PR Gatechecks + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + pr-gatechecks: + permissions: + pull-requests: write + name: PR Gatechecks + runs-on: ubuntu-latest + + steps: + - name: 🎛️ Checkout + uses: actions/checkout@v4 + + - name: 🛠️ Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: 🔄 Restore + run: dotnet restore + + - name: 👷 Build + run: dotnet build --no-restore --configuration Release + + - name: 🔍 Analyze OpenAPI + id: analyze + uses: ApyGuard/apyguard_openapi_analysis@v1.0.9 + with: + file: ${{ github.workspace }}/src/CheersDb.Api/CheersDb.Api.json + output_format: json + + - name: ✏️ Comment on PR + uses: actions/github-script@v9 + env: + ANALYSIS: ${{ steps.analyze.outputs.analysis }} + with: + script: | + const analysis = JSON.parse(process.env.ANALYSIS); + const categories = analysis.analysis_categories || {}; + const analytics = analysis.analytics || {}; + + const comment = `## 🔍 OpenAPI Analysis Results + + **Valid**: ${analysis.is_valid ? '✅' : '❌'} + **Total Suggestions**: ${analysis.suggestions ? Object.values(analysis.suggestions).reduce((total, suggestions) => total + suggestions.length, 0) : 0} + + ### 📊 Basic Metrics + - **Operations**: ${analysis.summary ? analysis.summary.operations_count : 0} + - **Paths**: ${analysis.summary ? analysis.summary.paths_count : 0} + - **Schemas**: ${analysis.summary ? analysis.summary.schemas_count : 0} + + ### 🎯 Advanced Analytics + - **Complexity Score**: ${analytics.complexity_score || 0} + - **Maintainability Score**: ${analytics.maintainability_score || 0}/100 + + ### 📋 Analysis Categories + - **Security Issues**: ${categories.security || 0} + - **Performance Issues**: ${categories.performance || 0} + - **Design Pattern Issues**: ${categories.design_patterns || 0} + - **Versioning Issues**: ${categories.versioning || 0} + - **Documentation Issues**: ${categories.documentation || 0} + - **Compliance Issues**: ${categories.compliance || 0} + - **Testing Recommendations**: ${categories.testing || 0} + - **Monitoring Recommendations**: ${categories.monitoring || 0} + - **Code Generation Opportunities**: ${categories.code_generation || 0} + - **Governance Issues**: ${categories.governance || 0} + + ${analysis.suggestions && Object.keys(analysis.suggestions).length > 0 ? + Object.entries(analysis.suggestions).map(([category, suggestions]) => + `### ${category} (${suggestions.length} issues)\n\n${suggestions.slice(0, 3).map(s => `- ${s}`).join('\n')}${suggestions.length > 3 ? `\n- ... and ${suggestions.length - 3} more` : ''}\n` + ).join('\n') : + '### ✅ No suggestions found! Your OpenAPI specification looks great! 🎉' + } + + --- + *Powered by ApyGuard OpenAPI Analyzer with comprehensive best practices analysis*`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + + - name: 🚦 OpenAPI Gatecheck + uses: actions/github-script@v9 + env: + ANALYSIS: ${{ steps.analyze.outputs.analysis }} + with: + script: | + const analysis = JSON.parse(process.env.ANALYSIS); + const categories = analysis.analysis_categories || {}; + const issueCount = categories.performance + categories.compliance + categories.versioning + categories.documentation; + + if (analysis.is_valid && issueCount === 0) { + console.log('✔️ OpenAPI analysis passed'); + process.exit(0); + } else { + console.log(`Valid: ${analysis.is_valid}`); + console.log(`Total blocking issues: ${issueCount}`); + console.error('❌ OpenAPI analysis failed'); + process.exit(1); + } \ No newline at end of file diff --git a/CheersDb.slnx b/CheersDb.slnx new file mode 100644 index 0000000..aa61e83 --- /dev/null +++ b/CheersDb.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..2da26e7 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + enable + 14.0 + true + enable + net10.0 + 0.0.1 + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..c66bc70 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,12 @@ + + + true + + + + + + + + + \ No newline at end of file diff --git a/src/CheersDb.Api/AppSettings.cs b/src/CheersDb.Api/AppSettings.cs new file mode 100644 index 0000000..20128e2 --- /dev/null +++ b/src/CheersDb.Api/AppSettings.cs @@ -0,0 +1,25 @@ +using CheersDb.Api.Http; +using Microsoft.OpenApi; + +namespace CheersDb.Api; + +/// +/// Represents the application settings for the CheersDb API. +/// +public class AppSettings +{ + /// + /// Gets the JWT auth settings + /// + public JwtOptions? JwtAuth { get; init; } + + /// + /// Gets the OpenAPI information for the API, such as title, version, and description. + /// + public OpenApiInfo? OpenApiInfo { get; init; } + + /// + /// Gets the OpenAPI security scheme for the API, which defines the authentication and authorization requirements. + /// + public OpenApiSecurityScheme? OpenApiSecurityScheme { get; init; } +} \ No newline at end of file diff --git a/src/CheersDb.Api/CheersDb.Api.csproj b/src/CheersDb.Api/CheersDb.Api.csproj new file mode 100644 index 0000000..7f10935 --- /dev/null +++ b/src/CheersDb.Api/CheersDb.Api.csproj @@ -0,0 +1,21 @@ + + + + true + $(MSBuildProjectDirectory) + true + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + \ No newline at end of file diff --git a/src/CheersDb.Api/CheersDb.Api.http b/src/CheersDb.Api/CheersDb.Api.http new file mode 100644 index 0000000..ec22e6a --- /dev/null +++ b/src/CheersDb.Api/CheersDb.Api.http @@ -0,0 +1,6 @@ +@CheersDb.Api_HostAddress = https://localhost:8339 +@BearerToken = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJDaGVlcnNEYkFwaSIsImF1ZCI6IkNoZWVyc0RiQXBpQ2xpZW50cyIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTgxMzkyOTI2M30.2GDbHeNP6iESLtZraeT4wFsNs_WB8XoSbr_WEu0Huv8 + +GET {{CheersDb.Api_HostAddress}}/producers/24 +Accept: application/json +Authorization: Bearer {{BearerToken}} \ No newline at end of file diff --git a/src/CheersDb.Api/CheersDb.Api.json b/src/CheersDb.Api/CheersDb.Api.json new file mode 100644 index 0000000..d92aca7 --- /dev/null +++ b/src/CheersDb.Api/CheersDb.Api.json @@ -0,0 +1,281 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "CheersDb API", + "description": "An API for retrieving and altering breweries on the CheersDb platform", + "contact": { + "name": "CheersDb Support", + "url": "https://cheersdb.org/support", + "email": "info@cheersdb.org" + }, + "license": { + "name": "GPL-3.0", + "url": "https://github.com/PetesBreenCoding/CheersDb?tab=GPL-3.0-1-ov-file" + }, + "version": "v1" + }, + "paths": { + "/producers/{id}": { + "get": { + "tags": [ + "Producers" + ], + "summary": "Get producer details", + "description": "Finds a specific producer usiung the passed id and returns the details in the response body", + "operationId": "GetProducerDetails", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The id of the producer to retrieve", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "example": 24 + } + ], + "responses": { + "200": { + "description": "Returns the requested producer in the response body", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/Cache-Control" + }, + "ETag": { + "$ref": "#/components/headers/ETag" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetProducerDetailsDto" + } + } + } + }, + "404": { + "description": "Indicates the requested producer was not found, or the URI is invalid", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + }, + "429": { + "description": "Indicates that the user has sent too many requests in a given amount of time", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + } + } + }, + "security": [ + { + "bearerAuth": [ ] + } + ] + } + } + }, + "components": { + "schemas": { + "GetProducerDetailsDto": { + "required": [ + "id", + "name", + "revision", + "links" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The id of the producer", + "format": "int32", + "example": 24 + }, + "name": { + "type": "string", + "description": "The name of the producer", + "example": "Rye River Brewing" + }, + "revision": { + "type": "integer", + "description": "The revision number of the producer", + "format": "int32" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LinkDto" + }, + "description": "Links related to the producer, such as a self link to retrieve the producer details", + "example": [ + { + "rel": "self", + "href": "/producers/24", + "method": "GET" + } + ] + } + }, + "description": "Details about a producer" + }, + "LinkDto": { + "required": [ + "rel", + "href", + "method" + ], + "type": "object", + "properties": { + "rel": { + "type": "string", + "description": "The relationship of the link to the current resource", + "example": "self" + }, + "href": { + "type": "string", + "description": "The URL of the link", + "example": "/producers/1" + }, + "method": { + "type": "string", + "description": "The HTTP method for the link", + "example": "GET" + } + }, + "description": "Represents a hypermedia link in the API response, providing information about the relationship, URL, and HTTP method for the link." + }, + "ProblemDetailsDto": { + "required": [ + "type", + "title", + "status", + "detail" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "A URI reference [RFC3986] that identifies the problem type", + "example": "https://cheersdb.org/error-codes/e100" + }, + "title": { + "type": "string", + "description": "A short, human-readable summary of the problem type", + "example": "Invalid request" + }, + "status": { + "type": "integer", + "description": "The HTTP status code ([RFC7231], Section 6) generated by the origin server for this occurrence of the problem", + "format": "int32", + "example": 400 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem", + "example": "The request payload is missing the required 'username' field." + }, + "instance": { + "type": [ + "null", + "string" + ], + "description": "A URI reference [RFC3986] that identifies the specific occurrence of the problem", + "example": "https://cheersdb.org/error-codes/e100/instances/12345" + } + }, + "description": "Represents a standardized error response according to RFC 7807 (Problem Details for HTTP APIs)." + } + }, + "responses": { + "InternalServerError": { + "description": "Indicates that an unexpected internal server error has occurred", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "headers": { + "Cache-Control": { + "description": "Instructions for caching mechanisms in responses", + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "Indicates the current version of the resource", + "schema": { + "type": "string" + } + }, + "Retry-After": { + "description": "Indicates how many seconds the user agent should wait before making a follow-up request", + "schema": { + "type": "number" + } + }, + "X-RateLimit-Limit": { + "description": "Indicates the maximum number of requests that the user is allowed to make in a given amount of time", + "schema": { + "type": "number" + }, + "example": 1000 + }, + "X-RateLimit-Remaining": { + "description": "Indicates the number of requests remaining in the current rate limit window", + "schema": { + "type": "number" + }, + "example": 999 + }, + "X-RateLimit-Reset": { + "description": "The number of seconds until the rate limit resets.", + "schema": { + "type": "number" + }, + "example": 60 + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "description": "JWT Bearer token authentication", + "scheme": "Bearer", + "bearerFormat": "JWT" + } + } + }, + "security": [ + { + "bearerAuth": [ ] + } + ], + "tags": [ + { + "name": "Producers" + } + ] +} \ No newline at end of file diff --git a/src/CheersDb.Api/Controllers/ProducersController.cs b/src/CheersDb.Api/Controllers/ProducersController.cs new file mode 100644 index 0000000..f1c2c70 --- /dev/null +++ b/src/CheersDb.Api/Controllers/ProducersController.cs @@ -0,0 +1,50 @@ +using CheersDb.Api.Dtos; +using CheersDb.Api.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using System.Net.Mime; + +namespace CheersDb.Api.Controllers; + +/// +/// Controller for managing producers in the CheersDb API. +/// +[ApiController] +[Route("[controller]")] +[Produces(MediaTypeNames.Application.Json)] +public class ProducersController : ControllerBase +{ + /// + /// Get producer details + /// + /// Finds a specific producer usiung the passed id and returns the details in the response body + /// The id of the producer to retrieve + /// The requested producer details + /// GET /producers/24 + [HttpGet("{id:int}", Name = nameof(GetProducerDetails))] + [ProducesResponseType(typeof(GetProducerDetailsDto), StatusCodes.Status200OK, Description = "Returns the requested producer in the response body")] + [ProducesResponseType(StatusCodes.Status404NotFound, Description = "Indicates the requested producer was not found, or the URI is invalid", Type = typeof(ProblemDetailsDto))] + [ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any, NoStore = false)] + public IActionResult GetProducerDetails([FromRoute] int id) + { + var producerDetails = new GetProducerDetailsDto() + { + Id = id, + Name = "Rye River Brewing Company", + Revision = 7, + Links = + [ + new() + { + Rel = LinkRels.Self, + Href = Url.RouteUrl(nameof(GetProducerDetails), new { id }) ?? string.Empty, + Method = HttpMethod.Get.ToString() + } + ] + }; + + Response.Headers.Append(HeaderNames.ETag, producerDetails.Revision.ToString()); + + return Ok(producerDetails); + } +} \ No newline at end of file diff --git a/src/CheersDb.Api/Dtos/GetProducerDetailsDto.cs b/src/CheersDb.Api/Dtos/GetProducerDetailsDto.cs new file mode 100644 index 0000000..76f62f3 --- /dev/null +++ b/src/CheersDb.Api/Dtos/GetProducerDetailsDto.cs @@ -0,0 +1,38 @@ +namespace CheersDb.Api.Dtos; + +/// +/// Details about a producer +/// +public class GetProducerDetailsDto +{ + /// + /// The id of the producer + /// + /// 24 + public required int Id { get; init; } + + /// + /// The name of the producer + /// + /// Rye River Brewing + public required string Name { get; init; } + + /// + /// The revision number of the producer + /// + public required int Revision { get; init; } + + /// + /// Links related to the producer, such as a self link to retrieve the producer details + /// + /// + /// [ + /// { + /// "rel": "self", + /// "href": "/producers/24", + /// "method": "GET" + /// } + /// ] + /// + public required List Links { get; init; } +} \ No newline at end of file diff --git a/src/CheersDb.Api/Dtos/LinkDto.cs b/src/CheersDb.Api/Dtos/LinkDto.cs new file mode 100644 index 0000000..488a435 --- /dev/null +++ b/src/CheersDb.Api/Dtos/LinkDto.cs @@ -0,0 +1,25 @@ +namespace CheersDb.Api.Dtos; + +/// +/// Represents a hypermedia link in the API response, providing information about the relationship, URL, and HTTP method for the link. +/// +public class LinkDto +{ + /// + /// The relationship of the link to the current resource + /// + /// self + public required string Rel { get; init; } + + /// + /// The URL of the link + /// + /// /producers/1 + public required string Href { get; init; } + + /// + /// The HTTP method for the link + /// + /// GET + public required string Method { get; init; } +} \ No newline at end of file diff --git a/src/CheersDb.Api/Dtos/ProblemDetailsDto.cs b/src/CheersDb.Api/Dtos/ProblemDetailsDto.cs new file mode 100644 index 0000000..8f7f587 --- /dev/null +++ b/src/CheersDb.Api/Dtos/ProblemDetailsDto.cs @@ -0,0 +1,37 @@ +namespace CheersDb.Api.Dtos; + +/// +/// Represents a standardized error response according to RFC 7807 (Problem Details for HTTP APIs). +/// +public class ProblemDetailsDto +{ + /// + /// A URI reference [RFC3986] that identifies the problem type + /// + /// https://cheersdb.org/error-codes/e100 + public required string Type { get; init; } + + /// + /// A short, human-readable summary of the problem type + /// + /// Invalid request + public required string Title { get; init; } + + /// + /// The HTTP status code ([RFC7231], Section 6) generated by the origin server for this occurrence of the problem + /// + /// 400 + public required int Status { get; init; } + + /// + /// A human-readable explanation specific to this occurrence of the problem + /// + /// The request payload is missing the required 'username' field. + public required string Detail { get; init; } + + /// + /// A URI reference [RFC3986] that identifies the specific occurrence of the problem + /// + /// https://cheersdb.org/error-codes/e100/instances/12345 + public string? Instance { get; set; } +} \ No newline at end of file diff --git a/src/CheersDb.Api/Extensions/ConfigurationManagerExtensions.cs b/src/CheersDb.Api/Extensions/ConfigurationManagerExtensions.cs new file mode 100644 index 0000000..8ec991b --- /dev/null +++ b/src/CheersDb.Api/Extensions/ConfigurationManagerExtensions.cs @@ -0,0 +1,32 @@ +namespace CheersDb.Api.Extensions; + +/// +/// Provides extension methods for the ConfigurationManager class +/// +public static class ConfigurationManagerExtensions +{ + extension(ConfigurationManager configurationManager) + { + /// + /// Gets the application settings from the configuration manager and validates them + /// + /// A validated AppSettings instance. + /// Thrown when the application settings are invalid. + public AppSettings GetAppSettings() + { + var appSettings = configurationManager.Get() + ?? throw new InvalidOperationException("There was an issue parsing the application settings."); + + if (appSettings.OpenApiInfo is null) + throw new InvalidOperationException($"{nameof(AppSettings.OpenApiInfo)} was not parsed in the application settings."); + + if (string.IsNullOrEmpty(appSettings.OpenApiSecurityScheme?.Name) || string.IsNullOrEmpty(appSettings.OpenApiSecurityScheme?.Scheme)) + throw new InvalidOperationException($"{nameof(AppSettings.OpenApiSecurityScheme)} was not parsed in the application settings."); + + if (string.IsNullOrEmpty(appSettings.JwtAuth?.Key)) + throw new InvalidOperationException($"{nameof(AppSettings.JwtAuth)} was not parsed in the application settings."); + + return appSettings; + } + } +} \ No newline at end of file diff --git a/src/CheersDb.Api/Extensions/OpenApiComponentsExtensions.cs b/src/CheersDb.Api/Extensions/OpenApiComponentsExtensions.cs new file mode 100644 index 0000000..09b0c42 --- /dev/null +++ b/src/CheersDb.Api/Extensions/OpenApiComponentsExtensions.cs @@ -0,0 +1,115 @@ +using CheersDb.Api.Dtos; +using CheersDb.Api.Http; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Net.Http.Headers; +using Microsoft.OpenApi; +using System.Net; +using System.Net.Mime; + +namespace CheersDb.Api.Extensions; + +/// +/// Provides extension methods for configuring OpenAPI components in the CheersDb API application. +/// +public static class OpenApiComponentsExtensions +{ + private static readonly OpenApiSchema _stringSchema = new() { Type = JsonSchemaType.String }; + private static readonly OpenApiSchema _numberSchema = new() { Type = JsonSchemaType.Number }; + + extension(OpenApiComponents components) + { + /// + /// Adds a response to the OpenAPI components if it does not already exist. + /// + /// The context of the OpenAPI document transformation. + /// A cancellation token that can be used to cancel the operation. + public async Task ConfigureResponsesAsync(OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + components.Responses ??= new Dictionary(); + + var problemDetailsSchema = await context.GetOrCreateSchemaAsync(typeof(ProblemDetailsDto), cancellationToken: cancellationToken); + + var internalServerErrorResonse = new OpenApiResponse + { + Description = "Indicates that an unexpected internal server error has occurred", + Content = new Dictionary + { + [MediaTypeNames.Application.Json] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference(nameof(ProblemDetailsDto)) + } + } + }; + + components.Responses.Add(nameof(HttpStatusCode.InternalServerError), internalServerErrorResonse); + } + + /// + /// Configures the OpenAPI components to include a header for caching mechanisms in responses. + /// + public void ConfigureHeaders() + { + components.Headers ??= new Dictionary(); + + var cacheControlHeader = new OpenApiHeader + { + Description = "Instructions for caching mechanisms in responses", + Schema = _stringSchema + }; + + var etagHeader = new OpenApiHeader + { + Description = "Indicates the current version of the resource", + Schema = _stringSchema + }; + + var retryAfterHeader = new OpenApiHeader + { + Description = "Indicates how many seconds the user agent should wait before making a follow-up request", + Schema = _numberSchema + }; + + var rateLimitLimitHeader = new OpenApiHeader + { + Description = "Indicates the maximum number of requests that the user is allowed to make in a given amount of time", + Example = 1000, + Schema = _numberSchema, + }; + + var rateLimitRemainingHeader = new OpenApiHeader + { + Description = "Indicates the number of requests remaining in the current rate limit window", + Example = 999, + Schema = _numberSchema, + }; + + var rateLimitResetHeader = new OpenApiHeader + { + Description = "The number of seconds until the rate limit resets.", + Example = 60, + Schema = _numberSchema, + }; + + components.Headers.Add(HeaderNames.CacheControl, cacheControlHeader); + components.Headers.Add(HeaderNames.ETag, etagHeader); + components.Headers.Add(HeaderNames.RetryAfter, retryAfterHeader); + components.Headers.Add(NonStandardHeaderNames.XRateLimitLimit, rateLimitLimitHeader); + components.Headers.Add(NonStandardHeaderNames.XRateLimitRemaining, rateLimitRemainingHeader); + components.Headers.Add(NonStandardHeaderNames.XRateLimitReset, rateLimitResetHeader); + } + + /// + /// Configures the OpenAPI components to include a security scheme for JWT Bearer authentication. + /// + /// The OpenAPI security scheme to include. + public void ConfigureSecuritySchemes(OpenApiSecurityScheme? openApiSecurityScheme) + { + if (openApiSecurityScheme is null) + return; + + components.SecuritySchemes ??= new Dictionary(); + components.SecuritySchemes.Add(openApiSecurityScheme.Name ?? JwtBearerDefaults.AuthenticationScheme, openApiSecurityScheme); + } + } +} \ No newline at end of file diff --git a/src/CheersDb.Api/Extensions/OpenApiOptionsExtensions.cs b/src/CheersDb.Api/Extensions/OpenApiOptionsExtensions.cs new file mode 100644 index 0000000..76fcd4f --- /dev/null +++ b/src/CheersDb.Api/Extensions/OpenApiOptionsExtensions.cs @@ -0,0 +1,98 @@ +using CheersDb.Api.Http; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Net.Http.Headers; +using Microsoft.OpenApi; +using System.Net; + +namespace CheersDb.Api.Extensions; + +/// +/// Provides extension methods for configuring OpenAPI options in the CheersDb API application. +/// +public static class OpenApiOptionsExtensions +{ + private static readonly string _internalServerErrorResponseKey = ((int)HttpStatusCode.InternalServerError).ToString(); + private static readonly string _tooManyRequestsResponseKey = ((int)HttpStatusCode.TooManyRequests).ToString(); + + private static readonly OpenApiResponseReference _internalServerErrorResponseReference = new(nameof(HttpStatusCode.InternalServerError)); + + private static readonly OpenApiHeaderReference _cacheControlHeaderReference = new(HeaderNames.CacheControl); + private static readonly OpenApiHeaderReference _etagHeaderReference = new(HeaderNames.ETag); + private static readonly OpenApiHeaderReference _retryAfterHeaderReference = new(HeaderNames.RetryAfter); + private static readonly OpenApiHeaderReference _rateLimitLimitHeaderReference = new(NonStandardHeaderNames.XRateLimitLimit); + private static readonly OpenApiHeaderReference _rateLimitRemainingHeaderReference = new(NonStandardHeaderNames.XRateLimitRemaining); + private static readonly OpenApiHeaderReference _rateLimitResetHeaderReference = new(NonStandardHeaderNames.XRateLimitReset); + private static OpenApiSecurityRequirement? _defaultSecurityRequirement = null; + + private static OpenApiSecurityRequirement BuildDefaultSecurityRequirement(AppSettings? appSettings, OpenApiDocument? document) + { + return _defaultSecurityRequirement ??= new OpenApiSecurityRequirement + { + { new OpenApiSecuritySchemeReference(appSettings?.OpenApiSecurityScheme?.Name ?? JwtBearerDefaults.AuthenticationScheme, document), new List() } + }; + } + + extension(OpenApiOptions options) + { + /// + /// Adds a document transformer to the OpenAPI options that applies the provided transformation function to the generated OpenAPI document. + /// + /// The application settings containing the OpenAPI information to be applied to the document. + public OpenApiOptions ConfigureDocument(AppSettings appSettings) + { + return options.AddDocumentTransformer(async (document, context, cancellationToken) => + { + if (appSettings?.OpenApiInfo is not null) + document.Info = appSettings.OpenApiInfo; + + document.Security ??= []; + document.Security.Add(BuildDefaultSecurityRequirement(appSettings, document)); + + document.Components ??= new OpenApiComponents(); + + await document.Components.ConfigureResponsesAsync(context, cancellationToken); + document.Components.ConfigureHeaders(); + document.Components.ConfigureSecuritySchemes(appSettings?.OpenApiSecurityScheme); + }); + } + + /// + /// Adds an operation transformer to the OpenAPI options that configures operation responses. + /// + public OpenApiOptions ConfigureOperations(AppSettings appSettings) + { + return options.AddOperationTransformer(async (operation, context, cancellationToken) => + { + operation.Responses ??= []; + operation.Responses.Add(_internalServerErrorResponseKey, _internalServerErrorResponseReference); + + var tooManyRequestsResponse = new OpenApiResponse + { + Description = "Indicates that the user has sent too many requests in a given amount of time", + Headers = new Dictionary + { + [HeaderNames.RetryAfter] = _retryAfterHeaderReference + } + }; + + operation.Responses.Add(_tooManyRequestsResponseKey, tooManyRequestsResponse); + + operation.Responses.TryGetValue(((int)HttpStatusCode.OK).ToString(), out var okResponse); + + if (okResponse is not null && okResponse is OpenApiResponse okResponseConcrete) + { + okResponseConcrete.Headers ??= new Dictionary(); + okResponseConcrete.Headers.Add(HeaderNames.CacheControl, _cacheControlHeaderReference); + okResponseConcrete.Headers.Add(HeaderNames.ETag, _etagHeaderReference); + okResponseConcrete.Headers.Add(NonStandardHeaderNames.XRateLimitLimit, _rateLimitLimitHeaderReference); + okResponseConcrete.Headers.Add(NonStandardHeaderNames.XRateLimitRemaining, _rateLimitRemainingHeaderReference); + okResponseConcrete.Headers.Add(NonStandardHeaderNames.XRateLimitReset, _rateLimitResetHeaderReference); + } + + operation.Security ??= []; + operation.Security.Add(BuildDefaultSecurityRequirement(appSettings, context.Document)); + }); + } + } +} \ No newline at end of file diff --git a/src/CheersDb.Api/Extensions/ServiceCollectionExtensions.cs b/src/CheersDb.Api/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..f5e5de4 --- /dev/null +++ b/src/CheersDb.Api/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.OpenApi; + +namespace CheersDb.Api.Extensions; + +/// +/// Provides extension methods for configuring services in the CheersDb API application. +/// +public static class ServiceCollectionExtensions +{ + extension(IServiceCollection services) + { + /// + /// Configures OpenAPI for the application using the provided AppSettings + /// + /// + public IServiceCollection AddConfiguredOpenApi(AppSettings appSettings) + { + return services.AddOpenApi(options => + { + options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; + options.ConfigureDocument(appSettings); + options.ConfigureOperations(appSettings); + }); + } + } +} \ No newline at end of file diff --git a/src/CheersDb.Api/Http/JwtOptions.cs b/src/CheersDb.Api/Http/JwtOptions.cs new file mode 100644 index 0000000..f236b0d --- /dev/null +++ b/src/CheersDb.Api/Http/JwtOptions.cs @@ -0,0 +1,22 @@ +namespace CheersDb.Api.Http; + +/// +/// Configuration options for JWT (JSON Web Token) authentication in the CheersDb API. +/// +public sealed class JwtOptions +{ + /// + /// Gets or initializes the issuer of the JWT. This is the principal that issued the token. + /// + public string? Issuer { get; init; } + + /// + /// Gets or initializes the intended audience of the JWT. This identifies the recipients that the JWT is intended for. + /// + public string? Audience { get; init; } + + /// + /// Gets or initializes the secret key used to sign and verify the JWT. + /// + public string? Key { get; init; } +} \ No newline at end of file diff --git a/src/CheersDb.Api/Http/LinkRels.cs b/src/CheersDb.Api/Http/LinkRels.cs new file mode 100644 index 0000000..777c153 --- /dev/null +++ b/src/CheersDb.Api/Http/LinkRels.cs @@ -0,0 +1,47 @@ +namespace CheersDb.Api.Http; + +/// +/// Defines standard link relationship types for hypermedia links in the API responses, following common conventions for RESTful APIs. +/// +public static class LinkRels +{ + /// + /// Indicates an alternate representation of the resource. + /// + public const string Alternate = "alternate"; + + /// + /// Identifies a collection resource (a list of items). + /// + public const string Collection = "collection"; + + /// + /// Identifies an individual item within a collection. + /// + public const string Item = "item"; + + /// + /// A link to the next page or resource in a sequence. + /// + public const string Next = "next"; + + /// + /// A link to the previous page or resource in a sequence. + /// + public const string Prev = "prev"; + + /// + /// A related resource linked to the current resource. + /// + public const string Related = "related"; + + /// + /// A link that points to the resource itself. + /// + public const string Self = "self"; + + /// + /// A link to a parent resource or higher-level context. + /// + public const string Up = "up"; +} \ No newline at end of file diff --git a/src/CheersDb.Api/Http/NonStandardHeaderNames.cs b/src/CheersDb.Api/Http/NonStandardHeaderNames.cs new file mode 100644 index 0000000..b487f00 --- /dev/null +++ b/src/CheersDb.Api/Http/NonStandardHeaderNames.cs @@ -0,0 +1,22 @@ +namespace CheersDb.Api.Http; + +/// +/// Contains constants for non-standard HTTP header names used in the CheersDb API. +/// +public static class NonStandardHeaderNames +{ + /// + /// The X-RateLimit-Limit header name. Indicates the maximum number of requests allowed in a given time period. + /// + public const string XRateLimitLimit = "X-RateLimit-Limit"; + + /// + /// The X-RateLimit-Remaining header name. Indicates the number of requests remaining in the current rate limit window. + /// + public const string XRateLimitRemaining = "X-RateLimit-Remaining"; + + /// + /// The X-RateLimit-Reset header name. Indicates the number of seconds until the rate limit window resets. + /// + public const string XRateLimitReset = "X-RateLimit-Reset"; +} \ No newline at end of file diff --git a/src/CheersDb.Api/Program.cs b/src/CheersDb.Api/Program.cs new file mode 100644 index 0000000..e6d633c --- /dev/null +++ b/src/CheersDb.Api/Program.cs @@ -0,0 +1,66 @@ +using CheersDb.Api.Extensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.IdentityModel.Tokens; +using Scalar.AspNetCore; +using System.Text; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); +var appSettings = builder.Configuration.GetAppSettings(); + +builder.Services + .AddAuthentication(appSettings.OpenApiSecurityScheme!.Scheme!) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new() + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + + ValidIssuer = appSettings.JwtAuth!.Issuer, + ValidAudience = appSettings.JwtAuth!.Audience, + + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(appSettings.JwtAuth!.Key!)) + }; + }); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict; +}); + +// Add services to the container. +builder.Services.AddControllers(); + +builder.Services.AddAuthorizationBuilder() + .SetFallbackPolicy(new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build()); + +builder.Services.AddRouting(options => +{ + options.LowercaseUrls = true; + options.LowercaseQueryStrings = true; +}); + +builder.Services.AddConfiguredOpenApi(appSettings); + +builder.Services.AddSingleton(appSettings); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi().AllowAnonymous(); + app.MapScalarApiReference("/docs").AllowAnonymous(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers().RequireAuthorization(); + +app.Run(); \ No newline at end of file diff --git a/src/CheersDb.Api/Properties/launchSettings.json b/src/CheersDb.Api/Properties/launchSettings.json new file mode 100644 index 0000000..0a54048 --- /dev/null +++ b/src/CheersDb.Api/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "https://localhost:8339/openapi/v1.json", + "applicationUrl": "https://localhost:8339", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/CheersDb.Api/appsettings.Development.json b/src/CheersDb.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/CheersDb.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/CheersDb.Api/appsettings.json b/src/CheersDb.Api/appsettings.json new file mode 100644 index 0000000..59636c5 --- /dev/null +++ b/src/CheersDb.Api/appsettings.json @@ -0,0 +1,36 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "JwtAuth": { + "Issuer": "CheersDbApi", + "Audience": "CheersDbApiClients", + "Key": "sDQailgaaSsl9fnl9eweVwgR9TTY464q" + }, + "OpenApiInfo": { + "Title": "CheersDb API", + "Description": "An API for retrieving and altering breweries on the CheersDb platform", + "Version": "v1", + "Contact": { + "Name": "CheersDb Support", + "Email": "info@cheersdb.org", + "Url": "https://cheersdb.org/support" + }, + "License": { + "Name": "GPL-3.0", + "Url": "https://github.com/PetesBreenCoding/CheersDb?tab=GPL-3.0-1-ov-file" + } + }, + "OpenApiSecurityScheme": { + "Name": "bearerAuth", + "Description": "JWT Bearer token authentication", + "Type": "Http", + "Scheme": "Bearer", + "BearerFormat": "JWT", + "In": "Header" + } +} \ No newline at end of file