diff --git a/InertiaCore/Extensions/Configure.cs b/InertiaCore/Extensions/Configure.cs index f0ff601..f6edb20 100644 --- a/InertiaCore/Extensions/Configure.cs +++ b/InertiaCore/Extensions/Configure.cs @@ -1,4 +1,3 @@ -using System.IO.Abstractions; using System.Net; using InertiaCore.Models; using InertiaCore.Ssr; @@ -8,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace InertiaCore.Extensions; @@ -45,6 +45,7 @@ public static IServiceCollection AddInertia(this IServiceCollection services, services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.Configure(mvcOptions => { mvcOptions.Filters.Add(); }); @@ -53,6 +54,16 @@ public static IServiceCollection AddInertia(this IServiceCollection services, return services; } + public static IServiceCollection UseInertiaSerializer(this IServiceCollection services) + where TImplementation : IInertiaSerializer + { + services.Replace( + new ServiceDescriptor(typeof(IInertiaSerializer), typeof(TImplementation), ServiceLifetime.Singleton) + ); + + return services; + } + public static IServiceCollection AddViteHelper(this IServiceCollection services, Action? options = null) { diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index ee364e6..43c4b62 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -1,5 +1,3 @@ -using System.Text.Json; -using System.Text.Json.Serialization; using InertiaCore.Extensions; using InertiaCore.Models; using InertiaCore.Utils; @@ -15,13 +13,14 @@ public class Response : IActionResult private readonly object _props; private readonly string _rootView; private readonly string? _version; + private readonly IInertiaSerializer _serializer; private ActionContext? _context; private Page? _page; private IDictionary? _viewData; - public Response(string component, object props, string rootView, string? version) - => (_component, _props, _rootView, _version) = (component, props, rootView, version); + public Response(string component, object props, string rootView, string? version, IInertiaSerializer serializer) + => (_component, _props, _rootView, _version, _serializer) = (component, props, rootView, version, serializer); public async Task ExecuteResultAsync(ActionContext context) { @@ -85,11 +84,7 @@ protected internal JsonResult GetJson() _context!.HttpContext.Response.Headers.Override("Vary", "Accept"); _context!.HttpContext.Response.StatusCode = 200; - return new JsonResult(_page, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - ReferenceHandler = ReferenceHandler.IgnoreCycles - }); + return _serializer.SerializeResult(_page); } private ViewResult GetView() diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index 0692b40..2c60b32 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -1,7 +1,4 @@ using System.Net; -using System.Text.Json; -using System.Text.Json.Serialization; -using InertiaCore.Extensions; using InertiaCore.Models; using InertiaCore.Ssr; using InertiaCore.Utils; @@ -29,18 +26,20 @@ internal class ResponseFactory : IResponseFactory { private readonly IHttpContextAccessor _contextAccessor; private readonly IGateway _gateway; + private readonly IInertiaSerializer _serializer; private readonly IOptions _options; private object? _version; - public ResponseFactory(IHttpContextAccessor contextAccessor, IGateway gateway, IOptions options) => - (_contextAccessor, _gateway, _options) = (contextAccessor, gateway, options); + public ResponseFactory(IHttpContextAccessor contextAccessor, IGateway gateway, IInertiaSerializer serializer, + IOptions options) + => (_contextAccessor, _gateway, _serializer, _options) = (contextAccessor, gateway, serializer, options); public Response Render(string component, object? props = null) { props ??= new { }; - return new Response(component, props, _options.Value.RootView, GetVersion()); + return new Response(component, props, _options.Value.RootView, GetVersion(), _serializer); } public async Task Head(dynamic model) @@ -74,13 +73,7 @@ public async Task Html(dynamic model) } } - var data = JsonSerializer.Serialize(model, - new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - ReferenceHandler = ReferenceHandler.IgnoreCycles - }); - + var data = _serializer.Serialize(model); var encoded = WebUtility.HtmlEncode(data); return new HtmlString($"
"); diff --git a/InertiaCore/Ssr/Gateway.cs b/InertiaCore/Ssr/Gateway.cs index 1878c52..abad241 100644 --- a/InertiaCore/Ssr/Gateway.cs +++ b/InertiaCore/Ssr/Gateway.cs @@ -1,7 +1,6 @@ using System.Net.Http.Json; using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; +using InertiaCore.Utils; namespace InertiaCore.Ssr; @@ -13,17 +12,14 @@ internal interface IGateway internal class Gateway : IGateway { private readonly IHttpClientFactory _httpClientFactory; + private readonly IInertiaSerializer _serializer; - public Gateway(IHttpClientFactory httpClientFactory) => _httpClientFactory = httpClientFactory; + public Gateway(IHttpClientFactory httpClientFactory, IInertiaSerializer serializer) + => (_httpClientFactory, _serializer) = (httpClientFactory, serializer); public async Task Dispatch(dynamic model, string url) { - var json = JsonSerializer.Serialize(model, - new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - ReferenceHandler = ReferenceHandler.IgnoreCycles - }); + var json = _serializer.Serialize(model); var content = new StringContent(json.ToString(), Encoding.UTF8, "application/json"); var client = _httpClientFactory.CreateClient(); diff --git a/InertiaCore/Utils/DefaultInertiaSerializer.cs b/InertiaCore/Utils/DefaultInertiaSerializer.cs new file mode 100644 index 0000000..1e15805 --- /dev/null +++ b/InertiaCore/Utils/DefaultInertiaSerializer.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace InertiaCore.Utils; + +public class DefaultInertiaSerializer : IInertiaSerializer +{ + protected static JsonSerializerOptions GetOptions() + { + return new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReferenceHandler = ReferenceHandler.IgnoreCycles + }; + } + + public string Serialize(object? obj) + { + return JsonSerializer.Serialize(obj, GetOptions()); + } + + public JsonResult SerializeResult(object? obj) + { + return new JsonResult(obj, GetOptions()); + } +} diff --git a/InertiaCore/Utils/IInertiaSerializer.cs b/InertiaCore/Utils/IInertiaSerializer.cs new file mode 100644 index 0000000..4252a21 --- /dev/null +++ b/InertiaCore/Utils/IInertiaSerializer.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; + +namespace InertiaCore.Utils; + +public interface IInertiaSerializer +{ + public string Serialize(object? obj); + + public JsonResult SerializeResult(object? obj); +} diff --git a/InertiaCoreTests/Setup.cs b/InertiaCoreTests/Setup.cs index bb70e88..c357d49 100644 --- a/InertiaCoreTests/Setup.cs +++ b/InertiaCoreTests/Setup.cs @@ -22,11 +22,12 @@ public void Setup() var contextAccessor = new Mock(); var httpClientFactory = new Mock(); - var gateway = new Gateway(httpClientFactory.Object); + var serializer = new DefaultInertiaSerializer(); + var gateway = new Gateway(httpClientFactory.Object, serializer); 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, serializer, options.Object); } /// diff --git a/InertiaCoreTests/UnitTestConfiguration.cs b/InertiaCoreTests/UnitTestConfiguration.cs index 5cf8581..e07198c 100644 --- a/InertiaCoreTests/UnitTestConfiguration.cs +++ b/InertiaCoreTests/UnitTestConfiguration.cs @@ -9,6 +9,10 @@ namespace InertiaCoreTests; +internal class DummySerializer : DefaultInertiaSerializer +{ +} + public partial class Tests { [Test] @@ -28,6 +32,7 @@ public void TestConfiguration() Assert.That(builder.Services.Any(s => s.ServiceType == typeof(IResponseFactory)), Is.True); Assert.That(builder.Services.Any(s => s.ServiceType == typeof(IGateway)), Is.True); + Assert.That(builder.Services.Any(s => s.ServiceType == typeof(IInertiaSerializer)), Is.True); }); var mvcConfiguration = @@ -45,4 +50,30 @@ public void TestConfiguration() Assert.DoesNotThrow(() => Inertia.GetVersion()); } + + [Test] + [Description("Test if the configuration registers properly custom JSON serializer.")] + public void TestSerializerConfiguration() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddInertia(); + + Assert.Multiple(() => + { + Assert.That(builder.Services.Any(s => s.ServiceType == typeof(IInertiaSerializer)), Is.True); + + Assert.That(builder.Services.Any(s => s.ImplementationType == typeof(DefaultInertiaSerializer)), Is.True); + Assert.That(builder.Services.Any(s => s.ImplementationType == typeof(DummySerializer)), Is.False); + }); + + Assert.DoesNotThrow(() => builder.Services.UseInertiaSerializer()); + + Assert.Multiple(() => + { + Assert.That(builder.Services.Any(s => s.ServiceType == typeof(IInertiaSerializer)), Is.True); + + Assert.That(builder.Services.Any(s => s.ImplementationType == typeof(DefaultInertiaSerializer)), Is.False); + Assert.That(builder.Services.Any(s => s.ImplementationType == typeof(DummySerializer)), Is.True); + }); + } } diff --git a/README.md b/README.md index da416f7..275a5ac 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,15 @@ - [Installation](#installation) - [Getting started](#getting-started) - [Usage](#usage) - * [Frontend](#frontend) - * [Backend](#backend) + * [Frontend](#frontend) + * [Backend](#backend) - [Features](#features) - * [Shared data](#shared-data) - * [Async Lazy Props](#async-lazy-props) - * [Server-side rendering](#server-side-rendering) - * [Vite helper](#vite-helper) - - [Examples](#examples-1) + * [Shared data](#shared-data) + * [Async Lazy Props](#async-lazy-props) + * [Server-side rendering](#server-side-rendering) + * [Custom JSON serializer](#custom-json-serializer) + * [Vite helper](#vite-helper) + - [Examples](#examples-1) ## Examples @@ -162,7 +163,9 @@ app.Use(async (context, next) => ### Async Lazy Props -You can use async lazy props to load data asynchronously in your components. This is useful for loading data that is not needed for the initial render of the page. +You can use async lazy props to load data asynchronously in your components. This is useful for loading data that is not +needed for the initial render of the page. + ```csharp // simply use the LazyProps the same way you normally would, except pass in an async function @@ -194,7 +197,7 @@ If you want to enable SSR in your Inertia app, remember to add `Inertia.Head()` My App - + @await Inertia.Head(Model) @@ -217,9 +220,78 @@ builder.Services.AddInertia(options => }); ``` +### Custom JSON serializer + +You can use a custom JSON serializer in your app by creating a custom class implementing the `IInertiaSerializer` +interface: + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; + +public class CustomSerializer : IInertiaSerializer +{ + // Used in HTML responses + public string Serialize(object? obj) + { + // Default serialization + return JsonSerializer.Serialize(obj, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReferenceHandler = ReferenceHandler.IgnoreCycles + }); + } + + // Used in JSON responses + public JsonResult SerializeResult(object? obj) + { + // Default serialization + return new JsonResult(obj, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReferenceHandler = ReferenceHandler.IgnoreCycles + }); + } +} +``` + +or extending the `DefaultInertiaSerializer` class, which also implements the `IInertiaSerializer` interface: + +```csharp +public class CustomSerializer : DefaultInertiaSerializer +{ + protected new static JsonSerializerOptions GetOptions() + { + // Default options + return new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReferenceHandler = ReferenceHandler.IgnoreCycles + }; + } +} +``` + +You can then register it in the configuration: + +```csharp +builder.Services.AddInertia(); + +[...] + +builder.Services.UseInertiaSerializer(); + +[...] + +app.UseInertia(); +``` + ### Vite Helper -A Vite helper class is available to automatically load your generated styles or scripts by simply using the `@Vite.Input("src/main.tsx")` helper. You can also enable HMR when using React by using the `@Vite.ReactRefresh()` helper. This pairs well with the `laravel-vite-plugin` npm package. +A Vite helper class is available to automatically load your generated styles or scripts by simply using the +`@Vite.Input("src/main.tsx")` helper. You can also enable HMR when using React by using the `@Vite.ReactRefresh()` +helper. This pairs well with the `laravel-vite-plugin` npm package. To get started with the Vite Helper, you will need to add one more line to the `Program.cs` or `Starup.cs` file. @@ -241,7 +313,6 @@ builder.Services.AddViteHelper(options => }); ``` - #### Examples --- @@ -252,52 +323,52 @@ Here's an example for a TypeScript React app with HMR: @using InertiaCore.Utils - - - + + + My App - - - @* This has to go first, otherwise preamble error *@ - @Vite.ReactRefresh() - @await Inertia.Html(Model) - @Vite.Input("src/main.tsx") - + + +@* This has to go first, otherwise preamble error *@ +@Vite.ReactRefresh() +@await Inertia.Html(Model) +@Vite.Input("src/main.tsx") + ``` And here is the corresponding `vite.config.js` ```js -import { defineConfig } from "vite"; +import {defineConfig} from "vite"; import react from "@vitejs/plugin-react"; import laravel from "laravel-vite-plugin"; import path from "path"; -import { mkdirSync } from "fs"; +import {mkdirSync} from "fs"; // Auto-initialize the default output directory const outDir = "../wwwroot/build"; -mkdirSync(outDir, { recursive: true }); +mkdirSync(outDir, {recursive: true}); // https://vitejs.dev/config/ export default defineConfig({ - plugins: [ - laravel({ - input: ["src/main.tsx"], - publicDirectory: outDir, - }), - react(), - ], - resolve: { - alias: { - "@": path.resolve(__dirname, "src"), + plugins: [ + laravel({ + input: ["src/main.tsx"], + publicDirectory: outDir, + }), + react(), + ], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, + build: { + outDir, + emptyOutDir: true, }, - }, - build: { - outDir, - emptyOutDir: true, - }, }); ``` @@ -310,15 +381,15 @@ Here's an example for a TypeScript Vue app with Hot Reload: @using InertiaCore.Utils - - - + + + My App - - - @await Inertia.Html(Model) - @Vite.Input("src/app.ts") - + + +@await Inertia.Html(Model) +@Vite.Input("src/app.ts") + ``` @@ -336,30 +407,30 @@ const outDir = "../wwwroot/build"; mkdirSync(outDir, {recursive: true}); export default defineConfig({ - plugins: [ - laravel({ - input: ["src/app.ts"], - publicDirectory: outDir, - refresh: true, - }), - vue({ - template: { - transformAssetUrls: { - base: null, - includeAbsolute: false, + plugins: [ + laravel({ + input: ["src/app.ts"], + publicDirectory: outDir, + refresh: true, + }), + vue({ + template: { + transformAssetUrls: { + base: null, + includeAbsolute: false, + }, + }, + }), + ], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), }, - }, - }), - ], - resolve: { - alias: { - "@": path.resolve(__dirname, "src"), }, - }, - build: { - outDir, - emptyOutDir: true, - }, + build: { + outDir, + emptyOutDir: true, + }, }); ``` @@ -372,13 +443,13 @@ Here's an example that just produces a single CSS file: @using InertiaCore.Utils - - - - - - @await Inertia.Html(Model) - @Vite.Input("src/main.scss") - + + + + + +@await Inertia.Html(Model) +@Vite.Input("src/main.scss") + ```