implement Problem Details for HTTP APIs #1335

Former-commit-id: ff789b6d535080c88e05c81ab3fb7d9689801ec7
This commit is contained in:
Gerasimos (Makis) Maropoulos 2019-08-12 12:05:21 +03:00
parent 5c91440e46
commit 8e83959c61
5 changed files with 364 additions and 21 deletions

View File

@ -7,12 +7,15 @@ import (
func main() {
app := iris.New()
// Catch a specific error code.
app.OnErrorCode(iris.StatusInternalServerError, func(ctx iris.Context) {
ctx.HTML("Message: <b>" + ctx.Values().GetString("message") + "</b>")
})
// Catch all error codes [app.OnAnyErrorCode...]
app.Get("/", func(ctx iris.Context) {
ctx.HTML(`Click <a href="/my500">here</a> to fire the 500 status code`)
ctx.HTML(`Click <a href="/my500">here</a> to pretend an HTTP error`)
})
app.Get("/my500", func(ctx iris.Context) {
@ -24,5 +27,60 @@ func main() {
ctx.Writef("Hello %s", ctx.Params().Get("firstname"))
})
app.Get("/product-problem", problemExample)
app.Get("/product-error", func(ctx iris.Context) {
ctx.Writef("explain the error")
})
// http://localhost:8080
// http://localhost:8080/my500
// http://localhost:8080/u/gerasimos
// http://localhost:8080/product-problem
app.Run(iris.Addr(":8080"))
}
func newProductProblem(productName, detail string) iris.Problem {
return iris.NewProblem().
// The type URI, if relative it automatically convert to absolute.
Type("/product-error").
// The title, if empty then it gets it from the status code.
Title("Product validation problem").
// Any optional details.
Detail(detail).
// The status error code, required.
Status(iris.StatusBadRequest).
// Any custom key-value pair.
Key("productName", productName)
// Optional cause of the problem, chain of Problems.
// Cause(iris.NewProblem().Type("/error").Title("cause of the problem").Status(400))
}
func problemExample(ctx iris.Context) {
/*
p := iris.NewProblem().
Type("/validation-error").
Title("Your request parameters didn't validate").
Detail("Optional details about the error.").
Status(iris.StatusBadRequest).
Key("customField1", customValue1)
Key("customField2", customValue2)
ctx.Problem(p)
// OR
ctx.Problem(iris.Problem{
"type": "/validation-error",
"title": "Your request parameters didn't validate",
"detail": "Optional details about the error.",
"status": iris.StatusBadRequest,
"customField1": customValue1,
"customField2": customValue2,
})
// OR
*/
// Response like JSON but with indent of " " and
// content type of "application/problem+json"
ctx.Problem(newProductProblem("product name", "problem error details"))
}

View File

@ -385,6 +385,8 @@ type Context interface {
// Look `StatusCode` too.
GetStatusCode() int
// AbsoluteURI parses the "s" and returns its absolute URI form.
AbsoluteURI(s string) string
// Redirect sends a redirect response to the client
// to a specific url or relative path.
// accepts 2 parameters string and an optional int
@ -395,7 +397,6 @@ type Context interface {
// or 303 (StatusSeeOther) if POST method,
// or StatusTemporaryRedirect(307) if that's nessecery.
Redirect(urlToRedirect string, statusHeader ...int)
// +------------------------------------------------------------+
// | Various Request and Post Data |
// +------------------------------------------------------------+
@ -777,6 +778,14 @@ type Context interface {
HTML(format string, args ...interface{}) (int, error)
// JSON marshals the given interface object and writes the JSON response.
JSON(v interface{}, options ...JSON) (int, error)
// Problem writes a JSON problem response.
// Order of fields are not always the same.
//
// Behaves exactly like `Context.JSON`
// but with indent of " " and a content type of "application/problem+json" instead.
//
// Read more at: https://github.com/kataras/iris/wiki/Routing-error-handlers
Problem(v interface{}, opts ...JSON) (int, error)
// JSONP marshals the given interface object and writes the JSON response.
JSONP(v interface{}, options ...JSONP) (int, error)
// XML marshals the given interface object and writes the XML response.
@ -1612,19 +1621,7 @@ func (ctx *context) IsWWW() bool {
// FullRqeuestURI returns the full URI,
// including the scheme, the host and the relative requested path/resource.
func (ctx *context) FullRequestURI() string {
scheme := ctx.request.URL.Scheme
if scheme == "" {
if ctx.request.TLS != nil {
scheme = "https:"
} else {
scheme = "http:"
}
}
host := ctx.Host()
path := ctx.Path()
return scheme + "//" + host + path
return ctx.AbsoluteURI(ctx.Path())
}
const xForwardedForHeaderKey = "X-Forwarded-For"
@ -2367,6 +2364,59 @@ func uploadTo(fh *multipart.FileHeader, destDirectory string) (int64, error) {
return io.Copy(out, src)
}
// AbsoluteURI parses the "s" and returns its absolute URI form.
func (ctx *context) AbsoluteURI(s string) string {
if s == "" {
return ""
}
if s[0] == '/' {
scheme := ctx.request.URL.Scheme
if scheme == "" {
if ctx.request.TLS != nil {
scheme = "https:"
} else {
scheme = "http:"
}
}
host := ctx.Host()
return scheme + "//" + host + path.Clean(s)
}
if u, err := url.Parse(s); err == nil {
r := ctx.request
if u.Scheme == "" && u.Host == "" {
oldpath := r.URL.Path
if oldpath == "" {
oldpath = "/"
}
if s == "" || s[0] != '/' {
olddir, _ := path.Split(oldpath)
s = olddir + s
}
var query string
if i := strings.Index(s, "?"); i != -1 {
s, query = s[:i], s[i:]
}
// clean up but preserve trailing slash
trailing := strings.HasSuffix(s, "/")
s = path.Clean(s)
if trailing && !strings.HasSuffix(s, "/") {
s += "/"
}
s += query
}
}
return s
}
// Redirect sends a redirect response to the client
// to a specific url or relative path.
// accepts 2 parameters string and an optional int
@ -2964,6 +3014,9 @@ const (
ContentHTMLHeaderValue = "text/html"
// ContentJSONHeaderValue header value for JSON data.
ContentJSONHeaderValue = "application/json"
// ContentJSONProblemHeaderValue header value for API problem error.
// Read more at: https://tools.ietf.org/html/rfc7807
ContentJSONProblemHeaderValue = "application/problem+json"
// ContentJavascriptHeaderValue header value for JSONP & Javascript data.
ContentJavascriptHeaderValue = "application/javascript"
// ContentTextHeaderValue header value for Text data.
@ -3133,6 +3186,30 @@ func (ctx *context) JSON(v interface{}, opts ...JSON) (n int, err error) {
return n, err
}
// Problem writes a JSON problem response.
// Order of fields are not always the same.
//
// Behaves exactly like `Context.JSON`
// but with indent of " " and a content type of "application/problem+json" instead.
//
// Read more at: https://github.com/kataras/iris/wiki/Routing-error-handlers
func (ctx *context) Problem(v interface{}, opts ...JSON) (int, error) {
options := DefaultJSONOptions
if len(opts) > 0 {
options = opts[0]
} else {
options.Indent = " "
}
ctx.contentTypeOnce(ContentJSONProblemHeaderValue, "")
if p, ok := v.(Problem); ok {
p.updateTypeToAbsolute(ctx)
}
return ctx.JSON(v, options)
}
var (
finishCallbackB = []byte(");")
)
@ -3334,6 +3411,7 @@ type N struct {
Binary []byte
JSON interface{}
Problem Problem
JSONP interface{}
XML interface{}
YAML interface{}
@ -3354,6 +3432,8 @@ func (n N) SelectContent(mime string) interface{} {
return n.Binary
case ContentJSONHeaderValue:
return n.JSON
case ContentJSONProblemHeaderValue:
return n.Problem
case ContentJavascriptHeaderValue:
return n.JSONP
case ContentXMLHeaderValue, ContentXMLUnreadableHeaderValue:
@ -3485,6 +3565,8 @@ func (ctx *context) Negotiate(v interface{}) (int, error) {
return ctx.Markdown(v.([]byte))
case ContentJSONHeaderValue:
return ctx.JSON(v)
case ContentJSONProblemHeaderValue:
return ctx.Problem(v)
case ContentJavascriptHeaderValue:
return ctx.JSONP(v)
case ContentXMLHeaderValue, ContentXMLUnreadableHeaderValue:
@ -3614,6 +3696,19 @@ func (n *NegotiationBuilder) JSON(v ...interface{}) *NegotiationBuilder {
return n.MIME(ContentJSONHeaderValue, content)
}
// Problem registers the "application/problem+json" content type and, optionally,
// a value that `Context.Negotiate` will render
// when a client accepts the "application/problem+json" content type.
//
// Returns itself for recursive calls.
func (n *NegotiationBuilder) Problem(v ...interface{}) *NegotiationBuilder {
var content interface{}
if len(v) > 0 {
content = v[0]
}
return n.MIME(ContentJSONProblemHeaderValue, content)
}
// JSONP registers the "application/javascript" content type and, optionally,
// a value that `Context.Negotiate` will render
// when a client accepts the "application/javascript" content type.
@ -3867,6 +3962,12 @@ func (n *NegotiationAcceptBuilder) JSON() *NegotiationAcceptBuilder {
return n.MIME(ContentJSONHeaderValue)
}
// Problem adds the "application/problem+json" as accepted client content type.
// Returns itself.
func (n *NegotiationAcceptBuilder) Problem() *NegotiationAcceptBuilder {
return n.MIME(ContentJSONProblemHeaderValue)
}
// JSONP adds the "application/javascript" as accepted client content type.
// Returns itself.
func (n *NegotiationAcceptBuilder) JSONP() *NegotiationAcceptBuilder {

172
context/problem.go Normal file
View File

@ -0,0 +1,172 @@
package context
import (
"fmt"
"net/http"
)
// Problem Details for HTTP APIs.
// Pass a Problem value to `context.Problem` to
// write an "application/problem+json" response.
//
// Read more at: https://github.com/kataras/iris/wiki/Routing-error-handlers
type Problem map[string]interface{}
// NewProblem retruns a new Problem.
// Head over to the `Problem` type godoc for more.
func NewProblem() Problem {
p := make(Problem)
return p
}
func (p Problem) keyExists(key string) bool {
if p == nil {
return false
}
_, found := p[key]
return found
}
// DefaultProblemStatusCode is being sent to the client
// when Problem's status is not a valid one.
var DefaultProblemStatusCode = http.StatusBadRequest
func (p Problem) getStatus() (int, bool) {
statusField, found := p["status"]
if !found {
return DefaultProblemStatusCode, false
}
status, ok := statusField.(int)
if !ok {
return DefaultProblemStatusCode, false
}
if !StatusCodeNotSuccessful(status) {
return DefaultProblemStatusCode, false
}
return status, true
}
func isEmptyTypeURI(uri string) bool {
return uri == "" || uri == "about:blank"
}
func (p Problem) getType() string {
typeField, found := p["type"]
if found {
if typ, ok := typeField.(string); ok {
if !isEmptyTypeURI(typ) {
return typ
}
}
}
return ""
}
// Updates "type" field to absolute URI, recursively.
func (p Problem) updateTypeToAbsolute(ctx Context) {
if p == nil {
return
}
if uriRef := p.getType(); uriRef != "" {
p.Type(ctx.AbsoluteURI(uriRef))
}
if cause, ok := p["cause"]; ok {
if causeP, ok := cause.(Problem); ok {
causeP.updateTypeToAbsolute(ctx)
}
}
}
// Key sets a custom key-value pair.
func (p Problem) Key(key string, value interface{}) Problem {
p[key] = value
return p
}
// Type URI SHOULD resolve to HTML [W3C.REC-html5-20141028]
// documentation that explains how to resolve the problem.
// Example: "https://example.net/validation-error"
//
// Empty URI or "about:blank", when used as a problem type,
// indicates that the problem has no additional semantics beyond that of the HTTP status code.
// When "about:blank" is used,
// the title is being automatically set the same as the recommended HTTP status phrase for that code
// (e.g., "Not Found" for 404, and so on) on `Status` call.
//
// Relative paths are also valid when writing this Problem to an Iris Context.
func (p Problem) Type(uri string) Problem {
return p.Key("type", uri)
}
// Title sets the problem's title field.
// Example: "Your request parameters didn't validate."
// It is set to status Code text if missing,
// (e.g., "Not Found" for 404, and so on).
func (p Problem) Title(title string) Problem {
return p.Key("title", title)
}
// Status sets HTTP error code for problem's status field.
// Example: 404
//
// It is required.
func (p Problem) Status(statusCode int) Problem {
shouldOverrideTitle := !p.keyExists("title")
if !shouldOverrideTitle {
typ, found := p["type"]
shouldOverrideTitle = !found || isEmptyTypeURI(typ.(string))
}
if shouldOverrideTitle {
// Set title by code.
p.Title(http.StatusText(statusCode))
}
return p.Key("status", statusCode)
}
// Detail sets the problem's detail field.
// Example: "Optional details about the error...".
func (p Problem) Detail(detail string) Problem {
return p.Key("detail", detail)
}
// Cause sets the problem's cause field.
// Any chain of problems.
func (p Problem) Cause(cause Problem) Problem {
if !cause.Validate() {
return p
}
return p.Key("cause", cause)
}
// Validate reports whether this Problem value is a valid problem one.
func (p Problem) Validate() bool {
// A nil problem is not a valid one.
if p == nil {
return false
}
return p.keyExists("type") &&
p.keyExists("title") &&
p.keyExists("status")
}
// Error method completes the go error.
// Returns the "[Status] Title" string form of this Problem.
// If Problem is not a valid one, it returns "invalid problem".
func (p Problem) Error() string {
if !p.Validate() {
return "invalid problem"
}
return fmt.Sprintf("[%d] %s", p["status"], p["title"])
}

10
go19.go
View File

@ -48,8 +48,16 @@ type (
// See `NewConditionalHandler` for more.
// An alias for the `context/Filter`.
Filter = context.Filter
// A Map is a shortcut of the map[string]interface{}.
// A Map is an alias of map[string]interface{}.
Map = context.Map
// Problem Details for HTTP APIs.
// Pass a Problem value to `context.Problem` to
// write an "application/problem+json" response.
//
// Read more at: https://github.com/kataras/iris/wiki/Routing-error-handlers
//
// It is an alias of `context.Problem` type.
Problem = context.Problem
// Supervisor is a shortcut of the `host#Supervisor`.
// Used to add supervisor configurators on common Runners

View File

@ -453,7 +453,6 @@ var (
//
// A shortcut of the `cache#Cache304`.
Cache304 = cache.Cache304
// CookiePath is a `CookieOption`.
// Use it to change the cookie's Path field.
//
@ -499,6 +498,11 @@ var (
//
// A shortcut for the `context#IsErrPath`.
IsErrPath = context.IsErrPath
// NewProblem retruns a new Problem.
// Head over to the `Problem` type godoc for more.
//
// A shortcut for the `context#NewProblem`.
NewProblem = context.NewProblem
)
// Contains the enum values of the `Context.GetReferrer()` method,