WebAPI - 统一ApiController和OAuthAuthorizationServerProvider的错误消息格式

24
在我的WebAPI项目中,我使用Owin.Security.OAuth来添加JWT身份验证。 在OAuthProvider的GrantResourceOwnerCredentials方法内,我使用以下代码设置错误信息:
context.SetError("invalid_grant", "Account locked.");

这会返回给客户端:

{
  "error": "invalid_grant",
  "error_description": "Account locked."
}

用户进行身份验证后,当他尝试对我的控制器之一进行“普通”请求时,如果模型无效(使用FluentValidation),他将获得以下响应:

{
  "message": "The request is invalid.",
  "modelState": {
    "client.Email": [
      "Email is not valid."
    ],
    "client.Password": [
      "Password is required."
    ]
  }
}

两个请求都返回了400 Bad Request,但有时您必须查找error_description字段,有时则要查找message字段。

我能够创建自定义响应消息,但这仅适用于我返回的结果。

我的问题是:是否可能在由ModelValidatorProviders返回的响应中将message替换为error,以及其他地方?

我已经阅读了关于ExceptionFilterAttribute的内容,但我不知道这是否是一个好的起点。FluentValidation不应成为问题,因为它所做的就是向ModelState添加错误。

编辑:
我试图解决的下一件事是WebApi中返回的数据命名约定不一致-当从OAuthProvider返回错误时,我们有error_details,但当从ApiController返回BadRequestModelState时,我们有modelState。正如您所看到的,第一个使用snake_case,而第二个使用camelCase


当您创建 HttpError 对象时,为什么要使用自定义属性?HttpError 类具有模拟您的属性的属性:https://msdn.microsoft.com/zh-cn/library/system.web.http.httperror(v=vs.118).aspx - Algemist
@returnsvoid 很抱歉回复晚了。HttpErrorMessageMessageDescription,但在 OAuthAuthorizationServerProvider 中使用 SetError 时,您设置的是 ErrorErrorDescription。我希望错误属性具有相同的名称(可以是 Error 或 Message,无所谓),这样当我返回某些出错信息时,用户总是会检查单个属性。希望这能澄清我的问题。 - Misiu
您使用了定制的OAuth提供程序吗? - Jeffrey A. Gochin
@JeffreyA.Gochin 是的,我基于http://bitoftech.net/2014/10/27/json-web-token-asp-net-web-api-2-jwt-owin-authorization-server/构建了我的。 - Misiu
如果有人给我负评,请写下原因。我想改进我的问题,但是如果不知道问题出在哪里,我就无法做到。 - Misiu
显示剩余2条评论
2个回答

6

更新的回答(使用中间件)

由于Web API原始委托处理程序的想法意味着它不会在OAuth中间件之前足够早地执行,因此需要创建自定义中间件...

public static class ErrorMessageFormatter {

    public static IAppBuilder UseCommonErrorResponse(this IAppBuilder app) {
        app.Use<JsonErrorFormatter>();
        return app;
    }

    public class JsonErrorFormatter : OwinMiddleware {
        public JsonErrorFormatter(OwinMiddleware next)
            : base(next) {
        }

        public override async Task Invoke(IOwinContext context) {
            var owinRequest = context.Request;
            var owinResponse = context.Response;
            //buffer the response stream for later
            var owinResponseStream = owinResponse.Body;
            //buffer the response stream in order to intercept downstream writes
            using (var responseBuffer = new MemoryStream()) {
                //assign the buffer to the resonse body
                owinResponse.Body = responseBuffer;

                await Next.Invoke(context);

                //reset body
                owinResponse.Body = owinResponseStream;

                if (responseBuffer.CanSeek && responseBuffer.Length > 0 && responseBuffer.Position > 0) {
                    //reset buffer to read its content
                    responseBuffer.Seek(0, SeekOrigin.Begin);
                }

                if (!IsSuccessStatusCode(owinResponse.StatusCode) && responseBuffer.Length > 0) {
                    //NOTE: perform your own content negotiation if desired but for this, using JSON
                    var body = await CreateCommonApiResponse(owinResponse, responseBuffer);

                    var content = JsonConvert.SerializeObject(body);

                    var mediaType = MediaTypeHeaderValue.Parse(owinResponse.ContentType);
                    using (var customResponseBody = new StringContent(content, Encoding.UTF8, mediaType.MediaType)) {
                        var customResponseStream = await customResponseBody.ReadAsStreamAsync();
                        await customResponseStream.CopyToAsync(owinResponseStream, (int)customResponseStream.Length, owinRequest.CallCancelled);
                        owinResponse.ContentLength = customResponseStream.Length;
                    }
                } else {
                    //copy buffer to response stream this will push it down to client
                    await responseBuffer.CopyToAsync(owinResponseStream, (int)responseBuffer.Length, owinRequest.CallCancelled);
                    owinResponse.ContentLength = responseBuffer.Length;
                }
            }
        }

