microsoft / reverse-proxy

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

dynamically configure Redirects in Routes? #2576

Closed burtonrodman closed 1 month ago

burtonrodman commented 3 months ago

Some details

Hello, my question is this: I realize that YARP is meant for proxying, not redirecting, but YARP does provide an elegant dynamic configuration system that is not default to .Net Core's Redirect setup... ie dynamically configuring Redirects in .Net Core is hard 😢

I looked at the YARP route configuration and didn't see any notion of Redirects. My current application has a table of Redirects that get configured on startup, but they are not dynamic -- ie if they change in the database we have to restart the site.

As a part of a rewrite of our app we're using YARP to proxy some pages to the new site and some to the old.

However, given that my YARP project now effectively provides a nice "pinch point" for all things route(ish), it seems like a good place to also handle general redirects in a more dynamic way.

Is there anything built-in that I'm missing, or can you point me to any examples that I could use to come to a solution for dynamically configurable redirects.

For some background, an example redirect is a feature of the old site that we're deprecating now should be redirected to the homepage.

How many backends are in your application?

How do you host your application?

Tratcher commented 3 months ago

You're right that this isn't natively supported, but you could add it with custom transforms keyed from config. https://microsoft.github.io/reverse-proxy/articles/transforms.html#extensibility https://github.com/microsoft/reverse-proxy/pull/1923

karelz commented 3 months ago

@burtonrodman did you look at the above? Is it sufficient for you?

burtonrodman commented 3 months ago

sorry, haven't had a chance to look back at this. will do asap.

karelz commented 2 months ago

@burtonrodman do you have rough ETA when it might be good time to check out the answer? Or should we close it as resolved and let you reopen if you find more problems / questions later?

MihaZupan commented 1 month ago

Closing as this is not actionable by us at the momemnt. Feel free to reopen if/when you have more info/questions.

burtonrodman commented 3 weeks ago

@Tratcher RequestTransforms did the trick -- although they are a bit confusing and docs could be better. Since I feel this could be a general(ish) solution, I'm sharing here:

in Program.cs

  .AddTransformFactory<MyTransformFactory>();

in my backgroundservice that updates in-memory proxy config regularly:

    // about 100 "dead" sub-sites pulled from db
    routes.Add(new RouteConfig() {
      RouteId = "deadsite",
      Match = new RouteMatch() { Path = "deadsite/{**catch-all}" },
      ClusterId = "subsiteCluster"
    }.WithTransformRedirect("/new-route-from-db", permanent: true));

    _provider.Update(new ReadOnlyCollection<RouteConfig>(routes), Clusters);

in RedirectRequestTransform.cs


using Yarp.ReverseProxy.Transforms;
using Yarp.ReverseProxy.Transforms.Builder;

namespace MyReverseProxy.Transforms;

public class RedirectRequestTransform(string location, bool permanent) : RequestTransform
{
  public const string RedirectKey = "redirect";
  public const string PermanentKey = "permanent";

  public override ValueTask ApplyAsync(RequestTransformContext context)
  {
    context.HttpContext.Response.Redirect(location, permanent);
    return default;
  }
}

public class MyTransformFactory : ITransformFactory
{
  public bool Validate(TransformRouteValidationContext context, IReadOnlyDictionary<string, string> transformValues)
  {
    if (transformValues.TryGetValue(RedirectRequestTransform.RedirectKey, out var location) &&
        transformValues.TryGetValue(RedirectRequestTransform.PermanentKey, out var permanentString))
    {
      if (string.IsNullOrWhiteSpace(location)) context.Errors.Add(new ArgumentException("a non-empty redirect value (location) is required."));
      if (!bool.TryParse(permanentString, out bool permanent)) context.Errors.Add(new ArgumentException("permanent must be a valid bool"));
      return true;
    }
    return false;
  }

  public bool Build(TransformBuilderContext context, IReadOnlyDictionary<string, string> transformValues)
  {
    if (transformValues.TryGetValue(RedirectRequestTransform.RedirectKey, out var location) &&
        transformValues.TryGetValue(RedirectRequestTransform.PermanentKey, out var permanentString))
    {
      if (string.IsNullOrWhiteSpace(location)) throw new ArgumentException("a non-empty redirect value (location) is required.");
      if (!bool.TryParse(permanentString, out bool permanent)) throw new ArgumentException("permanent must be a valid bool");
      context.RequestTransforms.Add(new RedirectRequestTransform(location, permanent));
      return true;
    }
    return false;
  }
}

public static class RouteConfigExtensions
{
  public static RouteConfig WithTransformRedirect(
    this RouteConfig route, string location, bool permanent
  )
  {
    if (location is null) throw new ArgumentNullException(nameof(location));
    if (string.IsNullOrWhiteSpace(location)) throw new InvalidOperationException("location must be at lease 1 character.");

    return route.WithTransform(transform =>
    {
      transform[RedirectRequestTransform.RedirectKey] = location;
      transform[RedirectRequestTransform.PermanentKey] = permanent.ToString();
    });
  }
}