diff --git a/samples/app_squared.cr b/samples/app_squared.cr new file mode 100644 index 0000000..60c7509 --- /dev/null +++ b/samples/app_squared.cr @@ -0,0 +1,17 @@ +require "../src/kemal/base" + +class MyApp < Kemal::Application + get "/" do |env| + "Hello Kemal!" + end +end + +class OtherApp < Kemal::Application + get "/" do |env| + "Hello World!" + end +end + +spawn { MyApp.run(3002) } + +OtherApp.run(3001) diff --git a/spec/application_mode_spec.cr b/spec/application_mode_spec.cr index 49e1f68..a844810 100644 --- a/spec/application_mode_spec.cr +++ b/spec/application_mode_spec.cr @@ -8,6 +8,10 @@ private class MyApp < Kemal::Application get "/route2" do |env| "Route 2" end + + get "/file" do |env| + send_file env, "Serdar".to_slice + end end describe MyApp do @@ -24,4 +28,23 @@ describe MyApp do end end end + + it "sends file with binary stream" do + request = HTTP::Request.new("GET", "/file") + response = call_request_on_app(MyApp.new, request) + response.status_code.should eq(200) + response.headers["Content-Type"].should eq("application/octet-stream") + response.headers["Content-Length"].should eq("6") + end + + it "responds to delayed route" do + app = MyApp.new + app.setup + app.get "/delayed" do |env| + "Happy addition!" + end + request = HTTP::Request.new("GET", "/delayed") + client_response = call_request_on_app(app, request) + client_response.body.should eq("Happy addition!") + end end diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index 4efa80c..4e70bfa 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -42,7 +42,7 @@ describe "Macros" do client_response.body.should eq("world") app.get "/breaking" do |env| - halt env, 404, "hello" + Kemal::Macros.halt env, 404, "hello" "world" end request = HTTP::Request.new("GET", "/breaking") @@ -54,7 +54,7 @@ describe "Macros" do it "can break block with halt macro using default values" do app = Kemal::Base.new app.get "/" do |env| - halt env + Kemal::Macros.halt env "world" end request = HTTP::Request.new("GET", "/") @@ -69,7 +69,7 @@ describe "Macros" do app = Kemal::Base.new app.get "/headers" do |env| env.response.headers.add "Content-Type", "image/png" - headers env, { + app.headers env, { "Access-Control-Allow-Origin" => "*", "Content-Type" => "text/plain", } @@ -85,7 +85,7 @@ describe "Macros" do it "sends file with given path and default mime-type" do app = Kemal::Base.new app.get "/" do |env| - send_file env, "./spec/asset/hello.ecr" + app.send_file env, "./spec/asset/hello.ecr" end request = HTTP::Request.new("GET", "/") @@ -98,7 +98,7 @@ describe "Macros" do it "sends file with given path and given mime-type" do app = Kemal::Base.new app.get "/" do |env| - send_file env, "./spec/asset/hello.ecr", "image/jpeg" + app.send_file env, "./spec/asset/hello.ecr", "image/jpeg" end request = HTTP::Request.new("GET", "/") @@ -111,7 +111,7 @@ describe "Macros" do it "sends file with binary stream" do app = Kemal::Base.new app.get "/" do |env| - send_file env, "Serdar".to_slice + app.send_file env, "Serdar".to_slice end request = HTTP::Request.new("GET", "/") diff --git a/src/kemal/application.cr b/src/kemal/application.cr index c5d1c4c..5aca003 100644 --- a/src/kemal/application.cr +++ b/src/kemal/application.cr @@ -14,7 +14,7 @@ class Kemal::Application < Kemal::Base super unless error_handlers.has_key?(404) - error 404 do |env| + self.error 404 do |env| render_404 end end @@ -22,7 +22,7 @@ class Kemal::Application < Kemal::Base # Test environment doesn't need to have signal trap, built-in images, and logging. unless @config.env == "test" # This route serves the built-in images for not_found and exceptions. - get "/__kemal__/:image" do |env| + self.get "/__kemal__/:image" do |env| image = env.params.url["image"] file_path = File.expand_path("lib/kemal/images/#{image}", Dir.current) if File.exists? file_path diff --git a/src/kemal/base.cr b/src/kemal/base.cr index b6eadd2..94ac3fc 100644 --- a/src/kemal/base.cr +++ b/src/kemal/base.cr @@ -13,7 +13,6 @@ class Kemal::Base include Macros include Base::DSL include Base::Builder - extend Base::ClassDSL # :nodoc: getter route_handler = Kemal::RouteHandler.new @@ -41,9 +40,7 @@ class Kemal::Base # Overload of self.run with the default startup logging def run(port : Int32? = nil) - run port do - log "[#{config.env}] Kemal is ready to lead at #{config.scheme}://#{config.host_binding}:#{config.port}" - end + run(port) { } end # The command to run a `Kemal` application. diff --git a/src/kemal/base/dsl.cr b/src/kemal/base/dsl.cr index fe2cc91..8b74b39 100644 --- a/src/kemal/base/dsl.cr +++ b/src/kemal/base/dsl.cr @@ -1,31 +1,42 @@ class Kemal::Base + private CUSTOM_METHODS_REGISTRY = {} of _ => _ + + macro inherited + {% CUSTOM_METHODS_REGISTRY[@type] = { + handlers: [] of _, + ws: [] of _, + error: [] of _, + filters: [] of _, + } %} + + include MacroDSL + end + module DSL HTTP_METHODS = %w(get post put patch delete options) FILTER_METHODS = %w(get post put patch delete options all) - macro included - # :nodoc: - DEFAULT_HANDLERS = [] of {String, String, (HTTP::Server::Context -> String)} - # :nodoc: - WEBSOCKET_HANDLERS = [] of {String, (HTTP::WebSocket, HTTP::Server::Context -> Void)} - # :nodoc: - DEFAULT_ERROR_HANDLERS = [] of {Int32, (HTTP::Server::Context, Exception -> String)} - # :nodoc: - DEFAULT_FILTERS = [] of {Symbol, String, String, (HTTP::Server::Context -> String)} - end - {% for method in HTTP_METHODS %} + # Add a `{{method.id.upcase}}` handler. + # + # The block receives an `HTTP::Server::Context` as argument. def {{method.id}}(path, &block : HTTP::Server::Context -> _) - raise Kemal::Exceptions::InvalidPathStartException.new({{method}}, path) unless Kemal::Utils.path_starts_with_slash?(path) + raise Kemal::Exceptions::InvalidPathStartException.new({{method}}, path) unless path.starts_with?("/") route_handler.add_route({{method}}.upcase, path, &block) end {% end %} + # Add a webservice handler. + # + # The block receives `HTTP::WebSocket` and `HTTP::Server::Context` as arguments. def ws(path, &block : HTTP::WebSocket, HTTP::Server::Context -> Void) - raise Kemal::Exceptions::InvalidPathStartException.new("ws", path) unless Kemal::Utils.path_starts_with_slash?(path) + raise Kemal::Exceptions::InvalidPathStartException.new("ws", path) unless path.starts_with?("/") websocket_handler.add_route path, &block end + # Add an error handler for *status_code*. + # + # The block receives `HTTP::Server::Context` and `Exception` as arguments. def error(status_code, &block : HTTP::Server::Context, Exception -> _) add_error_handler status_code, &block end @@ -35,57 +46,137 @@ class Kemal::Base # - after_all, after_get, after_post, after_put, after_patch, after_delete, after_options {% for type in ["before", "after"] %} {% for method in FILTER_METHODS %} + # Add a filter for this class that runs {{type.id}} each `{{method.id.upcase}}` request (optionally limited to a specific *path*). + # + # The block receives an `HTTP::Server::Context` as argument. def {{type.id}}_{{method.id}}(path = "*", &block : HTTP::Server::Context -> _) filter_handler.{{type.id}}({{method}}.upcase, path, &block) end {% end %} {% end %} - private def initialize_defaults - DEFAULT_HANDLERS.each do |method, path, block| - route_handler.add_route(method.upcase, path, &block) + private macro initialize_defaults + {% if CUSTOM_METHODS_REGISTRY[@type] %} + {% for handler in CUSTOM_METHODS_REGISTRY[@type][:handlers] %} + self.{{handler[0].id}}({{handler[1]}}) do |context| + {{handler[2].id}}(context) end + {% end %} - WEBSOCKET_HANDLERS.each do |path, block| - ws(path, &block) + {% for ws in CUSTOM_METHODS_REGISTRY[@type][:ws] %} + self.ws({{handler[0]}}) do |websocket, context| + {{handler[1].id}}(websocket, context) end + {% end %} - DEFAULT_ERROR_HANDLERS.each do |status_code, block| - add_error_handler status_code, &block + {% for ws in CUSTOM_METHODS_REGISTRY[@type][:error] %} + self.add_error_handler({{handler[0]}}) do |context| + {{handler[1].id}}(context) end + {% end %} - DEFAULT_FILTERS.each do |type, method, path, block| - if type == :before - filter_handler.before(method, path, &block) - else - filter_handler.after(method, path, &block) + {% for filter in CUSTOM_METHODS_REGISTRY[@type][:filters] %} + filter_handler.{{filter[0]}}({{filter[1]}}, {{filter[2]}}) do |context| + {{filter[3]}}(context) end - end + {% end %} + {% end %} end end - module ClassDSL + module MacroDSL {% for method in DSL::HTTP_METHODS %} - def {{method.id}}(path, &block : HTTP::Server::Context -> _) - DEFAULT_HANDLERS << { {{method}}, path, block } + # Define a `{{method.id.upcase}}` handler for this class. + # + # It will be initialized in every instance. + # The block receives an `HTTP::Server::Context` as argument and is scoped to the instance. + # + # Example: + # ``` + # class MyClass < Kemal::Base + # {{method.id}}("/route") do |context| + # # ... + # end + # end + # ``` + # NOTE: This macro *must* be called from class scope as it expands to a custom method definition. + macro {{method.id}}(path, &block) + \{% raise "invalid path start for {{method.id}}: path must start with \"/\"" unless path.starts_with?("/") %} + \{% method_name = "__{{method.id}}_#{path.id.gsub(/[^a-zA-Z0-9]/,"_").gsub(/__+/, "_").gsub(/\A_|_\z/, "")}_#{CUSTOM_METHODS_REGISTRY[@type][:handlers].size}" %} + def \{{method_name.id}}(\{{block.args[0].id}}) + \{{block.body}} + end + \{% CUSTOM_METHODS_REGISTRY[@type][:handlers] << { {{method}}, path, method_name } %} end {% end %} - def ws(path, &block : HTTP::WebSocket, HTTP::Server::Context -> Void) - WEBSOCKET_HANDLERS << {path, block} + # Define a webservice handler for this class. + # + # It will be initialized in every instance. + # The block receives `HTTP::WebSocket` and `HTTP::Server::Context` as arguments and is scoped to the instance. + # + # Example: + # ``` + # class MyClass < Kemal::Base + # ws("/wsroute") do |context| + # # ... + # end + # end + # ``` + # NOTE: This macro *must* be called from class scope as it expands to a custom method definition. + macro ws(path, &block) + \{% raise "invalid path start for webservice: path must start with \"/\"" unless path.starts_with?("/") %} + \{% method_name = "__ws_#{path.id.gsub(/[^a-zA-Z0-9]/,"_").gsub(/__+/, "_").gsub(/\A_|_\z/, "")}_#{CUSTOM_METHODS_REGISTRY[@type][:ws].size}" %} + def \{{method_name.id}}(\{{block.args[0].id}}, \{{block.args[1].id}}) + \{{block.body}} + end + \{% CUSTOM_METHODS_REGISTRY[@type][:ws] << { path, method_name } %} end - def error(status_code, &block : HTTP::Server::Context, Exception -> _) - DEFAULT_ERROR_HANDLERS << {status_code, block} + # Define an error handler for this class. + # + # It will be initialized in every instance. + # The block receives `HTTP::Server::Context` and `Exception` as arguments and is scoped to the instance. + # + # Example: + # ``` + # class MyClass < Kemal::Base + # error(403) do |context| + # # ... + # end + # end + # ``` + # NOTE: This macro *must* be called from class scope as it expands to a custom method definition. + macro error(status_code) + \{% method_name = "__error_#{status_code}_#{CUSTOM_METHODS_REGISTRY[@type][:error].size}" %} + def \{{method_name.id}}(\{{block.args[0].id}}) + \{{block.body}} + end + \{% CUSTOM_METHODS_REGISTRY[@type][:error] << { status_code, method_name } %} end - # All the helper methods available are: - # - before_all, before_get, before_post, before_put, before_patch, before_delete, before_options - # - after_all, after_get, after_post, after_put, after_patch, after_delete, after_options - {% for type in [:before, :after] %} + {% for type in ["before", "after"] %} {% for method in DSL::FILTER_METHODS %} - def {{type.id}}_{{method.id}}(path = "*", &block : HTTP::Server::Context -> _) - DEFAULT_FILTERS << { {{type}}, {{method}}, path, block } + # Define a filter for this class that runs {{type.id}} each `{{method.id.upcase}}` request (optionally limited to a specific *path*). + # + # The filter will be initialized in every instance of this class. + # The block receives an `HTTP::Context` as argument and is scoped to the instance. + # + # Example: + # ``` + # class MyClass < Kemal::Base + # {{type.id}}_{{method.id}}("/route") do |context| + # # ... + # end + # end + # ``` + # NOTE: This macro *must* be called from class scope as it expands to a custom method definition. + macro {{type.id}}_{{method.id}}(path = "*", &block) + \{% method_name = "__{{type.id}}_{{method.id}}_#{path.id.gsub(/[^a-zA-Z0-9]/,"_").gsub(/__+/, "_").gsub(/\A_|_\z/, "")}_#{CUSTOM_METHODS_REGISTRY[@type][:handlers].size}" %} + def \{{method_name.id}}(\{{block.args[0].id}}) + \{{block.body}} + end + \{% CUSTOM_METHODS_REGISTRY[@type][:fitlers] << { {{type}}, {{method}}, path, method_name } %} end {% end %} {% end %}