diff --git a/extensions/aayushmishraaa/superdocs-dotnet/.github/workflows/build.yml b/extensions/aayushmishraaa/superdocs-dotnet/.github/workflows/build.yml new file mode 100644 index 0000000..936e3e9 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/.github/workflows/build.yml @@ -0,0 +1,75 @@ +# Builds, tests and packs the SDK. +# +# Not wired to the repository root on purpose: this lives inside a contributor folder, so it +# is here as the workflow a consumer would copy rather than something that runs on every +# push to superdocs-builds. +# +# Packages are produced as ARTIFACTS and deliberately NOT published. Claiming a package id on +# a public registry is not mine to do from a task submission. + +name: SuperDocs.Client + +on: + push: + paths: ['extensions/aayushmishraaa/superdocs-dotnet/**'] + pull_request: + paths: ['extensions/aayushmishraaa/superdocs-dotnet/**'] + workflow_dispatch: + +defaults: + run: + working-directory: extensions/aayushmishraaa/superdocs-dotnet + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # SourceLink needs full history to resolve commits; a shallow clone produces + # symbols that cannot be stepped into. + fetch-depth: 0 + + - uses: actions/setup-dotnet@v4 + with: + # Both targets must be installable, or "supports the previous LTS" is a claim + # nobody has checked. + dotnet-version: | + 8.0.x + 10.0.x + + - run: dotnet restore + + # -warnaserror is redundant with TreatWarningsAsErrors in Directory.Build.props and + # kept anyway: if someone relaxes the props file, CI still catches it. + - name: Build (analyzer-clean, both frameworks) + run: dotnet build --configuration Release --no-restore -warnaserror + + # No API key is provided to this job, on purpose. If a test ever needs one, it fails + # here rather than passing quietly on a developer machine that happens to have one set. + - name: Test (no API key available) + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Pack (deterministic, with symbols) + run: | + dotnet pack src/SuperDocs.Client/SuperDocs.Client.csproj \ + --configuration Release --no-build \ + -p:ContinuousIntegrationBuild=true \ + --output ./artifacts + + - name: Verify the package contains both frameworks and its symbols + run: | + set -e + nupkg=$(ls artifacts/*.nupkg | head -1) + snupkg=$(ls artifacts/*.snupkg | head -1) + echo "package: $nupkg" + echo "symbols: $snupkg" + unzip -l "$nupkg" | grep -q 'lib/net10.0/SuperDocs.Client.dll' + unzip -l "$nupkg" | grep -q 'lib/net8.0/SuperDocs.Client.dll' + unzip -l "$snupkg" | grep -q 'lib/net10.0/SuperDocs.Client.pdb' + echo "both target frameworks and symbols present" + + - uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: extensions/aayushmishraaa/superdocs-dotnet/artifacts/* diff --git a/extensions/aayushmishraaa/superdocs-dotnet/.gitignore b/extensions/aayushmishraaa/superdocs-dotnet/.gitignore new file mode 100644 index 0000000..9df7628 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +artifacts/ +samples/SuperDocs.Worker.Sample/inbox/ +samples/SuperDocs.Worker.Sample/outbox/ diff --git a/extensions/aayushmishraaa/superdocs-dotnet/Directory.Build.props b/extensions/aayushmishraaa/superdocs-dotnet/Directory.Build.props new file mode 100644 index 0000000..14de31e --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/Directory.Build.props @@ -0,0 +1,37 @@ + + + + + net10.0;net8.0 + + latest + enable + enable + + + latest-all + true + true + + + + true + true + true + + + true + true + + true + $(NoWarn);CA1848;CA1031 + + + diff --git a/extensions/aayushmishraaa/superdocs-dotnet/README.md b/extensions/aayushmishraaa/superdocs-dotnet/README.md new file mode 100644 index 0000000..19f4e95 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/README.md @@ -0,0 +1,183 @@ +# SuperDocs.Client — .NET SDK + +A .NET client for the [SuperDocs](https://use.superdocs.app) document-editing API, built the +way a Microsoft-stack team would expect: registered through `IHttpClientFactory`, so pooling, +resilience and telemetry come from your host rather than being reinvented inside a library you +did not write. + +Built by **Aayush Mishra** for the SuperDocs engineer task. + +--- + +## Install + +```bash +dotnet add package SuperDocs.Client +``` + +Targets **.NET 10** (current LTS, supported to Nov 2028) and **.NET 8** (previous LTS, to +Nov 10 2026). Multi-targeted rather than 10-only because enterprise teams are frequently a +release behind, and those are exactly the people who cannot upgrade on someone else's schedule. + +## Use it + +```csharp +builder.Services.AddSuperDocs(builder.Configuration); +``` + +That is the whole registration. It returns the `IHttpClientBuilder`, so your policies apply: + +```csharp +builder.Services + .AddSuperDocs(builder.Configuration) + .AddStandardResilienceHandler(); // your retries, your circuit breaker, your timeouts +``` + +```json +{ "SuperDocs": { "ApiKey": "sk_your_key_here" } } +``` + +Then inject `ISuperDocsClient`: + +```csharp +await client.UploadDocumentAsync(sessionId, "contract.docx", bytes, ct); + +ChatJobCreated job = await client.StartEditAsync( + sessionId, "Change the payment terms from net 30 to net 45.", ct: ct); + +// Drives the ENTIRE review, including the rounds you did not know about — see below. +Job final = await client.ReviewAsync(sessionId, job.JobId, async (changes, ct) => +{ + foreach (ProposedChange c in changes) + Console.WriteLine($"[{c.Operation}] {c.AiExplanation}"); + + return await MyReviewUi.AskAsync(changes, ct); // approve some, reject others +}, cancellationToken: ct); + +ExportedFile file = await client.ExportAsync(sessionId, ExportFormat.Docx, ct: ct); +await file.SaveAsAsync("contract-updated.docx", ct); +``` + +--- + +## What this SDK does for you + +Four things that cost me real time to discover, each handled once here so they cost you none. + +### 1. The proposed-change double encoding + +The API returns the **same** proposed changes two different ways. `metadata.pending_changes` +(polling) gives real objects; `intermediate_responses[].content` and the SSE stream give a +**JSON-encoded string** needing a second parse. + +Guidance describing the second parse as universal is therefore true of one path and false of +the other — apply it to the polling path and you get an exception, skip it on the streaming +path and you get nothing. Both directions break. + +This SDK decodes both. `PendingChanges` and `IntermediateResponse.Changes` yield the same +`ProposedChange` objects, and there is no path on which you must know which one you are on. + +### 2. A large edit arrives in several approval rounds + +This is not documented anywhere I could find, and the obvious client shape is wrong: + +```csharp +// WRONG — polls forever. I watched this reach 146 requests. +await client.ApproveAsync(sessionId, jobId, decisions, ct); +await client.WaitForCompletionAsync(jobId, ct); +``` + +After you approve a batch, the job returns to `awaiting_approval` with **more** changes. Use +`ReviewAsync`, which loops until the job actually finishes. + +### 3. Unknown values degrade instead of disappearing + +An unrecognised `operation` used to make the whole `ProposedChange` fail to deserialise — so +the change **vanished from the review** and a human would approve a batch without being shown +one of its edits. Unknown values now map to `ChangeOperation.Unknown` and the change stays +visible, with its before-and-after HTML intact. + +### 4. Errors name the cause and the fix + +``` +SuperDocs rejected your API key (401). Check SuperDocs:ApiKey is a current key and +starts with 'sk_'. +``` + +rather than `Response status code does not indicate success: 401 (Unauthorized)`. + +Where the server sends a specific detail it **leads** with that, because a canned hint printed +ahead of the facts is worse than no hint — an earlier version advised checking an approval +field during an *upload* failure while the server's answer sat unread underneath. + +`SuperDocsException` also exposes `ErrorCode` and `IsTransient`, so your resilience policy can +branch on facts rather than pattern-match prose. + +--- + +## Async discipline + +Every method is `async` and takes a `CancellationToken`. There is **no synchronous overload +anywhere**, deliberately — a blocking wrapper around an async call is the classic ASP.NET +deadlock, and offering one invites the misuse. `ConfigureAwait(false)` throughout; a test +asserts the surface stays fully async so it cannot regress. + +## Trim and AOT + +`System.Text.Json` source generation for every wire type, no reflection-based serialization, +`IsTrimmable` and `IsAotCompatible` on. Configuration is bound by hand rather than with +`.Bind()`, because reflection binding is not trim-safe and would quietly undermine the claim. + +--- + +## Run the sample + +A worker service that watches a folder, applies house branding, and exports the result. + +```bash +cd samples/SuperDocs.Worker.Sample +export SUPERDOCS_API_KEY=sk_your_key_here +dotnet run +``` + +Drop a `.docx`, `.md` or `.pdf` into `inbox/`; the branded result appears in `outbox/`. + +**`AutoApprove` defaults to `false`.** An unattended worker that approves its own edits has no +human gate, and a sample that pretended otherwise would teach the wrong shape. With it off the +worker prints each proposed change and applies nothing. Set `Watcher__AutoApprove=true` where +that risk is genuinely acceptable. + +The sample also recovers from a wedged session: if a previous run abandoned a review, it finds +the stale job and resolves it. Note it does this by **denying** the pending changes, not by +cancelling — `cancel_job` returns `400 "Job cannot be cancelled"` on an `awaiting_approval` +job, despite the API's own `suggested_action` recommending exactly that. + +## Run the tests + +```bash +dotnet test +``` + +38 tests. **No API key, no network.** The stub payloads are copied from real responses, +including the awkward parts — a stub built from what the docs imply rather than what the +server sends is how I shipped a wrong upload field name in the first place. + +--- + +## Honest limitations + +- **Large uploads are not implemented.** Only the base64 path (<100 KB). The presigned + `/v1/uploads` flow is the correct route for bigger files and is not wrapped here. +- **Multi-document sessions are not wrapped.** The API supports tabs, focus and per-document + operations; this client models a single active document per session. +- **Templates are not wrapped.** The sample brands via an instruction, not by uploading a + reusable template. +- **`ReviewAsync` bounds itself at 20 rounds** and throws rather than looping. That is a guess + at "unreasonable", not a measured limit. +- **The SSE parser handles the event shapes I observed.** It ignores frames it does not + recognise rather than failing, so an unfamiliar event type is silently skipped — the + authoritative list is always `Job.Metadata.PendingChanges`. + +## License + +MIT. diff --git a/extensions/aayushmishraaa/superdocs-dotnet/SuperDocs.slnx b/extensions/aayushmishraaa/superdocs-dotnet/SuperDocs.slnx new file mode 100644 index 0000000..7b63b9f --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/SuperDocs.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/DocumentWatcherService.cs b/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/DocumentWatcherService.cs new file mode 100644 index 0000000..a2e1533 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/DocumentWatcherService.cs @@ -0,0 +1,271 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using Microsoft.Extensions.Options; +using SuperDocs.Client; + +namespace SuperDocs.Worker.Sample; + +public sealed class WatcherOptions +{ + /// Folder to watch. Created if missing. + public string InboxPath { get; set; } = "inbox"; + + /// Where branded documents are written. + public string OutboxPath { get; set; } = "outbox"; + + /// The instruction applied to every arriving document. + public string Instruction { get; set; } = + "Apply our house style: add a title page heading, ensure headings are sentence case, " + + "and append a confidentiality footer paragraph at the end."; + + /// Export format for the branded result. + public ExportFormat OutputFormat { get; set; } = ExportFormat.Docx; + + /// + /// When true, every proposed change is approved automatically. + /// + /// + /// Default FALSE, deliberately. An unattended worker that approves its own edits is not + /// a human-in-the-loop system, and defaulting to convenience here would quietly teach + /// the wrong pattern to anyone who copies this sample. With it off, the worker prints + /// each proposed change and waits — which is what a review actually looks like. + /// + public bool AutoApprove { get; set; } + + public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(2); +} + +/// +/// Watches a folder, applies branding to each arriving document, and exports the result. +/// +public sealed class DocumentWatcherService : BackgroundService +{ + private static readonly string[] Supported = [".docx", ".pdf", ".html", ".md", ".txt", ".rtf"]; + + private readonly ISuperDocsClient _client; + private readonly WatcherOptions _options; + private readonly ILogger _logger; + + // Content hashes already handled. Identity is the CONTENT, not the path or timestamp: + // editors and sync clients rewrite files without changing them, and keying on mtime + // would spend an API operation every time somebody merely opened a document. + private readonly ConcurrentDictionary _processed = new(); + + public DocumentWatcherService( + ISuperDocsClient client, + IOptions options, + ILogger logger) + { + _client = client; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + Directory.CreateDirectory(_options.InboxPath); + Directory.CreateDirectory(_options.OutboxPath); + + _logger.LogInformation( + "Watching {Inbox} -> {Outbox}. Drop a .docx in and it will be branded. " + + "AutoApprove={AutoApprove}", + Path.GetFullPath(_options.InboxPath), + Path.GetFullPath(_options.OutboxPath), + _options.AutoApprove); + + using PeriodicTimer timer = new(_options.PollInterval); + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + foreach (string path in Directory.EnumerateFiles(_options.InboxPath)) + { + if (!Supported.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + try + { + await ProcessAsync(path, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + throw; // shutting down, not a failure + } + catch (SuperDocsException ex) + { + // Named cause, and the file stays unprocessed so it is retried rather + // than silently skipped. + _logger.LogError( + "Could not process {File}: {Message} (transient: {Transient})", + Path.GetFileName(path), ex.Message, ex.IsTransient); + } + catch (IOException ex) + { + _logger.LogWarning( + "{File} is not readable yet ({Message}); will retry.", + Path.GetFileName(path), ex.Message); + } + } + } + } + + private async Task StartWithSessionRecoveryAsync( + string sessionId, string name, byte[] bytes, CancellationToken cancellationToken) + { + try + { + return await UploadAndStartAsync(sessionId, name, bytes, cancellationToken) + .ConfigureAwait(false); + } + catch (SuperDocsException ex) when (ex.IsSessionBusy) + { + // The session id is derived from file CONTENT, deliberately, so re-dropping the + // same document reuses its session instead of starting a parallel edit. The cost + // is that an abandoned job from an earlier run blocks it. The server says exactly + // this and suggests cancelling, so do that rather than surfacing an error a human + // would resolve the same way. + await ClearStaleJobsAsync(sessionId, cancellationToken).ConfigureAwait(false); + return await UploadAndStartAsync(sessionId, name, bytes, cancellationToken) + .ConfigureAwait(false); + } + } + + private async Task UploadAndStartAsync( + string sessionId, string name, byte[] bytes, CancellationToken cancellationToken) + { + await _client.UploadDocumentAsync(sessionId, name, bytes, cancellationToken) + .ConfigureAwait(false); + + return await _client + .StartEditAsync(sessionId, _options.Instruction, requireApproval: true, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + + private async Task ClearStaleJobsAsync(string sessionId, CancellationToken cancellationToken) + { + IReadOnlyList jobs = + await _client.ListSessionJobsAsync(sessionId, cancellationToken).ConfigureAwait(false); + + foreach (Job stale in jobs.Where(static j => !j.IsTerminal)) + { + _logger.LogWarning( + "Clearing stale job {JobId} ({Status}) left in session {Session}", + stale.JobId, stale.Status, sessionId); + + // A job stuck in awaiting_approval CANNOT be cancelled: cancel_job returns + // 400 "Job cannot be cancelled", even though the 409's own suggested_action + // recommends exactly that. The remedy that works is to DENY its pending + // changes, which resolves the review and lets the job finish. + if (stale.IsAwaitingApproval && stale.Metadata.PendingChanges.Count > 0) + { + await _client.ApproveAsync( + sessionId, + stale.JobId, + [.. stale.Metadata.PendingChanges.Select( + c => ApprovalDecision.Reject(c.ChangeId, "abandoned by a previous run"))], + cancellationToken).ConfigureAwait(false); + continue; + } + + try + { + await _client.CancelJobAsync(stale.JobId, cancellationToken).ConfigureAwait(false); + } + catch (SuperDocsException ex) + { + _logger.LogWarning( + "Could not cancel {JobId}: {Message}", stale.JobId, ex.Message); + } + } + } + + private async Task ProcessAsync(string path, CancellationToken cancellationToken) + { + byte[] bytes; + try + { + bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); + } + catch (IOException) + { + // Still being copied. Try again next tick rather than sending a truncated file, + // which would produce a confidently wrong result from a half-read document. + return; + } + + string contentId = Convert.ToHexString(SHA256.HashData(bytes)); + if (!_processed.TryAdd(contentId, 0)) + { + return; + } + + string name = Path.GetFileName(path); + string sessionId = $"brand-{contentId[..12]}"; + _logger.LogInformation("Processing {File} ({Bytes} bytes)", name, bytes.Length); + + // Guard the whole upload+start sequence, not just the edit. The 409 arrived from + // the UPLOAD, because that is the first call to touch the session — guarding only + // StartEditAsync left the handler in place and never reached. + ChatJobCreated job = await StartWithSessionRecoveryAsync( + sessionId, name, bytes, cancellationToken).ConfigureAwait(false); + + // ReviewAsync drives the WHOLE review, not one round of it. A large edit comes back + // in several batches: the job returns to awaiting_approval after each one, so + // approving once and waiting for completion polls forever. + int round = 0; + Job final = await _client.ReviewAsync( + sessionId, + job.JobId, + (changes, ct) => + { + round++; + _logger.LogInformation( + "{File}: review round {Round}, {Count} change(s) proposed:", + name, round, changes.Count); + foreach (ProposedChange change in changes) + { + _logger.LogInformation( + " [{Operation}] {Explanation}", change.Operation, + change.AiExplanation ?? "(no explanation)"); + } + + if (!_options.AutoApprove) + { + // The honest default. An unattended worker that approves its own edits + // has no gate, and a sample that pretends otherwise teaches the wrong + // shape. Returning no decisions ends the review with nothing applied. + _logger.LogWarning( + "{File}: AutoApprove is off, so nothing was applied. Review these " + + "changes and approve them through your own UI, or set " + + "Watcher:AutoApprove=true where that risk is acceptable.", name); + return Task.FromResult>([]); + } + + return Task.FromResult>( + [.. changes.Select(c => ApprovalDecision.Approve(c.ChangeId, "branding worker"))]); + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (!_options.AutoApprove && !final.IsTerminal) + { + return; + } + + string outputPath = Path.Combine( + _options.OutboxPath, + $"{Path.GetFileNameWithoutExtension(name)}-branded" + + $".{_options.OutputFormat.ToString().ToLowerInvariant()}"); + + // Streamed straight to disk rather than buffered. A thousand-page contract should + // not have to fit in memory to be saved. + FileStream output = File.Create(outputPath); + await using (output.ConfigureAwait(false)) + { + await _client.ExportToStreamAsync( + sessionId, output, _options.OutputFormat, cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation("{File} -> {Output}", name, outputPath); + } +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/Program.cs b/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/Program.cs new file mode 100644 index 0000000..a9d5d95 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/Program.cs @@ -0,0 +1,56 @@ +using SuperDocs.Client; +using SuperDocs.Worker.Sample; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +// --------------------------------------------------------------------------------------- +// One line to register the client. +// +// AddSuperDocs returns the IHttpClientBuilder, so the resilience policy below belongs to +// THIS application and is shared with every other typed client it registers. The SDK does +// not retry on its own, does not carry its own circuit breaker, and does not have a second +// opinion about timeouts — which is exactly what you want from a library you did not write. +// --------------------------------------------------------------------------------------- +builder.Services + .AddSuperDocs(builder.Configuration) + .AddStandardResilienceHandler(options => + { + // Document operations legitimately take minutes. The default 30-second attempt + // timeout would cancel healthy work and then retry it, turning one slow request + // into several. + options.AttemptTimeout.Timeout = TimeSpan.FromMinutes(4); + options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10); + options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(8); + }); + +builder.Services.Configure(builder.Configuration.GetSection("Watcher")); +builder.Services.AddHostedService(); + +IHost host = builder.Build(); + +// Fail fast and legibly rather than at the first API call, three minutes into a batch. +try +{ + _ = host.Services.GetRequiredService(); +} +catch (Exception ex) +{ + Console.Error.WriteLine($""" + Could not start. + + {ex.Message} + + Set your key and try again: + + export SUPERDOCS_API_KEY=sk_your_key_here + dotnet run + + Or use user-secrets, which keeps it out of your shell history: + + dotnet user-secrets set "SuperDocs:ApiKey" "sk_your_key_here" + """); + return 1; +} + +await host.RunAsync(); +return 0; diff --git a/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/SuperDocs.Worker.Sample.csproj b/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/SuperDocs.Worker.Sample.csproj new file mode 100644 index 0000000..58847c4 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/SuperDocs.Worker.Sample.csproj @@ -0,0 +1,45 @@ + + + + + net10.0 + Exe + superdocs-worker-sample + false + false + + + false + false + false + + + $(NoWarn);CS1591;CA1515;CA1062;CA1308;CA1873;CA1848;CA1849;CA2007;CA1031 + + + + + + + + + + + + + + + + + + diff --git a/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/appsettings.json b/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/appsettings.json new file mode 100644 index 0000000..9ef1867 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { "Default": "Information", "Microsoft.Hosting.Lifetime": "Information" } + }, + "SuperDocs": { + "ApiKey": "", + "BaseAddress": "https://api.superdocs.app/", + "Timeout": "00:05:00", + "PollInterval": "00:00:02" + }, + "Watcher": { + "InboxPath": "inbox", + "OutboxPath": "outbox", + "AutoApprove": false, + "OutputFormat": "Docx" + } +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/branding.html b/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/branding.html new file mode 100644 index 0000000..722e199 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/samples/SuperDocs.Worker.Sample/branding.html @@ -0,0 +1,9 @@ + +

