diff --git a/README.md b/README.md index d892bc0..ccf9614 100644 --- a/README.md +++ b/README.md @@ -331,4 +331,65 @@ Update appsettings.json: }, } } +``` + +# Swagger Transformation + +If you want to transform the Swagger to fit the modifications done by the transformations you have to implement the changes in the `ISwaggerTransformFactory`. Below is an example from a custom transformation called `RenameHeader`. + +```csharp +public class HeaderTransformFactory : ITransformFactory, ISwaggerTransformFactory +{ + public bool Validate(TransformRouteValidationContext context, IReadOnlyDictionary transformValues) + { + // validation implementation of custom transformation + } + + public bool Build(TransformBuilderContext context, IReadOnlyDictionary transformValues) + { + // transform implementation of custom transformation + } + + /// + /// Header title rename transformation for Swagger + /// + /// + /// + /// + public bool Build(OpenApiOperation operation, IReadOnlyDictionary transformValues) + { + if (transformValues.ContainsKey("RenameHeader")) + { + foreach (var parameter in operation.Parameters) + { + if (parameter.In.HasValue && parameter.In.Value.ToString().Equals("Header")) + { + if (transformValues.TryGetValue("RenameHeader", out var header) + && transformValues.TryGetValue("Set", out var newHeader)) + { + if (parameter.Name == newHeader) + { + parameter.Name = header; + } + } + } + } + + return true; + } + + return false; + } +} + +``` + +Then register the transformation in `Program.cs` + +``` +builder.Services + .AddReverseProxy() + .LoadFromConfig(configuration) + .AddTransformFactory() + .AddSwagger(configuration) ``` \ No newline at end of file diff --git a/sample/App2/Controllers/WeatherForecastController.cs b/sample/App2/Controllers/WeatherForecastController.cs index ad503b9..65c7235 100644 --- a/sample/App2/Controllers/WeatherForecastController.cs +++ b/sample/App2/Controllers/WeatherForecastController.cs @@ -20,9 +20,9 @@ public WeatherForecastController(ILogger logger) } [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() + public IEnumerable Get([FromHeader(Name = "x-range")] int range = 5) { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast + return Enumerable.Range(1, range).Select(index => new WeatherForecast { Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), TemperatureC = Random.Shared.Next(-20, 55), diff --git a/sample/Yarp/Program.cs b/sample/Yarp/Program.cs index a6be6d4..3c65281 100644 --- a/sample/Yarp/Program.cs +++ b/sample/Yarp/Program.cs @@ -5,6 +5,7 @@ using Yarp.Extensions; using Yarp.ReverseProxy.Swagger; using Yarp.ReverseProxy.Swagger.Extensions; +using Yarp.Transformations; var builder = WebApplication.CreateBuilder(args); @@ -31,6 +32,7 @@ .AddReverseProxy() .LoadFromConfig(configuration) .LoadFromConfig(configurationForOnlyPublishedRoutes) + .AddTransformFactory() .AddSwagger(configuration) .AddSwagger(configurationForOnlyPublishedRoutes); diff --git a/sample/Yarp/Transformations/HeaderTransformFactory.cs b/sample/Yarp/Transformations/HeaderTransformFactory.cs new file mode 100644 index 0000000..cf15980 --- /dev/null +++ b/sample/Yarp/Transformations/HeaderTransformFactory.cs @@ -0,0 +1,122 @@ +using Microsoft.OpenApi.Models; +using System.Net; +using Yarp.ReverseProxy.Swagger; +using Yarp.ReverseProxy.Transforms; +using Yarp.ReverseProxy.Transforms.Builder; + +namespace Yarp.Transformations; + +public class HeaderTransformFactory : ITransformFactory, ISwaggerTransformFactory +{ + /// + /// Property validates for header titel rename + /// + /// + /// + /// + public bool Validate(TransformRouteValidationContext context, IReadOnlyDictionary transformValues) + { + if (transformValues.TryGetValue("RenameHeader", out var header)) + { + if (string.IsNullOrEmpty(header)) + { + context.Errors.Add(new ArgumentException("A non-empty RenameHeader value is required")); + } + + if (transformValues.TryGetValue("Set", out var newHeader)) + { + if (string.IsNullOrEmpty(newHeader)) + { + context.Errors.Add(new ArgumentException("A non-empty Set value is required")); + } + } + else + { + context.Errors.Add(new ArgumentException("Set option is required")); + } + + return true; + } + + return false; + } + + /// + /// Header title rename transformation + /// + /// + /// + /// + /// + public bool Build(TransformBuilderContext context, IReadOnlyDictionary transformValues) + { + if (transformValues.TryGetValue("RenameHeader", out var header)) + { + if (string.IsNullOrEmpty(header)) + { + throw new ArgumentException("A non-empty RenameHeader value is required"); + } + + if (transformValues.TryGetValue("Set", out var newHeader)) + { + if (string.IsNullOrEmpty(newHeader)) + { + throw new ArgumentException("A non-empty Set value is required"); + } + } + else + { + throw new ArgumentException("Set option is required"); + } + + context.AddRequestTransform(transformContext => + { + if (transformContext.ProxyRequest.Headers.TryGetValues(header, out var headerValue)) + { + // Remove the original header + transformContext.ProxyRequest.Headers.Remove(header); + + // Add a new header with the same value(s) as the original header + transformContext.ProxyRequest.Headers.Add(newHeader, headerValue); + } + + return default; + }); + + return true; + } + + return false; + } + + /// + /// Header title rename transformation for Swagger + /// + /// + /// + /// + public bool Build(OpenApiOperation operation, IReadOnlyDictionary transformValues) + { + if (transformValues.ContainsKey("RenameHeader")) + { + foreach (var parameter in operation.Parameters) + { + if (parameter.In.HasValue && parameter.In.Value.ToString().Equals("Header")) + { + if (transformValues.TryGetValue("RenameHeader", out var header) + && transformValues.TryGetValue("Set", out var newHeader)) + { + if (parameter.Name == newHeader) + { + parameter.Name = header; + } + } + } + } + + return true; + } + + return false; + } +} diff --git a/sample/Yarp/appsettings.json b/sample/Yarp/appsettings.json index 72856b7..b282e10 100644 --- a/sample/Yarp/appsettings.json +++ b/sample/Yarp/appsettings.json @@ -34,9 +34,13 @@ "Path": "/proxy-app2/{**catch-all}" }, "Transforms": [ - { - "PathPattern": "{**catch-all}" - } + { + "PathPattern": "{**catch-all}" + }, + { + "RenameHeader": "days", + "Set": "x-range" + } ] } }, diff --git a/src/Yarp.ReverseProxy.Swagger/ISwaggerTransformFactory.cs b/src/Yarp.ReverseProxy.Swagger/ISwaggerTransformFactory.cs new file mode 100644 index 0000000..3af9146 --- /dev/null +++ b/src/Yarp.ReverseProxy.Swagger/ISwaggerTransformFactory.cs @@ -0,0 +1,10 @@ +using Microsoft.OpenApi.Models; +using System.Collections.Generic; + +namespace Yarp.ReverseProxy.Swagger +{ + public interface ISwaggerTransformFactory + { + bool Build(OpenApiOperation operation, IReadOnlyDictionary transformValues); + } +} diff --git a/src/Yarp.ReverseProxy.Swagger/ReverseProxyDocumentFilter.cs b/src/Yarp.ReverseProxy.Swagger/ReverseProxyDocumentFilter.cs index 9c1f7a4..a55adcb 100644 --- a/src/Yarp.ReverseProxy.Swagger/ReverseProxyDocumentFilter.cs +++ b/src/Yarp.ReverseProxy.Swagger/ReverseProxyDocumentFilter.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -6,7 +7,9 @@ using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Readers; using Swashbuckle.AspNetCore.SwaggerGen; +using Yarp.ReverseProxy.Configuration; using Yarp.ReverseProxy.Swagger.Extensions; +using Yarp.ReverseProxy.Transforms.Builder; namespace Yarp.ReverseProxy.Swagger { @@ -15,16 +18,19 @@ public sealed class ReverseProxyDocumentFilter : IDocumentFilter private readonly IHttpClientFactory _httpClientFactory; private ReverseProxyDocumentFilterConfig _config; private readonly IReadOnlyDictionary _operationTypeMapping; + private readonly List _factories; public ReverseProxyDocumentFilter( IHttpClientFactory httpClientFactory, - IOptionsMonitor configOptions) + IOptionsMonitor configOptions, + IEnumerable factories) { + _factories = factories?.ToList(); _config = configOptions.CurrentValue; _httpClientFactory = httpClientFactory; - + configOptions.OnChange(config => { _config = config; }); - + _operationTypeMapping = new Dictionary { {"GET", OperationType.Get}, @@ -47,7 +53,7 @@ DocumentFilterContext context { return; } - + IReadOnlyDictionary clusters; if (_config.Swagger.IsCommonDocument) @@ -60,10 +66,10 @@ DocumentFilterContext context .Where(x => x.Key == context.DocumentName) .ToDictionary(x => x.Key, x => x.Value); } - + Apply(swaggerDoc, clusters); } - + private void Apply( OpenApiDocument swaggerDoc, IReadOnlyDictionary clusters @@ -73,7 +79,7 @@ private void Apply( { return; } - + var info = swaggerDoc.Info; var paths = new OpenApiPaths(); var components = new OpenApiComponents(); @@ -84,12 +90,12 @@ private void Apply( { var clusterKey = clusterKeyValuePair.Key; var cluster = clusterKeyValuePair.Value; - + if (true != cluster.Destinations?.Any()) { continue; } - + foreach (var destination in cluster.Destinations) { if (true != destination.Value.Swaggers?.Any()) @@ -139,6 +145,7 @@ private void Apply( continue; } + var operationKeys = path.Value.Operations.Keys.ToList(); if (publishedRoutes != null) { var pathKey = $"{swagger.PrefixPath}{path.Key}"; @@ -152,7 +159,6 @@ private void Apply( .Where(q => methods.Contains(q.Key)) .Select(q => q.Value) .ToList(); - var operationKeys = path.Value.Operations.Keys.ToList(); foreach (var operationKey in operationKeys) { @@ -163,6 +169,8 @@ private void Apply( } } + ApplySwaggerTransformation(operationKeys, path, clusterKey); + paths.TryAdd($"{swagger.PrefixPath}{key}", value); } @@ -205,5 +213,31 @@ private static IReadOnlyDictionary> GetPublishedPath return validRoutes; } + + private void ApplySwaggerTransformation(List operationKeys, KeyValuePair path, string clusterKey) + { + var factories = _factories?.Where(x => x is ISwaggerTransformFactory).ToList(); + + if (factories == null) return; + + foreach (var operationKey in operationKeys) + { + path.Value.Operations.TryGetValue(operationKey, out var operation); + + var transforms = _config.Routes + .Where(x => x.Value.ClusterId == clusterKey) + .Where(x => x.Value.Transforms != null) + .SelectMany(x => x.Value.Transforms) + .ToList(); + + foreach (var swaggerFactory in factories.Select(factory => factory as ISwaggerTransformFactory)) + { + foreach (var transform in transforms) + { + swaggerFactory?.Build(operation, transform); + } + } + } + } } } \ No newline at end of file