initial public commit
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// BindBody binds request body content to i.
|
||||
//
|
||||
// This is similar to `echo.BindBody()`, but for JSON requests uses
|
||||
// custom json reader that **copies** the request body, allowing multiple reads.
|
||||
func BindBody(c echo.Context, i interface{}) error {
|
||||
req := c.Request()
|
||||
if req.ContentLength == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctype := req.Header.Get(echo.HeaderContentType)
|
||||
switch {
|
||||
case strings.HasPrefix(ctype, echo.MIMEApplicationJSON):
|
||||
err := ReadJsonBodyCopy(c.Request(), i)
|
||||
if err != nil {
|
||||
return echo.NewHTTPErrorWithInternal(http.StatusBadRequest, err, err.Error())
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
// fallback to the default binder
|
||||
return echo.BindBody(c, i)
|
||||
}
|
||||
}
|
||||
|
||||
// ReadJsonBodyCopy 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 {
|
||||
body := r.Body
|
||||
|
||||
// this usually shouldn't be needed because the Server calls close for us
|
||||
// but we are changing the request body with a new reader
|
||||
defer body.Close()
|
||||
|
||||
limitReader := io.LimitReader(body, DefaultMaxMemory)
|
||||
|
||||
bodyBytes, readErr := io.ReadAll(limitReader)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
|
||||
err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(i)
|
||||
|
||||
// set new body reader
|
||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package rest_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
)
|
||||
|
||||
func TestBindBody(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
body io.Reader
|
||||
contentType string
|
||||
result map[string]string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
strings.NewReader(""),
|
||||
echo.MIMEApplicationJSON,
|
||||
map[string]string{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
strings.NewReader(`{"test":"invalid`),
|
||||
echo.MIMEApplicationJSON,
|
||||
map[string]string{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
strings.NewReader(`{"test":"test123"}`),
|
||||
echo.MIMEApplicationJSON,
|
||||
map[string]string{"test": "test123"},
|
||||
false,
|
||||
},
|
||||
{
|
||||
strings.NewReader(url.Values{"test": []string{"test123"}}.Encode()),
|
||||
echo.MIMEApplicationForm,
|
||||
map[string]string{"test": "test123"},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", scenario.body)
|
||||
req.Header.Set(echo.HeaderContentType, scenario.contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
result := map[string]string{}
|
||||
err := rest.BindBody(c, &result)
|
||||
|
||||
if err == nil && scenario.expectError {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
|
||||
if err != nil && !scenario.expectError {
|
||||
t.Errorf("(%d) Expected nil, got error %v", i, err)
|
||||
}
|
||||
|
||||
if len(result) != len(scenario.result) {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.result, result)
|
||||
}
|
||||
|
||||
for k, v := range result {
|
||||
if sv, ok := scenario.result[k]; !ok || v != sv {
|
||||
t.Errorf("(%d) Expected value %v for key %s, got %v", i, sv, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJsonBodyCopy(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)
|
||||
result2 := map[string]string{}
|
||||
rest.ReadJsonBodyCopy(req, &result2)
|
||||
|
||||
if len(result1) == 0 {
|
||||
t.Error("Expected result1 to be filled")
|
||||
}
|
||||
|
||||
if len(result2) == 0 {
|
||||
t.Error("Expected result2 to be filled")
|
||||
}
|
||||
|
||||
if v, ok := result1["test"]; !ok || v != "test123" {
|
||||
t.Errorf("Expected result1.test to be %q, got %q", "test123", v)
|
||||
}
|
||||
|
||||
if v, ok := result2["test"]; !ok || v != "test123" {
|
||||
t.Errorf("Expected result2.test to be %q, got %q", "test123", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// DefaultMaxMemory defines the default max memory bytes that
|
||||
// will be used when parsing a form request body.
|
||||
const DefaultMaxMemory = 32 << 20 // 32mb
|
||||
|
||||
// UploadedFile defines a single multipart uploaded file instance.
|
||||
type UploadedFile struct {
|
||||
name string
|
||||
header *multipart.FileHeader
|
||||
bytes []byte
|
||||
}
|
||||
|
||||
// Name returns an assigned unique name to the uploaded file.
|
||||
func (f *UploadedFile) Name() string {
|
||||
return f.name
|
||||
}
|
||||
|
||||
// Header returns the file header that comes with the multipart request.
|
||||
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) {
|
||||
if r.MultipartForm == nil {
|
||||
err := r.ParseMultipartForm(DefaultMaxMemory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if r.MultipartForm == nil || r.MultipartForm.File == nil || len(r.MultipartForm.File[key]) == 0 {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
|
||||
result := make([]*UploadedFile, len(r.MultipartForm.File[key]))
|
||||
|
||||
for i, 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
|
||||
}
|
||||
|
||||
result[i] = &UploadedFile{
|
||||
name: fmt.Sprintf("%s%s", security.RandomString(32), filepath.Ext(fh.Filename)),
|
||||
header: fh,
|
||||
bytes: buf.Bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package rest_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
)
|
||||
|
||||
func TestFindUploadedFiles(t *testing.T) {
|
||||
// create a test temporary file
|
||||
tmpFile, err := os.CreateTemp(os.TempDir(), "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)
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Expected 1 file, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0].Header().Size != 4 {
|
||||
t.Fatalf("Expected the file size to be 4 bytes, got %d", result[0].Header().Size)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(result[0].Name(), ".txt") {
|
||||
t.Fatalf("Expected the file name to have suffix .txt - %v", result[0].Name())
|
||||
}
|
||||
|
||||
if string(result[0].Bytes()) != "test" {
|
||||
t.Fatalf("Expected the file content to be %q, got %q", "test", string(result[0].Bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindUploadedFilesMissing(t *testing.T) {
|
||||
body := new(bytes.Buffer)
|
||||
mp := multipart.NewWriter(body)
|
||||
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.Error("Expected error, got nil")
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("Expected result to be nil, got %v", result)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user