Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/build-and-publish.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 0 additions & 10 deletions .travis.yml

This file was deleted.

8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<Task<T>> 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<StreamResponse> types during content negotiation and subsequent response writing.
Expand Down
27 changes: 0 additions & 27 deletions publish.sh

This file was deleted.

38 changes: 34 additions & 4 deletions src/Extensions/HttpResponseExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,41 @@
T model,
CancellationToken cancellationToken = default)
{
List<IResponseNegotiator> negotiators = response.HttpContext.RequestServices.GetServices<IResponseNegotiator>().ToList();
var negotiator = ResolveNegotiator(response);

return negotiator.Handle(
response.HttpContext.Request, response, model, cancellationToken);

Check warning on line 28 in src/Extensions/HttpResponseExtensions.cs

View workflow job for this annotation

GitHub Actions / build-and-publish

Possible null reference argument for parameter 'model' in 'Task IResponseNegotiator.Handle(HttpRequest req, HttpResponse res, object model, CancellationToken cancellationToken)'.

Check warning on line 28 in src/Extensions/HttpResponseExtensions.cs

View workflow job for this annotation

GitHub Actions / build-and-publish

Possible null reference argument for parameter 'model' in 'Task IResponseNegotiator.Handle(HttpRequest req, HttpResponse res, object model, CancellationToken cancellationToken)'.
}

public static async Task Negotiate<T>(
this HttpResponse response,
Func<Task<T>> dataFunc,
CancellationToken cancellationToken = default)
{
var negotiator = ResolveNegotiator(response);

if (negotiator is HtmlNegotiator)
{
await negotiator.Handle(
response.HttpContext.Request, response, null, cancellationToken);

Check warning on line 41 in src/Extensions/HttpResponseExtensions.cs

View workflow job for this annotation

GitHub Actions / build-and-publish

Cannot convert null literal to non-nullable reference type.

Check warning on line 41 in src/Extensions/HttpResponseExtensions.cs

View workflow job for this annotation

GitHub Actions / build-and-publish

Cannot convert null literal to non-nullable reference type.
return;
}

var model = await dataFunc();
await negotiator.Handle(
response.HttpContext.Request, response, model, cancellationToken);

Check warning on line 47 in src/Extensions/HttpResponseExtensions.cs

View workflow job for this annotation

GitHub Actions / build-and-publish

Possible null reference argument for parameter 'model' in 'Task IResponseNegotiator.Handle(HttpRequest req, HttpResponse res, object model, CancellationToken cancellationToken)'.

Check warning on line 47 in src/Extensions/HttpResponseExtensions.cs

View workflow job for this annotation

GitHub Actions / build-and-publish

Possible null reference argument for parameter 'model' in 'Task IResponseNegotiator.Handle(HttpRequest req, HttpResponse res, object model, CancellationToken cancellationToken)'.
}

private static IResponseNegotiator ResolveNegotiator(HttpResponse response)
{
var negotiators = response.HttpContext.RequestServices
.GetServices<IResponseNegotiator>().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);
Expand All @@ -46,8 +77,7 @@
x => x.CanHandle(new MediaTypeHeaderValue("application/json")));
}

return negotiator.Handle(
response.HttpContext.Request, response, model, cancellationToken);
return negotiator;
}

public static Task FromStream(
Expand Down
81 changes: 81 additions & 0 deletions src/HtmlNegotiator.cs
Original file line number Diff line number Diff line change
@@ -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"]

Check warning on line 70 in src/HtmlNegotiator.cs

View workflow job for this annotation

GitHub Actions / build-and-publish

Possible null reference assignment.
};

var compiled = this.templateEngine.Render(viewModel, view).Result;

res.ContentType = "text/html";
res.StatusCode = (int)HttpStatusCode.OK;

await res.WriteAsync(compiled, cancellationToken);
}
}
}
9 changes: 9 additions & 0 deletions src/HtmlNegotiatorOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Linn.Common.Service
{
using System.Collections.Generic;

public class HtmlNegotiatorOptions
{
public Dictionary<string, string> ExtraSettings { get; set; } = new Dictionary<string, string>();
}
}
7 changes: 7 additions & 0 deletions src/IViewLoader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Linn.Common.Service
{
public interface IViewLoader
{
string Load(string viewName);
}
}
6 changes: 4 additions & 2 deletions src/Linn.Common.Service.csproj
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>.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.</Description>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Linn.Common.Service</AssemblyName>
<PackageId>Linn.Common.Service</PackageId>
<Version>2.0.0</Version>
<Version>3.0.0</Version>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Linn.Common.Configuration" Version="3.0.0" />
<PackageReference Include="Linn.Common.Facade" Version="13.1.0" />
<PackageReference Include="Linn.Common.Rendering" Version="1.0.0" />
<PackageReference Include="Linn.Common.Reporting.Resources" Version="1.7.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="CsvHelper" Version="30.0.1" />
Expand Down
23 changes: 23 additions & 0 deletions src/Models/ApplicationSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Linn.Common.Service.Models
{
using System.Collections.Generic;

using Linn.Common.Configuration;

public class ApplicationSettings
{
public Dictionary<string, string> Settings { get; } = new Dictionary<string, string>();

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;
}
}
}
9 changes: 9 additions & 0 deletions src/Models/ViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Linn.Common.Service.Models
{
public class ViewModel
{
public string AppSettings { get; set; }

Check warning on line 5 in src/Models/ViewModel.cs

View workflow job for this annotation

GitHub Actions / build-and-publish

Non-nullable property 'AppSettings' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

public string BuildNumber { get; set; }
}
}
7 changes: 7 additions & 0 deletions src/Models/ViewResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Linn.Common.Service.Models
{
public class ViewResponse
{
public string ViewName { get; set; }

Check warning on line 5 in src/Models/ViewResponse.cs

View workflow job for this annotation

GitHub Actions / build-and-publish

Non-nullable property 'ViewName' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 5 in src/Models/ViewResponse.cs

View workflow job for this annotation

GitHub Actions / build-and-publish

Non-nullable property 'ViewName' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
}
}
37 changes: 37 additions & 0 deletions src/ViewLoader.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> loadedViews = new Dictionary<string, string>();

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;

Check warning on line 25 in src/ViewLoader.cs

View workflow job for this annotation

GitHub Actions / build-and-publish

Possible null reference return.

Check warning on line 25 in src/ViewLoader.cs

View workflow job for this annotation

GitHub Actions / build-and-publish

Possible null reference return.
}
}

var view = File.ReadAllText(viewPath);
this.loadedViews.Add(viewName, view);
}

return this.loadedViews[viewName];
}
}
}
}
5 changes: 0 additions & 5 deletions test.sh

This file was deleted.

7 changes: 7 additions & 0 deletions tests/Fake/Modules/WidgetModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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);
Expand Down
Loading
Loading