diff --git a/Areas/Api/Controllers/VersionController.cs b/Areas/Api/Controllers/VersionController.cs new file mode 100644 index 00000000..311a3ba9 --- /dev/null +++ b/Areas/Api/Controllers/VersionController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Wayfarer.Services; + +namespace Wayfarer.Areas.Api.Controllers; + +/// +/// Exposes the compiled Wayfarer application version. +/// +[ApiController] +[Area("Api")] +[Route("api/version")] +public sealed class VersionController : ControllerBase +{ + private readonly IAppVersionProvider _appVersionProvider; + + /// + /// Creates the version API endpoint. + /// + /// The provider for the compiled application version. + public VersionController(IAppVersionProvider appVersionProvider) + { + _appVersionProvider = appVersionProvider; + } + + /// + /// Gets the compiled Wayfarer application version. + /// + [HttpGet] + [AllowAnonymous] + public IActionResult Get() + { + return Ok(new { version = _appVersionProvider.Version }); + } +} diff --git a/CommandLine/AppVersionCli.cs b/CommandLine/AppVersionCli.cs new file mode 100644 index 00000000..918e9b8d --- /dev/null +++ b/CommandLine/AppVersionCli.cs @@ -0,0 +1,37 @@ +using Wayfarer.Services; + +namespace Wayfarer.CommandLine; + +/// +/// Handles app-version CLI commands that do not require web host startup. +/// +public static class AppVersionCli +{ + /// + /// Handles the version command when present. + /// + /// The process arguments. + /// The provider for the compiled application version. + /// The writer used for command output. + /// The writer used for command errors. + /// The command exit code when handled. + /// True when this handler consumed the command; otherwise false. + public static bool TryHandle( + string[] args, + IAppVersionProvider appVersionProvider, + TextWriter output, + TextWriter error, + out int exitCode) + { + _ = error; + exitCode = 0; + + if (args.Length == 0 || !string.Equals(args[0], "version", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + output.WriteLine($"Wayfarer {appVersionProvider.Version}"); + return true; + } +} diff --git a/Middleware/AppVersionHeaderMiddleware.cs b/Middleware/AppVersionHeaderMiddleware.cs new file mode 100644 index 00000000..9ea9cc7c --- /dev/null +++ b/Middleware/AppVersionHeaderMiddleware.cs @@ -0,0 +1,44 @@ +using Wayfarer.Services; + +namespace Wayfarer.Middleware; + +/// +/// Adds the compiled Wayfarer version to normal HTTP responses. +/// +public sealed class AppVersionHeaderMiddleware +{ + /// + /// The response header that carries the Wayfarer version. + /// + public const string HeaderName = "X-Wayfarer-Version"; + + private readonly RequestDelegate _next; + private readonly IAppVersionProvider _appVersionProvider; + + /// + /// Creates middleware that appends the Wayfarer version header. + /// + /// The next middleware in the pipeline. + /// The provider for the compiled application version. + public AppVersionHeaderMiddleware(RequestDelegate next, IAppVersionProvider appVersionProvider) + { + _next = next; + _appVersionProvider = appVersionProvider; + } + + /// + /// Registers the version header before downstream middleware starts the response. + /// + /// The current HTTP context. + public async Task InvokeAsync(HttpContext context) + { + context.Response.OnStarting(static state => + { + var (httpContext, version) = ((HttpContext, string))state; + httpContext.Response.Headers[HeaderName] = version; + return Task.CompletedTask; + }, (context, _appVersionProvider.Version)); + + await _next(context); + } +} diff --git a/Program.cs b/Program.cs index 95889737..0f1955e4 100644 --- a/Program.cs +++ b/Program.cs @@ -16,6 +16,7 @@ using Quartz.Impl; using Quartz.Spi; using Serilog; +using Wayfarer.CommandLine; using Wayfarer.Jobs; using Wayfarer.Middleware; using Wayfarer.Models; @@ -24,30 +25,29 @@ using Wayfarer.Swagger; using Wayfarer.Util; using IPNetwork = System.Net.IPNetwork; -// for AddQuartz(), AddQuartzHostedService() -// for UseMicrosoftDependencyInjectionJobFactory(), UsePersistentStore(), etc. -// for IJobFactory -// for UseNewtonsoftJsonSerializer() + +if (AppVersionCli.TryHandle(args, new AppVersionProvider(), Console.Out, Console.Error, out var versionExitCode)) +{ + Environment.ExitCode = versionExitCode; + return; +} var builder = WebApplication.CreateBuilder(args); #region CLI Command Handling -// Handling the "reset-password" command from the CLI if (args.Length > 0 && args[0] == "reset-password") { await HandlePasswordResetCommand(args); return; } #endregion CLI Command Handling #region Configuration Setup -// Configuring the application settings, such as JSON configuration files ConfigureConfiguration(builder); #endregion Configuration Setup #region Serilog Logging Setup -// Setting up logging, including Serilog for file, console, and PostgreSQL logging ConfigureLogging(builder); #endregion Serilog Logging Setup @@ -465,6 +465,7 @@ static void ConfigureServices(WebApplicationBuilder builder) // Explicitly register IHttpContextAccessor for services that need it (e.g., TileCacheService). // Some framework components may register it implicitly, but explicit registration is safer. builder.Services.AddHttpContextAccessor(); + builder.Services.AddSingleton(); // Register memory cache for application services builder.Services.AddMemoryCache(); @@ -700,6 +701,7 @@ static async Task ConfigureMiddleware(WebApplication app) // CRITICAL: Add this as the FIRST middleware to process forwarded headers from nginx app.UseForwardedHeaders(); + app.UseMiddleware(); app.UseMiddleware(); // Enriches Serilog LogContext with HttpContext.TraceIdentifier app.UseMiddleware(); // Custom middleware for monitoring performance diff --git a/Services/AppVersionDisplay.cs b/Services/AppVersionDisplay.cs new file mode 100644 index 00000000..8481da63 --- /dev/null +++ b/Services/AppVersionDisplay.cs @@ -0,0 +1,17 @@ +namespace Wayfarer.Services; + +/// +/// Formats user-visible Wayfarer version text. +/// +public static class AppVersionDisplay +{ + /// + /// Formats the shared footer version text. + /// + /// The provider for the compiled application version. + /// The footer version string. + public static string FooterText(IAppVersionProvider appVersionProvider) + { + return $"Wayfarer v{appVersionProvider.Version}"; + } +} diff --git a/Services/AppVersionProvider.cs b/Services/AppVersionProvider.cs new file mode 100644 index 00000000..3bea7930 --- /dev/null +++ b/Services/AppVersionProvider.cs @@ -0,0 +1,59 @@ +using System.Reflection; + +namespace Wayfarer.Services; + +/// +/// Provides the compiled Wayfarer application version. +/// +public interface IAppVersionProvider +{ + /// + /// Gets the application release version from assembly informational metadata. + /// + string Version { get; } +} + +/// +/// Reads the Wayfarer application version from assembly informational metadata. +/// +public sealed class AppVersionProvider : IAppVersionProvider +{ + private readonly Assembly _assembly; + private string? _version; + + /// + /// Creates a provider for the Wayfarer application assembly. + /// + public AppVersionProvider() + : this(typeof(AppVersionProvider).Assembly) + { + } + + /// + /// Creates a provider for the assembly that contains the supplied marker type. + /// + /// A type from the assembly whose metadata should be read. + public AppVersionProvider(Type markerType) + : this(markerType.Assembly) + { + } + + /// + /// Creates a provider for the supplied assembly. + /// + /// The assembly whose informational version should be read. + public AppVersionProvider(Assembly assembly) + { + _assembly = assembly; + } + + /// + public string Version => _version ??= ReadInformationalVersion(_assembly); + + private static string ReadInformationalVersion(Assembly assembly) + { + return assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "0.0.0"; + } +} diff --git a/Version.props b/Version.props new file mode 100644 index 00000000..9ce09201 --- /dev/null +++ b/Version.props @@ -0,0 +1,9 @@ + + + 1.4.0 + $(WayfarerVersion) + $(WayfarerVersion) + $(WayfarerVersion) + false + + diff --git a/Views/Shared/_Layout.cshtml b/Views/Shared/_Layout.cshtml index 19a9c814..decf1ebe 100644 --- a/Views/Shared/_Layout.cshtml +++ b/Views/Shared/_Layout.cshtml @@ -3,6 +3,7 @@ @using Wayfarer.Services @using Wayfarer.Areas.Public.Controllers @inject Wayfarer.Parsers.IApplicationSettingsService ApplicationSettingsService +@inject IAppVersionProvider AppVersionProvider @{ bool embed = ViewBag.IsEmbed as bool? ?? false; } @@ -143,7 +144,8 @@ Mobile - Privacy + class="link-offset-2 link-offset-3-hover link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Privacy - + @AppVersionDisplay.FooterText(AppVersionProvider) diff --git a/Wayfarer.csproj b/Wayfarer.csproj index ba9cf7ed..55160c98 100644 --- a/Wayfarer.csproj +++ b/Wayfarer.csproj @@ -1,5 +1,7 @@ + + net10.0 enable diff --git a/docs/23-Versioning.md b/docs/23-Versioning.md new file mode 100644 index 00000000..993ff72e --- /dev/null +++ b/docs/23-Versioning.md @@ -0,0 +1,32 @@ +# Versioning + +`Version.props` is the runtime and release version source for this slice. The root +file contains the manually edited `WayfarerVersion` value and maps the standard +MSBuild metadata directly from it: + +```xml +1.4.0 +$(WayfarerVersion) +$(WayfarerVersion) +$(WayfarerVersion) +false +``` + +The running app reads `AssemblyInformationalVersion` from the compiled Wayfarer +assembly through `IAppVersionProvider`. Runtime surfaces such as +`dotnet run --no-launch-profile -- version`, `GET /api/version`, +`X-Wayfarer-Version`, and the shared layout footer use that provider instead of +separate constants. + +Use `dotnet run --no-launch-profile -- version` when validating exact CLI +output. The app writes exactly `Wayfarer 1.4.0`; `--no-launch-profile` avoids +.NET SDK launch-profile messages so validation stays focused on app output. + +## Manual bump process + +To prepare a later release version, edit only the root `Version.props` file and +change `WayfarerVersion` to the target release value. Rebuild the app so the new +value is compiled into assembly metadata. + +Release helper automation, changelog checks, tag validation, and GitHub release +validation are deferred to issue #324. diff --git a/docs/_sidebar.md b/docs/_sidebar.md index e5423c6e..467e40e7 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -23,4 +23,5 @@ - [Deployment](20-Deployment.md) - [Security](21-Security.md) - [Testing](22-Testing.md) + - [Versioning](23-Versioning.md) diff --git a/tests/Wayfarer.Tests/Versioning/AppVersionCliTests.cs b/tests/Wayfarer.Tests/Versioning/AppVersionCliTests.cs new file mode 100644 index 00000000..0ee4fed8 --- /dev/null +++ b/tests/Wayfarer.Tests/Versioning/AppVersionCliTests.cs @@ -0,0 +1,55 @@ +using FluentAssertions; +using Wayfarer.CommandLine; +using Wayfarer.Services; +using Xunit; + +namespace Wayfarer.Tests.Versioning; + +public class AppVersionCliTests +{ + [Fact] + public void TryHandle_VersionCommand_WritesExactVersionLine() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + + var handled = AppVersionCli.TryHandle( + new[] { "version" }, + new StubAppVersionProvider("1.4.0"), + output, + error, + out var exitCode); + + handled.Should().BeTrue(); + exitCode.Should().Be(0); + output.ToString().Should().Be($"Wayfarer 1.4.0{Environment.NewLine}"); + error.ToString().Should().BeEmpty(); + } + + [Fact] + public void TryHandle_VersionCommand_DoesNotRequireWebHostOrDatabaseStartup() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + + var handled = AppVersionCli.TryHandle( + new[] { "version" }, + new StubAppVersionProvider("1.4.0"), + output, + error, + out var exitCode); + + handled.Should().BeTrue(); + exitCode.Should().Be(0); + } + + private sealed class StubAppVersionProvider : IAppVersionProvider + { + public StubAppVersionProvider(string version) + { + Version = version; + } + + public string Version { get; } + } +} diff --git a/tests/Wayfarer.Tests/Versioning/AppVersionDisplayTests.cs b/tests/Wayfarer.Tests/Versioning/AppVersionDisplayTests.cs new file mode 100644 index 00000000..f86e117b --- /dev/null +++ b/tests/Wayfarer.Tests/Versioning/AppVersionDisplayTests.cs @@ -0,0 +1,46 @@ +using FluentAssertions; +using Wayfarer.Services; +using Xunit; + +namespace Wayfarer.Tests.Versioning; + +public class AppVersionDisplayTests +{ + [Fact] + public void FooterText_RendersSharedLayoutVersionText() + { + AppVersionDisplay.FooterText(new StubAppVersionProvider("1.4.0")) + .Should() + .Be("Wayfarer v1.4.0"); + } + + [Fact] + public void SharedLayout_UsesProviderBackedFooterHelper() + { + var layoutPath = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "..", + "Views", + "Shared", + "_Layout.cshtml")); + var layout = File.ReadAllText(layoutPath); + + layout.Should().Contain("@inject IAppVersionProvider AppVersionProvider"); + layout.Should().Contain("@AppVersionDisplay.FooterText(AppVersionProvider)"); + layout.Should().NotContain("Wayfarer v1.4.0"); + } + + private sealed class StubAppVersionProvider : IAppVersionProvider + { + public StubAppVersionProvider(string version) + { + Version = version; + } + + public string Version { get; } + } +} diff --git a/tests/Wayfarer.Tests/Versioning/AppVersionProviderTests.cs b/tests/Wayfarer.Tests/Versioning/AppVersionProviderTests.cs new file mode 100644 index 00000000..d43b1cfb --- /dev/null +++ b/tests/Wayfarer.Tests/Versioning/AppVersionProviderTests.cs @@ -0,0 +1,53 @@ +using System.Reflection; +using System.Reflection.Emit; +using FluentAssertions; +using Wayfarer.Services; +using Xunit; + +namespace Wayfarer.Tests.Versioning; + +public class AppVersionProviderTests +{ + [Fact] + public void Version_DefaultProviderReadsCompiledWayfarerVersion() + { + var provider = new AppVersionProvider(); + + provider.Version.Should().Be("1.4.0"); + } + + [Fact] + public void Version_ReadsAssemblyInformationalVersion() + { + var assembly = CreateAssemblyWithInformationalVersion("9.8.7-test"); + + var provider = new AppVersionProvider(assembly); + + provider.Version.Should().Be("9.8.7-test"); + } + + [Fact] + public void Version_CanUseMarkerTypeAssemblyInsteadOfEntryAssembly() + { + var provider = new AppVersionProvider(typeof(AppVersionProviderTests)); + var expectedVersion = typeof(AppVersionProviderTests).Assembly + .GetCustomAttribute()! + .InformationalVersion; + + provider.Version.Should().Be(expectedVersion); + Assert.NotSame(typeof(AppVersionProviderTests).Assembly, Assembly.GetEntryAssembly()); + } + + private static Assembly CreateAssemblyWithInformationalVersion(string version) + { + var attributeConstructor = typeof(AssemblyInformationalVersionAttribute) + .GetConstructor(new[] { typeof(string) })!; + var assemblyName = new AssemblyName($"WayfarerVersionTest{Guid.NewGuid():N}"); + var informationalVersion = new CustomAttributeBuilder(attributeConstructor, new object[] { version }); + + return AssemblyBuilder.DefineDynamicAssembly( + assemblyName, + AssemblyBuilderAccess.Run, + new[] { informationalVersion }); + } +} diff --git a/tests/Wayfarer.Tests/Versioning/VersionHttpTests.cs b/tests/Wayfarer.Tests/Versioning/VersionHttpTests.cs new file mode 100644 index 00000000..05f3b674 --- /dev/null +++ b/tests/Wayfarer.Tests/Versioning/VersionHttpTests.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Wayfarer.Areas.Api.Controllers; +using Wayfarer.Middleware; +using Wayfarer.Services; +using Xunit; + +namespace Wayfarer.Tests.Versioning; + +public class VersionHttpTests : IDisposable +{ + private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"wayfarer-version-tests-{Guid.NewGuid():N}"); + + [Fact] + public async Task GetVersion_ReturnsExpectedJsonAndContentType() + { + using var host = await CreateApiHostAsync(); + using var client = host.GetTestClient(); + + using var response = await client.GetAsync("/api/version"); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); + using var document = JsonDocument.Parse(body); + var property = document.RootElement.EnumerateObject().Should().ContainSingle().Subject; + property.Name.Should().Be("version"); + property.Value.GetString().Should().Be("1.4.0"); + } + + [Fact] + public async Task VersionHeader_AppearsOnApiResponse() + { + using var host = await CreateApiHostAsync(); + using var client = host.GetTestClient(); + + using var response = await client.GetAsync("/api/version"); + + response.Headers.GetValues(AppVersionHeaderMiddleware.HeaderName).Should().ContainSingle("1.4.0"); + } + + [Fact] + public async Task VersionHeader_AppearsOnDocsStaticResponse() + { + Directory.CreateDirectory(_tempDirectory); + await File.WriteAllTextAsync(Path.Combine(_tempDirectory, "version-test.txt"), "docs"); + + using var host = await CreateDocsStaticHostAsync(_tempDirectory); + using var client = host.GetTestClient(); + + using var response = await client.GetAsync("/docs/version-test.txt"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.GetValues(AppVersionHeaderMiddleware.HeaderName).Should().ContainSingle("1.4.0"); + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + } + + private static async Task CreateApiHostAsync() + { + var host = new HostBuilder() + .ConfigureWebHost(webHost => webHost + .UseTestServer() + .ConfigureServices(services => + { + services.AddSingleton(new StubAppVersionProvider("1.4.0")); + services.AddControllers() + .AddApplicationPart(typeof(VersionController).Assembly); + }) + .Configure(app => + { + app.UseMiddleware(); + app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + })) + .Build(); + + await host.StartAsync(); + return host; + } + + private static async Task CreateDocsStaticHostAsync(string docsPath) + { + var host = new HostBuilder() + .ConfigureWebHost(webHost => webHost + .UseTestServer() + .ConfigureServices(services => + { + services.AddSingleton(new StubAppVersionProvider("1.4.0")); + }) + .Configure(app => + { + app.UseMiddleware(); + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(docsPath), + RequestPath = "/docs", + ContentTypeProvider = new FileExtensionContentTypeProvider() + }); + })) + .Build(); + + await host.StartAsync(); + return host; + } + + private sealed class StubAppVersionProvider : IAppVersionProvider + { + public StubAppVersionProvider(string version) + { + Version = version; + } + + public string Version { get; } + } +} diff --git a/tests/Wayfarer.Tests/Wayfarer.Tests.csproj b/tests/Wayfarer.Tests/Wayfarer.Tests.csproj index 0331f910..d23e9c99 100644 --- a/tests/Wayfarer.Tests/Wayfarer.Tests.csproj +++ b/tests/Wayfarer.Tests/Wayfarer.Tests.csproj @@ -8,6 +8,7 @@ + all