我正在做一个自定义的EnableQueryAttribute来阻止某些属性被查询:
[EnableSpecificQueryable(AllowedSelectProperties = "Id, FirstName, LastName")]
它正在工作,但是如果没有发送查询(只有http://foo.bar/api/foo之类的内容),则永远不会调用ValidateQuery和ApplyQuery(请参阅EnableQueryAttribute),默认行为会显示所有属性,我不想要的。如何管理这个问题?我在哪里编写这种情况的代码?
在此之后,我对一般设计有一些疑问。 IMO,View Model在维护它时非常糟糕。它有很多重复的代码和很多文件,并没有那么多。
1。限制Action可以返回哪些属性的最佳方法是什么?
我真的很喜欢简单地给出每个Action上的属性名称列表,而不是使用数百个视图模型。遗憾的是,这只适用于GET请求,我想对帖子和补丁做同样的事情。
2。如何在不使用View Model等冗余代码的情况下为POST / PUT / PATCH应用相同的设计?
这个问题的答案需要考虑每个Action的专用Data注释(能够覆盖Model的数据注释并添加新的验证)。
我正在使用Entity Framework Code First在Web APi项目中使用OData。
谢谢!
答案 0 :(得分:1)
此解决方案分为两部分:GET /读取操作和写入操作。让我们首先关注读取操作。
在自定义EnableQueryAttribute中,有两种方法可以覆盖,在这种情况下可能会有所帮助:
<强> OnActionExecuting 强> 在这里,您可以在处理WebAPI请求之前操作URL。 你可以轻松地重新编写网址,而不必担心重新创建整个OData上下文,但是...... 你没有关于控制器的任何上下文信息,除了你通过attribuite
/// <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请求的解决方案!
对于这个例子,我们将把我们的逻辑放在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操作。我应用了一堆其他的安全修整和东西,但是对于GET操作我发誓OnActionExecuting解决方案,执行速度比其他任何我能想到的更快,因为它发生在解析操作之前。