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 + +[![NuGet Version](https://img.shields.io/nuget/v/CarpaNet.AspNetCore.svg)](https://www.nuget.org/packages/CarpaNet.AspNetCore/) ![License](https://img.shields.io/badge/License-MIT-blue.svg) + +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 +}