diff --git a/Project.toml b/Project.toml index 7d2c1b86..39a63c3a 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "Oxygen" uuid = "df9a0d86-3283-4920-82dc-4555fc0d1d8b" authors = ["Nathan Ortega "] -version = "1.1.5" +version = "1.1.6" [deps] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" diff --git a/README.md b/README.md index 3b2e6546..54dddf17 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,9 @@ Breathe easy knowing you can quickly spin up a web server with abstractions you' - Out-of-the-box JSON serialization & deserialization (customizable) - Type definition support for path parameters - Built-in multithreading support -- Static file hosting +- Built-in Cron Scheduling (on endpoints & functions) - Middleware chaining (at the application, router, and route levels) +- Static & Dynamic file hosting - Route tagging - Repeat tasks @@ -204,11 +205,12 @@ The `router()` function is an HOF (higher order function) that allows you to reu Below are the arguments the `router()` function can take: ```julia -router(prefix::String; tags::Vector, middleware::Vector, interval::Real) +router(prefix::String; tags::Vector, middleware::Vector, interval::Real, cron::String) ``` -- `tags` are used to organize endpoints in the autogenerated docs -- `middleware` is used to setup router & route-specific middleware -- `interval` is used to support repeat actions (*calling a request handler on a set interval in seconds*) +- `tags` - are used to organize endpoints in the autogenerated docs +- `middleware` - is used to setup router & route-specific middleware +- `interval` - is used to support repeat actions (*calling a request handler on a set interval in seconds*) +- `cron` - is used to specify a cron expression that determines when to call the request handler. ```julia using Oxygen @@ -229,6 +231,83 @@ end serve() ``` +## Cron Scheduling + +Oxygen comes with a built-in cron scheduling system that allows you to call endpoints and functions automatically when the cron expression matches the current time. + +When a job is scheduled, a new task is created and runs in the background. Each task uses its given cron expression and the current time to determine how long it needs to sleep before it can execute. + +The cron parser in Oxygen is based on the same specifications as the one used in Spring. You can find more information about this on the [Spring Cron Expressions](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/support/CronExpression.html) page. + +### Cron Expression Syntax +The following is a breakdown of what each parameter in our cron expression represents. While our specification closely resembles the one defined by Spring, it's not an exact 1-to-1 match. +``` +The string has six single space-separated time and date fields: + + ┌───────────── second (0-59) + │ ┌───────────── minute (0 - 59) + │ │ ┌───────────── hour (0 - 23) + │ │ │ ┌───────────── day of the month (1 - 31) + │ │ │ │ ┌───────────── month (1 - 12) (or JAN-DEC) + │ │ │ │ │ ┌───────────── day of the week (1 - 7) + │ │ │ │ │ │ (Monday is 1, Tue is 2... and Sunday is 7) + │ │ │ │ │ │ + * * * * * * +``` +Partial expressions are also supported, which means that subsequent expressions can be left out (they are defaulted to `'*'`). + +```julia +# In this example we see only the `seconds` part of the expression is defined. +# This means that all following expressions are automatically defaulted to '*' expressions +@cron "*/2" function() + println("runs every 2 seconds") +end +``` + +### Scheduling Endpoints + +The `router()` function has a keyword argument called `cron`, which accepts a cron expression that determines when an endpoint is called. Just like the other keyword arguments, it can be reused by endpoints that share routers or be overridden by inherited endpoints. + +```julia +# execute at 8, 9 and 10 o'clock of every day. +@get router("/cron-example", cron="0 0 8-10 * * *") function(req) + println("here") +end + +# execute this endpoint every 5 seconds (whenever current_seconds % 5 == 0) +every5 = router("/cron", cron="*/5") + +# this endpoint inherits the cron expression +@get every5("/first") function(req) + println("first") +end + +# Now this endpoint executes every 2 seconds ( whenever current_seconds % 2 == 0 ) instead of every 5 +@get every5("/second", cron="*/2") function(req) + println("second") +end +``` + +### Scheduling Functions + +In addition to scheduling endpoints, you can also use the new `@cron` macro to schedule functions. This is useful if you want to run code at specific times without making it visible or callable in the API. + +```julia +@cron "*/2" function() + println("runs every 2 seconds") +end + +@cron "0 0/30 8-10 * * *" function() + println("runs at 8:00, 8:30, 9:00, 9:30, 10:00 and 10:30 every day") +end +``` + +### Starting & Stopping Cron Jobs + +When you run `serve()` or `serveparallel()`, all registered cron jobs are automatically started. If the server is stopped or killed, all running jobs will also be terminated. You can stop the server and all repeat tasks and cron jobs by calling the `terminate()` function or manually killing the server with `ctrl+C`. + +In addition, Oxygen provides utility functions to manually start and stop cron jobs: `startcronjobs()` and `stopcronjobs()`. These functions can be used outside of a web server as well. + ## Repeat Tasks The `router()` function has an `interval` parameter which is used to call diff --git a/demo/crondemo.jl b/demo/crondemo.jl new file mode 100644 index 00000000..c8e3834e --- /dev/null +++ b/demo/crondemo.jl @@ -0,0 +1,58 @@ +module CronDemo + +include("../src/Oxygen.jl") +using .Oxygen +using HTTP +using Dates + +# You can use the @cron macro directly + +@cron "*/2" function() + println("every 2 seconds") +end + +@cron "*/5" function every5seconds() + println("every 5 seconds") +end + +value = 0 + +# You can also just use the 'cron' keyword that's apart of the router() function +@get router("/increment", cron="*/11", interval=4) function() + global value += 1 + return value +end + +@get router("/getvalue") function() + return value +end + +# all endpoints will inherit this cron expression +pingpong = router("/pingpong", cron="*/3") + +@get pingpong("/ping") function() + println("ping") + return "ping" +end + +# here we override the inherited cron expression +@get pingpong("/pong", cron="*/7") function() + println("pong") + return "pong" +end + +@get "/home" function() + "home" +end + +@get "/stop" function() + stopcronjobs() +end + +@get "/start" function() + startcronjobs() +end + +serve() + +end \ No newline at end of file diff --git a/docs/make.jl b/docs/make.jl index 01d6eef3..afb6a587 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -14,6 +14,7 @@ makedocs( "tutorial/path_parameters.md", "tutorial/query_parameters.md", "tutorial/request_body.md", + "tutorial/cron_scheduling.md", "tutorial/bigger_applications.md", "tutorial/oauth2.md" ] diff --git a/docs/src/api.md b/docs/src/api.md index 7bf870ab..b7179b8e 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -49,13 +49,20 @@ json binary ``` +## Repeat Tasks & Cron Scheduling +```@docs +@cron +starttasks +stoptasks +startcronjobs +stopcronjobs +``` + ## Extra's ```@docs router internalrequest redirect terminate -starttasks -stoptasks resetstate ``` diff --git a/docs/src/index.md b/docs/src/index.md index 1a0f0e13..bce060c8 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -27,8 +27,9 @@ Breathe easy knowing you can quickly spin up a web server with abstractions you' - Out-of-the-box JSON serialization & deserialization (customizable) - Type definition support for path parameters - Built-in multithreading support -- Static file hosting +- Built-in Cron Scheduling (on endpoints & functions) - Middleware chaining (at the application, router, and route levels) +- Static & Dynamic file hosting - Route tagging - Repeat tasks @@ -204,11 +205,12 @@ The `router()` function is an HOF (higher order function) that allows you to reu Below are the arguments the `router()` function can take: ```julia -router(prefix::String; tags::Vector, middleware::Vector, interval::Real) +router(prefix::String; tags::Vector, middleware::Vector, interval::Real, cron::String) ``` -- `tags` are used to organize endpoints in the autogenerated docs -- `middleware` is used to setup router & route-specific middleware -- `interval` is used to support repeat actions (*calling a request handler on a set interval in seconds*) +- `tags` - are used to organize endpoints in the autogenerated docs +- `middleware` - is used to setup router & route-specific middleware +- `interval` - is used to support repeat actions (*calling a request handler on a set interval in seconds*) +- `cron` - is used to specify a cron expression that determines when to call the request handler. ```julia using Oxygen @@ -229,6 +231,83 @@ end serve() ``` +## Cron Scheduling + +Oxygen comes with a built-in cron scheduling system that allows you to call endpoints and functions automatically when the cron expression matches the current time. + +When a job is scheduled, a new task is created and runs in the background. Each task uses its given cron expression and the current time to determine how long it needs to sleep before it can execute. + +The cron parser in Oxygen is based on the same specifications as the one used in Spring. You can find more information about this on the [Spring Cron Expressions](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/support/CronExpression.html) page. + +### Cron Expression Syntax +The following is a breakdown of what each parameter in our cron expression represents. While our specification closely resembles the one defined by Spring, it's not an exact 1-to-1 match. +``` +The string has six single space-separated time and date fields: + + ┌───────────── second (0-59) + │ ┌───────────── minute (0 - 59) + │ │ ┌───────────── hour (0 - 23) + │ │ │ ┌───────────── day of the month (1 - 31) + │ │ │ │ ┌───────────── month (1 - 12) (or JAN-DEC) + │ │ │ │ │ ┌───────────── day of the week (1 - 7) + │ │ │ │ │ │ (Monday is 1, Tue is 2... and Sunday is 7) + │ │ │ │ │ │ + * * * * * * +``` +Partial expressions are also supported, which means that subsequent expressions can be left out (they are defaulted to `'*'`). + +```julia +# In this example we see only the `seconds` part of the expression is defined. +# This means that all following expressions are automatically defaulted to '*' expressions +@cron "*/2" function() + println("runs every 2 seconds") +end +``` + +### Scheduling Endpoints + +The `router()` function has a keyword argument called `cron`, which accepts a cron expression that determines when an endpoint is called. Just like the other keyword arguments, it can be reused by endpoints that share routers or be overridden by inherited endpoints. + +```julia +# execute at 8, 9 and 10 o'clock of every day. +@get router("/cron-example", cron="0 0 8-10 * * *") function(req) + println("here") +end + +# execute this endpoint every 5 seconds (whenever current_seconds % 5 == 0) +every5 = router("/cron", cron="*/5") + +# this endpoint inherits the cron expression +@get every5("/first") function(req) + println("first") +end + +# Now this endpoint executes every 2 seconds ( whenever current_seconds % 2 == 0 ) instead of every 5 +@get every5("/second", cron="*/2") function(req) + println("second") +end +``` + +### Scheduling Functions + +In addition to scheduling endpoints, you can also use the new `@cron` macro to schedule functions. This is useful if you want to run code at specific times without making it visible or callable in the API. + +```julia +@cron "*/2" function() + println("runs every 2 seconds") +end + +@cron "0 0/30 8-10 * * *" function() + println("runs at 8:00, 8:30, 9:00, 9:30, 10:00 and 10:30 every day") +end +``` + +### Starting & Stopping Cron Jobs + +When you run `serve()` or `serveparallel()`, all registered cron jobs are automatically started. If the server is stopped or killed, all running jobs will also be terminated. You can stop the server and all repeat tasks and cron jobs by calling the `terminate()` function or manually killing the server with `ctrl+C`. + +In addition, Oxygen provides utility functions to manually start and stop cron jobs: `startcronjobs()` and `stopcronjobs()`. These functions can be used outside of a web server as well. + ## Repeat Tasks The `router()` function has an `interval` parameter which is used to call diff --git a/docs/src/tutorial/cron_scheduling.md b/docs/src/tutorial/cron_scheduling.md new file mode 100644 index 00000000..cceba883 --- /dev/null +++ b/docs/src/tutorial/cron_scheduling.md @@ -0,0 +1,76 @@ +# Cron Scheduling + +Oxygen comes with a built-in cron scheduling system that allows you to call endpoints and functions automatically when the cron expression matches the current time. + +When a job is scheduled, a new task is created and runs in the background. Each task uses its given cron expression and the current time to determine how long it needs to sleep before it can execute. + +The cron parser in Oxygen is based on the same specifications as the one used in Spring. You can find more information about this on the [Spring Cron Expressions](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/support/CronExpression.html) page. + +## Cron Expression Syntax +The following is a breakdown of what each parameter in our cron expression represents. While our specification closely resembles the one defined by Spring, it's not an exact 1-to-1 match. +``` +The string has six single space-separated time and date fields: + + ┌───────────── second (0-59) + │ ┌───────────── minute (0 - 59) + │ │ ┌───────────── hour (0 - 23) + │ │ │ ┌───────────── day of the month (1 - 31) + │ │ │ │ ┌───────────── month (1 - 12) (or JAN-DEC) + │ │ │ │ │ ┌───────────── day of the week (1 - 7) + │ │ │ │ │ │ (Monday is 1, Tue is 2... and Sunday is 7) + │ │ │ │ │ │ + * * * * * * +``` +Partial expressions are also supported, which means that subsequent expressions can be left out (they are defaulted to `'*'`). + +```julia +# In this example we see only the `seconds` part of the expression is defined. +# This means that all following expressions are automatically defaulted to '*' expressions +@cron "*/2" function() + println("runs every 2 seconds") +end +``` + +## Scheduling Endpoints + +The `router()` function has a keyword argument called `cron`, which accepts a cron expression that determines when an endpoint is called. Just like the other keyword arguments, it can be reused by endpoints that share routers or be overridden by inherited endpoints. + +```julia +# execute at 8, 9 and 10 o'clock of every day. +@get router("/cron-example", cron="0 0 8-10 * * *") function(req) + println("here") +end + +# execute this endpoint every 5 seconds (whenever current_seconds % 5 == 0) +every5 = router("/cron", cron="*/5") + +# this endpoint inherits the cron expression +@get every5("/first") function(req) + println("first") +end + +# Now this endpoint executes every 2 seconds ( whenever current_seconds % 2 == 0 ) instead of every 5 +@get every5("/second", cron="*/2") function(req) + println("second") +end +``` + +## Scheduling Functions + +In addition to scheduling endpoints, you can also use the new `@cron` macro to schedule functions. This is useful if you want to run code at specific times without making it visible or callable in the API. + +```julia +@cron "*/2" function() + println("runs every 2 seconds") +end + +@cron "0 0/30 8-10 * * *" function() + println("runs at 8:00, 8:30, 9:00, 9:30, 10:00 and 10:30 every day") +end +``` + +## Starting & Stopping Cron Jobs + +When you run `serve()` or `serveparallel()`, all registered cron jobs are automatically started. If the server is stopped or killed, all running jobs will also be terminated. You can stop the server and all repeat tasks and cron jobs by calling the `terminate()` function or manually killing the server with `ctrl+C`. + +In addition, Oxygen provides utility functions to manually start and stop cron jobs: `startcronjobs()` and `stopcronjobs()`. These functions can be used outside of a web server as well. \ No newline at end of file diff --git a/src/Oxygen.jl b/src/Oxygen.jl index 07d06d81..9075881e 100644 --- a/src/Oxygen.jl +++ b/src/Oxygen.jl @@ -7,10 +7,10 @@ include("util.jl" ); using .Util include("bodyparsers.jl"); using .BodyParsers include("core.jl"); using .Core -export @get, @post, @put, @patch, @delete, @route, @staticfiles, @dynamicfiles, +export @get, @post, @put, @patch, @delete, @route, @staticfiles, @dynamicfiles, @cron, serve, serveparallel, terminate, internalrequest, redirect, queryparams, binary, text, json, html, file, configdocs, mergeschema, setschema, getschema, router, enabledocs, disabledocs, isdocsenabled, starttasks, stoptasks, - resetstate + resetstate, startcronjobs, stopcronjobs end \ No newline at end of file diff --git a/src/autodoc.jl b/src/autodoc.jl index ce152bd9..19f7258c 100644 --- a/src/autodoc.jl +++ b/src/autodoc.jl @@ -2,12 +2,14 @@ module AutoDoc using HTTP using Dates +include("cron.jl"); using .Cron include("util.jl"); using .Util export registerschema, docspath, schemapath, getschema, swaggerhtml, configdocs, mergeschema, setschema, router, enabledocs, disabledocs, isdocsenabled, registermountedfolder, - getrepeatasks, hasmiddleware, compose, resetstatevariables + getrepeatasks, hasmiddleware, compose, resetstatevariables, + @cron, stopcronjobs, startcronjobs, getcronjobs, resetcronstate struct TaggedRoute httpmethods::Vector{String} @@ -29,6 +31,7 @@ global schemapath = "/schema" global mountedfolders = Set{String}() global taggedroutes = Dict{String, TaggedRoute}() global repeattasks = [] +global cronjobs = [] global schema = defaultSchema global const custommiddlware = Ref{Dict{String, Tuple}}(Dict()) @@ -36,6 +39,10 @@ function getrepeatasks() return repeattasks end +function getcronjobs() + return cronjobs +end + function resetstatevariables() global enable_auto_docs = true global docspath = "/docs" @@ -43,6 +50,7 @@ function resetstatevariables() global mountedfolders = Set{String}() global taggedroutes = Dict{String, TaggedRoute}() global repeattasks = [] + global cronjobs = [] global schema = defaultSchema custommiddlware[] = Dict() return @@ -204,15 +212,17 @@ are used to group and organize the autogenerated documentation function router(prefix::String = ""; tags::Vector{String} = Vector{String}(), middleware::Union{Nothing, Vector} = nothing, - interval::Union{Real, Nothing} = nothing) + interval::Union{Real, Nothing} = nothing, + cron::Union{String, Nothing} = nothing) - return createrouter(prefix, tags, middleware, interval) + return createrouter(prefix, tags, middleware, interval, cron) end function createrouter(prefix::String, routertags::Vector{String}, routermiddleware::Union{Nothing, Vector}, - routerinterval::Union{Real, Nothing}) + routerinterval::Union{Real, Nothing}, + routercron::Union{String, Nothing} = nothing) # appends a "/" character to the given string if doesn't have one. function fixpath(path::String) @@ -227,7 +237,8 @@ function createrouter(prefix::String, return function(path = nothing; tags::Vector{String} = Vector{String}(), middleware::Union{Nothing, Vector} = nothing, - interval::Union{Real, Nothing} = routerinterval) + interval::Union{Real, Nothing} = routerinterval, + cron::Union{String, Nothing} = routercron) # combine the current routers prefix with this specfic path path = !isnothing(path) ? "$(fixpath(prefix))$(fixpath(path))" : fixpath(prefix) @@ -251,6 +262,15 @@ function createrouter(prefix::String, end end + # register cron expression for this route + if !isnothing(cron) && !isempty(cron) + task = (path, httpmethod, cron) + # don't add duplicate cron jobs + if isnothing(findfirst(x -> x === task, cronjobs)) + push!(cronjobs, task) + end + end + # register tags if !haskey(taggedroutes, path) taggedroutes[path] = TaggedRoute([httpmethod], combinedtags) diff --git a/src/core.jl b/src/core.jl index 2c1279e3..5c5064c0 100644 --- a/src/core.jl +++ b/src/core.jl @@ -9,11 +9,11 @@ include("fileutil.jl"); using .FileUtil include("streamutil.jl"); using .StreamUtil include("autodoc.jl"); using .AutoDoc -export @get, @post, @put, @patch, @delete, @route, @staticfiles, @dynamicfiles, +export @get, @post, @put, @patch, @delete, @route, @staticfiles, @dynamicfiles, @cron, start, serve, serveparallel, terminate, internalrequest, file, configdocs, mergeschema, setschema, getschema, router, enabledocs, disabledocs, isdocsenabled, registermountedfolder, - starttasks, stoptasks, resetstate + starttasks, stoptasks, resetstate, startcronjobs, stopcronjobs global const ROUTER = Ref{HTTP.Handlers.Router}(HTTP.Router()) global const server = Ref{Union{HTTP.Server, Nothing}}(nothing) @@ -43,14 +43,41 @@ Start all background repeat tasks """ function starttasks() timers[] = [] - for task in getrepeatasks() + tasks = getrepeatasks() + + # exit function early if no tasks are register + if isempty(tasks) + return + end + + println() + printstyled("[ Starting $(length(tasks)) Repeat Task(s)\n", color = :magenta, bold = true) + + for task in tasks path, httpmethod, interval = task + message = "method: $httpmethod, path: $path, inverval: $interval seconds" + printstyled("[ Task: ", color = :magenta, bold = true) + println(message) action = (timer) -> internalrequest(HTTP.Request(httpmethod, path)) timer = Timer(action, 0, interval=interval) push!(timers[], timer) end end + +""" +Register all cron jobs +""" +function registercronjobs() + for job in getcronjobs() + path, httpmethod, expression = job + @cron expression path function() + internalrequest(HTTP.Request(httpmethod, path)) + end + end +end + + """ stoptasks() @@ -106,6 +133,7 @@ function serve(; middleware::Vector=[], host="127.0.0.1", port=8080, serialize=t startserver(host, port, kwargs, async, (kwargs) -> HTTP.serve!(stream_handler(configured_middelware), host, port; kwargs...) ) + server[] # this value is returned if startserver() is ran in async mode end """ @@ -122,6 +150,7 @@ function serveparallel(; middleware::Vector=[], host="127.0.0.1", port=8080, que startserver(host, port, kwargs, async, (kwargs) -> StreamUtil.start(stream_handler(configured_middelware); host=host, port=port, queuesize=queuesize, kwargs...) ) + server[] # this value is returned if startserver() is ran in async mode end @@ -146,15 +175,20 @@ function startserver(host, port, kwargs, async, start) try serverwelcome(host, port) setup() - kwargs = preprocesskwargs(kwargs) + server[] = start(preprocesskwargs(kwargs)) starttasks() - server[] = start(kwargs) + registercronjobs() + startcronjobs() if !async - wait(server[]) + try + wait(server[]) + catch + println() # this pushes the "[ Info: Server on 127.0.0.1:8080 closing" to the next line + end end finally # close server on exit if we aren't running asynchronously - if !async + if !async terminate() end # only reset state on exit if we aren't running asynchronously & are running it interactively @@ -174,6 +208,8 @@ function resetstate() server[] = nothing # reset autodocs state variables resetstatevariables() + # reset cron module state + resetcronstate() end @@ -211,6 +247,8 @@ stops the webserver immediately """ function terminate() if !isnothing(server[]) && isopen(server[]) + # stop background cron jobs + stopcronjobs() # stop background tasks stoptasks() # stop any background worker threads diff --git a/src/cron.jl b/src/cron.jl new file mode 100644 index 00000000..f179966f --- /dev/null +++ b/src/cron.jl @@ -0,0 +1,569 @@ +module Cron + +using Dates +export @cron, startcronjobs, stopcronjobs, resetcronstate + +global const jobs = Ref{Vector}([]) +global const job_definitions = Ref{Vector}([]) + +""" + @cron(expression::String, func::Function) + +Registers a function with a cron expression. This will extract either the function name +or the random Id julia assigns to each lambda function. +""" +macro cron(expression, func) + quote + local f = $(esc(func)) + local job = ($(esc(expression)), "$f", f) + push!($job_definitions[], job) + end +end + +""" + @cron(expression::String, name::String, func::Function) + +This variation Provide another way manually "name" a registered function. This information +is used by the server on startup to log out all cron jobs. +""" +macro cron(expression, name, func) + quote + local job = ($(esc(expression)), $(esc(name)), $(esc(func))) + push!($job_definitions[], job) + end +end + + +""" + stopcronjobs() + +Stop each background task by sending an InterruptException to each one +""" +function stopcronjobs() + for job in jobs[] + try Base.throwto(job, InterruptException()) catch end + end +end + +""" +Reset the globals in this module +""" +function resetcronstate() + stopcronjobs() + job_definitions[] = [] + jobs[] = [] +end + +""" + startcronjobs() + +Start all the cron job_definitions within their own async task. Each individual task will loop conintually +and sleep untill the next time it's suppost to +""" +function startcronjobs() + + if isempty(job_definitions[]) + return + end + + println() + printstyled("[ Starting $(length(job_definitions[])) Cron Job(s)\n", color = :green, bold = true) + + i = 1 + for (expression, name, func) in job_definitions[] + message = isnothing(name) ? "$expression" : "id: $i, expr: $expression, name: $name" + printstyled("[ Cron: ", color = :green, bold = true) + println(message) + t = @async begin + while true + # get the current datetime object + current_time::DateTime = now() + # get the next datetime object that matches this cron expression + next_date = next(expression, current_time) + # figure out how long we need to wait + ms_to_wait = sleep_until(current_time, next_date) + # Ensures that the function is never called twice in the in same second + if ms_to_wait >= 1_000 + sleep(Millisecond(ms_to_wait)) + try + @async func() + catch error + @error "ERROR in CRON job: " exception=(error, catch_backtrace()) + end + end + end + end + push!(jobs[], t) + i += 1 + end + println() +end + +weeknames = Dict( + "SUN" => 7, + "MON" => 1, + "TUE" => 2, + "WED" => 3, + "THU" => 4, + "FRI" => 5, + "SAT" => 6, +) + +monthnames = Dict( + "JAN" => 1, + "FEB" => 2, + "MAR" => 3, + "APR" => 4, + "MAY" => 5, + "JUN" => 6, + "JUL" => 7, + "AUG" => 8, + "SEP" => 9, + "OCT" => 10, + "NOV" => 11, + "DEC" => 12 +) + +function translate(input::SubString) + if haskey(weeknames, input) + return weeknames[input] + elseif haskey(monthnames, input) + return monthnames[input] + else + return input + end +end + + +function customparse(type, input) + if isa(input, type) + return input + end + return parse(type, input) +end + +# https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/support/CronExpression.html +# https://crontab.cronhub.io/ + + +""" +return the date for the last weekday (Friday) of the month +""" +function lastweekdayofmonth(time::DateTime) + current = lastdayofmonth(time) + while dayofweek(current) > 5 + current -= Day(1) + end + return current +end + +function isweekday(time::DateTime) + daynumber = dayofweek(time) + return daynumber >= 1 && daynumber <= 5 +end + +""" +Return the date of the weekday that's nearest to the nth day of the month +""" +function nthweekdayofmonth(time::DateTime, n::Int64) + if n < 1 || n > Dates.daysinmonth(time) + error("n must be between 1 and $(Dates.daysinmonth(time))") + end + + target = DateTime(year(time), month(time), day(n)) + if isweekday(target) + return target + end + + current_month = month(time) + before = DateTime(year(time), month(time), day(max(1, n-1))) + after = DateTime(year(time), month(time), day(min(n+1, Dates.daysinmonth(time)))) + + while true + if isweekday(before) && month(before) == current_month + return before + elseif isweekday(after) && month(after) == current_month + return after + end + if day(before) > 1 + before -= Day(1) + elseif day(after) < Dates.daysinmonth(time) + after += Day(1) + else + break + end + end +end + +""" +return the date for the last weekday (Friday) of the week +""" +function lastweekday(time::DateTime) + current = lastdayofweek(time) + while dayofweek(current) > 5 + current -= Day(1) + end + return current +end + +function lasttargetdayofmonth(time::DateTime, daynumber::Int64) + last_day = lastdayofmonth(time) + current = DateTime(year(last_day), month(last_day), day(Dates.daysinmonth(time))) + while dayofweek(current) != daynumber + current -= Day(1) + end + return current +end + +function getoccurance(time::DateTime, daynumber::Int64, occurance::Int64) + baseline = firstdayofmonth(time) + # increment untill we hit the daytype we want + while dayofweek(baseline) !== daynumber + baseline += Day(1) + end + + # keep jumping by 7 days untill we the the target occurance + while dayofweekofmonth(baseline) !== occurance + baseline += Day(7) + end + + return baseline +end + + +function matchexpression(input::Union{SubString,Nothing}, time::DateTime, converter, maxvalue, adjustedmax=nothing) :: Bool + try + + # base case: return true if + if isnothing(input) + return true + end + + # Handle sole week or month expressions + if haskey(weeknames, input) || haskey(monthnames, input) + input = translate(input) + end + + numericvalue = isa(input, Int64) ? input : tryparse(Int64, input) + + current = converter(time) + + # need to convert zero based max values to their "real world" equivalent + if !isnothing(adjustedmax) && current == 0 + current = adjustedmax + end + + # Every Second + if input == "*" + return true + # At X seconds past the minute + elseif numericvalue !== nothing + # If this field is zero indexed and set to 0 + if !isnothing(adjustedmax) && current == adjustedmax + return numericvalue == (current - adjustedmax) + else + return numericvalue == current + end + + elseif contains(input, ",") + lowerbound, upperbound = split(input, ",") + lowerbound, upperbound = translate(lowerbound), translate(upperbound) + return current == customparse(Int64, lowerbound) || current === customparse(Int64, upperbound) + + elseif contains(input, "-") + lowerbound, upperbound = split(input, "-") + lowerbound, upperbound = translate(lowerbound), translate(upperbound) + + if lowerbound == "*" + return current <= customparse(Int64, upperbound) + elseif upperbound == "*" + return current >= customparse(Int64, lowerbound) + else + return current >= customparse(Int64, lowerbound) && current <= customparse(Int64, upperbound) + end + + elseif contains(input, "/") + numerator, denominator = split(input, "/") + numerator, denominator = translate(numerator), translate(denominator) + # Every second, starting at Y seconds past the minute + if denominator == "*" + numerator = customparse(Int64, numerator) + return current >= numerator + elseif numerator == "*" + # Every X seconds + denominator = customparse(Int64, denominator) + return current % denominator == 0 + else + # Every X seconds, starting at Y seconds past the minute + numerator = customparse(Int64, numerator) + denominator = customparse(Int64, denominator) + return current % denominator == 0 && current >= numerator + end + end + + return false + catch + return false + end +end + + +function match_special(input::Union{SubString,Nothing}, time::DateTime, converter, maxvalue, adjustedmax=nothing) :: Bool + + # base case: return true if + if isnothing(input) + return true + end + + # Handle sole week or month expressions + if haskey(weeknames, input) || haskey(monthnames, input) + input = translate(input) + end + + numericvalue = isa(input, Int64) ? input : tryparse(Int64, input) + current = converter(time) + + # if given a datetime as a max value, means this is a special case expression + if maxvalue isa Function + maxvalue = converter(maxvalue(time)) + end + + current = converter(time) + + # need to convert zero based max values to their "real world" equivalent + if !isnothing(adjustedmax) && current == 0 + current = adjustedmax + end + + # At X seconds past the minute + if numericvalue !== nothing + # If this field is zero indexed and set to 0 + if !isnothing(adjustedmax) && current == adjustedmax + return numericvalue == (current - adjustedmax) + else + return numericvalue == current + end + + elseif input == "?" || input == "*" + return true + + # comamnd: Return the last valid value for this field + elseif input == "L" + return current == maxvalue + + # command: the last weekday of the month + elseif input == "LW" + return current == converter(lastweekdayofmonth(time)) + + # command negative offset (i.e. L-n), it means "nth-to-last day of the month". + elseif contains(input, "L-") + return current == maxvalue - customparse(Int64, replace(input, "L-" => "")) + + # ex.) "11W" = on the weekday nearest day 11th of the month + elseif match(r"[0-9]+W", input) !== nothing + daynumber = parse(Int64, replace(input, "W" => "")) + return current == dayofmonth(nthweekdayofmonth(time, daynumber)) + + # ex.) "4L" = last Thursday of the month + elseif match(r"[0-9]+L", input) !== nothing + daynumber = parse(Int64, replace(input, "L" => "")) + return dayofmonth(time) == dayofmonth(lasttargetdayofmonth(time, daynumber)) + + # ex.) "THUL" = last Thursday of the month + elseif match(r"([A-Z]+)L", input) !== nothing + dayabbreviation = match(r"([A-Z]+)L", input)[1] + daynumber = weeknames[dayabbreviation] + return dayofmonth(time) == dayofmonth(lasttargetdayofmonth(time, daynumber)) + + # ex.) 5#2" = the second Friday in the month + elseif match(r"([0-9])#([0-9])", input) !== nothing + daynumber, position = match(r"([0-9])#([0-9])", input).captures + target = getoccurance(time, parse(Int64, daynumber), parse(Int64, position)) + return dayofmonth(time) == dayofmonth(target) + + # ex.) "MON#1" => the first Monday in the month + elseif match(r"([A-Z]+)#([0-9])", input) !== nothing + daynumber, position = match(r"([A-Z]+)#([0-9])", input).captures + target = getoccurance(time, weeknames[daynumber], parse(Int64, position)) + return dayofmonth(time) == dayofmonth(target) + else + return false + end + +end + + +function iscronmatch(expression::String, time::DateTime) :: Bool + parsed_expression::Vector{Union{Nothing, SubString{String}}} = split(strip(expression), " ") + + # fill in any missing arguments with nothing, so the array is always + fill_length = 6 - length(parsed_expression) + if fill_length > 0 + parsed_expression = vcat(parsed_expression, fill(nothing, fill_length)) + end + + # extract individual expressions + seconds_expression, minute_expression, hour_expression, + dayofmonth_expression, month_expression, dayofweek_expression = parsed_expression + + if !match_month(month_expression, time) + return false + end + + if !match_dayofmonth(dayofmonth_expression, time) + return false + end + + if !match_dayofweek(dayofweek_expression, time) + return false + end + + if !match_hour(hour_expression, time) + return false + end + + if !match_minutes(minute_expression, time) + return false + end + + if !match_seconds(seconds_expression, time) + return false + end + + return true +end + + +function match_seconds(seconds_expression, time::DateTime) + return matchexpression(seconds_expression, time, second, 59, 60) +end + +function match_minutes(minute_expression, time::DateTime) + return matchexpression(minute_expression, time, minute, 59, 60) +end + +function match_hour(hour_expression, time::DateTime) + return matchexpression(hour_expression, time, hour, 23, 24) +end + +function match_dayofmonth(dayofmonth_expression, time::DateTime) + return match_special(dayofmonth_expression, time, dayofmonth, lastdayofmonth) || matchexpression(dayofmonth_expression, time, dayofmonth, lastdayofmonth) +end + +function match_month(month_expression, time::DateTime) + return match_special(month_expression, time, month, 12) || matchexpression(month_expression, time, month, 12) +end + +function match_dayofweek(dayofweek_expression, time::DateTime) + return match_special(dayofweek_expression, time, dayofweek, lastdayofweek) || matchexpression(dayofweek_expression, time, dayofweek, lastdayofweek) +end + + +""" +This function takes a cron expression and a start_time and returns the next datetime object that matches this +expression +""" +function next(cron_expr::String, start_time::DateTime)::DateTime + + parsed_expression::Vector{Union{Nothing, SubString{String}}} = split(strip(cron_expr), " ") + + # fill in any missing arguments with nothing, so the array is always + fill_length = 6 - length(parsed_expression) + if fill_length > 0 + parsed_expression = vcat(parsed_expression, fill(nothing, fill_length)) + end + + # extract individual expressions + seconds_expression, minute_expression, hour_expression, + dayofmonth_expression, month_expression, dayofweek_expression = parsed_expression + + # initialize a candidate time with start_time plus one second + candidate_time = start_time + Second(1) + + # loop until candidate time matches all fields of cron expression + while true + + # check if candidate time matches month field + if !match_month(month_expression, candidate_time) + # increment candidate time by one month and reset day, hour, + # minute and second to minimum values + candidate_time += Month(1) - Day(day(candidate_time)) + Day(1) - + Hour(hour(candidate_time)) + Hour(0) - + Minute(minute(candidate_time)) + Minute(0) - + Second(second(candidate_time)) + Second(0) + continue + end + + # check if candidate time matches day of month field + if !match_dayofmonth(dayofmonth_expression, candidate_time) + # increment candidate time by one day and reset hour, + # minute and second to minimum values + candidate_time += Day(1) - Hour(hour(candidate_time)) + + Hour(0) - Minute(minute(candidate_time)) + + Minute(0) - Second(second(candidate_time)) + + Second(0) + continue + end + + # check if candidate time matches day of week field + if !match_dayofweek(dayofweek_expression, candidate_time) + # increment candidate time by one day and reset hour, + # minute and second to minimum values + candidate_time += Day(1) - Hour(hour(candidate_time)) + + Hour(0) - Minute(minute(candidate_time)) + + Minute(0) - Second(second(candidate_time)) + + Second(0) + continue + end + + # check if candidate time matches hour field + if !match_hour(hour_expression, candidate_time) + # increment candidate time by one hour and reset minute + # and second to minimum values + candidate_time += Hour(1) - Minute(minute(candidate_time)) + + Minute(0) - Second(second(candidate_time)) + + Second(0) + continue + end + + # check if candidate time matches minute field + if !match_minutes(minute_expression, candidate_time) + # increment candidate time by one minute and reset second + # to minimum value + candidate_time += Minute(1) - Second(second(candidate_time)) + + Second(0) + continue + end + + # check if candidatet ime matches second field + if !match_seconds(seconds_expression, candidate_time) + # increment candidatet ime by one second + candidate_time += Second(1) + continue + end + + break # exit the loop as all fields match + end + + return remove_milliseconds(candidate_time) # return the next matching tme +end + +# remove the milliseconds from a datetime by rounding down at the seconds +function remove_milliseconds(dt::DateTime) + return floor(dt, Dates.Second) +end + +function sleep_until(now::DateTime, future::DateTime) + # Check if the future datetime is later than the current datetime + if future > now + # Convert the difference between future and now to milliseconds + ms = Dates.value(future - now) + # Return the milliseconds to sleep + return ms + else + # Return zero if the future datetime is not later than the current datetime + return 0 + end +end + +end \ No newline at end of file diff --git a/test/content/myfile b/test/content/myfile new file mode 100644 index 00000000..cd000c13 --- /dev/null +++ b/test/content/myfile @@ -0,0 +1 @@ +this file doesn't have an extension, which means it should be interpreted as as text/file \ No newline at end of file diff --git a/test/crontests.jl b/test/crontests.jl new file mode 100644 index 00000000..2da081ec --- /dev/null +++ b/test/crontests.jl @@ -0,0 +1,444 @@ +module CronTests +using Test +using HTTP +using JSON3 +using StructTypes +using Sockets +using Dates + +include("../src/Oxygen.jl") +using .Oxygen + +include("../src/cron.jl") +using .Cron: iscronmatch, isweekday, lastweekdayofmonth, + next, sleep_until, lastweekday, nthweekdayofmonth, + matchexpression + + +@testset "methods" begin + @test lastweekday(DateTime(2022,1,1,0,0,0)) == DateTime(2021,12,31,0,0,0) + + @test lastweekday(DateTime(2022,1,3,0,0,0)) != DateTime(2022,1,8,0,0,0) + @test lastweekday(DateTime(2022,1,3,0,0,0)) == DateTime(2022,1,7,0,0,0) + @test lastweekday(DateTime(2022,1,3,0,0,0)) != DateTime(2022,1,6,0,0,0) + +end + +@testset "lastweekdayofmonth" begin + @test lastweekdayofmonth(DateTime(2022,1,1,0,0,0)) == DateTime(2022,1,31,0,0,0) +end + + +@testset "nthweekdayofmonth" begin + @test_throws ErrorException nthweekdayofmonth(DateTime(2022,1,1,0,0,0), -1) + @test_throws ErrorException nthweekdayofmonth(DateTime(2022,1,1,0,0,0), 50) +end + +@testset "sleep_until" begin + @test sleep_until(now(), DateTime(2020,1,1,0,0,0)) == 0 +end + + +@testset "lastweekdayofmonth function tests" begin + # Test when last day of the month is a Friday + time = DateTime(2023, 9, 20) + @test lastweekdayofmonth(time) == DateTime(2023, 9, 29) + + # Test when last day of the month is a Saturday + time = DateTime(2023, 4, 15) + @test lastweekdayofmonth(time) == DateTime(2023, 4, 28) + + # Test when last day of the month is a Sunday + time = DateTime(2023, 10, 10) + @test lastweekdayofmonth(time) == DateTime(2023, 10, 31) # Corrected date + + # Test when last day of the month is a weekday (Monday-Thursday) + time = DateTime(2023, 11, 15) + @test lastweekdayofmonth(time) == DateTime(2023, 11, 30) + + # Test with a leap year (February) + time = DateTime(2024, 2, 20) + @test lastweekdayofmonth(time) == DateTime(2024, 2, 29) + + # Test with a non-leap year (February) + time = DateTime(2023, 2, 20) + @test lastweekdayofmonth(time) == DateTime(2023, 2, 28) # Corrected date +end + + + + +@testset "next function tests" begin + # Test all fields with asterisk (wildcard) + start_time = DateTime(2023, 4, 3, 12, 30, 45) + @test next("* * * * * *", start_time) == start_time + Second(1) + + # Test matching specific fields + start_time = DateTime(2023, 1, 1, 0, 0, 0) + @test next("30 15 10 5 2 *", start_time) == DateTime(2023, 2, 5, 10, 15, 30) + + # Test edge case: leap year + start_time = DateTime(2020, 1, 1, 0, 0, 0) + @test next("0 0 0 29 2 *", start_time) == DateTime(2020, 2, 29, 0, 0, 0) + + # Test edge case: end of the month + start_time = DateTime(2023, 12, 30, 23, 59, 59) + @test next("0 0 0 31 12 *", start_time) == DateTime(2023, 12, 31, 0, 0, 0) + + # Test edge case: end of the year + start_time = DateTime(2022, 12, 31, 23, 59, 59) + @test next("0 0 0 1 1 *", start_time) == DateTime(2023, 1, 1, 0, 0, 0) + + # Test day of the week + start_time = DateTime(2023, 4, 3, 11, 59, 59) + @test next("0 0 12 * * 1", start_time) == DateTime(2023, 4, 3, 12, 0, 0) + + # Test ranges and increments + start_time = DateTime(2023, 4, 3, 10, 35, 0) + @test next("0 */10 8-18 * * *", start_time) == DateTime(2023, 4, 3, 10, 40, 0) + + # Test wrapping around years + start_time = DateTime(2023, 12, 31, 23, 59, 59) + @test next("0 0 0 1 1 *", start_time) == DateTime(2024, 1, 1, 0, 0, 0) + + # Test wrapping around months + start_time = DateTime(2023, 12, 31, 23, 59, 59) + @test next("0 0 0 1 * *", start_time) == DateTime(2024, 1, 1, 0, 0, 0) + + # Test wrapping around days + start_time = DateTime(2023, 4, 3, 23, 59, 59) + @test next("0 0 * * * *", start_time) == DateTime(2023, 4, 4, 0, 0, 0) + + # Test wrapping around hours + start_time = DateTime(2023, 4, 3, 23, 59, 59) + @test next("0 * * * * *", start_time) == DateTime(2023, 4, 4, 0, 0, 0) + + # Test wrapping around minutes + start_time = DateTime(2023, 4, 3, 23, 59, 59) + @test next("* * * * * *", start_time) == DateTime(2023, 4, 4, 0, 0, 0) + + # Test case with only seconds field + start_time = DateTime(2023, 4, 3, 12, 30, 29) + @test next("30 * * * * *", start_time) == DateTime(2023, 4, 3, 12, 30, 30) +end + +using Test +@testset "matchexpression function tests" begin + time = DateTime(2023, 4, 3, 23, 59, 59) + minute = Dates.minute + second = Dates.second + + @test matchexpression(SubString("*"), time, minute, 59) == true + @test matchexpression(SubString("59"), time, minute, 59) == true + @test matchexpression(SubString("15"), time, minute, 59) == false + @test matchexpression(SubString("45,59"), time, second, 59) == true + @test matchexpression(SubString("15,30"), time, second, 59) == false + @test matchexpression(SubString("50-59"), time, second, 59) == true + @test matchexpression(SubString("10-40"), time, second, 59) == false + @test matchexpression(SubString("59-*"), time, minute, 59) == true + @test matchexpression(SubString("*-58"), time, minute, 59) == false + @test matchexpression(SubString("*/10"), time, minute, 59) == false + @test matchexpression(SubString("25/5"), time, minute, 59) == false +end + + + + +@testset "seconds tests" begin + # check exact second + @test iscronmatch("5/*", DateTime(2022,1,1,1,0,5)) == true + @test iscronmatch("7/*", DateTime(2022,1,1,1,0,7)) == true + + # check between ranges + @test iscronmatch("5-*", DateTime(2022,1,1,1,0,7)) == true + @test iscronmatch("*-10", DateTime(2022,1,1,1,0,7)) == true +end + +@testset "static matches" begin + + # Exact second match + @test iscronmatch("0", DateTime(2022,1,1,1,0,0)) == true + @test iscronmatch("1", DateTime(2022,1,1,1,0,1)) == true + @test iscronmatch("5", DateTime(2022,1,1,1,0,5)) == true + @test iscronmatch("7", DateTime(2022,1,1,1,0,7)) == true + @test iscronmatch("39", DateTime(2022,1,1,1,0,39)) == true + @test iscronmatch("59", DateTime(2022,1,1,1,0,59)) == true + + # Exact minute match + @test iscronmatch("* 0", DateTime(2022,1,1,1,0,0)) == true + @test iscronmatch("* 1", DateTime(2022,1,1,1,1,0)) == true + @test iscronmatch("* 5", DateTime(2022,1,1,1,5,0)) == true + @test iscronmatch("* 7", DateTime(2022,1,1,1,7,0)) == true + @test iscronmatch("* 39", DateTime(2022,1,1,1,39,0)) == true + @test iscronmatch("* 59", DateTime(2022,1,1,1,59,0)) == true + + # Exact hour match + @test iscronmatch("* * 0", DateTime(2022,1,1,0,0,0)) == true + @test iscronmatch("* * 1", DateTime(2022,1,1,1,0,0)) == true + @test iscronmatch("* * 5", DateTime(2022,1,1,5,0,0)) == true + @test iscronmatch("* * 12", DateTime(2022,1,1,12,0,0)) == true + @test iscronmatch("* * 20", DateTime(2022,1,1,20,0,0)) == true + @test iscronmatch("* * 23", DateTime(2022,1,1,23,0,0)) == true + + # Exact day match + @test iscronmatch("* * * 1", DateTime(2022,1,1,1,0,0)) == true + @test iscronmatch("* * * 5", DateTime(2022,1,5,1,0,0)) == true + @test iscronmatch("* * * 12", DateTime(2022,1,12,1,0,0)) == true + @test iscronmatch("* * * 20", DateTime(2022,1,20,1,0,0)) == true + @test iscronmatch("* * * 31", DateTime(2022,1,31,1,0,0)) == true + + # Exact month match + @test iscronmatch("* * * * 1", DateTime(2022,1,1,0,0,0)) == true + @test iscronmatch("* * * * 4", DateTime(2022,4,1,1,0,0)) == true + @test iscronmatch("* * * * 5", DateTime(2022,5,1,1,0,0)) == true + @test iscronmatch("* * * * 9", DateTime(2022,9,1,1,0,0)) == true + @test iscronmatch("* * * * 12", DateTime(2022,12,1,1,0,0)) == true + + # Exact day of week match + @test iscronmatch("* * * * * MON", DateTime(2022,1,3,0,0,0)) == true + @test iscronmatch("* * * * * MON", DateTime(2022,1,10,0,0,0)) == true + @test iscronmatch("* * * * * MON", DateTime(2022,1,17,0,0,0)) == true + + @test iscronmatch("* * * * * TUE", DateTime(2022,1,4,0,0,0)) == true + @test iscronmatch("* * * * * TUE", DateTime(2022,1,11,0,0,0)) == true + @test iscronmatch("* * * * * TUE", DateTime(2022,1,18,0,0,0)) == true + + @test iscronmatch("* * * * * WED", DateTime(2022,1,5,0,0,0)) == true + @test iscronmatch("* * * * * WED", DateTime(2022,1,12,0,0,0)) == true + @test iscronmatch("* * * * * WED", DateTime(2022,1,19,0,0,0)) == true + + @test iscronmatch("* * * * * THU", DateTime(2022,1,6,0,0,0)) == true + @test iscronmatch("* * * * * THU", DateTime(2022,1,13,0,0,0)) == true + @test iscronmatch("* * * * * THU", DateTime(2022,1,20,0,0,0)) == true + + @test iscronmatch("* * * * * FRI", DateTime(2022,1,7,0,0,0)) == true + @test iscronmatch("* * * * * FRI", DateTime(2022,1,14,0,0,0)) == true + @test iscronmatch("* * * * * FRI", DateTime(2022,1,21,0,0,0)) == true + + @test iscronmatch("* * * * * SAT", DateTime(2022,1,8,0,0,0)) == true + @test iscronmatch("* * * * * SAT", DateTime(2022,1,15,0,0,0)) == true + @test iscronmatch("* * * * * SAT", DateTime(2022,1,22,0,0,0)) == true + + @test iscronmatch("* * * * * SUN", DateTime(2022,1,2,0,0,0)) == true + @test iscronmatch("* * * * * SUN", DateTime(2022,1,9,0,0,0)) == true + @test iscronmatch("* * * * * SUN", DateTime(2022,1,16,0,0,0)) == true + +end + +# # More specific test cases +# # "0 0 * * * *" = the top of every hour of every day. +# # "*/10 * * * * *" = every ten seconds. +# # "0 0 8-10 * * *" = 8, 9 and 10 o'clock of every day. +# # "0 0 6,19 * * *" = 6:00 AM and 7:00 PM every day. +# # "0 0/30 8-10 * * *" = 8:00, 8:30, 9:00, 9:30, 10:00 and 10:30 every day. +# # "0 0 9-17 * * MON-FRI" = on the hour nine-to-five weekdays +# # "0 0 0 25 12 ?" = every Christmas Day at midnight +# # "0 0 0 L * *" = last day of the month at midnight +# # "0 0 0 L-3 * *" = third-to-last day of the month at midnight +# # "0 0 0 1W * *" = first weekday of the month at midnight +# # "0 0 0 LW * *" = last weekday of the month at midnight +# # "0 0 0 * * 5L" = last Friday of the month at midnight +# # "0 0 0 * * THUL" = last Thursday of the month at midnight +# # "0 0 0 ? * 5#2" = the second Friday in the month at midnight +# # "0 0 0 ? * MON#1" = the first Monday in the month at midnight + + +@testset "the top of every hour of every day" begin + for hour in 0:23 + @test iscronmatch("0 0 * * * *", DateTime(2022,1,1,hour,0,0)) == true + end +end + +@testset "the 16th minute of every hour of every day" begin + for hour in 0:23 + @test iscronmatch("0 16 * * * *", DateTime(2022,1,1,hour,16,0)) == true + end +end + + +@testset "8, 9 and 10 o'clock of every day." begin + for hour in 0:23 + @test iscronmatch("0 0 8-10 * * *", DateTime(2022,1,1,hour,0,0)) == (hour >= 8 && hour <= 10) + end +end + + +@testset "every 10 seconds" begin + @test iscronmatch("*/10 * * * * *", DateTime(2022,1,9,1,0,0)) == true + @test iscronmatch("*/10 * * * * *", DateTime(2022,1,9,1,0,10)) == true + @test iscronmatch("*/10 * * * * *", DateTime(2022,1,9,1,0,20)) == true + @test iscronmatch("*/10 * * * * *", DateTime(2022,1,9,1,0,30)) == true + @test iscronmatch("*/10 * * * * *", DateTime(2022,1,9,1,0,40)) == true + @test iscronmatch("*/10 * * * * *", DateTime(2022,1,9,1,0,50)) == true +end + +@testset "every 7 seconds" begin + @test iscronmatch("*/7 * * * * *", DateTime(2022,1,9,1,0,0)) == false + @test iscronmatch("*/7 * * * * *", DateTime(2022,1,9,1,0,7)) == true + @test iscronmatch("*/7 * * * * *", DateTime(2022,1,9,1,0,14)) == true + @test iscronmatch("*/7 * * * * *", DateTime(2022,1,9,1,0,21)) == true + @test iscronmatch("*/7 * * * * *", DateTime(2022,1,9,1,0,28)) == true + @test iscronmatch("*/7 * * * * *", DateTime(2022,1,9,1,0,35)) == true + @test iscronmatch("*/7 * * * * *", DateTime(2022,1,9,1,0,42)) == true + @test iscronmatch("*/7 * * * * *", DateTime(2022,1,9,1,0,49)) == true + @test iscronmatch("*/7 * * * * *", DateTime(2022,1,9,1,0,56)) == true +end + +@testset "every 15 seconds" begin + @test iscronmatch("*/15 * * * * *", DateTime(2022,1,9,1,0,0)) == true + @test iscronmatch("*/15 * * * * *", DateTime(2022,1,9,1,0,15)) == true + @test iscronmatch("*/15 * * * * *", DateTime(2022,1,9,1,0,30)) == true + @test iscronmatch("*/15 * * * * *", DateTime(2022,1,9,1,0,45)) == true +end + + +@testset "6:00 AM and 7:00 PM every day" begin + for day in 1:20 + for hour in 0:23 + @test iscronmatch("0 0 6,19 * * *", DateTime(2022,1,day,hour,0,0)) == (hour == 6 || hour == 19) + end + end +end + + +@testset "8:00, 8:30, 9:00, 9:30, 10:00 and 10:30 every day" begin + for day in 1:20 + for hour in 0:23 + @test iscronmatch("0 0/30 8-10 * * *", DateTime(2022,1,day,hour,30,0)) == (hour >= 8 && hour <= 10) + @test iscronmatch("0 0/30 8-10 * * *", DateTime(2022,1,day,hour,0,0)) == (hour >= 8 && hour <= 10) + end + end +end + + +@testset "on the hour nine-to-five weekdays" begin + for day in 1:20 + if isweekday(DateTime(2022,1,day,0,0,0)) + for hour in 0:23 + @test iscronmatch("0 0 9-17 * * MON-FRI", DateTime(2022,1,day,hour,0,0)) == (hour >= 9 && hour <= 17) + end + end + end +end + + +@testset "every Christmas Day at midnight" begin + for month in 1:12 + for hour in 0:23 + @test iscronmatch("0 0 0 25 12 ?", DateTime(2022,month,25,hour,0,0)) == (month == 12 && hour == 0) + end + end +end + +@testset "last day of the month at midnight" begin + for month in 1:12 + num_days = daysinmonth(2022, month) + for day in 1:num_days + for hour in 0:23 + @test iscronmatch("0 0 0 L * *", DateTime(2022,month,day,hour,0,0)) == (day == num_days && hour == 0) + end + end + end +end + + +@testset "third-to-last day of the month at midnight" begin + for month in 1:12 + num_days = daysinmonth(2022, month) + for day in 1:num_days + for hour in 0:23 + @test iscronmatch("0 0 0 L-3 * *", DateTime(2022,month,day,hour,0,0)) == (day == (num_days-3) && hour == 0) + end + end + end +end + +@testset "first weekday of the month at midnight" begin + + @test iscronmatch("0 0 0 1W * *", DateTime(2022, 1, 3, 0, 0, 0)) + @test iscronmatch("0 0 0 9W * *", DateTime(2022, 1, 10, 0, 0, 0)) + @test iscronmatch("0 0 0 13W * *", DateTime(2022, 1, 13, 0, 0, 0)) + @test iscronmatch("0 0 0 15W * *", DateTime(2022, 1, 14, 0, 0, 0)) + @test iscronmatch("0 0 0 22W * *", DateTime(2022, 1, 21, 0, 0, 0)) + @test iscronmatch("0 0 0 31W * *", DateTime(2022, 1, 31, 0, 0, 0)) + +end + +@testset "last weekday of the month at midnight" begin + @test iscronmatch("0 0 0 LW * *", DateTime(2022, 1, 28, 0, 0, 0)) == false + @test iscronmatch("0 0 0 LW * *", DateTime(2022, 1, 29, 0, 0, 0)) == false + @test iscronmatch("0 0 0 LW * *", DateTime(2022, 1, 30, 0, 0, 0)) == false + @test iscronmatch("0 0 0 LW * *", DateTime(2022, 1, 30, 0, 0, 0)) == false + @test iscronmatch("0 0 0 LW * *", DateTime(2022, 1, 31, 0, 0, 0)) +end + +@testset "last Friday of the month at midnight" begin + @test iscronmatch("0 0 0 * * 5L", DateTime(2022, 1, 28, 0, 0, 0)) + @test iscronmatch("0 0 0 * * 5L", DateTime(2022, 1, 29, 0, 0, 0)) == false + @test iscronmatch("0 0 0 * * 5L", DateTime(2022, 1, 29, 0, 0, 0)) == false + @test iscronmatch("0 0 0 * * 5L", DateTime(2022, 2, 25, 0, 0, 0)) +end + + +@testset "last Thursday of the month at midnight" begin + @test iscronmatch("0 0 0 * * THUL", DateTime(2022, 1, 26, 0, 0, 0)) == false + @test iscronmatch("0 0 0 * * THUL", DateTime(2022, 1, 27, 0, 0, 0)) + @test iscronmatch("0 0 0 * * THUL", DateTime(2022, 1, 28, 0, 0, 0)) == false + @test iscronmatch("0 0 0 * * THUL", DateTime(2022, 2, 3, 0, 0, 0)) == false +end + + +@testset "the second Friday in the month at midnight" begin + @test iscronmatch("0 0 0 ? * 5#2", DateTime(2022, 1, 14, 0, 0, 0)) + @test iscronmatch("0 0 0 ? * 5#2", DateTime(2022, 2, 11, 0, 0, 0)) + @test iscronmatch("0 0 0 ? * 5#2", DateTime(2022, 3, 11, 0, 0, 0)) + @test iscronmatch("0 0 0 ? * 5#2", DateTime(2022, 4, 8, 0, 0, 0)) + @test iscronmatch("0 0 0 ? * 5#2", DateTime(2022, 5, 13, 0, 0, 0)) +end + + +@testset "the first Monday in the month at midnight" begin + @test iscronmatch("0 0 0 ? * MON#1", DateTime(2022, 1, 2, 0, 0, 0)) == false + @test iscronmatch("0 0 0 ? * MON#1", DateTime(2022, 1, 3, 0, 0, 0)) + @test iscronmatch("0 0 0 ? * MON#1", DateTime(2022, 1, 4, 0, 0, 0)) == false + + @test iscronmatch("0 0 0 ? * MON#1", DateTime(2022, 2, 6, 0, 0, 0)) == false + @test iscronmatch("0 0 0 ? * MON#1", DateTime(2022, 2, 7, 0, 0, 0)) + @test iscronmatch("0 0 0 ? * MON#1", DateTime(2022, 2, 8, 0, 0, 0)) == false + + @test iscronmatch("0 0 0 ? * MON#1", DateTime(2022, 3, 6, 0, 0, 0)) == false + @test iscronmatch("0 0 0 ? * MON#1", DateTime(2022, 3, 7, 0, 0, 0)) + @test iscronmatch("0 0 0 ? * MON#1", DateTime(2022, 3, 8, 0, 0, 0)) == false + + @test iscronmatch("0 0 0 ? * MON#1", DateTime(2022, 4, 3, 0, 0, 0)) == false + @test iscronmatch("0 0 0 ? * MON#1", DateTime(2022, 4, 4, 0, 0, 0)) + @test iscronmatch("0 0 0 ? * MON#1", DateTime(2022, 4, 5, 0, 0, 0)) == false +end + + + +localhost = "http://127.0.0.1:8080" + +crondata = Dict("api_value" => 0) + +@get router("/cron-increment", cron="*") function(req) + crondata["api_value"] = crondata["api_value"] + 1 + return crondata["api_value"] +end + +@get "/get-cron-increment" function() + return crondata["api_value"] +end + +server = serve(async=true) +sleep(3) + +@testset "Testing CRON API access" begin + r = internalrequest(HTTP.Request("GET", "/get-cron-increment")) + @test r.status == 200 + @test parse(Int64, text(r)) > 0 +end + +close(server) + +end diff --git a/test/runtests.jl b/test/runtests.jl index f35367aa..da2a2b3b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -6,10 +6,11 @@ using StructTypes using Sockets using Dates +include("crontests.jl") + include("../src/Oxygen.jl") using .Oxygen - struct Person name::String age::Int @@ -844,6 +845,7 @@ if Threads.nthreads() > 1 && VERSION != parse(VersionNumber, "1.6.6") end end +terminate() resetstate() -end +end \ No newline at end of file