From b8beabb08865b1bbcd18bc23ac6f5c0d887ae5f6 Mon Sep 17 00:00:00 2001 From: Fred Carle Date: Wed, 8 Jun 2022 09:20:24 -0400 Subject: [PATCH] refactor: Change handler functions implementation and response formatting (#498) RELEVANT ISSUE(S) Resolves #384 Resolves #458 Resolves #494 DESCRIPTION After the HTTP API refactor, we now focus on the refactoring of the handler functions themselves ensuring common response formats for both successful and error responses across the API. This PR also changes the API version number to v0 from v1 to show "unstable" status and responds with the appropriate content-type with JSON payloads. --- README.md | 2 +- api/http/errors.go | 28 +- api/http/errors_test.go | 45 ++- api/http/handler.go | 29 ++ api/http/handler_test.go | 57 ++- api/http/handlerfuncs.go | 216 +++------- api/http/handlerfuncs_test.go | 740 ++++++++++++++++++++++++++++++++++ api/http/logger_test.go | 4 +- api/http/router.go | 4 +- go.mod | 1 + go.sum | 1 + 11 files changed, 920 insertions(+), 207 deletions(-) create mode 100644 api/http/handlerfuncs_test.go diff --git a/README.md b/README.md index 83bdefb5f9..755322120e 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ defradb client ping ``` which should respond with `Success!` -Once you've confirmed your node is running correctly, if you're using the GraphiQL client to interact with the database, then make sure you set the `GraphQL Endpoint` to `http://localhost:9181/api/v1/graphql`. +Once you've confirmed your node is running correctly, if you're using the GraphiQL client to interact with the database, then make sure you set the `GraphQL Endpoint` to `http://localhost:9181/api/v0/graphql`. ### Add a Schema type diff --git a/api/http/errors.go b/api/http/errors.go index 2d440890a8..15b107d2b5 100644 --- a/api/http/errors.go +++ b/api/http/errors.go @@ -21,9 +21,18 @@ import ( var env = os.Getenv("DEFRA_ENV") type errorResponse struct { - Status int `json:"status"` - Message string `json:"message"` - Stack string `json:"stack,omitempty"` + Errors []errorItem `json:"errors"` +} + +type errorItem struct { + Message string `json:"message"` + Extensions *extensions `json:"extensions,omitempty"` +} + +type extensions struct { + Status int `json:"status"` + HTTPError string `json:"httpError"` + Stack string `json:"stack,omitempty"` } func handleErr(ctx context.Context, rw http.ResponseWriter, err error, status int) { @@ -35,9 +44,16 @@ func handleErr(ctx context.Context, rw http.ResponseWriter, err error, status in ctx, rw, errorResponse{ - Status: status, - Message: http.StatusText(status), - Stack: formatError(err), + Errors: []errorItem{ + { + Message: err.Error(), + Extensions: &extensions{ + Status: status, + HTTPError: http.StatusText(status), + Stack: formatError(err), + }, + }, + }, }, status, ) diff --git a/api/http/errors_test.go b/api/http/errors_test.go index f5e6f78bef..1cfdc19070 100644 --- a/api/http/errors_test.go +++ b/api/http/errors_test.go @@ -54,11 +54,14 @@ func TestHandleErrOnBadRequest(t *testing.T) { t.Fatal(err) } - assert.Equal(t, http.StatusBadRequest, errResponse.Status) - assert.Equal(t, http.StatusText(http.StatusBadRequest), errResponse.Message) + if len(errResponse.Errors) != 1 { + t.Fatal("expecting exactly one error") + } - lines := strings.Split(errResponse.Stack, "\n") - assert.Equal(t, "[DEV] test error", lines[0]) + assert.Equal(t, http.StatusBadRequest, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, http.StatusText(http.StatusBadRequest), errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "test error", errResponse.Errors[0].Message) + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "[DEV] test error") } func TestHandleErrOnInternalServerError(t *testing.T) { @@ -83,11 +86,13 @@ func TestHandleErrOnInternalServerError(t *testing.T) { t.Fatal(err) } - assert.Equal(t, http.StatusInternalServerError, errResponse.Status) - assert.Equal(t, http.StatusText(http.StatusInternalServerError), errResponse.Message) - - lines := strings.Split(errResponse.Stack, "\n") - assert.Equal(t, "[DEV] test error", lines[0]) + if len(errResponse.Errors) != 1 { + t.Fatal("expecting exactly one error") + } + assert.Equal(t, http.StatusInternalServerError, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, http.StatusText(http.StatusInternalServerError), errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "test error", errResponse.Errors[0].Message) + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "[DEV] test error") } func TestHandleErrOnNotFound(t *testing.T) { @@ -112,11 +117,14 @@ func TestHandleErrOnNotFound(t *testing.T) { t.Fatal(err) } - assert.Equal(t, http.StatusNotFound, errResponse.Status) - assert.Equal(t, http.StatusText(http.StatusNotFound), errResponse.Message) + if len(errResponse.Errors) != 1 { + t.Fatal("expecting exactly one error") + } - lines := strings.Split(errResponse.Stack, "\n") - assert.Equal(t, "[DEV] test error", lines[0]) + assert.Equal(t, http.StatusNotFound, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, http.StatusText(http.StatusNotFound), errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "test error", errResponse.Errors[0].Message) + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "[DEV] test error") } func TestHandleErrOnDefault(t *testing.T) { @@ -141,9 +149,12 @@ func TestHandleErrOnDefault(t *testing.T) { t.Fatal(err) } - assert.Equal(t, http.StatusUnauthorized, errResponse.Status) - assert.Equal(t, "Unauthorized", errResponse.Message) + if len(errResponse.Errors) != 1 { + t.Fatal("expecting exactly one error") + } - lines := strings.Split(errResponse.Stack, "\n") - assert.Equal(t, "[DEV] Unauthorized", lines[0]) + assert.Equal(t, http.StatusUnauthorized, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, http.StatusText(http.StatusUnauthorized), errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "Unauthorized", errResponse.Errors[0].Message) + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "[DEV] Unauthorized") } diff --git a/api/http/handler.go b/api/http/handler.go index f816f0ead7..56228b5ae6 100644 --- a/api/http/handler.go +++ b/api/http/handler.go @@ -32,6 +32,35 @@ type handler struct { type ctxDB struct{} +type dataResponse struct { + Data interface{} `json:"data"` +} + +// simpleDataResponse is a helper function that returns a dataResponse struct. +// Odd arguments are the keys and must be strings otherwise they are ignored. +// Even arguments are the values associated with the previous key. +// Odd arguments are also ignored if there are no following arguments. +func simpleDataResponse(args ...interface{}) dataResponse { + data := make(map[string]interface{}) + + for i := 0; i < len(args); i += 2 { + if len(args) >= i+2 { + switch a := args[i].(type) { + case string: + data[a] = args[i+1] + + default: + continue + + } + } + } + + return dataResponse{ + Data: data, + } +} + // newHandler returns a handler with the router instantiated. func newHandler(db client.DB, opts serverOptions) *handler { return setRoutes(&handler{ diff --git a/api/http/handler_test.go b/api/http/handler_test.go index a35bc0ebd7..b7fa409f12 100644 --- a/api/http/handler_test.go +++ b/api/http/handler_test.go @@ -28,6 +28,35 @@ import ( "github.com/stretchr/testify/assert" ) +func TestSimpleDataResponse(t *testing.T) { + resp := simpleDataResponse("key", "value", "key2", "value2") + switch v := resp.Data.(type) { + case map[string]interface{}: + assert.Equal(t, "value", v["key"]) + assert.Equal(t, "value2", v["key2"]) + default: + t.Fatalf("data should be of type map[string]interface{} but got %T", resp.Data) + } + + resp2 := simpleDataResponse("key", "value", "key2") + switch v := resp2.Data.(type) { + case map[string]interface{}: + assert.Equal(t, "value", v["key"]) + assert.Equal(t, nil, v["key2"]) + default: + t.Fatalf("data should be of type map[string]interface{} but got %T", resp.Data) + } + + resp3 := simpleDataResponse("key", "value", 2, "value2") + switch v := resp3.Data.(type) { + case map[string]interface{}: + assert.Equal(t, "value", v["key"]) + assert.Equal(t, nil, v["2"]) + default: + t.Fatalf("data should be of type map[string]interface{} but got %T", resp.Data) + } +} + func TestNewHandlerWithLogger(t *testing.T) { h := newHandler(nil, serverOptions{}) @@ -40,14 +69,14 @@ func TestNewHandlerWithLogger(t *testing.T) { OutputPaths: []string{logFile}, }) - req, err := http.NewRequest("GET", "/ping", nil) + req, err := http.NewRequest("GET", PingPath, nil) if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() - - loggerMiddleware(h.handle(pingHandler)).ServeHTTP(rec, req) + lrw := newLoggingResponseWriter(rec) + h.ServeHTTP(lrw, req) assert.Equal(t, 200, rec.Result().StatusCode) // inspect the log file @@ -65,13 +94,12 @@ func TestGetJSON(t *testing.T) { Name string } - jsonStr := []byte(` - { - "Name": "John Doe" - } - `) + jsonStr := ` +{ + "Name": "John Doe" +}` - req, err := http.NewRequest("POST", "/ping", bytes.NewBuffer(jsonStr)) + req, err := http.NewRequest("POST", "/ping", bytes.NewBuffer([]byte(jsonStr))) if err != nil { t.Fatal(err) } @@ -90,13 +118,12 @@ func TestGetJSONWithError(t *testing.T) { Name string } - jsonStr := []byte(` - { - "Name": 10 - } - `) + jsonStr := ` +{ + "Name": 10 +}` - req, err := http.NewRequest("POST", "/ping", bytes.NewBuffer(jsonStr)) + req, err := http.NewRequest("POST", "/ping", bytes.NewBuffer([]byte(jsonStr))) if err != nil { t.Fatal(err) } diff --git a/api/http/handlerfuncs.go b/api/http/handlerfuncs.go index 5b5c9109c8..1a40d07f9a 100644 --- a/api/http/handlerfuncs.go +++ b/api/http/handlerfuncs.go @@ -11,7 +11,6 @@ package http import ( - "encoding/json" "io" "net/http" @@ -22,7 +21,6 @@ import ( dag "github.com/ipfs/go-merkledag" "github.com/multiformats/go-multihash" "github.com/pkg/errors" - "github.com/sourcenetwork/defradb/client" corecrdt "github.com/sourcenetwork/defradb/core/crdt" ) @@ -33,35 +31,23 @@ const ( ) func rootHandler(rw http.ResponseWriter, req *http.Request) { - _, err := rw.Write( - []byte("Welcome to the DefraDB HTTP API. Use /graphql to send queries to the database"), + sendJSON( + req.Context(), + rw, + simpleDataResponse( + "response", "Welcome to the DefraDB HTTP API. Use /graphql to send queries to the database", + ), + http.StatusOK, ) - if err != nil { - handleErr( - req.Context(), - rw, - errors.WithMessage( - err, - "DefraDB HTTP API Welcome message writing failed", - ), - http.StatusInternalServerError, - ) - } } func pingHandler(rw http.ResponseWriter, req *http.Request) { - _, err := rw.Write([]byte("pong")) - if err != nil { - handleErr( - req.Context(), - rw, - errors.WithMessage( - err, - "Writing pong with HTTP failed", - ), - http.StatusInternalServerError, - ) - } + sendJSON( + req.Context(), + rw, + simpleDataResponse("response", "pong", "test"), + http.StatusOK, + ) } func dumpHandler(rw http.ResponseWriter, req *http.Request) { @@ -72,30 +58,20 @@ func dumpHandler(rw http.ResponseWriter, req *http.Request) { } db.PrintDump(req.Context()) - _, err = rw.Write([]byte("ok")) - if err != nil { - handleErr( - req.Context(), - rw, - errors.WithMessage( - err, - "Writing ok with HTTP failed", - ), - http.StatusInternalServerError, - ) - } + sendJSON( + req.Context(), + rw, + simpleDataResponse("response", "ok"), + http.StatusOK, + ) } type gqlRequest struct { - Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` - OperationName string `json:"operationName"` + Query string `json:"query"` } func execGQLHandler(rw http.ResponseWriter, req *http.Request) { - query := req.URL.Query().Get("query") - if query == "" { switch req.Header.Get("Content-Type") { case contentTypeJSON: @@ -122,6 +98,10 @@ func execGQLHandler(rw http.ResponseWriter, req *http.Request) { fallthrough default: + if req.Body == nil { + handleErr(req.Context(), rw, errors.New("body cannot be empty"), http.StatusBadRequest) + return + } body, err := io.ReadAll(req.Body) if err != nil { handleErr(req.Context(), rw, errors.WithStack(err), http.StatusBadRequest) @@ -144,34 +124,13 @@ func execGQLHandler(rw http.ResponseWriter, req *http.Request) { } result := db.ExecQuery(req.Context(), query) - err = json.NewEncoder(rw).Encode(result) - if err != nil { - handleErr(req.Context(), rw, errors.WithStack(err), http.StatusBadRequest) - return - } + sendJSON(req.Context(), rw, result, http.StatusOK) } func loadSchemaHandler(rw http.ResponseWriter, req *http.Request) { - var result client.QueryResult sdl, err := io.ReadAll(req.Body) - - defer func() { - err = req.Body.Close() - if err != nil { - handleErr(req.Context(), rw, errors.WithStack(err), http.StatusInternalServerError) - } - }() - if err != nil { - result.Errors = []interface{}{err.Error()} - - err = json.NewEncoder(rw).Encode(result) - if err != nil { - handleErr(req.Context(), rw, errors.WithStack(err), http.StatusInternalServerError) - return - } - - rw.WriteHeader(http.StatusBadRequest) + handleErr(req.Context(), rw, err, http.StatusBadRequest) return } @@ -180,33 +139,22 @@ func loadSchemaHandler(rw http.ResponseWriter, req *http.Request) { handleErr(req.Context(), rw, err, http.StatusInternalServerError) return } + err = db.AddSchema(req.Context(), string(sdl)) if err != nil { - result.Errors = []interface{}{err.Error()} - - err = json.NewEncoder(rw).Encode(result) - if err != nil { - handleErr(req.Context(), rw, errors.WithStack(err), http.StatusInternalServerError) - return - } - - rw.WriteHeader(http.StatusBadRequest) + handleErr(req.Context(), rw, err, http.StatusBadRequest) return } - result.Data = map[string]string{ - "result": "success", - } - - err = json.NewEncoder(rw).Encode(result) - if err != nil { - handleErr(req.Context(), rw, errors.WithStack(err), http.StatusInternalServerError) - return - } + sendJSON( + req.Context(), + rw, + simpleDataResponse("result", "success"), + http.StatusBadRequest, + ) } func getBlockHandler(rw http.ResponseWriter, req *http.Request) { - var result client.QueryResult cidStr := chi.URLParam(req, "cid") // try to parse CID @@ -218,16 +166,7 @@ func getBlockHandler(rw http.ResponseWriter, req *http.Request) { var hash multihash.Multihash hash, err = dshelp.DsKeyToMultihash(key) if err != nil { - result.Errors = []interface{}{err.Error()} - result.Data = err.Error() - - err = json.NewEncoder(rw).Encode(result) - if err != nil { - handleErr(req.Context(), rw, errors.WithStack(err), http.StatusInternalServerError) - return - } - - rw.WriteHeader(http.StatusBadRequest) + handleErr(req.Context(), rw, err, http.StatusBadRequest) return } cID = cid.NewCidV1(cid.Raw, hash) @@ -238,97 +177,46 @@ func getBlockHandler(rw http.ResponseWriter, req *http.Request) { handleErr(req.Context(), rw, err, http.StatusInternalServerError) return } + block, err := db.Blockstore().Get(req.Context(), cID) if err != nil { - result.Errors = []interface{}{err.Error()} - - err = json.NewEncoder(rw).Encode(result) - if err != nil { - handleErr(req.Context(), rw, errors.WithStack(err), http.StatusInternalServerError) - return - } - - rw.WriteHeader(http.StatusBadRequest) + handleErr(req.Context(), rw, err, http.StatusBadRequest) return } nd, err := dag.DecodeProtobuf(block.RawData()) if err != nil { - result.Errors = []interface{}{err.Error()} - result.Data = err.Error() - - err = json.NewEncoder(rw).Encode(result) - if err != nil { - handleErr(req.Context(), rw, errors.WithStack(err), http.StatusInternalServerError) - return - } - - rw.WriteHeader(http.StatusBadRequest) + handleErr(req.Context(), rw, err, http.StatusBadRequest) return } + buf, err := nd.MarshalJSON() if err != nil { - result.Errors = []interface{}{err.Error()} - - err = json.NewEncoder(rw).Encode(result) - if err != nil { - handleErr(req.Context(), rw, errors.WithStack(err), http.StatusInternalServerError) - return - } - - rw.WriteHeader(http.StatusBadRequest) + handleErr(req.Context(), rw, err, http.StatusInternalServerError) return } reg := corecrdt.LWWRegister{} delta, err := reg.DeltaDecode(nd) if err != nil { - result.Errors = []interface{}{err.Error()} - - err = json.NewEncoder(rw).Encode(result) - if err != nil { - handleErr(req.Context(), rw, errors.WithStack(err), http.StatusInternalServerError) - return - } - - rw.WriteHeader(http.StatusBadRequest) + handleErr(req.Context(), rw, err, http.StatusInternalServerError) return } data, err := delta.Marshal() if err != nil { - result.Errors = []interface{}{err.Error()} - - err = json.NewEncoder(rw).Encode(result) - if err != nil { - handleErr(req.Context(), rw, errors.WithStack(err), http.StatusInternalServerError) - return - } - - rw.WriteHeader(http.StatusBadRequest) + handleErr(req.Context(), rw, err, http.StatusInternalServerError) return } - result.Data = map[string]interface{}{ - "block": string(buf), - "delta": string(data), - "val": delta.Value(), - } - - enc := json.NewEncoder(rw) - enc.SetIndent("", "\t") - err = enc.Encode(result) - if err != nil { - result.Errors = []interface{}{err.Error()} - result.Data = nil - - err := json.NewEncoder(rw).Encode(result) - if err != nil { - handleErr(req.Context(), rw, errors.WithStack(err), http.StatusInternalServerError) - return - } - - rw.WriteHeader(http.StatusBadRequest) - return - } + sendJSON( + req.Context(), + rw, + simpleDataResponse( + "block", string(buf), + "delta", string(data), + "val", delta.Value(), + ), + http.StatusOK, + ) } diff --git a/api/http/handlerfuncs_test.go b/api/http/handlerfuncs_test.go new file mode 100644 index 0000000000..ab65749c74 --- /dev/null +++ b/api/http/handlerfuncs_test.go @@ -0,0 +1,740 @@ +// Copyright 2022 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package http + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + badger "github.com/dgraph-io/badger/v3" + "github.com/ipfs/go-cid" + dshelp "github.com/ipfs/go-ipfs-ds-help" + "github.com/sourcenetwork/defradb/client" + badgerds "github.com/sourcenetwork/defradb/datastore/badger/v3" + "github.com/sourcenetwork/defradb/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type testOptions struct { + Testing *testing.T + DB client.DB + Handlerfunc http.HandlerFunc + Method string + Path string + Body io.Reader + Headers map[string]string + ExpectedStatus int + ResponseData interface{} +} + +type testUser struct { + Key string `json:"_key"` + Versions []testVersion `json:"_version"` +} + +type testVersion struct { + CID string `json:"cid"` +} + +func TestRootHandler(t *testing.T) { + resp := dataResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "GET", + Path: RootPath, + Body: nil, + ExpectedStatus: 200, + ResponseData: &resp, + }) + switch v := resp.Data.(type) { + case map[string]interface{}: + assert.Equal(t, "Welcome to the DefraDB HTTP API. Use /graphql to send queries to the database", v["response"]) + default: + t.Fatalf("data should be of type map[string]interface{} but got %T", resp.Data) + } +} + +func TestPingHandler(t *testing.T) { + resp := dataResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "GET", + Path: PingPath, + Body: nil, + ExpectedStatus: 200, + ResponseData: &resp, + }) + + switch v := resp.Data.(type) { + case map[string]interface{}: + assert.Equal(t, "pong", v["response"]) + default: + t.Fatalf("data should be of type map[string]interface{} but got %T", resp.Data) + } +} + +func TestDumpHandlerWithNoError(t *testing.T) { + ctx := context.Background() + defra := testNewInMemoryDB(t, ctx) + + resp := dataResponse{} + testRequest(testOptions{ + Testing: t, + DB: defra, + Method: "GET", + Path: DumpPath, + Body: nil, + ExpectedStatus: 200, + ResponseData: &resp, + }) + + switch v := resp.Data.(type) { + case map[string]interface{}: + assert.Equal(t, "ok", v["response"]) + default: + t.Fatalf("data should be of type map[string]interface{} but got %T", resp.Data) + } +} + +func TestDumpHandlerWithDBError(t *testing.T) { + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "GET", + Path: DumpPath, + Body: nil, + ExpectedStatus: 500, + ResponseData: &errResponse, + }) + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "no database available") + assert.Equal(t, http.StatusInternalServerError, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Internal Server Error", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "no database available", errResponse.Errors[0].Message) +} + +func TestExecGQLWithNilBody(t *testing.T) { + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "POST", + Path: GraphQLPath, + Body: nil, + ExpectedStatus: 400, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "body cannot be empty") + assert.Equal(t, http.StatusBadRequest, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Bad Request", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "body cannot be empty", errResponse.Errors[0].Message) +} + +func TestExecGQLWithEmptyBody(t *testing.T) { + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "POST", + Path: GraphQLPath, + Body: bytes.NewBuffer([]byte("")), + ExpectedStatus: 400, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "missing GraphQL query") + assert.Equal(t, http.StatusBadRequest, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Bad Request", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "missing GraphQL query", errResponse.Errors[0].Message) + +} + +type mockReadCloser struct { + mock.Mock +} + +func (m *mockReadCloser) Read(p []byte) (n int, err error) { + args := m.Called(p) + return args.Int(0), args.Error(1) +} + +func TestExecGQLWithMockBody(t *testing.T) { + mockReadCloser := mockReadCloser{} + // if Read is called, it will return error + mockReadCloser.On("Read", mock.AnythingOfType("[]uint8")).Return(0, fmt.Errorf("error reading")) + + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "POST", + Path: GraphQLPath, + Body: &mockReadCloser, + ExpectedStatus: 400, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "error reading") + assert.Equal(t, http.StatusBadRequest, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Bad Request", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "error reading", errResponse.Errors[0].Message) + +} + +func TestExecGQLWithNoDB(t *testing.T) { + errResponse := errorResponse{} + stmt := ` +mutation { + create_user(data: "{\"age\": 31, \"verified\": true, \"points\": 90, \"name\": \"Bob\"}") { + _key + } +}` + + buf := bytes.NewBuffer([]byte(stmt)) + testRequest(testOptions{ + Testing: t, + Method: "POST", + Path: GraphQLPath, + Body: buf, + ExpectedStatus: 500, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "no database available") + assert.Equal(t, http.StatusInternalServerError, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Internal Server Error", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "no database available", errResponse.Errors[0].Message) + +} + +func TestExecGQLHandlerContentTypeJSONWithJSONError(t *testing.T) { + // statement with JSON formatting error + stmt := ` +[ + "query": "mutation { + create_user( + data: \"{ + \\\"age\\\": 31, + \\\"verified\\\": true, + \\\"points\\\": 90, + \\\"name\\\": \\\"Bob\\\" + }\" + ) {_key} + }" +]` + + buf := bytes.NewBuffer([]byte(stmt)) + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "POST", + Path: GraphQLPath, + Body: buf, + Headers: map[string]string{"Content-Type": contentTypeJSON}, + ExpectedStatus: 400, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "invalid character") + assert.Equal(t, http.StatusBadRequest, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Bad Request", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "unmarshall error: invalid character ':' after array element", errResponse.Errors[0].Message) + +} + +func TestExecGQLHandlerContentTypeJSON(t *testing.T) { + ctx := context.Background() + defra := testNewInMemoryDB(t, ctx) + + // load schema + testLoadSchema(t, ctx, defra) + + // add document + stmt := ` +{ + "query": "mutation { + create_user( + data: \"{ + \\\"age\\\": 31, + \\\"verified\\\": true, + \\\"points\\\": 90, + \\\"name\\\": \\\"Bob\\\" + }\" + ) {_key} + }" +}` + // remote line returns and tabulation from formatted statement + stmt = strings.ReplaceAll(strings.ReplaceAll(stmt, "\t", ""), "\n", "") + + buf := bytes.NewBuffer([]byte(stmt)) + users := []testUser{} + resp := dataResponse{ + Data: &users, + } + testRequest(testOptions{ + Testing: t, + DB: defra, + Method: "POST", + Path: GraphQLPath, + Body: buf, + Headers: map[string]string{"Content-Type": contentTypeJSON}, + ExpectedStatus: 200, + ResponseData: &resp, + }) + + assert.Contains(t, users[0].Key, "bae-") + +} + +func TestExecGQLHandlerContentTypeFormURLEncoded(t *testing.T) { + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "POST", + Path: GraphQLPath, + Body: nil, + Headers: map[string]string{"Content-Type": contentTypeFormURLEncoded}, + ExpectedStatus: 400, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "content type application/x-www-form-urlencoded not yet supported") + assert.Equal(t, http.StatusBadRequest, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Bad Request", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "content type application/x-www-form-urlencoded not yet supported", errResponse.Errors[0].Message) + +} + +func TestExecGQLHandlerContentTypeGraphQL(t *testing.T) { + ctx := context.Background() + defra := testNewInMemoryDB(t, ctx) + + // load schema + testLoadSchema(t, ctx, defra) + + // add document + stmt := ` +mutation { + create_user(data: "{\"age\": 31, \"verified\": true, \"points\": 90, \"name\": \"Bob\"}") { + _key + } +}` + + buf := bytes.NewBuffer([]byte(stmt)) + users := []testUser{} + resp := dataResponse{ + Data: &users, + } + testRequest(testOptions{ + Testing: t, + DB: defra, + Method: "POST", + Path: GraphQLPath, + Body: buf, + Headers: map[string]string{"Content-Type": contentTypeGraphQL}, + ExpectedStatus: 200, + ResponseData: &resp, + }) + + assert.Contains(t, users[0].Key, "bae-") +} + +func TestExecGQLHandlerContentTypeText(t *testing.T) { + ctx := context.Background() + defra := testNewInMemoryDB(t, ctx) + + // load schema + testLoadSchema(t, ctx, defra) + + // add document + stmt := ` +mutation { + create_user(data: "{\"age\": 31, \"verified\": true, \"points\": 90, \"name\": \"Bob\"}") { + _key + } +}` + + buf := bytes.NewBuffer([]byte(stmt)) + users := []testUser{} + resp := dataResponse{ + Data: &users, + } + testRequest(testOptions{ + Testing: t, + DB: defra, + Method: "POST", + Path: GraphQLPath, + Body: buf, + ExpectedStatus: 200, + ResponseData: &resp, + }) + + assert.Contains(t, users[0].Key, "bae-") +} + +func TestLoadSchemaHandlerWithReadBodyError(t *testing.T) { + mockReadCloser := mockReadCloser{} + // if Read is called, it will return error + mockReadCloser.On("Read", mock.AnythingOfType("[]uint8")).Return(0, fmt.Errorf("error reading")) + + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "POST", + Path: SchemaLoadPath, + Body: &mockReadCloser, + ExpectedStatus: 400, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "error reading") + assert.Equal(t, http.StatusBadRequest, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Bad Request", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "error reading", errResponse.Errors[0].Message) + +} + +func TestLoadSchemaHandlerWithoutDB(t *testing.T) { + stmt := ` +type user { + name: String + age: Int + verified: Boolean + points: Float +}` + + buf := bytes.NewBuffer([]byte(stmt)) + + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "POST", + Path: SchemaLoadPath, + Body: buf, + ExpectedStatus: 500, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "no database available") + assert.Equal(t, http.StatusInternalServerError, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Internal Server Error", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "no database available", errResponse.Errors[0].Message) +} + +func TestLoadSchemaHandlerWithAddSchemaError(t *testing.T) { + ctx := context.Background() + defra := testNewInMemoryDB(t, ctx) + + // statement with types instead of type + stmt := ` +types user { + name: String + age: Int + verified: Boolean + points: Float +}` + + buf := bytes.NewBuffer([]byte(stmt)) + + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: defra, + Method: "POST", + Path: SchemaLoadPath, + Body: buf, + ExpectedStatus: 400, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "Syntax Error GraphQL (2:1) Unexpected Name") + assert.Equal(t, http.StatusBadRequest, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Bad Request", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal( + t, + "Syntax Error GraphQL (2:1) Unexpected Name \"types\"\n\n1: \n2: types user {\n ^\n3: \\u0009name: String \n", + errResponse.Errors[0].Message, + ) +} + +func TestLoadSchemaHandlerWitNoError(t *testing.T) { + ctx := context.Background() + defra := testNewInMemoryDB(t, ctx) + + stmt := ` +type user { + name: String + age: Int + verified: Boolean + points: Float +}` + + buf := bytes.NewBuffer([]byte(stmt)) + + resp := dataResponse{} + testRequest(testOptions{ + Testing: t, + DB: defra, + Method: "POST", + Path: SchemaLoadPath, + Body: buf, + ExpectedStatus: 400, + ResponseData: &resp, + }) + + switch v := resp.Data.(type) { + case map[string]interface{}: + assert.Equal(t, "success", v["result"]) + + default: + t.Fatalf("data should be of type map[string]interface{} but got %T\n%v", resp.Data, v) + } +} + +func TestGetBlockHandlerWithMultihashError(t *testing.T) { + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "GET", + Path: BlocksPath + "/1234", + Body: nil, + ExpectedStatus: 400, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "illegal base32 data at input byte 0") + assert.Equal(t, http.StatusBadRequest, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Bad Request", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "illegal base32 data at input byte 0", errResponse.Errors[0].Message) +} +func TestGetBlockHandlerWithDSKeyWithNoDB(t *testing.T) { + cID, err := cid.Parse("bafybeidembipteezluioakc2zyke4h5fnj4rr3uaougfyxd35u3qzefzhm") + if err != nil { + t.Fatal(err) + } + dsKey := dshelp.MultihashToDsKey(cID.Hash()) + + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "GET", + Path: BlocksPath + dsKey.String(), + Body: nil, + ExpectedStatus: 500, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "no database available") + assert.Equal(t, http.StatusInternalServerError, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Internal Server Error", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "no database available", errResponse.Errors[0].Message) +} + +func TestGetBlockHandlerWithNoDB(t *testing.T) { + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: nil, + Method: "GET", + Path: BlocksPath + "/bafybeidembipteezluioakc2zyke4h5fnj4rr3uaougfyxd35u3qzefzhm", + Body: nil, + ExpectedStatus: 500, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "no database available") + assert.Equal(t, http.StatusInternalServerError, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Internal Server Error", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "no database available", errResponse.Errors[0].Message) +} + +func TestGetBlockHandlerWithGetBlockstoreError(t *testing.T) { + ctx := context.Background() + defra := testNewInMemoryDB(t, ctx) + + errResponse := errorResponse{} + testRequest(testOptions{ + Testing: t, + DB: defra, + Method: "GET", + Path: BlocksPath + "/bafybeidembipteezluioakc2zyke4h5fnj4rr3uaougfyxd35u3qzefzhm", + Body: nil, + ExpectedStatus: 400, + ResponseData: &errResponse, + }) + + assert.Contains(t, errResponse.Errors[0].Extensions.Stack, "ipld: could not find bafybeidembipteezluioakc2zyke4h5fnj4rr3uaougfyxd35u3qzefzhm") + assert.Equal(t, http.StatusBadRequest, errResponse.Errors[0].Extensions.Status) + assert.Equal(t, "Bad Request", errResponse.Errors[0].Extensions.HTTPError) + assert.Equal(t, "ipld: could not find bafybeidembipteezluioakc2zyke4h5fnj4rr3uaougfyxd35u3qzefzhm", errResponse.Errors[0].Message) +} + +func TestGetBlockHandlerWithValidBlockstore(t *testing.T) { + ctx := context.Background() + defra := testNewInMemoryDB(t, ctx) + + testLoadSchema(t, ctx, defra) + + // add document + stmt := ` +mutation { + create_user(data: "{\"age\": 31, \"verified\": true, \"points\": 90, \"name\": \"Bob\"}") { + _key + } +}` + + buf := bytes.NewBuffer([]byte(stmt)) + + users := []testUser{} + resp := dataResponse{ + Data: &users, + } + testRequest(testOptions{ + Testing: t, + DB: defra, + Method: "POST", + Path: GraphQLPath, + Body: buf, + ExpectedStatus: 200, + ResponseData: &resp, + }) + + if !strings.Contains(users[0].Key, "bae-") { + t.Fatal("expected valid document key") + } + + // get document cid + stmt2 := ` +query { + user (dockey: "%s") { + _version { + cid + } + } +}` + buf2 := bytes.NewBuffer([]byte(fmt.Sprintf(stmt2, users[0].Key))) + + users2 := []testUser{} + resp2 := dataResponse{ + Data: &users2, + } + testRequest(testOptions{ + Testing: t, + DB: defra, + Method: "POST", + Path: GraphQLPath, + Body: buf2, + ExpectedStatus: 200, + ResponseData: &resp2, + }) + + _, err := cid.Decode(users2[0].Versions[0].CID) + if err != nil { + t.Fatal(err) + } + + resp3 := dataResponse{} + testRequest(testOptions{ + Testing: t, + DB: defra, + Method: "GET", + Path: BlocksPath + "/" + users2[0].Versions[0].CID, + Body: buf, + ExpectedStatus: 200, + ResponseData: &resp3, + }) + + switch d := resp3.Data.(type) { + case map[string]interface{}: + switch val := d["val"].(type) { + case string: + assert.Equal(t, "pGNhZ2UYH2RuYW1lY0JvYmZwb2ludHMYWmh2ZXJpZmllZPU=", val) + default: + t.Fatalf("expecting string but got %T", val) + } + default: + t.Fatalf("expecting map[string]interface{} but got %T", d) + } +} + +func testRequest(opt testOptions) { + req, err := http.NewRequest(opt.Method, opt.Path, opt.Body) + if err != nil { + opt.Testing.Fatal(err) + } + + for k, v := range opt.Headers { + req.Header.Set(k, v) + } + + h := newHandler(opt.DB, serverOptions{}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + assert.Equal(opt.Testing, opt.ExpectedStatus, rec.Result().StatusCode) + + respBody, err := io.ReadAll(rec.Result().Body) + if err != nil { + opt.Testing.Fatal(err) + } + + err = json.Unmarshal(respBody, &opt.ResponseData) + if err != nil { + opt.Testing.Fatal(err) + } +} + +func testNewInMemoryDB(t *testing.T, ctx context.Context) client.DB { + // init in memory DB + opts := badgerds.Options{Options: badger.DefaultOptions("").WithInMemory(true)} + rootstore, err := badgerds.NewDatastore("", &opts) + if err != nil { + t.Fatal(err) + } + + var options []db.Option + + defra, err := db.NewDB(ctx, rootstore, options...) + if err != nil { + t.Fatal(err) + } + return defra +} + +func testLoadSchema(t *testing.T, ctx context.Context, db client.DB) { + stmt := ` +type user { + name: String + age: Int + verified: Boolean + points: Float +}` + err := db.AddSchema(ctx, stmt) + if err != nil { + t.Fatal(err) + } +} diff --git a/api/http/logger_test.go b/api/http/logger_test.go index 29ddd78e65..3a5d09dea7 100644 --- a/api/http/logger_test.go +++ b/api/http/logger_test.go @@ -93,14 +93,14 @@ func TestLoggerKeyValueOutput(t *testing.T) { } // check that everything is as expected - assert.Equal(t, "pong", rec2.Body.String()) + assert.Equal(t, "{\"data\":{\"response\":\"pong\"}}", rec2.Body.String()) assert.Equal(t, "INFO", kv["level"]) assert.Equal(t, "defra.http", kv["logger"]) assert.Equal(t, "Request", kv["msg"]) assert.Equal(t, "GET", kv["Method"]) assert.Equal(t, "/ping", kv["Path"]) assert.Equal(t, float64(200), kv["Status"]) - assert.Equal(t, float64(4), kv["Length"]) + assert.Equal(t, float64(28), kv["Length"]) } func readLog(path string) (map[string]interface{}, error) { diff --git a/api/http/router.go b/api/http/router.go index 2d5a60c986..328c4ecf3e 100644 --- a/api/http/router.go +++ b/api/http/router.go @@ -21,12 +21,12 @@ import ( ) const ( - version string = "/api/v1" + version string = "/api/v0" RootPath string = version + "" PingPath string = version + "/ping" DumpPath string = version + "/debug/dump" - BlocksPath string = version + "/blocks/get" + BlocksPath string = version + "/blocks" GraphQLPath string = version + "/graphql" SchemaLoadPath string = version + "/schema/load" ) diff --git a/go.mod b/go.mod index e60298a505..3324131f8b 100644 --- a/go.mod +++ b/go.mod @@ -198,6 +198,7 @@ require ( github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.1.1 // indirect github.com/subosito/gotenv v1.2.0 // indirect github.com/textileio/go-log/v2 v2.1.3-gke-2 // indirect github.com/whyrusleeping/cbor-gen v0.0.0-20220514204315-f29c37e9c44c // indirect diff --git a/go.sum b/go.sum index e0a10922d2..9d4d5dfbbb 100644 --- a/go.sum +++ b/go.sum @@ -1604,6 +1604,7 @@ github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3 github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=