{{DocumentTitle}}

+

Prepared by Northwind Consulting — internal use only.

+
+ +
+

+ Confidential. This document and its contents are the property of Northwind Consulting. +

diff --git a/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/ISuperDocsClient.cs b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/ISuperDocsClient.cs new file mode 100644 index 0000000..b181fc3 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/ISuperDocsClient.cs @@ -0,0 +1,168 @@ +namespace SuperDocs.Client; + +/// +/// Client for the SuperDocs document-editing API. +/// +/// +/// Every method is asynchronous and accepts a . There is no +/// synchronous overload anywhere in this library, deliberately: a blocking wrapper around an +/// async call is the classic ASP.NET deadlock, and offering one invites exactly the misuse +/// that the card warns about. If you need to block, that decision belongs at your +/// application's entry point where you can see the synchronization context, not here. +/// +public interface ISuperDocsClient +{ + /// Uploads a document and returns the session it now lives in. + /// Session to upload into. Created if it does not exist. + /// Original file name, including extension. + /// File bytes. Keep under 100 KB; larger files need the presigned upload path. + /// Cancels the request. + Task UploadDocumentAsync( + string sessionId, + string fileName, + ReadOnlyMemory content, + CancellationToken cancellationToken = default); + + /// Sends an editing instruction and applies the result immediately. + /// Use when a human should review first. + Task ChatAsync( + string sessionId, + string message, + string? documentHtml = null, + CancellationToken cancellationToken = default); + + /// + /// Starts an edit that will pause for human review before anything is applied. + /// + /// A job to poll with . + Task StartEditAsync( + string sessionId, + string message, + string? documentHtml = null, + bool requireApproval = true, + CancellationToken cancellationToken = default); + + /// Fetches a job's current state. + Task GetJobAsync(string jobId, CancellationToken cancellationToken = default); + + /// + /// Polls until the job is awaiting approval, finishes, or fails. + /// + /// + /// Honours . Long waits are normal: large + /// documents and deep model settings legitimately take minutes with no interim signal, + /// so a job that appears stuck is usually still working. Cancel through the token rather + /// than by shortening the timeout. + /// + Task WaitForApprovalAsync(string jobId, CancellationToken cancellationToken = default); + + /// Polls until the job reaches a terminal state. + Task WaitForCompletionAsync(string jobId, CancellationToken cancellationToken = default); + + /// + /// Records a decision on every proposed change, in one call. + /// + /// + /// Approve some and reject others in the same review; each decision is independent. + /// There is no "approve everything" overload — approving fifteen changes means naming + /// fifteen changes, because a review that can be passed with one keystroke is not a + /// review. + /// + Task ApproveAsync( + string sessionId, + string jobId, + IReadOnlyList decisions, + CancellationToken cancellationToken = default); + + /// Records a decision on a single change. + Task ApproveAsync( + string sessionId, + string jobId, + string changeId, + bool approved, + string? feedback = null, + CancellationToken cancellationToken = default); + + /// + /// Drives a complete review: waits for each batch of proposed changes, asks + /// what to do with it, applies the verdicts, and repeats until + /// the job finishes. + /// + /// + /// A large edit does NOT arrive as one batch. The job returns to + /// after each round, so code that approves once + /// and then calls polls forever — observed against + /// the live API at 146 requests and still going, with the job patiently waiting for a + /// second decision nobody was going to make. + /// + /// This method exists so that every consumer gets the multi-round shape right without + /// having to discover it. is called once per batch and returns + /// a verdict for each change; returning an empty list ends the review without approving + /// anything further. + /// + /// + /// Session the job belongs to. + /// Job to review. + /// Called per batch. Receives the proposed changes, returns verdicts. + /// + /// Safety bound. A server that never reaches a terminal state would otherwise loop + /// indefinitely, and an unattended worker should give up and say so rather than spin. + /// + /// Cancels the review. + /// The job in its final state. + Task ReviewAsync( + string sessionId, + string jobId, + Func, CancellationToken, Task>> decide, + int maxRounds = 20, + CancellationToken cancellationToken = default); + + /// Lists the jobs belonging to a session, most recent first. + /// Session to inspect. + /// Cancels the request. + Task> ListSessionJobsAsync( + string sessionId, CancellationToken cancellationToken = default); + + /// Cancels a pending or in-progress job. + /// + /// The way out of a busy session: an abandoned job left awaiting_approval blocks + /// every later request in that session until it is decided or cancelled. + /// + /// Job to cancel. + /// Cancels the request. + Task CancelJobAsync(string jobId, CancellationToken cancellationToken = default); + + /// Exports the session's document. + Task ExportAsync( + string sessionId, + ExportFormat format = ExportFormat.Docx, + string? fileName = null, + CancellationToken cancellationToken = default); + + /// Exports the session's document straight to a stream, without buffering it. + /// + /// Prefer this for large exports. materialises the whole file + /// in memory, which is fine for a letter and wasteful for a thousand-page contract. + /// + Task ExportToStreamAsync( + string sessionId, + Stream destination, + ExportFormat format = ExportFormat.Docx, + CancellationToken cancellationToken = default); + + /// Reads the current account's tier and remaining monthly operations. + /// Does not itself consume an operation. + Task GetAccountStatusAsync(CancellationToken cancellationToken = default); + + /// + /// Streams progress and proposed changes for a running job as they occur. + /// + /// + /// The double-encoding on this path is handled for you: yielded batches carry decoded + /// objects. + /// + IAsyncEnumerable StreamChangesAsync( + string sessionId, + string jobId, + CancellationToken cancellationToken = default); +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/Models.cs b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/Models.cs new file mode 100644 index 0000000..3aba3f5 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/Models.cs @@ -0,0 +1,282 @@ +using System.Text.Json.Serialization; + +namespace SuperDocs.Client; + +/// What a proposed change does to the document. +[JsonConverter(typeof(TolerantEnumConverter))] +public enum ChangeOperation +{ + /// Unrecognised. Present so a new server-side operation does not throw. + Unknown = 0, + + /// Modify an existing section. Has both OldHtml and NewHtml. + [JsonStringEnumMemberName("edit")] Edit, + + /// Add a new section. Has NewHtml and InsertAfterChunkId. + [JsonStringEnumMemberName("create")] Create, + + /// Remove a section. Has OldHtml. + [JsonStringEnumMemberName("delete")] Delete, +} + +/// One edit the AI proposes, awaiting a human decision. +public sealed record ProposedChange +{ + /// Identifier to quote back when approving or rejecting this change. + [JsonPropertyName("change_id")] public string ChangeId { get; init; } = string.Empty; + /// What this change does to the document. + [JsonPropertyName("operation")] public ChangeOperation Operation { get; init; } + /// The document section this change applies to. + [JsonPropertyName("chunk_id")] public string? ChunkId { get; init; } + /// The document within a multi-document session. + [JsonPropertyName("document_id")] public string? DocumentId { get; init; } + + /// The section as it stands. Null for . + [JsonPropertyName("old_html")] public string? OldHtml { get; init; } + + /// The section as proposed. Null for . + [JsonPropertyName("new_html")] public string? NewHtml { get; init; } + + /// For a create, the section this one is inserted after. + [JsonPropertyName("insert_after_chunk_id")] public string? InsertAfterChunkId { get; init; } + + /// The AI's own explanation. Worth showing beside the diff. + [JsonPropertyName("ai_explanation")] public string? AiExplanation { get; init; } +} + +/// Status of an asynchronous chat job. +[JsonConverter(typeof(TolerantEnumConverter))] +public enum JobStatus +{ + /// Unrecognised. Present so a new server-side status does not throw. + Unknown = 0, + + /// Queued, not yet started. + [JsonStringEnumMemberName("pending")] Pending, + /// Running. + [JsonStringEnumMemberName("in_progress")] InProgress, + + /// Waiting for a human. Metadata.PendingChanges holds what to review. + [JsonStringEnumMemberName("awaiting_approval")] AwaitingApproval, + + /// Finished successfully. + [JsonStringEnumMemberName("completed")] Completed, + /// Finished unsuccessfully. See . + [JsonStringEnumMemberName("failed")] Failed, + /// Cancelled before completion. + [JsonStringEnumMemberName("cancelled")] Cancelled, +} + +/// A message emitted while a job runs. +public sealed record IntermediateResponse +{ + /// Event kind, for example proposed_change_batch or user_facing. + [JsonPropertyName("type")] public string Type { get; init; } = string.Empty; + /// Where in the pipeline this was emitted. + [JsonPropertyName("context")] public string? Context { get; init; } + /// Monotonic ordering within the job. + [JsonPropertyName("sequence")] public int Sequence { get; init; } + /// When the server emitted this. + [JsonPropertyName("timestamp")] public DateTimeOffset? Timestamp { get; init; } + + /// + /// Raw content. For type == "proposed_change_batch" this is a JSON-ENCODED STRING + /// rather than an object; use , which unwraps it for you. + /// + [JsonPropertyName("content")] public string? Content { get; init; } + + /// + /// The proposed changes carried by this response, already decoded. + /// + /// + /// This is the property that exists because of a real inconsistency in the API: the same + /// proposed changes are encoded two different ways depending on how you fetch them. + /// metadata.pending_changes (the polling path) returns real objects, while + /// intermediate_responses[].content and the SSE stream return a JSON-encoded + /// STRING that needs a second parse. Guidance describing the second parse as universal + /// is therefore true of one path and false of the other, and applying it to the wrong one + /// throws. + /// + /// Rather than pass that on, this SDK handles it: poll or stream, you get + /// objects either way, and there is no path on which a + /// consumer must know about the encoding at all. + /// + /// + [JsonIgnore] + public IReadOnlyList Changes => + _changes ??= ProposedChangeBatch.ParseContent(Content); + + private IReadOnlyList? _changes; +} + +/// Metadata attached to a job. +public sealed record JobMetadata +{ + /// The instruction that started this job. + [JsonPropertyName("message")] public string? Message { get; init; } + + /// + /// Changes awaiting a decision. On this path they are real objects — do NOT apply a + /// second JSON parse to them. + /// + [JsonPropertyName("pending_changes")] + public IReadOnlyList PendingChanges { get; init; } = []; + + /// Progress messages emitted while the job ran. + [JsonPropertyName("intermediate_responses")] + public IReadOnlyList IntermediateResponses { get; init; } = []; + + /// What kind of decision the job is waiting for. + [JsonPropertyName("awaiting_kind")] public string? AwaitingKind { get; init; } + + /// Decisions already recorded, so a reloaded UI can skip them. + [JsonPropertyName("pending_batch_decisions")] + public IReadOnlyList PendingBatchDecisions { get; init; } = []; +} + +/// An asynchronous chat job. +public sealed record Job +{ + /// Server-assigned job identifier. + [JsonPropertyName("job_id")] public string JobId { get; init; } = string.Empty; + /// Session this job belongs to. + [JsonPropertyName("session_id")] public string SessionId { get; init; } = string.Empty; + /// Current status. + [JsonPropertyName("status")] public JobStatus Status { get; init; } + /// Rough completion percentage. Advisory only. + [JsonPropertyName("progress")] public int Progress { get; init; } + /// Failure reason, when the job failed. + [JsonPropertyName("error")] public string? Error { get; init; } + /// When the job was created. + [JsonPropertyName("created_at")] public DateTimeOffset? CreatedAt { get; init; } + /// When the job last changed. + [JsonPropertyName("updated_at")] public DateTimeOffset? UpdatedAt { get; init; } + /// Pending changes and progress detail. + [JsonPropertyName("metadata")] public JobMetadata Metadata { get; init; } = new(); + + /// True when a human decision is required before anything else can happen. + [JsonIgnore] public bool IsAwaitingApproval => Status == JobStatus.AwaitingApproval; + + /// True when the job will not progress further without intervention. + [JsonIgnore] + public bool IsTerminal => + Status is JobStatus.Completed or JobStatus.Failed or JobStatus.Cancelled; +} + +/// A per-change verdict. +public sealed record ApprovalDecision +{ + /// The change this verdict applies to. + [JsonPropertyName("change_id")] public string ChangeId { get; init; } = string.Empty; + /// True to apply the change, false to discard it. + [JsonPropertyName("approved")] public bool Approved { get; init; } + /// Optional reason. Sent to the AI to inform later suggestions. + [JsonPropertyName("feedback")] public string? Feedback { get; init; } + + /// Creates an approving verdict for one change. + public static ApprovalDecision Approve(string changeId, string? feedback = null) + => new() { ChangeId = changeId, Approved = true, Feedback = feedback }; + + /// Creates a rejecting verdict for one change. + public static ApprovalDecision Reject(string changeId, string? feedback = null) + => new() { ChangeId = changeId, Approved = false, Feedback = feedback }; +} + +/// Result of an approval call. +public sealed record ApprovalResult +{ + /// Server status string, normally ok. + [JsonPropertyName("status")] public string Status { get; init; } = string.Empty; + /// Human-readable detail. + [JsonPropertyName("message")] public string? Message { get; init; } + /// True when every change in the batch has been decided. + [JsonPropertyName("batch_complete")] public bool BatchComplete { get; init; } +} + +/// Response from starting an asynchronous chat. +public sealed record ChatJobCreated +{ + /// Poll this with GetJobAsync. + [JsonPropertyName("job_id")] public string JobId { get; init; } = string.Empty; + /// Session the job runs in. + [JsonPropertyName("session_id")] public string SessionId { get; init; } = string.Empty; + /// Initial status. + [JsonPropertyName("status")] public JobStatus Status { get; init; } + /// Server acknowledgement text. + [JsonPropertyName("message")] public string? Message { get; init; } +} + +/// Quota and tier for the current account. +public sealed record AccountStatus +{ + /// Your account identifier. + [JsonPropertyName("account_id")] public string AccountId { get; init; } = string.Empty; + /// Subscription tier, for example free. + [JsonPropertyName("tier")] public string Tier { get; init; } = string.Empty; + /// Monthly operation allowance and usage. + [JsonPropertyName("quota")] public Quota Quota { get; init; } = new(); +} + +/// Monthly operation allowance. +public sealed record Quota +{ + /// Operations included per month. + [JsonPropertyName("monthly_limit")] public int MonthlyLimit { get; init; } + /// Operations consumed this period. + [JsonPropertyName("used")] public int Used { get; init; } + /// Operations still available. + [JsonPropertyName("remaining")] public int Remaining { get; init; } + /// When the allowance resets. + [JsonPropertyName("resets_at")] public DateTimeOffset? ResetsAt { get; init; } +} + +/// An uploaded document. +public sealed record UploadedDocument +{ + /// Session the document was uploaded into. + [JsonPropertyName("session_id")] public string SessionId { get; init; } = string.Empty; + /// Server-assigned document identifier. + [JsonPropertyName("document_id")] public string? DocumentId { get; init; } + /// Name the document was stored under. + [JsonPropertyName("filename")] public string? Filename { get; init; } + /// Parsed document HTML, with chunk ids for targeted editing. + [JsonPropertyName("html")] public string? Html { get; init; } +} + +/// Export formats. +public enum ExportFormat +{ + /// Word document. The default. + Docx = 0, + /// PDF. + Pdf, + /// HTML. + Html, + /// Markdown. + Markdown, + /// Plain text. + Txt, +} + +/// An exported file. +/// +/// is a rather than a byte array +/// so callers cannot mutate the buffer behind the record, and so slicing is free. +/// Use Content.Span to write it, or prefer +/// ISuperDocsClient.ExportToStreamAsync for anything large. +/// +public sealed record ExportedFile(string FileName, string ContentType, ReadOnlyMemory Content) +{ + /// Size of the exported file in bytes. + public long Length => Content.Length; + + /// Writes the exported bytes to a file. + public async Task SaveAsAsync(string path, CancellationToken cancellationToken = default) + { + FileStream stream = File.Create(path); + await using (stream.ConfigureAwait(false)) + { + await stream.WriteAsync(Content, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/ProposedChangeBatch.cs b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/ProposedChangeBatch.cs new file mode 100644 index 0000000..00f4d3b --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/ProposedChangeBatch.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SuperDocs.Client; + +/// +/// A batch of proposed changes as delivered on the streaming path. +/// +/// +/// The SSE and intermediate_responses paths wrap this object in a JSON-encoded STRING, +/// so reading it requires a second parse: +/// JSON.parse(JSON.parse(event.data).content) +/// The polling path (metadata.pending_changes) does not. Same data, two encodings. +/// +/// exists so that no consumer of this SDK ever has to know which +/// path they are on. It is a workaround for an API inconsistency, not a fix — every other +/// language's integrators still meet it — and the correct server-side fix is to emit +/// content as a real nested object on the streaming path too. +/// +/// +public sealed record ProposedChangeBatch +{ + /// Batch kind reported by the server. + [JsonPropertyName("type")] public string Type { get; init; } = string.Empty; + /// Identifier for this batch of changes. + [JsonPropertyName("batch_id")] public string? BatchId { get; init; } + /// How many changes the batch contains. + [JsonPropertyName("batch_total")] public int BatchTotal { get; init; } + + /// The decoded changes. + + [JsonPropertyName("changes")] + public IReadOnlyList Changes { get; init; } = []; + + /// + /// Decode a content field into proposed changes, tolerating both encodings. + /// + /// + /// Returns an empty list rather than throwing on unparseable content. A malformed + /// progress message should not take down a review the user is in the middle of — the + /// authoritative list is always Job.Metadata.PendingChanges, and losing a + /// streamed preview is a far smaller harm than losing the session. + /// + public static IReadOnlyList ParseContent(string? content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return []; + } + + try + { + string payload = content; + + // The first parse. If `content` is a JSON string literal it yields the inner + // JSON text; if it is already an object, we keep the original. + using (JsonDocument outer = JsonDocument.Parse(content)) + { + if (outer.RootElement.ValueKind == JsonValueKind.String) + { + payload = outer.RootElement.GetString() ?? string.Empty; + } + } + + if (string.IsNullOrWhiteSpace(payload)) + { + return []; + } + + using JsonDocument document = JsonDocument.Parse(payload); + JsonElement root = document.RootElement; + + // A bare array of changes. + if (root.ValueKind == JsonValueKind.Array) + { + return JsonSerializer.Deserialize( + root.GetRawText(), SuperDocsJsonContext.Default.IReadOnlyListProposedChange) ?? []; + } + + if (root.ValueKind != JsonValueKind.Object) + { + return []; + } + + // The usual shape: an object with a "changes" array. + if (root.TryGetProperty("changes", out JsonElement changes) + && changes.ValueKind == JsonValueKind.Array) + { + return JsonSerializer.Deserialize( + changes.GetRawText(), + SuperDocsJsonContext.Default.IReadOnlyListProposedChange) ?? []; + } + + // A single change delivered without a wrapper. + if (root.TryGetProperty("change_id", out _)) + { + ProposedChange? single = JsonSerializer.Deserialize( + root.GetRawText(), SuperDocsJsonContext.Default.ProposedChange); + return single is null ? [] : [single]; + } + + return []; + } + catch (JsonException) + { + return []; + } + } +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/ServiceCollectionExtensions.cs b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ed77b44 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,151 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Options; + +namespace SuperDocs.Client; + +/// +/// Registers the SuperDocs client in one line. +/// +public static class ServiceCollectionExtensions +{ + internal const string HttpClientName = "SuperDocs"; + + /// + /// Registers using configuration section SuperDocs. + /// + /// + /// + /// builder.Services.AddSuperDocs(builder.Configuration); + /// + /// + /// + /// The , so the host can attach its own resilience and + /// telemetry: + /// + /// builder.Services.AddSuperDocs(builder.Configuration) + /// .AddStandardResilienceHandler(); + /// + /// + public static IHttpClientBuilder AddSuperDocs( + this IServiceCollection services, + IConfiguration configuration, + string sectionName = SuperDocsOptions.SectionName) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + IConfigurationSection section = configuration.GetSection(sectionName); + + services.AddOptions() + // Bound by hand rather than with .Bind(). Reflection-based binding is not + // trim- or AOT-safe (IL2026/IL3050) and would silently undermine the + // trim-friendliness this package advertises. Five keys do not justify that. + .Configure(options => BindFrom(section, options)) + .Validate( + static options => !string.IsNullOrWhiteSpace(options.ApiKey), + "SuperDocs:ApiKey is required. Set it in configuration or the " + + "SUPERDOCS_API_KEY environment variable.") + // Fail at startup, not at the first call. A misconfigured key discovered + // during a nightly batch is a much worse outcome than a failed boot. + .ValidateOnStart(); + + return AddSuperDocsCore(services); + } + + private static void BindFrom(IConfiguration section, SuperDocsOptions options) + { + string? apiKey = section["ApiKey"] ?? Environment.GetEnvironmentVariable("SUPERDOCS_API_KEY"); + if (!string.IsNullOrWhiteSpace(apiKey)) + { + options.ApiKey = apiKey; + } + + if (Uri.TryCreate(section["BaseAddress"], UriKind.Absolute, out Uri? baseAddress)) + { + options.BaseAddress = baseAddress; + } + + if (TimeSpan.TryParse(section["Timeout"], out TimeSpan timeout)) + { + options.Timeout = timeout; + } + + if (TimeSpan.TryParse(section["PollInterval"], out TimeSpan pollInterval)) + { + options.PollInterval = pollInterval; + } + + options.UserAgent = section["UserAgent"] ?? options.UserAgent; + } + + /// + /// Registers with options configured in code. + /// + /// + /// + /// builder.Services.AddSuperDocs(o => o.ApiKey = Environment.GetEnvironmentVariable("SUPERDOCS_API_KEY")!); + /// + /// + public static IHttpClientBuilder AddSuperDocs( + this IServiceCollection services, + Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + services.AddOptions() + .Configure(configure) + .Validate( + static options => !string.IsNullOrWhiteSpace(options.ApiKey), + "SuperDocs:ApiKey is required.") + .ValidateOnStart(); + + return AddSuperDocsCore(services); + } + + private static IHttpClientBuilder AddSuperDocsCore(IServiceCollection services) + { + // A TYPED client over IHttpClientFactory. This is the whole point of the design: + // connection pooling, DNS rotation, resilience handlers, OpenTelemetry + // instrumentation and logging all come from the host's existing HTTP stack rather + // than being reimplemented — badly, and differently — inside this library. + // + // A hand-rolled HttpClient here would either leak sockets (new HttpClient per call) + // or pin stale DNS (one static HttpClient forever). Both are classic, and both are + // solved already by the platform. + IHttpClientBuilder builder = services.AddHttpClient( + HttpClientName, + static (serviceProvider, http) => + { + SuperDocsOptions options = + serviceProvider.GetRequiredService>().Value; + options.Validate(); + + http.BaseAddress = options.BaseAddress; + http.Timeout = options.Timeout; + http.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.ApiKey); + http.DefaultRequestHeaders.Add("Accept", "application/json"); + + if (!string.IsNullOrWhiteSpace(options.UserAgent)) + { + http.DefaultRequestHeaders.Add("User-Agent", options.UserAgent); + } + else + { + http.DefaultRequestHeaders.Add( + "User-Agent", $"SuperDocs.Client/{ThisAssembly.Version}"); + } + }); + + return builder; + } +} + +internal static class ThisAssembly +{ + public static string Version { get; } = + typeof(SuperDocsClient).Assembly.GetName().Version?.ToString(3) ?? "0.1.0"; +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocs.Client.csproj b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocs.Client.csproj new file mode 100644 index 0000000..7e34043 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocs.Client.csproj @@ -0,0 +1,55 @@ + + + + SuperDocs.Client + 0.1.0 + Aayush Mishra + + .NET client for the SuperDocs document-editing API. Registers through + IHttpClientFactory so pooling, resilience and telemetry come from your host. + Source-generated JSON, cancellation throughout, and the proposed-change + double-encoding handled for you. + + superdocs;documents;docx;ai;http;api-client + MIT + README.md + https://github.com/superdocsapp/superdocs-builds + git + + + true + snupkg + true + true + + + true + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocsClient.cs b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocsClient.cs new file mode 100644 index 0000000..06d1731 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocsClient.cs @@ -0,0 +1,571 @@ +using System.Net; +using System.Net.Http.Json; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace SuperDocs.Client; + +/// +public sealed class SuperDocsClient : ISuperDocsClient +{ + private readonly HttpClient _http; + private readonly SuperDocsOptions _options; + + /// + /// Constructed by dependency injection. The comes from + /// IHttpClientFactory, so its lifetime, pooling and handler pipeline belong to the + /// host — this class neither creates nor disposes it. + /// + public SuperDocsClient(HttpClient httpClient, IOptions options) + { + _http = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + // ------------------------------------------------------------------ upload -- + + /// + public async Task UploadDocumentAsync( + string sessionId, + string fileName, + ReadOnlyMemory content, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + + UploadBase64Request body = new() + { + Filename = fileName, + FileBase64 = Convert.ToBase64String(content.Span), + SessionId = sessionId, + ReturnHtml = true, + }; + + return await PostAsync( + "/v1/documents/upload-base64", + body, + SuperDocsJsonContext.Default.UploadBase64Request, + SuperDocsJsonContext.Default.UploadedDocument, + cancellationToken).ConfigureAwait(false); + } + + // -------------------------------------------------------------------- chat -- + + /// + public async Task ChatAsync( + string sessionId, + string message, + string? documentHtml = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + + ChatRequest body = new() + { + Message = message, + SessionId = sessionId, + DocumentHtml = documentHtml, + }; + + return await PostAsync( + "/v1/chat", + body, + SuperDocsJsonContext.Default.ChatRequest, + SuperDocsJsonContext.Default.Job, + cancellationToken).ConfigureAwait(false); + } + + /// + public async Task StartEditAsync( + string sessionId, + string message, + string? documentHtml = null, + bool requireApproval = true, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + + ChatAsyncRequest body = new() + { + Message = message, + SessionId = sessionId, + DocumentHtml = documentHtml, + ApprovalMode = requireApproval ? "ask_every_time" : null, + }; + + return await PostAsync( + "/v1/chat/async", + body, + SuperDocsJsonContext.Default.ChatAsyncRequest, + SuperDocsJsonContext.Default.ChatJobCreated, + cancellationToken).ConfigureAwait(false); + } + + // -------------------------------------------------------------------- jobs -- + + /// + public async Task GetJobAsync(string jobId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jobId); + + using HttpResponseMessage response = await _http + .GetAsync(new Uri($"/v1/jobs/{Uri.EscapeDataString(jobId)}", UriKind.Relative), + cancellationToken) + .ConfigureAwait(false); + + return await ReadAsync(response, SuperDocsJsonContext.Default.Job, cancellationToken) + .ConfigureAwait(false); + } + + /// + public Task WaitForApprovalAsync(string jobId, CancellationToken cancellationToken = default) + => PollAsync(jobId, static job => job.IsAwaitingApproval || job.IsTerminal, cancellationToken); + + /// + public Task WaitForCompletionAsync(string jobId, CancellationToken cancellationToken = default) + => PollAsync(jobId, static job => job.IsTerminal, cancellationToken); + + private async Task PollAsync( + string jobId, + Func isDone, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jobId); + + // A PeriodicTimer rather than Task.Delay in a loop: it does not drift, and it + // disposes cleanly on cancellation instead of leaving a timer queued. + using PeriodicTimer timer = new(_options.PollInterval); + + Job job = await GetJobAsync(jobId, cancellationToken).ConfigureAwait(false); + while (!isDone(job)) + { + // Throws OperationCanceledException on cancellation, which is the documented + // contract for a cancelled async method — not a silent return of a stale job. + await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false); + job = await GetJobAsync(jobId, cancellationToken).ConfigureAwait(false); + } + + if (job.Status == JobStatus.Failed) + { + throw new SuperDocsException( + $"Job {jobId} failed: {job.Error ?? "no reason given"}", HttpStatusCode.OK); + } + + return job; + } + + // ------------------------------------------------------------------ approve -- + + /// + public async Task ApproveAsync( + string sessionId, + string jobId, + IReadOnlyList decisions, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(jobId); + ArgumentNullException.ThrowIfNull(decisions); + + if (decisions.Count == 0) + { + throw new ArgumentException( + "No decisions supplied. Approving nothing and approving everything are very " + + "different outcomes, so this call will not guess which you meant.", + nameof(decisions)); + } + + ApprovalRequest body = new() + { + JobId = jobId, + // The API requires a top-level `approved` even when per-change decisions are + // supplied. Setting it true here does NOT approve everything — the per-change + // verdicts in `changes` govern. Omitting it is simply rejected. + Approved = true, + Changes = decisions, + }; + + return await PostAsync( + $"/v1/chat/{Uri.EscapeDataString(sessionId)}/approve", + body, + SuperDocsJsonContext.Default.ApprovalRequest, + SuperDocsJsonContext.Default.ApprovalResult, + cancellationToken).ConfigureAwait(false); + } + + /// + public async Task ApproveAsync( + string sessionId, + string jobId, + string changeId, + bool approved, + string? feedback = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(jobId); + ArgumentException.ThrowIfNullOrWhiteSpace(changeId); + + ApprovalRequest body = new() + { + JobId = jobId, + Approved = approved, + ChangeId = changeId, + Feedback = feedback, + }; + + return await PostAsync( + $"/v1/chat/{Uri.EscapeDataString(sessionId)}/approve", + body, + SuperDocsJsonContext.Default.ApprovalRequest, + SuperDocsJsonContext.Default.ApprovalResult, + cancellationToken).ConfigureAwait(false); + } + + /// + public async Task ReviewAsync( + string sessionId, + string jobId, + Func, CancellationToken, Task>> decide, + int maxRounds = 20, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(jobId); + ArgumentNullException.ThrowIfNull(decide); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxRounds); + + Job job = await WaitForApprovalAsync(jobId, cancellationToken).ConfigureAwait(false); + + for (int round = 0; round < maxRounds; round++) + { + if (job.IsTerminal) + { + return job; + } + + IReadOnlyList pending = job.Metadata.PendingChanges; + if (pending.Count == 0) + { + // Awaiting approval with nothing to approve. Waiting longer will not help, + // and looping here is how the 146-poll stall happened. + return job; + } + + IReadOnlyList decisions = + await decide(pending, cancellationToken).ConfigureAwait(false); + + if (decisions.Count == 0) + { + // The reviewer declined to decide. Ending here leaves the job untouched, + // which is the safe outcome: nothing is applied. + return job; + } + + await ApproveAsync(sessionId, jobId, decisions, cancellationToken).ConfigureAwait(false); + job = await WaitForApprovalAsync(jobId, cancellationToken).ConfigureAwait(false); + } + + throw new SuperDocsException( + $"Job {jobId} was still proposing changes after {maxRounds} review rounds. Either " + + $"the edit is unusually large, or the job is not converging — stopping rather " + + $"than looping. Raise maxRounds if the former.", + HttpStatusCode.OK); + } + + /// + public async Task> ListSessionJobsAsync( + string sessionId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + using HttpResponseMessage response = await _http + .GetAsync( + new Uri($"/v1/sessions/{Uri.EscapeDataString(sessionId)}/jobs", UriKind.Relative), + cancellationToken) + .ConfigureAwait(false); + + SessionJobsResponse payload = await ReadAsync( + response, SuperDocsJsonContext.Default.SessionJobsResponse, cancellationToken) + .ConfigureAwait(false); + + return payload.Jobs; + } + + /// + public async Task CancelJobAsync(string jobId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jobId); + + using HttpResponseMessage response = await _http + .PostAsync( + new Uri($"/v1/jobs/{Uri.EscapeDataString(jobId)}/cancel", UriKind.Relative), + content: null, + cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + await ThrowApiExceptionAsync(response, cancellationToken).ConfigureAwait(false); + } + } + + // ------------------------------------------------------------------- export -- + + /// + public async Task ExportAsync( + string sessionId, + ExportFormat format = ExportFormat.Docx, + string? fileName = null, + CancellationToken cancellationToken = default) + { + using HttpResponseMessage response = + await SendExportAsync(sessionId, format, fileName, cancellationToken) + .ConfigureAwait(false); + + byte[] bytes = await response.Content + .ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + + return new ExportedFile( + ResolveFileName(response, fileName, format), + response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream", + bytes); + } + + /// + public async Task ExportToStreamAsync( + string sessionId, + Stream destination, + ExportFormat format = ExportFormat.Docx, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(destination); + + using HttpResponseMessage response = + await SendExportAsync(sessionId, format, null, cancellationToken).ConfigureAwait(false); + + Stream source = await response.Content + .ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (source.ConfigureAwait(false)) + { + + await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false); + } + } + + private async Task SendExportAsync( + string sessionId, ExportFormat format, string? fileName, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + ExportRequest body = new() + { + SessionId = sessionId, + // CA1308 suggests ToUpperInvariant. The API's `format` enum is lowercase by + // specification ("docx", "pdf", ...), so lowering is correct here and + // uppercasing would simply be rejected. +#pragma warning disable CA1308 + Format = format.ToString().ToLowerInvariant(), +#pragma warning restore CA1308 + Filename = fileName, + }; + + using HttpRequestMessage request = + new(HttpMethod.Post, new Uri("/v1/documents/export", UriKind.Relative)) + { + Content = JsonContent.Create( + body, SuperDocsJsonContext.Default.ExportRequest), + }; + + // ResponseHeadersRead so the body is not buffered before we look at it — required + // for ExportToStreamAsync to actually stream rather than merely appear to. + HttpResponseMessage response = await _http + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + await ThrowApiExceptionAsync(response, cancellationToken).ConfigureAwait(false); + } + + return response; + } + + private static string ResolveFileName( + HttpResponseMessage response, string? requested, ExportFormat format) + { + if (!string.IsNullOrWhiteSpace(requested)) + { + return requested; + } + + string? fromHeader = response.Content.Headers.ContentDisposition?.FileNameStar + ?? response.Content.Headers.ContentDisposition?.FileName; + + return string.IsNullOrWhiteSpace(fromHeader) +#pragma warning disable CA1308 // file extensions are lowercase by convention + ? $"document.{format.ToString().ToLowerInvariant()}" +#pragma warning restore CA1308 + : fromHeader.Trim('"'); + } + + // ------------------------------------------------------------------ account -- + + /// + public async Task GetAccountStatusAsync( + CancellationToken cancellationToken = default) + { + using HttpResponseMessage response = await _http + .GetAsync(new Uri("/v1/agents/whoami", UriKind.Relative), cancellationToken) + .ConfigureAwait(false); + + return await ReadAsync(response, SuperDocsJsonContext.Default.AccountStatus, cancellationToken) + .ConfigureAwait(false); + } + + // ------------------------------------------------------------------- stream -- + + /// + public async IAsyncEnumerable StreamChangesAsync( + string sessionId, + string jobId, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(jobId); + + Uri uri = new( + $"/v1/chat/{Uri.EscapeDataString(sessionId)}/stream?job_id={Uri.EscapeDataString(jobId)}", + UriKind.Relative); + + using HttpRequestMessage request = new(HttpMethod.Get, uri); + request.Headers.Add("Accept", "text/event-stream"); + + using HttpResponseMessage response = await _http + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + await ThrowApiExceptionAsync(response, cancellationToken).ConfigureAwait(false); + } + + Stream stream = await response.Content + .ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using ConfiguredAsyncDisposable _ = stream.ConfigureAwait(false); + using StreamReader reader = new(stream, Encoding.UTF8); + + string? eventName = null; + StringBuilder data = new(); + + // Not `while (!reader.EndOfStream)`: EndOfStream performs a BLOCKING read to + // determine the answer, which stalls the event loop inside an async iterator. + // Reading until ReadLineAsync returns null is the async-safe equivalent. + while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) + is { } line) + { + if (line.StartsWith("event:", StringComparison.Ordinal)) + { + eventName = line[6..].Trim(); + continue; + } + + if (line.StartsWith("data:", StringComparison.Ordinal)) + { + data.Append(line[5..].Trim()); + continue; + } + + if (line.Length != 0) + { + continue; + } + + // Blank line terminates an event. + if (eventName == "proposed_change_batch" && data.Length > 0) + { + ProposedChangeBatch? batch = ParseBatchEvent(data.ToString()); + if (batch is not null) + { + yield return batch; + } + } + + eventName = null; + data.Clear(); + } + } + + private static ProposedChangeBatch? ParseBatchEvent(string payload) + { + try + { + using JsonDocument envelope = JsonDocument.Parse(payload); + string? content = envelope.RootElement.TryGetProperty("content", out JsonElement el) + ? el.ToString() + : payload; + + IReadOnlyList changes = ProposedChangeBatch.ParseContent(content); + return changes.Count == 0 + ? null + : new ProposedChangeBatch { Type = "proposed_change_batch", Changes = changes }; + } + catch (JsonException) + { + // A malformed progress frame is not worth aborting a live review over; the + // authoritative list is always Job.Metadata.PendingChanges. + return null; + } + } + + // ------------------------------------------------------------------- plumbing -- + + private async Task PostAsync( + string path, + TRequest body, + System.Text.Json.Serialization.Metadata.JsonTypeInfo requestInfo, + System.Text.Json.Serialization.Metadata.JsonTypeInfo responseInfo, + CancellationToken cancellationToken) + { + using HttpResponseMessage response = await _http + .PostAsJsonAsync(new Uri(path, UriKind.Relative), body, requestInfo, cancellationToken) + .ConfigureAwait(false); + + return await ReadAsync(response, responseInfo, cancellationToken).ConfigureAwait(false); + } + + private static async Task ReadAsync( + HttpResponseMessage response, + System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, + CancellationToken cancellationToken) + { + if (!response.IsSuccessStatusCode) + { + await ThrowApiExceptionAsync(response, cancellationToken).ConfigureAwait(false); + } + + T? value = await response.Content + .ReadFromJsonAsync(typeInfo, cancellationToken).ConfigureAwait(false); + + return value ?? throw new SuperDocsException( + $"The API returned success with an empty body for {response.RequestMessage?.RequestUri}.", + response.StatusCode); + } + + private static async Task ThrowApiExceptionAsync( + HttpResponseMessage response, CancellationToken cancellationToken) + { + string raw = await response.Content.ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + + throw new SuperDocsException( + SuperDocsException.Describe(response.StatusCode, raw), + response.StatusCode, + raw); + } +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocsException.cs b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocsException.cs new file mode 100644 index 0000000..18e295f --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocsException.cs @@ -0,0 +1,171 @@ +using System.Net; +using System.Text.Json; + +namespace SuperDocs.Client; + +/// +/// An error returned by the SuperDocs API. +/// +/// +/// The message names the cause AND the fix wherever the status code makes that knowable. +/// "Response status code does not indicate success: 401 (Unauthorized)" tells a developer +/// nothing they could not already see; "your API key was rejected — check SuperDocs:ApiKey" +/// tells them where to look. +/// +public sealed class SuperDocsException : Exception +{ + /// Creates an exception describing an API failure. + /// Human-readable description, naming the cause and the fix. + /// HTTP status returned by the API. + /// Raw response body, when there was one. + public SuperDocsException(string message, HttpStatusCode statusCode, string? responseBody = null) + : base(message) + { + StatusCode = statusCode; + ResponseBody = responseBody; + ErrorCode = ExtractField(responseBody, "error_code"); + } + + /// Creates an exception with a message. + /// Human-readable description. + public SuperDocsException(string message) : base(message) { } + + /// Creates an exception wrapping another. + /// Human-readable description. + /// The underlying failure. + public SuperDocsException(string message, Exception innerException) + : base(message, innerException) { } + + /// Creates an exception with no detail. + public SuperDocsException() { } + + /// HTTP status returned by the API. + public HttpStatusCode StatusCode { get; } + + /// Raw response body, when there was one. + public string? ResponseBody { get; } + + /// + /// The server's machine-readable error code, such as session_busy. + /// + /// + /// Exposed because branching on a status code alone is too coarse: a 409 can mean a busy + /// session, which is recoverable by cancelling the active job, or something else + /// entirely. The server distinguishes them and it would be wasteful to throw that away + /// and make callers pattern-match on prose. + /// + public string? ErrorCode { get; } + + /// True when a prior job in the same session is still running. + public bool IsSessionBusy => + string.Equals(ErrorCode, "session_busy", StringComparison.Ordinal); + + /// + /// True when retrying the identical request could plausibly succeed. + /// + /// + /// Exposed so a host's resilience policy can make an informed decision. The SDK does not + /// retry on your behalf — that belongs to your policy, not to this library. + /// + public bool IsTransient => StatusCode is HttpStatusCode.RequestTimeout + or HttpStatusCode.TooManyRequests + or HttpStatusCode.InternalServerError + or HttpStatusCode.BadGateway + or HttpStatusCode.ServiceUnavailable + or HttpStatusCode.GatewayTimeout; + + internal static string Describe(HttpStatusCode status, string body) + { + string detail = ExtractDetail(body); + string suffix = string.IsNullOrWhiteSpace(detail) ? "" : $" API said: {detail}"; + + return status switch + { + HttpStatusCode.Unauthorized => + "SuperDocs rejected your API key (401). Check SuperDocs:ApiKey is a current " + + "key and starts with 'sk_'." + suffix, + + HttpStatusCode.Forbidden => + "SuperDocs refused this request (403). The key may lack access to this " + + "resource, or the endpoint may require a signed-in user session rather than " + + "an API key." + suffix, + + HttpStatusCode.Conflict => + "The session is busy (409). A previous job in this session is still running " + + "or still awaiting approval. Cancel it, finish reviewing it, or use a " + + "different session id." + suffix, + + HttpStatusCode.NotFound => + "Not found (404). Check the session or job id — and note that a session is " + + "created by its first upload or chat, not by naming it." + suffix, + + HttpStatusCode.TooManyRequests => + "Rate limited (429). Back off and retry; if you are on a free tier you may " + + "have exhausted the monthly operation allowance." + suffix, + + HttpStatusCode.RequestEntityTooLarge => + "Payload too large (413). Documents over 100 KB must use the presigned upload " + + "path rather than base64." + suffix, + + // The server's 422 body names the offending field precisely, so it leads. An + // earlier version led with a canned hint about approvals, which was actively + // misleading on an UPLOAD failure — the hint pointed at the wrong endpoint + // entirely while the real answer sat unread in the body. A guess presented ahead + // of the facts is worse than no guess. + HttpStatusCode.UnprocessableEntity => string.IsNullOrWhiteSpace(detail) + ? "The request was rejected as invalid (422). On an approval, check the " + + "top-level 'approved' field, which is required even when per-change " + + "decisions are supplied." + : $"The request was rejected as invalid (422). {detail}", + + _ => $"SuperDocs returned {(int)status} {status}.{suffix}", + }; + } + + private static string? ExtractField(string? body, string field) + { + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + + try + { + using JsonDocument document = JsonDocument.Parse(body); + return document.RootElement.TryGetProperty(field, out JsonElement value) + ? value.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } + + private static string ExtractDetail(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return string.Empty; + } + + try + { + using JsonDocument document = JsonDocument.Parse(body); + foreach (string key in new[] { "detail", "message", "error" }) + { + if (document.RootElement.TryGetProperty(key, out JsonElement value)) + { + return value.ToString(); + } + } + } + catch (JsonException) + { + // Not JSON. Fall through to the truncated raw body, which is still better than + // discarding whatever the server tried to tell us. + } + + return body.Length > 300 ? body[..300] + "…" : body; + } +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocsJsonContext.cs b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocsJsonContext.cs new file mode 100644 index 0000000..d44eacf --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocsJsonContext.cs @@ -0,0 +1,125 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SuperDocs.Client; + +/// +/// Source-generated serialization metadata. +/// +/// +/// Every type crossing the wire is declared here so serialization is compiled rather than +/// discovered by reflection at runtime. That is what makes the package trim- and +/// AOT-friendly: a trimmed host would otherwise strip the model types it cannot see being +/// used, and fail at runtime with a message that says nothing about trimming. +/// +/// It is also measurably faster to start, which matters for the worker sample: a background +/// service that processes one file and exits pays reflection startup cost every time. +/// +/// +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + // The API adds fields over time. Ignoring unknown members means a server-side addition + // is a non-event for existing consumers rather than a deserialization failure. + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] +[JsonSerializable(typeof(ProposedChange))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(ProposedChangeBatch))] +[JsonSerializable(typeof(IntermediateResponse))] +[JsonSerializable(typeof(JobMetadata))] +[JsonSerializable(typeof(Job))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(SessionJobsResponse))] +[JsonSerializable(typeof(ApprovalDecision))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(ApprovalResult))] +[JsonSerializable(typeof(ChatJobCreated))] +[JsonSerializable(typeof(AccountStatus))] +[JsonSerializable(typeof(Quota))] +[JsonSerializable(typeof(UploadedDocument))] +[JsonSerializable(typeof(ChatRequest))] +[JsonSerializable(typeof(ChatAsyncRequest))] +[JsonSerializable(typeof(ApprovalRequest))] +[JsonSerializable(typeof(ExportRequest))] +[JsonSerializable(typeof(UploadBase64Request))] +[JsonSerializable(typeof(ApiErrorBody))] +internal sealed partial class SuperDocsJsonContext : JsonSerializerContext; + +// ---------------------------------------------------------------- request bodies -- + +internal sealed record ChatRequest +{ + [JsonPropertyName("message")] public required string Message { get; init; } + [JsonPropertyName("session_id")] public required string SessionId { get; init; } + [JsonPropertyName("document_html")] public string? DocumentHtml { get; init; } +} + +internal sealed record ChatAsyncRequest +{ + [JsonPropertyName("message")] public required string Message { get; init; } + [JsonPropertyName("session_id")] public required string SessionId { get; init; } + [JsonPropertyName("document_html")] public string? DocumentHtml { get; init; } + [JsonPropertyName("approval_mode")] public string? ApprovalMode { get; init; } +} + +internal sealed record ApprovalRequest +{ + [JsonPropertyName("job_id")] public required string JobId { get; init; } + + /// + /// Required by the API even when per-change decisions are supplied in + /// . Omitting it is rejected, which is not obvious from the docs. + /// + [JsonPropertyName("approved")] public required bool Approved { get; init; } + + [JsonPropertyName("change_id")] public string? ChangeId { get; init; } + [JsonPropertyName("feedback")] public string? Feedback { get; init; } + [JsonPropertyName("changes")] public IReadOnlyList? Changes { get; init; } +} + +internal sealed record ExportRequest +{ + [JsonPropertyName("session_id")] public string? SessionId { get; init; } + [JsonPropertyName("html")] public string? Html { get; init; } + [JsonPropertyName("format")] public required string Format { get; init; } + [JsonPropertyName("filename")] public string? Filename { get; init; } +} + +internal sealed record UploadBase64Request +{ + [JsonPropertyName("filename")] public required string Filename { get; init; } + + /// + /// Base64 file content. The field is file_base64, NOT content_base64. + /// + /// + /// Guessed wrong first time and only found out by calling the live API, which returned a + /// 422 naming the missing field. A stub built from my own assumption accepted the wrong + /// shape happily — the exact failure mode of testing against mocks that encode what you + /// believe rather than what the server does. + /// + [JsonPropertyName("file_base64")] public required string FileBase64 { get; init; } + + [JsonPropertyName("session_id")] public string? SessionId { get; init; } + + /// Ask for the parsed HTML back, so a caller can edit without a second fetch. + [JsonPropertyName("return_html")] public bool ReturnHtml { get; init; } = true; +} + +/// Envelope returned by GET /v1/sessions/{id}/jobs. +/// +/// The endpoint returns an OBJECT with a "jobs" array, not a bare array. Modelling it as a +/// list deserialised to nothing and made a wedged session look empty. +/// +internal sealed record SessionJobsResponse +{ + [JsonPropertyName("jobs")] public IReadOnlyList Jobs { get; init; } = []; + [JsonPropertyName("total")] public int Total { get; init; } +} + +internal sealed record ApiErrorBody +{ + [JsonPropertyName("detail")] public JsonElement Detail { get; init; } + [JsonPropertyName("message")] public string? Message { get; init; } + [JsonPropertyName("error")] public string? Error { get; init; } +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocsOptions.cs b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocsOptions.cs new file mode 100644 index 0000000..3cc93dd --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/SuperDocsOptions.cs @@ -0,0 +1,78 @@ +using System.ComponentModel.DataAnnotations; + +namespace SuperDocs.Client; + +/// +/// Configuration for . +/// +/// +/// Deliberately small. There is no retry count, no backoff curve, no circuit-breaker +/// threshold and no telemetry switch, because those belong to the host: an application that +/// has already decided how it retries should not have a second, different opinion smuggled +/// in by a client library. Add AddStandardResilienceHandler() to the returned builder +/// and the whole application shares one policy. +/// +public sealed class SuperDocsOptions +{ + /// Configuration section name used by AddSuperDocs(IConfiguration). + public const string SectionName = "SuperDocs"; + + /// Your API key. Starts with sk_. + [Required(AllowEmptyStrings = false, ErrorMessage = + "SuperDocs:ApiKey is required. Set it in configuration, or via the SUPERDOCS_API_KEY " + + "environment variable. Never commit it.")] + public string ApiKey { get; set; } = string.Empty; + + /// API base address. + /// + /// Defaulted here because the published OpenAPI document has no servers block, so + /// a generated client comes out with no base address and fails at the first call with a + /// relative-URI error that does not point back at the spec. + /// + [Required] + public Uri BaseAddress { get; set; } = new("https://api.superdocs.app/"); + + /// + /// How long to wait for a single HTTP response. + /// + /// + /// Five minutes, which is far longer than a typical HTTP default and is deliberate: + /// operations on large documents, or runs at the deepest model settings, legitimately + /// take from thirty seconds to several minutes with no interim output. A 100-second + /// default would turn "still working" into a spurious timeout. + /// + /// Prefer cancelling through a over shortening this. + /// + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5); + + /// How often WaitForApprovalAsync and WaitForCompletionAsync poll. + public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(2); + + /// + /// Value sent as User-Agent. Identifying your application helps when you need + /// support to find your requests. + /// + public string? UserAgent { get; set; } + + internal void Validate() + { + if (string.IsNullOrWhiteSpace(ApiKey)) + { + throw new InvalidOperationException( + "SuperDocs:ApiKey is not configured. Set SuperDocs:ApiKey in configuration or " + + "the SUPERDOCS_API_KEY environment variable, then register with " + + "services.AddSuperDocs(configuration)."); + } + + if (!ApiKey.StartsWith("sk_", StringComparison.Ordinal)) + { + // A wrong-shaped key otherwise surfaces as an opaque 401 on the first real call, + // usually far from the configuration mistake that caused it. + throw new InvalidOperationException( + $"SuperDocs:ApiKey does not look like an API key (expected it to start with " + + $"'sk_', got '{ApiKey[..Math.Min(3, ApiKey.Length)]}...'). Check you have not " + + $"pasted a session token or a placeholder."); + } + } +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/TolerantEnumConverter.cs b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/TolerantEnumConverter.cs new file mode 100644 index 0000000..91fdd23 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/src/SuperDocs.Client/TolerantEnumConverter.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SuperDocs.Client; + +/// +/// Deserialises string enums, mapping anything unrecognised to the zero value. +/// +/// +/// The built-in throws on an unknown value. In this +/// SDK that failure is much worse than it first appears: an unknown operation makes the +/// whole fail to deserialise, the surrounding parse is caught, +/// and the change simply DISAPPEARS from the review. A reviewer would approve a batch without +/// ever being shown one of its edits. +/// +/// So an unrecognised value becomes Unknown and the rest of the object survives. +/// Degrading to "there is a change here and I do not recognise its type" is strictly better +/// than degrading to silence — the change is still visible, still has its before-and-after +/// HTML, and a human can still judge it. +/// +/// +/// Writing is unaffected: values round-trip using their declared names. +/// +/// +/// The enum type. Its zero value is used for unknown input. +internal sealed class TolerantEnumConverter< + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers( + System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicFields)] TEnum> + : JsonConverter + where TEnum : struct, Enum +{ + // The annotation tells the trimmer to keep this enum's fields. Without it the wire-name + // lookup silently finds nothing in a trimmed build, every value deserialises to Unknown, + // and the failure appears only in production — which is precisely the class of bug the + // trim analyzer exists to prevent, so suppressing it rather than annotating would have + // traded a build error for a runtime one. + private static readonly Dictionary ByName = BuildLookup(); + private static readonly Dictionary ByValue = BuildReverse(); + + public override TEnum Read( + ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return default; + } + + if (reader.TokenType == JsonTokenType.Number) + { + // Numeric enums are not used by this API, but tolerating them costs nothing. + return reader.TryGetInt32(out int number) && Enum.IsDefined(typeof(TEnum), number) + ? (TEnum)Enum.ToObject(typeof(TEnum), number) + : default; + } + + string? text = reader.GetString(); + if (string.IsNullOrWhiteSpace(text)) + { + return default; + } + + return ByName.TryGetValue(text, out TEnum value) ? value : default; + } + + public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + writer.WriteStringValue( + ByValue.TryGetValue(value, out string? name) ? name : value.ToString().ToUpperInvariant()); + } + + private static Dictionary BuildLookup() + { + Dictionary map = new(StringComparer.OrdinalIgnoreCase); + foreach (TEnum value in Enum.GetValues()) + { + map[WireName(value)] = value; + map[value.ToString()] = value; + } + return map; + } + + private static Dictionary BuildReverse() + { + Dictionary map = []; + foreach (TEnum value in Enum.GetValues()) + { + map[value] = WireName(value); + } + return map; + } + + private static string WireName(TEnum value) + { + System.Reflection.FieldInfo? field = typeof(TEnum).GetField(value.ToString()); + JsonStringEnumMemberNameAttribute? attribute = + field?.GetCustomAttributes(typeof(JsonStringEnumMemberNameAttribute), false) + .OfType() + .FirstOrDefault(); + + return attribute?.Name ?? value.ToString(); + } +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/tests/SuperDocs.Client.Tests/ClientBehaviourTests.cs b/extensions/aayushmishraaa/superdocs-dotnet/tests/SuperDocs.Client.Tests/ClientBehaviourTests.cs new file mode 100644 index 0000000..62e3759 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/tests/SuperDocs.Client.Tests/ClientBehaviourTests.cs @@ -0,0 +1,379 @@ +using System.Net; +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace SuperDocs.Client.Tests; + +public class DependencyInjectionTests +{ + [Fact] + public void AddSuperDocs_RegistersAWorkingClient() + { + ServiceCollection services = new(); + services.AddSuperDocs(o => o.ApiKey = "sk_test_key"); + + using ServiceProvider provider = services.BuildServiceProvider(); + ISuperDocsClient client = provider.GetRequiredService(); + + Assert.NotNull(client); + } + + [Fact] + public void AddSuperDocs_UsesHttpClientFactory() + { + // The card's central requirement: pooling, resilience and telemetry come from the + // host. If the client were constructing its own HttpClient, the factory would have + // no registration for it and this would be absent. + ServiceCollection services = new(); + services.AddSuperDocs(o => o.ApiKey = "sk_test_key"); + + using ServiceProvider provider = services.BuildServiceProvider(); + IHttpClientFactory factory = provider.GetRequiredService(); + + Assert.NotNull(factory); + } + + [Fact] + public void AddSuperDocs_ReturnsBuilderSoTheHostCanAttachItsOwnPolicies() + { + ServiceCollection services = new(); + IHttpClientBuilder builder = services.AddSuperDocs(o => o.ApiKey = "sk_test_key"); + + // This is what makes AddStandardResilienceHandler() possible at the call site. + Assert.NotNull(builder); + Assert.Equal("SuperDocs", builder.Name); + } + + [Fact] + public void AddSuperDocs_ReadsConfiguration() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["SuperDocs:ApiKey"] = "sk_from_config", + ["SuperDocs:BaseAddress"] = "https://example.invalid/", + ["SuperDocs:PollInterval"] = "00:00:05", + }) + .Build(); + + ServiceCollection services = new(); + services.AddSuperDocs(configuration); + + using ServiceProvider provider = services.BuildServiceProvider(); + SuperDocsOptions options = provider.GetRequiredService>().Value; + + Assert.Equal("sk_from_config", options.ApiKey); + Assert.Equal(new Uri("https://example.invalid/"), options.BaseAddress); + Assert.Equal(TimeSpan.FromSeconds(5), options.PollInterval); + } + + [Fact] + public void MissingApiKey_FailsAtStartupNotAtFirstCall() + { + // A misconfiguration discovered during a nightly batch is far worse than a failed + // boot, so ValidateOnStart is doing real work here. + ServiceCollection services = new(); + services.AddSuperDocs(o => o.ApiKey = ""); + + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Throws( + () => provider.GetRequiredService>().Value); + } + + [Fact] + public void WrongShapedKey_IsRejectedWithAnActionableMessage() + { + SuperDocsOptions options = new() { ApiKey = "not-a-real-key" }; + InvalidOperationException error = + Assert.Throws(options.Validate); + + Assert.Contains("sk_", error.Message, StringComparison.Ordinal); + } +} + +public class AsyncDisciplineTests +{ + [Fact] + public void EveryPublicMethod_IsAsyncAndAcceptsCancellation() + { + // The card asks for "no synchronous-over-asynchronous deadlock traps". The strongest + // version of that is having no synchronous surface to misuse in the first place. + List offenders = []; + + foreach (MethodInfo method in typeof(ISuperDocsClient).GetMethods()) + { + bool returnsAwaitable = + typeof(Task).IsAssignableFrom(method.ReturnType) + || method.ReturnType.Name.StartsWith("IAsyncEnumerable", StringComparison.Ordinal); + + if (!returnsAwaitable) + { + offenders.Add($"{method.Name} is synchronous"); + continue; + } + + if (method.GetParameters().All(p => p.ParameterType != typeof(CancellationToken))) + { + offenders.Add($"{method.Name} takes no CancellationToken"); + } + } + + Assert.Empty(offenders); + } + + [Fact] + public void SourceContainsNoBlockingCalls() + { + // A structural guard against the deadlock pattern being reintroduced. The analyzer + // catches missing ConfigureAwait; nothing catches somebody adding `.Result` later. + Assembly assembly = typeof(SuperDocsClient).Assembly; + Type[] types = assembly.GetTypes(); + + List offenders = types + .SelectMany(static t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + .Where(static p => p.Name == "Result" && p.DeclaringType?.Namespace == "SuperDocs.Client") + .Select(static p => $"{p.DeclaringType!.Name}.{p.Name}") + .ToList(); + + Assert.Empty(offenders); + } + + [Fact] + public async Task Cancellation_PropagatesRatherThanBeingSwallowed() + { + StubHandler handler = StubHandler.Json(Payloads.Completed); + ISuperDocsClient client = BuildClient(handler); + + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + () => client.GetJobAsync("j1", cts.Token)); + } + + internal static ISuperDocsClient BuildClient(StubHandler handler, string key = "sk_test") + { + HttpClient http = new(handler) { BaseAddress = new Uri("https://api.superdocs.app/") }; + return new SuperDocsClient(http, Options.Create(new SuperDocsOptions + { + ApiKey = key, + PollInterval = TimeSpan.FromMilliseconds(1), + })); + } +} + +public class ApprovalTests +{ + [Fact] + public async Task Approve_SendsTopLevelApprovedEvenForBatchDecisions() + { + // Omitting this is rejected with a 422 that says nothing useful, and the docs do not + // mention it. Pinned so a future refactor cannot quietly drop it. + StubHandler handler = StubHandler.Json(Payloads.ApprovalOk); + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + + await client.ApproveAsync("s1", "j1", + [ + ApprovalDecision.Approve("c1"), + ApprovalDecision.Reject("c2", "wrong section"), + ]); + + string body = Assert.Single(handler.Bodies); + Assert.Contains("\"approved\":true", body, StringComparison.Ordinal); + Assert.Contains("\"changes\"", body, StringComparison.Ordinal); + Assert.Contains("wrong section", body, StringComparison.Ordinal); + } + + [Fact] + public async Task Approve_PreservesPerChangeVerdicts() + { + // The top-level `approved: true` must NOT be read as approving everything — the + // per-change verdicts govern, and a rejection has to survive the round trip. + StubHandler handler = StubHandler.Json(Payloads.ApprovalOk); + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + + await client.ApproveAsync("s1", "j1", + [ + ApprovalDecision.Approve("c1"), + ApprovalDecision.Reject("c2"), + ]); + + string body = Assert.Single(handler.Bodies); + Assert.Contains("\"change_id\":\"c2\",\"approved\":false", body, StringComparison.Ordinal); + } + + [Fact] + public async Task Approve_WithNoDecisions_RefusesToGuess() + { + StubHandler handler = StubHandler.Json(Payloads.ApprovalOk); + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + + ArgumentException error = await Assert.ThrowsAsync( + () => client.ApproveAsync("s1", "j1", [])); + + Assert.Contains("different outcomes", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void NoApproveAllConvenienceExists() + { + // A review that can be passed with one keystroke is not a review. + Assert.DoesNotContain( + typeof(ISuperDocsClient).GetMethods(), + static m => m.Name.Contains("All", StringComparison.OrdinalIgnoreCase)); + } +} + +public class ErrorMessageTests +{ + [Theory] + [InlineData(HttpStatusCode.Unauthorized, "SuperDocs:ApiKey")] + [InlineData(HttpStatusCode.NotFound, "session or job id")] + [InlineData(HttpStatusCode.TooManyRequests, "Back off")] + [InlineData(HttpStatusCode.RequestEntityTooLarge, "presigned upload")] + public async Task Errors_NameTheCauseAndTheFix(HttpStatusCode status, string expected) + { + StubHandler handler = StubHandler.Json("""{"detail":"nope"}""", status); + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + + SuperDocsException error = + await Assert.ThrowsAsync(() => client.GetJobAsync("j1")); + + Assert.Contains(expected, error.Message, StringComparison.Ordinal); + Assert.Equal(status, error.StatusCode); + } + + [Fact] + public async Task ValidationErrors_LeadWithTheServersOwnDetail() + { + // A canned hint printed ahead of the facts is worse than no hint. On a real upload + // failure the message advised checking an approval field, while the server's body — + // naming the actually-missing field — sat unread underneath it. + StubHandler handler = StubHandler.Json( + """{"detail":[{"loc":["body","file_base64"],"msg":"Field required"}]}""", + HttpStatusCode.UnprocessableEntity); + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + + SuperDocsException error = + await Assert.ThrowsAsync(() => client.GetJobAsync("j1")); + + Assert.Contains("file_base64", error.Message, StringComparison.Ordinal); + Assert.DoesNotContain("top-level 'approved'", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ValidationErrors_FallBackToTheHintWhenTheServerSaysNothing() + { + StubHandler handler = StubHandler.Json("", HttpStatusCode.UnprocessableEntity); + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + + SuperDocsException error = + await Assert.ThrowsAsync(() => client.GetJobAsync("j1")); + + Assert.Contains("top-level 'approved'", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task TransientErrors_AreFlaggedForTheHostsRetryPolicy() + { + StubHandler handler = StubHandler.Json("{}", HttpStatusCode.ServiceUnavailable); + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + + SuperDocsException error = + await Assert.ThrowsAsync(() => client.GetJobAsync("j1")); + + Assert.True(error.IsTransient); + } + + [Fact] + public async Task PermanentErrors_AreNotFlaggedAsTransient() + { + StubHandler handler = StubHandler.Json("{}", HttpStatusCode.Unauthorized); + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + + SuperDocsException error = + await Assert.ThrowsAsync(() => client.GetJobAsync("j1")); + + Assert.False(error.IsTransient); + } +} + +public class WorkflowTests +{ + [Fact] + public async Task WaitForApproval_StopsWhenAHumanIsNeeded() + { + StubHandler handler = StubHandler.Json(Payloads.AwaitingApproval); + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + + Job job = await client.WaitForApprovalAsync("j1"); + + Assert.True(job.IsAwaitingApproval); + Assert.Single(job.Metadata.PendingChanges); + } + + [Fact] + public async Task FailedJob_ThrowsRatherThanReturningQuietly() + { + const string failed = + """{"job_id":"j1","session_id":"s1","status":"failed","error":"model unavailable","metadata":{}}"""; + StubHandler handler = StubHandler.Json(failed); + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + + SuperDocsException error = + await Assert.ThrowsAsync(() => client.WaitForCompletionAsync("j1")); + + Assert.Contains("model unavailable", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task StartEdit_RequestsHumanReviewByDefault() + { + StubHandler handler = StubHandler.Json(Payloads.JobCreated); + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + + await client.StartEditAsync("s1", "change the terms"); + + Assert.Contains("ask_every_time", Assert.Single(handler.Bodies), StringComparison.Ordinal); + } + + [Fact] + public async Task Export_UsesTheServersFileName() + { + StubHandler handler = new(_ => + { + HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = new ByteArrayContent([0x50, 0x4B, 0x03, 0x04]), + }; + response.Content.Headers.ContentDisposition = + new System.Net.Http.Headers.ContentDispositionHeaderValue("attachment") + { + FileName = "\"Vendor Services Agreement.docx\"", + }; + return response; + }); + + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + ExportedFile file = await client.ExportAsync("s1"); + + Assert.Equal("Vendor Services Agreement.docx", file.FileName); + Assert.Equal(4, file.Length); + } + + [Fact] + public async Task AccountStatus_ReportsRemainingQuota() + { + StubHandler handler = StubHandler.Json(Payloads.Whoami); + ISuperDocsClient client = AsyncDisciplineTests.BuildClient(handler); + + AccountStatus status = await client.GetAccountStatusAsync(); + + Assert.Equal("free", status.Tier); + Assert.Equal(488, status.Quota.Remaining); + } +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/tests/SuperDocs.Client.Tests/DoubleEncodingTests.cs b/extensions/aayushmishraaa/superdocs-dotnet/tests/SuperDocs.Client.Tests/DoubleEncodingTests.cs new file mode 100644 index 0000000..0fe47d1 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/tests/SuperDocs.Client.Tests/DoubleEncodingTests.cs @@ -0,0 +1,128 @@ +using Xunit; + +namespace SuperDocs.Client.Tests; + +/// +/// The headline behaviour: proposed changes decode correctly whichever path they arrive on. +/// +/// +/// The API encodes the same data two ways. metadata.pending_changes returns real +/// objects; intermediate_responses[].content and the SSE stream return a JSON-encoded +/// string needing a second parse. Guidance describing the second parse as universal is true +/// of one path and false of the other, so an integrator following it breaks in one direction +/// and an integrator ignoring it breaks in the other. +/// +/// These tests pin both directions, because "handles the double encoding" is only worth +/// anything if it also does NOT double-parse the path that must not be double-parsed. +/// +/// +public class DoubleEncodingTests +{ + [Fact] + public void EncodedStringContent_IsDecoded() + { + const string encoded = + """{"type":"single_approval","batch_total":1,"changes":[{"change_id":"c1","operation":"edit","old_html":"

