Add support for headers, method, body and json path with arrays

This commit is contained in:
TwinProduction
2020-04-14 19:20:00 -04:00
parent 88d0d8a724
commit fe3e60dbd4
14 changed files with 531 additions and 234 deletions

51
core/condition.go Normal file
View File

@ -0,0 +1,51 @@
package core
import (
"fmt"
"log"
"strings"
)
type Condition string
func (c *Condition) evaluate(result *Result) bool {
condition := string(*c)
success := false
var resolvedCondition string
if strings.Contains(condition, "==") {
parts := sanitizeAndResolve(strings.Split(condition, "=="), result)
success = parts[0] == parts[1]
resolvedCondition = fmt.Sprintf("%v == %v", parts[0], parts[1])
} else if strings.Contains(condition, "!=") {
parts := sanitizeAndResolve(strings.Split(condition, "!="), result)
success = parts[0] != parts[1]
resolvedCondition = fmt.Sprintf("%v != %v", parts[0], parts[1])
} else if strings.Contains(condition, "<=") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
success = parts[0] <= parts[1]
resolvedCondition = fmt.Sprintf("%v <= %v", parts[0], parts[1])
} else if strings.Contains(condition, ">=") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result)
success = parts[0] >= parts[1]
resolvedCondition = fmt.Sprintf("%v >= %v", parts[0], parts[1])
} else if strings.Contains(condition, ">") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result)
success = parts[0] > parts[1]
resolvedCondition = fmt.Sprintf("%v > %v", parts[0], parts[1])
} else if strings.Contains(condition, "<") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result)
success = parts[0] < parts[1]
resolvedCondition = fmt.Sprintf("%v < %v", parts[0], parts[1])
} else {
result.Errors = append(result.Errors, fmt.Sprintf("invalid condition '%s' has been provided", condition))
return false
}
conditionToDisplay := condition
// If the condition isn't a success, return the resolved condition
if !success {
log.Printf("[Condition][evaluate] Condition '%s' did not succeed because '%s' is false", condition, resolvedCondition)
conditionToDisplay = resolvedCondition
}
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})
return success
}

View File

