diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ccbc86..c47223a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,61 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.6] - 2023-29-11 + +### Changed + +- Add default middlewares to `Lennarb::Router` class. Now, the `Lennarb::Router` class has the following middlewares by default: + - `Lennarb::Middleware::Default::Logging` + - `Lennarb::Middleware::Default::ErrorHandling` + +### Changed + +- Replace `assign_status` to `=` on Response + +```rb +response.status = 200 +``` + +- Rename `Lenna::Base` to `Lenna::Application` and accept a block to build the routes. Ex. + +```rb +Lenna::Application.new do |app| + app.get '/hello' do |req, res| + res.status = 200 + res['Content-Type'] = 'text/plain' + res.body = 'Hello World' + end + app.post '/hello' do |req, res| + res.status = 200 + res['Content-Type'] = 'text/plain' + res.body = 'Hello World' + end +end +``` + +- The Middleware app now implements [Singleton](https://ruby-doc.org/stdlib-2.5.1/libdoc/singleton/rdoc/Singleton.html) pattern to manager state. + +### Added + +- Add alias to `assign_header` to `[]=` on Response. Now, you can use: + +```rb +response['Content-Type'] = 'application/json' +``` + +- Add alias to `assign_body` to `:body=` on Response. Now, you can use: + +```rb +response.body = 'Hello World' +``` + +- Add alias to `assign_params` to `:params=` on Request. Now, you can use: + +```rb +request.params = { name: 'John' } +``` + ## [0.1.5] - 2023-25-11 ### Added diff --git a/Gemfile b/Gemfile index 9312618..52e6d12 100644 --- a/Gemfile +++ b/Gemfile @@ -39,4 +39,9 @@ group :development, :test do # Minitest provides a complete suite of testing facilities supporting TDD, # BDD, mocking, and benchmarking. gem 'minitest', '~> 5.20' + # [https://rubygems.org/gems/rack-test] + # Rack::Test is a small, simple testing API for Rack apps. It can be used on + # its own or as a reusable starting point for Web frameworks and testing + # libraries to build on. + gem 'rack-test', '~> 2.1' end diff --git a/Gemfile.lock b/Gemfile.lock index 749c6d1..c778734 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - lennarb (0.1.5) + lennarb (0.1.6) colorize (~> 1.1) puma (~> 6.4) rack (~> 3.0, >= 3.0.8) @@ -24,6 +24,8 @@ GEM nio4r (~> 2.0) racc (1.7.3) rack (3.0.8) + rack-test (2.1.0) + rack (>= 1.3) rainbow (3.1.1) rake (13.1.0) regexp_parser (2.8.2) @@ -55,6 +57,7 @@ DEPENDENCIES minitest (~> 5.20) puma (~> 6.4) rack (~> 3.0, >= 3.0.8) + rack-test (~> 2.1) rake (~> 13.0, >= 13.0.6) rubocop (~> 1.57, >= 1.57.2) rubocop-minitest (~> 0.33.0) diff --git a/README.md b/README.md index 520636e..c405e1e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Lennarb is a experimental lightweight, fast, and modular web framework for Ruby based on Rack. -image +[![Testing](https://github.com/aristotelesbr/lennarb/actions/workflows/main.yml/badge.svg)](https://github.com/aristotelesbr/lennarb/blob/main/.github/workflows/main.yml) ## Table of Contents @@ -27,37 +27,65 @@ Lennarb is designed to be simple and easy to use, while still providing the powe Add this line to your application's Gemfile: -```rb +~~~ rb gem 'lennarb' -``` +~~~ And then execute: -```bash +~~~ bash $ bundle install -``` +~~~ Or install it yourself as: -```bash +~~~ bash $ gem install lennarb -``` +~~~ ## Usage After installing, you can begin using Lennarb to build your modular web applications with Ruby. Here is an example of how to get started: -```rb -app = Lenna::Base.new +~~~ rb +require 'lennarb' -app.get('/hello/:name') do |req, res| +Lenna::Application do |app| + # Simple routes + app.get('/hello') do |_req, res| + res.html('Hello World!') + end + app.get('/hello/:name') do |req, res| name = req.params[:name] - res.html("Hello #{name}!") + res.json({message: "Hello #{name}!"}) + end + + # With namespaces + app.namespace("/api/v1") do |r| + r.get('/hello/:name') do |_req, res| + name = req.params[:name] + + res.html("Hello #{name} from API V1") + end + end + + # With middlewares + app.get('/hello', TimeMiddleware) do |_req, res| + res.html('Hello World!') + end + + # With multiple middlewares + app.get('/hello', [TimeMiddleware, LoggerMiddleware]) do |_req, res| + res.html('Hello World!') + end + + # With global middlewares + app.use(Lenna::Middleware::Logger) end -app.listen(8000) -``` +run app +~~~ This example creates a new Lennarb application, defines a route for the `/hello/:name` path, and then starts the application on port 8000. When a request is made to the `/hello/:name` path, the application will respond with `Hello !`, where `` is the value of the `:name` parameter in the request path. @@ -65,11 +93,11 @@ This example creates a new Lennarb application, defines a route for the `/hello/ Lennarb uses a simple routing system that allows you to define routes for your application. Routes are defined using the `get`, `post`, `put`, `patch`, `delete`. These methods take three arguments: the path to match, an Array of the middlewares that can be apply on the current route and a block to execute when a request is made to the path. The block is passed two arguments: the [`request`](https://github.com/aristotelesbr/lennarb/blob/main/lib/lenna/router/request.rb) object and the [`response`](https://github.com/aristotelesbr/lennarb/blob/main/lib/lenna/router/response.rb) object. The request object contains information about the request, such as the request method, headers, and body. The response object contains methods for setting the response status code, headers, and body. Ex. -```rb +~~~ rb app.get('/hello') do |_req, res| - res.html('Hello World!') + res.html('Hello World!') end -``` +~~~ The example above defines a route for the `/hello` path. When a request is made to the `/hello` path, the application will respond with `Hello World!`. @@ -77,13 +105,13 @@ The example above defines a route for the `/hello` path. When a request is made Lennarb allows you to define parameters in your routes. Parameters are defined using the `:` character, followed by the name of the parameter. Parameters are passed to the route block as a hash in the request object's `params` property. -```rb +~~~ rb app.get('/hello/:name') do |req, res| - name = req.params[:name] + name = req.params[:name] - res.html("Hello #{name}!") + res.html("Hello #{name}!") end -``` +~~~ The example above defines a route for the `/hello/:name` path. When a request is made to the `/hello/:name` path, the application will respond with `Hello !`, where `` is the value of the `:name` parameter in the request path. @@ -91,13 +119,13 @@ The example above defines a route for the `/hello/:name` path. When a request is Lennarb allows you to define namespaces in your routes. Namespaces are defined using the `namespace` method on the application object. Namespaces are passed to the route block as a hash in the request object's `params` property. -```rb +~~~ rb app.namespace('/api') do |router| - roter.get('/hello') do |_req, res| - res.html('Hello World!') - end + roter.get('/hello') do |_req, res| + res.html('Hello World!') + end end -``` +~~~ The example above defines a namespace for the `/api` path. When a request is made to the `/api/hello` path, the application will respond with `Hello World!`. @@ -105,107 +133,127 @@ The example above defines a namespace for the `/api` path. When a request is mad The Lennarb application object has a `use` method that allows you to add middleware to your application. Middleware is defined using the `use` method on the application object. Ex. -```rb +~~~ rb app.get('/hello') do |_req, res| - res.html('Hello World!') + res.html('Hello World!') end app.use(Lenna::Middleware::Logger) -``` +~~~ You can also define middleware for specific route. -```rb +~~~ rb app.get('/hello', TimeMiddleware) do |_req, res| - res.html('Hello World!') + res.html('Hello World!') end -``` +~~~ You can create your own middleware by creating a class that implements the `call` method. This methods receive three -```rb +~~~ rb class TimeMiddleware - def call(req, res, next_middleware) - puts Time.now + def call(req, res, next_middleware) + puts Time.now - req.headers['X-Time'] = Time.now + req.headers['X-Time'] = Time.now - next_middleware.call - end + next_middleware.call + end end -``` +~~~ Or using a lambda functions. -```rb +~~~ rb TimeMiddleware = ->(req, res, next_middleware) do - puts Time.now - - req.headers['X-Time'] = Time.now + puts Time.now - next_middleware.call + req.headers['X-Time'] = Time.now + + next_middleware.call end -``` +~~~ So you can use it like this: -```rb +~~~ rb app.get('/hello', TimeMiddleware) do |_req, res| - res.html('Hello World!') + res.html('Hello World!') end -``` +~~~ And you can use multiple middlewares on the same route. -```rb +~~~ rb app.get('/hello', [TimeMiddleware, LoggerMiddleware]) do |_req, res| - res.html('Hello World!') + res.html('Hello World!') end -``` +~~~ + +#### Default middlewares + +Lennarb use two default middlewares: `Logging` and `ErrorHandling`. + +- `Lenna::Middleware::Logging` - Colorized http request status. + +~~~ bash +❯ bundle exec puma ./config.ru +Puma starting in single mode... +* Puma version: 6.4.0 (ruby 3.2.2-p53) ("The Eagle of Durango") +* Min threads: 0 +* Max threads: 5 +* Environment: development +* PID: 88312 +* Listening on http://0.0.0.0:9292 +Use Ctrl-C to stop +[2023-11-29 14:06:08 -0300] "GET /" 200 0.19ms +[2023-11-29 14:06:11 -0300] "GET /" 200 0.10ms +[2023-11-29 14:06:11 -0300] "GET /" 200 0.20ms +~~~ + +- `Lenna::Middleware::ErrorHandling` - Error handling middleware in development mode. + +

+ +

### Render HTML templates Lennarb allows you to render HTML templates using the `render` method on the response object. The `render` method takes two arguments: the name of the template file, and a hash of variables to pass to the template. -```rb +~~~ rb app.get('/hello') do |_req, res| - res.render('hello', locals: { name: 'World' }) + res.render('hello', locals: { name: 'World' }) end -``` +~~~ The example above renders the `hello.html.erb` template, passing the `name` variable to the template. By default, Lennarb looks for templates in the `views` directory in root path. You can change this specifying the path for `views` in render method. Ex. -```rb +~~~ rb app.get('/hello') do |_req, res| - res.render('hello', path: 'app/web/templates', locals: { name: 'World' }) + res.render('hello', path: 'app/web/templates', locals: { name: 'World' }) end -``` +~~~ ### Render JSON Lennarb allows you to render JSON using the `json` method on the response object. The `json` method takes one argument: the object to render as JSON. -```rb +~~~ rb app.get('/hello') do |_req, res| - res.json(data: { message: 'Hello World!' }) + res.json(message: 'Hello World!') end -``` +~~~ The example above renders the `{ message: 'Hello World!' }` object as JSON. - - ### TODO - [ ] Add support for mime types -- [ ] Add support for sessions -- [ ] Add support for websockets - [ ] Add support for streaming -- [ ] Add support for CORS - [ ] Add support for CSRF -- [ ] Add support for caching -- [ ] Add support for gzip compression - [ ] Add support for SSL - [ ] Add support for HTTP/2 - [ ] Add support for HTTP/3 @@ -214,15 +262,15 @@ The example above renders the `{ message: 'Hello World!' }` object as JSON. To set up the development environment after cloning the repo, run: -```bash +~~~ bash $ bin/setup -``` +~~~ To run the tests, run: -```bash +~~~ bash $ rake test -``` +~~~ ## Contributing diff --git a/lennarb.gemspec b/lennarb.gemspec index 537134b..17128ea 100644 --- a/lennarb.gemspec +++ b/lennarb.gemspec @@ -33,6 +33,7 @@ Gem::Specification.new do |spec| # Uncomment to register a new dependency of your gem spec.add_development_dependency 'minitest', '~> 5.20' + spec.add_development_dependency 'rack-test', '~> 2.1' 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/application.rb similarity index 60% rename from lib/lenna/base.rb rename to lib/lenna/application.rb index 2990340..da680ae 100644 --- a/lib/lenna/base.rb +++ b/lib/lenna/application.rb @@ -10,13 +10,45 @@ module Lenna # The base class is used to start the server. - class Base < Router + # + class Application < Router + # Initialize the base class + # + # @param block [Proc] The block to be executed + # + # @return [void] + # + # @example: + # Lenna::Base.new do |app| + # app.get('/hello') do |req, res| + # res.html('Hello World!') + # end + # app.get('/hello/:name') do |req, res| + # name = req.params[:name] + # res.html("Hello #{name}!") + # end + # end + # + # @api public + # + def initialize + super + + yield self if block_given? + end + # The default port is 3000 + # DEFAULT_PORT = 3000 private_constant :DEFAULT_PORT + + # The default host is localhost + # DEFAULT_HOST = 'localhost' private_constant :DEFAULT_HOST - # This method will start the server. + # This method will start the puma server and listen on the specified port + # and host. The default port is 3000 and the default host is localhost. + # Use only in development. # # @param port [Integer] The port to listen on (default: 3000) # @param host [String] The host to listen on (default: ' @@ -27,7 +59,7 @@ class Base < Router # app.listen(8080) # # => ⚡ Listening on localhost:8080 # - # or specify the host and port + # # or specify the host and port # # app = Lenna::Base.new # app.listen(8000, host: '0.0.0.0') @@ -38,6 +70,7 @@ class Base < Router # @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}. \n Use Ctrl-C to stop the server." diff --git a/lib/lenna/middleware/app.rb b/lib/lenna/middleware/app.rb index ea99447..0e61fc0 100644 --- a/lib/lenna/middleware/app.rb +++ b/lib/lenna/middleware/app.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true +require 'singleton' + 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 # @@ -14,15 +13,19 @@ module Middleware # 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 + include Singleton - # @return [Hash] the middleware chains cache - attr_reader :middleware_chains_cache + # @return [Mutex] the mutex used to synchronize the access to the global + # middlewares. + # + attr_accessor :global_middlewares + # @return [Mutex] the mutex used to synchronize the access to the + # middleware chains cache. + # + attr_accessor :middleware_chains_cache # This method will initialize the global middlewares and the # middleware chains cache. @@ -30,8 +33,22 @@ class App # @return [void] # # @since 0.1.0 + # def initialize - @mutex = ::Mutex.new + @global_middlewares = [] + @middleware_chains_cache = {} + end + + # This method is used to reset the global middlewares and the middleware + # chains cache. + # + # @return [void] + # + # @see #initialize + # + # @api public + # + def reset! @global_middlewares = [] @middleware_chains_cache = {} end @@ -41,11 +58,10 @@ def initialize # @return [void] # # @since 0.1.0 + # def use(middlewares) - @mutex.synchronize do - @global_middlewares += Array(middlewares) - @middleware_chains_cache = {} - end + @global_middlewares += Array(middlewares) + @middleware_chains_cache = {} end # This method is used to fetch or build the middleware chain for the given @@ -58,13 +74,22 @@ def use(middlewares) # @see #build_middleware_chain # # @since 0.1.0 - def fetch_or_build_middleware_chain(action, route_middlewares) - middleware_signature = action.object_id.to_s + # + def fetch_or_build_middleware_chain( + action, + route_middlewares, + http_method: nil, + path: nil + ) + signature = + if http_method && path + [http_method, path, route_middlewares].hash.to_s + else + ['global', route_middlewares].hash.to_s + end - @mutex.synchronize do - @middleware_chains_cache[middleware_signature] ||= - build_middleware_chain(action, route_middlewares) - end + @middleware_chains_cache[signature] ||= + build_middleware_chain(action, route_middlewares) end # This method is used to build the middleware chain for the given action @@ -83,8 +108,9 @@ def fetch_or_build_middleware_chain(action, route_middlewares) # 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 = (@global_middlewares + Array(middlewares)).uniq all_middlewares.reverse.reduce(action) do |next_middleware, middleware| ->(req, res) { diff --git a/lib/lenna/middleware/default/error_handler.rb b/lib/lenna/middleware/default/error_handler.rb index 934e99b..b39587b 100644 --- a/lib/lenna/middleware/default/error_handler.rb +++ b/lib/lenna/middleware/default/error_handler.rb @@ -6,6 +6,7 @@ module Lenna module Middleware module Default # This middleware will handle errors. + # module ErrorHandler extend self @@ -24,7 +25,7 @@ def call(req, res, next_middleware) env = req.env log_error(env, e) - render_error_page(e, env, req, res) + render_error_page(e, req, res) end private @@ -37,10 +38,11 @@ def call(req, res, next_middleware) # @return [void] # # @api private - def render_error_page(error, env, req, res) + # + def render_error_page(error, req, res) case req.headers['Content-Type'] - in 'application/json' then render_json(error, env, res) - else render_html(error, env, res) + in 'application/json' then render_json(error, res) + else render_html(error, res) end end @@ -52,15 +54,16 @@ def render_error_page(error, env, req, res) # @return [void] # # @api private - def render_json(error, env, res) - case env['RACK_ENV'] + # + def render_json(error, res) + case ENV.fetch('RACK_ENV', 'development') in 'development' | 'test' then res.json( data: { error: error.message, status: 500 } ) - else res.json(error: 'Internal Server Error', status: 500) + else res.json(data: { error: 'Internal Server Error', status: 500 }) end end @@ -72,8 +75,9 @@ def render_json(error, env, res) # @return [void] # # @api private - def render_html(error, env, res) - res.html(error_page(error, env), status: 500) + # + def render_html(error, res) + res.html(error_page(error), status: 500) end # This method will log the error. @@ -83,6 +87,7 @@ def render_html(error, env, res) # @return [void] # # @api private + # def log_error(env, error) env['rack.errors'].puts error.message env['rack.errors'].puts error.backtrace.join("\n") @@ -90,7 +95,8 @@ def log_error(env, error) end # This method will render the error page. - def error_page(error, env) + # + def error_page(error) style = <<-STYLE