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