Add support for [BODY] placeholder and basic JSON path support

Note that arrays are not currently supported, same with asterisks
This commit is contained in:
TwinProduction 2020-04-10 22:56:38 -04:00
parent 15b8f8a293
commit 3e2b56ba89
5 changed files with 134 additions and 19 deletions

View File

@ -38,12 +38,14 @@ 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 |
| `[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

View File

@ -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

View File

@ -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)
}

View File

@ -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()

30
jsonpath/jsonpath.go Normal file
View File

@ -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 ""
}