From be0a01ae718aaf19282b6b65b61502f21ddc6b59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 05:54:15 +0000 Subject: [PATCH 1/3] Add fluent assertions language builder Implement a fluent API for building Cronitor assertion strings with type safety and discoverability, replacing raw string construction. https://claude.ai/code/session_01XFQ49AHWaNZH3pyffR78LN --- Cronitor.Tests/AssertionTests.cs | 72 ++++++++++++++++++++++++++++++++ Cronitor/Constants/Assertion.cs | 49 ++++++++++++++++++++++ README.md | 24 ++++++++++- 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 Cronitor.Tests/AssertionTests.cs create mode 100644 Cronitor/Constants/Assertion.cs diff --git a/Cronitor.Tests/AssertionTests.cs b/Cronitor.Tests/AssertionTests.cs new file mode 100644 index 0000000..818c32c --- /dev/null +++ b/Cronitor.Tests/AssertionTests.cs @@ -0,0 +1,72 @@ +using Cronitor.Constants; +using Xunit; + +namespace Cronitor.Tests +{ + public class AssertionTests + { + [Fact] + public void MetricDurationLessThan() + { + var result = Assertion.Metric.Duration.LessThan("30s"); + + Assert.Equal("metric.duration < 30s", result); + } + + [Fact] + public void MetricErrorCountLessThan() + { + var result = Assertion.Metric.ErrorCount.LessThan(5); + + Assert.Equal("metric.error_count < 5", result); + } + + [Fact] + public void MetricCountGreaterThan() + { + var result = Assertion.Metric.Count.GreaterThan(0); + + Assert.Equal("metric.count > 0", result); + } + + [Fact] + public void ResponseCodeEquals() + { + var result = Assertion.Response.Code.Equals(200); + + Assert.Equal("response.code = 200", result); + } + + [Fact] + public void ResponseTimeLessThan() + { + var result = Assertion.Response.Time.LessThan("2s"); + + Assert.Equal("response.time < 2s", result); + } + + [Fact] + public void ResponseBodyContains() + { + var result = Assertion.Response.Body.Contains("healthy"); + + Assert.Equal("response.body contains healthy", result); + } + + [Fact] + public void ResponseJsonGreaterThan() + { + var result = Assertion.Response.Json("user.count").GreaterThan(10); + + Assert.Equal("response.json user.count > 10", result); + } + + [Fact] + public void ResponseHeaderEquals() + { + var result = Assertion.Response.Header("X-Version").Equals("1.2.3"); + + Assert.Equal("response.header X-Version = 1.2.3", result); + } + } +} diff --git a/Cronitor/Constants/Assertion.cs b/Cronitor/Constants/Assertion.cs new file mode 100644 index 0000000..7a041c7 --- /dev/null +++ b/Cronitor/Constants/Assertion.cs @@ -0,0 +1,49 @@ +namespace Cronitor.Constants +{ + public static class Assertion + { + public static MetricAssertion Metric => new MetricAssertion(); + public static ResponseAssertion Response => new ResponseAssertion(); + } + + public class MetricAssertion + { + public AssertionBuilder Duration => new AssertionBuilder("metric.duration"); + public AssertionBuilder Count => new AssertionBuilder("metric.count"); + public AssertionBuilder ErrorCount => new AssertionBuilder("metric.error_count"); + } + + public class ResponseAssertion + { + public AssertionBuilder Code => new AssertionBuilder("response.code"); + public AssertionBuilder Time => new AssertionBuilder("response.time"); + public AssertionBuilder Body => new AssertionBuilder("response.body"); + + public AssertionBuilder Json(string key) => new AssertionBuilder("response.json", key); + public AssertionBuilder Header(string key) => new AssertionBuilder("response.header", key); + } + + public class AssertionBuilder + { + private readonly string _assertion; + private readonly string _key; + + public AssertionBuilder(string assertion, string key = null) + { + _assertion = assertion; + _key = key; + } + + public new string Equals(object value) => Build("=", value); + public string LessThan(object value) => Build("<", value); + public string GreaterThan(object value) => Build(">", value); + public string Contains(object value) => Build("contains", value); + + private string Build(string op, object value) + { + return _key != null + ? $"{_assertion} {_key} {op} {value}" + : $"{_assertion} {op} {value}"; + } + } +} diff --git a/README.md b/README.md index 6a2555b..818d9d8 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,29 @@ public class SomeClass * Add support for Quartz.NET Jobs * Implement Timezone constant (if not too big of a hassle to maintain) * Implement cron expression-language (if found as needed?) -* Implement Cronitor `assertions`-language (if found as needed?) +* ~~Implement Cronitor `assertions`-language (if found as needed?)~~ + +### Assertions Language +A fluent builder for Cronitor assertions is available via the `Assertion` class in `Cronitor.Constants`. Instead of writing raw assertion strings, you can use the builder for type safety and discoverability: + +```c# +using Cronitor.Constants; + +var monitor = new Job("my-job") +{ + Assertions = new[] + { + Assertion.Metric.Duration.LessThan("30s"), + Assertion.Metric.ErrorCount.LessThan(5), + Assertion.Metric.Count.GreaterThan(0), + Assertion.Response.Code.Equals(200), + Assertion.Response.Time.LessThan("2s"), + Assertion.Response.Body.Contains("healthy"), + Assertion.Response.Json("user.count").GreaterThan(10), + Assertion.Response.Header("X-Version").Equals("1.2.3"), + } +}; +``` ## Contributing Pull requests and features are happily considered! By participating in this project you agree to abide by the [Code of Conduct](http://contributor-covenant.org/version/2/0). From 772c6275b3a383ce2c0d078200b0af375c44f25f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 05:59:53 +0000 Subject: [PATCH 2/3] Change Monitor.Assertions from string to AssertionRule type The Assertions property now accepts AssertionRule objects instead of raw strings. AssertionRule serializes to/from JSON as a plain string via a custom JsonConverter, so the API payload is unchanged. Implicit conversions between string and AssertionRule are supported for backwards compatibility. https://claude.ai/code/session_01XFQ49AHWaNZH3pyffR78LN --- Cronitor.Tests/AssertionTests.cs | 71 ++++++++++++++++--- Cronitor.Tests/Builders/JobBuilder.cs | 5 +- Cronitor.Tests/MonitorTypeTests.cs | 7 +- .../Requests/CreateMonitorRequestTests.cs | 2 +- .../Requests/UpdateMonitorRequestTests.cs | 2 +- Cronitor/Constants/Assertion.cs | 47 ++++++++++-- Cronitor/Models/Monitor.cs | 3 +- 7 files changed, 114 insertions(+), 23 deletions(-) diff --git a/Cronitor.Tests/AssertionTests.cs b/Cronitor.Tests/AssertionTests.cs index 818c32c..251693e 100644 --- a/Cronitor.Tests/AssertionTests.cs +++ b/Cronitor.Tests/AssertionTests.cs @@ -1,4 +1,6 @@ using Cronitor.Constants; +using Cronitor.Serialization; +using System.Collections.Generic; using Xunit; namespace Cronitor.Tests @@ -10,7 +12,7 @@ public void MetricDurationLessThan() { var result = Assertion.Metric.Duration.LessThan("30s"); - Assert.Equal("metric.duration < 30s", result); + Assert.Equal("metric.duration < 30s", result.Value); } [Fact] @@ -18,7 +20,7 @@ public void MetricErrorCountLessThan() { var result = Assertion.Metric.ErrorCount.LessThan(5); - Assert.Equal("metric.error_count < 5", result); + Assert.Equal("metric.error_count < 5", result.Value); } [Fact] @@ -26,7 +28,7 @@ public void MetricCountGreaterThan() { var result = Assertion.Metric.Count.GreaterThan(0); - Assert.Equal("metric.count > 0", result); + Assert.Equal("metric.count > 0", result.Value); } [Fact] @@ -34,7 +36,7 @@ public void ResponseCodeEquals() { var result = Assertion.Response.Code.Equals(200); - Assert.Equal("response.code = 200", result); + Assert.Equal("response.code = 200", result.Value); } [Fact] @@ -42,7 +44,7 @@ public void ResponseTimeLessThan() { var result = Assertion.Response.Time.LessThan("2s"); - Assert.Equal("response.time < 2s", result); + Assert.Equal("response.time < 2s", result.Value); } [Fact] @@ -50,7 +52,7 @@ public void ResponseBodyContains() { var result = Assertion.Response.Body.Contains("healthy"); - Assert.Equal("response.body contains healthy", result); + Assert.Equal("response.body contains healthy", result.Value); } [Fact] @@ -58,7 +60,7 @@ public void ResponseJsonGreaterThan() { var result = Assertion.Response.Json("user.count").GreaterThan(10); - Assert.Equal("response.json user.count > 10", result); + Assert.Equal("response.json user.count > 10", result.Value); } [Fact] @@ -66,7 +68,60 @@ public void ResponseHeaderEquals() { var result = Assertion.Response.Header("X-Version").Equals("1.2.3"); - Assert.Equal("response.header X-Version = 1.2.3", result); + Assert.Equal("response.header X-Version = 1.2.3", result.Value); + } + + [Fact] + public void ToStringReturnsValue() + { + var rule = Assertion.Metric.Duration.LessThan("30s"); + + Assert.Equal("metric.duration < 30s", rule.ToString()); + } + + [Fact] + public void ImplicitStringConversion() + { + string result = Assertion.Response.Code.Equals(200); + + Assert.Equal("response.code = 200", result); + } + + [Fact] + public void ImplicitAssertionRuleFromString() + { + AssertionRule rule = "metric.duration < 30s"; + + Assert.Equal("metric.duration < 30s", rule.Value); + } + + [Fact] + public void SerializesAsJsonString() + { + var rules = new List + { + Assertion.Metric.Duration.LessThan("15min") + }; + + var json = Serializer.Serialize(new { assertions = rules }); + + Assert.Equal("{\"assertions\":[\"metric.duration < 15min\"]}", json); + } + + [Fact] + public void DeserializesFromJsonString() + { + var json = "{\"assertions\":[\"metric.duration < 15min\"]}"; + + var result = Serializer.Deserialize(json); + + Assert.Single(result.Assertions); + Assert.Equal("metric.duration < 15min", result.Assertions[0].Value); + } + + private class AssertionContainer + { + public List Assertions { get; set; } } } } diff --git a/Cronitor.Tests/Builders/JobBuilder.cs b/Cronitor.Tests/Builders/JobBuilder.cs index 6c02350..84f148b 100644 --- a/Cronitor.Tests/Builders/JobBuilder.cs +++ b/Cronitor.Tests/Builders/JobBuilder.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Cronitor.Constants; using Cronitor.Models.Monitors; namespace Cronitor.Tests.Builders @@ -16,7 +17,7 @@ public class JobBuilder private readonly int? _scheduleTolerance = 1; private readonly string _timeZone = "Europe/Stockholm"; - private List _assertions = new List { "metric.duration < 30s", "metric.error_count < 5" }; + private List _assertions = new List { Assertion.Metric.Duration.LessThan("30s"), Assertion.Metric.ErrorCount.LessThan(5) }; private List _notify = new List { "developers" }; private readonly List _tags = new List { "tag", "attribute" }; @@ -45,7 +46,7 @@ public JobBuilder Key(string key) return this; } - public JobBuilder Assertions(List assertions) + public JobBuilder Assertions(List assertions) { _assertions = assertions; return this; diff --git a/Cronitor.Tests/MonitorTypeTests.cs b/Cronitor.Tests/MonitorTypeTests.cs index d52574f..ee178a9 100644 --- a/Cronitor.Tests/MonitorTypeTests.cs +++ b/Cronitor.Tests/MonitorTypeTests.cs @@ -1,5 +1,4 @@ using Cronitor.Constants; -using Cronitor.Models; using Cronitor.Models.Monitors; using Cronitor.Tests.Helpers; using System.Collections.Generic; @@ -73,10 +72,10 @@ public void ShouldCreateHeartbeatMonitor() [Fact] public void ShouldCreateJobMonitor() { - var assertions = new List + var assertions = new List { - "metric.duration < 30s", - "metric.error_count < 5" + Assertion.Metric.Duration.LessThan("30s"), + Assertion.Metric.ErrorCount.LessThan(5) }; var notify = new List { diff --git a/Cronitor.Tests/Requests/CreateMonitorRequestTests.cs b/Cronitor.Tests/Requests/CreateMonitorRequestTests.cs index 21efabc..deb280b 100644 --- a/Cronitor.Tests/Requests/CreateMonitorRequestTests.cs +++ b/Cronitor.Tests/Requests/CreateMonitorRequestTests.cs @@ -19,7 +19,7 @@ public async Task ShouldCreateMonitorRequestAsync(string expected) .With(x => x.Schedule, "0 0 * * *") .With(x => x.Notify, new List { "default" }) .With(x => x.Tags, new List { "nightly" }) - .With(x => x.Assertions, new List { "metric.duration < 15min" }) + .With(x => x.Assertions, new List { Assertion.Metric.Duration.LessThan("15min") }) .With(x => x.Timezone, "Europe/Stockholm") .With(x => x.Note, "note") .With(x => x.Platform, "linux") diff --git a/Cronitor.Tests/Requests/UpdateMonitorRequestTests.cs b/Cronitor.Tests/Requests/UpdateMonitorRequestTests.cs index fc0a886..7889537 100644 --- a/Cronitor.Tests/Requests/UpdateMonitorRequestTests.cs +++ b/Cronitor.Tests/Requests/UpdateMonitorRequestTests.cs @@ -20,7 +20,7 @@ public async Task ShouldCreateUpdateMonitorRequestAsync(string expected) .With(x => x.Schedule, "0 0 * * *") .With(x => x.Notify, new List { "default" }) .With(x => x.Tags, new List { "nightly" }) - .With(x => x.Assertions, new List { "metric.duration < 15min" }) + .With(x => x.Assertions, new List { Assertion.Metric.Duration.LessThan("15min") }) .With(x => x.Timezone, "Europe/Stockholm") .With(x => x.Note, "note") .With(x => x.Platform, "linux"); diff --git a/Cronitor/Constants/Assertion.cs b/Cronitor/Constants/Assertion.cs index 7a041c7..4e770a4 100644 --- a/Cronitor/Constants/Assertion.cs +++ b/Cronitor/Constants/Assertion.cs @@ -1,3 +1,7 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + namespace Cronitor.Constants { public static class Assertion @@ -34,16 +38,47 @@ public AssertionBuilder(string assertion, string key = null) _key = key; } - public new string Equals(object value) => Build("=", value); - public string LessThan(object value) => Build("<", value); - public string GreaterThan(object value) => Build(">", value); - public string Contains(object value) => Build("contains", value); + public new AssertionRule Equals(object value) => Build("=", value); + public AssertionRule LessThan(object value) => Build("<", value); + public AssertionRule GreaterThan(object value) => Build(">", value); + public AssertionRule Contains(object value) => Build("contains", value); - private string Build(string op, object value) + private AssertionRule Build(string op, object value) { - return _key != null + var assertion = _key != null ? $"{_assertion} {_key} {op} {value}" : $"{_assertion} {op} {value}"; + + return new AssertionRule(assertion); + } + } + + [JsonConverter(typeof(AssertionRuleConverter))] + public class AssertionRule + { + public string Value { get; } + + public AssertionRule(string value) + { + Value = value; + } + + public override string ToString() => Value; + + public static implicit operator string(AssertionRule rule) => rule?.Value; + public static implicit operator AssertionRule(string value) => new AssertionRule(value); + } + + public class AssertionRuleConverter : JsonConverter + { + public override AssertionRule Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new AssertionRule(reader.GetString()); + } + + public override void Write(Utf8JsonWriter writer, AssertionRule value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); } } } diff --git a/Cronitor/Models/Monitor.cs b/Cronitor/Models/Monitor.cs index ba2705b..5fcc136 100644 --- a/Cronitor/Models/Monitor.cs +++ b/Cronitor/Models/Monitor.cs @@ -3,6 +3,7 @@ using System.Linq.Expressions; using System.Reflection; using System.Text.Json.Serialization; +using Cronitor.Constants; namespace Cronitor.Models { @@ -35,7 +36,7 @@ public class Monitor /// "response.header X-App-Version = 1.2.3" /// [JsonPropertyName("assertions")] - public IEnumerable Assertions { get; set; } + public IEnumerable Assertions { get; set; } /// /// job and event: number of telemetry events with state='fail' to allow before sending an alert. /// check: number of consecutive failed requests allow before sending an alert. From 7dccfef46242a97baa3fcce6c50aeb03b01780d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 06:19:52 +0000 Subject: [PATCH 3/3] Fix deserialization test missing JsonPropertyName attribute The AssertionContainer test class lacked a [JsonPropertyName] attribute, so System.Text.Json could not match the lowercase JSON key to the PascalCase property, leaving it null. https://claude.ai/code/session_01XFQ49AHWaNZH3pyffR78LN --- Cronitor.Tests/AssertionTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Cronitor.Tests/AssertionTests.cs b/Cronitor.Tests/AssertionTests.cs index 251693e..d3215e7 100644 --- a/Cronitor.Tests/AssertionTests.cs +++ b/Cronitor.Tests/AssertionTests.cs @@ -121,6 +121,7 @@ public void DeserializesFromJsonString() private class AssertionContainer { + [System.Text.Json.Serialization.JsonPropertyName("assertions")] public List Assertions { get; set; } } }