From 7cb84c12b2c056c930dd92651792250a34eaede0 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Mon, 12 Aug 2019 12:05:21 +0300 Subject: [PATCH] implement Problem Details for HTTP APIs #1335 --- _examples/routing/http-errors/main.go | 60 ++++++++- context/context.go | 137 +++++++++++++++++--- context/problem.go | 172 ++++++++++++++++++++++++++ go19.go | 10 +- iris.go | 6 +- 5 files changed, 364 insertions(+), 21 deletions(-) create mode 100644 context/problem.go diff --git a/_examples/routing/http-errors/main.go b/_examples/routing/http-errors/main.go index c41dfbf46..c33a9c0cd 100644 --- a/_examples/routing/http-errors/main.go +++ b/_examples/routing/http-errors/main.go @@ -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: " + ctx.Values().GetString("message") + "") }) + // Catch all error codes [app.OnAnyErrorCode...] + app.Get("/", func(ctx iris.Context) { - ctx.HTML(`Click here to fire the 500 status code`) + ctx.HTML(`Click here 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")) +} diff --git a/context/context.go b/context/context.go index ee33c9f50..b281690f7 100644 --- a/context/context.go +++ b/context/context.go @@ -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(");") ) @@ -3333,10 +3410,11 @@ type N struct { Markdown []byte Binary []byte - JSON interface{} - JSONP interface{} - XML interface{} - YAML interface{} + JSON interface{} + Problem Problem + JSONP interface{} + XML interface{} + YAML interface{} Other []byte // custom content types. } @@ -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 { diff --git a/context/problem.go b/context/problem.go new file mode 100644 index 000000000..6f3cf9a7c --- /dev/null +++ b/context/problem.go @@ -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"]) +} diff --git a/go19.go b/go19.go index d98299c56..25ad84a08 100644 --- a/go19.go +++ b/go19.go @@ -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 diff --git a/iris.go b/iris.go index 5db0d9272..507ef2f8b 100644 --- a/iris.go +++ b/iris.go @@ -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,