NTResult.Refit 1.6.1

dotnet add package NTResult.Refit --version 1.6.1
                    
NuGet\Install-Package NTResult.Refit -Version 1.6.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="NTResult.Refit" Version="1.6.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="NTResult.Refit" Version="1.6.1" />
                    
Directory.Packages.props
<PackageReference Include="NTResult.Refit" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add NTResult.Refit --version 1.6.1
                    
#r "nuget: NTResult.Refit, 1.6.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package NTResult.Refit@1.6.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=NTResult.Refit&version=1.6.1
                    
Install as a Cake Addin
#tool nuget:?package=NTResult.Refit&version=1.6.1
                    
Install as a Cake Tool

<p align="center"> <img src="Logo.png" alt="NTResult logo" width="128" height="128" /> </p>

NTResult

NTResult is a small set of .NET libraries for representing an operation as either a successful value or an error. It keeps expected failures explicit without requiring exceptions for normal control flow, then carries that result cleanly through ASP.NET Core endpoints and Refit clients.

The typical flow is:

application/service -> INTResult<T> -> HTTP response -> Refit IApiResponse<T> -> INTResult<T>

Packages

Package Use it for Targets
NTResult Core INTResult, INTResult<T>, Expected, Optional, and NTFileDownload types .NET 8
NTResult.AspNetCore.Http Minimal API and controller integration through HttpNTResult, ToIResult, and NTResultControllerBase .NET 8 and later, including .NET 11 Preview
NTResult.Refit Converting Refit IApiResponse values into client-side INTResult values .NET 8 and later, including .NET 11 Preview

Install only the packages needed by each project. The integration packages reference the core package transitively.

# Domain, application, or shared project
dotnet add package NTResult

# ASP.NET Core API project
dotnet add package NTResult.AspNetCore.Http

# Refit client project
dotnet add package NTResult.Refit

Core package: NTResult

Create successful or failed results with the NTResult factory. A typed result exposes its value only on success and retains the original exception on failure.

using NTResult;
using NTResult.Exceptions;

public sealed record Order(int Id, string Description);

public static INTResult<Order> FindOrder(int id)
{
    Order? order = id == 42 ? new Order(42, "Replacement keyboard") : null;

    return order is null
        ? NTResult.Failure<Order>(new NotFoundException(typeof(Order), id))
        : NTResult.Success(order);
}

INTResult<Order> result = FindOrder(42);

if (result.TryGetValue(out var order))
{
    Console.WriteLine(order.Description);
}
else if (result.HasFailed)
{
    Console.WriteLine(result.ErrorMessage);
}

Useful result members include:

  • IsSuccessful, HasFailed, and IsCanceled for three-state branching.
  • TryGetValue, GetValueOrDefault, and ValueOr for non-throwing access.
  • ValueOrThrow and ThrowOnFailure when an exception boundary is appropriate.
  • OnCanceled and OnCanceledAsync for optional cancellation-result callbacks; transport cancellation still propagates.
  • OnSuccess, OnFailure, and Finally for small continuations.
  • Async equivalents for Task<INTResult> and Task<INTResult<T>> in NTResult.Ext.

The core package also includes Expected<TValue, TError>, Optional<T>, and NTFileDownload for code that needs those more specialized representations.

Server package: NTResult.AspNetCore.Http

HttpNTResult combines an INTResult with an ASP.NET Core IResult. It can be returned directly from a minimal API and supports common HTTP outcomes such as OK, Created, Accepted, Bad Request, Not Found, Unauthorized, Forbidden, redirects, file downloads, and Internal Server Error.

using Microsoft.AspNetCore.Mvc;
using NTResult.AspNetCore.Http;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/orders/{id:int}", (int id) =>
{
    var order = id == 42 ? new Order(42, "Replacement keyboard") : null;

    return order is null
        ? HttpNTResult<Order>.NotFound($"Order {id} was not found")
        : HttpNTResult<Order>.Ok(order);
});

app.MapPost("/orders", (Order order) =>
    HttpNTResult<Order>.Created($"/orders/{order.Id}", order));

app.MapGet("/health/dependency", () =>
    HttpNTResult.InternalServerError(new ProblemDetails
    {
        Title = "Dependency unavailable",
        Detail = "The inventory service could not be reached."
    }));

app.Run();

If application services already return core INTResult values, convert them at the HTTP boundary:

using NTResult.AspNetCore.Http.Ext;

app.MapGet("/orders/{id:int}", async (int id, OrderService service) =>
    (await service.FindOrderAsync(id)).ToIResult());

ToIResult maps NotFoundException to 404, UnauthorizedAccessException to 401, ForbiddenException to 403, and other failures to 400. Cancellation exceptions propagate to the ASP.NET Core host instead of being assigned an HTTP status without request context. Use HttpNTResult.RequestTimeout() for an incomplete request (408), HttpNTResult.GatewayTimeout() for an upstream timeout (504), or CustomError with an explicit IResult when the endpoint needs another specific response.

