Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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/*
5 changes: 5 additions & 0 deletions extensions/aayushmishraaa/superdocs-dotnet/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
bin/
obj/
artifacts/
samples/SuperDocs.Worker.Sample/inbox/
samples/SuperDocs.Worker.Sample/outbox/
37 changes: 37 additions & 0 deletions extensions/aayushmishraaa/superdocs-dotnet/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<Project>

<PropertyGroup>
<!-- Current LTS and the previous one. .NET 10 is supported to Nov 2028; .NET 8 to
Nov 10 2026. Multi-targeting rather than dropping net8.0 because enterprise
Microsoft-stack teams — the audience for this SDK — are frequently a release
behind, and that is exactly who cannot upgrade on our schedule. -->
<TargetFrameworks>net10.0;net8.0</TargetFrameworks>

<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>

<!-- Analyzer-clean is enforced, not aspired to. Warnings-as-errors is what makes the
claim mean something in CI. -->
<AnalysisLevel>latest-all</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsNotAsErrors></WarningsNotAsErrors>

<!-- Trim/AOT friendliness. Source-generated JSON means no reflection-based
serialization, so a trimmed or AOT-published host keeps working. -->
<IsTrimmable Condition="'$(TargetFramework)' != 'net8.0'">true</IsTrimmable>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<IsAotCompatible Condition="'$(TargetFramework)' != 'net8.0'">true</IsAotCompatible>

<!-- Deterministic, reproducible builds. ContinuousIntegrationBuild normalises the
paths embedded in the PDB so two builds of the same commit produce identical
output. -->
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(GITHUB_ACTIONS)' == 'true'">true</ContinuousIntegrationBuild>

<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CA1848;CA1031</NoWarn>
</PropertyGroup>

</Project>
183 changes: 183 additions & 0 deletions extensions/aayushmishraaa/superdocs-dotnet/README.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions extensions/aayushmishraaa/superdocs-dotnet/SuperDocs.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Solution>
<Folder Name="/samples/">
<Project Path="samples/SuperDocs.Worker.Sample/SuperDocs.Worker.Sample.csproj" />
</Folder>
<Folder Name="/src/">
<Project Path="src/SuperDocs.Client/SuperDocs.Client.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SuperDocs.Client.Tests/SuperDocs.Client.Tests.csproj" />
</Folder>
</Solution>
Loading