C#OData设置$ select返回的默认属性

时间:2016-05-24 20:56:06

标签: c# odata

我正在做一个自定义的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。

谢谢!

1 个答案:

答案 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请求的解决方案!

  1. 我们希望在没有提供时提供默认的$ select
  2. 我们希望将$ select的任何值限制为仅指定字段
  3. 对于这个例子,我们将把我们的逻辑放在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解决方案,执行速度比其他任何我能想到的更快,因为它发生在解析操作之前。