NTResultControllerBase provides the same result factories as protected helpers for controller-based APIs.

Client package: NTResult.Refit

Keep Refit methods typed as IApiResponse<T>, then convert the response into the same result abstraction used by the rest of the application.

using NTResult;
using NTResult.Refit.Ext;
using Refit;

public interface IOrdersApi
{
    [Get("/orders/{id}")]
    Task<IApiResponse<Order>> GetOrderAsync(int id);
}

IOrdersApi api = RestService.For<IOrdersApi>("https://api.example.com");
INTResult<Order> result = await api.GetOrderAsync(42).ToNTResultAsync();

if (result.IsSuccessful)
{
    Console.WriteLine(result.Value.Description);
}
else if (result.HasFailed)
{
    Console.WriteLine(result.ErrorMessage);
}

After applying the special 408, 499, and 504 mappings described below, the converter selects the most useful available error in this order:

  1. ProblemDetails.Detail
  2. ProblemDetails.Title
  3. Response body text
  4. HTTP reason phrase
  5. A status-code fallback message

HTTP 408 and 504 responses become failures containing a TimeoutException. HTTP 499 becomes a canceled result regardless of response headers or body, including responses from servers that do not use NTResult. Transport cancellation propagates as an exception from ToNTResultAsync. Created and redirect responses can return their Location header when the requested result type is string or Uri.

See cancellation and migration guidance for the three-state contract, serialization, Intersect examples, and breaking changes.

File downloads

A Refit stream response converts to INTResult<NTFileDownload>. Dispose the returned download after consuming it; that releases both the stream and its owning HTTP response.

public interface IReportsApi
{
    [Get("/reports/{id}")]
    Task<IApiResponse<Stream>> DownloadAsync(int id);
}

INTResult<NTFileDownload> result = await reportsApi.DownloadAsync(7).ToNTResultAsync();
using NTFileDownload download = result.ValueOrThrow();

await download.Contents.Stream!.CopyToAsync(destinationStream);

For other disposable Refit content types, keep and dispose the original IApiResponse<T> after the returned content is no longer needed. Non-disposable response content is disposed automatically during conversion.

Server-sent events (.NET 10+)

NTResult.AspNetCore.Http supports native ASP.NET Core SSE responses for results containing IAsyncEnumerable<T>. Create a full result with HttpNTResult.ServerSentEvents; ordinary ToIResult conversion keeps its existing behavior.

Choose the event payload type

T is the data in one event. It can be a string, number, DTO, record, collection, or any type supported by your JSON serialization configuration. SSE still requires a stream: INTResult<MyDto> alone is not an SSE response. Return INTResult<IAsyncEnumerable<MyDto>> to send DTOs as they become available, or yield a single item if you only need one event.

Data per event Server result Refit conversion
Plain text INTResult<IAsyncEnumerable<string>> ToNTResultAsync(textParser)
Number or DTO INTResult<IAsyncEnumerable<T>> ToNTResultAsync(jsonTypeInfo)
A batch of DTOs INTResult<IAsyncEnumerable<List<T>>> Pass metadata for List<T>
Data with event type, ID, or retry interval INTResult<IAsyncEnumerable<SseItem<T>>> Pass metadata for T, not SseItem<T>
Different payload types in one stream Use a shared envelope, configured polymorphic base type, or SseItem<JsonElement> Use matching metadata or a custom SseItemParser<T>

Strings are written as raw text, so pass a UTF-8 parser to the Refit conversion for string events. That parser also lets you inspect JSON events as text without deserializing them. Non-string data is JSON; types such as streams and HTTP result objects should be converted to serializable event payloads first. An INTResult without a value needs an explicit payload (for example, a status DTO) if you want to send an event.

Set up the shared JSON contract

Target .NET 10 or later. Reference NTResult.AspNetCore.Http in the server and NTResult.Refit in the client. Place your payload types and JSON context in a shared project, or provide equivalent definitions on both sides. Put each declaration below in its own source file.

public sealed record ProgressUpdate(int Percent, string Message);
using System.Text.Json;
using System.Text.Json.Serialization;

[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(ProgressUpdate))]
[JsonSerializable(typeof(List<ProgressUpdate>))]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(JsonElement))]
public partial class AppJsonContext : JsonSerializerContext;

Replace or extend the [JsonSerializable] entries for your payload types. Register the actual collection or envelope type when that is what each event contains. You do not need metadata for IAsyncEnumerable<T> or SseItem<T>: the SSE writer serializes each event's data separately. Configure converters and polymorphic derived types when your payload requires them.

Produce typed events on the server

Register the context before building the app, then return a typed async stream:

using NTResult;
using NTResult.AspNetCore.Http;
using System.Runtime.CompilerServices;

var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
var app = builder.Build();

