【问题标题】:C# OData set default properties returned by $selectC# OData 设置 $select 返回的默认属性
【发布时间】:2016-05-24 20:56:05
【问题描述】:

我正在做一个自定义 EnableQueryAttribute 来阻止某些属性被查询:

[EnableSpecificQueryable(AllowedSelectProperties = "Id, FirstName, LastName")]

它正在工作,但如果没有发送查询(仅类似http://foo.bar/api/foo),则永远不会调用 ValidateQuery 和 ApplyQuery(请参阅 EnableQueryAttribute),并且默认行为会显示所有属性,这是我不想要的。如何管理这个问题?对于这种情况,我必须在哪里编写代码?

在此之后,我有一些关于一般设计的问题。 IMO,View Model 在维护方面真的很糟糕。它有很多重复的代码和很多文件。

1.限制 Action 可以返回哪些属性的最佳方法是什么?

我真的很喜欢在每个动作上简单地列出属性名称的想法,而不是使用数百个视图模型。遗憾的是,这只适用于 GET 请求,我想对 post 和 patch 做同样的事情。

2。如何在不使用 View Model 之类的冗余代码的情况下为 POST/PUT/PATCH 应用相同的设计?

这个问题的答案需要考虑每个动作的专门数据注释(能够覆盖模型的数据注释并添加新的验证)。

我正在使用实体框架代码优先的 Web APi 项目中使用 OData。

谢谢!

【问题讨论】:

    标签: c# odata


    【解决方案1】:

    这个解决方案有两个部分,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 请求的解决方案!

    1. 我们希望在没有提供 $select 时提供默认值
    2. 我们希望将 $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 操作,它的执行速度比我能想到的任何其他方法都快,因为它发生在解析操作之前。

    【讨论】:

    • 我只是做了一个自定义动作过滤器并在动作执行时实现。在那里,我们可以获取接收到的模型并列出接收到的每个属性,并将其与允许发布补丁或放置的属性列表进行比较。之后我们可以加载当前实体并仅在允许的属性上映射并保存。对于 odata 中的补丁,delta 可以为我们提供修改后的属性,我们可以从那里做同样的事情。
    • 我这样做的方式不同,我的基本控制器类调用了一个抽象方法 ApplyUserPolicy(patch, ODataOperation.Overwrite) 然后在每个类中我用逻辑覆盖实体控制器中的这个方法来进行验证,如果有的话需要。 (如果无效则抛出异常)所以在我的实现中,我仍然可以使用您的属性并让基类进行此验证,除非控制器覆盖它。我的业务案例是,不同的用户上下文和数据项的状态意味着不同的字段可在不同的时间进行编辑。
    猜你喜欢
    • 1970-01-01
    • 2012-04-07
    • 2011-03-21
    • 1970-01-01
    • 2011-02-08
    • 2012-01-08
    • 1970-01-01
    • 2019-04-08
    • 1970-01-01
    相关资源
    最近更新 更多