diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..019be792 --- /dev/null +++ b/Gemfile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Gemfile +ruby '3.3.3' +source 'https://rubygems.org' + +gem 'puma' +gem 'rack' +gem 'rackup' +gem 'rubocop', require: false +gem 'sequel' +gem 'sqlite3' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..8d0d87c3 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,52 @@ +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.2) + bigdecimal (3.2.0) + json (2.11.3) + nio4r (2.7.4) + parallel (1.23.0) + parser (3.2.2.1) + ast (~> 2.4.1) + puma (6.6.0) + nio4r (~> 2.0) + rack (3.1.15) + rackup (2.2.1) + rack (>= 3) + rainbow (3.1.1) + regexp_parser (2.10.0) + rexml (3.4.1) + rubocop (1.50.2) + json (~> 2.3) + parallel (~> 1.10) + parser (>= 3.2.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.28.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.28.1) + parser (>= 3.2.1.0) + ruby-progressbar (1.13.0) + sequel (5.92.0) + bigdecimal + sqlite3 (1.7.3-arm64-darwin) + unicode-display_width (2.4.2) + +PLATFORMS + arm64-darwin-24 + +DEPENDENCIES + puma + rack + rackup + rubocop + sequel + sqlite3 + +RUBY VERSION + ruby 3.3.3p89 + +BUNDLED WITH + 2.6.9 diff --git a/app/controllers/tests_controller.rb b/app/controllers/tests_controller.rb index 1526a689..222884a8 100644 --- a/app/controllers/tests_controller.rb +++ b/app/controllers/tests_controller.rb @@ -1,11 +1,21 @@ -class TestsController < Simpler::Controller +# frozen_string_literal: true +class TestsController < Simpler::Controller def index @time = Time.now end def create + render json: { message: 'Created successfully' }, status: 201 + end + def plain + headers['X-Time-Header'] = Time.now.to_s + render plain: "Time.now: #{Time.now}\n" end + def show + @test_id = params[:id] + render plain: "Test ID: #{@test_id}" + end end diff --git a/app/models/test.rb b/app/models/test.rb index 86376668..db135e04 100644 --- a/app/models/test.rb +++ b/app/models/test.rb @@ -1,8 +1,9 @@ +# frozen_string_literal: true + # Simpler.application.db.create_table(:tests) do # primary_key :id # String :title, null: false # Integer :level, default: 0 # end class Test < Sequel::Model - end diff --git a/config.ru b/config.ru index 3060cc20..c92e3187 100644 --- a/config.ru +++ b/config.ru @@ -1,3 +1,6 @@ -require_relative 'config/environment' +# frozen_string_literal: true +require_relative './config/environment' + +use Simpler::Middleware::Logger run Simpler.application diff --git a/config/environment.rb b/config/environment.rb index 7a0d38c3..c037546f 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +require_relative '../lib/simpler/middleware/logger' require_relative '../lib/simpler' Simpler.application.bootstrap! diff --git a/config/routes.rb b/config/routes.rb index 4a751251..55ad3d6f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,8 @@ +# frozen_string_literal: true + Simpler.application.routes do get '/tests', 'tests#index' + get '/tests/plain', 'tests#plain' post '/tests', 'tests#create' + get '/tests/:id', 'tests#show' end diff --git a/lib/simpler.rb b/lib/simpler.rb index f9dfe3c4..6fb23105 100644 --- a/lib/simpler.rb +++ b/lib/simpler.rb @@ -1,8 +1,9 @@ +# frozen_string_literal: true + require 'pathname' require_relative 'simpler/application' module Simpler - class << self def application Application.instance @@ -12,5 +13,4 @@ def root Pathname.new(File.expand_path('..', __dir__)) end end - end diff --git a/lib/simpler/application.rb b/lib/simpler/application.rb index 711946a9..f763233a 100644 --- a/lib/simpler/application.rb +++ b/lib/simpler/application.rb @@ -1,3 +1,6 @@ +# frozen_string_literal: true + +require 'rack' require 'yaml' require 'singleton' require 'sequel' @@ -6,7 +9,6 @@ module Simpler class Application - include Singleton attr_reader :db @@ -28,10 +30,13 @@ def routes(&block) def call(env) route = @router.route_for(env) - controller = route.controller.new(env) - action = route.action - - make_response(controller, action) + if route.nil? + not_found_response + else + controller = route.controller.new(env) + action = route.action + make_response(controller, action) + end end private @@ -50,9 +55,20 @@ def setup_database @db = Sequel.connect(database_config) end + def not_found_response + build_error_response(404, 'Not Found') + end + + def build_error_response(status, body) + Rack::Response.new do |res| + res.status = status + res['Content-Type'] = 'text/plain' + res.write(body) + end.finish + end + def make_response(controller, action) controller.make_response(action) end - end end diff --git a/lib/simpler/controller.rb b/lib/simpler/controller.rb index 9383b035..7a514d70 100644 --- a/lib/simpler/controller.rb +++ b/lib/simpler/controller.rb @@ -1,14 +1,17 @@ +# frozen_string_literal: true + +require 'json' require_relative 'view' module Simpler class Controller - attr_reader :name, :request, :response def initialize(env) @name = extract_name @request = Rack::Request.new(env) @response = Rack::Response.new + @env = env end def make_response(action) @@ -22,6 +25,10 @@ def make_response(action) @response.finish end + def headers + @response.headers + end + private def extract_name @@ -29,26 +36,67 @@ def extract_name end def set_default_headers - @response['Content-Type'] = 'text/html' + @response['Content-Type'] ||= 'text/html' end def write_response - body = render_body - - @response.write(body) + @response.write(render_body) end def render_body - View.new(@request.env).render(binding) + return render_json if json_response? + return render_plain if plain_response? + + render_template end - def params - @request.params + def plain_response? + @env.key?('simpler.plain') + end + + def json_response? + @env.key?('simpler.json') end - def render(template) - @request.env['simpler.template'] = template + def render_plain + @response['Content-Type'] = 'text/plain' + @env['simpler.plain'] end + def render_json + @response['Content-Type'] = 'application/json' + JSON.generate(@env['simpler.json']) + end + + def render_template + View.new(@env).render(binding) + end + + def params + return @params if @params + + @params = @request.params.transform_keys(&:to_sym) + route_params = @env['simpler.route_params']&.transform_keys(&:to_sym) + @params.merge!(route_params) if route_params + @params + end + + def render(options) + case options + when String, Symbol + @env['simpler.template'] = options.to_s + when Hash + @response.status = options.delete(:status) if options.key?(:status) + if options[:plain] + @env['simpler.plain'] = options[:plain].to_s + elsif options[:json] + @env['simpler.json'] = options[:json] + else + raise ArgumentError, "Unknown render option: #{options.keys.join(', ')}" + end + else + raise ArgumentError, "Invalid argument for render: #{options.inspect}" + end + end end end diff --git a/lib/simpler/middleware/logger.rb b/lib/simpler/middleware/logger.rb new file mode 100644 index 00000000..48f57c5e --- /dev/null +++ b/lib/simpler/middleware/logger.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'logger' +require 'fileutils' + +module Simpler + module Middleware + class Logger + LOG_FILE = 'log/app.log' + + def initialize(app) + @app = app + ensure_log_directory + @logger = setup_logger + end + + def call(env) + start = Time.now + status, headers, body = @app.call(env) + + log_request(env) + log_response(status, headers, env) + + [status, headers, body] + end + + private + + def ensure_log_directory + FileUtils.mkdir_p(File.dirname(LOG_FILE)) + end + + def setup_logger + ::Logger.new(LOG_FILE, 'daily') + end + + def log_request(env) + request = Rack::Request.new(env) + controller = env['simpler.controller'] + action = env['simpler.action'] + + message = [] + message << "Request: #{env['REQUEST_METHOD']} #{env['PATH_INFO']}#{env['QUERY_STRING'].empty? ? '' : "?#{env['QUERY_STRING']}"}" + message << "Handler: #{controller.class.name}##{action}" if controller && action + message << "Parameters: #{collect_parameters(request, env)}" + + @logger.info(message.join("\n")) + end + + def log_response(status, headers, env) + message = "Response: #{status} #{Rack::Utils::HTTP_STATUS_CODES[status]}" + message << " [#{headers['Content-Type']}]" + message << " #{env['simpler.template']}" if env['simpler.template'] + + @logger.info(message) + end + + def collect_parameters(request, env) + params = {} + + # Query parameters + params.merge!(request.GET) + + # POST parameters + params.merge!(request.POST) if request.post? + + # Route parameters + params.merge!(env['simpler.route_params'] || {}) + + params + end + end + end +end diff --git a/lib/simpler/router.rb b/lib/simpler/router.rb index 14b3415c..816ed98a 100644 --- a/lib/simpler/router.rb +++ b/lib/simpler/router.rb @@ -1,8 +1,9 @@ +# frozen_string_literal: true + require_relative 'router/route' module Simpler class Router - def initialize @routes = [] end @@ -19,15 +20,18 @@ def route_for(env) method = env['REQUEST_METHOD'].downcase.to_sym path = env['PATH_INFO'] - @routes.find { |route| route.match?(method, path) } + route = @routes.find { |r| r.match?(method, path) } + return unless route + + env['simpler.route_params'] = route.extract_params(path) + route end private def add_route(method, path, route_point) - route_point = route_point.split('#') - controller = controller_from_string(route_point[0]) - action = route_point[1] + controller_name, action = route_point.split('#') + controller = controller_from_string(controller_name) route = Route.new(method, path, controller, action) @routes.push(route) @@ -36,6 +40,5 @@ def add_route(method, path, route_point) def controller_from_string(controller_name) Object.const_get("#{controller_name.capitalize}Controller") end - end end diff --git a/lib/simpler/router/route.rb b/lib/simpler/router/route.rb index 4c66b4b7..12a4abcc 100644 --- a/lib/simpler/router/route.rb +++ b/lib/simpler/router/route.rb @@ -1,20 +1,53 @@ +# frozen_string_literal: true + module Simpler class Router class Route - - attr_reader :controller, :action + attr_reader :controller, :action, :params_pattern def initialize(method, path, controller, action) @method = method - @path = path + @path = normalize_path(path) @controller = controller @action = action + @params_pattern = build_params_pattern(path) + end + + def match?(method, request_path) + @method == method && path_match?(normalize_path(request_path)) end - def match?(method, path) - @method == method && path.match(@path) + def extract_params(request_path) + return {} unless @params_pattern + + matches = normalize_path(request_path).match(@params_pattern) + return {} unless matches + + matches.named_captures.transform_keys(&:to_sym) end + private + + def normalize_path(path) + path.chomp('/') + end + + def build_params_pattern(path) + return nil unless path.include?(':') + + pattern = normalize_path(path).gsub(%r{:[^/]+}) do |match| + param_name = match[1..] + "(?<#{param_name}>[^\/]+)" + end + + Regexp.new("^#{pattern}$") + end + + def path_match?(request_path) + return request_path == @path unless @params_pattern + + request_path.match?(@params_pattern) + end end end end diff --git a/lib/simpler/view.rb b/lib/simpler/view.rb index 19a73b34..5c6e7a25 100644 --- a/lib/simpler/view.rb +++ b/lib/simpler/view.rb @@ -1,9 +1,10 @@ +# frozen_string_literal: true + require 'erb' module Simpler class View - - VIEW_BASE_PATH = 'app/views'.freeze + VIEW_BASE_PATH = 'app/views' def initialize(env) @env = env @@ -34,6 +35,5 @@ def template_path Simpler.root.join(VIEW_BASE_PATH, "#{path}.html.erb") end - end end