ODATA provides several methods to validate the query options a client can specify. However it is not too easy to modify them, which might become necessary when we only want to allow certain options in relation to one another. In this article I will show how this can be done on a per request level or via an attribute.
The main problem is that all of the ODATA validation options we can override or extend (such as EnableQueryAttribute.ValidateQuery
) only jump in after the request has been processed and data is about to leave the controller method. However two approaches are possible:
- Replacing ODataQueryOptions inside the respective Controller method
- Replacing ODataQueryOptions inside
HttpActionContext.ActionArguments
Replacing ODataQueryOptions
inside the respective Controller method
A typical ODATA method for returning an entity set has a signature similar to this:
public IHttpActionResult Get(ODataQueryOptions<TEntity> queryOptions);
Instantiating ODataQueryOptions
(generic or non-generic) requires an ODataQueryContext
and an HttpRequestMessage
. Both of these are available as properties on the original ODataQueryOptions
. So in order to override our query options we only have to split the incoming RequestUri
and create new options from it. In the following example will strip off any $expand
options that contain a property called Details
.
public static ODataQueryOptions RemoveExpandDetails(ODataQueryOptions queryOptions) { const char QUERY_OPTIONS_PREFIX = '?'; const char QUERY_OPTIONS_SEPARATOR = '&'; const char QUERY_OPTIONS_DELIMITER = '='; const string QUERY_OPTION_EXPAND = "$expand"; const char QUERY_OPTION_EXPAND_DELIMITER = ','; if (!queryOptions.SelectExpand.RawExpand.Contains(nameof(PublicEntity.Details))) { return queryOptions; } var uri = queryOptions.Request.RequestUri; var queryParameters = uri.Query .TrimStart(QUERY_OPTIONS_PREFIX) .Split(QUERY_OPTIONS_SEPARATOR) .ToDictionary(e => e.Split(QUERY_OPTIONS_DELIMITER).FirstOrDefault(), e => e.Split(QUERY_OPTIONS_DELIMITER).LastOrDefault()); var newQueryOptions = new StringBuilder(); queryParameters.ForEach(e => { if (e.Key != QUERY_OPTION_EXPAND) { newQueryOptions.Append(QUERY_OPTIONS_SEPARATOR); newQueryOptions.Append(e.Key); newQueryOptions.Append(QUERY_OPTIONS_DELIMITER); newQueryOptions.Append(e.Value); return; } var value = e.Value.Replace(nameof(PublicEntity.Details), string.Empty).Trim(QUERY_OPTION_EXPAND_DELIMITER); if (string.IsNullOrWhiteSpace(value)) { return; } newQueryOptions.Append(QUERY_OPTIONS_SEPARATOR); newQueryOptions.Append(e.Key); newQueryOptions.Append(QUERY_OPTIONS_DELIMITER); newQueryOptions.Append(value); }); queryOptions.Request.RequestUri = new Uri($"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath}{QUERY_OPTIONS_PREFIX}{newQueryOptions}"); var newOdataQueryOptions = (ODataQueryOptions) Activator.CreateInstance(queryOptions.GetType(), queryOptions.Context, queryOptions.Request); return newOdataQueryOptions; }
Now can call this method in our Get
method:
public IHttpActionResult Get(ODataQueryOptions<TEntity> queryOptions) { QueryOptionsUtilities.RemoveExpandDetails(queryOptions); // ... }
Replacing ODataQueryOptions inside HttpActionContext.ActionArguments
Instead of rewriting query options in every requeest we can also create a custom EnableQuery
attribute and apply it to our controller methods. The problem here is that the ValidateQuery
method which contains a parameter with ODataQueryOptions
is only called after the request has been processed by the underlying LINQ provider. However, the very first method called in our query attribute is OnActionExecuting
and contains an HttpActionContext
. Inside this context we can access ActionArguments
which contain the query options available under the queryOptions
key.
With this we can extract the options an re-attach our modified options:
public class AppclusiveEnableQueryAttribute : EnableQueryAttribute { private const string QUERY_OPTIONS_KEY = "queryOptions"; public override void OnActionExecuting(HttpActionContext actionContext) { Contract.Requires(actionContext.ActionArguments.ContainsKey(QUERY_OPTIONS_KEY)); var queryOptions = (ODataQueryOptions) actionContext.ActionArguments[QUERY_OPTIONS_KEY]; queryOptions = QueryOptionsUtilities.RemoveExpandDetails(queryOptions); actionContext.ActionArguments[QUERY_OPTIONS_KEY] = queryOptions; base.OnActionExecuting(actionContext); } }
Now with these two options we can modify incoming query options in a very granular way and modify them on the fly.
[UPDATE]
A complete version with more flexibility of invocation can be found here: https://gist.github.com/dfch/12697689ed0ed1c855828acb85294342
/** | |
* Copyright 2017 d-fens GmbH | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
*/ | |
using System; | |
using System.Collections.Generic; | |
using System.Diagnostics.Contracts; | |
using System.Web.Http.OData.Query; | |
namespace Net.Appclusive.WebApi.OdataServices | |
{ | |
public static class ODataQueryOptionsExtensions | |
{ | |
public static readonly ODataQuerySettings _OdataQuerySettings = new ODataQuerySettings(); | |
public static ODataQueryOptions Transform | |
( | |
this ODataQueryOptions queryOptions, | |
ODataQueryOptionsUtilitiesTransformSettings transform | |
) | |
{ | |
return ODataQueryOptionsUtilities.Transform | |
( | |
queryOptions, | |
transform | |
); | |
} | |
public static ODataQueryOptions Transform | |
( | |
this ODataQueryOptions queryOptions, | |
Func<IReadOnlyDictionary<string, string>, FilterQueryOption, string> transformFilter, | |
Func<IReadOnlyDictionary<string, string>, OrderByQueryOption, IList<string>> transformOrderBy, | |
Func<IReadOnlyDictionary<string, string>, SelectExpandQueryOption, IList<string>> transformExpand, | |
Func<IReadOnlyDictionary<string, string>, SelectExpandQueryOption, IList<string>> transformSelect, | |
Func<IReadOnlyDictionary<string, string>, SkipQueryOption, int?> transformSkip, | |
Func<IReadOnlyDictionary<string, string>, TopQueryOption, int?> transformTop | |
) | |
{ | |
Contract.Requires(null != queryOptions); | |
return ODataQueryOptionsUtilities.Transform | |
( | |
queryOptions: queryOptions, | |
transformFilter: transformFilter, | |
transformOrderBy: transformOrderBy, | |
transformExpand: transformExpand, | |
transformSelect: transformSelect, | |
transformSkip: transformSkip, | |
transformTop: transformTop | |
); | |
} | |
public static ODataQueryOptions RemoveSkipCount(this ODataQueryOptions queryOptions) | |
{ | |
Contract.Requires(null != queryOptions); | |
Contract.Ensures(null != Contract.Result<ODataQueryOptions>()); | |
return ODataQueryOptionsUtilities.Transform | |
( | |
queryOptions, | |
new ODataQueryOptionsUtilitiesTransformSettings | |
{ | |
Skip = (map, option) => default(int?) | |
} | |
); | |
} | |
public static ODataQueryOptions SetSkipCount(this ODataQueryOptions queryOptions, int value) | |
{ | |
Contract.Requires(null != queryOptions); | |
Contract.Requires(0 <= value); | |
Contract.Ensures(null != Contract.Result<ODataQueryOptions>()); | |
return ODataQueryOptionsUtilities.Transform | |
( | |
queryOptions, | |
new ODataQueryOptionsUtilitiesTransformSettings | |
{ | |
Skip = (map, option) => value | |
} | |
); | |
} | |
} | |
} |
/** | |
* Copyright 2017 d-fens GmbH | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
*/ | |
using System; | |
using System.Collections.Generic; | |
using System.Collections.ObjectModel; | |
using System.Diagnostics.Contracts; | |
using System.Linq; | |
using System.Text; | |
using System.Web.Http.OData.Query; | |
using biz.dfch.CS.Commons.Linq; | |
using Net.Appclusive.Public.Domain; | |
namespace Net.Appclusive.WebApi.OdataServices | |
{ | |
public class ODataQueryOptionsUtilities | |
{ | |
public const char QUERY_OPTIONS_PREFIX = '?'; | |
public const char QUERY_OPTIONS_SEPARATOR = '&'; | |
public const char QUERY_OPTIONS_DELIMITER = '='; | |
public const string QUERY_OPTION_EXPAND = "$expand"; | |
public const string QUERY_OPTION_SELECT = "$select"; | |
public const string QUERY_OPTION_SKIP = "$skip"; | |
public const string QUERY_OPTION_TOP = "$top"; | |
public const char QUERY_OPTION_SELECT_EXPAND_DELIMITER = ','; | |
public static ODataQueryOptions Transform | |
( | |
ODataQueryOptions queryOptions, | |
ODataQueryOptionsUtilitiesTransformSettings settings | |
) | |
{ | |
Contract.Requires(null != queryOptions); | |
Contract.Requires(null != settings); | |
Contract.Ensures(null != Contract.Result<ODataQueryOptions>()); | |
return Transform | |
( | |
queryOptions: queryOptions, | |
transformFilter: settings.Filter, | |
transformOrderBy: settings.OrderBy, | |
transformExpand: settings.Expand, | |
transformSelect: settings.Select, | |
transformSkip: settings.Skip, | |
transformTop: settings.Top | |
); | |
} | |
public static ODataQueryOptions Transform | |
( | |
ODataQueryOptions queryOptions, | |
Func<IReadOnlyDictionary<string, string>, FilterQueryOption, string> transformFilter, | |
Func<IReadOnlyDictionary<string, string>, OrderByQueryOption, IList<string>> transformOrderBy, | |
Func<IReadOnlyDictionary<string, string>, SelectExpandQueryOption, IList<string>> transformExpand, | |
Func<IReadOnlyDictionary<string, string>, SelectExpandQueryOption, IList<string>> transformSelect, | |
Func<IReadOnlyDictionary<string, string>, SkipQueryOption, int?> transformSkip, | |
Func<IReadOnlyDictionary<string, string>, TopQueryOption, int?> transformTop | |
) | |
{ | |
Contract.Requires(null != queryOptions); | |
Contract.Ensures(null != Contract.Result<ODataQueryOptions>()); | |
var uri = queryOptions.Request.RequestUri; | |
var queryParameters = uri.Query | |
.TrimStart(QUERY_OPTIONS_PREFIX) | |
.Split(new[] { QUERY_OPTIONS_SEPARATOR }, StringSplitOptions.RemoveEmptyEntries) | |
.ToDictionary | |
( | |
e => e.Split(QUERY_OPTIONS_DELIMITER).FirstOrDefault(), | |
e => e.Split(QUERY_OPTIONS_DELIMITER).LastOrDefault() | |
); | |
if (null != transformFilter) | |
{ | |
var result = transformFilter(new ReadOnlyDictionary<string, string>(queryParameters), queryOptions.Filter); | |
if (null != result) | |
{ | |
queryParameters[QUERY_OPTION_EXPAND] = result; | |
} | |
} | |
if (null != transformOrderBy) | |
{ | |
var result = transformOrderBy(new ReadOnlyDictionary<string, string>(queryParameters), queryOptions.OrderBy); | |
if (null != result) | |
{ | |
queryParameters[QUERY_OPTION_EXPAND] = string.Join(QUERY_OPTION_SELECT_EXPAND_DELIMITER.ToString(), result); | |
} | |
} | |
if (null != transformExpand) | |
{ | |
var result = transformExpand(new ReadOnlyDictionary<string, string>(queryParameters), queryOptions.SelectExpand); | |
if (null != result) | |
{ | |
queryParameters[QUERY_OPTION_EXPAND] = string.Join(QUERY_OPTION_SELECT_EXPAND_DELIMITER.ToString(), result); | |
} | |
} | |
if (null != transformSelect) | |
{ | |
var result = transformSelect(new ReadOnlyDictionary<string, string>(queryParameters), queryOptions.SelectExpand); | |
if (null != result) | |
{ | |
queryParameters[QUERY_OPTION_SELECT] = string.Join(QUERY_OPTION_SELECT_EXPAND_DELIMITER.ToString(), result); | |
} | |
} | |
if (null != transformSkip) | |
{ | |
var result = transformSkip(new ReadOnlyDictionary<string, string>(queryParameters), queryOptions.Skip); | |
if (result.HasValue) | |
{ | |
queryParameters[QUERY_OPTION_SKIP] = result.Value.ToString(); | |
} | |
else | |
{ | |
if (queryParameters.ContainsKey(QUERY_OPTION_SKIP)) | |
{ | |
queryParameters.Remove(QUERY_OPTION_SKIP); | |
} | |
} | |
} | |
if (null != transformTop) | |
{ | |
var result = transformTop(new ReadOnlyDictionary<string, string>(queryParameters), queryOptions.Top); | |
if (result.HasValue) | |
{ | |
queryParameters[QUERY_OPTION_TOP] = result.Value.ToString(); | |
} | |
else | |
{ | |
if (queryParameters.ContainsKey(QUERY_OPTION_TOP)) | |
{ | |
queryParameters.Remove(QUERY_OPTION_TOP); | |
} | |
} | |
} | |
var newQueryOptions = new StringBuilder(); | |
queryParameters.ForEach(e => | |
{ | |
newQueryOptions.Append(QUERY_OPTIONS_SEPARATOR); | |
newQueryOptions.Append(e.Key); | |
newQueryOptions.Append(QUERY_OPTIONS_DELIMITER); | |
newQueryOptions.Append(e.Value); | |
}); | |
queryOptions.Request.RequestUri = | |
new Uri($"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath}{QUERY_OPTIONS_PREFIX}{newQueryOptions}"); | |
var newOdataQueryOptions = (ODataQueryOptions)Activator.CreateInstance(queryOptions.GetType(), queryOptions.Context, queryOptions.Request); | |
return newOdataQueryOptions; | |
} | |
} | |
} |
/** | |
* Copyright 2017 d-fens GmbH | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
*/ | |
using System; | |
using System.Collections.Generic; | |
using System.Web.Http.OData.Query; | |
namespace Net.Appclusive.WebApi.OdataServices | |
{ | |
public class ODataQueryOptionsUtilitiesTransformSettings | |
{ | |
public Func<IReadOnlyDictionary<string, string>, FilterQueryOption, string> Filter; | |
public Func<IReadOnlyDictionary<string, string>, OrderByQueryOption, IList<string>> OrderBy; | |
public Func<IReadOnlyDictionary<string, string>, SelectExpandQueryOption, IList<string>> Expand; | |
public Func<IReadOnlyDictionary<string, string>, SelectExpandQueryOption, IList<string>> Select; | |
public Func<IReadOnlyDictionary<string, string>, SkipQueryOption, int?> Skip; | |
public Func<IReadOnlyDictionary<string, string>, TopQueryOption, int?> Top; | |
} | |
} |
What is PublicEntity.Details?