5

Assume we have a multi-tenant blog application. Each user of the application may have a number of blogs hosted by the service.

Our API allows for both reading and writing of blog posts. In some cases specifying a BlogId is optional, for example, getting all posts tagged with ASP.NET:

/api/posts?tags=aspnet

If we wanted to view all posts tagged with ASP.NET on a specific blog, we could request:

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

Some API methods require a valid BlogId, such as when creating a new blog post:

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

The BlogId needs to be validated on the server to ensure it belongs to the current (authenticated) user. I would also like to infer the user's default blogId if it is not specified in the request (for simplicity you can assume that default is the user's first blog).

We have an IAccountContext object that contains information about the current user. This can be injected if necessary.

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

In ASP.NET Web API what would be the recommended approach to:

  1. If the BlogId is specified either in the message body or uri, validate it to ensure it belongs to the current user. Throw a 400 error if not.
  2. If the BlogId is not specified in the request, retrieve the default BlogId from IAccountContext and make it available to the controller action. I don't want the controller to be aware of this logic which is why I don't want to call IAccountContext directly from my action.

[Update]

Following discussions on Twitter and taking into account @Aliostad's advice, I decided to treat the Blog as a resource and make it part of my Uri template (so it is always required) i.e.

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

My query logic for loading single items was updated to load by Post id and blog id (to avoid tenants loading/updating other peoples posts).

The only thing left to do was validate the BlogId. It is a shame that we can't use validation attributes on Uri parameters otherwise @alexanderb's recommendation would have worked. Instead I opted to use an 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
    }
}

Validating the blogId is now simply a case of decorating my controller/action with [ValidateBlog].

Virtually everyone's answers helped in the solution but I have marked as @alexanderb's as the answer since it did not couple the validation logic inside my controller.

4

4 回答 4

11

I am afraid this is probably not the type of answer you are looking for but well it might add a humble bit to the discussion.

You see all that trouble you are going through to and jumping all the hoops since you need to infer the blogId? I think that is the problem. REST is all about stateless while you seem to holding a separate state (context) on the server which comes into clash with stateless nature of HTTP.

BlogId when is an integral part of the operation, needs to be explicitly part of the resource identifier - hence I would put it simply in URL. If you do not, the problem here is the URL/URI does not really uniquely identify a resource - unlike the name implies. If John goes to that resource sees a different resource from when Amy does.

This will simplify the design which is also telling. When design is right, it all works nicely. I strive to achieve simplicity.

于 2012-08-28T20:44:13.097 回答
6

Here is the way, how I would implemented (taking into account, I'm not ASP.NET Web API expert).

So, fist of all - validation. You need to have a simple model, like this:

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

    [Required]
    public string Title { get; set; }
}

For this model, it's better to implement custom validation rule. In case if blogId is available, it would be validate against the rule. The implementation could be,

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;
    }
}

(here and after, I'm assuming to use Ninject, but you can go ahead without it).

Next, you don't want to expose the details of blogId initialization. Best candidate for that job is action filter.

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();
        }
    }
}

So, if blogPost model is binded and it does not have Id, the default will be applied.

So, finally the API controller

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);
    }
}

That's it. I just quickly tried that in my VS, seems to work.

I think it should fulfil your requirements.

于 2012-08-28T18:54:10.470 回答
3

Probably you could use HttpParameterBinding also for your scenario. You can look at the posts from Mike and Hongmei for more details.

Example below:

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>**);
                    }
                }
            });
    }
}

}

[UPDATED] My colleague Youssef suggested a very easy approach using ActionFilter. Following is an example using that approach:

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>;
                }
            }
        }
    }
}
于 2012-08-28T19:11:55.107 回答
2

As not all of the controller action requires this, normally I would implement an Action Filter for this purpose and make the validation there but your requirements have other concerns which makes this option, well not an option.

Also, I would require for the client to send the BlogId as part of the Uri because with this in place, you would avoid extra deserializing of the body (as you don't want to handle this inside the controller action).

You have some requirements here and they matter:

  • You don't want to handle this inside every action method.
  • You want to get the id automatically if it is not supplied.
  • If it is supplied but not valid (e.g. not belong to current user), you want to return 400 Bad Request.

Considering these requirements, best option is to handle this through the base controller. Might be a not good option for you but handles all your requirements:

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);
    }
}

You can also consider implementing the IValidatableObject for your RequestModel but that might make your model a little bit coupled with another parts of your application

于 2012-08-28T17:52:01.840 回答