tarsier 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01eb416d71704d9bd9fa4ec5b9cbdf04cf8e4384343791a4895c57b97e275bcc
4
- data.tar.gz: 1e4c18917ba174fda64094a4fe20bc2b5df714b3d797e0f7175b155829f0d8cd
3
+ metadata.gz: bf96475cd9f2aec9a31bc0c4686bfce7c54bc2e963bee68639b0c062847f6d11
4
+ data.tar.gz: 36028c91e5f5d7156f30b8f17ecc85ec8e9294f39db2b34b88978c7bbd335b32
5
5
  SHA512:
6
- metadata.gz: 29edfa202c43d584aab94b16dd876824adf232425c500f57d2d89ba39cc5f992e8d7bc0e59a740c889b275dfd6a7533d0a4b4cb70c09d8514bff025ae32310ee
7
- data.tar.gz: f0a3a46be85efeb607f0b4398be3e4b529d452c9f2f295982c5eff43be336429e69e57a27d4ab1be0c5835724a42e73ae0619f9e01f7594ad0e30d08b8de4a2b
6
+ metadata.gz: b888e28e58a2fa5444e9362a34baf1d5653f669d1d00652daf98522f700affbce6f4c0b2e52efe070e9c76fecfe38558b11300cff77154054369c0b30cc05660
7
+ data.tar.gz: c7658b5ce137659fd5cb6ecf0cfc8bc8e2bb6cb0cb318586889cf0b61290bcc09c24251e553e3da3f2cca901c39983a988aa256fd95a6a6231f9a4612222dde5
data/CHANGELOG.md CHANGED
@@ -8,6 +8,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
 
11
+ ## [0.1.1] - 2026-01-09
12
+
13
+ ### Fixed
14
+
15
+ - **Router**: Fixed root path matching edge case with trailing slashes - paths like `/users/` now correctly match `/users`
16
+ - **Response**: Fixed `content_type=` method signature - keyword arguments don't work with assignment syntax in Ruby. Added `set_content_type` method for charset option
17
+ - **Errors**: Removed duplicate `ValidationError` and `RecordNotFoundError` class definitions from `model.rb` - now defined only in `errors.rb`
18
+ - **Query**: `exists?` method now handles missing database connection gracefully
19
+ - **CSRF Middleware**: Token is now exposed to views via `request.env["tarsier.csrf_token"]` and response header `X-CSRF-Token`
20
+ - **Params**: Removed unused error variable in rescue block for cleaner code
21
+ - **Controller**: `validated_params` now returns raw params as hash when no schema is defined, instead of empty hash
22
+ - **Static Middleware**: Improved directory traversal protection with proper path resolution using `File.expand_path`
23
+ - **Middleware::Base**: Fixed `after` hook to receive the response object instead of the result
24
+
25
+ ### Added
26
+
27
+ - **Request**: Added `to_h` / `to_hash` method for debugging and logging request data
28
+ - **Response**: Added `status_text` helper method to get human-readable status text (e.g., "OK", "Not Found")
29
+ - **Router**: Added `route_exists?(method, path)` method for quick route existence checks
30
+ - **Model**: Added `first`, `first!`, `last`, `last!` class methods for convenience
31
+ - **Model**: Added `find_by!` class method that raises `RecordNotFoundError` when no record found
32
+ - **Query**: Added `first!` and `last!` methods that raise `RecordNotFoundError` when no record found
33
+ - **RateLimit Middleware**: Added `skip` option to bypass rate limiting for specific paths (e.g., health checks)
34
+ - **Logger Middleware**: Added request ID tracking with `X-Request-Id` header propagation for distributed tracing
35
+ - **Application**: Added `env` accessor method for direct access to current environment
36
+ - **Database**: Added `ping` and `alive?` methods to check database connection health
37
+ - **WebSocket**: Added `on_error` handler support for error handling during WebSocket connections
38
+ - **CLI Generators**: Improved error messages with specific details for permission denied and other failures
39
+
40
+ ### Changed
41
+
42
+ - **RecordNotFoundError**: Now accepts optional `id` parameter for more flexible error messages
43
+ - **Logger Middleware**: Now generates/propagates request IDs by default (can be disabled with `request_id: false`)
44
+
45
+
11
46
  ## [0.1.0] - 2025-01-01
12
47
 
