diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..45042aa --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,35 @@ +version: 2 + +jobs: + + test: + working_directory: /root/project/Dash + + docker: + - image: julia:latest + + steps: + - checkout + + - run: + name: ℹī¸ CI Context + command: | + echo "TRIGGERER: ${CIRCLE_USERNAME}" + echo "BUILD_NUMBER: ${CIRCLE_BUILD_NUM}" + echo "BUILD_URL: ${CIRCLE_BUILD_URL}" + echo "BRANCH: ${CIRCLE_BRANCH}" + echo "RUNNING JOB: ${CIRCLE_JOB}" + echo "JOB PARALLELISM: ${CIRCLE_NODE_TOTAL}" + echo "CIRCLE_REPOSITORY_URL: ${CIRCLE_REPOSITORY_URL}" + echo $CIRCLE_JOB > circlejob.txt + + - run: + name: 🔎 Unit tests + command: | + julia -e 'using Pkg; Pkg.update(); Pkg.add(PackageSpec(path=pwd())); Pkg.build("Dash"); Pkg.test("Dash", coverage=true);' + +workflows: + version: 2 + build: + jobs: + - "test" diff --git a/Project.toml b/Project.toml index 0c3acfe..cf7407b 100644 --- a/Project.toml +++ b/Project.toml @@ -8,6 +8,7 @@ DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" JSON2 = "2535ab7d-5cd8-5a07-80ac-9b1792aadce3" +CodecZlib = "944b1d66-785c-5afd-91f1-9de20f533193" LibGit2 = "76f85450-5226-5b5a-8eaa-529ad045b433" MacroTools = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" PlotlyBase = "a03496cd-edff-5a9b-9e67-9cda94a718b5" diff --git a/src/Dash.jl b/src/Dash.jl index 962f19c..ef1d7fa 100644 --- a/src/Dash.jl +++ b/src/Dash.jl @@ -1,5 +1,5 @@ module Dash -import HTTP, JSON2 +import HTTP, JSON2, CodecZlib using MacroTools include("ComponentPackages.jl") include("ComponentMetas.jl") diff --git a/src/app.jl b/src/app.jl index 5adf7ad..530e295 100644 --- a/src/app.jl +++ b/src/app.jl @@ -73,6 +73,7 @@ struct DashConfig assets_external_path ::Union{String, Nothing} include_assets_files ::Bool show_undo_redo ::Bool + compress ::Bool end """ @@ -87,7 +88,7 @@ struct DashApp config ::DashConfig layout ::Layout callbacks ::Dict{Symbol, Callback} - callable_components ::Dict{Symbol, Component} + callable_components ::Dict{Symbol, Component} DashApp(name::String, config::DashConfig) = new(name, config, Layout(nothing), Dict{Symbol, Callback}(), Dict{Symbol, Component}()) @@ -129,7 +130,8 @@ end index_string, assets_external_path, include_assets_files, - show_undo_redo + show_undo_redo, + compress ) Construct a dash app @@ -209,7 +211,10 @@ Construct a dash app - `show_undo_redo::Bool`: Default ``false``, set to ``true`` to enable undo and redo buttons for stepping through the history of the app state. - + +- `compress::Bool`: Default ``true``, controls whether gzip is used to compress + files and data served by HTTP.jl when supported by the client. Set to + ``false`` to disable compression completely. """ function dash(name::String; external_stylesheets = ExternalSrcType[], @@ -227,7 +232,8 @@ function dash(name::String; index_string = default_index, assets_external_path = nothing, include_assets_files = true, - show_undo_redo = false + show_undo_redo = false, + compress = true ) @@ -250,7 +256,8 @@ function dash(name::String; index_string, assets_external_path, include_assets_files, - show_undo_redo + show_undo_redo, + compress ) result = DashApp(name, config) @@ -273,7 +280,8 @@ function dash(layout_maker ::Function, name; index_string = default_index, assets_external_path = nothing, include_assets_files = true, - show_undo_redo = false + show_undo_redo = false, + compress = true ) result = dash(name, external_stylesheets=external_stylesheets, @@ -291,7 +299,8 @@ function dash(layout_maker ::Function, name; index_string = index_string, assets_external_path = assets_external_path, include_assets_files = include_assets_files, - show_undo_redo = show_undo_redo + show_undo_redo = show_undo_redo, + compress = compress ) layout!(result, layout_maker()) return result diff --git a/src/handlers.jl b/src/handlers.jl index cb2f9ca..b42dce0 100644 --- a/src/handlers.jl +++ b/src/handlers.jl @@ -62,35 +62,64 @@ function process_callback(app::DashApp, body::String) end -function process_assets(app::DashApp, path) +function make_response(status, headers, body, compress::Bool) + message_headers = headers + + if compress + push!(message_headers, "Content-Encoding" => "gzip") + body = transcode(CodecZlib.GzipCompressor, body) + end + + response = HTTP.Response(status, body) + for header in message_headers + HTTP.Messages.setheader(response, header) + end + return response +end + +function process_assets(app::DashApp, path, compress::Bool) assets_path = "$(app.config.routes_pathname_prefix)" * strip(app.config.assets_url_path, '/') * "/" - - filename = joinpath(app.config.assets_folder, replace(path, assets_path=>"")) + filename = joinpath(app.config.assets_folder, replace(path, assets_path=>"")) + try - return HTTP.Response(200, [], body = read(filename)) + file_contents = read(filename) + mimetype = HTTP.sniff(file_contents) + use_gzip = compress && occursin(r"text|javascript", mimetype) + headers = ["Content-Type" => mimetype] + return make_response(200, headers, file_contents, use_gzip) catch return HTTP.Response(404) end end - function make_handler(app::DashApp; debug::Bool = false) index_string::String = index_page(app, debug = debug) return function (req::HTTP.Request) + body::Union{Nothing, String} = nothing uri = HTTP.URI(req.target) + + # verify that the client accepts compression + accepts_gz = occursin("gzip", HTTP.header(req, "Accept-Encoding")) + # verify that the server was not launched with compress=false + with_gzip = accepts_gz && app.config.compress + + headers = [] + ComponentPackages.@register_js_sources(uri.path, app.config.routes_pathname_prefix) if uri.path == "$(app.config.routes_pathname_prefix)" - return HTTP.Response(200, index_string) + body = index_page(app, debug = debug) end if uri.path == "$(app.config.routes_pathname_prefix)_dash-layout" - return HTTP.Response(200, ["Content-Type" => "application/json"], body = JSON2.write(app.layout)) + body = JSON2.write(app.layout) + push!(headers, "Content-Type" => "application/json") end if uri.path == "$(app.config.routes_pathname_prefix)_dash-dependencies" - return HTTP.Response(200, ["Content-Type" => "application/json"], body = dependencies_json(app)) + body = dependencies_json(app) + push!(headers, "Content-Type" => "application/json") end if startswith(uri.path, "$(app.config.routes_pathname_prefix)assets/") - return process_assets(app, uri.path) + return process_assets(app, uri.path, with_gzip) end if uri.path == "$(app.config.routes_pathname_prefix)_dash-update-component" && req.method == "POST" try @@ -107,7 +136,9 @@ function make_handler(app::DashApp; debug::Bool = false) end end end + if !isnothing(body) + return make_response(200, headers, body, with_gzip) + end return HTTP.Response(404) - end - -end \ No newline at end of file + end +end diff --git a/test/Project.toml b/test/Project.toml new file mode 100644 index 0000000..b27edd9 --- /dev/null +++ b/test/Project.toml @@ -0,0 +1,11 @@ +[deps] +CodecZlib = "944b1d66-785c-5afd-91f1-9de20f533193" +DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +Inflate = "d25df0c9-e2be-5dd7-82c8-3ad0b3e990b9" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +JSON2 = "2535ab7d-5cd8-5a07-80ac-9b1792aadce3" +LibGit2 = "76f85450-5226-5b5a-8eaa-529ad045b433" +MacroTools = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" +PlotlyBase = "a03496cd-edff-5a9b-9e67-9cda94a718b5" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/test/assets/test.css b/test/assets/test.css new file mode 100644 index 0000000..7c37b30 --- /dev/null +++ b/test/assets/test.css @@ -0,0 +1 @@ +/* Test */ diff --git a/test/config_functional.jl b/test/config_functional.jl index b53b6ab..0ee3833 100644 --- a/test/config_functional.jl +++ b/test/config_functional.jl @@ -67,37 +67,37 @@ end @testset "assets paths" begin app = dash("test app") - res = Dash.process_assets(app, "/assets/test.png") + res = Dash.process_assets(app, "/assets/test.png", false) @test res.status == 200 - res = Dash.process_assets(app, "/assets/test3.png") + res = Dash.process_assets(app, "/assets/test3.png", false) @test res.status == 404 - res = Dash.process_assets(app, "/images/test.png") + res = Dash.process_assets(app, "/images/test.png", false) @test res.status == 404 app = dash("test app", url_base_pathname = "/test/") - res = Dash.process_assets(app, "/assets/test.png") + res = Dash.process_assets(app, "/assets/test.png", false) @test res.status == 404 - res = Dash.process_assets(app, "/test/assets/test.png") + res = Dash.process_assets(app, "/test/assets/test.png", false) @test res.status == 200 - res = Dash.process_assets(app, "/images/test.png") + res = Dash.process_assets(app, "/images/test.png", false) @test res.status == 404 app = dash("test app", assets_url_path = "ass") - res = Dash.process_assets(app, "/ass/test.png") + res = Dash.process_assets(app, "/ass/test.png", false) @test res.status == 200 - res = Dash.process_assets(app, "/ass/test3.png") + res = Dash.process_assets(app, "/ass/test3.png", false) @test res.status == 404 - res = Dash.process_assets(app, "/assets/test3.png") + res = Dash.process_assets(app, "/assets/test3.png", false) @test res.status == 404 - res = Dash.process_assets(app, "/images/test.png") + res = Dash.process_assets(app, "/images/test.png", false) @test res.status == 404 app = dash("test app", assets_folder = "images") - res = Dash.process_assets(app, "/assets/test.png") + res = Dash.process_assets(app, "/assets/test.png", false) @test res.status == 404 - res = Dash.process_assets(app, "/assets/test_images.png") + res = Dash.process_assets(app, "/assets/test_images.png", false) @test res.status == 200 - res = Dash.process_assets(app, "/images/test.png") + res = Dash.process_assets(app, "/images/test.png", false) @test res.status == 404 end @@ -204,4 +204,4 @@ end index_page = Dash.index_page(app) @test !isnothing(findfirst("\"show_undo_redo\":true", index_page)) -end \ No newline at end of file +end diff --git a/test/core.jl b/test/core.jl index a2129e6..a00fd0b 100644 --- a/test/core.jl +++ b/test/core.jl @@ -1,6 +1,7 @@ import HTTP, JSON2 using Test using Dash +using Inflate @testset "Components" begin a_comp = html_a("test", id = "test-a") @@ -354,4 +355,72 @@ end @test result[:response][:props][:children] == 10 -end \ No newline at end of file +end + +@testset "HTTP Compression" begin + # test compression of assets + app = dash("Test app", assets_folder = "assets") do + html_div() do + html_div("test") + end + end + + # verify that JSON is not compressed when compress = true + # and Accept-Encoding = "gzip" is not present within request headers + handler = Dash.make_handler(app) + request = HTTP.Request("GET", "/_dash-dependencies") + response = handler(request) + @test app.config.compress == true + @test String(response.body) == "[]" + @test !in("Content-Encoding"=>"gzip", response.headers) + + # verify that JSON is compressed when compress = true + # and Accept-Encoding = "gzip" is present within request headers + request = HTTP.Request("GET", "/_dash-dependencies", ["Accept-Encoding"=>"gzip"]) + response = handler(request) + @test String(inflate_gzip(response.body)) == "[]" + @test String(response.body) != "[]" + @test in("Content-Encoding"=>"gzip", response.headers) + + # ensure no compression of assets when Accept-Encoding not passed + request = HTTP.Request("GET", "/assets/test.css") + response = handler(request) + @test String(response.body) == "/* Test */\n" + @test !in("Content-Encoding"=>"gzip", response.headers) + + # ensure compression when Accept-Encoding = "gzip" + request = HTTP.Request("GET", "/assets/test.css", ["Accept-Encoding"=>"gzip"]) + response = handler(request) + @test String(inflate_gzip(response.body)) == "/* Test */\n" + @test String(response.body) != "/* Test */\n" + @test in("Content-Encoding"=>"gzip", response.headers) + + # test cases for compress = false + app = dash("Test app", assets_folder = "assets", compress=false) do + html_div() do + html_div("test") + end + end + + # verify that JSON is not compressed when compress = false + # and Accept-Encoding = "gzip" is not present within request headers + handler = Dash.make_handler(app) + request = HTTP.Request("GET", "/_dash-dependencies") + response = handler(request) + @test app.config.compress == false + @test String(response.body) == "[]" + @test !in("Content-Encoding"=>"gzip", response.headers) + + # verify that JSON is NOT compressed when compress = false + # and Accept-Encoding = "gzip" is present within request headers + request = HTTP.Request("GET", "/_dash-dependencies", ["Accept-Encoding"=>"gzip"]) + response = handler(request) + @test String(response.body) == "[]" + @test !in("Content-Encoding"=>"gzip", response.headers) + + # ensure NO compression when Accept-Encoding = "gzip" and compress = false + request = HTTP.Request("GET", "/assets/test.css", ["Accept-Encoding"=>"gzip"]) + response = handler(request) + @test String(response.body) == "/* Test */\n" + @test !in("Content-Encoding"=>"gzip", response.headers) +end