diff --git a/Project.toml b/Project.toml index 2e34a562..cdd5bebd 100644 --- a/Project.toml +++ b/Project.toml @@ -17,11 +17,12 @@ Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" [compat] -DataStructures = "^0.18.15" +CairoMakie = "0.11, 1" +DataStructures = "0.18.15, 1" Dates = "^1" HTTP = "^1" JSON3 = "^1.9" -MIMEs = "^0.1.4" +MIMEs = "0.1.4, 1" Mustache = "^1" OteraEngine = "^0" Pkg = "^1" @@ -31,16 +32,17 @@ Requires = "^1" Sockets = "^1" Statistics = "^1" StructTypes = "^1" -Suppressor = "^0.2.6" +Suppressor = "0.2.6, 1" julia = "^1.6" [extras] +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Mustache = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70" OteraEngine = "b2d7f28f-acd6-4007-8b26-bc27716e5513" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" StructTypes = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" Suppressor = "fd094767-a336-5f1f-9728-57cf17d0bbfb" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Mustache", "OteraEngine", "Pkg", "StructTypes", "Test", "Suppressor"] +test = ["CairoMakie", "Mustache", "OteraEngine", "Pkg", "StructTypes", "Test", "Suppressor"] diff --git a/README.md b/README.md index 422a1086..3effabfd 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Breathe easy knowing you can quickly spin up a web server with abstractions you' - Middleware chaining (at the application, router, and route levels) - Static & Dynamic file hosting - Templating Support +- Plotting Support - Route tagging - Repeat tasks @@ -536,7 +537,24 @@ end # start the web server in parallel mode serveparallel() ``` +## Plotting Support +Oxygen has a package extension for the CairoMakie.jl library that helps us return Figures directly from a request handler while keeping all operations in-memory. Each helper function will read the figure into an IOBuffer, set the content type, and return the binary data inside a `HTTP.Response`. + +Here are the currently supported helper utils: `png`, `svg`, `pdf`, `html` + +```julia +using CairoMakie: heatmap +using Oxygen + +# generate a random heatmap plot and return it as a png +@get "/plot/png" function() + fig, ax, pl = heatmap(rand(50, 50)) + png(fig) +end + +serve() +``` ## Templating @@ -886,33 +904,6 @@ mergeschema( ) ``` -# Common Issues & Tips - -## Problems working with Julia's REPL - -This is a recurring issue that occurs when writing and testing code in the REPL. Often, people find that their changes are not reflected when they rerun the server. The reason for this is that all the routing utilities are defined as macros, and they are only executed during the precompilation stage. To have your changes take effect, you need to move your route declarations to the `__init__()` function in your module. - -```julia -module OxygenExample -using Oxygen -using HTTP - -# is called whenever you load this module -function __init__() - @get "/greet" function(req::HTTP.Request) - return "hello world!" - end -end - -# you can call this function from the REPL to start the server -function runserver() - serve() -end - -end -``` - - # API Reference (macros) diff --git a/demo/CairoMakieDemo.jl b/demo/CairoMakieDemo.jl new file mode 100644 index 00000000..237aef92 --- /dev/null +++ b/demo/CairoMakieDemo.jl @@ -0,0 +1,33 @@ +module CairoMakieDemo +using CairoMakie: heatmap +using Oxygen: text # CairoMakie also exports text +using Oxygen + +get("/") do + text("welcome to the random plot api!") +end + +# generate a random plot +get("/plot/png") do + fig, ax, pl = heatmap(rand(50, 50)) # or something + png(fig) +end + +get("/plot/svg") do + fig, ax, pl = heatmap(rand(50, 50)) # or something + svg(fig) +end + +get("/plot/pdf") do + fig, ax, pl = heatmap(rand(50, 50)) # or something + pdf(fig) +end + +get("/plot/html") do + fig, ax, pl = heatmap(rand(50, 50)) # or something + html(fig) +end + +serve() + +end \ No newline at end of file diff --git a/demo/Project.toml b/demo/Project.toml index 925e9a79..ee6cb6f1 100644 --- a/demo/Project.toml +++ b/demo/Project.toml @@ -1,6 +1,8 @@ [deps] +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" +Gtk = "4c0ca9eb-093a-5379-98c5-f87ac0bbbf44" HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" MIMEs = "6c6e2e6c-3030-632d-7369-2d6c69616d65" diff --git a/demo/paralleldemo.jl b/demo/paralleldemo.jl index 045a959c..8dd0da46 100644 --- a/demo/paralleldemo.jl +++ b/demo/paralleldemo.jl @@ -1,49 +1,47 @@ module ParallelDemo +using Oxygen +using HTTP +using JSON3 +using StructTypes +using SwaggerMarkdown +using Base.Threads - include("../src/Oxygen.jl") - using .Oxygen - using HTTP - using JSON3 - using StructTypes - using SwaggerMarkdown - using Base.Threads +############## Atomic variable example ############## - ############## Atomic variable example ############## +StructTypes.StructType(::Type{Atomic{Int64}}) = StructTypes.Struct() - StructTypes.StructType(::Type{Atomic{Int64}}) = StructTypes.Struct() +x = Atomic{Int64}(0); - x = Atomic{Int64}(0); - - @get "/atomic/show" function(req) - return x - end +@get "/atomic/show" function(req) + return x +end - @get "/atomic/increment" function() - atomic_add!(x, 1) - return x - end +@get "/atomic/increment" function() + atomic_add!(x, 1) + return x +end - ############## ReentrantLock example ############## +############## ReentrantLock example ############## - global a = 0 - rl = ReentrantLock() +global a = 0 +rl = ReentrantLock() - @get "/lock/show" function() - return a - end +@get "/lock/show" function() + return a +end - @get "/lock/increment" function() - lock(rl) - try - global a - a += 1 - finally - unlock(rl) - end - return a +@get "/lock/increment" function() + lock(rl) + try + global a + a += 1 + finally + unlock(rl) end + return a +end - # start the web server in parallel mode - serveparallel() +# start the web server in parallel mode +serveparallel() end \ No newline at end of file diff --git a/docs/src/index.md b/docs/src/index.md index ee78f089..9f2c3a8a 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -33,6 +33,7 @@ Breathe easy knowing you can quickly spin up a web server with abstractions you' - Middleware chaining (at the application, router, and route levels) - Static & Dynamic file hosting - Templating Support +- Plotting Support - Route tagging - Repeat tasks @@ -536,7 +537,24 @@ end # start the web server in parallel mode serveparallel() ``` +## Plotting Support +Oxygen has a package extension for the CairoMakie.jl library that helps us return Figures directly from a request handler while keeping all operations in-memory. Each helper function will read the figure into an IOBuffer, set the content type, and return the binary data inside a `HTTP.Response`. + +Here are the currently supported helper utils: `png`, `svg`, `pdf`, `html` + +```julia +using CairoMakie: heatmap +using Oxygen + +# generate a random heatmap plot and return it as a png +@get "/plot/png" function() + fig, ax, pl = heatmap(rand(50, 50)) + png(fig) +end + +serve() +``` ## Templating @@ -886,33 +904,6 @@ mergeschema( ) ``` -# Common Issues & Tips - -## Problems working with Julia's REPL - -This is a recurring issue that occurs when writing and testing code in the REPL. Often, people find that their changes are not reflected when they rerun the server. The reason for this is that all the routing utilities are defined as macros, and they are only executed during the precompilation stage. To have your changes take effect, you need to move your route declarations to the `__init__()` function in your module. - -```julia -module OxygenExample -using Oxygen -using HTTP - -# is called whenever you load this module -function __init__() - @get "/greet" function(req::HTTP.Request) - return "hello world!" - end -end - -# you can call this function from the REPL to start the server -function runserver() - serve() -end - -end -``` - - # API Reference (macros) diff --git a/src/Oxygen.jl b/src/Oxygen.jl index 08eeb5c9..ee331f0a 100644 --- a/src/Oxygen.jl +++ b/src/Oxygen.jl @@ -2,7 +2,6 @@ module Oxygen include("core.jl"); using .Core include("instances.jl"); using .Instances -# Load any optional extensions include("extensions/load.jl"); import HTTP: Request, Response diff --git a/src/autodoc.jl b/src/autodoc.jl index 065609d0..1513540d 100644 --- a/src/autodoc.jl +++ b/src/autodoc.jl @@ -5,9 +5,9 @@ using DataStructures using Reexport using RelocatableFolders +using ..Util: html, recursive_merge using ..Constants -using ..Util -using ..Core: Context, Documenation +using ..AppContext: Context, Documenation using ..Types: TaggedRoute, TaskDefinition, CronDefinition, Nullable export registerschema, diff --git a/src/core.jl b/src/core.jl index 5e530ee9..eb09010f 100644 --- a/src/core.jl +++ b/src/core.jl @@ -33,12 +33,13 @@ oxygen_title = raw""" """ -function serverwelcome(host::String, port::Int, docs::Bool, metrics::Bool, docspath::String) +function serverwelcome(host::String, port::Int, docs::Bool, metrics::Bool, parallel::Bool, docspath::String) printstyled(oxygen_title, color = :blue, bold = true) @info "📦 Version 1.5.0 (2024-02-26)" @info "✅ Started server: http://$host:$port" - docs && @info "📖 Documentation: http://$host:$port$docspath" - metrics && @info "📊 Metrics: http://$host:$port$docspath/metrics" + docs && @info "📖 Documentation: http://$host:$port$docspath" + metrics && @info "📊 Metrics: http://$host:$port$docspath/metrics" + parallel && @info "🚀 Running in parallel mode with $(Threads.nthreads()) available threads" end @@ -77,7 +78,7 @@ function serve(ctx::Context; handle_stream = handler(configured_middelware) # The cleanup of resources are put at the topmost level in `methods.jl` - return startserver(ctx, show_banner, host, port, docs, metrics, kwargs, async, (kwargs) -> + return startserver(ctx; host, port, show_banner, docs, metrics, kwargs, async, start=(kwargs) -> HTTP.serve!(handle_stream, host, port; kwargs...)) end @@ -126,7 +127,7 @@ function serveparallel(ctx::Context; # setup the primary stream handler function (can be customized by the caller) handle_stream = handler(configured_middelware) |> parallel_stream_handler - return startserver(ctx, show_banner, host, port, docs, metrics, kwargs, async, (kwargs) -> + return startserver(ctx; host, port, show_banner, docs, metrics, parallel=true, async, kwargs, start=(kwargs) -> HTTP.serve!(handle_stream, host, port; kwargs...)) end @@ -252,9 +253,9 @@ end """ Internal helper function to launch the server in a consistent way """ -function startserver(ctx::Context, show_banner, host, port, docs, metrics, kwargs, async, start) :: Server +function startserver(ctx::Context; host, port, show_banner=false, docs=false, metrics=false, parallel=false, async=false, kwargs, start) :: Server - show_banner && serverwelcome(host, port, docs, metrics, ctx.docs.docspath[]) + show_banner && serverwelcome(host, port, docs, metrics, parallel, ctx.docs.docspath[]) docs && setupdocs(ctx) metrics && setupmetrics(ctx) @@ -577,10 +578,7 @@ function setupmetrics(router::Router, history::History, docspath::String, histor staticfiles(router, "$DATA_PATH/dashboard", "$docspath/metrics"; loadfile=loadfile) - - """ - Create a thread-safe copy of the history object and it's internal data - """ + # Create a thread-safe copy of the history object and it's internal data function safe_get_transactions(history::History) :: Vector{HTTPTransaction} transactions = [] lock(history_lock) do diff --git a/src/cron.jl b/src/cron.jl index 6b3f0a2e..871f80ea 100644 --- a/src/cron.jl +++ b/src/cron.jl @@ -1,9 +1,9 @@ module Cron import Base: @kwdef using Dates -using ..Util: countargs -using ..Core: CronContext + using ..Types: ActiveCron, RegisteredCron, Nullable +using ..AppContext: CronContext export cron, startcronjobs, stopcronjobs, clearcronjobs diff --git a/src/extensions/load.jl b/src/extensions/load.jl index 7554b7b4..1afb21c3 100644 --- a/src/extensions/load.jl +++ b/src/extensions/load.jl @@ -26,4 +26,9 @@ function __init__() @require Mustache="ffc61752-8dc7-55ee-8c37-f3e9cdd09e70" include("templating/mustache.jl") @require OteraEngine="b2d7f28f-acd6-4007-8b26-bc27716e5513" include("templating/oteraengine.jl") + + ################################################################ + # Plotting Extensions # + ################################################################ + @require CairoMakie="13f3f980-e62b-5c42-98c6-ff1f3baf88f0" include("plotting/cairomakie.jl") end diff --git a/src/extensions/plotting/cairomakie.jl b/src/extensions/plotting/cairomakie.jl new file mode 100644 index 00000000..683ab9c9 --- /dev/null +++ b/src/extensions/plotting/cairomakie.jl @@ -0,0 +1,56 @@ +import HTTP +import .CairoMakie: Figure +import .Core.Util: html # import the html function from util so we can override it + +export png, svg, pdf, html + +# Here we list all our supported MIME types +const PNG = MIME"image/png"() +const SVG = MIME"image/svg+xml"() +const PDF = MIME"application/pdf"() +const HTML = MIME"text/html"() + +""" +Converts a Figure object to the designated MIME type and wraps it inside an HTTP response. +""" +function response(fig::Figure, mime_type::MIME, status::Int, headers::Vector) :: HTTP.Response + # Convert & load the figure into an IOBuffer + io = IOBuffer() + show(io, mime_type, fig) + body = take!(io) + + # format the response + resp = HTTP.Response(status, headers, body) + HTTP.setheader(resp, "Content-Type" => string(mime_type)) + HTTP.setheader(resp, "Content-Length" => string(sizeof(body))) + return resp +end + +""" + svg(fig::Figure) :: HTTP.Response + +Convert a figure to an PNG and wrap it inside an HTTP response. +""" +png(fig::Figure, status=200, headers=[]) :: HTTP.Response = response(fig, PNG, status, headers) + + +""" + svg(fig::Figure) :: HTTP.Response + +Convert a figure to an SVG and wrap it inside an HTTP response. +""" +svg(fig::Figure, status=200, headers=[]) :: HTTP.Response = response(fig, SVG, status, headers) + +""" + pdf(fig::Figure) :: HTTP.Response + +Convert a figure to a PDF and wrap it inside an HTTP response. +""" +pdf(fig::Figure, status=200, headers=[]) :: HTTP.Response = response(fig, PDF, status, headers) + +""" + html(fig::Figure) :: HTTP.Response + +Convert a figure to HTML and wrap it inside an HTTP response. +""" +html(fig::Figure, status=200, headers=[]) :: HTTP.Response = response(fig, HTML, status, headers) \ No newline at end of file diff --git a/src/repeattasks.jl b/src/repeattasks.jl index 33c87e51..4d05bcc4 100644 --- a/src/repeattasks.jl +++ b/src/repeattasks.jl @@ -1,6 +1,7 @@ module RepeatTasks using HTTP -using ..Core: TasksContext, RegisteredTask, TaskDefinition, ActiveTask +using ..Types: RegisteredTask, TaskDefinition, ActiveTask +using ..AppContext: TasksContext export starttasks, stoptasks, cleartasks, task diff --git a/test/bodyparsertests.jl b/test/bodyparsertests.jl index 3ba8dbae..dcf53f9b 100644 --- a/test/bodyparsertests.jl +++ b/test/bodyparsertests.jl @@ -5,7 +5,7 @@ using HTTP using StructTypes using Oxygen -using Oxygen.Core.Util: set_content_size! +using Oxygen: set_content_size! struct rank title :: String diff --git a/test/cairomakietests.jl b/test/cairomakietests.jl new file mode 100644 index 00000000..a06226ad --- /dev/null +++ b/test/cairomakietests.jl @@ -0,0 +1,109 @@ +module CairoMakieTests +using HTTP +using Test +using CairoMakie: heatmap +using Oxygen; @oxidise +import Oxygen: text +using ..Constants + +@testset "CairoMakie Utils" begin + # create a random heatmap + fig, ax, pl = heatmap(rand(50, 50)) + + response = png(fig) + @test response isa HTTP.Response + @test response.status == 200 + @test HTTP.header(response, "Content-Type") == "image/png" + @test parse(Int, HTTP.header(response, "Content-Length")) >= 0 + + response = svg(fig) + @test response isa HTTP.Response + @test response.status == 200 + @test HTTP.header(response, "Content-Type") == "image/svg+xml" + @test parse(Int, HTTP.header(response, "Content-Length")) >= 0 + + response = pdf(fig) + @test response isa HTTP.Response + @test response.status == 200 + @test HTTP.header(response, "Content-Type") == "application/pdf" + @test parse(Int, HTTP.header(response, "Content-Length")) >= 0 + + response = html(fig) + @test response isa HTTP.Response + @test response.status == 200 + @test HTTP.header(response, "Content-Type") == "text/html" + @test parse(Int, HTTP.header(response, "Content-Length")) >= 0 +end + +@testset "CairoMakie server" begin + + get("/") do + text("hello world") + end + + get("/html") do + html("hello world") + end + + # generate a random plot + get("/plot/png") do + fig, ax, pl = heatmap(rand(50, 50)) # or something + png(fig) + end + + get("/plot/svg") do + fig, ax, pl = heatmap(rand(50, 50)) # or something + svg(fig) + end + + get("/plot/pdf") do + fig, ax, pl = heatmap(rand(50, 50)) # or something + pdf(fig) + end + + get("/plot/html") do + fig, ax, pl = heatmap(rand(50, 50)) # or something + html(fig) + end + + serve(host=HOST, port=PORT, async=true, show_banner=false, access_log=nothing) + + # Test overloaded text() function + r = HTTP.get("$localhost/") + @test r.status == 200 + @test HTTP.header(r, "Content-Type") == "text/plain; charset=utf-8" + @test parse(Int, HTTP.header(r, "Content-Length")) >= 0 + + # Test overloaded html function + r = HTTP.get("$localhost/html") + @test r.status == 200 + @test HTTP.header(r, "Content-Type") == "text/html; charset=utf-8" + @test parse(Int, HTTP.header(r, "Content-Length")) >= 0 + + # Test for /plot/png endpoint + r = HTTP.get("$localhost/plot/png") + @test r.status == 200 + @test HTTP.header(r, "Content-Type") == "image/png" + @test parse(Int, HTTP.header(r, "Content-Length")) >= 0 + + # Test for /plot/svg endpoint + r = HTTP.get("$localhost/plot/svg") + @test r.status == 200 + @test HTTP.header(r, "Content-Type") == "image/svg+xml" + @test parse(Int, HTTP.header(r, "Content-Length")) >= 0 + + # Test for /plot/pdf endpoint + r = HTTP.get("$localhost/plot/pdf") + @test r.status == 200 + @test HTTP.header(r, "Content-Type") == "application/pdf" + @test parse(Int, HTTP.header(r, "Content-Length")) >= 0 + + # Test for /plot/html endpoint + r = HTTP.get("$localhost/plot/html") + @test r.status == 200 + @test HTTP.header(r, "Content-Type") == "text/html" + @test parse(Int, HTTP.header(r, "Content-Length")) >= 0 + terminate() +end + +end \ No newline at end of file diff --git a/test/crontests.jl b/test/crontests.jl index e4874347..233c4ba6 100644 --- a/test/crontests.jl +++ b/test/crontests.jl @@ -8,7 +8,7 @@ using Dates using Oxygen -using Oxygen.Core.Cron: iscronmatch, isweekday, lastweekdayofmonth, +using Oxygen.Cron: iscronmatch, isweekday, lastweekdayofmonth, next, sleep_until, lastweekday, nthweekdayofmonth, matchexpression diff --git a/test/paralleltests.jl b/test/paralleltests.jl index 1930ec93..a9c2cacb 100644 --- a/test/paralleltests.jl +++ b/test/paralleltests.jl @@ -60,7 +60,7 @@ using ..Constants # only run these tests if we have more than one thread to work with if Threads.nthreads() > 1 - serveparallel(host=HOST, port=PORT, show_errors=true, async=true, show_banner=false) + serveparallel(host=HOST, port=PORT, show_errors=true, async=true, show_banner=true) sleep(3) r = HTTP.get("$localhost/get") diff --git a/test/runtests.jl b/test/runtests.jl index 7b614269..208b1051 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -6,6 +6,7 @@ include("metricstests.jl") include("templatingtests.jl") include("routingfunctionstests.jl") include("rendertests.jl") +include("cairomakietests.jl") include("bodyparsertests.jl") include("crontests.jl") include("oxidise.jl")