@ -5,7 +5,7 @@ import (
"time"
)
func TestEvaluateWithIp(t *testing.T) {
func TestCondition_evaluateWithIp(t *testing.T) {
condition := Condition("[IP] == 127.0.0.1")
result := &Result{Ip: "127.0.0.1"}
condition.evaluate(result)
@ -14,7 +14,7 @@ func TestEvaluateWithIp(t *testing.T) {
}
}
func TestEvaluateWithStatus(t *testing.T) {
func TestCondition_evaluateWithStatus(t *testing.T) {
condition := Condition("[STATUS] == 201")
result := &Result{HttpStatus: 201}
condition.evaluate(result)
@ -23,7 +23,7 @@ func TestEvaluateWithStatus(t *testing.T) {
}
}
func TestEvaluateWithStatusFailure(t *testing.T) {
func TestCondition_evaluateWithStatusFailure(t *testing.T) {
condition := Condition("[STATUS] == 200")
result := &Result{HttpStatus: 500}
condition.evaluate(result)
@ -32,7 +32,7 @@ func TestEvaluateWithStatusFailure(t *testing.T) {
}
}
func TestEvaluateWithStatusUsingLessThan(t *testing.T) {
func TestCondition_evaluateWithStatusUsingLessThan(t *testing.T) {
condition := Condition("[STATUS] < 300")
result := &Result{HttpStatus: 201}
condition.evaluate(result)
@ -41,7 +41,7 @@ func TestEvaluateWithStatusUsingLessThan(t *testing.T) {
}
}
func TestEvaluateWithStatusFailureUsingLessThan(t *testing.T) {
func TestCondition_evaluateWithStatusFailureUsingLessThan(t *testing.T) {
condition := Condition("[STATUS] < 300")
result := &Result{HttpStatus: 404}
condition.evaluate(result)
@ -50,7 +50,7 @@ func TestEvaluateWithStatusFailureUsingLessThan(t *testing.T) {
}
}
func TestEvaluateWithResponseTimeUsingLessThan(t *testing.T) {
func TestCondition_evaluateWithResponseTimeUsingLessThan(t *testing.T) {
condition := Condition("[RESPONSE_TIME] < 500")
result := &Result{Duration: time.Millisecond * 50}
condition.evaluate(result)
@ -59,7 +59,7 @@ func TestEvaluateWithResponseTimeUsingLessThan(t *testing.T) {
}
}
func TestEvaluateWithResponseTimeUsingGreaterThan(t *testing.T) {
func TestCondition_evaluateWithResponseTimeUsingGreaterThan(t *testing.T) {
condition := Condition("[RESPONSE_TIME] > 500")
result := &Result{Duration: time.Millisecond * 750}
condition.evaluate(result)
@ -68,7 +68,7 @@ func TestEvaluateWithResponseTimeUsingGreaterThan(t *testing.T) {
}
}
func TestEvaluateWithResponseTimeUsingGreaterThanOrEqualTo(t *testing.T) {
func TestCondition_evaluateWithResponseTimeUsingGreaterThanOrEqualTo(t *testing.T) {
condition := Condition("[RESPONSE_TIME] >= 500")
result := &Result{Duration: time.Millisecond * 500}
condition.evaluate(result)
@ -77,7 +77,7 @@ func TestEvaluateWithResponseTimeUsingGreaterThanOrEqualTo(t *testing.T) {
}
}
func TestEvaluateWithResponseTimeUsingLessThanOrEqualTo(t *testing.T) {
func TestCondition_evaluateWithResponseTimeUsingLessThanOrEqualTo(t *testing.T) {
condition := Condition("[RESPONSE_TIME] <= 500")
result := &Result{Duration: time.Millisecond * 500}
condition.evaluate(result)
@ -86,7 +86,7 @@ func TestEvaluateWithResponseTimeUsingLessThanOrEqualTo(t *testing.T) {
}
}
func TestEvaluateWithBody(t *testing.T) {
func TestCondition_evaluateWithBody(t *testing.T) {
condition := Condition("[BODY] == test")
result := &Result{Body: []byte("test")}
condition.evaluate(result)
@ -95,7 +95,7 @@ func TestEvaluateWithBody(t *testing.T) {
}
}
func TestEvaluateWithBodyJsonPath(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPath(t *testing.T) {
condition := Condition("[BODY].status == UP")
result := &Result{Body: []byte("{\"status\":\"UP\"}")}
condition.evaluate(result)
@ -104,7 +104,7 @@ func TestEvaluateWithBodyJsonPath(t *testing.T) {
}
}
func TestEvaluateWithBodyJsonPathComplex(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPathComplex(t *testing.T) {
condition := Condition("[BODY].data.name == john")
result := &Result{Body: []byte("{\"data\": {\"id\": 1, \"name\": \"john\"}}")}
condition.evaluate(result)
@ -113,7 +113,7 @@ func TestEvaluateWithBodyJsonPathComplex(t *testing.T) {
}
}
func TestEvaluateWithBodyJsonPathComplexInt(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPathLongInt(t *testing.T) {
condition := Condition("[BODY].data.id == 1")
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
condition.evaluate(result)
@ -122,7 +122,16 @@ func TestEvaluateWithBodyJsonPathComplexInt(t *testing.T) {
}
}
func TestEvaluateWithBodyJsonPathComplexIntUsingGreaterThan(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPathComplexInt(t *testing.T) {
condition := Condition("[BODY].data[1].id == 2")
result := &Result{Body: []byte("{\"data\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}")}
condition.evaluate(result)
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
}
func TestCondition_evaluateWithBodyJsonPathComplexIntUsingGreaterThan(t *testing.T) {
condition := Condition("[BODY].data.id > 0")
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
condition.evaluate(result)
@ -131,7 +140,7 @@ func TestEvaluateWithBodyJsonPathComplexIntUsingGreaterThan(t *testing.T) {
}
}
func TestEvaluateWithBodyJsonPathComplexIntFailureUsingGreaterThan(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPathComplexIntFailureUsingGreaterThan(t *testing.T) {
condition := Condition("[BODY].data.id > 5")
result := &Result{Body: []byte("{\"data\": {\"id\": 1}}")}
condition.evaluate(result)
@ -140,7 +149,7 @@ func TestEvaluateWithBodyJsonPathComplexIntFailureUsingGreaterThan(t *testing.T)
}
}
func TestEvaluateWithBodyJsonPathComplexIntUsingLessThan(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPathComplexIntUsingLessThan(t *testing.T) {
condition := Condition("[BODY].data.id < 5")
result := &Result{Body: []byte("{\"data\": {\"id\": 2}}")}
condition.evaluate(result)
@ -149,7 +158,7 @@ func TestEvaluateWithBodyJsonPathComplexIntUsingLessThan(t *testing.T) {
}
}
func TestEvaluateWithBodyJsonPathComplexIntFailureUsingLessThan(t *testing.T) {
func TestCondition_evaluateWithBodyJsonPathComplexIntFailureUsingLessThan(t *testing.T) {
condition := Condition("[BODY].data.id < 5")
result := &Result{Body: []byte("{\"data\": {\"id\": 10}}")}
condition.evaluate(result)
@ -157,35 +166,3 @@ func TestEvaluateWithBodyJsonPathComplexIntFailureUsingLessThan(t *testing.T) {
t.Errorf("Condition '%s' should have been a failure", condition)
}
}
func TestIntegrationEvaluateConditions(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "TwiNNatioN",
Url: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
}
result := service.EvaluateConditions()
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
if !result.Success {
t.Error("Because all conditions passed, this should have been a success")
}
}
func TestIntegrationEvaluateConditionsWithFailure(t *testing.T) {
condition := Condition("[STATUS] == 500")
service := Service{
Name: "TwiNNatioN",
Url: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
}
result := service.EvaluateConditions()
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
if result.Success {
t.Error("Because one of the conditions failed, success should have been false")
}
}

95
core/service.go Normal file
View File

@ -0,0 +1,95 @@
package core
import (
"bytes"
"github.com/TwinProduction/gatus/client"
"io/ioutil"
"net"
"net/http"
"net/url"
"time"
)
type Service struct {
Name string `yaml:"name"`
Interval time.Duration `yaml:"interval,omitempty"`
Url string `yaml:"url"`
Method string `yaml:"method,omitempty"`
Body string `yaml:"body,omitempty"`
Headers map[string]string `yaml:"headers"`
Conditions []*Condition `yaml:"conditions"`
}
func (service *Service) Validate() {
// Set default values
if service.Interval == 0 {
service.Interval = 10 * time.Second
}
if len(service.Method) == 0 {
service.Method = http.MethodGet
}
// Make sure that the request can be created
_, err := http.NewRequest(service.Method, service.Url, bytes.NewBuffer([]byte(service.Body)))
if err != nil {
panic(err)
}
}
func (service *Service) EvaluateConditions() *Result {
result := &Result{Success: true, Errors: []string{}}
service.getIp(result)
if len(result.Errors) == 0 {
service.call(result)
} else {
result.Success = false
}
for _, condition := range service.Conditions {
success := condition.evaluate(result)
if !success {
result.Success = false
}
}
result.Timestamp = time.Now()
return result
}
func (service *Service) getIp(result *Result) {
urlObject, err := url.Parse(service.Url)
if err != nil {
result.Errors = append(result.Errors, err.Error())
return
}
result.Hostname = urlObject.Hostname()
ips, err := net.LookupIP(urlObject.Hostname())
if err != nil {
result.Errors = append(result.Errors, err.Error())
return
}
result.Ip = ips[0].String()
}
func (service *Service) call(result *Result) {
request := service.buildRequest()
startTime := time.Now()
response, err := client.GetHttpClient().Do(request)
if err != nil {
result.Duration = time.Since(startTime)
result.Errors = append(result.Errors, err.Error())
return
}
result.Duration = time.Since(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) buildRequest() *http.Request {
request, _ := http.NewRequest(service.Method, service.Url, bytes.NewBuffer([]byte(service.Body)))
for k, v := range service.Headers {
request.Header.Set(k, v)
}
return request
}

37
core/service_test.go Normal file
View File

@ -0,0 +1,37 @@
package core
import (
"testing"
)
func TestIntegrationEvaluateConditions(t *testing.T) {
condition := Condition("[STATUS] == 200")
service := Service{
Name: "TwiNNatioN",
Url: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
}
result := service.EvaluateConditions()
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
if !result.Success {
t.Error("Because all conditions passed, this should have been a success")
}
}
func TestIntegrationEvaluateConditionsWithFailure(t *testing.T) {
condition := Condition("[STATUS] == 500")
service := Service{
Name: "TwiNNatioN",
Url: "https://twinnation.org/health",
Conditions: []*Condition{&condition},
}
result := service.EvaluateConditions()
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
if result.Success {
t.Error("Because one of the conditions failed, success should have been false")
}
}

View File

@ -1,24 +1,9 @@
package core
import (
"fmt"
"github.com/TwinProduction/gatus/jsonpath"
"io/ioutil"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
StatusPlaceholder = "[STATUS]"
IPPlaceHolder = "[IP]"
ResponseTimePlaceHolder = "[RESPONSE_TIME]"
BodyPlaceHolder = "[BODY]"
)
type HealthStatus struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
@ -36,135 +21,7 @@ type Result struct {
Timestamp time.Time `json:"timestamp"`
}
type Service struct {
Name string `yaml:"name"`
Url string `yaml:"url"`
Interval time.Duration `yaml:"interval,omitempty"`
Conditions []*Condition `yaml:"conditions"`
}
func (service *Service) getIp(result *Result) {
urlObject, err := url.Parse(service.Url)
if err != nil {
result.Errors = append(result.Errors, err.Error())
return
}
result.Hostname = urlObject.Hostname()
ips, err := net.LookupIP(urlObject.Hostname())
if err != nil {
result.Errors = append(result.Errors, err.Error())
return
}
result.Ip = ips[0].String()
}
func (service *Service) call(result *Result) {
// TODO: re-use the same client instead of creating multiple clients
client := &http.Client{
Timeout: time.Second * 10,
}
startTime := time.Now()
response, err := client.Get(service.Url)
if err != nil {
result.Errors = append(result.Errors, err.Error())
return
}
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.call(result)
} else {
result.Success = false
}
for _, condition := range service.Conditions {
success := condition.evaluate(result)
if !success {
result.Success = false
}
}
result.Timestamp = time.Now()
return result
}
type ConditionResult struct {
Condition *Condition `json:"condition"`
Success bool `json:"success"`
}
type Condition string
func (c *Condition) evaluate(result *Result) bool {
condition := string(*c)
success := false
if strings.Contains(condition, "==") {
parts := sanitizeAndResolve(strings.Split(condition, "=="), result)
success = parts[0] == parts[1]
} else if strings.Contains(condition, "!=") {
parts := sanitizeAndResolve(strings.Split(condition, "!="), result)
success = parts[0] != parts[1]
} else if strings.Contains(condition, "<=") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
success = parts[0] <= parts[1]
} else if strings.Contains(condition, ">=") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result)
success = parts[0] >= parts[1]
} else if strings.Contains(condition, ">") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result)
success = parts[0] > parts[1]
} else if strings.Contains(condition, "<") {
parts := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result)
success = parts[0] < parts[1]
} else {
result.Errors = append(result.Errors, fmt.Sprintf("invalid condition '%s' has been provided", condition))
return false
}
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: c, Success: success})
return success
}
func sanitizeAndResolve(list []string, result *Result) []string {
var sanitizedList []string
for _, element := range list {
element = strings.TrimSpace(element)
switch strings.ToUpper(element) {
case StatusPlaceholder:
element = strconv.Itoa(result.HttpStatus)
case IPPlaceHolder:
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)
}
return sanitizedList
}
func sanitizeAndResolveNumerical(list []string, result *Result) []int {
var sanitizedNumbers []int
sanitizedList := sanitizeAndResolve(list, result)
for _, element := range sanitizedList {
if number, err := strconv.Atoi(element); err != nil {
// Default to 0 if the string couldn't be converted to an integer
sanitizedNumbers = append(sanitizedNumbers, 0)
} else {
sanitizedNumbers = append(sanitizedNumbers, number)
}
}
return sanitizedNumbers
Condition string `json:"condition"`
Success bool `json:"success"`
}

61
core/util.go Normal file
View File

@ -0,0 +1,61 @@
package core
import (
"fmt"
"github.com/TwinProduction/gatus/jsonpath"
"strconv"
"strings"
)
const (
StatusPlaceholder = "[STATUS]"
IPPlaceHolder = "[IP]"
ResponseTimePlaceHolder = "[RESPONSE_TIME]"
BodyPlaceHolder = "[BODY]"
InvalidConditionElementSuffix = "(INVALID)"
)
func sanitizeAndResolve(list []string, result *Result) []string {
var sanitizedList []string
for _, element := range list {
element = strings.TrimSpace(element)
switch strings.ToUpper(element) {
case StatusPlaceholder:
element = strconv.Itoa(result.HttpStatus)
case IPPlaceHolder:
element = result.Ip
case ResponseTimePlaceHolder:
element = strconv.Itoa(int(result.Duration.Milliseconds()))
case BodyPlaceHolder:
element = string(result.Body)
default:
// if starts with BodyPlaceHolder, then evaluate json path
if strings.HasPrefix(element, BodyPlaceHolder) {
resolvedElement, err := jsonpath.Eval(strings.Replace(element, fmt.Sprintf("%s.", BodyPlaceHolder), "", 1), result.Body)
if err != nil {
result.Errors = append(result.Errors, err.Error())
element = fmt.Sprintf("%s %s", element, InvalidConditionElementSuffix)
} else {
element = resolvedElement
}
}
}
sanitizedList = append(sanitizedList, element)
}
return sanitizedList
}
func sanitizeAndResolveNumerical(list []string, result *Result) []int {
var sanitizedNumbers []int
sanitizedList := sanitizeAndResolve(list, result)
for _, element := range sanitizedList {
if number, err := strconv.Atoi(element); err != nil {
// Default to 0 if the string couldn't be converted to an integer
sanitizedNumbers = append(sanitizedNumbers, 0)
} else {
sanitizedNumbers = append(sanitizedNumbers, number)
}
}
return sanitizedNumbers
}