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