diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml new file mode 100644 index 0000000..fe04e00 --- /dev/null +++ b/.github/workflows/build-and-publish.yml @@ -0,0 +1,43 @@ +name: Build and Publish + +on: + push: + pull_request: + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore src + + - name: Build + run: dotnet build src --configuration Release --no-restore + + - name: Test + run: dotnet test tests --configuration Release + + - name: Pack + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: dotnet pack src --configuration Release --no-build + + - name: Publish to NuGet + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + PACKAGE_PATH=$(find src/bin/Release -name "*.nupkg" | head -n 1) + if [ -z "$PACKAGE_PATH" ]; then + echo "Error: No .nupkg file found in src/bin/Release" + exit 1 + fi + dotnet nuget push "$PACKAGE_PATH" \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 009f3ca..0000000 --- a/.travis.yml +++ /dev/null @@ -1,10 +0,0 @@ -language: csharp -dist: focal -sudo: required -mono: none -dotnet: 9.0 -before_script: -- chmod +x *.sh -- dotnet restore -- ./test.sh -script: ./publish.sh \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b901ecd..e70431d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # Changelog +## [3.0.0] - 2026-05-12 +### Changes +- Updated to .NET 10 +- Added a new lazy overload of Negotiate that takes a Func> instead of an already-evaluated model. This allows endpoints that serve both the SPA shell and API data to avoid fetching data unnecessarily for HTML requests - the delegate is only invoked when the client actually wants JSON/CSV/etc. +- Moved HtmlNegotiator into this library so consuming projects no longer need to duplicate it. Includes IViewLoader, ViewLoader, and the common models (ApplicationSettings, ViewModel, ViewResponse). +- ApplicationSettings now uses a dictionary-based approach. Consumers can override defaults or add extra keys via HtmlNegotiatorOptions at registration time. +- The lazy Negotiate overload resolves the negotiator explicitly rather than relying on DI registration order, fixing a subtle bug where UniversalResponseNegotiator (CanHandle always returns true) could intercept HTML requests if registered before HtmlNegotiator. +- Migrated CI/CD from Travis CI to GitHub Actions. ## [2.0.0] - 2026-02-19 ### Changes - Added new StreamCopyingResultHandler, which will be invoked on IResult types during content negotiation and subsequent response writing. diff --git a/publish.sh b/publish.sh deleted file mode 100644 index fb57c2c..0000000 --- a/publish.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -set -e - -if [ "${TRAVIS_BRANCH}" = "main" ] && [ "${TRAVIS_PULL_REQUEST}" = "false" ]; then - echo "Starting publish..." - - dotnet restore src - dotnet pack -c Release src - - PACKAGE_PATH=$(find src/bin/Release -name "*.nupkg" | head -n 1) - - if [ -z "$PACKAGE_PATH" ]; then - echo "Error: No .nupkg file found in src/bin/Release" - exit 1 - fi - - echo "Publishing package: $PACKAGE_PATH" - - dotnet nuget push "$PACKAGE_PATH" \ - --api-key "$NUGET_API_KEY" \ - --source https://www.nuget.org/api/v2/package \ - --skip-duplicate - - echo "...done publishing" -else - echo "Skipping publish" -fi diff --git a/src/Extensions/HttpResponseExtensions.cs b/src/Extensions/HttpResponseExtensions.cs index 874c278..ab270d1 100644 --- a/src/Extensions/HttpResponseExtensions.cs +++ b/src/Extensions/HttpResponseExtensions.cs @@ -22,10 +22,41 @@ public static Task Negotiate( T model, CancellationToken cancellationToken = default) { - List negotiators = response.HttpContext.RequestServices.GetServices().ToList(); + var negotiator = ResolveNegotiator(response); + + return negotiator.Handle( + response.HttpContext.Request, response, model, cancellationToken); + } + + public static async Task Negotiate( + this HttpResponse response, + Func> dataFunc, + CancellationToken cancellationToken = default) + { + var negotiator = ResolveNegotiator(response); + + if (negotiator is HtmlNegotiator) + { + await negotiator.Handle( + response.HttpContext.Request, response, null, cancellationToken); + return; + } + + var model = await dataFunc(); + await negotiator.Handle( + response.HttpContext.Request, response, model, cancellationToken); + } + + private static IResponseNegotiator ResolveNegotiator(HttpResponse response) + { + var negotiators = response.HttpContext.RequestServices + .GetServices().ToList(); + IResponseNegotiator? negotiator = null; - MediaTypeHeaderValue.TryParseList(response.HttpContext.Request.Headers["Accept"], out var accept); + MediaTypeHeaderValue.TryParseList( + response.HttpContext.Request.Headers["Accept"], out var accept); + if (accept != null) { var ordered = accept.OrderByDescending(x => x.Quality ?? 1); @@ -46,8 +77,7 @@ public static Task Negotiate( x => x.CanHandle(new MediaTypeHeaderValue("application/json"))); } - return negotiator.Handle( - response.HttpContext.Request, response, model, cancellationToken); + return negotiator; } public static Task FromStream( diff --git a/src/HtmlNegotiator.cs b/src/HtmlNegotiator.cs new file mode 100644 index 0000000..cedcff2 --- /dev/null +++ b/src/HtmlNegotiator.cs @@ -0,0 +1,81 @@ +namespace Linn.Common.Service +{ + using System.Net; + using System.Threading; + using System.Threading.Tasks; + + using Linn.Common.Configuration; + using Linn.Common.Rendering; + using Linn.Common.Service.Models; + + using Microsoft.AspNetCore.Http; + using Microsoft.Net.Http.Headers; + + using Newtonsoft.Json; + using Newtonsoft.Json.Serialization; + + public class HtmlNegotiator : IResponseNegotiator + { + private readonly IViewLoader viewLoader; + + private readonly ITemplateEngine templateEngine; + + private readonly HtmlNegotiatorOptions options; + + public HtmlNegotiator(IViewLoader viewLoader, ITemplateEngine templateEngine) + : this(viewLoader, templateEngine, new HtmlNegotiatorOptions()) + { + } + + public HtmlNegotiator( + IViewLoader viewLoader, + ITemplateEngine templateEngine, + HtmlNegotiatorOptions options) + { + this.viewLoader = viewLoader; + this.templateEngine = templateEngine; + this.options = options; + } + + public bool CanHandle(MediaTypeHeaderValue accept) + { + return accept.MediaType.Equals("text/html"); + } + + public async Task Handle(HttpRequest req, HttpResponse res, object model, CancellationToken cancellationToken) + { + var viewName = model is ViewResponse viewResponse + ? viewResponse.ViewName + : "Index.cshtml"; + + var view = this.viewLoader.Load(viewName); + + var appSettings = ApplicationSettings.GetDefaults(); + foreach (var kvp in this.options.ExtraSettings) + { + appSettings.Settings[kvp.Key] = kvp.Value; + } + + var jsonAppSettings = JsonConvert.SerializeObject( + appSettings.Settings, + Formatting.Indented, + new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver() + }); + + var viewModel = new ViewModel + { + AppSettings = jsonAppSettings, + BuildNumber = ConfigurationManager.Configuration["BUILD_NUMBER"] + }; + + var compiled = this.templateEngine.Render(viewModel, view).Result; + + res.ContentType = "text/html"; + res.StatusCode = (int)HttpStatusCode.OK; + + await res.WriteAsync(compiled, cancellationToken); + } + } +} diff --git a/src/HtmlNegotiatorOptions.cs b/src/HtmlNegotiatorOptions.cs new file mode 100644 index 0000000..9d053ef --- /dev/null +++ b/src/HtmlNegotiatorOptions.cs @@ -0,0 +1,9 @@ +namespace Linn.Common.Service +{ + using System.Collections.Generic; + + public class HtmlNegotiatorOptions + { + public Dictionary ExtraSettings { get; set; } = new Dictionary(); + } +} diff --git a/src/IViewLoader.cs b/src/IViewLoader.cs new file mode 100644 index 0000000..2cb0290 --- /dev/null +++ b/src/IViewLoader.cs @@ -0,0 +1,7 @@ +namespace Linn.Common.Service +{ + public interface IViewLoader + { + string Load(string viewName); + } +} diff --git a/src/Linn.Common.Service.csproj b/src/Linn.Common.Service.csproj index 9613cd2..793067b 100644 --- a/src/Linn.Common.Service.csproj +++ b/src/Linn.Common.Service.csproj @@ -1,18 +1,20 @@  .NET utilities for consistent API and service responses. Provides standard result types (e.g., SuccessResult, BadRequestResult, NotFoundResult) and helpers for generating HTTP responses with correct status codes and serialized content. - net9.0 + net10.0 enable Linn.Common.Service Linn.Common.Service - 2.0.0 + 3.0.0 enable + + diff --git a/src/Models/ApplicationSettings.cs b/src/Models/ApplicationSettings.cs new file mode 100644 index 0000000..af523e7 --- /dev/null +++ b/src/Models/ApplicationSettings.cs @@ -0,0 +1,23 @@ +namespace Linn.Common.Service.Models +{ + using System.Collections.Generic; + + using Linn.Common.Configuration; + + public class ApplicationSettings + { + public Dictionary Settings { get; } = new Dictionary(); + + public static ApplicationSettings GetDefaults() + { + var appSettings = new ApplicationSettings(); + appSettings.Settings["cognitoHost"] = ConfigurationManager.Configuration["COGNITO_HOST"]; + appSettings.Settings["appRoot"] = ConfigurationManager.Configuration["APP_ROOT"]; + appSettings.Settings["proxyRoot"] = ConfigurationManager.Configuration["PROXY_ROOT"]; + appSettings.Settings["cognitoClientId"] = ConfigurationManager.Configuration["COGNITO_CLIENT_ID"]; + appSettings.Settings["cognitoDomainPrefix"] = ConfigurationManager.Configuration["COGNITO_DOMAIN_PREFIX"]; + appSettings.Settings["entraLogoutUri"] = ConfigurationManager.Configuration["ENTRA_LOGOUT_URI"]; + return appSettings; + } + } +} diff --git a/src/Models/ViewModel.cs b/src/Models/ViewModel.cs new file mode 100644 index 0000000..5b89b90 --- /dev/null +++ b/src/Models/ViewModel.cs @@ -0,0 +1,9 @@ +namespace Linn.Common.Service.Models +{ + public class ViewModel + { + public string AppSettings { get; set; } + + public string BuildNumber { get; set; } + } +} diff --git a/src/Models/ViewResponse.cs b/src/Models/ViewResponse.cs new file mode 100644 index 0000000..154970f --- /dev/null +++ b/src/Models/ViewResponse.cs @@ -0,0 +1,7 @@ +namespace Linn.Common.Service.Models +{ + public class ViewResponse + { + public string ViewName { get; set; } + } +} diff --git a/src/ViewLoader.cs b/src/ViewLoader.cs new file mode 100644 index 0000000..2e4bd9e --- /dev/null +++ b/src/ViewLoader.cs @@ -0,0 +1,37 @@ +namespace Linn.Common.Service +{ + using System.Collections.Generic; + using System.IO; + + public class ViewLoader : IViewLoader + { + private static readonly object Key = new object(); + + private readonly Dictionary loadedViews = new Dictionary(); + + public string Load(string viewName) + { + lock (Key) + { + if (!this.loadedViews.ContainsKey(viewName)) + { + var viewPath = $"./Views/{viewName}"; + + if (!File.Exists(viewPath)) + { + viewPath = $"/app/views/{viewName}"; + if (!File.Exists(viewPath)) + { + return null; + } + } + + var view = File.ReadAllText(viewPath); + this.loadedViews.Add(viewName, view); + } + + return this.loadedViews[viewName]; + } + } + } +} diff --git a/test.sh b/test.sh deleted file mode 100644 index d45d64b..0000000 --- a/test.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -set -ev - -# dotnet tests -dotnet test ./tests/Linn.Common.Service.Tests.csproj \ No newline at end of file diff --git a/tests/Fake/Modules/WidgetModule.cs b/tests/Fake/Modules/WidgetModule.cs index 968dc6c..f3e291f 100644 --- a/tests/Fake/Modules/WidgetModule.cs +++ b/tests/Fake/Modules/WidgetModule.cs @@ -15,6 +15,7 @@ public class WidgetModule : IModule public void MapEndpoints(IEndpointRouteBuilder app) { app.MapGet("/widgets/{id:int}", this.GetWidget); + app.MapGet("/widgets/{id:int}/lazy", this.GetWidgetLazy); app.MapPost("/widgets", this.PostWidget); } @@ -26,6 +27,12 @@ private async Task GetWidget( await res.Negotiate(result); } + private async Task GetWidgetLazy( + HttpRequest req, HttpResponse res, int id, IWidgetService widgetService) + { + await res.Negotiate(() => Task.FromResult(widgetService.GetWidget(id))); + } + private async Task PostWidget(HttpRequest req, HttpResponse res, WidgetResource resource, IWidgetService widgetService) { var result = widgetService.CreateWidget(resource); diff --git a/tests/LazyNegotiateContextBase.cs b/tests/LazyNegotiateContextBase.cs new file mode 100644 index 0000000..b2b4702 --- /dev/null +++ b/tests/LazyNegotiateContextBase.cs @@ -0,0 +1,55 @@ +namespace Linn.Common.Service.Tests +{ + using Linn.Common.Rendering; + using Linn.Common.Service.Handlers; + using Linn.Common.Service.Tests.Fake; + using Linn.Common.Service.Tests.Fake.Facades; + using Linn.Common.Service.Tests.Fake.Modules; + using Linn.Common.Service.Tests.Fake.ResourceBuilders; + using Linn.Common.Service.Tests.Fake.Resources; + + using Microsoft.Extensions.DependencyInjection; + + using NSubstitute; + + using NUnit.Framework; + + public class LazyNegotiateContextBase + { + protected HttpClient Client { get; private set; } + + protected HttpResponseMessage Response { get; set; } + + protected IWidgetService WidgetService { get; private set; } + + protected IViewLoader ViewLoader { get; private set; } + + protected ITemplateEngine TemplateEngine { get; private set; } + + [SetUp] + public void SetupContext() + { + this.WidgetService = Substitute.For(); + this.ViewLoader = Substitute.For(); + this.TemplateEngine = Substitute.For(); + + this.ViewLoader.Load("Index.cshtml").Returns("@Model.AppSettings"); + this.TemplateEngine.Render(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("rendered")); + + this.Client = TestClient.With( + s => + { + s.AddSingleton(); + s.AddSingleton(this.ViewLoader); + s.AddSingleton(this.TemplateEngine); + s.AddSingleton(); + s.AddTransient(); + s.AddSingleton>(); + s.AddSingleton>(); + s.AddSingleton(this.WidgetService); + }, + FakeAuthMiddleware.EmployeeMiddleware); + } + } +} diff --git a/tests/Linn.Common.Service.Tests.csproj b/tests/Linn.Common.Service.Tests.csproj index 7c17ad2..21a9048 100644 --- a/tests/Linn.Common.Service.Tests.csproj +++ b/tests/Linn.Common.Service.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable @@ -13,6 +13,7 @@ + diff --git a/tests/WhenLazyNegotiatingAsHtml.cs b/tests/WhenLazyNegotiatingAsHtml.cs new file mode 100644 index 0000000..c4daa96 --- /dev/null +++ b/tests/WhenLazyNegotiatingAsHtml.cs @@ -0,0 +1,48 @@ +namespace Linn.Common.Service.Tests +{ + using System.Net; + + using FluentAssertions; + + using Linn.Common.Service.Tests.Extensions; + + using NSubstitute; + + using NUnit.Framework; + + public class WhenLazyNegotiatingAsHtml : LazyNegotiateContextBase + { + [SetUp] + public void SetUp() + { + this.Response = this.Client.Get( + "/widgets/1/lazy", + with => { with.Accept("text/html"); }).Result; + } + + [Test] + public void ShouldReturnOk() + { + this.Response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Test] + public void ShouldReturnHtmlContentType() + { + this.Response.Content.Headers.ContentType.MediaType.Should().Be("text/html"); + } + + [Test] + public void ShouldReturnRenderedHtml() + { + var body = this.Response.Content.ReadAsStringAsync().Result; + body.Should().Be("rendered"); + } + + [Test] + public void ShouldNotHaveInvokedTheService() + { + this.WidgetService.DidNotReceive().GetWidget(Arg.Any()); + } + } +} diff --git a/tests/WhenLazyNegotiatingAsJson.cs b/tests/WhenLazyNegotiatingAsJson.cs new file mode 100644 index 0000000..c387bed --- /dev/null +++ b/tests/WhenLazyNegotiatingAsJson.cs @@ -0,0 +1,58 @@ +namespace Linn.Common.Service.Tests +{ + using System.Linq; + using System.Net; + + using FluentAssertions; + + using Linn.Common.Facade; + using Linn.Common.Service.Tests.Extensions; + using Linn.Common.Service.Tests.Fake.Resources; + + using NSubstitute; + + using NUnit.Framework; + + public class WhenLazyNegotiatingAsJson : LazyNegotiateContextBase + { + [SetUp] + public void SetUp() + { + var widgetResource = new WidgetResource { WidgetName = "Widget 1" }; + + this.WidgetService.GetWidget(1).Returns(new SuccessResult(widgetResource)); + + this.Response = this.Client.Get( + "/widgets/1/lazy", + with => { with.Accept("application/json"); }).Result; + } + + [Test] + public void ShouldReturnOk() + { + this.Response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Test] + public void ShouldReturnJsonContentType() + { + var contentTypeHeader = this.Response.Content.Headers.FirstOrDefault(h => h.Key == "Content-Type"); + contentTypeHeader.Should().NotBeNull(); + contentTypeHeader.Value.First().Should().Contain("application/json"); + } + + [Test] + public void ShouldReturnJsonBody() + { + var resources = this.Response.DeserializeBody(); + resources.Should().NotBeNull(); + resources.WidgetName.Should().Be("Widget 1"); + } + + [Test] + public void ShouldHaveInvokedTheService() + { + this.WidgetService.Received(1).GetWidget(1); + } + } +}