initial v0.8 pre-release
This commit is contained in:
@@ -1,107 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
)
|
||||
|
||||
// ApiError defines the properties for a basic api error response.
|
||||
type ApiError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]any `json:"data"`
|
||||
|
||||
// stores unformatted error data (could be an internal error, text, etc.)
|
||||
rawData any
|
||||
}
|
||||
|
||||
// Error makes it compatible with the `error` interface.
|
||||
func (e *ApiError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e *ApiError) RawData() any {
|
||||
return e.rawData
|
||||
}
|
||||
|
||||
// NewNotFoundError creates and returns 404 `ApiError`.
|
||||
func NewNotFoundError(message string, data any) *ApiError {
|
||||
if message == "" {
|
||||
message = "The requested resource wasn't found."
|
||||
}
|
||||
|
||||
return NewApiError(http.StatusNotFound, message, data)
|
||||
}
|
||||
|
||||
// NewBadRequestError creates and returns 400 `ApiError`.
|
||||
func NewBadRequestError(message string, data any) *ApiError {
|
||||
if message == "" {
|
||||
message = "Something went wrong while processing your request."
|
||||
}
|
||||
|
||||
return NewApiError(http.StatusBadRequest, message, data)
|
||||
}
|
||||
|
||||
// NewForbiddenError creates and returns 403 `ApiError`.
|
||||
func NewForbiddenError(message string, data any) *ApiError {
|
||||
if message == "" {
|
||||
message = "You are not allowed to perform this request."
|
||||
}
|
||||
|
||||
return NewApiError(http.StatusForbidden, message, data)
|
||||
}
|
||||
|
||||
// NewUnauthorizedError creates and returns 401 `ApiError`.
|
||||
func NewUnauthorizedError(message string, data any) *ApiError {
|
||||
if message == "" {
|
||||
message = "Missing or invalid authentication token."
|
||||
}
|
||||
|
||||
return NewApiError(http.StatusUnauthorized, message, data)
|
||||
}
|
||||
|
||||
// NewApiError creates and returns new normalized `ApiError` instance.
|
||||
func NewApiError(status int, message string, data any) *ApiError {
|
||||
message = inflector.Sentenize(message)
|
||||
|
||||
formattedData := map[string]any{}
|
||||
|
||||
if v, ok := data.(validation.Errors); ok {
|
||||
formattedData = resolveValidationErrors(v)
|
||||
}
|
||||
|
||||
return &ApiError{
|
||||
rawData: data,
|
||||
Data: formattedData,
|
||||
Code: status,
|
||||
Message: strings.TrimSpace(message),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveValidationErrors(validationErrors validation.Errors) map[string]any {
|
||||
result := map[string]any{}
|
||||
|
||||
// extract from each validation error its error code and message.
|
||||
for name, err := range validationErrors {
|
||||
// check for nested errors
|
||||
if nestedErrs, ok := err.(validation.Errors); ok {
|
||||
result[name] = resolveValidationErrors(nestedErrs)
|
||||
continue
|
||||
}
|
||||
|
||||
errCode := "validation_invalid_value" // default
|
||||
if errObj, ok := err.(validation.ErrorObject); ok {
|
||||
errCode = errObj.Code()
|
||||
}
|
||||
|
||||
result[name] = map[string]string{
|
||||
"code": errCode,
|
||||
"message": inflector.Sentenize(err.Error()),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package rest_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
)
|
||||
|
||||
func TestNewApiErrorWithRawData(t *testing.T) {
|
||||
e := rest.NewApiError(
|
||||
300,
|
||||
"message_test",
|
||||
"rawData_test",
|
||||
)
|
||||
|
||||
result, _ := json.Marshal(e)
|
||||
expected := `{"code":300,"message":"Message_test.","data":{}}`
|
||||
|
||||
if string(result) != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, string(result))
|
||||
}
|
||||
|
||||
if e.Error() != "Message_test." {
|
||||
t.Errorf("Expected %q, got %q", "Message_test.", e.Error())
|
||||
}
|
||||
|
||||
if e.RawData() != "rawData_test" {
|
||||
t.Errorf("Expected rawData %v, got %v", "rawData_test", e.RawData())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
e := rest.NewApiError(
|
||||
300,
|
||||
"message_test",
|
||||
validation.Errors{
|
||||
"err1": errors.New("test error"),
|
||||
"err2": validation.ErrRequired,
|
||||
"err3": validation.Errors{
|
||||
"sub1": errors.New("test error"),
|
||||
"sub2": validation.ErrRequired,
|
||||
"sub3": validation.Errors{
|
||||
"sub11": validation.ErrRequired,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
result, _ := json.Marshal(e)
|
||||
expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Test error."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
|
||||
|
||||
if string(result) != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, string(result))
|
||||
}
|
||||
|
||||
if e.Error() != "Message_test." {
|
||||
t.Errorf("Expected %q, got %q", "Message_test.", e.Error())
|
||||
}
|
||||
|
||||
if e.RawData() == nil {
|
||||
t.Error("Expected non-nil rawData")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNotFoundError(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
message string
|
||||
data any
|
||||
expected string
|
||||
}{
|
||||
{"", nil, `{"code":404,"message":"The requested resource wasn't found.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":404,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewNotFoundError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBadRequestError(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
message string
|
||||
data any
|
||||
expected string
|
||||
}{
|
||||
{"", nil, `{"code":400,"message":"Something went wrong while processing your request.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":400,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewBadRequestError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewForbiddenError(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
message string
|
||||
data any
|
||||
expected string
|
||||
}{
|
||||
{"", nil, `{"code":403,"message":"You are not allowed to perform this request.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":403,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewForbiddenError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUnauthorizedError(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
message string
|
||||
data any
|
||||
expected string
|
||||
}{
|
||||
{"", nil, `{"code":401,"message":"Missing or invalid authentication token.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":401,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewUnauthorizedError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func BindBody(c echo.Context, i interface{}) error {
|
||||
ctype := req.Header.Get(echo.HeaderContentType)
|
||||
switch {
|
||||
case strings.HasPrefix(ctype, echo.MIMEApplicationJSON):
|
||||
err := ReadJsonBodyCopy(c.Request(), i)
|
||||
err := CopyJsonBody(c.Request(), i)
|
||||
if err != nil {
|
||||
return echo.NewHTTPErrorWithInternal(http.StatusBadRequest, err, err.Error())
|
||||
}
|
||||
@@ -34,9 +34,9 @@ func BindBody(c echo.Context, i interface{}) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ReadJsonBodyCopy reads the request body into i by
|
||||
// CopyJsonBody reads the request body into i by
|
||||
// creating a copy of `r.Body` to allow multiple reads.
|
||||
func ReadJsonBodyCopy(r *http.Request, i interface{}) error {
|
||||
func CopyJsonBody(r *http.Request, i interface{}) error {
|
||||
body := r.Body
|
||||
|
||||
// this usually shouldn't be needed because the Server calls close for us
|
||||
|
||||
@@ -75,14 +75,14 @@ func TestBindBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJsonBodyCopy(t *testing.T) {
|
||||
func TestCopyJsonBody(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(`{"test":"test123"}`))
|
||||
|
||||
// simulate multiple reads from the same request
|
||||
result1 := map[string]string{}
|
||||
rest.ReadJsonBodyCopy(req, &result1)
|
||||
rest.CopyJsonBody(req, &result1)
|
||||
result2 := map[string]string{}
|
||||
rest.ReadJsonBodyCopy(req, &result2)
|
||||
rest.CopyJsonBody(req, &result2)
|
||||
|
||||
if len(result1) == 0 {
|
||||
t.Error("Expected result1 to be filled")
|
||||
|
||||
+17
-19
@@ -1,15 +1,14 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
@@ -24,7 +23,6 @@ var extensionInvalidCharsRegex = regexp.MustCompile(`[^\w\.\*\-\+\=\#]+`)
|
||||
type UploadedFile struct {
|
||||
name string
|
||||
header *multipart.FileHeader
|
||||
bytes []byte
|
||||
}
|
||||
|
||||
// Name returns an assigned unique name to the uploaded file.
|
||||
@@ -37,11 +35,6 @@ func (f *UploadedFile) Header() *multipart.FileHeader {
|
||||
return f.header
|
||||
}
|
||||
|
||||
// Bytes returns a slice with the file content.
|
||||
func (f *UploadedFile) Bytes() []byte {
|
||||
return f.bytes
|
||||
}
|
||||
|
||||
// FindUploadedFiles extracts all form files of `key` from a http request
|
||||
// and returns a slice with `UploadedFile` instances (if any).
|
||||
func FindUploadedFiles(r *http.Request, key string) ([]*UploadedFile, error) {
|
||||
@@ -56,26 +49,32 @@ func FindUploadedFiles(r *http.Request, key string) ([]*UploadedFile, error) {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
|
||||
result := make([]*UploadedFile, len(r.MultipartForm.File[key]))
|
||||
result := make([]*UploadedFile, 0, len(r.MultipartForm.File[key]))
|
||||
|
||||
for i, fh := range r.MultipartForm.File[key] {
|
||||
for _, fh := range r.MultipartForm.File[key] {
|
||||
file, err := fh.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if _, err := io.Copy(buf, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// extension
|
||||
// ---
|
||||
originalExt := filepath.Ext(fh.Filename)
|
||||
sanitizedExt := extensionInvalidCharsRegex.ReplaceAllString(originalExt, "")
|
||||
if sanitizedExt == "" {
|
||||
// try to detect the extension from the mime type
|
||||
mt, err := mimetype.DetectReader(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sanitizedExt = mt.Extension()
|
||||
}
|
||||
|
||||
// name
|
||||
// ---
|
||||
originalName := strings.TrimSuffix(fh.Filename, originalExt)
|
||||
sanitizedName := inflector.Snakecase(originalName)
|
||||
|
||||
if length := len(sanitizedName); length < 3 {
|
||||
// the name is too short so we concatenate an additional random part
|
||||
sanitizedName += ("_" + security.RandomString(10))
|
||||
@@ -91,11 +90,10 @@ func FindUploadedFiles(r *http.Request, key string) ([]*UploadedFile, error) {
|
||||
sanitizedExt,
|
||||
)
|
||||
|
||||
result[i] = &UploadedFile{
|
||||
result = append(result, &UploadedFile{
|
||||
name: uploadedFilename,
|
||||
header: fh,
|
||||
bytes: buf.Bytes(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -2,11 +2,10 @@ package rest_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -14,58 +13,51 @@ import (
|
||||
)
|
||||
|
||||
func TestFindUploadedFiles(t *testing.T) {
|
||||
// create a test temporary file (with very large prefix to test if it will be truncated)
|
||||
tmpFile, err := os.CreateTemp(os.TempDir(), strings.Repeat("a", 150)+"tmpfile-*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tmpFile.Write([]byte("test")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpFile.Seek(0, 0)
|
||||
defer tmpFile.Close()
|
||||
defer os.Remove(tmpFile.Name())
|
||||
// ---
|
||||
|
||||
// stub multipart form file body
|
||||
body := new(bytes.Buffer)
|
||||
mp := multipart.NewWriter(body)
|
||||
w, err := mp.CreateFormFile("test", tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.Copy(w, tmpFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mp.Close()
|
||||
// ---
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Add("Content-Type", mp.FormDataContentType())
|
||||
|
||||
result, err := rest.FindUploadedFiles(req, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
scenarios := []struct {
|
||||
filename string
|
||||
expectedPattern string
|
||||
}{
|
||||
{"ab.png", `^ab_\w{10}_\w{10}\.png$`},
|
||||
{"test", `^test_\w{10}\.txt$`},
|
||||
{"a b c d!@$.j!@$pg", `^a_b_c_d_\w{10}\.jpg$`},
|
||||
{strings.Repeat("a", 150), `^a{100}_\w{10}\.txt$`},
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Expected 1 file, got %d", len(result))
|
||||
}
|
||||
for i, s := range scenarios {
|
||||
// create multipart form file body
|
||||
body := new(bytes.Buffer)
|
||||
mp := multipart.NewWriter(body)
|
||||
w, err := mp.CreateFormFile("test", s.filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write([]byte("test"))
|
||||
mp.Close()
|
||||
// ---
|
||||
|
||||
if result[0].Header().Size != 4 {
|
||||
t.Fatalf("Expected the file size to be 4 bytes, got %d", result[0].Header().Size)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Add("Content-Type", mp.FormDataContentType())
|
||||
|
||||
if !strings.HasSuffix(result[0].Name(), ".txt") {
|
||||
t.Fatalf("Expected the file name to have suffix .txt, got %v", result[0].Name())
|
||||
}
|
||||
result, err := rest.FindUploadedFiles(req, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if length := len(result[0].Name()); length != 115 { // truncated + random part + ext
|
||||
t.Fatalf("Expected the file name to have length of 115, got %d\n%q", length, result[0].Name())
|
||||
}
|
||||
if len(result) != 1 {
|
||||
t.Errorf("[%d] Expected 1 file, got %d", i, len(result))
|
||||
}
|
||||
|
||||
if string(result[0].Bytes()) != "test" {
|
||||
t.Fatalf("Expected the file content to be %q, got %q", "test", string(result[0].Bytes()))
|
||||
if result[0].Header().Size != 4 {
|
||||
t.Errorf("[%d] Expected the file size to be 4 bytes, got %d", i, result[0].Header().Size)
|
||||
}
|
||||
|
||||
pattern, err := regexp.Compile(s.expectedPattern)
|
||||
if err != nil {
|
||||
t.Errorf("[%d] Invalid filename pattern %q: %v", i, s.expectedPattern, err)
|
||||
}
|
||||
if !pattern.MatchString(result[0].Name()) {
|
||||
t.Fatalf("Expected filename to match %s, got filename %s", s.expectedPattern, result[0].Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user