app.MapGet("/progress", (CancellationToken cancellationToken) =>
{
    return HttpNTResult.ServerSentEvents(ReadUpdates(cancellationToken), eventType: "progress");
});

app.Run();

static async IAsyncEnumerable<ProgressUpdate> ReadUpdates([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    yield return new ProgressUpdate(0, "Starting");
    await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); // Replace with your asynchronous work.
    yield return new ProgressUpdate(100, "Complete");
}

The stream-only factory returns HttpNTResult<IAsyncEnumerable<T>>, implementing both INTResult<IAsyncEnumerable<T>> and IResult. Its Value is the original stream. For an existing service, use the request-aware factory to preserve success, failure, and cancellation:

app.MapGet("/progress", (HttpRequest request, IProgressService service) =>
    HttpNTResult.ServerSentEvents(request, service.GetUpdatesAsync, eventType: "progress"));

Use either endpoint example, not both for the same route. Replace ProgressUpdate with your own T throughout the stream and JSON context. For collections, yield a collection for one batch event, or yield its elements individually for one event per element.

For per-event metadata, return INTResult<IAsyncEnumerable<SseItem<T>>> using System.Net.ServerSentEvents.SseItem<T> and call HttpNTResult.ServerSentEvents(stream) without an event type. The native writer preserves each item's EventType, EventId, and ReconnectionInterval.

using System.Net.ServerSentEvents;

static async IAsyncEnumerable<SseItem<ProgressUpdate>> ReadEvents([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    await foreach (var update in ReadUpdates(cancellationToken))
    {
        yield return new SseItem<ProgressUpdate>(update, "progress")
        {
            EventId = update.Percent.ToString(),
            ReconnectionInterval = TimeSpan.FromSeconds(2)
        };
    }
}

// In an endpoint:
// return HttpNTResult.ServerSentEvents(ReadEvents(cancellationToken));

The request-aware overload awaits the selected service factory and returns Task<HttpNTResult>. Strings are sent as text; other payloads use the application's HTTP JSON serializer options. Native AOT applications must register JSON serialization metadata for their event payload types.

In the request-aware overload, failures (including custom HTTP errors) and canceled results use the existing HTTP mappings before enumeration begins. Successful results use the negotiated format even if they carry another HTTP success response. A null stream is invalid; an empty stream produces an empty SSE response or JSON []. SSE enumeration starts when ASP.NET Core executes the response; JSON enumeration finishes before the helper returns. Both receive RequestAborted; async iterators should use [EnumeratorCancellation] to observe client disconnects. Exceptions after streaming starts propagate and cannot become a new HTTP error response. The stream-only HttpNTResult.ServerSentEvents(stream) overload does not negotiate Accept. Use HttpNTResult.ServerSentEvents(request, events) below when one endpoint should support both SSE and JSON.

The package retains its .NET 8 target; these SSE APIs are available on .NET 10 and later.

Consuming SSE with Refit

On .NET 10+, InteractiveServer and InteractiveWebAssembly services can expose the same Task<INTResult<T>> contract. The following setup goes from a Refit API to the final value consumed by a component. Use NTResult.Refit in the client and NTResult.AspNetCore.Http in the server. Put the DTO, JSON context, and service interface in the shared project.

1. Define the shared contract and JSON metadata

Each type below belongs in its own file. If you already defined ProgressUpdate or AppJsonContext using an earlier example, extend those definitions instead of adding duplicates.

public sealed record ProgressUpdate(int Percent, string Message);
using System.Text.Json;
using System.Text.Json.Serialization;

[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(ProgressUpdate))]
[JsonSerializable(typeof(List<ProgressUpdate>))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(int?))]
public partial class AppJsonContext : JsonSerializerContext;
using NTResult;

public interface IProgressService
{
    Task<INTResult<ProgressUpdate>> GetFinalAsync(CancellationToken cancellationToken);
    Task<INTResult<IAsyncEnumerable<ProgressUpdate>>> GetUpdatesAsync(CancellationToken cancellationToken);
}
2. Declare the Refit API and client implementation

The streaming HTTP declaration returns Stream. The client repository converts that transport type to the shared service type. A conventional JSON endpoint can declare its DTO directly.

using Refit;

public interface IProgressApi
{
    [Get("/progress")]
    Task<IApiResponse<Stream>> GetProgressAsync(CancellationToken cancellationToken, [Header("Accept")] string accept = "text/event-stream, application/json;q=0.9");

    [Get("/progress/final")]
    Task<IApiResponse<ProgressUpdate>> GetFinalAsync(CancellationToken cancellationToken);
}
using NTResult;
using NTResult.Refit.Ext;

public sealed class ClientProgressService(IProgressApi _api) : IProgressService
{
    public Task<INTResult<ProgressUpdate>> GetFinalAsync(CancellationToken cancellationToken)
        => _api.GetFinalAsync(cancellationToken).ToNTResultAsync();

