diff --git a/DevProxy.Tests/ApiEndpointSchemasTests.cs b/DevProxy.Tests/ApiEndpointSchemasTests.cs new file mode 100644 index 00000000..9328dd60 --- /dev/null +++ b/DevProxy.Tests/ApiEndpointSchemasTests.cs @@ -0,0 +1,141 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.ApiControllers; +using DevProxy.Commands; +using DevProxy.Jwt; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Text.Json; + +using Xunit; + +namespace DevProxy.Tests; + +public class ApiEndpointSchemasTests +{ + public static TheoryData ControllerEndpoints() + { + var data = new TheoryData(); + var controllerRoute = "/" + nameof(ProxyController).Replace("Controller", "", StringComparison.Ordinal).ToLowerInvariant(); + foreach (var action in typeof(ProxyController).GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + foreach (var attribute in action.GetCustomAttributes()) + { + var path = string.IsNullOrEmpty(attribute.Template) ? controllerRoute : $"{controllerRoute}/{attribute.Template}"; + foreach (var method in attribute.HttpMethods) + { + data.Add(method, path, action.Name); + } + } + } + return data; + } + + [Theory] + [MemberData(nameof(ControllerEndpoints))] + public void Get_EveryControllerEndpoint_HasSchema(string method, string path, string actionName) + { + _ = actionName; + var schema = ApiEndpointSchemas.Get(method, path); + + Assert.NotNull(schema); + Assert.NotEmpty(schema.Responses); + } + + [Theory] + [MemberData(nameof(ControllerEndpoints))] + public void Get_QueryParameters_MatchControllerAction(string method, string path, string actionName) + { + var action = GetAction(actionName); + var expected = action.GetParameters() + .Where(p => p.GetCustomAttribute() is not null) + .ToDictionary(p => p.Name!, p => p.GetCustomAttribute() is not null); + + var query = ApiEndpointSchemas.Get(method, path)!.Parameters ?? []; + var actual = query.ToDictionary(p => p!["name"]!.GetValue(), p => p!["required"]!.GetValue()); + + Assert.Equal(expected.OrderBy(kv => kv.Key), actual.OrderBy(kv => kv.Key)); + } + + [Theory] + [MemberData(nameof(ControllerEndpoints))] + public void Get_RequestBody_PresentOnlyForFromBodyActions(string method, string path, string actionName) + { + var action = GetAction(actionName); + var hasBody = action.GetParameters().Any(p => p.GetCustomAttribute() is not null); + + Assert.Equal(hasBody, ApiEndpointSchemas.Get(method, path)!.RequestBody is not null); + } + + [Fact] + public void Get_JwtTokenRequestBody_ListsAllJwtOptionsProperties() + { + var properties = GetRequestBodyProperties("POST", "/proxy/jwtToken"); + + Assert.Equal(CamelCasePropertyNames(), properties); + } + + [Fact] + public void Get_ProxyStatusResponse_ListsAllProxyInfoProperties() + { + var schema = ApiEndpointSchemas.Get("GET", "/proxy")!; + var properties = schema.Responses["200"]!["content"]![ApiEndpointSchemas.JsonContentType]!["schema"]!["properties"]! + .AsObject().Select(p => p.Key).Order(StringComparer.Ordinal); + + Assert.Equal(CamelCasePropertyNames(), properties); + } + + [Fact] + public void Get_LogsJsonResponse_ListsAllLogEntryProperties() + { + var logEntryType = typeof(ProxyController).GetNestedType("LogEntryDto", BindingFlags.NonPublic)!; + var expected = logEntryType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name).Order(StringComparer.Ordinal); + + var schema = ApiEndpointSchemas.Get("GET", "/proxy/logs")!; + var properties = schema.Responses["200"]!["content"]![ApiEndpointSchemas.JsonContentType]!["schema"]!["items"]!["properties"]! + .AsObject().Select(p => p.Key).Order(StringComparer.Ordinal); + + Assert.Equal(expected, properties); + } + + [Fact] + public void Get_UpdateProxyRequestBody_OnlyAllowsWritableProperties() + { + Assert.Equal(["recording"], GetRequestBodyProperties("POST", "/proxy")); + var schema = ApiEndpointSchemas.Get("POST", "/proxy")!.RequestBody!["content"]![ApiEndpointSchemas.JsonContentType]!["schema"]!; + Assert.False(schema["additionalProperties"]!.GetValue()); + } + + [Theory] + [InlineData("POST", "/proxy/stopProxy", "202")] + [InlineData("POST", "/proxy/mockRequest", "202")] + [InlineData("GET", "/proxy/rootCertificate", "400")] + [InlineData("GET", "/proxy/logs", "400")] + public void Get_DocumentsNonDefaultStatusCodes(string method, string path, string statusCode) + { + Assert.True(ApiEndpointSchemas.Get(method, path)!.Responses.ContainsKey(statusCode)); + } + + [Fact] + public void Get_UnknownEndpoint_ReturnsNull() + { + Assert.Null(ApiEndpointSchemas.Get("DELETE", "/proxy")); + } + + private static MethodInfo GetAction(string name) => + typeof(ProxyController).GetMethod(name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)!; + + private static IEnumerable GetRequestBodyProperties(string method, string path) => + ApiEndpointSchemas.Get(method, path)!.RequestBody!["content"]![ApiEndpointSchemas.JsonContentType]!["schema"]!["properties"]! + .AsObject().Select(p => p.Key).Order(StringComparer.Ordinal); + + private static IEnumerable CamelCasePropertyNames() => + typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => JsonNamingPolicy.CamelCase.ConvertName(p.Name)) + .Order(StringComparer.Ordinal); +} diff --git a/DevProxy/Commands/ApiCommand.cs b/DevProxy/Commands/ApiCommand.cs index 78119ddc..8575fb18 100644 --- a/DevProxy/Commands/ApiCommand.cs +++ b/DevProxy/Commands/ApiCommand.cs @@ -140,11 +140,18 @@ private void PrintApiInfo(OutputFormat outputFormat) { baseUrl, authentication = new { scheme = "Bearer", header = "Authorization", tokenFilePattern }, - endpoints = endpoints.Select(e => new + endpoints = endpoints.Select(e => { - method = e.Method, - path = e.Path, - description = e.Description + var schema = ApiEndpointSchemas.Get(e.Method, e.Path); + return new + { + method = e.Method, + path = e.Path, + description = e.Description, + parameters = schema?.Parameters, + requestBody = schema?.RequestBody, + responses = schema?.Responses + }; }) }, ProxyUtils.JsonSerializerOptions); _logger.LogStructuredOutput(json); diff --git a/DevProxy/Commands/ApiEndpointSchemas.cs b/DevProxy/Commands/ApiEndpointSchemas.cs new file mode 100644 index 00000000..3ffd00eb --- /dev/null +++ b/DevProxy/Commands/ApiEndpointSchemas.cs @@ -0,0 +1,283 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Nodes; + +namespace DevProxy.Commands; + +/// +/// Request and response shapes of the Dev Proxy API endpoints exposed by +/// . Shapes follow OpenAPI 3.1 +/// conventions (JSON Schema for bodies, content maps keyed by media type) so +/// tools and agents can build requests without additional documentation. +/// +static class ApiEndpointSchemas +{ + internal const string JsonContentType = "application/json"; + internal const string ProblemContentType = "application/problem+json"; + + internal sealed class EndpointSchema + { + public JsonArray? Parameters { get; init; } + public JsonObject? RequestBody { get; init; } + public required JsonObject Responses { get; init; } + } + + public static EndpointSchema? Get(string method, string path) => (method.ToUpperInvariant(), path) switch + { + ("GET", "/proxy") => new() + { + Responses = Responses( + ("200", Response("Current proxy status", JsonContentType, ProxyInfoSchema()))) + }, + ("POST", "/proxy") => new() + { + RequestBody = RequestBody(new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["recording"] = new JsonObject + { + ["type"] = "boolean", + ["description"] = "true to start recording, false to stop recording. Omit to leave unchanged." + } + }, + ["additionalProperties"] = false, + ["examples"] = new JsonArray(new JsonObject { ["recording"] = true }) + }), + Responses = Responses( + ("200", Response("Updated proxy status", JsonContentType, ProxyInfoSchema())), + ("400", Response("Invalid request, for example when the body is missing or sets configFile", ProblemContentType, ValidationProblemSchema()))) + }, + ("POST", "/proxy/mockRequest") => new() + { + Responses = Responses( + ("202", Response("Mock request issued"))) + }, + ("POST", "/proxy/stopProxy") => new() + { + Responses = Responses( + ("202", Response("Shutdown accepted; Dev Proxy stops asynchronously"))) + }, + ("POST", "/proxy/jwtToken") => new() + { + RequestBody = RequestBody(JwtOptionsSchema()), + Responses = Responses( + ("200", Response("Generated JWT token", JsonContentType, new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["token"] = new JsonObject { ["type"] = "string" } + }, + ["required"] = new JsonArray("token") + })), + ("400", Response("Invalid request, for example when the body is missing or the signing key is too short", ProblemContentType, ValidationProblemSchema()))) + }, + ("GET", "/proxy/rootCertificate") => new() + { + Parameters = new JsonArray( + QueryParameter("format", "Certificate format. Only crt (PEM-encoded) is supported.", required: true, new JsonObject + { + ["type"] = "string", + ["enum"] = new JsonArray("crt") + })), + Responses = Responses( + ("200", Response("PEM-encoded root certificate (devProxy.pem)", "application/x-x509-ca-cert", new JsonObject { ["type"] = "string" })), + ("400", Response("format is missing or not supported", ProblemContentType, ValidationProblemSchema()))) + }, + ("GET", "/proxy/logs") => new() + { + Parameters = new JsonArray( + QueryParameter("lines", "Number of most recent log lines to return", required: false, new JsonObject + { + ["type"] = "integer", + ["default"] = 50 + }), + QueryParameter("follow", "Keep the connection open and stream new log lines as server-sent events", required: false, new JsonObject + { + ["type"] = "boolean", + ["default"] = false + }), + QueryParameter("since", "Only return lines logged after this time. Use a duration such as 30s, 5m, 1h or 1d, or a date-time.", required: false, new JsonObject + { + ["type"] = "string" + })), + Responses = Responses( + ("200", new JsonObject + { + ["description"] = "Log lines. Select the format with the Accept header: text/plain (default), application/json, or text/event-stream (also used when follow=true; combine with application/json for JSON events).", + ["content"] = new JsonObject + { + ["text/plain"] = new JsonObject { ["schema"] = new JsonObject { ["type"] = "string" } }, + [JsonContentType] = new JsonObject + { + ["schema"] = new JsonObject + { + ["type"] = "array", + ["items"] = LogEntrySchema() + } + }, + ["text/event-stream"] = new JsonObject { ["schema"] = new JsonObject { ["type"] = "string" } } + } + }), + ("400", Response("A query parameter has an invalid value, for example follow=invalid", ProblemContentType, ValidationProblemSchema())), + ("404", Response("Dev Proxy isn't running in detached mode, or the log file wasn't found", "text/plain", new JsonObject { ["type"] = "string" }))) + }, + _ => null + }; + + private static JsonObject ProxyInfoSchema() => new() + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["recording"] = new JsonObject + { + ["type"] = "boolean", + ["description"] = "Whether Dev Proxy is recording requests" + }, + ["configFile"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "Path of the configuration file Dev Proxy loaded. Read-only." + } + } + }; + + private static JsonObject JwtOptionsSchema() => new() + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["name"] = StringProperty("Name of the user to create the token for. Default: Dev Proxy"), + ["audiences"] = StringArrayProperty("Audiences to create the token for. Default: [\"https://myserver.com\"]"), + ["issuer"] = StringProperty("Issuer of the token. Default: dev-proxy"), + ["roles"] = StringArrayProperty("Role claims to add to the token"), + ["scopes"] = StringArrayProperty("Scope claims to add to the token"), + ["claims"] = new JsonObject + { + ["type"] = "object", + ["additionalProperties"] = new JsonObject { ["type"] = "string" }, + ["description"] = "Additional claims to add to the token" + }, + ["validFor"] = new JsonObject + { + ["type"] = "number", + ["exclusiveMinimum"] = 0, + ["description"] = "Token lifetime in minutes. Default: 60" + }, + ["signingKey"] = new JsonObject + { + ["type"] = "string", + ["minLength"] = 32, + ["description"] = "Key to sign the token with. Default: random key" + } + }, + ["examples"] = new JsonArray(new JsonObject + { + ["name"] = "Dev Proxy", + ["audiences"] = new JsonArray("https://myserver.com"), + ["scopes"] = new JsonArray("read", "write"), + ["validFor"] = 60 + }) + }; + + // The logs endpoint serializes entries with default (PascalCase) naming. + private static JsonObject LogEntrySchema() => new() + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["Time"] = NullableStringProperty("Time the line was logged (HH:mm:ss.fff)"), + ["Level"] = NullableStringProperty("Log level"), + ["Category"] = NullableStringProperty("Log category"), + ["Message"] = NullableStringProperty("Log message"), + ["Raw"] = NullableStringProperty("Unparsed log line") + } + }; + + private static JsonObject ValidationProblemSchema() => new() + { + ["type"] = "object", + ["description"] = "RFC 9457 problem details", + ["properties"] = new JsonObject + { + ["type"] = new JsonObject { ["type"] = "string" }, + ["title"] = new JsonObject { ["type"] = "string" }, + ["status"] = new JsonObject { ["type"] = "integer" }, + ["detail"] = new JsonObject { ["type"] = "string" }, + ["errors"] = new JsonObject + { + ["type"] = "object", + ["additionalProperties"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" } + } + } + } + }; + + private static JsonObject StringProperty(string description) => new() + { + ["type"] = "string", + ["description"] = description + }; + + private static JsonObject NullableStringProperty(string description) => new() + { + ["type"] = new JsonArray("string", "null"), + ["description"] = description + }; + + private static JsonObject StringArrayProperty(string description) => new() + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" }, + ["description"] = description + }; + + private static JsonObject QueryParameter(string name, string description, bool required, JsonObject schema) => new() + { + ["name"] = name, + ["in"] = "query", + ["description"] = description, + ["required"] = required, + ["schema"] = schema + }; + + private static JsonObject RequestBody(JsonObject schema) => new() + { + ["required"] = true, + ["content"] = new JsonObject + { + [JsonContentType] = new JsonObject { ["schema"] = schema } + } + }; + + private static JsonObject Response(string description, string? contentType = null, JsonObject? schema = null) + { + var response = new JsonObject { ["description"] = description }; + if (contentType is not null) + { + response["content"] = new JsonObject + { + [contentType] = schema is null ? new JsonObject() : new JsonObject { ["schema"] = schema } + }; + } + return response; + } + + private static JsonObject Responses(params (string StatusCode, JsonObject Response)[] responses) + { + var result = new JsonObject(); + foreach (var (statusCode, response) in responses) + { + result[statusCode] = response; + } + return result; + } +} diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index dfc9b763..ad67181a 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -246,7 +246,7 @@ public DevProxyCommand( IProxyConfiguration proxyConfiguration, IServiceProvider serviceProvider, UpdateNotification updateNotification, - ILogger logger) : base($"Start Dev Proxy\n\nAPI:\n Dev Proxy exposes an authenticated REST API for runtime management.\n OpenAPI spec: {ApiSecurity.GetApiUrl(new UriBuilder(Uri.UriSchemeHttp, proxyConfiguration.ApiIpAddress, proxyConfiguration.ApiPort).Uri.AbsoluteUri)}/swagger\n Use --api-port (default: {proxyConfiguration.ApiPort}) and --api-ip-address (default: 127.0.0.1).\n The API bind address is independent of --ip-address.\n Run 'devproxy status' for the API URL and token, or 'devproxy api show' for endpoints.") + ILogger logger) : base($"Start Dev Proxy\n\nAPI:\n Dev Proxy exposes an authenticated REST API for runtime management.\n API URL: {ApiSecurity.GetApiUrl(new UriBuilder(Uri.UriSchemeHttp, proxyConfiguration.ApiIpAddress, proxyConfiguration.ApiPort).Uri.AbsoluteUri)}\n Use --api-port (default: {proxyConfiguration.ApiPort}) and --api-ip-address (default: 127.0.0.1).\n The API bind address is independent of --ip-address.\n Run 'devproxy status' for the API URL and token, or 'devproxy api show' for endpoints\n ('devproxy api show --output json' includes request and response schemas).") { _serviceProvider = serviceProvider; _plugins = plugins; diff --git a/skills/dev-proxy/references/ci-cd-integration.md b/skills/dev-proxy/references/ci-cd-integration.md index f3a98d22..f66e187e 100644 --- a/skills/dev-proxy/references/ci-cd-integration.md +++ b/skills/dev-proxy/references/ci-cd-integration.md @@ -376,7 +376,7 @@ done | `/proxy/mockRequest` | POST | Trigger mock request (equivalent of pressing `w`) | | `/proxy/rootCertificate?format=crt` | GET | Download root cert in PEM format | -Run `devproxy api show` for endpoint information. All endpoints require `Authorization: Bearer `; browser origins must also be explicitly allowed with `apiAllowedOrigins`. +Run `devproxy api show` for endpoint information, or `devproxy api show --output json` for query parameters, request body schemas, and response codes per endpoint. All endpoints require `Authorization: Bearer `; browser origins must also be explicitly allowed with `apiAllowedOrigins`. ## Environment Variables for Azure API Center