覆盖 webapi odata 链接的主机

Override host of webapi odata links

我正在使用 WebAPI 2.2 和 Microsoft.AspNet.OData 5.7.0 创建支持分页的 OData 服务。

当托管在生产环境中时,WebAPI 位于不对外公开的服务器上,因此 OData 响应中返回的各种链接(例如 @odata.context@odata.nextLink 指向内部 IP 地址,例如http://192.168.X.X/<AccountName>/api/...

我已经能够通过在每个 ODataController 方法中实现一些逻辑来修改 Request.ODataProperties().NextLink,以将内部 URL 替换为外部 URL,例如 https://account-name.domain.com/api/..., 但这很不方便,而且只能修复 NextLinks。

有什么方法可以在配置 OData 服务时设置外部主机名吗?我看到了 属性 Request.ODataProperties().Path 并且想知道是否可以在 config.MapODataServiceRoute("odata", "odata", GetModel()); 调用中设置基本路径,或者在 GetModel() 实现中使用例如 ODataConventionModelBuilder?


更新: 到目前为止我想出的最好的解决方案是创建一个 BaseODataController 覆盖 Initialize 方法并检查是否Request.RequestUri.Host.StartsWith("beginning-of-known-internal-IP-address") 然后像这样重写 RequestUri:

var externalAddress = ConfigClient.Get().ExternalAddress;  // e.g. https://account-name.domain.com
var account = ConfigClient.Get().Id;  // e.g. AccountName
var uriToReplace = new Uri(new Uri("http://" + Request.RequestUri.Host), account);
string originalUri = Request.RequestUri.AbsoluteUri;
Request.RequestUri = new Uri(Request.RequestUri.AbsoluteUri.Replace(uriToReplace.AbsoluteUri, externalAddress));
string newUri = Request.RequestUri.AbsoluteUri;
this.GetLogger().Info($"Request URI was rewritten from {originalUri} to {newUri}");

这完美地修复了所有控制器的 @odata.nextLink URLs,但由于某些原因 @odata.context URLs 仍然得到 AccountName 部分(例如 https://account-name.domain.com/AccountName/api/odata/$metadata#ControllerName) 所以它们仍然不起作用。

您的问题归结为从服务本身控制服务根 URI。我的第一个想法是寻找用于序列化响应的媒体类型格式化程序的挂钩。 ODataMediaTypeFormatter.MessageWriterSettings.PayloadBaseUriODataMediaTypeFormatter.MessageWriterSettings.ODataUri.ServiceRoot 都是建议解决方案的可设置属性。不幸的是,ODataMediaTypeFormatter resets these properties on every call to WriteToStreamAsync.

解决方法并不明显,但如果您深入研究源代码,您最终会找到对 IODataPathHandler.Link 的调用。路径处理程序是一个 OData 扩展点,因此您可以创建一个自定义路径处理程序,它总是 returns 一个以您想要的服务根开头的绝对 URI。

public class CustomPathHandler : DefaultODataPathHandler
{
    private const string ServiceRoot = "http://example.com/";

    public override string Link(ODataPath path)
    {
        return ServiceRoot + base.Link(path);
    }
}

然后在服务配置期间注册该路径处理程序。

// config is an instance of HttpConfiguration
config.MapODataServiceRoute(
    routeName: "ODataRoute",
    routePrefix: null,
    model: builder.GetEdmModel(),
    pathHandler: new CustomPathHandler(),
    routingConventions: ODataRoutingConventions.CreateDefault()
);

重写 RequestUri 足以影响 @odata.nextLink 值,因为 code that computes the next link depends on the RequestUri directly. The other @odata.xxx links are computed via a UrlHelper 以某种方式 引用了原始请求中的路径网址。 (因此您在 @odata.context link 中看到 AccountName。我在我的代码中看到了这种行为,但我无法追踪缓存 URI 路径的来源.)

而不是重写 RequestUri,我们可以通过创建 CustomUrlHelper class 来即时重写 OData link 来解决问题。新的 GetNextPageLink 方法将处理 @odata.nextLink 重写,Link 方法覆盖将处理所有其他重写。

public class CustomUrlHelper : System.Web.Http.Routing.UrlHelper
{
    public CustomUrlHelper(HttpRequestMessage request) : base(request)
    { }

    // Change these strings to suit your specific needs.
    private static readonly string ODataRouteName = "ODataRoute"; // Must be the same as used in api config
    private static readonly string TargetPrefix = "http://localhost:8080/somePathPrefix"; 
    private static readonly int TargetPrefixLength = TargetPrefix.Length;
    private static readonly string ReplacementPrefix = "http://www.contoso.com"; // Do not end with slash

    // Helper method.
    protected string ReplaceTargetPrefix(string link)
    {
        if (link.StartsWith(TargetPrefix))
        {
            if (link.Length == TargetPrefixLength)
            {
                link = ReplacementPrefix;
            }
            else if (link[TargetPrefixLength] == '/')
            {
                link = ReplacementPrefix + link.Substring(TargetPrefixLength);
            }
        }

        return link;
    }

    public override string Link(string routeName, IDictionary<string, object> routeValues)
    {
        var link = base.Link(routeName, routeValues);

        if (routeName == ODataRouteName)
        {
            link = this.ReplaceTargetPrefix(link);
        }

        return link;
    }

    public Uri GetNextPageLink(int pageSize)
    {
        return new Uri(this.ReplaceTargetPrefix(this.Request.GetNextPageLink(pageSize).ToString()));
    }
}

在基本控制器 Initialize 方法中连接 CustomUrlHelper class。

public abstract class BaseODataController : ODataController
{
    protected abstract int DefaultPageSize { get; }

    protected override void Initialize(System.Web.Http.Controllers.HttpControllerContext controllerContext)
    {
        base.Initialize(controllerContext);

        var helper = new CustomUrlHelper(controllerContext.Request);
        controllerContext.RequestContext.Url = helper;
        controllerContext.Request.ODataProperties().NextLink = helper.GetNextPageLink(this.DefaultPageSize);
    }

注意上面的页面大小对于给定控制器中的所有操作都是相同的class。您可以通过将 ODataProperties().NextLink 的赋值移动到特定操作方法的主体来解决此限制,如下所示:

var helper = this.RequestContext.Url as CustomUrlHelper;
this.Request.ODataProperties().NextLink = helper.GetNextPageLink(otherPageSize);

使用 system.web.odata 6.0.0.0.

过早设置 NextLink 属性 会出现问题。然后每个回复都会有一个 nextLink。最后一页当然应该没有这样的装饰。

http://docs.oasis-open.org/odata/odata-json-format/v4.0/os/odata-json-format-v4.0-os.html#_Toc372793048 说:

URLs present in a payload (whether request or response) MAY be represented as relative URLs.

我希望可行的一种方法是覆盖 EnableQueryAttribute:

public class myEnableQueryAttribute : EnableQueryAttribute
{
    public override IQueryable ApplyQuery(IQueryable queryable, ODataQueryOptions queryOptions)
    {
        var result = base.ApplyQuery(queryable, queryOptions);
        var nextlink = queryOptions.Request.ODataProperties().NextLink;
        if (nextlink != null)
            queryOptions.Request.ODataProperties().NextLink = queryOptions.Request.RequestUri.MakeRelativeUri(nextlink);
        return result;
    }
}

ApplyQuery() 是检测到 "overflow" 的地方。它基本上要求 pagesize+1 行,如果结果集包含超过 pagesize 行,将设置 NextLink

此时将NextLink改写成亲戚URL就相对容易了。

缺点是每个 odata 方法现在都必须使用新的 myEnableQuery 属性进行装饰:

[myEnableQuery]
public async Task<IHttpActionResult> Get(ODataQueryOptions<TElement> options)
{
  ...
}

和其他嵌入其他地方的 URL 仍然存在问题。 odata.context 仍然是一个问题。我想避免使用请求 URL,因为我看不出随着时间的推移如何维护它。

还有另一种解决方案,但它会覆盖整个上下文的 url。 我想建议的是:

  1. 创建owin中间件并覆盖里面的Host和Scheme属性
  2. 将中间件注册为第一个

这里有一个中间件的例子

public class RewriteUrlMiddleware : OwinMiddleware
{
    public RewriteUrlMiddleware(OwinMiddleware next)
        : base(next)
    {
    }

    public override async Task Invoke(IOwinContext context)
    {
        context.Request.Host = new HostString(Settings.Default.ProxyHost);
        context.Request.Scheme = Settings.Default.ProxyScheme;
        await Next.Invoke(context);
    }
}

ProxyHost 是您想要的主机。示例:test.com

ProxyScheme 是您想要的方案:示例:https

中间件注册示例

public class Startup
{
    public void Configuration(IAppBuilder app)
    {
        app.Use(typeof(RewriteUrlMiddleware));
        var config = new HttpConfiguration();
        WebApiConfig.Register(config);
        app.UseWebApi(config);
    }
}

lencharest 的回答很有希望,但我发现他的方法有所改进。我没有使用 UrlHelper,而是创建了一个派生自 System.Net.Http.DelegatingHandler 的 class。此 class 被插入(首先)到消息处理管道中,因此在更改传入的 HttpRequestMessage 时有一个破解。这是对上述解决方案的改进,因为除了更改 controller-specific URL(如 UrlHelper 所做的那样,例如 https://data.contoso.com/odata/MyController), it also alters the url that appears as the xml:base in the OData service document (e.g., https://data.contoso.com/odata)。

我的特定应用程序是在代理服务器后面托管 OData 服务,我希望服务器提供的所有 URL 都是 externally-visible URL,而不是 internally-visible URL。而且,我不想为此依赖注释;我希望它是全自动的。

消息处理程序如下所示:

    public class BehindProxyMessageHandler : DelegatingHandler
    {
        protected async override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken cancellationToken)
        {
            var builder = new UriBuilder(request.RequestUri);
            var visibleHost = builder.Host;
            var visibleScheme = builder.Scheme;
            var visiblePort = builder.Port;

            if (request.Headers.Contains("X-Forwarded-Host"))
            {
                string[] forwardedHosts = request.Headers.GetValues("X-Forwarded-Host").First().Split(new char[] { ',' });
                visibleHost = forwardedHosts[0].Trim();
            }

            if (request.Headers.Contains("X-Forwarded-Proto"))
            {
                visibleScheme = request.Headers.GetValues("X-Forwarded-Proto").First();
            }

            if (request.Headers.Contains("X-Forwarded-Port"))
            {
                try
                {
                    visiblePort = int.Parse(request.Headers.GetValues("X-Forwarded-Port").First());
                }
                catch (Exception)
                { }
            }

            builder.Host = visibleHost;
            builder.Scheme = visibleScheme;
            builder.Port = visiblePort;

            request.RequestUri = builder.Uri;
            var response = await base.SendAsync(request, cancellationToken);
            return response;
        }
    }

您在 WebApiConfig.cs 中连接处理程序:

    config.Routes.MapODataServiceRoute(
        routeName: "odata",
        routePrefix: "odata",
        model: builder.GetEdmModel(),
        pathHandler: new DefaultODataPathHandler(),
        routingConventions: ODataRoutingConventions.CreateDefault()
    );
    config.MessageHandlers.Insert(0, new BehindProxyMessageHandler());

几年后,使用 ASP.NET Core,我认为在我的服务中应用它的最简单方法是创建一个伪装主机名的过滤器。 (AppConfig 是自定义配置 class,其中包含主机名等。)

public class MasqueradeHostFilterAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        var appConfig = context.HttpContext.RequestServices.GetService<AppConfig>();
        if (!string.IsNullOrEmpty(appConfig?.MasqueradeHost))
            context.HttpContext.Request.Host = new HostString(appConfig.MasqueradeHost);
    }
}

将过滤器应用于控制器底座 class。

[MasqueradeHostFilter]
public class AppODataController : ODataController
{
}

结果是格式良好的输出:

{ "@odata.context":"https://app.example.com/odata/$metadata" }

只是我的两分钱。