From d10b7dc88d4accee10116a2f0eef25ed8cb65e88 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 27 Nov 2024 23:04:18 -0800 Subject: [PATCH 1/5] feat: JSON ref responses --- .../Coalesce.Web.Vue3/src/components/test.vue | 2 + .../Api/Controllers/ApiActionFilter.cs | 38 +++++++++--- .../CoalesceJsonReferenceHandler.cs | 62 +++++++++++++++++++ .../Mapping/IMappingContext.cs | 4 +- src/IntelliTect.Coalesce/Mapping/Mapper.cs | 7 +-- .../Mapping/MappingContext.cs | 10 +-- src/coalesce-vue/src/api-client.ts | 17 +++++ src/coalesce-vue/src/model.ts | 16 +++++ 8 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 src/IntelliTect.Coalesce/Api/Controllers/CoalesceJsonReferenceHandler.cs diff --git a/playground/Coalesce.Web.Vue3/src/components/test.vue b/playground/Coalesce.Web.Vue3/src/components/test.vue index 824900ed8..aaa9356a6 100644 --- a/playground/Coalesce.Web.Vue3/src/components/test.vue +++ b/playground/Coalesce.Web.Vue3/src/components/test.vue @@ -20,6 +20,7 @@ density="compact" class="my-4" error-messages="sdfsdf" + :params="{useRef: true}" > @@ -147,6 +148,7 @@ export default class Test extends Base { this.personList.$params.noCount = true; this.personList.$load(); + this.caseVm.$params.useRef = true; await this.caseVm.$load(15); await this.caseVm.downloadImage(); await this.company.$load(1); diff --git a/src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs b/src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs index 2ddcd9529..68cc5f242 100644 --- a/src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs +++ b/src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; @@ -9,12 +10,15 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; using System; using System.Collections.Generic; using System.Data.Common; using System.Linq; using System.Net; using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; namespace IntelliTect.Coalesce.Api.Controllers { @@ -113,15 +117,33 @@ public virtual void OnActionExecuted(ActionExecutedContext context) } } - if (response.StatusCode == (int)HttpStatusCode.OK - && context.Result is ObjectResult result - && result.Value is ApiResult apiResult - && !apiResult.WasSuccessful - ) + if (context.Result is ObjectResult result) { - result.StatusCode = - response.StatusCode = - (int)HttpStatusCode.BadRequest; + var refHeader = new MediaTypeHeaderValue("application/json+ref"); + if (context.HttpContext.Request.GetTypedHeaders().Accept.Any(h => h.IsSubsetOf(refHeader))) + { + var jsonOptions = context.HttpContext.RequestServices.GetService>()?.Value ?? new JsonOptions + { + JsonSerializerOptions = + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + } + }; + var newOptions = new JsonSerializerOptions(jsonOptions.JsonSerializerOptions) + { + ReferenceHandler = new CoalesceJsonReferenceHandler() + }; + result.Formatters.Add(new SystemTextJsonOutputFormatter(newOptions)); + } + + if (response.StatusCode == (int)HttpStatusCode.OK && + result.Value is ApiResult apiResult && + !apiResult.WasSuccessful) + { + result.StatusCode = + response.StatusCode = + (int)HttpStatusCode.BadRequest; + } } } diff --git a/src/IntelliTect.Coalesce/Api/Controllers/CoalesceJsonReferenceHandler.cs b/src/IntelliTect.Coalesce/Api/Controllers/CoalesceJsonReferenceHandler.cs new file mode 100644 index 000000000..ef16d8ea1 --- /dev/null +++ b/src/IntelliTect.Coalesce/Api/Controllers/CoalesceJsonReferenceHandler.cs @@ -0,0 +1,62 @@ +using IntelliTect.Coalesce.Models; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json.Serialization; + +namespace IntelliTect.Coalesce.Api.Controllers; + + +internal sealed class CoalesceJsonReferenceHandler : ReferenceHandler +{ + public override ReferenceResolver CreateResolver() => new PreserveReferenceResolver(); + + /// + /// From https://source.dot.net/#System.Text.Json/System/Text/Json/Serialization/PreserveReferenceResolver.cs + /// Same as the normal reference handler, but doesn't make refs for the root , + /// nor for any s because collections will never be duplicated in Coalesce responses. + /// + internal sealed class PreserveReferenceResolver : ReferenceResolver + { + private uint _referenceCount; + private readonly Dictionary? _objectToReferenceIdMap; + + public PreserveReferenceResolver() + { + _objectToReferenceIdMap = new Dictionary(ReferenceEqualityComparer.Instance); + } + + public override void AddReference(string referenceId, object value) + => throw new NotSupportedException(); + + public override string GetReference(object value, out bool alreadyExists) + { + Debug.Assert(_objectToReferenceIdMap != null); + if (value is ApiResult || value is ICollection) + { + // Don't produce refs for the root response object, + // nor for collections (collections will never be duplicated) + alreadyExists = false; + return null!; + } + + if (_objectToReferenceIdMap.TryGetValue(value, out string? referenceId)) + { + alreadyExists = true; + } + else + { + _referenceCount++; + referenceId = _referenceCount.ToString(); + _objectToReferenceIdMap.Add(value, referenceId); + alreadyExists = false; + } + + return referenceId; + } + + public override object ResolveReference(string referenceId) + => throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/src/IntelliTect.Coalesce/Mapping/IMappingContext.cs b/src/IntelliTect.Coalesce/Mapping/IMappingContext.cs index d478e4339..18cebdbf3 100644 --- a/src/IntelliTect.Coalesce/Mapping/IMappingContext.cs +++ b/src/IntelliTect.Coalesce/Mapping/IMappingContext.cs @@ -8,15 +8,15 @@ namespace IntelliTect.Coalesce public interface IMappingContext { string? Includes { get; } - Dictionary MappedObjects { get; } ClaimsPrincipal User { get; } bool IsInRoleCached(string role); - void AddMapping(object sourceObject, object mappedObject); + void AddMapping(object sourceObject, IncludeTree? includeTree, object mappedObject); bool TryGetMapping( object sourceObject, + IncludeTree? includeTree, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out TDto? mappedObject ) diff --git a/src/IntelliTect.Coalesce/Mapping/Mapper.cs b/src/IntelliTect.Coalesce/Mapping/Mapper.cs index 3e6eee593..a70101bc5 100644 --- a/src/IntelliTect.Coalesce/Mapping/Mapper.cs +++ b/src/IntelliTect.Coalesce/Mapping/Mapper.cs @@ -13,12 +13,11 @@ public static class Mapper { if (obj == null) return default; - // See if the object is already created, but only if we aren't restricting by an includes tree. - // If we do have an IncludeTree, we know the exact structure of our return data, so we don't need to worry about circular refs. - if (tree == null && context.TryGetMapping(obj, out TDto? existing)) return existing; + // See if we already mapped this object: + if (context.TryGetMapping(obj, tree, out TDto? existing)) return existing; var dto = new TDto(); - if (tree == null) context.AddMapping(obj, dto); + context.AddMapping(obj, tree, dto); dto.MapFrom(obj, context, tree); diff --git a/src/IntelliTect.Coalesce/Mapping/MappingContext.cs b/src/IntelliTect.Coalesce/Mapping/MappingContext.cs index 52f857ea8..b463ec569 100644 --- a/src/IntelliTect.Coalesce/Mapping/MappingContext.cs +++ b/src/IntelliTect.Coalesce/Mapping/MappingContext.cs @@ -16,8 +16,7 @@ public class MappingContext : IMappingContext public IServiceProvider? Services { get; } - public Dictionary MappedObjects { get; } = new(); - + private Dictionary<(object, IncludeTree?, Type), object> _mappedObjects { get; } = new(); private Dictionary? _roleCache; private Dictionary? _restrictionCache; @@ -43,19 +42,20 @@ public bool IsInRoleCached(string role) return _roleCache[role] = User?.IsInRole(role) ?? false; } - public void AddMapping(object sourceObject, object mappedObject) + public void AddMapping(object sourceObject, IncludeTree? includeTree, object mappedObject) { - MappedObjects[sourceObject] = mappedObject; + _mappedObjects[(sourceObject, includeTree, mappedObject.GetType())] = mappedObject; } public bool TryGetMapping( object sourceObject, + IncludeTree? includeTree, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out TDto? mappedObject ) where TDto : class { - if (!MappedObjects.TryGetValue(sourceObject, out object? existingMapped) || existingMapped is not TDto) + if (!_mappedObjects.TryGetValue((sourceObject, includeTree, typeof(TDto)), out object? existingMapped)) { mappedObject = default; return false; diff --git a/src/coalesce-vue/src/api-client.ts b/src/coalesce-vue/src/api-client.ts index 5cb7d46e9..cdb4ae7eb 100644 --- a/src/coalesce-vue/src/api-client.ts +++ b/src/coalesce-vue/src/api-client.ts @@ -86,11 +86,18 @@ export interface DataSourceParameters { * Classes are found in `models.g.ts` as `.DataSources.`, e.g. `Person.DataSources.WithRelations`. */ dataSource?: DataSource | null; + + /** If true, request that the server use System.Text.Json reference preservation handling when serializing the response, + * which can significantly reduce the size of the response payload. This will also cause the resulting + * `Model` and `ViewModel` instances on the client to be deduplicated. + */ + useRef?: boolean; } export class DataSourceParameters { constructor() { this.includes = null; this.dataSource = null; + this.useRef = false; } } @@ -753,12 +760,22 @@ export class ApiClient { query = mappedParams; } + let headers = config?.headers; + if (standardParameters?.useRef) { + headers = { + ...config?.headers, + Accept: standardParameters?.useRef + ? ["application/json+ref", "application/json"] + : ["application/json"], + }; + } const axiosRequest = { method: method.httpMethod, url: url, data: body, responseType: method.return.type == "file" ? "blob" : "json", cancelToken: this._cancelToken, + headers, ...config, params: { ...query, diff --git a/src/coalesce-vue/src/model.ts b/src/coalesce-vue/src/model.ts index fdb66a46f..b7a91c033 100644 --- a/src/coalesce-vue/src/model.ts +++ b/src/coalesce-vue/src/model.ts @@ -288,6 +288,7 @@ export function parseValue( class ModelConversionVisitor extends Visitor { private objects = new Map(); + private refs = new Map(); public override visitValue(value: any, meta: Value): any { // Models shouldn't contain undefined - only nulls where a value isn't present. @@ -314,6 +315,21 @@ class ModelConversionVisitor extends Visitor { if (this.objects.has(value)) return this.objects.get(value)! as Model; + if ("$ref" in value) { + const target = this.refs.get(value.$ref); + if (!target) { + console.warn(`Unresolved $ref ${value.$ref} in object`); + } + value = target; + } + + if ("$id" in value) { + this.refs.set(value.$id, value); + if (this.mode == "convert") { + delete value.$id; + } + } + const props = meta.props; let target: Indexable>; From 5a0598184f8f33078815c532fa47ca3aa7e31318 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 3 Dec 2024 13:55:12 -0800 Subject: [PATCH 2/5] changelog --- CHANGELOG.md | 1 + src/coalesce-vue/src/api-client.ts | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7294070a..bbbe9a64a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - `c-select-many-to-many` is now based on `c-select` rather than `v-autocomplete`. As a result, it has gained support for all of the props and slots of `c-select`. - Added strong types for pass-through Vuetify slots and props to `c-input`, `c-select`, `c-select-many-to-many`, and `c-datetime-picker`. - Added a `color` prop to `c-datetime-picker`. +- Added experimental client-side support for System.Text.Json's PreserveReferences reference handling option in server responses. This does not require changes to your JSON settings in Program.cs - instead, it is activated by setting `refResponse` on the `DataSourceParameters` for a request (i.e. the `$params` object on a ViewModel or ListViewModel). This option can significantly reduce response sizes in cases where the same object occurs many times in a response. # 5.2.1 diff --git a/src/coalesce-vue/src/api-client.ts b/src/coalesce-vue/src/api-client.ts index cdb4ae7eb..c8440f8a0 100644 --- a/src/coalesce-vue/src/api-client.ts +++ b/src/coalesce-vue/src/api-client.ts @@ -91,13 +91,13 @@ export interface DataSourceParameters { * which can significantly reduce the size of the response payload. This will also cause the resulting * `Model` and `ViewModel` instances on the client to be deduplicated. */ - useRef?: boolean; + refResponse?: boolean; } export class DataSourceParameters { constructor() { this.includes = null; this.dataSource = null; - this.useRef = false; + this.refResponse = false; } } @@ -761,10 +761,10 @@ export class ApiClient { } let headers = config?.headers; - if (standardParameters?.useRef) { + if (standardParameters?.refResponse) { headers = { ...config?.headers, - Accept: standardParameters?.useRef + Accept: standardParameters?.refResponse ? ["application/json+ref", "application/json"] : ["application/json"], }; @@ -775,8 +775,8 @@ export class ApiClient { data: body, responseType: method.return.type == "file" ? "blob" : "json", cancelToken: this._cancelToken, - headers, ...config, + headers, params: { ...query, ...(config && config.params ? config.params : null), From a46b3734adae8970f2641878ff2ea502b00d2c72 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 3 Dec 2024 13:56:18 -0800 Subject: [PATCH 3/5] new name --- playground/Coalesce.Web.Vue3/src/components/test.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/playground/Coalesce.Web.Vue3/src/components/test.vue b/playground/Coalesce.Web.Vue3/src/components/test.vue index aaa9356a6..c1400da0f 100644 --- a/playground/Coalesce.Web.Vue3/src/components/test.vue +++ b/playground/Coalesce.Web.Vue3/src/components/test.vue @@ -20,7 +20,7 @@ density="compact" class="my-4" error-messages="sdfsdf" - :params="{useRef: true}" + :params="{ refResponse: true }" > @@ -148,7 +148,7 @@ export default class Test extends Base { this.personList.$params.noCount = true; this.personList.$load(); - this.caseVm.$params.useRef = true; + this.caseVm.$params.refResponse = true; await this.caseVm.$load(15); await this.caseVm.downloadImage(); await this.company.$load(1); From 30391cafb671633b10eac212beadd7f8b30f7fef Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 3 Dec 2024 14:00:28 -0800 Subject: [PATCH 4/5] cleanup --- src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs | 5 +++-- .../Api/Controllers/CoalesceJsonReferenceHandler.cs | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs b/src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs index 68cc5f242..d9dc07f5e 100644 --- a/src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs +++ b/src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs @@ -27,6 +27,8 @@ public class ApiActionFilter : IApiActionFilter protected readonly ILogger logger; protected readonly IOptions options; + private static readonly MediaTypeHeaderValue RefTypeHeader = new MediaTypeHeaderValue("application/json+ref"); + public ApiActionFilter(ILogger logger, IOptions options) { this.logger = logger; @@ -119,8 +121,7 @@ public virtual void OnActionExecuted(ActionExecutedContext context) if (context.Result is ObjectResult result) { - var refHeader = new MediaTypeHeaderValue("application/json+ref"); - if (context.HttpContext.Request.GetTypedHeaders().Accept.Any(h => h.IsSubsetOf(refHeader))) + if (context.HttpContext.Request.GetTypedHeaders().Accept.Any(h => h.IsSubsetOf(RefTypeHeader))) { var jsonOptions = context.HttpContext.RequestServices.GetService>()?.Value ?? new JsonOptions { diff --git a/src/IntelliTect.Coalesce/Api/Controllers/CoalesceJsonReferenceHandler.cs b/src/IntelliTect.Coalesce/Api/Controllers/CoalesceJsonReferenceHandler.cs index ef16d8ea1..7ec245fcb 100644 --- a/src/IntelliTect.Coalesce/Api/Controllers/CoalesceJsonReferenceHandler.cs +++ b/src/IntelliTect.Coalesce/Api/Controllers/CoalesceJsonReferenceHandler.cs @@ -7,7 +7,10 @@ namespace IntelliTect.Coalesce.Api.Controllers; - +/// +/// A modified implementation of the default that +/// avoids putting IDs/refs on items that will never be duplicated (the root response, and collections). +/// internal sealed class CoalesceJsonReferenceHandler : ReferenceHandler { public override ReferenceResolver CreateResolver() => new PreserveReferenceResolver(); From cb9dba51ee67befe3096c59573d0fb690dd762fa Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 3 Dec 2024 14:13:20 -0800 Subject: [PATCH 5/5] fix: interaction between refResponse and simultaneousGetCaching --- playground/Coalesce.Web.Vue3/src/components/test.vue | 10 ++++++++++ src/coalesce-vue/src/api-client.ts | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/playground/Coalesce.Web.Vue3/src/components/test.vue b/playground/Coalesce.Web.Vue3/src/components/test.vue index c1400da0f..b6324b936 100644 --- a/playground/Coalesce.Web.Vue3/src/components/test.vue +++ b/playground/Coalesce.Web.Vue3/src/components/test.vue @@ -23,6 +23,16 @@ :params="{ refResponse: true }" > + + { : ["application/json"], }; } + let cacheKey = JSON.stringify(headers); + const axiosRequest = { method: method.httpMethod, url: url, @@ -797,14 +799,13 @@ export class ApiClient { } let doCache = false; - let cacheKey: string; if ( method.httpMethod === "GET" && this._simultaneousGetCaching && !config ) { - cacheKey = AxiosClient.getUri(axiosRequest); + cacheKey += "_" + AxiosClient.getUri(axiosRequest); if (simultaneousGetCache.has(cacheKey)) { return simultaneousGetCache.get(cacheKey) as any; } else {