Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion loader/condition.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package loader

import (
"fmt"
"healthy-api/model"
"healthy-api/registry"
"log/slog"
Expand All @@ -9,11 +10,19 @@ import (
func LoadConditions(cfg *model.Config, reg *registry.Registry[model.Condition], logger *slog.Logger) int {
count := 0
for _, cond := range cfg.Conditions {
if cond.ID == "" {
logger.Error("invalid_condition", "error", "missing condition ID")
continue
}
if _, ok := reg.Get(cond.ID); ok {
logger.Error("condition_already_exists", "id", cond.ID)
continue
}
if err := cond.Condition.Validate("conditions.condition"); err != nil {
if cond.Condition == nil {
logger.Error("invalid_condition", "id", cond.ID, "error", "condition body is missing (check YAML structure)")
continue
}
if err := cond.Condition.Validate(fmt.Sprintf("conditions[%s].condition", cond.ID)); err != nil {
logger.Error("invalid_condition", "id", cond.ID, "error", err)
continue
}
Expand Down
113 changes: 62 additions & 51 deletions model/condition.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,32 @@ func (c *Condition) Validate(path string) error {
}

func (c *Condition) Evaluate(resp *http.Response, body []byte, duration time.Duration) EvaluationResult {
// 1. منطق AND
// 1. Evaluate NOT Logic (First Priority)
if c.Not != nil {
res := c.Not.Evaluate(resp, body, duration)
if res.IsHealthy {
// Inversion: The forbidden condition matched!
reason := res.Reason
if c.Not.StatusCode != nil {
reason = fmt.Sprintf("✘ Forbidden Status Code: Received %d (which is disallowed)", resp.StatusCode)
} else {
// General NOT failure
reason = "✘ NOT condition triggered:\n " + strings.ReplaceAll(res.Reason, "\n", "\n ")
}
return EvaluationResult{
IsHealthy: false,
Reason: reason,
Type: NotificationConditionFailed,
}
}
// If inner was NOT healthy, then NOT(Unhealthy) is Healthy.
return EvaluationResult{IsHealthy: true, Reason: "Condition bypassed successfully"}
}

// 2. Evaluate AND Logic
if c.And != nil {
var failures []string
var successes []string
var firstType NotificationType
for _, cond := range c.And {
res := cond.Evaluate(resp, body, duration)
Expand All @@ -128,6 +151,8 @@ func (c *Condition) Evaluate(resp *http.Response, body []byte, duration time.Dur
firstType = res.Type
}
failures = append(failures, res.Reason)
} else {
successes = append(successes, res.Reason)
}
}
if len(failures) > 0 {
Expand All @@ -137,105 +162,91 @@ func (c *Condition) Evaluate(resp *http.Response, body []byte, duration time.Dur
Type: firstType,
}
}
return EvaluationResult{IsHealthy: true}
return EvaluationResult{IsHealthy: true, Reason: strings.Join(successes, "\n")}
}

// 2. منطق OR
// 3. Evaluate OR Logic
if c.Or != nil {
var subFailures []string
for _, cond := range c.Or {
res := cond.Evaluate(resp, body, duration)
if res.IsHealthy {
return EvaluationResult{IsHealthy: true}
return EvaluationResult{IsHealthy: true, Reason: res.Reason}
}
// Indent sub-failures of OR
indented := " " + strings.ReplaceAll(res.Reason, "\n", "\n ")
subFailures = append(subFailures, indented)
}
return EvaluationResult{
IsHealthy: false,
Reason: "- All OR conditions failed:\n" + strings.Join(subFailures, "\n"),
Reason: " All OR conditions failed:\n" + strings.Join(subFailures, "\n"),
Type: NotificationConditionFailed,
}
}

// 3. منطق NOT
if c.Not != nil {
res := c.Not.Evaluate(resp, body, duration)
if res.IsHealthy {
return EvaluationResult{
IsHealthy: false,
Reason: "- NOT condition failed: Forbidden condition matched successfully",
Type: NotificationConditionFailed,
}
}
return EvaluationResult{IsHealthy: true}
}
// 4. Leaf Conditions - Always return detailed Reason even on success (for NOT inversion)

// 4. بررسی Regex
// Regex
if c.Regex != nil {
matched, _ := regexp.Match(c.Regex.Regex, body)
reason := fmt.Sprintf("Body Match: Pattern '%s' %s in response", c.Regex.Regex, map[bool]string{true: "found", false: "not found"}[matched])
if !matched {
return EvaluationResult{
IsHealthy: false,
Reason: fmt.Sprintf("- Body Match: Pattern '%s' not found in response", c.Regex.Regex),
Type: NotificationConditionFailed,
}
return EvaluationResult{IsHealthy: false, Reason: "✘ " + reason, Type: NotificationConditionFailed}
}
return EvaluationResult{IsHealthy: true}
return EvaluationResult{IsHealthy: true, Reason: "✔ " + reason}
}

// 5. بررسی StatusCode
// StatusCode
if c.StatusCode != nil {
if resp == nil {
return EvaluationResult{IsHealthy: false, Reason: "- Status Code: No response received", Type: NotificationHttpError}
return EvaluationResult{IsHealthy: false, Reason: " Status Code: No response received", Type: NotificationHttpError}
}
if resp.StatusCode != c.StatusCode.Code {
isMatch := resp.StatusCode == c.StatusCode.Code
reason := fmt.Sprintf("Status Code: Expected %d, Got %d", c.StatusCode.Code, resp.StatusCode)
if !isMatch {
return EvaluationResult{
IsHealthy: false,
Reason: fmt.Sprintf("- Status Code: Expected %d, Got %d\n- Reason: %s", c.StatusCode.Code, resp.StatusCode, http.StatusText(resp.StatusCode)),
Reason: fmt.Sprintf("✘ %s\n- Reason: %s", reason, http.StatusText(resp.StatusCode)),
Type: NotificationHttpError,
}
}
return EvaluationResult{IsHealthy: true}
return EvaluationResult{IsHealthy: true, Reason: "✔ " + reason}
}

// 6. بررسی Headers
// Header
if c.Header != nil {
if resp == nil {
return EvaluationResult{IsHealthy: false, Reason: "- Header: No response headers available", Type: NotificationConditionFailed}
return EvaluationResult{IsHealthy: false, Reason: " Header: No response headers available", Type: NotificationConditionFailed}
}
var headerFailures []string
var failures []string
var successes []string
for _, h := range *c.Header {
actual := resp.Header.Get(h.Key)
if actual != h.Value {
headerFailures = append(headerFailures, fmt.Sprintf("- Header [%s]: Expected '%s', Got '%s'", h.Key, h.Value, actual))
match := actual == h.Value
reason := fmt.Sprintf("Header [%s]: Expected '%s', Got '%s'", h.Key, h.Value, actual)
if !match {
failures = append(failures, "✘ "+reason)
} else {
successes = append(successes, "✔ "+reason)
}
}
if len(headerFailures) > 0 {
return EvaluationResult{
IsHealthy: false,
Reason: strings.Join(headerFailures, "\n"),
Type: NotificationConditionFailed,
}
if len(failures) > 0 {
return EvaluationResult{IsHealthy: false, Reason: strings.Join(failures, "\n"), Type: NotificationConditionFailed}
}
return EvaluationResult{IsHealthy: true}
return EvaluationResult{IsHealthy: true, Reason: strings.Join(successes, "\n")}
}

// 7. بررسی Response Time
// Response Time
if c.ResponseTime != nil {
max, _ := time.ParseDuration(c.ResponseTime.MaxDuration)
if duration > max {
return EvaluationResult{
IsHealthy: false,
Reason: fmt.Sprintf("- Latency: Expected <%s, Got %v", c.ResponseTime.MaxDuration, duration.Round(time.Millisecond)),
Type: NotificationSlowResponse,
}
isMatch := duration <= max
reason := fmt.Sprintf("Latency: Expected <%s, Got %v", c.ResponseTime.MaxDuration, duration.Round(time.Millisecond))
if !isMatch {
return EvaluationResult{IsHealthy: false, Reason: "✘ " + reason, Type: NotificationSlowResponse}
}
return EvaluationResult{IsHealthy: true}
return EvaluationResult{IsHealthy: true, Reason: "✔ " + reason}
}

return EvaluationResult{IsHealthy: false, Reason: "- No valid condition defined", Type: NotificationDefault}
return EvaluationResult{IsHealthy: false, Reason: " No valid condition defined", Type: NotificationDefault}
}

func (r *RegexCondition) Evaluate(body []byte) bool {
Expand Down
6 changes: 3 additions & 3 deletions model/detailed_reason_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func TestEvaluate_DetailedReasons(t *testing.T) {
if result.IsHealthy {
t.Fatal("expected failure")
}
if !strings.Contains(result.Reason, "- Body Match: Pattern 'UP' not found") {
if !strings.Contains(result.Reason, " Body Match: Pattern 'UP' not found") {
t.Errorf("expected original failure message to be included, got: %s", result.Reason)
}
})
Expand All @@ -41,7 +41,7 @@ func TestEvaluate_DetailedReasons(t *testing.T) {
if result.IsHealthy {
t.Fatal("expected failure")
}
if !strings.Contains(result.Reason, "- All OR conditions failed") {
if !strings.Contains(result.Reason, " All OR conditions failed") {
t.Errorf("expected OR failure message, got: %s", result.Reason)
}
if !strings.Contains(result.Reason, "Status Code: Expected 200, Got 500") {
Expand All @@ -64,7 +64,7 @@ func TestEvaluate_DetailedReasons(t *testing.T) {
if result.IsHealthy {
t.Fatal("expected failure")
}
if !strings.Contains(result.Reason, "NOT condition failed") {
if !strings.Contains(result.Reason, "✘ Forbidden Status Code: Received 500") {
t.Errorf("expected NOT failure message, got: %s", result.Reason)
}
})
Expand Down