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