initial public commit

This commit is contained in:
Gani Georgiev
2022-07-07 00:19:05 +03:00
commit 3d07f0211d
484 changed files with 92412 additions and 0 deletions
+107
View File
@@ -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
}
+150
View File
@@ -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))
}
}
}
+59
View File
@@ -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
}
+102
View File
@@ -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)
}
}
+76
View File
@@ -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
}
+84
View File
@@ -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)
}
}