假设我们有一个多租户博客应用程序。应用程序的每个用户可以具有由该服务托管的多个博客。
我们的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中,建议使用什么方法:
- 如果在消息正文或uri中指定了BlogId,请验证它以确保它属于当前用户。如果不是,则抛出400错误
- 如果请求中未指定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,但这可能会使您的模型与应用程序的其他部分有点耦合