From 74569599aad16ccc4759759460732528d6fdf728 Mon Sep 17 00:00:00 2001 From: Lewis Renfrew Date: Tue, 12 May 2026 14:19:15 +0100 Subject: [PATCH 1/5] negotiate idea --- src/Extensions/HttpResponseExtensions.cs | 38 ++++++++++++-- src/HtmlNegotiator.cs | 64 ++++++++++++++++++++++++ src/IViewLoader.cs | 7 +++ src/Linn.Common.Service.csproj | 6 ++- src/Models/ApplicationSettings.cs | 32 ++++++++++++ src/Models/ViewModel.cs | 9 ++++ src/Models/ViewResponse.cs | 7 +++ src/ViewLoader.cs | 37 ++++++++++++++ tests/Fake/Modules/WidgetModule.cs | 7 +++ tests/LazyNegotiateContextBase.cs | 55 ++++++++++++++++++++ tests/Linn.Common.Service.Tests.csproj | 3 +- tests/WhenLazyNegotiatingAsHtml.cs | 48 ++++++++++++++++++ tests/WhenLazyNegotiatingAsJson.cs | 58 +++++++++++++++++++++ 13 files changed, 364 insertions(+), 7 deletions(-) create mode 100644 src/HtmlNegotiator.cs create mode 100644 src/IViewLoader.cs create mode 100644 src/Models/ApplicationSettings.cs create mode 100644 src/Models/ViewModel.cs create mode 100644 src/Models/ViewResponse.cs create mode 100644 src/ViewLoader.cs create mode 100644 tests/LazyNegotiateContextBase.cs create mode 100644 tests/WhenLazyNegotiatingAsHtml.cs create mode 100644 tests/WhenLazyNegotiatingAsJson.cs 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..aa55d2d --- /dev/null +++ b/src/HtmlNegotiator.cs @@ -0,0 +1,64 @@ +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; + + public HtmlNegotiator(IViewLoader viewLoader, ITemplateEngine templateEngine) + { + this.viewLoader = viewLoader; + this.templateEngine = templateEngine; + } + + 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 jsonAppSettings = JsonConvert.SerializeObject( + ApplicationSettings.Get(), + 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/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..2071f8b --- /dev/null +++ b/src/Models/ApplicationSettings.cs @@ -0,0 +1,32 @@ +namespace Linn.Common.Service.Models +{ + using Linn.Common.Configuration; + + public class ApplicationSettings + { + public string CognitoHost { get; set; } + + public string AppRoot { get; set; } + + public string ProxyRoot { get; set; } + + public string CognitoClientId { get; set; } + + public string CognitoDomainPrefix { get; set; } + + public string EntraLogoutUri { get; set; } + + public static ApplicationSettings Get() + { + return new ApplicationSettings + { + CognitoHost = ConfigurationManager.Configuration["COGNITO_HOST"], + AppRoot = ConfigurationManager.Configuration["APP_ROOT"], + ProxyRoot = ConfigurationManager.Configuration["PROXY_ROOT"], + CognitoClientId = ConfigurationManager.Configuration["COGNITO_CLIENT_ID"], + CognitoDomainPrefix = ConfigurationManager.Configuration["COGNITO_DOMAIN_PREFIX"], + EntraLogoutUri = ConfigurationManager.Configuration["ENTRA_LOGOUT_URI"] + }; + } + } +} 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/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); + } + } +} From 1ca58c57fd64075145d5cf6dc8c168c89820f328 Mon Sep 17 00:00:00 2001 From: Lewis Renfrew Date: Tue, 12 May 2026 14:41:08 +0100 Subject: [PATCH 2/5] allow passing options dict --- src/HtmlNegotiator.cs | 19 +++++++++++++++++- src/HtmlNegotiatorOptions.cs | 9 +++++++++ src/Models/ApplicationSettings.cs | 33 +++++++++++-------------------- 3 files changed, 39 insertions(+), 22 deletions(-) create mode 100644 src/HtmlNegotiatorOptions.cs diff --git a/src/HtmlNegotiator.cs b/src/HtmlNegotiator.cs index aa55d2d..cedcff2 100644 --- a/src/HtmlNegotiator.cs +++ b/src/HtmlNegotiator.cs @@ -20,10 +20,21 @@ public class HtmlNegotiator : IResponseNegotiator 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) @@ -39,8 +50,14 @@ public async Task Handle(HttpRequest req, HttpResponse res, object model, Cancel 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( - ApplicationSettings.Get(), + appSettings.Settings, Formatting.Indented, new JsonSerializerSettings { 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/Models/ApplicationSettings.cs b/src/Models/ApplicationSettings.cs index 2071f8b..af523e7 100644 --- a/src/Models/ApplicationSettings.cs +++ b/src/Models/ApplicationSettings.cs @@ -1,32 +1,23 @@ namespace Linn.Common.Service.Models { + using System.Collections.Generic; + using Linn.Common.Configuration; public class ApplicationSettings { - public string CognitoHost { get; set; } - - public string AppRoot { get; set; } - - public string ProxyRoot { get; set; } - - public string CognitoClientId { get; set; } - - public string CognitoDomainPrefix { get; set; } - - public string EntraLogoutUri { get; set; } + public Dictionary Settings { get; } = new Dictionary(); - public static ApplicationSettings Get() + public static ApplicationSettings GetDefaults() { - return new ApplicationSettings - { - CognitoHost = ConfigurationManager.Configuration["COGNITO_HOST"], - AppRoot = ConfigurationManager.Configuration["APP_ROOT"], - ProxyRoot = ConfigurationManager.Configuration["PROXY_ROOT"], - CognitoClientId = ConfigurationManager.Configuration["COGNITO_CLIENT_ID"], - CognitoDomainPrefix = ConfigurationManager.Configuration["COGNITO_DOMAIN_PREFIX"], - EntraLogoutUri = ConfigurationManager.Configuration["ENTRA_LOGOUT_URI"] - }; + 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; } } } From c6c22fb45f9798d5b34fce9f8be7126aa2c54b4d Mon Sep 17 00:00:00 2001 From: Lewis Renfrew Date: Tue, 12 May 2026 14:57:22 +0100 Subject: [PATCH 3/5] gh actions --- .github/workflows/build-and-publish.yml | 45 +++++++++++++++++++++++++ .travis.yml | 10 ------ publish.sh | 27 --------------- test.sh | 5 --- 4 files changed, 45 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/build-and-publish.yml delete mode 100644 .travis.yml delete mode 100644 publish.sh delete mode 100644 test.sh diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml new file mode 100644 index 0000000..3a3f437 --- /dev/null +++ b/.github/workflows/build-and-publish.yml @@ -0,0 +1,45 @@ +name: Build and Publish + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +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/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/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 From 1ae62f49e80b473082227ccd64959540bdc3c8aa Mon Sep 17 00:00:00 2001 From: Lewis Renfrew Date: Tue, 12 May 2026 14:58:29 +0100 Subject: [PATCH 4/5] chlog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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. From 91417b320dff668ea56c08f5325363e29a35a882 Mon Sep 17 00:00:00 2001 From: Lewis Renfrew Date: Tue, 12 May 2026 14:59:20 +0100 Subject: [PATCH 5/5] tweak ga --- .github/workflows/build-and-publish.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 3a3f437..fe04e00 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -2,9 +2,7 @@ name: Build and Publish on: push: - branches: [ main ] pull_request: - branches: [ main ] jobs: build-and-publish: