From 231d42dbc16bed03920130e3bcc346cd145bae9d Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 27 Nov 2024 23:04:18 -0800 Subject: [PATCH] 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 e94742fce..59b42b38f 100644 --- a/playground/Coalesce.Web.Vue3/src/components/test.vue +++ b/playground/Coalesce.Web.Vue3/src/components/test.vue @@ -12,6 +12,7 @@ density="compact" class="my-4" error-messages="sdfsdf" + :params="{useRef: true}" > @@ -163,6 +164,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 21e94bfa3..e87e7bb97 100644 --- a/src/coalesce-vue/src/model.ts +++ b/src/coalesce-vue/src/model.ts @@ -250,6 +250,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. @@ -276,6 +277,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>;