正在验证/操作多对映web API中的输入参数



假设我们有一个多租户博客应用程序。应用程序的每个用户可以具有由该服务托管的多个博客。

我们的API允许阅读和撰写博客文章。在某些情况下,指定BlogId是可选的,例如,获取所有使用ASP.NET标记的帖子:

/api/posts?tags=aspnet

如果我们想查看特定博客上所有使用ASP.NET标记的帖子,我们可以请求:

/api/posts?blogId=10&tags=aspnet

一些API方法需要一个有效的BlogId,例如当创建一个新的博客文章时:

POST: /api/posts
{
    "blogid" : "10",
    "title" : "This is a blog post."
}

BlogId需要在服务器上进行验证,以确保它属于当前(已验证)用户。如果请求中没有指定,我还想推断用户的默认blogId(为了简单起见,您可以假设默认是用户的第一个博客)。

我们有一个IAccountContext对象,它包含有关当前用户的信息。如有必要,可进行注射。

{
    bool ValidateBlogId(int blogId);
    string GetDefaultBlog();
}

在ASP.NET Web API中,建议使用什么方法:

  1. 如果在消息正文或uri中指定了BlogId,请验证它以确保它属于当前用户。如果不是,则抛出400错误
  2. 如果请求中未指定BlogId,则从IAccountContext检索默认BlogId并使其可用于控制器操作。我不希望控制器知道这个逻辑,这就是为什么我不想直接从我的操作中调用IAccountContext

[更新]

经过在Twitter上的讨论,并考虑到@Aliostad的建议,我决定将博客视为一种资源,并将其作为我的Uri模板的一部分(因此它始终是必需的),即

GET api/blog/1/posts -- get all posts for blog 1
PUT api/blog/1/posts/5 -- update post 5 in blog 1

我加载单个项目的查询逻辑已更新为按Post id和博客id加载(以避免租户加载/更新其他人的帖子)。

剩下要做的唯一一件事就是验证BlogId。遗憾的是,我们不能在Uri参数上使用验证属性,否则@alexanderb的建议会起作用。相反,我选择使用ActionFilter:

public class ValidateBlogAttribute : ActionFilterAttribute
{
    public IBlogValidator Validator { get; set; }
    public ValidateBlogAttribute()
    {
        // set up a fake validator for now
        Validator = new FakeBlogValidator();
    }
    public override void OnActionExecuting(HttpActionContext actionContext)
    {
        var blogId = actionContext.ActionArguments["blogId"] as int?;
        if (blogId.HasValue && !Validator.IsValidBlog(blogId.Value))
        {
            var message = new HttpResponseMessage(HttpStatusCode.BadRequest);
            message.ReasonPhrase = "Blog {0} does not belong to you.".FormatWith(blogId);
            throw new HttpResponseException(message);
        }
        base.OnActionExecuting(actionContext);
    }
}
public class FakeBlogValidator : IBlogValidator
{
    public bool IsValidBlog(int blogId)
    {
        return blogId != 999; // so we have something to test
    }
}

验证blogId现在只是用[ValidateBlog]装饰我的控制器/操作。

事实上,每个人的答案都有助于解决方案,但我将其标记为@alexanderb的答案,因为它没有与我的控制器中的验证逻辑相耦合。

恐怕这可能不是你想要的答案类型,但它可能会给讨论增加一点不起眼的内容。

你看到了你正在经历的所有麻烦,并跳过了所有的障碍,因为你需要推断blogId吗?我认为这就是问题所在。REST是关于无状态的,而您似乎在服务器上持有一个单独的状态(上下文),这与HTTP的无状态性质相冲突。

BlogId是操作的一个组成部分,需要明确地成为资源标识符的一部分——因此我会把它简单地放在URL中。如果您不这样做,这里的问题是URL/URI并不是真正的唯一地标识资源——这与名称所暗示的不同。如果John转到该资源,则会看到与Amy不同的资源。

这将简化设计,这也很有说服力。当设计正确时,一切都很好我努力做到简单

