Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: JSON ref responses #502

Merged
merged 5 commits into from
Dec 4, 2024
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions playground/Coalesce.Web.Vue3/src/components/test.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@
density="compact"
class="my-4"
error-messages="sdfsdf"
:params="{ refResponse: true }"
>
</c-select>
<c-select
:model="caseVm"
for="assignedTo"
variant="outlined"
density="compact"
class="my-4"
error-messages="sdfsdf"
:params="{ refResponse: true }"
>
</c-select>
</v-col>
Expand Down Expand Up @@ -147,6 +158,7 @@ export default class Test extends Base {
this.personList.$params.noCount = true;
this.personList.$load();

this.caseVm.$params.refResponse = true;
await this.caseVm.$load(15);
await this.caseVm.downloadImage();
await this.company.$load(1);
Expand Down
39 changes: 31 additions & 8 deletions src/IntelliTect.Coalesce/Api/Controllers/ApiActionFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@
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;
using Microsoft.EntityFrameworkCore.Storage;
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
{
Expand All @@ -23,6 +27,8 @@ public class ApiActionFilter : IApiActionFilter
protected readonly ILogger<ApiActionFilter> logger;
protected readonly IOptions<CoalesceOptions> options;

private static readonly MediaTypeHeaderValue RefTypeHeader = new MediaTypeHeaderValue("application/json+ref");

public ApiActionFilter(ILogger<ApiActionFilter> logger, IOptions<CoalesceOptions> options)
{
this.logger = logger;
Expand Down Expand Up @@ -113,15 +119,32 @@ 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;
if (context.HttpContext.Request.GetTypedHeaders().Accept.Any(h => h.IsSubsetOf(RefTypeHeader)))
{
var jsonOptions = context.HttpContext.RequestServices.GetService<IOptions<JsonOptions>>()?.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;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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;

/// <summary>
/// A modified implementation of the default <see cref="PreserveReferenceResolver"/> that
/// avoids putting IDs/refs on items that will never be duplicated (the root response, and collections).
/// </summary>
internal sealed class CoalesceJsonReferenceHandler : ReferenceHandler
{
public override ReferenceResolver CreateResolver() => new PreserveReferenceResolver();

/// <summary>
/// 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 <see cref="ApiResult"/>,
/// nor for any <see cref="ICollection"/>s because collections will never be duplicated in Coalesce responses.
/// </summary>
internal sealed class PreserveReferenceResolver : ReferenceResolver
{
private uint _referenceCount;
private readonly Dictionary<object, string>? _objectToReferenceIdMap;

public PreserveReferenceResolver()
{
_objectToReferenceIdMap = new Dictionary<object, string>(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();
}
}
4 changes: 2 additions & 2 deletions src/IntelliTect.Coalesce/Mapping/IMappingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ namespace IntelliTect.Coalesce
public interface IMappingContext
{
string? Includes { get; }
Dictionary<object, object> 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<TDto>(
object sourceObject,
IncludeTree? includeTree,
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)]
out TDto? mappedObject
)
Expand Down
7 changes: 3 additions & 4 deletions src/IntelliTect.Coalesce/Mapping/Mapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
10 changes: 5 additions & 5 deletions src/IntelliTect.Coalesce/Mapping/MappingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ public class MappingContext : IMappingContext

public IServiceProvider? Services { get; }

public Dictionary<object, object> MappedObjects { get; } = new();

private Dictionary<(object, IncludeTree?, Type), object> _mappedObjects { get; } = new();
private Dictionary<string, bool>? _roleCache;
private Dictionary<Type, IPropertyRestriction>? _restrictionCache;

Expand All @@ -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<TDto>(
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;
Expand Down
22 changes: 20 additions & 2 deletions src/coalesce-vue/src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,18 @@ export interface DataSourceParameters {
* Classes are found in `models.g.ts` as `<ModelName>.DataSources.<DataSourceName>`, e.g. `Person.DataSources.WithRelations`.
*/
dataSource?: DataSource<DataSourceType> | 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.
*/
refResponse?: boolean;
}
export class DataSourceParameters {
constructor() {
this.includes = null;
this.dataSource = null;
this.refResponse = false;
}
}

Expand Down Expand Up @@ -753,13 +760,25 @@ export class ApiClient<T extends ApiRoutedType> {
query = mappedParams;
}

let headers = config?.headers;
if (standardParameters?.refResponse) {
headers = {
...config?.headers,
Accept: standardParameters?.refResponse
? ["application/json+ref", "application/json"]
: ["application/json"],
};
}
let cacheKey = JSON.stringify(headers);

const axiosRequest = <AxiosRequestConfig>{
method: method.httpMethod,
url: url,
data: body,
responseType: method.return.type == "file" ? "blob" : "json",
cancelToken: this._cancelToken,
...config,
headers,
params: {
...query,
...(config && config.params ? config.params : null),
Expand All @@ -780,14 +799,13 @@ export class ApiClient<T extends ApiRoutedType> {
}

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 {
Expand Down
16 changes: 16 additions & 0 deletions src/coalesce-vue/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ export function parseValue(

class ModelConversionVisitor extends Visitor<any, any[] | null, any | null> {
private objects = new Map<object, object>();
private refs = new Map<object, object>();

public override visitValue(value: any, meta: Value): any {
// Models shouldn't contain undefined - only nulls where a value isn't present.
Expand All @@ -314,6 +315,21 @@ class ModelConversionVisitor extends Visitor<any, any[] | null, any | null> {
if (this.objects.has(value))
return this.objects.get(value)! as Model<TMeta>;

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<Model<TMeta>>;
Expand Down
Loading