diff --git a/InertiaCore/Models/ComponentNotFoundException.cs b/InertiaCore/Models/ComponentNotFoundException.cs new file mode 100644 index 0000000..501e39c --- /dev/null +++ b/InertiaCore/Models/ComponentNotFoundException.cs @@ -0,0 +1,18 @@ +namespace InertiaCore.Models; + +public class ComponentNotFoundException : Exception +{ + public string Component { get; } + + public ComponentNotFoundException(string component) + : base($"Inertia page component '{component}' not found.") + { + Component = component; + } + + public ComponentNotFoundException(string component, Exception innerException) + : base($"Inertia page component '{component}' not found.", innerException) + { + Component = component; + } +} \ No newline at end of file diff --git a/InertiaCore/Models/InertiaOptions.cs b/InertiaCore/Models/InertiaOptions.cs index cc5de44..2194d84 100644 --- a/InertiaCore/Models/InertiaOptions.cs +++ b/InertiaCore/Models/InertiaOptions.cs @@ -7,4 +7,8 @@ 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 bool EnsurePagesExist { get; set; } = false; + public string[] PagePaths { get; set; } = new[] { "~/ClientApp/src/Pages", "~/ClientApp/src/pages", "~/src/Pages", "~/src/pages" }; + public string[] PageExtensions { get; set; } = new[] { ".vue", ".svelte", ".js", ".jsx", ".ts", ".tsx" }; } diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index 534b7ba..af3a084 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Hosting; namespace InertiaCore; @@ -36,16 +37,22 @@ internal class ResponseFactory : IResponseFactory private readonly IHttpContextAccessor _contextAccessor; private readonly IGateway _gateway; private readonly IOptions _options; + private readonly IWebHostEnvironment _environment; 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, IWebHostEnvironment environment) => + (_contextAccessor, _gateway, _options, _environment) = (contextAccessor, gateway, options, environment); public Response Render(string component, object? props = null) { + if (_options.Value.EnsurePagesExist) + { + FindComponentOrFail(component); + } + props ??= new { }; var dictProps = props switch { @@ -144,4 +151,42 @@ 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); + + private void FindComponentOrFail(string component) + { + var exists = FindComponent(component); + if (!exists) + { + throw new ComponentNotFoundException(component); + } + } + + private bool FindComponent(string component) + { + foreach (var path in _options.Value.PagePaths) + { + var resolvedPath = ResolvePath(path); + if (string.IsNullOrEmpty(resolvedPath)) continue; + + foreach (var extension in _options.Value.PageExtensions) + { + var normalizedComponent = component.Replace('/', Path.DirectorySeparatorChar); + var fullPath = Path.Combine(resolvedPath, normalizedComponent + extension); + if (File.Exists(fullPath)) + { + return true; + } + } + } + return false; + } + + private string? ResolvePath(string path) + { + if (path.StartsWith("~/")) + { + return Path.Combine(_environment.ContentRootPath, path[2..]); + } + return Path.IsPathRooted(path) ? path : Path.Combine(_environment.ContentRootPath, path); + } } diff --git a/InertiaCoreTests/Setup.cs b/InertiaCoreTests/Setup.cs index 5942c2b..0574a9a 100644 --- a/InertiaCoreTests/Setup.cs +++ b/InertiaCoreTests/Setup.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Hosting; using Moq; namespace InertiaCoreTests; @@ -21,12 +22,14 @@ public void Setup() { var contextAccessor = new Mock(); var httpClientFactory = new Mock(); + var environment = new Mock(); + environment.SetupGet(x => x.ContentRootPath).Returns(Path.GetTempPath()); var gateway = new Gateway(httpClientFactory.Object); var options = new Mock>(); options.SetupGet(x => x.Value).Returns(new InertiaOptions()); - _factory = new ResponseFactory(contextAccessor.Object, gateway, options.Object); + _factory = new ResponseFactory(contextAccessor.Object, gateway, options.Object, environment.Object); } /// diff --git a/InertiaCoreTests/UnitTestComponentValidation.cs b/InertiaCoreTests/UnitTestComponentValidation.cs new file mode 100644 index 0000000..188aa58 --- /dev/null +++ b/InertiaCoreTests/UnitTestComponentValidation.cs @@ -0,0 +1,131 @@ +using InertiaCore; +using InertiaCore.Models; +using InertiaCore.Ssr; +using InertiaCore.Utils; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Moq; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Test component exists validation is disabled by default")] + public void TestComponentValidationDisabledByDefault() + { + var contextAccessor = new Mock(); + var httpClientFactory = new Mock(); + var environment = new Mock(); + environment.SetupGet(x => x.ContentRootPath).Returns(Path.GetTempPath()); + + var gateway = new Gateway(httpClientFactory.Object); + var options = new Mock>(); + options.SetupGet(x => x.Value).Returns(new InertiaOptions { EnsurePagesExist = false }); + + var factory = new ResponseFactory(contextAccessor.Object, gateway, options.Object, environment.Object); + + Assert.DoesNotThrow(() => factory.Render("NonexistentComponent")); + } + + [Test] + [Description("Test component validation throws exception when enabled and component doesn't exist")] + public void TestComponentValidationThrowsWhenComponentMissing() + { + var contextAccessor = new Mock(); + var httpClientFactory = new Mock(); + var environment = new Mock(); + environment.SetupGet(x => x.ContentRootPath).Returns(Path.GetTempPath()); + + var gateway = new Gateway(httpClientFactory.Object); + var options = new Mock>(); + options.SetupGet(x => x.Value).Returns(new InertiaOptions { EnsurePagesExist = true }); + + var factory = new ResponseFactory(contextAccessor.Object, gateway, options.Object, environment.Object); + + var ex = Assert.Throws(() => factory.Render("NonexistentComponent")); + Assert.That(ex.Component, Is.EqualTo("NonexistentComponent")); + Assert.That(ex.Message, Contains.Substring("NonexistentComponent")); + } + + [Test] + [Description("Test component validation passes when component exists")] + public void TestComponentValidationPassesWhenComponentExists() + { + var tempDir = Path.GetTempPath(); + var pagesDir = Path.Combine(tempDir, "src", "Pages"); + Directory.CreateDirectory(pagesDir); + + var testComponent = Path.Combine(pagesDir, "TestComponent.tsx"); + File.WriteAllText(testComponent, "export default function TestComponent() { return
Test
; }"); + + try + { + var contextAccessor = new Mock(); + var httpClientFactory = new Mock(); + var environment = new Mock(); + environment.SetupGet(x => x.ContentRootPath).Returns(tempDir); + + var gateway = new Gateway(httpClientFactory.Object); + var options = new Mock>(); + options.SetupGet(x => x.Value).Returns(new InertiaOptions + { + EnsurePagesExist = true, + PagePaths = new[] { "~/src/Pages" }, + PageExtensions = new[] { ".tsx" } + }); + + var factory = new ResponseFactory(contextAccessor.Object, gateway, options.Object, environment.Object); + + Assert.DoesNotThrow(() => factory.Render("TestComponent")); + } + finally + { + if (File.Exists(testComponent)) + File.Delete(testComponent); + if (Directory.Exists(pagesDir)) + Directory.Delete(pagesDir, true); + } + } + + [Test] + [Description("Test component validation works with nested paths")] + public void TestComponentValidationWithNestedPaths() + { + var tempDir = Path.GetTempPath(); + var pagesDir = Path.Combine(tempDir, "ClientApp", "src", "Pages", "Auth"); + Directory.CreateDirectory(pagesDir); + + var testComponent = Path.Combine(pagesDir, "Login.vue"); + File.WriteAllText(testComponent, ""); + + try + { + var contextAccessor = new Mock(); + var httpClientFactory = new Mock(); + var environment = new Mock(); + environment.SetupGet(x => x.ContentRootPath).Returns(tempDir); + + var gateway = new Gateway(httpClientFactory.Object); + var options = new Mock>(); + options.SetupGet(x => x.Value).Returns(new InertiaOptions + { + EnsurePagesExist = true, + PagePaths = new[] { "~/ClientApp/src/Pages" }, + PageExtensions = new[] { ".vue" } + }); + + var factory = new ResponseFactory(contextAccessor.Object, gateway, options.Object, environment.Object); + + Assert.DoesNotThrow(() => factory.Render("Auth/Login")); + } + finally + { + if (File.Exists(testComponent)) + File.Delete(testComponent); + if (Directory.Exists(pagesDir)) + Directory.Delete(pagesDir, true); + } + } +} \ No newline at end of file