From f15f739bd6cf2e66425e4784c8109463e15f23d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arist=C3=B3teles=20Coutinho?= Date: Thu, 16 Nov 2023 17:59:38 -0300 Subject: [PATCH] feat: add versioning, cache, and base server --- .rubocop.yml | 12 + Gemfile | 22 +- Gemfile.lock | 32 +- LICENCE | 24 ++ lennarb.gemspec | 22 +- lib/lenna/base.rb | 52 +++ lib/lenna/middleware/app.rb | 103 +++++ lib/lenna/middleware/default/error_handler.rb | 205 ++++++++++ lib/lenna/middleware/default/logging.rb | 95 +++++ lib/lenna/router.rb | 172 +++++++++ lib/lenna/router/builder.rb | 99 +++++ lib/lenna/router/cache.rb | 38 ++ lib/lenna/router/namespace_stack.rb | 73 ++++ lib/lenna/router/request.rb | 77 ++++ lib/lenna/router/response.rb | 357 ++++++++++++++++++ lib/lenna/router/route_matcher.rb | 69 ++++ lib/lennarb.rb | 6 +- lib/lennarb/version.rb | 1 + test/lib/lenna/middleware/test_app.rb | 52 +++ test/lib/lenna/router/test_builder.rb | 58 +++ test/lib/lenna/router/test_cache.rb | 39 ++ test/lib/lenna/router/test_namespace_stack.rb | 57 +++ test/lib/lenna/router/test_request.rb | 58 +++ test/lib/lenna/router/test_response.rb | 195 ++++++++++ test/lib/lenna/router/test_route_matcher.rb | 63 ++++ test/lib/lenna/test_router.rb | 95 +++++ 26 files changed, 2057 insertions(+), 19 deletions(-) create mode 100644 LICENCE create mode 100644 lib/lenna/base.rb create mode 100644 lib/lenna/middleware/app.rb create mode 100644 lib/lenna/middleware/default/error_handler.rb create mode 100644 lib/lenna/middleware/default/logging.rb create mode 100644 lib/lenna/router.rb create mode 100644 lib/lenna/router/builder.rb create mode 100644 lib/lenna/router/cache.rb create mode 100644 lib/lenna/router/namespace_stack.rb create mode 100644 lib/lenna/router/request.rb create mode 100644 lib/lenna/router/response.rb create mode 100644 lib/lenna/router/route_matcher.rb create mode 100644 test/lib/lenna/middleware/test_app.rb create mode 100644 test/lib/lenna/router/test_builder.rb create mode 100644 test/lib/lenna/router/test_cache.rb create mode 100644 test/lib/lenna/router/test_namespace_stack.rb create mode 100644 test/lib/lenna/router/test_request.rb create mode 100644 test/lib/lenna/router/test_response.rb create mode 100644 test/lib/lenna/router/test_route_matcher.rb create mode 100644 test/lib/lenna/test_router.rb diff --git a/.rubocop.yml b/.rubocop.yml index 675bf6d..85c0306 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -39,12 +39,24 @@ Metrics/MethodLength: Enabled: false Metrics/ClassLength: + Max: 150 + Exclude: + - "test/**/*" + +Metrics/ModuleLength: + Max: 150 Exclude: - "test/**/*" Metrics/PerceivedComplexity: Max: 10 +Style/AsciiComments: + Enabled: false + +Style/AccessModifierDeclarations: + Enabled: false + Style/Documentation: Enabled: false diff --git a/Gemfile b/Gemfile index 2c64813..b70a597 100644 --- a/Gemfile +++ b/Gemfile @@ -5,11 +5,29 @@ source 'https://rubygems.org' # Specify your gem's dependencies in lennarb.gemspec gemspec +# [https://rubygems.org/gems/puma] +# Puma is a simple, fast, threaded, and highly concurrent HTTP 1.1 server for +# Ruby/Rack applications. Puma is intended for use in both development and +# production environments. In order to get the best throughput, it is highly +# recommended that you use a Ruby implementation with real threads like Rubinius +# or JRuby. +gem 'puma', '~> 6.4' +# [https://rubygems.org/gems/rack] +# Rack provides a minimal, modular, and adaptable interface for developing web +# applications in Ruby. By wrapping HTTP requests and responses in the simplest +# way possible, it unifies and distills the API for web servers, web frameworks, +# and software in between (the so-called middleware) into a single method call. +gem 'rack', '~> 3.0', '>= 3.0.8' +# [https://rubygems.org/gems/colorize] +# Colorize is a Ruby gem used to color text in terminals. +gem 'colorize', '~> 1.1' + group :development, :test do + gem 'debug' # [https://rubygems.org/gems/rake] # Rake is a Make-like program implemented in Ruby. Tasks and dependencies are # specified in standard Ruby syntax. - gem 'rake', '~> 13.0' + gem 'rake', '~> 13.0', '>= 13.0.6' # [https://rubygems.org/gems/rubocop] # Automatic Ruby code style checking tool. Aims to enforce the # community-driven Ruby Style Guide. @@ -21,5 +39,5 @@ group :development, :test do # [https://rubygems.org/gems/minitest] # Minitest provides a complete suite of testing facilities supporting TDD, # BDD, mocking, and benchmarking. - gem 'minitest', '~> 5.0' + gem 'minitest', '~> 5.20' end diff --git a/Gemfile.lock b/Gemfile.lock index a50c13a..0db57cb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,22 +2,44 @@ PATH remote: . specs: lennarb (0.1.0) + colorize (~> 1.1) + puma (~> 6.4) + rack (~> 3.0, >= 3.0.8) + rake (~> 13.0, >= 13.0.6) GEM remote: https://rubygems.org/ specs: ast (2.4.2) + colorize (1.1.0) + debug (1.8.0) + irb (>= 1.5.0) + reline (>= 0.3.1) + io-console (0.6.0) + irb (1.9.0) + rdoc + reline (>= 0.3.8) json (2.6.3) language_server-protocol (3.17.0.3) minitest (5.20.0) + nio4r (2.5.9) parallel (1.23.0) parser (3.2.2.4) ast (~> 2.4.1) racc + psych (5.1.1.1) + stringio + puma (6.4.0) + nio4r (~> 2.0) racc (1.7.3) + rack (3.0.8) rainbow (3.1.1) rake (13.1.0) + rdoc (6.6.0) + psych (>= 4.0.0) regexp_parser (2.8.2) + reline (0.4.0) + io-console (~> 0.5) rexml (3.2.6) rubocop (1.57.2) json (~> 2.3) @@ -35,16 +57,20 @@ GEM rubocop-minitest (0.33.0) rubocop (>= 1.39, < 2.0) ruby-progressbar (1.13.0) + stringio (3.0.9) unicode-display_width (2.5.0) PLATFORMS x86_64-linux DEPENDENCIES - bundler + colorize (~> 1.1) + debug lennarb! - minitest (~> 5.0) - rake (~> 13.0) + minitest (~> 5.20) + puma (~> 6.4) + rack (~> 3.0, >= 3.0.8) + rake (~> 13.0, >= 13.0.6) rubocop (~> 1.57, >= 1.57.2) rubocop-minitest (~> 0.33.0) diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..5982868 --- /dev/null +++ b/LICENCE @@ -0,0 +1,24 @@ +The MIT License (MIT) + +Copyright (c) 2023, Aristóteles Coutinho costa + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/lennarb.gemspec b/lennarb.gemspec index b843a0c..21b43ec 100644 --- a/lennarb.gemspec +++ b/lennarb.gemspec @@ -5,27 +5,31 @@ require_relative 'lib/lennarb/version' Gem::Specification.new do |spec| spec.name = 'lennarb' spec.version = Lennarb::VERSION + spec.license = 'MIT' spec.authors = ['Aristóteles Coutinho'] spec.email = ['aristotelesbr@gmail.com'] - spec.summary = "A modular web framework for Ruby." + spec.summary = 'A lightweight and experimental web framework for Ruby.' spec.description = <<~EOF - Lenna is a lightweight, fast and easy framework for building modular - web applications and APIS with Ruby. Also, that's how I affectionately call my wife. + Lenna is a lightweight and experimental web framework for Ruby. It's designed to be modular and easy to use. Also, that's how I affectionately call my wife. EOF spec.homepage = 'https://rubygems.org/gems/lennarb' - spec.required_ruby_version = '>= 2.7.0' + spec.required_ruby_version = '>= 3.2.0' spec.metadata['allowed_push_host'] = 'https://rubygems.org/gems/lennarb' spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = 'https://github.com/aristotelesbr/lennarb' spec.metadata['changelog_uri'] = 'https://github.com/aristotelesbr/lennarb/blob/master/CHANGELOG.md' - spec.files = Dir['lib/**/*'] + %w[MIT-LICENSE README.md SPEC.rdoc] - spec.extra_rdoc_files = ['README.md', 'CHANGELOG', 'CONTRIBUTING.md'] + spec.files = Dir['lib/**/*'] + spec.extra_rdoc_files = %w[README.md LICENCE CHANGELOG.md] spec.bindir = 'exe' spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] + spec.add_dependency 'puma', '~> 6.4' + spec.add_dependency 'rack', '~> 3.0', '>= 3.0.8' + spec.add_dependency 'rake', '~> 13.0', '>= 13.0.6' + spec.add_dependency 'colorize', '~> 1.1' # Uncomment to register a new dependency of your gem - spec.add_development_dependency 'minitest', "~> 5.20" - spec.add_development_dependency 'bundler' - spec.add_development_dependency 'rake' + spec.add_development_dependency 'minitest', '~> 5.20' + spec.add_development_dependency 'rake', '~> 13.0', '>= 13.0.6' + spec.metadata['rubygems_mfa_required'] = 'true' end diff --git a/lib/lenna/base.rb b/lib/lenna/base.rb new file mode 100644 index 0000000..064a5a2 --- /dev/null +++ b/lib/lenna/base.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# External dependencies +require 'puma' + +# Internal dependencies +require_relative 'middleware/default/error_handler' +require_relative 'middleware/default/logging' +require_relative 'router' + +module Lenna + # The base class is used to start the server. + class Base < Router + DEFAULT_PORT = 3000 + private_constant :DEFAULT_PORT + DEFAULT_HOST = 'localhost' + private_constant :DEFAULT_HOST + + # This method will start the server. + # + # @param port [Integer] The port to listen on (default: 3000) + # @param host [String] The host to listen on (default: ' + # @return [void] + # + # @example + # app = Lenna::Base.new + # app.listen(8080) + # # => ⚡ Listening on localhost:8080 + # + # or specify the host and port + # + # app = Lenna::Base.new + # app.listen(8000, host: '0.0.0.0') + # # => ⚡ Listening on 0.0.0.0:8000 + # + # @api public + # + # @todo: Add Lenna::Server to handle the server logic + # + # @since 0.1.0 + def listen(port = DEFAULT_PORT, host: DEFAULT_HOST, **) + puts "⚡ Listening on #{host}:#{port}" + + # Add the logging middleware to the stack + use(Middleware::Default::Logging, Middleware::Default::ErrorHandler) + + server = ::Puma::Server.new(self, **) + server.add_tcp_listener(host, port) + server.run.join + end + end +end diff --git a/lib/lenna/middleware/app.rb b/lib/lenna/middleware/app.rb new file mode 100644 index 0000000..ea99447 --- /dev/null +++ b/lib/lenna/middleware/app.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Lenna + module Middleware + # The MiddlewareManager class is responsible for managing the middlewares. + # + # @attr mutex [Mutex] the mutex used to synchronize the + # access to the global middlewares and + # the middleware chains cache. + # @attr global_middlewares [Array] the global middlewares + # @attr middleware_chains_cache [Hash] the middleware chains cache + # + # @note Middleware chains are cached by action. + # The middlewares that are added to a specific route are added to the + # global middlewares. + # + # @api private + # + # @since 0.1.0 + class App + # @return [Mutex] the mutex used to synchronize the access to the global + attr_reader :global_middlewares + + # @return [Hash] the middleware chains cache + attr_reader :middleware_chains_cache + + # This method will initialize the global middlewares and the + # middleware chains cache. + # + # @return [void] + # + # @since 0.1.0 + def initialize + @mutex = ::Mutex.new + @global_middlewares = [] + @middleware_chains_cache = {} + end + + # This method is used to add a middleware to the global middlewares. + # @param middlewares [Array] the middlewares to be used + # @return [void] + # + # @since 0.1.0 + def use(middlewares) + @mutex.synchronize do + @global_middlewares += Array(middlewares) + @middleware_chains_cache = {} + end + end + + # This method is used to fetch or build the middleware chain for the given + # action and route middlewares. + # + # @param action [Proc] the action to be executed + # @param route_middlewares [Array] the middlewares to be used + # @return [Proc] the middleware chain + # + # @see #build_middleware_chain + # + # @since 0.1.0 + def fetch_or_build_middleware_chain(action, route_middlewares) + middleware_signature = action.object_id.to_s + + @mutex.synchronize do + @middleware_chains_cache[middleware_signature] ||= + build_middleware_chain(action, route_middlewares) + end + end + + # This method is used to build the middleware chain for the given action + # and middlewares. + # + # @param action [Proc] the action to be executed + # @param middlewares [Array] the middlewares to be used + # @return [Proc] the middleware chain + # + # @since 0.1.0 + # + # @example Given the action: + # `->(req, res) { res << 'Hello' }` and the + # middlewares [mw1, mw2], the middleware + # chain will be: + # mw1 -> mw2 -> action + # The action will be the last middleware in the + # chain. + def build_middleware_chain(action, middlewares) + all_middlewares = @global_middlewares + Array(middlewares) + + all_middlewares.reverse.reduce(action) do |next_middleware, middleware| + ->(req, res) { + middleware.call( + req, + res, + -> { + next_middleware.call(req, res) + } + ) + } + end + end + end + end +end diff --git a/lib/lenna/middleware/default/error_handler.rb b/lib/lenna/middleware/default/error_handler.rb new file mode 100644 index 0000000..db71b76 --- /dev/null +++ b/lib/lenna/middleware/default/error_handler.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'cgi' + +module Lenna + module Middleware + module Default + # This middleware will handle errors. + module ErrorHandler + extend self + + # This method will be called by the server. + # + # @param req [Rack::Request] The request object + # @param res [Rack::Response] The response object + # @param next_middleware [Proc] The next middleware in the stack + # @return [void] + # + # @api private + # + def call(req, res, next_middleware) + next_middleware.call + rescue StandardError => e + env = req.env + log_error(env, e) + + render_error_page(e, env, res) + end + + private + + # This method will render the error page. + # + # @param error [StandardError] The error object + # @param env [Hash] The environment variables + # @param res [Rack::Response] The response object + # @return [void] + # + # @api private + def render_error_page(error, env, res) + res.put_status(500) + res.put_header('Content-Type', 'text/html') + res.put_body(error_page(error, env)) + end + + # This method will log the error. + # + # @param env [Hash] The environment variables + # @param error [StandardError] The error object + # @return [void] + # + # @api private + def log_error(env, error) + env['rack.errors'].puts error.message + env['rack.errors'].puts error.backtrace.join("\n") + env['rack.errors'].flush + end + + # This method will render the error page. + def error_page(error, env) + style = <<-STYLE + + STYLE + + # SVG logo + svg_logo = <<~SVG + + + + #{' '} + SVG + + # HTML page + <<-HTML + + + + + + #{' '} + System Error + #{style} + + +
+
+
+

