diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b78f820..899c0f2 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -23,7 +23,11 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 9.0.x + dotnet-version: | + 6.0.x + 7.0.x + 8.0.x + 9.0.x - name: Restore dependencies run: dotnet restore - name: Build diff --git a/InertiaCore/Extensions/InertiaExtensions.cs b/InertiaCore/Extensions/InertiaExtensions.cs index 9aa0391..90c058d 100644 --- a/InertiaCore/Extensions/InertiaExtensions.cs +++ b/InertiaCore/Extensions/InertiaExtensions.cs @@ -8,31 +8,6 @@ namespace InertiaCore.Extensions; internal static class InertiaExtensions { - internal static Dictionary<string, object?> OnlyProps(this ActionContext context, Dictionary<string, object?> props) - { - var onlyKeys = context.HttpContext.Request.Headers[InertiaHeader.PartialOnly] - .ToString().Split(',') - .Select(k => k.Trim()) - .Where(k => !string.IsNullOrEmpty(k)) - .ToList(); - - return props.Where(kv => onlyKeys.Contains(kv.Key, StringComparer.OrdinalIgnoreCase)) - .ToDictionary(kv => kv.Key, kv => kv.Value); - } - - internal static Dictionary<string, object?> ExceptProps(this ActionContext context, - Dictionary<string, object?> props) - { - var exceptKeys = context.HttpContext.Request.Headers[InertiaHeader.PartialExcept] - .ToString().Split(',') - .Select(k => k.Trim()) - .Where(k => !string.IsNullOrEmpty(k)) - .ToList(); - - return props.Where(kv => exceptKeys.Contains(kv.Key, StringComparer.OrdinalIgnoreCase) == false) - .ToDictionary(kv => kv.Key, kv => kv.Value); - } - internal static bool IsInertiaPartialComponent(this ActionContext context, string component) => context.HttpContext.Request.Headers[InertiaHeader.PartialComponent] == component; @@ -55,4 +30,23 @@ internal static bool Override<TKey, TValue>(this IDictionary<TKey, TValue> dicti return true; } + + internal static Task<object?> ResolveAsync(this Func<object?> func) + { + var rt = func.Method.ReturnType; + + if (!rt.IsGenericType || rt.GetGenericTypeDefinition() != typeof(Task<>)) + return Task.Run(func.Invoke); + + var task = func.DynamicInvoke() as Task; + return task!.ResolveResult(); + } + + internal static async Task<object?> ResolveResult(this Task task) + { + await task.ConfigureAwait(false); + var result = task.GetType().GetProperty("Result"); + + return result?.GetValue(task); + } } diff --git a/InertiaCore/Props/InvokableProp.cs b/InertiaCore/Props/InvokableProp.cs index 2fd3f3b..02ddb6f 100644 --- a/InertiaCore/Props/InvokableProp.cs +++ b/InertiaCore/Props/InvokableProp.cs @@ -1,3 +1,5 @@ +using InertiaCore.Extensions; + namespace InertiaCore.Props; public class InvokableProp @@ -10,9 +12,9 @@ public class InvokableProp { return _value switch { - Func<Task<object?>> asyncCallable => asyncCallable.Invoke(), - Func<object?> callable => Task.Run(() => callable.Invoke()), - Task<object?> value => value, + Func<object?> f => f.ResolveAsync(), + Task t => t.ResolveResult(), + InvokableProp p => p.Invoke(), _ => Task.FromResult(_value) }; } diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index d052844..4b9ed72 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -13,7 +13,7 @@ namespace InertiaCore; public class Response : IActionResult { private readonly string _component; - private readonly object _props; + private readonly Dictionary<string, object?> _props; private readonly string _rootView; private readonly string? _version; @@ -21,46 +21,149 @@ public class Response : IActionResult private Page? _page; private IDictionary<string, object>? _viewData; - public Response(string component, object props, string rootView, string? version) + internal Response(string component, Dictionary<string, object?> props, string rootView, string? version) => (_component, _props, _rootView, _version) = (component, props, rootView, version); public async Task ExecuteResultAsync(ActionContext context) { SetContext(context); await ProcessResponse(); - await GetResult().ExecuteResultAsync(_context!); } protected internal async Task ProcessResponse() { + var props = await ResolveProperties(); + var page = new Page { Component = _component, Version = _version, Url = _context!.RequestedUri(), - Props = await ResolveProperties(_props.GetType().GetProperties() - .ToDictionary(o => o.Name.ToCamelCase(), o => o.GetValue(_props))) + Props = props }; - var shared = _context!.HttpContext.Features.Get<InertiaSharedData>(); - if (shared != null) - page.Props = shared.GetMerged(page.Props); - page.Props["errors"] = GetErrors(); SetPage(page); } - private static async Task<Dictionary<string, object?>> PrepareProps(Dictionary<string, object?> props) + /// <summary> + /// Resolve the properties for the response. + /// </summary> + private async Task<Dictionary<string, object?>> ResolveProperties() + { + var props = _props; + + props = ResolveSharedProps(props); + props = ResolvePartialProperties(props); + props = ResolveAlways(props); + props = await ResolvePropertyInstances(props); + + return props; + } + + /// <summary> + /// Resolve `shared` props stored in the current request context. + /// </summary> + private Dictionary<string, object?> ResolveSharedProps(Dictionary<string, object?> props) + { + var shared = _context!.HttpContext.Features.Get<InertiaSharedProps>(); + if (shared != null) + props = shared.GetMerged(props); + + return props; + } + + /// <summary> + /// Resolve the `only` and `except` partial request props. + /// </summary> + private Dictionary<string, object?> ResolvePartialProperties(Dictionary<string, object?> props) + { + var isPartial = _context!.IsInertiaPartialComponent(_component); + + if (!isPartial) + return props + .Where(kv => kv.Value is not LazyProp) + .ToDictionary(kv => kv.Key, kv => kv.Value); + + props = props.ToDictionary(kv => kv.Key, kv => kv.Value); + + if (_context!.HttpContext.Request.Headers.ContainsKey(InertiaHeader.PartialOnly)) + props = ResolveOnly(props); + + if (_context!.HttpContext.Request.Headers.ContainsKey(InertiaHeader.PartialExcept)) + props = ResolveExcept(props); + + return props; + } + + /// <summary> + /// Resolve the `only` partial request props. + /// </summary> + private Dictionary<string, object?> ResolveOnly(Dictionary<string, object?> props) + { + var onlyKeys = _context!.HttpContext.Request.Headers[InertiaHeader.PartialOnly] + .ToString().Split(',') + .Select(k => k.Trim()) + .Where(k => !string.IsNullOrEmpty(k)) + .ToList(); + + return props.Where(kv => onlyKeys.Contains(kv.Key, StringComparer.OrdinalIgnoreCase)) + .ToDictionary(kv => kv.Key, kv => kv.Value); + } + + /// <summary> + /// Resolve the `except` partial request props. + /// </summary> + private Dictionary<string, object?> ResolveExcept(Dictionary<string, object?> props) + { + var exceptKeys = _context!.HttpContext.Request.Headers[InertiaHeader.PartialExcept] + .ToString().Split(',') + .Select(k => k.Trim()) + .Where(k => !string.IsNullOrEmpty(k)) + .ToList(); + + return props.Where(kv => exceptKeys.Contains(kv.Key, StringComparer.OrdinalIgnoreCase) == false) + .ToDictionary(kv => kv.Key, kv => kv.Value); + } + + /// <summary> + /// Resolve `always` properties that should always be included on all visits, regardless of "only" or "except" requests. + /// </summary> + private Dictionary<string, object?> ResolveAlways(Dictionary<string, object?> props) + { + var alwaysProps = _props.Where(o => o.Value is AlwaysProp); + + return props + .Where(kv => kv.Value is not AlwaysProp) + .Concat(alwaysProps).ToDictionary(kv => kv.Key, kv => kv.Value); + } + + /// <summary> + /// Resolve all necessary class instances in the given props. + /// </summary> + private static async Task<Dictionary<string, object?>> ResolvePropertyInstances(Dictionary<string, object?> props) { - return (await Task.WhenAll(props.Select(async pair => pair.Value switch + return (await Task.WhenAll(props.Select(async pair => { - Func<object?> f => (pair.Key, f.Invoke()), - LazyProp l => (pair.Key, await l.Invoke()), - AlwaysProp l => (pair.Key, await l.Invoke()), - _ => (pair.Key, pair.Value) - }))).ToDictionary(pair => pair.Key, pair => pair.Item2); + var key = pair.Key.ToCamelCase(); + + var value = pair.Value switch + { + Func<object?> f => (key, await f.ResolveAsync()), + Task t => (key, await t.ResolveResult()), + InvokableProp p => (key, await p.Invoke()), + _ => (key, pair.Value) + }; + + if (value.Item2 is Dictionary<string, object?> dict) + { + value = (key, await ResolvePropertyInstances(dict)); + } + + return value; + }))).ToDictionary(pair => pair.key, pair => pair.Item2); } protected internal JsonResult GetJson() @@ -93,7 +196,7 @@ private ViewResult GetView() protected internal IActionResult GetResult() => _context!.IsInertiaRequest() ? GetJson() : GetView(); - private IDictionary<string, string> GetErrors() + private Dictionary<string, string> GetErrors() { if (!_context!.ModelState.IsValid) return _context!.ModelState.ToDictionary(o => o.Key.ToCamelCase(), @@ -111,48 +214,4 @@ public Response WithViewData(IDictionary<string, object> viewData) _viewData = viewData; return this; } - - private async Task<Dictionary<string, object?>> ResolveProperties(Dictionary<string, object?> props) - { - var isPartial = _context!.IsInertiaPartialComponent(_component); - - if (!isPartial) - { - props = props - .Where(kv => kv.Value is not LazyProp) - .ToDictionary(kv => kv.Key, kv => kv.Value); - } - else - { - props = props.ToDictionary(kv => kv.Key, kv => kv.Value); - - if (_context!.HttpContext.Request.Headers.ContainsKey(InertiaHeader.PartialOnly)) - props = ResolveOnly(props); - - if (_context!.HttpContext.Request.Headers.ContainsKey(InertiaHeader.PartialExcept)) - props = ResolveExcept(props); - } - - props = ResolveAlways(props); - props = await PrepareProps(props); - - return props; - } - - private Dictionary<string, object?> ResolveOnly(Dictionary<string, object?> props) - => _context!.OnlyProps(props); - - private Dictionary<string, object?> ResolveExcept(Dictionary<string, object?> props) - => _context!.ExceptProps(props); - - private Dictionary<string, object?> ResolveAlways(Dictionary<string, object?> props) - { - var alwaysProps = _props.GetType().GetProperties() - .Where(o => o.PropertyType == typeof(AlwaysProp)) - .ToDictionary(o => o.Name.ToCamelCase(), o => o.GetValue(_props)); - - return props - .Where(kv => kv.Value is not AlwaysProp) - .Concat(alwaysProps).ToDictionary(kv => kv.Key, kv => kv.Value); - } } diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index b5fbd21..8bce7cf 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -42,8 +42,14 @@ public ResponseFactory(IHttpContextAccessor contextAccessor, IGateway gateway, I public Response Render(string component, object? props = null) { props ??= new { }; + var dictProps = props switch + { + Dictionary<string, object?> dict => dict, + _ => props.GetType().GetProperties() + .ToDictionary(o => o.Name, o => o.GetValue(props)) + }; - return new Response(component, props, _options.Value.RootView, GetVersion()); + return new Response(component, dictProps, _options.Value.RootView, GetVersion()); } public async Task<IHtmlContent> Head(dynamic model) @@ -104,8 +110,8 @@ public void Share(string key, object? value) { var context = _contextAccessor.HttpContext!; - var sharedData = context.Features.Get<InertiaSharedData>(); - sharedData ??= new InertiaSharedData(); + var sharedData = context.Features.Get<InertiaSharedProps>(); + sharedData ??= new InertiaSharedProps(); sharedData.Set(key, value); context.Features.Set(sharedData); @@ -115,16 +121,16 @@ public void Share(IDictionary<string, object?> data) { var context = _contextAccessor.HttpContext!; - var sharedData = context.Features.Get<InertiaSharedData>(); - sharedData ??= new InertiaSharedData(); + var sharedData = context.Features.Get<InertiaSharedProps>(); + sharedData ??= new InertiaSharedProps(); sharedData.Merge(data); context.Features.Set(sharedData); } - public LazyProp Lazy(Func<object?> callback) => new LazyProp(callback); - public LazyProp Lazy(Func<Task<object?>> callback) => new LazyProp(callback); - public AlwaysProp Always(object? value) => new AlwaysProp(value); - public AlwaysProp Always(Func<object?> callback) => new AlwaysProp(callback); - public AlwaysProp Always(Func<Task<object?>> callback) => new AlwaysProp(callback); + public LazyProp Lazy(Func<object?> callback) => new(callback); + public LazyProp Lazy(Func<Task<object?>> callback) => new(callback); + public AlwaysProp Always(object? value) => new(value); + public AlwaysProp Always(Func<object?> callback) => new(callback); + public AlwaysProp Always(Func<Task<object?>> callback) => new(callback); } diff --git a/InertiaCore/Utils/InertiaSharedData.cs b/InertiaCore/Utils/InertiaSharedProps.cs similarity index 95% rename from InertiaCore/Utils/InertiaSharedData.cs rename to InertiaCore/Utils/InertiaSharedProps.cs index dfb9ff7..68b9bbd 100644 --- a/InertiaCore/Utils/InertiaSharedData.cs +++ b/InertiaCore/Utils/InertiaSharedProps.cs @@ -2,7 +2,7 @@ namespace InertiaCore.Utils; -internal class InertiaSharedData +internal class InertiaSharedProps { private IDictionary<string, object?>? Data { get; set; } diff --git a/InertiaCoreTests/Setup.cs b/InertiaCoreTests/Setup.cs index bb70e88..5942c2b 100644 --- a/InertiaCoreTests/Setup.cs +++ b/InertiaCoreTests/Setup.cs @@ -33,9 +33,9 @@ public void Setup() /// Prepares ActionContext for usage in tests. /// </summary> /// <param name="headers">Optional request headers.</param> - /// <param name="sharedData">Optional Inertia shared data.</param> + /// <param name="sharedProps">Optional Inertia shared data.</param> /// <param name="modelState">Optional validation errors dictionary.</param> - private static ActionContext PrepareContext(HeaderDictionary? headers = null, InertiaSharedData? sharedData = null, + private static ActionContext PrepareContext(HeaderDictionary? headers = null, InertiaSharedProps? sharedProps = null, Dictionary<string, string>? modelState = null) { var request = new Mock<HttpRequest>(); @@ -45,8 +45,8 @@ private static ActionContext PrepareContext(HeaderDictionary? headers = null, In response.SetupGet(r => r.Headers).Returns(new HeaderDictionary()); var features = new FeatureCollection(); - if (sharedData != null) - features.Set(sharedData); + if (sharedProps != null) + features.Set(sharedProps); var httpContext = new Mock<HttpContext>(); httpContext.SetupGet(c => c.Request).Returns(request.Object); diff --git a/InertiaCoreTests/UnitTestAlwaysData.cs b/InertiaCoreTests/UnitTestAlwaysData.cs index 9374fb0..480ac47 100644 --- a/InertiaCoreTests/UnitTestAlwaysData.cs +++ b/InertiaCoreTests/UnitTestAlwaysData.cs @@ -67,17 +67,11 @@ public async Task TestAlwaysPartialData() [Description("Test if the always async data is fetched properly.")] public async Task TestAlwaysAsyncData() { - var testFunction = new Func<Task<object?>>(async () => - { - await Task.Delay(100); - return "Always Async"; - }); - var response = _factory.Render("Test/Page", new { Test = "Test", TestFunc = new Func<string>(() => "Func"), - TestAlways = _factory.Always(testFunction) + TestAlways = _factory.Always(() => Task.FromResult<object?>("Always Async")) }); var context = PrepareContext(); @@ -100,16 +94,10 @@ public async Task TestAlwaysAsyncData() [Description("Test if the always async data is fetched properly with specified partial props.")] public async Task TestAlwaysAsyncPartialData() { - var testFunction = new Func<Task<string>>(async () => - { - await Task.Delay(100); - return "Always Async"; - }); - var response = _factory.Render("Test/Page", new { TestFunc = new Func<string>(() => "Func"), - TestAlways = _factory.Always(async () => await testFunction()) + TestAlways = _factory.Always(() => Task.FromResult<object?>("Always Async")) }); var headers = new HeaderDictionary @@ -137,16 +125,10 @@ public async Task TestAlwaysAsyncPartialData() [Description("Test if the always async data is fetched properly without specified partial props.")] public async Task TestAlwaysAsyncPartialDataOmitted() { - var testFunction = new Func<Task<string>>(async () => - { - await Task.Delay(100); - return "Always Async"; - }); - var response = _factory.Render("Test/Page", new { TestFunc = new Func<string>(() => "Func"), - TestAlways = _factory.Always(async () => await testFunction()) + TestAlways = _factory.Always(() => Task.FromResult<object?>("Always Async")) }); var headers = new HeaderDictionary diff --git a/InertiaCoreTests/UnitTestDictionaryData.cs b/InertiaCoreTests/UnitTestDictionaryData.cs new file mode 100644 index 0000000..cc3d538 --- /dev/null +++ b/InertiaCoreTests/UnitTestDictionaryData.cs @@ -0,0 +1,53 @@ +using InertiaCore.Models; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Test if all nested dictionaries and its values are resolved properly.")] + public async Task TestDictionaryData() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestDict = new Dictionary<string, object> + { + ["Key"] = () => "Value", + ["KeyAsync"] = () => Task.FromResult("ValueAsync"), + ["KeyAsync2"] = Task.FromResult("ValueAsync2"), + ["Nested"] = () => new Dictionary<string, object> + { + ["Key"] = () => "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<string, object?> + { + { "test", "Test" }, + { + "testDict", new Dictionary<string, object?> + { + { "key", "Value" }, + { "keyAsync", "ValueAsync" }, + { "keyAsync2", "ValueAsync2" }, + { + "nested", new Dictionary<string, object?> + { + { "key", "Value" }, + } + } + } + }, + { "errors", new Dictionary<string, string>(0) } + })); + } +} diff --git a/InertiaCoreTests/UnitTestLazyData.cs b/InertiaCoreTests/UnitTestLazyData.cs index 423cbf4..085eb60 100644 --- a/InertiaCoreTests/UnitTestLazyData.cs +++ b/InertiaCoreTests/UnitTestLazyData.cs @@ -71,18 +71,15 @@ public async Task TestLazyPartialData() [Description("Test if the lazy async data is fetched properly.")] public async Task TestLazyAsyncData() { - var testFunction = new Func<Task<object?>>(async () => - { - Assert.Fail(); - await Task.Delay(100); - return "Lazy Async"; - }); - var response = _factory.Render("Test/Page", new { Test = "Test", TestFunc = new Func<string>(() => "Func"), - TestLazy = _factory.Lazy(testFunction) + TestLazy = _factory.Lazy(() => + { + Assert.Fail(); + return Task.FromResult<object?>("Lazy Async"); + }) }); var context = PrepareContext(); @@ -104,16 +101,10 @@ public async Task TestLazyAsyncData() [Description("Test if the lazy async data is fetched properly with specified partial props.")] public async Task TestLazyAsyncPartialData() { - var testFunction = new Func<Task<string>>(async () => - { - await Task.Delay(100); - return "Lazy Async"; - }); - var response = _factory.Render("Test/Page", new { TestFunc = new Func<string>(() => "Func"), - TestLazy = _factory.Lazy(async () => await testFunction()) + TestLazy = _factory.Lazy(() => Task.FromResult<object?>("Lazy Async")) }); var headers = new HeaderDictionary diff --git a/InertiaCoreTests/UnitTestSharedData.cs b/InertiaCoreTests/UnitTestSharedData.cs index 9155c8a..fc64336 100644 --- a/InertiaCoreTests/UnitTestSharedData.cs +++ b/InertiaCoreTests/UnitTestSharedData.cs @@ -7,17 +7,17 @@ public partial class Tests { [Test] [Description("Test if shared data is merged with the props properly.")] - public async Task TestSharedData() + public async Task TestSharedProps() { var response = _factory.Render("Test/Page", new { Test = "Test" }); - var sharedData = new InertiaSharedData(); - sharedData.Set("TestShared", "Shared"); + var sharedProps = new InertiaSharedProps(); + sharedProps.Set("TestShared", "Shared"); - var context = PrepareContext(null, sharedData); + var context = PrepareContext(null, sharedProps); response.SetContext(context); await response.ProcessResponse();