diff --git a/vfs/directory.go b/vfs/directory.go index 90762f81c22..9cca1f10993 100644 --- a/vfs/directory.go +++ b/vfs/directory.go @@ -8,7 +8,8 @@ import ( "github.com/spf13/afero" ) -type dirAttributes struct { +// DirAttributes is a struct with the attributes of a directory +type DirAttributes struct { Name string `json:"name"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -24,7 +25,7 @@ type DirDoc struct { // Directory revision DRev string `json:"_rev,omitempty"` // Directory attributes - Attrs *dirAttributes `json:"attributes"` + Attrs *DirAttributes `json:"attributes"` // Parent folder identifier FolderID string `json:"folderID"` // Directory path on VFS @@ -62,10 +63,9 @@ func (d *DirDoc) SetRev(rev string) { // ToJSONApi implements temporary interface JSONApier to serialize // the directory document func (d *DirDoc) ToJSONApi() ([]byte, error) { - qid := d.DID data := map[string]interface{}{ "type": d.DocType(), - "id": qid, + "id": d.ID(), "rev": d.Rev(), "attributes": d.Attrs, } @@ -88,7 +88,7 @@ func CreateDirectory(m *DocAttributes, fs afero.Fs, dbPrefix string) (doc *DirDo } createDate := time.Now() - attrs := &dirAttributes{ + attrs := &DirAttributes{ Name: m.name, CreatedAt: createDate, UpdatedAt: createDate, diff --git a/vfs/errors.go b/vfs/errors.go index d89e0add612..e8c71f0df8e 100644 --- a/vfs/errors.go +++ b/vfs/errors.go @@ -2,7 +2,6 @@ package vfs import ( "errors" - "net/http" "os" ) @@ -26,26 +25,3 @@ var ( // match the calculated one ErrContentLengthMismatch = errors.New("Content length does not match") ) - -// HTTPStatus returns the HTTP status code associated to a given -// error. If the error is not part of vfs errors, the code returned is -// 0. -func HTTPStatus(err error) (code int) { - switch err { - case ErrDocAlreadyExists: - code = http.StatusConflict - case ErrDocDoesNotExist: - code = http.StatusNotFound - case ErrParentDoesNotExist: - code = http.StatusNotFound - case ErrDocTypeInvalid: - code = http.StatusUnprocessableEntity - case ErrIllegalFilename: - code = http.StatusUnprocessableEntity - case ErrInvalidHash: - code = http.StatusPreconditionFailed - case ErrContentLengthMismatch: - code = http.StatusUnprocessableEntity - } - return -} diff --git a/vfs/file.go b/vfs/file.go index d06edb399ff..2de53cd4718 100644 --- a/vfs/file.go +++ b/vfs/file.go @@ -14,7 +14,8 @@ import ( "github.com/spf13/afero" ) -type fileAttributes struct { +// FileAttributes is a struct with the attributes of a file +type FileAttributes struct { Name string `json:"name"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -34,7 +35,7 @@ type FileDoc struct { // File revision FRev string `json:"_rev,omitempty"` // File attributes - Attrs *fileAttributes `json:"attributes"` + Attrs *FileAttributes `json:"attributes"` // Parent folder identifier FolderID string `json:"folderID"` // File path on VFS @@ -127,17 +128,17 @@ func ServeFileContent(fileDoc *FileDoc, req *http.Request, w http.ResponseWriter // Etag. // // The content disposition is attached -func ServeFileContentByPath(pth string, req *http.Request, w http.ResponseWriter, fs afero.Fs) (err error) { +func ServeFileContentByPath(pth string, req *http.Request, w http.ResponseWriter, fs afero.Fs) error { fileInfo, err := fs.Stat(pth) if err != nil { - return + return ErrDocDoesNotExist } name := path.Base(pth) w.Header().Set("Content-Disposition", "attachment; filename="+name) serveContent(req, w, fs, pth, name, fileInfo.ModTime()) - return + return nil } func serveContent(req *http.Request, w http.ResponseWriter, fs afero.Fs, pth, name string, modtime time.Time) (err error) { @@ -164,7 +165,7 @@ func CreateFileAndUpload(m *DocAttributes, fs afero.Fs, dbPrefix string, body io } createDate := time.Now() - attrs := &fileAttributes{ + attrs := &FileAttributes{ Name: m.name, CreatedAt: createDate, UpdatedAt: createDate, diff --git a/web/data/data.go b/web/data/data.go index 55356d36ebf..8f7a79a3300 100644 --- a/web/data/data.go +++ b/web/data/data.go @@ -6,7 +6,6 @@ import ( "net/http" "github.com/cozy/cozy-stack/couchdb" - "github.com/cozy/cozy-stack/web/errors" "github.com/cozy/cozy-stack/web/middlewares" "github.com/gin-gonic/gin" ) @@ -15,7 +14,7 @@ func validDoctype(c *gin.Context) { // TODO extends me to verificate characters allowed in db name. doctype := c.Param("doctype") if doctype == "" { - c.AbortWithError(http.StatusBadRequest, errors.InvalidDoctype(doctype)) + c.AbortWithError(http.StatusBadRequest, invalidDoctypeErr(doctype)) } else { c.Set("doctype", doctype) } @@ -32,7 +31,7 @@ func getDoc(c *gin.Context) { var out couchdb.JSONDoc err := couchdb.GetDoc(prefix, doctype, docid, &out) if err != nil { - c.AbortWithError(errors.HTTPStatus(err), err) + c.AbortWithError(HTTPStatus(err), err) return } out.Type = doctype @@ -53,7 +52,7 @@ func createDoc(c *gin.Context) { err := couchdb.CreateDoc(prefix, doc) if err != nil { - c.AbortWithError(errors.HTTPStatus(err), err) + c.AbortWithError(HTTPStatus(err), err) return } @@ -91,7 +90,7 @@ func updateDoc(c *gin.Context) { err := couchdb.UpdateDoc(prefix, doc) if err != nil { - c.AbortWithError(errors.HTTPStatus(err), err) + c.AbortWithError(HTTPStatus(err), err) return } @@ -119,7 +118,7 @@ func deleteDoc(c *gin.Context) { tombrev, err := couchdb.Delete(prefix, doctype, docid, rev) if err != nil { - c.AbortWithError(errors.HTTPStatus(err), err) + c.AbortWithError(HTTPStatus(err), err) return } diff --git a/web/data/data_test.go b/web/data/data_test.go index fdfef3e9d63..65706883900 100644 --- a/web/data/data_test.go +++ b/web/data/data_test.go @@ -11,7 +11,6 @@ import ( "os" "testing" - "github.com/cozy/cozy-stack/web/errors" "github.com/cozy/cozy-stack/web/middlewares" "github.com/gin-gonic/gin" "github.com/sourcegraph/checkup" @@ -79,7 +78,6 @@ func injectInstance(instance *middlewares.Instance) gin.HandlerFunc { } func TestMain(m *testing.M) { - // First we make sure couchdb is started couchdb, err := checkup.HTTPChecker{URL: CouchURL}.Check() if err != nil || couchdb.Status() != checkup.Healthy { @@ -87,12 +85,14 @@ func TestMain(m *testing.M) { os.Exit(1) } - router := gin.New() + gin.SetMode(gin.TestMode) instance := &middlewares.Instance{ Domain: Host, StorageURL: "mem://test", } - router.Use(errors.Handler()) + + router := gin.New() + router.Use(middlewares.ErrorHandler()) router.Use(injectInstance(instance)) Routes(router.Group("/data")) ts = httptest.NewServer(router) diff --git a/web/data/errors.go b/web/data/errors.go new file mode 100644 index 00000000000..d4ad77a100e --- /dev/null +++ b/web/data/errors.go @@ -0,0 +1,30 @@ +package data + +import ( + "fmt" + "net/http" + "os" + + "github.com/cozy/cozy-stack/couchdb" +) + +// HTTPStatus gives the http status for given error +func HTTPStatus(err error) (code int) { + if os.IsNotExist(err) { + code = http.StatusNotFound + } else if os.IsExist(err) { + code = http.StatusConflict + } else if couchErr, isCouchErr := err.(*couchdb.Error); isCouchErr { + code = couchErr.StatusCode + } + + if code == 0 { + code = http.StatusInternalServerError + } + + return +} + +func invalidDoctypeErr(doctype string) error { + return fmt.Errorf("Invalid doctype '%s'", doctype) +} diff --git a/web/errors/errors.go b/web/errors/errors.go deleted file mode 100644 index 60027eec174..00000000000 --- a/web/errors/errors.go +++ /dev/null @@ -1,63 +0,0 @@ -package errors - -import ( - "fmt" - "net/http" - "os" - - "github.com/cozy/cozy-stack/couchdb" - "github.com/cozy/cozy-stack/vfs" - "github.com/gin-gonic/gin" -) - -// NoInstance is the err to be returned when there is no instance -var NoInstance = &gin.Error{ - Err: fmt.Errorf("Cannot find instance for request"), - Meta: gin.H{ - "error": "internal_server_error", - "reason": "Cannot find instance for request", - }, -} - -// HTTPStatus gives the http status for given error -func HTTPStatus(err error) (code int) { - if os.IsNotExist(err) { - code = http.StatusNotFound - } else if os.IsExist(err) { - code = http.StatusConflict - } else if couchErr, isCouchErr := err.(*couchdb.Error); isCouchErr { - code = couchErr.StatusCode - } else { - code = vfs.HTTPStatus(err) - } - - if code == 0 { - code = http.StatusInternalServerError - } - - return -} - -// Handler returns a gin middleware to handle the errors -func Handler() gin.HandlerFunc { - return func(c *gin.Context) { - - // let the controller do its thing - c.Next() - - errors := c.Errors.ByType(gin.ErrorTypeAny) - if len(errors) > 0 { - ginerr := errors.Last() - if coucherr, iscoucherr := ginerr.Err.(*couchdb.Error); iscoucherr { - c.JSON(-1, coucherr.JSON()) - } else { - c.JSON(-1, ginerr.JSON()) - } - } - } -} - -// InvalidDoctype : the passed doctype is not valid -func InvalidDoctype(doctype string) error { - return fmt.Errorf("Invalid doctype '%s'", doctype) -} diff --git a/web/files/files.go b/web/files/files.go index f9068a1294f..24822978890 100644 --- a/web/files/files.go +++ b/web/files/files.go @@ -11,7 +11,6 @@ import ( "strings" "github.com/cozy/cozy-stack/vfs" - "github.com/cozy/cozy-stack/web/errors" "github.com/cozy/cozy-stack/web/jsonapi" "github.com/cozy/cozy-stack/web/middlewares" "github.com/gin-gonic/gin" @@ -31,7 +30,7 @@ func CreationHandler(c *gin.Context) { dbPrefix := instance.GetDatabasePrefix() storage, err := instance.GetStorageProvider() if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) + jsonapi.AbortWithError(c, jsonapi.InternalServerError(err)) return } @@ -44,13 +43,13 @@ func CreationHandler(c *gin.Context) { givenMD5, err = parseMD5Hash(md5Str) } if err != nil { - c.AbortWithError(http.StatusUnprocessableEntity, err) + jsonapi.AbortWithError(c, jsonapi.InvalidParameter("Content-MD5", err)) return } size, err := parseContentLength(header.Get("Content-Length")) if err != nil { - c.AbortWithError(http.StatusUnprocessableEntity, err) + jsonapi.AbortWithError(c, jsonapi.InvalidParameter("Content-Length", err)) return } @@ -67,7 +66,7 @@ func CreationHandler(c *gin.Context) { ) if err != nil { - c.AbortWithError(errors.HTTPStatus(err), err) + jsonapi.AbortWithError(c, jsonapi.WrapVfsError(err)) return } @@ -80,13 +79,13 @@ func CreationHandler(c *gin.Context) { } if err != nil { - c.AbortWithError(errors.HTTPStatus(err), err) + jsonapi.AbortWithError(c, jsonapi.WrapVfsError(err)) return } data, err := doc.ToJSONApi() if err != nil { - c.AbortWithError(errors.HTTPStatus(err), err) + jsonapi.AbortWithError(c, jsonapi.WrapVfsError(err)) return } @@ -108,7 +107,7 @@ func ReadHandler(c *gin.Context) { dbPrefix := instance.GetDatabasePrefix() storage, err := instance.GetStorageProvider() if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) + jsonapi.AbortWithError(c, jsonapi.InternalServerError(err)) return } @@ -128,7 +127,7 @@ func ReadHandler(c *gin.Context) { } if err != nil { - c.AbortWithError(errors.HTTPStatus(err), err) + jsonapi.AbortWithError(c, jsonapi.WrapVfsError(err)) return } } diff --git a/web/jsonapi/errors.go b/web/jsonapi/errors.go new file mode 100644 index 00000000000..684ee0c186b --- /dev/null +++ b/web/jsonapi/errors.go @@ -0,0 +1,117 @@ +package jsonapi + +import ( + "net/http" + + "github.com/cozy/cozy-stack/couchdb" + "github.com/cozy/cozy-stack/vfs" +) + +// SourceError contains references to the source of the error +type SourceError struct { + Pointer string `json:"pointer,omitempty"` + Parameter string `json:"parameter,omitempty"` +} + +// Error objects provide additional information about problems encountered +// while performing an operation. +// See http://jsonapi.org/format/#error-objects +type Error struct { + Status int `json:"status,string"` + Title string `json:"title"` + Detail string `json:"detail"` + Source SourceError `json:"source,omitempty"` +} + +// WrapCouchError returns a formatted error from a couchdb error +func WrapCouchError(err *couchdb.Error) *Error { + return &Error{ + Status: err.StatusCode, + Title: err.Name, + Detail: err.Reason, + } +} + +// WrapVfsError returns a formatted error from a golang error emitted by the vfs +func WrapVfsError(err error) *Error { + if couchErr, isCouchErr := err.(*couchdb.Error); isCouchErr { + return WrapCouchError(couchErr) + } + switch err { + case vfs.ErrDocAlreadyExists: + return &Error{ + Status: http.StatusConflict, + Title: "Conflict", + Detail: err.Error(), + } + case vfs.ErrDocDoesNotExist: + return NotFound(err) + case vfs.ErrParentDoesNotExist: + return NotFound(err) + case vfs.ErrDocTypeInvalid: + return InvalidAttribute("type", err) + case vfs.ErrIllegalFilename: + return InvalidParameter("folder-id", err) + case vfs.ErrInvalidHash: + return PreconditionFailed("Content-MD5", err) + case vfs.ErrContentLengthMismatch: + return PreconditionFailed("Content-Length", err) + } + return InternalServerError(err) +} + +// NotFound returns a 404 formatted error +func NotFound(err error) *Error { + return &Error{ + Status: http.StatusNotFound, + Title: "Not Found", + Detail: err.Error(), + } +} + +// InternalServerError returns a 500 formatted error +func InternalServerError(err error) *Error { + return &Error{ + Status: http.StatusInternalServerError, + Title: "Internal Server Error", + Detail: err.Error(), + } +} + +// PreconditionFailed returns a 412 formatted error when an expectation from an +// HTTP header is not matched +func PreconditionFailed(parameter string, err error) *Error { + return &Error{ + Status: http.StatusPreconditionFailed, + Title: "Precondition Failed", + Detail: err.Error(), + Source: SourceError{ + Parameter: parameter, + }, + } +} + +// InvalidParameter returns a 422 formatted error when an HTTP or Query-String +// parameter is invalid +func InvalidParameter(parameter string, err error) *Error { + return &Error{ + Status: http.StatusUnprocessableEntity, + Title: "Invalid Parameter", + Detail: err.Error(), + Source: SourceError{ + Parameter: parameter, + }, + } +} + +// InvalidAttribute returns a 422 formatted error when an attribute is invalid +func InvalidAttribute(attribute string, err error) *Error { + return &Error{ + Status: http.StatusUnprocessableEntity, + Title: "Invalid Attribute", + Detail: err.Error(), + Source: SourceError{ + Pointer: "/data/attributes/" + attribute, + }, + } +} diff --git a/web/jsonapi/jsonapi.go b/web/jsonapi/jsonapi.go index cb28ebca731..c90b56a5ad2 100644 --- a/web/jsonapi/jsonapi.go +++ b/web/jsonapi/jsonapi.go @@ -2,6 +2,13 @@ // checking the content-type, etc. package jsonapi +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" +) + // ContentType is the official mime-type for JSON-API const ContentType = "application/vnd.api+json" @@ -11,3 +18,18 @@ const ContentType = "application/vnd.api+json" type JSONApier interface { ToJSONApi() ([]byte, error) } + +// AbortWithError can be called to abort the current http request/response +// processing, and send an error in the JSON-API format +func AbortWithError(c *gin.Context, e *Error) { + doc := map[string]interface{}{ + "errors": []*Error{e}, + } + body, err := json.Marshal(doc) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + c.Data(e.Status, ContentType, body) + c.Abort() +} diff --git a/web/middlewares/errors.go b/web/middlewares/errors.go new file mode 100644 index 00000000000..3768a950dad --- /dev/null +++ b/web/middlewares/errors.go @@ -0,0 +1,25 @@ +package middlewares + +import ( + "github.com/cozy/cozy-stack/couchdb" + "github.com/gin-gonic/gin" +) + +// ErrorHandler returns a gin middleware to handle the errors +func ErrorHandler() gin.HandlerFunc { + return func(c *gin.Context) { + + // let the controller do its thing + c.Next() + + errors := c.Errors.ByType(gin.ErrorTypeAny) + if len(errors) > 0 { + ginerr := errors.Last() + if coucherr, iscoucherr := ginerr.Err.(*couchdb.Error); iscoucherr { + c.JSON(-1, coucherr.JSON()) + } else { + c.JSON(-1, ginerr.JSON()) + } + } + } +} diff --git a/web/middlewares/instance.go b/web/middlewares/instance.go index 9a995e79c51..6f2773b076d 100644 --- a/web/middlewares/instance.go +++ b/web/middlewares/instance.go @@ -4,11 +4,11 @@ package middlewares import ( "fmt" - "net/http" "net/url" "os" "strings" + "github.com/cozy/cozy-stack/web/jsonapi" "github.com/gin-gonic/gin" "github.com/spf13/afero" ) @@ -59,7 +59,7 @@ func SetInstance() gin.HandlerFunc { } wd, err := os.Getwd() if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) + jsonapi.AbortWithError(c, jsonapi.InternalServerError(err)) return } storageURL := "file://localhost" + wd + "/" + domain + "/" diff --git a/web/router.go b/web/router.go index 05121821104..5e1d9ae173f 100644 --- a/web/router.go +++ b/web/router.go @@ -24,7 +24,6 @@ package web import ( "github.com/cozy/cozy-stack/web/data" - "github.com/cozy/cozy-stack/web/errors" "github.com/cozy/cozy-stack/web/files" "github.com/cozy/cozy-stack/web/middlewares" "github.com/cozy/cozy-stack/web/status" @@ -35,9 +34,9 @@ import ( // SetupRoutes sets the routing for HTTP endpoints to the Go methods func SetupRoutes(router *gin.Engine) { router.Use(middlewares.SetInstance()) - router.Use(errors.Handler()) + router.Use(middlewares.ErrorHandler()) + data.Routes(router.Group("/data")) files.Routes(router.Group("/files")) status.Routes(router.Group("/status")) - data.Routes(router.Group("/data")) version.Routes(router.Group("/version")) } diff --git a/web/version/version.go b/web/version/version.go index 3b70340a83c..7b2281a69fa 100644 --- a/web/version/version.go +++ b/web/version/version.go @@ -11,7 +11,7 @@ import ( // go build -ldflags "-X github.com/cozy/cozy-stack/web/version.Build=" var Build = "Unknown" -// Version responds the git commit used at the build +// Version responds with the git commit used at the build // // swagger:route GET /version version showVersion //