Skip to content
Open
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 src/StrawberryShake/Client/StrawberryShake.Client.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<Folder Name="/test/">
<Project Path="test/Core.Tests/StrawberryShake.Core.Tests.csproj" />
<Project Path="test/Persistence.SQLite.Tests/StrawberryShake.Persistence.SQLite.Tests.csproj" />
<Project Path="test/Razor.Tests/StrawberryShake.Razor.Tests.csproj" />
<Project Path="test/Transport.Http.Tests/StrawberryShake.Transport.Http.Tests.csproj" />
<Project Path="test/Transport.InMemory.Tests/StrawberryShake.Transport.InMemory.Tests.csproj" />
<Project Path="test/Transport.WebSocket.Tests/StrawberryShake.Transport.WebSocket.Tests.csproj" />
Expand Down
15 changes: 15 additions & 0 deletions src/StrawberryShake/Client/src/Core/IOperationResultBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,19 @@ public interface IOperationResultBuilder<TResponseBody, TResultData>
/// </returns>
IOperationResult<TResultData> Build(
Response<TResponseBody> response);

/// <summary>
/// Builds a runtime operation result from a previously persisted transport "data"
/// payload, without executing the operation. This is used to rehydrate state that was
/// captured during a server prerender.
/// </summary>
/// <param name="persistedData">
/// The UTF-8 encoded JSON of the GraphQL response "data" object.
/// </param>
/// <returns>
/// Returns the runtime result.
/// </returns>
IOperationResult<TResultData> BuildFromPersistedData(ReadOnlyMemory<byte> persistedData)
=> throw new NotSupportedException(
"This operation result builder does not support rehydrating from persisted data.");
}
54 changes: 53 additions & 1 deletion src/StrawberryShake/Client/src/Core/OperationResultBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Buffers;
using System.Text.Json;
using StrawberryShake.Json;
using static StrawberryShake.ResultFields;
Expand All @@ -24,6 +25,7 @@ public IOperationResult<TResultData> Build(
IOperationResultDataInfo? dataInfo = null;
IReadOnlyList<IClientError>? errors = null;
IReadOnlyDictionary<string, object?>? extensions = null;
JsonElement? persistedData = null;

try
{
Expand All @@ -34,6 +36,11 @@ public IOperationResult<TResultData> Build(
{
dataInfo = BuildData(dataProp);
data = ResultDataFactory.Create(dataInfo);

if (CapturePersistedData)
{
persistedData = dataProp.Clone();
}
}

if (body.RootElement.TryGetProperty(Errors, out var errorsProp)
Expand Down Expand Up @@ -90,13 +97,58 @@ public IOperationResult<TResultData> Build(
};
}

var contextData = response.ContextData;

if (persistedData is { } captured)
{
var augmented = contextData is null
? new Dictionary<string, object?>()
: new Dictionary<string, object?>(contextData);
augmented[WellKnownContextData.PersistedData] = captured;
contextData = augmented;
}

return new OperationResult<TResultData>(
data,
dataInfo,
ResultDataFactory,
errors,
extensions,
response.ContextData);
contextData);
}

/// <summary>
/// When overridden to return <c>true</c>, the raw transport "data" payload is captured
/// into <see cref="IOperationResult.ContextData"/> under
/// <see cref="WellKnownContextData.PersistedData"/> so it can be persisted and later
/// rehydrated via <see cref="BuildFromPersistedData"/>.
/// </summary>
protected virtual bool CapturePersistedData => false;

/// <summary>
/// Builds a runtime operation result from a previously persisted transport "data"
/// payload, without executing the operation.
/// </summary>
/// <param name="persistedData">
/// The UTF-8 encoded JSON of the GraphQL response "data" object.
/// </param>
/// <returns>
/// Returns the runtime result.
/// </returns>
public IOperationResult<TResultData> BuildFromPersistedData(ReadOnlyMemory<byte> persistedData)
{
var bufferWriter = new ArrayBufferWriter<byte>();

using (var writer = new Utf8JsonWriter(bufferWriter))
{
writer.WriteStartObject();
writer.WritePropertyName(Data);
writer.WriteRawValue(persistedData.Span, skipInputValidation: true);
writer.WriteEndObject();
}

using var document = JsonDocument.Parse(bufferWriter.WrittenMemory);
return Build(new Response<JsonDocument>(document, null));
}

protected abstract IOperationResultDataInfo BuildData(JsonElement obj);
Expand Down
15 changes: 15 additions & 0 deletions src/StrawberryShake/Client/src/Core/WellKnownContextData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace StrawberryShake;

/// <summary>
/// Well known keys for <see cref="IOperationResult.ContextData"/>.
/// </summary>
public static class WellKnownContextData
{
/// <summary>
/// The key under which the raw transport "data" payload of an operation result is
/// captured. This allows the payload to be persisted (for example via Blazor's
/// <c>PersistentComponentState</c>) and later rehydrated without re-executing the
/// operation.
/// </summary>
public const string PersistedData = "StrawberryShake.PersistedData";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#if NET10_0_OR_GREATER
using System.Buffers;
using System.Text.Json;
using Microsoft.AspNetCore.Components;

namespace StrawberryShake.Razor;

/// <summary>
/// A <see cref="PersistentComponentStateSerializer{T}"/> for StrawberryShake operation
/// results. It persists the raw transport "data" payload that was captured on the result and
/// rehydrates it through the operation's result builder. This lets a property of type
/// <c>IOperationResult&lt;TData&gt;</c> annotated with <c>[PersistentState]</c> survive the
/// prerender to interactive boundary without re-executing the operation.
/// </summary>
/// <typeparam name="TData">
/// The operation result type.
/// </typeparam>
public sealed class OperationResultPersistentStateSerializer<TData>
: PersistentComponentStateSerializer<IOperationResult<TData>>
where TData : class
{
private static readonly byte[] s_emptyData = "{}"u8.ToArray();

private readonly IOperationResultBuilder<JsonDocument, TData> _resultBuilder;

public OperationResultPersistentStateSerializer(
IOperationResultBuilder<JsonDocument, TData> resultBuilder)
{
ArgumentNullException.ThrowIfNull(resultBuilder);

_resultBuilder = resultBuilder;
}

public override void Persist(IOperationResult<TData> value, IBufferWriter<byte> writer)
{
ArgumentNullException.ThrowIfNull(value);
ArgumentNullException.ThrowIfNull(writer);

if (value.ContextData.TryGetValue(WellKnownContextData.PersistedData, out var payload)
&& payload is JsonElement element)
{
using var jsonWriter = new Utf8JsonWriter(writer);
element.WriteTo(jsonWriter);
}
else
{
// No captured payload (for example an error result): persist an empty data
// object so the value still round-trips to a non-null result.
writer.Write(s_emptyData);
}
}

public override IOperationResult<TData> Restore(ReadOnlySequence<byte> data)
{
return _resultBuilder.BuildFromPersistedData(data.ToArray());
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#if NET10_0_OR_GREATER
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;

namespace StrawberryShake.Razor;

/// <summary>
/// Service collection extensions for persisting StrawberryShake operation results across
/// the Blazor prerender to interactive boundary.
/// </summary>
public static class StrawberryShakePersistentStateServiceCollectionExtensions
{
/// <summary>
/// Registers a <see cref="PersistentComponentStateSerializer{T}"/> for
/// <c>IOperationResult&lt;TData&gt;</c>, so that a component or service property of that
/// type annotated with <c>[PersistentState]</c> is persisted during a server prerender
/// and rehydrated on the interactive client without re-executing the operation.
/// </summary>
/// <typeparam name="TData">
/// The operation result type (the type argument of <c>IOperationResult&lt;&gt;</c>).
/// </typeparam>
/// <param name="services">
/// The service collection. The matching StrawberryShake client must already be
/// registered so that <c>IOperationResultBuilder&lt;JsonDocument, TData&gt;</c> can be
/// resolved.
/// </param>
/// <returns>
/// The service collection so that further calls can be chained.
/// </returns>
public static IServiceCollection AddPersistentOperationResult<TData>(
this IServiceCollection services)
where TData : class
{
ArgumentNullException.ThrowIfNull(services);

services.AddSingleton<
PersistentComponentStateSerializer<IOperationResult<TData>>,
OperationResultPersistentStateSerializer<TData>>();

return services;
}
}
#endif
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Buffers;
using System.Text.Json;

namespace StrawberryShake;
Expand Down Expand Up @@ -35,6 +36,72 @@ public void Build_With_Extensions()
Assert.Equal(3.14, result.Extensions["d"]);
}

[Fact]
public void Build_With_Capture_Stores_Data_Payload_In_ContextData()
{
// arrange
var builder = new RecordingOperationResultBuilder(new DocumentDataFactory());
var response = new Response<JsonDocument>(
JsonDocument.Parse(@"{""data"": { ""name"": ""Strawberry"" } }"),
null);

// act
var result = builder.Build(response);

// assert
Assert.True(
result.ContextData.TryGetValue(WellKnownContextData.PersistedData, out var payload));
var element = Assert.IsType<JsonElement>(payload);
Assert.Equal("Strawberry", element.GetProperty("name").GetString());
}

[Fact]
public void Build_Without_Capture_Does_Not_Store_Data_Payload()
{
// arrange
var builder = new DocumentOperationResultBuilder(new DocumentDataFactory());
var response = new Response<JsonDocument>(
JsonDocument.Parse(@"{""data"": { ""name"": ""Strawberry"" } }"),
null);

// act
var result = builder.Build(response);

// assert
Assert.Empty(result.ContextData);
}

[Fact]
public void BuildFromPersistedData_RoundTrips_The_Captured_Payload()
{
// arrange
var builder = new RecordingOperationResultBuilder(new DocumentDataFactory());
var captured = builder.Build(
new Response<JsonDocument>(
JsonDocument.Parse(@"{""data"": { ""name"": ""Strawberry"" } }"),
null));
var payload = (JsonElement)captured.ContextData[WellKnownContextData.PersistedData]!;

// act
var rehydrated = builder.BuildFromPersistedData(ToUtf8(payload));

// assert
Assert.NotNull(rehydrated.Data);
Assert.Equal("Strawberry", builder.LastData!.Value.GetProperty("name").GetString());
}

private static ReadOnlyMemory<byte> ToUtf8(JsonElement element)
{
var buffer = new ArrayBufferWriter<byte>();

using (var writer = new Utf8JsonWriter(buffer))
{
element.WriteTo(writer);
}

return buffer.WrittenMemory;
}

internal class Document;

internal class DocumentDataInfo : IOperationResultDataInfo
Expand Down Expand Up @@ -78,4 +145,25 @@ protected override IOperationResultDataInfo BuildData(JsonElement obj)
return new DocumentDataInfo();
}
}

internal sealed class RecordingOperationResultBuilder : OperationResultBuilder<Document>
{
public RecordingOperationResultBuilder(
IOperationResultDataFactory<Document> resultDataFactory)
{
ResultDataFactory = resultDataFactory;
}

public JsonElement? LastData { get; private set; }

protected override bool CapturePersistedData => true;

protected override IOperationResultDataFactory<Document> ResultDataFactory { get; }

protected override IOperationResultDataInfo BuildData(JsonElement obj)
{
LastData = obj.Clone();
return new DocumentDataInfo();
}
}
}
Loading
Loading