    public Task<INTResult<IAsyncEnumerable<ProgressUpdate>>> GetUpdatesAsync(CancellationToken cancellationToken)
    {
        var request = (CancellationToken token) => _api.GetProgressAsync(token);
        return request.ToNTResultAsync(AppJsonContext.Default.ProgressUpdate, cancellationToken);
    }
}
3. Register Refit and the client service in WebAssembly

Add this to the WebAssembly Program.cs before building the host. Replace the base address with your API address. If HttpClient is already registered appropriately, reuse it.

using Microsoft.Extensions.DependencyInjection;
using Refit;

builder.Services.AddScoped(_ => new HttpClient
{
    BaseAddress = new Uri("https://localhost:5001")
});
builder.Services.AddScoped<IProgressApi>(services => RestService.For<IProgressApi>(
    services.GetRequiredService<HttpClient>(),
    new RefitSettings
    {
        ContentSerializer = new SystemTextJsonContentSerializer(AppJsonContext.Default.Options)
    }));
builder.Services.AddScoped<IProgressService, ClientProgressService>();

The Refit serializer handles typed JSON endpoints. The metadata passed to ToNTResultAsync handles the SSE/JSON stream body. Using the same context keeps their naming and converter settings aligned.

4. Implement and expose the server service

This finite example produces two updates; replace its source with your application operation. The server returns the same shared types directly, without Refit or HTTP wrappers.

using NTResult;
using System.Runtime.CompilerServices;

public sealed class ServerProgressService : IProgressService
{
    public Task<INTResult<ProgressUpdate>> GetFinalAsync(CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        return Task.FromResult(NTResult.NTResult.Success(new ProgressUpdate(100, "Complete")));
    }

    public Task<INTResult<IAsyncEnumerable<ProgressUpdate>>> GetUpdatesAsync(CancellationToken cancellationToken)
        => Task.FromResult(NTResult.NTResult.Success(ReadUpdates(cancellationToken)));

    private static async IAsyncEnumerable<ProgressUpdate> ReadUpdates([EnumeratorCancellation] CancellationToken cancellationToken)
    {
        await Task.CompletedTask;
        cancellationToken.ThrowIfCancellationRequested();
        yield return new ProgressUpdate(50, "Working");
        cancellationToken.ThrowIfCancellationRequested();
        yield return new ProgressUpdate(100, "Complete");
    }
}

In the server Program.cs, register the server implementation before builder.Build() and map the endpoints afterward. InteractiveServer components resolve this implementation directly.

using Microsoft.AspNetCore.Http;
using NTResult.AspNetCore.Http;
using NTResult.AspNetCore.Http.Ext;

builder.Services.AddScoped<IProgressService, ServerProgressService>();
builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));

var app = builder.Build();

app.MapGet("/progress", (HttpRequest request, IProgressService service) =>
    HttpNTResult.ServerSentEvents(request, token => service.GetUpdatesAsync(token)));
app.MapGet("/progress/final", async (IProgressService service, CancellationToken token) =>
    (await service.GetFinalAsync(token)).ToIResult());

// Keep the app's existing authentication, authorization, component mappings, and app.Run().
5. Consume the result through the shared interface

Inject IProgressService into the component or consuming service. Neither implementation requires a render-mode check. For a final DTO:

var result = await progressService.GetFinalAsync(cancellationToken);
if (result.IsCanceled) return;
if (result.HasFailed)
{
    ShowError(result.ErrorMessage);
    return;
}

ProgressUpdate final = result.Value;
ShowProgress(final);

ShowError and ShowProgress represent your component's UI updates. For a sequence, use the await foreach example below and handle failures during enumeration as well as the initial result.

Register ProgressUpdate in the generated JSON context; sequences need only element metadata. request.ToNTResultAsync(...) returns a successful result containing a deferred, single-use sequence. Use request.ToNTResult(...) when the service has a synchronous INTResult<IAsyncEnumerable<T>> signature. Both overloads accept a request factory; an existing response task has already started HTTP. That initial success means the sequence was created, not that HTTP succeeded. The first MoveNextAsync starts the request; obtaining an enumerator alone does not. HTTP errors, cancellation, and parsing errors propagate during enumeration. Unused sequences open no response and need no disposal or cast. Re-enumeration is rejected so an upload or other POST cannot accidentally run twice. Keep request arguments, including upload streams, alive until enumeration finishes. Pass the token given to the request factory to Refit; it combines operation and enumeration cancellation.

Callers use the shared interface in either render mode:

var result = await progressService.GetUpdatesAsync(cancellationToken);
if (result.IsCanceled) return;
if (result.HasFailed)
{
    ShowError(result.ErrorMessage);
    return;
}

try
{
    await foreach (var update in result.Value.WithCancellation(cancellationToken))
        ShowProgress(update);
}
catch (OperationCanceledException) { /* Operation stopped. */ }
catch (Exception error) { ShowError(error.Message); }