        async Task<object> CreateCommonApiResponse(IOwinResponse response, Stream stream) {

            var json = await new StreamReader(stream).ReadToEndAsync();

            var statusCode = ((HttpStatusCode)response.StatusCode).ToString();
            var responseReason = response.ReasonPhrase ?? statusCode;

            //Is this a HttpError
            var httpError = JsonConvert.DeserializeObject<HttpError>(json);
            if (httpError != null) {
                return new {
                    error = httpError.Message ?? responseReason,
                    error_description = (object)httpError.MessageDetail
                    ?? (object)httpError.ModelState
                    ?? (object)httpError.ExceptionMessage
                };
            }

            //Is this an OAuth Error
            var oAuthError = Newtonsoft.Json.Linq.JObject.Parse(json);
            if (oAuthError["error"] != null && oAuthError["error_description"] != null) {
                dynamic obj = oAuthError;
                return new {
                    error = (string)obj.error,
                    error_description = (object)obj.error_description
                };
            }

            //Is this some other unknown error (Just wrap in common model)
            var error = JsonConvert.DeserializeObject(json);
            return new {
                error = responseReason,
                error_description = error
            };
        }

        bool IsSuccessStatusCode(int statusCode) {
            return statusCode >= 200 && statusCode <= 299;
        }
    }
}

在认证中间件和 Web API 处理程序添加之前,及早在流水线中注册。

public class Startup {
    public void Configuration(IAppBuilder app) {

        app.UseResponseEncrypterMiddleware();

        app.UseRequestLogger();

        //...(after logging middle ware)
        app.UseCommonErrorResponse();

        //... (before auth middle ware)

        //...code removed for brevity
    }
} 

这个例子只是一个基础起点。它应该足够简单,能够扩展这个起点。

尽管在这个示例中,通用模型看起来像OAuthProvider返回的模型,但任何通用的对象模型都可以使用。

已经通过一些内存单元测试进行了测试,并且通过TDD使其运行。

[TestClass]
public class UnifiedErrorMessageTests {
    [TestMethod]
    public async Task _OWIN_Response_Should_Pass_When_Ok() {
        //Arrange
        var message = "\"Hello World\"";
        var expectedResponse = "\"I am working\"";

        using (var server = TestServer.Create<WebApiTestStartup>()) {
            var client = server.HttpClient;
            client.DefaultRequestHeaders.Accept.Clear();
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            var content = new StringContent(message, Encoding.UTF8, "application/json");

            //Act
            var response = await client.PostAsync("/api/Foo", content);

            //Assert
            Assert.IsTrue(response.IsSuccessStatusCode);

            var result = await response.Content.ReadAsStringAsync();

            Assert.AreEqual(expectedResponse, result);
        }
    }

    [TestMethod]
    public async Task _OWIN_Response_Should_Be_Unified_When_BadRequest() {
        //Arrange
        var expectedResponse = "invalid_grant";

        using (var server = TestServer.Create<WebApiTestStartup>()) {
            var client = server.HttpClient;
            client.DefaultRequestHeaders.Accept.Clear();
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            var content = new StringContent(expectedResponse, Encoding.UTF8, "application/json");

            //Act
            var response = await client.PostAsync("/api/Foo", content);

            //Assert
            Assert.IsFalse(response.IsSuccessStatusCode);

            var result = await response.Content.ReadAsAsync<dynamic>();

            Assert.AreEqual(expectedResponse, (string)result.error_description);
        }
    }

    [TestMethod]
    public async Task _OWIN_Response_Should_Be_Unified_When_MethodNotAllowed() {
        //Arrange
        var expectedResponse = "Method Not Allowed";

        using (var server = TestServer.Create<WebApiTestStartup>()) {
            var client = server.HttpClient;
            client.DefaultRequestHeaders.Accept.Clear();
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            //Act
            var response = await client.GetAsync("/api/Foo");

            //Assert
            Assert.IsFalse(response.IsSuccessStatusCode);

            var result = await response.Content.ReadAsAsync<dynamic>();

            Assert.AreEqual(expectedResponse, (string)result.error);
        }
    }

