diff --git a/changelog.md b/changelog.md index 384bec8..c782c0f 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.1] - 2024-05-17 + +### Added + +- Add `Lennarb::Plugin` module to manage the plugins in the project. Now, the `Lennarb` class is the main class of the project. +- Add `Lennarb::Plugin::Base` class to be the base class of the plugins in the project. +- Add simple guide to use `Lenn` plugins. See [guides/plugins/readme.md](guides/plugins/readme.md) for more details. + ## [0.4.4] - 2024-04-02 ### Added diff --git a/guides/plugin/readme.md b/guides/plugin/readme.md new file mode 100644 index 0000000..2256a08 --- /dev/null +++ b/guides/plugin/readme.md @@ -0,0 +1,69 @@ +# Plugin System + +## Overview + +The Lennarb Plugin System allows you to extend the functionality of your Lennarb application by registering and loading plugins. This system is designed to be simple and flexible, enabling you to add custom behaviors and features to your application effortlessly. + +## Implementation Details + +The plugin system is implemented using a module, `Lennarb::Plugin`, which centralizes the registration and loading of plugins. + +### Module: `Plugins` + +```ruby +class Lennarb + module Plugins + @plugins = {} + + def self.register_plugin(name, mod) + @plugins[name] = mod + end + + def self.load_plugin(name) + @plugins[name] || raise("Plugin #{name} did not register itself correctly") + end + + def self.plugins + @plugins + end + end +end +``` + +## Usage + +### Registering a Plugin + +To register a plugin, define a module with the desired functionality and register it with the Plugin module. + +```ruby +module MyCustomPlugin + def custom_method + "Custom Method Executed" + end +end + +Plugins.register(:my_custom_plugin, MyCustomPlugin) +``` + +### Load and Use a Plugin + +To load and use a plugin in your Lennarb application, call the plugin method in your application class. + +```ruby +class MyApp < Lennarb::Application::Base + plugin :my_custom_plugin + + get '/custom' do |_req, res| + res.status = 200 + res.html(custom_method) + end +end +``` + +In this example, the custom_method defined in `MyCustomPlugin` is available in the routes of `MyApp`. + +## Conclusion + +The Lennarb Plugin System provides a simple and flexible way to extend your application's functionality. By registering and loading plugins, you can easily add custom behaviors and features to your Lennarb application. + diff --git a/lib/lennarb.rb b/lib/lennarb.rb index 1124668..51595f5 100644 --- a/lib/lennarb.rb +++ b/lib/lennarb.rb @@ -3,8 +3,6 @@ # Released under the MIT License. # Copyright, 2023-2024, by Aristóteles Coutinho. -ENV['RACK_ENV'] ||= 'development' - # Core extensions # require 'pathname' @@ -13,6 +11,7 @@ # Base class for Lennarb # require_relative 'lennarb/application/base' +require_relative 'lennarb/plugin' require_relative 'lennarb/request' require_relative 'lennarb/response' require_relative 'lennarb/route_node' @@ -91,6 +90,17 @@ def patch(path, &block) = add_route(path, :PATCH, block) def delete(path, &block) = add_route(path, :DELETE, block) def options(path, &block) = add_route(path, :OPTIONS, block) + # Register a plugin + # + # @parameter [String | Symbol] plugin_name + # + # @returns [void] + # + def plugin(plugin_name) + plugin_module = Lennarb::Plugin.load(plugin_name) + extend plugin_module + end + private # Add a route diff --git a/lib/lennarb/application/base.rb b/lib/lennarb/application/base.rb index 6671826..009a78c 100644 --- a/lib/lennarb/application/base.rb +++ b/lib/lennarb/application/base.rb @@ -32,107 +32,97 @@ class Base # # @returns [Base] # - def self.inherited(subclass) - subclass.instance_variable_set(:@_route, Lennarb.new) - subclass.instance_variable_set(:@_middlewares, []) - subclass.instance_variable_set(:@_global_after_hooks, []) - subclass.instance_variable_set(:@_global_before_hooks, []) - subclass.instance_variable_set(:@_after_hooks, Lennarb::RouteNode.new) - subclass.instance_variable_set(:@_before_hooks, Lennarb::RouteNode.new) - end - - def self.get(...) = @_route.get(...) - def self.put(...) = @_route.put(...) - def self.post(...) = @_route.post(...) - def self.head(...) = @_route.head(...) - def self.match(...) = @_route.match(...) - def self.patch(...) = @_route.patch(...) - def self.delete(...) = @_route.delete(...) - def self.options(...) = @_route.options(...) - # @returns [Array] middlewares - def self.middlewares = @_middlewares - - # Use a middleware - # - # @parameter [Object] middleware - # @parameter [Array] args - # @parameter [Block] block - # - # @returns [Array] middlewares - # - def self.use(middleware, *args, &block) - @_middlewares << [middleware, args, block] - end + class << self + def inherited(subclass) + subclass.instance_variable_set(:@_route, Lennarb.new) + subclass.instance_variable_set(:@_middlewares, []) + subclass.instance_variable_set(:@_global_after_hooks, []) + subclass.instance_variable_set(:@_global_before_hooks, []) + subclass.instance_variable_set(:@_after_hooks, Lennarb::RouteNode.new) + subclass.instance_variable_set(:@_before_hooks, Lennarb::RouteNode.new) + end - # Add a before hook - # - # @parameter [String] path - # @parameter [Block] block - # - def self.before(path = nil, &block) - if path - parts = path.split('/').reject(&:empty?) - @_before_hooks.add_route(parts, :before, block) - else - @_global_before_hooks << block + def get(path, &) = @_route.get(path, &) + def put(...) = @_route.put(...) + def post(...) = @_route.post(...) + def head(...) = @_route.head(...) + def match(...) = @_route.match(...) + def patch(...) = @_route.patch(...) + def delete(...) = @_route.delete(...) + def options(...) = @_route.options(...) + + # @returns [Array] middlewares + def middlewares = @_middlewares + + # Use a middleware + # + # @parameter [Object] middleware + # @parameter [Array] args + # @parameter [Block] block + # + # @returns [Array] middlewares + # + def use(middleware, *args, &block) + @_middlewares << [middleware, args, block] end - end - # Add a after hook - # - # @parameter [String] path - # @parameter [Block] block - # - def self.after(path = nil, &block) - if path - parts = path.split('/').reject(&:empty?) - @_after_hooks.add_route(parts, :after, block) - else - @_global_after_hooks << block + # Add a before hook + # + # @parameter [String] path + # @parameter [Block] block + # + def before(path = nil, &block) + if path + parts = path.split('/').reject(&:empty?) + @_before_hooks.add_route(parts, :before, block) + else + @_global_before_hooks << block + end end - end - # Run the Application - # - # @returns [Base] self - # - # When you use this method, the application will be frozen. And you can't add more routes after that. - # This method is used to run the application in a Rack server so, you can use the `rackup` command - # to run the application. - # Ex. rackup -p 3000 - # This command will use the following middleware: - # - Rack::ShowExceptions - # - Rack::Lint - # - Rack::MethodOverride - # - Rack::Head - # - Rack::ContentLength - # - Rack::Static - # - def self.run! - stack = Rack::Builder.new - - use Rack::ShowExceptions - use Rack::Lint - use Rack::MethodOverride - use Rack::Head - use Rack::ContentLength - use Rack::Static, - root: 'public', - urls: ['/404.html', '/500.html'], - header_rules: [ - [200, %w[html], { 'content-type' => 'text/html; charset=utf-8' }], - [400, %w[html], { 'content-type' => 'text/html; charset=utf-8' }], - [500, %w[html], { 'content-type' => 'text/html; charset=utf-8' }] - ] - - middlewares.each do |(middleware, args, block)| - stack.use(middleware, *args, &block) + # Add a after hook + # + # @parameter [String] path + # @parameter [Block] block + # + def after(path = nil, &block) + if path + parts = path.split('/').reject(&:empty?) + @_after_hooks.add_route(parts, :after, block) + else + @_global_after_hooks << block + end end - stack.run ->(env) do - catch(:halt) do - begin + # Run the Application + # + # @returns [Base] self + # + # When you use this method, the application will be frozen. And you can't add more routes after that. + # This method is used to run the application in a Rack server so, you can use the `rackup` command + # to run the application. + # Ex. rackup -p 3000 + # This command will use the following middleware: + # - Rack::ShowExceptions + # - Rack::MethodOverride + # - Rack::Head + # - Rack::ContentLength + # + def run! + stack = Rack::Builder.new + + use Rack::ShowExceptions if test? || development? + use Rack::MethodOverride + use Rack::Head + use Rack::ContentLength + + middlewares.each do |(middleware, args, block)| + stack.use(middleware, *args, &block) + end + + stack.run ->(env) do + catch(:halt) do execute_hooks(@_before_hooks, env, :before) res = @_route.call(env) execute_hooks(@_after_hooks, env, :after) @@ -143,110 +133,113 @@ def self.run! puts e.message.red puts e.backtrace raise e - end.then do |response| - case response - in [400..499, _, _] then render_not_found - else response - end end end - end - @_route.freeze! + @_route.freeze! - stack.to_app - end - - def self.test? = ENV['RACK_ENV'] == 'test' || ENV['LENNARB_ENV'] == 'test' - def self.production? = ENV['RACK_ENV'] == 'production' || ENV['LENNARB_ENV'] == 'production' - def self.development? = ENV['RACK_ENV'] == 'development' || ENV['LENNARB_ENV'] == 'development' - - # Render a not found - # - # @returns [void] - # - def self.render_not_found(content = nil) - default = File.exist?('public/404.html') - body = content || default || 'Not Found' - throw :halt, [404, { 'content-type' => 'text/html' }, [body]] - end - - # Render an error - # - # @returns [void] - # - def self.render_error(content = nil) - default = File.exist?('public/500.html') - body = content || default || 'Internal Server Error' - throw :halt, [500, { 'content-type' => 'text/html' }, [body]] - end + stack.to_app + end - # Redirect to a path - # - # @parameter [String] path - # @parameter [Integer] status default is 302 - # - def self.redirect(path, status = 302) = throw :halt, [status, { 'location' => path }, []] + def test? = ENV['RACK_ENV'] == 'test' || ENV['LENNARB_ENV'] == 'test' + def production? = ENV['RACK_ENV'] == 'production' || ENV['LENNARB_ENV'] == 'production' + def development? = ENV['RACK_ENV'] == 'development' || ENV['LENNARB_ENV'] == 'development' + + # Render a not found + # + # @returns [void] + # + def render_not_found(content = nil) + default = File.exist?('public/404.html') + body = content || default || 'Not Found' + throw :halt, [404, { 'content-type' => 'text/html' }, [body]] + end - # Execute the hooks - # - # @parameter [RouteNode] hook_route - # @parameter [Hash] env - # @parameter [Symbol] action - # - # @returns [void] - # - def self.execute_hooks(hook_route, env, action) - execute_global_hooks(env, action) + # Render an error + # + # @returns [void] + # + def render_error(content = nil) + default = File.exist?('public/500.html') + body = content || default || 'Internal Server Error' + throw :halt, [500, { 'content-type' => 'text/html' }, [body]] + end - execute_route_hooks(hook_route, env, action) - end + # Redirect to a path + # + # @parameter [String] path + # @parameter [Integer] status default is 302 + # + def redirect(path, status = 302) = throw :halt, [status, { 'location' => path }, []] + + # To use a plugin + # + # @parameter [String | Symbol] plugin_name + # + # @returns [void] + # + def plugin(plugin_name) = @_route.plugin(plugin_name) + + private + + # Execute the hooks + # + # @parameter [RouteNode] hook_route + # @parameter [Hash] env + # @parameter [Symbol] action + # + # @returns [void] + # + def execute_hooks(hook_route, env, action) + execute_global_hooks(env, action) + + execute_route_hooks(hook_route, env, action) + end - # Execute the global hooks - # - # @parameter [Hash] env - # @parameter [Symbol] action - # - # @returns [void] - # - def self.execute_global_hooks(env, action) - global_hooks = action == :before ? @_global_before_hooks : @_global_after_hooks - global_hooks.each { |hook| hook.call(env) } - end + # Execute the global hooks + # + # @parameter [Hash] env + # @parameter [Symbol] action + # + # @returns [void] + # + def execute_global_hooks(env, action) + global_hooks = action == :before ? @_global_before_hooks : @_global_after_hooks + global_hooks.each { |hook| hook.call(env) } + end - # Execute the route hooks - # - # @parameter [RouteNode] hook_route - # @parameter [Hash] env - # @parameter [Symbol] action - # - # @returns [void] - # - def self.execute_route_hooks(hook_route, env, action) - parts = parse_path(env) - return unless parts + # Execute the route hooks + # + # @parameter [RouteNode] hook_route + # @parameter [Hash] env + # @parameter [Symbol] action + # + # @returns [void] + # + def execute_route_hooks(hook_route, env, action) + parts = parse_path(env) + return unless parts + + block, = hook_route.match_route(parts, action) + block&.call(env) + end - block, = hook_route.match_route(parts, action) - block&.call(env) + # Parse the path + # + # @parameter [Hash] env + # + # @returns [Array] parts + # + def parse_path(env) = env[Rack::PATH_INFO]&.split('/')&.reject(&:empty?) + + # Check if the request is a HTML request + # + # @parameter [Hash] env + # + # @returns [Boolean] + # + def html_request?(env) = env['HTTP_ACCEPT']&.include?('text/html') end - - # Parse the path - # - # @parameter [Hash] env - # - # @returns [Array] parts - # - def self.parse_path(env) = env[Rack::PATH_INFO]&.split('/')&.reject(&:empty?) - - # Check if the request is a HTML request - # - # @parameter [Hash] env - # - # @returns [Boolean] - # - def self.html_request?(env) = env['HTTP_ACCEPT']&.include?('text/html') - - private_class_method :execute_hooks, :execute_global_hooks, :execute_route_hooks, :parse_path, :html_request? end end end diff --git a/lib/lennarb/plugin.rb b/lib/lennarb/plugin.rb new file mode 100644 index 0000000..b2ed3b4 --- /dev/null +++ b/lib/lennarb/plugin.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023-2024, by Aristóteles Coutinho. + +class Lennarb + module Plugin + @plugins = {} + + def self.register(name, mod) + @plugins[name] = mod + end + + def self.load(name) + @plugins[name] || raise("Plugin #{name} did not register itself correctly") + end + + def self.plugins = @plugins + end +end diff --git a/lib/lennarb/version.rb b/lib/lennarb/version.rb index 333c5d9..5aae1a8 100644 --- a/lib/lennarb/version.rb +++ b/lib/lennarb/version.rb @@ -4,7 +4,7 @@ # Copyright, 2023-2024, by Aristóteles Coutinho. class Lennarb - VERSION = '0.5.1' + VERSION = '0.6.1' public_constant :VERSION end diff --git a/test/lib/lennarb/application/test_base.rb b/test/lib/lennarb/application/test_base.rb index 94491f9..d0835fe 100644 --- a/test/lib/lennarb/application/test_base.rb +++ b/test/lib/lennarb/application/test_base.rb @@ -10,7 +10,15 @@ module Application class TestBase < Minitest::Test include Rack::Test::Methods + module TestPlugin + def test_plugin_method = 'Plugin Method Executed' + end + + Lennarb::Plugin.register(:test_plugin, TestPlugin) + class MyApp < Lennarb::Application::Base + plugin :test_plugin + before do |context| context['x-before-hook'] = 'Before Hook' end @@ -28,6 +36,11 @@ class MyApp < Lennarb::Application::Base res.status = 201 res.html('POST Response') end + + get '/plugin' do |_req, res| + res.status = 200 + res.html(test_plugin_method) + end end def app = MyApp.run! @@ -81,6 +94,13 @@ def test_render_not_found assert_equal 'Not Found', last_response.body end + def test_plugin_method_execution + get '/plugin' + + assert_predicate last_response, :ok? + assert_equal 'Plugin Method Executed', last_response.body + end + class MockedMiddleware def initialize(app) @app = app diff --git a/test/lib/lennarb/test_plugin.rb b/test/lib/lennarb/test_plugin.rb new file mode 100644 index 0000000..0d258ae --- /dev/null +++ b/test/lib/lennarb/test_plugin.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023-2024, by Aristóteles Coutinho. + +require 'test_helper' + +class Lennarb + class TestPlugin < Minitest::Test + def setup + @plugin_name = :test_plugin + @plugin_module = Module.new do + def test_method + "test" + end + end + + Lennarb::Plugin.register(@plugin_name, @plugin_module) + end + + def teardown + Lennarb::Plugin.plugins.clear + end + + def test_register_plugin + assert_equal @plugin_module, Lennarb::Plugin.plugins[@plugin_name] + end + + def test_load_plugin + loaded_plugin = Lennarb::Plugin.load(@plugin_name) + assert_equal @plugin_module, loaded_plugin + end + + def test_load_unregistered_plugin_raises_error + assert_raises(RuntimeError) { Lennarb::Plugin.load(:nonexistent_plugin) } + end + end +end