Domain preparation failures may still be returned in the server's initial result, so shared callers handle both the initial result and enumeration failures.

Result shapes and transformations

The declared HTTP payload and supplied metadata determine which conversions are valid. Choosing a different result type does not change what the endpoint sends.

Desired result HTTP/API input Conversion When values become available
INTResult<T> for a DTO, primitive, nullable value, array, list, or dictionary Refit Task<IApiResponse<T>> from a normal JSON endpoint await apiCall.ToNTResultAsync() After Refit deserializes JSON
INTResult<IAsyncEnumerable<T>> Request factory returning Task<IApiResponse<Stream>>; SSE data of type T or JSON array of T await request.ToNTResultAsync(itemMetadata, token) Request starts on first enumeration; items arrive incrementally
INTResult<IAsyncEnumerable<T>> from a synchronous service method Same request factory request.ToNTResult(itemMetadata, token) Same deferred behavior; no request starts during conversion
INTResult<T> containing the final event or one JSON value Refit Task<IApiResponse<Stream>>; SSE data of type T or JSON value of type T await apiCall.ToNTValueAsync(valueMetadata, token) After response completion; empty SSE is a failure
INTResult<List<T>> or INTResult<T[]> collected from individual events An IAsyncEnumerable<T> result Enumerate with ToListAsync or ToArrayAsync, then wrap with NTResult.Success After the finite sequence completes
INTResult<IAsyncEnumerable<SseItem<T>>> preserving event ID, type, and retry interval SSE Task<IApiResponse<Stream>> await apiCall.ToNTResultAsync(itemMetadata) or a custom parser Response opens immediately; events are parsed during enumeration
INTResult<NTFileDownload> Task<IApiResponse<Stream>> without SSE conversion arguments await apiCall.ToNTResultAsync() Open download; caller disposes the value
INTResult without a value Refit Task<IApiResponse> await apiCall.ToNTResultAsync() After the response

Get only the final event. The /progress endpoint's JSON fallback is an array, so request SSE explicitly when reading its final individual DTO. The separate /progress/final JSON endpoint already returns one DTO and uses the ordinary typed Refit conversion shown above.

INTResult<ProgressUpdate> final = await api.GetProgressAsync(cancellationToken, "text/event-stream")
    .ToNTValueAsync(AppJsonContext.Default.ProgressUpdate, cancellationToken);

if (final.IsSuccessful)
    ShowProgress(final.Value);

Collect every event into a list or array. These examples deliberately use ValueOrThrow() to propagate an initial failure or cancellation; handle the result states first instead if your service needs to return them. Enumeration failures also propagate. Materialization is only appropriate for finite sequences and buffers all items in memory.

var result = await progressService.GetUpdatesAsync(cancellationToken);
List<ProgressUpdate> values = await result.ValueOrThrow().ToListAsync(cancellationToken);
INTResult<List<ProgressUpdate>> listResult = NTResult.NTResult.Success(values);

// Alternative to ToListAsync: enumerate a fresh result because sequences are single-use.
var anotherResult = await progressService.GetUpdatesAsync(cancellationToken);
ProgressUpdate[] array = await anotherResult.ValueOrThrow().ToArrayAsync(cancellationToken);
INTResult<ProgressUpdate[]> arrayResult = NTResult.NTResult.Success(array);

Project DTOs to another value while retaining streaming. This creates a new sequence without enumerating the source. Enumerating or disposing the projection's active enumerator propagates to the underlying sequence. HTTP and parsing failures still occur during enumeration.

var result = await progressService.GetUpdatesAsync(cancellationToken);
IAsyncEnumerable<string> messages = result.ValueOrThrow().Select(update => update.Message);
INTResult<IAsyncEnumerable<string>> messageResult = NTResult.NTResult.Success(messages);

Use another event payload type. The same methods support strings, numbers, nullable values, DTOs, and JSON-serializable collections. Supply metadata for what one event contains:

One event's data Metadata Deferred result type
ProgressUpdate object AppJsonContext.Default.ProgressUpdate INTResult<IAsyncEnumerable<ProgressUpdate>>
Raw string AppJsonContext.Default.String INTResult<IAsyncEnumerable<string>>
Integer AppJsonContext.Default.Int32 INTResult<IAsyncEnumerable<int>>
Nullable integer AppJsonContext.Default.NullableInt32 INTResult<IAsyncEnumerable<int?>>
JSON array of updates in each event AppJsonContext.Default.ListProgressUpdate INTResult<IAsyncEnumerable<List<ProgressUpdate>>>

For a batch endpoint, ToNTValueAsync(AppJsonContext.Default.ListProgressUpdate, token) returns the last batch as INTResult<List<ProgressUpdate>>. It does not collect individually streamed DTOs into a list. The negotiated JSON fallback for a sequence of batches is an array of arrays.

