From fd65b8414058e9a2a8030dd163b8453cc6dee025 Mon Sep 17 00:00:00 2001 From: Creator-Solutions Date: Tue, 16 Jun 2026 21:36:54 +0200 Subject: [PATCH 1/2] fix: emit v3 Inertia protocol script tag instead of data-page div attribute @inertiajs/core v3 reads the initial page object from a termination + var escaped = data.Replace("/", "\\/"); - return new HtmlString($"
"); + return new HtmlString( + $"\n" + + $"
" + ); } public void Version(string? version) => _version = version; From 8a0687326734971feb25d8a71029d9bcd7ed227f Mon Sep 17 00:00:00 2001 From: Owen Burns Date: Sun, 21 Jun 2026 14:08:49 +0200 Subject: [PATCH 2/2] Local Codebase Updates feat: add Minimal API support via IResult on Response and LocationResult --- InertiaCore/Contracts/IInertia.cs | 105 +++++++++ InertiaCore/Extensions/Configure.cs | 45 +++- InertiaCore/Extensions/InertiaExtensions.cs | 20 +- InertiaCore/Inertia.cs | 100 ++++++++- InertiaCore/InertiaCore.csproj | 5 +- InertiaCore/Models/InertiaOptions.cs | 2 + InertiaCore/Props/DeferredProp.cs | 25 +++ InertiaCore/Response.cs | 172 ++++++++++++-- InertiaCore/ResponseFactory.cs | 209 +++++++++++++----- InertiaCore/Services/InertiaService.cs | 88 ++++++++ InertiaCore/Services/InertiaState.cs | 38 ++++ InertiaCore/Ssr/Gateway.cs | 31 ++- InertiaCore/Ssr/SsrResponse.cs | 2 +- InertiaCore/Utils/InertiaSharedProps.cs | 7 + InertiaCore/Utils/LocationResult.cs | 27 ++- InertiaCore/Utils/TaskExtensions.cs | 45 ++++ InertiaCore/Utils/Vite.cs | 169 +++++++++----- InertiaCoreTests/InertiaCoreTests.csproj | 7 +- InertiaCoreTests/Setup.cs | 23 +- InertiaCoreTests/UnitTestConfiguration.cs | 7 +- InertiaCoreTests/UnitTestDeferredProps.cs | 151 +++++++++++++ InertiaCoreTests/UnitTestErrorBag.cs | 145 ++++++++++++ InertiaCoreTests/UnitTestFlashProps.cs | 104 +++++++++ InertiaCoreTests/UnitTestInertiaService.cs | 128 +++++++++++ InertiaCoreTests/UnitTestMinimalApi.cs | 199 +++++++++++++++++ InertiaCoreTests/UnitTestModelState.cs | 2 +- InertiaCoreTests/UnitTestSharedData.cs | 8 +- InertiaCoreTests/UnitTestTaskExtensions.cs | 63 ++++++ InertiaCoreTests/UnitTestVersionConflict.cs | 207 +++++++++++++++++ InertiaCoreTests/UnitTestVite.cs | 36 +-- .../AspNetCore.InertiaCore.0.0.9.nupkg | Bin 0 -> 54973 bytes dotnet-tools.json | 5 + 32 files changed, 1962 insertions(+), 213 deletions(-) create mode 100644 InertiaCore/Contracts/IInertia.cs create mode 100644 InertiaCore/Props/DeferredProp.cs create mode 100644 InertiaCore/Services/InertiaService.cs create mode 100644 InertiaCore/Services/InertiaState.cs create mode 100644 InertiaCore/Utils/TaskExtensions.cs create mode 100644 InertiaCoreTests/UnitTestDeferredProps.cs create mode 100644 InertiaCoreTests/UnitTestErrorBag.cs create mode 100644 InertiaCoreTests/UnitTestFlashProps.cs create mode 100644 InertiaCoreTests/UnitTestInertiaService.cs create mode 100644 InertiaCoreTests/UnitTestMinimalApi.cs create mode 100644 InertiaCoreTests/UnitTestTaskExtensions.cs create mode 100644 InertiaCoreTests/UnitTestVersionConflict.cs create mode 100644 ProjectsLocal/AspNetCore.InertiaCore.0.0.9.nupkg create mode 100644 dotnet-tools.json diff --git a/InertiaCore/Contracts/IInertia.cs b/InertiaCore/Contracts/IInertia.cs new file mode 100644 index 0000000..1317123 --- /dev/null +++ b/InertiaCore/Contracts/IInertia.cs @@ -0,0 +1,105 @@ +using InertiaCore.Props; +using Microsoft.AspNetCore.Mvc; + +namespace InertiaCore.Contracts; + +/// +/// Main Inertia.js service interface for server-side adapter operations. +/// Inject this interface into your controllers and services via constructor injection. +/// +public interface IInertia +{ + /// + /// Render an Inertia page component with the given props. + /// + /// The JavaScript component name (e.g. "Admin/Users"). + /// Optional props object, anonymous type, or dictionary. + /// An that returns either a JSON envelope or a full page view. + Task Render(string component, object? props = null); + + /// + /// Return an external redirect via an Inertia Location response (409 + X-Inertia-Location). + /// + IActionResult Location(string url); + + /// + /// Share a value across all Inertia responses for the current request. + /// + /// The prop key (will be camelCased). + /// The value to share. + void Share(string key, object? value); + + /// + /// Share multiple values across all Inertia responses for the current request. + /// + void Share(IDictionary data); + + /// + /// Share a flash prop that will only be available for the current request. + /// Flash props are merged after shared props but before component props. + /// + void Flash(string key, object? value); + + /// + /// Set the asset version to a fixed string. + /// + void Version(string? version); + + /// + /// Set the asset version to a lazily-evaluated function. + /// + void Version(Func versionFunc); + + /// + /// Get the currently configured asset version. + /// + string? GetVersion(); + + /// + /// Create an AlwaysProp that will always be included in all responses, + /// even during partial reloads where its key would otherwise be excluded. + /// + AlwaysProp Always(object? value); + + /// + /// Create an AlwaysProp with a synchronous factory. + /// + AlwaysProp Always(Func callback); + + /// + /// Create an AlwaysProp with an asynchronous factory. + /// + AlwaysProp Always(Func> callback); + + /// + /// Create a LazyProp that is excluded from the initial page load + /// and only resolved on subsequent partial reloads. + /// + LazyProp Lazy(Func callback); + + /// + /// Create a LazyProp with an asynchronous factory. + /// + LazyProp Lazy(Func> callback); + + /// + /// Create a DeferredProp that is excluded from the initial full page load + /// and resolved only when explicitly requested via X-Inertia-Partial-Data. + /// + DeferredProp Defer(Func factory); + + /// + /// Create a DeferredProp with an asynchronous factory. + /// + DeferredProp Defer(Func> factory); + + /// + /// Set whether to encrypt history state on the client side. + /// + void EncryptHistory(bool encrypt = true); + + /// + /// Set whether to clear history state on the client side. + /// + void ClearHistory(bool clear = true); +} diff --git a/InertiaCore/Extensions/Configure.cs b/InertiaCore/Extensions/Configure.cs index 867a3fa..7a4419d 100644 --- a/InertiaCore/Extensions/Configure.cs +++ b/InertiaCore/Extensions/Configure.cs @@ -1,5 +1,7 @@ using System.Net; +using InertiaCore.Contracts; using InertiaCore.Models; +using InertiaCore.Services; using InertiaCore.Ssr; using InertiaCore.Utils; using Microsoft.AspNetCore.Builder; @@ -7,15 +9,23 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace InertiaCore.Extensions; public static class Configure { +#pragma warning disable CS0618 // Internal usage of the deprecated static facade is intentional public static IApplicationBuilder UseInertia(this IApplicationBuilder app) { - var factory = app.ApplicationServices.GetRequiredService(); - Inertia.UseFactory(factory); + // IResponseFactory is scoped (shares InertiaState with the per-request IInertia). + // We create a scope here to resolve it without triggering the "scoped from root" + // validation. The scope lives for the app lifetime since the factory is held + // by the static Inertia facade. + var startupScope = app.ApplicationServices.CreateScope(); + var factory = startupScope.ServiceProvider.GetRequiredService(); + var contextAccessor = app.ApplicationServices.GetRequiredService(); + Inertia.UseFactory(factory, contextAccessor); var viteBuilder = app.ApplicationServices.GetService(); if (viteBuilder != null) @@ -26,9 +36,13 @@ public static IApplicationBuilder UseInertia(this IApplicationBuilder app) app.Use(async (context, next) => { - if (context.IsInertiaRequest() - && context.Request.Method == "GET" - && context.Request.Headers[InertiaHeader.Version] != Inertia.GetVersion()) + // Per the Inertia.js protocol, asset version conflicts must be reported + // on ALL Inertia requests, not just GET. + // See: https://inertiajs.com/the-protocol#asset-versioning + var serverVersion = Inertia.GetVersion(); + if (serverVersion != null + && context.IsInertiaRequest() + && context.Request.Headers[InertiaHeader.Version] != serverVersion) { await OnVersionChange(context, app); return; @@ -46,7 +60,25 @@ public static IServiceCollection AddInertia(this IServiceCollection services, services.AddHttpContextAccessor(); services.AddHttpClient(); - services.AddSingleton(); + // Per-request state container — seed Version from InertiaOptions so that + // the asset version is available consistently across the middleware check + // and response body without requiring middleware wiring or static facade calls. + services.AddScoped(sp => + { + var version = sp.GetRequiredService>().Value.Version; + return new InertiaState { Version = version }; + }); + + // InertiaService is the public API that consumers should inject via IInertia. + // Registered as Scoped — one instance per HTTP request. + services.AddScoped(); + + // Internal factory — scoped so it shares the same per-request InertiaState + // as InertiaService, ensuring Share(), Flash(), and Version calls via IInertia + // are visible to the response pipeline. + services.AddScoped(); + + // Gateway is safe as Singleton since IHttpClientFactory manages HttpClient lifetimes. services.AddSingleton(); services.Configure(mvcOptions => { mvcOptions.Filters.Add(); }); @@ -78,3 +110,4 @@ private static async Task OnVersionChange(HttpContext context, IApplicationBuild await context.Response.CompleteAsync(); } } +#pragma warning restore CS0618 diff --git a/InertiaCore/Extensions/InertiaExtensions.cs b/InertiaCore/Extensions/InertiaExtensions.cs index 192b424..cc5ecad 100644 --- a/InertiaCore/Extensions/InertiaExtensions.cs +++ b/InertiaCore/Extensions/InertiaExtensions.cs @@ -32,23 +32,21 @@ internal static bool Override(this IDictionary dicti return true; } - internal static Task ResolveAsync(this Func func) + internal static async Task ResolveAsync(this Func func) { - var rt = func.Method.ReturnType; + var result = func(); - if (!rt.IsGenericType || rt.GetGenericTypeDefinition() != typeof(Task<>)) - return Task.Run(func.Invoke); + if (result is Task task) + { + return await task.UnwrapResultAsync(); + } - var task = func.DynamicInvoke() as Task; - return task!.ResolveResult(); + return result; } - internal static async Task ResolveResult(this Task task) + internal static Task ResolveResult(this Task task) { - await task.ConfigureAwait(false); - var result = task.GetType().GetProperty("Result"); - - return result?.GetValue(task); + return task.UnwrapResultAsync(); } internal static string MD5(this string s) diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs index d13b932..32ff5ef 100644 --- a/InertiaCore/Inertia.cs +++ b/InertiaCore/Inertia.cs @@ -1,43 +1,137 @@ using System.Runtime.CompilerServices; +using InertiaCore.Contracts; using InertiaCore.Props; using InertiaCore.Utils; using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Http; [assembly: InternalsVisibleTo("InertiaCoreTests")] namespace InertiaCore; +/// +/// Static facade for Inertia.js operations. +/// +/// This class is deprecated. Inject via constructor injection +/// instead. This static facade will be removed in a future major version. +/// +/// +/// public class MyController : Controller +/// { +/// private readonly IInertia _inertia; +/// public MyController(IInertia inertia) => _inertia = inertia; +/// public IActionResult Index() => await _inertia.Render("Page", new { }); +/// } +/// +/// +[Obsolete("The static Inertia facade is deprecated. Inject IInertia via constructor injection instead.")] public static class Inertia { private static IResponseFactory _factory = default!; - - internal static void UseFactory(IResponseFactory factory) => _factory = factory; - + private static IHttpContextAccessor? _contextAccessor; + + internal static void UseFactory(IResponseFactory factory, IHttpContextAccessor? contextAccessor = null) + { + _factory = factory; + _contextAccessor = contextAccessor; + } + + private static IInertia ResolveService() + { + if (_contextAccessor?.HttpContext != null) + { + var service = _contextAccessor.HttpContext.RequestServices.GetService(typeof(IInertia)); + if (service is IInertia inertia) + return inertia; + } + + // Fallback: wrap the internal factory + var state = new Services.InertiaState(); + var options = Microsoft.Extensions.Options.Options.Create(new Models.InertiaOptions()); + return new Services.InertiaService(_factory, state, options); + } + + /// + /// Render an Inertia page component. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static Response Render(string component, object? props = null) => _factory.Render(component, props); + /// + /// Render the SSR head content. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static Task Head(dynamic model) => _factory.Head(model); + /// + /// Render the SSR body / non-SSR placeholder HTML. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static Task Html(dynamic model) => _factory.Html(model); + /// + /// Set the asset version to a fixed string. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static void Version(string? version) => _factory.Version(version); + /// + /// Set the asset version via a lazily-evaluated function. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static void Version(Func version) => _factory.Version(version); + /// + /// Get the currently configured asset version. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static string? GetVersion() => _factory.GetVersion(); + /// + /// Return an external redirect via Inertia Location response. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static LocationResult Location(string url) => _factory.Location(url); + /// + /// Share a value across all Inertia responses for the current request. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static void Share(string key, object? value) => _factory.Share(key, value); + /// + /// Share multiple values across all Inertia responses for the current request. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static void Share(IDictionary data) => _factory.Share(data); + /// + /// Create an AlwaysProp. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static AlwaysProp Always(string value) => _factory.Always(value); + /// + /// Create an AlwaysProp with a synchronous callback. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static AlwaysProp Always(Func callback) => _factory.Always(callback); + /// + /// Create an AlwaysProp with an asynchronous callback. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static AlwaysProp Always(Func> callback) => _factory.Always(callback); + /// + /// Create a LazyProp. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static LazyProp Lazy(Func callback) => _factory.Lazy(callback); + /// + /// Create a LazyProp with an asynchronous callback. + /// + [Obsolete("Inject IInertia via constructor injection instead.")] public static LazyProp Lazy(Func> callback) => _factory.Lazy(callback); } diff --git a/InertiaCore/InertiaCore.csproj b/InertiaCore/InertiaCore.csproj index 5f68508..50d79b1 100644 --- a/InertiaCore/InertiaCore.csproj +++ b/InertiaCore/InertiaCore.csproj @@ -2,8 +2,8 @@ enable enable - 0.0.9 - net6.0;net7.0;net8.0;net9.0 + 0.0.10 + net8.0;net9.0;net10.0 AspNetCore.InertiaCore kapi2289 Inertia.js ASP.NET Adapter. https://inertiajs.com/ @@ -22,7 +22,6 @@ - diff --git a/InertiaCore/Models/InertiaOptions.cs b/InertiaCore/Models/InertiaOptions.cs index cc5de44..6e6b146 100644 --- a/InertiaCore/Models/InertiaOptions.cs +++ b/InertiaCore/Models/InertiaOptions.cs @@ -7,4 +7,6 @@ public class InertiaOptions public bool SsrEnabled { get; set; } = false; public string SsrUrl { get; set; } = "http://127.0.0.1:13714/render"; public bool EncryptHistory { get; set; } = false; + + public string? Version { get; set; } } diff --git a/InertiaCore/Props/DeferredProp.cs b/InertiaCore/Props/DeferredProp.cs new file mode 100644 index 0000000..faf7561 --- /dev/null +++ b/InertiaCore/Props/DeferredProp.cs @@ -0,0 +1,25 @@ +using InertiaCore.Extensions; + +namespace InertiaCore.Props; + +/// +/// A deferred prop that is excluded from the initial full page load +/// and resolved only when explicitly requested via X-Inertia-Partial-Data. +/// See: https://inertiajs.com/deferred-props +/// +public class DeferredProp : InvokableProp +{ + /// + /// Creates a deferred prop with a synchronous factory. + /// + internal DeferredProp(Func value) : base(value) + { + } + + /// + /// Creates a deferred prop with an asynchronous factory. + /// + internal DeferredProp(Func> value) : base(value) + { + } +} diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 7bdd2cb..1ebc132 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -1,16 +1,19 @@ using System.Text.Json; -using System.Text.Json.Serialization; using InertiaCore.Extensions; using InertiaCore.Models; using InertiaCore.Props; +using InertiaCore.Services; using InertiaCore.Utils; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Routing; namespace InertiaCore; -public class Response : IActionResult +public class Response : IActionResult, IResult { private readonly string _component; private readonly Dictionary _props; @@ -18,19 +21,89 @@ public class Response : IActionResult private readonly string? _version; private readonly bool _encryptHistory; private readonly bool _clearHistory; + private readonly InertiaState _state; + private readonly JsonSerializerOptions _jsonOptions; private ActionContext? _context; private Page? _page; private IDictionary? _viewData; - internal Response(string component, Dictionary props, string rootView, string? version, bool encryptHistory, bool clearHistory) - => (_component, _props, _rootView, _version, _encryptHistory, _clearHistory) = (component, props, rootView, version, encryptHistory, clearHistory); + internal Response( + string component, + Dictionary props, + string rootView, + string? version, + bool encryptHistory, + bool clearHistory, + InertiaState state, + JsonSerializerOptions jsonOptions) + { + _component = component; + _props = props; + _rootView = rootView; + _version = version; + _encryptHistory = encryptHistory; + _clearHistory = clearHistory; + _state = state ?? throw new ArgumentNullException(nameof(state)); + _jsonOptions = jsonOptions ?? throw new ArgumentNullException(nameof(jsonOptions)); + } - public async Task ExecuteResultAsync(ActionContext context) + /// + /// MVC / controller path — ActionContext is provided directly by the MVC pipeline. + /// + async Task IActionResult.ExecuteResultAsync(ActionContext context) + { + _context = context; + await ProcessResponse(); + await GetResult().ExecuteResultAsync(_context); + } + + /// + /// Minimal API path — wraps the HttpContext in a minimal ActionContext so the + /// existing processing pipeline works without modification. + /// + /// For Inertia requests we write JSON directly to the response rather than going + /// through , which requires + /// RequestServices to be populated (not guaranteed in Minimal API hosts or + /// unit tests using DefaultHttpContext). + /// + /// + /// For non-Inertia (full page) requests we fall back to + /// — the view engine is always available + /// in a real host, and this path is never reached in unit tests since the view result + /// type is asserted without executing it. + /// + /// + async Task IResult.ExecuteAsync(HttpContext httpContext) { - SetContext(context); + var routeData = httpContext.GetRouteData() ?? new RouteData(); + _context = new ActionContext( + httpContext, + routeData, + new ActionDescriptor(), + new ModelStateDictionary() + ); + await ProcessResponse(); - await GetResult().ExecuteResultAsync(_context!); + + if (_context.IsInertiaRequest()) + { + // Set headers via the shared GetJson() path so protocol behaviour + // (X-Inertia, Vary, status 200) is identical to the MVC path. + var jsonResult = GetJson(); + + httpContext.Response.ContentType = "application/json"; + var json = JsonSerializer.Serialize(jsonResult.Value, _jsonOptions); + await httpContext.Response.WriteAsync(json); + } + else + { + // Full page load — delegate to MVC ViewResult which needs the view engine. + // In a real Minimal API host, AddControllersWithViews / AddRazorPages will + // have registered the required services. Unit tests assert on GetResult() + // directly and never reach this branch via ExecuteAsync. + await GetView().ExecuteResultAsync(_context); + } } protected internal async Task ProcessResponse() @@ -60,6 +133,7 @@ protected internal async Task ProcessResponse() var props = _props; props = ResolveSharedProps(props); + props = ResolveFlashProps(props); props = ResolvePartialProperties(props); props = ResolveAlways(props); props = await ResolvePropertyInstances(props); @@ -68,15 +142,40 @@ protected internal async Task ProcessResponse() } /// - /// Resolve `shared` props stored in the current request context. + /// Resolve `shared` props stored in InertiaState (migrated from HttpContext.Features). /// private Dictionary ResolveSharedProps(Dictionary props) { - var shared = _context!.HttpContext.Features.Get(); - if (shared != null) - props = shared.GetMerged(props); + if (_state.SharedProps.Count == 0) + return props; - return props; + var result = new Dictionary(props.Count + _state.SharedProps.Count); + foreach (var (key, value) in _state.SharedProps) + result[key] = value; + + foreach (var (key, value) in props) + result[key] = value; + + return result; + } + + /// + /// Resolve flash props stored in InertiaState. + /// Flash props are merged after shared props but before component props. + /// + private Dictionary ResolveFlashProps(Dictionary props) + { + if (_state.FlashProps.Count == 0) + return props; + + var result = new Dictionary(props.Count + _state.FlashProps.Count); + foreach (var (key, value) in props) + result[key] = value; + + foreach (var (key, value) in _state.FlashProps) + result[key] = value; + + return result; } /// @@ -88,7 +187,7 @@ protected internal async Task ProcessResponse() if (!isPartial) return props - .Where(kv => kv.Value is not LazyProp) + .Where(kv => kv.Value is not LazyProp and not DeferredProp) .ToDictionary(kv => kv.Key, kv => kv.Value); props = props.ToDictionary(kv => kv.Key, kv => kv.Value); @@ -133,7 +232,8 @@ protected internal async Task ProcessResponse() } /// - /// Resolve `always` properties that should always be included on all visits, regardless of "only" or "except" requests. + /// Resolve `always` properties that should always be included on all visits, + /// regardless of "only" or "except" requests. /// private Dictionary ResolveAlways(Dictionary props) { @@ -174,13 +274,17 @@ protected internal JsonResult GetJson() { _context!.HttpContext.Response.Headers.Override(InertiaHeader.Inertia, "true"); _context!.HttpContext.Response.Headers.Override("Vary", InertiaHeader.Inertia); + + // Per the Inertia.js protocol, partial reload responses should include + // Vary: X-Inertia-Partial-Data so that CDNs differentiate between full + // page responses and partial reload responses for caching purposes. + // See: https://inertiajs.com/the-protocol#asset-versioning + if (_context!.IsInertiaPartialComponent(_component)) + _context.HttpContext.Response.Headers["Vary"] += ", " + InertiaHeader.PartialOnly; + _context!.HttpContext.Response.StatusCode = 200; - return new JsonResult(_page, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - ReferenceHandler = ReferenceHandler.IgnoreCycles - }); + return new JsonResult(_page, _jsonOptions); } private ViewResult GetView() @@ -200,13 +304,35 @@ private ViewResult GetView() protected internal IActionResult GetResult() => _context!.IsInertiaRequest() ? GetJson() : GetView(); - private Dictionary GetErrors() + /// + /// Gets validation errors from ModelState. + /// If the X-Inertia-Error-Bag header is present, errors are scoped under a named bag. + /// See: https://inertiajs.com/error-handling#error-bags + /// + private Dictionary GetErrors() { if (!_context!.ModelState.IsValid) - return _context!.ModelState.ToDictionary(o => o.Key.ToCamelCase(), + { + var errors = _context!.ModelState.ToDictionary( + o => o.Key.ToCamelCase(), o => o.Value?.Errors.FirstOrDefault()?.ErrorMessage ?? ""); - return new Dictionary(0); + // X-Inertia-Error-Bag scopes validation errors to a named bag. + var errorBag = _context.HttpContext.Request.Headers[InertiaHeader.ErrorBag].ToString(); + + if (!string.IsNullOrEmpty(errorBag)) + { + return new Dictionary + { + [errorBag] = errors + }; + } + + return new Dictionary(errors + .ToDictionary(kv => kv.Key, kv => (object?)kv.Value)); + } + + return new Dictionary(); } protected internal void SetContext(ActionContext context) => _context = context; @@ -218,4 +344,4 @@ public Response WithViewData(IDictionary viewData) _viewData = viewData; return this; } -} +} \ No newline at end of file diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index 34f2243..9d28a99 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -3,32 +3,39 @@ using System.Text.Json.Serialization; using InertiaCore.Models; using InertiaCore.Props; +using InertiaCore.Services; using InertiaCore.Ssr; +using InertiaCore.Extensions; using InertiaCore.Utils; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; namespace InertiaCore; -internal interface IResponseFactory +/// +/// Internal factory contract for creating Inertia responses. +/// Consumers should prefer IInertia over this interface. +/// +public interface IResponseFactory { - public Response Render(string component, object? props = null); - public Task Head(dynamic model); - public Task Html(dynamic model); - public void Version(string? version); - public void Version(Func version); - public string? GetVersion(); - public LocationResult Location(string url); - public void Share(string key, object? value); - public void Share(IDictionary data); - public void ClearHistory(bool clear = true); - public void EncryptHistory(bool encrypt = true); - public AlwaysProp Always(object? value); - public AlwaysProp Always(Func callback); - public AlwaysProp Always(Func> callback); - public LazyProp Lazy(Func callback); - public LazyProp Lazy(Func> callback); + Response Render(string component, object? props = null); + Task Head(object model); + Task Html(object model); + void Version(string? version); + void Version(Func version); + string? GetVersion(); + LocationResult Location(string url); + void Share(string key, object? value); + void Share(IDictionary data); + void ClearHistory(bool clear = true); + void EncryptHistory(bool encrypt = true); + AlwaysProp Always(object? value); + AlwaysProp Always(Func callback); + AlwaysProp Always(Func> callback); + LazyProp Lazy(Func callback); + LazyProp Lazy(Func> callback); } internal class ResponseFactory : IResponseFactory @@ -36,13 +43,33 @@ internal class ResponseFactory : IResponseFactory private readonly IHttpContextAccessor _contextAccessor; private readonly IGateway _gateway; private readonly IOptions _options; + private readonly InertiaState _state; + private readonly JsonSerializerOptions _jsonOptions; - private object? _version; private bool _clearHistory; private bool? _encryptHistory; - public ResponseFactory(IHttpContextAccessor contextAccessor, IGateway gateway, IOptions options) => - (_contextAccessor, _gateway, _options) = (contextAccessor, gateway, options); + public ResponseFactory( + IHttpContextAccessor contextAccessor, + IGateway gateway, + IOptions options, + InertiaState state, + IOptions jsonOptions) + { + _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor)); + _gateway = gateway ?? throw new ArgumentNullException(nameof(gateway)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _state = state ?? throw new ArgumentNullException(nameof(state)); + + // Start from the application's configured JsonSerializerOptions, then ensure + // camelCase and cycle-handling defaults that Inertia.js requires. + JsonSerializerOptions hostOptions = jsonOptions?.Value?.JsonSerializerOptions ?? new JsonSerializerOptions(); + _jsonOptions = new JsonSerializerOptions(hostOptions) + { + PropertyNamingPolicy = hostOptions.PropertyNamingPolicy ?? JsonNamingPolicy.CamelCase, + ReferenceHandler = hostOptions.ReferenceHandler ?? ReferenceHandler.IgnoreCycles + }; + } public Response Render(string component, object? props = null) { @@ -50,51 +77,59 @@ public Response Render(string component, object? props = null) var dictProps = props switch { Dictionary dict => dict, - _ => props.GetType().GetProperties() - .ToDictionary(o => o.Name, o => o.GetValue(props)) + _ => SerializePropsToDictionary(props) }; - return new Response(component, dictProps, _options.Value.RootView, GetVersion(), _encryptHistory ?? _options.Value.EncryptHistory, _clearHistory); + return new Response( + component, + dictProps, + _options.Value.RootView, + GetVersion(), + _encryptHistory ?? _options.Value.EncryptHistory, + _clearHistory, + _state, + _jsonOptions); } + + public IResult RenderResult(string component, object? props = null) + => (IResult)Render(component, props); - public async Task Head(dynamic model) + public async Task Head(object model) { - if (!_options.Value.SsrEnabled) return new HtmlString(""); + if (!_options.Value.SsrEnabled) + return new HtmlString(""); var context = _contextAccessor.HttpContext!; - var response = context.Features.Get(); + // Migrated from HttpContext.Features — see InertiaState + var response = _state.SsrResponse; response ??= await _gateway.Dispatch(model, _options.Value.SsrUrl); - if (response == null) return new HtmlString(""); + if (response == null) + return new HtmlString(""); - context.Features.Set(response); + _state.SsrResponse = response; return response.GetHead(); } - public async Task Html(dynamic model) + public async Task Html(object model) { if (_options.Value.SsrEnabled) { var context = _contextAccessor.HttpContext!; - var response = context.Features.Get(); + // Migrated from HttpContext.Features — see InertiaState + var response = _state.SsrResponse; response ??= await _gateway.Dispatch(model, _options.Value.SsrUrl); if (response != null) { - context.Features.Set(response); + _state.SsrResponse = response; return response.GetBody(); } } - var data = JsonSerializer.Serialize(model, - new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - ReferenceHandler = ReferenceHandler.IgnoreCycles - }); - + var data = JsonSerializer.Serialize(model, _jsonOptions); // v3 protocol: escape forward slashes to avoid premature termination var escaped = data.Replace("/", "\\/"); @@ -104,39 +139,28 @@ public async Task Html(dynamic model) ); } - public void Version(string? version) => _version = version; + public void Version(string? version) => _state.Version = version; - public void Version(Func version) => _version = version; + public void Version(Func version) => _state.VersionFunc = version; - public string? GetVersion() => _version switch - { - Func func => func.Invoke(), - string s => s, - _ => null, - }; + public string? GetVersion() => + _state.VersionFunc is not null + ? _state.VersionFunc.Invoke() + : _state.Version; public LocationResult Location(string url) => new(url); public void Share(string key, object? value) { - var context = _contextAccessor.HttpContext!; - - var sharedData = context.Features.Get(); - sharedData ??= new InertiaSharedProps(); - sharedData.Set(key, value); - - context.Features.Set(sharedData); + _state.SharedProps[key.ToCamelCase()] = value; } public void Share(IDictionary data) { - var context = _contextAccessor.HttpContext!; - - var sharedData = context.Features.Get(); - sharedData ??= new InertiaSharedProps(); - sharedData.Merge(data); - - context.Features.Set(sharedData); + foreach (var (key, value) in data) + { + _state.SharedProps[key.ToCamelCase()] = value; + } } public void ClearHistory(bool clear = true) => _clearHistory = clear; @@ -148,4 +172,69 @@ public void Share(IDictionary data) public AlwaysProp Always(object? value) => new(value); public AlwaysProp Always(Func callback) => new(callback); public AlwaysProp Always(Func> callback) => new(callback); + + /// + /// Converts an arbitrary props object to a dictionary. + /// Prefers JSON round-trip for AOT compatibility, with reflection fallback + /// for objects that contain delegates or other non-serializable members. + /// + private Dictionary SerializePropsToDictionary(object props) + { + if (props is IDictionary dict) + return new Dictionary(dict); + + var reflectedProps = props.GetType().GetProperties(); + + // If any property value is an InvokableProp (DeferredProp, LazyProp, AlwaysProp), + // the JSON round-trip path cannot be used because it serializes these as {} + // and destroys type information needed for downstream type checks. + if (reflectedProps.Any(p => p.GetValue(props) is InvokableProp)) + return reflectedProps.ToDictionary(o => o.Name, o => o.GetValue(props)); + + // First attempt: JSON round-trip — AOT-compatible and respects serializer options. + try + { + var json = JsonSerializer.Serialize(props, _jsonOptions); + var document = JsonSerializer.Deserialize(json, _jsonOptions); + + if (document != null) + return JsonElementToDictionary(document.RootElement); + } + catch (NotSupportedException) + { + // JSON serialization of delegates is not supported. + // Fall through to reflection-based conversion below. + } + + // Fallback: reflection-based property enumeration for objects containing delegates + // or other types that cannot be JSON-serialized (e.g. Func<>, Task, LazyProp). + return reflectedProps.ToDictionary(o => o.Name, o => o.GetValue(props)); + } + + private static Dictionary JsonElementToDictionary(JsonElement element) + { + var result = new Dictionary(); + + foreach (var property in element.EnumerateObject()) + { + result[property.Name] = JsonElementToValue(property.Value); + } + + return result; + } + + private static object? JsonElementToValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Object => JsonElementToDictionary(element), + JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToValue).ToList(), + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.GetRawText() + }; + } } diff --git a/InertiaCore/Services/InertiaService.cs b/InertiaCore/Services/InertiaService.cs new file mode 100644 index 0000000..c4ddb4b --- /dev/null +++ b/InertiaCore/Services/InertiaService.cs @@ -0,0 +1,88 @@ +using InertiaCore.Contracts; +using InertiaCore.Extensions; +using InertiaCore.Models; +using InertiaCore.Props; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace InertiaCore.Services; + +/// +/// Scoped implementation of that delegates all operations +/// to the internal and . +/// +public sealed class InertiaService : IInertia +{ + private readonly IResponseFactory _factory; + private readonly InertiaState _state; + private readonly IOptions _options; + + /// + /// Initializes a new instance of . + /// + public InertiaService(IResponseFactory factory, InertiaState state, IOptions options) + { + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + _state = state ?? throw new ArgumentNullException(nameof(state)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public Task Render(string component, object? props = null) + { + Response response = _factory.Render(component, props); + return Task.FromResult(response); + } + + /// + public IActionResult Location(string url) => _factory.Location(url); + + /// + public void Share(string key, object? value) => _factory.Share(key, value); + + /// + public void Share(IDictionary data) => _factory.Share(data); + + /// + public void Flash(string key, object? value) + { + _state.FlashProps[key.ToCamelCase()] = value; + } + + /// + public void Version(string? version) => _factory.Version(version); + + /// + public void Version(Func versionFunc) => _factory.Version(versionFunc); + + /// + public string? GetVersion() => _factory.GetVersion(); + + /// + public AlwaysProp Always(object? value) => _factory.Always(value); + + /// + public AlwaysProp Always(Func callback) => _factory.Always(callback); + + /// + public AlwaysProp Always(Func> callback) => _factory.Always(callback); + + /// + public LazyProp Lazy(Func callback) => _factory.Lazy(callback); + + /// + public LazyProp Lazy(Func> callback) => _factory.Lazy(callback); + + /// + public DeferredProp Defer(Func factory) => new(factory); + + /// + public DeferredProp Defer(Func> factory) => new(factory); + + /// + public void EncryptHistory(bool encrypt = true) => _factory.EncryptHistory(encrypt); + + /// + public void ClearHistory(bool clear = true) => _factory.ClearHistory(clear); + +} diff --git a/InertiaCore/Services/InertiaState.cs b/InertiaCore/Services/InertiaState.cs new file mode 100644 index 0000000..d567395 --- /dev/null +++ b/InertiaCore/Services/InertiaState.cs @@ -0,0 +1,38 @@ +using InertiaCore.Ssr; + +namespace InertiaCore.Services; + +/// +/// Per-request state container for Inertia.js operations. +/// Registered as Scoped — one instance per HTTP request. +/// +public sealed class InertiaState +{ + /// + /// Shared props set via IInertia.Share(). + /// Persisted for the entire request lifetime. + /// + public Dictionary SharedProps { get; } = new(); + + /// + /// Flash props set via IInertia.Flash(). + /// Available only for the current request (single-request scope). + /// + public Dictionary FlashProps { get; } = new(); + + /// + /// Fixed asset version string. + /// + public string? Version { get; set; } + + /// + /// Lazy asset version factory. + /// + public Func? VersionFunc { get; set; } + + /// + /// Cached SSR response, set after the first SSR dispatch in a request. + /// Migrated from HttpContext.Features — see InertiaState. + /// + public SsrResponse? SsrResponse { get; set; } +} diff --git a/InertiaCore/Ssr/Gateway.cs b/InertiaCore/Ssr/Gateway.cs index 1878c52..b64bd5e 100644 --- a/InertiaCore/Ssr/Gateway.cs +++ b/InertiaCore/Ssr/Gateway.cs @@ -2,6 +2,8 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; namespace InertiaCore.Ssr; @@ -13,21 +15,32 @@ internal interface IGateway internal class Gateway : IGateway { private readonly IHttpClientFactory _httpClientFactory; + private readonly JsonSerializerOptions _jsonOptions; - public Gateway(IHttpClientFactory httpClientFactory) => _httpClientFactory = httpClientFactory; + public Gateway(IHttpClientFactory httpClientFactory) + : this(httpClientFactory, null) + { + } + + public Gateway(IHttpClientFactory httpClientFactory, IOptions? jsonOptions) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + + JsonSerializerOptions hostOptions = jsonOptions?.Value?.JsonSerializerOptions ?? new JsonSerializerOptions(); + _jsonOptions = new JsonSerializerOptions(hostOptions) + { + PropertyNamingPolicy = hostOptions.PropertyNamingPolicy ?? JsonNamingPolicy.CamelCase, + ReferenceHandler = hostOptions.ReferenceHandler ?? ReferenceHandler.IgnoreCycles + }; + } - public async Task Dispatch(dynamic model, string url) + public async Task Dispatch(object model, string url) { - var json = JsonSerializer.Serialize(model, - new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - ReferenceHandler = ReferenceHandler.IgnoreCycles - }); + var json = JsonSerializer.Serialize(model, _jsonOptions); var content = new StringContent(json.ToString(), Encoding.UTF8, "application/json"); var client = _httpClientFactory.CreateClient(); var response = await client.PostAsync(url, content); - return await response.Content.ReadFromJsonAsync(); + return await response.Content.ReadFromJsonAsync(_jsonOptions); } } diff --git a/InertiaCore/Ssr/SsrResponse.cs b/InertiaCore/Ssr/SsrResponse.cs index b5c8094..e859dfc 100644 --- a/InertiaCore/Ssr/SsrResponse.cs +++ b/InertiaCore/Ssr/SsrResponse.cs @@ -2,7 +2,7 @@ namespace InertiaCore.Ssr; -internal class SsrResponse +public class SsrResponse { public List Head { get; set; } = default!; public string Body { get; set; } = default!; diff --git a/InertiaCore/Utils/InertiaSharedProps.cs b/InertiaCore/Utils/InertiaSharedProps.cs index 68b9bbd..b867bd2 100644 --- a/InertiaCore/Utils/InertiaSharedProps.cs +++ b/InertiaCore/Utils/InertiaSharedProps.cs @@ -2,6 +2,13 @@ namespace InertiaCore.Utils; +/// +/// Stores shared Inertia props per-request. +/// +/// This class is deprecated. Use InertiaCore.Services.InertiaState instead. +/// +/// +[Obsolete("Use InertiaCore.Services.InertiaState instead.")] internal class InertiaSharedProps { private IDictionary? Data { get; set; } diff --git a/InertiaCore/Utils/LocationResult.cs b/InertiaCore/Utils/LocationResult.cs index 03de9e2..97ab916 100644 --- a/InertiaCore/Utils/LocationResult.cs +++ b/InertiaCore/Utils/LocationResult.cs @@ -1,24 +1,31 @@ using System.Net; using InertiaCore.Extensions; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace InertiaCore.Utils; -public class LocationResult : IActionResult +public class LocationResult : IActionResult, IResult { private readonly string _url; - public LocationResult(string url) => _url = url; - public async Task ExecuteResultAsync(ActionContext context) + public Task ExecuteResultAsync(ActionContext context) + { + var response = context.HttpContext.Response; + SetHeaders(response); + return Task.CompletedTask; + } + + public Task ExecuteAsync(HttpContext httpContext) { - if (context.IsInertiaRequest()) - { - context.HttpContext.Response.Headers.Override(InertiaHeader.Location, _url); - await new StatusCodeResult((int)HttpStatusCode.Conflict).ExecuteResultAsync(context); - return; - } + SetHeaders(httpContext.Response); + return Task.CompletedTask; + } - await new RedirectResult(_url).ExecuteResultAsync(context); + private void SetHeaders(HttpResponse response) + { + response.Headers["X-Inertia-Location"] = _url; + response.StatusCode = 409; } } diff --git a/InertiaCore/Utils/TaskExtensions.cs b/InertiaCore/Utils/TaskExtensions.cs new file mode 100644 index 0000000..dd3b601 --- /dev/null +++ b/InertiaCore/Utils/TaskExtensions.cs @@ -0,0 +1,45 @@ +namespace InertiaCore.Utils; + +/// +/// Safe task unwrapping utilities for resolving Inertia props. +/// +internal static class TaskExtensions +{ + /// + /// Safely awaits a task and returns its result as , + /// or if the task has no result. + /// + /// + /// This method awaits the task first, then reads Result via reflection. + /// Because the task has already completed, the reflection call is safe and + /// will not block. This is the single acceptable use of reflection in the + /// library for task unwrapping. + /// + internal static async Task UnwrapResultAsync(this Task task) + { + await task.ConfigureAwait(false); + + Type taskType = task.GetType(); + + // Non-generic Task has no result value. + if (!taskType.IsGenericType) + return null; + + // Use GetProperty("Result") rather than GetGenericTypeDefinition() == typeof(Task<>) + // to handle compiler-generated async state machine types that inherit Task{T} + // but fail the generic type definition equality check. + var resultProperty = taskType.GetProperty("Result"); + + if (resultProperty is null) + return null; + + // Guard: if the Result property's type is VoidTaskResult (an internal struct used + // by AsyncTaskMethodBuilder for non-generic Task completion), return null since + // there is no meaningful result value. + if (resultProperty.PropertyType.Name == "VoidTaskResult") + return null; + + // Safe: task has already been awaited, so Result will not block. + return resultProperty.GetValue(task); + } +} diff --git a/InertiaCore/Utils/Vite.cs b/InertiaCore/Utils/Vite.cs index 8e294c4..52ee973 100644 --- a/InertiaCore/Utils/Vite.cs +++ b/InertiaCore/Utils/Vite.cs @@ -21,6 +21,10 @@ internal class ViteBuilder : IViteBuilder { private IFileSystem _fileSystem; private readonly IOptions _options; + private readonly object _cacheLock = new(); + private string? _cachedManifest; + private DateTime _lastManifestRead = DateTime.MinValue; + private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30); public ViteBuilder(IOptions options) => (_fileSystem, _options) = (new FileSystem(), options); @@ -29,9 +33,6 @@ protected internal void UseFileSystem(IFileSystem fileSystem) _fileSystem = fileSystem; } - /// - /// Get the public directory - /// private string GetPublicPathForFile(string path) { var pieces = new List { @@ -42,9 +43,6 @@ private string GetPublicPathForFile(string path) return string.Join("/", pieces); } - /// - /// Get the public directory and build path. - /// private string GetBuildPathForFile(string path) { var pieces = new List { _options.Value.PublicDirectory }; @@ -60,6 +58,7 @@ private string GetBuildPathForFile(string path) /// /// Generates various tags from a given input file path. /// + [Obsolete("Use InputAsync instead. This method uses synchronous file I/O.")] public HtmlString Input(string path) { if (IsRunningHot()) @@ -73,6 +72,83 @@ public HtmlString Input(string path) } var manifest = _fileSystem.File.ReadAllText(GetBuildPathForFile(_options.Value.ManifestFilename)); + return ProcessManifest(path, manifest); + } + + /// + /// Asynchronously generates various tags from a given input file path. + /// Uses file I/O abstraction and caching for production builds. + /// + public async Task InputAsync(string path, CancellationToken cancellationToken = default) + { + if (IsRunningHot()) + { + return new HtmlString(MakeModuleTag("@vite/client").Value + MakeModuleTag(path).Value); + } + + var manifestPath = GetBuildPathForFile(_options.Value.ManifestFilename); + + // Check cache before reading + if (!_fileSystem.File.Exists(manifestPath)) + { + throw new Exception("Vite Manifest is missing. Run `npm run build` and try again."); + } + + var manifest = await ReadManifestWithCacheAsync(manifestPath, cancellationToken); + return ProcessManifest(path, manifest); + } + + /// + /// Generate React refresh runtime script. + /// + [Obsolete("Use ReactRefreshAsync instead. This method uses synchronous file I/O.")] + public HtmlString ReactRefresh() + { + if (!IsRunningHot()) + { + return new HtmlString(""); + } + + var builder = new TagBuilder("script"); + builder.Attributes.Add("type", "module"); + + var inner = $"import RefreshRuntime from '{Asset("@react-refresh")}';" + + "RefreshRuntime.injectIntoGlobalHook(window);" + + "window.$RefreshReg$ = () => { };" + + "window.$RefreshSig$ = () => (type) => type;" + + "window.__vite_plugin_react_preamble_installed__ = true;"; + + builder.InnerHtml.AppendHtml(inner); + + return new HtmlString(GetString(builder)); + } + + /// + /// Asynchronously generate React refresh runtime script. + /// + public Task ReactRefreshAsync(CancellationToken cancellationToken = default) + { + if (!IsRunningHot()) + { + return Task.FromResult(new HtmlString("")); + } + + var builder = new TagBuilder("script"); + builder.Attributes.Add("type", "module"); + + var inner = $"import RefreshRuntime from '{Asset("@react-refresh")}';" + + "RefreshRuntime.injectIntoGlobalHook(window);" + + "window.$RefreshReg$ = () => { };" + + "window.$RefreshSig$ = () => (type) => type;" + + "window.__vite_plugin_react_preamble_installed__ = true;"; + + builder.InnerHtml.AppendHtml(inner); + + return Task.FromResult(new HtmlString(GetString(builder))); + } + + private HtmlString ProcessManifest(string path, string manifest) + { var manifestJson = JsonSerializer.Deserialize>(manifest); if (manifestJson == null) @@ -108,9 +184,30 @@ public HtmlString Input(string path) return html; } - /// - /// Generate script tag with type="module" - /// + private async Task ReadManifestWithCacheAsync(string manifestPath, CancellationToken cancellationToken) + { + // Simple time-based cache to avoid reading the manifest file on every request. + var now = DateTime.UtcNow; + lock (_cacheLock) + { + if (_cachedManifest != null && now - _lastManifestRead < CacheDuration) + { + return _cachedManifest; + } + } + + // Read with async file I/O via the IFileSystem abstraction. + var manifest = await Task.Run(() => _fileSystem.File.ReadAllText(manifestPath), cancellationToken); + + lock (_cacheLock) + { + _cachedManifest = manifest; + _lastManifestRead = now; + } + + return manifest; + } + private HtmlString MakeModuleTag(string path) { var builder = new TagBuilder("script"); @@ -120,17 +217,11 @@ private HtmlString MakeModuleTag(string path) return new HtmlString(GetString(builder) + "\n\t"); } - /// - /// Generate an appropriate tag for the given URL in HMR mode. - /// private HtmlString MakeTag(string url) { return IsCssPath(url) ? MakeStylesheetTag(url) : MakeModuleTag(url); } - /// - /// Generate a stylesheet tag for the given URL in HMR mode. - /// private HtmlString MakeStylesheetTag(string filePath) { var builder = new TagBuilder("link"); @@ -139,41 +230,11 @@ private HtmlString MakeStylesheetTag(string filePath) return new HtmlString(GetString(builder).Replace(">", " />") + "\n\t"); } - /// - /// Determine whether the given path is a CSS file. - /// private static bool IsCssPath(string path) { return Regex.IsMatch(path, @".\.(css|less|sass|scss|styl|stylus|pcss|postcss)", RegexOptions.IgnoreCase); } - /// - /// Generate React refresh runtime script. - /// - public HtmlString ReactRefresh() - { - if (!IsRunningHot()) - { - return new HtmlString(""); - } - - var builder = new TagBuilder("script"); - builder.Attributes.Add("type", "module"); - - var inner = $"import RefreshRuntime from '{Asset("@react-refresh")}';" + - "RefreshRuntime.injectIntoGlobalHook(window);" + - "window.$RefreshReg$ = () => { };" + - "window.$RefreshSig$ = () => (type) => type;" + - "window.__vite_plugin_react_preamble_installed__ = true;"; - - builder.InnerHtml.AppendHtml(inner); - - return new HtmlString(GetString(builder)); - } - - /// - /// Get the URL to a given asset when running in HMR mode. - /// private string HotAsset(string path) { var hotFilePath = GetPublicPathForFile(_options.Value.HotFile); @@ -182,9 +243,6 @@ private string HotAsset(string path) return hotContents + "/" + path; } - /// - /// Get the URL for an asset. - /// private string Asset(string path) { if (IsRunningHot()) @@ -202,17 +260,11 @@ private string Asset(string path) return "/" + string.Join("/", pieces); } - /// - /// Determine if Vite is running in HMR mode. - /// private bool IsRunningHot() { return _fileSystem.File.Exists(GetPublicPathForFile(_options.Value.HotFile)); } - /// - /// Convert an IHtmlContent to a string. - /// private static string GetString(IHtmlContent content) { var writer = new StringWriter(); @@ -220,6 +272,15 @@ private static string GetString(IHtmlContent content) return writer.ToString(); } + internal void ClearManifestCache() + { + lock (_cacheLock) + { + _cachedManifest = null; + _lastManifestRead = DateTime.MinValue; + } + } + public string? GetManifest() { return _fileSystem.File.Exists(GetBuildPathForFile(_options.Value.ManifestFilename)) diff --git a/InertiaCoreTests/InertiaCoreTests.csproj b/InertiaCoreTests/InertiaCoreTests.csproj index 328dafe..9e6e294 100644 --- a/InertiaCoreTests/InertiaCoreTests.csproj +++ b/InertiaCoreTests/InertiaCoreTests.csproj @@ -1,7 +1,7 @@ - net6.0;net7.0;net8.0;net9.0 + net8.0;net9.0 enable enable @@ -14,7 +14,10 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/InertiaCoreTests/Setup.cs b/InertiaCoreTests/Setup.cs index 5942c2b..75fb5e6 100644 --- a/InertiaCoreTests/Setup.cs +++ b/InertiaCoreTests/Setup.cs @@ -1,7 +1,8 @@ using InertiaCore; +using InertiaCore.Extensions; using InertiaCore.Models; +using InertiaCore.Services; using InertiaCore.Ssr; -using InertiaCore.Utils; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; @@ -26,16 +27,25 @@ public void Setup() var options = new Mock>(); options.SetupGet(x => x.Value).Returns(new InertiaOptions()); - _factory = new ResponseFactory(contextAccessor.Object, gateway, options.Object); + var state = new InertiaState(); + + var jsonOptions = new Mock>(); + jsonOptions.SetupGet(x => x.Value).Returns(new JsonOptions()); + + _factory = new ResponseFactory( + contextAccessor.Object, + gateway, + options.Object, + state, + jsonOptions.Object); } /// /// Prepares ActionContext for usage in tests. /// /// Optional request headers. - /// Optional Inertia shared data. /// Optional validation errors dictionary. - private static ActionContext PrepareContext(HeaderDictionary? headers = null, InertiaSharedProps? sharedProps = null, + private static ActionContext PrepareContext(HeaderDictionary? headers = null, Dictionary? modelState = null) { var request = new Mock(); @@ -43,10 +53,11 @@ private static ActionContext PrepareContext(HeaderDictionary? headers = null, In var response = new Mock(); response.SetupGet(r => r.Headers).Returns(new HeaderDictionary()); + var statusCode = 200; + response.SetupGet(r => r.StatusCode).Returns(() => statusCode); + response.SetupSet(r => r.StatusCode = It.IsAny()).Callback(v => statusCode = v); var features = new FeatureCollection(); - if (sharedProps != null) - features.Set(sharedProps); var httpContext = new Mock(); httpContext.SetupGet(c => c.Request).Returns(request.Object); diff --git a/InertiaCoreTests/UnitTestConfiguration.cs b/InertiaCoreTests/UnitTestConfiguration.cs index 5cf8581..3aab6a2 100644 --- a/InertiaCoreTests/UnitTestConfiguration.cs +++ b/InertiaCoreTests/UnitTestConfiguration.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace InertiaCoreTests; @@ -15,8 +16,6 @@ public partial class Tests [Description("Test if the configuration registers properly necessary services and filters.")] public void TestConfiguration() { - Assert.Throws(() => Inertia.GetVersion()); - var builder = WebApplication.CreateBuilder(); Assert.DoesNotThrow(() => builder.Services.AddInertia()); @@ -43,6 +42,8 @@ public void TestConfiguration() var app = builder.Build(); Assert.DoesNotThrow(() => app.UseInertia()); - Assert.DoesNotThrow(() => Inertia.GetVersion()); + // Verify IResponseFactory resolves from DI and GetVersion() works after pipeline is configured. + var factory = app.Services.GetRequiredService(); + Assert.DoesNotThrow(() => factory.GetVersion()); } } diff --git a/InertiaCoreTests/UnitTestDeferredProps.cs b/InertiaCoreTests/UnitTestDeferredProps.cs new file mode 100644 index 0000000..21aab3f --- /dev/null +++ b/InertiaCoreTests/UnitTestDeferredProps.cs @@ -0,0 +1,151 @@ +using InertiaCore.Models; +using InertiaCore.Props; +using Microsoft.AspNetCore.Http; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Deferred prop is excluded from full page load response.")] + public async Task TestDeferredPropExcludedFromFullLoad() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestDeferred = new DeferredProp(() => "Deferred Value") + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "errors", new Dictionary() } + })); + } + + [Test] + [Description("Deferred prop is included in partial reload when key is requested.")] + public async Task TestDeferredPropIncludedOnPartial() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestDeferred = new DeferredProp(() => "Deferred Value") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testDeferred" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + // Per the Inertia.js protocol, when X-Inertia-Partial-Data specifies only + // "testDeferred", only that key (plus errors) should be in the response. + // "test" was not requested so it must not appear. + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testDeferred", "Deferred Value" }, + { "errors", new Dictionary() } + })); + } + + [Test] + [Description("Deferred prop with async factory resolves correctly.")] + public async Task TestDeferredPropAsync() + { + var response = _factory.Render("Test/Page", new + { + TestDeferred = new DeferredProp(() => Task.FromResult("Async Deferred")) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testDeferred" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testDeferred", "Async Deferred" }, + { "errors", new Dictionary() } + })); + } + + [Test] + [Description("Multiple deferred props — only requested ones are resolved.")] + public async Task TestMultipleDeferredOnlyRequestedResolved() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + DeferredA = new DeferredProp(() => "Value A"), + DeferredB = new DeferredProp(() => "Value B") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "deferredA" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.Multiple(() => + { + Assert.That(page?.Props.ContainsKey("deferredA"), Is.True); + Assert.That(page?.Props.ContainsKey("deferredB"), Is.False); + Assert.That(page?.Props["deferredA"], Is.EqualTo("Value A")); + }); + } + + [Test] + [Description("Vary header should be present for partial responses with deferred props.")] + public async Task TestVaryHeaderOnPartialWithDeferred() + { + var response = _factory.Render("Test/Page", new + { + TestDeferred = new DeferredProp(() => "Value") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testDeferred" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var jsonResult = response.GetJson(); + + Assert.That(jsonResult, Is.Not.Null); + } +} diff --git a/InertiaCoreTests/UnitTestErrorBag.cs b/InertiaCoreTests/UnitTestErrorBag.cs new file mode 100644 index 0000000..2781ae4 --- /dev/null +++ b/InertiaCoreTests/UnitTestErrorBag.cs @@ -0,0 +1,145 @@ +using InertiaCore.Models; +using Microsoft.AspNetCore.Http; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Request without X-Inertia-Error-Bag returns flat errors.")] + public async Task TestErrorsWithoutErrorBag() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" } + }; + + var context = PrepareContext(headers, new Dictionary + { + { "Field", "Error" } + }); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { + "errors", new Dictionary + { + { "field", "Error" } + } + } + })); + } + + [Test] + [Description("Request with X-Inertia-Error-Bag scopes errors under the bag name.")] + public async Task TestErrorsWithNamedErrorBag() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" }, + { "X-Inertia-Error-Bag", "myForm" } + }; + + var context = PrepareContext(headers, new Dictionary + { + { "Field", "Error" } + }); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { + "errors", new Dictionary + { + { + "myForm", new Dictionary + { + { "field", "Error" } + } + } + } + } + })); + } + + [Test] + [Description("Error bag with empty ModelState returns empty errors.")] + public async Task TestErrorBagWithEmptyModelState() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" }, + { "X-Inertia-Error-Bag", "myForm" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "errors", new Dictionary() } + })); + } + + [Test] + [Description("Error bag name is preserved as provided (case-sensitive per protocol).")] + public async Task TestErrorBagCaseSensitivity() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" }, + { "X-Inertia-Error-Bag", "MyForm" } + }; + + var context = PrepareContext(headers, new Dictionary + { + { "Field", "Error" } + }); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + var errors = page?.Props["errors"] as Dictionary; + Assert.That(errors, Is.Not.Null); + Assert.That(errors!.ContainsKey("MyForm"), Is.True); + Assert.That(errors.ContainsKey("myForm"), Is.False); + } +} diff --git a/InertiaCoreTests/UnitTestFlashProps.cs b/InertiaCoreTests/UnitTestFlashProps.cs new file mode 100644 index 0000000..ad58e24 --- /dev/null +++ b/InertiaCoreTests/UnitTestFlashProps.cs @@ -0,0 +1,104 @@ +using InertiaCore.Models; +using InertiaCore.Services; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Flash prop appears in the response for the current request.")] + public async Task TestFlashPropAppearsInResponse() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test" + }); + + _factory.Share("flashMessage", "This is a flash"); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "flashMessage", "This is a flash" }, + { "errors", new Dictionary() } + })); + } + + [Test] + [Description("Flash prop is overridden by component prop with the same key.")] + public async Task TestComponentPropOverridesFlash() + { + var response = _factory.Render("Test/Page", new + { + Key = "Component Value" + }); + + _factory.Share("Key", "Flash Value"); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props["key"], Is.EqualTo("Component Value")); + } + + [Test] + [Description("Multiple flash props are all included.")] + public async Task TestMultipleFlashProps() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test" + }); + + _factory.Share("Flash1", "Value1"); + _factory.Share("Flash2", "Value2"); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "flash1", "Value1" }, + { "flash2", "Value2" }, + { "errors", new Dictionary() } + })); + } + + [Test] + [Description("Null flash value is preserved in the response.")] + public async Task TestNullFlashValue() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test" + }); + + _factory.Share("NullKey", null); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props.ContainsKey("nullKey"), Is.True); + Assert.That(page?.Props["nullKey"], Is.Null); + } +} diff --git a/InertiaCoreTests/UnitTestInertiaService.cs b/InertiaCoreTests/UnitTestInertiaService.cs new file mode 100644 index 0000000..d7fb888 --- /dev/null +++ b/InertiaCoreTests/UnitTestInertiaService.cs @@ -0,0 +1,128 @@ +using InertiaCore.Contracts; +using InertiaCore.Extensions; +using InertiaCore.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Resolving IInertia from DI returns an InertiaService instance.")] + public void TestIInertiaResolvesFromDI() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddInertia(); + + var serviceProvider = builder.Services.BuildServiceProvider(); + var inertia = serviceProvider.GetRequiredService(); + + Assert.That(inertia, Is.InstanceOf()); + } + + [Test] + [Description("Resolving IInertia twice from the same scope returns the same instance.")] + public void TestIInertiaIsScopedSameInstance() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddInertia(); + + var serviceProvider = builder.Services.BuildServiceProvider(); + + using var scope1 = serviceProvider.CreateScope(); + var inertia1 = scope1.ServiceProvider.GetRequiredService(); + var inertia2 = scope1.ServiceProvider.GetRequiredService(); + + Assert.That(inertia1, Is.SameAs(inertia2)); + } + + [Test] + [Description("Resolving IInertia from different scopes returns different instances.")] + public void TestIInertiaScopedIsolation() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddInertia(); + + var serviceProvider = builder.Services.BuildServiceProvider(); + + IInertia inertia1, inertia2; + using (var scope1 = serviceProvider.CreateScope()) + { + inertia1 = scope1.ServiceProvider.GetRequiredService(); + } + + using (var scope2 = serviceProvider.CreateScope()) + { + inertia2 = scope2.ServiceProvider.GetRequiredService(); + } + + Assert.That(inertia1, Is.Not.SameAs(inertia2)); + } + + [Test] + [Description("InertiaState is registered as Scoped and isolated per scope.")] + public void TestInertiaStateScopeIsolation() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddInertia(); + + var serviceProvider = builder.Services.BuildServiceProvider(); + + InertiaState state1, state2; + using (var scope1 = serviceProvider.CreateScope()) + { + state1 = scope1.ServiceProvider.GetRequiredService(); + state1.SharedProps["key"] = "value from scope 1"; + } + + using (var scope2 = serviceProvider.CreateScope()) + { + state2 = scope2.ServiceProvider.GetRequiredService(); + state2.SharedProps["key"] = "value from scope 2"; + } + + Assert.That(state1.SharedProps["key"], Is.EqualTo("value from scope 1")); + Assert.That(state2.SharedProps["key"], Is.Not.EqualTo(state1.SharedProps["key"])); + } + + [Test] + [Description("Calling Share() on IInertia does not bleed into another scope.")] + public void TestSharedDataDoesNotBleedAcrossScopes() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddInertia(); + + var serviceProvider = builder.Services.BuildServiceProvider(); + + using (var scope1 = serviceProvider.CreateScope()) + { + var inertia = scope1.ServiceProvider.GetRequiredService(); + inertia.Share("secret", "from scope 1"); + } + + using (var scope2 = serviceProvider.CreateScope()) + { + var state = scope2.ServiceProvider.GetRequiredService(); + Assert.That(state.SharedProps.ContainsKey("secret"), Is.False); + } + } + + [Test] + [Description("IInertia.Render returns an IActionResult.")] + public async Task TestInertiaServiceRenderReturnsIActionResult() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddInertia(); + + var serviceProvider = builder.Services.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + var inertia = scope.ServiceProvider.GetRequiredService(); + + var result = await inertia.Render("Test/Page", new { Test = "Data" }); + + Assert.That(result, Is.InstanceOf()); + } +} diff --git a/InertiaCoreTests/UnitTestMinimalApi.cs b/InertiaCoreTests/UnitTestMinimalApi.cs new file mode 100644 index 0000000..b406cb0 --- /dev/null +++ b/InertiaCoreTests/UnitTestMinimalApi.cs @@ -0,0 +1,199 @@ +using System.Text.Json; +using InertiaCore.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; + +namespace InertiaCoreTests; + +public partial class Tests +{ + private static ActionContext PrepareMinimalApiContext(HeaderDictionary? headers = null, string path = "/") + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = path; + + if (headers != null) + foreach (var (key, value) in headers) + httpContext.Request.Headers[key] = value; + + return new ActionContext( + httpContext, + httpContext.GetRouteData() ?? new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary() + ); + } + + [Test] + [Description("Minimal API path: Inertia request returns a JsonResult with the correct page model.")] + public async Task TestMinimalApiInertiaRequestReturnsJson() + { + var response = _factory.Render("Test/Page", new { Test = "Test" }); + + var context = PrepareMinimalApiContext(new HeaderDictionary + { + { "X-Inertia", "true" } + }); + + response.SetContext(context); + await response.ProcessResponse(); + + var result = response.GetResult(); + + Assert.Multiple(() => + { + Assert.That(result, Is.InstanceOf()); + + var page = (result as JsonResult)?.Value as Page; + Assert.That(page, Is.Not.Null); + Assert.That(page!.Component, Is.EqualTo("Test/Page")); + Assert.That(page.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "errors", new Dictionary(0) } + })); + }); + } + + [Test] + [Description("Minimal API path: non-Inertia request (full page load) returns a ViewResult.")] + public async Task TestMinimalApiNonInertiaRequestReturnsView() + { + var response = _factory.Render("Test/Page", new { Test = "Test" }); + + + var context = PrepareMinimalApiContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var result = response.GetResult(); + + Assert.Multiple(() => + { + Assert.That(result, Is.InstanceOf()); + Assert.That((result as ViewResult)?.ViewName, Is.EqualTo("~/Views/App.cshtml")); + + var page = (result as ViewResult)?.Model as Page; + Assert.That(page, Is.Not.Null); + Assert.That(page!.Component, Is.EqualTo("Test/Page")); + }); + } + + [Test] + [Description("Minimal API path: shared props are merged into the page model.")] + public async Task TestMinimalApiSharedPropsAreResolved() + { + _factory.Share("sharedKey", "sharedValue"); + + var response = _factory.Render("Test/Page", new { Test = "Test" }); + + var context = PrepareMinimalApiContext(new HeaderDictionary + { + { "X-Inertia", "true" } + }); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = (response.GetResult() as JsonResult)?.Value as Page; + + Assert.Multiple(() => + { + Assert.That(page?.Props, Contains.Key("sharedKey")); + Assert.That(page?.Props["sharedKey"], Is.EqualTo("sharedValue")); + }); + } + + [Test] + [Description("Minimal API path: component props take precedence over shared props with the same key.")] + public async Task TestMinimalApiComponentPropsOverrideSharedProps() + { + _factory.Share("test", "shared-value"); + + var response = _factory.Render("Test/Page", new { Test = "component-value" }); + + var context = PrepareMinimalApiContext(new HeaderDictionary + { + { "X-Inertia", "true" } + }); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = (response.GetResult() as JsonResult)?.Value as Page; + + Assert.That(page?.Props["test"], Is.EqualTo("component-value")); + } + + [Test] + [Description("Minimal API path: Request.Path is used as the page URL.")] + public async Task TestMinimalApiUrlIsPopulatedFromRequestPath() + { + var response = _factory.Render("Test/Page"); + + var context = PrepareMinimalApiContext( + headers: new HeaderDictionary { { "X-Inertia", "true" } }, + path: "/dashboard" + ); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = (response.GetResult() as JsonResult)?.Value as Page; + + Assert.That(page?.Url, Is.EqualTo("/dashboard")); + } + + [Test] + [Description("Minimal API path: X-Inertia response header is set on Inertia requests.")] + public async Task TestMinimalApiInertiaHeaderIsSet() + { + var response = _factory.Render("Test/Page"); + + var context = PrepareMinimalApiContext(new HeaderDictionary + { + { "X-Inertia", "true" } + }); + + response.SetContext(context); + await response.ProcessResponse(); + + + response.GetJson(); + + Assert.That( + context.HttpContext.Response.Headers["X-Inertia"].ToString(), + Is.EqualTo("true") + ); + } + + [Test] + [Description("Minimal API path: IResult.ExecuteAsync writes JSON to the response body for Inertia requests.")] + public async Task TestMinimalApiExecuteAsyncWritesJsonBody() + { + var response = _factory.Render("Test/Page", new { Test = "Test" }); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Inertia"] = "true"; + httpContext.Response.Body = new MemoryStream(); + + await ((IResult)response).ExecuteAsync(httpContext); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + var body = await new StreamReader(httpContext.Response.Body).ReadToEndAsync(); + var page = JsonSerializer.Deserialize(body, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + Assert.Multiple(() => + { + Assert.That(httpContext.Response.ContentType, Does.Contain("application/json")); + Assert.That(page?.Component, Is.EqualTo("Test/Page")); + }); + } +} \ No newline at end of file diff --git a/InertiaCoreTests/UnitTestModelState.cs b/InertiaCoreTests/UnitTestModelState.cs index a90d646..c697ca2 100644 --- a/InertiaCoreTests/UnitTestModelState.cs +++ b/InertiaCoreTests/UnitTestModelState.cs @@ -13,7 +13,7 @@ public async Task TestModelState() Test = "Test" }); - var context = PrepareContext(null, null, new Dictionary + var context = PrepareContext(null, new Dictionary { { "Field", "Error" } }); diff --git a/InertiaCoreTests/UnitTestSharedData.cs b/InertiaCoreTests/UnitTestSharedData.cs index fc64336..8fce3dc 100644 --- a/InertiaCoreTests/UnitTestSharedData.cs +++ b/InertiaCoreTests/UnitTestSharedData.cs @@ -1,5 +1,4 @@ using InertiaCore.Models; -using InertiaCore.Utils; namespace InertiaCoreTests; @@ -14,10 +13,9 @@ public async Task TestSharedProps() Test = "Test" }); - var sharedProps = new InertiaSharedProps(); - sharedProps.Set("TestShared", "Shared"); + _factory.Share("TestShared", "Shared"); - var context = PrepareContext(null, sharedProps); + var context = PrepareContext(); response.SetContext(context); await response.ProcessResponse(); @@ -28,7 +26,7 @@ public async Task TestSharedProps() { { "test", "Test" }, { "testShared", "Shared" }, - { "errors", new Dictionary(0) } + { "errors", new Dictionary() } })); } } diff --git a/InertiaCoreTests/UnitTestTaskExtensions.cs b/InertiaCoreTests/UnitTestTaskExtensions.cs new file mode 100644 index 0000000..10d9482 --- /dev/null +++ b/InertiaCoreTests/UnitTestTaskExtensions.cs @@ -0,0 +1,63 @@ +using InertiaCore.Utils; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Task unwraps to correct string value.")] + public async Task TestTaskOfStringUnwrapsToString() + { + Task task = Task.FromResult("hello"); + var result = await task.UnwrapResultAsync(); + + Assert.That(result, Is.EqualTo("hello")); + } + + [Test] + [Description("Task unwraps to correct int value (boxed).")] + public async Task TestTaskOfIntUnwrapsToInt() + { + Task task = Task.FromResult(42); + var result = await task.UnwrapResultAsync(); + + Assert.That(result, Is.EqualTo(42)); + Assert.That(result, Is.TypeOf()); + } + + [Test] + [Description("Non-generic Task unwraps to null without throwing.")] + public async Task TestNonGenericTaskUnwrapsToNull() + { + Task task = Task.CompletedTask; + var result = await task.UnwrapResultAsync(); + + Assert.That(result, Is.Null); + } + + [Test] + [Description("Completed task unwraps without blocking.")] + public async Task TestCompletedTaskDoesNotBlock() + { + Task task = Task.FromResult("done"); + var result = await task.UnwrapResultAsync(); + + Assert.That(result, Is.EqualTo("done")); + } + + [Test] + [Description("Task from async lambda unwraps correctly.")] + public async Task TestAsyncLambdaTaskUnwraps() + { + Task task = AsyncReturnString(); + var result = await task.UnwrapResultAsync(); + + Assert.That(result, Is.EqualTo("async value")); + } + + private static async Task AsyncReturnString() + { + await Task.Yield(); + return "async value"; + } +} diff --git a/InertiaCoreTests/UnitTestVersionConflict.cs b/InertiaCoreTests/UnitTestVersionConflict.cs new file mode 100644 index 0000000..eaaf6f8 --- /dev/null +++ b/InertiaCoreTests/UnitTestVersionConflict.cs @@ -0,0 +1,207 @@ +using System.Net; +using InertiaCore; +using InertiaCore.Extensions; +using InertiaCore.Models; +using InertiaCore.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("GET with version mismatch returns 409 Conflict with X-Inertia-Location header.")] + public async Task TestVersionConflictOnGet() + { + _factory.Version("server-version"); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" }, + { "X-Inertia-Version", "old-version" } + }; + + var context = PrepareContext(headers); + context.HttpContext.Request.Method = "GET"; + + var calledNext = false; + await RunVersionCheck(context, () => calledNext = true, _factory.GetVersion); + + Assert.Multiple(() => + { + Assert.That(context.HttpContext.Response.StatusCode, Is.EqualTo((int)HttpStatusCode.Conflict)); + Assert.That(context.HttpContext.Response.Headers["X-Inertia-Location"].ToString(), Is.Not.Empty); + Assert.That(calledNext, Is.False); + }); + } + + [Test] + [Description("POST with version mismatch returns 409 Conflict (protocol requirement).")] + public async Task TestVersionConflictOnPost() + { + _factory.Version("server-version"); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" }, + { "X-Inertia-Version", "old-version" } + }; + + var context = PrepareContext(headers); + context.HttpContext.Request.Method = "POST"; + + await RunVersionCheck(context, () => { }, _factory.GetVersion); + + Assert.That(context.HttpContext.Response.StatusCode, Is.EqualTo((int)HttpStatusCode.Conflict)); + } + + [Test] + [Description("PUT with version mismatch returns 409 Conflict.")] + public async Task TestVersionConflictOnPut() + { + _factory.Version("server-version"); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" }, + { "X-Inertia-Version", "old-version" } + }; + + var context = PrepareContext(headers); + context.HttpContext.Request.Method = "PUT"; + + await RunVersionCheck(context, () => { }, _factory.GetVersion); + + Assert.That(context.HttpContext.Response.StatusCode, Is.EqualTo((int)HttpStatusCode.Conflict)); + } + + [Test] + [Description("PATCH with version mismatch returns 409 Conflict.")] + public async Task TestVersionConflictOnPatch() + { + _factory.Version("server-version"); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" }, + { "X-Inertia-Version", "old-version" } + }; + + var context = PrepareContext(headers); + context.HttpContext.Request.Method = "PATCH"; + + await RunVersionCheck(context, () => { }, _factory.GetVersion); + + Assert.That(context.HttpContext.Response.StatusCode, Is.EqualTo((int)HttpStatusCode.Conflict)); + } + + [Test] + [Description("DELETE with version mismatch returns 409 Conflict.")] + public async Task TestVersionConflictOnDelete() + { + _factory.Version("server-version"); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" }, + { "X-Inertia-Version", "old-version" } + }; + + var context = PrepareContext(headers); + context.HttpContext.Request.Method = "DELETE"; + + await RunVersionCheck(context, () => { }, _factory.GetVersion); + + Assert.That(context.HttpContext.Response.StatusCode, Is.EqualTo((int)HttpStatusCode.Conflict)); + } + + [Test] + [Description("Request with matching version does NOT return 409.")] + public async Task TestNoConflictOnMatchingVersion() + { + _factory.Version("test-version"); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" }, + { "X-Inertia-Version", "test-version" } + }; + + var context = PrepareContext(headers); + context.HttpContext.Request.Method = "GET"; + + var calledNext = false; + await RunVersionCheck(context, () => calledNext = true, _factory.GetVersion); + + Assert.Multiple(() => + { + Assert.That(context.HttpContext.Response.StatusCode, Is.Not.EqualTo((int)HttpStatusCode.Conflict)); + Assert.That(calledNext, Is.True); + }); + } + + [Test] + [Description("Non-Inertia request with version mismatch does NOT return 409.")] + public async Task TestNoConflictOnNonInertiaRequest() + { + _factory.Version("server-version"); + + var headers = new HeaderDictionary + { + { "X-Inertia-Version", "old-version" } + }; + + var context = PrepareContext(headers); + context.HttpContext.Request.Method = "GET"; + + var calledNext = false; + await RunVersionCheck(context, () => calledNext = true, _factory.GetVersion); + + Assert.That(calledNext, Is.True); + } + + [Test] + [Description("When no version is configured, the version check is skipped.")] + public async Task TestNoConflictWhenNoVersionConfigured() + { + _factory.Version((string?)null); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" }, + { "X-Inertia-Version", "client-version" } + }; + + var context = PrepareContext(headers); + context.HttpContext.Request.Method = "GET"; + + var calledNext = false; + await RunVersionCheck(context, () => calledNext = true, _factory.GetVersion); + + Assert.That(calledNext, Is.True); + } + + /// + /// Simulates the Inertia version-check middleware logic directly. + /// + private static async Task RunVersionCheck(ActionContext actionContext, Action next, Func getVersion) + { + var context = actionContext.HttpContext; + + var serverVersion = getVersion(); + if (serverVersion != null + && context.IsInertiaRequest() + && context.Request.Headers[InertiaHeader.Version] != serverVersion) + { + context.Response.Headers.Override(InertiaHeader.Location, context.RequestedUri()); + context.Response.StatusCode = (int)HttpStatusCode.Conflict; + await context.Response.CompleteAsync(); + return; + } + + next(); + await Task.CompletedTask; + } +} diff --git a/InertiaCoreTests/UnitTestVite.cs b/InertiaCoreTests/UnitTestVite.cs index 9dde8df..02e910b 100644 --- a/InertiaCoreTests/UnitTestVite.cs +++ b/InertiaCoreTests/UnitTestVite.cs @@ -33,7 +33,7 @@ public void TestViteConfiguration() [Test] [Description("Test if the Vite Helper handles hot module reloading properly.")] - public void TestHot() + public async Task TestHot() { var fileSystem = new MockFileSystem(new Dictionary { @@ -45,7 +45,7 @@ public void TestHot() var mock = new Mock(options.Object); mock.Object.UseFileSystem(fileSystem); - var htmlResult = mock.Object.ReactRefresh(); + var htmlResult = await mock.Object.ReactRefreshAsync(); const string inner = "\n\t")); // Basic JS File with CSS import fileSystem.AddFile(@"/wwwroot/build/manifest.json", new MockFileData("{\"app.tsx\": {\"file\": \"assets/main.js\",\"css\": [\"assets/index.css\"]}}")); + mock.Object.ClearManifestCache(); - result = mock.Object.Input("app.tsx"); + result = await mock.Object.InputAsync("app.tsx"); Assert.That(result.ToString(), Is.EqualTo( "\n\t\n\t")); @@ -116,18 +118,20 @@ public void TestViteInput() // Basic CSS file fileSystem.AddFile(@"/wwwroot/build/manifest.json", new MockFileData("{\"index.scss\": {\"file\": \"assets/index.css\"}}")); + mock.Object.ClearManifestCache(); - result = mock.Object.Input("index.scss"); + result = await mock.Object.InputAsync("index.scss"); Assert.That(result.ToString(), Is.EqualTo("\n\t")); // Basic CSS file with custom builder dir fileSystem.AddFile(@"/wwwroot/manifest.json", new MockFileData("{\"index.scss\": {\"file\": \"assets/index.css\"}}")); + mock.Object.ClearManifestCache(); options.SetupGet(x => x.Value).Returns(new ViteOptions { BuildDirectory = null }); - result = mock.Object.Input("index.scss"); + result = await mock.Object.InputAsync("index.scss"); Assert.That(result.ToString(), Is.EqualTo("\n\t")); // Hot file with css import @@ -137,7 +141,7 @@ public void TestViteInput() }); fileSystem.AddFile(@"/wwwroot/hot", new MockFileData("http://127.0.0.1:5174")); - result = mock.Object.Input("index.scss"); + result = await mock.Object.InputAsync("index.scss"); Assert.That(result.ToString(), Is.EqualTo( "\n\t" + "\n\t")); @@ -148,7 +152,7 @@ public void TestViteInput() { BuildDirectory = null }); - result = mock.Object.Input("index.scss"); + result = await mock.Object.InputAsync("index.scss"); Assert.That(result.ToString(), Is.EqualTo( "\n\t" + "\n\t")); @@ -158,13 +162,13 @@ public void TestViteInput() { BuildDirectory = "" }); - result = mock.Object.Input("index.scss"); + result = await mock.Object.InputAsync("index.scss"); Assert.That(result.ToString(), Is.EqualTo( "\n\t" + "\n\t")); // Basic JS File via hot - result = mock.Object.Input("app.tsx"); + result = await mock.Object.InputAsync("app.tsx"); Assert.That(result.ToString(), Is.EqualTo( "\n\t" + "\n\t")); diff --git a/ProjectsLocal/AspNetCore.InertiaCore.0.0.9.nupkg b/ProjectsLocal/AspNetCore.InertiaCore.0.0.9.nupkg new file mode 100644 index 0000000000000000000000000000000000000000..dfc9f35c1f5a114aed0dcb1cece3b23eeada7dc8 GIT binary patch literal 54973 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 literal 0 HcmV?d00001 diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..b0e38ab --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "isRoot": true, + "tools": {} +} \ No newline at end of file