microsoft / reverse-proxy

A toolkit for developing high-performance HTTP reverse proxy applications.
https://microsoft.github.io/reverse-proxy
MIT License
8.44k stars 831 forks source link

How to add Swagger for Gateway? #1789

Open P9avel opened 2 years ago

P9avel commented 2 years ago

Can you provide example using Swagger with proxy?

samsp-msft commented 2 years ago

There is not a swagger endpoint for the proxy - the proxy itself does not directly expose services that would have a swagger definition - it's the destinations of the proxy that might. Depending on the routes the proxy is configured with, it could expose multiple services that each has an OpenAPI definition. In theory you could create a service that would enumerate the routes the proxy supports, takes a OpenAPI definition and does a transform based on the route definition, but that has not been automated.

Depending on how the swagger UI is generated, it may work correctly through the proxy, but it will depend on how it generates links and request URLs for testing services.

rwkarg commented 2 years ago

We do this by probing routes for well known swagger.json endpoints, then expose those via an enumerator like https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1093#issuecomment-964622538

This allows us to have a single swagger UI served up by the proxy which has a drop down to select one of the routes that exposes a swagger.json file.

Authentication is a bit hit or miss. For example, some backends use PKCE and others don't but the swagger.json doesn't have a way to convey that so it has to be hard coded one way or the other. Haven't found a way to support both yet.

Also need to set up CORS to allow whatever host you're exposing the SwaggerUI as to call in to the actual routes (assuming they're not the same origin).

Other than the mentioned authentication issues, it "works" to select one of the routes on the Swagger UI and Try It! which calls to the backend as expected.

samsp-msft commented 2 years ago

We discussed this in triage. Its not a feature that we are likely to build for YARP, however if the community implements it, we'll seriously consider a PR.

alexandrehtrb commented 2 years ago

+1

psavoldelli commented 1 year ago

This is how I do with Swashbuckle. This is a work in progress, there is a lot of missing part (matching, cache...)

Create new specs list based on your services:

app.UseSwaggerUI(opt =>
{
    opt.SwaggerEndpoint("/swagger/1.0/swagger.json", "Gateway service by itself- 1.0");
    opt.SwaggerEndpoint("/swagger/serviceA/1.0/swagger.json", "ServiceA - 1.0");
    opt.SwaggerEndpoint("/swagger/serviceB/1.0/swagger.json", "ServiceB - 1.0");
});

Add endpoint to handle specs request

app.UseEndpoints(endpoints =>
{
    // map yarp swagger 
    endpoints.MapGetSwaggerForYarp(_configuration);

    // All controller
    endpoints.MapReverseProxy();
    endpoints.MapControllers().RequireAuthorization();
});

Create swagger options class

internal class GatewaySwaggerSpec
{
    public string Endpoint { get; set; }
    public string Spec { get; set; }
    public string OriginPath { get; set; }
    public string TargetPath { get; set; }
}

Config exemple, the main point is to provide ClusterId inside cluster configuration object to be able to match routes and cluster

{
  "Gateway": {
    "Routes": {
      "serviceA": {
        "ClusterId": "ServiceA",
        "AuthorizationPolicy": "Validated",
        "Match": {
          "Path": "/api/advertising/{**catch-all}"
        },
        "Transforms": [
          { "PathPattern": "/api/{**catch-all}" }
        ]
      }
    },
    "Clusters": {
      "ServiceA": {
        "ClusterId": "ServiceA",
        "Swagger": {
          "Endpoint": "/swagger/serviceA/1.0/swagger.json",
          "Spec": "http://localhost:5003/swagger/serviceA/1.0/swagger.json",
          "OriginPath": "/api/serviceA",
          "TargetPath": "/api"
        },
        "Destinations": {
          "serviceA": {
            "Address": ""
          }
        }
      }
    }
  }
}

