diff --git a/README.rst b/README.rst index 43d4890..49c0219 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,10 @@ Welcome to *Kapow!* =================== +.. image:: https://goreportcard.com/badge/github.com/bbva/kapow + :target: https://goreportcard.com/report/github.com/bbva/kapow + + With *Kapow!* you can publish simple **shell scripts** as **HTTP services** easily. *Kapow!* with an example @@ -38,5 +42,3 @@ This is the only line you'll need: Mention license and contributing - - diff --git a/doc/README.md b/doc/README.md deleted file mode 100644 index acc8529..0000000 --- a/doc/README.md +++ /dev/null @@ -1,226 +0,0 @@ -# Installing Kapow! - -Kapow! has a reference implementation in Go that is under active development right -now. If you want to start using Kapow! you can: -* Download a binary (linux, at this moment) from our -[releases](https://github.com/BBVA/kapow/releases) section -* Install the package with the `get` command (you need the Go runtime installed -and [configured](https://golang.org/cmd/go/)) -```sh -go get -u github.com/BBVA/kapow -``` - - -# Examples - -Below are some examples on how to define and invoke routes in Kapow! - -As you will see `kapow` binary is both a server and a CLI that you can use to configure -a running server. The server exposes an [API](/spec#http-control-api) that you -can use directly if you want. - -In order to get information from the request that fired the script execution and -to help you compose the response, the server exposes -some [resources](/spec#handlers) to interact with from the script. - - -## The mandatory Hello World (for WWW fans) - -First, you create a pow file named `greet.pow` with the following contents: -```sh -kapow route add /greet -c 'name=$(kapow get /request/params/name); echo Hello ${name:-World} | kapow set /response/body' -``` - -note that you have to escape it as the command will run on a shell itself. Then, you -execute: -```sh -kapow server greet.pow -``` - -to start a Kapow! server exposing your service. Now you can check that it works -as intended with good ole’ `curl`: -```sh -curl localhost:8080/greet -Hello World - -curl localhost:8080/greet?name=friend -Hello friend -``` - -If you want to work with JSON you can use this version of the pow -`greet-json.pow` -```sh -kapow route add -X POST /greet -c 'who=$(kapow get /request/body | jq -r .name); kapow set /response/status 201; jq --arg value "${who:-World}" -n \{name:\$value\} | kapow set /response/body' -``` - -that uses [jq](https://stedolan.github.io/jq/) to allow you to work with JSON -from the command line. Check that it works with -```sh -curl -X POST -H 'Content-Type: application/json' -d '{"name": "friend"}' localhost:8080/greet -{"name": "friend" } - -curl -X POST -H 'Content-Type: application/json' -d '' localhost:8080/greet -{"name": "World"} -``` - - -## The mandatory Echo (for UNIX fans) - -First, you create a pow file named `echo.pow` with the following contents: -```sh -kapow route add -X POST /echo -c 'kapow get /request/body | kapow set /response/body' -``` - -then, you execute: -```sh -kapow server echo.pow -``` - -and you can check that it works as intended with good ole’ `curl`: -```sh -curl -X POST -d '1,2,3... testing' localhost:8080/echo -1, 2, 3, 4, 5, 6, 7, 8, 9, testing -``` - -If you send a big file and want to see the content back as a real-time stream -you can use this version `echo-stream.pow` -```sh -kapow route add -X POST /echo -c 'kapow get /request/body | kapow set /response/stream' -``` - - -## The multiline fun - -Unless you're a hardcore Perl golfer, you'll probably need to write your stuff -over more than one line in order to avoid the mess we saw on our JSON greet -version. - -Don't worry, we need to write several lines, too. Bash, in its magnificent -UNIX® style, provides us with the -[here-documents](https://www.gnu.org/software/bash/manual/bash.html#Here-Documents) -mechanism that we can leverage precisely for this purpose. - -Imagine that we want to return both the standard output and a generated file from a -command execution. Let's write a `log-and-stuff.pow` file with the following content: -```sh -kapow route add /log_and_stuff - <<-'EOF' - echo this is a quite long sentence and other stuff | tee log.txt | kapow set /response/body - cat log.txt | kapow set /response/body -EOF -``` - -then we serve it with `kapow`: -```sh -kapow server log-and-stuff.pow -``` - -Yup. As simple as that. You can check it. -```sh -curl localhost:8080/log_and_stuff -this is a quite long sentence and other stuff -this is a quite long sentence and other stuff -``` - - -## Interact with other systems - -You can leverage all the power of the shell in your scripts and interact with -other systems by using all the available tools. Write a -`log-and-stuff-callback.pow` file with the following content: -```sh -kapow route add /log_and_stuff - <<-'EOF' - callback_url="$(kapow get /request/params/callback)" - echo this is a quite long sentence and other stuff | tee log.txt | kapow set /response/body - echo sending to $callback_url | kapow set /response/body - curl -X POST --data-binary @log.txt $callback_url | kapow set /response/body -EOF -``` - -serve it with `kapow`: -```sh -kapow server log-and-stuff-callback.pow -``` - -and finally check it. -```sh -curl localhost:8080/log_and_stuff?callback=nowhere.com -this is a quite long sentence and other stuff -sending to nowhere.com - -405 Not Allowed - -

405 Not Allowed

