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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions InertiaCore/Models/ComponentNotFoundException.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
4 changes: 4 additions & 0 deletions InertiaCore/Models/InertiaOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
}
49 changes: 47 additions & 2 deletions InertiaCore/ResponseFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Hosting;

namespace InertiaCore;

Expand Down Expand Up @@ -36,16 +37,22 @@ internal class ResponseFactory : IResponseFactory
private readonly IHttpContextAccessor _contextAccessor;
private readonly IGateway _gateway;
private readonly IOptions<InertiaOptions> _options;
private readonly IWebHostEnvironment _environment;

private object? _version;
private bool _clearHistory;
private bool? _encryptHistory;

public ResponseFactory(IHttpContextAccessor contextAccessor, IGateway gateway, IOptions<InertiaOptions> options) =>
(_contextAccessor, _gateway, _options) = (contextAccessor, gateway, options);
public ResponseFactory(IHttpContextAccessor contextAccessor, IGateway gateway, IOptions<InertiaOptions> 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
{
Expand Down Expand Up @@ -144,4 +151,42 @@ public void Share(IDictionary<string, object?> data)
public AlwaysProp Always(object? value) => new(value);
public AlwaysProp Always(Func<object?> callback) => new(callback);
public AlwaysProp Always(Func<Task<object?>> 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);
}
}
5 changes: 4 additions & 1 deletion InertiaCoreTests/Setup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,12 +22,14 @@ public void Setup()
{
var contextAccessor = new Mock<IHttpContextAccessor>();
var httpClientFactory = new Mock<IHttpClientFactory>();
var environment = new Mock<IWebHostEnvironment>();
environment.SetupGet(x => x.ContentRootPath).Returns(Path.GetTempPath());

var gateway = new Gateway(httpClientFactory.Object);
var options = new Mock<IOptions<InertiaOptions>>();
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);
}

/// <summary>
Expand Down
131 changes: 131 additions & 0 deletions InertiaCoreTests/UnitTestComponentValidation.cs
Original file line number Diff line number Diff line change
@@ -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<IHttpContextAccessor>();
var httpClientFactory = new Mock<IHttpClientFactory>();
var environment = new Mock<IWebHostEnvironment>();
environment.SetupGet(x => x.ContentRootPath).Returns(Path.GetTempPath());

var gateway = new Gateway(httpClientFactory.Object);
var options = new Mock<IOptions<InertiaOptions>>();
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<IHttpContextAccessor>();
var httpClientFactory = new Mock<IHttpClientFactory>();
var environment = new Mock<IWebHostEnvironment>();
environment.SetupGet(x => x.ContentRootPath).Returns(Path.GetTempPath());

var gateway = new Gateway(httpClientFactory.Object);
var options = new Mock<IOptions<InertiaOptions>>();
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<ComponentNotFoundException>(() => 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 <div>Test</div>; }");

try
{
var contextAccessor = new Mock<IHttpContextAccessor>();
var httpClientFactory = new Mock<IHttpClientFactory>();
var environment = new Mock<IWebHostEnvironment>();
environment.SetupGet(x => x.ContentRootPath).Returns(tempDir);

var gateway = new Gateway(httpClientFactory.Object);
var options = new Mock<IOptions<InertiaOptions>>();
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, "<template><div>Login</div></template>");

try
{
var contextAccessor = new Mock<IHttpContextAccessor>();
var httpClientFactory = new Mock<IHttpClientFactory>();
var environment = new Mock<IWebHostEnvironment>();
environment.SetupGet(x => x.ContentRootPath).Returns(tempDir);

var gateway = new Gateway(httpClientFactory.Object);
var options = new Mock<IOptions<InertiaOptions>>();
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);
}
}
}