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 534b7ba..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,89 +77,90 @@ 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 encoded = WebUtility.HtmlEncode(data); + var data = JsonSerializer.Serialize(model, _jsonOptions); + // v3 protocol: escape forward slashes to avoid premature termination + var escaped = data.Replace("/", "\\/"); - return new HtmlString($"
"); + return new HtmlString( + $"\n" + + $"
" + ); } - 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; @@ -144,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 0000000..dfc9f35 Binary files /dev/null and b/ProjectsLocal/AspNetCore.InertiaCore.0.0.9.nupkg differ 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