diff --git a/README.md b/README.md index 971704c6e7..172287e786 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/graphql` and the `Method` to `GET`. +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`. ### Add a Schema type @@ -391,6 +391,7 @@ When contributing to a DefraDB feature, you can find the relevant license in the - Andrew Sisley ([@AndrewSisley](https://github.com/AndrewSisley)) - Shahzad Lone ([@shahzadlone](https://github.com/shahzadlone)) - Orpheus Lummis ([@orpheuslummis](https://github.com/orpheuslummis)) +- Fred Carle ([@fredcarle](https://github.com/fredcarle))
diff --git a/api/http/api.go b/api/http/api.go deleted file mode 100644 index e2f4db8f0c..0000000000 --- a/api/http/api.go +++ /dev/null @@ -1,289 +0,0 @@ -// 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 ( - "context" - "encoding/json" - "io" - "net/http" - - "github.com/multiformats/go-multihash" - "github.com/sourcenetwork/defradb/client" - corecrdt "github.com/sourcenetwork/defradb/core/crdt" - - "github.com/go-chi/chi" - "github.com/go-chi/chi/middleware" - "github.com/ipfs/go-cid" - ds "github.com/ipfs/go-datastore" - dshelp "github.com/ipfs/go-ipfs-ds-help" - dag "github.com/ipfs/go-merkledag" - "github.com/sourcenetwork/defradb/logging" -) - -var ( - log = logging.MustNewLogger("defra.http") -) - -type Server struct { - db client.DB - router *chi.Mux -} - -func NewServer(db client.DB) *Server { - s := &Server{ - db: db, - } - r := chi.NewRouter() - // todo - we should log via our own log, not middleware.logger - r.Use(middleware.Logger) - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - _, err := w.Write( - []byte("Welcome to the DefraDB HTTP API. Use /graphql to send queries to the database"), - ) - if err != nil { - log.ErrorE(context.Background(), "DefraDB HTTP API Welcome message writing failed", err) - } - }) - - r.Get("/ping", s.ping) - r.Get("/dump", s.dump) - r.Get("/blocks/get/{cid}", s.getBlock) - r.Get("/graphql", s.execGQL) - r.Post("/schema/load", s.loadSchema) - s.router = r - return s -} - -func (s *Server) Listen(addr string) error { - ctx := context.Background() - if err := http.ListenAndServe(addr, s.router); err != nil { - log.FatalE(ctx, "Error: HTTP Listening and Serving Failed", err) - return err - } - return nil -} - -func (s *Server) ping(w http.ResponseWriter, r *http.Request) { - ctx := context.Background() - _, err := w.Write([]byte("pong")) - if err != nil { - log.ErrorE(ctx, "Writing pong with HTTP failed", err) - } -} - -func (s *Server) dump(w http.ResponseWriter, r *http.Request) { - ctx := context.Background() - s.db.PrintDump(ctx) - - _, err := w.Write([]byte("ok")) - if err != nil { - log.ErrorE(ctx, "Writing ok with HTTP failed", err) - } -} - -func (s *Server) execGQL(w http.ResponseWriter, r *http.Request) { - ctx := context.Background() - query := r.URL.Query().Get("query") - result := s.db.ExecQuery(ctx, query) - - err := json.NewEncoder(w).Encode(result) - if err != nil { - http.Error(w, err.Error(), 500) - return - } -} - -func (s *Server) loadSchema(w http.ResponseWriter, r *http.Request) { - ctx := context.Background() - var result client.QueryResult - sdl, err := io.ReadAll(r.Body) - - defer func() { - err = r.Body.Close() - if err != nil { - log.ErrorE(ctx, "Error on body close", err) - } - }() - - if err != nil { - result.Errors = []interface{}{err.Error()} - - err = json.NewEncoder(w).Encode(result) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - - w.WriteHeader(http.StatusBadRequest) - return - } - - err = s.db.AddSchema(ctx, string(sdl)) - if err != nil { - result.Errors = []interface{}{err.Error()} - - err = json.NewEncoder(w).Encode(result) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - - w.WriteHeader(http.StatusBadRequest) - return - } - - result.Data = map[string]string{ - "result": "success", - } - - err = json.NewEncoder(w).Encode(result) - if err != nil { - http.Error(w, err.Error(), 500) - return - } -} - -func (s *Server) getBlock(w http.ResponseWriter, r *http.Request) { - ctx := context.Background() - var result client.QueryResult - cidStr := chi.URLParam(r, "cid") - - // try to parse CID - c, err := cid.Decode(cidStr) - if err != nil { - // If we can't try to parse DSKeyToCID - // return error if we still can't - key := ds.NewKey(cidStr) - var hash multihash.Multihash - hash, err = dshelp.DsKeyToMultihash(key) - if err != nil { - result.Errors = []interface{}{err.Error()} - result.Data = err.Error() - - err = json.NewEncoder(w).Encode(result) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - - w.WriteHeader(http.StatusBadRequest) - return - } - c = cid.NewCidV1(cid.Raw, hash) - } - - block, err := s.db.Blockstore().Get(ctx, c) - if err != nil { - result.Errors = []interface{}{err.Error()} - - err = json.NewEncoder(w).Encode(result) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - - w.WriteHeader(http.StatusBadRequest) - return - } - - nd, err := dag.DecodeProtobuf(block.RawData()) - if err != nil { - result.Errors = []interface{}{err.Error()} - result.Data = err.Error() - - err = json.NewEncoder(w).Encode(result) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - - w.WriteHeader(http.StatusBadRequest) - return - } - buf, err := nd.MarshalJSON() - if err != nil { - result.Errors = []interface{}{err.Error()} - - err = json.NewEncoder(w).Encode(result) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - - w.WriteHeader(http.StatusBadRequest) - return - } - - reg := corecrdt.LWWRegister{} - delta, err := reg.DeltaDecode(nd) - if err != nil { - result.Errors = []interface{}{err.Error()} - - err = json.NewEncoder(w).Encode(result) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - - w.WriteHeader(http.StatusBadRequest) - return - } - - data, err := delta.Marshal() - if err != nil { - result.Errors = []interface{}{err.Error()} - - err = json.NewEncoder(w).Encode(result) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - - w.WriteHeader(http.StatusBadRequest) - return - } - - // var val interface{} - // err = cbor.Unmarshal(delta.Value().([]byte), &val) - // if err != nil { - // result.Errors = []interface{}{err.Error()} - // err = json.NewEncoder(w).Encode(result) - // if err != nil { - // http.Error(w, err.Error(), 500) - // return - // } - // w.WriteHeader(http.StatusBadRequest) - // return - // } - result.Data = map[string]interface{}{ - "block": string(buf), - "delta": string(data), - "val": delta.Value(), - } - - enc := json.NewEncoder(w) - enc.SetIndent("", "\t") - err = enc.Encode(result) - if err != nil { - result.Errors = []interface{}{err.Error()} - result.Data = nil - - err := json.NewEncoder(w).Encode(result) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - - w.WriteHeader(http.StatusBadRequest) - return - } -} diff --git a/api/http/errors.go b/api/http/errors.go new file mode 100644 index 0000000000..2d440890a8 --- /dev/null +++ b/api/http/errors.go @@ -0,0 +1,51 @@ +// 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 ( + "context" + "fmt" + "net/http" + "os" + "strings" +) + +var env = os.Getenv("DEFRA_ENV") + +type errorResponse struct { + Status int `json:"status"` + Message string `json:"message"` + Stack string `json:"stack,omitempty"` +} + +func handleErr(ctx context.Context, rw http.ResponseWriter, err error, status int) { + if status == http.StatusInternalServerError { + log.ErrorE(ctx, http.StatusText(status), err) + } + + sendJSON( + ctx, + rw, + errorResponse{ + Status: status, + Message: http.StatusText(status), + Stack: formatError(err), + }, + status, + ) +} + +func formatError(err error) string { + if strings.ToLower(env) == "dev" || strings.ToLower(env) == "development" { + return fmt.Sprintf("[DEV] %+v\n", err) + } + return "" +} diff --git a/api/http/errors_test.go b/api/http/errors_test.go new file mode 100644 index 0000000000..f5e6f78bef --- /dev/null +++ b/api/http/errors_test.go @@ -0,0 +1,149 @@ +// 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 ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +func TestFormatError(t *testing.T) { + env = "prod" + s := formatError(errors.New("test error")) + assert.Equal(t, "", s) + + env = "dev" + s = formatError(errors.New("test error")) + lines := strings.Split(s, "\n") + assert.Equal(t, "[DEV] test error", lines[0]) +} + +func TestHandleErrOnBadRequest(t *testing.T) { + env = "dev" + f := func(rw http.ResponseWriter, req *http.Request) { + handleErr(req.Context(), rw, errors.New("test error"), http.StatusBadRequest) + } + req, err := http.NewRequest("GET", "/test", nil) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + + f(rec, req) + + resp := rec.Result() + + errResponse := errorResponse{} + err = json.NewDecoder(resp.Body).Decode(&errResponse) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusBadRequest, errResponse.Status) + assert.Equal(t, http.StatusText(http.StatusBadRequest), errResponse.Message) + + lines := strings.Split(errResponse.Stack, "\n") + assert.Equal(t, "[DEV] test error", lines[0]) +} + +func TestHandleErrOnInternalServerError(t *testing.T) { + env = "dev" + f := func(rw http.ResponseWriter, req *http.Request) { + handleErr(req.Context(), rw, errors.New("test error"), http.StatusInternalServerError) + } + req, err := http.NewRequest("GET", "/test", nil) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + + f(rec, req) + + resp := rec.Result() + + errResponse := errorResponse{} + err = json.NewDecoder(resp.Body).Decode(&errResponse) + if err != nil { + 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]) +} + +func TestHandleErrOnNotFound(t *testing.T) { + env = "dev" + f := func(rw http.ResponseWriter, req *http.Request) { + handleErr(req.Context(), rw, errors.New("test error"), http.StatusNotFound) + } + req, err := http.NewRequest("GET", "/test", nil) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + + f(rec, req) + + resp := rec.Result() + + errResponse := errorResponse{} + err = json.NewDecoder(resp.Body).Decode(&errResponse) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusNotFound, errResponse.Status) + assert.Equal(t, http.StatusText(http.StatusNotFound), errResponse.Message) + + lines := strings.Split(errResponse.Stack, "\n") + assert.Equal(t, "[DEV] test error", lines[0]) +} + +func TestHandleErrOnDefault(t *testing.T) { + env = "dev" + f := func(rw http.ResponseWriter, req *http.Request) { + handleErr(req.Context(), rw, errors.New("Unauthorized"), http.StatusUnauthorized) + } + req, err := http.NewRequest("GET", "/test", nil) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + + f(rec, req) + + resp := rec.Result() + + errResponse := errorResponse{} + err = json.NewDecoder(resp.Body).Decode(&errResponse) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusUnauthorized, errResponse.Status) + assert.Equal(t, "Unauthorized", errResponse.Message) + + lines := strings.Split(errResponse.Stack, "\n") + assert.Equal(t, "[DEV] Unauthorized", lines[0]) +} diff --git a/api/http/handler.go b/api/http/handler.go new file mode 100644 index 0000000000..23cdda6d52 --- /dev/null +++ b/api/http/handler.go @@ -0,0 +1,79 @@ +// 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 ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/pkg/errors" + "github.com/sourcenetwork/defradb/client" +) + +type handler struct { + db client.DB + *chi.Mux +} + +type ctxDB struct{} + +// newHandler returns a handler with the router instantiated. +func newHandler(db client.DB) *handler { + return setRoutes(&handler{db: db}) +} + +func (h *handler) handle(f http.HandlerFunc) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + ctx := context.WithValue(req.Context(), ctxDB{}, h.db) + f(rw, req.WithContext(ctx)) + } +} + +func getJSON(req *http.Request, v interface{}) error { + err := json.NewDecoder(req.Body).Decode(v) + if err != nil { + return errors.Wrap(err, "unmarshall error") + } + return nil +} + +func sendJSON(ctx context.Context, rw http.ResponseWriter, v interface{}, code int) { + rw.Header().Set("Content-Type", "application/json") + + b, err := json.Marshal(v) + if err != nil { + log.Error(ctx, fmt.Sprintf("Error while encoding JSON: %v", err)) + rw.WriteHeader(http.StatusInternalServerError) + if _, err := io.WriteString(rw, `{"error": "Internal server error"}`); err != nil { + log.Error(ctx, err.Error()) + } + return + } + + rw.WriteHeader(code) + if _, err = rw.Write(b); err != nil { + rw.WriteHeader(http.StatusInternalServerError) + log.Error(ctx, err.Error()) + } +} + +func dbFromContext(ctx context.Context) (client.DB, error) { + db, ok := ctx.Value(ctxDB{}).(client.DB) + if !ok { + return nil, errors.New("no database available") + } + + return db, nil +} diff --git a/api/http/handler_test.go b/api/http/handler_test.go new file mode 100644 index 0000000000..04d3a681e2 --- /dev/null +++ b/api/http/handler_test.go @@ -0,0 +1,190 @@ +// 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" + "io/ioutil" + "math" + "net/http" + "net/http/httptest" + "path" + "testing" + + badger "github.com/dgraph-io/badger/v3" + "github.com/pkg/errors" + badgerds "github.com/sourcenetwork/defradb/datastore/badger/v3" + "github.com/sourcenetwork/defradb/db" + "github.com/sourcenetwork/defradb/logging" + "github.com/stretchr/testify/assert" +) + +func TestNewHandlerWithLogger(t *testing.T) { + h := newHandler(nil) + + dir := t.TempDir() + + // send logs to temp file so we can inspect it + logFile := path.Join(dir, "http_test.log") + log.ApplyConfig(logging.Config{ + EncoderFormat: logging.NewEncoderFormatOption(logging.JSON), + OutputPaths: []string{logFile}, + }) + + req, err := http.NewRequest("GET", "/ping", nil) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + + loggerMiddleware(h.handle(pingHandler)).ServeHTTP(rec, req) + assert.Equal(t, 200, rec.Result().StatusCode) + + // inspect the log file + kv, err := readLog(logFile) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "defra.http", kv["logger"]) + +} + +func TestGetJSON(t *testing.T) { + var obj struct { + Name string + } + + jsonStr := []byte(` + { + "Name": "John Doe" + } + `) + + req, err := http.NewRequest("POST", "/ping", bytes.NewBuffer(jsonStr)) + if err != nil { + t.Fatal(err) + } + + err = getJSON(req, &obj) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "John Doe", obj.Name) + +} + +func TestGetJSONWithError(t *testing.T) { + var obj struct { + Name string + } + + jsonStr := []byte(` + { + "Name": 10 + } + `) + + req, err := http.NewRequest("POST", "/ping", bytes.NewBuffer(jsonStr)) + if err != nil { + t.Fatal(err) + } + + err = getJSON(req, &obj) + assert.Error(t, err) +} + +func TestSendJSONWithNoErrors(t *testing.T) { + obj := struct { + Name string + }{ + Name: "John Doe", + } + + rec := httptest.NewRecorder() + + sendJSON(context.Background(), rec, obj, 200) + + body, err := ioutil.ReadAll(rec.Result().Body) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, []byte("{\"Name\":\"John Doe\"}"), body) +} + +func TestSendJSONWithMarshallFailure(t *testing.T) { + rec := httptest.NewRecorder() + + sendJSON(context.Background(), rec, math.Inf(1), 200) + + assert.Equal(t, http.StatusInternalServerError, rec.Result().StatusCode) +} + +type loggerTest struct { + loggingResponseWriter +} + +func (lt *loggerTest) Write(b []byte) (int, error) { + return 0, errors.New("this write will fail") +} + +func TestSendJSONWithMarshallFailureAndWriteFailer(t *testing.T) { + rec := httptest.NewRecorder() + lrw := loggerTest{} + lrw.ResponseWriter = rec + + sendJSON(context.Background(), &lrw, math.Inf(1), 200) + + assert.Equal(t, http.StatusInternalServerError, rec.Result().StatusCode) +} + +func TestSendJSONWithWriteFailure(t *testing.T) { + obj := struct { + Name string + }{ + Name: "John Doe", + } + + rec := httptest.NewRecorder() + lrw := loggerTest{} + lrw.ResponseWriter = rec + + sendJSON(context.Background(), &lrw, obj, 200) + + assert.Equal(t, http.StatusInternalServerError, lrw.statusCode) +} + +func TestDbFromContext(t *testing.T) { + _, err := dbFromContext(context.Background()) + assert.Error(t, err) + + opts := badgerds.Options{Options: badger.DefaultOptions("").WithInMemory(true)} + rootstore, err := badgerds.NewDatastore("", &opts) + if err != nil { + t.Fatal(err) + } + + var options []db.Option + ctx := context.Background() + + defra, err := db.NewDB(ctx, rootstore, options...) + if err != nil { + t.Fatal(err) + } + + reqCtx := context.WithValue(ctx, ctxDB{}, defra) + + _, err = dbFromContext(reqCtx) + assert.NoError(t, err) +} diff --git a/api/http/handlerfuncs.go b/api/http/handlerfuncs.go new file mode 100644 index 0000000000..5b5c9109c8 --- /dev/null +++ b/api/http/handlerfuncs.go @@ -0,0 +1,334 @@ +// 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 ( + "encoding/json" + "io" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/ipfs/go-cid" + ds "github.com/ipfs/go-datastore" + dshelp "github.com/ipfs/go-ipfs-ds-help" + 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" +) + +const ( + contentTypeJSON = "application/json" + contentTypeGraphQL = "application/graphql" + contentTypeFormURLEncoded = "application/x-www-form-urlencoded" +) + +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"), + ) + 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, + ) + } +} + +func dumpHandler(rw http.ResponseWriter, req *http.Request) { + db, err := dbFromContext(req.Context()) + if err != nil { + handleErr(req.Context(), rw, err, http.StatusInternalServerError) + return + } + 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, + ) + } +} + +type gqlRequest struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables"` + OperationName string `json:"operationName"` +} + +func execGQLHandler(rw http.ResponseWriter, req *http.Request) { + + query := req.URL.Query().Get("query") + + if query == "" { + switch req.Header.Get("Content-Type") { + case contentTypeJSON: + gqlReq := gqlRequest{} + + err := getJSON(req, &gqlReq) + if err != nil { + handleErr(req.Context(), rw, err, http.StatusBadRequest) + return + } + + query = gqlReq.Query + + case contentTypeFormURLEncoded: + handleErr( + req.Context(), + rw, + errors.New("content type application/x-www-form-urlencoded not yet supported"), + http.StatusBadRequest, + ) + return + + case contentTypeGraphQL: + fallthrough + + default: + body, err := io.ReadAll(req.Body) + if err != nil { + handleErr(req.Context(), rw, errors.WithStack(err), http.StatusBadRequest) + return + } + query = string(body) + } + } + + // if at this point query is still empty, return an error + if query == "" { + handleErr(req.Context(), rw, errors.New("missing GraphQL query"), http.StatusBadRequest) + return + } + + db, err := dbFromContext(req.Context()) + if err != nil { + handleErr(req.Context(), rw, err, http.StatusInternalServerError) + return + } + 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 + } +} + +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) + return + } + + db, err := dbFromContext(req.Context()) + if err != nil { + 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) + 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 + } +} + +func getBlockHandler(rw http.ResponseWriter, req *http.Request) { + var result client.QueryResult + cidStr := chi.URLParam(req, "cid") + + // try to parse CID + cID, err := cid.Decode(cidStr) + if err != nil { + // If we can't try to parse DSKeyToCID + // return error if we still can't + key := ds.NewKey(cidStr) + 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) + return + } + cID = cid.NewCidV1(cid.Raw, hash) + } + + db, err := dbFromContext(req.Context()) + if err != nil { + 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) + 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) + 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) + 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) + 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) + 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 + } +} diff --git a/api/http/http.go b/api/http/http.go new file mode 100644 index 0000000000..cea5da641a --- /dev/null +++ b/api/http/http.go @@ -0,0 +1,15 @@ +// 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 "github.com/sourcenetwork/defradb/logging" + +var log = logging.MustNewLogger("defra.http") diff --git a/api/http/logger.go b/api/http/logger.go new file mode 100644 index 0000000000..29fdb6ddd3 --- /dev/null +++ b/api/http/logger.go @@ -0,0 +1,84 @@ +// 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 ( + "net/http" + "strconv" + "time" + + "github.com/sourcenetwork/defradb/logging" +) + +type loggingResponseWriter struct { + statusCode int + contentLength int + + http.ResponseWriter +} + +func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter { + return &loggingResponseWriter{ + statusCode: http.StatusOK, + contentLength: 0, + ResponseWriter: w, + } +} + +func (lrw *loggingResponseWriter) WriteHeader(code int) { + lrw.statusCode = code + lrw.ResponseWriter.WriteHeader(code) +} + +func (lrw *loggingResponseWriter) Write(b []byte) (int, error) { + // used for chucked payloads. Content-Length should not be set + // for each chunk. + if lrw.ResponseWriter.Header().Get("Content-Length") != "" { + return lrw.ResponseWriter.Write(b) + } + + lrw.contentLength = len(b) + lrw.ResponseWriter.Header().Set("Content-Length", strconv.Itoa(lrw.contentLength)) + return lrw.ResponseWriter.Write(b) +} + +func loggerMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + start := time.Now() + lrw := newLoggingResponseWriter(rw) + next.ServeHTTP(lrw, req) + elapsed := time.Since(start) + log.Info( + req.Context(), + "Request", + logging.NewKV( + "Method", + req.Method, + ), + logging.NewKV( + "Path", + req.URL.Path, + ), + logging.NewKV( + "Status", + lrw.statusCode, + ), + logging.NewKV( + "Length", + lrw.contentLength, + ), + logging.NewKV( + "Elapsed", + elapsed, + ), + ) + }) +} diff --git a/api/http/logger_test.go b/api/http/logger_test.go new file mode 100644 index 0000000000..32e92eb349 --- /dev/null +++ b/api/http/logger_test.go @@ -0,0 +1,124 @@ +// 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 ( + "bufio" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path" + "strconv" + "testing" + + "github.com/pkg/errors" + "github.com/sourcenetwork/defradb/logging" + "github.com/stretchr/testify/assert" +) + +func TestNewLoggingResponseWriterLogger(t *testing.T) { + rec := httptest.NewRecorder() + lrw := newLoggingResponseWriter(rec) + + lrw.WriteHeader(400) + assert.Equal(t, 400, lrw.statusCode) + + content := "Hello world!" + + length, err := lrw.Write([]byte(content)) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, length, lrw.contentLength) + assert.Equal(t, rec.Body.String(), content) +} + +func TestLogginResponseWriterWriteWithChunks(t *testing.T) { + rec := httptest.NewRecorder() + lrw := newLoggingResponseWriter(rec) + + content := "Hello world!" + contentLength := len(content) + + lrw.Header().Set("Content-Length", strconv.Itoa(contentLength)) + + length1, err := lrw.Write([]byte(content[:contentLength/2])) + if err != nil { + t.Fatal(err) + } + + length2, err := lrw.Write([]byte(content[contentLength/2:])) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, contentLength, length1+length2) + assert.Equal(t, rec.Body.String(), content) +} + +func TestLoggerKeyValueOutput(t *testing.T) { + dir := t.TempDir() + + // send logs to temp file so we can inspect it + logFile := path.Join(dir, "http_test.log") + + req, err := http.NewRequest("GET", "/ping", nil) + if err != nil { + t.Fatal(err) + } + + rec2 := httptest.NewRecorder() + + h := newHandler(nil) + log.ApplyConfig(logging.Config{ + EncoderFormat: logging.NewEncoderFormatOption(logging.JSON), + OutputPaths: []string{logFile}, + }) + loggerMiddleware(h.handle(pingHandler)).ServeHTTP(rec2, req) + assert.Equal(t, 200, rec2.Result().StatusCode) + + // inspect the log file + kv, err := readLog(logFile) + if err != nil { + t.Fatal(err) + } + + // check that everything is as expected + assert.Equal(t, "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"]) +} + +func readLog(path string) (map[string]interface{}, error) { + // inspect the log file + f, err := os.Open(path) + if err != nil { + return nil, errors.WithStack(err) + } + + scanner := bufio.NewScanner(f) + scanner.Scan() + logLine := scanner.Text() + + kv := make(map[string]interface{}) + err = json.Unmarshal([]byte(logLine), &kv) + if err != nil { + return nil, errors.WithStack(err) + } + + return kv, nil +} diff --git a/api/http/router.go b/api/http/router.go new file mode 100644 index 0000000000..48cea3dab2 --- /dev/null +++ b/api/http/router.go @@ -0,0 +1,69 @@ +// 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 ( + "net/url" + "path" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/pkg/errors" +) + +const ( + version string = "/api/v1" + + RootPath string = version + "" + PingPath string = version + "/ping" + DumpPath string = version + "/debug/dump" + BlocksPath string = version + "/blocks/get" + GraphQLPath string = version + "/graphql" + SchemaLoadPath string = version + "/schema/load" +) + +var schemeError = errors.New("base must start with the http or https scheme") + +func setRoutes(h *handler) *handler { + h.Mux = chi.NewRouter() + + // setup logger middleware + h.Use(loggerMiddleware) + + // define routes + h.Get(RootPath, h.handle(rootHandler)) + h.Get(PingPath, h.handle(pingHandler)) + h.Get(DumpPath, h.handle(dumpHandler)) + h.Get(BlocksPath+"/{cid}", h.handle(getBlockHandler)) + h.Get(GraphQLPath, h.handle(execGQLHandler)) + h.Post(GraphQLPath, h.handle(execGQLHandler)) + h.Post(SchemaLoadPath, h.handle(loadSchemaHandler)) + + return h +} + +// JoinPaths takes a base path and any number of additionnal paths +// and combines them safely to form a full URL path. +// The base must start with a http or https. +func JoinPaths(base string, paths ...string) (*url.URL, error) { + if !strings.HasPrefix(base, "http") { + return nil, schemeError + } + + u, err := url.Parse(base) + if err != nil { + return nil, errors.WithStack(err) + } + + u.Path = path.Join(u.Path, strings.Join(paths, "/")) + + return u, nil +} diff --git a/api/http/router_test.go b/api/http/router_test.go new file mode 100644 index 0000000000..cb4bee3610 --- /dev/null +++ b/api/http/router_test.go @@ -0,0 +1,51 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestJoinPathsWithBase(t *testing.T) { + path, err := JoinPaths("http://localhost:9181", BlocksPath, "cid_of_some_sort") + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "http://localhost:9181"+BlocksPath+"/cid_of_some_sort", path.String()) +} + +func TestJoinPathsWithNoBase(t *testing.T) { + _, err := JoinPaths("", BlocksPath, "cid_of_some_sort") + assert.ErrorIs(t, schemeError, err) +} + +func TestJoinPathsWithBaseWithoutHttpPrefix(t *testing.T) { + _, err := JoinPaths("localhost:9181", BlocksPath, "cid_of_some_sort") + assert.ErrorIs(t, schemeError, err) + +} + +func TestJoinPathsWithNoPaths(t *testing.T) { + path, err := JoinPaths("http://localhost:9181") + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "http://localhost:9181", path.String()) +} + +func TestJoinPathsWithInvalidCharacter(t *testing.T) { + _, err := JoinPaths("https://%gh&%ij") + assert.Error(t, err) +} diff --git a/api/http/server.go b/api/http/server.go new file mode 100644 index 0000000000..f75fd28114 --- /dev/null +++ b/api/http/server.go @@ -0,0 +1,37 @@ +// 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 ( + "net/http" + + "github.com/sourcenetwork/defradb/client" +) + +// The Server struct holds the Handler for the HTTP API +type Server struct { + http.Server +} + +// NewServer instantiated a new server with the given http.Handler. +func NewServer(db client.DB) *Server { + return &Server{ + http.Server{ + Handler: newHandler(db), + }, + } +} + +// Listen calls ListenAndServe with our router. +func (s *Server) Listen(addr string) error { + s.Addr = addr + return s.ListenAndServe() +} diff --git a/api/http/server_test.go b/api/http/server_test.go new file mode 100644 index 0000000000..d8c1c5e4fc --- /dev/null +++ b/api/http/server_test.go @@ -0,0 +1,41 @@ +// 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 ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewServerAndListen(t *testing.T) { + s := NewServer(nil) + if ok := assert.NotNil(t, s); ok { + assert.Error(t, s.Listen(":303000")) + } + + serverRunning := make(chan struct{}) + serverDone := make(chan struct{}) + go func() { + close(serverRunning) + err := s.Listen(":3131") + assert.ErrorIs(t, http.ErrServerClosed, err) + defer close(serverDone) + }() + + <-serverRunning + + s.Shutdown(context.Background()) + + <-serverDone +} diff --git a/cli/defradb/cmd/blocks_get.go b/cli/defradb/cmd/blocks_get.go index a76e5e437f..2815c403fb 100644 --- a/cli/defradb/cmd/blocks_get.go +++ b/cli/defradb/cmd/blocks_get.go @@ -12,11 +12,11 @@ package cmd import ( "context" - "fmt" "io" "net/http" "strings" + httpapi "github.com/sourcenetwork/defradb/api/http" "github.com/sourcenetwork/defradb/logging" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -43,7 +43,13 @@ var getCmd = &cobra.Command{ } cid := args[0] - res, err := http.Get(fmt.Sprintf("%s/blocks/get/%s", dbaddr, cid)) + endpoint, err := httpapi.JoinPaths(dbaddr, httpapi.BlocksPath, cid) + if err != nil { + log.ErrorE(ctx, "join paths failed", err) + return + } + + res, err := http.Get(endpoint.String()) if err != nil { log.ErrorE(ctx, "request failed", err) return diff --git a/cli/defradb/cmd/dump.go b/cli/defradb/cmd/dump.go index 9b7098b638..8d27420e1d 100644 --- a/cli/defradb/cmd/dump.go +++ b/cli/defradb/cmd/dump.go @@ -17,6 +17,7 @@ import ( "net/http" "strings" + httpapi "github.com/sourcenetwork/defradb/api/http" "github.com/sourcenetwork/defradb/logging" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -38,7 +39,13 @@ var dumpCmd = &cobra.Command{ dbaddr = "http://" + dbaddr } - res, err := http.Get(fmt.Sprintf("%s/dump", dbaddr)) + endpoint, err := httpapi.JoinPaths(dbaddr, httpapi.DumpPath) + if err != nil { + log.ErrorE(ctx, "join paths failed", err) + return + } + + res, err := http.Get(endpoint.String()) if err != nil { log.ErrorE(ctx, "request failed", err) return diff --git a/cli/defradb/cmd/ping.go b/cli/defradb/cmd/ping.go index 7e62cde35d..4a6fa1c659 100644 --- a/cli/defradb/cmd/ping.go +++ b/cli/defradb/cmd/ping.go @@ -17,6 +17,7 @@ import ( "net/http" "strings" + httpapi "github.com/sourcenetwork/defradb/api/http" "github.com/sourcenetwork/defradb/logging" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -39,7 +40,14 @@ var pingCmd = &cobra.Command{ } log.Info(ctx, "Sending ping...") - res, err := http.Get(fmt.Sprintf("%s/ping", dbaddr)) + + endpoint, err := httpapi.JoinPaths(dbaddr, httpapi.PingPath) + if err != nil { + log.ErrorE(ctx, "join paths failed", err) + return + } + + res, err := http.Get(endpoint.String()) if err != nil { log.ErrorE(ctx, "request failed", err) return diff --git a/cli/defradb/cmd/query.go b/cli/defradb/cmd/query.go index 265b62a067..e30d14db41 100644 --- a/cli/defradb/cmd/query.go +++ b/cli/defradb/cmd/query.go @@ -12,12 +12,12 @@ package cmd import ( "context" - "fmt" "io" "net/http" "net/url" "strings" + httpapi "github.com/sourcenetwork/defradb/api/http" "github.com/sourcenetwork/defradb/logging" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -60,10 +60,11 @@ the additional documentation found at: https://hackmd.io/@source/BksQY6Qfw. log.Error(ctx, "missing query") return } - endpointStr := fmt.Sprintf("%s/graphql", dbaddr) - endpoint, err := url.Parse(endpointStr) + + endpoint, err := httpapi.JoinPaths(dbaddr, httpapi.GraphQLPath) if err != nil { - log.FatalE(ctx, "", err) + log.ErrorE(ctx, "join paths failed", err) + return } p := url.Values{} diff --git a/cli/defradb/cmd/schema_add.go b/cli/defradb/cmd/schema_add.go index 10f94ed5ca..ef1a348478 100644 --- a/cli/defradb/cmd/schema_add.go +++ b/cli/defradb/cmd/schema_add.go @@ -13,13 +13,12 @@ package cmd import ( "bytes" "context" - "fmt" "io" "net/http" - "net/url" "os" "strings" + httpapi "github.com/sourcenetwork/defradb/api/http" "github.com/sourcenetwork/defradb/logging" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -57,9 +56,11 @@ var addCmd = &cobra.Command{ if !strings.HasPrefix(dbaddr, "http") { dbaddr = "http://" + dbaddr } - endpointStr := fmt.Sprintf("%s/schema/load", dbaddr) - endpoint, err := url.Parse(endpointStr) - cobra.CheckErr(err) + endpoint, err := httpapi.JoinPaths(dbaddr, httpapi.SchemaLoadPath) + if err != nil { + log.ErrorE(ctx, "join paths failed", err) + return + } res, err := http.Post(endpoint.String(), "text", bytes.NewBuffer(schema)) cobra.CheckErr(err) diff --git a/cli/defradb/cmd/start.go b/cli/defradb/cmd/start.go index c161d06f64..6feb20a5f0 100644 --- a/cli/defradb/cmd/start.go +++ b/cli/defradb/cmd/start.go @@ -21,6 +21,7 @@ import ( "time" ma "github.com/multiformats/go-multiaddr" + httpapi "github.com/sourcenetwork/defradb/api/http" badgerds "github.com/sourcenetwork/defradb/datastore/badger/v3" "github.com/sourcenetwork/defradb/db" netapi "github.com/sourcenetwork/defradb/net/api" @@ -32,7 +33,7 @@ import ( badger "github.com/dgraph-io/badger/v3" ds "github.com/ipfs/go-datastore" - api "github.com/sourcenetwork/defradb/api/http" + "github.com/sourcenetwork/defradb/api/http" "github.com/sourcenetwork/defradb/logging" "github.com/spf13/cobra" "github.com/textileio/go-threads/broadcast" @@ -173,12 +174,14 @@ var startCmd = &cobra.Command{ log.Info( ctx, fmt.Sprintf( - "Providing HTTP API at http://%s. Use the GraphQL query endpoint at http://%s/graphql ", + "Providing HTTP API at http://%s%s. Use the GraphQL query endpoint at http://%s%s/graphql ", config.Database.Address, + httpapi.RootPath, config.Database.Address, + httpapi.RootPath, ), ) - s := api.NewServer(db) + s := http.NewServer(db) if err := s.Listen(config.Database.Address); err != nil { log.ErrorE(ctx, "Failed to start HTTP API listener", err) if n != nil { diff --git a/go.mod b/go.mod index 9b829a11eb..1b7a3ea779 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/dgraph-io/badger/v3 v3.2103.2 github.com/fxamacker/cbor/v2 v2.2.0 - github.com/go-chi/chi v1.5.2 github.com/gogo/protobuf v1.3.2 github.com/google/uuid v1.3.0 // indirect github.com/graphql-go/graphql v0.7.9 @@ -50,6 +49,7 @@ require ( require ( github.com/fatih/color v1.13.0 + github.com/go-chi/chi/v5 v5.0.7 github.com/pkg/errors v0.9.1 ) diff --git a/go.sum b/go.sum index 8e3448c27a..36d9bcf143 100644 --- a/go.sum +++ b/go.sum @@ -248,8 +248,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= -github.com/go-chi/chi v1.5.2 h1:YcLIBANL4OTaAOcTdp//sskGa0yGACQMCtbnr7YEn0Q= -github.com/go-chi/chi v1.5.2/go.mod h1:REp24E+25iKvxgeTfHmdUoL5x15kBiDBlnIl5bCwe2k= +github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= +github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-delve/delve v1.5.0/go.mod h1:c6b3a1Gry6x8a4LGCe/CWzrocrfaHvkUxCj3k4bvSUQ= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=