13
48
  ### Added
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
- # Tarsier
1
+ # Tarsier 🐒
2
2
 
3
3
  <p align="center">
4
- <img src="https://raw.githubusercontent.com/tarsier-rb/tarsier/main/assets/logo.png" alt="Tarsier Logo" width="200">
4
+ <img src="/assets/logo.png" alt="Tarsier Logo" width="200">
5
5
  </p>
6
6
 
7
7
  <p align="center">
@@ -10,21 +10,58 @@
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D%203.3-red.svg" alt="Ruby"></a>
13
- <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
13
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
14
+ <a href="https://discord.gg/jkDzBpYA"><img src="https://img.shields.io/discord/YOUR_DISCORD_ID?color=7289da&label=discord&logo=discord&logoColor=white" alt="Discord"></a>
14
15
  </p>
15
16
 
16
17
  ---
17
18
 
18
19
  Tarsier delivers sub-millisecond routing through a compiled radix tree architecture while maintaining an intuitive API that feels natural to Ruby developers.
19
20
 
21
+ ## 🚀 Quick Start
22
+
23
+ ```bash
24
+ # Install Tarsier
25
+ gem install tarsier
26
+
27
+ # Create a new app
28
+ tarsier new my_app
29
+ cd my_app
30
+
31
+ # Start the server
32
+ tarsier server
33
+ # => Listening on http://localhost:7827
34
+ ```
35
+
36
+ Or create a minimal app:
37
+
38
+ ```ruby
39
+ # app.rb
40
+ require 'tarsier'
41
+
42
+ app = Tarsier.app do
43
+ get('/') { { message: 'Hello, World!' } }
44
+ get('/users/:id') { |req| { user_id: req.params[:id] } }
45
+ end
46
+
47
+ run app
48
+ ```
49
+
50
+ ### 📊 Performance Highlights
51
+
52
+ - **700,000+** routes per second
53
+ - **Sub-millisecond** response times
54
+ - **Zero** runtime dependencies
55
+ - **100%** type coverage with RBS
56
+
20
57
  ---
21
58
 
22
59
  ## Table of Contents
23
60
 
