diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..d855c98 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,51 @@ +name: Deploy Docs + +on: + push: + branches: [ "main" ] + 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 0000000..8ed0742 Binary files /dev/null and b/docs/favicon.png differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..9ba2c3d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,51 @@ +# CarpaNet - a .NET ATProtocol Library + +[![NuGet Version](https://img.shields.io/nuget/v/CarpaNet.svg)](https://www.nuget.org/packages/CarpaNet/) ![License](https://img.shields.io/badge/License-MIT-blue.svg) + +CarpaNet is a .NET library for [ATProtocol](https://atproto.com/) that uses Roslyn source generators to produce type-safe API bindings from Lexicon JSON schema files. It's the replacement for [FishyFlip](https://github.com/drasticactions/FishyFlip). + +![1444070256569233](https://user-images.githubusercontent.com/898335/167266846-1ad2648f-91c1-4a04-a18d-6dd4d6c7d21c.gif) + +This site is under construction. + +## Quick Start + +Add the CarpaNet NuGet package and declare which lexicons you need: + +```xml + + + + + + + + + +``` + +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/ 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 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 +}