diff --git a/Cronitor.Tests/Builders/CheckBuilder.cs b/Cronitor.Tests/Builders/CheckBuilder.cs index 573bcb8..b4e2ad0 100644 --- a/Cronitor.Tests/Builders/CheckBuilder.cs +++ b/Cronitor.Tests/Builders/CheckBuilder.cs @@ -1,5 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Cronitor.Constants; +using Cronitor.Scheduling; using Cronitor.Models.Monitors; namespace Cronitor.Tests.Builders @@ -7,7 +8,7 @@ namespace Cronitor.Tests.Builders public class CheckBuilder { private readonly string _key = "Key"; - private readonly string _schedule = "every 60 seconds"; + private readonly ScheduleExpression _schedule = Schedule.Every(60).Seconds; private readonly string _timezone = "Europe/Stockholm"; private readonly string _url = "https://www.google.se"; @@ -35,4 +36,4 @@ public Check Build() }; } } -} \ No newline at end of file +} diff --git a/Cronitor.Tests/Builders/HeartbeatBuilder.cs b/Cronitor.Tests/Builders/HeartbeatBuilder.cs index 9b4afca..ac2d025 100644 --- a/Cronitor.Tests/Builders/HeartbeatBuilder.cs +++ b/Cronitor.Tests/Builders/HeartbeatBuilder.cs @@ -1,11 +1,12 @@ -using Cronitor.Models.Monitors; +using Cronitor.Scheduling; +using Cronitor.Models.Monitors; namespace Cronitor.Tests.Builders { public class HeartbeatBuilder { private readonly string _key = "Key"; - private readonly string _schedule = "every 60 seconds"; + private readonly ScheduleExpression _schedule = Schedule.Every(60).Seconds; private readonly string _timezone = "Europe/Stockholm"; public Heartbeat Build() @@ -16,4 +17,4 @@ public Heartbeat Build() }; } } -} \ No newline at end of file +} diff --git a/Cronitor.Tests/Builders/JobBuilder.cs b/Cronitor.Tests/Builders/JobBuilder.cs index b844e4b..9d6ce90 100644 --- a/Cronitor.Tests/Builders/JobBuilder.cs +++ b/Cronitor.Tests/Builders/JobBuilder.cs @@ -1,6 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Cronitor.Assertions; using Cronitor.Models.Monitors; +using Cronitor.Scheduling; namespace Cronitor.Tests.Builders { @@ -13,7 +14,7 @@ public class JobBuilder private readonly string _group = "Group"; private readonly string _note = "Note"; private readonly string _platform = "Platform"; - private string _schedule = "35 0 * * *"; + private ScheduleExpression _schedule = Schedule.Cron.Daily(hour: 0, minute: 35); private readonly int? _scheduleTolerance = 1; private readonly string _timeZone = "Europe/Stockholm"; @@ -39,7 +40,7 @@ public JobBuilder Notify(List notify) return this; } - public JobBuilder Schedule(string schedule) + public JobBuilder WithSchedule(ScheduleExpression schedule) { _schedule = schedule; return this; @@ -64,4 +65,4 @@ public Job Build() }; } } -} \ No newline at end of file +} diff --git a/Cronitor.Tests/MonitorTypeTests.cs b/Cronitor.Tests/MonitorTypeTests.cs index fd41b56..5ce66be 100644 --- a/Cronitor.Tests/MonitorTypeTests.cs +++ b/Cronitor.Tests/MonitorTypeTests.cs @@ -1,5 +1,6 @@ using Cronitor.Constants; using Cronitor.Models; +using Cronitor.Scheduling; using Cronitor.Models.Monitors; using Cronitor.Tests.Helpers; using System.Collections.Generic; @@ -28,7 +29,7 @@ public void ShouldCreateCheckMonitor() Region.Sydney, Region.Virginia }; - const string schedule = "every 60 seconds"; + var schedule = Schedule.Every(60).Seconds; const string timezone = "Europe/Stockholm"; const string url = "https://www.google.se"; @@ -55,7 +56,7 @@ public void ShouldCreateCheckMonitor() [Fact] public void ShouldCreateHeartbeatMonitor() { - const string schedule = "every 60 seconds"; + var schedule = Schedule.Every(60).Seconds; const string timezone = "Europe/Stockholm"; var monitor = new Heartbeat(MonitorKey, schedule) @@ -84,7 +85,7 @@ public void ShouldCreateJobMonitor() "developers", "administrators" }; - const string schedule = "every 60 seconds"; + var schedule = Schedule.Every(60).Seconds; const string timezone = "Europe/Stockholm"; var monitor = new Job(MonitorKey) diff --git a/Cronitor.Tests/MonitorsClientTests.cs b/Cronitor.Tests/MonitorsClientTests.cs index f2681d4..e7885ee 100644 --- a/Cronitor.Tests/MonitorsClientTests.cs +++ b/Cronitor.Tests/MonitorsClientTests.cs @@ -133,7 +133,7 @@ public void ShouldExecuteCreateMethod() }; var monitor = Make.Job .Notify(notify) - .Schedule("every 60 seconds") + .WithSchedule("every 60 seconds") .Build(); _httpClient.Setup(x => x.SendAsync(It.IsAny())).Returns(Task.FromResult(new CreateMonitorResponse { Monitors = new List { monitor } })); diff --git a/Cronitor.Tests/Requests/CreateMonitorRequestTests.cs b/Cronitor.Tests/Requests/CreateMonitorRequestTests.cs index 6830b12..2047a35 100644 --- a/Cronitor.Tests/Requests/CreateMonitorRequestTests.cs +++ b/Cronitor.Tests/Requests/CreateMonitorRequestTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Cronitor.Assertions; using Cronitor.Constants; +using Cronitor.Scheduling; using Cronitor.Models; using Cronitor.Requests; using Cronitor.Tests.Helpers; @@ -17,7 +18,7 @@ public async Task ShouldCreateMonitorRequestAsync(string expected) { var monitor = new Monitor("nightly-backup-job") .With(x => x.Type, MonitorType.Job.ToString()) - .With(x => x.Schedule, "0 0 * * *") + .With(x => x.Schedule, Schedule.Cron.Daily()) .With(x => x.Notify, new List { "default" }) .With(x => x.Tags, new List { "nightly" }) .With(x => x.Assertions, new List { Assertion.Metric.Duration.LessThan("15min") }) diff --git a/Cronitor.Tests/Requests/UpdateMonitorRequestTests.cs b/Cronitor.Tests/Requests/UpdateMonitorRequestTests.cs index cf100b9..844ff87 100644 --- a/Cronitor.Tests/Requests/UpdateMonitorRequestTests.cs +++ b/Cronitor.Tests/Requests/UpdateMonitorRequestTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Cronitor.Assertions; using Cronitor.Constants; +using Cronitor.Scheduling; using Cronitor.Models; using Cronitor.Requests; using Cronitor.Tests.Helpers; @@ -18,7 +19,7 @@ public async Task ShouldCreateUpdateMonitorRequestAsync(string expected) { var monitor = new Monitor("nightly-backup-job") .With(x => x.Type, MonitorType.Job.ToString()) - .With(x => x.Schedule, "0 0 * * *") + .With(x => x.Schedule, Schedule.Cron.Daily()) .With(x => x.Notify, new List { "default" }) .With(x => x.Tags, new List { "nightly" }) .With(x => x.Assertions, new List { Assertion.Metric.Duration.LessThan("15min") }) diff --git a/Cronitor.Tests/ScheduleTests.cs b/Cronitor.Tests/ScheduleTests.cs new file mode 100644 index 0000000..e433420 --- /dev/null +++ b/Cronitor.Tests/ScheduleTests.cs @@ -0,0 +1,255 @@ +using Cronitor.Scheduling; +using Cronitor.Serialization; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Xunit; + +namespace Cronitor.Tests +{ + public class ScheduleTests + { + [Fact] + public void EveryMinute() + { + var result = Schedule.Cron.EveryMinute(); + + Assert.Equal("* * * * *", result.Value); + } + + [Fact] + public void EveryHourAtZero() + { + var result = Schedule.Cron.EveryHour(); + + Assert.Equal("0 * * * *", result.Value); + } + + [Fact] + public void EveryHourAtMinute() + { + var result = Schedule.Cron.EveryHour(minute: 30); + + Assert.Equal("30 * * * *", result.Value); + } + + [Fact] + public void DailyAtMidnight() + { + var result = Schedule.Cron.Daily(); + + Assert.Equal("0 0 * * *", result.Value); + } + + [Fact] + public void DailyAtHour() + { + var result = Schedule.Cron.Daily(hour: 3); + + Assert.Equal("0 3 * * *", result.Value); + } + + [Fact] + public void DailyAtHourAndMinute() + { + var result = Schedule.Cron.Daily(hour: 0, minute: 35); + + Assert.Equal("35 0 * * *", result.Value); + } + + [Fact] + public void WeeklyOnDay() + { + var result = Schedule.Cron.Weekly(day: 1); + + Assert.Equal("0 0 * * 1", result.Value); + } + + [Fact] + public void WeeklyOnDayAtTime() + { + var result = Schedule.Cron.Weekly(day: 1, hour: 8, minute: 30); + + Assert.Equal("30 8 * * 1", result.Value); + } + + [Fact] + public void MonthlyOnDay() + { + var result = Schedule.Cron.Monthly(day: 15); + + Assert.Equal("0 0 15 * *", result.Value); + } + + [Fact] + public void MonthlyOnDayAtTime() + { + var result = Schedule.Cron.Monthly(day: 1, hour: 6, minute: 15); + + Assert.Equal("15 6 1 * *", result.Value); + } + + [Fact] + public void YearlyOnDate() + { + var result = Schedule.Cron.Yearly(month: 1, day: 1); + + Assert.Equal("0 0 1 1 *", result.Value); + } + + [Fact] + public void YearlyOnDateAtTime() + { + var result = Schedule.Cron.Yearly(month: 6, day: 15, hour: 12, minute: 30); + + Assert.Equal("30 12 15 6 *", result.Value); + } + + [Fact] + public void RawExpression() + { + var result = Schedule.Cron.Expression("0 0 * * *"); + + Assert.Equal("0 0 * * *", result.Value); + } + + [Fact] + public void EverySeconds() + { + var result = Schedule.Every(60).Seconds; + + Assert.Equal("every 60 seconds", result.Value); + } + + [Fact] + public void EveryMinutes() + { + var result = Schedule.Every(5).Minutes; + + Assert.Equal("every 5 minutes", result.Value); + } + + [Fact] + public void EveryHours() + { + var result = Schedule.Every(2).Hours; + + Assert.Equal("every 2 hours", result.Value); + } + + [Fact] + public void EveryDays() + { + var result = Schedule.Every(1).Days; + + Assert.Equal("every 1 days", result.Value); + } + + [Fact] + public void CustomCronBuilder() + { + var result = Schedule.Cron.Create() + .Minute(30) + .Hour(3) + .DayOfMonth(15) + .Month(6) + .DayOfWeek(1) + .Build(); + + Assert.Equal("30 3 15 6 1", result.Value); + } + + [Fact] + public void CustomCronBuilderDefaults() + { + var result = Schedule.Cron.Create() + .Minute(0) + .Hour(3) + .Build(); + + Assert.Equal("0 3 * * *", result.Value); + } + + [Fact] + public void CustomCronBuilderWithStep() + { + var result = Schedule.Cron.Create() + .MinuteEvery(15) + .Build(); + + Assert.Equal("*/15 * * * *", result.Value); + } + + [Fact] + public void CustomCronBuilderWithRange() + { + var result = Schedule.Cron.Create() + .Minute(0) + .HourRange(9, 17) + .DayOfWeekRange(1, 5) + .Build(); + + Assert.Equal("0 9-17 * * 1-5", result.Value); + } + + [Fact] + public void CustomCronBuilderToString() + { + var result = Schedule.Cron.Create() + .Minute(0) + .Hour(12) + .ToString(); + + Assert.Equal("0 12 * * *", result); + } + + [Fact] + public void ToStringReturnsValue() + { + var expression = Schedule.Cron.Daily(hour: 3); + + Assert.Equal("0 3 * * *", expression.ToString()); + } + + [Fact] + public void ImplicitStringConversion() + { + string result = Schedule.Every(5).Minutes; + + Assert.Equal("every 5 minutes", result); + } + + [Fact] + public void ImplicitScheduleExpressionFromString() + { + ScheduleExpression expression = "0 0 * * *"; + + Assert.Equal("0 0 * * *", expression.Value); + } + + [Fact] + public void SerializesAsJsonString() + { + var container = new ScheduleContainer { Schedule = Schedule.Cron.Daily() }; + + var json = Serializer.Serialize(container); + + Assert.Equal("{\"schedule\":\"0 0 * * *\"}", json); + } + + [Fact] + public void DeserializesFromJsonString() + { + var json = "{\"schedule\":\"0 3 * * *\"}"; + + var result = Serializer.Deserialize(json); + + Assert.Equal("0 3 * * *", result.Schedule.Value); + } + + private class ScheduleContainer + { + [JsonPropertyName("schedule")] + public ScheduleExpression Schedule { get; set; } + } + } +} diff --git a/Cronitor/Models/Monitor.cs b/Cronitor/Models/Monitor.cs index ac43231..d6ab5c9 100644 --- a/Cronitor/Models/Monitor.cs +++ b/Cronitor/Models/Monitor.cs @@ -4,6 +4,7 @@ using System.Reflection; using System.Text.Json.Serialization; using Cronitor.Assertions; +using Cronitor.Scheduling; namespace Cronitor.Models { @@ -112,7 +113,7 @@ public class Monitor /// An interval expression must be used. The range of accepted values is 30 seconds to 1 hour.e.g. ‘every 2 minutes’ /// [JsonPropertyName("schedule")] - public string Schedule { get; set; } + public ScheduleExpression Schedule { get; set; } /// /// Number of missed scheduled executions to allow before sending an alert. /// diff --git a/Cronitor/Models/Monitors/Heartbeat.cs b/Cronitor/Models/Monitors/Heartbeat.cs index 7eab02f..611e21a 100644 --- a/Cronitor/Models/Monitors/Heartbeat.cs +++ b/Cronitor/Models/Monitors/Heartbeat.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; +using Cronitor.Scheduling; namespace Cronitor.Models.Monitors { @@ -7,7 +8,7 @@ public class Heartbeat : Monitor [JsonPropertyName("type")] public override string Type { get; set; } = "event"; - public Heartbeat(string key, string schedule) + public Heartbeat(string key, ScheduleExpression schedule) : base(key) { Schedule = schedule; diff --git a/Cronitor/Scheduling/CronBuilder.cs b/Cronitor/Scheduling/CronBuilder.cs new file mode 100644 index 0000000..4357f78 --- /dev/null +++ b/Cronitor/Scheduling/CronBuilder.cs @@ -0,0 +1,45 @@ +namespace Cronitor.Scheduling +{ + public class CronBuilder + { + /// + /// Every minute: * * * * * + /// + public ScheduleExpression EveryMinute() => new ScheduleExpression("* * * * *"); + + /// + /// Every hour at the given minute: {minute} * * * * + /// + public ScheduleExpression EveryHour(int minute = 0) => new ScheduleExpression($"{minute} * * * *"); + + /// + /// Daily at the given time: {minute} {hour} * * * + /// + public ScheduleExpression Daily(int hour = 0, int minute = 0) => new ScheduleExpression($"{minute} {hour} * * *"); + + /// + /// Weekly on the given day at the given time: {minute} {hour} * * {day} + /// + public ScheduleExpression Weekly(int day, int hour = 0, int minute = 0) => new ScheduleExpression($"{minute} {hour} * * {day}"); + + /// + /// Monthly on the given day at the given time: {minute} {hour} {day} * * + /// + public ScheduleExpression Monthly(int day, int hour = 0, int minute = 0) => new ScheduleExpression($"{minute} {hour} {day} * *"); + + /// + /// Yearly on the given month and day at the given time: {minute} {hour} {day} {month} * + /// + public ScheduleExpression Yearly(int month, int day, int hour = 0, int minute = 0) => new ScheduleExpression($"{minute} {hour} {day} {month} *"); + + /// + /// Pass a raw cron expression through as-is. + /// + public ScheduleExpression Expression(string expression) => new ScheduleExpression(expression); + + /// + /// Start building a custom cron expression field by field. + /// + public CronFieldBuilder Create() => new CronFieldBuilder(); + } +} diff --git a/Cronitor/Scheduling/CronFieldBuilder.cs b/Cronitor/Scheduling/CronFieldBuilder.cs new file mode 100644 index 0000000..628618e --- /dev/null +++ b/Cronitor/Scheduling/CronFieldBuilder.cs @@ -0,0 +1,65 @@ +namespace Cronitor.Scheduling +{ + public class CronFieldBuilder + { + private string _minute = "*"; + private string _hour = "*"; + private string _dayOfMonth = "*"; + private string _month = "*"; + private string _dayOfWeek = "*"; + + public CronFieldBuilder Minute(int value) { _minute = value.ToString(); return this; } + public CronFieldBuilder Minute(string value) { _minute = value; return this; } + + public CronFieldBuilder Hour(int value) { _hour = value.ToString(); return this; } + public CronFieldBuilder Hour(string value) { _hour = value; return this; } + + public CronFieldBuilder DayOfMonth(int value) { _dayOfMonth = value.ToString(); return this; } + public CronFieldBuilder DayOfMonth(string value) { _dayOfMonth = value; return this; } + + public CronFieldBuilder Month(int value) { _month = value.ToString(); return this; } + public CronFieldBuilder Month(string value) { _month = value; return this; } + + public CronFieldBuilder DayOfWeek(int value) { _dayOfWeek = value.ToString(); return this; } + public CronFieldBuilder DayOfWeek(string value) { _dayOfWeek = value; return this; } + + /// + /// Set a step value for the minute field: */{step} + /// + public CronFieldBuilder MinuteEvery(int step) { _minute = $"*/{step}"; return this; } + + /// + /// Set a step value for the hour field: */{step} + /// + public CronFieldBuilder HourEvery(int step) { _hour = $"*/{step}"; return this; } + + /// + /// Set a range for the minute field: {from}-{to} + /// + public CronFieldBuilder MinuteRange(int from, int to) { _minute = $"{from}-{to}"; return this; } + + /// + /// Set a range for the hour field: {from}-{to} + /// + public CronFieldBuilder HourRange(int from, int to) { _hour = $"{from}-{to}"; return this; } + + /// + /// Set a range for the day of month field: {from}-{to} + /// + public CronFieldBuilder DayOfMonthRange(int from, int to) { _dayOfMonth = $"{from}-{to}"; return this; } + + /// + /// Set a range for the month field: {from}-{to} + /// + public CronFieldBuilder MonthRange(int from, int to) { _month = $"{from}-{to}"; return this; } + + /// + /// Set a range for the day of week field: {from}-{to} + /// + public CronFieldBuilder DayOfWeekRange(int from, int to) { _dayOfWeek = $"{from}-{to}"; return this; } + + public ScheduleExpression Build() => new ScheduleExpression($"{_minute} {_hour} {_dayOfMonth} {_month} {_dayOfWeek}"); + + public override string ToString() => Build().Value; + } +} diff --git a/Cronitor/Scheduling/IntervalBuilder.cs b/Cronitor/Scheduling/IntervalBuilder.cs new file mode 100644 index 0000000..899de41 --- /dev/null +++ b/Cronitor/Scheduling/IntervalBuilder.cs @@ -0,0 +1,17 @@ +namespace Cronitor.Scheduling +{ + public class IntervalBuilder + { + private readonly int _value; + + public IntervalBuilder(int value) + { + _value = value; + } + + public ScheduleExpression Seconds => new ScheduleExpression($"every {_value} seconds"); + public ScheduleExpression Minutes => new ScheduleExpression($"every {_value} minutes"); + public ScheduleExpression Hours => new ScheduleExpression($"every {_value} hours"); + public ScheduleExpression Days => new ScheduleExpression($"every {_value} days"); + } +} diff --git a/Cronitor/Scheduling/Schedule.cs b/Cronitor/Scheduling/Schedule.cs new file mode 100644 index 0000000..1d9a326 --- /dev/null +++ b/Cronitor/Scheduling/Schedule.cs @@ -0,0 +1,8 @@ +namespace Cronitor.Scheduling +{ + public static class Schedule + { + public static CronBuilder Cron => new CronBuilder(); + public static IntervalBuilder Every(int value) => new IntervalBuilder(value); + } +} diff --git a/Cronitor/Scheduling/ScheduleExpression.cs b/Cronitor/Scheduling/ScheduleExpression.cs new file mode 100644 index 0000000..cd6cd20 --- /dev/null +++ b/Cronitor/Scheduling/ScheduleExpression.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Cronitor.Scheduling +{ + [JsonConverter(typeof(ScheduleExpressionConverter))] + public class ScheduleExpression + { + public string Value { get; } + + public ScheduleExpression(string value) + { + Value = value; + } + + public override string ToString() => Value; + + public static implicit operator string(ScheduleExpression expression) => expression?.Value; + public static implicit operator ScheduleExpression(string value) => new ScheduleExpression(value); + } +} diff --git a/Cronitor/Scheduling/ScheduleExpressionConverter.cs b/Cronitor/Scheduling/ScheduleExpressionConverter.cs new file mode 100644 index 0000000..0dfc816 --- /dev/null +++ b/Cronitor/Scheduling/ScheduleExpressionConverter.cs @@ -0,0 +1,20 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cronitor.Scheduling +{ + public class ScheduleExpressionConverter : JsonConverter + { + public override ScheduleExpression Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value != null ? new ScheduleExpression(value) : null; + } + + public override void Write(Utf8JsonWriter writer, ScheduleExpression value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } + } +} diff --git a/README.md b/README.md index d61d131..b60805b 100644 --- a/README.md +++ b/README.md @@ -181,11 +181,34 @@ var monitor = new Job("my-job") }; ``` +### Schedule / Cron Expressions +A fluent builder for schedule expressions is available via the `Schedule` class in `Cronitor.Constants`. It supports both Cronitor interval expressions and standard cron expressions: + +```c# +using Cronitor.Scheduling; + +// Interval expressions +Schedule.Every(60).Seconds // "every 60 seconds" +Schedule.Every(5).Minutes // "every 5 minutes" + +// Cron shortcuts +Schedule.Cron.Daily(hour: 3) // "0 3 * * *" +Schedule.Cron.Weekly(day: 1, hour: 8) // "0 8 * * 1" +Schedule.Cron.Monthly(day: 15) // "0 0 15 * *" + +// Custom cron expressions +Schedule.Cron.Create() + .MinuteEvery(15) + .HourRange(9, 17) + .DayOfWeekRange(1, 5) + .Build() // "*/15 9-17 * * 1-5" +``` + ## Development ### Suggestions * 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?) + ## 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).