From b15e4b05ebf7b4b164e054d604531dde01a1aa24 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Mon, 30 Mar 2026 15:55:38 +0900 Subject: [PATCH 1/4] Update SKILL.md --- skills/carpanet/SKILL.md | 303 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) diff --git a/skills/carpanet/SKILL.md b/skills/carpanet/SKILL.md index 769e85c..81df525 100644 --- a/skills/carpanet/SKILL.md +++ b/skills/carpanet/SKILL.md @@ -609,3 +609,306 @@ dotnet publish -c Release ``` No additional configuration needed — the generated `ATProtoJsonContext` and `ATProtoCborContext` handle all serialization at compile time. + +--- + +## Migrating from FishyFlip + +This section helps users transition from [FishyFlip](https://github.com/drasticactions/FishyFlip) to CarpaNet. Both libraries target ATProtocol but differ significantly in architecture and API design. + +### Package Mapping + +| FishyFlip | CarpaNet | Notes | +|-----------|----------|-------| +| `FishyFlip` | `CarpaNet` | Core library | +| `FishyFlip` (built-in) | `CarpaNet.Jetstream` | Jetstream is a separate package in CarpaNet | +| `FishyFlip` (built-in) | `CarpaNet.OAuth` | OAuth is a separate package in CarpaNet | +| `FishyFlip.AspNetCore` | *(not yet available)* | ASP.NET Core integration | + +### Lexicon Declaration (New Concept) + +FishyFlip bundles all Bluesky lexicons — every API method is available immediately. CarpaNet requires you to **declare which lexicons you need** in your `.csproj`. The source generator then produces only the types and methods you use: + +```xml + + + + + + + + +``` + +To pull in all lexicons from Bluesky at once, use authority resolution: + +```xml + + + +``` + +### Client Creation + +**FishyFlip** uses a builder pattern that creates an `ATProtocol` instance: + +```csharp +// FishyFlip +var protocol = new ATProtocolBuilder() + .WithInstanceUrl(new Uri("https://bsky.social")) + .WithUserAgent("MyApp/1.0") + .WithLogger(logger) + .EnableAutoRenewSession(true) + .Build(); +``` + +**CarpaNet** uses static factory methods or constructor with options. A source-generated `ATProtoClientFactory` provides pre-configured JSON/CBOR contexts: + +```csharp +// CarpaNet — unauthenticated (public AppView) +var client = ATProtoClientFactory.Create(); + +// CarpaNet — with app password +var client = await ATProtoClient.CreateWithSessionAsync( + identifier: "alice.bsky.social", + password: "xxxx-xxxx-xxxx-xxxx", + options: new ATProtoClientOptions + { + JsonOptions = ATProtoJsonContext.DefaultOptions, + CborContext = ATProtoCborContext.Default, + }); + +// CarpaNet — full options +var client = ATProtoClientFactory.Create(new ATProtoClientOptions +{ + UserAgent = "MyApp/1.0", + LoggerFactory = loggerFactory, + EnableRateLimitHandler = true, + AutoRetryOnAuthFailure = true, +}); +``` + +### API Call Patterns + +**FishyFlip** organizes methods into endpoint groups (`protocol.Actor`, `protocol.Feed`, etc.): + +```csharp +// FishyFlip — endpoint groups +var (profile, error) = await protocol.Actor.GetProfileAsync( + ATHandle.Create("alice.bsky.social")); + +var (timeline, error) = await protocol.Feed.GetTimelineAsync(limit: 25); + +var (result, error) = await protocol.Feed.CreatePostAsync(post); +``` + +**CarpaNet** uses flat extension methods on `IATProtoClient`, named after the NSID: + +```csharp +// CarpaNet — extension methods +var profile = await client.AppBskyActorGetProfileAsync( + new AppBsky.Actor.GetProfileParameters { Actor = new ATHandle("alice.bsky.social") }); + +var timeline = await client.AppBskyFeedGetTimelineAsync( + new AppBsky.Feed.GetTimelineParameters { Limit = 25 }); + +var result = await client.ComAtprotoRepoCreateRecordAsync( + new ComAtproto.Repo.CreateRecordInput + { + Repo = new ATIdentifier(client.AuthenticatedDid!), + Collection = AppBsky.Feed.Post.RecordType, + Record = post.ToJson(), + }); +``` + +Key differences: +- CarpaNet uses **parameter/input record objects** instead of method parameters +- Method names follow the NSID pattern: `AppBskyActorGetProfileAsync` = `app.bsky.actor.getProfile` +- Creating records is done via the generic `ComAtprotoRepoCreateRecordAsync` with `Record = post.ToJson()`; FishyFlip provides typed helpers like `CreatePostAsync` + +### Error Handling + +**FishyFlip** uses a `Result` type (OneOf-based) with tuple deconstruction: + +```csharp +// FishyFlip +var (profile, error) = await protocol.Actor.GetProfileAsync(handle); +if (error is not null) +{ + Console.WriteLine($"Error: {error.Detail?.Message}"); + return; +} +Console.WriteLine(profile!.DisplayName); +``` + +**CarpaNet** throws exceptions on failure — use standard try/catch: + +```csharp +// CarpaNet +try +{ + var profile = await client.AppBskyActorGetProfileAsync( + new AppBsky.Actor.GetProfileParameters { Actor = handle }); + Console.WriteLine(profile.DisplayName); +} +catch (ATProtoException ex) +{ + Console.WriteLine($"Error: {ex.Message}"); +} +``` + +### Authentication + +#### App Password + +```csharp +// FishyFlip +var protocol = new ATProtocolBuilder().Build(); +var (session, error) = await protocol.AuthenticateWithPasswordResultAsync( + "alice.bsky.social", "xxxx-xxxx-xxxx-xxxx"); + +// CarpaNet +var client = await ATProtoClient.CreateWithSessionAsync( + identifier: "alice.bsky.social", + password: "xxxx-xxxx-xxxx-xxxx", + options: new ATProtoClientOptions + { + JsonOptions = ATProtoJsonContext.DefaultOptions, + CborContext = ATProtoCborContext.Default, + }); +``` + +#### OAuth 2.0 + +```csharp +// FishyFlip — OAuth is on ATProtocol directly +var protocol = new ATProtocolBuilder().Build(); +var (authUrl, error) = await protocol.GenerateOAuth2AuthenticationUrlResultAsync( + clientId: "http://localhost", + redirectUrl: "http://localhost:3000/callback", + scopes: new[] { "atproto" }, + instanceUrl: "https://bsky.social"); +// ... user completes browser login ... +var (session, error) = await protocol.AuthenticateWithOAuth2CallbackResultAsync(callbackUrl); + +// CarpaNet — OAuth is a separate OAuthSession class +using var oauthSession = new OAuthSession(new OAuthClientConfig +{ + ClientId = OAuthClientConfig.CreateLoopbackClientId(8080), + RedirectUri = OAuthClientConfig.CreateLoopbackRedirectUri(8080), + Scope = "atproto transition:generic", + JsonOptions = ATProtoJsonContext.DefaultOptions, + SessionStore = new MemoryOAuthSessionStore(), +}); +var authUrl = await oauthSession.AuthorizeAsync("alice.bsky.social"); +// ... user completes browser login ... +ATProtoOAuthClient atClient = await oauthSession.CallbackAsync(callbackUrl); +``` + +#### Session Persistence + +```csharp +// FishyFlip — serialize AuthSession to string +var saved = await protocol.RefreshAuthSessionResultAsync(); +File.WriteAllText("session.json", saved.ToString()); +// Restore: +var restored = AuthSession.FromString(File.ReadAllText("session.json")); +await protocol.AuthenticateWithOAuth2SessionResultAsync(restored, clientId); + +// CarpaNet — implement ISessionStore (password) or IOAuthSessionStore (OAuth) +var client = ATProtoClientFactory.Create(new ATProtoClientOptions +{ + SessionStore = new MyFileSessionStore(), +}); +bool restored = await client.RestoreSessionAsync(userDid); +``` + +### Identity Types + +Both libraries use similar strongly-typed identifiers but with different creation patterns: + +```csharp +// FishyFlip +var did = ATDid.Create("did:plc:abc123"); +var handle = ATHandle.Create("alice.bsky.social"); +var uri = ATUri.Create("at://did:plc:abc123/app.bsky.feed.post/rkey"); +var identifier = ATIdentifier.Create("alice.bsky.social"); + +// CarpaNet — constructors and implicit conversion +var did = new ATDid("did:plc:abc123"); +var handle = new ATHandle("alice.bsky.social"); +var uri = ATUri.Create("did:plc:abc123", "app.bsky.feed.post", "rkey"); +var identifier = new ATIdentifier("alice.bsky.social"); +``` + +### Jetstream (Real-Time Events) + +**FishyFlip** uses an event-based callback model: + +```csharp +// FishyFlip +var jetStream = new ATJetStream(new ATJetStreamOptions +{ + Url = new Uri("wss://jetstream.atproto.tools"), + WantedCollections = new[] { "app.bsky.feed.post" }, +}); +jetStream.OnRecordReceived += (s, e) => { /* handle event */ }; +await jetStream.ConnectAsync(); +``` + +**CarpaNet** uses `IAsyncEnumerable` for a cleaner streaming pattern: + +```csharp +// CarpaNet +using var client = new JetstreamClient( + new Uri("https://jetstream1.us-east.bsky.network")); + +await foreach (var evt in client.SubscribeAsync(new JetstreamSubscribeOptions +{ + WantedCollections = new[] { "app.bsky.feed.post" }, + Compress = true, +})) +{ + if (evt.Kind == "commit" && evt.Commit is { } commit) + Console.WriteLine($"{commit.Operation} {commit.Collection}/{commit.Rkey}"); +} +``` + +### Serialization Context + +FishyFlip manages its `SourceGenerationContext` internally — you don't need to think about it. CarpaNet requires you to pass source-generated contexts explicitly (they are generated per-project based on your declared lexicons): + +```csharp +// CarpaNet — contexts are auto-generated, used via ATProtoClientFactory or manually +var client = ATProtoClientFactory.Create(); // contexts pre-wired + +// Or manually: +var client = ATProtoClient.Create(new ATProtoClientOptions +{ + JsonOptions = ATProtoJsonContext.DefaultOptions, + CborContext = ATProtoCborContext.Default, +}); +``` + +### Quick Reference: Common Operations + +| Operation | FishyFlip | CarpaNet | +|-----------|-----------|----------| +| Get profile | `protocol.Actor.GetProfileAsync(handle)` | `client.AppBskyActorGetProfileAsync(new ... { Actor = handle })` | +| Create post | `protocol.Feed.CreatePostAsync(post)` | `client.ComAtprotoRepoCreateRecordAsync(new ... { Record = post.ToJson() })` | +| Delete post | `protocol.Feed.DeletePostAsync(repo, rkey)` | `client.ComAtprotoRepoDeleteRecordAsync(new ... { Rkey = rkey })` | +| Get timeline | `protocol.Feed.GetTimelineAsync(limit)` | `client.AppBskyFeedGetTimelineAsync(new ... { Limit = limit })` | +| Resolve handle | `protocol.ResolveATIdentifierAsync(handle)` | `resolver.ResolveAsync("alice.bsky.social")` | +| Check auth | `protocol.IsAuthenticated` | `client.IsAuthenticated` | +| Get current DID | `protocol.Session?.Did` | `client.AuthenticatedDid` | + +### Summary of Key Differences + +1. **Lexicon-driven**: CarpaNet generates only the APIs you declare — add lexicons to `.csproj` +2. **No endpoint groups**: Flat extension methods named after NSIDs instead of `protocol.Actor.*` +3. **No Result type**: CarpaNet throws exceptions instead of returning `Result` +4. **Explicit serialization contexts**: You pass `JsonOptions`/`CborContext` (auto-generated per project) +5. **Modular packages**: OAuth and Jetstream are separate NuGet packages +6. **IAsyncEnumerable streams**: Jetstream and firehose use `await foreach` instead of event callbacks +7. **Constructor-based types**: `new ATHandle(...)` instead of `ATHandle.Create(...)` +8. **Generic record operations**: Use `ComAtprotoRepoCreateRecordAsync` with `Record = obj.ToJson()` instead of typed `CreatePostAsync` helpers From 67aa30ff1643d4b901847ee4ef0c75b203b78164 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Mon, 30 Mar 2026 16:22:54 +0900 Subject: [PATCH 2/4] Start docs --- .github/workflows/docs.yml | 51 +++++ .gitignore | 6 +- docs/carpanet/public/main.css | 5 + docs/docfx.json | 52 +++++ docs/docs/authentication.md | 44 ++++ docs/docs/common-operations.md | 99 +++++++++ docs/docs/creating-clients.md | 80 +++++++ docs/docs/custom-lexicons.md | 30 +++ docs/docs/firehose.md | 32 +++ docs/docs/identity-resolution.md | 21 ++ docs/docs/jetstream.md | 52 +++++ docs/docs/migrating-from-fishyflip.md | 302 ++++++++++++++++++++++++++ docs/docs/oauth.md | 112 ++++++++++ docs/docs/project-setup.md | 100 +++++++++ docs/docs/repository-car.md | 26 +++ docs/docs/toc.yml | 14 ++ docs/favicon.png | Bin 0 -> 31615 bytes docs/index.md | 51 +++++ docs/toc.yml | 4 + 19 files changed, 1080 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/carpanet/public/main.css create mode 100644 docs/docfx.json create mode 100644 docs/docs/authentication.md create mode 100644 docs/docs/common-operations.md create mode 100644 docs/docs/creating-clients.md create mode 100644 docs/docs/custom-lexicons.md create mode 100644 docs/docs/firehose.md create mode 100644 docs/docs/identity-resolution.md create mode 100644 docs/docs/jetstream.md create mode 100644 docs/docs/migrating-from-fishyflip.md create mode 100644 docs/docs/oauth.md create mode 100644 docs/docs/project-setup.md create mode 100644 docs/docs/repository-car.md create mode 100644 docs/docs/toc.yml create mode 100644 docs/favicon.png create mode 100644 docs/index.md create mode 100644 docs/toc.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..990a6f6 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,51 @@ +name: Deploy Docs + +on: + push: + branches: [ "main", "develop" ] + paths: + - 'docs/**' + - 'src/**' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + actions: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build-docs: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Install docfx + run: dotnet tool install -g docfx + + - name: Build docs + run: docfx docs/docfx.json + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: 'docs/_site' + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 7802160..10e12b3 100644 --- a/.gitignore +++ b/.gitignore @@ -481,4 +481,8 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp -.serena/ \ No newline at end of file +.serena/ + +# DocFX generated site +docs/_site/ +docs/api/ \ No newline at end of file diff --git a/docs/carpanet/public/main.css b/docs/carpanet/public/main.css new file mode 100644 index 0000000..6e1ed72 --- /dev/null +++ b/docs/carpanet/public/main.css @@ -0,0 +1,5 @@ +#logo +{ + margin-right: 10px; + border-radius: 5px; +} diff --git a/docs/docfx.json b/docs/docfx.json new file mode 100644 index 0000000..1bcecce --- /dev/null +++ b/docs/docfx.json @@ -0,0 +1,52 @@ +{ + "metadata": [ + { + "src": [ + { + "src": "..", + "files": [ + "src/CarpaNet/*.csproj", + "src/CarpaNet.OAuth/*.csproj", + "src/CarpaNet.Jetstream/*.csproj", + "src/CarpaNet.AspNetCore/*.csproj" + ] + } + ], + "dest": "api", + "outputFormat": "apiPage" + } + ], + "build": { + "content": [ + { + "files": [ + "**/*.{md,yml}" + ], + "exclude": [ + "_site/**" + ] + } + ], + "resource": [ + { + "files": [ + "images/**", + "favicon.png" + ] + } + ], + "output": "_site", + "template": [ + "default", + "modern", + "carpanet" + ], + "postProcessors": [ "ExtractSearchIndex" ], + "globalMetadata": { + "_appName": "CarpaNet", + "_appTitle": "CarpaNet", + "_enableSearch": true, + "_appFaviconPath": "favicon.png" + } + } +} diff --git a/docs/docs/authentication.md b/docs/docs/authentication.md new file mode 100644 index 0000000..0a148e2 --- /dev/null +++ b/docs/docs/authentication.md @@ -0,0 +1,44 @@ +# Authentication + +CarpaNet supports two authentication methods: app passwords (for scripts and bots) and [OAuth 2.0](oauth.md) (for user-facing apps). + +## App Password + +The simplest way to authenticate. Create an app password in your Bluesky account settings, then: + +```csharp +var client = await ATProtoClient.CreateWithSessionAsync( + identifier: "alice.bsky.social", + password: "xxxx-xxxx-xxxx-xxxx", + options: new ATProtoClientOptions + { + JsonOptions = ATProtoJsonContext.DefaultOptions, + CborContext = ATProtoCborContext.Default, + }); +``` + +## Session Persistence + +Implement `ISessionStore` to persist sessions across app restarts: + +```csharp +var client = ATProtoClientFactory.Create(new ATProtoClientOptions +{ + SessionStore = new MySessionStore(), +}); +bool restored = await client.RestoreSessionAsync(userDid); +``` + +## Token Refresh + +Token refresh is handled automatically when `AutoRetryOnAuthFailure` is enabled (the default). Listen for refresh events to persist updated tokens: + +```csharp +if (client.TokenProvider is { } provider) +{ + provider.TokenRefreshed += (sender, args) => + { + SaveTokens(args.Did, args.AccessToken, args.RefreshToken); + }; +} +``` diff --git a/docs/docs/common-operations.md b/docs/docs/common-operations.md new file mode 100644 index 0000000..ac107d5 --- /dev/null +++ b/docs/docs/common-operations.md @@ -0,0 +1,99 @@ +# Common Operations + +## Get a Profile + +```csharp +var profile = await client.AppBskyActorGetProfileAsync( + new AppBsky.Actor.GetProfileParameters { Actor = new ATHandle("alice.bsky.social") }); +``` + +## Create a Post + +```csharp +var post = new AppBsky.Feed.Post +{ + Text = "Hello from CarpaNet!", + CreatedAt = DateTimeOffset.UtcNow, +}; + +var result = await client.ComAtprotoRepoCreateRecordAsync( + new ComAtproto.Repo.CreateRecordInput + { + Repo = new ATIdentifier(client.AuthenticatedDid!), + Collection = AppBsky.Feed.Post.RecordType, + Record = post.ToJson(), + }); + +Console.WriteLine($"Posted: {result.Uri}"); +``` + +## Delete a Record + +```csharp +var atUri = new ATUri(result.Uri.Value); + +await client.ComAtprotoRepoDeleteRecordAsync( + new ComAtproto.Repo.DeleteRecordInput + { + Repo = new ATIdentifier(client.AuthenticatedDid!), + Collection = AppBsky.Feed.Post.RecordType, + Rkey = atUri.RecordKey!, + }); +``` + +## List Records + +```csharp +var records = await client.ComAtprotoRepoListRecordsAsync( + new ComAtproto.Repo.ListRecordsParameters + { + Repo = "did:plc:example", + Collection = AppBsky.Feed.Post.RecordType, + Limit = 50, + }); + +foreach (var record in records.Records) +{ + var post = AppBsky.Feed.Post.FromJson(record.Value); + Console.WriteLine(post?.Text); +} +``` + +## Get a Timeline + +```csharp +var timeline = await client.AppBskyFeedGetTimelineAsync( + new AppBsky.Feed.GetTimelineParameters { Limit = 25 }); + +foreach (var item in timeline.Feed) +{ + Console.WriteLine($"{item.Post.Author.Handle}: {item.Post.Record}"); +} +``` + +## AT Protocol Types + +CarpaNet provides strongly-typed wrappers for AT Protocol identifiers: + +```csharp +// DID +var did = new ATDid("did:plc:z72i7hdynmk6r22z27h6tvur"); +Console.WriteLine(did.Method); // "plc" + +// Handle +var handle = new ATHandle("alice.bsky.social"); + +// AT URI +var uri = ATUri.Create("did:plc:example", "app.bsky.feed.post", "3k2la7k"); +Console.WriteLine(uri.Collection); // "app.bsky.feed.post" +Console.WriteLine(uri.RecordKey); // "3k2la7k" + +// AT Identifier (accepts either DID or Handle) +var id = new ATIdentifier("alice.bsky.social"); +Console.WriteLine(id.IsHandle); // true + +// CID +var cid = ATCid.FromSha256Hash(sha256Bytes); +``` + +All identifier types support equality, implicit string conversion, and JSON serialization. diff --git a/docs/docs/creating-clients.md b/docs/docs/creating-clients.md new file mode 100644 index 0000000..1e3c55e --- /dev/null +++ b/docs/docs/creating-clients.md @@ -0,0 +1,80 @@ +# Creating Clients + +## Public (Unauthenticated) Client + +Uses the Bluesky public AppView — can only make GET requests: + +```csharp +using CarpaNet; + +// ATProtoClientFactory is source-generated with your JSON/CBOR contexts preconfigured +var client = ATProtoClientFactory.Create(); + +var profile = await client.AppBskyActorGetProfileAsync( + new AppBsky.Actor.GetProfileParameters { Actor = new ATHandle("alice.bsky.social") }); + +Console.WriteLine($"{profile.DisplayName} (@{profile.Handle})"); +``` + +## Authenticated Client (App Password) + +```csharp +var client = await ATProtoClient.CreateWithSessionAsync( + identifier: "alice.bsky.social", // handle, email, or DID + password: "xxxx-xxxx-xxxx-xxxx", // app password + options: new ATProtoClientOptions + { + JsonOptions = ATProtoJsonContext.DefaultOptions, + CborContext = ATProtoCborContext.Default, + }); + +// Now you can make POST requests +var timeline = await client.AppBskyFeedGetTimelineAsync( + new AppBsky.Feed.GetTimelineParameters { Limit = 10 }); +``` + +## Authenticated Client with Custom Options + +```csharp +var client = ATProtoClientFactory.Create(new ATProtoClientOptions +{ + SessionStore = new MySessionStore(), // persist sessions across restarts + EnableRateLimitHandler = true, // automatic 429 retry (default: true) + AutoRetryOnAuthFailure = true, // retry on 401 with token refresh (default: true) + RateLimitMaxRetries = 3, + UserAgent = "MyApp/1.0", + LoggerFactory = loggerFactory, +}); +``` + +## Restoring a Session + +```csharp +// From explicit tokens +var client = ATProtoClient.CreateWithRestoredSession( + accessJwt: savedAccessJwt, + refreshJwt: savedRefreshJwt, + did: savedDid, + handle: savedHandle, + pdsUrl: new Uri(savedPdsUrl)); + +// Or from a session store +var client = ATProtoClientFactory.Create(new ATProtoClientOptions +{ + SessionStore = new MySessionStore(), +}); +bool restored = await client.RestoreSessionAsync(userDid); +``` + +## Listening for Token Refreshes + +```csharp +if (client.TokenProvider is { } provider) +{ + provider.TokenRefreshed += (sender, args) => + { + // Persist new tokens + SaveTokens(args.Did, args.AccessToken, args.RefreshToken); + }; +} +``` diff --git a/docs/docs/custom-lexicons.md b/docs/docs/custom-lexicons.md new file mode 100644 index 0000000..73f3df1 --- /dev/null +++ b/docs/docs/custom-lexicons.md @@ -0,0 +1,30 @@ +# Working with Custom Lexicons + +You can use your own or third-party lexicons beyond Bluesky's. Place JSON files in your project and reference them: + +```xml + + + + + + + +``` + +The source generator produces the same type-safe bindings for custom lexicons. Use the generated `FromJson()` method to parse records: + +```csharp +var records = await client.ComAtprotoRepoListRecordsAsync( + new ComAtproto.Repo.ListRecordsParameters + { + Repo = "did:plc:example", + Collection = MyCustom.Namespace.MyRecord.RecordType, + }); + +foreach (var record in records.Records) +{ + var parsed = MyCustom.Namespace.MyRecord.FromJson(record.Value); + Console.WriteLine(parsed?.SomeField); +} +``` diff --git a/docs/docs/firehose.md b/docs/docs/firehose.md new file mode 100644 index 0000000..02fac7c --- /dev/null +++ b/docs/docs/firehose.md @@ -0,0 +1,32 @@ +# Firehose + +Subscribe to the ATProtocol firehose for CBOR-encoded, batched commit events. Uses the core CarpaNet package — add `com.atproto.sync.subscribeRepos` to your lexicon resolves. + +```csharp +var client = ATProtoClientFactory.Create(new ATProtoClientOptions +{ + BaseUrl = new Uri("https://bsky.network"), // relay URL +}); + +await foreach (var message in client.ComAtprotoSyncSubscribeReposAsync(cancellationToken: cts.Token)) +{ + switch (message) + { + case ComAtproto.Sync.SubscribeReposCommit commit: + Console.WriteLine($"[Commit] seq={commit.Seq} repo={commit.Repo}"); + foreach (var op in commit.Ops ?? []) + { + Console.WriteLine($" {op.Action} {op.Path}"); + } + break; + + case ComAtproto.Sync.SubscribeReposIdentity identity: + Console.WriteLine($"[Identity] {identity.Did} → {identity.Handle}"); + break; + + case ComAtproto.Sync.SubscribeReposAccount account: + Console.WriteLine($"[Account] {account.Did} active={account.Active}"); + break; + } +} +``` diff --git a/docs/docs/identity-resolution.md b/docs/docs/identity-resolution.md new file mode 100644 index 0000000..026a265 --- /dev/null +++ b/docs/docs/identity-resolution.md @@ -0,0 +1,21 @@ +# Identity Resolution + +Resolve handles to DIDs and DID documents: + +```csharp +using CarpaNet.Identity; + +// Create with in-memory caching +var resolver = IdentityResolver.CreateWithCache(); + +// Handle → DID document +var didDoc = await resolver.ResolveAsync("alice.bsky.social"); +Console.WriteLine($"DID: {didDoc.Id}"); +Console.WriteLine($"PDS: {didDoc.PdsEndpoint}"); +Console.WriteLine($"Handle: {didDoc.Handle}"); + +// DID → DID document +var didDoc2 = await resolver.ResolveAsync("did:plc:z72i7hdynmk6r22z27h6tvur"); +``` + +The `ATProtoClient` creates an `IdentityResolver` automatically (configurable via `ATProtoClientOptions.CreateIdentityResolver`). diff --git a/docs/docs/jetstream.md b/docs/docs/jetstream.md new file mode 100644 index 0000000..b9d4023 --- /dev/null +++ b/docs/docs/jetstream.md @@ -0,0 +1,52 @@ +# Jetstream + +Jetstream provides a lightweight, JSON-based WebSocket event stream. Requires the `CarpaNet.Jetstream` package. +This is useful when you only care for a type of collection that you want to respond to. It also uses far less data than the full Firehose. + +```csharp +using CarpaNet.Jetstream; + +using var client = new JetstreamClient( + new Uri("https://jetstream1.us-east.bsky.network")); + +var options = new JetstreamSubscribeOptions +{ + WantedCollections = new[] { "app.bsky.feed.post", "app.bsky.feed.like" }, + WantedDids = new[] { "did:plc:z72i7hdynmk6r22z27h6tvur" }, // optional, max 10,000 + Cursor = 1725911162329308, // optional, resume from Unix microsecond timestamp + Compress = true, // enable zstd compression +}; + +await foreach (var evt in client.SubscribeAsync(options)) +{ + switch (evt.Kind) + { + case "commit" when evt.Commit is { } commit: + Console.WriteLine($"[{commit.Operation}] {commit.Collection}/{commit.Rkey}"); + if (commit.Record is { } record) + { + // record is a JsonElement — parse with your generated types + var type = record.TryGetProperty("$type", out var t) ? t.GetString() : null; + Console.WriteLine($" $type={type}"); + } + break; + + case "identity" when evt.Identity is { } identity: + Console.WriteLine($"[Identity] {evt.Did} → {identity.Handle}"); + break; + + case "account" when evt.Account is { } account: + Console.WriteLine($"[Account] {evt.Did} active={account.Active} status={account.Status}"); + break; + } +} +``` + +## Dynamic Filter Updates + +```csharp +await client.SendOptionsUpdateAsync(new JetstreamOptionsUpdate +{ + WantedCollections = new[] { "app.bsky.graph.follow" }, +}); +``` diff --git a/docs/docs/migrating-from-fishyflip.md b/docs/docs/migrating-from-fishyflip.md new file mode 100644 index 0000000..57287cd --- /dev/null +++ b/docs/docs/migrating-from-fishyflip.md @@ -0,0 +1,302 @@ +# Migrating from FishyFlip + +This guide helps users transition from [FishyFlip](https://github.com/drasticactions/FishyFlip) to CarpaNet. Both libraries target ATProtocol but differ significantly in architecture and API design. + +## Package Mapping + +| FishyFlip | CarpaNet | Notes | +|-----------|----------|-------| +| `FishyFlip` | `CarpaNet` | Core library | +| `FishyFlip` (built-in) | `CarpaNet.Jetstream` | Jetstream is a separate package | +| `FishyFlip` (built-in) | `CarpaNet.OAuth` | OAuth is a separate package | +| `FishyFlip.AspNetCore` | `CarpaNet.AspNetCore` | ASP.NET Core integration | + +## Declaring Lexicons (New Concept) + +FishyFlip bundles all Bluesky lexicons — every API method is available immediately. CarpaNet requires you to **declare which lexicons you need** in your `.csproj`. The source generator then produces only the types and methods you use: + +```xml + + + + + + + + +``` + +To pull in all lexicons from Bluesky at once, use handle resolution: + +```xml + + + + +``` + +## Client Creation + +FishyFlip uses a builder pattern that creates an `ATProtocol` instance: + +```csharp +// FishyFlip +var protocol = new ATProtocolBuilder() + .WithInstanceUrl(new Uri("https://bsky.social")) + .WithUserAgent("MyApp/1.0") + .WithLogger(logger) + .EnableAutoRenewSession(true) + .Build(); +``` + +CarpaNet uses static factory methods or a constructor with options. A source-generated `ATProtoClientFactory` provides pre-configured JSON/CBOR contexts: + +```csharp +// CarpaNet — unauthenticated (public AppView) +var client = ATProtoClientFactory.Create(); + +// CarpaNet — with app password +var client = await ATProtoClient.CreateWithSessionAsync( + identifier: "alice.bsky.social", + password: "xxxx-xxxx-xxxx-xxxx", + options: new ATProtoClientOptions + { + JsonOptions = ATProtoJsonContext.DefaultOptions, + CborContext = ATProtoCborContext.Default, + }); + +// CarpaNet — full options +var client = ATProtoClientFactory.Create(new ATProtoClientOptions +{ + UserAgent = "MyApp/1.0", + LoggerFactory = loggerFactory, + EnableRateLimitHandler = true, + AutoRetryOnAuthFailure = true, +}); +``` + +## API Call Patterns + +FishyFlip organizes methods into endpoint groups (`protocol.Actor`, `protocol.Feed`, etc.): + +```csharp +// FishyFlip — endpoint groups +var (profile, error) = await protocol.Actor.GetProfileAsync( + ATHandle.Create("alice.bsky.social")); + +var (timeline, error) = await protocol.Feed.GetTimelineAsync(limit: 25); + +var (result, error) = await protocol.Feed.CreatePostAsync(post); +``` + +CarpaNet uses flat extension methods on `IATProtoClient`, named after the NSID: + +```csharp +// CarpaNet — extension methods +var profile = await client.AppBskyActorGetProfileAsync( + new AppBsky.Actor.GetProfileParameters { Actor = new ATHandle("alice.bsky.social") }); + +var timeline = await client.AppBskyFeedGetTimelineAsync( + new AppBsky.Feed.GetTimelineParameters { Limit = 25 }); + +var result = await client.ComAtprotoRepoCreateRecordAsync( + new ComAtproto.Repo.CreateRecordInput + { + Repo = new ATIdentifier(client.AuthenticatedDid!), + Collection = AppBsky.Feed.Post.RecordType, + Record = post.ToJson(), + }); +``` + +Key differences: + +- CarpaNet uses **parameter/input record objects** instead of method parameters +- Method names follow the NSID pattern: `AppBskyActorGetProfileAsync` = `app.bsky.actor.getProfile` +- Creating records is done via the generic `ComAtprotoRepoCreateRecordAsync` with `Record = post.ToJson()`; FishyFlip provides typed helpers like `CreatePostAsync` + +## Error Handling + +FishyFlip uses a `Result` type (OneOf-based) with tuple deconstruction: + +```csharp +// FishyFlip +var (profile, error) = await protocol.Actor.GetProfileAsync(handle); +if (error is not null) +{ + Console.WriteLine($"Error: {error.Detail?.Message}"); + return; +} +Console.WriteLine(profile!.DisplayName); +``` + +CarpaNet throws exceptions on failure — use standard try/catch: + +```csharp +// CarpaNet +try +{ + var profile = await client.AppBskyActorGetProfileAsync( + new AppBsky.Actor.GetProfileParameters { Actor = handle }); + Console.WriteLine(profile.DisplayName); +} +catch (ATProtoException ex) +{ + Console.WriteLine($"Error: {ex.Message}"); +} +``` + +## Authentication + +### App Password + +```csharp +// FishyFlip +var protocol = new ATProtocolBuilder().Build(); +var (session, error) = await protocol.AuthenticateWithPasswordResultAsync( + "alice.bsky.social", "xxxx-xxxx-xxxx-xxxx"); + +// CarpaNet +var client = await ATProtoClient.CreateWithSessionAsync( + identifier: "alice.bsky.social", + password: "xxxx-xxxx-xxxx-xxxx", + options: new ATProtoClientOptions + { + JsonOptions = ATProtoJsonContext.DefaultOptions, + CborContext = ATProtoCborContext.Default, + }); +``` + +### OAuth 2.0 + +```csharp +// FishyFlip — OAuth is on ATProtocol directly +var protocol = new ATProtocolBuilder().Build(); +var (authUrl, error) = await protocol.GenerateOAuth2AuthenticationUrlResultAsync( + clientId: "http://localhost", + redirectUrl: "http://localhost:3000/callback", + scopes: new[] { "atproto" }, + instanceUrl: "https://bsky.social"); +// ... user completes browser login ... +var (session, error) = await protocol.AuthenticateWithOAuth2CallbackResultAsync(callbackUrl); + +// CarpaNet — OAuth is a separate OAuthSession class +using var oauthSession = new OAuthSession(new OAuthClientConfig +{ + ClientId = OAuthClientConfig.CreateLoopbackClientId(8080), + RedirectUri = OAuthClientConfig.CreateLoopbackRedirectUri(8080), + Scope = "atproto transition:generic", + JsonOptions = ATProtoJsonContext.DefaultOptions, + SessionStore = new MemoryOAuthSessionStore(), +}); +var authUrl = await oauthSession.AuthorizeAsync("alice.bsky.social"); +// ... user completes browser login ... +ATProtoOAuthClient atClient = await oauthSession.CallbackAsync(callbackUrl); +``` + +### Session Persistence + +```csharp +// FishyFlip — serialize AuthSession to string +var saved = await protocol.RefreshAuthSessionResultAsync(); +File.WriteAllText("session.json", saved.ToString()); +// Restore: +var restored = AuthSession.FromString(File.ReadAllText("session.json")); +await protocol.AuthenticateWithOAuth2SessionResultAsync(restored, clientId); + +// CarpaNet — implement ISessionStore (password) or IOAuthSessionStore (OAuth) +var client = ATProtoClientFactory.Create(new ATProtoClientOptions +{ + SessionStore = new MyFileSessionStore(), +}); +bool restored = await client.RestoreSessionAsync(userDid); +``` + +## Identity Types + +Both libraries use similar strongly-typed identifiers but with different creation patterns: + +```csharp +// FishyFlip +var did = ATDid.Create("did:plc:abc123"); +var handle = ATHandle.Create("alice.bsky.social"); +var uri = ATUri.Create("at://did:plc:abc123/app.bsky.feed.post/rkey"); +var identifier = ATIdentifier.Create("alice.bsky.social"); + +// CarpaNet — constructors and implicit conversion +var did = new ATDid("did:plc:abc123"); +var handle = new ATHandle("alice.bsky.social"); +var uri = ATUri.Create("did:plc:abc123", "app.bsky.feed.post", "rkey"); +var identifier = new ATIdentifier("alice.bsky.social"); +``` + +## Jetstream + +FishyFlip uses an event-based callback model: + +```csharp +// FishyFlip +var jetStream = new ATJetStream(new ATJetStreamOptions +{ + Url = new Uri("wss://jetstream.atproto.tools"), + WantedCollections = new[] { "app.bsky.feed.post" }, +}); +jetStream.OnRecordReceived += (s, e) => { /* handle event */ }; +await jetStream.ConnectAsync(); +``` + +CarpaNet uses `IAsyncEnumerable` for a cleaner streaming pattern: + +```csharp +// CarpaNet +using var client = new JetstreamClient( + new Uri("https://jetstream1.us-east.bsky.network")); + +await foreach (var evt in client.SubscribeAsync(new JetstreamSubscribeOptions +{ + WantedCollections = new[] { "app.bsky.feed.post" }, + Compress = true, +})) +{ + if (evt.Kind == "commit" && evt.Commit is { } commit) + Console.WriteLine($"{commit.Operation} {commit.Collection}/{commit.Rkey}"); +} +``` + +## Serialization Context + +FishyFlip manages its `SourceGenerationContext` internally — you don't need to think about it. CarpaNet requires you to pass source-generated contexts explicitly (they are generated per-project based on your declared lexicons): + +```csharp +// CarpaNet — contexts are auto-generated, used via ATProtoClientFactory or manually +var client = ATProtoClientFactory.Create(); // contexts pre-wired + +// Or manually: +var client = ATProtoClient.Create(new ATProtoClientOptions +{ + JsonOptions = ATProtoJsonContext.DefaultOptions, + CborContext = ATProtoCborContext.Default, +}); +``` + +## Quick Reference + +| Operation | FishyFlip | CarpaNet | +|-----------|-----------|----------| +| Get profile | `protocol.Actor.GetProfileAsync(handle)` | `client.AppBskyActorGetProfileAsync(new ... { Actor = handle })` | +| Create post | `protocol.Feed.CreatePostAsync(post)` | `client.ComAtprotoRepoCreateRecordAsync(new ... { Record = post.ToJson() })` | +| Delete post | `protocol.Feed.DeletePostAsync(repo, rkey)` | `client.ComAtprotoRepoDeleteRecordAsync(new ... { Rkey = rkey })` | +| Get timeline | `protocol.Feed.GetTimelineAsync(limit)` | `client.AppBskyFeedGetTimelineAsync(new ... { Limit = limit })` | +| Resolve handle | `protocol.ResolveATIdentifierAsync(handle)` | `resolver.ResolveAsync("alice.bsky.social")` | +| Check auth | `protocol.IsAuthenticated` | `client.IsAuthenticated` | +| Get current DID | `protocol.Session?.Did` | `client.AuthenticatedDid` | + +## Summary of Key Differences (It's a numbered list so you know an LLM did this) + +1. **Lexicon-driven**: CarpaNet generates only the APIs you declare — add lexicons to `.csproj` +2. **No endpoint groups**: Flat extension methods named after NSIDs instead of `protocol.Actor.*` +3. **No Result type**: CarpaNet throws exceptions instead of returning `Result` +4. **Explicit serialization contexts**: You pass `JsonOptions`/`CborContext` (auto-generated per project) +5. **Modular packages**: OAuth and Jetstream are separate NuGet packages +6. **IAsyncEnumerable streams**: Jetstream and firehose use `await foreach` instead of event callbacks +7. **Constructor-based types**: `new ATHandle(...)` instead of `ATHandle.Create(...)` +8. **Generic record operations**: Use `ComAtprotoRepoCreateRecordAsync` with `Record = obj.ToJson()` instead of typed `CreatePostAsync` helpers diff --git a/docs/docs/oauth.md b/docs/docs/oauth.md new file mode 100644 index 0000000..81cbb83 --- /dev/null +++ b/docs/docs/oauth.md @@ -0,0 +1,112 @@ +# OAuth Authentication + +OAuth is the recommended auth method for user-facing apps. Requires the `CarpaNet.OAuth` package. + +## Desktop/Console App Flow + +```csharp +using CarpaNet.OAuth; + +// 1. Configure with loopback URI for desktop apps +var port = 8080; +var config = new OAuthClientConfig +{ + ClientId = OAuthClientConfig.CreateLoopbackClientId(port), + RedirectUri = OAuthClientConfig.CreateLoopbackRedirectUri(port), + Scope = "atproto transition:generic", + JsonOptions = ATProtoJsonContext.DefaultOptions, + SessionStore = new MemoryOAuthSessionStore(), +}; + +// 2. Start the OAuth flow +using var oauthSession = new OAuthSession(config); +var authUrl = await oauthSession.AuthorizeAsync("alice.bsky.social"); + +// 3. Open browser and listen for callback +Console.WriteLine($"Open: {authUrl}"); +// ... start HTTP listener on port, capture callback URL ... + +// 4. Exchange code for tokens +ATProtoOAuthClient atClient = await oauthSession.CallbackAsync(callbackUrl); + +// 5. Use the authenticated client +var profile = await atClient.AppBskyActorGetProfileAsync( + new AppBsky.Actor.GetProfileParameters { Actor = new ATHandle("alice.bsky.social") }); + +// 6. Sign out when done +await atClient.SignOutAsync(); +``` + +## Web App Flow + +```csharp +var config = new OAuthClientConfig +{ + ClientId = "https://myapp.example.com/client-metadata.json", + RedirectUri = "https://myapp.example.com/callback", + Scope = "atproto transition:generic", + JsonOptions = ATProtoJsonContext.DefaultOptions, + SessionStore = myPersistentSessionStore, + StateStore = myPersistentStateStore, +}; + +using var oauthSession = new OAuthSession(config); +var authUrl = await oauthSession.AuthorizeAsync(userHandle); +// Redirect user to authUrl... + +// In callback handler: +var atClient = await oauthSession.CallbackAsync(Request.Url.ToString()); +``` + +## Restoring an OAuth Session + +```csharp +var config = new OAuthClientConfig +{ + ClientId = savedClientId, + RedirectUri = savedRedirectUri, + Scope = "atproto", + JsonOptions = ATProtoJsonContext.DefaultOptions, + SessionStore = mySessionStore, +}; + +using var oauthSession = new OAuthSession(config); +ATProtoOAuthClient atClient = await oauthSession.RestoreSessionAsync(userDid); +``` + +## Custom Session Storage + +Implement `IOAuthSessionStore` to persist OAuth sessions (DPoP keys, tokens) across app restarts: + +```csharp +public sealed class FileOAuthSessionStore : IOAuthSessionStore +{ + private readonly string _directory; + + public FileOAuthSessionStore(string directory) => _directory = directory; + + public Task StoreAsync(string sub, OAuthSessionData data, CancellationToken ct) + { + var json = JsonSerializer.Serialize(data); + File.WriteAllText(GetPath(sub), json); + return Task.CompletedTask; + } + + public Task GetAsync(string sub, CancellationToken ct) + { + var path = GetPath(sub); + if (!File.Exists(path)) return Task.FromResult(null); + var data = JsonSerializer.Deserialize(File.ReadAllText(path)); + return Task.FromResult(data); + } + + public Task DeleteAsync(string sub, CancellationToken ct) + { + File.Delete(GetPath(sub)); + return Task.CompletedTask; + } + + private string GetPath(string sub) => + Path.Combine(_directory, $"oauth-{sub.Replace(":", "_")}.json"); +} +``` diff --git a/docs/docs/project-setup.md b/docs/docs/project-setup.md new file mode 100644 index 0000000..a8c8bd8 --- /dev/null +++ b/docs/docs/project-setup.md @@ -0,0 +1,100 @@ +# Project Setup + +## Prerequisites + +- .NET 8 SDK or above +- `dotnet add package CarpaNet` + +## Minimal .csproj + +```xml + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + +``` + +When you build, the source generator resolves lexicons via DNS, caches them locally, and generates: + +- Data model classes with JSON attributes +- Extension methods on `IATProtoClient` for each query/procedure +- A `JsonSerializerContext` and `CborSerializerContext` for AOT-compatible serialization +- `ToJson()`/`FromJson()` helpers on each generated type +- An `ATProtoClientFactory` with preconfigured JSON/CBOR contexts + +## Lexicon Sources + +You can combine four ways to supply lexicons: + +```xml + + + + + + + + + + + + + + +``` + +## Auto-Resolve Transitive Dependencies + +Enable automatic discovery of referenced lexicons you haven't explicitly listed: + +```xml + + true + +``` + +This scans your lexicons for `ref` fields pointing to external NSIDs, resolves them via DNS, and repeats until all dependencies are satisfied. + +## MSBuild Properties + +| Property | Default | Description | +|----------|---------|-------------| +| `CarpaNet_JsonContextName` | `ATProtoJsonContext` | Name of the generated JSON serializer context | +| `CarpaNet_CborContextName` | `ATProtoCborContext` | Name of the generated CBOR serializer context | +| `CarpaNet_SourceGen_RootNamespace` | Project namespace | Root namespace for generated code | +| `CarpaNet_SourceGen_EmitValidationAttributes` | `false` | Emit `[ATStringLength]`, `[Range]` attributes | +| `CarpaNet_LexiconAutoResolve` | `false` | Auto-resolve transitive lexicon dependencies | +| `CarpaNet_LexiconAutoResolveMaxDepth` | `10` | Max iterations for transitive resolution | +| `CarpaNet_LexiconCacheDir` | `obj/lexicon-cache/` | Cache directory for resolved lexicons | +| `CarpaNet_LexiconCacheTtlHours` | `24` | Cache TTL in hours; `0` forces refresh | +| `CarpaNet_LexiconFailOnError` | `true` | Fail build on resolution errors | +| `CarpaNet_PlcDirectoryUrl` | `https://plc.directory` | PLC directory URL | +| `CarpaNet_DnsServers` | (empty) | Semicolon-separated DNS server IPs | + +## Inspecting Generated Code + +Roslyn allows for emiting the compiler generated files. This makes it easy to debug (and for LLMs to inspect, as it were.) + +```xml + + true + +``` + +Generated files appear at `obj/Debug/{TFM}/generated/CarpaNet.SourceGen/CarpaNet.LexiconGenerator/`. diff --git a/docs/docs/repository-car.md b/docs/docs/repository-car.md new file mode 100644 index 0000000..76a5860 --- /dev/null +++ b/docs/docs/repository-car.md @@ -0,0 +1,26 @@ +# Repository & CAR File Reading + +Read ATProtocol repositories from CAR (Content Addressable aRchive) files: + +```csharp +using CarpaNet.Repo; + +// Load from file +var repo = Repository.LoadFromFile("repository.car"); + +// Or from stream/bytes +var repo = Repository.Load(carStream); +var repo = Repository.Load(carBytes); + +// Inspect +Console.WriteLine($"Owner: {repo.Did}"); +Console.WriteLine($"Revision: {repo.Rev}"); +Console.WriteLine($"Root CID: {repo.RootCid}"); + +// Low-level CAR block reading +using var reader = new CarReader(stream); +foreach (var block in reader.ReadBlocks()) +{ + Console.WriteLine($"CID: {block.Cid}, Size: {block.Data.Length}"); +} +``` diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml new file mode 100644 index 0000000..e6a3030 --- /dev/null +++ b/docs/docs/toc.yml @@ -0,0 +1,14 @@ +- name: Getting Started +- name: Introduction + href: ../index.md +- href: project-setup.md +- href: creating-clients.md +- href: authentication.md +- href: common-operations.md +- href: oauth.md +- href: jetstream.md +- href: firehose.md +- href: identity-resolution.md +- href: repository-car.md +- href: custom-lexicons.md +- href: migrating-from-fishyflip.md diff --git a/docs/favicon.png b/docs/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8ed07425c73ef419d534e032f5e37bb0114e4296 GIT binary patch literal 31615 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 uJT2c%9+mlfQx?zVYz( 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 O0000 + + + + + + + + +``` + +Then use the generated client: + +```csharp +using CarpaNet; + +var client = ATProtoClientFactory.Create(); + +var profile = await client.AppBskyActorGetProfileAsync( + new AppBsky.Actor.GetProfileParameters { Actor = new ATHandle("alice.bsky.social") }); + +Console.WriteLine($"{profile.DisplayName} (@{profile.Handle})"); +``` + +## Packages + +| Package | Description | +|---------|-------------| +| [CarpaNet](https://www.nuget.org/packages/CarpaNet/) | Core runtime, source generator, XRPC protocol, identity resolution, DAG-CBOR, CAR files | +| [CarpaNet.OAuth](https://www.nuget.org/packages/CarpaNet.OAuth/) | OAuth 2.0 with PAR, PKCE, and DPoP support | +| [CarpaNet.Jetstream](https://www.nuget.org/packages/CarpaNet.Jetstream/) | Real-time event subscription via Bluesky Jetstream | +| [CarpaNet.AspNetCore](https://www.nuget.org/packages/CarpaNet.AspNetCore/) | ASP.NET Core integration | + +## Third-Party Libraries + +- [ZstdSharp.Port](https://github.com/oleg-st/ZstdSharp) — Zstandard compression diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 0000000..abd17b8 --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,4 @@ +- name: Docs + href: docs/ +- name: API + href: api/ From 6426dd6d62149d67f83054f8977d0859ba352207 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Mon, 30 Mar 2026 16:46:19 +0900 Subject: [PATCH 3/4] Fix Type name --- .../Generation/CborContextGenerator.cs | 7 + .../Generation/JsonContextGenerator.cs | 11 +- .../Generation/ObjectGenerator.cs | 12 +- .../Generation/ObjectGeneratorTests.cs | 162 ++++++++++++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 tests/CarpaNet.UnitTests/Generation/ObjectGeneratorTests.cs diff --git a/src/CarpaNet.SourceGen/Generation/CborContextGenerator.cs b/src/CarpaNet.SourceGen/Generation/CborContextGenerator.cs index ef1b672..d35c3ef 100644 --- a/src/CarpaNet.SourceGen/Generation/CborContextGenerator.cs +++ b/src/CarpaNet.SourceGen/Generation/CborContextGenerator.cs @@ -101,6 +101,13 @@ public static void GenerateCborTypeInfo( propName = propName + "Value"; } + // Records auto-generate a "Type" property for the $type discriminator, + // so rename any lexicon property that would also become "Type" + if (isRecord && propName == "Type") + { + propName = "TypeValue"; + } + propName = NsidHelper.EscapeIdentifier(propName); var propType = GetPropertyCSharpType(prop.Value, currentNsid, registry, shortClassName, prop.Key, typeNamespace); diff --git a/src/CarpaNet.SourceGen/Generation/JsonContextGenerator.cs b/src/CarpaNet.SourceGen/Generation/JsonContextGenerator.cs index daa1a55..c3b4a98 100644 --- a/src/CarpaNet.SourceGen/Generation/JsonContextGenerator.cs +++ b/src/CarpaNet.SourceGen/Generation/JsonContextGenerator.cs @@ -86,7 +86,7 @@ public static void GenerateJsonTypeInfo( if (isRecord) { collectedPropertyTypes?.Add("string"); - sb.AppendLine($"var prop_type = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreatePropertyInfo(options, new global::System.Text.Json.Serialization.Metadata.JsonPropertyInfoValues()"); + sb.AppendLine($"var prop__dtype = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreatePropertyInfo(options, new global::System.Text.Json.Serialization.Metadata.JsonPropertyInfoValues()"); sb.OpenBrace(); sb.AppendLine("IsProperty = true,"); sb.AppendLine("IsPublic = true,"); @@ -124,6 +124,13 @@ public static void GenerateJsonTypeInfo( propName = propName + "Value"; } + // Records auto-generate a "Type" property for the $type discriminator, + // so rename any lexicon property that would also become "Type" + if (isRecord && propName == "Type") + { + propName = "TypeValue"; + } + propName = NsidHelper.EscapeIdentifier(propName); // Resolve property type to fully qualified form @@ -175,7 +182,7 @@ public static void GenerateJsonTypeInfo( sb.Append("return new global::System.Text.Json.Serialization.Metadata.JsonPropertyInfo[] { "); if (isRecord) { - sb.Append("prop_type, "); + sb.Append("prop__dtype, "); } for (int i = 0; i < propList.Count; i++) diff --git a/src/CarpaNet.SourceGen/Generation/ObjectGenerator.cs b/src/CarpaNet.SourceGen/Generation/ObjectGenerator.cs index db3e2ad..6520314 100644 --- a/src/CarpaNet.SourceGen/Generation/ObjectGenerator.cs +++ b/src/CarpaNet.SourceGen/Generation/ObjectGenerator.cs @@ -63,7 +63,7 @@ public static void GenerateClass( foreach (var prop in properties) { - GenerateProperty(sb, className, prop.Key, prop.Value, currentNsid, registry, requiredProps, nullableProps); + GenerateProperty(sb, className, prop.Key, prop.Value, currentNsid, registry, requiredProps, nullableProps, isRecord); } sb.CloseBrace(); @@ -111,7 +111,8 @@ public static void GenerateProperty( string currentNsid, TypeRegistry registry, List requiredProps, - List nullableProps) + List nullableProps, + bool isRecord = false) { var isRequired = requiredProps.Contains(propertyName) || def.IsRequired; var isNullable = nullableProps.Contains(propertyName) || !isRequired; @@ -125,6 +126,13 @@ public static void GenerateProperty( csharpName = csharpName + "Value"; } + // Records auto-generate a "Type" property for the $type discriminator, + // so rename any lexicon property that would also become "Type" + if (isRecord && csharpName == "Type") + { + csharpName = "TypeValue"; + } + csharpName = NsidHelper.EscapeIdentifier(csharpName); sb.WriteSummary(def.Description); diff --git a/tests/CarpaNet.UnitTests/Generation/ObjectGeneratorTests.cs b/tests/CarpaNet.UnitTests/Generation/ObjectGeneratorTests.cs new file mode 100644 index 0000000..35762d0 --- /dev/null +++ b/tests/CarpaNet.UnitTests/Generation/ObjectGeneratorTests.cs @@ -0,0 +1,162 @@ +using CarpaNet.Generation; +using CarpaNet.Models; + +using Xunit; + +namespace CarpaNet.UnitTests.Generation; + +public class ObjectGeneratorTests +{ + [Fact] + public void RecordWithTypeProperty_RenamesPropertyToTypeValue() + { + // Arrange: a record type with a lexicon-defined "type" property + // This conflicts with the auto-generated "Type" property for the $type discriminator + var registry = new TypeRegistry(); + var def = new LexiconDefinition + { + Type = "object", + Properties = new Dictionary + { + ["type"] = new LexiconDefinition { Type = "string" }, + ["subject"] = new LexiconDefinition { Type = "string" }, + }, + RequiredRaw = CreateJsonArray("type", "subject"), + }; + + var sb = new SourceBuilder(); + + // Act + ObjectGenerator.GenerateClass( + sb, "Reaction", def, "tech.tokimeki.kaku.reaction", registry, + isRecord: true, recordType: "tech.tokimeki.kaku.reaction", + typeId: "tech.tokimeki.kaku.reaction"); + + var result = sb.ToString(); + + // Assert: the $type discriminator property exists + Assert.Contains("public string Type => RecordType;", result); + + // Assert: the lexicon "type" property is renamed to TypeValue + Assert.Contains("JsonPropertyName(\"type\")", result); + Assert.Contains("TypeValue", result); + + // Assert: there is no duplicate "Type" settable property (only the computed "Type =>" should exist) + Assert.DoesNotContain("public required string Type { get; set; }", result); + Assert.Contains("public required string TypeValue { get; set; }", result); + } + + [Fact] + public void NonRecordWithTypeProperty_KeepsPropertyAsType() + { + // Arrange: a non-record type with a "type" property — no conflict + var registry = new TypeRegistry(); + var def = new LexiconDefinition + { + Type = "object", + Properties = new Dictionary + { + ["type"] = new LexiconDefinition { Type = "string" }, + }, + RequiredRaw = CreateJsonArray("type"), + }; + + var sb = new SourceBuilder(); + + // Act + ObjectGenerator.GenerateClass( + sb, "PostEntity", def, "app.bsky.feed.post#entity", registry, + isRecord: false); + + var result = sb.ToString(); + + // Assert: the property keeps its original name since there's no $type discriminator + Assert.Contains("JsonPropertyName(\"type\")", result); + Assert.Contains("public required string Type { get; set; }", result); + Assert.DoesNotContain("TypeValue", result); + } + + [Fact] + public void RecordWithoutTypeProperty_GeneratesNormalProperties() + { + // Arrange: a record without a conflicting "type" property + var registry = new TypeRegistry(); + var def = new LexiconDefinition + { + Type = "object", + Properties = new Dictionary + { + ["text"] = new LexiconDefinition { Type = "string" }, + ["createdAt"] = new LexiconDefinition { Type = "string", Format = "datetime" }, + }, + RequiredRaw = CreateJsonArray("text", "createdAt"), + }; + + var sb = new SourceBuilder(); + + // Act + ObjectGenerator.GenerateClass( + sb, "Post", def, "app.bsky.feed.post", registry, + isRecord: true, recordType: "app.bsky.feed.post", + typeId: "app.bsky.feed.post"); + + var result = sb.ToString(); + + // Assert: $type discriminator exists + Assert.Contains("public string Type => RecordType;", result); + // Assert: normal properties are not renamed + Assert.Contains("public required string Text { get; set; }", result); + Assert.Contains("public required System.DateTimeOffset CreatedAt { get; set; }", result); + Assert.DoesNotContain("TypeValue", result); + } + + [Fact] + public void PropertyMatchingClassName_GetsSuffixed() + { + // Arrange: a property that matches the class name + var registry = new TypeRegistry(); + var def = new LexiconDefinition + { + Type = "object", + Properties = new Dictionary + { + ["reaction"] = new LexiconDefinition { Type = "string" }, + }, + RequiredRaw = CreateJsonArray("reaction"), + }; + + var sb = new SourceBuilder(); + + // Act + ObjectGenerator.GenerateClass( + sb, "Reaction", def, "test.ns.reaction", registry, isRecord: false); + + var result = sb.ToString(); + + // Assert: property is renamed to avoid matching class name + Assert.Contains("ReactionValue", result); + Assert.Contains("JsonPropertyName(\"reaction\")", result); + } + + #region Test Helpers + + private static System.Text.Json.JsonElement CreateJsonArray(params string[] values) + { + var json = System.Text.Json.JsonSerializer.Serialize(values); + return System.Text.Json.JsonDocument.Parse(json).RootElement.Clone(); + } + + private static int CountOccurrences(string source, string substring) + { + int count = 0; + int index = 0; + while ((index = source.IndexOf(substring, index, StringComparison.Ordinal)) != -1) + { + count++; + index += substring.Length; + } + return count; + } + + #endregion +} From 243c3fa777ac97595d3105941b2c1210e10fabb8 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Mon, 30 Mar 2026 16:47:28 +0900 Subject: [PATCH 4/4] Update on Main --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 990a6f6..d855c98 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,7 +2,7 @@ name: Deploy Docs on: push: - branches: [ "main", "develop" ] + branches: [ "main" ] paths: - 'docs/**' - 'src/**'