diff --git a/HACKING.md b/HACKING.md new file mode 100644 index 0000000..6bc0856 --- /dev/null +++ b/HACKING.md @@ -0,0 +1,30 @@ +# Notes and things to consider on Hyde hacking + +The structure of the Hyde rewrite was specifically redesigned to allow for +extensive modification and extension. So to keep things that way, you may +want to consider the methodology of writing Hyde code. + +## Recommendations + +To keep things beautiful, consider following recommendations: + +- **USE COMMON SENSE**. None of these guidelines will ever be adequate + enough to replace common sense. +- **Code less, think with ~~portals~~ what you have**. To minimize code + overhead, try to use existing functionality to get the effect you want. + (i.e. if you want to append headers to a request when it traverses a path, + don't write a new class variable and handler for this - just create a new + DSL method that really just appends a preprocessor to the path. Or avoid + making something like this at all - after all, preprocessors exist + exactly for that reason.) +- Preferably, **extend the DSL and not the class*. Extensive class + modifications make code a lot less maintainable, if it wasn't obvious + already. If it can't be helped, then at the very least use Rubocop. +- Document classes as if the next maintainer after you has you at gunpoint. + Document thoroughly, use YARD tags and **never** skip on public method + docs and class docs. As an example, consider Hyde::PatternMatching::Glob. +- Unit tests suck for many reasons. However, if you're writing a class that + does not have any dependents and which is frequently used, consider making + a unit test for it. People that might have to fix things further along + will be very thankful. + diff --git a/lib/hyde/LAYOUT.md b/LAYOUT.md similarity index 100% rename from lib/hyde/LAYOUT.md rename to LAYOUT.md diff --git a/config.ru b/config.ru deleted file mode 100644 index e9e6ba5..0000000 --- a/config.ru +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -$LOAD_PATH.unshift("#{File.dirname(__FILE__)}/lib") -require_relative 'lib/hyde' - -app = Hyde::Server.new do - path /^test\/\w+/ do - probe "probe" - end - - path "/subdir/test" do - probe "probe" - end - - path "/match/*/test/:test/" do - probe "probe" - end - - path "/match/:test/" do - probe "probe" - end - - path "/match2/*/" do - 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 - -run app diff --git a/examples/assets/index.html b/examples/assets/index.html new file mode 100644 index 0000000..7abab0a --- /dev/null +++ b/examples/assets/index.html @@ -0,0 +1,6 @@ + + + +index page
+ + diff --git a/examples/assets/pee.css b/examples/assets/pee.css new file mode 100644 index 0000000..a6432b7 --- /dev/null +++ b/examples/assets/pee.css @@ -0,0 +1,3 @@ +.a { + color: #FF00FF; +} diff --git a/examples/config.ru b/examples/config.ru new file mode 100644 index 0000000..3f36242 --- /dev/null +++ b/examples/config.ru @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift("#{File.dirname(__FILE__)}/lib") +require_relative 'lib/hyde' + +app = Hyde::Server.new do + preprocess do |request| + puts "New request: #{request}" + end + filter do + rand < 0.5 + end + index ["index"] + root "#{ENV['PWD']}/assets" + serve "*.(html|css|js)" +end + +run app diff --git a/examples/lib b/examples/lib new file mode 120000 index 0000000..dc598c5 --- /dev/null +++ b/examples/lib @@ -0,0 +1 @@ +../lib \ No newline at end of file diff --git a/lib/hyde/dsl/path_constructors.rb b/lib/hyde/dsl/path_constructors.rb index 06338cd..3a3a374 100644 --- a/lib/hyde/dsl/path_constructors.rb +++ b/lib/hyde/dsl/path_constructors.rb @@ -71,6 +71,10 @@ module Hyde register(Hyde::OPTIONSHandler.new(path, parent: @origin, &setup)) end + # Create a new {Hyde::GETHandler} that serves static files + def serve(path) + register(Hyde::ServeHandler.new(path, parent: @origin)) + end end end end diff --git a/lib/hyde/dsl/path_methods.rb b/lib/hyde/dsl/path_methods.rb new file mode 100644 index 0000000..152bcf4 --- /dev/null +++ b/lib/hyde/dsl/path_methods.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Hyde + # Shared DSL methods + module DSL + # Common path methods + module PathMethods + # Set path index + # @param index [Array,String] + def index(index) + case index + when Array + @origin.properties['index'] = index + when String + @origin.properties['index'] = [index] + else + raise StandardError, "index should be an Array or a String" + end + end + + # Set root path (appends matched part of the path). + # @param path [String + def root(path) + raise StandardError, "path should be a String" unless path.is_a? String + + @origin.root = path + end + + # Set root path (without appending matched part). + # @param path [String + def remap(path) + root(path) + @origin.remap = true + end + + # Add a preprocessor to the path. + # Does not modify path execution. + # @param block [#call] + # @yieldparam request [Hyde::Request] + def preprocess(&block) + @origin.preprocess(&block) + block + end + + # Add a postprocessor to the path. + # @param block [#call] + # @yieldparam request [Hyde::Request] + # @yieldparam response [Hyde::Response] + def postprocess(&block) + @origin.postprocess(&block) + block + end + + # Add a filter to the path. + # Blocks path access if a filter returns false. + # @param block [#call] + # @yieldparam request [Hyde::Request] + def filter(&block) + @origin.filter(&block) + block + end + end + end +end diff --git a/lib/hyde/node.rb b/lib/hyde/node.rb index 73e4721..9b757da 100644 --- a/lib/hyde/node.rb +++ b/lib/hyde/node.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative 'pattern_matching/util' + module Hyde # Abstract class that reacts to request navigation. # Does nothing by default, behaviour should be overriden through @@ -9,6 +11,8 @@ module Hyde # @param path [Object] def initialize(path) @pattern = Pattern.new(path).freeze + @root = nil + @remap = false end # Try to navigate the path. Run method callback in response. @@ -19,7 +23,9 @@ module Hyde return reject(request) unless @pattern.match?(request.path) request.push_state - request.path, splat, param = @pattern.match(request.path) + path, splat, param = @pattern.match(request.path) + do_filepath(request, request.path.delete_suffix(path)) + request.path = path request.splat.append(*splat) request.param.merge!(param) value = process(request) @@ -42,5 +48,19 @@ module Hyde def process(_request) true end + + attr_accessor :remap, :root + + private + + # Process filepath for request + def do_filepath(request, path) + if @root + request.filepath = "#{@root}/#{@remap ? '' : path}/" + else + request.filepath += "/#{path}/" + end + request.filepath.gsub!(/\/+/, "/") + end end end diff --git a/lib/hyde/path.rb b/lib/hyde/path.rb index 684fb8d..c53fb12 100644 --- a/lib/hyde/path.rb +++ b/lib/hyde/path.rb @@ -3,12 +3,14 @@ require_relative 'pattern_matching' require_relative 'node' require_relative 'dsl/path_constructors' +require_relative 'dsl/path_methods' require_relative 'util/lookup' module Hyde # Protected interface that provides DSL context for setup block. class PathBinding include Hyde::DSL::PathConstructors + include Hyde::DSL::PathMethods def initialize(path) @origin = path @@ -24,8 +26,14 @@ module Hyde # @param setup [#call] Setup block def initialize(path, parent:, &setup) super(path) + # Child nodes array @children = [] + # Inherited properties array @properties = Hyde::Util::Lookup.new(parent&.properties) + # Arrays of preprocessors, postprocessors and filters + @preprocessors = [] + @postprocessors = [] + @filters = [] binding = Binding.new(self) binding.instance_exec(&setup) @@ -36,6 +44,10 @@ module Hyde # @return [Boolean] true if further navigation will be done # @raise [UncaughtThrowError] by default throws :response if no matches found. def process(request) + return false unless run_filters(request) + + run_preprocessors(request) + enqueue_postprocessors(request) @children.each do |x| if (value = x.go(request)) return value @@ -49,10 +61,58 @@ module Hyde _die(500, backtrace: [e.to_s] + e.backtrace) end + # Add a preprocessor to the path. + # Does not modify path execution. + # @param block [#call] + # @yieldparam request [Hyde::Request] + def preprocess(&block) + @preprocessors.append(block) + end + + # Add a postprocessor to the path. + # @param block [#call] + # @yieldparam request [Hyde::Request] + # @yieldparam response [Hyde::Response] + def postprocess(&block) + @postprocessors.append(block) + end + + # Add a filter to the path. + # Blocks path access if a filter returns false. + # @param block [#call] + # @yieldparam request [Hyde::Request] + def filter(&block) + @filters.append(block) + end + attr_reader :children, :properties private + # Sequentially run through all filters and drop request if one is false + # @param request [Hyde::Request] + # @return [Boolean] true if request passed all filters + def run_filters(request) + @filters.each do |filter| + return false if filter.call(request).is_a? FalseClass + end + true + end + + # Sequentially run all preprocessors on a request + # @param request [Hyde::Request] + def run_preprocessors(request) + @preprocessors.each do |preproc| + preproc.call(request) + end + end + + # Append postprocessors to request + # @param request [Hyde::Request] + def enqueue_postprocessors(request) + request.postprocessors.append(*@postprocessors) + end + # Try to perform indexing on the path if possible # @param request [Hyde::Request] # @return [Boolean] true if indexing succeeded diff --git a/lib/hyde/probe.rb b/lib/hyde/probe.rb index 77a86b6..e64f50d 100644 --- a/lib/hyde/probe.rb +++ b/lib/hyde/probe.rb @@ -2,6 +2,7 @@ require_relative 'node' require_relative 'util/lookup' +require 'pp' module Hyde autoload :Handler, "hyde/probe/handler" @@ -14,7 +15,7 @@ module Hyde autoload :OPTIONSHandler, "hyde/probe/http_method" autoload :TRACEHandler, "hyde/probe/http_method" autoload :PATCHHandler, "hyde/probe/http_method" - + autoload :ServeHandler, "hyde/probe/serve_handler" # Test probe. Also base for all "reactive" nodes. class Probe < Hyde::Node # @param path [Object] @@ -31,8 +32,11 @@ module Hyde # @return [Boolean] true if further navigation is possible # @raise [StandardError] def process(request) + return reject(request) unless request.path.match?(/^\/?$/) + raise StandardError, <<~STREND probe reached #{request.splat.inspect}, #{request.param.inspect} + #{request.pretty_inspect} STREND end end diff --git a/lib/hyde/probe/handler.rb b/lib/hyde/probe/handler.rb index c1e5a64..0cbb29c 100644 --- a/lib/hyde/probe/handler.rb +++ b/lib/hyde/probe/handler.rb @@ -33,6 +33,8 @@ module Hyde # @return [Boolean] true if further navigation is possible # @raise [UncaughtThrowError] may raise if die() is called. def process(request) + return reject(request) unless request.path.match?(/^\/?$/) + @request = request response = catch(:break) do @binding.instance_exec(*request.splat, diff --git a/lib/hyde/probe/serve_handler.rb b/lib/hyde/probe/serve_handler.rb new file mode 100644 index 0000000..08e4c52 --- /dev/null +++ b/lib/hyde/probe/serve_handler.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require_relative '../probe' +require_relative 'binding' + +module Hyde + # Probe that sends files from a location + class ServeHandler < Hyde::Probe + # @param path [Object] + # @param parent [Hyde::Node] + # @param exec [#call] + def initialize(path, parent:) + super(path, parent: parent) + end + + attr_accessor :response + + # Method callback on successful request navigation. + # Tries to serve files matched by handler + # @param request [Hyde::Request] + # @return [Boolean] true if file was found + def process(request) + File.open(request.filepath.delete_suffix("/")) + rescue StandardError + false + end + end +end diff --git a/lib/hyde/request.rb b/lib/hyde/request.rb index f73f791..f7bd5ee 100644 --- a/lib/hyde/request.rb +++ b/lib/hyde/request.rb @@ -9,7 +9,57 @@ module Hyde def initialize(env) # Should not be used under regular circumstances or depended upon. @_original_env = env - # Rack environment variable bindings. Should be public and readonly. + # Rack environment variable bindings. Should be public and frozen. + init_request_params(env) + # Pattern matching parameters. Public, readable, unfrozen. + @param = {} + @splat = [] + # Traversal route. Public and writable. + @path = env["PATH_INFO"].dup + # File serving path. Public and writable. + @filepath = "/" + # Encapsulates all rack variables. Should not be public. + @rack = init_rack_vars(env) + # Internal navigation states. Private. + @states = [] + # Postprocessors for current request + @postprocessors = [] + end + + # Run postprocessors + # @param response [Hyde::Response] + def run_postprocessors(response) + @postprocessors.each do |postproc| + postproc.call(self, response) + end + end + + # Returns request body (if POST data exists) + # @return [nil, String] + def body + @rack.input&.gets + end + + # Push current navigation state (path, splat, param) onto state stack + def push_state + @states.push([@path, @param.dup, @splat.dup, @filepath.dup]) + end + + # Load last navigation state (path, splat, param) from state stack + def pop_state + @path, @param, @splat, @filepath = @states.pop + end + + attr_reader :request_method, :script_name, :path_info, :server_name, + :server_port, :server_protocol, :headers, :param, :splat, + :postprocessors + attr_accessor :path, :filepath + + private + + # Initialize basic rack request parameters + # @param env [Hash] + def init_request_params(env) @request_method = env["REQUEST_METHOD"] @script_name = env["SCRIPT_NAME"] @path_info = env["PATH_INFO"] @@ -17,37 +67,8 @@ module Hyde @server_port = env["SERVER_PORT"] @server_protocol = env["SERVER_PROTOCOL"] @headers = init_headers(env) - @param = {} - @splat = [] - # Traversal route. Public, writable and readable. - @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) - def body - @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 - - private - # Initialize rack parameters struct # @param env [Hash] # @return Object diff --git a/lib/hyde/response.rb b/lib/hyde/response.rb index dfe51a8..d546dbe 100644 --- a/lib/hyde/response.rb +++ b/lib/hyde/response.rb @@ -27,15 +27,15 @@ module Hyde end # Make internal representation conformant + # @return [Hyde::Response] def validate if [204, 304].include?(@status) or (100..199).include?(@status) @headers.delete "content-length" @headers.delete "content-type" @body = [] elsif @headers.empty? - length = @body.is_a?(String) ? @body.length : @body.join.length @headers = { - "content-length" => length, + "content-length" => content_size, "content-type" => "text/html" } end @@ -82,19 +82,30 @@ module Hyde when String, File, IO Response.new([200, { - "content-type" => "text/html", - "content-length" => obj.length + "content-type" => "text/html" }, - chunk_body(obj)]) + obj]).validate + else + Response.new([404, {}, []]) end end # Turn body into array of chunks + # @param text [String] + # @return [Array(String)] def self.chunk_body(text) - if text.is_a? String - text.chars.each_slice(@chunk_size).map(&:join) - elsif text.is_a? Array - text + text.chars.each_slice(@chunk_size).map(&:join) + end + + private + + # Try to figure out content length + # @return [Integer, nil] + def content_size + case @body + when String then @body.length + when Array then @body.join.length + when File then @body.size end end end diff --git a/lib/hyde/server.rb b/lib/hyde/server.rb index deb90c7..899d669 100644 --- a/lib/hyde/server.rb +++ b/lib/hyde/server.rb @@ -13,6 +13,8 @@ module Hyde class Server < Hyde::Path Binding = ServerBinding + # @param parent [Hyde::Node, nil] Parent object to inherit properties to + # @param setup [#call] Setup block def initialize(parent: nil, &setup) super("", parent: parent, &setup) return if parent @@ -28,18 +30,21 @@ module Hyde [headers, page] end }.each do |k, v| - @properties[k] = v + @properties[k] = v unless @properties[k] end end # Rack ingress point. # This should not be called under any circumstances twice in the same application, # although server nesting for the purpose of creating virtual hosts is allowed. + # @param env [Hash] + # @return [Array(Integer,Hash,Array)] def call(env) + request = Hyde::Request.new(env) response = catch(:finish) do - request = Hyde::Request.new(env) go(request) end + request.run_postprocessors(response) Response.convert(response).finalize end end