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 +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +72 -55
- data/lib/tarsier/application.rb +5 -0
- data/lib/tarsier/cli/generators/app.rb +15 -13
- data/lib/tarsier/cli/generators/base.rb +43 -10
- data/lib/tarsier/controller.rb +2 -1
- data/lib/tarsier/database.rb +32 -0
- data/lib/tarsier/errors.rb +19 -0
- data/lib/tarsier/middleware/base.rb +1 -1
- data/lib/tarsier/middleware/csrf.rb +10 -0
- data/lib/tarsier/middleware/logger.rb +22 -6
- data/lib/tarsier/middleware/rate_limit.rb +10 -1
- data/lib/tarsier/middleware/static.rb +6 -4
- data/lib/tarsier/model.rb +39 -27
- data/lib/tarsier/params.rb +1 -1
- data/lib/tarsier/query.rb +16 -0
- data/lib/tarsier/request.rb +18 -1
- data/lib/tarsier/response.rb +16 -2
- data/lib/tarsier/router/compiler.rb +13 -1
- data/lib/tarsier/router.rb +8 -0
- data/lib/tarsier/version.rb +1 -1
- data/lib/tarsier/websocket.rb +5 -1
- data/lib/tarsier.rb +9 -1
- metadata +2 -2
- /data/{LICENSE.txt → LICENSE} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf96475cd9f2aec9a31bc0c4686bfce7c54bc2e963bee68639b0c062847f6d11
|
|
4
|
+
data.tar.gz: 36028c91e5f5d7156f30b8f17ecc85ec8e9294f39db2b34b88978c7bbd335b32
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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="
|
|
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
|
|
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
|
|
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!
|
data/lib/tarsier/application.rb
CHANGED
|
@@ -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
|
-
#
|
|
138
|
-
# Routes are
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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)
|
data/lib/tarsier/controller.rb
CHANGED
|
@@ -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
|
data/lib/tarsier/database.rb
CHANGED
|
@@ -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]
|
data/lib/tarsier/errors.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
|
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
|
data/lib/tarsier/params.rb
CHANGED
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]
|
data/lib/tarsier/request.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/tarsier/response.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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]
|
data/lib/tarsier/router.rb
CHANGED
|
@@ -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
|
data/lib/tarsier/version.rb
CHANGED
data/lib/tarsier/websocket.rb
CHANGED
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
|
-
|
|
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.
|
|
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
|
|
162
|
+
- LICENSE
|
|
163
163
|
- README.md
|
|
164
164
|
- exe/tarsier
|
|
165
165
|
- lib/tarsier.rb
|
/data/{LICENSE.txt → LICENSE}
RENAMED
|
File without changes
|