这个解决方案有两个部分,GET/读操作和写操作。让我们先关注读操作。
在自定义 EnableQueryAttribute 中,您可以覆盖两种方法,这可能对这种情况有所帮助:
OnActionExecuting
在这里,您可以在处理 WebAPI 请求之前操作 url。
您可以轻松地重新编写 url,而不必担心重新创建整个 OData 上下文,但是... 除了您通过属性
/// <summary>
/// Manipulate the URL before the WebAPI request has been processed.
/// </summary>
/// <remarks>Simplifies logic and post-processing operations</remarks>
/// <param name="actionContext"></param>
public override void OnActionExecuting(HttpActionContext actionContext)
{
// Perform operations that require modification of the Url in OnActionExecuting instead of ApplyQuery.
// Apply Query should not modify the original Url if you can help it because there can be other validator
// processes that already have expectations on the output matching the original input request.
// This goes for injecting or mofying $select, $expand or $count parameters
// Modify the actionContext request directly before returning the base operation
// actionContext.Request.RequestUri = new Uri(modifiedUrl);
base.OnActionExecuting(actionContext);
}
我最初的答案是基于 ODataLib v5,当时我有点天真,当时我们被允许做不同的事情,所以我建议这个覆盖
ApplyQuery 此方法几乎在请求结束时运行,在控制器逻辑修改/创建查询后,您将获得 IQueryable(这是管道中执行这些类型的另一个有效位置的操作)
不幸的是,如果您更改结果的结构,则在此阶段对查询进行更改可能会破坏默认的 OData 序列化。有一些方法可以解决它,但是您必须操作 ODataQuerySettings 以及查询,并且您必须修改原始 URL。因此,Apply Query 现在更好地保留给不需要修改查询的被动逻辑操作,而是对查询进行操作,可能用于日志记录或一些安全操作
/// <summary>
/// Applies the query to the given IQueryable based on incoming query from uri and
/// query settings.
/// </summary>
/// <param name="queryable">The original queryable instance from the response message.</param>
/// <param name="queryOptions">The System.Web.OData.Query.ODataQueryOptions instance constructed based on the incomming request</param>
public override IQueryable ApplyQuery(IQueryable queryable, ODataQueryOptions queryOptions)
{
// TODO: add your custom logic here
return base.ApplyQuery(entity, options);
}
TL;DR - GET 请求的解决方案!
- 我们希望在没有提供 $select 时提供默认值
- 我们希望将 $select 的任何值限制为仅我们指定的字段
对于这个例子,我们将把我们的逻辑放在 OnActionExecuting 覆盖中,因为它非常简单,我们不需要担心控制器逻辑中的这个逻辑,我们不必操作任何 IQueryable 表达式,最后是重要的一个:
由请求 URI 生成的 ODataQueryOptions 用于在序列化期间约束响应负载,因此即使我们在控制器逻辑中选择或包含其他字段或导航属性,序列化器也会将响应限制为仅包含字段在 $select 和 $expand 中指定
这就是我们在这里想要做的,限制我们控制器的所有输出,以便只有一部分字段可用。
/// <summary>
/// Manipulate the URL before the WebAPI request has been processed.
/// AllowedSelectProperties may contain a CSV list of allowed field names to $select
/// </summary>
/// <remarks>If AllowedSelectProperties does not have a value, do not modify the request</remarks>
/// <param name="actionContext">Current Action context, access the Route defined parameters and the raw http request</param>
public override void OnActionExecuting(HttpActionContext actionContext)
{
// Only modify the request if AllowedSelectProperties has been specified
if (!String.IsNullOrWhiteSpace(this.AllowedSelectProperties))
{
// parse the url parameters so we can process them
var tokens = actionContext.Request.RequestUri.ParseQueryString();
// CS: Special Case - if $apply is requested, DO NOT process defaults, $apply must be fully declared in terms of outputs and filters by the caller
// $apply is outside of the scope of this question :) so if it exists, skip this logic.
if (String.IsNullOrEmpty(tokens["$apply"]))
{
// check the keys, do not evaluate if the value is empty, empty is allowed
// if $expand is specified, and by convention and should not return any fields from the root element
if (!tokens.AllKeys.Contains("$select"))
tokens["$select"] = this.AllowedSelectProperties;
else
{
// We need to parse and modify the $select token
var select = tokens["$select"];
IEnumerable<string> selectFields = select.Split(',').Select(x => x.Trim());
IEnumerable<string> allowedFields = this.AllowedSelectProperties.Split(',').Select(x => x.Trim());
// Intersect allows us to ujse our allowedFields as a MASK against the requested fields
// NOTE: THIS IS PASSIVE, you could throw an exception if you want to prevent execution when an invalid field is requested.
selectFields = selectFields.Intersect(allowedFields, StringComparer.OrdinalIgnoreCase);
tokens["$select"] = string.Join(",", selectFields);
}
// Rebuild our modified URI
System.Text.StringBuilder result = new System.Text.StringBuilder();
result.Append(actionContext.Request.RequestUri.AbsoluteUri.Split('?').First());
if (tokens.Count > 0)
{
result.Append("?");
result.Append(String.Join("&",
tokens.AllKeys.Select(key =>
String.Format("{0}={1}", key, Uri.EscapeDataString(tokens[key]))
)
)
);
}
// Apply the modified Uri to the action context
actionContext.Request.RequestUri = new Uri(result.ToString());
}
}
// Allow the base logic to complete
base.OnActionExecuting(actionContext);
}
TL;DR - 关于写操作
如何在不使用 View Model 等冗余代码的情况下为 POST/PUT/PATCH 应用相同的设计?
我们不能在 EnableQueryAttribute 中轻易影响写入操作,我们不能使用 ApplyQuery 覆盖,因为它在操作之后执行
(是的,如果您的控制器选择这样做,您仍然可以从 POST/PUT/PATCH 返回查询 - 让我们稍后再讨论)
但我们也不能在请求之前修改 OnActionExecuting 中的 POST/PUT,因为结构可能不再与模型匹配,并且数据不会被序列化并传递给您的控制器。
这必须在您的控制器逻辑中处理,但您可以轻松地在基类中执行此操作,以在用户尝试提供字段时拒绝请求或忽略它们,这是一个基类的示例处理这些规则的类。
/// <summary>
/// Base controller to support AllowedSelectProperties
/// </summary>
/// <typeparam name="TContext">You application DbContext that this Controller will operate against</typeparam>
/// <typeparam name="TEntity">The entity type that this controller is bound to</typeparam>
/// <typeparam name="TKey">The type of the key property for this TEntity</typeparam>
public abstract class MyODataController<TContext, TEntity, TKey> : ODataController
where TContext : DbContext
where TEntity : class
{
public string AllowedSelectProperties { get; set; }
protected static ODataValidationSettings _validationSettings = new ODataValidationSettings() { MaxExpansionDepth = 5 };
private TContext _db = null;
/// <summary>
/// Get a persistant DB Context per request
/// </summary>
/// <remarks>Inheriting classes can override RefreshDBContext to handle how a context is created</remarks>
protected TContext db
{
get
{
if (_db == null) _db = InitialiseDbContext();
return _db;
}
}
/// <summary>
/// Create the DbContext, provided to allow inheriting classes to manage how the context is initialised, without allowing them to change the sequence of when such actions ocurr.
/// </summary>
protected virtual TContext InitialiseDbContext()
{
// Using OWIN by default, you could simplify this to "return new TContext();" if you are not using OWIN to store context per request
return HttpContext.Current.GetOwinContext().Get<TContext>();
}
/// <summary>
/// Generic access point for specifying the DBSet that this entity collection can be accessed from
/// </summary>
/// <returns></returns>
protected virtual DbSet<TEntity> GetEntitySet()
{
return db.Set<TEntity>();
}
/// <summary>
/// Find this item in Db using the default Key lookup lambda
/// </summary>
/// <param name="key">Key value to lookup</param>
/// <param name="query">[Optional] Query to apply this filter to</param>
/// <returns></returns>
protected virtual async Task<TEntity> Find(TKey key, IQueryable<TEntity> query = null)
{
if (query != null)
return query.SingleOrDefault(FindByKey(key));
else
return GetEntitySet().SingleOrDefault(FindByKey(key));
}
/// <summary>
/// Force inheriting classes to define the Key lookup
/// </summary>
/// <example>protected override Expression<Func<TEntity, bool>> FindByKey(TKey key) => => x => x.Id == key;</example>
/// <param name="key">The Key value to lookup</param>
/// <returns>Linq expression that compares the key field on items in the query</returns>
protected abstract Expression<Func<TEntity, bool>> FindByKey(TKey key);
// PUT: odata/DataItems(5)
/// <summary>
/// Please use Patch, this action will Overwrite an item in the DB... I pretty much despise this operation but have left it in here in case you find a use for it later.
/// NOTE: Default UserPolicy will block this action.
/// </summary>
/// <param name="key">Identifier of the item to replace</param>
/// <param name="patch">A deltafied representation of the object that we want to overwrite the DB with</param>
/// <returns>UpdatedOdataResult</returns>
[HttpPut]
public async Task<IHttpActionResult> Put([FromODataUri] TKey key, Delta<TEntity> patch, ODataQueryOptions<TEntity> options)
{
Validate(patch.GetInstance());
if (!ModelState.IsValid)
return BadRequest(ModelState);
Delta<TEntity> restrictedObject = null;
if (!String.IsNullOrWhiteSpace(this.AllowedSelectProperties))
{
var updateableProperties = AllowedSelectProperties.Split(',').Select(x => x.Trim());
/*****************************************************************
* Example that prevents patch when invalid fields are presented *
* Comment this block to passively allow the operation and skip *
* over the invalid fields *
* ***************************************************************/
if (patch.GetChangedPropertyNames().Any(x => updateableProperties.Contains(x, StringComparer.OrdinalIgnoreCase)))
return BadRequest("Can only PUT an object with the following fields: " + this.AllowedSelectProperties);
/*****************************************************************
* Passive example, re-create the delta and skip invalid fields *
* ***************************************************************/
restrictedObject = new Delta<TEntity>();
foreach (var field in updateableProperties)
{
if (restrictedObject.TryGetPropertyValue(field, out object value))
restrictedObject.TrySetPropertyValue(field, value);
}
}
var itemQuery = GetEntitySet().Where(FindByKey(key));
var item = itemQuery.FirstOrDefault();
if (item == null)
return NotFound();
if (restrictedObject != null)
restrictedObject.Patch(item); // yep, revert to patch
else
patch.Put(item);
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!ItemExists(key))
return NotFound();
else
throw;
}
return Updated(item);
}
// PATCH: odata/DataItems(5)
/// <summary>
/// Update an existing item with a deltafied or partial declared JSON object
/// </summary>
/// <param name="key">The ID of the item that we want to update</param>
/// <param name="patch">The deltafied or partial representation of the fields that we want to update</param>
/// <returns>UpdatedOdataResult</returns>
[AcceptVerbs("PATCH", "MERGE")]
public virtual async Task<IHttpActionResult> Patch([FromODataUri] TKey key, Delta<TEntity> patch, ODataQueryOptions<TEntity> options)
{
Validate(patch.GetInstance());
if (!ModelState.IsValid)
return BadRequest(ModelState);
if (!String.IsNullOrWhiteSpace(this.AllowedSelectProperties))
{
var updateableProperties = AllowedSelectProperties.Split(',').Select(x => x.Trim());
/*****************************************************************
* Example that prevents patch when invalid fields are presented *
* Comment this block to passively allow the operation and skip *
* over the invalid fields *
* ***************************************************************/
if (patch.GetChangedPropertyNames().Any(x => updateableProperties.Contains(x, StringComparer.OrdinalIgnoreCase)))
return BadRequest("Can only Patch the following fields: " + this.AllowedSelectProperties);
/*****************************************************************
* Passive example, re-create the delta and skip invalid fields *
* ***************************************************************/
var delta = new Delta<TEntity>();
foreach (var field in updateableProperties)
{
if (delta.TryGetPropertyValue(field, out object value))
delta.TrySetPropertyValue(field, value);
}
patch = delta;
}
var itemQuery = GetEntitySet().Where(FindByKey(key));
var item = itemQuery.FirstOrDefault();
if (item == null)
return NotFound();
patch.Patch(item);
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!ItemExists(key))
return NotFound();
else
throw;
}
return Updated(item);
}
/// <summary>
/// Inserts a new item into this collection
/// </summary>
/// <param name="item">The item to insert</param>
/// <returns>CreatedODataResult</returns>
[HttpPost]
public virtual async Task<IHttpActionResult> Post(TEntity item)
{
// If you are validating model state, then the POST will still need to include the properties that we don't want to allow
// By convention lets consider that the value of the default fields must be equal to the default value for that type.
// You may need to remove this standard validation if this.AllowedSelectProperties has a value
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (!String.IsNullOrWhiteSpace(this.AllowedSelectProperties))
{
var updateableProperties = AllowedSelectProperties.Split(',').Select(x => x.Trim());
/*****************************************************************
* Example that prevents patch when invalid fields are presented *
* Comment this block to passively allow the operation and skip *
* over the invalid fields *
* ***************************************************************/
// I hate to use reflection here, instead of reflection I would use scripts or otherwise inject this logic
var props = typeof(TEntity).GetProperties();
foreach(var prop in props)
{
if (!updateableProperties.Contains(prop.Name, StringComparer.OrdinalIgnoreCase))
{
var value = prop.GetValue(item);
bool isNull = false;
if (prop.PropertyType.IsValueType)
isNull = value == Activator.CreateInstance(prop.PropertyType);
else
isNull = value == null;
if(isNull) return BadRequest("Can only PUT an object with the following fields: " + this.AllowedSelectProperties);
}
}
/***********************************************************************
* Passive example, create a new object with only the valid fields set *
* *********************************************************************/
var sanitized = Activator.CreateInstance<TEntity>();
foreach (var field in updateableProperties)
{
var prop = props.First(x => x.Name.Equals(field, StringComparison.OrdinalIgnoreCase));
prop.SetValue(sanitized, prop.GetValue(item));
}
item = sanitized;
}
GetEntitySet().Add(item);
await db.SaveChangesAsync();
return Created(item);
}
/// <summary>
/// Overwritable query to check if an item exists, provided to assist mainly with mocking
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
protected virtual bool ItemExists(TKey key)
{
return GetEntitySet().Count(FindByKey(key)) > 0;
}
}
这是我在应用程序中使用的基类的精简版,只是简化了通用 CRUD 操作。我应用了一堆其他的安全修整和东西,但是对于我发誓 OnActionExecuting 解决方案的 GET 操作,它的执行速度比我能想到的任何其他方法都快,因为它发生在解析操作之前。