From 3e2b56ba89173699594e789e1c74b060217c47ae Mon Sep 17 00:00:00 2001 From: TwinProduction Date: Fri, 10 Apr 2020 22:56:38 -0400 Subject: [PATCH] Add support for [BODY] placeholder and basic JSON path support Note that arrays are not currently supported, same with asterisks --- README.md | 16 +++++---- config.yaml | 3 +- core/types.go | 24 +++++++++---- core/types_test.go | 80 +++++++++++++++++++++++++++++++++++++++++--- jsonpath/jsonpath.go | 30 +++++++++++++++++ 5 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 jsonpath/jsonpath.go diff --git a/README.md b/README.md index 8cee2e72..f2e70728 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,15 @@ Note that you can also add environment variables in the your configuration file Here are some examples of conditions you can use: -| Condition | Description | Values that would pass | Values that would fail | -| ------------------------------------- | ----------------------------------------- | ---------------------- | ---------------------- | -| `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, 500 | -| `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, 400, 500 | -| `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, 400, 500 | -| `[STATUS] > 400` | Status must be greater than 400 | 401, 402, 403, 404 | 200, 201, 300, 400 | -| `[RESPONSE_TIME] < 500` | Response time must be below 500ms | 100ms, 200ms, 300ms | 500ms, 1500ms | +| Condition | Description | Values that would pass | Values that would fail | +| ------------------------------------- | ----------------------------------------- | ------------------------ | ----------------------- | +| `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, 500 | +| `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, 400, 500 | +| `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, 400, 500 | +| `[STATUS] > 400` | Status must be greater than 400 | 401, 402, 403, 404 | 200, 201, 300, 400 | +| `[RESPONSE_TIME] < 500` | Response time must be below 500ms | 100ms, 200ms, 300ms | 500ms, 1500ms | +| `[BODY] == 1` | The body must be equal to 1 | 1 | literally anything else | +| (beta) `[BODY].data.id == 1` | The jsonpath `$.data.id` is equal to 1 | `{ "data" : { "id": 1 }` | literally anything else | ## Docker diff --git a/config.yaml b/config.yaml index a8fea6ae..80f5fc7f 100644 --- a/config.yaml +++ b/config.yaml @@ -5,7 +5,8 @@ services: interval: 10s conditions: - "[STATUS] == 200" - - "[RESPONSE_TIME] < 30" + - "[RESPONSE_TIME] < 500" + - "[BODY].status == UP" - name: Example url: https://example.org/ interval: 30s diff --git a/core/types.go b/core/types.go index d76f67eb..4ee2e906 100644 --- a/core/types.go +++ b/core/types.go @@ -2,6 +2,8 @@ package core import ( "fmt" + "github.com/TwinProduction/gatus/jsonpath" + "io/ioutil" "net" "net/http" "net/url" @@ -14,6 +16,7 @@ const ( StatusPlaceholder = "[STATUS]" IPPlaceHolder = "[IP]" ResponseTimePlaceHolder = "[RESPONSE_TIME]" + BodyPlaceHolder = "[BODY]" ) type HealthStatus struct { @@ -21,13 +24,9 @@ type HealthStatus struct { Message string `json:"message,omitempty"` } -type ServerMessage struct { - Error bool `json:"error"` - Message string `json:"message"` -} - type Result struct { HttpStatus int `json:"status"` + Body []byte `json:"-"` Hostname string `json:"hostname"` Ip string `json:"-"` Duration time.Duration `json:"duration"` @@ -59,7 +58,8 @@ func (service *Service) getIp(result *Result) { result.Ip = ips[0].String() } -func (service *Service) getStatus(result *Result) { +func (service *Service) call(result *Result) { + // TODO: re-use the same client instead of creating multiple clients client := &http.Client{ Timeout: time.Second * 10, } @@ -71,13 +71,17 @@ func (service *Service) getStatus(result *Result) { } result.Duration = time.Now().Sub(startTime) result.HttpStatus = response.StatusCode + result.Body, err = ioutil.ReadAll(response.Body) + if err != nil { + result.Errors = append(result.Errors, err.Error()) + } } func (service *Service) EvaluateConditions() *Result { result := &Result{Success: true, Errors: []string{}} service.getIp(result) if len(result.Errors) == 0 { - service.getStatus(result) + service.call(result) } else { result.Success = false } @@ -138,7 +142,13 @@ func sanitizeAndResolve(list []string, result *Result) []string { element = result.Ip case ResponseTimePlaceHolder: element = strconv.Itoa(int(result.Duration.Milliseconds())) + case BodyPlaceHolder: + element = string(result.Body) default: + // if starts with BodyPlaceHolder, then do the jsonpath thingy + if strings.HasPrefix(element, BodyPlaceHolder) { + element = jsonpath.Eval(strings.Replace(element, fmt.Sprintf("%s.", BodyPlaceHolder), "", 1), result.Body) + } } sanitizedList = append(sanitizedList, element) } diff --git a/core/types_test.go b/core/types_test.go index 860f244b..0f9ddfb6 100644 --- a/core/types_test.go +++ b/core/types_test.go @@ -86,11 +86,83 @@ func TestEvaluateWithResponseTimeUsingLessThanOrEqualTo(t *testing.T) { } } +func TestEvaluateWithBody(t *testing.T) { + condition := Condition("[BODY] == test") + result := &Result{Body: []byte("test")} + condition.evaluate(result) + if !result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a success", condition) + } +} + +func TestEvaluateWithBodyJsonPath(t *testing.T) { + condition := Condition("[BODY].status == UP") + result := &Result{Body: []byte("{\"status\":\"UP\"}")} + condition.evaluate(result) + if !result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a success", condition) + } +} + +func TestEvaluateWithBodyJsonPathComplex(t *testing.T) { + condition := Condition("[BODY].data.name == john") + result := &Result{Body: []byte("{\"data\": {\"id\": 1, \"name\": \"john\"}}")} + condition.evaluate(result) + if !result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a success", condition) + } +} + +func TestEvaluateWithBodyJsonPathComplexInt(t *testing.T) { + condition := Condition("[BODY].data.id == 1") + result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")} + condition.evaluate(result) + if !result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a success", condition) + } +} + +func TestEvaluateWithBodyJsonPathComplexIntUsingGreaterThan(t *testing.T) { + condition := Condition("[BODY].data.id > 0") + result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")} + condition.evaluate(result) + if !result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a success", condition) + } +} + +func TestEvaluateWithBodyJsonPathComplexIntFailureUsingGreaterThan(t *testing.T) { + condition := Condition("[BODY].data.id > 5") + result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")} + condition.evaluate(result) + if result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a failure", condition) + } +} + +func TestEvaluateWithBodyJsonPathComplexIntUsingLessThan(t *testing.T) { + condition := Condition("[BODY].data.id < 5") + result := &Result{Body: []byte("{\"data\": {\"id\": 2}}")} + condition.evaluate(result) + if !result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a success", condition) + } +} + +func TestEvaluateWithBodyJsonPathComplexIntFailureUsingLessThan(t *testing.T) { + condition := Condition("[BODY].data.id < 5") + result := &Result{Body: []byte("{\"data\": {\"id\": 10}}")} + condition.evaluate(result) + if result.ConditionResults[0].Success { + t.Errorf("Condition '%s' should have been a failure", condition) + } +} + func TestIntegrationEvaluateConditions(t *testing.T) { condition := Condition("[STATUS] == 200") service := Service{ - Name: "GitHub", - Url: "https://api.github.com/healthz", + Name: "TwiNNatioN", + Url: "https://twinnation.org/health", Conditions: []*Condition{&condition}, } result := service.EvaluateConditions() @@ -105,8 +177,8 @@ func TestIntegrationEvaluateConditions(t *testing.T) { func TestIntegrationEvaluateConditionsWithFailure(t *testing.T) { condition := Condition("[STATUS] == 500") service := Service{ - Name: "GitHub", - Url: "https://api.github.com/healthz", + Name: "TwiNNatioN", + Url: "https://twinnation.org/health", Conditions: []*Condition{&condition}, } result := service.EvaluateConditions() diff --git a/jsonpath/jsonpath.go b/jsonpath/jsonpath.go new file mode 100644 index 00000000..936b1968 --- /dev/null +++ b/jsonpath/jsonpath.go @@ -0,0 +1,30 @@ +package jsonpath + +import ( + "encoding/json" + "fmt" + "strings" +) + +func Eval(path string, b []byte) string { + var object map[string]interface{} + err := json.Unmarshal(b, &object) + if err != nil { + return "" + } + return walk(path, object) +} + +func walk(path string, object map[string]interface{}) string { + keys := strings.Split(path, ".") + targetKey := keys[0] + // if there's only one key and the target key is that key, then return its value + if len(keys) == 1 { + return fmt.Sprintf("%v", object[targetKey]) + } + // if there's more than one key, then walk deeper + if len(keys) > 0 { + return walk(strings.Replace(path, fmt.Sprintf("%s.", targetKey), "", 1), object[targetKey].(map[string]interface{})) + } + return "" +}