diff --git a/.github/workflows/pr-gatechecks.yml b/.github/workflows/pr-gatechecks.yml new file mode 100644 index 0000000..d80466e --- /dev/null +++ b/.github/workflows/pr-gatechecks.yml @@ -0,0 +1,121 @@ +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; + + let issueCount = 0; + for (let propKey in categories){ + if (propKey != 'code_generation' && propKey != 'testing' && propKey != 'monitoring' && propKey != 'design_patterns'){ + issueCount += categories[propKey] + } + } + + if (issueCount > 0 + && Array.isArray(analysis.suggestions['Documentation']) + && analysis.suggestions['Documentation'].findIndex((s) => s == 'Operation GET /producers/{id} missing examples.') > -1) { + console.log('Ignoring incorrect documentation issue for GET /producers/{id}'); + issueCount -= 1; // Ignore the specific documentation issue for GET /producers/{id} + } + + 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/.gitignore b/.gitignore index d5a18de..b729452 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +#ADDED +src/CheersDb.Api/CheersDb.Api.json + # User-specific files *.rsuser *.suo diff --git a/CheersDb.slnx b/CheersDb.slnx new file mode 100644 index 0000000..3044f01 --- /dev/null +++ b/CheersDb.slnx @@ -0,0 +1,8 @@ + + + + + + + + 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..1520b69 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,12 @@ + + + true + + + + + + + + + \ 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..bdb950a --- /dev/null +++ b/src/CheersDb.Api/CheersDb.Api.csproj @@ -0,0 +1,22 @@ + + + + true + $(MSBuildProjectDirectory) + true + true + 670c1f6e-9a0e-4a77-bdf0-2f05341d6b43 + + + + + + + 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..5330eea --- /dev/null +++ b/src/CheersDb.Api/CheersDb.Api.http @@ -0,0 +1,12 @@ +@CheersDb.Api_HostAddress = https://localhost:8339 +@BearerToken = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6IlBldGVyIiwic3ViIjoiUGV0ZXIiLCJqdGkiOiJiMjA4NzI3ZSIsImF1ZCI6Imh0dHBzOi8vbG9jYWxob3N0OjgzMzkiLCJuYmYiOjE3ODQ1NjMxMTksImV4cCI6MTc5MjUxMTkxOSwiaWF0IjoxNzg0NTYzMTIwLCJpc3MiOiJkb3RuZXQtdXNlci1qd3RzIn0.JmHTxMVhx2jkGaPJstCIETjyRxBejJ7czXAoX66fU6I + +### GET health +GET {{CheersDb.Api_HostAddress}}/health +Accept: application/json +Authorization: Bearer {{BearerToken}} + +### GET single producer +GET {{CheersDb.Api_HostAddress}}/producers/24 +Accept: application/json +Authorization: Bearer {{BearerToken}} \ No newline at end of file diff --git a/src/CheersDb.Api/Controllers/HealthController.cs b/src/CheersDb.Api/Controllers/HealthController.cs new file mode 100644 index 0000000..5303c7f --- /dev/null +++ b/src/CheersDb.Api/Controllers/HealthController.cs @@ -0,0 +1,33 @@ +using CheersDb.Api.Dtos; +using Microsoft.AspNetCore.Mvc; +using System.Net.Mime; + +namespace CheersDb.Api.Controllers; + +/// +/// Controller for health status checks in the CheersDb API. +/// +[ApiController] +[Route("[controller]")] +[Produces(MediaTypeNames.Application.Json)] +public class HealthController : ControllerBase +{ + /// + /// Get the health status of the API + /// + /// Returns the current health status of the API and service information + /// The health status of the API + [HttpGet(Name = nameof(GetHealth))] + [ProducesResponseType(typeof(HealthStatusDto), StatusCodes.Status200OK, Description = "Indicates the API is healthy and operational")] + public IActionResult GetHealth() + { + var healthStatus = new HealthStatusDto() + { + Status = "Healthy", + Timestamp = DateTime.UtcNow, + Version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "Unknown" + }; + + return Ok(healthStatus); + } +} \ 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..f1b8d9b --- /dev/null +++ b/src/CheersDb.Api/Controllers/ProducersController.cs @@ -0,0 +1,51 @@ +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.Status400BadRequest, Description = "Indicates that the request is malformed or contains invalid data", Type = typeof(ProblemDetailsDto))] + [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..39fe5a9 --- /dev/null +++ b/src/CheersDb.Api/Dtos/GetProducerDetailsDto.cs @@ -0,0 +1,53 @@ +namespace CheersDb.Api.Dtos; + +/// +/// Details about a producer +/// +/// +/// { +/// "id": 24, +/// "name": "Rye River Brewing", +/// "revision": 7, +/// "links": [ +/// { +/// "rel": "self", +/// "href": "/producers/24", +/// "method": "GET" +/// } +/// ] +/// } +/// +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 + /// + /// 7 + 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/HealthStatusDto.cs b/src/CheersDb.Api/Dtos/HealthStatusDto.cs new file mode 100644 index 0000000..e9db827 --- /dev/null +++ b/src/CheersDb.Api/Dtos/HealthStatusDto.cs @@ -0,0 +1,32 @@ +namespace CheersDb.Api.Dtos; + +/// +/// Represents the health status of the API +/// +/// +/// { +/// "status": "Healthy", +/// "timestamp": "2024-01-15T10:30:00Z", +/// "version": "1.0.0.0" +/// } +/// +public class HealthStatusDto +{ + /// + /// The health status of the API + /// + /// Healthy + public required string Status { get; init; } + + /// + /// The timestamp when the health check was performed (UTC) + /// + /// 2024-01-15T10:30:00Z + public required DateTime Timestamp { get; init; } + + /// + /// The version of the API + /// + /// 1.0.0.0 + public required string Version { get; init; } +} diff --git a/src/CheersDb.Api/Dtos/LinkDto.cs b/src/CheersDb.Api/Dtos/LinkDto.cs new file mode 100644 index 0000000..fce3fe5 --- /dev/null +++ b/src/CheersDb.Api/Dtos/LinkDto.cs @@ -0,0 +1,32 @@ +namespace CheersDb.Api.Dtos; + +/// +/// Represents a hypermedia link in the API response, providing information about the relationship, URL, and HTTP method for the link. +/// +/// +/// { +/// "rel": "self", +/// "href": "/producers/24", +/// "method": "GET" +/// } +/// +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..b209f27 --- /dev/null +++ b/src/CheersDb.Api/Dtos/ProblemDetailsDto.cs @@ -0,0 +1,45 @@ +namespace CheersDb.Api.Dtos; + +/// +/// Represents a standardized error response according to RFC 7807 (Problem Details for HTTP APIs). +/// +/// +/// { +/// "type": "https://cheersdb.org/error-codes/e100", +/// "title": "Bad Request", +/// "status": 400, +/// "detail": "The request is malformed or contains invalid data" +/// } +/// +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..147e5b6 --- /dev/null +++ b/src/CheersDb.Api/Extensions/ConfigurationManagerExtensions.cs @@ -0,0 +1,37 @@ +using CheersDb.Api.Settings; + +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.OpenApi is null) + throw new InvalidOperationException($"{nameof(AppSettings.OpenApi)} was not parsed in the application settings."); + + if (appSettings.OpenApi.Info is null) + throw new InvalidOperationException($"{nameof(AppSettings.OpenApi.Info)} was not parsed in the application settings."); + + if (appSettings.OpenApi.Servers?.Count is null or 0) + throw new InvalidOperationException($"{nameof(AppSettings.OpenApi.Servers)} was not parsed in the application settings."); + + if (string.IsNullOrEmpty(appSettings.OpenApi.Security?.Name) || string.IsNullOrEmpty(appSettings.OpenApi.Security?.Scheme)) + throw new InvalidOperationException($"{nameof(AppSettings.OpenApi.Security)} 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..966bdb6 --- /dev/null +++ b/src/CheersDb.Api/Extensions/OpenApiComponentsExtensions.cs @@ -0,0 +1,121 @@ +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; +using System.Text.Json; + +namespace CheersDb.Api.Extensions; + +/// +/// Provides extension methods for configuring OpenAPI components in the CheersDb API application. +/// +public static class OpenApiComponentsExtensions +{ + 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); + + components.Responses.Add(nameof(HttpStatusCode.InternalServerError), new OpenApiResponse + { + Description = OpenApiSpec.InternalServerErrorDescription, + Content = new Dictionary + { + [MediaTypeNames.Application.Json] = new OpenApiMediaType + { + Schema = new OpenApiSchemaReference(nameof(ProblemDetailsDto)), + Example = JsonSerializer.SerializeToNode(new ProblemDetailsDto + { + Type = "e500", + Title = "Internal Server Error", + Status = (int)HttpStatusCode.InternalServerError, + Detail = "An unexpected internal server error has occurred. Please try again later or contact support if the issue persists." + }, JsonSerializerOptions.Web) + } + } + }); + } + + /// + /// 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", + Example = "max-age=3600, must-revalidate", + Schema = OpenApiSpec.StringSchema + }; + + var etagHeader = new OpenApiHeader + { + Description = "Indicates the current version of the resource", + Example = 453, + Schema = OpenApiSpec.StringSchema + }; + + var retryAfterHeader = new OpenApiHeader + { + Description = "Indicates how many seconds the user agent should wait before making a follow-up request", + Example = 60, + Schema = OpenApiSpec.StringSchema + }; + + 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 = OpenApiSpec.StringSchema, + }; + + var rateLimitRemainingHeader = new OpenApiHeader + { + Description = "Indicates the number of requests remaining in the current rate limit window", + Example = 999, + Schema = OpenApiSpec.NumberSchema, + }; + + var rateLimitResetHeader = new OpenApiHeader + { + Description = "The number of seconds until the rate limit resets.", + Example = 60, + Schema = OpenApiSpec.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..4d6fa88 --- /dev/null +++ b/src/CheersDb.Api/Extensions/OpenApiOptionsExtensions.cs @@ -0,0 +1,151 @@ +using CheersDb.Api.Controllers; +using CheersDb.Api.Dtos; +using CheersDb.Api.Http; +using CheersDb.Api.Settings; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Net.Http.Headers; +using Microsoft.OpenApi; +using System.Net.Mime; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CheersDb.Api.Extensions; + +/// +/// Provides extension methods for configuring OpenAPI options in the CheersDb API application. +/// +public static class OpenApiOptionsExtensions +{ + private static OpenApiSecurityRequirement? _defaultSecurityRequirement = null; + private static OpenApiSecurityRequirement GetDefaultSecurityRequirement(AppSettings? appSettings, OpenApiDocument? document) + { + return _defaultSecurityRequirement ??= new OpenApiSecurityRequirement + { + { new OpenApiSecuritySchemeReference(appSettings?.OpenApi?.Security?.Name ?? JwtBearerDefaults.AuthenticationScheme, document), new List() } + }; + } + + // This is hopefully temporary and response examples would be handled better in future .net versions + private static readonly Dictionary _responseExamples = new() + { + [$"{nameof(ProducersController.GetProducerDetails)}{StatusCodes.Status200OK}"] = JsonSerializer.SerializeToNode(new GetProducerDetailsDto() + { + Id = 24, + Name = "Rye River Brewing Company", + Revision = 7, + Links = + [ + new LinkDto() + { + Rel = LinkRels.Self, + Href = "/producers/24", + Method = HttpMethod.Get.ToString() + } + ] + })!, + [$"{nameof(ProducersController.GetProducerDetails)}{StatusCodes.Status400BadRequest}"] = JsonSerializer.SerializeToNode(new ProblemDetailsDto() + { + Type = "https://example.com/producers/bad-request", + Title = "Bad Request", + Detail = "The request is malformed or contains invalid data", + Status = StatusCodes.Status400BadRequest + })!, + [$"{nameof(ProducersController.GetProducerDetails)}{StatusCodes.Status404NotFound}"] = JsonSerializer.SerializeToNode(new ProblemDetailsDto() + { + Type = "https://example.com/producers/not-found", + Title = "Producer Not Found", + Detail = "The requested producer was not found, or the URI is invalid", + Status = StatusCodes.Status404NotFound + })! + }; + + 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?.OpenApi?.Info is not null) + document.Info = appSettings.OpenApi.Info; + + document.Security ??= []; + document.Security.Add(GetDefaultSecurityRequirement(appSettings, document)); + + if (appSettings?.OpenApi?.Servers?.Count > 0) + document.Servers = appSettings.OpenApi.Servers; + + document.Components ??= new OpenApiComponents(); + + await document.Components.ConfigureResponsesAsync(context, cancellationToken); + document.Components.ConfigureHeaders(); + document.Components.ConfigureSecuritySchemes(appSettings?.OpenApi?.Security); + }); + } + + /// + /// 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(StatusCodeStrings.Status401Unauthorized, new OpenApiResponse() + { + Description = "Indicates that the user is not authorized to access the resource" + }); + + operation.Responses.Add(StatusCodeStrings.Status403Forbidden, new OpenApiResponse() + { + Description = "Indicates that the user is forbidden from accessing the resource" + }); + + operation.Responses.Add(StatusCodeStrings.Status429TooManyRequests, new OpenApiResponse() + { + Description = "Indicates that the user has sent too many requests in a given amount of time", + Headers = new Dictionary + { + [HeaderNames.RetryAfter] = OpenApiSpec.RetryAfterHeaderReference + } + }); + + operation.Responses.Add(StatusCodeStrings.Status500InternalServerError, OpenApiSpec.InternalServerErrorResponse); + + operation.Responses.TryGetValue(StatusCodeStrings.Status200OK, out var okResponse); + + if (okResponse is not null && okResponse is OpenApiResponse okResponseConcrete) + { + okResponseConcrete.Headers ??= new Dictionary(); + okResponseConcrete.Headers.Add(HeaderNames.CacheControl, OpenApiSpec.CacheControlHeaderReference); + okResponseConcrete.Headers.Add(HeaderNames.ETag, OpenApiSpec.ETagHeaderReference); + okResponseConcrete.Headers.Add(NonStandardHeaderNames.XRateLimitLimit, OpenApiSpec.RateLimitLimitHeaderReference); + okResponseConcrete.Headers.Add(NonStandardHeaderNames.XRateLimitRemaining, OpenApiSpec.RateLimitRemainingHeaderReference); + okResponseConcrete.Headers.Add(NonStandardHeaderNames.XRateLimitReset, OpenApiSpec.RateLimitResetHeaderReference); + } + + foreach (var response in operation.Responses) + { + var responseExampleKey = $"{operation.OperationId}{response.Key}"; + + if (!string.IsNullOrEmpty(responseExampleKey) && _responseExamples.TryGetValue(responseExampleKey, out JsonNode? example)) + { + response.Value.Content?[MediaTypeNames.Application.Json]?.Examples ??= new Dictionary(); + response.Value.Content?[MediaTypeNames.Application.Json]?.Examples?.Add(responseExampleKey, new OpenApiExample + { + Value = example + }); + } + } + + operation.Security ??= []; + operation.Security.Add(GetDefaultSecurityRequirement(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..277319f --- /dev/null +++ b/src/CheersDb.Api/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using CheersDb.Api.Settings; +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/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/Http/StatusCodeStrings.cs b/src/CheersDb.Api/Http/StatusCodeStrings.cs new file mode 100644 index 0000000..f4138b9 --- /dev/null +++ b/src/CheersDb.Api/Http/StatusCodeStrings.cs @@ -0,0 +1,37 @@ +namespace CheersDb.Api.Http; + +/// +/// Contains string constants for HTTP status codes used throughout the API. +/// +public static class StatusCodeStrings +{ + /// + /// HTTP status code 200 — OK. + /// + public const string Status200OK = "200"; + + /// + /// HTTP status code 401 — Unauthorized. + /// + public const string Status401Unauthorized = "401"; + + /// + /// HTTP status code 403 — Forbidden. + /// + public const string Status403Forbidden = "403"; + + /// + /// HTTP status code 404 — Not Found. + /// + public const string Status404NotFound = "404"; + + /// + /// HTTP status code 429 — Too Many Requests. + /// + public const string Status429TooManyRequests = "429"; + + /// + /// HTTP status code 500 — Internal Server Error. + /// + public const string Status500InternalServerError = "500"; +} \ No newline at end of file diff --git a/src/CheersDb.Api/OpenApiSpec.cs b/src/CheersDb.Api/OpenApiSpec.cs new file mode 100644 index 0000000..9e31af3 --- /dev/null +++ b/src/CheersDb.Api/OpenApiSpec.cs @@ -0,0 +1,65 @@ +using CheersDb.Api.Http; +using Microsoft.Net.Http.Headers; +using Microsoft.OpenApi; +using System.Net; + +namespace CheersDb.Api; + +/// +/// Centralized OpenAPI specification constants and references used across the application. +/// +public static class OpenApiSpec +{ + /// + /// Reusable string schema definition. + /// + public static readonly OpenApiSchema StringSchema = new() { Type = JsonSchemaType.String }; + + /// + /// Reusable number schema definition. + /// + public static readonly OpenApiSchema NumberSchema = new() { Type = JsonSchemaType.Number }; + + /// + /// Description for the Internal Server Error response + /// + public const string InternalServerErrorDescription = "Indicates that an unexpected internal server error has occurred"; + + /// + /// A reusable reference to the Internal Server Error response defined in components.Responses. + /// + public static readonly OpenApiResponseReference InternalServerErrorResponse = new(nameof(HttpStatusCode.InternalServerError)) + { + Description = InternalServerErrorDescription + }; + + /// + /// Reusable reference for the Cache-Control response header. + /// + public static readonly OpenApiHeaderReference CacheControlHeaderReference = new(HeaderNames.CacheControl); + + /// + /// Reusable reference for the ETag response header. + /// + public static readonly OpenApiHeaderReference ETagHeaderReference = new(HeaderNames.ETag); + + /// + /// Reusable reference for the Retry-After response header. + /// + public static readonly OpenApiHeaderReference RetryAfterHeaderReference = new(HeaderNames.RetryAfter); + + /// + /// Reusable reference for the X-RateLimit-Limit response header. + /// + public static readonly OpenApiHeaderReference RateLimitLimitHeaderReference = new(NonStandardHeaderNames.XRateLimitLimit); + + /// + /// Reusable reference for the X-RateLimit-Remaining response header. + /// + public static readonly OpenApiHeaderReference RateLimitRemainingHeaderReference = new(NonStandardHeaderNames.XRateLimitRemaining); + + /// + /// Reusable reference for the X-RateLimit-Reset response header. + /// + public static readonly OpenApiHeaderReference RateLimitResetHeaderReference = new(NonStandardHeaderNames.XRateLimitReset); +} \ 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..360db5a --- /dev/null +++ b/src/CheersDb.Api/Program.cs @@ -0,0 +1,50 @@ +using CheersDb.Api.Extensions; +using Microsoft.AspNetCore.Authorization; +using Scalar.AspNetCore; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); +var appSettings = builder.Configuration.GetAppSettings(); + +builder.Services.AddAuthentication(appSettings.OpenApi!.Security!.Scheme!) + .AddJwtBearer(); + +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..0d99f8a --- /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/docs", + "applicationUrl": "https://localhost:8339", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/CheersDb.Api/Settings/AppSettings.cs b/src/CheersDb.Api/Settings/AppSettings.cs new file mode 100644 index 0000000..497caf6 --- /dev/null +++ b/src/CheersDb.Api/Settings/AppSettings.cs @@ -0,0 +1,14 @@ +using CheersDb.Api.Http; + +namespace CheersDb.Api.Settings; + +/// +/// Represents the application settings for the CheersDb API. +/// +public class AppSettings +{ + /// + /// Gets the OpenAPI settings for the API, including information and security scheme. + /// + public OpenApiAppSettings? OpenApi { get; init; } +} \ No newline at end of file diff --git a/src/CheersDb.Api/Settings/OpenApiAppSettings.cs b/src/CheersDb.Api/Settings/OpenApiAppSettings.cs new file mode 100644 index 0000000..df34494 --- /dev/null +++ b/src/CheersDb.Api/Settings/OpenApiAppSettings.cs @@ -0,0 +1,24 @@ +using Microsoft.OpenApi; + +namespace CheersDb.Api.Settings; + +/// +/// Represents the OpenAPI settings for the CheersDb API, including information and security scheme. +/// +public class OpenApiAppSettings +{ + /// + /// Gets the OpenAPI information for the API, such as title, version, and description. + /// + public OpenApiInfo? Info { get; init; } + + /// + /// Gets the OpenAPI security scheme for the API, which defines the authentication and authorization requirements. + /// + public OpenApiSecurityScheme? Security { get; init; } + + /// + /// Gets the list of OpenAPI servers for the API, which defines the available server URLs and descriptions. + /// + public List? Servers { get; init; } +} \ No newline at end of file diff --git a/src/CheersDb.Api/appsettings.Development.json b/src/CheersDb.Api/appsettings.Development.json new file mode 100644 index 0000000..8e1c1b7 --- /dev/null +++ b/src/CheersDb.Api/appsettings.Development.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Authentication": { + "Schemes": { + "Bearer": { + "ValidAudiences": [ + "https://localhost:8339" + ], + "ValidIssuer": "dotnet-user-jwts" + } + } + } +} \ No newline at end of file diff --git a/src/CheersDb.Api/appsettings.json b/src/CheersDb.Api/appsettings.json new file mode 100644 index 0000000..42bfdaa --- /dev/null +++ b/src/CheersDb.Api/appsettings.json @@ -0,0 +1,43 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "OpenApi": { + "Info": { + "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" + } + }, + "Security": { + "Name": "bearerAuth", + "Description": "JWT Bearer token authentication", + "Type": "Http", + "Scheme": "Bearer", + "BearerFormat": "JWT", + "In": "Header" + }, + "Servers": [ + { + "Url": "https://localhost:8339", + "Description": "Local development server" + }, + { + "Url": "https://api.cheersdb.org", + "Description": "Production server" + } + ] + } +} \ No newline at end of file