Advanced readers and event metadata

For eager response validation or generic forwarding, typed readers remain available. They preserve their exact return type without runtime generic construction or overload-dependent behavior:

static Task<INTResult<T>> ReadAsync<T>(Task<IApiResponse<Stream>> response, NTResponseReader<T> reader, CancellationToken token)
    => response.ToNTResultAsync<T>(reader, token);

A value reader deserializes JSON as T, or waits for SSE completion and returns the final data event as T. An SSE response with no data events fails with InvalidDataException; an explicit JSON null is still a successful value when permitted by the type. A malformed JSON event fails the operation. For string, SSE data is raw UTF-8 text, matching native ASP.NET Core: quotes stay in the string and data: null means the literal string "null". JSON responses still use normal JSON string/null semantics. Other SSE data types use the supplied JSON metadata.

For IAsyncEnumerable<T>, SSE yields each event's data and JSON yields each array element. await foreach releases the response on completion, an exception, or break. SSE metadata stays out of the shared DTO contract. The advanced ToNTResultAsync(NTResponseReader.Sequence(...)) path validates an already-started HTTP request before returning; unlike the request-factory overload, it owns an open response even if unused and its value must then be disposed through IDisposable.

The server and client must agree on the payload shape: a JSON fallback for a sequence is an array; a JSON fallback for a single DTO is that DTO. The server's negotiated stream helper materializes an array, so use the sequence contract for that endpoint. Single-value callers need a finite SSE source because the final event is only known when the response ends.

The existing one-metadata and custom-parser ToNTResultAsync overloads still return INTResult<IAsyncEnumerable<SseItem<T>>> for consumers that need event metadata. Use a typed reader to return the shared service value. ToNTResult(jsonTypeInfo) also opens an already awaited SSE response. The parser uses native SseParser and preserves event types, IDs, and retry intervals. Calling the stream overload without metadata or a parser still returns INTResult<NTFileDownload>.

The Refit return type remains Task<IApiResponse<Stream>> for every event payload type. Refit provides the open response body; the SSE adapter parses individual events. Here api is the IProgressApi instance registered above. Request SSE explicitly for the SSE-only overload:

// Produces INTResult<IAsyncEnumerable<SseItem<ProgressUpdate>>>.
var result = await api.GetProgressAsync(cancellationToken, "text/event-stream")
    .ToNTResultAsync(AppJsonContext.Default.ProgressUpdate);
if (result.IsCanceled)
    return;
if (result.HasFailed)
{
    Console.WriteLine(result.ErrorMessage);
    return;
}

var events = result.Value;
using var lifetime = (IDisposable)events;
await foreach (var item in events.WithCancellation(cancellationToken))
{
    ProgressUpdate update = item.Data;
    Console.WriteLine($"{item.EventId}: {item.EventType}: {update.Percent}% - {update.Message}");
}

Here cancellationToken is supplied by the calling operation. Pass it both to the Refit request and to enumeration. Enumeration closes the HTTP response on completion or early exit. The optional IDisposable lifetime scope also closes an eager response if you never enumerate it. These metadata-preserving overloads now return IAsyncEnumerable<SseItem<T>>; replace previous stream using declarations with the lifetime scope shown above.

For JSON payloads, pass generated JsonTypeInfo<T> metadata: ToNTResultAsync(AppJsonContext.Default.ProgressUpdate). For mixed event types or other data formats, pass a SseItemParser<T> delegate that receives the event type and UTF-8 data bytes. ToNTResult(jsonTypeInfo) and ToNTResult(itemParser) handle an already received IApiResponse<Stream>. JSON parsing uses the supplied metadata, rather than automatically using Refit's serializer settings. Match that metadata's naming and converter settings to the server. For ASP.NET Core's default camel-case JSON, generate the context with [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)].

For raw text events, pass a UTF-8 parser instead of JSON metadata:

using System.Text;

var result = await api.GetProgressAsync(cancellationToken, "text/event-stream")
    .ToNTResultAsync(static (_, bytes) => Encoding.UTF8.GetString(bytes));
// result is INTResult<IAsyncEnumerable<SseItem<string>>>; handle its state and dispose/enumerate its Value as above.

SSE results use the same IsSuccessful, HasFailed, IsCanceled, OnSuccess, and OnFailure contracts as other INTResult values. There is no separate server-side SSE conversion extension: create the response with HttpNTResult.ServerSentEvents(...), then use ToIResult() when your endpoint receives an INTResult-typed value.

For reusable client code, accept JsonTypeInfo<T> alongside the response task:

using NTResult;
using NTResult.Refit;
using NTResult.Refit.Ext;
using Refit;
using System.Text.Json.Serialization.Metadata;

