tarsier 0.1.0

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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +175 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +984 -0
  5. data/exe/tarsier +7 -0
  6. data/lib/tarsier/application.rb +336 -0
  7. data/lib/tarsier/cli/commands/console.rb +87 -0
  8. data/lib/tarsier/cli/commands/generate.rb +85 -0
  9. data/lib/tarsier/cli/commands/help.rb +50 -0
  10. data/lib/tarsier/cli/commands/new.rb +59 -0
  11. data/lib/tarsier/cli/commands/routes.rb +139 -0
  12. data/lib/tarsier/cli/commands/server.rb +123 -0
  13. data/lib/tarsier/cli/commands/version.rb +14 -0
  14. data/lib/tarsier/cli/generators/app.rb +528 -0
  15. data/lib/tarsier/cli/generators/base.rb +93 -0
  16. data/lib/tarsier/cli/generators/controller.rb +91 -0
  17. data/lib/tarsier/cli/generators/middleware.rb +81 -0
  18. data/lib/tarsier/cli/generators/migration.rb +109 -0
  19. data/lib/tarsier/cli/generators/model.rb +109 -0
  20. data/lib/tarsier/cli/generators/resource.rb +27 -0
  21. data/lib/tarsier/cli/loader.rb +18 -0
  22. data/lib/tarsier/cli.rb +46 -0
  23. data/lib/tarsier/controller.rb +282 -0
  24. data/lib/tarsier/database.rb +588 -0
  25. data/lib/tarsier/errors.rb +77 -0
  26. data/lib/tarsier/middleware/base.rb +47 -0
  27. data/lib/tarsier/middleware/compression.rb +113 -0
  28. data/lib/tarsier/middleware/cors.rb +101 -0
  29. data/lib/tarsier/middleware/csrf.rb +88 -0
  30. data/lib/tarsier/middleware/logger.rb +74 -0
  31. data/lib/tarsier/middleware/rate_limit.rb +110 -0
  32. data/lib/tarsier/middleware/stack.rb +143 -0
  33. data/lib/tarsier/middleware/static.rb +124 -0
  34. data/lib/tarsier/model.rb +590 -0
  35. data/lib/tarsier/params.rb +269 -0
  36. data/lib/tarsier/query.rb +495 -0
  37. data/lib/tarsier/request.rb +274 -0
  38. data/lib/tarsier/response.rb +282 -0
  39. data/lib/tarsier/router/compiler.rb +173 -0
  40. data/lib/tarsier/router/node.rb +97 -0
  41. data/lib/tarsier/router/route.rb +119 -0
  42. data/lib/tarsier/router.rb +272 -0
  43. data/lib/tarsier/version.rb +5 -0
  44. data/lib/tarsier/websocket.rb +275 -0
  45. data/lib/tarsier.rb +167 -0
  46. data/sig/tarsier.rbs +485 -0
  47. metadata +230 -0
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ class CLI
5
+ module Generators
6
+ class Middleware < Base
7
+ def generate
8
+ create_file(middleware_path, middleware_content)
9
+ create_file(spec_path, spec_content)
10
+ puts "\nMiddleware generated successfully!"
11
+ puts "Add to your application:"
12
+ puts " use #{camelize(name)}Middleware"
13
+ end
14
+
15
+ private
16
+
17
+ def middleware_path
18
+ "app/middleware/#{underscore(name)}_middleware.rb"
19
+ end
20
+
21
+ def spec_path
22
+ "spec/middleware/#{underscore(name)}_middleware_spec.rb"
23
+ end
24
+
25
+ def middleware_content
26
+ <<~RUBY
27
+ # frozen_string_literal: true
28
+
29
+ class #{camelize(name)}Middleware < Tarsier::Middleware::Base
30
+ def call(request, response)
31
+ # Before request processing
32
+ before(request, response)
33
+
34
+ # Call next middleware/app
35
+ @app.call(request, response)
36
+
37
+ # After request processing
38
+ after(request, response)
39
+
40
+ response
41
+ end
42
+
43
+ private
44
+
45
+ def before(request, response)
46
+ # Add your before logic here
47
+ end
48
+
49
+ def after(request, response)
50
+ # Add your after logic here
51
+ end
52
+ end
53
+ RUBY
54
+ end
55
+
56
+ def spec_content
57
+ <<~RUBY
58
+ # frozen_string_literal: true
59
+
60
+ require "spec_helper"
61
+
62
+ RSpec.describe #{camelize(name)}Middleware do
63
+ let(:app) { ->(req, res) { res } }
64
+ let(:middleware) { described_class.new(app) }
65
+
66
+ describe "#call" do
67
+ it "passes request to next middleware" do
68
+ request = double("request")
69
+ response = double("response")
70
+
71
+ expect(app).to receive(:call).with(request, response)
72
+ middleware.call(request, response)
73
+ end
74
+ end
75
+ end
76
+ RUBY
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ class CLI
5
+ module Generators
6
+ class Migration < Base
7
+ def initialize(name, columns = [])
8
+ super(name)
9
+ @columns = parse_columns(columns)
10
+ @timestamp = Time.now.strftime("%Y%m%d%H%M%S")
11
+ end
12
+
13
+ def generate
14
+ create_file(migration_path, migration_content)
15
+ puts "\nMigration generated successfully!"
16
+ puts "Run: tarsier db:migrate"
17
+ end
18
+
19
+ private
20
+
21
+ def parse_columns(cols)
22
+ cols.map do |col|
23
+ name, type = col.split(":")
24
+ type ||= "string"
25
+ { name: name, type: type }
26
+ end
27
+ end
28
+
29
+ def migration_path
30
+ "db/migrations/#{@timestamp}_#{underscore(name)}.rb"
31
+ end
32
+
33
+ def migration_content
34
+ if name.start_with?("create_")
35
+ create_table_migration
36
+ elsif name.start_with?("add_")
37
+ add_column_migration
38
+ else
39
+ generic_migration
40
+ end
41
+ end
42
+
43
+ def create_table_migration
44
+ table_name = name.sub(/^create_/, "")
45
+ <<~RUBY
46
+ # frozen_string_literal: true
47
+
48
+ class #{camelize(name)} < Tarsier::Migration
49
+ def up
50
+ create_table :#{table_name} do |t|
51
+ #{column_definitions}
52
+ t.timestamps
53
+ end
54
+ end
55
+
56
+ def down
57
+ drop_table :#{table_name}
58
+ end
59
+ end
60
+ RUBY
61
+ end
62
+
63
+ def add_column_migration
64
+ match = name.match(/^add_(.+)_to_(.+)$/)
65
+ column_name = match ? match[1] : "column"
66
+ table_name = match ? match[2] : "table"
67
+
68
+ <<~RUBY
69
+ # frozen_string_literal: true
70
+
71
+ class #{camelize(name)} < Tarsier::Migration
72
+ def up
73
+ add_column :#{table_name}, :#{column_name}, :string
74
+ end
75
+
76
+ def down
77
+ remove_column :#{table_name}, :#{column_name}
78
+ end
79
+ end
80
+ RUBY
81
+ end
82
+
83
+ def generic_migration
84
+ <<~RUBY
85
+ # frozen_string_literal: true
86
+
87
+ class #{camelize(name)} < Tarsier::Migration
88
+ def up
89
+ # Add your migration code here
90
+ end
91
+
92
+ def down
93
+ # Add rollback code here
94
+ end
95
+ end
96
+ RUBY
97
+ end
98
+
99
+ def column_definitions
100
+ return " # Add columns here" if @columns.empty?
101
+
102
+ @columns.map do |col|
103
+ " t.#{col[:type]} :#{col[:name]}"
104
+ end.join("\n")
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ class CLI
5
+ module Generators
6
+ class Model < Base
7
+ TYPES = {
8
+ "string" => ":string",
9
+ "text" => ":text",
10
+ "integer" => ":integer",
11
+ "float" => ":float",
12
+ "boolean" => ":boolean",
13
+ "datetime" => ":datetime",
14
+ "date" => ":date",
15
+ "json" => ":json"
16
+ }.freeze
17
+
18
+ def initialize(name, attributes = [])
19
+ super(name)
20
+ @attributes = parse_attributes(attributes)
21
+ end
22
+
23
+ def generate
24
+ create_file(model_path, model_content)
25
+ create_file(spec_path, spec_content)
26
+ puts "\nModel generated successfully!"
27
+ end
28
+
29
+ private
30
+
31
+ def parse_attributes(attrs)
32
+ attrs.map do |attr|
33
+ name, type = attr.split(":")
34
+ type ||= "string"
35
+ { name: name, type: TYPES[type] || ":string" }
36
+ end
37
+ end
38
+
39
+ def model_path
40
+ "app/models/#{underscore(name)}.rb"
41
+ end
42
+
43
+ def spec_path
44
+ "spec/models/#{underscore(name)}_spec.rb"
45
+ end
46
+
47
+ def model_content
48
+ <<~RUBY
49
+ # frozen_string_literal: true
50
+
51
+ class #{camelize(name)} < Tarsier::Model
52
+ table_name "#{pluralize(underscore(name))}"
53
+
54
+ #{attribute_definitions}
55
+ #{validation_definitions}
56
+ end
57
+ RUBY
58
+ end
59
+
60
+ def attribute_definitions
61
+ return " # Add attributes here" if @attributes.empty?
62
+
63
+ @attributes.map do |attr|
64
+ " attribute :#{attr[:name]}, #{attr[:type]}"
65
+ end.join("\n")
66
+ end
67
+
68
+ def validation_definitions
69
+ return "" if @attributes.empty?
70
+
71
+ "\n" + @attributes.map do |attr|
72
+ " # validates :#{attr[:name]}, presence: true"
73
+ end.join("\n")
74
+ end
75
+
76
+ def spec_content
77
+ <<~RUBY
78
+ # frozen_string_literal: true
79
+
80
+ require "spec_helper"
81
+
82
+ RSpec.describe #{camelize(name)} do
83
+ describe "validations" do
84
+ #{validation_specs}
85
+ end
86
+
87
+ describe "associations" do
88
+ pending "add association tests"
89
+ end
90
+ end
91
+ RUBY
92
+ end
93
+
94
+ def validation_specs
95
+ return ' pending "add validation tests"' if @attributes.empty?
96
+
97
+ @attributes.map do |attr|
98
+ <<~RUBY.chomp
99
+ it "has #{attr[:name]} attribute" do
100
+ model = described_class.new(#{attr[:name]}: "test")
101
+ expect(model.#{attr[:name]}).to eq("test")
102
+ end
103
+ RUBY
104
+ end.join("\n\n")
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ class CLI
5
+ module Generators
6
+ class Resource < Base
7
+ ACTIONS = %w[index show new create edit update destroy].freeze
8
+
9
+ def initialize(name, attributes = [])
10
+ super(name)
11
+ @attributes = attributes
12
+ end
13
+
14
+ def generate
15
+ puts "Generating resource: #{name}"
16
+ puts
17
+
18
+ Model.new(name, @attributes).generate
19
+ Controller.new(name, ACTIONS).generate
20
+
21
+ puts "\nAdd to config/routes.rb:"
22
+ puts " resources :#{pluralize(underscore(name))}"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load all CLI components
4
+ require_relative "../cli"
5
+ require_relative "commands/help"
6
+ require_relative "commands/version"
7
+ require_relative "commands/new"
8
+ require_relative "commands/generate"
9
+ require_relative "commands/server"
10
+ require_relative "commands/console"
11
+ require_relative "commands/routes"
12
+ require_relative "generators/base"
13
+ require_relative "generators/controller"
14
+ require_relative "generators/model"
15
+ require_relative "generators/resource"
16
+ require_relative "generators/middleware"
17
+ require_relative "generators/migration"
18
+ require_relative "generators/app"
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "fileutils"
5
+
6
+ module Tarsier
7
+ # Command-line interface for Tarsier framework
8
+ class CLI
9
+ COMMANDS = %w[new generate server console routes version help].freeze
10
+ ALIASES = { "g" => "generate", "s" => "server", "c" => "console", "r" => "routes", "n" => "new", "v" => "version" }.freeze
11
+
12
+ class << self
13
+ def start(args = ARGV)
14
+ new(args).run
15
+ end
16
+ end
17
+
18
+ def initialize(args)
19
+ @args = args
20
+ @command = resolve_command(args.shift)
21
+ end
22
+
23
+ def run
24
+ case @command
25
+ when "new" then Commands::New.run(@args)
26
+ when "generate" then Commands::Generate.run(@args)
27
+ when "server" then Commands::Server.run(@args)
28
+ when "console" then Commands::Console.run(@args)
29
+ when "routes" then Commands::Routes.run(@args)
30
+ when "version" then Commands::Version.run(@args)
31
+ when "help", nil then Commands::Help.run(@args)
32
+ else
33
+ puts "Unknown command: #{@command}"
34
+ Commands::Help.run([])
35
+ exit 1
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def resolve_command(cmd)
42
+ return nil if cmd.nil?
43
+ ALIASES[cmd] || cmd
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ # Base controller class with filters, parameter validation, and rendering
5
+ class Controller
6
+ class << self
7
+ # Define parameter schema for actions
8
+ # @yield [ParamSchema] schema builder
9
+ def params(&block)
10
+ @param_schema ||= ParamSchema.new
11
+ @param_schema.instance_eval(&block) if block_given?
12
+ @param_schema
13
+ end
14
+
15
+ # Get parameter schema
16
+ # @return [ParamSchema, nil]
17
+ def param_schema
18
+ @param_schema
19
+ end
20
+
21
+ # Define a before filter
22
+ # @param method_name [Symbol] filter method name
23
+ # @param only [Array<Symbol>] actions to apply to
24
+ # @param except [Array<Symbol>] actions to exclude
25
+ def before_action(method_name, only: nil, except: nil)
26
+ filters[:before] << { method: method_name, only: only, except: except }
27
+ end
28
+
29
+ # Define an after filter
30
+ # @param method_name [Symbol] filter method name
31
+ # @param only [Array<Symbol>] actions to apply to
32
+ # @param except [Array<Symbol>] actions to exclude
33
+ def after_action(method_name, only: nil, except: nil)
34
+ filters[:after] << { method: method_name, only: only, except: except }
35
+ end
36
+
37
+ # Define an around filter
38
+ # @param method_name [Symbol] filter method name
39
+ # @param only [Array<Symbol>] actions to apply to
40
+ # @param except [Array<Symbol>] actions to exclude
41
+ def around_action(method_name, only: nil, except: nil)
42
+ filters[:around] << { method: method_name, only: only, except: except }
43
+ end
44
+
45
+ # Get all filters
46
+ # @return [Hash]
47
+ def filters
48
+ @filters ||= { before: [], after: [], around: [] }
49
+ end
50
+
51
+ # Rescue from specific exceptions
52
+ # @param exception_class [Class] exception class to rescue
53
+ # @param with [Symbol] handler method name
54
+ def rescue_from(exception_class, with:)
55
+ rescue_handlers[exception_class] = with
56
+ end
57
+
58
+ # Get rescue handlers
59
+ # @return [Hash]
60
+ def rescue_handlers
61
+ @rescue_handlers ||= {}
62
+ end
63
+
64
+ # Skip a before filter
65
+ # @param method_name [Symbol] filter method name
66
+ # @param only [Array<Symbol>] actions to skip for
67
+ # @param except [Array<Symbol>] actions to not skip for
68
+ def skip_before_action(method_name, only: nil, except: nil)
69
+ skipped_filters[:before] << { method: method_name, only: only, except: except }
70
+ end
71
+
72
+ # Get skipped filters
73
+ # @return [Hash]
74
+ def skipped_filters
75
+ @skipped_filters ||= { before: [], after: [], around: [] }
76
+ end
77
+ end
78
+
79
+ attr_reader :request, :response, :params, :action_name
80
+
81
+ # @param request [Request] the request object
82
+ # @param response [Response] the response object
83
+ # @param action [Symbol] the action to execute
84
+ def initialize(request, response, action)
85
+ @request = request
86
+ @response = response
87
+ @action_name = action.to_sym
88
+ @params = request.params
89
+ @halted = false
90
+ end
91
+
92
+ # Execute the controller action with filters
93
+ # @return [Response]
94
+ def dispatch
95
+ run_filters(:before)
96
+ return @response if halted?
97
+
98
+ run_around_filters do
99
+ validate_params! if self.class.param_schema
100
+ send(@action_name)
101
+ end
102
+
103
+ run_filters(:after) unless halted?
104
+ @response
105
+ rescue StandardError => e
106
+ handle_exception(e)
107
+ end
108
+
109
+ # Get validated parameters
110
+ # @return [Hash]
111
+ def validated_params
112
+ @validated_params ||= {}
113
+ end
114
+
115
+ # Render a response
116
+ # @param options [Hash] render options
117
+ def render(json: nil, html: nil, text: nil, status: 200, **options)
118
+ @response.status = status
119
+
120
+ if json
121
+ @response.json(json, status: status)
122
+ elsif html
123
+ @response.html(html, status: status)
124
+ elsif text
125
+ @response.text(text, status: status)
126
+ end
127
+ end
128
+
129
+ # Redirect to another URL
130
+ # @param url [String] redirect URL
131
+ # @param status [Integer] HTTP status code
132
+ def redirect_to(url, status: 302)
133
+ @response.redirect(url, status: status)
134
+ halt!
135
+ end
136
+
137
+ # Send a file
138
+ # @param path [String] file path
139
+ # @param options [Hash] options
140
+ def send_file(path, content_type: nil, filename: nil, disposition: "attachment")
141
+ raise Errno::ENOENT, path unless File.exist?(path)
142
+
143
+ content_type ||= guess_content_type(path)
144
+ filename ||= File.basename(path)
145
+
146
+ @response.set_header("Content-Type", content_type)
147
+ @response.set_header("Content-Disposition", "#{disposition}; filename=\"#{filename}\"")
148
+ @response.body = File.read(path)
149
+ end
150
+
151
+ # Stream response
152
+ # @yield [StreamWriter] stream writer
153
+ def stream(&block)
154
+ @response.stream(&block)
155
+ end
156
+
157
+ # Head-only response
158
+ # @param status [Integer] HTTP status code
159
+ # @param headers [Hash] additional headers
160
+ def head(status, **headers)
161
+ @response.status = status
162
+ headers.each { |k, v| @response.set_header(k.to_s, v) }
163
+ @response.body = ""
164
+ end
165
+
166
+ # Halt request processing
167
+ def halt!
168
+ @halted = true
169
+ end
170
+
171
+ # Check if processing is halted
172
+ # @return [Boolean]
173
+ def halted?
174
+ @halted
175
+ end
176
+
177
+ # Access session (if middleware is loaded)
178
+ # @return [Hash]
179
+ def session
180
+ @request.env["tarsier.session"] ||= {}
181
+ end
182
+
183
+ # Access flash messages
184
+ # @return [Hash]
185
+ def flash
186
+ session[:flash] ||= {}
187
+ end
188
+
189
+ protected
190
+
191
+ # Override in subclasses for custom authentication
192
+ def authenticate
193
+ # Default: no-op
194
+ end
195
+
196
+ # Override in subclasses for custom authorization
197
+ def authorize
198
+ # Default: no-op
199
+ end
200
+
201
+ private
202
+
203
+ def run_filters(type)
204
+ applicable_filters(type).each do |filter|
205
+ break if halted?
206
+ send(filter[:method])
207
+ end
208
+ end
209
+
210
+ def run_around_filters(&block)
211
+ around_filters = applicable_filters(:around)
212
+
213
+ if around_filters.empty?
214
+ yield
215
+ else
216
+ chain = around_filters.reverse.reduce(block) do |next_filter, filter|
217
+ -> { send(filter[:method]) { next_filter.call } }
218
+ end
219
+ chain.call
220
+ end
221
+ end
222
+
223
+ def applicable_filters(type)
224
+ all_filters = self.class.filters[type]
225
+ skipped = self.class.skipped_filters[type]
226
+
227
+ all_filters.select do |filter|
228
+ applies_to_action?(filter) && !skipped_for_action?(filter, skipped)
229
+ end
230
+ end
231
+
232
+ def applies_to_action?(filter)
233
+ return false if filter[:only] && !filter[:only].include?(@action_name)
234
+ return false if filter[:except] && filter[:except].include?(@action_name)
235
+ true
236
+ end
237
+
238
+ def skipped_for_action?(filter, skipped)
239
+ skipped.any? do |skip|
240
+ skip[:method] == filter[:method] && applies_to_action?(skip)
241
+ end
242
+ end
243
+
244
+ def validate_params!
245
+ @validated_params = self.class.param_schema.validate(@params)
246
+ end
247
+
248
+ def handle_exception(exception)
249
+ handler = find_rescue_handler(exception.class)
250
+
251
+ if handler
252
+ send(handler, exception)
253
+ @response
254
+ else
255
+ raise exception
256
+ end
257
+ end
258
+
259
+ def find_rescue_handler(exception_class)
260
+ self.class.rescue_handlers.each do |klass, handler|
261
+ return handler if exception_class <= klass
262
+ end
263
+ nil
264
+ end
265
+
266
+ def guess_content_type(path)
267
+ ext = File.extname(path).downcase
268
+ {
269
+ ".html" => "text/html",
270
+ ".css" => "text/css",
271
+ ".js" => "application/javascript",
272
+ ".json" => "application/json",
273
+ ".xml" => "application/xml",
274
+ ".png" => "image/png",
275
+ ".jpg" => "image/jpeg",
276
+ ".gif" => "image/gif",
277
+ ".svg" => "image/svg+xml",
278
+ ".pdf" => "application/pdf"
279
+ }[ext] || "application/octet-stream"
280
+ end
281
+ end
282
+ end