diff --git a/AuthKit.slnx b/AuthKit.slnx index 9a470a1..90ca6e1 100644 --- a/AuthKit.slnx +++ b/AuthKit.slnx @@ -10,10 +10,15 @@ + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 093b472..6985be3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,7 +11,6 @@ - @@ -24,14 +23,11 @@ - - - diff --git a/Dockerfile b/Dockerfile index 2ef19b7..9dfb642 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,10 @@ COPY ["src/Host/Host.csproj", "src/Host/"] COPY ["src/Core/Core.csproj", "src/Core/"] COPY ["src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj", "src/Plugins/Abstractions/"] COPY ["src/Plugins/Solutions/DevTokens/DevTokens.csproj", "src/Plugins/Solutions/DevTokens/"] +COPY ["src/Plugins/Solutions/DevTools/DevTools.csproj", "src/Plugins/Solutions/DevTools/"] + +COPY ["tests/Host/AuthKit.Host.Tests.csproj", "tests/Host/"] +COPY ["tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj", "tests/Plugins/Abstractions/"] COPY ["tools/PluginContractValidator/PluginContractValidator.csproj", "tools/PluginContractValidator/"] RUN dotnet restore "AuthKit.slnx" @@ -30,6 +34,7 @@ FROM build AS publish WORKDIR /src RUN dotnet publish "src/Host/Host.csproj" -c Release -o /app/publish RUN dotnet publish "src/Plugins/Solutions/DevTokens/DevTokens.csproj" -c Release -o /app/publish/plugins/DevTokens +RUN dotnet publish "src/Plugins/Solutions/DevTools/DevTools.csproj" -c Release -o /app/publish/plugins/DevTools COPY src/Plugins/Solutions/DevTokens/plugin.manifest.json /app/publish/plugins/DevTokens/plugin.manifest.json FROM base AS final diff --git a/Docs/ADR/020-devtools-plugin.md b/Docs/ADR/020-devtools-plugin.md new file mode 100644 index 0000000..733f934 --- /dev/null +++ b/Docs/ADR/020-devtools-plugin.md @@ -0,0 +1,57 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./019-plugin-metadata-attribute.md) | [Next](./021-swagger-serving-via-reflection.md) + +# [ADR-020] Host Developer Tools Through A Dedicated DevTools Plugin + +*2026-09* | Status: accepted + +**Tag:** #adr_020 + +**Date:** 2026-09-11 + +**Scope:** Host + Plugins.Solutions.DevTools + +## Context + +AuthKit needs developer facing tooling to exercise its own surfaces: an interactive page to call any exposed gRPC method, and Swagger UI to browse the REST/OpenAPI surface. Earlier work exposed a gRPC UI as a standalone `GrpcUI` plugin and served Swagger UI on the host itself through `UseSwagger` / `UseSwaggerUI` (Swashbuckle direct middleware). The two tools lived in different architectural homes: one was a plugin, the other was hard wired into the host's request pipeline. + +## Problem + +A tool baked into the host can only ever be enabled together with the host, couples the host to Swashbuckle's serving middleware, and cannot be shared across host flavors or removed without editing host code. A dedicated `GrpcUI` plugin covered gRPC but left the REST/OpenAPI tooling outside the plugin boundary two mechanisms for the same job, two places to configure. Tooling URLs were also fixed implicit host paths rather than configurable plugin owned prefixes. + +## Decision + +Both developer tools move into single `DevTools` plugin (`src/Plugins/Solutions/DevTools`), replacing the standalone `GrpcUI` plugin and removing Swagger serving from the host: + +- The plugin owns its URL space through `DevToolsOptions`: gRPC UI at `GrpcUiPathBase` (`/grpc-ui`), Swagger UI under the `Swagger` section (default route prefix `swagger`, document name `v1`, title `AuthKit API`), and a landing page at `PathBase` (`/devtools`). +- The gRPC UI is in process: `GrpcServiceCatalog` scans the default assembly load context for static `ServiceDescriptor` properties, `GrpcDynamicInvoker` executes unary methods dynamically over `Grpc.Net.Client` without generated stubs, and `DevToolsMiddleware` dispatches `/grpc-ui` and its `/api/services` + `/api/invoke` endpoints. +- Swagger document generation stays in the host (`RestfulConfiguration` + `AddSwaggerGen`); only the SWAGGER SERVING middleware moves into `DevTools` via `SwaggerHost`. +- The host no longer calls `UseSwagger` / `UseSwaggerUI` `AppMiddlewareConfiguration` contains only the plugin slot. Developer tools are present in a deployment exactly when the plugin is deployed. +- Swagger UI serving is gated: by default enabled only in the development environment (`Swagger.Enabled ?? environment.IsDevelopment()`), with pinning of the OpenAPI spec version for the served document. +- The host is published with `DevTools` selected by default (solution file and Dockerfile), so a default deployment keeps both tools available. + +### Design Rationale + +- **One tooling surface**: REST (Swagger) and gRPC (in-process UI) are both interactive representations of the same host one plugin owns the developer experience. +- **Consistent plugin boundary**: everything a template developer visits is contributed by plugin, under plugin owned path bases, removable by not deploying the plugin. +- **In-process over proxy**: the gRPC UI talks to the host directly rather than fronting separate process and using gRPC reflection, keeping discovery local and configuration minimal. +- **No stubs needed**: dynamic invocation over method descriptors means the plugin never needs generated client code for the services it renders. + +## Rejected + +- Keeping Swagger serving in the host (`UseSwagger`/`UseSwaggerUI`) couples host to specific tool and a specific serving middleware. +- A standalone `GrpcUI` plugin with host owned Swagger two homes for equivalent functionality. +- A gRPC proxy process (eg. maintained third party gRPC UI binary) for streaming and health support additional deployment process and transport hop the extra support was deemed unnecessary for an internal developer tool. +- Serving Swagger from the plugin while re-emitting document generation there would duplicate `AddSwaggerGen` configuration generation stays in the host, serving stays in the plugin. + +## Consequences + +The host's `AppMiddlewareConfiguration` is smaller and no longer references Swashbuckle. The `DevTools` solution owns compiler friendly tooling code (catalog, invoker, middleware, options, and UI assets). Swagger UI availability depends on plugin deployment and environment gating rather than host build configuration. The gRPC UI only supports unary methods streaming methods are reported as unsupported rather than approximated. Configuration is centralized in `DevToolsOptions`, resolved with environment variables (`GRPC_UI_TARGET`, `DEV_CERT_PORT_GRPC`) for local development against the HTTPS gRPC endpoint. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - plugin contract and dynamic discovery +- [ADR-010](./010-plugin-loading-from-directory.md) - plugin middleware slot used by DevToolsMiddleware +- [ADR-013](./013-dual-rest-and-grpc-transport.md) - dual REST + gRPC surfaces being exercised +- [ADR-021](./021-swagger-serving-via-reflection.md) - how SwaggerHost serves SwaggerUI through reflection + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./019-plugin-metadata-attribute.md) | [Next](./021-swagger-serving-via-reflection.md) \ No newline at end of file diff --git a/Docs/ADR/021-swagger-serving-via-reflection.md b/Docs/ADR/021-swagger-serving-via-reflection.md new file mode 100644 index 0000000..f5874f7 --- /dev/null +++ b/Docs/ADR/021-swagger-serving-via-reflection.md @@ -0,0 +1,55 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./020-devtools-plugin.md) | [Next]() + +# [ADR-021] Serve Swagger Through Reflection-Based SwaggerHost In DevTools + +*2026-09* | Status: accepted + +**Tag:** #adr_021 + +**Date:** 2026-09-11 + +**Scope:** Plugins.Solutions.DevTools + +## Context + +ADR-020 moved Swagger UI serving from the host into the `DevTools` plugin. The plugin already consumed the OpenAPI document Provider (`ISwaggerProvider`, registered by `RestfulConfiguration` + `AddSwaggerGen`), but the actual document serialization and HTML UI rendering were performed by Swashbuckle's `SwaggerMiddleware` and `SwaggerUIMiddleware` — both of which are **internal** to their NuGet packages, so a plugin cannot call them the way the host used to. + +## Problem + +The plugin must reproduce what the host previously did with the public `UseSwagger` / `UseSwaggerUI` extension methods, but the middlewares behind those extensions are not part of the public API surface. The plugin also needs to control the OpenAPI spec version pinned for the served document — independently of any document that `RestfulConfiguration` might generate for other consumers. + +## Decision + +`DevTools` serves Swagger with a reflection-based `SwaggerHost`: + +- `SwaggerHost` resolves the internal `Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware` and `Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware` types by assembly-qualified name at startup, constructs one instance per middleware via `Activator.CreateInstance` with the standard (notFound, options) constructor shape, and invokes the internal `Invoke` methods reflectively per request. +- The Swagger document middleware is configured with a route template under the configured Swagger route prefix and the resolved OpenAPI spec version. +- The Swagger UI middleware receives UI options (route prefix, document title, `IndexStream` pointing at the embedded `index.html` resource) and an endpoint pointing at the document route. +- `DevToolsMiddleware` decides whether a request path belongs to the plugin (gRPC UI, Swagger, landing page) and, for the Swagger segment, delegates to `SwaggerHost.TryServeAsync`; a 404-not-found `RequestDelegate` is handed to the Swashbuckle middlewares when the path does not match. +- The OpenAPI spec version for the served document is resolved by `SwaggerHost.ResolveSpecVersion`: a `DevTools:Swagger:SpecVersion` override, then the host's `OpenApi:SpecVersion`, defaulting to `3.0`. Supported values are `3.0.x` and `3.1.x`; an unknown value logs a warning and falls back to `3.0`. +- Because the served document uses the spec version pinned for DevTools, mutual-TLS schemes (ADR-018) surface natively (`mutualTLS`) only when the document is pinned to OpenAPI 3.1. + +### Design Rationale + +- **Reuse over reimplementation**: the plugin does not re-implement OpenAPI serialization or the HTML UI — it drives the exact middleware the host used, through reflection, because the types are internal. +- **Single construction**: middleware instances are built once at plugin startup and reused for every request; per-request work is a reflection invoke, not a rebuild. +- **Independent pinning**: DevTools controls its own document spec version rather than inheriting a fixed host document, which is what lets the OpenAPI mapper's 3.1-dependent cases (ADR-018) work in DevTools. + +## Rejected + +- Re-implementing the Swagger UI page and OpenAPI serialization inside the plugin — duplicates a large, maintained surface for no architectural gain. +- Depending on the internal Swashbuckle types as a hard library reference — they are internal by design; a hard reference simply would not compile. +- Copying/shipping the Swashbuckle middleware source into the plugin — license and maintenance burden, and drift from upstream bug fixes. +- Removing Swagger serving from DevTools entirely (document-only JSON at a well-known URL) — loses the interactive UI that motivated the plugin. + +## Consequences + +`DevTools` stays on the Swashbuckle version that ships the internal middlewares; upgrading a Swashbuckle version could change the internal type/methods signatures and break `SwaggerHost` at startup (fail-fast with a descriptive exception). The reflection layer is a deliberate, visible cost: two `Type`/`MethodInfo` lookups and a per-request `Invoke` instead of direct calls. Unknown spec versions degrade to a warning + 3.0 in DevTools serving (so the UI still works), which is intentionally lazier than `RestfulConfiguration`, which hard-fails on unknown spec versions in app configuration. + +## Related + +- [ADR-013](./013-dual-rest-and-grpc-transport.md) - the OpenAPI surface served by DevTools +- [ADR-020](./020-devtools-plugin.md) - why Swagger serving lives in the plugin at all +- [ADR-018](./018-security-scheme-contract-explicit-handling.md) - mutual-TLS schemes require the 3.1 pin DevTools provides + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./020-devtools-plugin.md) | [Next]() \ No newline at end of file diff --git a/src/Host/Configuration/RestfulConfiguration.cs b/src/Host/Configuration/RestfulConfiguration.cs index f9e6b95..623a639 100644 --- a/src/Host/Configuration/RestfulConfiguration.cs +++ b/src/Host/Configuration/RestfulConfiguration.cs @@ -1,8 +1,4 @@ -using AuthKit.Plugins.Abstractions; -using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Host.Plugins; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; using Microsoft.OpenApi; namespace Host.Configuration; diff --git a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs new file mode 100644 index 0000000..0d4f29c --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs @@ -0,0 +1,183 @@ +using System.Reflection; +using System.Runtime.Loader; +using Google.Protobuf.Reflection; +using Microsoft.Extensions.Logging; + +namespace DevTools.Catalog; + +/// +/// Provides an in-process catalog of the gRPC services exposed by the +/// host application. +/// +/// +/// +/// Generated gRPC code exposes each service as a static Descriptor +/// property of type on the generated static +/// service class (e.g. Host.Greeter.Descriptor). Services are +/// discovered by scanning those properties across the assemblies loaded +/// into the default load context. +/// +/// +/// Message fields are expanded up to levels +/// so recursive messages remain finite when rendered by the UI. +/// +/// +public sealed class GrpcServiceCatalog(ILogger logger) : IGrpcServiceCatalog +{ + /// + /// Maximum number of message nesting levels expanded when describing + /// request and response schemas. + /// + private const int MaxMessageDepth = 5; + + private readonly Lazy<(IReadOnlyList Services, + IReadOnlyDictionary Methods)> _catalog = new(() => Scan(logger)); + + /// + /// Gets the discovered gRPC services ordered by full name. + /// + /// + /// A collection of describing the + /// discovered services and their methods. + /// + public IReadOnlyList GetServices() => _catalog.Value.Services; + + /// + /// Attempts to locate a method by its full name. + /// + /// The full name of the service. + /// The name of the method. + /// + /// The for the matching method, + /// or null when no such method exists in the catalog. + /// + public GrpcMethodCatalogEntry? TryGetMethod(string serviceName, string methodName) + { + _catalog.Value.Methods.TryGetValue($"{serviceName}/{methodName}", out var entry); + return entry; + } + + /// + /// Scans the assemblies currently loaded into the default load context and + /// gathers every gRPC service and method into an ordered, immutable snapshot. + /// + private static (IReadOnlyList Services, IReadOnlyDictionary Methods) Scan(ILogger logger) + { + var services = new List(); + var methods = new Dictionary(); + var seenServices = new HashSet(StringComparer.Ordinal); + + foreach (var assembly in AssemblyLoadContext.Default.Assemblies.ToArray()) + { + foreach (var type in SafeGetTypes(assembly)) + { + foreach (var property in type.GetProperties(BindingFlags.Static | BindingFlags.Public)) + { + if (property.PropertyType != typeof(ServiceDescriptor) || property.GetIndexParameters().Length != 0) + continue; + + if (property.GetValue(null) is not ServiceDescriptor descriptor || !seenServices.Add(descriptor.FullName)) + continue; + + try + { + services.Add(BuildServiceInfo(descriptor)); + foreach (var method in descriptor.Methods) + methods[$"{descriptor.FullName}/{method.Name}"] = + new GrpcMethodCatalogEntry(BuildMethodInfo(method), method); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to describe gRPC service {Service}.", descriptor.FullName); + } + } + } + } + + logger.LogInformation("gRPC catalog discovered {Count} service(s).", services.Count); + return (services + .OrderBy(s => s.FullName, StringComparer.Ordinal) + .ToArray(), methods); + } + + private static Type[] SafeGetTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t is not null).Cast().ToArray(); + } + } + + private static GrpcServiceInfo BuildServiceInfo(ServiceDescriptor descriptor) => + new() + { + Name = descriptor.Name, + FullName = descriptor.FullName, + Package = descriptor.File.Package, + FileName = descriptor.File.Name, + Methods = descriptor.Methods + .Select(BuildMethodInfo) + .ToArray() + }; + + private static GrpcMethodInfo BuildMethodInfo(MethodDescriptor method) => + new() + { + Name = method.Name, + FullName = $"{method.Service.FullName}/{method.Name}", + IsClientStreaming = method.IsClientStreaming, + IsServerStreaming = method.IsServerStreaming, + Request = BuildMessageSchema(method.InputType, depth: 0), + Response = BuildMessageSchema(method.OutputType, depth: 0) + }; + + private static GrpcMessageSchema BuildMessageSchema(MessageDescriptor message, int depth) => + new() + { + Name = message.Name, + FullName = message.FullName, + Fields = depth >= MaxMessageDepth + ? [] + : message.Fields.InFieldNumberOrder() + .Select(field => BuildFieldSchema(field, depth)) + .ToArray() + }; + + private static GrpcFieldSchema BuildFieldSchema(FieldDescriptor field, int depth) + { + var schema = new GrpcFieldSchema + { + Name = field.Name, + FieldType = field.FieldType.ToString(), + IsRepeated = field.IsRepeated, + IsMap = field.IsMap + }; + + if (field.IsMap) + { + var keyField = field.MessageType.Fields.InFieldNumberOrder()[0]; + var valueField = field.MessageType.Fields.InFieldNumberOrder()[1]; + + schema.MapKeyType = keyField.FieldType.ToString(); + if (valueField.FieldType == FieldType.Message) + schema.MapValue = BuildMessageSchema(valueField.MessageType, depth + 1); + else + schema.MapValueType = valueField.FieldType.ToString(); + } + + if (field is { FieldType: FieldType.Message, IsMap: false }) + schema.Message = BuildMessageSchema(field.MessageType, depth + 1); + + if (field.FieldType == FieldType.Enum) + { + schema.EnumType = field.EnumType.Name; + schema.EnumValues = field.EnumType.Values.Select(v => v.Name).ToArray(); + } + + return schema; + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs new file mode 100644 index 0000000..115e8c8 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs @@ -0,0 +1,173 @@ +namespace DevTools.Catalog; + +/// +/// Descriptor of a protobuf scalar or composite field in a request or +/// response message. +/// +/// +/// Only one of the composite members is populated for a given field: +/// for nested messages, for +/// message values of a map, or plus +/// for enums. +/// +public sealed class GrpcFieldSchema +{ + /// + /// Gets the name of the field. + /// + public required string Name { get; set; } + + /// + /// Gets the protobuf field type (e.g. String, Int32, Message). + /// + public required string FieldType { get; set; } + + /// + /// Gets a value indicating whether the field is repeated. + /// + public bool IsRepeated { get; set; } + + /// + /// Gets a value indicating whether the field is a map. + /// + public bool IsMap { get; set; } + + /// + /// Gets the key type of a map field. + /// + public string? MapKeyType { get; set; } + + /// + /// Gets the scalar value type of a map field. + /// + public string? MapValueType { get; set; } + + /// + /// Gets the message value schema of a map field. + /// + public GrpcMessageSchema? MapValue { get; set; } + + /// + /// Gets the nested message schema of a message field. + /// + public GrpcMessageSchema? Message { get; set; } + + /// + /// Gets the enum type name of an enum field. + /// + public string? EnumType { get; set; } + + /// + /// Gets the enum values of an enum field. + /// + public IEnumerable? EnumValues { get; set; } +} + +/// +/// Protobuf message layout used to render the request and response schema +/// in the UI. +/// +/// +/// Nested messages are expanded up to a fixed depth to avoid infinite +/// recursion on self-referencing messages. +/// +public sealed class GrpcMessageSchema +{ + /// + /// Gets the short name of the message. + /// + public required string Name { get; init; } + + /// + /// Gets the fully qualified name of the message. + /// + public required string FullName { get; init; } + + /// + /// Gets the fields of the message in field number order. + /// + public required IEnumerable Fields { get; init; } +} + +/// +/// Descriptor of a single RPC method including its input and output schemas. +/// +public sealed class GrpcMethodInfo +{ + /// + /// Gets the name of the method. + /// + public required string Name { get; init; } + + /// + /// Gets the fully qualified method name (e.g. greet.Greeter/SayHello). + /// + public required string FullName { get; init; } + + /// + /// Gets a value indicating whether the method is a client-streaming method. + /// + public bool IsClientStreaming { get; init; } + + /// + /// Gets a value indicating whether the method is a server-streaming method. + /// + public bool IsServerStreaming { get; init; } + + /// + /// Gets the schema of the method's input message. + /// + public required GrpcMessageSchema Request { get; init; } + + /// + /// Gets the schema of the method's output message. + /// + public required GrpcMessageSchema Response { get; init; } +} + +/// +/// Descriptor of a gRPC service together with all of its methods. +/// +public sealed class GrpcServiceInfo +{ + /// + /// Gets the short name of the service. + /// + public required string Name { get; init; } + + /// + /// Gets the fully qualified name of the service. + /// + public required string FullName { get; init; } + + /// + /// Gets the protobuf package the service belongs to. + /// + public required string Package { get; init; } + + /// + /// Gets the name of the .proto file defining the service. + /// + public required string FileName { get; init; } + + /// + /// Gets the methods exposed by the service. + /// + public required IEnumerable Methods { get; init; } +} + +/// +/// Payload returned by the catalog API endpoint. +/// +public sealed class GrpcCatalogResponse +{ + /// + /// Gets the gRPC endpoint used to execute invocations. + /// + public required string Target { get; init; } + + /// + /// Gets the discovered gRPC services. + /// + public required IEnumerable Services { get; init; } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/Catalog/IGrpcServiceCatalog.cs b/src/Plugins/Solutions/DevTools/Catalog/IGrpcServiceCatalog.cs new file mode 100644 index 0000000..2447a05 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Catalog/IGrpcServiceCatalog.cs @@ -0,0 +1,43 @@ +using Google.Protobuf.Reflection; + +namespace DevTools.Catalog; + +/// +/// Provides the gRPC services available in the current process together with +/// the protobuf schema required to render and invoke their methods. +/// +public interface IGrpcServiceCatalog +{ + /// + /// Gets the discovered gRPC services ordered by full name. + /// + /// + /// A collection of describing the + /// discovered services and their methods. + /// + IReadOnlyList GetServices(); + + /// + /// Locates a method by its full method name. + /// + /// The full name of the service. + /// The name of the method. + /// + /// The for the matching method, + /// or null when no such method exists in the catalog. + /// + GrpcMethodCatalogEntry? TryGetMethod(string serviceName, string methodName); +} + +/// +/// Catalog entry carrying the live Protobuf reflection objects needed to +/// serialize and invoke the method. +/// +/// +/// The is required at invocation time so the +/// dynamic marshallers can be built +/// without generated client stubs. +/// +public sealed record GrpcMethodCatalogEntry( + GrpcMethodInfo Info, + MethodDescriptor Descriptor); \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/DevTools.csproj b/src/Plugins/Solutions/DevTools/DevTools.csproj new file mode 100644 index 0000000..8965d52 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/DevTools.csproj @@ -0,0 +1,25 @@ + + + net10.0 + preview + enable + enable + true + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs b/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs new file mode 100644 index 0000000..e067dbd --- /dev/null +++ b/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs @@ -0,0 +1,90 @@ +using DevTools.Options; +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using DevTools.Catalog; +using DevTools.Middleware; +using DevTools.Runtime; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace DevTools; + +/// +/// AuthKit developer tools plugin responsible for exposing Swagger UI for the +/// REST API and a web interface for browsing and invoking gRPC services. +/// +/// +/// +/// The plugin serves the Swagger UI for the host's OpenAPI document and +/// provides a single-page interface for inspecting gRPC services, their +/// methods, and messages, and executing unary requests. +/// +/// +/// The Host core provides the OpenAPI document itself and the gRPC services +/// through dependency injection. The plugin does not generate Swagger +/// documents and does not require generated gRPC client stubs; methods are +/// invoked dynamically using the protobuf descriptors returned by the +/// catalog. +/// +/// +[PluginMetadata( + id: "authkit.devtools", + version: "1.0.0", + tags: ["developer", "tools", "grpc", "swagger"], + dependsOn: [], + capabilities: ["developer-tools"], + name: "DevTools", + displayName: "Developer Tools", + description: "Swagger UI for REST and web UI for gRPC services.", + author: "AuthKit Contributors", + license: "MIT", + licenseUrl: "https://opensource.org/licenses/MIT", + homepage: "https://example.org/devtools", + repositoryUrl: "https://example.org/devtools.git" +)] +public sealed class DevToolsPlugin : IAuthKitPlugin +{ + /// + /// Registers the gRPC service catalog, invocation runtime, and Swagger + /// serving components in the dependency injection container. + /// + /// The used to register plugin services. + /// Application configuration used to configure . + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection("DevTools")); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + /// + /// The middleware serving the Swagger UI and the gRPC UI together with + /// their JSON APIs. + /// + public Type MiddlewareType => typeof(DevToolsMiddleware); + + /// + /// Verifies that the gRPC service catalog is resolvable and can be + /// enumerated. + /// + /// The root service provider of the host application. + /// true when the catalog is available; otherwise, false. + public Task CheckHealthAsync(IServiceProvider services) + { + var catalog = services.GetService(); + if (catalog is null) return Task.FromResult(false); + + try + { + _ = catalog.GetServices(); + return Task.FromResult(true); + } + catch + { + return Task.FromResult(false); + } + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/Middleware/DevToolsMiddleware.cs b/src/Plugins/Solutions/DevTools/Middleware/DevToolsMiddleware.cs new file mode 100644 index 0000000..f5045f3 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Middleware/DevToolsMiddleware.cs @@ -0,0 +1,217 @@ +using System.Text; +using System.Text.Json; +using DevTools.Catalog; +using DevTools.Runtime; +using DevTools.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DevTools.Middleware; + +/// +/// Serves the developer tooling contributed by the plugin: Swagger UI for the +/// REST API and the gRPC UI for browsing and invoking gRPC methods. +/// +/// +/// +/// The middleware owns the routing for the tool pages and their JSON APIs. +/// It also serves a small landing page linking both tools. +/// +/// +/// All other paths are delegated to the remaining pipeline, so authentication, +/// REST endpoints, and gRPC endpoints continue to work unchanged. +/// +/// +public sealed class DevToolsMiddleware(RequestDelegate next, IOptions options, + IGrpcServiceCatalog catalog, GrpcDynamicInvoker invoker, SwaggerHost swagger, + IHostEnvironment environment, ILogger logger) +{ + private static readonly Lazy GrpcUiPage = new(LoadEmbeddedPage); + + /// + /// Routes the request to the gRPC UI, Swagger UI, or landing page, and + /// otherwise delegates to the remaining pipeline. + /// + /// The current HTTP request context. + public async Task InvokeAsync(HttpContext context) + { + var grpcPathBase = options.Value.GrpcUiPathBase.TrimEnd('/'); + + if (PathMatches(context, grpcPathBase, out var grpcRelative)) + { + await ServeGrpcUiAsync(context, grpcRelative); + return; + } + + var swaggerEnabled = options.Value.Swagger.Enabled ?? environment.IsDevelopment(); + if (swaggerEnabled && await swagger.TryServeAsync(context)) + return; + + if (PathMatches(context, options.Value.PathBase, out _)) + { + await ServeLandingAsync(context); + return; + } + + await next(context); + } + + /// + /// Determines whether the request path starts with the given prefix and + /// returns the remainder of the path. + /// + private static bool PathMatches(HttpContext context, string pathBase, out string relative) + { + var path = context.Request.Path.Value ?? "/"; + relative = ""; + + if (string.IsNullOrEmpty(pathBase)) + return false; + + if (!path.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)) + return false; + + var rest = path[pathBase.Length..]; + if (rest.Length > 0 && !rest.StartsWith('/')) + return false; + + relative = rest; + return true; + } + + /// + /// Serves the gRPC UI page and its JSON API endpoints. + /// + private async Task ServeGrpcUiAsync(HttpContext context, string relative) + { + switch (relative) + { + case "": + context.Response.Redirect(options.Value.GrpcUiPathBase.TrimEnd('/') + "/"); + return; + case "/": + case "/index.html": + { + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.WriteAsync(RenderGrpcUiPage(), context.RequestAborted); + return; + } + case "/api/services": + await ApiServicesAsync(context); + return; + case "/api/invoke" when HttpMethods.IsPost(context.Request.Method): + await ApiInvokeAsync(context); + return; + default: + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + } + + /// + /// Serves the landing page linking the Swagger UI and the gRPC UI. + /// + private async Task ServeLandingAsync(HttpContext context) + { + var grpcPathBase = options.Value.GrpcUiPathBase.TrimEnd('/'); + var swaggerPrefix = "/" + options.Value.Swagger.RoutePrefix.Trim('/'); + + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.WriteAsync( + """ + + + AuthKit DevTools + +
+