以下是实现的方式(考虑到我不是ASP.NET Web API专家)。

所以,首先是验证。你需要有一个简单的模型,像这样:

public class BlogPost
{
    [Required]
    [ValidateBlogId]
    public string BlogId { get; set; }
    [Required]
    public string Title { get; set; }
}

对于这个模型,最好实现自定义验证规则。如果blogId可用,则会根据规则对其进行验证。实现可以是,

public class ValidateBlogId : ValidationAttribute
{
    [Inject]
    public IAccountContext Context { get; set; }
    public override bool IsValid(object value)
    {
        var blogId = value as string;
        if (!string.IsNullOrEmpty(blogId))
        {
            return Context.ValidateBlogId(blogId);
        }
        return true;
    }
}

(在此之后,我假设使用Ninject,但您可以在没有它的情况下继续)。

接下来,您不想公开blogId初始化的详细信息。该职位的最佳人选是行动过滤器。

public class InitializeBlogIdAttribute : ActionFilterAttribute
{
    [Inject]
    public IAccountContext Context { get; set; }
    public override void OnActionExecuting(HttpActionContext actionContext)
    {
        var blogPost = actionContext.ActionArguments["blogPost"] as BlogPost;
        if (blogPost != null) 
        {
            blogPost.BlogId = blogPost.BlogId ?? Context.DefaultBlogId();
        }
    }
}

所以,如果blogPost模型是绑定的,并且它没有Id,那么将应用默认值。

最后,API控制器

public class PostsController : ApiController
{
    [InitializeBlogId]
    public HttpResponseMessage Post([FromBody]BlogPost blogPost) 
    {
        if (ModelState.IsValid)
        {
            // do the job
            return new HttpResponseMessage(HttpStatusCode.Ok);
        }
        return new HttpResponseMessage(HttpStatusCode.BadRequest);
    }
}

就是这样。我只是很快在VS中尝试了一下,似乎奏效了。

我认为它应该满足你的要求。

您可能也可以将HttpParameterBinding用于您的场景。你可以看看迈克和红梅的帖子了解更多细节。

以下示例:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Runtime.Serialization;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Controllers;
using System.Web.Http.Metadata;
namespace MvcApplication49.Controllers
{
public class PostsController : ApiController
{
    public string Get([BlogIdBinding]int blogId, string tags = null)
    {
        return ModelState.IsValid + blogId.ToString();
    }
    public string Post([BlogIdBinding]BlogPost post)
    {
        return ModelState.IsValid + post.BlogId.ToString();
    }
}
[DataContract]
public class BlogPost
{
    [DataMember]
    public int? BlogId { get; set; }
    [DataMember(IsRequired = true)]
    public string Title { get; set; }
    [DataMember(IsRequired = true)]
    public string Details { get; set; }
}
public class BlogIdBindingAttribute : ParameterBindingAttribute
{
    public override System.Web.Http.Controllers.HttpParameterBinding GetBinding(System.Web.Http.Controllers.HttpParameterDescriptor parameter)
    {
        return new BlogIdParameterBinding(parameter);
    }
}
public class BlogIdParameterBinding : HttpParameterBinding
{
    HttpParameterBinding _defaultUriBinding;
    HttpParameterBinding _defaultFormatterBinding;
    public BlogIdParameterBinding(HttpParameterDescriptor desc)
        : base(desc)
    {
        _defaultUriBinding = new FromUriAttribute().GetBinding(desc);
        _defaultFormatterBinding = new FromBodyAttribute().GetBinding(desc);
    }
    public override Task ExecuteBindingAsync(ModelMetadataProvider metadataProvider,
                                HttpActionContext actionContext, CancellationToken cancellationToken)
    {
        Task task = null;
        if (actionContext.Request.Method == HttpMethod.Post)
        {
            task = _defaultFormatterBinding.ExecuteBindingAsync(metadataProvider, actionContext, cancellationToken);
        }
        else if (actionContext.Request.Method == HttpMethod.Get)
        {
            task = _defaultUriBinding.ExecuteBindingAsync(metadataProvider, actionContext, cancellationToken);
        }
        return task.ContinueWith((tsk) =>
            {
                IPrincipal principal = Thread.CurrentPrincipal;
                object currentBoundValue = this.GetValue(actionContext);
                if (actionContext.Request.Method == HttpMethod.Post)
                {
                    if (currentBoundValue != null)
                    {
                        BlogPost post = (BlogPost)currentBoundValue;
                        if (post.BlogId == null)
                        {
                            post.BlogId = **<Set User's Default Blog Id here>**;
                        }
                    }
                }
                else if (actionContext.Request.Method == HttpMethod.Get)
                {
                    if(currentBoundValue == null)
                    {
                        SetValue(actionContext, **<Set User's Default Blog Id here>**);
                    }
                }
            });
    }
}

}

[更新]我的同事Youssef提出了一个使用ActionFilter的非常简单的方法。以下是使用该方法的示例:

public class PostsController : ApiController
{
    [BlogIdFilter]
    public string Get(int? blogId = null, string tags = null)
    {
    }
    [BlogIdFilter]
    public string Post(BlogPost post)
    {
    }
}
public class BlogIdFilterAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(HttpActionContext actionContext)
    {
        if (actionContext.Request.Method == HttpMethod.Get && actionContext.ActionArguments["blogId"] == null)
        {
            actionContext.ActionArguments["blogId"] = <Set User's Default Blog Id here>;
        }
        else if (actionContext.Request.Method == HttpMethod.Post)
        {
            if (actionContext.ActionArguments["post"] != null)
            {
                BlogPost post = (BlogPost)actionContext.ActionArguments["post"];
                if (post.BlogId == null)
                {
                    post.BlogId = <Set User's Default Blog Id here>;
                }
            }
        }
    }
}

