diff --git a/client/client.go b/client/client.go new file mode 100644 index 00000000..bc0b9381 --- /dev/null +++ b/client/client.go @@ -0,0 +1,19 @@ +package client + +import ( + "net/http" + "time" +) + +var ( + client *http.Client +) + +func GetHttpClient() *http.Client { + if client == nil { + client = &http.Client{ + Timeout: time.Second * 10, + } + } + return client +} diff --git a/config.yaml b/config.yaml index 80f5fc7f..796209cc 100644 --- a/config.yaml +++ b/config.yaml @@ -1,14 +1,21 @@ metrics: true services: - - name: Twinnation - url: https://twinnation.org/health +# - name: twinnation +# interval: 10s +# url: https://twinnation.org/health +# conditions: +# - "[STATUS] == 200" +# - "[RESPONSE_TIME] < 1000" +# - "[BODY].status == UP" + - name: twinnation-articles-api interval: 10s + url: https://twinnation.org/api/v1/articles conditions: - "[STATUS] == 200" - - "[RESPONSE_TIME] < 500" - - "[BODY].status == UP" - - name: Example - url: https://example.org/ - interval: 30s - conditions: - - "[STATUS] == 200" \ No newline at end of file + - "[BODY].[0].id == 42" + +# - name: example +# url: https://example.org/ +# interval: 30s +# conditions: +# - "[STATUS] == 200" diff --git a/config/config.go b/config/config.go index 09545934..5af058bd 100644 --- a/config/config.go +++ b/config/config.go @@ -7,7 +7,6 @@ import ( "io/ioutil" "log" "os" - "time" ) type Config struct { @@ -74,9 +73,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) { } else { // Set the default values if they aren't set for _, service := range config.Services { - if service.Interval == 0 { - service.Interval = 10 * time.Second - } + service.Validate() } } return diff --git a/core/condition.go b/core/condition.go new file mode 100644 index 00000000..a403e685 --- /dev/null +++ b/core/condition.go @@ -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 +} diff --git a/core/types_test.go b/core/condition_test.go similarity index 69% rename from core/types_test.go rename to core/condition_test.go index 0f9ddfb6..7d90ef92 100644 --- a/core/types_test.go +++ b/core/condition_test.go @@ -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") - } -} diff --git a/core/service.go b/core/service.go new file mode 100644 index 00000000..89b657ad --- /dev/null +++ b/core/service.go @@ -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 +} diff --git a/core/service_test.go b/core/service_test.go new file mode 100644 index 00000000..f45f8189 --- /dev/null +++ b/core/service_test.go @@ -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") + } +} diff --git a/core/types.go b/core/types.go index 4ee2e906..c15dd0e2 100644 --- a/core/types.go +++ b/core/types.go @@ -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"` } diff --git a/core/util.go b/core/util.go new file mode 100644 index 00000000..1e3e85e7 --- /dev/null +++ b/core/util.go @@ -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 +} diff --git a/jsonpath/jsonpath.go b/jsonpath/jsonpath.go index 936b1968..dcbf293a 100644 --- a/jsonpath/jsonpath.go +++ b/jsonpath/jsonpath.go @@ -3,28 +3,69 @@ package jsonpath import ( "encoding/json" "fmt" + "strconv" "strings" ) -func Eval(path string, b []byte) string { - var object map[string]interface{} +func Eval(path string, b []byte) (string, error) { + var object interface{} err := json.Unmarshal(b, &object) if err != nil { - return "" + // Try to unmarshall it into an array instead + return "", err } return walk(path, object) } -func walk(path string, object map[string]interface{}) string { +func walk(path string, object interface{}) (string, error) { 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]) - } + currentKey := keys[0] // 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{})) + if len(keys) > 1 { + switch value := extractValue(currentKey, object).(type) { + case map[string]interface{}: + return walk(strings.Replace(path, fmt.Sprintf("%s.", currentKey), "", 1), value) + case interface{}: + return fmt.Sprintf("%v", value), nil + default: + return "", fmt.Errorf("couldn't walk through '%s' because type was '%T', but expected 'map[string]interface{}'", currentKey, value) + } } - return "" + // if there's only one key and the target key is that key, then return its value + return fmt.Sprintf("%v", extractValue(currentKey, object)), nil +} + +func extractValue(currentKey string, value interface{}) interface{} { + // Check if the current key ends with [#] + if strings.HasSuffix(currentKey, "]") && strings.Contains(currentKey, "[") { + tmp := strings.SplitN(currentKey, "[", 3) + arrayIndex, err := strconv.Atoi(strings.Replace(tmp[1], "]", "", 1)) + if err != nil { + return value + } + currentKey := tmp[0] + // if currentKey contains only an index (i.e. [0] or 0) + if len(currentKey) == 0 { + array := value.([]interface{}) + if len(array) > arrayIndex { + if len(tmp) > 2 { + // Nested array? Go deeper. + return extractValue(fmt.Sprintf("%s[%s", currentKey, tmp[2]), array[arrayIndex]) + } + return array[arrayIndex] + } + return nil + } + // if currentKey contains both a key and an index (i.e. data[0]) + array := value.(map[string]interface{})[currentKey].([]interface{}) + if len(array) > arrayIndex { + if len(tmp) > 2 { + // Nested array? Go deeper. + return extractValue(fmt.Sprintf("[%s", tmp[2]), array[arrayIndex]) + } + return array[arrayIndex] + } + return nil + } + return value.(map[string]interface{})[currentKey] } diff --git a/jsonpath/jsonpath_test.go b/jsonpath/jsonpath_test.go new file mode 100644 index 00000000..1c9e944c --- /dev/null +++ b/jsonpath/jsonpath_test.go @@ -0,0 +1,148 @@ +package jsonpath + +import "testing" + +func TestEval(t *testing.T) { + path := "simple" + data := `{"simple": "value"}` + + expectedOutput := "value" + + output, err := Eval(path, []byte(data)) + if err != nil { + t.Error("Didn't expect any error, but got", err) + } + if output != expectedOutput { + t.Errorf("Expected output to be %v, but was %v", expectedOutput, output) + } +} + +func TestEvalWithLongSimpleWalk(t *testing.T) { + path := "long.simple.walk" + data := `{"long": {"simple": {"walk": "value"}}}` + + expectedOutput := "value" + + output, err := Eval(path, []byte(data)) + if err != nil { + t.Error("Didn't expect any error, but got", err) + } + if output != expectedOutput { + t.Errorf("Expected output to be %v, but was %v", expectedOutput, output) + } +} + +func TestEvalWithArrayOfMaps(t *testing.T) { + path := "ids[1].id" + data := `{"ids": [{"id": 1}, {"id": 2}]}` + + expectedOutput := "2" + + output, err := Eval(path, []byte(data)) + if err != nil { + t.Error("Didn't expect any error, but got", err) + } + if output != expectedOutput { + t.Errorf("Expected output to be %v, but was %v", expectedOutput, output) + } +} + +func TestEvalWithArrayOfValues(t *testing.T) { + path := "ids[0]" + data := `{"ids": [1, 2]}` + + expectedOutput := "1" + + output, err := Eval(path, []byte(data)) + if err != nil { + t.Error("Didn't expect any error, but got", err) + } + if output != expectedOutput { + t.Errorf("Expected output to be %v, but was %v", expectedOutput, output) + } +} + +func TestEvalWithRootArrayOfValues(t *testing.T) { + path := "[1]" + data := `[1, 2]` + + expectedOutput := "2" + + output, err := Eval(path, []byte(data)) + if err != nil { + t.Error("Didn't expect any error, but got", err) + } + if output != expectedOutput { + t.Errorf("Expected output to be %v, but was %v", expectedOutput, output) + } +} + +func TestEvalWithRootArrayOfMaps(t *testing.T) { + path := "[0].id" + data := `[{"id": 1}, {"id": 2}]` + + expectedOutput := "1" + + output, err := Eval(path, []byte(data)) + if err != nil { + t.Error("Didn't expect any error, but got", err) + } + if output != expectedOutput { + t.Errorf("Expected output to be %v, but was %v", expectedOutput, output) + } +} + +func TestEvalWithRootArrayOfMapsUsingInvalidArrayIndex(t *testing.T) { + path := "[5].id" + data := `[{"id": 1}, {"id": 2}]` + + _, err := Eval(path, []byte(data)) + if err == nil { + t.Error("Should've returned an error, but didn't") + } +} + +func TestEvalWithLongWalkAndArray(t *testing.T) { + path := "data.ids[0].id" + data := `{"data": {"ids": [{"id": 1}, {"id": 2}, {"id": 3}]}}` + + expectedOutput := "1" + + output, err := Eval(path, []byte(data)) + if err != nil { + t.Error("Didn't expect any error, but got", err) + } + if output != expectedOutput { + t.Errorf("Expected output to be %v, but was %v", expectedOutput, output) + } +} + +func TestEvalWithNestedArray(t *testing.T) { + path := "[3][2]" + data := `[[1, 2], [3, 4], [], [5, 6, 7]]` + + expectedOutput := "7" + + output, err := Eval(path, []byte(data)) + if err != nil { + t.Error("Didn't expect any error, but got", err) + } + if output != expectedOutput { + t.Errorf("Expected output to be %v, but was %v", expectedOutput, output) + } +} + +func TestEvalWithMapOfNestedArray(t *testing.T) { + path := "data[1][1]" + data := `{"data": [["a", "b", "c"], ["d", "e", "f"]]}` + + expectedOutput := "e" + + output, err := Eval(path, []byte(data)) + if err != nil { + t.Error("Didn't expect any error, but got", err) + } + if output != expectedOutput { + t.Errorf("Expected output to be %v, but was %v", expectedOutput, output) + } +} diff --git a/main.go b/main.go index 4fa8a725..1651bec5 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,6 @@ package main import ( "encoding/json" "github.com/TwinProduction/gatus/config" - "github.com/TwinProduction/gatus/core" "github.com/TwinProduction/gatus/watchdog" "github.com/prometheus/client_golang/prometheus/promhttp" "log" @@ -13,7 +12,6 @@ import ( func main() { cfg := loadConfiguration() - go watchdog.Monitor(cfg) http.HandleFunc("/api/v1/results", serviceResultsHandler) http.HandleFunc("/health", healthHandler) http.Handle("/", http.FileServer(http.Dir("./static"))) @@ -21,6 +19,7 @@ func main() { http.Handle("/metrics", promhttp.Handler()) } log.Println("[main][main] Listening on port 8080") + go watchdog.Monitor(cfg) log.Fatal(http.ListenAndServe(":8080", nil)) } @@ -40,21 +39,20 @@ func loadConfiguration() *config.Config { func serviceResultsHandler(writer http.ResponseWriter, _ *http.Request) { serviceResults := watchdog.GetServiceResults() + data, err := json.Marshal(serviceResults) + if err != nil { + log.Printf("[main][serviceResultsHandler] Unable to marshall object to JSON: %s", err.Error()) + writer.WriteHeader(http.StatusInternalServerError) + _, _ = writer.Write([]byte("Unable to marshall object to JSON")) + return + } writer.Header().Add("Content-type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(structToJsonBytes(serviceResults)) + _, _ = writer.Write(data) } func healthHandler(writer http.ResponseWriter, _ *http.Request) { writer.Header().Add("Content-type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(structToJsonBytes(&core.HealthStatus{Status: "UP"})) -} - -func structToJsonBytes(obj interface{}) []byte { - bytes, err := json.Marshal(obj) - if err != nil { - log.Printf("[main][structToJsonBytes] Unable to marshall object to JSON: %s", err.Error()) - } - return bytes + _, _ = writer.Write([]byte("{\"status\":\"UP\"}")) } diff --git a/static/index.html b/static/index.html index 1012f6ee..0e6b48f0 100644 --- a/static/index.html +++ b/static/index.html @@ -49,13 +49,13 @@ let conditions = ""; for (let conditionResultIndex in serviceResult['condition-results']) { let conditionResult = serviceResult['condition-results'][conditionResultIndex]; - conditions += "\n- " + (conditionResult.success ? "✓" : "X") + " ~ " + conditionResult.condition; + conditions += "\n" + (conditionResult.success ? "✓" : "X") + " ~ " + htmlEntities(conditionResult.condition); } output = output.replace("__CONDITIONS__", "\n\nConditions:" + conditions); if (serviceResult['errors'].length > 0) { let errors = ""; for (let errorIndex in serviceResult['errors']) { - errors += "\n- " + serviceResult['errors'][errorIndex]; + errors += "\n- " + htmlEntities(serviceResult['errors'][errorIndex]); } output = output.replace("__ERRORS__", "\n\nErrors: " + errors); } else { @@ -116,6 +116,15 @@ return YYYY+"-"+MM+"-"+DD+" "+hh+":"+mm+":"+ss; } + function htmlEntities(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + refreshResults(); setInterval(function() { refreshResults(); diff --git a/watchdog/watchdog.go b/watchdog/watchdog.go index 1374c2e3..779e9cb7 100644 --- a/watchdog/watchdog.go +++ b/watchdog/watchdog.go @@ -22,7 +22,7 @@ func Monitor(cfg *config.Config) { for _, service := range cfg.Services { go monitor(service) // To prevent multiple requests from running at the same time - time.Sleep(500 * time.Millisecond) + time.Sleep(1111 * time.Millisecond) } }