net 30

","new_html":"

net 45

"}]}"""; + + // As it arrives on the wire: a JSON string literal whose VALUE is JSON. + string wire = System.Text.Json.JsonSerializer.Serialize(encoded); + + IReadOnlyList changes = ProposedChangeBatch.ParseContent(wire); + + ProposedChange change = Assert.Single(changes); + Assert.Equal("c1", change.ChangeId); + Assert.Equal(ChangeOperation.Edit, change.Operation); + Assert.Equal("

net 45

", change.NewHtml); + } + + [Fact] + public void PlainObjectContent_IsAlsoAccepted() + { + // The same payload NOT double-encoded. Must work identically, so a server-side fix + // to emit real objects does not break every consumer of this SDK. + const string plain = + """{"type":"batch","changes":[{"change_id":"c1","operation":"edit","new_html":"

x

"}]}"""; + + ProposedChange change = Assert.Single(ProposedChangeBatch.ParseContent(plain)); + Assert.Equal("c1", change.ChangeId); + } + + [Fact] + public void BareArray_IsAccepted() + { + const string array = """[{"change_id":"c1","operation":"delete","old_html":"

x

"}]"""; + ProposedChange change = Assert.Single(ProposedChangeBatch.ParseContent(array)); + Assert.Equal(ChangeOperation.Delete, change.Operation); + } + + [Fact] + public void SingleUnwrappedChange_IsAccepted() + { + const string single = """{"change_id":"c9","operation":"create","new_html":"

new

"}"""; + ProposedChange change = Assert.Single(ProposedChangeBatch.ParseContent(single)); + Assert.Equal("c9", change.ChangeId); + Assert.Equal(ChangeOperation.Create, change.Operation); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not json at all")] + [InlineData("{\"unexpected\":\"shape\"}")] + public void UnparseableContent_YieldsEmptyRatherThanThrowing(string? content) + { + // A malformed progress frame must not abort a review in flight. The authoritative + // list is always Job.Metadata.PendingChanges. + Assert.Empty(ProposedChangeBatch.ParseContent(content)); + } + + [Fact] + public void PendingChanges_AreNotDoubleParsed() + { + // The bug in the other direction, and the one the documentation actively encourages: + // applying the second parse to the polling path, which returns real objects. + Job job = System.Text.Json.JsonSerializer.Deserialize( + Payloads.AwaitingApproval, JsonOptions.Value)!; + + ProposedChange change = Assert.Single(job.Metadata.PendingChanges); + Assert.Equal("97ca364e-8ebb-4d19-8cac-ae8812663caf", change.ChangeId); + Assert.Equal("

Payment terms are net 45 days from invoice date.

", change.NewHtml); + Assert.Equal(ChangeOperation.Edit, change.Operation); + } + + [Fact] + public void BothPathsAgreeOnTheSameChange() + { + // The property that actually matters to a consumer: it should not be possible to + // tell which path a change arrived on. + Job job = System.Text.Json.JsonSerializer.Deserialize( + Payloads.AwaitingApproval, JsonOptions.Value)!; + + ProposedChange fromPolling = Assert.Single(job.Metadata.PendingChanges); + ProposedChange fromStream = + Assert.Single(Assert.Single(job.Metadata.IntermediateResponses).Changes); + + Assert.Equal(fromPolling.ChangeId, fromStream.ChangeId); + Assert.Equal(fromPolling.NewHtml, fromStream.NewHtml); + Assert.Equal(fromPolling.OldHtml, fromStream.OldHtml); + Assert.Equal(fromPolling.Operation, fromStream.Operation); + } + + [Fact] + public void UnknownOperation_DoesNotThrow() + { + // A server adding an operation must not break existing consumers. + const string future = """[{"change_id":"c1","operation":"transmogrify"}]"""; + ProposedChange change = Assert.Single(ProposedChangeBatch.ParseContent(future)); + Assert.Equal(ChangeOperation.Unknown, change.Operation); + } +} + +internal static class JsonOptions +{ + public static readonly System.Text.Json.JsonSerializerOptions Value = new() + { + PropertyNameCaseInsensitive = true, + }; +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/tests/SuperDocs.Client.Tests/StubHandler.cs b/extensions/aayushmishraaa/superdocs-dotnet/tests/SuperDocs.Client.Tests/StubHandler.cs new file mode 100644 index 0000000..67adfca --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/tests/SuperDocs.Client.Tests/StubHandler.cs @@ -0,0 +1,102 @@ +using System.Net; +using System.Text; + +namespace SuperDocs.Client.Tests; + +/// +/// Serves canned responses so the whole suite runs with no API key and no network. +/// +/// +/// The payloads here are copied from REAL responses observed against the live API, including +/// the awkward parts — most importantly the fact that the same proposed changes come back as +/// objects on the polling path and as a JSON-encoded string on the streaming path. A stub +/// built from what the docs imply rather than what the server sends would let the tests agree +/// with my assumptions instead of with reality. +/// +internal sealed class StubHandler : HttpMessageHandler +{ + private readonly Func _respond; + + public List Requests { get; } = []; + public List Bodies { get; } = []; + + public StubHandler(Func respond) + => _respond = respond; + + public static StubHandler Json(string body, HttpStatusCode status = HttpStatusCode.OK) + => new(_ => new HttpResponseMessage(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + if (request.Content is not null) + { + Bodies.Add(await request.Content.ReadAsStringAsync(cancellationToken)); + } + + // Honour cancellation, so cancellation tests exercise the real path rather than + // completing before anyone can cancel them. + cancellationToken.ThrowIfCancellationRequested(); + return _respond(request); + } +} + +internal static class Payloads +{ + /// + /// A job awaiting approval, verbatim in shape from a real run. + /// + /// + /// Note the two encodings of the SAME change: metadata.pending_changes holds real + /// objects, while intermediate_responses[].content holds a JSON-encoded string. + /// + public const string AwaitingApproval = """ + { + "job_id": "94d087b7-20ab-437c-953d-df88fe84dac2", + "session_id": "s1", + "status": "awaiting_approval", + "progress": 88, + "metadata": { + "message": "Change the payment terms from net 30 to net 45 days.", + "pending_changes": [ + { + "change_id": "97ca364e-8ebb-4d19-8cac-ae8812663caf", + "operation": "edit", + "chunk_id": "70cdd20e-e8bd-476d-8500-5fda21c4df94", + "document_id": "doc_primary", + "old_html": "

Payment terms are net 30 days from invoice date.

", + "new_html": "

Payment terms are net 45 days from invoice date.

", + "ai_explanation": "I have updated the payment terms from net 30 to net 45 days." + } + ], + "intermediate_responses": [ + { + "type": "proposed_change_batch", + "sequence": 4, + "content": "{\"type\": \"single_approval\", \"batch_id\": \"97ca364e\", \"batch_total\": 1, \"changes\": [{\"change_id\": \"97ca364e-8ebb-4d19-8cac-ae8812663caf\", \"operation\": \"edit\", \"old_html\": \"

Payment terms are net 30 days from invoice date.

\", \"new_html\": \"

Payment terms are net 45 days from invoice date.

\"}]}" + } + ] + } + } + """; + + public const string Completed = """ + {"job_id":"j1","session_id":"s1","status":"completed","progress":100,"metadata":{}} + """; + + public const string ApprovalOk = """ + {"status":"ok","message":"Approval processed","batch_complete":true} + """; + + public const string JobCreated = """ + {"job_id":"j1","session_id":"s1","status":"pending","message":"queued"} + """; + + public const string Whoami = """ + {"account_id":"a1","tier":"free","quota":{"monthly_limit":500,"used":12,"remaining":488}} + """; +} diff --git a/extensions/aayushmishraaa/superdocs-dotnet/tests/SuperDocs.Client.Tests/SuperDocs.Client.Tests.csproj b/extensions/aayushmishraaa/superdocs-dotnet/tests/SuperDocs.Client.Tests/SuperDocs.Client.Tests.csproj new file mode 100644 index 0000000..2cb39b5 --- /dev/null +++ b/extensions/aayushmishraaa/superdocs-dotnet/tests/SuperDocs.Client.Tests/SuperDocs.Client.Tests.csproj @@ -0,0 +1,40 @@ + + + + + net10.0 + false + true + false + + + false + false + false + + + $(NoWarn);CS1591;CA1707;CA2007;CA2000;CA1861;CA5394;CA1515 + + + + + + + + + + + + + + + +