diff --git a/Directory.Packages.props b/Directory.Packages.props index e3f1b39..8079011 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,21 +5,21 @@ - - - + + + - + - - - + + + diff --git a/Dockerfile b/Dockerfile index 36858ea..2bfd702 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /app # Copy solution and all .csproj files @@ -14,6 +14,7 @@ COPY tests/TheOfficeAPI.Level2.Tests.Integration/*.csproj ./tests/TheOfficeAPI.L COPY tests/TheOfficeAPI.Level3.Tests.Unit/*.csproj ./tests/TheOfficeAPI.Level3.Tests.Unit/ COPY tests/TheOfficeAPI.Level3.Tests.Integration/*.csproj ./tests/TheOfficeAPI.Level3.Tests.Integration/ COPY tests/TheOfficeAPI.Tests.E2E/*.csproj ./tests/TheOfficeAPI.Tests.E2E/ +COPY tests/TheOfficeAPI.Common.Tests.Unit/*.csproj ./tests/TheOfficeAPI.Common.Tests.Unit/ # Restore dependencies RUN dotnet restore TheOfficeAPI.sln @@ -28,7 +29,7 @@ RUN dotnet publish src/TheOfficeAPI/TheOfficeAPI.csproj \ --no-restore # Runtime stage -FROM mcr.microsoft.com/dotnet/aspnet:9.0 +FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app # Copy compiled files diff --git a/TheOfficeAPI.sln b/TheOfficeAPI.sln index 50ccada..3c33469 100644 --- a/TheOfficeAPI.sln +++ b/TheOfficeAPI.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI", "src/TheOfficeAPI/TheOfficeAPI.csproj", "{B1D1A596-0809-4313-8A9F-AFBC87A87458}" @@ -13,15 +13,15 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI.Level1.Tests.I EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI.Level2.Tests.Unit", "tests/TheOfficeAPI.Level2.Tests.Unit/TheOfficeAPI.Level2.Tests.Unit.csproj", "{E3FA0913-3456-7890-1234-D362FE48FE0D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI.Level2.Tests.Integration", "tests/TheOfficeAPI.Level2.Tests.Integration/TheOfficeAPI.Level2.Tests.Integration.csproj", "{00000000-0000-0000-0000-000000000000}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI.Level2.Tests.Integration", "tests/TheOfficeAPI.Level2.Tests.Integration/TheOfficeAPI.Level2.Tests.Integration.csproj", "{9078F4DA-6AD0-4024-8764-679B805E0C28}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI.Level3.Tests.Unit", "tests/TheOfficeAPI.Level3.Tests.Unit/TheOfficeAPI.Level3.Tests.Unit.csproj", "{00000000-0000-0000-0000-000000000000}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI.Level3.Tests.Unit", "tests/TheOfficeAPI.Level3.Tests.Unit/TheOfficeAPI.Level3.Tests.Unit.csproj", "{DB0B4A87-CBA6-4B04-98FD-479E0E4A1E84}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI.Level3.Tests.Integration", "tests/TheOfficeAPI.Level3.Tests.Integration/TheOfficeAPI.Level3.Tests.Integration.csproj", "{00000000-0000-0000-0000-000000000000}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI.Level3.Tests.Integration", "tests/TheOfficeAPI.Level3.Tests.Integration/TheOfficeAPI.Level3.Tests.Integration.csproj", "{3586BC66-210E-47DD-ABCE-8DC3D0638291}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI.Tests.E2E", "tests/TheOfficeAPI.Tests.E2E/TheOfficeAPI.Tests.E2E.csproj", "{00000000-0000-0000-0000-000000000000}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI.Tests.E2E", "tests/TheOfficeAPI.Tests.E2E/TheOfficeAPI.Tests.E2E.csproj", "{EDE0ED8F-C286-4FFC-80BC-1972BE240130}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI.Common.Tests.Unit", "tests/TheOfficeAPI.Common.Tests.Unit/TheOfficeAPI.Common.Tests.Unit.csproj", "{00000000-0000-0000-0000-000000000000}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheOfficeAPI.Common.Tests.Unit", "tests/TheOfficeAPI.Common.Tests.Unit/TheOfficeAPI.Common.Tests.Unit.csproj", "{91D7F9AC-B773-4409-90BB-C743351DB612}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -53,7 +53,25 @@ Global {E3FA0913-3456-7890-1234-D362FE48FE0D}.Debug|Any CPU.Build.0 = Debug|Any CPU {E3FA0913-3456-7890-1234-D362FE48FE0D}.Release|Any CPU.ActiveCfg = Release|Any CPU {E3FA0913-3456-7890-1234-D362FE48FE0D}.Release|Any CPU.Build.0 = Release|Any CPU - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9078F4DA-6AD0-4024-8764-679B805E0C28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9078F4DA-6AD0-4024-8764-679B805E0C28}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9078F4DA-6AD0-4024-8764-679B805E0C28}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9078F4DA-6AD0-4024-8764-679B805E0C28}.Release|Any CPU.Build.0 = Release|Any CPU + {DB0B4A87-CBA6-4B04-98FD-479E0E4A1E84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB0B4A87-CBA6-4B04-98FD-479E0E4A1E84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB0B4A87-CBA6-4B04-98FD-479E0E4A1E84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB0B4A87-CBA6-4B04-98FD-479E0E4A1E84}.Release|Any CPU.Build.0 = Release|Any CPU + {3586BC66-210E-47DD-ABCE-8DC3D0638291}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3586BC66-210E-47DD-ABCE-8DC3D0638291}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3586BC66-210E-47DD-ABCE-8DC3D0638291}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3586BC66-210E-47DD-ABCE-8DC3D0638291}.Release|Any CPU.Build.0 = Release|Any CPU + {EDE0ED8F-C286-4FFC-80BC-1972BE240130}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EDE0ED8F-C286-4FFC-80BC-1972BE240130}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EDE0ED8F-C286-4FFC-80BC-1972BE240130}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EDE0ED8F-C286-4FFC-80BC-1972BE240130}.Release|Any CPU.Build.0 = Release|Any CPU + {91D7F9AC-B773-4409-90BB-C743351DB612}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91D7F9AC-B773-4409-90BB-C743351DB612}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91D7F9AC-B773-4409-90BB-C743351DB612}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91D7F9AC-B773-4409-90BB-C743351DB612}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/TheOfficeAPI/Common/Services/HealthCheckService.cs b/src/TheOfficeAPI/Common/Services/HealthCheckService.cs index 214d35c..9f18f0f 100644 --- a/src/TheOfficeAPI/Common/Services/HealthCheckService.cs +++ b/src/TheOfficeAPI/Common/Services/HealthCheckService.cs @@ -8,6 +8,8 @@ namespace TheOfficeAPI.Common.Services; /// public class HealthCheckService { + private const string HealthyStatus = "Healthy"; + private readonly DateTime _startTime; private readonly string _version; @@ -24,7 +26,7 @@ public HealthCheckResponse GetLivenessStatus() { return new HealthCheckResponse { - Status = "Healthy", + Status = HealthyStatus, Timestamp = DateTime.UtcNow, Message = "Application is alive" }; @@ -37,7 +39,7 @@ public DetailedHealthCheckResponse GetReadinessStatus() { var response = new DetailedHealthCheckResponse { - Status = "Healthy", + Status = HealthyStatus, Timestamp = DateTime.UtcNow, Message = "Application is ready to serve traffic", Uptime = DateTime.UtcNow - _startTime, @@ -46,7 +48,7 @@ public DetailedHealthCheckResponse GetReadinessStatus() { ["application"] = new ComponentHealth { - Status = "Healthy", + Status = HealthyStatus, Description = "Application is running normally", Data = new Dictionary { @@ -56,7 +58,7 @@ public DetailedHealthCheckResponse GetReadinessStatus() }, ["dataService"] = new ComponentHealth { - Status = "Healthy", + Status = HealthyStatus, Description = "In-memory data service is available", Data = new Dictionary { @@ -77,7 +79,7 @@ public HealthCheckResponse GetHealthStatus() { return new HealthCheckResponse { - Status = "Healthy", + Status = HealthyStatus, Timestamp = DateTime.UtcNow, Message = "OK" }; diff --git a/src/TheOfficeAPI/Level0/Controllers/HealthController.cs b/src/TheOfficeAPI/Level0/Controllers/HealthController.cs index 27b2669..e763550 100644 --- a/src/TheOfficeAPI/Level0/Controllers/HealthController.cs +++ b/src/TheOfficeAPI/Level0/Controllers/HealthController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using TheOfficeAPI.Common.Models; using TheOfficeAPI.Common.Services; namespace TheOfficeAPI.Level0.Controllers; @@ -23,7 +24,7 @@ public HealthController(HealthCheckService healthCheckService) /// Health status /// Service is healthy [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(HealthCheckResponse), StatusCodes.Status200OK)] public IActionResult Get() { var health = _healthCheckService.GetHealthStatus(); @@ -40,7 +41,7 @@ public IActionResult Get() /// is still running. If this fails, the container should be restarted. /// [HttpGet("live")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(HealthCheckResponse), StatusCodes.Status200OK)] public IActionResult GetLiveness() { var health = _healthCheckService.GetLivenessStatus(); @@ -57,7 +58,7 @@ public IActionResult GetLiveness() /// is ready to receive traffic. If this fails, traffic should not be routed to this instance. /// [HttpGet("ready")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DetailedHealthCheckResponse), StatusCodes.Status200OK)] public IActionResult GetReadiness() { var health = _healthCheckService.GetReadinessStatus(); diff --git a/src/TheOfficeAPI/Level0/Controllers/OfficeApiController.cs b/src/TheOfficeAPI/Level0/Controllers/OfficeApiController.cs index 00c0dfa..750a70c 100644 --- a/src/TheOfficeAPI/Level0/Controllers/OfficeApiController.cs +++ b/src/TheOfficeAPI/Level0/Controllers/OfficeApiController.cs @@ -118,6 +118,7 @@ public Level0Controller(TheOfficeService theOfficeService) /// /// [HttpPost("theOffice")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public IActionResult HandleRequest([FromBody] ApiRequest request) { // Level 0: Always return 200 OK, put actual status in response body diff --git a/src/TheOfficeAPI/Level0/Extensions/SwaggerConfigurationExtensions.cs b/src/TheOfficeAPI/Level0/Extensions/SwaggerConfigurationExtensions.cs index 47a3cac..2b99e3d 100644 --- a/src/TheOfficeAPI/Level0/Extensions/SwaggerConfigurationExtensions.cs +++ b/src/TheOfficeAPI/Level0/Extensions/SwaggerConfigurationExtensions.cs @@ -1,5 +1,5 @@ using System.Reflection; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace TheOfficeAPI.Level0.Extensions { diff --git a/src/TheOfficeAPI/Level1/Controllers/EpisodesController.cs b/src/TheOfficeAPI/Level1/Controllers/EpisodesController.cs index b8f90b3..2159e69 100644 --- a/src/TheOfficeAPI/Level1/Controllers/EpisodesController.cs +++ b/src/TheOfficeAPI/Level1/Controllers/EpisodesController.cs @@ -49,6 +49,7 @@ public EpisodesController(TheOfficeService theOfficeService) /// /// [HttpPost] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public IActionResult GetSeasonEpisodes([FromRoute] int seasonNumber) { try @@ -108,6 +109,7 @@ public IActionResult GetSeasonEpisodes([FromRoute] int seasonNumber) /// /// [HttpPost("{episodeNumber}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public IActionResult GetEpisode([FromRoute] int seasonNumber, [FromRoute] int episodeNumber) { try diff --git a/src/TheOfficeAPI/Level1/Controllers/HealthController.cs b/src/TheOfficeAPI/Level1/Controllers/HealthController.cs index 20d5c65..bb1793e 100644 --- a/src/TheOfficeAPI/Level1/Controllers/HealthController.cs +++ b/src/TheOfficeAPI/Level1/Controllers/HealthController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using TheOfficeAPI.Common.Models; using TheOfficeAPI.Common.Services; namespace TheOfficeAPI.Level1.Controllers; @@ -23,7 +24,7 @@ public HealthController(HealthCheckService healthCheckService) /// Health status /// Service is healthy [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(HealthCheckResponse), StatusCodes.Status200OK)] public IActionResult Get() { var health = _healthCheckService.GetHealthStatus(); @@ -40,7 +41,7 @@ public IActionResult Get() /// is still running. If this fails, the container should be restarted. /// [HttpGet("live")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(HealthCheckResponse), StatusCodes.Status200OK)] public IActionResult GetLiveness() { var health = _healthCheckService.GetLivenessStatus(); @@ -57,7 +58,7 @@ public IActionResult GetLiveness() /// is ready to receive traffic. If this fails, traffic should not be routed to this instance. /// [HttpGet("ready")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DetailedHealthCheckResponse), StatusCodes.Status200OK)] public IActionResult GetReadiness() { var health = _healthCheckService.GetReadinessStatus(); diff --git a/src/TheOfficeAPI/Level1/Controllers/SeasonsController.cs b/src/TheOfficeAPI/Level1/Controllers/SeasonsController.cs index 8391e97..7f9e795 100644 --- a/src/TheOfficeAPI/Level1/Controllers/SeasonsController.cs +++ b/src/TheOfficeAPI/Level1/Controllers/SeasonsController.cs @@ -50,6 +50,7 @@ public SeasonsController(TheOfficeService theOfficeService) /// /// [HttpPost] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public IActionResult GetAllSeasons() { try diff --git a/src/TheOfficeAPI/Level1/Extensions/SwaggerConfigurationExtensions.cs b/src/TheOfficeAPI/Level1/Extensions/SwaggerConfigurationExtensions.cs index 9ee23e3..755461e 100644 --- a/src/TheOfficeAPI/Level1/Extensions/SwaggerConfigurationExtensions.cs +++ b/src/TheOfficeAPI/Level1/Extensions/SwaggerConfigurationExtensions.cs @@ -1,5 +1,5 @@ using System.Reflection; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace TheOfficeAPI.Level1.Extensions { diff --git a/src/TheOfficeAPI/Level2/Controllers/HealthController.cs b/src/TheOfficeAPI/Level2/Controllers/HealthController.cs index 8b1c3ce..616fc41 100644 --- a/src/TheOfficeAPI/Level2/Controllers/HealthController.cs +++ b/src/TheOfficeAPI/Level2/Controllers/HealthController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using TheOfficeAPI.Common.Models; using TheOfficeAPI.Common.Services; namespace TheOfficeAPI.Level2.Controllers; @@ -23,7 +24,7 @@ public HealthController(HealthCheckService healthCheckService) /// Health status /// Service is healthy [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(HealthCheckResponse), StatusCodes.Status200OK)] public IActionResult Get() { var health = _healthCheckService.GetHealthStatus(); @@ -40,7 +41,7 @@ public IActionResult Get() /// is still running. If this fails, the container should be restarted. /// [HttpGet("live")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(HealthCheckResponse), StatusCodes.Status200OK)] public IActionResult GetLiveness() { var health = _healthCheckService.GetLivenessStatus(); @@ -57,7 +58,7 @@ public IActionResult GetLiveness() /// is ready to receive traffic. If this fails, traffic should not be routed to this instance. /// [HttpGet("ready")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DetailedHealthCheckResponse), StatusCodes.Status200OK)] public IActionResult GetReadiness() { var health = _healthCheckService.GetReadinessStatus(); diff --git a/src/TheOfficeAPI/Level2/Extensions/SwaggerConfigurationExtensions.cs b/src/TheOfficeAPI/Level2/Extensions/SwaggerConfigurationExtensions.cs index a3473f8..647cd18 100644 --- a/src/TheOfficeAPI/Level2/Extensions/SwaggerConfigurationExtensions.cs +++ b/src/TheOfficeAPI/Level2/Extensions/SwaggerConfigurationExtensions.cs @@ -1,5 +1,5 @@ using System.Reflection; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace TheOfficeAPI.Level2.Extensions { diff --git a/src/TheOfficeAPI/Level3/Controllers/EpisodesController.cs b/src/TheOfficeAPI/Level3/Controllers/EpisodesController.cs index cb02fca..cc9f7e8 100644 --- a/src/TheOfficeAPI/Level3/Controllers/EpisodesController.cs +++ b/src/TheOfficeAPI/Level3/Controllers/EpisodesController.cs @@ -10,6 +10,7 @@ public class EpisodesController : ControllerBase { private const string RelCollection = "collection"; private const string RelSeason = "season"; + private const string SeasonsBasePath = "/api/v3/seasons"; private readonly TheOfficeService _theOfficeService; @@ -80,8 +81,8 @@ public IActionResult GetSeasonEpisodes([FromRoute] int seasonNumber) ReleasedDate = e.ReleasedDate, Links = new List { - new Link { Rel = "self", Href = $"/api/v3/seasons/{seasonNumber}/episodes/{e.EpisodeNumber}", Method = "GET" }, - new Link { Rel = RelSeason, Href = $"/api/v3/seasons/{seasonNumber}", Method = "GET" } + new Link { Rel = "self", Href = $"{SeasonsBasePath}/{seasonNumber}/episodes/{e.EpisodeNumber}", Method = "GET" }, + new Link { Rel = RelSeason, Href = $"{SeasonsBasePath}/{seasonNumber}", Method = "GET" } } }).ToList(); @@ -92,9 +93,9 @@ public IActionResult GetSeasonEpisodes([FromRoute] int seasonNumber) Message = $"Episodes for season {seasonNumber} retrieved successfully", Links = new List { - new Link { Rel = "self", Href = $"/api/v3/seasons/{seasonNumber}/episodes", Method = "GET" }, - new Link { Rel = RelSeason, Href = $"/api/v3/seasons/{seasonNumber}", Method = "GET" }, - new Link { Rel = RelCollection, Href = "/api/v3/seasons", Method = "GET" } + new Link { Rel = "self", Href = $"{SeasonsBasePath}/{seasonNumber}/episodes", Method = "GET" }, + new Link { Rel = RelSeason, Href = $"{SeasonsBasePath}/{seasonNumber}", Method = "GET" }, + new Link { Rel = RelCollection, Href = SeasonsBasePath, Method = "GET" } } }; @@ -191,9 +192,9 @@ public IActionResult GetEpisode([FromRoute] int seasonNumber, [FromRoute] int ep Message = "Episode not found", Links = new List { - new Link { Rel = "episodes", Href = $"/api/v3/seasons/{seasonNumber}/episodes", Method = "GET" }, - new Link { Rel = RelSeason, Href = $"/api/v3/seasons/{seasonNumber}", Method = "GET" }, - new Link { Rel = RelCollection, Href = "/api/v3/seasons", Method = "GET" } + new Link { Rel = "episodes", Href = $"{SeasonsBasePath}/{seasonNumber}/episodes", Method = "GET" }, + new Link { Rel = RelSeason, Href = $"{SeasonsBasePath}/{seasonNumber}", Method = "GET" }, + new Link { Rel = RelCollection, Href = SeasonsBasePath, Method = "GET" } } }); } @@ -206,7 +207,7 @@ public IActionResult GetEpisode([FromRoute] int seasonNumber, [FromRoute] int ep ReleasedDate = episode.ReleasedDate, Links = new List { - new Link { Rel = "self", Href = $"/api/v3/seasons/{seasonNumber}/episodes/{episodeNumber}", Method = "GET" } + new Link { Rel = "self", Href = $"{SeasonsBasePath}/{seasonNumber}/episodes/{episodeNumber}", Method = "GET" } } }; @@ -217,7 +218,7 @@ public IActionResult GetEpisode([FromRoute] int seasonNumber, [FromRoute] int ep episodeResource.Links.Add(new Link { Rel = "next", - Href = $"/api/v3/seasons/{seasonNumber}/episodes/{episodeNumber + 1}", + Href = $"{SeasonsBasePath}/{seasonNumber}/episodes/{episodeNumber + 1}", Method = "GET" }); } @@ -228,15 +229,15 @@ public IActionResult GetEpisode([FromRoute] int seasonNumber, [FromRoute] int ep episodeResource.Links.Add(new Link { Rel = "previous", - Href = $"/api/v3/seasons/{seasonNumber}/episodes/{episodeNumber - 1}", + Href = $"{SeasonsBasePath}/{seasonNumber}/episodes/{episodeNumber - 1}", Method = "GET" }); } // Add parent and collection links - episodeResource.Links.Add(new Link { Rel = RelSeason, Href = $"/api/v3/seasons/{seasonNumber}", Method = "GET" }); - episodeResource.Links.Add(new Link { Rel = "episodes", Href = $"/api/v3/seasons/{seasonNumber}/episodes", Method = "GET" }); - episodeResource.Links.Add(new Link { Rel = RelCollection, Href = "/api/v3/seasons", Method = "GET" }); + episodeResource.Links.Add(new Link { Rel = RelSeason, Href = $"{SeasonsBasePath}/{seasonNumber}", Method = "GET" }); + episodeResource.Links.Add(new Link { Rel = "episodes", Href = $"{SeasonsBasePath}/{seasonNumber}/episodes", Method = "GET" }); + episodeResource.Links.Add(new Link { Rel = RelCollection, Href = SeasonsBasePath, Method = "GET" }); var response = new HateoasResponse { @@ -245,8 +246,8 @@ public IActionResult GetEpisode([FromRoute] int seasonNumber, [FromRoute] int ep Message = "Episode retrieved successfully", Links = new List { - new Link { Rel = "self", Href = $"/api/v3/seasons/{seasonNumber}/episodes/{episodeNumber}", Method = "GET" }, - new Link { Rel = RelCollection, Href = "/api/v3/seasons", Method = "GET" } + new Link { Rel = "self", Href = $"{SeasonsBasePath}/{seasonNumber}/episodes/{episodeNumber}", Method = "GET" }, + new Link { Rel = RelCollection, Href = SeasonsBasePath, Method = "GET" } } }; @@ -275,7 +276,7 @@ public IActionResult GetEpisode([FromRoute] int seasonNumber, [FromRoute] int ep Message = "Invalid request", Links = new List { - new Link { Rel = RelCollection, Href = "/api/v3/seasons", Method = "GET" } + new Link { Rel = RelCollection, Href = SeasonsBasePath, Method = "GET" } } }); } @@ -294,9 +295,9 @@ public IActionResult GetEpisode([FromRoute] int seasonNumber, [FromRoute] int ep Message = "Invalid request", Links = new List { - new Link { Rel = "episodes", Href = $"/api/v3/seasons/{season}/episodes", Method = "GET" }, - new Link { Rel = RelSeason, Href = $"/api/v3/seasons/{season}", Method = "GET" }, - new Link { Rel = RelCollection, Href = "/api/v3/seasons", Method = "GET" } + new Link { Rel = "episodes", Href = $"{SeasonsBasePath}/{season}/episodes", Method = "GET" }, + new Link { Rel = RelSeason, Href = $"{SeasonsBasePath}/{season}", Method = "GET" }, + new Link { Rel = RelCollection, Href = SeasonsBasePath, Method = "GET" } } }); } diff --git a/src/TheOfficeAPI/Level3/Controllers/HealthController.cs b/src/TheOfficeAPI/Level3/Controllers/HealthController.cs index 9970866..0fd3ba1 100644 --- a/src/TheOfficeAPI/Level3/Controllers/HealthController.cs +++ b/src/TheOfficeAPI/Level3/Controllers/HealthController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using TheOfficeAPI.Common.Models; using TheOfficeAPI.Common.Services; namespace TheOfficeAPI.Level3.Controllers; @@ -23,7 +24,7 @@ public HealthController(HealthCheckService healthCheckService) /// Health status /// Service is healthy [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(HealthCheckResponse), StatusCodes.Status200OK)] public IActionResult Get() { var health = _healthCheckService.GetHealthStatus(); @@ -40,7 +41,7 @@ public IActionResult Get() /// is still running. If this fails, the container should be restarted. /// [HttpGet("live")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(HealthCheckResponse), StatusCodes.Status200OK)] public IActionResult GetLiveness() { var health = _healthCheckService.GetLivenessStatus(); @@ -57,7 +58,7 @@ public IActionResult GetLiveness() /// is ready to receive traffic. If this fails, traffic should not be routed to this instance. /// [HttpGet("ready")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(DetailedHealthCheckResponse), StatusCodes.Status200OK)] public IActionResult GetReadiness() { var health = _healthCheckService.GetReadinessStatus(); diff --git a/src/TheOfficeAPI/Level3/Extensions/SwaggerConfigurationExtensions.cs b/src/TheOfficeAPI/Level3/Extensions/SwaggerConfigurationExtensions.cs index 8792805..bf8c36b 100644 --- a/src/TheOfficeAPI/Level3/Extensions/SwaggerConfigurationExtensions.cs +++ b/src/TheOfficeAPI/Level3/Extensions/SwaggerConfigurationExtensions.cs @@ -1,5 +1,5 @@ using System.Reflection; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace TheOfficeAPI.Level3.Extensions { diff --git a/src/TheOfficeAPI/Program.cs b/src/TheOfficeAPI/Program.cs index b82be18..3479bdc 100644 --- a/src/TheOfficeAPI/Program.cs +++ b/src/TheOfficeAPI/Program.cs @@ -7,6 +7,8 @@ namespace TheOfficeAPI; public class Program { + protected Program() { } + public static void Main(string[] args) { CreateWebApplication(args).Run(); @@ -26,30 +28,11 @@ public static WebApplication CreateWebApplication(string[] args) var environmentOptions = builder.Configuration.GetSection(EnvironmentOptions.SectionName).Get(); - // RAILWAY: Use Railway's PORT environment variable and bind to 0.0.0.0 - var port = Environment.GetEnvironmentVariable("PORT"); - string url; - - if (port != null) - { - url = $"http://0.0.0.0:{port}"; - Console.WriteLine($"=== RAILWAY/PRODUCTION MODE ==="); - Console.WriteLine($"PORT from environment: {port}"); - Console.WriteLine($"Binding to: {url}"); - Console.WriteLine($"================================"); - } - else - { - url = serverOptions?.DefaultUrl ?? "http://localhost:5000"; - Console.WriteLine($"=== LOCAL DEVELOPMENT MODE ==="); - Console.WriteLine($"Using config URL: {url}"); - Console.WriteLine($"================================"); - } - + var url = DetermineBindingUrl(serverOptions); builder.WebHost.UseUrls(url); var maturityLevel = DetermineMaturityLevel(environmentOptions?.MaturityLevelVariable ?? "MATURITY_LEVEL"); - var hasMaturityLevel = maturityLevel == MaturityLevel.Level0 || maturityLevel == MaturityLevel.Level1 || maturityLevel == MaturityLevel.Level2 || maturityLevel == MaturityLevel.Level3; + var hasMaturityLevel = maturityLevel != null; if (hasMaturityLevel) { @@ -59,73 +42,7 @@ public static WebApplication CreateWebApplication(string[] args) } else { - Console.WriteLine("Starting with basic configuration..."); - builder.Services.AddSingleton(); - builder.Services.AddControllers(); - builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddSwaggerGen(c => - { - // Register all API versions - c.SwaggerDoc("v0", new Microsoft.OpenApi.Models.OpenApiInfo - { - Title = "The Office API - Level 0", - Version = "v0", - Description = "Richardson Maturity Model Level 0 implementation" - }); - - c.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo - { - Title = "The Office API - Level 1", - Version = "v1", - Description = "Richardson Maturity Model Level 1 implementation - Introduces resource-based URIs" - }); - - c.SwaggerDoc("v2", new Microsoft.OpenApi.Models.OpenApiInfo - { - Title = "The Office API - Level 2", - Version = "v2", - Description = "Richardson Maturity Model Level 2 implementation - Introduces HTTP verbs and proper status codes" - }); - - c.SwaggerDoc("v3", new Microsoft.OpenApi.Models.OpenApiInfo - { - Title = "The Office API - Level 3", - Version = "v3", - Description = "Richardson Maturity Model Level 3 implementation - HATEOAS with hypermedia links" - }); - - // Filter controllers by namespace for each version - c.DocInclusionPredicate((docName, apiDesc) => - { - var controllerActionDescriptor = apiDesc.ActionDescriptor as Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor; - if (controllerActionDescriptor == null) return false; - - var controllerNamespace = controllerActionDescriptor.ControllerTypeInfo.Namespace ?? string.Empty; - - return docName switch - { - "v0" => controllerNamespace.StartsWith("TheOfficeAPI.Level0"), - "v1" => controllerNamespace.StartsWith("TheOfficeAPI.Level1"), - "v2" => controllerNamespace.StartsWith("TheOfficeAPI.Level2"), - "v3" => controllerNamespace.StartsWith("TheOfficeAPI.Level3"), - _ => false - }; - }); - - // Include XML comments if available - var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; - var xmlPath = System.IO.Path.Combine(AppContext.BaseDirectory, xmlFile); - if (System.IO.File.Exists(xmlPath)) - { - c.IncludeXmlComments(xmlPath); - } - }); - - // Register services for all levels to support all API versions - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + ConfigureBasicServices(builder); } var app = builder.Build(); @@ -142,6 +59,94 @@ public static WebApplication CreateWebApplication(string[] args) return app; } + private static string DetermineBindingUrl(ServerOptions? serverOptions) + { + var port = Environment.GetEnvironmentVariable("PORT"); + + if (port != null) + { + var url = FormattableString.Invariant($"http://0.0.0.0:{port}"); // NOSONAR - internal container binding, TLS terminated at load balancer + Console.WriteLine("=== RAILWAY/PRODUCTION MODE ==="); + Console.WriteLine($"PORT from environment: {port}"); + Console.WriteLine($"Binding to: {url}"); + Console.WriteLine("================================"); + return url; + } + + var defaultUrl = serverOptions?.DefaultUrl ?? "http://localhost:5000"; // NOSONAR - local development only + Console.WriteLine("=== LOCAL DEVELOPMENT MODE ==="); + Console.WriteLine($"Using config URL: {defaultUrl}"); + Console.WriteLine("================================"); + return defaultUrl; + } + + private static void ConfigureBasicServices(WebApplicationBuilder builder) + { + Console.WriteLine("Starting with basic configuration..."); + builder.Services.AddSingleton(); + builder.Services.AddControllers(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(c => + { + c.SwaggerDoc("v0", new Microsoft.OpenApi.OpenApiInfo + { + Title = "The Office API - Level 0", + Version = "v0", + Description = "Richardson Maturity Model Level 0 implementation" + }); + + c.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo + { + Title = "The Office API - Level 1", + Version = "v1", + Description = "Richardson Maturity Model Level 1 implementation - Introduces resource-based URIs" + }); + + c.SwaggerDoc("v2", new Microsoft.OpenApi.OpenApiInfo + { + Title = "The Office API - Level 2", + Version = "v2", + Description = "Richardson Maturity Model Level 2 implementation - Introduces HTTP verbs and proper status codes" + }); + + c.SwaggerDoc("v3", new Microsoft.OpenApi.OpenApiInfo + { + Title = "The Office API - Level 3", + Version = "v3", + Description = "Richardson Maturity Model Level 3 implementation - HATEOAS with hypermedia links" + }); + + c.DocInclusionPredicate((docName, apiDesc) => + { + var controllerActionDescriptor = apiDesc.ActionDescriptor as Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor; + if (controllerActionDescriptor == null) return false; + + var controllerNamespace = controllerActionDescriptor.ControllerTypeInfo.Namespace ?? string.Empty; + + return docName switch + { + "v0" => controllerNamespace.StartsWith("TheOfficeAPI.Level0"), + "v1" => controllerNamespace.StartsWith("TheOfficeAPI.Level1"), + "v2" => controllerNamespace.StartsWith("TheOfficeAPI.Level2"), + "v3" => controllerNamespace.StartsWith("TheOfficeAPI.Level3"), + _ => false + }; + }); + + var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = System.IO.Path.Combine(AppContext.BaseDirectory, xmlFile); + if (System.IO.File.Exists(xmlPath)) + { + c.IncludeXmlComments(xmlPath); + } + }); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + private static MaturityLevel? DetermineMaturityLevel(string environmentVariable) { var maturityLevelString = Environment.GetEnvironmentVariable(environmentVariable); diff --git a/tests/TheOfficeAPI.Common.Tests.Unit/HealthCheckServiceTests.cs b/tests/TheOfficeAPI.Common.Tests.Unit/HealthCheckServiceTests.cs index 0389f67..359a65c 100644 --- a/tests/TheOfficeAPI.Common.Tests.Unit/HealthCheckServiceTests.cs +++ b/tests/TheOfficeAPI.Common.Tests.Unit/HealthCheckServiceTests.cs @@ -83,11 +83,11 @@ public void GetReadinessStatus_IncludesVersion() } [Fact] - public void GetReadinessStatus_IncludesUptime() + public async Task GetReadinessStatus_IncludesUptime() { // Arrange var service = new HealthCheckService(); - Thread.Sleep(100); // Wait a bit to ensure uptime is > 0 + await Task.Delay(100); // Wait a bit to ensure uptime is > 0 // Act var result = service.GetReadinessStatus(); @@ -138,18 +138,18 @@ public void GetReadinessStatus_IncludesDataServiceComponent() Assert.True(dataComponent.Data.ContainsKey("type")); Assert.Equal("In-Memory", dataComponent.Data["type"]); Assert.True(dataComponent.Data.ContainsKey("initialized")); - Assert.Equal(true, dataComponent.Data["initialized"]); + Assert.True((bool)dataComponent.Data["initialized"]); } [Fact] - public void GetReadinessStatus_UptimeIncreasesOverTime() + public async Task GetReadinessStatus_UptimeIncreasesOverTime() { // Arrange var service = new HealthCheckService(); // Act var result1 = service.GetReadinessStatus(); - Thread.Sleep(50); + await Task.Delay(50); var result2 = service.GetReadinessStatus(); // Assert @@ -157,11 +157,11 @@ public void GetReadinessStatus_UptimeIncreasesOverTime() } [Fact] - public void MultipleInstances_HaveIndependentStartTimes() + public async Task MultipleInstances_HaveIndependentStartTimes() { // Arrange & Act var service1 = new HealthCheckService(); - Thread.Sleep(50); + await Task.Delay(50); var service2 = new HealthCheckService(); var result1 = service1.GetReadinessStatus(); diff --git a/tests/TheOfficeAPI.Common.Tests.Unit/SwaggerConfigurationExtensionsTests.cs b/tests/TheOfficeAPI.Common.Tests.Unit/SwaggerConfigurationExtensionsTests.cs index ecfd292..a2f76cf 100644 --- a/tests/TheOfficeAPI.Common.Tests.Unit/SwaggerConfigurationExtensionsTests.cs +++ b/tests/TheOfficeAPI.Common.Tests.Unit/SwaggerConfigurationExtensionsTests.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; namespace TheOfficeAPI.Common.Tests.Unit; diff --git a/tests/TheOfficeAPI.Common.Tests.Unit/TheOfficeAPI.Common.Tests.Unit.csproj b/tests/TheOfficeAPI.Common.Tests.Unit/TheOfficeAPI.Common.Tests.Unit.csproj index bcb6e29..08ca3aa 100644 --- a/tests/TheOfficeAPI.Common.Tests.Unit/TheOfficeAPI.Common.Tests.Unit.csproj +++ b/tests/TheOfficeAPI.Common.Tests.Unit/TheOfficeAPI.Common.Tests.Unit.csproj @@ -1,4 +1,4 @@ - + net10.0 diff --git a/tests/TheOfficeAPI.Level0.Tests.Unit/TheOfficeAPI.Level0.Tests.Unit.csproj b/tests/TheOfficeAPI.Level0.Tests.Unit/TheOfficeAPI.Level0.Tests.Unit.csproj index a0c8773..5a165e7 100644 --- a/tests/TheOfficeAPI.Level0.Tests.Unit/TheOfficeAPI.Level0.Tests.Unit.csproj +++ b/tests/TheOfficeAPI.Level0.Tests.Unit/TheOfficeAPI.Level0.Tests.Unit.csproj @@ -1,4 +1,4 @@ - + net10.0 diff --git a/tests/TheOfficeAPI.Level1.Tests.Unit/TheOfficeAPI.Level1.Tests.Unit.csproj b/tests/TheOfficeAPI.Level1.Tests.Unit/TheOfficeAPI.Level1.Tests.Unit.csproj index a0c8773..5a165e7 100644 --- a/tests/TheOfficeAPI.Level1.Tests.Unit/TheOfficeAPI.Level1.Tests.Unit.csproj +++ b/tests/TheOfficeAPI.Level1.Tests.Unit/TheOfficeAPI.Level1.Tests.Unit.csproj @@ -1,4 +1,4 @@ - + net10.0 diff --git a/tests/TheOfficeAPI.Level2.Tests.Unit/TheOfficeAPI.Level2.Tests.Unit.csproj b/tests/TheOfficeAPI.Level2.Tests.Unit/TheOfficeAPI.Level2.Tests.Unit.csproj index a0c8773..5a165e7 100644 --- a/tests/TheOfficeAPI.Level2.Tests.Unit/TheOfficeAPI.Level2.Tests.Unit.csproj +++ b/tests/TheOfficeAPI.Level2.Tests.Unit/TheOfficeAPI.Level2.Tests.Unit.csproj @@ -1,4 +1,4 @@ - + net10.0 diff --git a/tests/TheOfficeAPI.Level3.Tests.Unit/TheOfficeAPI.Level3.Tests.Unit.csproj b/tests/TheOfficeAPI.Level3.Tests.Unit/TheOfficeAPI.Level3.Tests.Unit.csproj index a0c8773..5a165e7 100644 --- a/tests/TheOfficeAPI.Level3.Tests.Unit/TheOfficeAPI.Level3.Tests.Unit.csproj +++ b/tests/TheOfficeAPI.Level3.Tests.Unit/TheOfficeAPI.Level3.Tests.Unit.csproj @@ -1,4 +1,4 @@ - + net10.0