AuthKit DevTools

+ Swagger UI + gRPC UI +
dev environment · Swagger is only served in Development
+
+ """.Replace("__SWAGGER__", swaggerPrefix) + .Replace("__GRPCUI__", grpcPathBase + "/"), + context.RequestAborted); + } + + /// + /// Serves the JSON payload listing the discovered gRPC services. + /// + private async Task ApiServicesAsync(HttpContext context) + { + var response = new GrpcCatalogResponse + { + Target = options.Value.ResolveGrpcTarget(), + Services = catalog.GetServices() + }; + + context.Response.ContentType = "application/json; charset=utf-8"; + await context.Response.WriteAsJsonAsync(response, context.RequestAborted); + } + + /// + /// Reads the invocation request and writes the invocation result as JSON. + /// + /// + /// Malformed JSON bodies are answered with 400 Bad Request. + /// + private async Task ApiInvokeAsync(HttpContext context) + { + var jsonOptions = context.RequestServices.GetService>()?.Value.SerializerOptions + ?? new JsonSerializerOptions(JsonSerializerDefaults.Web); + + try + { + var request = await JsonSerializer.DeserializeAsync( + context.Request.Body, + jsonOptions, + cancellationToken: context.RequestAborted); + + if (request is null) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new { error = "Request body is required." }, jsonOptions, context.RequestAborted); + return; + } + + logger.LogInformation("gRPC UI invoking {Service}/{Method}.", request.Service, request.Method); + + var result = await invoker.InvokeAsync(request, context.RequestAborted); + + context.Response.ContentType = "application/json; charset=utf-8"; + await context.Response.WriteAsJsonAsync(result, jsonOptions, context.RequestAborted); + } + catch (JsonException ex) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new { error = ex.Message }, jsonOptions, context.RequestAborted); + } + } + + private static string LoadEmbeddedPage() + { + var assembly = typeof(DevToolsMiddleware).Assembly; + const string resourceName = "DevTools.UI.ui.html"; + + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found."); + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } + + private string RenderGrpcUiPage() => + GrpcUiPage.Value.Replace("{{pathBase}}", options.Value.GrpcUiPathBase.TrimEnd('/')); +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/Middleware/SwaggerHost.cs b/src/Plugins/Solutions/DevTools/Middleware/SwaggerHost.cs new file mode 100644 index 0000000..10a3e35 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Middleware/SwaggerHost.cs @@ -0,0 +1,166 @@ +using System.Reflection; +using DevTools.Options; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi; +using Swashbuckle.AspNetCore.Swagger; +using Swashbuckle.AspNetCore.SwaggerUI; + +namespace DevTools.Middleware; + +/// +/// Serves the OpenAPI document and the Swagger UI contributed by the host's +/// Swagger generation pipeline. +/// +/// +/// +/// The host generates the OpenAPI document (SwaggerGen) but no longer +/// wires the Swashbuckle serving middleware. +/// +/// +/// Both serving middlewares are internal to Swashbuckle, so they are +/// instantiated and invoked through reflection. The middleware instances +/// are constructed once and reused for every request. +/// +/// +public sealed class SwaggerHost( + IOptions options, + IConfiguration configuration, + ISwaggerProvider swaggerProvider, + ILogger logger) +{ + private static readonly Type SwaggerMiddlewareType = typeof(SwaggerOptions).Assembly + .GetType("Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware", throwOnError: true)!; + + private static readonly Type SwaggerUiMiddlewareType = typeof(SwaggerUIOptions).Assembly + .GetType("Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware", throwOnError: true)!; + + private static readonly MethodInfo SwaggerInvoke = SwaggerMiddlewareType + .GetMethod("Invoke", [typeof(HttpContext), typeof(ISwaggerProvider)])!; + + private static readonly MethodInfo SwaggerUiInvoke = SwaggerUiMiddlewareType + .GetMethod("Invoke", [typeof(HttpContext)])!; + + private readonly (string RoutePrefix, object Document, object Ui) _content = + CreateContent(options, configuration, logger); + + /// + /// Returns whether the request path belongs to the Swagger document or UI + /// and, when it does, serves it. + /// + /// The current HTTP request context. + /// + /// true when the request was handled by the Swagger document or UI; + /// otherwise, false. + /// + public async Task TryServeAsync(HttpContext context) + { + var path = context.Request.Path.Value ?? "/"; + + if (!path.StartsWith(_content.RoutePrefix + "/", StringComparison.OrdinalIgnoreCase) + && !path.Equals(_content.RoutePrefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + await InvokeAsync(SwaggerInvoke, _content.Document, context, swaggerProvider); + + if (context.Response.HasStarted) + return true; + + await InvokeAsync(SwaggerUiInvoke, _content.Ui, context); + return true; + } + + /// + /// Invokes the middleware method reflectively. + /// + private static async Task InvokeAsync(MethodInfo method, object instance, params object[] arguments) + { + var invocation = method.Invoke(instance, arguments) as Task + ?? throw new InvalidOperationException( + $"Middleware method '{method.Name}' did not return a task."); + + await invocation; + } + + /// + /// Builds the reflection-based Swagger document and UI middleware instances + /// together with the resolved route prefix. + /// + /// + /// Each instance is constructed once and reused for every request. + /// + private static (string RoutePrefix, object Document, object Ui) CreateContent( + IOptions options, + IConfiguration configuration, + ILogger logger) + { + var swagger = options.Value.Swagger; + var routePrefix = "/" + swagger.RoutePrefix.Trim('/'); + + var specVersion = ResolveSpecVersion(swagger.SpecVersion, configuration, logger); + + var document = Activator.CreateInstance( + SwaggerMiddlewareType, + (RequestDelegate)PassThrough, + new SwaggerOptions + { + RouteTemplate = $"{routePrefix}/{{documentName}}/swagger.json", + OpenApiVersion = specVersion + })!; + + var uiOptions = new SwaggerUIOptions + { + RoutePrefix = swagger.RoutePrefix.Trim('/'), + DocumentTitle = swagger.DocumentTitle, + IndexStream = () => typeof(SwaggerUIOptions).Assembly + .GetManifestResourceStream("Swashbuckle.AspNetCore.SwaggerUI.index.html") + }; + + uiOptions.SwaggerEndpoint( + $"/{swagger.RoutePrefix.Trim('/')}/{swagger.DocumentName}/swagger.json", + swagger.DocumentName); + + var ui = Activator.CreateInstance(SwaggerUiMiddlewareType, (RequestDelegate)NotFound, uiOptions)!; + + logger.LogInformation("Swagger UI configured at {RoutePrefix} (spec {SpecVersion}).", + routePrefix, specVersion.ToString()); + + return (routePrefix, document, ui); + + Task PassThrough(HttpContext _) => Task.CompletedTask; + + Task NotFound(HttpContext context) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + } + } + + /// + /// Resolves the OpenAPI spec version from the options override, the host + /// configuration, or a sensible default. + /// + private static OpenApiSpecVersion ResolveSpecVersion( + string? overrideValue, + IConfiguration configuration, + ILogger logger) + { + var value = overrideValue ?? configuration["OpenApi:SpecVersion"]?.Trim() ?? "3.0"; + + if (value.Equals("3.0", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("3.0.", StringComparison.OrdinalIgnoreCase)) + return OpenApiSpecVersion.OpenApi3_0; + + if (value.Equals("3.1", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("3.1.", StringComparison.OrdinalIgnoreCase)) + return OpenApiSpecVersion.OpenApi3_1; + + logger.LogWarning("Unknown OpenApi:SpecVersion '{Value}'; falling back to 3.0.", value); + return OpenApiSpecVersion.OpenApi3_0; + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/Options/DevToolsOptions.cs b/src/Plugins/Solutions/DevTools/Options/DevToolsOptions.cs new file mode 100644 index 0000000..a7f09c3 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Options/DevToolsOptions.cs @@ -0,0 +1,60 @@ +namespace DevTools.Options; + +/// +/// Configuration root for the DevTools plugin. +/// +/// +/// +/// The and sections +/// control which URL prefixes the gRPC UI and the Swagger UI are served on. +/// +/// +/// The URL and the gRPC port can be overridden +/// through the GRPC_UI_TARGET and DEV_CERT_PORT_GRPC +/// environment variables. +/// +/// +public sealed class DevToolsOptions +{ + /// + /// Gets or sets the URL prefix under which the gRPC UI is served. + /// + public string GrpcUiPathBase { get; set; } = "/grpc-ui"; + + /// + /// Gets or sets the URL of the gRPC endpoint invoked when executing a + /// request. + /// + /// + /// Defaults to the host gRPC port unless DEV_CERT_PORT_GRPC is set. + /// + public string? GrpcTarget { get; set; } + + /// + /// Gets or sets the URL prefix under which a landing page with links to + /// both tools is served. + /// + public string PathBase { get; set; } = "/devtools"; + + /// + /// Gets or sets the Swagger UI serving settings. + /// + public SwaggerUiOptions Swagger { get; set; } = new(); + + /// + /// Resolves the effective gRPC target, honoring the environment variable + /// overrides. + /// + /// + /// The resolved target URL, for example https://localhost:5001. + /// + public string ResolveGrpcTarget() => + GrpcTarget ?? + (Environment.GetEnvironmentVariable("GRPC_UI_TARGET") + ?? $"https://localhost:{GrpcPortFromEnvironment()}"); + + private static int GrpcPortFromEnvironment() => + int.TryParse(Environment.GetEnvironmentVariable("DEV_CERT_PORT_GRPC"), out var port) + ? port + : 5001; +} diff --git a/src/Plugins/Solutions/DevTools/Options/SwaggerUiOptions.cs b/src/Plugins/Solutions/DevTools/Options/SwaggerUiOptions.cs new file mode 100644 index 0000000..b6a0ee4 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Options/SwaggerUiOptions.cs @@ -0,0 +1,49 @@ +namespace DevTools.Options; + +/// +/// Swagger UI serving configuration. +/// +/// +/// +/// The determines the URL path for the Swagger JSON +/// document and the Swagger UI page. With the default prefix swagger, +/// the document is served at /swagger/v1/swagger.json. +/// +/// +/// The spec version can be pinned with when omitted +/// the OpenApi:SpecVersion host configuration is used. +/// +/// +public sealed class SwaggerUiOptions +{ + /// + /// Gets or sets the route prefix under which the Swagger document and UI + /// are served. + /// + /// + /// For example, value of swagger yields /swagger/v1/swagger.json + public string RoutePrefix { get; set; } = "swagger"; + + /// + /// Gets or sets the name of the OpenAPI document served by Swagger UI. + /// + public string DocumentName { get; set; } = "v1"; + + /// + /// Gets or sets the title shown in the Swagger UI browser tab. + /// + public string DocumentTitle { get; set; } = "AuthKit API"; + + /// + /// Gets or sets the OpenAPI spec version to pin when serializing the + /// document. + /// + /// Overrides the OpenApi:SpecVersion host setting when provided. + public string? SpecVersion { get; set; } + + /// + /// Gets or sets value indicating whether Swagger UI is enabled. + /// + /// When null the UI is served only in the development environment. + public bool? Enabled { get; set; } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs b/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs new file mode 100644 index 0000000..06edcaa --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs @@ -0,0 +1,172 @@ +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Grpc.Core; +using Grpc.Net.Client; +using DevTools.Catalog; +using DevTools.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DevTools.Runtime; + +/// +/// Executes unary gRPC calls discovered through the catalog without requiring +/// generated client stubs. +/// +/// +/// +/// Messages are marshaled over using the protobuf +/// descriptors returned by the server catalog, so no compiled client code is +/// needed. +/// +/// +/// TLS validation is relaxed because the host uses a development certificate. +/// Streaming methods are not executed and are reported as unsupported. +/// +/// +public sealed class GrpcDynamicInvoker( + IOptions options, + IGrpcServiceCatalog catalog, + ILogger logger) +{ + private readonly Lazy _channel = new(() => CreateChannel(options, logger)); + + /// + /// Invokes the requested unary method and returns a serializable result. + /// + /// The invocation request describing the method and payload. + /// Cancellation token propagated to the gRPC call. + /// + /// The describing the outcome of the + /// invocation. + /// + public async Task InvokeAsync( + GrpcInvocationRequest request, + CancellationToken cancellationToken = default) + { + var entry = catalog.TryGetMethod(request.Service, request.Method); + if (entry is null) + return new GrpcInvocationResult + { + Success = false, + StatusName = nameof(StatusCode.NotFound), + StatusCode = (int)StatusCode.NotFound, + Detail = $"Unknown method '{request.Service}/{request.Method}'." + }; + + var method = entry.Descriptor; + if (method.IsClientStreaming || method.IsServerStreaming) + return new GrpcInvocationResult + { + Success = false, + StatusName = nameof(StatusCode.Unimplemented), + StatusCode = (int)StatusCode.Unimplemented, + Detail = "Streaming methods are not supported by the gRPC UI." + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + try + { + var response = await InvokeUnaryAsync(method, request.Headers, request.RequestJson, cancellationToken); + stopwatch.Stop(); + + return new GrpcInvocationResult + { + Success = true, + StatusName = nameof(StatusCode.OK), + StatusCode = (int)StatusCode.OK, + ResponseJson = JsonFormatter.Default.Format(response), + ElapsedMs = Math.Round(stopwatch.Elapsed.TotalMilliseconds, 2) + }; + } + catch (RpcException ex) + { + stopwatch.Stop(); + logger.LogWarning(ex, "gRPC invocation of {Method} failed.", method.FullName); + + return new GrpcInvocationResult + { + Success = false, + StatusName = ex.StatusCode.ToString(), + StatusCode = (int)ex.StatusCode, + Detail = ex.Status.Detail, + Trailers = ex.Trailers.ToDictionary(t => t.Key, t => t.Value), + ElapsedMs = Math.Round(stopwatch.Elapsed.TotalMilliseconds, 2) + }; + } + catch (InvalidJsonException ex) + { + stopwatch.Stop(); + return new GrpcInvocationResult + { + Success = false, + StatusName = nameof(StatusCode.InvalidArgument), + StatusCode = (int)StatusCode.InvalidArgument, + Detail = $"Request JSON is invalid: {ex.Message}", + ElapsedMs = Math.Round(stopwatch.Elapsed.TotalMilliseconds, 2) + }; + } + } + + /// + /// Serializes the request, builds a dynamic unary call over the shared + /// channel, and deserializes the response into an . + /// + private async Task InvokeUnaryAsync( + MethodDescriptor method, + IReadOnlyDictionary headers, + string requestJson, + CancellationToken cancellationToken) + { + var input = method.InputType; + var output = method.OutputType; + + var requestMarshaller = new Marshaller( + message => message.ToByteArray(), + bytes => input.Parser.ParseFrom(bytes)); + + var responseMarshaller = new Marshaller( + message => message.ToByteArray(), + bytes => output.Parser.ParseFrom(bytes)); + + var call = new Method( + MethodType.Unary, + method.Service.FullName, + method.Name, + requestMarshaller, + responseMarshaller); + + var metadata = new Metadata(); + foreach (var (key, value) in headers) + metadata.Add(key, value); + + var request = input.Parser.ParseJson(requestJson); + var options = new CallOptions(metadata, cancellationToken: cancellationToken); + + var invoker = _channel.Value.CreateCallInvoker(); + var callResult = invoker.AsyncUnaryCall(call, host: null, options, request); + + return await callResult.ResponseAsync; + } + + /// + /// Creates the shared used for invocations. + /// + /// + /// The channel is created lazily on the first invocation and is reused + /// for every subsequent call. + /// + private static GrpcChannel CreateChannel(IOptions options, ILogger logger) + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + + var target = options.Value.ResolveGrpcTarget(); + logger.LogInformation("gRPC UI channel configured for target {Target}.", target); + + return GrpcChannel.ForAddress(target, new GrpcChannelOptions { HttpHandler = handler }); + } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationRequest.cs b/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationRequest.cs new file mode 100644 index 0000000..a0253d0 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationRequest.cs @@ -0,0 +1,33 @@ +namespace DevTools.Runtime; + +/// +/// Request payload for invoking a unary gRPC method. +/// +/// +/// The request message is supplied as JSON and is parsed using the protobuf +/// JSON parser, so enum names, well-known types, and field names are handled +/// the same way as generated clients. +/// +public sealed class GrpcInvocationRequest +{ + /// + /// Gets the full name of the service (e.g. greet.Greeter). + /// + public required string Service { get; init; } + + /// + /// Gets the name of the method to invoke. + /// + public required string Method { get; init; } + + /// + /// Gets the request message serialized as JSON. + /// + public required string RequestJson { get; init; } + + /// + /// Gets the additional gRPC metadata (headers) sent with the call. + /// + public IReadOnlyDictionary Headers { get; init; } = + new Dictionary(); +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs b/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs new file mode 100644 index 0000000..9f25fae --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Runtime/GrpcInvocationResult.cs @@ -0,0 +1,48 @@ +namespace DevTools.Runtime; + +/// +/// Result of a single gRPC invocation exposed by the UI API. +/// +/// +/// A successful invocation carries the serialized response in +/// . Failures carry the gRPC status identifier and +/// code, a human-readable detail, and any response trailers. +/// +public sealed class GrpcInvocationResult +{ + /// + /// Gets a value indicating whether the invocation completed successfully. + /// + public required bool Success { get; init; } + + /// + /// Gets the gRPC status name of the invocation (e.g. OK). + /// + public required string StatusName { get; init; } + + /// + /// Gets the numeric gRPC status code of the invocation. + /// + public required int StatusCode { get; init; } + + /// + /// Gets the human-readable status detail when the invocation failed. + /// + public string? Detail { get; init; } + + /// + /// Gets the response message serialized as JSON when the invocation + /// succeeded. + /// + public string? ResponseJson { get; init; } + + /// + /// Gets the invocation duration in milliseconds. + /// + public double ElapsedMs { get; init; } + + /// + /// Gets the response trailers returned with the call. + /// + public IReadOnlyDictionary? Trailers { get; init; } +} \ No newline at end of file diff --git a/src/Plugins/Solutions/DevTools/Taskfile.yml b/src/Plugins/Solutions/DevTools/Taskfile.yml new file mode 100644 index 0000000..32d9314 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/Taskfile.yml @@ -0,0 +1,56 @@ +version: '3' + +vars: + PROJECT_NAME: DevTools # Name of the project + CONFIGURATION: Debug # Build configuration (Debug/Release) + TARGET_FRAMEWORK: net10.0 # Target .NET framework + OUTPUT_DIR: bin/{{.CONFIGURATION}}/{{.TARGET_FRAMEWORK}} # Output directory for builds + PROJECT_FILE: '{{.PROJECT_NAME}}.csproj' # Path to the project file + ASSEMBLY_FILE: '{{.USER_WORKING_DIR}}/{{.OUTPUT_DIR}}/{{.PROJECT_NAME}}.dll' # Path to the compiled DLL + MANIFEST_OUTPUT: '{{.USER_WORKING_DIR}}/manifest.json' # Output path for the generated manifest + MANIFEST_GENERATOR: ../../../../tools/AuthKit.ManifestGenerator # Path to the manifest generator tool + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' # Disables .NET CLI telemetry + +tasks: + default: + desc: Lists available tasks + cmds: + - task --list + + build: + desc: Builds the DevTools project without restoring NuGet packages + cmds: + - dotnet build {{.PROJECT_FILE}} --configuration {{.CONFIGURATION}} --no-restore + + clean: + desc: Cleans the DevTools project (deletes build artifacts) + cmds: + - dotnet clean {{.PROJECT_FILE}} --configuration {{.CONFIGURATION}} + + generate-manifest: + desc: Generates a manifest for DevTools using AuthKit.ManifestGenerator + deps: + - build # Ensures the project is built before generating the manifest + - build-manifest-generator # Ensures the manifest generator is built + cmds: + - cd {{.MANIFEST_GENERATOR}} && dotnet run --project CLI/AuthKit.ManifestGenerator.CLI.csproj --configuration {{.CONFIGURATION}} --no-build -- --input {{.ASSEMBLY_FILE}} --output {{.MANIFEST_OUTPUT}} + - echo "Manifest generated at {{.MANIFEST_OUTPUT}}" + + build-manifest-generator: + desc: Builds the AuthKit.ManifestGenerator tool + cmds: + - cd {{.MANIFEST_GENERATOR}} && dotnet build --configuration {{.CONFIGURATION}} --no-restore + + validate: + desc: Cleans and builds the project + cmds: + - task: clean + - task: build + + ci: + desc: Full validation of the project and manifest generation (CI-friendly) + cmds: + - task: validate + - task: generate-manifest diff --git a/src/Plugins/Solutions/DevTools/UI/ui.html b/src/Plugins/Solutions/DevTools/UI/ui.html new file mode 100644 index 0000000..9c3eae2 --- /dev/null +++ b/src/Plugins/Solutions/DevTools/UI/ui.html @@ -0,0 +1,328 @@ + + + + + +gRPC UI — AuthKit + + + +
+

gRPC UI

+ AuthKit plugin + scanning… +
+
+ +
+
Select a method to inspect its schema and execute a request.
+
+
+ + + \ No newline at end of file diff --git a/t-root.csproj b/t-root.csproj new file mode 100644 index 0000000..555ae43 --- /dev/null +++ b/t-root.csproj @@ -0,0 +1,5 @@ + + + net10.0 + +