6

Problem Details for Better REST HTTP API Errors

 3 years ago
source link: https://codeopinion.com/problem-details-for-better-rest-http-api-errors/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

How do you tell your API Consumers explicitly if there are errors or problems with their request? Everyone creating HTTP APIs seems to implement error responses differently. Wouldn’t it be great if HTTP API Errors had a standard? Well, there is! It’s called Problem Details (https://tools.ietf.org/html/rfc7807)

YouTube

Check out my YouTube channel where I post all kinds of content that accompanies my posts including this video showing everything that is in this post.

If you’re creating a RESTful HTTP API, Status Codes can be a good way to indicate to the client if a request was successful or not. The most common usage is 200 range status codes indicate a successful response and using 400 range indicate a client errors.

However, in most client error situations, how do you tell the client specifically what was wrong? Sure an HTTP 400 status code tells the client that there’s an issue, but what exactly is the issue?

Different Responses

Here are two made-up examples that were inspired by 2 different APIs that I’ve recently consumed. Both indicate there a client error, however, they both do this in very different ways.

This first example is using a Status Code of 400 Bad Request, which is good. They provide a response body that has a Message member which is human readable. There is also a documentation member which is a URI, I assume to give the developer more info on why it occurred.

Here’s another example but very different response.

This response has an HTTP 200 OK. Which is interesting, to say the least. Instead, to indicate success or failure, they include in the response body a success member was a Boolean. There is an error object which is useful because it contains an info member, which is human readable. But what’s really nice is the code and type members which appear to be machine-readable. Meaning we can read their documentation, and write the appropriate code to handle when we receive an error with code=101, then we might want to show our end-user some specific message or possibly perform some other type of action.

Commonality

So what do these 2 different HTTP APIs have in common when it comes to providing the client with error information?

Nothing.

They are both providing widely different response body’s and using HTTP status codes completely differently.

This means that every time you’re consuming an HTTP API, you have to write 100% custom code to handle the various ways that errors are returned.

At the bare minimum, it would be nice if there was a consistent and standard way to define the human-readable error message. In one of the responses, this was called “message”, in the other, it was “error.info“.

Problem Details

Wouldn’t it be great if there was a standard for providing error info to your clients? There is! It’s called Problem Details (RFC7807)

   HTTP [RFC7230] status codes are sometimes not sufficient to convey
   enough information about an error to be helpful.  While humans behind
   Web browsers can be informed about the nature of the problem with an
   HTML [W3C.REC-html5-20141028] response body, non-human consumers of
   so-called "HTTP APIs" are usually not.

   This specification defines simple JSON [RFC7159] and XML
   [W3C.REC-xml-20081126] document formats to suit this purpose.  They
   are designed to be reused by HTTP APIs, which can identify distinct
   "problem types" specific to their needs.

   Thus, API clients can be informed of both the high-level error class
   (using the status code) and the finer-grained details of the problem
   (using one of these formats).

Here’s an example using Problem Details for the first example defined above.

type“: URI or relative path that defines what the problem is. In a similar way, as the first example had a “documentation” member, this is the intent of this member as well. It’s to allow the developer to understand the exact meaning of this error. However, this is meant to also be machine-readable. Meaning this URI should be stable and always represent the same error. This way we can write our client code to handle this specific type of error how we see fit. It’s acting in a similar way as an error code or error key.

title“: A short human-readable message of the problem type. It should NOT change from occurrence to occurrence.

status“: The status member represents the same HTTP status code.

detail“: Human-readable explanation of the exact issue that occurred. This can differ from occurrence to occurrence.

instance“: A URI that represents the specific occurrence of the problem.

Here’s another example using Problem Details from the second example above.

Type & Extensions

In the example above, traceId is an extension. You can add any members you want to extend the response object. This allows you to provide more details to your clients when errors occur.

This is important because if you use the type member, which is the primary way you identify what the problem is, then you can provide more extension members based on the type you return.

In other words, in your HTTP API documentation, you can specify a problem type by its URI, and let the developer know there will be certain other extension members available to them for that specific problem.

Multiple Problems

As with everything, nothing is perfect. Problem Details has no explicit way of defining multiple problems in a single response. You can achieve this by defining a specific type, which indicates there will be a problems member which will be an array of the normal problem details members. Just as I described above to leverage bot the type and extensions together.

{ "type": "http://example.com/problems/multiple", "title": "Multiple Problems", "status": 400, "detail": "There were multiple problems that have occurred.", "instance": "/sales/products/abc123/availableForSale", "problems": [ { "type": "http://example.com/problems/already-available", "title": "Cannot set product as available.", "detail": "Product is already Available For Sale.", "instance": "/sales/products/abc123/availableForSale", }, { "type": "http://example.com/problems/no-quantity", "title": "Cannot set product as available.", "detail": "Product has no Quantity on Hand.", "instance": "/sales/products/abc123/availableForSale", } ] }

ASP.NET Core

If you’re using ASP.NET Core, you can use Problem Details today in your Controllers. Simply call the Problem() which returns an IActionResult.

[SwaggerOperation( OperationId = "AvailableForSale", Tags = new[] { "Sales" })] [HttpPost("/sales/products/{sku}/availableForSale")] public async Task<IActionResult> AvailableForSale([FromRoute] string sku) { var product = await _db.Products.SingleOrDefaultAsync(x => x.Sku == sku); if (product == null) { return NotFound(); }

if (product.ForSale) { return Problem( "Product is already Available For Sale.", $"/sales/products/{sku}/availableForSale", 400, "Cannot set product as available.", "http://example.com/problems/already-available"); }

if (product.QuantityOnHand <= 0) { return Problem( "Product has no Quantity on Hand.", $"/sales/products/{sku}/availableForSale", 400, "Cannot set product as available.", "http://example.com/problems/no-quantity"); }

product.ForSale = true; await _db.SaveChangesAsync();

return Created(_urlHelper.Action("GetSalesProduct", "GetSalesProduct", new { sku }), null); }

If you don’t have thin controllers and have business logic outside of your controllers, then you can use Hellang.Middleware.ProblemDetails by Kristian Hellang which is a middleware that maps exceptions to problem details.

using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Net.Http; using System.Threading.Tasks; using Hellang.Middleware.ProblemDetails.Mvc; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting;

namespace Hellang.Middleware.ProblemDetails.Sample { public class Startup { public Startup(IWebHostEnvironment environment) { Environment = environment; }

private IWebHostEnvironment Environment { get; }

public static void Main(string[] args) { CreateHostBuilder(args).Build().Run(); }

public static IHostBuilder CreateHostBuilder(string[] args) { return Host.CreateDefaultBuilder(args) .UseEnvironment(Environments.Development) //.UseEnvironment(Environments.Production) // Uncomment to remove exception details from responses. .ConfigureWebHostDefaults(web => { web.UseStartup<Startup>(); }); }

public void ConfigureServices(IServiceCollection services) { services.AddProblemDetails(ConfigureProblemDetails) .AddControllers() // Adds MVC conventions to work better with the ProblemDetails middleware. .AddProblemDetailsConventions() .AddJsonOptions(x => x.JsonSerializerOptions.IgnoreNullValues = true); }

public void Configure(IApplicationBuilder app) { app.UseProblemDetails();

app.Use(CustomMiddleware);

app.UseRouting();

app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); }

private void ConfigureProblemDetails(ProblemDetailsOptions options) { // Only include exception details in a development environment. There's really no nee // to set this as it's the default behavior. It's just included here for completeness :) options.IncludeExceptionDetails = (ctx, ex) => Environment.IsDevelopment();

// You can configure the middleware to re-throw certain types of exceptions, all exceptions or based on a predicate. // This is useful if you have upstream middleware that needs to do additional handling of exceptions. options.Rethrow<NotSupportedException>();

// This will map NotImplementedException to the 501 Not Implemented status code. options.MapToStatusCode<NotImplementedException>(StatusCodes.Status501NotImplemented);

// This will map HttpRequestException to the 503 Service Unavailable status code. options.MapToStatusCode<HttpRequestException>(StatusCodes.Status503ServiceUnavailable);

// Because exceptions are handled polymorphically, this will act as a "catch all" mapping, which is why it's added last. // If an exception other than NotImplementedException and HttpRequestException is thrown, this will handle it. options.MapToStatusCode<Exception>(StatusCodes.Status500InternalServerError); }

private Task CustomMiddleware(HttpContext context, Func<Task> next) { if (context.Request.Path.StartsWithSegments("/middleware", out _, out var remaining)) { if (remaining.StartsWithSegments("/error")) { throw new Exception("This is an exception thrown from middleware."); }

if (remaining.StartsWithSegments("/status", out _, out remaining)) { var statusCodeString = remaining.Value.Trim('/');

if (int.TryParse(statusCodeString, out var statusCode)) { context.Response.StatusCode = statusCode; return Task.CompletedTask; } } }

return next(); } }

[Route("mvc")] [ApiController] public class MvcController : ControllerBase { [HttpGet("status/{statusCode}")] public ActionResult Status([FromRoute] int statusCode) { return StatusCode(statusCode); }

[HttpGet("error")] public ActionResult Error() { throw new NotImplementedException("This is an exception thrown from an MVC controller."); }

[HttpGet("modelstate")] public ActionResult InvalidModelState([Required, FromQuery] string asdf) { return Ok(); }

[HttpGet("error/details")] public ActionResult ErrorDetails() { ModelState.AddModelError("someProperty", "This property failed validation.");

var validation = new ValidationProblemDetails(ModelState) { Status = StatusCodes.Status422UnprocessableEntity };

throw new ProblemDetailsException(validation); }

[HttpGet("detail")] public ActionResult<string> Detail() { return BadRequest("This will end up in the 'detail' field."); }

[HttpGet("result")] public ActionResult<OutOfCreditProblemDetails> Result() { var problem = new OutOfCreditProblemDetails { Type = "https://example.com/probs/out-of-credit", Title = "You do not have enough credit.", Detail = "Your current balance is 30, but that costs 50.", Instance = "/account/12345/msgs/abc", Balance = 30.0m, Accounts = { "/account/12345","/account/67890" } };

return BadRequest(problem); } }

public class OutOfCreditProblemDetails : Microsoft.AspNetCore.Mvc.ProblemDetails { public OutOfCreditProblemDetails() { Accounts = new List<string>(); }

public decimal Balance { get; set; }

public ICollection<string> Accounts { get; } } }

Source Code

Developer-level members of my CodeOpinion YouTube channel get access to the full source for any working demo application that I post on my blog or YouTube. Check out the membership for more info.

Additional Related Posts

Follow @CodeOpinion on Twitter

Leave this field empty if you're human:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK