From fccc7ea9f0a85b0efb48c8f6b49d76cb0729966c Mon Sep 17 00:00:00 2001 From: Yessiest Date: Wed, 6 Sep 2023 02:34:17 +0400 Subject: [PATCH] header modifiers, status modifier, proper call rollbacks --- config.ru | 15 +++++- lib/hyde.rb | 1 - lib/hyde/LAYOUT.md | 21 ++++++++ lib/hyde/dsl/path_constructors.rb | 46 +++++++++++++++++ lib/hyde/dsl/probe_methods.rb | 84 +++++++++++++++++++++++++++++++ lib/hyde/node.rb | 8 ++- lib/hyde/path.rb | 22 +++++++- lib/hyde/probe.rb | 12 +++++ lib/hyde/probe/binding.rb | 12 +++++ lib/hyde/probe/handler.rb | 51 +++++++++++++++++++ lib/hyde/probe/http_method.rb | 74 +++++++++++++++++++++++++++ lib/hyde/request.rb | 12 +++++ lib/hyde/response.rb | 33 ++++++++++-- 13 files changed, 383 insertions(+), 8 deletions(-) create mode 100644 lib/hyde/dsl/probe_methods.rb create mode 100644 lib/hyde/probe/binding.rb create mode 100644 lib/hyde/probe/handler.rb create mode 100644 lib/hyde/probe/http_method.rb diff --git a/config.ru b/config.ru index b1bb75d..e9e6ba5 100644 --- a/config.ru +++ b/config.ru @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift("#{File.dirname(__FILE__)}/lib") require_relative 'lib/hyde' app = Hyde::Server.new do @@ -18,7 +21,17 @@ app = Hyde::Server.new do end path "/match2/*/" do - probe "probe" + head "probe" do + "

Hello world!

" + end + get "probe" do + code 400 + header "Random-Shit", "peepee" + "

Hello world!

" + end + post "probe" do + "

Hello world!

" + end end end diff --git a/lib/hyde.rb b/lib/hyde.rb index dc7cbe7..f824d7f 100644 --- a/lib/hyde.rb +++ b/lib/hyde.rb @@ -7,7 +7,6 @@ require_relative 'hyde/request' require_relative 'hyde/response' # Hyde is a hideously simple ruby web framework -# module Hyde # Hyde version # @type [String] diff --git a/lib/hyde/LAYOUT.md b/lib/hyde/LAYOUT.md index 4f43ff7..9716577 100644 --- a/lib/hyde/LAYOUT.md +++ b/lib/hyde/LAYOUT.md @@ -36,3 +36,24 @@ These are module mixins that add common methods to DSL bindings. These are self-contained classes and methods that add extra functionality to Hyde. - Hyde::Util::Lookup [util/lookup.rb] + +## Probe subclasses + +These are reactive request handlers with their own semantics, if needed. + +- Hyde::Handler [probe/handler.rb] +- Hyde::GETHandler [probe/http\_method.rb] +- Hyde::POSTHandler [probe/http\_method.rb] +- Hyde::HEADHandler [probe/http\_method.rb] +- Hyde::PUTHandler [probe/http\_method.rb] +- Hyde::DELETEHandler [probe/http\_method.rb] +- Hyde::CONNECTHandler [probe/http\_method.rb] +- Hyde::OPTIONSHandler [probe/http\_method.rb] +- Hyde::TRACEHandler [probe/http\_method.rb] +- Hyde::PATCHHandler [probe/http\_method.rb] + +## Path subclasses + +These are navigation handlers with their own semantics. + +(currently none) diff --git a/lib/hyde/dsl/path_constructors.rb b/lib/hyde/dsl/path_constructors.rb index 42f784d..06338cd 100644 --- a/lib/hyde/dsl/path_constructors.rb +++ b/lib/hyde/dsl/path_constructors.rb @@ -25,6 +25,52 @@ module Hyde def probe(path, &_setup) register(Hyde::Probe.new(path, parent: @origin)) end + + # Create a new {Hyde::GETHandler} object + def get(path, &setup) + register(Hyde::GETHandler.new(path, parent: @origin, &setup)) + end + + # create a new {Hyde::POSTHandler} object + def post(path, &setup) + register(Hyde::POSTHandler.new(path, parent: @origin, &setup)) + end + + # Create a new {Hyde::PUTHandler} object + def put(path, &setup) + register(Hyde::PUTHandler.new(path, parent: @origin, &setup)) + end + + # Create a new {Hyde::HEADHandler} object + def head(path, &setup) + register(Hyde::HEADHandler.new(path, parent: @origin, &setup)) + end + + # Create a new {Hyde::DELETEHandler} object + def delete(path, &setup) + register(Hyde::DELETEHandler.new(path, parent: @origin, &setup)) + end + + # Create a new {Hyde::CONNECTHandler} object + def connect(path, &setup) + register(Hyde::CONNECTHandler.new(path, parent: @origin, &setup)) + end + + # Create a new {Hyde::TRACEHandler} object + def trace(path, &setup) + register(Hyde::TRACEHandler.new(path, parent: @origin, &setup)) + end + + # Create a new {Hyde::PATCHHandler} object + def patch(path, &setup) + register(Hyde::PATCHHandler.new(path, parent: @origin, &setup)) + end + + # Create a new {Hyde::OPTIONSHandler} object + def options(path, &setup) + register(Hyde::OPTIONSHandler.new(path, parent: @origin, &setup)) + end + end end end diff --git a/lib/hyde/dsl/probe_methods.rb b/lib/hyde/dsl/probe_methods.rb new file mode 100644 index 0000000..7673ecb --- /dev/null +++ b/lib/hyde/dsl/probe_methods.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require_relative '../response' + +module Hyde + module DSL + # Common methods for Probe objects + module ProbeMethods + # Get the current request + # @return [Hyde::Request] + def request + @request + end + + # Stop execution and generate a boilerplate response with the given code + # @param errorcode [Integer] + # @param backtrace [Array(String), nil] + # @raise [UncaughtThrowError] throws :finish to return back to Server + def die(errorcode, backtrace: nil) + throw :finish, [errorcode].append( + *(@properties["handle.#{errorcode}"] or + @properties["handle.default"]).call( + errorcode, + backtrace: backtrace + ) + ) + end + + # Bounce request to the next handler + # @raise [UncaughtThrowError] throws :break to get out of the callback + def bounce + raise :break + end + + # Set response status (generate response if one doesn't exist yet) + # @param status [Integer] http status code + def status(status) + @response = (@response or Hyde::Response.new) + @response.status = status + end + + alias code status + + # Set response header (generate response if one doesn't exist yet) + # @param key [String] header name + # @param value [String] header value + def header(key, value) + return status(value) if key.downcase == "status" + + if key.match(/(?:[(),\/:;<=>?@\[\]{}"]|[^ -~])/) + raise StandardError, "header key has invalid characters" + end + + if value.match(/[^ -~]/) + raise StandardError, "value key has invalid characters" + end + + @origin.response = (@origin.response or Hyde::Response.new) + key = key.downcase + @origin.response.add_header(key, value) + end + + # Delete a header value from the headers hash + # If no value is provided, deletes all key entries + # @param key [String] header name + # @param value [String, nil] header value + def remove_header(key, value = nil) + return unless @origin.response + + return if key.downcase == "status" + + if key.match(/(?:[(),\/:;<=>?@\[\]{}"]|[^ -~])/) + raise StandardError, "header key has invalid characters" + end + + if value&.match(/[^ -~]/) + raise StandardError, "value key has invalid characters" + end + + @origin.response.delete_header(key, value) + end + end + end +end diff --git a/lib/hyde/node.rb b/lib/hyde/node.rb index aaa795a..73e4721 100644 --- a/lib/hyde/node.rb +++ b/lib/hyde/node.rb @@ -15,12 +15,18 @@ module Hyde # @param [Hyde::Request] # @return [Boolean] def go(request) + # rejected at pattern return reject(request) unless @pattern.match?(request.path) + request.push_state request.path, splat, param = @pattern.match(request.path) request.splat.append(*splat) request.param.merge!(param) - process(request) + value = process(request) + # rejected at callback - restore state + request.pop_state unless value + # finally, return process value + value end # Method callback on failed request navigation diff --git a/lib/hyde/path.rb b/lib/hyde/path.rb index d1964e0..684fb8d 100644 --- a/lib/hyde/path.rb +++ b/lib/hyde/path.rb @@ -33,7 +33,7 @@ module Hyde # Method callback on successful request navigation. # Finds the next appropriate path to go to. - # @return [Boolean] true if further navigation is possible + # @return [Boolean] true if further navigation will be done # @raise [UncaughtThrowError] by default throws :response if no matches found. def process(request) @children.each do |x| @@ -41,6 +41,9 @@ module Hyde return value end end + value = index(request) + return value if value + _die(404) rescue StandardError => e _die(500, backtrace: [e.to_s] + e.backtrace) @@ -50,6 +53,23 @@ module Hyde private + # Try to perform indexing on the path if possible + # @param request [Hyde::Request] + # @return [Boolean] true if indexing succeeded + def index(request) + return false unless request.path.match?(/^\/?$/) + + @properties["index"].each do |index| + request.path = index + @children.each do |x| + if (value = x.go(request)) + return value + end + end + end + false + end + # Handle an errorcode # @param errorcode [Integer] # @param backtrace [Array(String), nil] diff --git a/lib/hyde/probe.rb b/lib/hyde/probe.rb index ea69935..77a86b6 100644 --- a/lib/hyde/probe.rb +++ b/lib/hyde/probe.rb @@ -4,6 +4,17 @@ require_relative 'node' require_relative 'util/lookup' module Hyde + autoload :Handler, "hyde/probe/handler" + autoload :GETHandler, "hyde/probe/http_method" + autoload :POSTHandler, "hyde/probe/http_method" + autoload :HEADHandler, "hyde/probe/http_method" + autoload :PUTHandler, "hyde/probe/http_method" + autoload :DELETEHandler, "hyde/probe/http_method" + autoload :CONNECTHandler, "hyde/probe/http_method" + autoload :OPTIONSHandler, "hyde/probe/http_method" + autoload :TRACEHandler, "hyde/probe/http_method" + autoload :PATCHHandler, "hyde/probe/http_method" + # Test probe. Also base for all "reactive" nodes. class Probe < Hyde::Node # @param path [Object] @@ -16,6 +27,7 @@ module Hyde # Method callback on successful request navigation. # Throws an error upon reaching the path. # This behaviour should only be used internally. + # @param request [Hyde::Request] # @return [Boolean] true if further navigation is possible # @raise [StandardError] def process(request) diff --git a/lib/hyde/probe/binding.rb b/lib/hyde/probe/binding.rb new file mode 100644 index 0000000..334d49b --- /dev/null +++ b/lib/hyde/probe/binding.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require_relative "../dsl/probe_methods" + +module Hyde + class ProbeBinding + def initialize(origin) + @origin = origin + end + include Hyde::DSL::ProbeMethods + end +end diff --git a/lib/hyde/probe/handler.rb b/lib/hyde/probe/handler.rb new file mode 100644 index 0000000..c1e5a64 --- /dev/null +++ b/lib/hyde/probe/handler.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative '../probe' +require_relative 'binding' + +module Hyde + # Probe that executes a callback on request + class Handler < Hyde::Probe + # @param path [Object] + # @param parent [Hyde::Node] + # @param exec [#call] + def initialize(path, parent:, &exec) + super(path, parent: parent) + @callback = exec + @binding = Hyde::ProbeBinding.new(self) + @response = nil + end + + attr_accessor :response + + # Method callback on successful request navigation. + # Runs block supplied with object initialization. + # Request's #splat and #param are passed to block. + # + # Callback's returned should be one of viable responses: + # + # - {Hyde::Response} object + # - An array that matches Rack return form + # - An array that matches old (Rack 2.x) return form + # - A string (returned as HTML with code 200) + # - false (bounces the request to next handler) + # @param request [Hyde::Request] + # @return [Boolean] true if further navigation is possible + # @raise [UncaughtThrowError] may raise if die() is called. + def process(request) + @request = request + response = catch(:break) do + @binding.instance_exec(*request.splat, + **request.param, + &@callback) + end + return false unless response + + if @response and [String, File, IO].include? response.class + @response.body = response + throw :finish, @response + end + throw :finish, response + end + end +end diff --git a/lib/hyde/probe/http_method.rb b/lib/hyde/probe/http_method.rb new file mode 100644 index 0000000..0daf5fd --- /dev/null +++ b/lib/hyde/probe/http_method.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative '../probe' +require_relative 'binding' +require_relative 'handler' + +module Hyde + # Probe that executes callback on a GET + class GETHandler < Hyde::Handler + METHOD = "GET" + + # Method callback on successful request navigation. + # Runs block supplied with object initialization. + # Request's #splat and #param are passed to block. + # + # Callback's returned should be one of viable responses: + # + # - {Hyde::Response} object + # - An array that matches Rack return form + # - An array that matches old (Rack 2.x) return form + # - A string (returned as HTML with code 200) + # - false (bounces the request to next handler) + # @param request [Hyde::Request] + # @return [Boolean] true if further navigation is possible + # @raise [UncaughtThrowError] may raise if die() is called. + def process(request) + unless request.request_method.casecmp(self.class::METHOD).zero? + return false + end + + super(request) + end + end + + # Probe that executes callback on a POST + class POSTHandler < GETHandler + METHOD = "POST" + end + + # Probe that executes callback on a HEAD + class HEADHandler < GETHandler + METHOD = "HEAD" + end + + # Probe that executes callback on a PUT + class PUTHandler < GETHandler + METHOD = "PUT" + end + + # Probe that executes callback on a DELETE + class DELETEHandler < GETHandler + METHOD = "DELETE" + end + + # Probe that executes callback on a CONNECT + class CONNECTHandler < GETHandler + METHOD = "CONNECT" + end + + # Probe that executes callback on a OPTIONS + class OPTIONSHandler < GETHandler + METHOD = "OPTIONS" + end + + # Probe that executes callback on a TRACE + class TRACEHandler < GETHandler + METHOD = "TRACE" + end + + # Probe that executes callback on a PATCH + class PATCHHandler < GETHandler + METHOD = "PATCH" + end +end diff --git a/lib/hyde/request.rb b/lib/hyde/request.rb index e2b71b8..f73f791 100644 --- a/lib/hyde/request.rb +++ b/lib/hyde/request.rb @@ -23,6 +23,8 @@ module Hyde @path = env["PATH_INFO"].dup # Encapsulates all rack variables. Should not be public. @rack = init_rack_vars(env) + # Internal navigation states + @states = [] end # Returns request body (if POST data exists) @@ -30,6 +32,16 @@ module Hyde @rack.input&.gets end + # Push current navigation state (path, splat, param) onto state stack + def push_state + @states.push([@path, @param.dup, @splat.dup]) + end + + # Load last navigation state (path, splat, param) from state stack + def pop_state + @path, @param, @splat = @states.pop + end + attr_reader :request_method, :script_name, :path_info, :server_name, :server_port, :server_protocol, :headers, :param, :splat attr_accessor :path diff --git a/lib/hyde/response.rb b/lib/hyde/response.rb index c04435c..dfe51a8 100644 --- a/lib/hyde/response.rb +++ b/lib/hyde/response.rb @@ -43,6 +43,33 @@ module Hyde self end + # Add a header to the headers hash + # @param key [String] header name + # @param value [String] header value + def add_header(key, value) + if @headers[key].is_a? String + @headers[key] = [@headers[key], value] + elsif @headers[key].is_a? Array + @headers[key].append(value) + else + @headers[key] = value + end + end + + # Delete a header value from the headers hash + # If no value is provided, deletes all key entries + # @param key [String] header name + # @param value [String, nil] header value + def delete_header(key, value = nil) + if value and @response[key] + @response[key].delete(value) + else + @response.delete(key) + end + end + + attr_accessor :status, :headers, :body + # Ensure response correctness # @param obj [String, Array, Hyde::Response] # @return Response @@ -52,18 +79,16 @@ module Hyde obj.validate when Array Response.new(obj).validate - when String + when String, File, IO Response.new([200, { "content-type" => "text/html", "content-length" => obj.length }, - self.class.chunk_body(obj)]) + chunk_body(obj)]) end end - attr_accessor :status, :headers, :body - # Turn body into array of chunks def self.chunk_body(text) if text.is_a? String