# .NET SDK

The official .NET client for FetchCatch. Two NuGet packages, both public:

| Package | Use it when |
|---------|-------------|
| [`FetchCatch.Client`](https://www.nuget.org/packages/FetchCatch.Client) | Anything .NET — console apps, workers, libraries. No DI dependency. |
| [`FetchCatch.AspNetCore`](https://www.nuget.org/packages/FetchCatch.AspNetCore) | ASP.NET Core hosts. Adds `AddFetchCatch()` for one-line DI registration with `IHttpClientFactory`. |

Both target `net8.0`, `net10.0`, and (for `FetchCatch.Client`) `netstandard2.0` so .NET Framework 4.7.2+ also works.

## Install

```bash
# In an ASP.NET Core project:
dotnet add package FetchCatch.AspNetCore

# Console / worker / library:
dotnet add package FetchCatch.Client
```

## Authenticate

Create a Tenant API key from **Console → Settings → API keys** and pass it to the SDK. Treat it like a database password — never commit it.

```bash
# environment variable
setx FETCHCATCH_API_KEY "fc_live_..."
```

```json
// appsettings.json
{
  "FetchCatch": {
    "BaseUrl": "https://api.fetchcatch.com",
    "ApiKey": "fc_live_..."
  }
}
```

## Quick start (ASP.NET Core)

```csharp
// Program.cs
builder.Services.AddFetchCatch(builder.Configuration);

// CheckoutService.cs
public class CheckoutService(IFetchCatchClient fc)
{
    public async Task<CheckoutDecision> EvaluateAsync(CheckoutInput input, CancellationToken ct)
    {
        var result = await fc.EvaluateAsync<CheckoutInput, CheckoutDecision>(
            flowSlug: "checkout",
            input: input,
            options: new EvaluateOptions { CorrelationKey = input.OrderId },
            cancellationToken: ct);

        return result.Status switch
        {
            RunStatus.Completed => result.Decision!,
            RunStatus.Waiting   => throw new CheckoutPausedException(result.RunId, result.WaitingForEvent!),
            _                   => throw new FetchCatchException(result.Error ?? "Unknown error"),
        };
    }
}

public record CheckoutInput(string OrderId, decimal Amount, string UserId);
public record CheckoutDecision(bool Approved, string? Reason);
```

## Quick start (console / worker)

```csharp
using FetchCatch.Client;

using var fc = new FetchCatchClient(new FetchCatchOptions
{
    ApiKey = Environment.GetEnvironmentVariable("FETCHCATCH_API_KEY")!,
});

var result = await fc.EvaluateAsync("hello", new { name = "world" });
Console.WriteLine(result.Decision); // JsonElement when you don't specify TDecision
```

## API surface

### `EvaluateAsync<TInput, TDecision>(slug, input, options?, ct?)`

Calls a published flow. Returns `EvaluateResponse<TDecision>` with one of three useful outcomes:

| `Status` | What it means | What to do |
|---|---|---|
| `Completed` | The flow reached an End node. `Decision` is populated. | Use `result.Decision`. |
| `Waiting`   | The flow paused at a `wait_for_event` node. `WaitingForEvent` tells you which event to send. | Persist `result.RunId`, render UI, call `ResumeAsync` later. |
| `Failed` / `TimedOut` | Unhandled error or expired wait. `Error` is populated. | Treat as a failure. |

Use the helpers `IsCompleted`, `IsWaiting`, `IsFailed` for clean branching.

### `ResumeAsync<TPayload, TDecision>(runId, eventName, payload, ct?)`

Continues a paused run. `eventName` must match `result.WaitingForEvent` exactly.

```csharp
var resumed = await fc.ResumeAsync<UserConfirmation, CheckoutDecision>(
    runId: pendingRunId,
    eventName: "user_confirmed",
    payload: new UserConfirmation { Code = "123456" });
```

### `ListRunsAsync(filter?, ct?)`

Streams matching runs with automatic cursor pagination — you never see a cursor token. The returned `IAsyncEnumerable<FlowRunListItem>` stops when the server runs out, when `MaxItems` is reached, or when you cancel.

```csharp
var lastHour = new RunFilter
{
    Status = RunStatus.Failed,
    FromUtc = DateTime.UtcNow.AddHours(-1),
    MaxItems = 500,
};

await foreach (var run in fc.ListRunsAsync(lastHour, ct))
{
    logger.LogWarning("{Flow} {Run}: {Error}", run.FlowSlug, run.Id, run.Error);
}
```

### `GetRunAsync(runId, ct?)`

Single run with its full per-step trace. Returns `null` for unknown ids (no exception).

### `GetRunStatsAsync(fromUtc?, toUtc?, ct?)`

Aggregate KPIs — the exact numbers behind the FetchCatch console dashboard. Default range is the last 24 hours.

```csharp
var stats = await fc.GetRunStatsAsync(DateTime.UtcNow.AddDays(-7), DateTime.UtcNow);

Console.WriteLine($"{stats.Completed}/{stats.Total} succeeded ({stats.SuccessRate:P0})");
Console.WriteLine($"p50 {stats.P50Ms}ms · p95 {stats.P95Ms}ms · p99 {stats.P99Ms}ms");

foreach (var top in stats.TopFlows.Take(5))
    Console.WriteLine($"  {top.FlowSlug}: {top.Total} ({top.Failed} failed)");
```

## Error handling

Every non-2xx response maps to a structured exception:

| HTTP | Exception | When |
|------|-----------|------|
| 400 | `FetchCatchValidationException` | Payload didn't match the flow's input schema. |
| 401 / 403 | `FetchCatchAuthException` | API key missing, revoked, or wrong workspace. |
| 404 | `FetchCatchNotFoundException` | Flow slug, run id, or workspace not found. |
| 429 | `FetchCatchRateLimitedException` | Read `RetryAfter` to schedule retry. |
| 5xx, network | `FetchCatchException` (base) | Inspect `StatusCode` and `ResponseBody`. Retried automatically by default. |

```csharp
try
{
    await fc.EvaluateAsync<CheckoutInput, CheckoutDecision>("checkout", input);
}
catch (FetchCatchRateLimitedException rl)
{
    await Task.Delay(rl.RetryAfter ?? TimeSpan.FromSeconds(5));
    // retry…
}
catch (FetchCatchNotFoundException)
{
    // The flow slug is wrong, surface it in the build pipeline.
}
```

## Configuration reference

| Option | Default | Notes |
|---|---|---|
| `BaseUrl` | `https://api.fetchcatch.com` | Override for self-hosted / staging. |
| `ApiKey` | *(required)* | Tenant API key. |
| `Timeout` | `30s` | Per-request HTTP timeout. Matches the server's evaluate ceiling. |
| `EnableRetries` | `true` | Retry transient 5xx and network errors with exponential backoff. |
| `MaxRetryAttempts` | `3` | Including the initial attempt. |
| `UserAgentApp` | `null` | Appended to `User-Agent`. Surfaces in support / rate-limit logs. |

## Observability

The SDK exposes an `ActivitySource("FetchCatch.Client")` — every public method opens a span (`fetchcatch.evaluate`, `fetchcatch.resume`, `fetchcatch.list_runs`, etc.). To export spans via OpenTelemetry:

```csharp
builder.Services.AddOpenTelemetry()
    .WithTracing(t => t.AddSource("FetchCatch.Client").AddOtlpExporter());
```

## Mocking in tests

Inject the `IFetchCatchClient` interface and swap with your favourite mocking library:

```csharp
var fc = Substitute.For<IFetchCatchClient>();
fc.EvaluateAsync<CheckoutInput, CheckoutDecision>(default!, default!, default, default)
    .ReturnsForAnyArgs(Task.FromResult(new EvaluateResponse<CheckoutDecision>
    {
        RunId = Guid.NewGuid(),
        Status = RunStatus.Completed,
        Decision = new CheckoutDecision(Approved: true, Reason: null),
    }));
```

## AOT and trimming

`FetchCatch.Client` works under AOT for the inspection APIs (`ListRunsAsync`, `GetRunAsync`, `GetRunStatsAsync`) which use known wire types. The generic `EvaluateAsync<TInput, TDecision>` and `ResumeAsync<TPayload, TDecision>` overloads use reflection-based JSON for caller-supplied types — `v0.2` will add `JsonTypeInfo<T>` overloads for full AOT compatibility. Until then, AOT consumers can use the non-generic `EvaluateAsync` / `ResumeAsync` overloads that return `EvaluateResponse<JsonElement>` and parse the decision themselves with their own source-gen context.

## Versioning

The SDK follows semver. The HTTP API (`/v1/...`) is stable; SDK surface may evolve before `1.0`. **Pin a version** in production:

```xml
<PackageReference Include="FetchCatch.Client" Version="0.1.*" />
```

## License

MIT. See [github.com/teclogist/fetchcatch](https://github.com/teclogist/fetchcatch).