-
nginx
- - -``` - -You must be aware that you must have all the dependencies you use in your -scripts installed in the host that will run the Kapow! server. - -In addition, a pow file can contain as many routes as you like, so you can start -a server with several routes configured in one shot. - - -# Sample Docker usage - -## Clone the project - -```sh -git clone https://github.com/BBVA/kapow.git -``` - - -## Build the kapow! docker image - -```sh -make docker -``` - -Now you have a container image with all the above pow files copied in /tmp so -you can start each example by running -```sh -docker run --rm -p 8080:8080 docker server example.pow -``` - - -## Build a docker image for running the nmap example -```sh -cd /path/to/kapow/poc/examples/nmap; docker build -t kapow-nmap . -``` - - -## Run kapow -```sh -docker run \ - -d \ - -p 8080:8080 \ - kapow-nmap -``` -which will output something like this: -```sh -e7da20c7d9a39624b5c56157176764671e5d2d8f1bf306b3ede898d66fe3f4bf -``` - - -## Test /list endpoint - -In another terminal, try running: -```sh -curl http://localhost:8080/list/github.com -``` - -which will respond something like: -```sh -Starting Nmap 7.70 ( https://nmap.org ) at 2019-05-10 14:01 UTC -Nmap scan report for github.com (140.82.118.3) -rDNS record for 140.82.118.3: lb-140-82-118-3-ams.github.com -Nmap done: 1 IP address (0 hosts up) scanned in 0.04 seconds -``` - -et voilà ! - - -# License - -This project is distributed under the [Apache License 2.0](/LICENSE). diff --git a/internal/client/get.go b/internal/client/get.go index d392fe0..f0816ee 100644 --- a/internal/client/get.go +++ b/internal/client/get.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package client import ( diff --git a/internal/client/get_test.go b/internal/client/get_test.go index 60c4b6d..4d4d21e 100644 --- a/internal/client/get_test.go +++ b/internal/client/get_test.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package client import ( diff --git a/internal/client/route_add.go b/internal/client/route_add.go index e008203..f1cdffe 100644 --- a/internal/client/route_add.go +++ b/internal/client/route_add.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package client import ( diff --git a/internal/client/route_add_test.go b/internal/client/route_add_test.go index 6c74e28..e66779f 100644 --- a/internal/client/route_add_test.go +++ b/internal/client/route_add_test.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package client import ( diff --git a/internal/client/route_list.go b/internal/client/route_list.go index b2c2053..a22f86f 100644 --- a/internal/client/route_list.go +++ b/internal/client/route_list.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package client import ( diff --git a/internal/client/route_list_test.go b/internal/client/route_list_test.go index bb6a80f..e5eadb5 100644 --- a/internal/client/route_list_test.go +++ b/internal/client/route_list_test.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package client import ( diff --git a/internal/client/route_remove.go b/internal/client/route_remove.go index d26f599..53329ed 100644 --- a/internal/client/route_remove.go +++ b/internal/client/route_remove.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package client import ( diff --git a/internal/client/route_remove_test.go b/internal/client/route_remove_test.go index ac42b15..21fa460 100644 --- a/internal/client/route_remove_test.go +++ b/internal/client/route_remove_test.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package client import ( diff --git a/internal/client/set.go b/internal/client/set.go index 0df76d1..745c1ab 100644 --- a/internal/client/set.go +++ b/internal/client/set.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package client import ( diff --git a/internal/client/set_test.go b/internal/client/set_test.go index f18d76c..d13f5b9 100644 --- a/internal/client/set_test.go +++ b/internal/client/set_test.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package client_test import ( diff --git a/internal/cmd/get.go b/internal/cmd/get.go index 1fdbe56..497a859 100644 --- a/internal/cmd/get.go +++ b/internal/cmd/get.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package cmd import ( diff --git a/internal/cmd/route.go b/internal/cmd/route.go index 51d9499..3925517 100644 --- a/internal/cmd/route.go +++ b/internal/cmd/route.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package cmd import ( diff --git a/internal/cmd/server.go b/internal/cmd/server.go index f176b91..14719c8 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package cmd import ( diff --git a/internal/cmd/set.go b/internal/cmd/set.go index 1f81a36..f3e2f95 100644 --- a/internal/cmd/set.go +++ b/internal/cmd/set.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package cmd import ( diff --git a/internal/cmd/validations.go b/internal/cmd/validations.go index 2e99b98..19012c8 100644 --- a/internal/cmd/validations.go +++ b/internal/cmd/validations.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package cmd import ( diff --git a/internal/http/reason.go b/internal/http/reason.go index 464f3dc..89d8732 100644 --- a/internal/http/reason.go +++ b/internal/http/reason.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package http import ( diff --git a/internal/http/reason_test.go b/internal/http/reason_test.go index d71ff82..aa17b93 100644 --- a/internal/http/reason_test.go +++ b/internal/http/reason_test.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package http import ( diff --git a/internal/http/request.go b/internal/http/request.go index 970a8aa..a01063d 100644 --- a/internal/http/request.go +++ b/internal/http/request.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package http import ( diff --git a/internal/http/request_test.go b/internal/http/request_test.go index b0785dc..86e9921 100644 --- a/internal/http/request_test.go +++ b/internal/http/request_test.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package http import ( diff --git a/internal/server/control/control.go b/internal/server/control/control.go index 216e8ff..d29d790 100644 --- a/internal/server/control/control.go +++ b/internal/server/control/control.go @@ -13,26 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package control import ( "encoding/json" "io/ioutil" - "log" "net/http" "github.com/google/uuid" "github.com/gorilla/mux" "github.com/BBVA/kapow/internal/server/model" + "github.com/BBVA/kapow/internal/server/srverrors" "github.com/BBVA/kapow/internal/server/user" ) -// Run must start the control server in a specific address -func Run(bindAddr string) { - log.Fatal(http.ListenAndServe(bindAddr, configRouter())) -} - +// configRouter Populates the server mux with all the supported routes. The +// server exposes list, get, delete and add route endpoints. func configRouter() *mux.Router { r := mux.NewRouter() r.HandleFunc("/routes/{id}", removeRoute). @@ -46,20 +44,27 @@ func configRouter() *mux.Router { return r } +// funcRemove Method used to ask the route model module to delete a route var funcRemove func(id string) error = user.Routes.Delete +// removeRoute Handler that removes the requested route. If it doesn't exist, +// returns 404 and an error entity func removeRoute(res http.ResponseWriter, req *http.Request) { vars := mux.Vars(req) id := vars["id"] if err := funcRemove(id); err != nil { - res.WriteHeader(http.StatusNotFound) + srverrors.WriteErrorResponse(http.StatusNotFound, "Route Not Found", res) return } + res.WriteHeader(http.StatusNoContent) } +// funcList Method used to ask the route model module for the list of routes var funcList func() []model.Route = user.Routes.List +// listRoutes Handler that retrieves a list of the existing routes. An empty +// list is returned when no routes exist func listRoutes(res http.ResponseWriter, req *http.Request) { list := funcList() @@ -69,58 +74,71 @@ func listRoutes(res http.ResponseWriter, req *http.Request) { _, _ = res.Write(listBytes) } +// funcAdd Method used to ask the route model module to append a new route var funcAdd func(model.Route) model.Route = user.Routes.Append + +// idGenerator UUID generator for new routes var idGenerator = uuid.NewUUID +// pathValidator Validates that a path complies with the gorilla mux +// requirements var pathValidator func(string) error = func(path string) error { return mux.NewRouter().NewRoute().BuildOnly().Path(path).GetError() } +// addRoute Handler that adds a new route. Makes all parameter validation and +// creates the a new is for the route func addRoute(res http.ResponseWriter, req *http.Request) { var route model.Route payload, _ := ioutil.ReadAll(req.Body) err := json.Unmarshal(payload, &route) if err != nil { - res.WriteHeader(http.StatusBadRequest) + srverrors.WriteErrorResponse(http.StatusBadRequest, "Malformed JSON", res) return } + if route.Method == "" { - res.WriteHeader(http.StatusUnprocessableEntity) + srverrors.WriteErrorResponse(http.StatusUnprocessableEntity, "Invalid Route", res) return } + if route.Pattern == "" { - res.WriteHeader(http.StatusUnprocessableEntity) + srverrors.WriteErrorResponse(http.StatusUnprocessableEntity, "Invalid Route", res) return } err = pathValidator(route.Pattern) if err != nil { - res.WriteHeader(http.StatusUnprocessableEntity) + srverrors.WriteErrorResponse(http.StatusUnprocessableEntity, "Invalid Route", res) return } id, err := idGenerator() if err != nil { - res.WriteHeader(http.StatusInternalServerError) + srverrors.WriteErrorResponse(http.StatusInternalServerError, "Internal Server Error", res) return } + route.ID = id.String() created := funcAdd(route) createdBytes, _ := json.Marshal(created) - res.WriteHeader(http.StatusCreated) res.Header().Set("Content-Type", "application/json") + res.WriteHeader(http.StatusCreated) _, _ = res.Write(createdBytes) } +// funcGet Method used to ask the route model module for the details of a route var funcGet func(string) (model.Route, error) = user.Routes.Get +// getRoute Handler that retrieves the details of a route. If the route doesn't +// exists returns 404 and an error entity func getRoute(res http.ResponseWriter, req *http.Request) { id := mux.Vars(req)["id"] if r, err := funcGet(id); err != nil { - res.WriteHeader(http.StatusNotFound) + srverrors.WriteErrorResponse(http.StatusNotFound, "Route Not Found", res) } else { res.Header().Set("Content-Type", "application/json") rBytes, _ := json.Marshal(r) diff --git a/internal/server/control/control_test.go b/internal/server/control/control_test.go index 6f792c6..9d1738d 100644 --- a/internal/server/control/control_test.go +++ b/internal/server/control/control_test.go @@ -13,11 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package control import ( "encoding/json" "errors" + "fmt" "io/ioutil" "net/http" "net/http/httptest" @@ -29,9 +31,33 @@ import ( "github.com/gorilla/mux" "github.com/BBVA/kapow/internal/server/model" + "github.com/BBVA/kapow/internal/server/srverrors" "github.com/BBVA/kapow/internal/server/user" ) +func checkErrorResponse(r *http.Response, expectedErrcode int, expectedReason string) []error { + errList := make([]error, 0) + + if r.StatusCode != expectedErrcode { + errList = append(errList, fmt.Errorf("HTTP status mismatch. Expected: %d, got: %d", expectedErrcode, r.StatusCode)) + } + + if v := r.Header.Get("Content-Type"); v != "application/json; charset=utf-8" { + errList = append(errList, fmt.Errorf("Content-Type header mismatch. Expected: %q, got: %q", "application/json; charset=utf-8", v)) + } + + errMsg := srverrors.ServerErrMessage{} + if bodyBytes, err := ioutil.ReadAll(r.Body); err != nil { + errList = append(errList, fmt.Errorf("Unexpected error reading response body: %v", err)) + } else if err := json.Unmarshal(bodyBytes, &errMsg); err != nil { + errList = append(errList, fmt.Errorf("Response body contains invalid JSON entity: %v", err)) + } else if errMsg.Reason != expectedReason { + errList = append(errList, fmt.Errorf("Unexpected reason in response. Expected: %q, got: %q", expectedReason, errMsg.Reason)) + } + + return errList +} + func TestConfigRouterHasRoutesWellConfigured(t *testing.T) { testCases := []struct { pattern, method string @@ -100,17 +126,15 @@ func TestAddRouteReturnsBadRequestWhenMalformedJSONBody(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/routes", strings.NewReader(reqPayload)) resp := httptest.NewRecorder() - handler := http.HandlerFunc(addRoute) - handler.ServeHTTP(resp, req) - if resp.Code != http.StatusBadRequest { - t.Errorf("HTTP status mismatch. Expected: %d, got: %d", http.StatusBadRequest, resp.Code) + addRoute(resp, req) + + for _, e := range checkErrorResponse(resp.Result(), http.StatusBadRequest, "Malformed JSON") { + t.Error(e) } - } func TestAddRouteReturns422ErrorWhenMandatoryFieldsMissing(t *testing.T) { - handler := http.HandlerFunc(addRoute) tc := []struct { payload, testCase string testMustFail bool @@ -166,18 +190,19 @@ func TestAddRouteReturns422ErrorWhenMandatoryFieldsMissing(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/routes", strings.NewReader(test.payload)) resp := httptest.NewRecorder() - handler.ServeHTTP(resp, req) + addRoute(resp, req) + r := resp.Result() if test.testMustFail { - if resp.Code != http.StatusUnprocessableEntity { - t.Errorf("HTTP status mismatch in case %s. Expected: %d, got: %d", test.testCase, http.StatusUnprocessableEntity, resp.Code) + for _, e := range checkErrorResponse(r, http.StatusUnprocessableEntity, "Invalid Route") { + t.Error(e) } } else if !test.testMustFail { - if resp.Code != http.StatusCreated { - t.Errorf("HTTP status mismatch in case %s. Expected: %d, got: %d", test.testCase, http.StatusUnprocessableEntity, resp.Code) + if r.StatusCode != http.StatusCreated { + t.Errorf("HTTP status mismatch in case %s. Expected: %d, got: %d", test.testCase, http.StatusUnprocessableEntity, r.StatusCode) } - if ct := resp.Header().Get("Content-Type"); ct != "application/json" { - t.Errorf("Incorrect content type in response. Expected: application/json, got: %s", ct) + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Incorrect content type in response. Expected: application/json, got: %q", ct) } } } @@ -192,7 +217,6 @@ func TestAddRouteGeneratesRouteID(t *testing.T) { }` req := httptest.NewRequest(http.MethodPost, "/routes", strings.NewReader(reqPayload)) resp := httptest.NewRecorder() - handler := http.HandlerFunc(addRoute) var genID string funcAdd = func(input model.Route) model.Route { genID = input.ID @@ -203,7 +227,7 @@ func TestAddRouteGeneratesRouteID(t *testing.T) { defer func() { pathValidator = origPathValidator }() pathValidator = func(path string) error { return nil } - handler.ServeHTTP(resp, req) + addRoute(resp, req) if _, err := uuid.Parse(genID); err != nil { t.Error("ID not generated properly") @@ -219,7 +243,6 @@ func TestAddRoute500sWhenIDGeneratorFails(t *testing.T) { }` req := httptest.NewRequest(http.MethodPost, "/routes", strings.NewReader(reqPayload)) resp := httptest.NewRecorder() - handler := http.HandlerFunc(addRoute) origPathValidator := pathValidator defer func() { pathValidator = origPathValidator }() @@ -229,14 +252,13 @@ func TestAddRoute500sWhenIDGeneratorFails(t *testing.T) { defer func() { idGenerator = idGenOrig }() idGenerator = func() (uuid.UUID, error) { var uuid uuid.UUID - return uuid, errors.New( - "End of Time reached; Try again before, or in the next Big Bang cycle") + return uuid, errors.New("End of Time reached; Try again before, or in the next Big Bang cycle") } - handler.ServeHTTP(resp, req) + addRoute(resp, req) - if resp.Result().StatusCode != http.StatusInternalServerError { - t.Errorf("HTTP status mismatch. Expected: %d, got: %d", http.StatusInternalServerError, resp.Result().StatusCode) + for _, e := range checkErrorResponse(resp.Result(), http.StatusInternalServerError, "Internal Server Error") { + t.Error(e) } } @@ -250,7 +272,6 @@ func TestAddRouteReturnsCreated(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/routes", strings.NewReader(reqPayload)) resp := httptest.NewRecorder() - handler := http.HandlerFunc(addRoute) var genID string funcAdd = func(input model.Route) model.Route { expected := model.Route{ID: input.ID, Method: "GET", Pattern: "/hello", Entrypoint: "/bin/sh -c", Command: "echo Hello World | kapow set /response/body"} @@ -266,7 +287,7 @@ func TestAddRouteReturnsCreated(t *testing.T) { defer func() { pathValidator = origPathValidator }() pathValidator = func(path string) error { return nil } - handler.ServeHTTP(resp, req) + addRoute(resp, req) if resp.Code != http.StatusCreated { t.Errorf("HTTP status mismatch. Expected: %d, got: %d", http.StatusCreated, resp.Code) @@ -296,15 +317,14 @@ func TestAddRoute422sWhenInvalidRoute(t *testing.T) { }` req := httptest.NewRequest(http.MethodPost, "/routes", strings.NewReader(reqPayload)) resp := httptest.NewRecorder() - handler := http.HandlerFunc(addRoute) origPathValidator := pathValidator defer func() { pathValidator = origPathValidator }() pathValidator = func(path string) error { return errors.New("Invalid route") } - handler.ServeHTTP(resp, req) + addRoute(resp, req) - if resp.Code != http.StatusUnprocessableEntity { - t.Error("Invalid route registered") + for _, e := range checkErrorResponse(resp.Result(), http.StatusUnprocessableEntity, "Invalid Route") { + t.Error(e) } } @@ -314,7 +334,6 @@ func TestRemoveRouteReturnsNotFound(t *testing.T) { handler := mux.NewRouter() handler.HandleFunc("/routes/{id}", removeRoute). Methods("DELETE") - funcRemove = func(id string) error { if id == "ROUTE_XXXXXXXXXXXXXXXXXX" { return errors.New(id) @@ -324,8 +343,9 @@ func TestRemoveRouteReturnsNotFound(t *testing.T) { } handler.ServeHTTP(resp, req) - if resp.Code != http.StatusNotFound { - t.Errorf("HTTP status mismatch. Expected: %d, got: %d", http.StatusNotFound, resp.Code) + + for _, e := range checkErrorResponse(resp.Result(), http.StatusNotFound, "Route Not Found") { + t.Error(e) } } @@ -344,6 +364,7 @@ func TestRemoveRouteReturnsNoContent(t *testing.T) { } handler.ServeHTTP(resp, req) + if resp.Code != http.StatusNoContent { t.Errorf("HTTP status mismatch. Expected: %d, got: %d", http.StatusNoContent, resp.Code) } @@ -414,13 +435,12 @@ func TestGetRouteReturns404sWhenRouteDoesntExist(t *testing.T) { handler.ServeHTTP(w, r) - resp := w.Result() - if resp.StatusCode != http.StatusNotFound { - t.Errorf("HTTP status mismatch. Expected: %d, got: %d", http.StatusNotFound, resp.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusNotFound, "Route Not Found") { + t.Error(e) } } -func TestGetRouteSetsCorrctContentType(t *testing.T) { +func TestGetRouteSetsCorrectContentType(t *testing.T) { handler := mux.NewRouter() handler.HandleFunc("/routes/{id}", getRoute). Methods("GET") diff --git a/internal/server/control/server.go b/internal/server/control/server.go new file mode 100644 index 0000000..5afafea --- /dev/null +++ b/internal/server/control/server.go @@ -0,0 +1,27 @@ +/* + * Copyright 2019 Banco Bilbao Vizcaya Argentaria, S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package control + +import ( + "log" + "net/http" +) + +// Run Starts the control server listening in bindAddr +func Run(bindAddr string) { + log.Fatal(http.ListenAndServe(bindAddr, configRouter())) +} diff --git a/internal/server/data/decorator.go b/internal/server/data/decorator.go index 62695e7..bd708be 100644 --- a/internal/server/data/decorator.go +++ b/internal/server/data/decorator.go @@ -13,12 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package data import ( "net/http" "github.com/BBVA/kapow/internal/server/model" + "github.com/BBVA/kapow/internal/server/srverrors" "github.com/gorilla/mux" ) @@ -38,7 +40,7 @@ func checkHandler(fn resourceHandler) func(http.ResponseWriter, *http.Request) { if h, ok := Handlers.Get(handlerID); ok { fn(w, r, h) } else { - w.WriteHeader(http.StatusNotFound) + srverrors.WriteErrorResponse(http.StatusNotFound, "Handler ID Not Found", w) } } } diff --git a/internal/server/data/decorator_test.go b/internal/server/data/decorator_test.go index b66fd8a..1c75cdd 100644 --- a/internal/server/data/decorator_test.go +++ b/internal/server/data/decorator_test.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package data import ( @@ -126,9 +127,8 @@ func TestCheckHandlerReturnsAFunctionsThat404sWhenHandlerDoesNotExist(t *testing fn(w, r) - res := w.Result() - if res.StatusCode != http.StatusNotFound { - t.Errorf("Status code mismatch. Expected 404. Got %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusNotFound, "Handler ID Not Found") { + t.Error(e) } } diff --git a/internal/server/data/resource.go b/internal/server/data/resource.go index d4a8a70..d55fc72 100644 --- a/internal/server/data/resource.go +++ b/internal/server/data/resource.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package data import ( @@ -23,15 +24,22 @@ import ( "strconv" "github.com/BBVA/kapow/internal/server/model" + "github.com/BBVA/kapow/internal/server/srverrors" "github.com/gorilla/mux" ) +const ( + ResourceItemNotFound = "Resource Item Not Found" + NonIntegerValue = "Non Integer Value" + InvalidStatusCode = "Invalid Status Code" +) + func getRequestBody(w http.ResponseWriter, r *http.Request, h *model.Handler) { w.Header().Add("Content-Type", "application/octet-stream") n, err := io.Copy(w, h.Request.Body) if err != nil { if n == 0 { - w.WriteHeader(http.StatusInternalServerError) + srverrors.WriteErrorResponse(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError), w) } else { // Only way to abort current connection as of go 1.13 // https://github.com/golang/go/issues/16542 @@ -63,7 +71,7 @@ func getRequestMatches(w http.ResponseWriter, r *http.Request, h *model.Handler) if value, ok := vars[name]; ok { _, _ = w.Write([]byte(value)) } else { - w.WriteHeader(http.StatusNotFound) + srverrors.WriteErrorResponse(http.StatusNotFound, ResourceItemNotFound, w) } } @@ -73,7 +81,7 @@ func getRequestParams(w http.ResponseWriter, r *http.Request, h *model.Handler) if values, ok := h.Request.URL.Query()[name]; ok { _, _ = w.Write([]byte(values[0])) } else { - w.WriteHeader(http.StatusNotFound) + srverrors.WriteErrorResponse(http.StatusNotFound, ResourceItemNotFound, w) } } @@ -83,7 +91,7 @@ func getRequestHeaders(w http.ResponseWriter, r *http.Request, h *model.Handler) if values, ok := h.Request.Header[textproto.CanonicalMIMEHeaderKey(name)]; ok { _, _ = w.Write([]byte(values[0])) } else { - w.WriteHeader(http.StatusNotFound) + srverrors.WriteErrorResponse(http.StatusNotFound, ResourceItemNotFound, w) } } @@ -93,7 +101,7 @@ func getRequestCookies(w http.ResponseWriter, r *http.Request, h *model.Handler) if cookie, err := h.Request.Cookie(name); err == nil { _, _ = w.Write([]byte(cookie.Value)) } else { - w.WriteHeader(http.StatusNotFound) + srverrors.WriteErrorResponse(http.StatusNotFound, ResourceItemNotFound, w) } } @@ -109,11 +117,11 @@ func getRequestForm(w http.ResponseWriter, r *http.Request, h *model.Handler) { // We tried to exercise this execution path but didn't know how. err := h.Request.ParseForm() if err != nil { - w.WriteHeader(http.StatusNotFound) + srverrors.WriteErrorResponse(http.StatusNotFound, ResourceItemNotFound, w) } else if values, ok := h.Request.Form[name]; ok { _, _ = w.Write([]byte(values[0])) } else { - w.WriteHeader(http.StatusNotFound) + srverrors.WriteErrorResponse(http.StatusNotFound, ResourceItemNotFound, w) } } @@ -124,7 +132,7 @@ func getRequestFileName(w http.ResponseWriter, r *http.Request, h *model.Handler if err == nil { _, _ = w.Write([]byte(header.Filename)) } else { - w.WriteHeader(http.StatusNotFound) + srverrors.WriteErrorResponse(http.StatusNotFound, ResourceItemNotFound, w) } } @@ -135,7 +143,7 @@ func getRequestFileContent(w http.ResponseWriter, r *http.Request, h *model.Hand if err == nil { _, _ = io.Copy(w, file) } else { - w.WriteHeader(http.StatusNotFound) + srverrors.WriteErrorResponse(http.StatusNotFound, ResourceItemNotFound, w) } } @@ -144,17 +152,16 @@ func getRequestFileContent(w http.ResponseWriter, r *http.Request, h *model.Hand func setResponseStatus(w http.ResponseWriter, r *http.Request, h *model.Handler) { sb, err := ioutil.ReadAll(r.Body) if err != nil { - w.WriteHeader(http.StatusInternalServerError) + srverrors.WriteErrorResponse(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError), w) return } - si, err := strconv.Atoi(string(sb)) - if http.StatusText(si) == "" { - w.WriteHeader(http.StatusBadRequest) - } else if err == nil { - h.Writer.WriteHeader(int(si)) + if si, err := strconv.Atoi(string(sb)); err != nil { + srverrors.WriteErrorResponse(http.StatusUnprocessableEntity, NonIntegerValue, w) + } else if http.StatusText(si) == "" { + srverrors.WriteErrorResponse(http.StatusBadRequest, InvalidStatusCode, w) } else { - w.WriteHeader(http.StatusBadRequest) + h.Writer.WriteHeader(int(si)) } } @@ -162,7 +169,7 @@ func setResponseHeaders(w http.ResponseWriter, r *http.Request, h *model.Handler name := mux.Vars(r)["name"] vb, err := ioutil.ReadAll(r.Body) if err != nil { - w.WriteHeader(http.StatusInternalServerError) + srverrors.WriteErrorResponse(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError), w) return } @@ -178,7 +185,7 @@ func setResponseCookies(w http.ResponseWriter, r *http.Request, h *model.Handler name := mux.Vars(r)["name"] vb, err := ioutil.ReadAll(r.Body) if err != nil { - w.WriteHeader(http.StatusInternalServerError) + srverrors.WriteErrorResponse(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError), w) return } @@ -191,6 +198,6 @@ func setResponseBody(w http.ResponseWriter, r *http.Request, h *model.Handler) { if n > 0 { panic("Truncated body") } - w.WriteHeader(http.StatusInternalServerError) + srverrors.WriteErrorResponse(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError), w) } } diff --git a/internal/server/data/resource_test.go b/internal/server/data/resource_test.go index 1d5e387..7ed8fae 100644 --- a/internal/server/data/resource_test.go +++ b/internal/server/data/resource_test.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package data import ( @@ -120,9 +121,8 @@ func TestGetRequestBody500sWhenHandlerRequestErrors(t *testing.T) { getRequestBody(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusInternalServerError { - t.Error("status not 500") + for _, e := range checkErrorResponse(w.Result(), http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) { + t.Error(e) } } @@ -369,9 +369,8 @@ func TestGetRequestMatchesReturnsNotFoundWhenMatchDoesntExists(t *testing.T) { getRequestMatches(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusNotFound { - t.Errorf("Status code mismatch. Expected: 404. Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusNotFound, "Resource Item Not Found") { + t.Error(e) } } @@ -433,9 +432,8 @@ func TestGetRequestParams404sWhenParamDoesntExist(t *testing.T) { getRequestParams(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusNotFound { - t.Errorf("Status code mismatch. Expected: 404. Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusNotFound, "Resource Item Not Found") { + t.Error(e) } } @@ -478,14 +476,15 @@ func TestGetRequestHeadersSetsOctectStreamContentType(t *testing.T) { Request: httptest.NewRequest("GET", "/", nil), Writer: httptest.NewRecorder(), } + h.Request.Header.Set("bar", "BAZ") r := createMuxRequest("/handlers/HANDLERID/request/headers/{name}", "/handlers/HANDLERID/request/headers/bar", "GET", nil) w := httptest.NewRecorder() getRequestHeaders(w, r, &h) res := w.Result() - if res.Header.Get("Content-Type") != "application/octet-stream" { - t.Error("Content Type mismatch") + if v := res.Header.Get("Content-Type"); v != "application/octet-stream" { + t.Errorf("Content Type mismatch. Expected: application/octet-stream. Got: %q", v) } } @@ -567,9 +566,8 @@ func TestGetRequestHeaders404sWhenHeaderDoesntExist(t *testing.T) { getRequestHeaders(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusNotFound { - t.Error("Status code mismatch") + for _, e := range checkErrorResponse(w.Result(), http.StatusNotFound, "Resource Item Not Found") { + t.Error(e) } } @@ -652,9 +650,8 @@ func TestGetRequestCookies404sIfCookieDoesntExist(t *testing.T) { getRequestCookies(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusNotFound { - t.Errorf("Status code mismatch. Expected: 404, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusNotFound, "Resource Item Not Found") { + t.Error(e) } } @@ -754,9 +751,8 @@ func TestGetRequestForm404sWhenFieldDoesntExist(t *testing.T) { getRequestForm(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusNotFound { - t.Errorf("Status code mismatch. Expected: 404, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusNotFound, "Resource Item Not Found") { + t.Error(e) } } @@ -810,9 +806,8 @@ func TestGetRequestForm404sWhenFormDoesntExist(t *testing.T) { getRequestForm(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusNotFound { - t.Errorf("Status code mismatch. Expected: 404, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusNotFound, "Resource Item Not Found") { + t.Error(e) } } @@ -885,9 +880,8 @@ func TestGetRequestFileName404sWhenFileDoesntExist(t *testing.T) { getRequestFileName(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusNotFound { - t.Errorf("Status code mismatch. Expected: 404, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusNotFound, "Resource Item Not Found") { + t.Error(e) } } @@ -902,9 +896,8 @@ func TestGetRequestFileName404sWhenFormDoesntExist(t *testing.T) { getRequestFileName(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusNotFound { - t.Errorf("Status code mismatch. Expected: 404, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusNotFound, "Resource Item Not Found") { + t.Error(e) } } @@ -966,9 +959,8 @@ func TestGetRequestFileContent404sWhenFileDoesntExist(t *testing.T) { getRequestFileContent(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusNotFound { - t.Errorf("Status code mismatch. Expected: 404, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusNotFound, "Resource Item Not Found") { + t.Error(e) } } @@ -983,9 +975,8 @@ func TestGetRequestFileContent404sWhenFormDoesntExist(t *testing.T) { getRequestFileContent(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusNotFound { - t.Errorf("Status code mismatch. Expected: 404, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusNotFound, "Resource Item Not Found") { + t.Error(e) } } @@ -1009,9 +1000,8 @@ func TestGetRequestFileContent500sWhenHandlerRequestErrors(t *testing.T) { getRequestFileContent(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusInternalServerError { - t.Error("status not 500", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) { + t.Error(e) } } @@ -1058,9 +1048,8 @@ func TestSetResponseStatus400sWhenNonparseableStatusCode(t *testing.T) { setResponseStatus(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusBadRequest { - t.Errorf("Status code mismatch. Expected: 400, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusUnprocessableEntity, "Non Integer Value") { + t.Error(e) } } @@ -1074,9 +1063,8 @@ func TestSetResponseStatus500sWhenErrorReadingRequest(t *testing.T) { setResponseStatus(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusInternalServerError { - t.Errorf("Status code mismatch. Expected: 500, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) { + t.Error(e) } } @@ -1092,9 +1080,8 @@ func TestSetResponseStatus400sWhenStatusCodeNotSupportedByGo(t *testing.T) { setResponseStatus(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusBadRequest { - t.Errorf("Status code mismatch. Expected: 400, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusBadRequest, "Invalid Status Code") { + t.Error(e) } } @@ -1162,9 +1149,8 @@ func TestSetResponseHeaders500sWhenErrorReadingRequest(t *testing.T) { setResponseHeaders(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusInternalServerError { - t.Errorf("Status code mismatch. Expected: 500, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) { + t.Error(e) } } @@ -1181,9 +1167,8 @@ func TestSetResponseHeaders400sOnInvalidHeaderKey(t *testing.T) { setResponseHeaders(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusBadRequest { - t.Errorf("Status code mismatch. Expected: 400, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusBadRequest, "Invalid Header Name") { + t.Error(e) } } @@ -1200,9 +1185,8 @@ func TestSetResponseHeaders400sOnInvalidHeaderValue(t *testing.T) { setResponseHeaders(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusBadRequest { - t.Errorf("Status code mismatch. Expected: 400, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusBadRequest, "Invalid Header Value") { + t.Error(e) } } @@ -1250,9 +1234,8 @@ func TestSetResponseCookies500sWhenErrorReadingRequest(t *testing.T) { setResponseCookies(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusInternalServerError { - t.Errorf("Status code mismatch. Expected: 500, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) { + t.Error(e) } } @@ -1322,9 +1305,8 @@ func TestSetResponseBody500sWhenReaderFailsInFirstRead(t *testing.T) { setResponseBody(w, r, &h) - res := w.Result() - if res.StatusCode != http.StatusInternalServerError { - t.Errorf("Status code mismatch. Expected: 500, Got: %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) { + t.Error(e) } } diff --git a/internal/server/data/server.go b/internal/server/data/server.go index 6139428..140b984 100644 --- a/internal/server/data/server.go +++ b/internal/server/data/server.go @@ -13,12 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package data import ( "log" "net/http" + "github.com/BBVA/kapow/internal/server/srverrors" "github.com/gorilla/mux" ) @@ -35,7 +37,9 @@ func configRouter(rs []routeSpec) (r *mux.Router) { } r.HandleFunc( "/handlers/{handlerID}/{resource:.*}", - func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) }) + func(w http.ResponseWriter, r *http.Request) { + srverrors.WriteErrorResponse(http.StatusBadRequest, "Invalid Resource Path", w) + }) return r } diff --git a/internal/server/data/server_test.go b/internal/server/data/server_test.go index eaeb932..7481e5a 100644 --- a/internal/server/data/server_test.go +++ b/internal/server/data/server_test.go @@ -13,16 +13,44 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package data import ( + "encoding/json" + "fmt" + "io/ioutil" "net/http" "net/http/httptest" "testing" "github.com/BBVA/kapow/internal/server/model" + "github.com/BBVA/kapow/internal/server/srverrors" ) +func checkErrorResponse(r *http.Response, expectedErrcode int, expectedReason string) []error { + errList := make([]error, 0) + + if r.StatusCode != expectedErrcode { + errList = append(errList, fmt.Errorf("HTTP status mismatch. Expected: %d, got: %d", expectedErrcode, r.StatusCode)) + } + + if v := r.Header.Get("Content-Type"); v != "application/json; charset=utf-8" { + errList = append(errList, fmt.Errorf("Content-Type header mismatch. Expected: %q, got: %q", "application/json; charset=utf-8", v)) + } + + errMsg := srverrors.ServerErrMessage{} + if bodyBytes, err := ioutil.ReadAll(r.Body); err != nil { + errList = append(errList, fmt.Errorf("Unexpected error reading response body: %v", err)) + } else if err := json.Unmarshal(bodyBytes, &errMsg); err != nil { + errList = append(errList, fmt.Errorf("Response body contains invalid JSON entity: %v", err)) + } else if errMsg.Reason != expectedReason { + errList = append(errList, fmt.Errorf("Unexpected reason in response. Expected: %q, got: %q", expectedReason, errMsg.Reason)) + } + + return errList +} + func TestConfigRouterReturnsRouterWithDecoratedRoutes(t *testing.T) { var handlerID string rs := []routeSpec{ @@ -49,8 +77,7 @@ func TestConfigRouterReturnsRouterThat400sOnUnconfiguredResources(t *testing.T) m.ServeHTTP(w, httptest.NewRequest("GET", "/handlers/FOO/dummy", nil)) - res := w.Result() - if res.StatusCode != http.StatusBadRequest { - t.Errorf("Status code mismatch. Expected 400. Got %d", res.StatusCode) + for _, e := range checkErrorResponse(w.Result(), http.StatusBadRequest, "Invalid Resource Path") { + t.Error(e) } } diff --git a/internal/server/data/state.go b/internal/server/data/state.go index e95a294..94c4cfc 100644 --- a/internal/server/data/state.go +++ b/internal/server/data/state.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package data import ( diff --git a/internal/server/data/state_test.go b/internal/server/data/state_test.go index 7ca5e15..f082c3a 100644 --- a/internal/server/data/state_test.go +++ b/internal/server/data/state_test.go @@ -15,6 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package data import ( diff --git a/internal/server/model/handler.go b/internal/server/model/handler.go index b96ac44..8d79b35 100644 --- a/internal/server/model/handler.go +++ b/internal/server/model/handler.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package model import ( diff --git a/internal/server/model/route.go b/internal/server/model/route.go index 900fd59..f630dce 100644 --- a/internal/server/model/route.go +++ b/internal/server/model/route.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package model // Route contains the data needed to represent a Kapow! user route. diff --git a/internal/server/server.go b/internal/server/server.go index 7c8da7e..766ec3c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package server import ( diff --git a/internal/server/srverrors/error.go b/internal/server/srverrors/error.go new file mode 100644 index 0000000..77c7083 --- /dev/null +++ b/internal/server/srverrors/error.go @@ -0,0 +1,19 @@ +package srverrors + +import ( + "encoding/json" + "net/http" +) + +type ServerErrMessage struct { + Reason string `json:"reason"` +} + +func WriteErrorResponse(statusCode int, reasonMsg string, res http.ResponseWriter) { + respBody := ServerErrMessage{} + respBody.Reason = reasonMsg + bb, _ := json.Marshal(respBody) + res.Header().Set("Content-Type", "application/json; charset=utf-8") + res.WriteHeader(statusCode) + _, _ = res.Write(bb) +} diff --git a/internal/server/srverrors/error_test.go b/internal/server/srverrors/error_test.go new file mode 100644 index 0000000..adda7b5 --- /dev/null +++ b/internal/server/srverrors/error_test.go @@ -0,0 +1,47 @@ +package srverrors_test + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/BBVA/kapow/internal/server/srverrors" +) + +func TestWriteErrorResponseSetsAppJsonContentType(t *testing.T) { + w := httptest.NewRecorder() + + srverrors.WriteErrorResponse(0, "Not Important Here", w) + + if v := w.Result().Header.Get("Content-Type"); v != "application/json; charset=utf-8" { + t.Errorf("Content-Type header mismatch. Expected: %q, got: %q", "application/json; charset=utf-8", v) + } +} + +func TestWriteErrorResponseSetsRequestedStatusCode(t *testing.T) { + w := httptest.NewRecorder() + + srverrors.WriteErrorResponse(http.StatusGone, "Not Important Here", w) + + if v := w.Result().StatusCode; v != http.StatusGone { + t.Errorf("Status code mismatch. Expected: %d, got: %d", http.StatusGone, v) + } +} + +func TestWriteErrorResponseSetsBodyCorrectly(t *testing.T) { + expectedReason := "Something Not Found" + w := httptest.NewRecorder() + + srverrors.WriteErrorResponse(http.StatusNotFound, expectedReason, w) + + errMsg := srverrors.ServerErrMessage{} + if bodyBytes, err := ioutil.ReadAll(w.Result().Body); err != nil { + t.Errorf("Unexpected error reading response body: %v", err) + } else if err := json.Unmarshal(bodyBytes, &errMsg); err != nil { + t.Errorf("Response body contains invalid JSON entity: %v", err) + } else if errMsg.Reason != expectedReason { + t.Errorf("Unexpected reason in response. Expected: %q, got: %q", expectedReason, errMsg.Reason) + } +} diff --git a/internal/server/user/mux/gorillize.go b/internal/server/user/mux/gorillize.go index a089f07..9ebcc2d 100644 --- a/internal/server/user/mux/gorillize.go +++ b/internal/server/user/mux/gorillize.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package mux import ( diff --git a/internal/server/user/mux/gorillize_test.go b/internal/server/user/mux/gorillize_test.go index 69ba2f4..f531f2d 100644 --- a/internal/server/user/mux/gorillize_test.go +++ b/internal/server/user/mux/gorillize_test.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package mux import ( diff --git a/internal/server/user/mux/handlerbuilder.go b/internal/server/user/mux/handlerbuilder.go index 64fe7d7..09dbdfb 100644 --- a/internal/server/user/mux/handlerbuilder.go +++ b/internal/server/user/mux/handlerbuilder.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package mux import ( diff --git a/internal/server/user/mux/handlerbuilder_test.go b/internal/server/user/mux/handlerbuilder_test.go index 3e2d7f3..da5dde1 100644 --- a/internal/server/user/mux/handlerbuilder_test.go +++ b/internal/server/user/mux/handlerbuilder_test.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package mux import ( diff --git a/internal/server/user/mux/mux.go b/internal/server/user/mux/mux.go index 9946f5f..508cbcb 100644 --- a/internal/server/user/mux/mux.go +++ b/internal/server/user/mux/mux.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package mux import ( diff --git a/internal/server/user/mux/mux_test.go b/internal/server/user/mux/mux_test.go index 5cbc04f..771ad78 100644 --- a/internal/server/user/mux/mux_test.go +++ b/internal/server/user/mux/mux_test.go @@ -15,6 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package mux import ( diff --git a/internal/server/user/server.go b/internal/server/user/server.go index bf3800a..15f0706 100644 --- a/internal/server/user/server.go +++ b/internal/server/user/server.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package user import ( diff --git a/internal/server/user/spawn/spawn.go b/internal/server/user/spawn/spawn.go index fb13231..50f4f4d 100644 --- a/internal/server/user/spawn/spawn.go +++ b/internal/server/user/spawn/spawn.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package spawn import ( diff --git a/internal/server/user/spawn/spawn_test.go b/internal/server/user/spawn/spawn_test.go index f5ebae3..ef77791 100644 --- a/internal/server/user/spawn/spawn_test.go +++ b/internal/server/user/spawn/spawn_test.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package spawn import ( diff --git a/internal/server/user/state.go b/internal/server/user/state.go index fc7e3a5..ed240b5 100644 --- a/internal/server/user/state.go +++ b/internal/server/user/state.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package user import ( diff --git a/internal/server/user/state_test.go b/internal/server/user/state_test.go index bb0c13e..2ba52f7 100644 --- a/internal/server/user/state_test.go +++ b/internal/server/user/state_test.go @@ -15,6 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package user import ( diff --git a/main.go b/main.go index 9a7a88e..6fd8089 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package main import ( diff --git a/master.pow b/master.pow deleted file mode 100644 index 451a01b..0000000 --- a/master.pow +++ /dev/null @@ -1 +0,0 @@ -go run . route add -c 'echo foo' / \ No newline at end of file diff --git a/poc/Makefile b/poc/Makefile index b7a9a04..9c5a4ff 100644 --- a/poc/Makefile +++ b/poc/Makefile @@ -6,4 +6,7 @@ sync: pipenv sync --dev test: sync - pipenv run make -C ../spec/test + KAPOW_DATAAPI_URL=http://localhost:8081 pipenv run make -C ../spec/test + +fix: + KAPOW_DATAAPI_URL=http://localhost:8081 pipenv run make -C ../spec/test fix diff --git a/poc/bin/kapow b/poc/bin/kapow index e9d6e66..5c371fb 100755 --- a/poc/bin/kapow +++ b/poc/bin/kapow @@ -178,14 +178,14 @@ async def get_field(request): try: connection = CONNECTIONS[id] except KeyError: - response = web.Response(status=404, reason="Handler ID Not Found") + response = web.json_response(data=error_body("Handler ID Not Found"), status=404, reason="Not Found") else: try: content = await connection.get(field) except ValueError: - return web.Response(status=400, reason="Invalid Resource Path") + return web.json_response(data=error_body("Invalid Resource Path"), status=400, reason="Bad Request") except KeyError: - return web.Response(status=404, reason="Resource Item Not Found") + return web.json_response(data=error_body("Resource Item Not Found"), status=404, reason="Not Found") if isinstance(content, StreamReader): response = web.StreamResponse(status=200, reason="OK") @@ -210,8 +210,10 @@ async def set_field(request): try: connection = CONNECTIONS[id] + except ValueError: + return web.json_response(data=error_body("Invalid Resource Path"), status=400, reason="Bad Request") except KeyError: - response = web.Response(status=404, reason="Handler ID Not Found") + response = web.json_response(data=error_body("Handler ID Not Found"), status=404, reason="Not Found") else: try: await connection.set(field, request.content) @@ -275,6 +277,9 @@ def handle_route(entrypoint, command): ######################################################################## +def error_body(reason): + return {"reason": reason, "foo": "bar"} + def get_routes(app): async def _get_routes(request): """Return the list of registered routes.""" @@ -302,7 +307,7 @@ def get_route(app): "entrypoint": r.entrypoint, "command": r.command}) else: - return web.Response(status=404, reason="Not Found") + return web.json_response(data=error_body("Route Not Found"), status=404, reason="Not Found") return _get_route @@ -312,7 +317,7 @@ def insert_route(app): try: content = await request.json() except ValueError: - return web.Response(status=400, reason="Malformed JSON") + return web.json_response(data=error_body("Malformed JSON"), status=400, reason="Bad Request") try: index = int(content["index"]) @@ -330,7 +335,7 @@ def insert_route(app): + [route] + app["user_routes"][index:])) except (InvalidRouteError, KeyError, AssertionError, ValueError) as exc: - return web.Response(status=422, reason="Invalid Route") + return web.json_response(data=error_body("Invalid Route"), status=422, reason="Unprocessable Entity") else: app["user_routes"].insert(index, route) return web.json_response({"id": route.id, @@ -348,7 +353,7 @@ def append_route(app): try: content = await request.json() except ValueError as exc: - return web.Response(status=400, reason="Malformed JSON") + return web.json_response(data=error_body("Malformed JSON"), status=400, reason="Bad Request") try: method = content.get("method", "GET") @@ -362,7 +367,7 @@ def append_route(app): handler=handle_route(entrypoint, command)) app.change_routes(app["user_routes"] + [route]) except (InvalidRouteError, KeyError) as exc: - return web.Response(status=422, reason="Invalid Route") + return web.json_response(data=error_body("Invalid Route"), status=422, reason="Unprocessable Entity") else: app["user_routes"].append(route) return web.json_response({"id": route.id, @@ -381,7 +386,7 @@ def delete_route(app): id = request.match_info["id"] routes = [r for r in app["user_routes"] if r.id != id] if len(routes) == len(app["user_routes"]): - return web.Response(status=404, reason="Not Found") + return web.json_response(data=error_body("Route Not Found"), status=404, reason="Not Found") else: app.change_routes(routes) app["user_routes"] = routes diff --git a/poc/examples/eval.pow b/poc/examples/eval.pow deleted file mode 100755 index acc18bd..0000000 --- a/poc/examples/eval.pow +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -# -# Copyright 2019 Banco Bilbao Vizcaya Argentaria, S.A. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -kapow route add -X POST '/eval' -c '$($(kapow get /request/body) | kapow set /response/stream)' diff --git a/poc/examples/eval.testme b/poc/examples/eval.testme deleted file mode 100755 index a17bfc9..0000000 --- a/poc/examples/eval.testme +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env sh - -curl -X POST --data-binary @- http://localhost:8080/eval < - - - - Nmap - - -
-
- Nmap parameters -
- - -