由于并非所有控制器操作都需要此功能,通常我会为此目的实现一个action Filter,并在那里进行验证,但您的需求还有其他问题,这使得此选项成为一个选项,而不是一个选项。

此外,我还要求客户端将BlogId作为Uri的一部分发送,因为这样做可以避免对主体进行额外的反序列化(因为您不想在控制器操作中处理此问题)。

你在这里有一些要求,它们很重要:

  • 您不希望在每个操作方法中都处理此问题
  • 如果没有提供id,您希望自动获取该id
  • 如果它被提供但无效(例如不属于当前用户),您想要返回400错误请求

考虑到这些要求,最好的选择是通过基本控制器来处理。可能对你来说不是一个好的选择,但可以满足你的所有需求:

public abstract class ApiControllerBase : ApiController {
    public int BlogId { get; set; }
    public override Task<HttpResponseMessage> ExecuteAsync(HttpControllerContext controllerContext, CancellationToken cancellationToken) {
        var query = controllerContext.Request.RequestUri.ParseQueryString();
        var accountContext = controllerContext.Request.GetDependencyScope().GetService(typeof(IAccountContext));
        if (query.AllKeys.Any(x => x.Equals("BlogId", StringComparison.OrdinalIgnoreCase | StringComparison.InvariantCulture))) {
            int blogId;
            if (int.TryParse(query["BlogId"], out blogId) && accountContext.ValidateBlogId(blogId)) {
                BlogId = blogId;
            }
            else {
                ModelState.AddModelError("BlogId", "BlogId is invalid");
                TaskCompletionSource<HttpResponseMessage> tcs = 
                    new TaskCompletionSource<HttpResponseMessage>();
                tcs.SetResult(
                    controllerContext.Request.CreateErrorResponse(
                        HttpStatusCode.BadRequest, ModelState));
                return tcs.Task;
            }
        }
        else {
            BlogId = accountContext.GetDefaultBlogId();
        }
        return base.ExecuteAsync(controllerContext, cancellationToken);
    }
}

您也可以考虑为RequestModel实现IValidatableObject,但这可能会使您的模型与应用程序的其他部分有点耦合

最新更新