Get all swagger to implements from the configuration

  public static void MapGetSwaggerForYarp(this IEndpointRouteBuilder endpoints,IConfiguration configuration)
  {
      var clusters = configuration.GetSection("Gateway:Clusters");
      var routes = configuration.GetSection("Gateway:Routes").Get<List<RouteConfig>>();

      if (clusters != null)
      {
          foreach (var child in clusters.GetChildren())
          {
              if (child.GetSection("Swagger").Exists())
              {
                  var cluster = child.Get<ClusterConfig>();
                  var swagger = child.GetSection("Swagger").Get<GatewaySwaggerSpec>();

                 // Map swagger endpoint if we find a cluster with swagger configuration
                  endpoints.MapSwaggerSpecs(routes, cluster, swagger);
              }
          }
      }
  }

And finaly, this is how I get the swagger spec and filter routes and operations that does not match the proxy configuration

public static void MapSwaggerSpecs(this IEndpointRouteBuilder endpoints, List<RouteConfig> config, ClusterConfig cluster, GatewaySwaggerSpec swagger)
  {
      endpoints.Map(swagger.Endpoint, async context => {
          var client = new HttpClient();
          var stream = await client.GetStreamAsync(swagger.Spec);

          var document = new OpenApiStreamReader().Read(stream, out var diagnostic);
          var rewrite = new OpenApiPaths();

          // map existing path
          var routes = config.Where(p => p.ClusterId == cluster.ClusterId);
          var hasCatchAll = routes != null && routes.Any(p => p.Match.Path.Contains("**catch-all")) ;

          foreach (var path in document.Paths) {

              var rewritedPath = path.Key.Replace(swagger.TargetPath, swagger.OriginPath);

              // there is a catch all, all route are elligible 
              // or there is a route that match without any operation methods filtering
              if (hasCatchAll || routes.Any(p => p.Match.Path.Equals(rewritedPath) && p.Match.Methods == null ))
              {
                  rewrite.Add(rewritedPath, path.Value);
              }
              else
              {
                  // there is a route that match
                  var routeThatMatchPath = routes.Any( p => p.Match.Path.Equals(rewritedPath) );
                  if(routeThatMatchPath)
                  {
                      // filter operation based on yarp config
                      var operationToRemoves = new List<OperationType>();
                      foreach (var operation in path.Value.Operations)
                      {
                          // match route and method
                          var hasRoute = routes.Any(
                              p => p.Match.Path.Equals(rewritedPath) && p.Match.Methods.Contains(operation.Key.ToString().ToUpperInvariant())
                          );

                          if (!hasRoute)
                          {
                              operationToRemoves.Add(operation.Key);
                          }
                      }

                      // remove operation routes
                      foreach (var operationToRemove in operationToRemoves)
                      {
                          path.Value.Operations.Remove(operationToRemove);
                      }

                      // add path if there is any operation
                      if (path.Value.Operations.Any())
                      {
                          rewrite.Add(rewritedPath, path.Value);
                      }
                  }
              }
          }

          document.Paths = rewrite;

          var result = document.Serialize(OpenApiSpecVersion.OpenApi3_0, OpenApiFormat.Json);
          await context.Response.WriteAsync(
              result
          );
      });
psavoldelli commented 1 year ago

Btw do we have a way to retrieve the yarp object configuration directly instead of reading the config files manually?

Tratcher commented 1 year ago

See https://github.com/microsoft/reverse-proxy/blob/main/src/ReverseProxy/Management/IProxyStateLookup.cs

sorcerb commented 1 year ago

Thank you. I will try to write the code above with ProxyState function. I will share when it will be done

P9avel commented 1 year ago

Many thx, sorceb

alpacat00 commented 1 year ago

Is it possible to get the list of RouteModel (from IProxyStateLookup.GetRoutes()) or RouteConfig objects with all the transformations already applied? Or do I need to read all of the transforms from the config/proxy state as well and apply them manually?

I'm trying to configure swagger for YARP gateway using code similiar to the above and when I'm filtering the OpenApiPaths I would like to be able to compare them with transformed routes. What would be the best approach to do this?

Tratcher commented 1 year ago

No, transforms aren't applied to routes, they're applied to outgoing requests on a per-request basis.

Why would you include transformed routes in the public swagger?

alpacat00 commented 1 year ago

Why would you include transformed routes in the public swagger?

I don't want to include them in swagger, I just need them to compare with paths that are generated in swagger by default.

psavoldelli commented 1 year ago

I think I can help a bit with this part. But I'm not sure if this swagger addition should be put in this repository, or a separate one There are quite a few constraints, depending on the integration of spec files. In particular the whole cluster part, but also the "catch-all" part on the routes.

Tratcher commented 1 year ago

@psavoldelli I'd expect a YARP swagger generator to only cover the routes? The clusters should be opaque to the caller.

You can outline a design proposal here, or send a draft PR for discussion.

psavoldelli commented 1 year ago

Here is the main idea (I will push also a PR soon!!)

problem statement

Find a way with Yarp to expose open API specifications based on underlying services

Why is this important to you?

We use yarp as a gateway for microservices exposition. The frontend automaticaly builds its data access layer directly from the open API specs for obvious reasons.

It is therefore necessary to correctly expose the routes via Yarp, and that they are aligned with what is really made available by the different microservices, as we do not allow manual changes to the data access generation on frontend side.

Proposals:

Naive implementation:

This implementation is not complete and has its small drawbacks. I assume that there is only one destination per cluster, or at least that each destination respects the same interface contract. There is no hot reload configuration consideration also...

The proxy must provide the routes for the spec files it makes available.

// Configure app services
var builder = WebApplication.CreateBuilder(args);

// Http stack
var app = builder.Build();

app.MapOpenApiSpecs(app.Configuration)
app.MapReverseProxy();
app.Run();

It provide specs based on cluster configuration as all clusters do not provide open api specs

Sample cluster configuration

"Clusters": {
  "<clusterName>": {
    "ClusterId": "<clusterId>",
    "OpenApiSpec": {
      "Endpoint": "/swagger/content/1.0/swagger.json",
      "Spec": "swagger/1.0/swagger.json"
    },
    "Destinations": {
      "<destinationName>": {
        "Address": "http://localhost:5000"
      }
    }
  }
}
/// <summary>
/// open api configuration model
/// </summary>
/// <param name="Endpoint">Endpoint provided to get the specs</param>
/// <param name="Spec">Specification route based on cluster first destination base address</param>
internal record OpenApiSpecOptions(string Endpoint, string Spec);

public static void MapOpenApiSpecs(this IEndpointRouteBuilder endpoints, IConfiguration configuration)
{
    var clusters = configuration.GetSection("Gateway:Clusters");

    if (clusters != null)
    {
        foreach (var child in clusters.GetChildren())
        {
            if (child.GetSection("OpenApiSpec").Exists())
            {
                // get raw cluster config only to get id
                var cluster = child.Get<ClusterConfig>();
                var config = child.GetSection("OpenApiSpec").Get<OpenApiSpecOptions>();

                endpoints.MapOpenApiSpecs(config, cluster.ClusterId);
            }
        }
    }
}

And finally map the endpoint for that cluser.

This is an overview of the road I took. I use Microsoft.OpenApi to parse the open spec file.

/// <summary>
/// Map open api spec based on cluster config
/// </summary>
public static void MapOpenApiSpec(this IEndpointRouteBuilder endpoints, OpenApiSpecOptions options, string clusterId)
{
    endpoints.Map(options.Endpoint, async context =>
    {
        // get cluster configuration
        var configuration = endpoints.ServiceProvider.GetService<IProxyStateLookup>();
        configuration.TryGetCluster(clusterId, out var cluster);

        // get first destination open api specs
        var client = new HttpClient();
        var root = cluster.Destinations.First().Value.Model.Config.Address;
        var stream = await client.GetStreamAsync($"{root.TrimEnd('/')}/{options.Spec.TrimStart('/')}");

        // get open api documents
        var document = new OpenApiStreamReader().Read(stream, out var diagnostic);

        // get an api document rewritten and filtered
        // according to the route configuration that matches the cluster
        var specifications = FilterOpenApiDocument(configuration, cluster, document);

        // write the new specifications as a response
        var result = specifications.Serialize(OpenApiSpecVersion.OpenApi3_0, OpenApiFormat.Json);
        await context.Response.WriteAsync(
            result
        );
    });
}

The main idea...

private static OpenApiDocument FilterOpenApiDocument(IProxyStateLookup configuration, ClusterState cluster, OpenApiDocument document)
{
    // create new paths
    var rewrite = new OpenApiPaths();

    // map existing path
    var routes = configuration.GetRoutes().Where(p => p.Cluster.ClusterId == cluster.ClusterId);
    var hasCatchAll = routes != null && routes.Any(p => p.Config.Match.Path.Contains("**catch-all"));

    // filter routes and apply transformation.
    foreach (var path in document.Paths)
    {
        // the main work :) ...
    }

    // apply specific tagging for each route (optional!)
    foreach (var path in rewrite)
    {
        foreach (var operation in path.Value.Operations)
        {
            operation.Value.Tags = new List<OpenApiTag> {
                new OpenApiTag() { Name = cluster.ClusterId }
            };
        }
    }

    // return new document
    return new OpenApiDocument(document) {
        Paths = rewrite
    };
}
Vulthil commented 1 year ago

Here is the main idea (I will push also a PR soon!!)

Any progress on this ?

ctyar commented 1 year ago

I have created a NuGet package to fix this issue: https://github.com/ctyar/Swashbuckle.Yarp

andreytreyt commented 1 year ago

Hi there πŸ‘‹

I have created a NuGet package too, but with generation of Swagger files by clusters. Repo is in link.

Welcome contributors and issuers πŸ‘

mesandeepkr commented 1 year ago

@andreytreyt , thank you for the nuget it was very much needed. I tried that, and seems all working but it exposes all the endpoints. Do you have something similar to Ocelet to expose only methods that are mentioned in the routing?
For example, the downstream API has 10 endpoints and we want to expose only 2 out of them and the gateway has mapping of only 2 endpoints.

andreytreyt commented 1 year ago

@andreytreyt , thank you for the nuget it was very much needed. I tried that, and seems all working but it exposes all the endpoints. Do you have something similar to Ocelet to expose only methods that are mentioned in the routing?
For example, the downstream API has 10 endpoints and we want to expose only 2 out of them and the gateway has mapping of only 2 endpoints.

Welcome! It doesn't have a similar function. But if you create an issue in the repository of my package I'll resolve it.

vadimkhm commented 1 year ago

Hi there many thanks to @andreytreyt! πŸ‘ Waiting for when this will be included in the main library wondering how YARP can be used without swagger UI, it helps a lot when you have different endpoints.

asulyanarsen commented 1 year ago

I'm eagerly awaiting a resolution to this issue as well. Integrating Swagger with YARP would be a significant enhancement for my projects. Hope to see this feature soon!

dbeattie71 commented 1 year ago

Also a thanks to @andreytreyt! Using that lib and it works great. Adapted a middleware for our reverse-proxy from a blog post so its protected by oAuth, gist and links referencing the post are here: https://gist.github.com/dbeattie71/390da65f45b22c9b284555b96ddc055c

agerchev commented 11 months ago

Hello,

andreytreyt, just curious why not use OpenApiFilterService.CreateFilteredDocument to filter the openApiSpec. We combined it with TemplateMatcher to filter the operations from the route template in the Proxy Config.

psavoldelli any progress on the PR? We are doing something similar, but it would be great if there is an 'official' solution for the problem.

andreytreyt commented 11 months ago

@agerchev hi there! It's better to see an example of your implementation πŸ˜‰

agerchev commented 11 months ago

Hello,

These are the fragments we use last two years in our projects. They are working for us while waiting for something more 'official' from the YARP team.

Sample configuration:

        "MetaData": {
          "backendOpenApiUrl": "/openapi.json",
          "PathPrefix": "/api/sse"
        }

We register the swagger endpoints for the clusters that have open api spec configured:

                var proxyConfigProvider = endpoints.ServiceProvider.GetRequiredService<IProxyConfigProvider>();

                var proxyConfig = proxyConfigProvider.GetConfig();

                var clusters = proxyConfig.Clusters.Where(cc =>
                {
                    if (cc == null || cc.Metadata == null) return false;

                    if (!cc.Metadata.TryGetValue("backendOpenApiUrl", out string clusterOpenApiDocumentUrl)) return false;

                    return !string.IsNullOrEmpty(clusterOpenApiDocumentUrl);
                });

                foreach (var cluster in clusters)
                {
                    c.SwaggerEndpoint($"{cluster.ClusterId}/swagger.json", $"{ cluster.ClusterId}");
                }

Additionally we register a swaggerProvider:

public class YarpSwaggerProvider : ISwaggerProvider, IAsyncSwaggerProvider
    {
        private static readonly string cachePrefix = Guid.NewGuid().ToString();

        private SwaggerGenerator _generator;
        private IProxyConfig _proxyConfig;
        private IMemoryCache _memoryCache;

        #region Types

        internal class OperationSearchFixer : OperationSearch
        {
            private int _mode;

            private class FixerExtension : IOpenApiExtension
            {
                public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion)
                {
                    throw new NotImplementedException();
                }
            }

            public OperationSearchFixer(int mode) : base((url, operationType, operation) => true)
            {
                _mode = mode;
            }

            public override void Visit(IList<OpenApiParameter> parameters)
            {
                if (_mode == 0)
                {
                    /* Π² Π±Π°Π·ΠΎΠ²Π°Ρ‚Π° имплСмСнтация, сС сСтва parameter.Explode. 
                     * https://github.com/microsoft/OpenAPI.NET/blob/0ec11c156cfd2169f7c0ccdf9720240ba97816dd/src/Microsoft.OpenApi/Services/OperationSearch.cs#L62 
                     * 
                     */
                    foreach (var parameter in parameters.Where(x => x.Style == ParameterStyle.Form))
                    {
                        if (parameter.Explode)
                            parameter.AddExtension("x-set-explode-true", new FixerExtension());
                    }

                    base.Visit(parameters);
                }
                else if (_mode == 1)
                {
                    base.Visit(parameters);

                    /* Π² Π±Π°Π·ΠΎΠ²Π°Ρ‚Π° имплСмСнтация, сС сСтва parameter.Explode. 
                     * https://github.com/microsoft/OpenAPI.NET/blob/0ec11c156cfd2169f7c0ccdf9720240ba97816dd/src/Microsoft.OpenApi/Services/OperationSearch.cs#L62 
                     * 
                     */
                    foreach (var parameter in parameters.Where(x => x.Style == ParameterStyle.Form))
                    {
                        if (parameter.Extensions.ContainsKey("x-set-explode-true"))
                        {
                            parameter.Explode = true;
                            parameter.Extensions.Remove("x-set-explode-true");
                        }
                    }
                }
                else
                    base.Visit(parameters);
            }
        }

        #endregion

        #region Constructors 

        public YarpSwaggerProvider(SwaggerGenerator generator, IProxyConfigProvider proxyConfigProvider, IMemoryCache memoryCache)
        {
            _generator = generator;
            _proxyConfig = proxyConfigProvider.GetConfig();
            _memoryCache = memoryCache;
        }

        #endregion

        public OpenApiDocument GetSwagger(string documentName, string host = null, string basePath = null)
        {
            return GetSwaggerAsync(documentName, host, basePath).Result;
        }

        public Task<OpenApiDocument> GetSwaggerAsync(string documentName, string host = null, string basePath = null)
        {
            return _memoryCache.GetOrCreate(cachePrefix + documentName, async entry =>
            {
                OpenApiDocument document = null;

                //ΠΏΡ€ΠΎΠ±Π²Π°ΠΌΠ΅ Π΄Π° Π³Π΅Π½Π΅Ρ€ΠΈΡ€Π°ΠΌΠ΅ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ ΠΎΡ‚ конфигурацията Π½Π° yarp. Ако няма съвпадСниС, сС ΠΈΠ·ΠΏΠΎΠ»Π·Π²Π° SwaggerGenerator -  Π°
                document = await CreateDocumentFromProxyConfig(documentName, host, basePath);

                if (document != null) return document;

                document = await _generator.GetSwaggerAsync(documentName, host, basePath);

                entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(3);

                return document;
            });
        }

        private async Task<OpenApiDocument> CreateDocumentFromProxyConfig(string documentName, string host = null, string basePath = null)
        {
            ClusterConfig cConfig = _proxyConfig.Clusters.SingleOrDefault((cluster) => { return string.Compare(cluster.ClusterId, documentName, true) == 0; });

            if (cConfig == null || cConfig.Metadata == null) return null;

            if (!cConfig.Metadata.TryGetValue("backendOpenApiUrl", out string clusterOpenApiDocumentUrl)) return null;

            var routesToProcess = _proxyConfig.Routes.Where((routeConfig) => { return string.Compare(routeConfig.ClusterId, cConfig.ClusterId, true) == 0; }).ToList();

            if (routesToProcess.Count == 0) return null;

            OpenApiDocument openApiDocument;

            #region Read serviceOpenApiDocument 

            using (HttpClient client = new HttpClient())
            {
                client.BaseAddress = new Uri(cConfig.Destinations["default"].Address);

                OpenApiStreamReader openApiStreamReader = new OpenApiStreamReader();

                openApiDocument = openApiStreamReader.Read(await client.GetStreamAsync(clusterOpenApiDocumentUrl), out OpenApiDiagnostic diagnostic);
            }

            #endregion

            new OpenApiWalker(new OperationSearchFixer(0)).Walk(openApiDocument);

            var openApiResultDocument = OpenApiFilterService.CreateFilteredDocument(openApiDocument, (url, operationType, operation) =>
            {
                foreach (var route in routesToProcess)
                {
                    if (route.Match.Methods != null &&
                        route.Match.Methods.Count > 0)
                    {
                        if (!operationType.HasValue) continue;
                        else if (!route.Match.Methods.Contains(Enum.GetName(operationType.Value), StringComparer.OrdinalIgnoreCase)) continue;
                    }

                    string template = route.Match.Path;

                    if (cConfig.Metadata.TryGetValue("PathPrefix", out string pathPrefix))
                        template = template.Replace(pathPrefix, "");

                    TemplateMatcher matcher = new TemplateMatcher(TemplateParser.Parse(template), new RouteValueDictionary());
                    var routeValues = new RouteValueDictionary();

                    if (matcher.TryMatch(url, routeValues))
                        return true;
                }

                return false;
            });

            new OpenApiWalker(new OperationSearchFixer(1)).Walk(openApiDocument);

            openApiResultDocument.Components.SecuritySchemes.Clear();

            openApiResultDocument.SecurityRequirements.Clear();

            openApiResultDocument.Info.Title = documentName;

            openApiResultDocument.Servers.Add(new OpenApiServer()
            {
                Url = cConfig.Metadata["PathPrefix"]
            });

            return openApiResultDocument;
        }
    }
moedeveloper commented 7 months ago

@agerchev can you please make a sample of it? I cant get it working?

moedeveloper commented 7 months ago

@andreytreyt seems not able to use JwtBearer

andreytreyt commented 7 months ago

@moedeveloper hello! Which tool? If you found any error you can create an issue in yarp swagger repo.

agerchev commented 7 months ago

Hello, @moedeveloper these days i'm out of time, and right now, i cannot create a full repo, sorry for that. Where do you have trouble ?

PS: You have to register the Swagger provider like this.

services.AddTransient<ISwaggerProvider, YarpSwaggerProvider>(); //use SwaggerGenerator in YarpSwaggerProvider implementation services.AddTransient<SwaggerGenerator, SwaggerGenerator>();

BrachaG commented 2 months ago

@andreytreyt thanks very much of your library its very helpful is it possible to also the yarp itself controllers in the swagger (finally to see also the proxy swagger and the yarp controllers)