- Can pass hostnames, IP addresses, networks, etc. e.g.: - scanme.nmap.org, microsoft.com/24, 192.168.0.1; - 10.0.0-255.1-254 -

-
-
- - -

- Only scan specified ports. e.g.: 22; 1-65535; - U:53,111,137,T:21-25,80,139,8080,S:9 -

-
-
- - -
-
-
- - diff --git a/poc/examples/nmap-web.pow b/poc/examples/nmap-web.pow deleted file mode 100755 index 6fb0755..0000000 --- a/poc/examples/nmap-web.pow +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash - -# -# Copyright 2019 Banco Bilbao Vizcaya Argentaria, S.A. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# -# Nmap produces an XML report, suitable for rendering in a web browser -# - -# Call examples: -# -# $ browser http://localhost:8080 -# -# $ curl -v http://localhost:8080/nmap.xml -d 'target_spec=127.0.0.1&port_ranges=9000' -# - -kapow route add -X GET / - <<-'EOF' - cat nmap-web.html | kapow set /response/body -EOF - -kapow route add -X GET /nmap.xsl - <<-'EOF' - curl --silent https://svn.nmap.org/nmap/docs/nmap.xsl \ - | kapow set /response/body -EOF - -kapow route add -X POST /nmap.xml - <<-'EOF' - - TARGET_SPEC=$(kapow get /request/form/target_spec) - : ${TARGET_SPEC:=127.0.0.1} - - PORT_RANGES=$(kapow get /request/form/port_ranges) - : ${PORT_RANGES:=8080} - - nmap \ - -Pn \ - -n \ - -p "$PORT_RANGES" \ - -oX - \ - --stylesheet /nmap.xsl \ - "$TARGET_SPEC" \ - | kapow set /response/body -EOF diff --git a/poc/examples/nmap/Dockerfile b/poc/examples/nmap/Dockerfile deleted file mode 100644 index 5fe5224..0000000 --- a/poc/examples/nmap/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM bbvalabsci/kapow:latest - -RUN apk add nmap - -COPY nmap.pow /tmp/ - -CMD ["server", "/tmp/nmap.pow"] diff --git a/poc/examples/nmap/nmap.pow b/poc/examples/nmap/nmap.pow deleted file mode 100755 index 37ec075..0000000 --- a/poc/examples/nmap/nmap.pow +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -# -# Copyright 2019 Banco Bilbao Vizcaya Argentaria, S.A. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -kapow route add -X GET '/list/{ip}' -c 'nmap -sL $(kapow get /request/matches/ip) | kapow set /response/body' diff --git a/poc/examples/operator.pow b/poc/examples/operator.pow deleted file mode 100755 index 27bca22..0000000 --- a/poc/examples/operator.pow +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash - -# -# Copyright 2019 Banco Bilbao Vizcaya Argentaria, S.A. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -kapow route add /list/files -c 'ls -la $(kapow get /request/params/path) | kapow set /response/body' - -kapow route add /list/processes -c 'ps -aux | kapow set /response/body' - -kapow route add /show/cpuinfo -c 'kapow set /response/body < /proc/cpuinfo' - -kapow route add /show/memory -c 'free -m | kapow set /response/body' - -kapow route add /show/disk -c 'df -h | kapow set /response/body' - -kapow route add /show/connections -c 'ss -pluton | kapow set /response/body' - -kapow route add /show/mounts -c 'mount | kapow set /response/body' - -kapow route add /tail/dmesg - <<-'EOF' - kapow set /response/headers/Content-Type text/plain - dmesg -w | kapow set /response/stream -EOF - -kapow route add /tail/journal - <<-'EOF' - kapow set /response/headers/Content-Type text/plain - journalctl -f | kapow set /response/stream -EOF diff --git a/poc/examples/pandoc/pandoc.pow b/poc/examples/pandoc/pandoc.pow deleted file mode 100755 index fb245c5..0000000 --- a/poc/examples/pandoc/pandoc.pow +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -# -# Copyright 2019 Banco Bilbao Vizcaya Argentaria, S.A. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -kapow route add -X POST --entrypoint '/bin/zsh -c' '/convert/{from}/{to}' - <<-'EOF' - pandoc --from=$(kapow get /request/matches/from) \ - --to=$(kapow get /request/matches/to) \ - --output=>(kapow set /response/body) \ - =(kapow get /request/body) -EOF -kapow route add -X GET '/formats/input' -c 'pandoc --list-input-formats | kapow set /response/body' -kapow route add -X GET '/formats/output' -c 'pandoc --list-output-formats | grep -v pdf | kapow set /response/body' diff --git a/poc/examples/pandoc/testme b/poc/examples/pandoc/testme deleted file mode 100755 index e2fabd3..0000000 --- a/poc/examples/pandoc/testme +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env sh - -curl -X POST --data-binary @- http://localhost:8080/convert/markdown/man < - - PDF Editor - - - -
-
-
-
-
-
-
-
AWYSIWYG PDF Editor
-
-
InputFormat
- -
-
-
InputFormat
- -
-
-
Preview!
-
-
-
-
-
- -
-
-
-
-
- - - diff --git a/poc/examples/pdfeditor/pdfeditor.pow b/poc/examples/pdfeditor/pdfeditor.pow deleted file mode 100755 index 06eb815..0000000 --- a/poc/examples/pdfeditor/pdfeditor.pow +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -# -# Copyright 2019 Banco Bilbao Vizcaya Argentaria, S.A. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -kapow route add -X POST --entrypoint ./topdf '/editor/pdf' -kapow route add / -c 'kapow set /response/headers/Content-Type text/html && kapow set /response/body < pdfeditor.html' diff --git a/poc/examples/pdfeditor/topdf b/poc/examples/pdfeditor/topdf deleted file mode 100755 index 8e2dddc..0000000 --- a/poc/examples/pdfeditor/topdf +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/zsh - -# -# Copyright 2019 Banco Bilbao Vizcaya Argentaria, S.A. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -tmpfile=$(mktemp --suffix=.pdf) -pandoc --from=$(kapow get /request/form/from) --to=pdf --output=${tmpfile} -t latex =(kapow get /request/form/content) -if [ $? -eq 0 ]; then - kapow set /response/headers/Content-Type application/pdf - kapow set /response/body < ${tmpfile} - kapow set /response/status 200 -else - kapow set /response/status 500 -fi -rm -f ${tmpfile} diff --git a/poc/examples/tcpdump/README.md b/poc/examples/tcpdump/README.md deleted file mode 100644 index de84f51..0000000 --- a/poc/examples/tcpdump/README.md +++ /dev/null @@ -1,16 +0,0 @@ -Remote tcpdump sniffer with source filtering -============================================ - -1. Add any filter you want to the `tcpdump` command inside `tcpdump.pow` to filter - any traffic you don't want to be sniffed! -2. For the sake of simplicity, run `sudo -E kapow server tcpdump.pow`. In a - production environment, `tcpdump` should be run with the appropiate permissions, - but kapow can (and should) run as an unprivileged user. -3. In your local machine run: - ```bash - curl http://localhost:8080/sniff/ | sudo -E wireshark -k -i - - ``` - Again, for the sake of simplicity, `Wireshark` is running as root. If you don't want - to run it this way, follow this guide: - https://gist.github.com/MinaMikhailcom/0825906230cbbe478faf4d08abe9d11a -4. Profit! diff --git a/poc/examples/tcpdump/tcpdump.pow b/poc/examples/tcpdump/tcpdump.pow deleted file mode 100644 index 36b83d9..0000000 --- a/poc/examples/tcpdump/tcpdump.pow +++ /dev/null @@ -1 +0,0 @@ -kapow route add /sniff/{iface} -c 'tcpdump -i "$(kapow get /request/matches/iface)" -U -s0 -w - "not port 8080" | kapow set /response/stream' diff --git a/poc/examples/torrent.pow b/poc/examples/torrent.pow deleted file mode 100755 index ee31e7d..0000000 --- a/poc/examples/torrent.pow +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash - -# -# Copyright 2019 Banco Bilbao Vizcaya Argentaria, S.A. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -kapow route add / - <<-'EOF' - kapow set /response/headers/Content-Type text/html - kapow set /response/body <<-HTML - - - Add me to your bookmarks! - - - HTML -EOF - -kapow route add /save/magnet -e '/bin/bash -c' - <<-'EOF' - link=$(kapow get /request/params/link) - [ -z $link ] && kapow set /response/status 400 && exit 0 - - watch_folder=/tmp - cd $watch_folder - [[ "$link" =~ xt=urn:btih:([^&/]+) ]] || exit; - echo "d10:magnet-uri${#link}:${link}e" > "meta-${BASH_REMATCH[1]}.torrent" - - kapow set /response/status 302 - kapow set /response/headers/Location /torrent/list -EOF - -kapow route add /torrent/list -c 'kapow set /response/body "Not Implemented Yet"' diff --git a/spec/README.md b/spec/README.md index 2fb6fe0..aadca97 100644 --- a/spec/README.md +++ b/spec/README.md @@ -131,14 +131,19 @@ whole lifetime of the server. conservative in what you do, be liberal in what you accept from others. * We reuse conventions of well-established software projects, such as Docker. * All requests and responses will leverage JSON as the data encoding method. -* The API calls responses have two parts: +* The API calls responses have several parts: * The HTTP status code (e.g., `400`, which is a bad request). The target audience of this information is the client code. The client can thus use this information to control the program flow. - * The body is optional + * The body is optional depending on the request method and the status code. For + error responses (4xx and 5xx) a json body is included with a reason phrase. + The target audience in this case is the human operating the client. The + human can use this information to make a decision on how to proceed. * All successful API calls will return a representation of the *final* state attained by the objects which have been addressed (either requested, set or deleted). +* When several error conditions can happen at the same time, the order of the + checks is implementation-defined. For instance, given this request: ```http @@ -163,6 +168,15 @@ Content-Length: 189 ] ``` +While an error response may look like this: +```http +404 Not Found +Content-Type: application/json +Content-Length: 25 + +{"reason": "Not Found"} +``` + ## API Elements @@ -242,8 +256,8 @@ A new id is created for the appended route so it can be referenced later. } ``` * **Error Responses**: - * **Code**: `400 Bad Request` - * **Code**: `422 Unprocessable Entity` + * **Code**: `400`; **Reason**: `Malformed JSON` + * **Code**: `422`; **Reason**: `Invalid Route` * **Sample Call**:
```sh $ curl -X POST --data-binary @- $KAPOW_URL/routes < ```sh $ curl -X PUT --data-binary @- $KAPOW_URL/routes < ```sh $ curl -X DELETE $KAPOW_URL/routes/ROUTE_1f186c92_f906_4506_9788_a1f541b11d0f @@ -357,7 +371,7 @@ Retrieves the information about the route identified by `{id}`. } ``` * **Error Responses**: - * **Code**: `404 Not Found` + * **Code**: `404`; Reason: `Route Not Found` * **Sample Call**:
```sh $ curl -X GET $KAPOW_URL/routes/ROUTE_1f186c92_f906_4506_9788_a1f541b11d0f @@ -381,11 +395,16 @@ response. * The HTTP status code (e.g., `400`, which is a bad request). The target audience of this information is the client code. The client can thus use this information to control the program flow. - * The HTTP body. On read requests, containing the data retrieved as - 'application/octet-stream' mime type. + * The HTTP reason phrase. The target audience in this case is the human + operating the client. The human can use this information to make a + decision on how to proceed. * Regarding HTTP request and response bodies: - * The response body will be empty in case of error. - * It will transport binary data in other case. + * In case of error the response body will be a json entity containing a reason + phrase. The target audience in this case is the human operating the client. + The human can use this information to make a decision on how to proceed. + * It will transport binary data in any other case. +* When several error conditions can happen at the same time, the order of the + checks is implementation-defined. ## API Elements @@ -510,12 +529,11 @@ path doesn't exist or is invalid. **Header**: `Content-Type: application/octet-stream`
**Content**: The value of the resource. Note that it may be empty. * **Error Responses**: - * **Code**: `400 Bad Request`
- **Notes**: An invalid resource path has been requested. Check the list of - valid resource paths at the top of this section. - * **Code**: `404 Not Found`
+ * **Code**: `400`; Reason: `Invalid Resource Path`
+ **Notes**: Check the list of valid resource paths at the top of this section. + * **Code**: `404`; Reason: `Handler ID Not Found`
**Notes**: Refers to the handler resource itself. - * **Code**: `404 Not Found`
+ * **Code**: `404`; Reason: `Resource Item Not Found`
**Notes**: Refers to the named item in the corresponding data API resource. * **Sample Call**:
```sh @@ -534,10 +552,13 @@ path doesn't exist or is invalid. * **Success Responses**: * **Code**: `200 OK` * **Error Responses**: - * **Code**: `400 Bad Request`
- **Notes**: An invalid resource path has been requested. Check the list of - valid resource paths at the top of this section. - * **Code**: `404 Not Found`
+ * **Code**: `400`; Reason: `Invalid Resource Path`
+ **Notes**: Check the list of valid resource paths at the top of this section. + * **Code**: `422`; Reason: `Non Integer Value`
+ **Notes**: When setting the status code with a non integer value. + * **Code**: `400`; Reason: `Invalid Status Code`
+ **Notes**: When setting a non-supported status code. + * **Code**: `404`; Reason: `Handler ID Not Found`
**Notes**: Refers to the handler resource itself. * **Sample Call**:
```sh diff --git a/spec/test/Makefile b/spec/test/Makefile index 571fcb8..62ca762 100644 --- a/spec/test/Makefile +++ b/spec/test/Makefile @@ -11,6 +11,6 @@ wip: test: lint pipenv run behave --no-capture --tags=~@skip fix: lint - KAPOW_DEBUG_TESTS=1 pipenv run behave --stop --no-capture + KAPOW_DEBUG_TESTS=1 pipenv run behave --stop --no-capture --tags=~@skip catalog: pipenv run behave --format steps.usage --dry-run --no-summary -q diff --git a/spec/test/features/control/append/error_malformed.feature b/spec/test/features/control/append/error_malformed.feature index 567a4e3..493375c 100644 --- a/spec/test/features/control/append/error_malformed.feature +++ b/spec/test/features/control/append/error_malformed.feature @@ -28,4 +28,10 @@ Feature: Kapow! server reject append requests with malformed JSON bodies. Hi! I am an invalid JSON document. """ Then I get 400 as response code -# And I get "Malformed JSON" as response reason phrase + And the response header "Content-Type" contains "application/json" + And I get the following response body: + """ + { + "reason": "Malformed JSON" + } + """ diff --git a/spec/test/features/control/append/error_unprocessable.feature b/spec/test/features/control/append/error_unprocessable.feature index 92121d9..06c2a0d 100644 --- a/spec/test/features/control/append/error_unprocessable.feature +++ b/spec/test/features/control/append/error_unprocessable.feature @@ -31,7 +31,13 @@ Feature: Kapow! server rejects requests with semantic errors. } """ Then I get 422 as response code -# And I get "Invalid Route" as response reason phrase + And the response header "Content-Type" contains "application/json" + And I get the following response body: + """ + { + "reason": "Invalid Route" + } + """ Scenario: Error because bad route format. If a request contains an invalid expression in the @@ -48,4 +54,10 @@ Feature: Kapow! server rejects requests with semantic errors. } """ Then I get 422 as response code -# And I get "Invalid Route" as response reason phrase + And the response header "Content-Type" contains "application/json" + And I get the following response body: + """ + { + "reason": "Invalid Route" + } + """ diff --git a/spec/test/features/control/append/success.feature b/spec/test/features/control/append/success.feature index f66ef29..bf67e0c 100644 --- a/spec/test/features/control/append/success.feature +++ b/spec/test/features/control/append/success.feature @@ -33,7 +33,7 @@ Feature: Append new routes in Kapow! server. } """ Then I get 201 as response code -# And I get "Created" as response reason phrase + And I get "Created" as response reason phrase And I get the following response body: """ { @@ -64,7 +64,7 @@ Feature: Append new routes in Kapow! server. } """ Then I get 201 as response code -# And I get "Created" as response reason phrase + And I get "Created" as response reason phrase And I get the following response body: """ { diff --git a/spec/test/features/control/delete/error_notfound.feature b/spec/test/features/control/delete/error_notfound.feature index 587363f..18d2a90 100644 --- a/spec/test/features/control/delete/error_notfound.feature +++ b/spec/test/features/control/delete/error_notfound.feature @@ -24,4 +24,10 @@ Feature: Fail to delete a route in Kapow! server. Given I have a just started Kapow! server When I delete the route with id "xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx" Then I get 404 as response code -# And I get "Not Found" as response reason phrase + And the response header "Content-Type" contains "application/json" + And I get the following response body: + """ + { + "reason": "Route Not Found" + } + """ diff --git a/spec/test/features/control/delete/success.feature b/spec/test/features/control/delete/success.feature index 0b7a12a..614f186 100644 --- a/spec/test/features/control/delete/success.feature +++ b/spec/test/features/control/delete/success.feature @@ -26,4 +26,4 @@ Feature: Delete routes in Kapow! server. | GET | /qux/{dirname} | /bin/sh -c | ls -la /request/params/dirname \| kapow set /response/body | When I delete the first route Then I get 204 as response code -# And I get "No Content" as response reason phrase + And I get "No Content" as response reason phrase diff --git a/spec/test/features/control/get/error_notfound.feature b/spec/test/features/control/get/error_notfound.feature index 903711c..dfdb951 100644 --- a/spec/test/features/control/get/error_notfound.feature +++ b/spec/test/features/control/get/error_notfound.feature @@ -24,4 +24,10 @@ Feature: Fail to retrieve route details in Kapow! server. Given I have a just started Kapow! server When I get the route with id "xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx" Then I get 404 as response code -# And I get "Not Found" as response reason phrase + And the response header "Content-Type" contains "application/json" + And I get the following response body: + """ + { + "reason": "Route Not Found" + } + """ diff --git a/spec/test/features/control/get/success.feature b/spec/test/features/control/get/success.feature index d8a73cf..04d1287 100644 --- a/spec/test/features/control/get/success.feature +++ b/spec/test/features/control/get/success.feature @@ -26,7 +26,7 @@ Feature: Retrieve route details in Kapow! server. | GET | /qux/{dirname} | /bin/sh -c | ls -la /request/params/dirname \| kapow set /response/body | When I get the first route Then I get 200 as response code -# And I get "OK" as response reason phrase + And I get "OK" as response reason phrase And I get the following response body: """ { diff --git a/spec/test/features/control/insert/error_malformed.feature b/spec/test/features/control/insert/error_malformed.feature index 22f9806..20596bc 100644 --- a/spec/test/features/control/insert/error_malformed.feature +++ b/spec/test/features/control/insert/error_malformed.feature @@ -37,4 +37,10 @@ Feature: Kapow! server rejects insertion requests with malformed JSON bodies. } """ Then I get 400 as response code -# And I get "Malformed JSON" as response reason phrase + And the response header "Content-Type" contains "application/json" + And I get the following response body: + """ + { + "reason": "Malformed JSON" + } + """ diff --git a/spec/test/features/control/insert/error_unprocessable.feature b/spec/test/features/control/insert/error_unprocessable.feature index 07f2a19..18468da 100644 --- a/spec/test/features/control/insert/error_unprocessable.feature +++ b/spec/test/features/control/insert/error_unprocessable.feature @@ -31,7 +31,7 @@ Feature: Kapow! server rejects insertion requests with semantic errors. } """ Then I get 422 as response code -# And I get "Invalid Route" as response reason phrase + And I get "Invalid Route" as response reason phrase Scenario: Error because wrong route specification. If a request contains an invalid expression in the @@ -49,7 +49,7 @@ Feature: Kapow! server rejects insertion requests with semantic errors. } """ Then I get 422 as response code -# And I get "Invalid Route" as response reason phrase + And I get "Invalid Route" as response reason phrase Scenario: Error because negative index specified. If a request contains a negative number in the @@ -67,4 +67,10 @@ Feature: Kapow! server rejects insertion requests with semantic errors. } """ Then I get 422 as response code -# And I get "Invalid Route" as response reason phrase + And the response header "Content-Type" contains "application/json" + And I get the following response body: + """ + { + "reason": "Invalid Route" + } + """ diff --git a/spec/test/features/control/insert/success.feature b/spec/test/features/control/insert/success.feature index 9264106..d93d5e1 100644 --- a/spec/test/features/control/insert/success.feature +++ b/spec/test/features/control/insert/success.feature @@ -40,7 +40,7 @@ Feature: Insert new routes in Kapow! server. } """ Then I get 201 as response code -# And I get "Created" as response reason phrase + And I get "Created" as response reason phrase And I get the following response body: """ { @@ -69,7 +69,7 @@ Feature: Insert new routes in Kapow! server. } """ Then I get 201 as response code -# And I get "Created" as response reason phrase + And I get "Created" as response reason phrase And I get the following response body: """ { diff --git a/spec/test/features/control/list/success.feature b/spec/test/features/control/list/success.feature index 4817467..0361043 100644 --- a/spec/test/features/control/list/success.feature +++ b/spec/test/features/control/list/success.feature @@ -25,7 +25,7 @@ Feature: Listing routes in a Kapow! server. Given I have a just started Kapow! server When I request a routes listing Then I get 200 as response code -# And I get "OK" as response reason phrase + And I get "OK" as response reason phrase And I get the following response body: """ [] @@ -41,7 +41,7 @@ Feature: Listing routes in a Kapow! server. | GET | /qux/{dirname} | /bin/sh -c | ls -la /request/params/dirname \| kapow set /response/body | When I request a routes listing Then I get 200 as response code -# And I get "OK" as response reason phrase + And I get "OK" as response reason phrase And I get the following response body: """ [ diff --git a/spec/test/features/data/handler/error_handleridnotfound.feature b/spec/test/features/data/handler/error_handleridnotfound.feature index 782a2dc..464891e 100644 --- a/spec/test/features/data/handler/error_handleridnotfound.feature +++ b/spec/test/features/data/handler/error_handleridnotfound.feature @@ -25,14 +25,10 @@ Feature: Fail to retrieve resources from nonexistent handler in Kapow! server. Given I have a running Kapow! server When I get the resource "/request/path" for the handler with id "XXXXXXXXXX" Then I get 404 as response code -# And I get "Handler ID Not Found" as response reason phrase - - Scenario: Try to get an invalid resource from a nonexistent handler. - A request to retrieve an invalid resource from a nonexistent - handler will trigger an invalid resource path error - even if the resource is invalid. - - Given I have a running Kapow! server - When I get the resource "/invalid/path" for the handler with id "XXXXXXXXXX" - Then I get 400 as response code -# And I get "Invalid Resource Path" as response reason phrase + And the response header "Content-Type" contains "application/json" + And I get the following response body: + """ + { + "reason": "Handler ID Not Found" + } + """ diff --git a/spec/test/features/data/handler/error_invalidresource.feature b/spec/test/features/data/handler/error_invalidresource.feature index 7afa680..fc911f1 100644 --- a/spec/test/features/data/handler/error_invalidresource.feature +++ b/spec/test/features/data/handler/error_invalidresource.feature @@ -27,4 +27,10 @@ Feature: Fail to retrieve an invalid resource for a handler in Kapow! server. When I send a request to the testing route "/foo" And I get the resource "/invented/path" Then I get 400 as response code -# And I get "Invalid Resource Path" as response reason phrase + And the response header "Content-Type" contains "application/json" + And I get the following response body: + """ + { + "reason": "Invalid Resource Path" + } + """ diff --git a/spec/test/features/data/handler/error_itemnotfound.feature b/spec/test/features/data/handler/error_itemnotfound.feature index 9b505ab..16dd246 100644 --- a/spec/test/features/data/handler/error_itemnotfound.feature +++ b/spec/test/features/data/handler/error_itemnotfound.feature @@ -28,4 +28,10 @@ Feature: Fail to retrieve nonexistent resource items in Kapow! server. When I send a request to the testing route "/foo" And I get the resource "/request/params/meloinvento" Then I get 404 as response code -# And I get "Resource Item Not Found" as response reason phrase + And the response header "Content-Type" contains "application/json" + And I get the following response body: + """ + { + "reason": "Resource Item Not Found" + } + """ diff --git a/spec/test/features/data/handler/success.feature b/spec/test/features/data/handler/success.feature index 5c4bbc5..91c5a36 100644 --- a/spec/test/features/data/handler/success.feature +++ b/spec/test/features/data/handler/success.feature @@ -28,7 +28,7 @@ Feature: Retrieve a resource from a handler in Kapow! server. When I send a request to the testing route "/foo" And I get the resource "/request/path" Then I get 200 as response code -# And I get "OK" as response reason phrase + And I get "OK" as response reason phrase And I get the following response raw body: """ /foo @@ -44,7 +44,7 @@ Feature: Retrieve a resource from a handler in Kapow! server. When I send a request to the testing route "/foo?name=bar" And I get the resource "/request/params/name" Then I get 200 as response code -# And I get "OK" as response reason phrase + And I get "OK" as response reason phrase And I get the following response raw body: """ bar diff --git a/spec/test/features/data/request/success.feature b/spec/test/features/data/request/success.feature index 3c2fa2a..03ca2a9 100644 --- a/spec/test/features/data/request/success.feature +++ b/spec/test/features/data/request/success.feature @@ -34,7 +34,7 @@ Feature: Retrieve request resources from a handler in Kapow! server. | body | | bodyVal1 | And I get the resource "" Then I get 200 as response code -# And I get "OK" as response reason phrase + And I get "OK" as response reason phrase And I get the following response raw body: """ diff --git a/spec/test/features/data/response/success.feature b/spec/test/features/data/response/success.feature index 3fc46e7..05cff7e 100644 --- a/spec/test/features/data/response/success.feature +++ b/spec/test/features/data/response/success.feature @@ -41,7 +41,7 @@ Feature: Setting values for handler response resources in Kapow! server. And I set the resource "" with value "" And I release the testing request Then I get 200 as response code -# And I get "OK" as response reason phrase + And I get "OK" as response reason phrase And I get the value "" for the response "" named "" in the testing request Examples: diff --git a/spec/test/features/steps/steps.py b/spec/test/features/steps/steps.py index f280e0f..4fc6ffb 100644 --- a/spec/test/features/steps/steps.py +++ b/spec/test/features/steps/steps.py @@ -52,7 +52,7 @@ class Env(EnvironConfig): #: Where the User Interface is KAPOW_USER_URL = StringVar(default="http://localhost:8080") - KAPOW_BOOT_TIMEOUT = IntVar(default=10) + KAPOW_BOOT_TIMEOUT = IntVar(default=1000) KAPOW_DEBUG_TESTS = BooleanVar(default=False) @@ -185,6 +185,16 @@ def step_impl(context, code): assert context.testing_response.status_code == int(code), f"Got {context.testing_response.status_code} instead" +@then('the response header "{header_name}" contains "{value}"') +def step_impl(context, header_name, value): + assert context.response.headers.get(header_name, "").split(';')[0] == value, f"Got {context.response.headers.get(header_name)} instead" + + +@then('the testing response header {header_name} contains {value}') +def step_impl(context, header_name, value): + assert context.testing_response.headers.get(header_name) == value, f"Got {context.testing_response.headers.get(header_name)} instead" + + @then('I get "{reason}" as response reason phrase') def step_impl(context, reason): assert context.response.reason == reason, f"Got {context.response.reason} instead"