Override host of webapi odata links

2019-01-28 16:27发布

问题:

I'm using WebAPI 2.2 and Microsoft.AspNet.OData 5.7.0 to create an OData service that supports paging.

When hosted in the production environment, the WebAPI lives on a server that is not exposed externally, hence the various links returned in the OData response such as the @odata.context and @odata.nextLink point to the internal IP address e.g. http://192.168.X.X/<AccountName>/api/... etc.

I've been able to modify the Request.ODataProperties().NextLink by implementing some logic in each and every ODataController method to replace the internal URL with an external URL like https://account-name.domain.com/api/..., but this is very inconvenient and it only fixes the NextLinks.

Is there some way to set an external host name at configuration time of the OData service? I've seen a property Request.ODataProperties().Path and wonder if it's possible to set a base path at the config.MapODataServiceRoute("odata", "odata", GetModel()); call, or in the GetModel() implementation using for instance the ODataConventionModelBuilder?


UPDATE: The best solution I've come up with so far, is to create a BaseODataController that overrides the Initialize method and checks whether the Request.RequestUri.Host.StartsWith("beginning-of-known-internal-IP-address") and then do a RequestUri rewrite like so:

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}");

This perfectly fixes the @odata.nextLink URLs for all controllers, but for some reason the @odata.context URLs still get the AccountName part (e.g. https://account-name.domain.com/AccountName/api/odata/$metadata#ControllerName) so they still don't work.

回答1:

Rewriting the RequestUri is sufficient to affect @odata.nextLink values because the code that computes the next link depends on the RequestUri directly. The other @odata.xxx links are computed via a UrlHelper, which is somehow referencing the path from the original request URI. (Hence the AccountName you see in your @odata.context link. I've seen this behavior in my code, but I haven't been able to track down the source of the cached URI path.)

Rather than rewrite the RequestUri, we can solve the problem by creating a CustomUrlHelper class to rewrite OData links on the fly. The new GetNextPageLink method will handle @odata.nextLink rewrites, and the Link method override will handle all other rewrites.

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()));
    }
}

Wire-up the CustomUrlHelper in the Initialize method of a base controller 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);
    }

Note in the above that the page size will be the same for all actions in a given controller class. You can work around this limitation by moving the assignment of ODataProperties().NextLink to the body of a specific action method as follows:

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


回答2:

There is another solution, but it overrides url for the entire context. What I'd like to suggest is:

  1. Create owin middleware and override Host and Scheme properties inside
  2. Register the middleware as the first one

Here is an example of middleware

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 is the host you want to have. Example: test.com

ProxyScheme is the scheme you want: Example: https

Example of middleware registration

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


回答3:

Using system.web.odata 6.0.0.0.

Setting the NextLink property too soon is problematic. Every reply will then have a nextLink in it. The last page should of course be free of such decorations.

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

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

One way that I hope will work is to override 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() is where the "overflow" is detected. It basically asks for pagesize+1 rows and will set NextLink if the result set contains more than pagesize rows.

At this point it is relatively easy to rewrite NextLink to a relative URL.

The downside is that every odata method must now be adorned with the new myEnableQuery attribute:

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

and other URLs embedded elsewhere remains problematic. odata.context remains a problem. I want to avoid playing with the request URL, because I fail to see how that is maintainable over time.



回答4:

Your question boils down to controlling the service root URI from within the service itself. My first thought was to look for a hook on the media type formatters used to serialize responses. ODataMediaTypeFormatter.MessageWriterSettings.PayloadBaseUri and ODataMediaTypeFormatter.MessageWriterSettings.ODataUri.ServiceRoot are both settable properties that suggest a solution. Unfortunately, ODataMediaTypeFormatter resets these properties on every call to WriteToStreamAsync.

The work-around is not obvious, but if you dig through the source code you'll eventually reach a call to IODataPathHandler.Link. A path handler is an OData extension point, so you can create a custom path handler that always returns an absolute URI which begins with the service root you desire.

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

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

And then register that path handler during service configuration.

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