Oops! An error has occurred.

+
+
#{svg_logo}
+
+ #{' '} + #{error_message_and_backtrace(error, env)} +
+ + + HTML + end + + # This method will render the error message and backtrace. + # + # @param error [StandardError] The error object + # @param env [Hash] The environment variables + # @return [String] The HTML string + # + # @api private + def error_message_and_backtrace(error, env) + if env['RACK_ENV'] == 'development' + truncated_message = + error.message[0..500] + (error.message.length > 500 ? '...' : '') + + file, line = error.backtrace.first.split(':') + line_number = Integer(line) + <<-DETAILS +
+

Error Details:

+

+ Message: #{CGI.escapeHTML(truncated_message)} +

+
+

Location: #{CGI.escapeHTML(file)}:#{line_number}

+
#{extract_source(file, line_number)}
+
+ +
+ Details +

Full Backtrace:

+

Full Message: #{CGI.escapeHTML(error.message)}

+
#{error.backtrace.join("\n")}
+

+
+
+ DETAILS + else + "

We're sorry, but something went wrong. We've been notified " \ + 'about this issue and will take a look at it shortly.

' + end + end + + # This method will extract the source code. + # + # @param file [String] The file path + # @param line_number [Integer] The line number + # @return [String] The HTML string + # + # @api private + # + # @example: + # extract_source('/path/to/file.rb', 10) + # # => " 7: => + # def foo\n 8: + # puts 'bar'\n 9: + # end\n 10: foo\n 11: " + def extract_source(file, line_number) + lines = ::File.readlines(file) + start_line = [line_number - 3, 0].max + end_line = [line_number + 3, lines.size].min + + line_ranger = lines[start_line...end_line] + + format_lines(line_ranger, line_number).join + end + + # This method will format the lines. + # + # @api private + # + # @example: + # format_lines(line_ranger, line_number) + # # => [" 7: =>\n", + # " 8: puts 'bar'\n", + # " 9: end\n", + # " 10: foo\n", + # " 11: "] + def format_lines(lines, highlight_line) + lines.map.with_index(highlight_line - 3 + 1) do |line, line_num| + line_number_text = "#{line_num.to_s.rjust(6)}: " + formatted_line = ::CGI.escapeHTML(line) + + if line_num == highlight_line + "#{line_number_text} " \ + "#{formatted_line}" + else + "#{line_number_text}#{formatted_line}" + end + end + end + end + end + end +end diff --git a/lib/lenna/middleware/default/logging.rb b/lib/lenna/middleware/default/logging.rb new file mode 100644 index 0000000..64ef569 --- /dev/null +++ b/lib/lenna/middleware/default/logging.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'colorize' + +module Lenna + module Middleware + module Default + # The Logging module is responsible for logging the requests. + # + # @api private + # + # @example: + # Logging.call(req, res, next_middleware) + # # => [2021-01-01 00:00:00 +0000] "GET /" 200 0.00ms + module Logging + extend self + + # This method is used to log the request. + # + # @param req [Rack::Request ] The request + # @param res [Rack::Response] The response + # @param next_middleware [Proc] The next middleware + def call(req, res, next_middleware) + start_time = ::Time.now + next_middleware.call + end_time = ::Time.now + + http_method = colorize_http_method(req.request_method) + status_code = colorize_status_code(res.status.to_s) + duration = calculate_duration(start_time, end_time) + + log_message = "[#{start_time}] \"#{http_method} #{req.path_info}\" " \ + "#{status_code} #{format('%.2f', duration)}ms" + + ::Kernel.puts(log_message) + end + + private + + # This method is used to colorize the request method. + # + # @param request_method [String] The request method + # @return [String] The colorized request method + # + # @api private + # + # @example: + # colorize_http_method('GET') # => 'GET'.green + def colorize_http_method(request_method) + case request_method + in 'GET' then 'GET'.green + in 'POST' then 'POST'.magenta + in 'PUT' then 'PUT'.yellow + in 'DELETE' then 'DELETE'.red + else request_method.blue + end + end + + # This method is used to colorize the status code. + # + # @param status_code [String] The status code + # @return [String] The colorized status code + # + # @api private + # + # @example: + # colorize_status_code('200') # => '200'.green + def colorize_status_code(status_code) + case status_code[0] + in '2' then status_code.green + in '3' then status_code.blue + in '4' then status_code.yellow + in '5' then status_code.red + else status_code + end + end + + # This method is used to calculate the duration. + # + # @param start_time [Time] The start time + # @param end_time [Time] The end time + # @return [Float] The duration + # + # @api private + # + # @example: + # calculate_duration(Time.now, Time.now + 1) # => 1000 + def calculate_duration(start_time, end_time) + millis_in_second = 1000.0 + (end_time - start_time) * millis_in_second + end + end + end + end +end diff --git a/lib/lenna/router.rb b/lib/lenna/router.rb new file mode 100644 index 0000000..a1204b4 --- /dev/null +++ b/lib/lenna/router.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +# Standard library dependencies +require 'erb' +require 'json' + +# External dependencies +require 'rack' + +require_relative 'middleware/app' +require_relative 'router/builder' +require_relative 'router/cache' +require_relative 'router/namespace_stack' +require_relative 'router/request' +require_relative 'router/response' +require_relative 'router/route_matcher' + +module Lenna + # The Node struct is used to represent a node in the tree of routes. + # @attr children [Hash] the children of the node + # @attr endpoint [String] the endpoint of the node + # @attr placeholder_name [String] the name of the placeholder + # @attr placeholder [Bool] whether the node is a placeholder + Node = + ::Struct.new(:children, :endpoint, :placeholder_name, :placeholder) do + def initialize(children = {}, endpoint = nil, placeholder_name = nil) + super(children, endpoint, placeholder_name, !placeholder_name.nil?) + end + end + public_constant :Node + + # The router class is responsible for adding routes and calling the + # middleware chain for each request. + class Router + # @return [Node] the root node of the tree of routes + attr_reader :root_node + + # @return [Route::Cache] the cache of routes + attr_reader :cache + + # @return [Route::NamespaceStack] the stack of namespaces + attr_reader :namespace_stack + + # @return [MiddlewareManager] the middleware manager + attr_reader :middleware_manager + + # @return [Route::Builder] the route builder + attr_reader :roter_builder + + # @return [void] + def initialize(middleware_manager: Middleware::App.new, cache: Cache.new) + @cache = cache + @root_node = Node.new({}, nil) + @middleware_manager = middleware_manager + @namespace_stack = NamespaceStack.new + @roter_builder = Builder.new(@root_node) + end + + # This method is used to add a namespace to the routes. + # + # @param prefix [String] the prefix to be used + # @param block [Proc] the block to be executed + # @return [void] + # @note This method is used to add a namespaces + # to the routes. + # + # @since 0.1.0 + # + # @see Route::NamespaceStack#push + # + # @example: + # + # namespace '/api' do |route| + # route.get '/users' do + # # ... + # end + # end + def namespace(prefix, &block) + @namespace_stack.push(prefix) + block.call(self) + @namespace_stack.pop + end + + # This method is used to add a middleware to the middleware manager. + # + # @see MiddlewareManager#use + # + # @since 0.1.0 + # + # @param middlewares [Array] the middlewares to be used + # @return [void] + def use(*middlewares) + @middleware_manager.use(middlewares) + end + + # Proxy methods to add routes + # + # @see #add_route + # + # @since 0.1.0 + # + # @param path [String] the path to be matched + # @param middlewares [Array] the middlewares to be used + # @param action [Proc] the action to be executed + # @return [Lennarb::Route] the route that was added + def get(path, *, &) = add_route(::Rack::GET, path, *, &) + def put(path, *, &) = add_route(::Rack::PUT, path, *, &) + def post(path, *, &) = add_route(::Rack::POST, path, *, &) + def delete(path, *, &) = add_route(::Rack::DELETE, path, *, &) + + # @see #call! + def call(env) = dup.call!(env) + + # This method is used to call the middleware chain for each request. + # + # @param env [Hash] the Rack env + # @return [Array] the Lennarb::Response + # + # @since 0.1.0 + def call!(env) + # TODO: - Remove this after. + # env.fetch('RACK_ENV', 'development') + env['RACK_ENV'] ||= 'development' + + middleware_pipeline = @middleware_manager.fetch_or_build_middleware_chain( + method(:process_request), [] + ) + + req = Request.new(env) + res = Response.new + + middleware_pipeline.call(req, res) + + res.finish + end + + private + + # This method is used to add a route to the tree of routes. + # + # @see MiddlewareManager#build_middleware_chain + # @see Route::Cache#add + # @see Route::Builder#call + # @see Route::NamespaceStack#current_prefix + # + # @since 0.1.0 + # + # @return [Lenna::Route] the route that was added + def add_route(http_method, path, *middlewares, &action) + full_path = @namespace_stack.current_prefix + path + + middleware_chain = @middleware_manager.build_middleware_chain( + action, + middlewares + ) + + @roter_builder.call(http_method, full_path, middleware_chain, @cache) + end + + # This method is used to process the request. + # + # @see Route::Matcher#match_and_execute_route + # + # @param req [Request] the request + # @param res [Response] the response + # @return [void] + def process_request(req, res) + @route_matcher ||= RouteMatcher.new(@root_node) + @route_matcher.match_and_execute_route(req, res) + end + end +end diff --git a/lib/lenna/router/builder.rb b/lib/lenna/router/builder.rb new file mode 100644 index 0000000..c2e3312 --- /dev/null +++ b/lib/lenna/router/builder.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Lenna + class Router + # The Route::Builder class is responsible for building the tree of routes. + # + # The tree of routes is built by adding routes to the tree. Each route is + # represented by a node in the tree and each node has a path and an + # endpoint. The path is the path of the route and the endpoint is then + # action to be executed when the route is matched. + # + # Those nodes are stored in a cache to avoid rebuilding the tree of routes + # for each request. + # + # The tree use `Trie` data structures to optimize the search for a route. + # The trie is a tree where each node is a character of the path. + # This way, the search for a route is O(n) where n is the length of the + # path. + # + class Builder + def initialize(root_node) = @root_node = root_node + + # @param method [String] the HTTP method + # @param path [String] the path to be matched + # @param action [Proc] the action to be executed + # @param cache [Cache] the cache to be used + # @return [void] + def call(method, path, action, cache) + path_key = cache.cache_key(method, path) + + return if cache.exist?(path_key) + + current_node = find_or_create_route_node(path) + setup_endpoint(current_node, method, action) + + cache.add(path_key, current_node) + end + + private + + # This method will create routes that are missing. + # @param path [String] the path to be matched + # @return [Node] the node that matches the path + def find_or_create_route_node(path) + current_node = @root_node + split_path(path).each do |part| + current_node = find_or_create_node(current_node, part) + end + current_node + end + + # @param current_node [Node] the current node + # @param part [String] the part of the path + # @return [Node] the node that matches the part of the path + # @note This method will create the nodes that are missing. + # This way, the tree of routes is built. + # @example Given the part ':id' and the tree bellow: + # root + # └── users + # └── :id + # The method will return the node :id. + # If the node :id does not exist, it will be created. + # The tree will be: + # root + # └── users + # └── :id + def find_or_create_node(current_node, part) + if part.start_with?(':') + # If it is a placeholder, then we just create or update + # the placeholder node with the placeholder name. + placeholder_name = part[1..].to_sym + current_node.children[:placeholder] ||= Node.new( + {}, + nil, + placeholder_name + ) + else + current_node.children[part] ||= Node.new + end + current_node.children[part.start_with?(':') ? :placeholder : part] + end + + # @param current_node [Node] the current node + # @param method [String] the HTTP method + # @param action [Proc] the action to be executed + def setup_endpoint(current_node, method, action) + current_node.endpoint ||= {} + current_node.endpoint[method] = action + end + + # @param path [String] the path to be split + # @return [Array] the splitted path + # @todo: Move this to a separate file and require it here. + # Maybe utils or something like that. + # Use Rack::Utils.split_path_info instead. + def split_path(path) = path.split('/').reject(&:empty?) + end + end +end diff --git a/lib/lenna/router/cache.rb b/lib/lenna/router/cache.rb new file mode 100644 index 0000000..8687ad4 --- /dev/null +++ b/lib/lenna/router/cache.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Lenna + class Router + # @api public + # @note This class is used to cache the routes. + class Cache + def initialize = @cache = {} + + # @api public + # @param [String] method + # @param [String] path + # @return [String] + # @note This method is used to generate a key for the cache. + def cache_key(method, path) = "#{method} #{path}" + + # @api public + # @param route_key [String] The key for the route. + # @param node [Lenna::Route::Node] The node for the route. + # @return [Lenna::Route::Node] + # @note This method is used to add a route to the cache. + def add(route_key, node) = @cache[route_key] = node + + # @api public + # @param route_key [String] The key for the route. + # @return [Lenna::Route::Node] + # @note This method is used to get a route from the cache. + def get(route_key) = @cache[route_key] + + # @api public + # @param route_key [String] The key for the route. + # @return [Boolean] + # @note This method is used to check if a route exists + # in the cache. + def exist?(route_key) = @cache.key?(route_key) + end + end +end diff --git a/lib/lenna/router/namespace_stack.rb b/lib/lenna/router/namespace_stack.rb new file mode 100644 index 0000000..b601f63 --- /dev/null +++ b/lib/lenna/router/namespace_stack.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Lenna + class Router + # This class is used to manage the namespaces. + # + # @api private + class NamespaceStack + # @return [Array] The stack of namespaces + # + # @api private + attr_reader :stack + + # @return [void] + # + # @api private + def initialize = @stack = [''] + + # This method is used to push a prefix to the stack. + # + # @param prefix [String] The prefix to be pushed + # @return [void] + # + # @example: + # + # stack = NamespaceStack.new + # stack.push('/users') + # stack.current_prefix # => '/users' + # + # @see #resolve_prefix + # + # @api private + def push(prefix) + @stack.push(resolve_prefix(prefix)) + end + + # @return [String] The popped prefix + # + # @api private + def pop + @stack.pop unless @stack.size == 1 + end + + # @return [String] The current prefix + # + # @api private + # + # @since 0.1.0 + def current_prefix = @stack.last + + # The to_s method is used to return the current prefix. + # + # @return [String] The current prefix + # + # @api private + def to_s = current_prefix + + private + + # The resolve_prefix method is used to resolve the prefix. + # + # @param prefix [String] The prefix to be resolved + # @return [String] The resolved prefix + # + # @see #current_prefix + # + # @since 0.1.0 + def resolve_prefix(prefix) + current_prefix + prefix + end + end + end +end diff --git a/lib/lenna/router/request.rb b/lib/lenna/router/request.rb new file mode 100644 index 0000000..7a6750c --- /dev/null +++ b/lib/lenna/router/request.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Lenna + class Router + # The Request class is responsible for managing the request. + # + # @attr headers [Hash] the request headers + # @attr body [Hash] the request body + # @attr params [Hash] the request params + class Request < ::Rack::Request + # This method is used to parse the body params. + # + # @return [Hash] the request params + # + # @public + def params = super.merge(parse_body_params) + + # This method rewinds the body + # + # @return [String] the request body content + # + # @api public + # + # @since 0.1.0 + def body_content + body.rewind + body.read + end + + # This method returns the headers in a normalized way. + # + # @return [Hash] the request headers + # + # @api public + # + # @example: + # Turn this: + # HTTP_FOO=bar Foo=bar + def headers + @headers ||= env.select { |k, _| k.start_with?('HTTP_') } + .transform_keys { |k| format_header_name(k) } + end + + # This method returns the request body in a normalized way. + # + # @return [Hash] the request body + # + # @api public + def json_body = @json_body ||= parse_body_params + + private + + def json_request? = media_type == 'application/json' + + def parse_json_body + @parsed_json_body ||= ::JSON.parse(body_content) if json_request? + rescue ::JSON::ParserError + {} + end + + def parse_body_params + case media_type + when 'application/json' + parse_json_body + when 'application/x-www-form-urlencoded', 'multipart/form-data' + post_params + else + {} + end + end + + def format_header_name(name) + name.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-') + end + end + end +end diff --git a/lib/lenna/router/response.rb b/lib/lenna/router/response.rb new file mode 100644 index 0000000..1bc8b61 --- /dev/null +++ b/lib/lenna/router/response.rb @@ -0,0 +1,357 @@ +# frozen_string_literal: true + +module Lenna + class Router + # The Response class is responsible for managing the response. + # + # @attr _headers [Hash] the response headers + # @attr _body [Array(String)] the response body + # @attr _status [Integer] the response status + # @attr params [Hash] the response params + class Response + public attr_reader :params + private attr_accessor :_headers, :_body, :_status + + # Initialize the Response + def initialize(headers = {}, status = 200, body = []) + self._headers = headers + self._status = status + self._body = body + @params = {} + end + + # @api public + # @return [Integer] the response status + def status = fetch_status + + # @api public + # @param status [Integer] the response status + # @return [void] + def put_status(value) = status!(value) + + # @api public + # @return [Array(String)] the body value + def body = fetch_body + + # @api public + # @param value [Array(String)] the body value + # @return [void] + def put_body(value) = body!(value) + + # @api public + # @param header [String] the header name + # @return [String] the header value + # @note This method will get the header value. + def header(key) = fetch_header(key) + + # @api public + # @return [Hash] the response headers + def headers = fetch_headers + + # @api public + # @param header [String] the header name + # @param value [String] the header value + # @return [void] + # @note This method will set the header value. + # If the header already exists, then the value will + # be appended to the header. + # + # @example + # put_header('X-Request-Id', '123') + # # => '123' + # + # put_header('X-Request-Id', '456') + # # => ['123', '456'] + # + # put_header('X-Request-Id', ['456', '789']) + # # => ['123', '456', '789'] + def put_header(key, value) = header!(key, value) + + # Add multiple headers. + # @param headers [Hash] the headers + # @return [void] + # @note This method will add the headers. + # The headers are a hash where the key is the + # header name and the value is the header value. + # + # @example + # headers = { + # 'Content-Type' => 'application/json', + # 'X-Request-Id' => '123' + # } + # + def put_headers(headers) + headers => ::Hash + + headers.each { |key, value| put_header(key, value) } + end + + # @api public + # @param header [String] the header name + # @return [void] + # @note This method will delete the header. + def remove_header(key) = delete_header(key) + + # @api public + # @param value [String] the key of the cookie + # @return [String] the cookie + # @note This method will get the cookie. + def cookie(value) + value => ::String + + fetch_header('Set-Cookie') + .then { |cookie| cookie.split('; ') } + .then { |cookie| cookie.find { |c| c.start_with?("#{value}=") } } + .then { |cookie| cookie.split('=').last } + end + + # @api public + # @param key [String] the key of the cookie + # @param value [String] the value of the cookie + # @return [void] + # @note This method will set the cookie. + def put_cookie(key, value) + key => ::String + value => ::String + + cookie = "#{key}=#{value}" + + header!('Set-Cookie', cookie) + end + + # @api public + # @return [Hash] the cookies + def cookies + fetch_header('Set-Cookie') + .then { |cookie| cookie.split('; ') } + .each_with_object({}) do |cookie, acc| + key, value = cookie.split('=') + + acc[key] = value + end + end + + # @api public + # @param location [String] the redirect location + # @param status [Integer] the redirect status + # @return [void] + # @note This method will set the redirect location and + # status and finish the response. + def redirect(location, status: 302) + location => ::String + + header!('Location', location) + status!(status) + + finish! + rescue ::NoMatchingPatternError + raise ::ArgumentError, 'location must be a string' + end + + # @api public + # @return [void] + # @note This method will finish the response. + def finish = finish! + + # @api public + # @return [String] the response content type + # @note This method will set + # the response content type. + def content_type = header('Content-Type') + + # @api public + # @param type [String] the response content type + # @param charset [Hash] the response charset + # @return [void] + def put_content_type(type, charset: nil) + type => ::String + + case charset + in ::String then header!('Content-Type', "#{type}; charset=#{charset}") + else header!('Content-Type', type) + end + rescue ::NoMatchingPatternError + raise ::ArgumentError, 'type must be a string' + end + + # @api public + # @param data [Hash, Array] the response data + # @return [void] + # @note This method will set the response data and + # finish the response. + def json(data:, status: 200) + data => ::Array | ::Hash + + status!(status) + header!('Content-Type', 'application/json') + body!(data.to_json) + + finish! + end + + # Set the response content type to text/html. + # @param str [String] the response body + # @return [void] + def html(str = nil, status: 200) + status!(status) + header!('Content-Type', 'text/html') + body!(str) + + finish! + end + + # @param template_nam [String] the template name + # @param path [String] the template path, default is 'views' + # @param locals [Hash] the template locals + # @return [void | Exception] + # @note This method will render the template. + # The template engine is determined by the + # file extension. + # + # @example + # render('index') + # # => Render the template `views/index.html.erb` + # + # render('users/index') + # # => Render the template `views/users/index.html.erb` + # + # render('index', path: 'app/views/users') + # # => Render the template `app/views/users/index.html.erb` + # + # render('index', locals: { name: 'John' }) + # # => Render the template `views/index.html.erb` with the local + # # variable `name` set to 'John' + def render(template_name, path: 'views', locals: {}, status: 200) + template_path = ::File.join(path, "#{template_name}.html.erb") + + # Check if the template exists + unless File.exist?(template_path) + msg = "Template not found: #{template_path} 🤷‍♂️." + + # Oops! The template doesn't exist or the path is wrong. + # + # The template exists? 🤔 + # If you want to render a template from a custom path, then you + # can pass the full path though the path: keyword argument instead + # of just the name. For example: + # render('index', path: 'app/views/users') + raise msg + end + + ::File + .read(template_path) + .then { |template| ::ERB.new(template).result_with_hash(locals) } + .then { |erb_template| html(erb_template, status:) } + end + + # Helper methods for the response. + # @api public + # @return [void] + # @note This method will finish the response with a 404 status. + def not_found + body!(['Not Found']) + status!(404) + finish! + end + + private + + # @api private + # @return [Integer] the response status + # @note This method will get the response status. + def fetch_status = _status + + # @api private + # @return [Integer] the response status + # @note This method will get the response status. + def status!(value) + value => ::Integer + + self._status = value + rescue ::NoMatchingPatternError + raise ::ArgumentError, 'status must be an integer' + end + + # @api private + # @return [Array(String)] the body value + def fetch_body = _body + + # @api private + # @param body [Array(String)] the body to be used + # @return [void] + # @note This method will set the body. + def body!(value) + body => ::String | ::Array + + case value + in ::String then self._body = [value] + in ::Array then self._body = value + end + rescue ::NoMatchingPatternError + raise ::ArgumentError, 'body must be a string or an array' + end + + # @api private + # @param header [String] the header name + # @return [String] the header value + # @note This method will get the header value. + def fetch_header(header) = _headers[header] + + # @api private + # @return [Hash] the response headers + def fetch_headers = _headers + + # @api private + # @param key [String] the header name + # @param value [String] the value to be used + # @return [void] + # @note This method will set the header value. + # If the header already exists, then the value will + # be appended to the header. + def header!(key, value) + key => ::String + value => ::String | ::Array + + header_value = fetch_header(key) + + case value + in ::String then _headers[key] = [*header_value, value].uniq.join(', ') + in ::Array then _headers[key] = [*header_value, *value].uniq.join(', ') + end + rescue ::NoMatchingPatternError + raise ::ArgumentError, 'header must be a string or an array' + end + + # @api private + # @param key [String] the header name + # @return [void] + # @note This method will delete the header. + def delete_header(key) = _headers.delete(key) + + # @api private + # @param value [String] the redirect location + # @return [void] + def location!(value) + value => ::String + + header!('Location', value) + end + + # @api private + # @param value [String] the content value + # @return [String] the size of the content + # @note This method will get the size of the content. + def content_length!(value) = header!('Content-Length', value) + + # @api private + # @return [void] + # @note This method will finish the response. + def finish! + put_content_type('text/html') unless header('Content-Type') + content_length!(body.join.size.to_s) unless header('Content-Length') + + [_status, _headers, _body] + end + end + end +end diff --git a/lib/lenna/router/route_matcher.rb b/lib/lenna/router/route_matcher.rb new file mode 100644 index 0000000..610b4bb --- /dev/null +++ b/lib/lenna/router/route_matcher.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Lenna + class Router + # @note This class is responsible for matching the request path + # to an endpoint and executing the endpoint action. + # + # @api private + # + # @since 0.1.0 + # + # This will match the request path to an endpoint and execute + # the endpoint action. + class RouteMatcher + # @param root_node [Lenna::Node] The root node + def initialize(root_node) = @root_node = root_node + + # This method will match the request path to an endpoint and execute + # the endpoint action. + # + # @param req [Lenna::Request] The request object + # @param res [Lenna::Response] The response object + # @return [Lenna::Response] The response object + # + # @see #split_path + # @see #find_endpoint + # @see Lenna::Response#not_found + def match_and_execute_route(req, res) + params = {} + path_parts = split_path(req.path_info) + endpoint = find_endpoint(@root_node, path_parts, params) + + if endpoint && (action = endpoint[req.request_method]) + req.params.merge!(params) + action.call(req, res) + else + res.not_found + end + end + + private + + # @todo: Refactor this method to a module. + def split_path(path) = path.split('/').reject(&:empty?) + + # @param node [Lenna::Node] The node to search + # @param parts [Array] The path parts + # @param params [Hash] The params hash + # @return [Lenna::Node] The node that matches the path + # + # @note This method is recursive. + # + # @since 0.1.0 + def find_endpoint(node, parts, params) + return node.endpoint if parts.empty? + + part = parts.shift + child_node = node.children[part] + + if child_node.nil? && (placeholder_node = node.children[:placeholder]) + params[placeholder_node.placeholder_name] = part + child_node = placeholder_node + end + + find_endpoint(child_node, parts, params) if child_node + end + end + end +end diff --git a/lib/lennarb.rb b/lib/lennarb.rb index 67e3b25..328ecc6 100644 --- a/lib/lennarb.rb +++ b/lib/lennarb.rb @@ -1,8 +1,4 @@ # frozen_string_literal: true +require_relative 'lenna/base' require_relative 'lennarb/version' - -module Lennarb - class Error < StandardError; end - # Your code goes here... -end diff --git a/lib/lennarb/version.rb b/lib/lennarb/version.rb index 2085b83..e9e5bdc 100644 --- a/lib/lennarb/version.rb +++ b/lib/lennarb/version.rb @@ -2,4 +2,5 @@ module Lennarb VERSION = '0.1.0' + public_constant :VERSION end diff --git a/test/lib/lenna/middleware/test_app.rb b/test/lib/lenna/middleware/test_app.rb new file mode 100644 index 0000000..9f5a90f --- /dev/null +++ b/test/lib/lenna/middleware/test_app.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Lenna + module Middleware + class TestApp < Minitest::Test + def test_use + manager = App.new + mdw = ->(_req, _res, next_middleware) { next_middleware.call } + manager.use(mdw) + + assert_equal manager.global_middlewares, [mdw] + end + + def test_fetch_or_build_middleware_chain + manager = App.new + action = ->(_req, _res) { 'Hello, World!' } + middleware_chain = manager.fetch_or_build_middleware_chain(action, []) + + assert_equal middleware_chain, action + end + + def test_middleware_execution_order_and_final_state + manager = App.new + env = ::Rack::MockRequest.env_for('/') + request = Lenna::Router::Request.new(env) + response = Lenna::Router::Response.new + + mdw1 = + lambda do |_req, res, next_middleware| + res.put_header('Custom-Header', 'From mdw1') + next_middleware.call + end + mdw2 = + lambda do |_req, res, next_middleware| + res.put_header('Custom-Header', 'From mdw2') + next_middleware.call + end + + manager.use([mdw1, mdw2]) + action = ->(_req, res) { [200, res.headers, ['OK']] } + chain = manager.build_middleware_chain(action, []) + status, headers, body = chain.call(request, response) + + assert_equal(200, status) + assert_equal({ 'Custom-Header' => 'From mdw1, From mdw2' }, headers) + assert_equal(['OK'], body) + end + end + end +end diff --git a/test/lib/lenna/router/test_builder.rb b/test/lib/lenna/router/test_builder.rb new file mode 100644 index 0000000..69b3b31 --- /dev/null +++ b/test/lib/lenna/router/test_builder.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Lenna + class Router + class TestBuilder < Minitest::Test + def test_build + cache = Cache.new + root_node = Node.new({}, nil) + builder = Builder.new(root_node) + + builder.call('GET', '/foo', -> { 'foo' }, cache) + + assert_pattern do + root_node.children['foo'].children => ::Hash + root_node.children['foo'].endpoint['GET'] => ::Proc + end + end + + def test_build_with_placeholder_and_endpoint + cache = Cache.new + root_node = Node.new({}, nil) + builder = Builder.new(root_node) + + builder.call('GET', '/foo/:id', -> { 'foo' }, cache) + + assert_pattern do + root_node.children['foo'] => Lenna::Node + root_node.children['foo'].children[:placeholder] => Lenna::Node + root_node.children['foo'].children[:placeholder].endpoint['GET'] => ::Proc + end + end + + def test_build_with_placeholder_and_endpoint_and_namespace + cache = Cache.new + root_node = Node.new({}, nil) + builder = Builder.new(root_node) + + builder.call('GET', 'api/v1/foo/:id', -> { 'foo' }, cache) + + assert_pattern do + root_node.children['api'] => Lenna::Node + root_node.children['api'].children['v1'] => Lenna::Node + root_node.children['api'].children['v1'].children['foo'] => Lenna::Node + root_node + .children['api'] + .children['v1'] + .children['foo'].children => ::Hash + root_node + .children['api'] + .children['v1'] + .children['foo'].children[:placeholder].endpoint['GET'] => ::Proc + end + end + end + end +end diff --git a/test/lib/lenna/router/test_cache.rb b/test/lib/lenna/router/test_cache.rb new file mode 100644 index 0000000..0b5baf5 --- /dev/null +++ b/test/lib/lenna/router/test_cache.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Lenna + class Router + class TestCache < Minitest::Test + def setup + @cache = Cache.new + end + + def test_cache + assert_nil @cache.get(:foo) + end + + def test_add_and_get + root_node = Node.new({}, nil) + + cache_key = @cache.cache_key('GET', '/') + @cache.add(cache_key, root_node) + + assert_equal @cache.get(cache_key), root_node + end + + def test_when_does_not_exist + refute @cache.exist?(:foo) + end + + def test_when_exist + root_node = Node.new({}, nil) + + cache_key = @cache.cache_key('GET', '/') + @cache.add(cache_key, root_node) + + assert @cache.exist?(cache_key) + end + end + end +end diff --git a/test/lib/lenna/router/test_namespace_stack.rb b/test/lib/lenna/router/test_namespace_stack.rb new file mode 100644 index 0000000..be0fb6f --- /dev/null +++ b/test/lib/lenna/router/test_namespace_stack.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Lenna + class Router + class TestNamespaceStack < Minitest::Test + def setup + @stack = NamespaceStack.new + end + + def test_push + @stack.push('/api') + @stack.push('/v1') + @stack.push('/lenna-router') + + assert_equal '/api/v1/lenna-router', @stack.to_s + end + + def test_pop + @stack.push('/api') + @stack.push('/v1') + @stack.push('/lenna-router') + + @stack.pop + + assert_equal '/api/v1', @stack.to_s + + @stack.pop + + assert_equal '/api', @stack.to_s + + @stack.pop + + assert_equal '', @stack.to_s + end + + def test_current_prefix + @stack.push('/api') + @stack.push('/v1') + @stack.push('/lenna-router') + + assert_equal '/api/v1/lenna-router', @stack.current_prefix + end + + def test_to_s_with_empty_stack + assert_equal '', @stack.to_s + end + + def test_to_s_with_one_element_stack + @stack.push('/api') + + assert_equal '/api', @stack.to_s + end + end + end +end diff --git a/test/lib/lenna/router/test_request.rb b/test/lib/lenna/router/test_request.rb new file mode 100644 index 0000000..83eb854 --- /dev/null +++ b/test/lib/lenna/router/test_request.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Lenna + class Router + class TestRequest < Minitest::Test + def test_with_query_string + env = ::Rack::MockRequest.env_for('/?foo=bar') + + assert_equal({ 'foo' => 'bar' }, Request.new(env).params) + end + + def test_with_headers + env = ::Rack::MockRequest.env_for('/', 'HTTP_FOO' => 'bar') + + assert_equal({ 'Foo' => 'bar' }, Request.new(env).headers) + end + + def test_json_body + env = ::Rack::MockRequest.env_for( + '/', + 'CONTENT_TYPE' => 'application/json', + :input => '{"title":"foo","description":"bar"}' + ) + request = Request.new(env) + + assert_equal 'foo', request.json_body['title'] + assert_equal 'bar', request.json_body['description'] + end + + def test_combined_params + env = ::Rack::MockRequest.env_for( + '/?query_param=baz', + 'CONTENT_TYPE' => 'application/json', + :method => 'POST', + :input => '{"title":"foo","description":"bar"}' + ) + request = Request.new(env) + + assert_equal 'baz', request.params['query_param'] + assert_equal 'foo', request.params['title'] + assert_equal 'bar', request.params['description'] + end + + def test_invalid_json_body + env = ::Rack::MockRequest.env_for( + '/', + 'CONTENT_TYPE' => 'application/json', + :input => 'not a json' + ) + request = Request.new(env) + + assert_empty request.json_body + end + end + end +end diff --git a/test/lib/lenna/router/test_response.rb b/test/lib/lenna/router/test_response.rb new file mode 100644 index 0000000..35ad0dc --- /dev/null +++ b/test/lib/lenna/router/test_response.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +require 'test_helper' + +require 'erb' +require 'minitest/mock' + +module Lenna + class Router + class TestResponse < Minitest::Test + def setup + @res = Response.new + end + + def test_with_invlaid_status + assert_raises(::ArgumentError) { @res.put_status('404') } + end + + def test_status + assert_equal 200, @res.status + end + + def test_put_status + @res.put_status(404) + + assert_equal 404, @res.status + end + + def test_with_invlaid_body + assert_raises(::ArgumentError) { @res.put_body(123) } + end + + def test_body + assert_empty @res.body + end + + def test_put_body + @res.put_body('Hello') + + assert_equal ['Hello'], @res.body + + @res.put_body(['World']) + + assert_equal ['World'], @res.body + end + + def test_with_invlaid_header + assert_raises(::ArgumentError) { @res.put_header(123) } + end + + def test_header_with_key + @res.put_header('Content-Type', 'text/plain') + + assert_equal 'text/plain', @res.header('Content-Type') + end + + def test_headers + @res.put_header('Content-Type', 'text/plain') + + assert_equal({ 'Content-Type' => 'text/plain' }, @res.headers) + end + + def test_put_header + @res.put_header('Content-Type', ['text/plain', 'text/html']) + + assert_equal( + { 'Content-Type' => 'text/plain, text/html' }, + @res.headers + ) + end + + def test_put_headers + @res.put_headers('Content-Type' => 'text/plain') + + assert_equal({ 'Content-Type' => 'text/plain' }, @res.headers) + end + + def test_delete_header + @res.put_header('Content-Type', 'text/plain') + + @res.remove_header('Content-Type') + + assert_empty @res.headers + end + + def test_with_invlaid_cookieh + assert_raises(::ArgumentError) { @res.put_cookie(123) } + end + + def test_cookie_with_key + @res.put_cookie('foo', 'bar') + + assert_equal 'bar', @res.cookie('foo') + end + + def test_cookies + @res.put_cookie('foo', 'bar') + + assert_equal({ 'foo' => 'bar' }, @res.cookies) + end + + def test_put_cookie + @res.put_cookie('foo', 'bar') + + assert_equal({ 'foo' => 'bar' }, @res.cookies) + end + + # Content-Type + + def test_content_type + @res.put_content_type('text/plain') + + assert_equal 'text/plain', @res.content_type + end + + def test_content_type_with_charset + @res.put_content_type('text/plain', charset: 'utf-8') + + assert_equal 'text/plain; charset=utf-8', @res.content_type + end + + # Location + + # Redirection + + def test_redirect + @res.redirect('/foo') + + assert_equal 302, @res.status + assert_equal '/foo', @res.header('Location') + end + + def test_redirect_with_status + @res.redirect('/foo', status: 301) + + assert_equal 301, @res.status + assert_equal '/foo', @res.header('Location') + end + + # Formats + + def test_html + @res.html('Hello') + + assert_equal ['Hello'], @res.body + assert_equal 'text/html', @res.content_type + end + + def test_json + @res.json(data: { hello: 'World' }) + + assert_equal ['{"hello":"World"}'], @res.body + assert_equal 'application/json', @res.content_type + end + + def test_render_raises_exception_when_template_not_found + template_name = 'nonexistent_template' + mock_path = 'fake/path' + + ::File.stub :exist?, false do + assert_raises(::RuntimeError) do + @res.render(template_name, path: mock_path) + end + end + end + + def test_render_successfully_renders_template + template_name = 'existent_template' + mock_path = 'fake/path' + fake_file_content = '

Hello <%= name %>

' + expected_html = ['

Hello World

'] + expected_headers = { + 'Content-Type' => 'text/html', + 'Content-Length' => '20' + } + expected_status = 200 + + ::File.stub :exist?, true do + ::File.stub :read, fake_file_content do + ::ERB.stub :new, ::ERB.new(fake_file_content) do + assert_equal( + [expected_status, expected_headers, expected_html], + @res.render( + template_name, + path: mock_path, + locals: { name: 'World' } + ) + ) + end + end + end + end + end + end +end diff --git a/test/lib/lenna/router/test_route_matcher.rb b/test/lib/lenna/router/test_route_matcher.rb new file mode 100644 index 0000000..2fd611e --- /dev/null +++ b/test/lib/lenna/router/test_route_matcher.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Lenna + class Router + class TestRouteMatcher < Minitest::Test + def test_match_and_execute_route + root_node = Node.new({}, nil) + builder = Builder.new(root_node) + cache = Cache.new + + builder.call( + 'GET', + '/foo', + ->(_req, res) { + res.html('Hello, World!') + }, + cache + ) + + matcher = RouteMatcher.new(root_node) + env = Rack::MockRequest.env_for('/foo') + + req = Request.new(env) + res = Response.new + + matcher.match_and_execute_route(req, res) + + assert_equal 200, res.status + assert_equal 'text/html', res.headers['Content-Type'] + assert_equal 'Hello, World!', res.body.join + end + + def test_not_found + root_node = Node.new({}, nil) + builder = Builder.new(root_node) + cache = Cache.new + + builder.call( + 'GET', + '/foo', + ->(_req, res) { + res.html('Hello, World!') + }, + cache + ) + + matcher = RouteMatcher.new(root_node) + env = Rack::MockRequest.env_for('/bar') + + req = Request.new(env) + res = Response.new + + matcher.match_and_execute_route(req, res) + + assert_equal 404, res.status + assert_equal 'text/html', res.headers['Content-Type'] + assert_equal 'Not Found', res.body.join + end + end + end +end diff --git a/test/lib/lenna/test_router.rb b/test/lib/lenna/test_router.rb new file mode 100644 index 0000000..33961a2 --- /dev/null +++ b/test/lib/lenna/test_router.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Lenna + class Router + class TestRouter < Minitest::Test + def test_initialize + router = Router.new + + assert_kind_of Node, router.root_node + assert_kind_of Cache, router.cache + assert_kind_of Builder, router.roter_builder + assert_kind_of NamespaceStack, router.namespace_stack + assert_kind_of Middleware::App, router.middleware_manager + end + + def test_add_namespace + router = Router.new + router.namespace('/foo') do |route| + route.get('/bar') { |_req, res| res.html('Hello, World!') } + end + + env = ::Rack::MockRequest.env_for('/foo/bar') + + status, headers, body = router.call(env) + + assert_equal 200, status + assert_equal 'text/html', headers['Content-Type'] + assert_equal 'Hello, World!', body.join + end + + def test_call + router = Router.new + router.get('/foo') { |_req, res| res.html('Hello, World!') } + + env = ::Rack::MockRequest.env_for('/foo') + + status, headers, body = router.call(env) + + assert_equal 200, status + assert_equal 'text/html', headers['Content-Type'] + assert_equal 'Hello, World!', body.join + end + + def test_call_with_middleware + router = Router.new + + timex = ::Time.now.to_s + + simple_middleware = + -> (_req, res, next_middleware) { + res.headers['X-Time'] = timex + next_middleware.call + } + + router.use(simple_middleware) + + router.get('/foo') { |_req, res| res.html('Hello, World!') } + + env = ::Rack::MockRequest.env_for('/foo') + + status, headers, body = router.call(env) + + assert_equal 200, status + assert_equal timex, headers['X-Time'] + assert_equal 'Hello, World!', body.join + end + + def test_call_with_middleware_in_route + router = Router.new + + timex = ::Time.now.to_s + + simple_middleware = + -> (_req, res, next_middleware) { + res.headers['X-Time'] = timex + next_middleware.call + } + + router.get('/foo', simple_middleware) do |_req, res| + res.html('Hello, World!') + end + + env = ::Rack::MockRequest.env_for('/foo') + + status, headers, body = router.call(env) + + assert_equal 200, status + assert_equal timex, headers['X-Time'] + assert_equal 'Hello, World!', body.join + end + end + end +end