diff --git a/AspNetCore.Sequence/CHANGELOG.md b/AspNetCore.Sequence/CHANGELOG.md new file mode 100644 index 0000000..9c00e7d --- /dev/null +++ b/AspNetCore.Sequence/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog — Juner.AspNetCore.Sequence + +## [1.0.0] — 2026-03-31 + +### Added + +- Initial release of ASP.NET Core integration for streaming JSON. +- Streaming **output** via: + - `JsonSequenceResult` + - `JsonLineResult` + - `NdJsonResult` + - `SequenceResult` (content negotiation) +- Streaming **input** via: + - `Sequence` (Minimal API model binding) + - MVC `SequenceInputFormatter` +- Streaming **output** for MVC via: + - `JsonSequenceOutputFormatter` + - `JsonLineOutputFormatter` + - `NdJsonOutputFormatter` +- Content negotiation for streaming formats (`Accept` header). +- OpenAPI (.NET 10+) support: + - `x-streaming: true` + - `x-itemSchema` + - Per‑format content types +- Minimal API integration: + - `TypedResults.JsonSequence` + - `TypedResults.JsonLine` + - `TypedResults.NdJson` + - `TypedResults.Sequence` +- MVC integration: + - `[Consumes]` / `[Produces]` metadata for streaming formats + - Controller binding for `Sequence` + +### Notes + +- Minimal API streaming is **AOT‑safe**. +- MVC streaming uses formatters and is **not AOT‑safe** due to ASP.NET Core limitations. +- JSON Array (`application/json`) is accepted/returned only as a **non‑streaming convenience**. diff --git a/AspNetCore.Sequence/Http/Content.cs b/AspNetCore.Sequence/Http/Content.cs index 2b63289..a5f33ab 100644 --- a/AspNetCore.Sequence/Http/Content.cs +++ b/AspNetCore.Sequence/Http/Content.cs @@ -5,4 +5,4 @@ namespace Juner.AspNetCore.Sequence.Http; /// /// /// -public record Content(string ContentType, bool IsStreaming) : IContent; \ No newline at end of file +public record Content(string ContentType, bool IsStreaming, Type? NoStreamingType = null) : IContent; \ No newline at end of file diff --git a/AspNetCore.Sequence/Http/HttpResults/JsonLineResultOfT.cs b/AspNetCore.Sequence/Http/HttpResults/JsonLineResultOfT.cs index dddda28..798b941 100644 --- a/AspNetCore.Sequence/Http/HttpResults/JsonLineResultOfT.cs +++ b/AspNetCore.Sequence/Http/HttpResults/JsonLineResultOfT.cs @@ -2,6 +2,9 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using System.Reflection; using System.Threading.Channels; @@ -58,4 +61,6 @@ public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) typeof(T), [new Content(CONTENT_TYPE, true)])); } + + protected override ILogger GetLogger(IServiceProvider provider) => provider.GetService>>() ?? (ILogger)NullLogger.Instance; } \ No newline at end of file diff --git a/AspNetCore.Sequence/Http/HttpResults/JsonSequenceResultOfT.cs b/AspNetCore.Sequence/Http/HttpResults/JsonSequenceResultOfT.cs index c4d45bc..2c3b0c2 100644 --- a/AspNetCore.Sequence/Http/HttpResults/JsonSequenceResultOfT.cs +++ b/AspNetCore.Sequence/Http/HttpResults/JsonSequenceResultOfT.cs @@ -2,6 +2,9 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using System.Reflection; using System.Threading.Channels; @@ -63,4 +66,5 @@ public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) typeof(T), [new Content(CONTENT_TYPE, true)])); } + protected override ILogger GetLogger(IServiceProvider provider) => provider.GetService>>() ?? (ILogger)NullLogger.Instance; } \ No newline at end of file diff --git a/AspNetCore.Sequence/Http/HttpResults/NdJsonResultOfT.cs b/AspNetCore.Sequence/Http/HttpResults/NdJsonResultOfT.cs index 87f5a0c..a348e7b 100644 --- a/AspNetCore.Sequence/Http/HttpResults/NdJsonResultOfT.cs +++ b/AspNetCore.Sequence/Http/HttpResults/NdJsonResultOfT.cs @@ -2,6 +2,9 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using System.Reflection; using System.Threading.Channels; @@ -57,4 +60,5 @@ public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) typeof(T), [new Content(CONTENT_TYPE, true)])); } + protected override ILogger GetLogger(IServiceProvider provider) => provider.GetService>>() ?? (ILogger)NullLogger.Instance; } \ No newline at end of file diff --git a/AspNetCore.Sequence/Http/HttpResults/SequenceResultBaseOfT.ExecuteAsync.cs b/AspNetCore.Sequence/Http/HttpResults/SequenceResultBaseOfT.ExecuteAsync.cs index a39e4a5..31379f8 100644 --- a/AspNetCore.Sequence/Http/HttpResults/SequenceResultBaseOfT.ExecuteAsync.cs +++ b/AspNetCore.Sequence/Http/HttpResults/SequenceResultBaseOfT.ExecuteAsync.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json; using System.Diagnostics; using Juner.Sequence; @@ -19,13 +18,7 @@ namespace Juner.AspNetCore.Sequence.Http.HttpResults; [DebuggerDisplay("{Values,nq}")] public abstract partial class SequenceResultBase : IResult { - ILogger GetLogger(IServiceProvider provider) - { - var loggerType = typeof(ILogger<>).MakeGenericType(GetType()); - var logger = provider.GetService(loggerType) as ILogger; - if (logger is not null) return logger; - return NullLogger.Instance; - } + protected abstract ILogger GetLogger(IServiceProvider provider); static JsonSerializerOptions GetOptions(IServiceProvider provider, ILogger logger) { var jsonOptions = provider.GetService>()?.Value; diff --git a/AspNetCore.Sequence/Http/HttpResults/SequenceResultOfT.ExecuteAsync.cs b/AspNetCore.Sequence/Http/HttpResults/SequenceResultOfT.ExecuteAsync.cs index 5916e22..bb998ea 100644 --- a/AspNetCore.Sequence/Http/HttpResults/SequenceResultOfT.ExecuteAsync.cs +++ b/AspNetCore.Sequence/Http/HttpResults/SequenceResultOfT.ExecuteAsync.cs @@ -6,9 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using System.Diagnostics; -using System.Text; using System.Text.Json; -using System.Net.Mime; using Juner.Sequence; #if !NET8_0_OR_GREATER @@ -20,13 +18,7 @@ namespace Juner.AspNetCore.Sequence.Http.HttpResults; [DebuggerDisplay("{Values,nq}")] public partial class SequenceResult : IResult { - ILogger GetLogger(IServiceProvider provider) - { - var loggerType = typeof(ILogger<>).MakeGenericType(GetType()); - var logger = provider.GetService(loggerType) as ILogger; - if (logger is not null) return logger; - return NullLogger.Instance; - } + ILogger GetLogger(IServiceProvider provider) => provider.GetService>>() ?? (ILogger)NullLogger.Instance; JsonSerializerOptions GetOptions(IServiceProvider provider, ILogger logger) { var jsonOptions = provider.GetService>()?.Value; @@ -60,7 +52,7 @@ public async Task ExecuteAsync(HttpContext httpContext) #endif var values = ToAsyncEnumerable(httpContext.RequestAborted); - if (options.IsEmpty) + if (options.IsInvalid) { var jsonTypeInfo = serializerOptions.GetTypeInfo>(); diff --git a/AspNetCore.Sequence/Http/HttpResults/SequenceResultOfT.cs b/AspNetCore.Sequence/Http/HttpResults/SequenceResultOfT.cs index 4e133b0..daa9bb0 100644 --- a/AspNetCore.Sequence/Http/HttpResults/SequenceResultOfT.cs +++ b/AspNetCore.Sequence/Http/HttpResults/SequenceResultOfT.cs @@ -113,7 +113,7 @@ public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) builder.Metadata.Add(new ProducesSequenceResponseTypeMetadata( STATUS_CODE, typeof(T), - [.. MakePatternList.Select(v => new Content(v.ContentType, v.IsStreaming))])); + [.. MakePatternList.Select(v => new Content(v.ContentType, v.IsStreaming, v.IsStreaming ? null : typeof(IAsyncEnumerable)))])); } record MakePattern(string ContentType, ISequenceSerializerWriteOptions Options, bool IsStreaming); @@ -189,7 +189,7 @@ static IEnumerable MakePatterns() { const string contentType = "application/json"; - yield return new(contentType, SequenceSerializerOptions.Empty, false); + yield return new(contentType, SequenceSerializerOptions.Default, false); } } diff --git a/AspNetCore.Sequence/Http/IContent.cs b/AspNetCore.Sequence/Http/IContent.cs index 009d4ee..254bb8b 100644 --- a/AspNetCore.Sequence/Http/IContent.cs +++ b/AspNetCore.Sequence/Http/IContent.cs @@ -4,4 +4,5 @@ public interface IContent { string ContentType { get; } bool IsStreaming { get; } + Type? NoStreamingType { get; } } \ No newline at end of file diff --git a/AspNetCore.Sequence/Http/SequenceOfT.PopulateMetadata.cs b/AspNetCore.Sequence/Http/SequenceOfT.PopulateMetadata.cs index 6e08f65..69776fe 100644 --- a/AspNetCore.Sequence/Http/SequenceOfT.PopulateMetadata.cs +++ b/AspNetCore.Sequence/Http/SequenceOfT.PopulateMetadata.cs @@ -9,7 +9,7 @@ public sealed partial class Sequence : IEndpointParameterMetadataProvider public static void PopulateMetadata(ParameterInfo parameter, EndpointBuilder builder) => builder.Metadata.Add(new AcceptsSequenceMetadata( itemType: typeof(T), - contentTypes: [.. MakePatternActionList.Select(v => new Content(v.ContentType, v.IsStreaming))], + contentTypes: [.. MakePatternActionList.Select(v => new Content(v.ContentType, v.IsStreaming, v.IsStreaming ? null : typeof(IAsyncEnumerable)))], isOptional: false )); } \ No newline at end of file diff --git a/AspNetCore.Sequence/Http/SequenceOfT.cs b/AspNetCore.Sequence/Http/SequenceOfT.cs index b5783ac..da8f3f5 100644 --- a/AspNetCore.Sequence/Http/SequenceOfT.cs +++ b/AspNetCore.Sequence/Http/SequenceOfT.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.Logging; -using System.Runtime.CompilerServices; using System.Threading.Channels; namespace Juner.AspNetCore.Sequence.Http; diff --git a/AspNetCore.Sequence/Internals/InternalFormatReader.cs b/AspNetCore.Sequence/Internals/InternalFormatReader.cs index 5947d80..2d85948 100644 --- a/AspNetCore.Sequence/Internals/InternalFormatReader.cs +++ b/AspNetCore.Sequence/Internals/InternalFormatReader.cs @@ -1,5 +1,6 @@ using Juner.Sequence; using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.IO.Pipelines; using System.Text.Json.Serialization.Metadata; using System.Threading.Channels; @@ -29,6 +30,8 @@ public static object ReadResult( }; } + [RequiresDynamicCode("Uses dynamic generic delegate generation")] + [RequiresUnreferencedCode("Uses reflection to create generic methods")] public static object ReadResult( Type elementType, EnumerableType enumerableType, @@ -59,6 +62,8 @@ CancellationToken cancellationToken static readonly ConcurrentDictionary cache = new(); + [RequiresDynamicCode("Uses dynamic generic delegate generation")] + [RequiresUnreferencedCode("Uses reflection to create generic methods")] static Delegate CreateDelegate(Type elementType) { var method = diff --git a/AspNetCore.Sequence/Internals/InternalFormatWriter.cs b/AspNetCore.Sequence/Internals/InternalFormatWriter.cs index 9ff9854..080775c 100644 --- a/AspNetCore.Sequence/Internals/InternalFormatWriter.cs +++ b/AspNetCore.Sequence/Internals/InternalFormatWriter.cs @@ -11,10 +11,6 @@ using System.Text.Json.Serialization.Metadata; using System.Threading.Channels; -#if NET9_0_OR_GREATER -using System.IO.Pipelines; -#endif - namespace Juner.AspNetCore.Sequence.Internals; internal static class InternalFormatWriter @@ -27,6 +23,8 @@ internal static class InternalFormatWriter {typeof(ChannelReader<>), EnumerableType.ChannelReader }, {typeof(Http.Sequence<>), EnumerableType.Sequence }, }.AsReadOnly(); + + [RequiresUnreferencedCode("Uses reflection to search interfaces")] public static bool TryGetOutputMode([NotNullWhen(true)] Type? objectType, [NotNullWhen(true)] out EnumerableType outputType, [NotNullWhen(true)] out Type type) { outputType = default; @@ -54,6 +52,9 @@ public static bool TryGetOutputMode([NotNullWhen(true)] Type? objectType, [NotNu } static JsonTypeInfo GetJsonTypeInfo(JsonSerializerOptions serializerOptions, Type type) => serializerOptions.GetTypeInfo(type); + + [RequiresDynamicCode("Uses dynamic generic delegate generation")] + [RequiresUnreferencedCode("Uses reflection to create generic methods")] public static Task WriteResponseBodyAsync( Type? objectType, object? @object, @@ -75,6 +76,7 @@ public static Task WriteResponseBodyAsync( cancellationToken); } + [RequiresUnreferencedCode("Uses reflection to search interfaces")] public static Task WriteAsync( Enumerable? @object, HttpContext httpContext, @@ -117,6 +119,8 @@ static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable? values, [E static readonly ConcurrentDictionary cache = new(); + [RequiresDynamicCode("Uses dynamic generic delegate generation")] + [RequiresUnreferencedCode("Uses reflection to create generic methods")] static Task WriteAsync( Type objectType, object? @object, @@ -147,6 +151,8 @@ static Task WriteAsync( cancellationToken); } + [RequiresDynamicCode("Uses dynamic generic delegate generation")] + [RequiresUnreferencedCode("Uses reflection to create generic methods")] static Delegate CreateDelegate(Type objectType) { if (!TryGetOutputMode(objectType, out _, out var type)) diff --git a/AspNetCore.Sequence/Juner.AspNetCore.Sequence.csproj b/AspNetCore.Sequence/Juner.AspNetCore.Sequence.csproj index 7c98d02..e842276 100644 --- a/AspNetCore.Sequence/Juner.AspNetCore.Sequence.csproj +++ b/AspNetCore.Sequence/Juner.AspNetCore.Sequence.csproj @@ -1,8 +1,8 @@  - - Streaming JSON sequence support for ASP.NET Core. -Supports NDJSON, JSON Lines, JSON Sequence, and JSON arrays using IAsyncEnumerable. + Streaming JSON support for ASP.NET Core (NDJSON, JSON Lines, JSON Sequence). +Also supports JSON arrays as non-streaming input/output for convenience. +Provides Minimal API and MVC integration with content negotiation and OpenAPI support. aspnetcore;streaming;json;ndjson;jsonlines;json-seq;iasyncenumerable https://github.com/juner/Sequence True diff --git a/AspNetCore.Sequence/Mvc/Formatters/JsonLineOutputFormatter.cs b/AspNetCore.Sequence/Mvc/Formatters/JsonLineOutputFormatter.cs index fcfc24f..69a62a7 100644 --- a/AspNetCore.Sequence/Mvc/Formatters/JsonLineOutputFormatter.cs +++ b/AspNetCore.Sequence/Mvc/Formatters/JsonLineOutputFormatter.cs @@ -9,6 +9,8 @@ using System.Text; using System.Text.Json; using Juner.Sequence; +using System.Diagnostics.CodeAnalysis; + #if NET8_0_OR_GREATER @@ -22,6 +24,8 @@ namespace Juner.AspNetCore.Sequence.Mvc.Formatters; /// /// application/jsonl 対応の フォーマッター /// +[RequiresDynamicCode("Uses dynamic generic delegate generation")] +[RequiresUnreferencedCode("Uses reflection to create generic methods")] public partial class JsonLineOutputFormatter : TextOutputFormatter { /// @@ -37,7 +41,7 @@ public JsonLineOutputFormatter() const string ContentType = "application/jsonl"; - /// + /// protected override bool CanWriteType(Type? type) => InternalFormatWriter.TryGetOutputMode(type, out _, out _); diff --git a/AspNetCore.Sequence/Mvc/Formatters/JsonSequenceOutputFormatter.cs b/AspNetCore.Sequence/Mvc/Formatters/JsonSequenceOutputFormatter.cs index 59f9d5a..9583549 100644 --- a/AspNetCore.Sequence/Mvc/Formatters/JsonSequenceOutputFormatter.cs +++ b/AspNetCore.Sequence/Mvc/Formatters/JsonSequenceOutputFormatter.cs @@ -9,6 +9,8 @@ using System.Text; using System.Text.Json; using Juner.Sequence; +using System.Diagnostics.CodeAnalysis; + #if NET8_0_OR_GREATER @@ -22,6 +24,8 @@ namespace Juner.AspNetCore.Sequence.Mvc.Formatters; /// /// application/json-seq 対応の フォーマッター /// +[RequiresDynamicCode("Uses dynamic generic delegate generation")] +[RequiresUnreferencedCode("Uses reflection to create generic methods")] public partial class JsonSequenceOutputFormatter : TextOutputFormatter { /// diff --git a/AspNetCore.Sequence/Mvc/Formatters/NdJsonOutputFormatter.cs b/AspNetCore.Sequence/Mvc/Formatters/NdJsonOutputFormatter.cs index 24bfbd5..b29952f 100644 --- a/AspNetCore.Sequence/Mvc/Formatters/NdJsonOutputFormatter.cs +++ b/AspNetCore.Sequence/Mvc/Formatters/NdJsonOutputFormatter.cs @@ -9,6 +9,8 @@ using System.Text; using System.Text.Json; using Juner.Sequence; +using System.Diagnostics.CodeAnalysis; + #if NET8_0_OR_GREATER @@ -22,6 +24,8 @@ namespace Juner.AspNetCore.Sequence.Mvc.Formatters; /// /// application/x-ndjson 対応の フォーマッター /// +[RequiresDynamicCode("Uses dynamic generic delegate generation")] +[RequiresUnreferencedCode("Uses reflection to create generic methods")] public partial class NdJsonOutputFormatter : TextOutputFormatter { /// diff --git a/AspNetCore.Sequence/Mvc/Formatters/SequenceInputFormatter.cs b/AspNetCore.Sequence/Mvc/Formatters/SequenceInputFormatter.cs index 76a3053..220b697 100644 --- a/AspNetCore.Sequence/Mvc/Formatters/SequenceInputFormatter.cs +++ b/AspNetCore.Sequence/Mvc/Formatters/SequenceInputFormatter.cs @@ -11,11 +11,15 @@ using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization.Metadata; using System.Threading.Channels; +#if !NET8_0_OR_GREATER +using System.Text.Json.Serialization.Metadata; +#endif namespace Juner.AspNetCore.Sequence.Mvc.Formatters; +[RequiresDynamicCode("Uses dynamic generic delegate generation")] +[RequiresUnreferencedCode("Uses reflection to create generic methods")] public partial class SequenceInputFormatter : TextInputFormatter { @@ -58,13 +62,7 @@ public override bool CanRead(InputFormatterContext context) return true; } - ILogger GetLogger(IServiceProvider provider) - { - var loggerType = typeof(ILogger<>).MakeGenericType(GetType()); - var logger = provider.GetService(loggerType) as ILogger; - if (logger is not null) return logger; - return NullLogger.Instance; - } + ILogger GetLogger(IServiceProvider provider) => provider.GetService>() ?? (ILogger)NullLogger.Instance; static JsonSerializerOptions GetOptions(IServiceProvider provider, ILogger logger) { var jsonOptions = provider.GetService>()?.Value; diff --git a/AspNetCore.Sequence/OpenApiExtensions.cs b/AspNetCore.Sequence/OpenApiExtensions.cs index 0b1765e..d71bdd8 100644 --- a/AspNetCore.Sequence/OpenApiExtensions.cs +++ b/AspNetCore.Sequence/OpenApiExtensions.cs @@ -7,6 +7,7 @@ #pragma warning disable IDE0130 // Namespace がフォルダー構造と一致しません namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 // Namespace がフォルダー構造と一致しません public static class OpenApiExtensions { @@ -72,6 +73,7 @@ public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTra { var contentType = contentType_.ContentType; var isStreaming = contentType_.IsStreaming; + var notStreamingType = contentType_.NoStreamingType; if (string.IsNullOrEmpty(contentType)) continue; if (!content.TryGetValue(contentType, out var mediaType)) continue; @@ -82,9 +84,9 @@ public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTra var extensions = mediaType.Extensions ??= new Dictionary(); extensions.TryAdd("x-streaming", new JsonNodeExtension(JsonValue.Create(true))); extensions.TryAdd("x-itemSchema", new JsonNodeExtension(typeNode.DeepClone())); - } else + } else if (notStreamingType is not null) { - mediaType.Schema = await context.GetOrCreateSchemaAsync(typeof(IAsyncEnumerable<>).MakeGenericType(sequenceMetadata.ItemType), cancellationToken: cancellationToken); + mediaType.Schema = await context.GetOrCreateSchemaAsync(notStreamingType, cancellationToken: cancellationToken); } } return requestBody; @@ -127,6 +129,7 @@ public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTra { var contentType = contentType_.ContentType; var isStreaming = contentType_.IsStreaming; + var noStreamingType = contentType_.NoStreamingType; if (!(response.Content ??= new Dictionary()).TryGetValue(contentType, out var schema_)) continue; @@ -138,9 +141,9 @@ public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTra extensions.TryAdd("x-streaming", new JsonNodeExtension(JsonValue.Create(true))); extensions.TryAdd("x-itemSchema", new JsonNodeExtension(itemSchemaJsonNode.DeepClone())); } - else + else if (noStreamingType is not null) { - schema_.Schema = await context.GetOrCreateSchemaAsync(typeof(IAsyncEnumerable<>).MakeGenericType(metadata.ItemType), cancellationToken: cancellationToken); + schema_.Schema = await context.GetOrCreateSchemaAsync(noStreamingType, cancellationToken: cancellationToken); } } } diff --git a/AspNetCore.Sequence/README.md b/AspNetCore.Sequence/README.md index 96b053e..a903968 100644 --- a/AspNetCore.Sequence/README.md +++ b/AspNetCore.Sequence/README.md @@ -1,348 +1,272 @@ # Juner.AspNetCore.Sequence -> [!CAUTION] -> This package is currently in preview. -> APIs may change in future releases. +Streaming support for record‑oriented JSON formats (NDJSON, JSON Lines, JSON Sequence) in ASP.NET Core MVC and Minimal API. -Streaming JSON formats (NDJSON, JSON Lines, JSON Sequence) for ASP.NET Core. +This package integrates **Juner.Sequence** with ASP.NET Core, providing: -`Juner.AspNetCore.Sequence` is an ASP.NET Core formatter that provides -**streaming JSON support** for: +- Streaming **input** via `SequenceInputFormatter` and `Sequence` +- Streaming **output** via `JsonSequenceResult`, `JsonLineResult`, `NdJsonResult`, and `SequenceResult` +- Natural Minimal API integration +- MVC integration through formatters and metadata +- OpenAPI (.NET 10+) support for streaming schemas +- Content negotiation for streaming formats -* NDJSON (`application/x-ndjson`) -* JSON Lines (`application/jsonl`) -* JSON Sequence (`application/json-seq`) -* JSON Array (`application/json`) - -It enables **incremental serialization and deserialization** using -`IAsyncEnumerable`, `IEnumerable`, `ChannelReader`, arrays, or lists. +It enables true end‑to‑end streaming pipelines in ASP.NET Core without buffering entire payloads. --- -## Why this library? (The Gap in ASP.NET Core) - -While ASP.NET Core natively supports `IAsyncEnumerable` for `application/json`, -it is limited to **JSON arrays (`[...]`)**. - -For streaming-friendly formats like: - -* NDJSON -* JSON Lines -* JSON Sequence - -you typically need to: - -* implement custom `InputFormatter` / `OutputFormatter` -* manually parse request bodies -* handle Minimal API binding yourself +## Installation -**This library fills that gap** by enabling consistent streaming handling across formats. +```bash +dotnet add package Juner.AspNetCore.Sequence +``` --- -## Quick Example (Minimal API) +# Quick Start + +## Minimal API — Streaming JSON Output ```csharp -app.MapPost("/process", async (Sequence sequence) => +app.MapGet("/events", () => + TypedResults.JsonSequence(GetEvents())); + +static async IAsyncEnumerable GetEvents() { - await foreach (var person in sequence) + while (true) { - Console.WriteLine($"Received: {person.Name}"); + yield return new Event { Message = "tick", Time = DateTime.UtcNow }; + await Task.Delay(1000); } - - return Results.Ok(); -}); +} ``` -Request (NDJSON): +## Minimal API — Streaming JSON Input -```jsonl -{"name":"alice"} -{"name":"bob"} +```csharp +app.MapPost("/upload", async (Sequence items) => +{ + await foreach (var item in items) + Console.WriteLine(item); +}); ``` +`Sequence` is an ASP.NET Core–native type that represents a streaming JSON input, +allowing items to be consumed as they arrive without buffering. + --- -## Installation +# Supported Streaming Formats -```bash -dotnet add package Juner.AspNetCore.Sequence -``` +| Format | Content-Type | Notes | +|--------|--------------|-------| +| JSON Sequence | `application/json-seq` | RFC 7464 (RS‑delimited) | +| NDJSON | `application/x-ndjson` | newline‑delimited | +| JSON Lines | `application/jsonl` | equivalent to NDJSON | --- -## Setup +# Streaming Output -### Minimal API +The following return types are supported for streaming responses: -```csharp -builder.Services.AddSequenceOpenApi(); -``` +| Return Type | Streaming? | Notes | +|-------------|------------|-------| +| `IAsyncEnumerable` | ✔ | ideal for streaming | +| `ChannelReader` | ✔ | backpressure‑friendly | +| `IEnumerable` | △ | buffered | +| `List` | △ | buffered | +| `T[]` | △ | buffered | +| `Sequence` | ✔ | ASP.NET Core–native streaming | -### ASP.NET Core (MVC) +### Minimal API Result Types ```csharp -builder.Services.AddSequenceOpenApi(); -builder.Services.AddControllers() - .AddSequenceFormatter(); -`` - ---- - -## Features +return TypedResults.JsonSequence(values); +return TypedResults.JsonLine(values); +return TypedResults.NdJson(values); +return TypedResults.Sequence(values); // content negotiation +``` -* Supports multiple streaming formats +### MVC OutputFormatter - * NDJSON - * JSON Lines - * JSON Sequence - * JSON Array -* Supports multiple sequence sources +Streaming is enabled when the client sends: - * `IAsyncEnumerable` - * `IEnumerable` - * `T[]` - * `List` - * `ChannelReader` -* Minimal API ready (`Sequence` binding) -* Results extensions (`Results.*`, `TypedResults.*`) -* Incremental JSON parsing -* Built on `System.Text.Json` and `PipeReader` -* Low memory usage (streaming, non-buffered) +- `Accept: application/json-seq` +- `Accept: application/x-ndjson` +- `Accept: application/jsonl` --- -## Usage +# Streaming Input -### Minimal API +ASP.NET Core actions can accept the following types as streaming input: -```csharp -app.MapPost("/ndjson", - async (Sequence sequence) => - { - await foreach (var item in sequence) - { - Console.WriteLine(item.Name); - } +| Parameter Type | Streaming? | +|----------------|------------| +| `Sequence` | ✔ | +| `IAsyncEnumerable` | ✔ | +| `ChannelReader` | ✔ | +| `IEnumerable` | △ (buffered) | +| `List` | △ | +| `T[]` | △ | - return Results.Ok(); - }); -``` - -### MVC Controller +### Minimal API Example ```csharp -[HttpPost] -[Consumes("application/x-ndjson")] -public async Task Post([FromBody] IAsyncEnumerable data) +app.MapPost("/items", async (Sequence items) => { - await foreach (var item in data) - { - Console.WriteLine(item.Name); - } - - return Ok(); -} + await foreach (var item in items) + Console.WriteLine(item); +}); ``` ---- +### MVC InputFormatter -## Results APIs +Streaming is enabled for: -This library provides multiple ways to return streaming responses. +- `application/json-seq` +- `application/x-ndjson` +- `application/jsonl` -### SequenceResults (baseline API) - -```csharp -SequenceResults.NdJson(sequence) -``` +--- -Works in all environments without relying on extension methods. +# Content Negotiation ---- +`SequenceResult` automatically selects the best streaming format based on the client's `Accept` header. -### Results / TypedResults extensions +| Accept | Output | +|--------|--------| +| `application/json-seq` | JSON Sequence | +| `application/x-ndjson` | NDJSON | +| `application/jsonl` | JSON Lines | +| `application/json` | JSON array (non‑streaming) | ```csharp -Results.NdJson(sequence) -TypedResults.NdJson(sequence) +return TypedResults.Sequence(values); ``` -Provides a more natural Minimal API experience. - --- -### Which should I use? +# JSON Array (`application/json`) -* Use `SequenceResults` if: +JSON arrays are not true streaming formats, as they require full buffering. - * you need maximum compatibility - * you prefer explicit usage - -* Use `Results` / `TypedResults` if: - - * you are using modern ASP.NET Core - * you prefer idiomatic Minimal API style - -All APIs produce the same streaming behavior. - ---- - -## Supported Formats - -| Format | RFC | Content-Type | Notes | -| ------------- | -------- | -------------------- | -------------------------------- | -| JSON Sequence | RFC 7464 | application/json-seq | record separator based | -| NDJSON | informal | application/x-ndjson | newline delimited | -| JSON Lines | informal | application/jsonl | similar to NDJSON | -| JSON Array | RFC 8259 | application/json | buffered or streaming-compatible | - ---- +`SequenceResult` can return JSON arrays, but they are fully buffered. -## Supported Input Types +For true streaming, use: -* `IAsyncEnumerable` -* `IEnumerable` -* `T[]` -* `List` -* `ChannelReader` -* `Sequence` +- `JsonSequenceResult` +- `JsonLineResult` +- `NdJsonResult` --- -## Supported Output Types +# OpenAPI Integration (.NET 10+) -* `IAsyncEnumerable` -* `IEnumerable` -* `T[]` -* `List` -* `ChannelReader` - ---- - -## Why not just use `[FromBody]`? +Enable OpenAPI support: ```csharp -app.MapPost("/json", - async ([FromBody] IAsyncEnumerable items) => - { - await foreach (var item in items) - { - Console.WriteLine(item.Id); - } - }); +services.AddSequenceOpenApi(); ``` -This expects a JSON array: +Streaming endpoints are annotated with: -```json -[ - { "id": 1 }, - { "id": 2 } -] -``` +- `x-streaming: true` +- `x-itemSchema: { ... }` +- Correct content types per format -It does **not** support streaming formats like NDJSON. +Both request and response schemas are generated accurately. --- -### With `Sequence` +# Architecture -```csharp -app.MapPost("/ndjson", - async (Sequence sequence) => - { - await foreach (var item in sequence) - { - Console.WriteLine(item.Id); - } - }); -``` +```mermaid +graph TD; + A[Juner.Sequence
Core] + B[Juner.Http.Sequence] + C[Juner.AspNetCore.Sequence] -Request: + A --> B + B --> C -```text -{"id":1} -{"id":2} -{"id":3} + C --> D[InputFormatter
SequenceInputFormatter] + C --> E[OutputFormatter
JsonSequence / JsonLine / NdJson] + C --> F[Result Types
JsonSequenceResult / JsonLineResult / NdJsonResult / SequenceResult] + C --> G[Sequence
Minimal API Integration] + C --> H[OpenAPI (.NET 10+)] ``` --- -## Comparison +# AOT Considerations -| Feature | Standard ASP.NET Core | With This Library | -| ------- | --------------------- | ----------------- | -| JSON Array | ✅ | ✅ | -| NDJSON / JSONL | ❌ | ✅ | -| JSON Sequence | ❌ | ✅ | -| Minimal API binding | ⚠️ JSON only | ✅ | -| Request streaming (NDJSON etc.) | ❌ | ✅ | -| Streaming output | ⚠️ limited | ✅ | +ASP.NET Core MVC formatters rely on: ---- +- dynamic code generation +- reflection +- `JsonSerializerOptions` and `TypeInfoResolver` -## OpenAPI support +Because of these framework‑level constraints, +**MVC streaming (InputFormatter / OutputFormatter) is not AOT‑safe**. -> [!CAUTION] -> `AddSequenceOpenApi()` is currently available only for .NET 10 +However: -OpenAPI (Swagger) does not fully support streaming formats such as: +### ✔ Minimal API is AOT‑friendly -* NDJSON -* JSON Lines -* JSON Sequence +When using only: -To avoid misleading schemas, response type information may be omitted. +- `Sequence` (for streaming input) +- `JsonSequenceResult`, `JsonLineResult`, `NdJsonResult`, `SequenceResult` (for streaming output) -Please refer to the examples for actual usage. +no MVC formatters are involved, and +**Minimal API streaming works under Native AOT**. -Support may improve with future OpenAPI versions (e.g. 3.2). +### Summary + +| Feature | AOT‑safe? | Notes | +|--------|-----------|-------| +| Minimal API streaming | ✔ | Uses `Sequence` and result types only | +| MVC streaming | ✘ | Requires formatters (dynamic code) | +| OpenAPI (.NET 10+) | ✔ | Works in both modes | +| JSON array fallback | ✔ | Uses built‑in JSON serialization | --- -## Internals +# Samples -The formatter integrates with ASP.NET Core's formatter pipeline. +This repository includes two complete samples: -Supported formats can be bound to multiple types, including: +- **Minimal API JSON Sequence Streaming Sample** +- **MVC JSON Sequence Streaming Sample** -* `IAsyncEnumerable` -* `ChannelReader` -* `Sequence` +Both demonstrate: -`Sequence` is a wrapper that enables unified streaming input handling across formats. +- Streaming output (`JsonSequenceResult`) +- Streaming input (`Sequence`) +- Bidirectional streaming using `fetch()` with `duplex: 'half'` +- Browser‑side JSON Sequence parsing (`json-seq-stream`) +- OpenAPI (.NET 10+) integration -The implementation is based on: +### Minimal API Sample -* `System.Text.Json` -* `PipeReader` +Located at: -Serialization and deserialization are performed incrementally. +``` +../samples/AspNetCore.Sequence/MinimalApiJsonSequenceStreamingSample.cs +``` ---- +### MVC Sample -## Target Framework +Located at: -* .NET 7 -* .NET 8 -* .NET 9 -* .NET 10 -* ASP.NET Core +``` +../samples/AspNetCore.Sequence/MvcJsonSequenceStreamingSample.cs +``` --- -## License - -[MIT](./LICENSE) - -## See also +# License -* RFC 7464 - JavaScript Object Notation (JSON) Text Sequences \ -[https://datatracker.ietf.org/doc/html/rfc7464](https://datatracker.ietf.org/doc/html/rfc7464) -* JSON Lines \ -[https://jsonlines.org](https://jsonlines.org) -* JSON streaming - Wikipedia (en) \ -[https://en.wikipedia.org/wiki/JSON_streaming](https://en.wikipedia.org/wiki/JSON_streaming) -* npm:json-seq-stream -[https://www.npmjs.com/package/json-seq-stream](https://www.npmjs.com/package/json-seq-stream/v/1.0.10) +MIT diff --git a/AspNetCore.Sequence/SequenceExtensions.cs b/AspNetCore.Sequence/SequenceExtensions.cs index d6dfd0f..bc27853 100644 --- a/AspNetCore.Sequence/SequenceExtensions.cs +++ b/AspNetCore.Sequence/SequenceExtensions.cs @@ -1,6 +1,7 @@ using Juner.AspNetCore.Sequence.Mvc.Formatters; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Formatters; +using System.Diagnostics.CodeAnalysis; #pragma warning disable IDE0130 // Namespace がフォルダー構造と一致しません namespace Microsoft.Extensions.DependencyInjection; @@ -8,6 +9,8 @@ namespace Microsoft.Extensions.DependencyInjection; public static class SequenceExtensions { + [RequiresDynamicCode("Uses dynamic generic delegate generation")] + [RequiresUnreferencedCode("Uses reflection to create generic methods")] public static IMvcBuilder AddSequenceFormatter(this IMvcBuilder builder) { builder.AddSequenceInputFormatter(); @@ -16,6 +19,9 @@ public static IMvcBuilder AddSequenceFormatter(this IMvcBuilder builder) builder.AddJsonLineOutputFormatter(); return builder; } + + [RequiresDynamicCode("Uses dynamic generic delegate generation")] + [RequiresUnreferencedCode("Uses reflection to create generic methods")] public static IMvcBuilder AddSequenceInputFormatter(this IMvcBuilder builder) { builder.Services.Configure(options => @@ -25,8 +31,19 @@ public static IMvcBuilder AddSequenceInputFormatter(this IMvcBuilder builder) }); return builder; } + + [RequiresDynamicCode("Uses dynamic generic delegate generation")] + [RequiresUnreferencedCode("Uses reflection to create generic methods")] public static IMvcBuilder AddJsonSequenceOutputFormatter(this IMvcBuilder builder) => builder.AddOutputFormatter(); + + + [RequiresDynamicCode("Uses dynamic generic delegate generation")] + [RequiresUnreferencedCode("Uses reflection to create generic methods")] public static IMvcBuilder AddNdJsonOutputFormatter(this IMvcBuilder builder) => builder.AddOutputFormatter(); + + + [RequiresDynamicCode("Uses dynamic generic delegate generation")] + [RequiresUnreferencedCode("Uses reflection to create generic methods")] public static IMvcBuilder AddJsonLineOutputFormatter(this IMvcBuilder builder) => builder.AddOutputFormatter(); static IMvcBuilder AddOutputFormatter(this IMvcBuilder builder) where T : TextOutputFormatter, new() diff --git a/Directory.Build.props b/Directory.Build.props index ad3e4c7..671dad2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,4 +1,5 @@ + net7.0;net8.0;net9.0;net10.0 enable @@ -6,22 +7,9 @@ true 14 true - true true true false - true - - - - 1.0.0-preview-2 - 1.0.0.2 - 1.0.0.2 - juner - juner - git - https://github.com/juner/Sequence - false diff --git a/Directory.Build.targets b/Directory.Build.targets index 799a94d..bff9c2e 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,4 +1,26 @@ + + true + + + true + + + + 1.0.0 + 1.0.0.3 + 1.0.0.3 + juner + juner + git + https://github.com/juner/Sequence + false + + diff --git a/Http.Sequence.Tests/HttpContentExtensionsTests.cs b/Http.Sequence.Tests/HttpContentExtensionsTests.cs index f5920d3..5409520 100644 --- a/Http.Sequence.Tests/HttpContentExtensionsTests.cs +++ b/Http.Sequence.Tests/HttpContentExtensionsTests.cs @@ -84,7 +84,7 @@ public async Task WithJsonLinesContent_Should_RoundTrip() { var source = GetAsyncEnumerable( new TestData(1), - new TestData( 2) + new TestData(2) ); var request = new HttpRequestMessage() diff --git a/Http.Sequence/CHANGELOG.md b/Http.Sequence/CHANGELOG.md new file mode 100644 index 0000000..b7707ab --- /dev/null +++ b/Http.Sequence/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog — Juner.Http.Sequence + +## [1.0.0] — 2026-03-31 + +### Added + +- Initial release of HttpClient integration for Juner.Sequence. +- `HttpContent` extensions for streaming JSON: + - `WithNdJsonContent` + - `WithJsonLinesContent` + - `WithJsonSequenceContent` +- Streaming deserialization helpers: + - `ReadJsonLinesAsyncEnumerable` + - `ReadJsonSequenceAsyncEnumerable` +- Automatic Content-Type assignment for streaming formats. +- AOT‑friendly API using `JsonTypeInfo`. + +### Notes + +- No dependency on `Juner.AspNetCore.Sequence`. +- JSON Array (`application/json`) is not treated as a streaming format. diff --git a/Http.Sequence/Extensions/Json/HttpContentJsonSerializerOptionsExtensions.cs b/Http.Sequence/Extensions/HttpContentJsonSerializerOptionsExtensions.cs similarity index 54% rename from Http.Sequence/Extensions/Json/HttpContentJsonSerializerOptionsExtensions.cs rename to Http.Sequence/Extensions/HttpContentJsonSerializerOptionsExtensions.cs index e410a67..4a1407e 100644 --- a/Http.Sequence/Extensions/Json/HttpContentJsonSerializerOptionsExtensions.cs +++ b/Http.Sequence/Extensions/HttpContentJsonSerializerOptionsExtensions.cs @@ -1,5 +1,4 @@ using Juner.Sequence; -using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization.Metadata; @@ -10,13 +9,11 @@ namespace Juner.Http.Sequence.Extensions.Json; ///
public static class HttpContentJsonSerializerOptionsExtensions { - const string RequiresUnreferencedCodeMessage = "Uses JsonSerializerOptions.Default which may require reflection."; - const string RequiresDynamicCodeMessage = "May not be AOT compatible."; static string GenerateErrorMessage() => $"JsonTypeInfo<{typeof(T).FullName}> not found. " + $"Ensure the type is registered in JsonSerializerOptions.TypeInfoResolver or source-generated context."; - + /// /// /// @@ -36,8 +33,11 @@ public static IAsyncEnumerable ReadSequenceEnumerable( ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(jsonSerializerOptions); - if (options.IsEmpty) + if (options.IsInvalid) throw new ArgumentException("Options must not be empty.", nameof(options)); +#if !NET8_0_OR_GREATER + jsonSerializerOptions.TypeInfoResolver ??= new DefaultJsonTypeInfoResolver(); +#endif if (jsonSerializerOptions.GetTypeInfo(typeof(T)) is not JsonTypeInfo jsonTypeInfo) throw new InvalidOperationException(GenerateErrorMessage()); return HttpContentExtensions.ReadSequenceEnumerable( @@ -84,64 +84,4 @@ public static IAsyncEnumerable ReadJsonLinesAsyncEnumerable( SequenceSerializerOptions.JsonLines, cancellationToken ); - - /// - /// - /// - /// - /// - /// - /// - /// - /// - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public static IAsyncEnumerable ReadSequenceEnumerable( - this HttpContent content, - ISequenceSerializerReadOptions options, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(options); - return ReadSequenceEnumerable( - content, - JsonSerializerOptions.Default, - options, - cancellationToken); - } - - /// - /// - /// - /// - /// - /// - /// - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public static IAsyncEnumerable ReadJsonSequenceAsyncEnumerable( - this HttpContent content, - CancellationToken cancellationToken = default) - => ReadSequenceEnumerable( - content, - SequenceSerializerOptions.JsonSequence, - cancellationToken - ); - - /// - /// - /// - /// - /// - /// - /// - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public static IAsyncEnumerable ReadJsonLinesAsyncEnumerable( - this HttpContent content, - CancellationToken cancellationToken = default) - => ReadSequenceEnumerable( - content, - SequenceSerializerOptions.JsonLines, - cancellationToken - ); -} +} \ No newline at end of file diff --git a/Http.Sequence/Extensions/HttpContentJsonTypeInfoExtensions.cs b/Http.Sequence/Extensions/HttpContentJsonTypeInfoExtensions.cs index 922e8ab..2c262f9 100644 --- a/Http.Sequence/Extensions/HttpContentJsonTypeInfoExtensions.cs +++ b/Http.Sequence/Extensions/HttpContentJsonTypeInfoExtensions.cs @@ -9,7 +9,7 @@ namespace Juner.Http.Sequence.Extensions; public static class HttpContentJsonTypeInfoExtensions { static string GetErrorMessage(JsonTypeInfo jsonTypeInfo) => $"JsonTypeInfo<{typeof(T).FullName}> expected but got {jsonTypeInfo.GetType()}"; - + public static IAsyncEnumerable ReadSequenceEnumerable( this HttpContent content, JsonTypeInfo jsonTypeInfo, @@ -19,7 +19,7 @@ public static IAsyncEnumerable ReadSequenceEnumerable( ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(jsonTypeInfo); - if (options.IsEmpty) + if (options.IsInvalid) throw new ArgumentException("Options must not be empty.", nameof(options)); if (jsonTypeInfo is not JsonTypeInfo jsonTypeInfo2) throw new InvalidOperationException(GetErrorMessage(jsonTypeInfo)); @@ -51,4 +51,4 @@ public static IAsyncEnumerable ReadJsonLinesAsyncEnumerable( SequenceSerializerOptions.JsonLines, cancellationToken ); -} +} \ No newline at end of file diff --git a/Http.Sequence/Extensions/Json/HttpRequestMessageJsonSerializerOptionsExtensions.cs b/Http.Sequence/Extensions/HttpRequestMessageJsonSerializerOptionsExtensions.cs similarity index 54% rename from Http.Sequence/Extensions/Json/HttpRequestMessageJsonSerializerOptionsExtensions.cs rename to Http.Sequence/Extensions/HttpRequestMessageJsonSerializerOptionsExtensions.cs index 5c97765..7018f61 100644 --- a/Http.Sequence/Extensions/Json/HttpRequestMessageJsonSerializerOptionsExtensions.cs +++ b/Http.Sequence/Extensions/HttpRequestMessageJsonSerializerOptionsExtensions.cs @@ -1,5 +1,4 @@ using Juner.Sequence; -using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization.Metadata; @@ -10,13 +9,10 @@ namespace Juner.Http.Sequence.Extensions.Json; ///
public static class HttpRequestMessageJsonSerializerOptionsExtensions { - const string RequiresUnreferencedCodeMessage = "Uses JsonSerializerOptions.Default which may require reflection."; - const string RequiresDynamicCodeMessage = "May not be AOT compatible."; - static string GenerateErrorMessage() => $"JsonTypeInfo<{typeof(T).FullName}> not found. " + $"Ensure the type is registered in JsonSerializerOptions.TypeInfoResolver or source-generated context."; - + /// /// /// @@ -46,8 +42,11 @@ public static HttpRequestMessage WithSequenceContent( ArgumentNullException.ThrowIfNullOrEmpty(contentType); #endif ArgumentNullException.ThrowIfNull(jsonSerializerOptions); - if (options.IsEmpty) + if (options.IsInvalid) throw new ArgumentException("Options must not be empty.", nameof(options)); +#if !NET8_0_OR_GREATER + jsonSerializerOptions.TypeInfoResolver ??= new DefaultJsonTypeInfoResolver(); +#endif if (jsonSerializerOptions.GetTypeInfo(typeof(T)) is not JsonTypeInfo jsonTypeInfo) throw new InvalidOperationException(GenerateErrorMessage()); return HttpRequestMessageExtensions.WithSequenceContent( @@ -80,7 +79,7 @@ public static HttpRequestMessage WithJsonSequenceContent( SequenceSerializerOptions.JsonSequence, "application/json-seq", cancellationToken); - + /// /// /// @@ -101,7 +100,7 @@ public static HttpRequestMessage WithJsonLinesContent( SequenceSerializerOptions.JsonLines, "application/jsonl", cancellationToken); - + /// /// /// @@ -122,93 +121,4 @@ public static HttpRequestMessage WithNdJsonContent( SequenceSerializerOptions.JsonLines, "application/x-ndjson", cancellationToken); - - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public static HttpRequestMessage WithSequenceContent( - this HttpRequestMessage request, - IAsyncEnumerable source, - ISequenceSerializerWriteOptions options, - string contentType, - CancellationToken cancellationToken = default) - => WithSequenceContent( - request, - source, - JsonSerializerOptions.Default, - options, - contentType, - cancellationToken); - - /// - /// - /// - /// - /// - /// - /// - /// - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public static HttpRequestMessage WithJsonSequenceContent( - this HttpRequestMessage request, - IAsyncEnumerable source, - CancellationToken cancellationToken = default - ) => WithSequenceContent( - request, - source, - SequenceSerializerOptions.JsonSequence, - "application/json-seq", - cancellationToken); - - /// - /// - /// - /// - /// - /// - /// - /// - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public static HttpRequestMessage WithJsonLinesContent( - this HttpRequestMessage request, - IAsyncEnumerable source, - CancellationToken cancellationToken = default - ) => WithSequenceContent( - request, - source, - SequenceSerializerOptions.JsonLines, - "application/jsonl", - cancellationToken); - - /// - /// - /// - /// - /// - /// - /// - /// - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public static HttpRequestMessage WithNdJsonContent( - this HttpRequestMessage request, - IAsyncEnumerable source, - CancellationToken cancellationToken = default - ) => WithSequenceContent( - request, - source, - SequenceSerializerOptions.JsonLines, - "application/x-ndjson", - cancellationToken); } \ No newline at end of file diff --git a/Http.Sequence/Extensions/HttpRequestMessageJsonTypeInfoExtensions.cs b/Http.Sequence/Extensions/HttpRequestMessageJsonTypeInfoExtensions.cs index 7659b16..e08fd46 100644 --- a/Http.Sequence/Extensions/HttpRequestMessageJsonTypeInfoExtensions.cs +++ b/Http.Sequence/Extensions/HttpRequestMessageJsonTypeInfoExtensions.cs @@ -41,7 +41,7 @@ CancellationToken cancellationToken #else ArgumentNullException.ThrowIfNullOrEmpty(contentType); #endif - if (options.IsEmpty) + if (options.IsInvalid) throw new ArgumentException("Options must not be empty.", nameof(options)); if (jsonTypeInfo is not JsonTypeInfo jsonTypeInfo2) throw new InvalidOperationException(GetErrorMessage(jsonTypeInfo)); @@ -77,7 +77,7 @@ CancellationToken cancellationToken "application/json-seq", cancellationToken ); - + /// /// /// @@ -100,7 +100,7 @@ CancellationToken cancellationToken "application/jsonl", cancellationToken ); - + /// /// /// diff --git a/Http.Sequence/Extensions/Json/HttpContentDefaultJsonSerializerOptionsExtensions.cs b/Http.Sequence/Extensions/Json/HttpContentDefaultJsonSerializerOptionsExtensions.cs new file mode 100644 index 0000000..d1e9af7 --- /dev/null +++ b/Http.Sequence/Extensions/Json/HttpContentDefaultJsonSerializerOptionsExtensions.cs @@ -0,0 +1,74 @@ +using Juner.Sequence; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Juner.Http.Sequence.Extensions.Json; + +/// +/// +/// +public static class HttpContentDefaultJsonSerializerOptionsExtensions +{ + const string RequiresUnreferencedCodeMessage = "Uses JsonSerializerOptions.Default which may require reflection."; + const string RequiresDynamicCodeMessage = "May not be AOT compatible."; + + /// + /// + /// + /// + /// + /// + /// + /// + /// + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public static IAsyncEnumerable ReadSequenceEnumerable( + this HttpContent content, + ISequenceSerializerReadOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return HttpContentJsonSerializerOptionsExtensions.ReadSequenceEnumerable( + content, + JsonSerializerOptions.Default, + options, + cancellationToken); + } + + /// + /// + /// + /// + /// + /// + /// + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public static IAsyncEnumerable ReadJsonSequenceAsyncEnumerable( + this HttpContent content, + CancellationToken cancellationToken = default) + => ReadSequenceEnumerable( + content, + SequenceSerializerOptions.JsonSequence, + cancellationToken + ); + + /// + /// + /// + /// + /// + /// + /// + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public static IAsyncEnumerable ReadJsonLinesAsyncEnumerable( + this HttpContent content, + CancellationToken cancellationToken = default) + => ReadSequenceEnumerable( + content, + SequenceSerializerOptions.JsonLines, + cancellationToken + ); +} \ No newline at end of file diff --git a/Http.Sequence/Extensions/Json/HttpRequestMessageDefaultJsonSerializerOptionsExtensions.cs b/Http.Sequence/Extensions/Json/HttpRequestMessageDefaultJsonSerializerOptionsExtensions.cs new file mode 100644 index 0000000..01a0c73 --- /dev/null +++ b/Http.Sequence/Extensions/Json/HttpRequestMessageDefaultJsonSerializerOptionsExtensions.cs @@ -0,0 +1,103 @@ +using Juner.Sequence; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Juner.Http.Sequence.Extensions.Json; + +/// +/// +/// +public static class HttpRequestMessageDefaultJsonSerializerOptionsExtensions +{ + const string RequiresUnreferencedCodeMessage = "Uses JsonSerializerOptions.Default which may require reflection."; + const string RequiresDynamicCodeMessage = "May not be AOT compatible."; + + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public static HttpRequestMessage WithSequenceContent( + this HttpRequestMessage request, + IAsyncEnumerable source, + ISequenceSerializerWriteOptions options, + string contentType, + CancellationToken cancellationToken = default) + => HttpRequestMessageJsonSerializerOptionsExtensions.WithSequenceContent( + request, + source, + JsonSerializerOptions.Default, + options, + contentType, + cancellationToken); + + /// + /// + /// + /// + /// + /// + /// + /// + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public static HttpRequestMessage WithJsonSequenceContent( + this HttpRequestMessage request, + IAsyncEnumerable source, + CancellationToken cancellationToken = default + ) => WithSequenceContent( + request, + source, + SequenceSerializerOptions.JsonSequence, + "application/json-seq", + cancellationToken); + + /// + /// + /// + /// + /// + /// + /// + /// + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public static HttpRequestMessage WithJsonLinesContent( + this HttpRequestMessage request, + IAsyncEnumerable source, + CancellationToken cancellationToken = default + ) => WithSequenceContent( + request, + source, + SequenceSerializerOptions.JsonLines, + "application/jsonl", + cancellationToken); + + /// + /// + /// + /// + /// + /// + /// + /// + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public static HttpRequestMessage WithNdJsonContent( + this HttpRequestMessage request, + IAsyncEnumerable source, + CancellationToken cancellationToken = default + ) => WithSequenceContent( + request, + source, + SequenceSerializerOptions.JsonLines, + "application/x-ndjson", + cancellationToken); +} \ No newline at end of file diff --git a/Http.Sequence/HttpContentExtensions.cs b/Http.Sequence/HttpContentExtensions.cs index 2fa0e1a..d7fdd4a 100644 --- a/Http.Sequence/HttpContentExtensions.cs +++ b/Http.Sequence/HttpContentExtensions.cs @@ -13,7 +13,7 @@ public static IAsyncEnumerable ReadSequenceEnumerable( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(options); - if (options.IsEmpty) throw new ArgumentException(null, nameof(options)); + if (options.IsInvalid) throw new ArgumentException(null, nameof(options)); var stream = content.ReadAsStream(cancellationToken); var reader = PipeReader.Create(stream); diff --git a/Http.Sequence/Juner.Http.Sequence.csproj b/Http.Sequence/Juner.Http.Sequence.csproj index 6d3089c..9d523f6 100644 --- a/Http.Sequence/Juner.Http.Sequence.csproj +++ b/Http.Sequence/Juner.Http.Sequence.csproj @@ -1,13 +1,8 @@  - - - true - - - SStreaming JSON sequence support for HttpClient and HttpContent. -Seamlessly send and receive NDJSON, JSON Lines, and JSON Sequence using IAsyncEnumerable. -Built on System.Text.Json and Juner.Sequence. + Streaming JSON sequence support for HttpClient and HttpContent. +Send and receive NDJSON, JSON Lines, and JSON Sequence using IAsyncEnumerable<T>. +Built on System.Text.Json and the Juner.Sequence core serializer. httpclient;httpcontent;streaming;json;ndjson;jsonlines;json-seq;iasyncenumerable;system-text-json https://github.com/juner/Sequence True diff --git a/Http.Sequence/README.md b/Http.Sequence/README.md index 09b1af7..65a68da 100644 --- a/Http.Sequence/README.md +++ b/Http.Sequence/README.md @@ -1,172 +1,287 @@ # Juner.Http.Sequence -Streaming JSON sequence support for `HttpClient` and `HttpContent`. +HTTP streaming for record-oriented JSON formats in .NET. -This library provides seamless integration between `Juner.Sequence` and HTTP APIs, enabling efficient streaming of JSON data using `IAsyncEnumerable`. +It enables end-to-end streaming from HTTP transport to application code, +without buffering the entire payload. ---- +This allows true streaming pipelines over HTTP with minimal allocations. + +`Juner.Http.Sequence` integrates `Juner.Sequence` with `HttpContent` and `HttpRequestMessage`, +providing end‑to‑end streaming support for formats such as: -## 📦 Package +- **NDJSON** (`application/x-ndjson`) +- **JSON Lines** (`application/jsonl`) +- **JSON Text Sequences** (RFC 7464, `application/json-seq`) -- `Juner.Http.Sequence` +These formats represent a sequence of independent JSON values, +allowing **incremental processing** without loading the entire payload. --- -## 🚀 Features +## Installation -- 🌐 `HttpClient` integration for streaming JSON -- 🔄 `IAsyncEnumerable` support for request and response -- 🧩 Supports: - - NDJSON (`application/x-ndjson`) - - JSON Lines (`application/jsonl`) - - JSON Sequence (`application/json-seq`) -- 🛡️ AOT-friendly via `JsonTypeInfo` -- ⚡ Minimal overhead, fully streaming +```bash +dotnet add package Juner.Http.Sequence +``` --- -## ✨ Quick Example +## Quick Start -### Send streaming request (NDJSON) +### JsonSerializerContext (AOT‑safe) ```csharp -var request = new HttpRequestMessage(HttpMethod.Post, url) - .WithNdJsonContent(source, MyJsonContext.Default.MyType); - -var response = await httpClient.SendAsync(request); +[JsonSerializable(typeof(MyType))] +public partial class MyJsonContext : JsonSerializerContext { } ``` --- -### Receive streaming response +## Reading (streaming) + +Assuming you obtained an `HttpContent` from `HttpResponseMessage`: + +### Core API (AOT‑safe) ```csharp -await foreach (var item in response.Content.ReadJsonLinesAsyncEnumerable( - MyJsonContext.Default.MyType)) +await foreach (var item in content.ReadSequenceEnumerable( + MyJsonContext.Default.MyType, + SequenceSerializerOptions.JsonLines, + cancellationToken)) { Console.WriteLine(item); } ``` +### Format‑specific shortcuts + +```csharp +await foreach (var item in content.ReadJsonLinesAsyncEnumerable( + MyJsonContext.Default.MyType)) +{ + ... +} + +await foreach (var item in content.ReadJsonSequenceAsyncEnumerable( + MyJsonContext.Default.MyType)) +{ + ... +} +``` + +### Notes + +- Uses `PipeReader.Create(stream)` internally +- Throws `ArgumentException` if `options.IsInvalid` +- Fully streaming: no buffering of the entire HTTP payload +- Backpressure is naturally handled via `IAsyncEnumerable` + --- -## 🧠 API Design +## Writing (streaming) -### ✅ AOT-safe (recommended) +### Core API (AOT‑safe) ```csharp -JsonTypeInfo +var request = new HttpRequestMessage(HttpMethod.Post, url) + .WithSequenceContent( + source, + MyJsonContext.Default.MyType, + SequenceSerializerOptions.JsonLines, + "application/jsonl"); ``` -- No reflection -- Fully compatible with Native AOT -- Best performance +The content type should match the selected sequence format. + +### Format‑specific shortcuts + +```csharp +request.WithJsonLinesContent(source, MyJsonContext.Default.MyType); +request.WithJsonSequenceContent(source, MyJsonContext.Default.MyType); +request.WithNdJsonContent(source, MyJsonContext.Default.MyType); +``` + +### Notes + +- Uses `PipeWriter.Create(stream)` internally +- Does not compute content length (`TryComputeLength` returns false) +- Streaming is performed record‑by‑record +- Suitable for chunked transfer encoding --- -### ⚠️ Convenience APIs +## AOT‑Friendly Design + +All AOT‑safe APIs require: ```csharp -JsonSerializerOptions.Default +JsonTypeInfo ``` -- Easier to use -- Uses default metadata resolution (`TypeInfoResolver`) -- May rely on reflection -- Not guaranteed to be AOT-safe - -👉 Prefer `JsonTypeInfo` for AOT scenarios +This avoids runtime reflection and ensures compatibility with Native AOT. --- -## 🔧 Writing (Request) +# Optional Extensions + +## JsonSerializerOptions Support — *not guaranteed AOT‑safe* + +### Reading + +```csharp +await foreach (var item in content.ReadSequenceEnumerable( + jsonSerializerOptions, + SequenceSerializerOptions.JsonLines)) +{ + ... +} +``` + +### Writing ```csharp request.WithSequenceContent( source, - MyJsonContext.Default.MyType, + jsonSerializerOptions, SequenceSerializerOptions.JsonLines, "application/jsonl"); ``` -### Shortcuts +### Notes + +- Uses `JsonSerializerOptions.TypeInfoResolver` +- Throws if metadata for `T` is not found +- Not AOT‑safe +- On .NET 7 or earlier, `DefaultJsonTypeInfoResolver` is assigned automatically if missing + +--- + +## JsonSerializerOptions.Default Support — *explicitly not AOT‑safe* + +### Reading ```csharp -request.WithJsonSequenceContent(source, typeInfo); -request.WithJsonLinesContent(source, typeInfo); -request.WithNdJsonContent(source, typeInfo); +await foreach (var item in content.ReadJsonLinesAsyncEnumerable()) +{ + ... +} ``` +### Writing + +```csharp +request.WithJsonLinesContent(source); +``` + +These APIs: + +- Use `JsonSerializerOptions.Default` +- Are annotated with: + - `RequiresUnreferencedCode` + - `RequiresDynamicCode` +- Are convenience wrappers around the JsonSerializerOptions APIs +- Not AOT‑safe + --- -## 🔧 Reading (Response) +## non‑generic JsonTypeInfo Support — *advanced use only* + +### Reading ```csharp -await foreach (var item in response.Content.ReadSequenceEnumerable( - typeInfo, +await foreach (var item in content.ReadSequenceEnumerable( + (JsonTypeInfo)jsonTypeInfo, SequenceSerializerOptions.JsonLines)) { - // ... + ... } ``` -### Shortcuts +### Writing ```csharp -response.Content.ReadJsonSequenceAsyncEnumerable(typeInfo); -response.Content.ReadJsonLinesAsyncEnumerable(typeInfo); +request.WithSequenceContent( + source, + (JsonTypeInfo)jsonTypeInfo, + SequenceSerializerOptions.JsonLines, + "application/jsonl"); ``` +### Notes + +- Throws if the provided `JsonTypeInfo` does not match `T` +- Not AOT‑safe +- Provided only for advanced scenarios + --- -## 🧩 Supported Formats +## Supported Formats + +The following formats are supported: -| Format | Content-Type | -|-------|-------------| -| NDJSON | `application/x-ndjson` | -| JSON Lines | `application/jsonl` | -| JSON Sequence | `application/json-seq` | +| Format | Content-Type | Notes | +|--------|--------------|-------| +| NDJSON | application/x-ndjson | newline‑delimited | +| JSON Lines | application/jsonl | equivalent to NDJSON | +| JSON Sequence | application/json-seq | RFC 7464 (RS‑delimited) | --- -## 🔗 Relationship +## About JSON Array (`application/json`) -``` -Juner.Sequence - ↓ -Juner.Http.Sequence +JSON arrays are already well supported by standard JSON APIs. + +Juner.Http.Sequence focuses on record-oriented streaming formats, +and does not provide support for JSON arrays. + +If you need to read or write JSON arrays, use: + +```csharp +HttpContent.ReadFromJsonAsync() ``` -- Depends on `Juner.Sequence` -- Adds HTTP integration layer -- No ASP.NET Core dependency +or the standard `JsonSerializer`. --- -## 📌 When to use +## Architecture -Use this package when: +The library is built as a thin HTTP layer on top of Juner.Sequence: -- You need to stream large JSON datasets over HTTP -- You want `HttpClient` to work with `IAsyncEnumerable` -- You are working with NDJSON / JSON Lines APIs -- You want AOT-friendly serialization over HTTP +```mermaid +graph TD; + A[Juner.Sequence
Core] + B[Juner.Http.Sequence] ---- + A --> B -## 📄 License + B --> C[HttpContent Extensions] + B --> D[HttpRequestMessage Extensions] -MIT + B --> E[JsonSerializerOptions Extensions
(not AOT‑safe)] + B --> F[JsonSerializerOptions.Default Extensions
(explicitly not AOT‑safe)] + B --> G[JsonTypeInfo (non‑generic)
Advanced] +``` --- -## 🔗 Links +## When to Use + +- Consuming large streaming JSON responses +- Real‑time event streams over HTTP +- High‑performance pipelines +- Native AOT applications +- Avoiding buffering entire HTTP payloads -- GitHub: https://github.com/juner/Sequence -- NuGet: https://www.nuget.org/packages/Juner.Http.Sequence +## When NOT to Use + +- Small payloads → `HttpContent.ReadFromJsonAsync` is simpler +- JSON arrays → use standard JSON APIs +- Native AOT scenarios → avoid `JsonSerializerOptions.Default` (not AOT‑safe) --- -## 🙌 Contributions +## License -Issues and PRs are welcome! +MIT diff --git a/Http.Sequence/SequenceHttpContentOfT.cs b/Http.Sequence/SequenceHttpContentOfT.cs index 7960725..bae90c4 100644 --- a/Http.Sequence/SequenceHttpContentOfT.cs +++ b/Http.Sequence/SequenceHttpContentOfT.cs @@ -5,7 +5,7 @@ namespace Juner.Http.Sequence; -public sealed class SequenceHttpContent : HttpContent +public sealed class SequenceHttpContent : HttpContent { readonly IAsyncEnumerable _source; readonly JsonTypeInfo _typeInfo; diff --git a/Juner.Sequence.code-workspace b/Juner.Sequence.code-workspace index ae3b594..0d978d9 100644 --- a/Juner.Sequence.code-workspace +++ b/Juner.Sequence.code-workspace @@ -62,5 +62,11 @@ "package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock", "appsettings.json": "appsettings.*.json", }, + }, + "extensions": { + "recommendations": [ + "usernamehw.errorlens", + "ms-dotnettools.csdevkit" + ] } } \ No newline at end of file diff --git a/README.md b/README.md index 73a82cc..77f93bb 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,68 @@ # Juner.Sequence -High-performance streaming JSON serialization for .NET using `IAsyncEnumerable`. +[![NuGet](https://img.shields.io/nuget/v/Juner.Sequence.svg)](https://www.nuget.org/packages/Juner.Sequence/) +[![NuGet](https://img.shields.io/nuget/v/Juner.Http.Sequence.svg)](https://www.nuget.org/packages/Juner.Http.Sequence/) +[![NuGet](https://img.shields.io/nuget/v/Juner.AspNetCore.Sequence.svg)](https://www.nuget.org/packages/Juner.AspNetCore.Sequence/) +[![.NET Test](https://github.com/juner/Sequence/actions/workflows/test.yml/badge.svg)](https://github.com/juner/Sequence/actions/workflows/test.yml) -Supports: +High‑performance streaming JSON serialization for .NET using `IAsyncEnumerable`. +Ideal for large datasets, real‑time APIs, and memory‑efficient processing. -- NDJSON (Newline Delimited JSON) -- JSON Lines -- JSON Sequence (`application/json-seq`) -- JSON arrays +Supports modern streaming‑friendly JSON formats: -Built on top of `System.Text.Json` with **AOT-friendly design** and **zero-allocation streaming** in mind. +- **NDJSON** (`application/x-ndjson`) +- **JSON Lines** (`application/jsonl`) +- **JSON Sequence** (`application/json-seq`) + +Built on top of `System.Text.Json` with an **AOT‑friendly**, **zero‑allocation**, and **layered** design. --- -## 📦 Packages +## Packages -| Package | Description | -|--------|------------| -| `Juner.Sequence` | Core streaming serializer (no HTTP dependency) | -| `Juner.Http.Sequence` | HttpClient / HttpContent integration | -| `Juner.AspNetCore.Sequence` | ASP.NET Core request/response streaming support | +| Package | Description | AOT-safe? | +|--------|-------------|-----------| +| **Juner.Sequence** | Core streaming serializer (no HTTP dependency) | ✔ Fully AOT-safe | +| **Juner.Http.Sequence** | `HttpClient` / `HttpContent` integration | ✔ Fully AOT-safe | +| **Juner.AspNetCore.Sequence** | ASP.NET Core streaming input/output | ✔ Minimal API only
✘ MVC formatters | --- -## 🚀 Features +## Features -- ⚡ High-performance streaming JSON -- 🔄 Full `IAsyncEnumerable` support -- 🧩 Multiple formats (NDJSON / JSON Lines / JSON Sequence / Array) -- 🛡️ AOT-friendly (`JsonTypeInfo`-based API) -- 🧱 Clean layered architecture -- 🌐 HTTP integration support +- ⚡ High‑performance streaming JSON +- 🔄 Full `IAsyncEnumerable` support +- 🧩 Multiple streaming formats (NDJSON / JSON Lines / JSON Sequence) +- 🛡️ AOT‑friendly core (`JsonTypeInfo`‑based API; no reflection) +- 🧼 Clean and layered architecture +- 🌐 HttpClient integration (`Juner.Http.Sequence`) +- 🌐 ASP.NET Core integration (`Juner.AspNetCore.Sequence`) --- -## ✨ Quick Example +## Architecture + +```mermaid +graph TD; + A[Juner.Sequence
Core] --> B[Juner.Http.Sequence
HTTP Integration]; + A --> C[Juner.AspNetCore.Sequence
ASP.NET Core]; +``` + +Note: **Juner.AspNetCore.Sequence does not depend on Juner.Http.Sequence**. +Both integrate independently with the core serializer. + +--- + +## Quick Start + +### JsonSerializerContext (AOT-safe) + +```csharp +[JsonSerializable(typeof(MyType))] +public partial class MyJsonContext : JsonSerializerContext { } +``` + +--- ### Serialize (NDJSON) @@ -47,9 +75,7 @@ await SequenceSerializer.SerializeAsync( cancellationToken); ``` ---- - -### Deserialize (Streaming) +### Deserialize (streaming) ```csharp await foreach (var item in SequenceSerializer.DeserializeAsyncEnumerable( @@ -64,9 +90,7 @@ await foreach (var item in SequenceSerializer.DeserializeAsyncEnumerable( --- -## 🌐 HttpClient Integration - -Using `Juner.Http.Sequence`: +## HttpClient Integration ```csharp var request = new HttpRequestMessage(HttpMethod.Post, url) @@ -83,87 +107,50 @@ await foreach (var item in response.Content.ReadJsonLinesAsyncEnumerable --- -## 🧠 API Design - -This library provides two styles of APIs: - -### ✅ AOT-safe (recommended) +## API Design -```csharp -JsonTypeInfo -``` +### AOT-safe API (Recommended) -- No reflection -- Fully compatible with Native AOT +- No reflection +- Fully compatible with Native AOT +- Uses `JsonTypeInfo` ---- +### Convenience API -### ⚠️ Convenience APIs - -```csharp -JsonSerializerOptions.Default -``` - -- Easier to use -- May require reflection -- Not guaranteed AOT-safe +- Based on `JsonSerializerOptions` +- May require reflection +- Not guaranteed to be AOT-safe --- -## 🧩 Supported Formats +## Supported Streaming Formats | Format | Content-Type | Option | -|-------|-------------|--------| -| NDJSON | `application/x-ndjson` | `JsonLines` | -| JSON Lines | `application/jsonl` | `JsonLines` | -| JSON Sequence | `application/json-seq` | `JsonSequence` | - ---- - -## 🏗️ Architecture - -``` -Juner.Sequence - ↓ -Juner.Http.Sequence - ↓ -Juner.AspNetCore.Sequence -``` - -- Core is independent from HTTP -- Extensions layer adds integration -- ASP.NET Core layer is optional - ---- - -## 📌 Why Juner.Sequence? - -- `System.Text.Json` does not provide streaming sequence formats out-of-the-box -- Existing solutions often: - - allocate heavily - - lack AOT support - - are tightly coupled to frameworks +|--------|--------------|--------| +| NDJSON | application/x-ndjson | `JsonLines` | +| JSON Lines | application/jsonl | `JsonLines` | +| JSON Sequence | application/json-seq | `JsonSequence` | -👉 **Juner.Sequence solves these problems with a clean, composable design** +*(JSON Array intentionally omitted — see Note below.)* --- -## 📄 License +## Notes on JSON Array Support -MIT +> **Note on JSON Arrays** +> `application/json` (JSON arrays) is **not** a streaming format and is not supported by the core library. +> However, `Juner.AspNetCore.Sequence` accepts and returns JSON arrays **only for convenience**, +> and they are always handled as **non‑streaming buffered JSON**. --- -## 🔗 Links +## License -- GitHub: https://github.com/juner/Sequence -- NuGet: - - https://www.nuget.org/packages/Juner.Sequence - - https://www.nuget.org/packages/Juner.Http.Sequence - - https://www.nuget.org/packages/Juner.AspNetCore.Sequence +MIT License +See the `LICENSE` file for details. --- -## 🙌 Contributions +## Links -Issues and PRs are welcome! +- Repository: https://github.com/juner/Sequence diff --git a/Sequence.Tests/SequenceSerializer_SerializeTests.cs b/Sequence.Tests/SequenceSerializer_SerializeTests.cs index fcb5177..dd4ca9b 100644 --- a/Sequence.Tests/SequenceSerializer_SerializeTests.cs +++ b/Sequence.Tests/SequenceSerializer_SerializeTests.cs @@ -80,7 +80,7 @@ await SequenceSerializer.SerializeAsync( stream, Data(), TypeInfo, - SequenceSerializerOptions.Empty, + SequenceSerializerOptions.Default, CancellationToken); var result = Encoding.UTF8.GetString(stream.ToArray()); diff --git a/Sequence.Tests/TrackingStream.cs b/Sequence.Tests/TrackingStream.cs index 3595f45..4a61678 100644 --- a/Sequence.Tests/TrackingStream.cs +++ b/Sequence.Tests/TrackingStream.cs @@ -6,10 +6,10 @@ class TrackingStream : MemoryStream public TrackingStream() : base() => stream = new(); public TrackingStream(byte[] buffer) : base() => stream = new(buffer); public TrackingStream(int capacity) : base() => stream = new(capacity); - public TrackingStream(byte[] buffer, bool writable): base() => stream = new(buffer, writable); - public TrackingStream(byte[] buffer, int index, int count): base() => stream = new(buffer, index, count); - public TrackingStream(byte[] buffer, int index, int count, bool writable): base() => stream = new(buffer, index, count, writable); - public TrackingStream(byte[] buffer, int index, int count, bool writable, bool publiclyVisible): base() => stream = new(buffer, index, count, writable, publiclyVisible); + public TrackingStream(byte[] buffer, bool writable) : base() => stream = new(buffer, writable); + public TrackingStream(byte[] buffer, int index, int count) : base() => stream = new(buffer, index, count); + public TrackingStream(byte[] buffer, int index, int count, bool writable) : base() => stream = new(buffer, index, count, writable); + public TrackingStream(byte[] buffer, int index, int count, bool writable, bool publiclyVisible) : base() => stream = new(buffer, index, count, writable, publiclyVisible); public override bool CanRead => stream.CanRead; public override bool CanSeek => stream.CanSeek; public override bool CanTimeout => stream.CanTimeout; diff --git a/Sequence/CHANGELOG.md b/Sequence/CHANGELOG.md new file mode 100644 index 0000000..de77a05 --- /dev/null +++ b/Sequence/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog — Juner.Sequence + +## [1.0.0] — 2026-03-31 + +### Added + +- Initial stable release of the core streaming JSON serializer. +- High‑performance serialization for: + - NDJSON (`application/x-ndjson`) + - JSON Lines (`application/jsonl`) + - JSON Sequence (`application/json-seq`, RFC 7464) +- Fully streaming deserialization via `DeserializeAsyncEnumerable`. +- Fully streaming serialization via `SerializeAsync`. +- Zero‑allocation, AOT‑friendly API using `JsonTypeInfo`. +- Convenience API using `JsonSerializerOptions`. +- Pluggable `ISequenceSerializerWriteOptions` and `ISequenceSerializerReadOptions`. +- Unified `SequenceSerializerOptions` presets (`JsonLines`, `JsonSequence`, `Default`). +- Strict separation from HTTP concerns (no HttpClient / ASP.NET Core dependencies). + +### Notes + +- JSON Array (`application/json`) is intentionally **not supported** as a streaming format. diff --git a/Sequence/Extensions/Json/SequenceSerializerDefaultJsonSerializerOptionsExtensions.cs b/Sequence/Extensions/Json/SequenceSerializerDefaultJsonSerializerOptionsExtensions.cs new file mode 100644 index 0000000..e3b5ba0 --- /dev/null +++ b/Sequence/Extensions/Json/SequenceSerializerDefaultJsonSerializerOptionsExtensions.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Pipelines; +using System.Text.Json; + +namespace Juner.Sequence.Extensions.Json; + +/// +/// use extensions +/// +public static class SequenceSerializerDefaultJsonSerializerOptionsExtensions +{ + const string RequiresUnreferencedCodeMessage = "Uses JsonSerializerOptions.Default which may require reflection."; + const string RequiresDynamicCodeMessage = "May not be AOT compatible."; + extension(SequenceSerializer) + { + + /// + /// + /// + /// + /// + /// + /// + /// + /// + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public static Task SerializeAsync(PipeWriter writer, IAsyncEnumerable enumerable, ISequenceSerializerWriteOptions options, CancellationToken cancellationToken = default) + => SequenceSerializerJsonSerializerOptionsExtensions.SerializeAsync(writer, enumerable, JsonSerializerOptions.Default, options, cancellationToken); + + /// + /// + /// + /// + /// + /// + /// + /// + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public static IAsyncEnumerable DeserializeAsyncEnumerable(PipeReader reader, ISequenceSerializerReadOptions options, CancellationToken cancellationToken = default) + => SequenceSerializerJsonSerializerOptionsExtensions.DeserializeAsyncEnumerable(reader, JsonSerializerOptions.Default, options, cancellationToken); + } +} \ No newline at end of file diff --git a/Sequence/Extensions/SequenceSerializerEncodeExntensions.cs b/Sequence/Extensions/SequenceSerializerEncodeExntensions.cs index 0be6e66..b882d6b 100644 --- a/Sequence/Extensions/SequenceSerializerEncodeExntensions.cs +++ b/Sequence/Extensions/SequenceSerializerEncodeExntensions.cs @@ -67,4 +67,4 @@ static async IAsyncEnumerable WrappedDeserializeAsyncEnumerable(Stream stream } } } -} +} \ No newline at end of file diff --git a/Sequence/Extensions/Json/SequenceSerializerJsonSerializerOptionsExtensions.cs b/Sequence/Extensions/SequenceSerializerJsonSerializerOptionsExtensions.cs similarity index 51% rename from Sequence/Extensions/Json/SequenceSerializerJsonSerializerOptionsExtensions.cs rename to Sequence/Extensions/SequenceSerializerJsonSerializerOptionsExtensions.cs index 3dcccbd..5590236 100644 --- a/Sequence/Extensions/Json/SequenceSerializerJsonSerializerOptionsExtensions.cs +++ b/Sequence/Extensions/SequenceSerializerJsonSerializerOptionsExtensions.cs @@ -1,17 +1,14 @@ -using System.Diagnostics.CodeAnalysis; -using System.IO.Pipelines; +using System.IO.Pipelines; using System.Text.Json; using System.Text.Json.Serialization.Metadata; -namespace Juner.Sequence.Extensions.Json; +namespace Juner.Sequence.Extensions; /// /// use extensions /// -public static class SequenceJsonSerializerOptionsExtensions +public static class SequenceSerializerJsonSerializerOptionsExtensions { - const string RequiresUnreferencedCodeMessage = "Uses JsonSerializerOptions.Default which may require reflection."; - const string RequiresDynamicCodeMessage = "May not be AOT compatible."; static string GenerateErrorMessage() => $"JsonTypeInfo<{typeof(T).FullName}> not found. " + $"Ensure the type is registered in JsonSerializerOptions.TypeInfoResolver."; @@ -28,11 +25,16 @@ static string GenerateErrorMessage() /// /// public static Task SerializeAsync(PipeWriter writer, IAsyncEnumerable enumerable, JsonSerializerOptions jsonSerializerOptions, ISequenceSerializerWriteOptions options, CancellationToken cancellationToken = default) - => jsonSerializerOptions.GetTypeInfo(typeof(T)) switch + { +#if !NET8_0_OR_GREATER + jsonSerializerOptions.TypeInfoResolver ??= new DefaultJsonTypeInfoResolver(); +#endif + return jsonSerializerOptions.GetTypeInfo(typeof(T)) switch { JsonTypeInfo jsonTypeInfo => SequenceSerializer.SerializeAsync(writer, enumerable, jsonTypeInfo, options, cancellationToken), _ => throw new InvalidOperationException(GenerateErrorMessage()), }; + } /// /// @@ -44,37 +46,15 @@ public static Task SerializeAsync(PipeWriter writer, IAsyncEnumerable enum /// /// public static IAsyncEnumerable DeserializeAsyncEnumerable(PipeReader reader, JsonSerializerOptions jsonSerializerOptions, ISequenceSerializerReadOptions options, CancellationToken cancellationToken = default) - => jsonSerializerOptions.GetTypeInfo(typeof(T)) switch + { +#if !NET8_0_OR_GREATER + jsonSerializerOptions.TypeInfoResolver ??= new DefaultJsonTypeInfoResolver(); +#endif + return jsonSerializerOptions.GetTypeInfo(typeof(T)) switch { JsonTypeInfo jsonTypeInfo => SequenceSerializer.DeserializeAsyncEnumerable(reader, jsonTypeInfo, options, cancellationToken), _ => throw new InvalidOperationException(GenerateErrorMessage()), }; - - /// - /// - /// - /// - /// - /// - /// - /// - /// - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public static Task SerializeAsync(PipeWriter writer, IAsyncEnumerable enumerable, ISequenceSerializerWriteOptions options, CancellationToken cancellationToken = default) - => SerializeAsync(writer, enumerable, JsonSerializerOptions.Default, options, cancellationToken); - - /// - /// - /// - /// - /// - /// - /// - /// - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public static IAsyncEnumerable DeserializeAsyncEnumerable(PipeReader reader, ISequenceSerializerReadOptions options, CancellationToken cancellationToken = default) - => DeserializeAsyncEnumerable(reader, JsonSerializerOptions.Default, options, cancellationToken); + } } } \ No newline at end of file diff --git a/Sequence/Extensions/SequenceSerializerJsonTypeInfoExtensions.cs b/Sequence/Extensions/SequenceSerializerJsonTypeInfoExtensions.cs index 3509008..6e5d6fd 100644 --- a/Sequence/Extensions/SequenceSerializerJsonTypeInfoExtensions.cs +++ b/Sequence/Extensions/SequenceSerializerJsonTypeInfoExtensions.cs @@ -17,11 +17,11 @@ public static Task SerializeAsync(PipeWriter writer, IAsyncEnumerable enum _ => throw new InvalidOperationException(), }; - public static IAsyncEnumerable DeserializeAsyncEnumerable(PipeReader reader, JsonTypeInfo jsonTypeInfo, ISequenceSerializerReadOptions options, CancellationToken cancellationToken = default) - => jsonTypeInfo switch - { - JsonTypeInfo jsonTypeInfo2 => SequenceSerializer.DeserializeAsyncEnumerable(reader, jsonTypeInfo2, options, cancellationToken), + public static IAsyncEnumerable DeserializeAsyncEnumerable(PipeReader reader, JsonTypeInfo jsonTypeInfo, ISequenceSerializerReadOptions options, CancellationToken cancellationToken = default) + => jsonTypeInfo switch + { + JsonTypeInfo jsonTypeInfo2 => SequenceSerializer.DeserializeAsyncEnumerable(reader, jsonTypeInfo2, options, cancellationToken), _ => throw new InvalidOperationException(), - }; + }; } } \ No newline at end of file diff --git a/Sequence/Extensions/SequenceSerializerStreamExtensions.cs b/Sequence/Extensions/SequenceSerializerStreamExtensions.cs index 764c74e..34f6a1c 100644 --- a/Sequence/Extensions/SequenceSerializerStreamExtensions.cs +++ b/Sequence/Extensions/SequenceSerializerStreamExtensions.cs @@ -60,21 +60,23 @@ public static async Task SerializeAsync(Stream stream, IAsyncEnumerable en /// /// /// - public static async IAsyncEnumerable DeserializeAsyncEnumerable(Stream stream, JsonTypeInfo jsonTypeInfo, ISequenceSerializerReadOptions options, [EnumeratorCancellation]CancellationToken cancellationToken = default) + public static async IAsyncEnumerable DeserializeAsyncEnumerable(Stream stream, JsonTypeInfo jsonTypeInfo, ISequenceSerializerReadOptions options, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var reader = PipeReader.Create(stream); await using var enumerator = SequenceSerializer.DeserializeAsyncEnumerable(reader, jsonTypeInfo, options, cancellationToken).GetAsyncEnumerator(cancellationToken); var next = false; do { - try { + try + { next = await enumerator.MoveNextAsync(); if (!next) { await reader.CompleteAsync(); yield break; } - } catch (Exception e) + } + catch (Exception e) { await reader.CompleteAsync(e); throw; @@ -82,7 +84,7 @@ public static async IAsyncEnumerable DeserializeAsyncEnumerable(Stream str yield return enumerator.Current; } while (next); - + } } -} +} \ No newline at end of file diff --git a/Sequence/Juner.Sequence.csproj b/Sequence/Juner.Sequence.csproj index 946d06a..229c366 100644 --- a/Sequence/Juner.Sequence.csproj +++ b/Sequence/Juner.Sequence.csproj @@ -1,14 +1,8 @@  - - - true - - - High-performance streaming JSON sequence serialization for .NET. -Supports NDJSON, JSON Lines, JSON Sequence, and JSON array formats using IAsyncEnumerable. -AOT-friendly and System.Text.Json-based. - json;streaming;ndjson;jsonlines;json-seq;iasyncenumerable;system-text-json;aot;serialization + High-performance streaming JSON serialization for NDJSON, JSON Lines, and JSON Sequence. +AOT-friendly, zero-allocation, and built on System.Text.Json with IAsyncEnumerable<T> support. + json;streaming;ndjson;jsonlines;json-seq;iasyncenumerable;system-text-json;aot;serialization;json-sequence https://github.com/juner/Sequence True @@ -31,6 +25,8 @@ AOT-friendly and System.Text.Json-based. + + diff --git a/Sequence/README.md b/Sequence/README.md index bbfb586..f0b49ca 100644 --- a/Sequence/README.md +++ b/Sequence/README.md @@ -1,61 +1,62 @@ # Juner.Sequence -A high-performance, AOT-friendly JSON sequence serializer for .NET. +High​‑performance, AOT-friendly streaming serializer for record-oriented JSON formats in .NET. -`Juner.Sequence` provides streaming serialization and deserialization for sequence-based JSON formats such as: +Record-oriented formats represent a sequence of independent JSON values, +rather than a single JSON array. -- JSON Lines (`.jsonl`) -- JSON Text Sequences (RFC 7464, RS-delimited) +`Juner.Sequence` provides zero‑allocation, fully streaming serialization and deserialization for **record‑oriented JSON formats**, including: -It is designed with **System.IO.Pipelines** and **System.Text.Json** in mind, focusing on: +- **NDJSON** (`application/x-ndjson`) +- **JSON Lines** (`application/jsonl`) +- **JSON Text Sequences** (RFC 7464, `application/json-seq`) + +Built on top of `System.Text.Json` and `System.IO.Pipelines`, designed for: - 🚀 High performance (minimal allocations) -- 🔒 AOT compatibility (no reflection by default) +- 🔒 AOT compatibility (`JsonTypeInfo`‑based) - 🔄 True streaming via `IAsyncEnumerable` +- 🧱 Clean, layered architecture --- -## ✨ Features +## Installation -- Fully streaming (no full buffering required) -- `JsonTypeInfo`-based (AOT safe) -- Supports JSON Lines and JSON Sequence formats -- Works directly with `PipeReader` / `PipeWriter` -- Optional extensions for: - - Encoding support - - `JsonSerializerOptions` compatibility +```bash +dotnet add package Juner.Sequence +``` --- -## 📦 Installation +## Quick Start -```bash -dotnet add package Juner.Sequence +### JsonSerializerContext (AOT‑safe) + +```csharp +[JsonSerializable(typeof(MyType))] +public partial class MyJsonContext : JsonSerializerContext { } ``` --- -## 🚀 Quick Start - -### Serialize +### Serialize (NDJSON / JSON Lines) ```csharp +// Serialize await SequenceSerializer.SerializeAsync( writer, - asyncEnumerable, - jsonTypeInfo, + source, + MyJsonContext.Default.MyType, SequenceSerializerOptions.JsonLines, cancellationToken); ``` ---- - -### Deserialize +### Deserialize (streaming) ```csharp await foreach (var item in SequenceSerializer.DeserializeAsyncEnumerable( reader, - jsonTypeInfo, + MyJsonContext.Default.MyType, SequenceSerializerOptions.JsonLines, cancellationToken)) { @@ -65,9 +66,9 @@ await foreach (var item in SequenceSerializer.DeserializeAsyncEnumerable( --- -## 🔑 AOT-Friendly Design +## AOT‑Friendly Design -This library is built around: +`Juner.Sequence` is built around: ```csharp JsonTypeInfo @@ -77,140 +78,232 @@ instead of `JsonSerializerOptions`. ### Why? -- No runtime reflection -- Works with Native AOT -- Better performance and predictability +- No runtime reflection +- Native AOT compatible +- Faster and more predictable metadata generation --- -## ⚠️ Optional: JsonSerializerOptions Support +## Runtime Behavior -You can opt-in to `JsonSerializerOptions` support: +### .NET 9 or later +`SequenceSerializer` writes directly to `PipeWriter` using `JsonSerializer.SerializeAsync`, providing the fastest possible path. + +### .NET 8 or earlier +Serialization falls back to a stream‑based implementation: ```csharp -using Juner.Sequence.Extensions.Json; +writer.AsStream() ``` -Example: +This ensures compatibility, though it may introduce additional allocations +compared to the PipeWriter-based fast path. + +--- + +## SequenceSerializerOptions + +`SequenceSerializerOptions` defines how records are framed during serialization and deserialization. + +### Built‑in presets + +| Name | Description | +|------|-------------| +| `JsonSequence` | RFC 7464 (`RS` + JSON + `LF`) | +| `JsonLines` | NDJSON / JSON Lines ( JSON + `LF`) | + +### Invalid Options + +A valid sequence format must define at least one start or end delimiter. +Options that define **no framing** are considered **invalid** and are not supported. + +The library contains an internal default value used only for initialization, +but it is not available for public use. + +--- + +## FlushStrategy + +Controls how flushing is performed during serialization. + +| Strategy | Behavior | +|----------|----------| +| `None` | Caller or transport controls flushing | +| `PerRecord` | Flush after each record (default) | + +`PerRecord` improves real‑time behavior but reduces throughput. + +`PerRecord` is useful for real-time streaming scenarios (e.g. logs, HTTP streaming), +while `None` maximizes throughput in batch processing. + +--- + +## Framing Engine (Core Feature) + +The deserializer uses optimized fast‑paths for common formats: + +- **NDJSON / JSON Lines** + (`Start = empty`, `End = 1 byte`) + +- **JSON Sequence** + (`Start = 1 byte`, `End = 1 byte`) + +For custom formats, it falls back to a general delimiter‑matching engine that supports: + +- Multiple start delimiters +- Multiple end delimiters +- Variable‑length delimiters +- Longest‑match semantics + +The final frame is handled separately, with optional support for ignoring incomplete frames. + +--- + +## Optional Extensions + +### JsonTypeInfo (non‑generic) Extensions — *advanced use only* + +These extensions allow passing a non‑generic `JsonTypeInfo`: ```csharp +using Juner.Sequence.Extensions; + await SequenceSerializer.SerializeAsync( writer, - asyncEnumerable, - jsonSerializerOptions, + source, + (JsonTypeInfo)myTypeInfo, SequenceSerializerOptions.JsonLines); ``` -> ⚠️ These APIs are **not AOT-safe** and may use reflection. +> ⚠️ Not recommended for general use. +> Not AOT‑safe and will throw if the provided `JsonTypeInfo` does not match `T`. --- -## 🌐 Encoding Support +### JsonSerializerOptions Support — *not guaranteed AOT‑safe* -By default, the library operates in UTF-8. - -To use other encodings: +These extensions resolve metadata via `JsonSerializerOptions.TypeInfoResolver`: ```csharp -using Juner.Sequence.Extensions; +using Juner.Sequence.Extensions.Json; await SequenceSerializer.SerializeAsync( writer, - asyncEnumerable, - jsonTypeInfo, - SequenceSerializerOptions.JsonLines, - Encoding.UTF8); + source, + jsonSerializerOptions, + SequenceSerializerOptions.JsonLines); ``` -Internally, this uses a transcoding stream. +> ⚠️ May rely on reflection and is **not guaranteed to be AOT‑safe**. --- -## 📚 Supported Formats +### JsonSerializerOptions.Default Support — *explicitly not AOT‑safe* -### JSON Lines +Convenience APIs using `JsonSerializerOptions.Default`: -```json -{"id":1} -{"id":2} +```csharp +using Juner.Sequence.Extensions.Json; + +await SequenceSerializer.SerializeAsync( + writer, + source, + SequenceSerializerOptions.JsonLines); ``` ---- +These APIs are annotated with: -### JSON Text Sequence (RFC 7464) +- `RequiresUnreferencedCode` +- `RequiresDynamicCode` -``` -RS {"id":1} -RS {"id":2} -``` +> ⚠️ **Explicitly not AOT‑safe.** --- -## 🧱 Architecture +### Encoding Support (AOT‑safe) +Supports non‑UTF‑8 encodings via transcoding streams: + +```csharp +using Juner.Sequence.Extensions; + +await SequenceSerializer.SerializeAsync( + writer, + source, + typeInfo, + SequenceSerializerOptions.JsonLines, + Encoding.UTF32); ``` -Juner.Sequence - ├ Core (AOT-safe) - │ └ JsonTypeInfo APIs - │ - ├ Extensions - │ └ Encoding support - │ - └ Extensions.Json - └ JsonSerializerOptions support (⚠ not AOT-safe) -``` + +UTF‑8 uses the fast path with no overhead. --- -## 🧪 Example with PipeReader +### Stream‑based Extensions (AOT‑safe) + +Convenience APIs for working directly with `Stream`: ```csharp -var reader = PipeReader.Create(stream); +using Juner.Sequence.Extensions; -await foreach (var item in SequenceSerializer.DeserializeAsyncEnumerable( - reader, - jsonTypeInfo, - SequenceSerializerOptions.JsonLines)) -{ - // process item -} +await SequenceSerializer.SerializeAsync( + stream, + source, + typeInfo, + SequenceSerializerOptions.JsonLines); ``` ---- +Internally uses `PipeReader.Create(stream)` for deserialization. -## 📌 When to Use +--- -Use this library when: +## Supported Formats -- Processing large JSON streams -- Building high-performance APIs -- Targeting Native AOT -- Working with pipelines or streaming systems +| Format | Content-Type | Notes | +|--------|--------------|-------| +| NDJSON | application/x-ndjson | newline‑delimited | +| JSON Lines | application/jsonl | equivalent to NDJSON | +| JSON Sequence | application/json-seq | RFC 7464 (RS‑delimited) | --- -## ⚠️ When NOT to Use +## About JSON Array (`application/json`) -- Small payloads → use `JsonSerializer` -- Reflection-heavy scenarios → use `JsonSerializerOptions` directly +JSON arrays are already well supported by `JsonSerializer` for stream-based scenarios. + +Juner.Sequence is designed specifically for record-oriented streaming formats, +where each JSON value can be processed independently. + +For this reason, JSON arrays are intentionally not supported. --- -## 📄 License +## Architecture -MIT License +```mermaid +graph TD; + A[Juner.Sequence
Core] --> B[Extensions
Encoding Support]; + A --> C[Extensions.Json
JsonSerializerOptions Support]; + A --> D[Extensions.JsonTypeInfo
Advanced Scenarios]; +``` --- -## 🙌 Contributing +## When to Use + +- Processing large JSON streams +- Building high‑performance pipelines +- Targeting Native AOT +- Working with `PipeReader` / `PipeWriter` + +## When NOT to Use -Contributions are welcome! -Feel free to open issues or pull requests. +- Small payloads → `JsonSerializer` is simpler +- JSON arrays → use `JsonSerializer` +- Native AOT scenarios → avoid `JsonSerializerOptions.Default` (not AOT‑safe) --- -## 💡 Notes +## License -- Prefer `JsonTypeInfo` for best performance and AOT safety -- Use extensions only when necessary -- Keep streaming — avoid buffering \ No newline at end of file +MIT diff --git a/Sequence/SequenceSerializer.Serialize.cs b/Sequence/SequenceSerializer.Serialize.cs index b917ca1..c98a822 100644 --- a/Sequence/SequenceSerializer.Serialize.cs +++ b/Sequence/SequenceSerializer.Serialize.cs @@ -57,4 +57,4 @@ public static async Task SerializeAsync(PipeWriter writer, IAsyncEnumerable - /// empty option + /// invalid emtpy option (default value) ///
- public static ISequenceSerializerOptions Empty + internal static SequenceSerializerOptions Default { get { diff --git a/Sequence/SequenceSerializerReadOptionsExtensions.cs b/Sequence/SequenceSerializerReadOptionsExtensions.cs index bd0cab1..2c842f2 100644 --- a/Sequence/SequenceSerializerReadOptionsExtensions.cs +++ b/Sequence/SequenceSerializerReadOptionsExtensions.cs @@ -5,8 +5,8 @@ public static class SequenceSerializerReadOptionsExtensions extension(ISequenceSerializerReadOptions options) { /// - /// sequence read options is Empty + /// sequence read options is Invalid /// - public bool IsEmpty => options is not ({ Start.Count: > 0 } or { End.Count: > 0 }); + public bool IsInvalid => options is not ({ Start.Count: > 0 } or { End.Count: > 0 }); } } \ No newline at end of file diff --git a/Sequence/SequenceSerializerWriteOptionsExtensions.cs b/Sequence/SequenceSerializerWriteOptionsExtensions.cs index fcb5af2..393edf9 100644 --- a/Sequence/SequenceSerializerWriteOptionsExtensions.cs +++ b/Sequence/SequenceSerializerWriteOptionsExtensions.cs @@ -4,6 +4,9 @@ public static class SequenceSerializerWriteOptionsExtensions { extension(ISequenceSerializerWriteOptions options) { - public bool IsEmpty => options is not ({ Start.IsEmpty: false } or { End.IsEmpty: false }); + /// + /// sequence write options is Invalid + /// + public bool IsInvalid => options is not ({ Start.IsEmpty: false } or { End.IsEmpty: false }); } } \ No newline at end of file diff --git a/samples/AspNetCore.Sequence/appsettings.json b/samples/AspNetCore.Sequence/appsettings.json index 149cb09..29c7ef6 100644 --- a/samples/AspNetCore.Sequence/appsettings.json +++ b/samples/AspNetCore.Sequence/appsettings.json @@ -5,5 +5,6 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "urls": "https://localhost:5000" } \ No newline at end of file diff --git a/samples/Sequence/BenchMark.cs b/samples/Sequence/BenchMark.cs index 7a15e90..a2cf922 100644 --- a/samples/Sequence/BenchMark.cs +++ b/samples/Sequence/BenchMark.cs @@ -1,164 +1,148 @@ #!/usr/bin/env dotnet run #:sdk Microsoft.NET.Sdk -#:project ../../Sequence/Juner.Sequence.csproj #:property TargetFramework=net10.0 #:property TargetFrameworks=net8.0;net9.0;net10.0 #:property PublishAot=false #:property Configuration=Release #:property Optimize=true +#:property LangVersion=14 +#:package BenchmarkDotNet +#:package Juner.Sequence@1.0.0-preview-2 -using System.Diagnostics; using System.IO.Pipelines; using System.Text.Json.Serialization; -using Juner.Sequence; +using System.Text.Json.Serialization.Metadata; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Running; // ========================== -// Benchmark Config +// Entry Point // ========================== -const int Count = 100_000; +BenchmarkRunner.Run(args: args); -// ========================== -// Data -// ========================== -static async IAsyncEnumerable Generate(int count) -{ - for (var i = 0; i < count; i++) - { - yield return new TestData(i, $"Name-{i}"); - } -} -// ========================== -// Benchmark Runner -// ========================== -static async Task RunAsync(string[] args, CancellationToken cancellationToken = default) +namespace Juner.Sequence.BenchMarkSample { - var count = Count; - if (args is { Length: > 0 } && int.Parse(args[0]) is { } count2) - count = count2; - var jsonTypeInfo = AppJsonContext.Default.TestData; - - Console.WriteLine($"Count: {count}"); - Console.WriteLine(); - // ========================== - // Serialize Benchmark + // Benchmark Config // ========================== + [InProcess] + public class SequenceBenchmarks { - var pipe = new Pipe(); + [Params(100_000)] + public int Count; + + private JsonTypeInfo _jsonTypeInfo = null!; - var sw = Stopwatch.StartNew(); + [GlobalSetup] + public void Setup() => _jsonTypeInfo = AppJsonContext.Default.TestData; - var writerTask = WriteToComplete(); - async Task WriteToComplete() + // ========================== + // Data + // ========================== + private static async IAsyncEnumerable Generate(int count) { - await SequenceSerializer.SerializeAsync( - pipe.Writer, - Generate(count), - jsonTypeInfo, - SequenceSerializerOptions.JsonLines, - cancellationToken); - await pipe.Writer.CompleteAsync(); + for (var i = 0; i < count; i++) + { + yield return new TestData(i, $"Name-{i}"); + } } - var readerTask = Consume(pipe.Reader); + // ========================== + // Serialize Benchmark + // ========================== + [Benchmark] + public async Task Serialize() + { + var pipe = new Pipe(); - await Task.WhenAll(writerTask, readerTask); + var writerTask = WriteAsync(pipe.Writer, Count); - sw.Stop(); + async Task WriteAsync(PipeWriter writer, int count, CancellationToken cancellationToken = default) + { + await SequenceSerializer.SerializeAsync( + writer, + Generate(count), + _jsonTypeInfo, + SequenceSerializerOptions.JsonLines, + cancellationToken); + await writer.CompleteAsync(); + } - Console.WriteLine($"Serialize: {sw.ElapsedMilliseconds} ms"); - } + var readerTask = Consume(pipe.Reader); - // ========================== - // Deserialize Benchmark - // ========================== - { - var pipe = new Pipe(); - - // 事前にデータ流し込む - await SequenceSerializer.SerializeAsync( - pipe.Writer, - Generate(count), - jsonTypeInfo, - SequenceSerializerOptions.JsonLines, - cancellationToken); - - await pipe.Writer.CompleteAsync(); + await Task.WhenAll(writerTask, readerTask); + } - var sw = Stopwatch.StartNew(); + // ========================== + // Deserialize Benchmark + // ========================== + [Benchmark] + public async Task Deserialize() + { + var pipe = new Pipe(); + var WriteTask = Writing(pipe.Writer, _jsonTypeInfo, Count); + static async Task Writing(PipeWriter writer, JsonTypeInfo jsonTypeInfo, int Count, CancellationToken cancellationToken = default) + { + // preload + await SequenceSerializer.SerializeAsync( + writer, + Generate(Count), + jsonTypeInfo, + SequenceSerializerOptions.JsonLines, + cancellationToken); + + await writer.CompleteAsync(); + } + var ReadTask = Reading(pipe.Reader, _jsonTypeInfo); + static async Task Reading(PipeReader reader, JsonTypeInfo jsonTypeInfo, CancellationToken cancellationToken = default) + { + var readCount = 0; + + await foreach (var _ in SequenceSerializer.DeserializeAsyncEnumerable( + reader, + jsonTypeInfo, + SequenceSerializerOptions.JsonLines, + cancellationToken)) + { + readCount++; + } + return readCount; + } + await Task.WhenAll(WriteTask, ReadTask); - var readCount = 0; + } - await foreach (var _ in SequenceSerializer.DeserializeAsyncEnumerable( - pipe.Reader, - jsonTypeInfo, - SequenceSerializerOptions.JsonLines, - cancellationToken)) + // ========================== + // Helper + // ========================== + private static async Task Consume(PipeReader reader) { - readCount++; - } + while (true) + { + var result = await reader.ReadAsync(); + var buffer = result.Buffer; - sw.Stop(); + reader.AdvanceTo(buffer.End); - Console.WriteLine($"Deserialize: {sw.ElapsedMilliseconds} ms"); - Console.WriteLine($"Read Count: {readCount}"); - } -} + if (result.IsCompleted) + break; + } -// ========================== -// Helper -// ========================== -static async Task Consume(PipeReader reader) -{ - while (true) - { - var result = await reader.ReadAsync(); - var buffer = result.Buffer; - - // discard - reader.AdvanceTo(buffer.End); - - if (result.IsCompleted) - break; + await reader.CompleteAsync(); + } } - await reader.CompleteAsync(); -} - -var source = new CancellationTokenSource(); -Console.CancelKeyPress += (o, v) => -{ - if (v.Cancel) source.CancelAsync(); -}; -// ========================== -// Run -// ========================== -try -{ - await RunAsync(args, source.Token); - return 0; -} -catch (OperationCanceledException) -{ - return 1; -} -catch (Exception e) -{ - Console.Error.WriteLine(e.Message); - return -1; -} - - -// ========================== -// Model -// ========================== -record TestData(int Id, string Name); + // ========================== + // Model + // ========================== + public record TestData(int Id, string Name); -// ========================== -// Source Generator Context -// ========================== -[JsonSerializable(typeof(TestData))] -internal partial class AppJsonContext : JsonSerializerContext -{ + // ========================== + // Source Generator Context + // ========================== + [JsonSerializable(typeof(TestData))] + internal partial class AppJsonContext : JsonSerializerContext + { + } } \ No newline at end of file