61
+ - [Quick Start](#-quick-start)
24
62
  - [Features](#features)
25
63
  - [Requirements](#requirements)
26
64
  - [Installation](#installation)
27
- - [Quick Start](#quick-start)
28
65
  - [Command Line Interface](#command-line-interface)
29
66
  - [Routing](#routing)
30
67
  - [Controllers](#controllers)
@@ -36,6 +73,7 @@ Tarsier delivers sub-millisecond routing through a compiled radix tree architect
36
73
  - [Configuration](#configuration)
37
74
  - [Testing](#testing)
38
75
  - [Benchmarks](#benchmarks)
76
+ - [Community](#community)
39
77
  - [Contributing](#contributing)
40
78
  - [License](#license)
41
79
 
@@ -43,14 +81,14 @@ Tarsier delivers sub-millisecond routing through a compiled radix tree architect
43
81
 
44
82
  ## Features
45
83
 
46
- - **High Performance**: Compiled radix tree router achieving 700,000+ routes per second
47
- - **Zero Runtime Dependencies**: Core framework operates without external dependencies
48
- - **Multi-Database Support**: Built-in support for SQLite, PostgreSQL, and MySQL
49
- - **Modern Ruby**: Built for Ruby 3.3+ with YJIT and Fiber scheduler support
50
- - **Async Native**: First-class support for Fiber-based concurrency patterns
51
- - **Type Safety Ready**: Complete RBS type signatures for static analysis
52
- - **Developer Experience**: Intuitive APIs, detailed error messages, minimal boilerplate
53
- - **Production Ready**: Built-in middleware for CORS, CSRF, rate limiting, and compression
84
+ - **High Performance**: Compiled radix tree router achieving 700,000+ routes per second
85
+ - 📦 **Zero Runtime Dependencies**: Core framework operates without external dependencies
86
+ - 🗄️ **Multi-Database Support**: Built-in support for SQLite, PostgreSQL, and MySQL
87
+ - 💎 **Modern Ruby**: Built for Ruby 3.3+ with YJIT and Fiber scheduler support
88
+ - 🔄 **Async Native**: First-class support for Fiber-based concurrency patterns
89
+ - 📝 **Type Safety Ready**: Complete RBS type signatures for static analysis
90
+ - 🚀 **Developer Experience**: Intuitive APIs, detailed error messages, minimal boilerplate
91
+ - 🔒 **Production Ready**: Built-in middleware for CORS, CSRF, rate limiting, and compression
54
92
 
55
93
  ---
56
94
 
@@ -81,48 +119,7 @@ Or install globally for CLI access:
81
119
  gem install tarsier
82
120
  ```
83
121
 
84
- ---
85
-
86
- ## Quick Start
87
-
88
- Create a new application:
89
-
90
- ```bash
91
- tarsier new my_app
92
- cd my_app
93
- bin/server
94
- ```
95
-
96
- Or create a minimal application manually:
97
-
98
- ```ruby
99
- # app.rb
100
- require 'tarsier'
101
-
102
- # Flask-style minimal syntax
103
- app = Tarsier.app do
104
- get('/') { { message: 'Hello, World!' } }
105
-
106
- get('/users/:id') do |req|
107
- { user_id: req.params[:id] }
108
- end
109
-
110
- post('/users') do |req, res|
111
- res.status = 201
112
- { created: true, name: req.params[:name] }
113
- end
114
- end
115
-
116
- run app
117
- ```
118
-
119
- Start the server:
120
-
121
- ```bash
122
- rackup app.rb
123
- ```
124
-
125
- Visit `http://localhost:9292` to see your application running.
122
+ > 📖 **Need help getting started?** Check out our [comprehensive documentation website](https://tarsier-rb.github.io/tarsier) with step-by-step guides, examples, and API reference.
126
123
 
127
124
  ---
128
125
 
@@ -947,6 +944,24 @@ Benchmarks performed on Ruby 3.3 with YJIT enabled.
947
944
 
948
945
  ---
949
946
 
947
+ ## Community
948
+
949
+ Join the Tarsier community to get help, share ideas, and contribute to the project:
950
+
951
+ - 💬 **Discord**: [Join our Discord server](https://discord.gg/jkDzBpYA) for real-time discussions and support
952
+ - � **Documentation**: [Complete documentation website](https://tarsier-rb.github.io/tarsier) with guides and examples
953
+ - � ***Issues**: [Report bugs or request features](https://github.com/tarsier-rb/tarsier/issues) on GitHub
954
+ - 💎 **RubyGems**: [View package details](https://rubygems.org/gems/tarsier) and installation stats
955
+
956
+ ### Getting Help
957
+
958
+ - Check the [documentation](https://tarsier-rb.github.io/tarsier) for comprehensive guides
959
+ - Join our [Discord community](https://discord.gg/jkDzBpYA) for quick questions and discussions
960
+ - Search [existing issues](https://github.com/tarsier-rb/tarsier/issues) before creating new ones
961
+ - Follow the [contributing guidelines](#contributing) when submitting PRs
962
+
963
+ ---
964
+
950
965
  ## Contributing
951
966
 
952
967
  1. Fork the repository
@@ -975,10 +990,12 @@ bundle exec yard doc
975
990
 
976
991
  ## License
977
992
 
978
- Tarsier is released under the [MIT License](LICENSE.txt).
993
+ Tarsier is released under the [MIT License](LICENSE).
979
994
 
980
995
  ---
981
996
 
982
997
  ## Acknowledgments
983
998
 
984
999
  Tarsier draws inspiration from the Ruby web framework ecosystem, particularly Rails, Sinatra, and Roda, while pursuing its own vision of performance and simplicity.
1000
+
1001
+ Special thanks to our community members and contributors who help make Tarsier better every day. Join us on [Discord](https://discord.gg/jkDzBpYA) to be part of the journey!
@@ -32,6 +32,11 @@ module Tarsier
32
32
  # @return [Configuration] application configuration
33
33
  attr_reader :config
34
34
 
35
+ # @return [String] current environment
36
+ def env
37
+ Tarsier.env
38
+ end
39
+
35
40
  # Create a new application
36
41
  def initialize
37
42
  @router = Router.new
@@ -107,6 +107,9 @@ module Tarsier
107
107
  # Load application files
108
108
  Dir[File.expand_path("../app/**/*.rb", __dir__)].sort.each { |f| require f }
109
109
 
110
+ # Load routes
111
+ require_relative "routes"
112
+
110
113
  Tarsier.application do
111
114
  configure do
112
115
  self.secret_key = ENV.fetch("SECRET_KEY", "development_secret_\#{SecureRandom.hex(32)}")
@@ -115,17 +118,6 @@ module Tarsier
115
118
  # Middleware
116
119
  use Tarsier::Middleware::Logger
117
120
  #{options[:api] ? "" : "use Tarsier::Middleware::Static, root: 'public'"}
118
-
119
- # Routes
120
- routes do
121
- root to: "home#index"
122
-
123
- # Add your routes here
124
- # resources :users
125
- # namespace :api do
126
- # resources :posts
127
- # end
128
- end
129
121
  end
130
122
  RUBY
131
123
  end
@@ -134,8 +126,8 @@ module Tarsier
134
126
  <<~RUBY
135
127
  # frozen_string_literal: true
136
128
 
137
- # This file documents your routes.
138
- # Routes are defined in config/application.rb within the routes block.
129
+ # Define your application routes here
130
+ # Routes are automatically loaded by config/application.rb
139
131
  #
140
132
  # Example routes:
141
133
  # root to: "home#index"
@@ -144,6 +136,16 @@ module Tarsier
144
136
  # namespace :api do
145
137
  # resources :posts
146
138
  # end
139
+
140
+ Tarsier.routes do
141
+ root to: "home#index"
142
+
143
+ # Add your routes here
144
+ # resources :users
145
+ # namespace :api do
146
+ # resources :posts
147
+ # end
148
+ end
147
149
  RUBY
148
150
  end
149
151
 
@@ -19,16 +19,33 @@ module Tarsier
19
19
 
20
20
  def create_file(path, content)
21
21
  dir = File.dirname(path)
22
- FileUtils.mkdir_p(dir) unless File.directory?(dir)
22
+
23
+ begin
24
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
25
+ rescue Errno::EACCES => e
26
+ puts " \e[31merror\e[0m Cannot create directory #{dir}: Permission denied"
27
+ return false
28
+ rescue StandardError => e
29
+ puts " \e[31merror\e[0m Cannot create directory #{dir}: #{e.message}"
30
+ return false
31
+ end
23
32
 
24
33
  if File.exist?(path)
25
34
  puts " \e[33mskip\e[0m #{path} (already exists)"
26
35
  return false
27
36
  end
28
37
 
29
- File.write(path, content)
30
- puts " \e[32mcreate\e[0m #{path}"
31
- true
38
+ begin
39
+ File.write(path, content)
40
+ puts " \e[32mcreate\e[0m #{path}"
41
+ true
42
+ rescue Errno::EACCES
43
+ puts " \e[31merror\e[0m Cannot write to #{path}: Permission denied"
44
+ false
45
+ rescue StandardError => e
46
+ puts " \e[31merror\e[0m Cannot write to #{path}: #{e.message}"
47
+ false
48
+ end
32
49
  end
33
50
 
34
51
  def create_directory(path)
@@ -37,9 +54,17 @@ module Tarsier
37
54
  return false
38
55
  end
39
56
 
40
- FileUtils.mkdir_p(path)
41
- puts " \e[32mcreate\e[0m #{path}/"
42
- true
57
+ begin
58
+ FileUtils.mkdir_p(path)
59
+ puts " \e[32mcreate\e[0m #{path}/"
60
+ true
61
+ rescue Errno::EACCES
62
+ puts " \e[31merror\e[0m Cannot create directory #{path}: Permission denied"
63
+ false
64
+ rescue StandardError => e
65
+ puts " \e[31merror\e[0m Cannot create directory #{path}: #{e.message}"
66
+ false
67
+ end
43
68
  end
44
69
 
45
70
  def append_to_file(path, content)
@@ -48,9 +73,17 @@ module Tarsier
48
73
  return false
49
74
  end
50
75
 
51
- File.open(path, "a") { |f| f.puts content }
52
- puts " \e[32mappend\e[0m #{path}"
53
- true
76
+ begin
77
+ File.open(path, "a") { |f| f.puts content }
78
+ puts " \e[32mappend\e[0m #{path}"
79
+ true
80
+ rescue Errno::EACCES
81
+ puts " \e[31merror\e[0m Cannot append to #{path}: Permission denied"
82
+ false
83
+ rescue StandardError => e
84
+ puts " \e[31merror\e[0m Cannot append to #{path}: #{e.message}"
85
+ false
86
+ end
54
87
  end
55
88
 
56
89
  def camelize(string)
@@ -107,9 +107,10 @@ module Tarsier
107
107
  end
108
108
 
109
109
  # Get validated parameters
110
+ # Returns validated params if schema defined, otherwise returns raw params as hash
110
111
  # @return [Hash]
111
112
  def validated_params
112
- @validated_params ||= {}
113
+ @validated_params ||= (self.class.param_schema ? {} : @params.to_h)
113
114
  end
114
115
 
115
116
  # Render a response
@@ -166,6 +166,38 @@ module Tarsier
166
166
  @connected && !@raw_connection.nil?
167
167
  end
168
168
 
169
+ # Check if the database connection is alive
170
+ # Performs a simple query to verify connectivity
171
+ #
172
+ # @return [Boolean]
173
+ def alive?
174
+ return false unless connected?
175
+
176
+ ping
177
+ rescue StandardError
178
+ false
179
+ end
180
+
181
+ # Ping the database to check connectivity
182
+ #
183
+ # @return [Boolean]
184
+ # @raise [DatabaseError] if connection is not alive
185
+ def ping
186
+ return false unless connected?
187
+
188
+ case @adapter
189
+ when :sqlite
190
+ @raw_connection.execute("SELECT 1")
191
+ when :postgres
192
+ @raw_connection.exec("SELECT 1")
193
+ when :mysql
194
+ @raw_connection.ping
195
+ end
196
+ true
197
+ rescue StandardError => e
198
+ raise DatabaseError, "Database ping failed: #{e.message}"
199
+ end
200
+
169
201
  # Close the connection
170
202
  #
171
203
  # @return [void]
@@ -74,4 +74,23 @@ module Tarsier
74
74
  super("Streaming is not supported by the current server")
75
75
  end
76
76
  end
77
+
78
+ # Raised when a record is not found
79
+ class RecordNotFoundError < Error
80
+ # @return [Class] the model class
81
+ attr_reader :model
82
+
83
+ # @return [Object] the ID that was not found
84
+ attr_reader :id
85
+
86
+ def initialize(model, id = nil)
87
+ @model = model
88
+ @id = id
89
+ if id
90
+ super("#{model.name} with #{model.primary_key}=#{id} not found")
91
+ else
92
+ super("#{model.name} record not found")
93
+ end
94
+ end
95
+ end
77
96
  end
@@ -21,7 +21,7 @@ module Tarsier
21
21
  def call(request, response)
22
22
  before(request, response)
23
23
  result = @app.call(request, response)
24
- after(request, result)
24
+ after(request, response)
25
25
  result
26
26
  end
27
27
 
@@ -26,10 +26,13 @@ module Tarsier
26
26
 
27
27
  if safe_request?(request)
28
28
  ensure_token(session)
29
+ # Expose CSRF token in response header and request env for views
30
+ expose_token(request, response, session[SESSION_KEY])
29
31
  @app.call(request, response)
30
32
  else
31
33
  if valid_token?(request, session)
32
34
  rotate_token(session) if @options[:rotate]
35
+ expose_token(request, response, session[SESSION_KEY])
33
36
  @app.call(request, response)
34
37
  else
35
38
  handle_invalid_token(response)
@@ -39,6 +42,13 @@ module Tarsier
39
42
 
40
43
  private
41
44
 
45
+ def expose_token(request, response, token)
46
+ # Make token available in request env for views/templates
47
+ request.env["tarsier.csrf_token"] = token
48
+ # Also set as response header for JavaScript access
49
+ response.set_header("X-CSRF-Token", token)
50
+ end
51
+
42
52
  def skip_csrf?(request)
43
53
  @skip_paths.any? { |path| request.path.start_with?(path) }
44
54
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Tarsier
4
6
  module Middleware
5
7
  # Request logging middleware
@@ -13,42 +15,56 @@ module Tarsier
13
15
  reset: "\e[0m"
14
16
  }.freeze
15
17
 
18
+ REQUEST_ID_HEADER = "X-Request-Id"
19
+
16
20
  def initialize(app, logger: nil, **options)
17
21
  super(app, **options)
18
22
  @logger = logger || default_logger
19
23
  @colorize = options.fetch(:colorize, $stdout.tty?)
24
+ @include_request_id = options.fetch(:request_id, true)
20
25
  end
21
26
 
22
27
  def call(request, response)
23
28
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
29
 
30
+ # Generate or propagate request ID
31
+ request_id = if @include_request_id
32
+ existing_id = request.header(REQUEST_ID_HEADER)
33
+ id = existing_id || SecureRandom.uuid
34
+ request.env["tarsier.request_id"] = id
35
+ response.set_header(REQUEST_ID_HEADER, id)
36
+ id
37
+ end
38
+
25
39
  @app.call(request, response)
26
40
 
27
41
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
28
- log_request(request, response, duration)
42
+ log_request(request, response, duration, request_id)
29
43
 
30
44
  response
31
45
  end
32
46
 
33
47
  private
34
48
 
35
- def log_request(request, response, duration)
49
+ def log_request(request, response, duration, request_id = nil)
36
50
  status = response.status
37
51
  method = request.method
38
52
  path = request.path
39
53
  duration_ms = (duration * 1000).round(2)
40
54
 
41
- message = format_log(method, path, status, duration_ms)
55
+ message = format_log(method, path, status, duration_ms, request_id)
42
56
  @logger.info(message)
43
57
  end
44
58
 
45
- def format_log(method, path, status, duration_ms)
59
+ def format_log(method, path, status, duration_ms, request_id = nil)
46
60
  if @colorize
47
61
  status_color = status_color(status)
48
- "#{colorize(method.to_s.ljust(7), :cyan)} #{path} " \
62
+ base = "#{colorize(method.to_s.ljust(7), :cyan)} #{path} " \
49
63
  "#{colorize(status.to_s, status_color)} #{duration_ms}ms"
64
+ request_id ? "#{base} [#{request_id[0..7]}]" : base
50
65
  else
51
- "#{method.to_s.ljust(7)} #{path} #{status} #{duration_ms}ms"
66
+ base = "#{method.to_s.ljust(7)} #{path} #{status} #{duration_ms}ms"
67
+ request_id ? "#{base} [#{request_id[0..7]}]" : base
52
68
  end
53
69
  end
54
70
 
@@ -9,16 +9,21 @@ module Tarsier
9
9
  limit: 100,
10
10
  window: 60,
11
11
  key_prefix: "tarsier:rate_limit:",
12
- headers: true
12
+ headers: true,
13
+ skip: []
13
14
  }.freeze
14
15
 
15
16
  def initialize(app, store: nil, **options)
16
17
  super(app, **options)
17
18
  @options = DEFAULT_OPTIONS.merge(options)
18
19
  @store = store || MemoryStore.new
20
+ @skip_paths = Array(@options[:skip])
19
21
  end
20
22
 
21
23
  def call(request, response)
24
+ # Skip rate limiting for configured paths
25
+ return @app.call(request, response) if skip_rate_limit?(request)
26
+
22
27
  key = rate_limit_key(request)
23
28
  current_time = Time.now.to_i
24
29
  window_start = current_time - @options[:window]
@@ -42,6 +47,10 @@ module Tarsier
42
47
 
43
48
  private
44
49
 
50
+ def skip_rate_limit?(request)
51
+ @skip_paths.any? { |path| request.path.start_with?(path) }
52
+ end
53
+
45
54
  def rate_limit_key(request)
46
55
  identifier = if @options[:key_generator]
47
56
  @options[:key_generator].call(request)
@@ -66,12 +66,14 @@ module Tarsier
66
66
  end
67
67
 
68
68
  def file_path(request_path)
69
- # Prevent directory traversal
70
- path = File.join(@root, request_path)
69
+ # Prevent directory traversal by expanding the path and checking it's within root
70
+ # First, clean the path of any traversal attempts
71
+ clean_path = request_path.gsub(/\.\./, "")
72
+ path = File.expand_path(File.join(@root, clean_path))
71
73
  path = File.join(path, @options[:index]) if File.directory?(path)
72
74
 
73
- # Ensure path is within root
74
- return nil unless path.start_with?(@root)
75
+ # Ensure the fully resolved path is within root directory
76
+ return nil unless path.start_with?("#{@root}/") || path == @root
75
77
 
76
78
  path
77
79
  end
data/lib/tarsier/model.rb CHANGED
@@ -183,6 +183,45 @@ module Tarsier
183
183
  where(attributes).first
184
184
  end
185
185
 
186
+ # Find by attributes or raise
187
+ #
188
+ # @param attributes [Hash] attributes to match
189
+ # @return [Model]
190
+ # @raise [RecordNotFoundError]
191
+ def find_by!(**attributes)
192
+ find_by(**attributes) || raise(RecordNotFoundError.new(self))
193
+ end
194
+
195
+ # Get the first record
196
+ #
197
+ # @return [Model, nil]
198
+ def first
199
+ query.first
200
+ end
201
+
202
+ # Get the first record or raise
203
+ #
204
+ # @return [Model]
205
+ # @raise [RecordNotFoundError]
206
+ def first!
207
+ first || raise(RecordNotFoundError.new(self))
208
+ end
209
+
210
+ # Get the last record
211
+ #
212
+ # @return [Model, nil]
213
+ def last
214
+ query.last
215
+ end
216
+
217
+ # Get the last record or raise
218
+ #
219
+ # @return [Model]
220
+ # @raise [RecordNotFoundError]
221
+ def last!
222
+ last || raise(RecordNotFoundError.new(self))
223
+ end
224
+
186
225
  # Get all records
187
226
  #
188
227
  # @return [Array<Model>]
@@ -560,31 +599,4 @@ module Tarsier
560
599
  klass.find_by(config[:foreign_key] => id)
561
600
  end
562
601
  end
563
-
564
- # Record not found error
565
- class RecordNotFoundError < Error
566
- # @return [Class] the model class
567
- attr_reader :model
568
-
569
- # @return [Object] the ID that was not found
570
- attr_reader :id
571
-
572
- def initialize(model, id)
573
- @model = model
574
- @id = id
575
- super("#{model.name} with #{model.primary_key}=#{id} not found")
576
- end
577
- end
578
-
579
- # Validation error
580
- class ValidationError < Error
581
- # @return [Hash] validation errors
582
- attr_reader :errors
583
-
584
- def initialize(errors)
585
- @errors = errors
586
- messages = errors.flat_map { |k, v| v.map { |m| "#{k} #{m}" } }
587
- super("Validation failed: #{messages.join(', ')}")
588
- end
589
- end
590
602
  end
@@ -152,7 +152,7 @@ module Tarsier
152
152
  raise TypeCoercionError.new(key, type, value) unless coercer
153
153
 
154
154
  coercer.call(value)
155
- rescue ArgumentError, TypeError => e
155
+ rescue ArgumentError, TypeError
156
156
  raise TypeCoercionError.new(key, type, value)
157
157
  end
158
158
  end
data/lib/tarsier/query.rb CHANGED
@@ -173,6 +173,14 @@ module Tarsier
173
173
  limit(1).to_a.first
174
174
  end
175
175
 
176
+ # Get first record or raise
177
+ #
178
+ # @return [Model]
179
+ # @raise [RecordNotFoundError]
180
+ def first!
181
+ first || raise(RecordNotFoundError.new(@model))
182
+ end
183
+
176
184
  # Get last record
177
185
  #
178
186
  # @return [Model, nil]
@@ -180,6 +188,14 @@ module Tarsier
180
188
  order(@model.primary_key => :desc).limit(1).to_a.first
181
189
  end
182
190
 
191
+ # Get last record or raise
192
+ #
193
+ # @return [Model]
194
+ # @raise [RecordNotFoundError]
195
+ def last!
196
+ last || raise(RecordNotFoundError.new(@model))
197
+ end
198
+
183
199
  # Get record count
184
200
  #
185
201
  # @return [Integer]
@@ -15,7 +15,7 @@ module Tarsier
15
15
  # @param env [Hash] Rack environment hash
16
16
  # @param route_params [Hash] parameters extracted from route matching
17
17
  def initialize(env, route_params = {})
18
- @env = env.freeze
18
+ @env = env
19
19
  @route_params = route_params.transform_keys(&:to_sym).freeze
20
20
  @parsed_body = nil
21
21
  @query_params = nil
@@ -201,6 +201,23 @@ module Tarsier
201
201
  %i[GET HEAD PUT DELETE OPTIONS].include?(method)
202
202
  end
203
203
 
204
+ # Convert request to hash (useful for debugging/logging)
205
+ # @return [Hash]
206
+ def to_h
207
+ {
208
+ method: method,
209
+ path: path,
210
+ url: url,
211
+ query_params: query_params,
212
+ headers: headers,
213
+ content_type: content_type,
214
+ ip: ip,
215
+ user_agent: user_agent
216
+ }
217
+ end
218
+
219
+ alias to_hash to_h
220
+
204
221
  private
205
222
 
206
223
  def parse_query_string
@@ -81,9 +81,17 @@ module Tarsier
81
81
 
82
82
  # Set content type
83
83
  # @param type [String] content type
84
- # @param charset [String] character set
84
+ # @param charset [String] character set (pass nil to omit)
85
85
  # @return [self]
86
- def content_type=(type, charset: "utf-8")
86
+ def content_type=(type)
87
+ set_header("Content-Type", type.include?(";") ? type : "#{type}; charset=utf-8")
88
+ end
89
+
90
+ # Set content type with options
91
+ # @param type [String] content type
92
+ # @param charset [String, nil] character set
93
+ # @return [self]
94
+ def set_content_type(type, charset: "utf-8")
87
95
  set_header("Content-Type", charset ? "#{type}; charset=#{charset}" : type)
88
96
  end
89
97
 
@@ -209,6 +217,12 @@ module Tarsier
209
217
  @sent
210
218
  end
211
219
 
220
+ # Get the status text for the current status code
221
+ # @return [String]
222
+ def status_text
223
+ STATUS_CODES[@status] || "Unknown"
224
+ end
225
+
212
226
  # Mark response as sent
213
227
  def mark_sent!
214
228
  @sent = true
@@ -49,10 +49,22 @@ module Tarsier
49
49
  tree = @trees[method.to_s.upcase.to_sym]
50
50
  return nil unless tree
51
51
 
52
- segments = path == "/" ? [""] : path.split("/").drop(1)
52
+ # Normalize path: remove trailing slash (except for root)
53
+ normalized_path = path.chomp("/")
54
+ normalized_path = "/" if normalized_path.empty?
55
+
56
+ segments = normalized_path == "/" ? [""] : normalized_path.split("/").drop(1)
53
57
  match_segments(tree, segments, {})
54
58
  end
55
59
 
60
+ # Check if a route exists for the given method and path
61
+ # @param method [Symbol] HTTP method
62
+ # @param path [String] the path to check
63
+ # @return [Boolean]
64
+ def route_exists?(method, path)
65
+ !match(method, path).nil?
66
+ end
67
+
56
68
  # Get a named route
57
69
  # @param name [Symbol] route name
58
70
  # @return [Route, nil]
@@ -42,6 +42,14 @@ module Tarsier
42
42
  @compiler.path_for(name, params)
43
43
  end
44
44
 
45
+ # Check if a route exists for the given method and path
46
+ # @param method [Symbol] HTTP method
47
+ # @param path [String] the path to check
48
+ # @return [Boolean]
49
+ def route_exists?(method, path)
50
+ @compiler.route_exists?(method, path)
51
+ end
52
+
45
53
  # Get all defined routes
46
54
  # @return [Array<Route>]
47
55
  def routes
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tarsier
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
@@ -91,7 +91,11 @@ module Tarsier
91
91
  break
92
92
  end
93
93
  end
94
- rescue IOError, Errno::ECONNRESET
94
+ rescue IOError, Errno::ECONNRESET => e
95
+ trigger(:error, e)
96
+ trigger(:close)
97
+ rescue StandardError => e
98
+ trigger(:error, e)
95
99
  trigger(:close)
96
100
  ensure
97
101
  cleanup
data/lib/tarsier.rb CHANGED
@@ -117,8 +117,16 @@ module Tarsier
117
117
  # Define routes for the application
118
118
  #
119
119
  # @yield [Router] the router instance
120
+ #
121
+ # @example In config/routes.rb
122
+ # Tarsier.routes do
123
+ # root to: "home#index"
124
+ # resources :users
125
+ # end
120
126
  def routes(&block)
121
- current_app&.routes(&block)
127
+ # If called without a current app, create one
128
+ @current_app ||= Application.new
129
+ @current_app.routes(&block)
122
130
  end
123
131
 
124
132
  # Access the current router
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tarsier
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eira Elif
@@ -159,7 +159,7 @@ extensions: []
159
159
  extra_rdoc_files: []
160
160
  files:
161
161
  - CHANGELOG.md
162
- - LICENSE.txt
162
+ - LICENSE
163
163
  - README.md
164
164
  - exe/tarsier
165
165
  - lib/tarsier.rb
File without changes