diff --git a/samples/AuthTest/AuthTest.csproj b/samples/AuthTest/AuthTest.csproj index 4e58cdc..9143152 100644 --- a/samples/AuthTest/AuthTest.csproj +++ b/samples/AuthTest/AuthTest.csproj @@ -26,6 +26,7 @@ + diff --git a/samples/AuthTest/Program.cs b/samples/AuthTest/Program.cs index 8edf069..a4ce2dc 100644 --- a/samples/AuthTest/Program.cs +++ b/samples/AuthTest/Program.cs @@ -264,12 +264,13 @@ async Task AuthenticatedMenuAsync(IATProtoClient client, string? handle, string Console.WriteLine(" 1. Get my profile"); Console.WriteLine(" 2. Get my preferences"); Console.WriteLine(" 3. Get my timeline"); - Console.WriteLine(" 4. Get notifications"); - Console.WriteLine(" 5. Create a post"); - Console.WriteLine(" 6. Delete a post"); - Console.WriteLine(" 7. Save session"); - Console.WriteLine(" 8. Show session tokens"); - Console.WriteLine(" 9. Exit"); + Console.WriteLine(" 4. Get posts by URIs"); + Console.WriteLine(" 5. Get notifications"); + Console.WriteLine(" 6. Create a post"); + Console.WriteLine(" 7. Delete a post"); + Console.WriteLine(" 8. Save session"); + Console.WriteLine(" 9. Show session tokens"); + Console.WriteLine(" 10. Exit"); Console.Write("> "); var choice = Console.ReadLine()?.Trim(); @@ -288,21 +289,24 @@ async Task AuthenticatedMenuAsync(IATProtoClient client, string? handle, string await GetTimelineAsync(client); break; case "4": - await GetNotificationsAsync(client); + await GetPostsAsync(client); break; case "5": - await CreatePostAsync(client, did); + await GetNotificationsAsync(client); break; case "6": - await DeletePostAsync(client, did); + await CreatePostAsync(client, did); break; case "7": - SaveCurrentSession(client); + await DeletePostAsync(client, did); break; case "8": - await ShowSessionTokensAsync(client); + SaveCurrentSession(client); break; case "9": + await ShowSessionTokensAsync(client); + break; + case "10": return; default: Console.WriteLine("Invalid choice."); @@ -356,6 +360,47 @@ async Task GetTimelineAsync(IATProtoClient client) } } +async Task GetPostsAsync(IATProtoClient client) +{ + Console.WriteLine("Enter AT URIs (one per line, blank line to finish):"); + var uris = new List(); + while (true) + { + Console.Write("> "); + var line = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(line)) + break; + + var uri = new ATUri(line); + if (!uri.IsValid) + { + Console.WriteLine($" Skipped invalid URI: {line}"); + continue; + } + uris.Add(uri); + } + + if (uris.Count == 0) + { + Console.WriteLine("No URIs provided."); + return; + } + + // Demonstrates the fix for https://github.com/drasticactions/CarpaNet/issues/12 — + // passing multiple URIs serializes as repeated query parameters (?uris=a&uris=b). + var parameters = new GetPostsParameters { Uris = uris }; + var response = await client.AppBskyFeedGetPostsAsync(parameters); + + Console.WriteLine($" Returned posts: {response.Posts?.Count ?? 0}"); + if (response.Posts != null) + { + foreach (var post in response.Posts) + { + Console.WriteLine($" - @{post.Author?.Handle}: {post.Uri}"); + } + } +} + async Task GetNotificationsAsync(IATProtoClient client) { var parameters = new ListNotificationsParameters { Limit = 10 }; diff --git a/src/CarpaNet.OAuth/ATProtoOAuthClient.cs b/src/CarpaNet.OAuth/ATProtoOAuthClient.cs index 81827e8..03b6850 100644 --- a/src/CarpaNet.OAuth/ATProtoOAuthClient.cs +++ b/src/CarpaNet.OAuth/ATProtoOAuthClient.cs @@ -101,35 +101,35 @@ internal ATProtoOAuthClient( /// public async Task GetAsync( string nsid, - IReadOnlyDictionary? parameters = null, + IEnumerable>? parameters = null, CancellationToken cancellationToken = default) { ThrowIfDisposed(); _logger.LogDebug("OAuth GET {Nsid}", nsid); - var url = (await XrpcHttpHandler.BuildUrlAsync(BaseUrl, nsid, parameters, _identityResolver, cancellationToken).ConfigureAwait(false)).ToString(); + var url = (await XrpcHttpHandler.BuildUrlAsync(BaseUrl, nsid, parameters, _identityResolver, _logger, cancellationToken).ConfigureAwait(false)).ToString(); using var request = await _tokenProvider.CreateDPoPRequestAsync(HttpMethod.Get, url).ConfigureAwait(false); XrpcHttpHandler.AddCommonHeaders(request, null, LabelerDids); var response = await SendWithRetryAsync(request, url, cancellationToken).ConfigureAwait(false); - return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, _logger, cancellationToken).ConfigureAwait(false); } /// public async Task GetAsync( string nsid, string proxyServiceDid, - IReadOnlyDictionary? parameters = null, + IEnumerable>? parameters = null, CancellationToken cancellationToken = default) { ThrowIfDisposed(); - var url = (await XrpcHttpHandler.BuildUrlAsync(BaseUrl, nsid, parameters, _identityResolver, cancellationToken).ConfigureAwait(false)).ToString(); + var url = (await XrpcHttpHandler.BuildUrlAsync(BaseUrl, nsid, parameters, _identityResolver, _logger, cancellationToken).ConfigureAwait(false)).ToString(); using var request = await _tokenProvider.CreateDPoPRequestAsync(HttpMethod.Get, url).ConfigureAwait(false); XrpcHttpHandler.AddCommonHeaders(request, proxyServiceDid, LabelerDids); var response = await SendWithRetryAsync(request, url, cancellationToken).ConfigureAwait(false); - return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, _logger, cancellationToken).ConfigureAwait(false); } /// @@ -153,7 +153,7 @@ public async Task PostAsync( } var response = await SendWithRetryAsync(request, url, cancellationToken).ConfigureAwait(false); - return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, _logger, cancellationToken).ConfigureAwait(false); } /// @@ -177,13 +177,13 @@ public async Task PostAsync( } var response = await SendWithRetryAsync(request, url, cancellationToken).ConfigureAwait(false); - return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, _logger, cancellationToken).ConfigureAwait(false); } /// public IAsyncEnumerable SubscribeAsync( string nsid, - IReadOnlyDictionary? parameters = null, + IEnumerable>? parameters = null, CancellationToken cancellationToken = default) { ThrowIfDisposed(); diff --git a/src/CarpaNet.SourceGen/Generation/ApiGenerator.cs b/src/CarpaNet.SourceGen/Generation/ApiGenerator.cs index 9abd269..c6a74f9 100644 --- a/src/CarpaNet.SourceGen/Generation/ApiGenerator.cs +++ b/src/CarpaNet.SourceGen/Generation/ApiGenerator.cs @@ -51,25 +51,27 @@ public static void GenerateParametersClass( new List()); } - // Generate ToDictionary method for converting to query parameters - GenerateToDictionaryMethod(sb, def.Parameters.Properties, requiredProps); + // Generate ToQueryParameters method for converting to query parameters + GenerateToQueryParametersMethod(sb, def.Parameters.Properties, requiredProps); sb.CloseBrace(); } /// - /// Generates the ToDictionary method for a parameters class. + /// Generates the ToQueryParameters method for a parameters class. /// - private static void GenerateToDictionaryMethod( + private static void GenerateToQueryParametersMethod( SourceBuilder sb, Dictionary properties, List requiredProps) { sb.AppendLine(); - sb.WriteSummary("Converts the parameters to a dictionary for query string serialization."); - sb.AppendLine("public System.Collections.Generic.IReadOnlyDictionary ToDictionary()"); + sb.WriteSummary("Converts the parameters to a sequence of key/value pairs for query string serialization. " + + "Returns IEnumerable> rather than a dictionary so that array properties " + + "(e.g. uris on app.bsky.feed.getPosts) can emit repeated query parameters."); + sb.AppendLine("public System.Collections.Generic.IEnumerable> ToQueryParameters()"); sb.OpenBrace(); - sb.AppendLine("var dict = new System.Collections.Generic.Dictionary();"); + sb.AppendLine("var list = new System.Collections.Generic.List>();"); sb.AppendLine(); foreach (var prop in properties) @@ -88,11 +90,11 @@ private static void GenerateToDictionaryMethod( sb.OpenBrace(); if (prop.Value.Items?.Type == "string") { - sb.AppendLine($"dict.Add(\"{jsonName}\", item);"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", item));"); } else { - sb.AppendLine($"dict.Add(\"{jsonName}\", item?.ToString() ?? \"\");"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", item?.ToString() ?? \"\"));"); } sb.CloseBrace(); sb.CloseBrace(); @@ -102,13 +104,13 @@ private static void GenerateToDictionaryMethod( { if (isRequired) { - sb.AppendLine($"dict[\"{jsonName}\"] = {propName}.ToString().ToLowerInvariant();"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", {propName}.ToString().ToLowerInvariant()));"); } else { sb.AppendLine($"if ({propName} != null)"); sb.OpenBrace(); - sb.AppendLine($"dict[\"{jsonName}\"] = {propName}.Value.ToString().ToLowerInvariant();"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", {propName}.Value.ToString().ToLowerInvariant()));"); sb.CloseBrace(); } } @@ -117,13 +119,13 @@ private static void GenerateToDictionaryMethod( { if (isRequired) { - sb.AppendLine($"dict[\"{jsonName}\"] = {propName}.ToString();"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", {propName}.ToString()));"); } else { sb.AppendLine($"if ({propName} != null)"); sb.OpenBrace(); - sb.AppendLine($"dict[\"{jsonName}\"] = {propName}.Value.ToString();"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", {propName}.Value.ToString()));"); sb.CloseBrace(); } } @@ -137,13 +139,13 @@ private static void GenerateToDictionaryMethod( { if (isRequired) { - sb.AppendLine($"dict[\"{jsonName}\"] = {propName}.ToString(\"o\")!;"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", {propName}.ToString(\"o\")!));"); } else { sb.AppendLine($"if ({propName} != null)"); sb.OpenBrace(); - sb.AppendLine($"dict[\"{jsonName}\"] = {propName}.Value.ToString(\"o\")!;"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", {propName}.Value.ToString(\"o\")!));"); sb.CloseBrace(); } } @@ -152,13 +154,13 @@ private static void GenerateToDictionaryMethod( { if (isRequired) { - sb.AppendLine($"dict[\"{jsonName}\"] = {propName}.ToString()!;"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", {propName}.ToString()!));"); } else { sb.AppendLine($"if ({propName} != null)"); sb.OpenBrace(); - sb.AppendLine($"dict[\"{jsonName}\"] = {propName}.ToString()!;"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", {propName}.ToString()!));"); sb.CloseBrace(); } } @@ -167,13 +169,13 @@ private static void GenerateToDictionaryMethod( { if (isRequired) { - sb.AppendLine($"dict[\"{jsonName}\"] = {propName};"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", {propName}));"); } else { sb.AppendLine($"if ({propName} != null)"); sb.OpenBrace(); - sb.AppendLine($"dict[\"{jsonName}\"] = {propName};"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", {propName}));"); sb.CloseBrace(); } } @@ -184,14 +186,14 @@ private static void GenerateToDictionaryMethod( if (isRequired) { // Required reference type - ToString() is safe - sb.AppendLine($"dict[\"{jsonName}\"] = {propName}.ToString()!;"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", {propName}.ToString()!));"); } else { // Optional reference type - check for null first sb.AppendLine($"if ({propName} != null)"); sb.OpenBrace(); - sb.AppendLine($"dict[\"{jsonName}\"] = {propName}.ToString()!;"); + sb.AppendLine($"list.Add(new System.Collections.Generic.KeyValuePair(\"{jsonName}\", {propName}.ToString()!));"); sb.CloseBrace(); } } @@ -199,7 +201,7 @@ private static void GenerateToDictionaryMethod( sb.AppendLine(); } - sb.AppendLine("return dict;"); + sb.AppendLine("return list;"); sb.CloseBrace(); } @@ -477,7 +479,7 @@ public static void GenerateQueryExtension( sb.Indent(); sb.AppendLine($"\"{currentNsid}\","); sb.AppendLine($"{proxyServiceDid},"); - sb.AppendLine(hasParameters ? "parameters?.ToDictionary()," : "null,"); + sb.AppendLine(hasParameters ? "parameters?.ToQueryParameters()," : "null,"); sb.AppendLine("cancellationToken);"); sb.Unindent(); } @@ -486,7 +488,7 @@ public static void GenerateQueryExtension( sb.AppendLine($"return await client.GetAsync<{outputType}>("); sb.Indent(); sb.AppendLine($"\"{currentNsid}\","); - sb.AppendLine(hasParameters ? "parameters?.ToDictionary()," : "null,"); + sb.AppendLine(hasParameters ? "parameters?.ToQueryParameters()," : "null,"); sb.AppendLine("cancellationToken);"); sb.Unindent(); } @@ -610,7 +612,7 @@ public static void GenerateSubscriptionExtension( sb.AppendLine($"return client.SubscribeAsync<{currentNamespace}.{interfaceName}>("); sb.Indent(); sb.AppendLine($"\"{currentNsid}\","); - sb.AppendLine(hasParameters ? "parameters?.ToDictionary()," : "null,"); + sb.AppendLine(hasParameters ? "parameters?.ToQueryParameters()," : "null,"); sb.AppendLine("cancellationToken);"); sb.Unindent(); sb.CloseBrace(); diff --git a/src/CarpaNet/ATProtoClient.cs b/src/CarpaNet/ATProtoClient.cs index 2b34c9a..42911ec 100644 --- a/src/CarpaNet/ATProtoClient.cs +++ b/src/CarpaNet/ATProtoClient.cs @@ -356,7 +356,7 @@ private ATProtoClient(ATProtoClientOptions options, bool ownsHttpClient, bool ow /// public async Task GetAsync( string nsid, - IReadOnlyDictionary? parameters = null, + IEnumerable>? parameters = null, CancellationToken cancellationToken = default) { ThrowIfDisposed(); @@ -364,32 +364,32 @@ public async Task GetAsync( var url = await XrpcHttpHandler.BuildUrlAsync( this.TokenProvider?.PdsUrl ?? BaseUrl, nsid, parameters, - this.IdentityResolver, cancellationToken).ConfigureAwait(false); + this.IdentityResolver, _logger, cancellationToken).ConfigureAwait(false); using var request = XrpcHttpHandler.CreateGetRequest(url, proxyServiceDid: null, LabelerDids); await AddAuthHeaderAsync(request, cancellationToken).ConfigureAwait(false); var response = await SendWithRetryAsync(request, cancellationToken).ConfigureAwait(false); - return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, _logger, cancellationToken).ConfigureAwait(false); } /// public async Task GetAsync( string nsid, string proxyServiceDid, - IReadOnlyDictionary? parameters = null, + IEnumerable>? parameters = null, CancellationToken cancellationToken = default) { ThrowIfDisposed(); var url = await XrpcHttpHandler.BuildUrlAsync( this.TokenProvider?.PdsUrl ?? BaseUrl, nsid, parameters, - this.IdentityResolver, cancellationToken).ConfigureAwait(false); + this.IdentityResolver, _logger, cancellationToken).ConfigureAwait(false); using var request = XrpcHttpHandler.CreateGetRequest(url, proxyServiceDid, LabelerDids); await AddAuthHeaderAsync(request, cancellationToken).ConfigureAwait(false); var response = await SendWithRetryAsync(request, cancellationToken).ConfigureAwait(false); - return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, _logger, cancellationToken).ConfigureAwait(false); } /// @@ -413,7 +413,7 @@ public async Task PostAsync( await AddAuthHeaderAsync(request, cancellationToken).ConfigureAwait(false); var response = await SendWithRetryAsync(request, cancellationToken).ConfigureAwait(false); - return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, _logger, cancellationToken).ConfigureAwait(false); } /// @@ -437,13 +437,13 @@ public async Task PostAsync( await AddAuthHeaderAsync(request, cancellationToken).ConfigureAwait(false); var response = await SendWithRetryAsync(request, cancellationToken).ConfigureAwait(false); - return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + return await XrpcHttpHandler.ProcessResponseAsync(response, _jsonOptions, _logger, cancellationToken).ConfigureAwait(false); } /// public async IAsyncEnumerable SubscribeAsync( string nsid, - IReadOnlyDictionary? parameters = null, + IEnumerable>? parameters = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ThrowIfDisposed(); diff --git a/src/CarpaNet/Auth/SessionTokenProvider.cs b/src/CarpaNet/Auth/SessionTokenProvider.cs index faf0c8d..e924a99 100644 --- a/src/CarpaNet/Auth/SessionTokenProvider.cs +++ b/src/CarpaNet/Auth/SessionTokenProvider.cs @@ -140,7 +140,7 @@ public async Task LoginAsync( if (!response.IsSuccessStatusCode) { - await XrpcHttpHandler.ThrowForErrorResponseAsync(response, cancellationToken).ConfigureAwait(false); + await XrpcHttpHandler.ThrowForErrorResponseAsync(response, _logger, cancellationToken).ConfigureAwait(false); } #if NET8_0_OR_GREATER @@ -234,7 +234,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default) if (!response.IsSuccessStatusCode) { _logger.LogWarning("Session token refresh failed with HTTP {StatusCode}", (int)response.StatusCode); - await XrpcHttpHandler.ThrowForErrorResponseAsync(response, cancellationToken).ConfigureAwait(false); + await XrpcHttpHandler.ThrowForErrorResponseAsync(response, _logger, cancellationToken).ConfigureAwait(false); } #if NET8_0_OR_GREATER diff --git a/src/CarpaNet/Http/XrpcHttpHandler.cs b/src/CarpaNet/Http/XrpcHttpHandler.cs index 4ea9245..4ce7aae 100644 --- a/src/CarpaNet/Http/XrpcHttpHandler.cs +++ b/src/CarpaNet/Http/XrpcHttpHandler.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using CarpaNet.Identity; using CarpaNet.Xrpc; +using Microsoft.Extensions.Logging; namespace CarpaNet.Http; @@ -29,7 +30,7 @@ public static class XrpcHttpHandler /// The NSID of the endpoint. /// Optional query parameters. /// The complete URL. - public static Uri BuildUrl(Uri baseUrl, string nsid, IReadOnlyDictionary? parameters = null) + public static Uri BuildUrl(Uri baseUrl, string nsid, IEnumerable>? parameters = null) { if (string.IsNullOrEmpty(nsid)) throw new ArgumentException("NSID cannot be null or empty.", nameof(nsid)); @@ -39,7 +40,7 @@ public static Uri BuildUrl(Uri baseUrl, string nsid, IReadOnlyDictionary 0) + if (parameters != null) { var queryBuilder = new StringBuilder(); var first = true; @@ -80,30 +81,49 @@ public static Uri BuildUrl(Uri baseUrl, string nsid, IReadOnlyDictionary BuildUrlAsync( Uri baseUrl, string nsid, - IReadOnlyDictionary? parameters = null, + IEnumerable>? parameters = null, IdentityResolver? identityResolver = null, + ILogger? logger = null, CancellationToken cancellationToken = default) { - if (identityResolver != null - && parameters != null - && parameters.TryGetValue("repo", out var repoValue) - && !string.IsNullOrEmpty(repoValue)) + // Only materialize when we need to both inspect "repo" and re-enumerate for BuildUrl. + // On the common path (no identity resolution), pass the enumerable straight through. + if (identityResolver == null || parameters == null) + { + return BuildUrl(baseUrl, nsid, parameters); + } + + var materialized = parameters as ICollection> + ?? new List>(parameters); + + string? repoValue = null; + foreach (var kvp in materialized) + { + if (kvp.Key == "repo") + { + repoValue = kvp.Value; + break; + } + } + + if (!string.IsNullOrEmpty(repoValue)) { try { - var didDoc = await identityResolver.ResolveAsync(repoValue, cancellationToken).ConfigureAwait(false); + var didDoc = await identityResolver.ResolveAsync(repoValue!, cancellationToken).ConfigureAwait(false); if (didDoc.PdsEndpoint != null) { baseUrl = new Uri(didDoc.PdsEndpoint); } } - catch + catch (Exception ex) { // Resolution failed — fall back to original baseUrl + logger?.LogWarning(ex, "Identity resolution failed for repo {Repo}; falling back to base URL {BaseUrl}", repoValue, baseUrl); } } - return BuildUrl(baseUrl, nsid, parameters); + return BuildUrl(baseUrl, nsid, materialized); } /// @@ -113,7 +133,7 @@ public static async Task BuildUrlAsync( /// The NSID of the subscription endpoint. /// Optional query parameters. /// The WebSocket URL. - public static Uri BuildWebSocketUrl(Uri baseUrl, string nsid, IReadOnlyDictionary? parameters = null) + public static Uri BuildWebSocketUrl(Uri baseUrl, string nsid, IEnumerable>? parameters = null) { var url = BuildUrl(baseUrl, nsid, parameters); var uriBuilder = new UriBuilder(url); @@ -195,12 +215,13 @@ public static void AddCommonHeaders( public static async Task ProcessResponseAsync( HttpResponseMessage response, JsonSerializerOptions jsonOptions, + ILogger? logger = null, CancellationToken cancellationToken = default) { // Check for errors first if (!response.IsSuccessStatusCode) { - await ThrowForErrorResponseAsync(response, cancellationToken).ConfigureAwait(false); + await ThrowForErrorResponseAsync(response, logger, cancellationToken).ConfigureAwait(false); } // Handle 204 No Content @@ -237,6 +258,7 @@ public static async Task ProcessResponseAsync( /// public static async Task ThrowForErrorResponseAsync( HttpResponseMessage response, + ILogger? logger = null, CancellationToken cancellationToken = default) { XrpcError? error = null; @@ -256,9 +278,10 @@ public static async Task ThrowForErrorResponseAsync( #endif } } - catch + catch (Exception ex) { // Ignore deserialization errors - will use status code for message + logger?.LogDebug(ex, "Failed to deserialize XRPC error body for HTTP {StatusCode}; using default error message", (int)response.StatusCode); } var message = error?.GetFormattedMessage() ?? GetDefaultErrorMessage(response.StatusCode); diff --git a/src/CarpaNet/IATProtoClient.cs b/src/CarpaNet/IATProtoClient.cs index 9ed64df..0a6d854 100644 --- a/src/CarpaNet/IATProtoClient.cs +++ b/src/CarpaNet/IATProtoClient.cs @@ -80,7 +80,7 @@ public interface IATProtoClient /// The deserialized response. Task GetAsync( string nsid, - IReadOnlyDictionary? parameters = null, + IEnumerable>? parameters = null, CancellationToken cancellationToken = default); /// @@ -99,7 +99,7 @@ Task GetAsync( Task GetAsync( string nsid, string proxyServiceDid, - IReadOnlyDictionary? parameters = null, + IEnumerable>? parameters = null, CancellationToken cancellationToken = default); /// @@ -142,6 +142,6 @@ Task PostAsync( /// An async enumerable of messages. IAsyncEnumerable SubscribeAsync( string nsid, - IReadOnlyDictionary? parameters = null, + IEnumerable>? parameters = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/CarpaNet/README.md b/src/CarpaNet/README.md index 4739651..62af926 100644 --- a/src/CarpaNet/README.md +++ b/src/CarpaNet/README.md @@ -296,7 +296,7 @@ await client.AppBskyActorGetProfileAsync(parameters); await client.ComAtprotoRepoCreateRecordAsync(input); ``` -Query parameters are gathered into a `*Parameters` class with a `ToDictionary()` method. Procedure inputs use an `*Input` class. Subscriptions generate `SubscribeAsync` extensions returning `IAsyncEnumerable`. +Query parameters are gathered into a `*Parameters` class with a `ToQueryParameters()` method returning `IEnumerable>` (so array params like `uris` can emit repeated keys). Procedure inputs use an `*Input` class. Subscriptions generate `SubscribeAsync` extensions returning `IAsyncEnumerable`. ### Union types (`UnionImplementations.g.cs`) diff --git a/tests/CarpaNet.UnitTests/Generation/ApiGeneratorTests.cs b/tests/CarpaNet.UnitTests/Generation/ApiGeneratorTests.cs new file mode 100644 index 0000000..e4076b9 --- /dev/null +++ b/tests/CarpaNet.UnitTests/Generation/ApiGeneratorTests.cs @@ -0,0 +1,58 @@ +using CarpaNet.Generation; +using CarpaNet.Models; +using Xunit; + +namespace CarpaNet.UnitTests.Generation; + +public class ApiGeneratorTests +{ + + [Fact] + public void GenerateParametersClass_ArrayProperty_EmitsKeyValuePairForEachItem() + { + var registry = new TypeRegistry(); + var doc = new LexiconDocument + { + Id = "app.bsky.feed.getPosts", + Defs = new Dictionary + { + ["main"] = new LexiconDefinition + { + Type = "query", + Parameters = new LexiconDefinition + { + Type = "params", + Properties = new Dictionary + { + ["uris"] = new LexiconDefinition + { + Type = "array", + Items = new LexiconDefinition { Type = "string", Format = "at-uri" }, + }, + }, + RequiredRaw = CreateJsonArray("uris"), + }, + Output = new LexiconIO + { + Encoding = "application/json", + Schema = new LexiconDefinition { Type = "object", Properties = new() }, + }, + }, + }, + }; + registry.RegisterDocument(doc); + + var sb = new SourceBuilder(); + ApiGenerator.GenerateParametersClass(sb, "GetPosts", doc.Defs["main"], doc.Id, registry); + var generated = sb.ToString(); + + // The generated emitter should add a KeyValuePair entry per array item. + Assert.Contains("new System.Collections.Generic.KeyValuePair(\"uris\"", generated); + } + + 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(); + } +} diff --git a/tests/CarpaNet.UnitTests/Http/XrpcHttpHandlerTests.cs b/tests/CarpaNet.UnitTests/Http/XrpcHttpHandlerTests.cs index 2dbe4da..d78486e 100644 --- a/tests/CarpaNet.UnitTests/Http/XrpcHttpHandlerTests.cs +++ b/tests/CarpaNet.UnitTests/Http/XrpcHttpHandlerTests.cs @@ -109,6 +109,43 @@ public async Task GetAsync_WithSpecialCharacters_EncodesCorrectly() Assert.Contains("query=hello", query.TrimStart('?')); } + [Fact] + public async Task GetAsync_WithRepeatedKeys_EmitsRepeatedQueryParams() + { + Uri? capturedUri = null; + var handler = new MockHttpMessageHandler((request, ct) => + { + capturedUri = request.RequestUri; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }); + }); + + using var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://bsky.social") }; + using var client = CreateClient(httpClient, new Uri("https://bsky.social")); + + var parameters = new List> + { + new("uris", "at://did:plc:abc/app.bsky.feed.post/1"), + new("uris", "at://did:plc:abc/app.bsky.feed.post/2"), + }; + + await client.GetAsync("app.bsky.feed.getPosts", parameters); + + Assert.NotNull(capturedUri); + var query = capturedUri!.Query.TrimStart('?'); + var urisCount = 0; + foreach (var pair in query.Split('&')) + { + if (pair.StartsWith("uris=", StringComparison.Ordinal)) + urisCount++; + } + Assert.Equal(2, urisCount); + Assert.Contains("uris=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.bsky.feed.post%2F1", query); + Assert.Contains("uris=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.bsky.feed.post%2F2", query); + } + [Fact] public async Task GetAsync_WithEmptyParameterValue_SkipsParameter() { diff --git a/tests/CarpaNet.UnitTests/Identity/IdentityResolverTests.cs b/tests/CarpaNet.UnitTests/Identity/IdentityResolverTests.cs index 35122d7..d177dc0 100644 --- a/tests/CarpaNet.UnitTests/Identity/IdentityResolverTests.cs +++ b/tests/CarpaNet.UnitTests/Identity/IdentityResolverTests.cs @@ -416,10 +416,10 @@ private class MockATProtoClient : IATProtoClient public HttpClient HttpClient => throw new NotImplementedException(); - public Task GetAsync(string nsid, IReadOnlyDictionary? parameters = null, CancellationToken cancellationToken = default) + public Task GetAsync(string nsid, IEnumerable>? parameters = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task GetAsync(string nsid, string proxyServiceDid, IReadOnlyDictionary? parameters = null, CancellationToken cancellationToken = default) + public Task GetAsync(string nsid, string proxyServiceDid, IEnumerable>? parameters = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task PostAsync(string nsid, TInput? input, CancellationToken cancellationToken = default) @@ -428,7 +428,7 @@ public Task PostAsync(string nsid, TInput? input, Canc public Task PostAsync(string nsid, string proxyServiceDid, TInput? input, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public IAsyncEnumerable SubscribeAsync(string nsid, IReadOnlyDictionary? parameters = null, CancellationToken cancellationToken = default) + public IAsyncEnumerable SubscribeAsync(string nsid, IEnumerable>? parameters = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); }