diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml
index ccaa8a7..ea0ce16 100644
--- a/.github/workflows/nuget.yml
+++ b/.github/workflows/nuget.yml
@@ -31,6 +31,7 @@ jobs:
dotnet pack src/CarpaNet/CarpaNet.csproj --configuration Release --no-build --output nupkg
dotnet pack src/CarpaNet.OAuth/CarpaNet.OAuth.csproj --configuration Release --no-build --output nupkg
dotnet pack src/CarpaNet.Jetstream/CarpaNet.Jetstream.csproj --configuration Release --no-build --output nupkg
+ dotnet pack src/CarpaNet.AspNetCore/CarpaNet.AspNetCore.csproj --configuration Release --no-build --output nupkg
- name: Upload Nuget
uses: actions/upload-artifact@v4
diff --git a/CarpaNet.Samples.slnx b/CarpaNet.Samples.slnx
index 43dce93..92984d7 100644
--- a/CarpaNet.Samples.slnx
+++ b/CarpaNet.Samples.slnx
@@ -4,12 +4,14 @@
+
+
diff --git a/CarpaNet.slnx b/CarpaNet.slnx
index bfd6854..a60a3ce 100644
--- a/CarpaNet.slnx
+++ b/CarpaNet.slnx
@@ -5,6 +5,7 @@
+
diff --git a/samples/XrpcServer/Controllers/MyIdentityController.cs b/samples/XrpcServer/Controllers/MyIdentityController.cs
new file mode 100644
index 0000000..99720df
--- /dev/null
+++ b/samples/XrpcServer/Controllers/MyIdentityController.cs
@@ -0,0 +1,30 @@
+using CarpaNet.AspNetCore;
+using Microsoft.AspNetCore.Http.HttpResults;
+
+namespace XrpcServer.Controllers;
+
+///
+/// Sample implementation of the generated abstract IdentityController.
+///
+public class MyIdentityController : Xrpc.ComAtproto.Identity.IdentityController
+{
+ ///
+ public override Task, ATErrorResult>> ResolveHandleAsync(
+ string handle,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrEmpty(handle))
+ {
+ return Task.FromResult, ATErrorResult>>(
+ ATErrorResult.BadRequest("Handle is required"));
+ }
+
+ var output = new ComAtproto.Identity.ResolveHandleOutput
+ {
+ Did = new CarpaNet.ATDid("did:plc:example123"),
+ };
+
+ return Task.FromResult, ATErrorResult>>(
+ TypedResults.Ok(output));
+ }
+}
diff --git a/samples/XrpcServer/Controllers/MyServerController.cs b/samples/XrpcServer/Controllers/MyServerController.cs
new file mode 100644
index 0000000..1d4d9b1
--- /dev/null
+++ b/samples/XrpcServer/Controllers/MyServerController.cs
@@ -0,0 +1,25 @@
+using CarpaNet.AspNetCore;
+using Microsoft.AspNetCore.Http.HttpResults;
+
+namespace XrpcServer.Controllers;
+
+///
+/// Sample implementation of the generated abstract ServerController.
+/// Demonstrates how to implement XRPC server endpoints using CarpaNet-generated controllers.
+///
+public class MyServerController : Xrpc.ComAtproto.Server.ServerController
+{
+ ///
+ public override Task, ATErrorResult>> DescribeServerAsync(
+ CancellationToken cancellationToken = default)
+ {
+ var output = new ComAtproto.Server.DescribeServerOutput
+ {
+ AvailableUserDomains = new List { ".example.com" },
+ Did = new CarpaNet.ATDid("did:web:example.com"),
+ };
+
+ return Task.FromResult, ATErrorResult>>(
+ TypedResults.Ok(output));
+ }
+}
diff --git a/samples/XrpcServer/Program.cs b/samples/XrpcServer/Program.cs
new file mode 100644
index 0000000..7957835
--- /dev/null
+++ b/samples/XrpcServer/Program.cs
@@ -0,0 +1,6 @@
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddControllers();
+
+var app = builder.Build();
+app.MapControllers();
+app.Run();
diff --git a/samples/XrpcServer/README.md b/samples/XrpcServer/README.md
new file mode 100644
index 0000000..0a4fca5
--- /dev/null
+++ b/samples/XrpcServer/README.md
@@ -0,0 +1,3 @@
+# XrpcServer
+
+A sample application for generating [XRPC](https://docs.bsky.app/docs/api/at-protocol-xrpc-api) endpoints via the CarpaNet source generator.
\ No newline at end of file
diff --git a/samples/XrpcServer/XrpcServer.csproj b/samples/XrpcServer/XrpcServer.csproj
new file mode 100644
index 0000000..ce285ac
--- /dev/null
+++ b/samples/XrpcServer/XrpcServer.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+ true
+ true
+ ATProtoJsonContext
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CarpaNet.AspNetCore/ATErrorResult.cs b/src/CarpaNet.AspNetCore/ATErrorResult.cs
new file mode 100644
index 0000000..4f9a582
--- /dev/null
+++ b/src/CarpaNet.AspNetCore/ATErrorResult.cs
@@ -0,0 +1,112 @@
+//
+#nullable enable
+
+using Microsoft.AspNetCore.Http;
+
+namespace CarpaNet.AspNetCore;
+
+///
+/// ATProtocol XRPC error result implementing .
+///
+public sealed class ATErrorResult : IResult
+{
+ ///
+ /// Gets the HTTP status code.
+ ///
+ public int StatusCode { get; private init; }
+
+ ///
+ /// Gets the error type identifier.
+ ///
+ public string? Error { get; private init; }
+
+ ///
+ /// Gets the human-readable error message.
+ ///
+ public string? Message { get; private init; }
+
+ ///
+ /// Creates a Bad Request (400) error.
+ ///
+ public static ATErrorResult BadRequest(string? message = null)
+ => new() { StatusCode = 400, Error = "BadRequest", Message = message ?? "Bad Request" };
+
+ ///
+ /// Creates an Unauthorized (401) error.
+ ///
+ public static ATErrorResult Unauthorized(string? message = null)
+ => new() { StatusCode = 401, Error = "AuthMissing", Message = message ?? "Authentication Required" };
+
+ ///
+ /// Creates a Forbidden (403) error.
+ ///
+ public static ATErrorResult Forbidden(string? message = null)
+ => new() { StatusCode = 403, Error = "Forbidden", Message = message ?? "Forbidden" };
+
+ ///
+ /// Creates a Not Found (404) error.
+ ///
+ public static ATErrorResult NotFound(string? message = null)
+ => new() { StatusCode = 404, Error = "NotFound", Message = message ?? "Not Found" };
+
+ ///
+ /// Creates a Payload Too Large (413) error.
+ ///
+ public static ATErrorResult PayloadTooLarge(string? message = null)
+ => new() { StatusCode = 413, Error = "PayloadTooLarge", Message = message ?? "Payload Too Large" };
+
+ ///
+ /// Creates a Too Many Requests (429) error.
+ ///
+ public static ATErrorResult TooManyRequests(string? message = null)
+ => new() { StatusCode = 429, Error = "TooManyRequests", Message = message ?? "Too Many Requests" };
+
+ ///
+ /// Creates an Internal Server Error (500) error.
+ ///
+ public static ATErrorResult InternalServerError(string? message = null)
+ => new() { StatusCode = 500, Error = "InternalServerError", Message = message ?? "Internal Server Error" };
+
+ ///
+ /// Creates an Internal Server Error (500) from an exception.
+ ///
+ public static ATErrorResult InternalServerError(Exception exception)
+ => new() { StatusCode = 500, Error = "InternalServerError", Message = exception.Message };
+
+ ///
+ /// Creates a Not Implemented (501) error.
+ ///
+ public static ATErrorResult NotImplemented(string? message = null)
+ => new() { StatusCode = 501, Error = "NotImplemented", Message = message ?? "Not Implemented" };
+
+ ///
+ /// Creates a Bad Gateway (502) error.
+ ///
+ public static ATErrorResult BadGateway(string? message = null)
+ => new() { StatusCode = 502, Error = "BadGateway", Message = message ?? "Bad Gateway" };
+
+ ///
+ /// Creates a Service Unavailable (503) error.
+ ///
+ public static ATErrorResult ServiceUnavailable(string? message = null)
+ => new() { StatusCode = 503, Error = "ServiceUnavailable", Message = message ?? "Service Unavailable" };
+
+ ///
+ /// Creates a Gateway Timeout (504) error.
+ ///
+ public static ATErrorResult GatewayTimeout(string? message = null)
+ => new() { StatusCode = 504, Error = "GatewayTimeout", Message = message ?? "Gateway Timeout" };
+
+ ///
+ public Task ExecuteAsync(HttpContext httpContext)
+ {
+ httpContext.Response.StatusCode = StatusCode;
+ httpContext.Response.ContentType = "application/json";
+ var error = new XrpcError
+ {
+ Error = this.Error,
+ Message = this.Message,
+ };
+ return httpContext.Response.WriteAsync(error.ToJson());
+ }
+}
diff --git a/src/CarpaNet.AspNetCore/CarpaNet.AspNetCore.csproj b/src/CarpaNet.AspNetCore/CarpaNet.AspNetCore.csproj
new file mode 100644
index 0000000..567295e
--- /dev/null
+++ b/src/CarpaNet.AspNetCore/CarpaNet.AspNetCore.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0;net9.0;net10.0
+ enable
+ enable
+ latest
+ true
+ CarpaNet.AspNetCore
+ ASP.NET Core XRPC endpoint support for CarpaNet ATProtocol library.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CarpaNet.AspNetCore/README.md b/src/CarpaNet.AspNetCore/README.md
new file mode 100644
index 0000000..8437591
--- /dev/null
+++ b/src/CarpaNet.AspNetCore/README.md
@@ -0,0 +1,135 @@
+# CarpaNet.AspNetCore
+
+[](https://www.nuget.org/packages/CarpaNet.AspNetCore/) 
+
+ASP.NET Core XRPC server endpoint support for [CarpaNet](https://github.com/drasticactions/carpanet). Generate abstract ATProtocol XRPC controllers from lexicon definitions and implement your own ATProtocol server endpoints.
+
+## Installation
+
+```
+dotnet add package CarpaNet.AspNetCore
+```
+
+You also need the core `CarpaNet` package (with source generator) to generate the controllers and data model types.
+
+## Quick Start
+
+### 1. Enable XRPC endpoint generation
+
+Set `CarpaNet_EmitXrpcEndpoints` to `true` in your ASP.NET Core project:
+
+```xml
+
+ true
+ true
+
+
+
+
+
+
+```
+
+This tells the CarpaNet source generator to produce abstract controller classes alongside the usual data model types.
+
+### 2. Implement the generated controllers
+
+The generator creates abstract controllers grouped by NSID namespace. Subclass them and implement the abstract methods:
+
+```csharp
+using CarpaNet.AspNetCore;
+using Microsoft.AspNetCore.Http.HttpResults;
+
+public class MyServerController : Xrpc.ComAtproto.Server.ServerController
+{
+ public override Task, ATErrorResult>> DescribeServerAsync(
+ CancellationToken cancellationToken = default)
+ {
+ var output = new ComAtproto.Server.DescribeServerOutput
+ {
+ AvailableUserDomains = new List { ".example.com" },
+ Did = new CarpaNet.ATDid("did:web:example.com"),
+ };
+
+ return Task.FromResult, ATErrorResult>>(
+ TypedResults.Ok(output));
+ }
+}
+```
+
+### 3. Wire up ASP.NET
+
+```csharp
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddControllers();
+
+var app = builder.Build();
+app.MapControllers();
+app.Run();
+```
+
+Your endpoints are now available at `/xrpc/{nsid}` (e.g., `/xrpc/com.atproto.server.describeServer`).
+
+## How It Works
+
+The CarpaNet source generator reads ATProtocol lexicon JSON files and produces:
+
+- **Data model classes** — Records, objects, input/output types (generated by the existing pipeline)
+- **Abstract controllers** — One per NSID namespace group (e.g., `ServerController` for `com.atproto.server.*`)
+
+Lexicon queries map to `[HttpGet]` with `[FromQuery]` parameters. Lexicon procedures map to `[HttpPost]` with `[FromBody]` input.
+
+### Generated Controller Pattern
+
+```
+Lexicon: com.atproto.identity.resolveHandle (query)
+ ↓
+Xrpc.ComAtproto.Identity.IdentityController (abstract)
+ → ResolveHandleAsync([FromQuery] string handle, CancellationToken ct)
+ → Returns Task, ATErrorResult>>
+```
+
+```
+Lexicon: com.atproto.repo.createRecord (procedure)
+ ↓
+Xrpc.ComAtproto.Repo.RepoController (abstract)
+ → CreateRecordAsync([FromBody] CreateRecordInput input, CancellationToken ct)
+ → Returns Task, ATErrorResult>>
+```
+
+## Error Handling
+
+Use `ATErrorResult` factory methods to return ATProtocol-compliant error responses:
+
+```csharp
+// Returns { "error": "NotFound", "message": "Record not found" } with HTTP 404
+return ATErrorResult.NotFound("Record not found");
+
+// Returns { "error": "AuthMissing", "message": "Authentication Required" } with HTTP 401
+return ATErrorResult.Unauthorized();
+
+// Returns { "error": "BadRequest", "message": "Invalid handle format" } with HTTP 400
+return ATErrorResult.BadRequest("Invalid handle format");
+```
+
+Available factory methods:
+
+| Method | Status Code | Error Type |
+|---|---|---|
+| `BadRequest()` | 400 | BadRequest |
+| `Unauthorized()` | 401 | AuthMissing |
+| `Forbidden()` | 403 | Forbidden |
+| `NotFound()` | 404 | NotFound |
+| `PayloadTooLarge()` | 413 | PayloadTooLarge |
+| `TooManyRequests()` | 429 | TooManyRequests |
+| `InternalServerError()` | 500 | InternalServerError |
+| `NotImplemented()` | 501 | NotImplemented |
+| `BadGateway()` | 502 | BadGateway |
+| `ServiceUnavailable()` | 503 | ServiceUnavailable |
+| `GatewayTimeout()` | 504 | GatewayTimeout |
+
+## MSBuild Properties
+
+| Property | Description | Default |
+|---|---|---|
+| `CarpaNet_EmitXrpcEndpoints` | Enable XRPC controller generation | `false` |
diff --git a/src/CarpaNet.AspNetCore/XrpcError.cs b/src/CarpaNet.AspNetCore/XrpcError.cs
new file mode 100644
index 0000000..8dbd0b4
--- /dev/null
+++ b/src/CarpaNet.AspNetCore/XrpcError.cs
@@ -0,0 +1,34 @@
+//
+#nullable enable
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+
+namespace CarpaNet.AspNetCore;
+
+///
+/// ATProtocol XRPC error response body.
+///
+public sealed class XrpcError
+{
+ ///
+ /// Gets the error type identifier.
+ ///
+ [JsonPropertyName("error")]
+ public string? Error { get; init; }
+
+ ///
+ /// Gets the human-readable error message.
+ ///
+ [JsonPropertyName("message")]
+ public string? Message { get; init; }
+
+ ///
+ /// Serializes this error to a JSON string.
+ ///
+ public string ToJson()
+ {
+ return JsonSerializer.Serialize(this, XrpcJsonContext.Default.XrpcError);
+ }
+}
diff --git a/src/CarpaNet.AspNetCore/XrpcJsonContext.cs b/src/CarpaNet.AspNetCore/XrpcJsonContext.cs
new file mode 100644
index 0000000..310b005
--- /dev/null
+++ b/src/CarpaNet.AspNetCore/XrpcJsonContext.cs
@@ -0,0 +1,11 @@
+//
+#nullable enable
+
+using System.Text.Json.Serialization;
+
+namespace CarpaNet.AspNetCore;
+
+[JsonSerializable(typeof(XrpcError))]
+internal sealed partial class XrpcJsonContext : JsonSerializerContext
+{
+}
diff --git a/src/CarpaNet.SourceGen/Generation/XrpcEndpointGenerator.cs b/src/CarpaNet.SourceGen/Generation/XrpcEndpointGenerator.cs
new file mode 100644
index 0000000..f67793c
--- /dev/null
+++ b/src/CarpaNet.SourceGen/Generation/XrpcEndpointGenerator.cs
@@ -0,0 +1,423 @@
+//
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using CarpaNet.Models;
+using CarpaNet.Utilities;
+
+namespace CarpaNet.Generation;
+
+///
+/// Generates abstract ASP.NET controller classes for XRPC server endpoints.
+///
+public static class XrpcEndpointGenerator
+{
+ ///
+ /// Generates all XRPC controllers from the given namespace-grouped lexicons.
+ ///
+ public static string Generate(
+ Dictionary> byNamespace,
+ TypeRegistry registry,
+ GeneratorOptions options)
+ {
+ // Collect all query/procedure definitions and group by controller (first 3 NSID segments)
+ var controllerGroups = new Dictionary>();
+
+ foreach (var kvp in byNamespace)
+ {
+ var ns = kvp.Key;
+ foreach (var (nsid, doc) in kvp.Value)
+ {
+ if (doc.Defs == null) continue;
+
+ foreach (var def in doc.Defs)
+ {
+ if (def.Value.Type != "query" && def.Value.Type != "procedure")
+ continue;
+
+ var groupKey = GetControllerGroup(nsid);
+ if (groupKey == null) continue;
+
+ if (!controllerGroups.TryGetValue(groupKey, out var list))
+ {
+ list = new List();
+ controllerGroups[groupKey] = list;
+ }
+
+ var className = NsidHelper.ToTypeName(nsid);
+ list.Add(new EndpointInfo
+ {
+ Nsid = nsid,
+ ClassName = className,
+ Definition = def.Value,
+ Namespace = ns,
+ });
+ }
+ }
+ }
+
+ if (controllerGroups.Count == 0)
+ return string.Empty;
+
+ var sb = new SourceBuilder();
+ sb.WriteHeader();
+
+ foreach (var kvp in controllerGroups.OrderBy(x => x.Key))
+ {
+ var groupNsid = kvp.Key;
+ var endpoints = kvp.Value;
+ var controllerName = GetControllerName(groupNsid);
+ var controllerNamespace = GetControllerNamespace(groupNsid, options.RootNamespace);
+
+ sb.WriteNamespaceBlock(controllerNamespace);
+ GenerateController(sb, groupNsid, controllerName, endpoints, registry, options);
+ sb.CloseBrace(); // namespace
+ sb.AppendLine();
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Extracts the controller group from an NSID (first 3 segments).
+ /// For example, "app.bsky.actor.getProfile" returns "app.bsky.actor".
+ ///
+ public static string? GetControllerGroup(string nsid)
+ {
+ var parts = nsid.Split('.');
+ if (parts.Length < 4) return null;
+ return $"{parts[0]}.{parts[1]}.{parts[2]}";
+ }
+
+ ///
+ /// Gets the controller class name from a group NSID.
+ /// For example, "app.bsky.actor" returns "ActorController".
+ ///
+ public static string GetControllerName(string groupNsid)
+ {
+ var parts = groupNsid.Split('.');
+ return NsidHelper.ToPascalCase(parts[parts.Length - 1]) + "Controller";
+ }
+
+ ///
+ /// Gets the namespace for a controller.
+ /// For example, "app.bsky.actor" with root "MyApp" returns "MyApp.Xrpc.AppBsky.Actor".
+ ///
+ public static string GetControllerNamespace(string groupNsid, string? rootNamespace)
+ {
+ var nsidNamespace = NsidHelper.ToNamespace(groupNsid + ".placeholder", rootNamespace);
+ if (rootNamespace != null)
+ return $"{rootNamespace}.Xrpc.{nsidNamespace.Substring(rootNamespace.Length + 1)}";
+ return $"Xrpc.{nsidNamespace}";
+ }
+
+ ///
+ /// Generates an abstract controller class for a group of endpoints.
+ ///
+ internal static void GenerateController(
+ SourceBuilder sb,
+ string groupNsid,
+ string controllerName,
+ List endpoints,
+ TypeRegistry registry,
+ GeneratorOptions options)
+ {
+ sb.WriteSummary($"Abstract XRPC controller for {groupNsid} endpoints.");
+ sb.WriteAttribute("Microsoft.AspNetCore.Mvc.ApiController");
+ sb.AppendLine($"public abstract partial class {controllerName} : Microsoft.AspNetCore.Mvc.ControllerBase");
+ sb.OpenBrace();
+
+ for (int i = 0; i < endpoints.Count; i++)
+ {
+ var endpoint = endpoints[i];
+ if (i > 0)
+ sb.AppendLine();
+
+ if (endpoint.Definition.Type == "query")
+ GenerateQueryMethod(sb, endpoint, registry, options);
+ else if (endpoint.Definition.Type == "procedure")
+ GenerateProcedureMethod(sb, endpoint, registry, options);
+ }
+
+ sb.CloseBrace(); // class
+ }
+
+ ///
+ /// Generates an abstract method for a query (HTTP GET) endpoint.
+ ///
+ internal static void GenerateQueryMethod(
+ SourceBuilder sb,
+ EndpointInfo endpoint,
+ TypeRegistry registry,
+ GeneratorOptions options)
+ {
+ var def = endpoint.Definition;
+ var hasOutput = def.Output?.Schema != null;
+ var returnType = GetReturnType(endpoint, registry, hasOutput);
+ var methodName = GetMethodName(endpoint.Nsid) + "Async";
+
+ sb.WriteSummary(def.Description ?? $"XRPC query: {endpoint.Nsid}");
+ sb.WriteAttribute($"Microsoft.AspNetCore.Mvc.HttpGet(\"/xrpc/{endpoint.Nsid}\")");
+ sb.Append("");
+ sb.AppendIndent();
+ sb.Append($"public abstract System.Threading.Tasks.Task<{returnType}> {methodName}(");
+
+ var parameters = BuildQueryParameters(def, endpoint.Nsid, registry);
+ WriteParameterList(sb, parameters);
+
+ sb.Append(");\n");
+ }
+
+ ///
+ /// Generates an abstract method for a procedure (HTTP POST) endpoint.
+ ///
+ internal static void GenerateProcedureMethod(
+ SourceBuilder sb,
+ EndpointInfo endpoint,
+ TypeRegistry registry,
+ GeneratorOptions options)
+ {
+ var def = endpoint.Definition;
+ var hasInput = def.Input?.Schema != null;
+ var hasOutput = def.Output?.Schema != null;
+ var returnType = GetReturnType(endpoint, registry, hasOutput);
+ var methodName = GetMethodName(endpoint.Nsid) + "Async";
+
+ sb.WriteSummary(def.Description ?? $"XRPC procedure: {endpoint.Nsid}");
+ sb.WriteAttribute($"Microsoft.AspNetCore.Mvc.HttpPost(\"/xrpc/{endpoint.Nsid}\")");
+ sb.Append("");
+ sb.AppendIndent();
+ sb.Append($"public abstract System.Threading.Tasks.Task<{returnType}> {methodName}(");
+
+ var parameters = new List();
+
+ if (hasInput)
+ {
+ var inputType = ApiGenerator.ResolveInputType(def, endpoint.Nsid, endpoint.Namespace, endpoint.ClassName, registry);
+ parameters.Add(new ParameterDef
+ {
+ Attribute = "Microsoft.AspNetCore.Mvc.FromBody",
+ Type = $"global::{inputType}",
+ Name = "input",
+ IsOptional = false,
+ });
+ }
+
+ parameters.Add(new ParameterDef
+ {
+ Type = "System.Threading.CancellationToken",
+ Name = "cancellationToken",
+ DefaultValue = "default",
+ IsOptional = true,
+ });
+
+ WriteParameterList(sb, parameters);
+
+ sb.Append(");\n");
+ }
+
+ ///
+ /// Builds the parameter list for a query method from the definition's parameters.
+ ///
+ private static List BuildQueryParameters(
+ LexiconDefinition def,
+ string currentNsid,
+ TypeRegistry registry)
+ {
+ var parameters = new List();
+
+ if (def.Parameters?.Properties != null)
+ {
+ var requiredProps = def.Parameters.Required ?? new List();
+
+ foreach (var prop in def.Parameters.Properties)
+ {
+ var isRequired = requiredProps.Contains(prop.Key);
+ var csharpType = ResolveParameterType(prop.Value, currentNsid, registry);
+ var isArray = prop.Value.Type == "array";
+
+ if (!isRequired && !isArray)
+ {
+ csharpType = MakeNullable(csharpType);
+ }
+
+ parameters.Add(new ParameterDef
+ {
+ Attribute = "Microsoft.AspNetCore.Mvc.FromQuery",
+ AttributeArgs = $"Name = \"{prop.Key}\"",
+ Type = csharpType,
+ Name = NsidHelper.EscapeIdentifier(NsidHelper.ToCamelCase(prop.Key)),
+ DefaultValue = isRequired ? null : "default",
+ IsOptional = !isRequired,
+ });
+ }
+ }
+
+ parameters.Add(new ParameterDef
+ {
+ Type = "System.Threading.CancellationToken",
+ Name = "cancellationToken",
+ DefaultValue = "default",
+ IsOptional = true,
+ });
+
+ return parameters;
+ }
+
+ ///
+ /// Resolves a parameter definition to its C# type.
+ ///
+ internal static string ResolveParameterType(LexiconDefinition def, string currentNsid, TypeRegistry registry)
+ {
+ return def.Type switch
+ {
+ "boolean" => "bool",
+ "integer" => "long",
+ "string" => MapStringFormat(def),
+ "array" => ResolveArrayParameterType(def, currentNsid, registry),
+ "ref" when def.Ref != null => registry.ResolveToCSharpType(def.Ref, currentNsid),
+ _ => "string",
+ };
+ }
+
+ ///
+ /// Maps a string type's format to a C# type for query parameters.
+ ///
+ private static string MapStringFormat(LexiconDefinition def)
+ {
+ if (def.Format == null)
+ return "string";
+
+ return def.Format switch
+ {
+ "at-identifier" => "string",
+ "at-uri" => "string",
+ "cid" => "string",
+ "datetime" => "string",
+ "did" => "string",
+ "handle" => "string",
+ "nsid" => "string",
+ "tid" => "string",
+ "record-key" => "string",
+ "uri" => "string",
+ "language" => "string",
+ _ => "string",
+ };
+ }
+
+ ///
+ /// Resolves an array parameter type.
+ ///
+ private static string ResolveArrayParameterType(LexiconDefinition def, string currentNsid, TypeRegistry registry)
+ {
+ if (def.Items == null)
+ return "System.Collections.Generic.List";
+
+ var itemType = ResolveParameterType(def.Items, currentNsid, registry);
+ return $"System.Collections.Generic.List<{itemType}>";
+ }
+
+ ///
+ /// Gets the return type for an endpoint.
+ ///
+ private static string GetReturnType(EndpointInfo endpoint, TypeRegistry registry, bool hasOutput)
+ {
+ if (hasOutput)
+ {
+ var outputType = ApiGenerator.ResolveOutputType(
+ endpoint.Definition, endpoint.Nsid, endpoint.Namespace, endpoint.ClassName, registry);
+ return $"Microsoft.AspNetCore.Http.HttpResults.Results, CarpaNet.AspNetCore.ATErrorResult>";
+ }
+ return "Microsoft.AspNetCore.Http.HttpResults.Results";
+ }
+
+ ///
+ /// Gets the method name from an NSID. Uses the last segment in PascalCase.
+ /// For example, "app.bsky.actor.getProfile" returns "GetProfile".
+ ///
+ public static string GetMethodName(string nsid)
+ {
+ var parts = nsid.Split('.');
+ return NsidHelper.ToPascalCase(parts[parts.Length - 1]);
+ }
+
+ ///
+ /// Writes a parameter list inline.
+ ///
+ private static void WriteParameterList(SourceBuilder sb, List parameters)
+ {
+ if (parameters.Count == 0)
+ return;
+
+ sb.Append("\n");
+ sb.Indent();
+
+ for (int i = 0; i < parameters.Count; i++)
+ {
+ var param = parameters[i];
+ sb.AppendIndent();
+
+ if (param.Attribute != null)
+ {
+ if (param.AttributeArgs != null)
+ sb.Append($"[{param.Attribute}({param.AttributeArgs})] ");
+ else
+ sb.Append($"[{param.Attribute}] ");
+ }
+
+ sb.Append($"{param.Type} {param.Name}");
+
+ if (param.DefaultValue != null)
+ sb.Append($" = {param.DefaultValue}");
+
+ if (i < parameters.Count - 1)
+ sb.Append(",");
+
+ sb.Append("\n");
+ }
+
+ sb.Unindent();
+ sb.AppendIndent();
+ }
+
+ ///
+ /// Makes a type nullable by appending '?' if not already nullable.
+ ///
+ private static string MakeNullable(string type)
+ {
+ if (type.EndsWith("?"))
+ return type;
+
+ // Reference types that are already nullable by convention
+ if (type.StartsWith("System.Collections.Generic.List<"))
+ return type + "?";
+
+ return type + "?";
+ }
+
+ ///
+ /// Represents a method parameter to be emitted.
+ ///
+ internal sealed class ParameterDef
+ {
+ public string? Attribute { get; set; }
+ public string? AttributeArgs { get; set; }
+ public string Type { get; set; } = "object";
+ public string Name { get; set; } = "param";
+ public string? DefaultValue { get; set; }
+ public bool IsOptional { get; set; }
+ }
+
+ ///
+ /// Represents a collected endpoint to generate.
+ ///
+ internal sealed class EndpointInfo
+ {
+ public string Nsid { get; set; } = "";
+ public string ClassName { get; set; } = "";
+ public LexiconDefinition Definition { get; set; } = new();
+ public string Namespace { get; set; } = "";
+ }
+}
diff --git a/src/CarpaNet.SourceGen/LexiconGenerator.cs b/src/CarpaNet.SourceGen/LexiconGenerator.cs
index b3cb245..715d4e8 100644
--- a/src/CarpaNet.SourceGen/LexiconGenerator.cs
+++ b/src/CarpaNet.SourceGen/LexiconGenerator.cs
@@ -40,6 +40,11 @@ public sealed class GeneratorOptions
/// Name of the generated JSON resolver context class. Default is "ATProtoJsonContext".
///
public string JsonContextName { get; set; } = "ATProtoJsonContext";
+
+ ///
+ /// Whether to emit XRPC server endpoint controllers. Default is false.
+ ///
+ public bool EmitXrpcEndpoints { get; set; } = false;
}
///
@@ -102,6 +107,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
options.JsonContextName = jsonContextName;
}
+ if (provider.GlobalOptions.TryGetValue("build_property.CarpaNet_EmitXrpcEndpoints", out var emitXrpc)
+ && bool.TryParse(emitXrpc, out var emitXrpcValue))
+ {
+ options.EmitXrpcEndpoints = emitXrpcValue;
+ }
+
return options;
});
@@ -205,7 +216,17 @@ private static void GenerateSource(
context.AddSource("ATProtoClientFactory.g.cs", SourceText.From(factorySource, Encoding.UTF8));
}
- // PHASE 8: Report diagnostics for unresolved lexicon references
+ // PHASE 8: Generate XRPC endpoint controllers (opt-in)
+ if (options.EmitXrpcEndpoints)
+ {
+ var xrpcSource = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+ if (!string.IsNullOrEmpty(xrpcSource))
+ {
+ context.AddSource("XrpcControllers.g.cs", SourceText.From(xrpcSource, Encoding.UTF8));
+ }
+ }
+
+ // PHASE 9: Report diagnostics for unresolved lexicon references
var unresolvedRefs = registry.GetUnresolvedReferences();
if (unresolvedRefs.Count > 0)
{
diff --git a/src/CarpaNet/build/CarpaNet.targets b/src/CarpaNet/build/CarpaNet.targets
index 5225378..f34bb0d 100644
--- a/src/CarpaNet/build/CarpaNet.targets
+++ b/src/CarpaNet/build/CarpaNet.targets
@@ -33,6 +33,7 @@
+
diff --git a/tests/CarpaNet.UnitTests/Generation/XrpcEndpointGeneratorTests.cs b/tests/CarpaNet.UnitTests/Generation/XrpcEndpointGeneratorTests.cs
new file mode 100644
index 0000000..8b98c6e
--- /dev/null
+++ b/tests/CarpaNet.UnitTests/Generation/XrpcEndpointGeneratorTests.cs
@@ -0,0 +1,667 @@
+using CarpaNet.Generation;
+using CarpaNet.Models;
+using Xunit;
+
+namespace CarpaNet.UnitTests.Generation;
+
+public class XrpcEndpointGeneratorTests
+{
+ [Fact]
+ public void GenerateQueryEndpoint_WithParameters_EmitsHttpGetAndFromQuery()
+ {
+ var (byNamespace, registry) = CreateQueryWithParameters();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = true };
+
+ var result = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+
+ Assert.Contains("[Microsoft.AspNetCore.Mvc.HttpGet(\"/xrpc/app.bsky.actor.getProfile\")]", result);
+ Assert.Contains("[Microsoft.AspNetCore.Mvc.FromQuery(Name = \"actor\")]", result);
+ Assert.Contains("string actor", result);
+ Assert.Contains("GetProfileAsync", result);
+ }
+
+ [Fact]
+ public void GenerateQueryEndpoint_NoParameters_EmitsOnlyCancellationToken()
+ {
+ var (byNamespace, registry) = CreateQueryNoParameters();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = true };
+
+ var result = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+
+ Assert.Contains("[Microsoft.AspNetCore.Mvc.HttpGet(\"/xrpc/app.bsky.actor.getSuggestions\")]", result);
+ Assert.Contains("System.Threading.CancellationToken cancellationToken", result);
+ Assert.DoesNotContain("FromQuery", result);
+ }
+
+ [Fact]
+ public void GenerateProcedureEndpoint_WithInputAndOutput_EmitsHttpPostAndFromBody()
+ {
+ var (byNamespace, registry) = CreateProcedureWithInputAndOutput();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = true };
+
+ var result = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+
+ Assert.Contains("[Microsoft.AspNetCore.Mvc.HttpPost(\"/xrpc/com.atproto.repo.createRecord\")]", result);
+ Assert.Contains("[Microsoft.AspNetCore.Mvc.FromBody]", result);
+ Assert.Contains("CreateRecordInput input", result);
+ Assert.Contains("Ok<", result);
+ }
+
+ [Fact]
+ public void GenerateProcedureEndpoint_NoOutput_ReturnsOkWithoutGenericType()
+ {
+ var (byNamespace, registry) = CreateProcedureNoOutput();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = true };
+
+ var result = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+
+ Assert.Contains("[Microsoft.AspNetCore.Mvc.HttpPost(\"/xrpc/com.atproto.repo.deleteRecord\")]", result);
+ Assert.Contains("Results", result);
+ }
+
+ [Fact]
+ public void GenerateProcedureEndpoint_NoInput_OmitsFromBody()
+ {
+ var (byNamespace, registry) = CreateProcedureNoInput();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = true };
+
+ var result = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+
+ Assert.Contains("[Microsoft.AspNetCore.Mvc.HttpPost(\"/xrpc/com.atproto.server.refreshSession\")]", result);
+ Assert.DoesNotContain("FromBody", result);
+ Assert.Contains("System.Threading.CancellationToken cancellationToken", result);
+ }
+
+ [Fact]
+ public void ControllerGrouping_SameGroup_ProducesOneController()
+ {
+ var (byNamespace, registry) = CreateMultipleEndpointsSameGroup();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = true };
+
+ var result = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+
+ // Should have exactly one ActorController
+ var controllerCount = CountOccurrences(result, "class ActorController");
+ Assert.Equal(1, controllerCount);
+
+ // Both methods should be present
+ Assert.Contains("GetProfileAsync", result);
+ Assert.Contains("GetPreferencesAsync", result);
+ }
+
+ [Fact]
+ public void ControllerGrouping_DifferentGroups_ProducesSeparateControllers()
+ {
+ var (byNamespace, registry) = CreateEndpointsDifferentGroups();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = true };
+
+ var result = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+
+ Assert.Contains("class ActorController", result);
+ Assert.Contains("class RepoController", result);
+ }
+
+ [Fact]
+ public void EmitXrpcEndpoints_False_GeneratesNothing()
+ {
+ var (byNamespace, registry) = CreateQueryWithParameters();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = false };
+
+ // The Generate method is only called when EmitXrpcEndpoints is true,
+ // but we can verify the option exists
+ Assert.False(options.EmitXrpcEndpoints);
+ }
+
+ [Fact]
+ public void RefInputType_ResolvesCorrectly()
+ {
+ var (byNamespace, registry) = CreateProcedureWithRefInput();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = true };
+
+ var result = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+
+ // The ref input should resolve to the referenced type
+ Assert.Contains("FromBody", result);
+ Assert.Contains("input", result);
+ }
+
+ [Fact]
+ public void ArrayQueryParameter_GeneratesList()
+ {
+ var (byNamespace, registry) = CreateQueryWithArrayParameter();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = true };
+
+ var result = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+
+ Assert.Contains("System.Collections.Generic.List", result);
+ Assert.Contains("FromQuery", result);
+ }
+
+ [Fact]
+ public void GetControllerGroup_ReturnsFirstThreeSegments()
+ {
+ Assert.Equal("app.bsky.actor", XrpcEndpointGenerator.GetControllerGroup("app.bsky.actor.getProfile"));
+ Assert.Equal("com.atproto.server", XrpcEndpointGenerator.GetControllerGroup("com.atproto.server.createSession"));
+ Assert.Null(XrpcEndpointGenerator.GetControllerGroup("too.short"));
+ }
+
+ [Fact]
+ public void GetControllerName_ReturnsPascalCaseWithSuffix()
+ {
+ Assert.Equal("ActorController", XrpcEndpointGenerator.GetControllerName("app.bsky.actor"));
+ Assert.Equal("ServerController", XrpcEndpointGenerator.GetControllerName("com.atproto.server"));
+ }
+
+ [Fact]
+ public void GetMethodName_ReturnsLastSegmentPascalCase()
+ {
+ Assert.Equal("GetProfile", XrpcEndpointGenerator.GetMethodName("app.bsky.actor.getProfile"));
+ Assert.Equal("CreateRecord", XrpcEndpointGenerator.GetMethodName("com.atproto.repo.createRecord"));
+ }
+
+ [Fact]
+ public void OptionalQueryParameters_AreNullableWithDefault()
+ {
+ var (byNamespace, registry) = CreateQueryWithOptionalParameters();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = true };
+
+ var result = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+
+ Assert.Contains("long?", result);
+ Assert.Contains("= default", result);
+ }
+
+ [Fact]
+ public void ControllerNamespace_IncludesXrpcPrefix()
+ {
+ var (byNamespace, registry) = CreateQueryWithParameters();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = true };
+
+ var result = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+
+ Assert.Contains("namespace Xrpc.AppBsky.Actor", result);
+ }
+
+ [Fact]
+ public void Controller_IsAbstractPartial()
+ {
+ var (byNamespace, registry) = CreateQueryWithParameters();
+ var options = new GeneratorOptions { EmitXrpcEndpoints = true };
+
+ var result = XrpcEndpointGenerator.Generate(byNamespace, registry, options);
+
+ Assert.Contains("public abstract partial class ActorController", result);
+ }
+
+ #region Test Helpers
+
+ private static (Dictionary>, TypeRegistry) CreateQueryWithParameters()
+ {
+ var registry = new TypeRegistry();
+ var doc = new LexiconDocument
+ {
+ Id = "app.bsky.actor.getProfile",
+ Defs = new Dictionary
+ {
+ ["main"] = new LexiconDefinition
+ {
+ Type = "query",
+ Description = "Get detailed profile view of an actor.",
+ Parameters = new LexiconDefinition
+ {
+ Type = "params",
+ Properties = new Dictionary
+ {
+ ["actor"] = new LexiconDefinition { Type = "string", Format = "at-identifier" }
+ },
+ RequiredRaw = CreateJsonArray("actor"),
+ },
+ Output = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["did"] = new LexiconDefinition { Type = "string", Format = "did" },
+ ["handle"] = new LexiconDefinition { Type = "string", Format = "handle" },
+ }
+ }
+ }
+ }
+ }
+ };
+
+ registry.RegisterDocument(doc);
+ var byNamespace = GroupByNamespace(doc, registry);
+ return (byNamespace, registry);
+ }
+
+ private static (Dictionary>, TypeRegistry) CreateQueryNoParameters()
+ {
+ var registry = new TypeRegistry();
+ var doc = new LexiconDocument
+ {
+ Id = "app.bsky.actor.getSuggestions",
+ Defs = new Dictionary
+ {
+ ["main"] = new LexiconDefinition
+ {
+ Type = "query",
+ Output = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["actors"] = new LexiconDefinition { Type = "array", Items = new LexiconDefinition { Type = "string" } }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ registry.RegisterDocument(doc);
+ var byNamespace = GroupByNamespace(doc, registry);
+ return (byNamespace, registry);
+ }
+
+ private static (Dictionary>, TypeRegistry) CreateProcedureWithInputAndOutput()
+ {
+ var registry = new TypeRegistry();
+ var doc = new LexiconDocument
+ {
+ Id = "com.atproto.repo.createRecord",
+ Defs = new Dictionary
+ {
+ ["main"] = new LexiconDefinition
+ {
+ Type = "procedure",
+ Input = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["repo"] = new LexiconDefinition { Type = "string", Format = "at-identifier" },
+ ["collection"] = new LexiconDefinition { Type = "string", Format = "nsid" },
+ }
+ }
+ },
+ Output = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["uri"] = new LexiconDefinition { Type = "string", Format = "at-uri" },
+ ["cid"] = new LexiconDefinition { Type = "string", Format = "cid" },
+ }
+ }
+ }
+ }
+ }
+ };
+
+ registry.RegisterDocument(doc);
+ var byNamespace = GroupByNamespace(doc, registry);
+ return (byNamespace, registry);
+ }
+
+ private static (Dictionary>, TypeRegistry) CreateProcedureNoOutput()
+ {
+ var registry = new TypeRegistry();
+ var doc = new LexiconDocument
+ {
+ Id = "com.atproto.repo.deleteRecord",
+ Defs = new Dictionary
+ {
+ ["main"] = new LexiconDefinition
+ {
+ Type = "procedure",
+ Input = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["repo"] = new LexiconDefinition { Type = "string", Format = "at-identifier" },
+ }
+ }
+ }
+ }
+ }
+ };
+
+ registry.RegisterDocument(doc);
+ var byNamespace = GroupByNamespace(doc, registry);
+ return (byNamespace, registry);
+ }
+
+ private static (Dictionary>, TypeRegistry) CreateProcedureNoInput()
+ {
+ var registry = new TypeRegistry();
+ var doc = new LexiconDocument
+ {
+ Id = "com.atproto.server.refreshSession",
+ Defs = new Dictionary
+ {
+ ["main"] = new LexiconDefinition
+ {
+ Type = "procedure",
+ Output = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["accessJwt"] = new LexiconDefinition { Type = "string" },
+ ["refreshJwt"] = new LexiconDefinition { Type = "string" },
+ }
+ }
+ }
+ }
+ }
+ };
+
+ registry.RegisterDocument(doc);
+ var byNamespace = GroupByNamespace(doc, registry);
+ return (byNamespace, registry);
+ }
+
+ private static (Dictionary>, TypeRegistry) CreateMultipleEndpointsSameGroup()
+ {
+ var registry = new TypeRegistry();
+ var doc1 = new LexiconDocument
+ {
+ Id = "app.bsky.actor.getProfile",
+ Defs = new Dictionary
+ {
+ ["main"] = new LexiconDefinition
+ {
+ Type = "query",
+ Parameters = new LexiconDefinition
+ {
+ Type = "params",
+ Properties = new Dictionary
+ {
+ ["actor"] = new LexiconDefinition { Type = "string" }
+ },
+ RequiredRaw = CreateJsonArray("actor"),
+ },
+ Output = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition { Type = "object", Properties = new() }
+ }
+ }
+ }
+ };
+
+ var doc2 = new LexiconDocument
+ {
+ Id = "app.bsky.actor.getPreferences",
+ Defs = new Dictionary
+ {
+ ["main"] = new LexiconDefinition
+ {
+ Type = "query",
+ Output = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition { Type = "object", Properties = new() }
+ }
+ }
+ }
+ };
+
+ registry.RegisterDocument(doc1);
+ registry.RegisterDocument(doc2);
+ var byNamespace = GroupByNamespace(new[] { doc1, doc2 }, registry);
+ return (byNamespace, registry);
+ }
+
+ private static (Dictionary>, TypeRegistry) CreateEndpointsDifferentGroups()
+ {
+ var registry = new TypeRegistry();
+ var doc1 = new LexiconDocument
+ {
+ Id = "app.bsky.actor.getProfile",
+ Defs = new Dictionary
+ {
+ ["main"] = new LexiconDefinition
+ {
+ Type = "query",
+ Parameters = new LexiconDefinition
+ {
+ Type = "params",
+ Properties = new Dictionary
+ {
+ ["actor"] = new LexiconDefinition { Type = "string" }
+ },
+ RequiredRaw = CreateJsonArray("actor"),
+ },
+ Output = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition { Type = "object", Properties = new() }
+ }
+ }
+ }
+ };
+
+ var doc2 = new LexiconDocument
+ {
+ Id = "com.atproto.repo.createRecord",
+ Defs = new Dictionary
+ {
+ ["main"] = new LexiconDefinition
+ {
+ Type = "procedure",
+ Input = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["repo"] = new LexiconDefinition { Type = "string" }
+ }
+ }
+ },
+ Output = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition { Type = "object", Properties = new() }
+ }
+ }
+ }
+ };
+
+ registry.RegisterDocument(doc1);
+ registry.RegisterDocument(doc2);
+ var byNamespace = GroupByNamespace(new[] { doc1, doc2 }, registry);
+ return (byNamespace, registry);
+ }
+
+ private static (Dictionary>, TypeRegistry) CreateProcedureWithRefInput()
+ {
+ var registry = new TypeRegistry();
+
+ // Register the referenced type first
+ var defsDoc = new LexiconDocument
+ {
+ Id = "com.atproto.repo.defs",
+ Defs = new Dictionary
+ {
+ ["recordInput"] = new LexiconDefinition
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["repo"] = new LexiconDefinition { Type = "string" }
+ }
+ }
+ }
+ };
+
+ var doc = new LexiconDocument
+ {
+ Id = "com.atproto.repo.applyWrites",
+ Defs = new Dictionary
+ {
+ ["main"] = new LexiconDefinition
+ {
+ Type = "procedure",
+ Input = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition
+ {
+ Type = "ref",
+ Ref = "com.atproto.repo.defs#recordInput"
+ }
+ },
+ Output = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition { Type = "object", Properties = new() }
+ }
+ }
+ }
+ };
+
+ registry.RegisterDocument(defsDoc);
+ registry.RegisterDocument(doc);
+ var byNamespace = GroupByNamespace(new[] { defsDoc, doc }, registry);
+ return (byNamespace, registry);
+ }
+
+ private static (Dictionary>, TypeRegistry) CreateQueryWithArrayParameter()
+ {
+ var registry = new TypeRegistry();
+ var doc = new LexiconDocument
+ {
+ Id = "app.bsky.actor.getProfiles",
+ Defs = new Dictionary
+ {
+ ["main"] = new LexiconDefinition
+ {
+ Type = "query",
+ Parameters = new LexiconDefinition
+ {
+ Type = "params",
+ Properties = new Dictionary
+ {
+ ["actors"] = new LexiconDefinition
+ {
+ Type = "array",
+ Items = new LexiconDefinition { Type = "string", Format = "at-identifier" }
+ }
+ },
+ RequiredRaw = CreateJsonArray("actors"),
+ },
+ Output = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition { Type = "object", Properties = new() }
+ }
+ }
+ }
+ };
+
+ registry.RegisterDocument(doc);
+ var byNamespace = GroupByNamespace(doc, registry);
+ return (byNamespace, registry);
+ }
+
+ private static (Dictionary>, TypeRegistry) CreateQueryWithOptionalParameters()
+ {
+ var registry = new TypeRegistry();
+ var doc = new LexiconDocument
+ {
+ Id = "app.bsky.feed.getTimeline",
+ Defs = new Dictionary
+ {
+ ["main"] = new LexiconDefinition
+ {
+ Type = "query",
+ Parameters = new LexiconDefinition
+ {
+ Type = "params",
+ Properties = new Dictionary
+ {
+ ["limit"] = new LexiconDefinition { Type = "integer" },
+ ["cursor"] = new LexiconDefinition { Type = "string" },
+ },
+ },
+ Output = new LexiconIO
+ {
+ Encoding = "application/json",
+ Schema = new LexiconDefinition { Type = "object", Properties = new() }
+ }
+ }
+ }
+ };
+
+ registry.RegisterDocument(doc);
+ var byNamespace = GroupByNamespace(doc, registry);
+ return (byNamespace, registry);
+ }
+
+ private static Dictionary> GroupByNamespace(
+ LexiconDocument doc, TypeRegistry registry)
+ {
+ return GroupByNamespace(new[] { doc }, registry);
+ }
+
+ private static Dictionary> GroupByNamespace(
+ LexiconDocument[] docs, TypeRegistry registry)
+ {
+ var result = new Dictionary>();
+ foreach (var doc in docs)
+ {
+ var ns = Utilities.NsidHelper.ToNamespace(doc.Id);
+ if (!result.TryGetValue(ns, out var list))
+ {
+ list = new List<(string, LexiconDocument)>();
+ result[ns] = list;
+ }
+ list.Add((doc.Id, doc));
+ }
+ return result;
+ }
+
+ 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
+}