Skip to content

Commit

Permalink
feat: JSON ref responses
Browse files Browse the repository at this point in the history
  • Loading branch information
ascott18 committed Nov 28, 2024
1 parent 2099985 commit 231d42d
Show file tree
Hide file tree
Showing 8 changed files with 137 additions and 19 deletions.
2 changes: 2 additions & 0 deletions playground/Coalesce.Web.Vue3/src/components/test.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
density="compact"
class="my-4"
error-messages="sdfsdf"
:params="{useRef: true}"
>
</c-select>
</v-col>
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 30 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 Down Expand Up @@ -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<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,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();

/// <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
17 changes: 17 additions & 0 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.
*/
useRef?: boolean;
}
export class DataSourceParameters {
constructor() {
this.includes = null;
this.dataSource = null;
this.useRef = false;
}
}

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

let headers = config?.headers;
if (standardParameters?.useRef) {
headers = {
...config?.headers,
Accept: standardParameters?.useRef
? ["application/json+ref", "application/json"]
: ["application/json"],
};
}
const axiosRequest = <AxiosRequestConfig>{
method: method.httpMethod,
url: url,
data: body,
responseType: method.return.type == "file" ? "blob" : "json",
cancelToken: this._cancelToken,
headers,
...config,
params: {
...query,
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 @@ -250,6 +250,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 @@ -276,6 +277,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

0 comments on commit 231d42d

Please sign in to comment.