    [TestMethod]
    public async Task _OWIN_Response_Should_Be_Unified_When_NotFound() {
        //Arrange
        var expectedResponse = "Not Found";

        using (var server = TestServer.Create<WebApiTestStartup>()) {
            var client = server.HttpClient;
            client.DefaultRequestHeaders.Accept.Clear();
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            //Act
            var response = await client.GetAsync("/api/Bar");

            //Assert
            Assert.IsFalse(response.IsSuccessStatusCode);

            var result = await response.Content.ReadAsAsync<dynamic>();

            Assert.AreEqual(expectedResponse, (string)result.error);
        }
    }

    public class WebApiTestStartup {
        public void Configuration(IAppBuilder app) {

            app.UseCommonErrorMessageMiddleware();

            var config = new HttpConfiguration();
            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
            );

            app.UseWebApi(config);
        }
    }

    public class FooController : ApiController {
        public FooController() {

        }
        [HttpPost]
        public IHttpActionResult Bar([FromBody]string input) {
            if (input == "Hello World")
                return Ok("I am working");

            return BadRequest("invalid_grant");
        }
    }
}

使用DelegatingHandler进行处理

考虑使用 DelegatingHandler

引用一篇在线文章。

Delegating handlers非常有用,可用于横切关注点。 它们钩入请求-响应管道的非常早期和非常晚期阶段,使它们非常适合在将响应发送回客户端之前调整响应。

这个示例是为 HttpError 响应统一错误消息的简化尝试。

public class HttpErrorHandler : DelegatingHandler {

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
        var response = await base.SendAsync(request, cancellationToken);

        return NormalizeResponse(request, response);
    }

    private HttpResponseMessage NormalizeResponse(HttpRequestMessage request, HttpResponseMessage response) {
        object content;
        if (!response.IsSuccessStatusCode && response.TryGetContentValue(out content)) {

            var error = content as HttpError;
            if (error != null) {

                var unifiedModel = new {
                    error = error.Message,
                    error_description = (object)error.MessageDetail ?? error.ModelState
                };

                var newResponse = request.CreateResponse(response.StatusCode, unifiedModel);

                foreach (var header in response.Headers) {
                    newResponse.Headers.Add(header.Key, header.Value);
                }

                return newResponse;
            }

        }
        return response;
    }
}

尽管这个例子非常基础,但将它扩展以适应您的定制需求非常简单。
现在只需要将处理程序添加到管道中即可。
public static class WebApiConfig {
    public static void Register(HttpConfiguration config) {

        config.MessageHandlers.Add(new HttpErrorHandler());

        // Other code not shown...
    }
}

消息处理程序按照它们在 MessageHandlers 集合中出现的顺序调用。因为它们是嵌套的,所以响应消息会沿着另一个方向传递。也就是说,最后一个处理程序是首先获得响应消息的。

来源:ASP.NET Web API中的HTTP消息处理程序


非常感谢您的回复。我会立即尝试。在我的应用程序中,我使用中间件来记录请求和响应。如果我添加DelegatingHandler,它会在中间件之前还是之后工作?在中间件的Invoke方法中,我从请求中获取所有参数,然后调用Next.Invoke并从响应中获取所有参数。理想情况下,我希望DelegatingHandler在我记录响应之前修改响应。 - Misiu
这个几乎非常好用。我已经添加了您的代码并确认它正在替换ApiController返回的内容,但是当使用context.SetError时,我无法访问由OAuthAuthorizationServerProvider返回的响应。Can DelegatingHandler访问该响应吗? - Misiu
@Misiu,委托处理程序可能不够早在管道中拦截OAuth请求。这意味着需要创建一个自定义中间件来执行与上述相同或类似的操作。 - Nkosi
OwinMiddleware有一个缺点,而DelegatingHandler则更容易修改响应并将当前内容作为类获取。对于OwinMiddleware,您必须手动完成所有操作。 - Misiu
我有一个DelegatingHandler来记录响应,它适用于所有控制器,除了OAuthAuthorizationServerProvider,很遗憾我需要创建另一个中间件来记录这个类的响应。 - JobaDiniz
显示剩余7条评论

0
能否在由ModelValidatorProviders返回的响应中用错误替换消息?
否则,我们可以使用重载的SetError来进行操作,将错误替换为消息。
BaseValidatingContext<TOptions>.SetError Method (String)

将此上下文标记为未经应用程序验证,并分配各种错误信息属性。由于调用,HasError变为true,IsValidated变为false。

string msg = "{\"message\": \"Account locked.\"}";
context.SetError(msg); 
Response.StatusCode = 400;
context.Response.Write(msg);

谢谢您的快速回复,但那不是我想要的。OAuthGrantCustomExtensionContext.SetError 返回了正确的响应(它有 errorerror_details 字段)。我需要的是从 ApiController 返回 BadRequest,但返回的 JSON 中,我希望有一个 error 字段,而不是 message 字段。希望这能澄清我的问题。 - Misiu

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接