static Task<INTResult<IAsyncEnumerable<SseItem<T>>>> OpenEventsAsync<T>(Task<IApiResponse<Stream>> response, JsonTypeInfo<T> jsonTypeInfo)
    => response.ToNTResultAsync(jsonTypeInfo);

// DTO events:
// await OpenEventsAsync(api.GetProgressAsync(cancellationToken, "text/event-stream"), AppJsonContext.Default.ProgressUpdate);
// Batch events from an endpoint returning List<ProgressUpdate> per event:
// await OpenEventsAsync(api.GetProgressAsync(cancellationToken, "text/event-stream"), AppJsonContext.Default.ListProgressUpdate);

The metadata must describe the actual data sent by the endpoint. Changing the client type does not change the server's payload. JSON null is allowed when the chosen type supports it; include null handling in your consumer when your contract permits null events.

Different payload types in one stream

For named events with unrelated payloads, one option is to send SseItem<JsonElement> and select the client parser by event type. Add each DTO to AppJsonContext and convert each server payload with its metadata:

var progressEvent = new SseItem<JsonElement>(
    JsonSerializer.SerializeToElement(new ProgressUpdate(50, "Working"), AppJsonContext.Default.ProgressUpdate),
    "progress");
var countEvent = new SseItem<JsonElement>(
    JsonSerializer.SerializeToElement(12, AppJsonContext.Default.Int32),
    "count");
// Yield these from an IAsyncEnumerable<SseItem<JsonElement>>, then call HttpNTResult.ServerSentEvents(stream).

On the client, supply a parser returning a common type. This example returns object; a shared domain interface or envelope is also suitable:

var result = await api.GetProgressAsync(cancellationToken, "text/event-stream").ToNTResultAsync<object>(
    (eventType, bytes) => eventType switch
    {
        "progress" => JsonSerializer.Deserialize(bytes, AppJsonContext.Default.ProgressUpdate)
            ?? throw new JsonException("Expected a progress payload."),
        "count" => JsonSerializer.Deserialize(bytes, AppJsonContext.Default.Int32),
        _ => throw new JsonException($"Unsupported event type: {eventType}")
    });
// Handle the result, dispose its stream, and enumerate as in the typed example above.
// item.Data is ProgressUpdate or int; item.EventType and item.EventId remain available.

These snippets use System.Net.ServerSentEvents and System.Text.Json. If using a polymorphic base type instead, configure matching JSON discriminators and derived types on both sides. Avoid sending arbitrary runtime objects without an explicit serialization contract.

Negotiate SSE with a JSON fallback

Use HttpNTResult.ServerSentEvents(request, events) with one factory that opens your stream. If the client accepts SSE, items are sent as they arrive. Otherwise, the helper enumerates the same stream to completion and returns all values in order as one JSON array. An empty stream returns []; null elements remain null. The factory runs once, with HttpContext.RequestAborted, and is never invoked when negotiation returns 406.

The JSON path waits for completion and holds the collected values in memory. Use finite streams for endpoints that offer this fallback; use the stream-only SSE helper for ongoing subscriptions. Request cancellation is passed to both the factory and enumeration.

Request Accept Selected response
Missing, empty, or */* JSON
application/json JSON
text/event-stream SSE
text/event-stream, application/json;q=0.9 SSE
text/event-stream;q=0.5, application/json JSON
text/event-stream;q=0, */* JSON
text/plain, or both supported formats excluded with q=0 406 Not Acceptable

SSE requires an explicit, matching text/event-stream media range with a positive quality. Wildcard ranges alone do not opt into streaming. The higher effective quality wins; ties favor explicit SSE. More specific JSON ranges override broader wildcards, including exclusions. Malformed headers or quality values return 406. The helper adds Vary: Accept while preserving existing Vary fields. These are application selection rules built on HTTP content negotiation.

For example, given a service with this method:

public interface IProgressService
{
    Task<INTResult<IAsyncEnumerable<ProgressUpdate>>> GetUpdatesAsync(CancellationToken cancellationToken);
}

Register the service and HTTP JSON context as above, then map the negotiated endpoint:

using NTResult.AspNetCore.Http;

app.MapGet("/progress", (HttpRequest request, IProgressService service) =>
    HttpNTResult.ServerSentEvents(request,
        service.GetUpdatesAsync,
        eventType: "progress"));

The method returns Task<HttpNTResult>, preserving the full INTResult state as well as the HTTP response. Either successful representation is a successful result; rejected negotiation is a failed result with HTTP 406. Return the factory result directly from an endpoint, or use ToIResult() when your service exposes it as INTResult.

For events of type T, JSON contains a List<T>. Register JSON metadata for both T and List<T> (the shared context above includes both for ProgressUpdate). If each event is itself a collection, the JSON response is an array of those collections. For streams of SseItem<T>, use the overload without eventType: SSE preserves the metadata, while JSON collects only each item's Data. Strings become JSON strings inside the array. Both formats use HTTP JSON options and retain the existing factory failure and cancellation mappings. Errors or cancellation during collection propagate before the JSON response is written. Authenticate and authorize the endpoint normally.

Use SSE from an NTResult controller

NTResultControllerBase exposes the same ServerSentEvents helpers on .NET 10+. For negotiation, omit the request argument: the helper uses the controller's Request and passes RequestAborted to the factory and enumeration. Return the resulting INTResult through the usual ToIResult() conversion at the action boundary.

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NTResult.AspNetCore.Http;
using NTResult.AspNetCore.Http.Ext;

[Route("api/progress")]
public sealed class ProgressController(IProgressService _service) : NTResultControllerBase
{
    [HttpGet]
    public async Task<IResult> Get()
    {
        var result = await ServerSentEvents(_service.GetUpdatesAsync, eventType: "progress");
        return result.ToIResult();
    }
}

Register controllers with builder.Services.AddControllers() and app.MapControllers(). Keep the HTTP JSON configuration shown above; these helpers execute HTTP results using those options. Direct streaming is also available as ServerSentEvents(values, eventType) or ServerSentEvents(items) for an IAsyncEnumerable<SseItem<T>>. The direct helpers return INTResult<IAsyncEnumerable<T>> / INTResult<IAsyncEnumerable<SseItem<T>>>; the negotiated helpers return Task<INTResult> and retain success, failure, and cancellation states.

Consume either negotiated format with Refit

Keep the Refit return type as Task<IApiResponse<Stream>>, but advertise both formats:

public interface IProgressApi
{
    [Get("/progress")]
    [Headers("Accept: text/event-stream, application/json;q=0.9")]
    Task<IApiResponse<Stream>> GetProgressAsync(CancellationToken cancellationToken);
}

Use this interface instead of the SSE-only example. To request JSON, use Accept: application/json or omit Accept. A dynamic Refit [Header("Accept")] parameter can select the preference per call.

using NTResult.Refit.Ext;

var request = (CancellationToken token) => api.GetProgressAsync(token);
var result = request.ToNTResult(AppJsonContext.Default.ProgressUpdate, cancellationToken);

await foreach (ProgressUpdate update in result.Value.WithCancellation(cancellationToken))
    Console.WriteLine($"{update.Percent}%: {update.Message}");

Both SSE events and JSON array items produce the same IAsyncEnumerable<ProgressUpdate>. The HTTP request starts on enumeration; HTTP failures, parsing errors, and cancellation propagate during enumeration. An unused sequence opens no response and needs no cleanup. The reader selects the format from the response Content-Type.

For a single DTO, use ToNTValueAsync(itemMetadata, cancellationToken): it returns the JSON value or the final SSE event as INTResult<T>. Malformed JSON becomes a failed result; transport I/O and cancellation propagate. A JSON fallback for this scalar path must contain a DTO, not the array returned by a negotiated stream endpoint.

Errors, cancellation, and response ownership

The eager adapter takes ownership of the response. Cast its returned sequence to IDisposable for a lifetime scope if it might never be enumerated. Enumeration is single-use and closes the response on completion, early exit, cancellation, or a parsing/I/O error. The outer INTResult describes opening the stream: HTTP failures retain the existing mappings (including canceled HTTP 499), while errors during enumeration propagate to the caller. The SSE-only conversion rejects successful responses with a missing body or a content type other than text/event-stream; the negotiated conversion also accepts application/json. Reconnection and sending Last-Event-ID on a new request remain the caller's responsibility.

End-to-end example

Using NTResult.AspNetCore.Http on the server and NTResult.Refit on the client preserves both sides of the operation:

  • A successful HttpNTResult<T> becomes a successful client INTResult<T> containing the deserialized value.
  • A string error body becomes the client result's ErrorMessage.
  • RFC Problem Details responses preserve their specific detail, with title as a fallback.
  • The Refit response is disposed after conversion unless its disposable content must remain alive.

This lets callers work with one INTResult<T> contract instead of separately handling status codes, response bodies, deserialization errors, and thrown HTTP exceptions.

Legacy TnTResult names

Obsolete TnTResult, HttpTnTResult, and related extension aliases remain available for source compatibility. New code should use the NTResult names shown above.

Build and test

Run these commands from the repository root:

dotnet restore NTResult.slnx
dotnet build NTResult.slnx --configuration Release
dotnet test --project .\NTResult.Tests\NTResult.Tests.csproj --configuration Release

License

NTResult is licensed under the MIT License.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.6.1 84 9/14/2026
1.6.1-preview.1 46 9/14/2026
1.6.0 74 9/11/2026
1.5.0 79 9/10/2026
1.4.0 788 8/21/2026
1.3.0 101 8/14/2026
1.2.0 232 7/28/2026
1.1.3 876 7/13/2026
1.1.2 123 7/13/2026
1.1.1 132 7/8/2026
1.1.0 1,858 6/8/2026
Loading failed