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,588 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ # Database abstraction layer with support for SQLite, PostgreSQL, and MySQL
5
+ #
6
+ # Provides a unified interface for database operations across different
7
+ # adapters. Connections are lazy-loaded and pooled for performance.
8
+ #
9
+ # @example Connect to SQLite
10
+ # Tarsier.db :sqlite, 'app.db'
11
+ #
12
+ # @example Connect to PostgreSQL
13
+ # Tarsier.db :postgres, 'postgres://localhost/myapp'
14
+ #
15
+ # @example Raw queries
16
+ # Tarsier.database.execute('SELECT * FROM users WHERE id = ?', 1)
17
+ #
18
+ # @since 0.1.0
19
+ module Database
20
+ # Supported database adapters
21
+ ADAPTERS = {
22
+ sqlite: "SQLite",
23
+ sqlite3: "SQLite",
24
+ postgres: "PostgreSQL",
25
+ postgresql: "PostgreSQL",
26
+ pg: "PostgreSQL",
27
+ mysql: "MySQL",
28
+ mysql2: "MySQL"
29
+ }.freeze
30
+
31
+ class << self
32
+ # @return [Connection, nil] the current database connection
33
+ attr_reader :connection
34
+
35
+ # Establish a database connection
36
+ #
37
+ # @param adapter [Symbol] database adapter
38
+ # @param connection_string [String, nil] connection URL or path
39
+ # @param options [Hash] connection options
40
+ # @return [Connection]
41
+ # @raise [DatabaseError] if adapter is unsupported
42
+ #
43
+ # @example
44
+ # Database.connect(:sqlite, 'db/app.db')
45
+ # Database.connect(:postgres, host: 'localhost', database: 'myapp')
46
+ def connect(adapter, connection_string = nil, **options)
47
+ adapter_name = ADAPTERS[adapter.to_sym]
48
+ raise DatabaseError, "Unsupported adapter: #{adapter}" unless adapter_name
49
+
50
+ @connection = Connection.new(adapter, connection_string, **options)
51
+ end
52
+
53
+ # Disconnect from database
54
+ #
55
+ # @return [void]
56
+ def disconnect
57
+ @connection&.close
58
+ @connection = nil
59
+ end
60
+
61
+ # Check if connected
62
+ #
63
+ # @return [Boolean]
64
+ def connected?
65
+ @connection&.connected? || false
66
+ end
67
+ end
68
+
69
+ # Database connection wrapper
70
+ #
71
+ # Provides a unified interface for executing queries across different
72
+ # database adapters. Handles connection pooling and query execution.
73
+ #
74
+ # @since 0.1.0
75
+ class Connection
76
+ # @return [Symbol] the database adapter
77
+ attr_reader :adapter
78
+
79
+ # @return [Object] the underlying database connection
80
+ attr_reader :raw_connection
81
+
82
+ # Create a new database connection
83
+ #
84
+ # @param adapter [Symbol] database adapter
85
+ # @param connection_string [String, nil] connection URL or path
86
+ # @param options [Hash] connection options
87
+ def initialize(adapter, connection_string = nil, **options)
88
+ @adapter = normalize_adapter(adapter)
89
+ @options = parse_connection(connection_string, options)
90
+ @raw_connection = nil
91
+ @connected = false
92
+ end
93
+
94
+ # Execute a SQL query
95
+ #
96
+ # @param sql [String] SQL query
97
+ # @param params [Array] query parameters
98
+ # @return [Array<Hash>] query results
99
+ #
100
+ # @example
101
+ # connection.execute('SELECT * FROM users WHERE id = ?', 1)
102
+ def execute(sql, *params)
103
+ ensure_connected
104
+ execute_query(sql, params)
105
+ end
106
+
107
+ # Execute a query and return first result
108
+ #
109
+ # @param sql [String] SQL query
110
+ # @param params [Array] query parameters
111
+ # @return [Hash, nil]
112
+ def get(sql, *params)
113
+ execute(sql, *params).first
114
+ end
115
+
116
+ # Insert a record and return the ID
117
+ #
118
+ # @param table [String, Symbol] table name
119
+ # @param attributes [Hash] column values
120
+ # @return [Integer] inserted row ID
121
+ def insert(table, attributes)
122
+ columns = attributes.keys.join(", ")
123
+ placeholders = (["?"] * attributes.size).join(", ")
124
+ sql = "INSERT INTO #{table} (#{columns}) VALUES (#{placeholders})"
125
+
126
+ execute(sql, *attributes.values)
127
+ last_insert_id
128
+ end
129
+
130
+ # Update records
131
+ #
132
+ # @param table [String, Symbol] table name
133
+ # @param attributes [Hash] column values to update
134
+ # @param conditions [Hash] WHERE conditions
135
+ # @return [Integer] number of affected rows
136
+ def update(table, attributes, conditions = {})
137
+ set_clause = attributes.keys.map { |k| "#{k} = ?" }.join(", ")
138
+ where_clause, where_values = build_where(conditions)
139
+
140
+ sql = "UPDATE #{table} SET #{set_clause}"
141
+ sql += " WHERE #{where_clause}" unless where_clause.empty?
142
+
143
+ execute(sql, *attributes.values, *where_values)
144
+ affected_rows
145
+ end
146
+
147
+ # Delete records
148
+ #
149
+ # @param table [String, Symbol] table name
150
+ # @param conditions [Hash] WHERE conditions
151
+ # @return [Integer] number of affected rows
152
+ def delete(table, conditions = {})
153
+ where_clause, where_values = build_where(conditions)
154
+
155
+ sql = "DELETE FROM #{table}"
156
+ sql += " WHERE #{where_clause}" unless where_clause.empty?
157
+
158
+ execute(sql, *where_values)
159
+ affected_rows
160
+ end
161
+
162
+ # Check if connected
163
+ #
164
+ # @return [Boolean]
165
+ def connected?
166
+ @connected && !@raw_connection.nil?
167
+ end
168
+
169
+ # Close the connection
170
+ #
171
+ # @return [void]
172
+ def close
173
+ return unless @raw_connection
174
+
175
+ case @adapter
176
+ when :sqlite
177
+ @raw_connection.close
178
+ when :postgres
179
+ @raw_connection.close
180
+ when :mysql
181
+ @raw_connection.close
182
+ end
183
+
184
+ @raw_connection = nil
185
+ @connected = false
186
+ end
187
+
188
+ # Run migrations
189
+ #
190
+ # @param direction [Symbol] :up or :down
191
+ # @return [void]
192
+ def migrate(direction = :up)
193
+ migrations_path = File.join(Tarsier.root, "db", "migrations")
194
+ return unless File.directory?(migrations_path)
195
+
196
+ create_migrations_table
197
+
198
+ Dir.glob(File.join(migrations_path, "*.rb")).sort.each do |file|
199
+ version = File.basename(file).split("_").first
200
+ next if direction == :up && migrated?(version)
201
+ next if direction == :down && !migrated?(version)
202
+
203
+ require file
204
+ class_name = File.basename(file, ".rb").split("_")[1..].map(&:capitalize).join
205
+ migration = Object.const_get(class_name).new(self)
206
+
207
+ if direction == :up
208
+ migration.up
209
+ record_migration(version)
210
+ else
211
+ migration.down
212
+ remove_migration(version)
213
+ end
214
+ end
215
+ end
216
+
217
+ # Begin a transaction
218
+ #
219
+ # @yield block to execute within transaction
220
+ # @return [Object] block result
221
+ def transaction
222
+ execute("BEGIN")
223
+ result = yield
224
+ execute("COMMIT")
225
+ result
226
+ rescue StandardError => e
227
+ execute("ROLLBACK")
228
+ raise e
229
+ end
230
+
231
+ private
232
+
233
+ def normalize_adapter(adapter)
234
+ case adapter.to_sym
235
+ when :sqlite, :sqlite3 then :sqlite
236
+ when :postgres, :postgresql, :pg then :postgres
237
+ when :mysql, :mysql2 then :mysql
238
+ else adapter.to_sym
239
+ end
240
+ end
241
+
242
+ def parse_connection(connection_string, options)
243
+ return options unless connection_string
244
+
245
+ if connection_string.is_a?(String) && connection_string.include?("://")
246
+ parse_url(connection_string)
247
+ else
248
+ options.merge(database: connection_string)
249
+ end
250
+ end
251
+
252
+ def parse_url(url)
253
+ uri = URI.parse(url)
254
+ {
255
+ host: uri.host,
256
+ port: uri.port,
257
+ database: uri.path[1..],
258
+ username: uri.user,
259
+ password: uri.password
260
+ }.compact
261
+ end
262
+
263
+ def ensure_connected
264
+ return if connected?
265
+
266
+ @raw_connection = create_connection
267
+ @connected = true
268
+ end
269
+
270
+ def create_connection
271
+ case @adapter
272
+ when :sqlite
273
+ require "sqlite3"
274
+ SQLite3::Database.new(@options[:database] || ":memory:")
275
+ when :postgres
276
+ require "pg"
277
+ PG.connect(**pg_options)
278
+ when :mysql
279
+ require "mysql2"
280
+ Mysql2::Client.new(**mysql_options)
281
+ else
282
+ raise DatabaseError, "Unsupported adapter: #{@adapter}"
283
+ end
284
+ end
285
+
286
+ def pg_options
287
+ {
288
+ host: @options[:host] || "localhost",
289
+ port: @options[:port] || 5432,
290
+ dbname: @options[:database],
291
+ user: @options[:username],
292
+ password: @options[:password]
293
+ }.compact
294
+ end
295
+
296
+ def mysql_options
297
+ {
298
+ host: @options[:host] || "localhost",
299
+ port: @options[:port] || 3306,
300
+ database: @options[:database],
301
+ username: @options[:username],
302
+ password: @options[:password]
303
+ }.compact
304
+ end
305
+
306
+ def execute_query(sql, params)
307
+ case @adapter
308
+ when :sqlite
309
+ execute_sqlite(sql, params)
310
+ when :postgres
311
+ execute_postgres(sql, params)
312
+ when :mysql
313
+ execute_mysql(sql, params)
314
+ end
315
+ end
316
+
317
+ def execute_sqlite(sql, params)
318
+ @raw_connection.results_as_hash = true
319
+ stmt = @raw_connection.prepare(sql)
320
+ result = stmt.execute(*params)
321
+ result.to_a.map { |row| symbolize_keys(row) }
322
+ ensure
323
+ stmt&.close
324
+ end
325
+
326
+ def execute_postgres(sql, params)
327
+ # Convert ? placeholders to $1, $2, etc.
328
+ pg_sql = sql.gsub(/\?/).with_index(1) { |_, i| "$#{i}" }
329
+ result = @raw_connection.exec_params(pg_sql, params)
330
+ result.map { |row| symbolize_keys(row) }
331
+ end
332
+
333
+ def execute_mysql(sql, params)
334
+ stmt = @raw_connection.prepare(sql)
335
+ result = stmt.execute(*params)
336
+ result&.to_a&.map { |row| symbolize_keys(row) } || []
337
+ end
338
+
339
+ def symbolize_keys(hash)
340
+ hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
341
+ end
342
+
343
+ def last_insert_id
344
+ case @adapter
345
+ when :sqlite
346
+ @raw_connection.last_insert_row_id
347
+ when :postgres
348
+ execute("SELECT lastval()").first&.values&.first
349
+ when :mysql
350
+ @raw_connection.last_id
351
+ end
352
+ end
353
+
354
+ def affected_rows
355
+ case @adapter
356
+ when :sqlite
357
+ @raw_connection.changes
358
+ when :postgres
359
+ @raw_connection.cmd_tuples
360
+ when :mysql
361
+ @raw_connection.affected_rows
362
+ end
363
+ end
364
+
365
+ def build_where(conditions)
366
+ return ["", []] if conditions.empty?
367
+
368
+ clauses = conditions.map { |k, _| "#{k} = ?" }
369
+ [clauses.join(" AND "), conditions.values]
370
+ end
371
+
372
+ def create_migrations_table
373
+ sql = <<~SQL
374
+ CREATE TABLE IF NOT EXISTS schema_migrations (
375
+ version VARCHAR(255) PRIMARY KEY
376
+ )
377
+ SQL
378
+ execute(sql)
379
+ end
380
+
381
+ def migrated?(version)
382
+ result = execute("SELECT version FROM schema_migrations WHERE version = ?", version)
383
+ result.any?
384
+ end
385
+
386
+ def record_migration(version)
387
+ execute("INSERT INTO schema_migrations (version) VALUES (?)", version)
388
+ end
389
+
390
+ def remove_migration(version)
391
+ execute("DELETE FROM schema_migrations WHERE version = ?", version)
392
+ end
393
+ end
394
+
395
+ # Base class for migrations
396
+ #
397
+ # @example
398
+ # class CreateUsers < Tarsier::Database::Migration
399
+ # def up
400
+ # create_table :users do |t|
401
+ # t.string :name
402
+ # t.string :email
403
+ # t.timestamps
404
+ # end
405
+ # end
406
+ #
407
+ # def down
408
+ # drop_table :users
409
+ # end
410
+ # end
411
+ #
412
+ # @since 0.1.0
413
+ class Migration
414
+ # @param connection [Connection] database connection
415
+ def initialize(connection)
416
+ @connection = connection
417
+ end
418
+
419
+ # Override in subclass
420
+ def up
421
+ raise NotImplementedError
422
+ end
423
+
424
+ # Override in subclass
425
+ def down
426
+ raise NotImplementedError
427
+ end
428
+
429
+ # Create a table
430
+ #
431
+ # @param name [Symbol, String] table name
432
+ # @yield [TableDefinition] table definition
433
+ def create_table(name, &block)
434
+ definition = TableDefinition.new(name)
435
+ definition.instance_eval(&block) if block_given?
436
+ @connection.execute(definition.to_sql)
437
+ end
438
+
439
+ # Drop a table
440
+ #
441
+ # @param name [Symbol, String] table name
442
+ def drop_table(name)
443
+ @connection.execute("DROP TABLE IF EXISTS #{name}")
444
+ end
445
+
446
+ # Add a column
447
+ #
448
+ # @param table [Symbol, String] table name
449
+ # @param column [Symbol, String] column name
450
+ # @param type [Symbol] column type
451
+ # @param options [Hash] column options
452
+ def add_column(table, column, type, **options)
453
+ sql_type = TableDefinition.sql_type(type, options)
454
+ @connection.execute("ALTER TABLE #{table} ADD COLUMN #{column} #{sql_type}")
455
+ end
456
+
457
+ # Remove a column
458
+ #
459
+ # @param table [Symbol, String] table name
460
+ # @param column [Symbol, String] column name
461
+ def remove_column(table, column)
462
+ @connection.execute("ALTER TABLE #{table} DROP COLUMN #{column}")
463
+ end
464
+
465
+ # Add an index
466
+ #
467
+ # @param table [Symbol, String] table name
468
+ # @param columns [Array<Symbol>] column names
469
+ # @param options [Hash] index options
470
+ def add_index(table, columns, unique: false, name: nil)
471
+ columns = Array(columns)
472
+ index_name = name || "idx_#{table}_#{columns.join('_')}"
473
+ unique_str = unique ? "UNIQUE " : ""
474
+ @connection.execute("CREATE #{unique_str}INDEX #{index_name} ON #{table} (#{columns.join(', ')})")
475
+ end
476
+
477
+ # Execute raw SQL
478
+ #
479
+ # @param sql [String] SQL statement
480
+ def execute(sql)
481
+ @connection.execute(sql)
482
+ end
483
+ end
484
+
485
+ # Table definition builder for migrations
486
+ #
487
+ # @since 0.1.0
488
+ class TableDefinition
489
+ TYPE_MAP = {
490
+ string: "VARCHAR(255)",
491
+ text: "TEXT",
492
+ integer: "INTEGER",
493
+ bigint: "BIGINT",
494
+ float: "REAL",
495
+ decimal: "DECIMAL",
496
+ boolean: "BOOLEAN",
497
+ date: "DATE",
498
+ datetime: "DATETIME",
499
+ timestamp: "TIMESTAMP",
500
+ time: "TIME",
501
+ binary: "BLOB",
502
+ json: "JSON"
503
+ }.freeze
504
+
505
+ # @param name [Symbol, String] table name
506
+ def initialize(name)
507
+ @name = name
508
+ @columns = []
509
+ @columns << "id INTEGER PRIMARY KEY AUTOINCREMENT"
510
+ end
511
+
512
+ # Add a column
513
+ #
514
+ # @param name [Symbol, String] column name
515
+ # @param type [Symbol] column type
516
+ # @param options [Hash] column options
517
+ def column(name, type, **options)
518
+ @columns << "#{name} #{self.class.sql_type(type, options)}"
519
+ end
520
+
521
+ # Column type shortcuts
522
+ %i[string text integer bigint float decimal boolean date datetime timestamp time binary json].each do |type|
523
+ define_method(type) do |name, **options|
524
+ column(name, type, **options)
525
+ end
526
+ end
527
+
528
+ # Add timestamps (created_at, updated_at)
529
+ def timestamps
530
+ @columns << "created_at DATETIME"
531
+ @columns << "updated_at DATETIME"
532
+ end
533
+
534
+ # Add foreign key reference
535
+ #
536
+ # @param table [Symbol, String] referenced table
537
+ # @param options [Hash] reference options
538
+ def references(table, foreign_key: true, **options)
539
+ column_name = "#{table}_id"
540
+ @columns << "#{column_name} INTEGER"
541
+ @columns << "FOREIGN KEY (#{column_name}) REFERENCES #{table}(id)" if foreign_key
542
+ end
543
+
544
+ # Generate SQL type string
545
+ #
546
+ # @param type [Symbol] column type
547
+ # @param options [Hash] column options
548
+ # @return [String]
549
+ def self.sql_type(type, options = {})
550
+ base = TYPE_MAP[type] || "VARCHAR(255)"
551
+
552
+ if type == :string && options[:limit]
553
+ base = "VARCHAR(#{options[:limit]})"
554
+ elsif type == :decimal && (options[:precision] || options[:scale])
555
+ precision = options[:precision] || 10
556
+ scale = options[:scale] || 0
557
+ base = "DECIMAL(#{precision}, #{scale})"
558
+ end
559
+
560
+ base += " NOT NULL" if options[:null] == false
561
+ base += " DEFAULT #{quote_default(options[:default])}" if options.key?(:default)
562
+ base
563
+ end
564
+
565
+ # Generate CREATE TABLE SQL
566
+ #
567
+ # @return [String]
568
+ def to_sql
569
+ "CREATE TABLE #{@name} (\n #{@columns.join(",\n ")}\n)"
570
+ end
571
+
572
+ private
573
+
574
+ def self.quote_default(value)
575
+ case value
576
+ when String then "'#{value}'"
577
+ when nil then "NULL"
578
+ when true then "1"
579
+ when false then "0"
580
+ else value.to_s
581
+ end
582
+ end
583
+ end
584
+ end
585
+
586
+ # Database-related error
587
+ class DatabaseError < Error; end
588
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ # Base error class for all Tarsier errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a route cannot be found
8
+ class RouteNotFoundError < Error
9
+ attr_reader :method, :path
10
+
11
+ def initialize(method, path)
12
+ @method = method
13
+ @path = path
14
+ super("No route matches [#{method}] #{path}")
15
+ end
16
+ end
17
+
18
+ # Raised when route definition is invalid
19
+ class InvalidRouteError < Error; end
20
+
21
+ # Raised when parameter validation fails
22
+ class ValidationError < Error
23
+ attr_reader :errors
24
+
25
+ def initialize(errors)
26
+ @errors = errors
27
+ super("Validation failed: #{errors.map { |k, v| "#{k} #{v.join(', ')}" }.join('; ')}")
28
+ end
29
+ end
30
+
31
+ # Raised when a required parameter is missing
32
+ class MissingParameterError < ValidationError
33
+ def initialize(param_name)
34
+ super({ param_name => ["is required"] })
35
+ end
36
+ end
37
+
38
+ # Raised when parameter type coercion fails
39
+ class TypeCoercionError < Error
40
+ attr_reader :param_name, :expected_type, :actual_value
41
+
42
+ def initialize(param_name, expected_type, actual_value)
43
+ @param_name = param_name
44
+ @expected_type = expected_type
45
+ @actual_value = actual_value
46
+ super("Cannot coerce '#{actual_value}' to #{expected_type} for parameter '#{param_name}'")
47
+ end
48
+ end
49
+
50
+ # Raised when middleware configuration is invalid
51
+ class MiddlewareError < Error; end
52
+
53
+ # Raised when controller action is not found
54
+ class ActionNotFoundError < Error
55
+ attr_reader :controller, :action
56
+
57
+ def initialize(controller, action)
58
+ @controller = controller
59
+ @action = action
60
+ super("Action '#{action}' not found in #{controller}")
61
+ end
62
+ end
63
+
64
+ # Raised when response has already been sent
65
+ class ResponseAlreadySentError < Error
66
+ def initialize
67
+ super("Response has already been sent")
68
+ end
69
+ end
70
+
71
+ # Raised when streaming is not supported
72
+ class StreamingNotSupportedError < Error
73
+ def initialize
74
+ super("Streaming is not supported by the current server")
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarsier
4
+ module Middleware
5
+ # Base class for all middleware
6
+ # Provides a consistent interface for request/response processing
7
+ class Base
8
+ attr_reader :app, :options
9
+
10
+ # @param app [Object] the next middleware or application
11
+ # @param options [Hash] middleware options
12
+ def initialize(app, **options)
13
+ @app = app
14
+ @options = options
15
+ end
16
+
17
+ # Process the request
18
+ # @param request [Request] the request object
19
+ # @param response [Response] the response object
20
+ # @return [Response]
21
+ def call(request, response)
22
+ before(request, response)
23
+ result = @app.call(request, response)
24
+ after(request, result)
25
+ result
26
+ end
27
+
28
+ protected
29
+
30
+ # Called before the next middleware/app
31
+ # Override in subclasses
32
+ # @param request [Request]
33
+ # @param response [Response]
34
+ def before(request, response)
35
+ # Override in subclass
36
+ end
37
+
38
+ # Called after the next middleware/app
39
+ # Override in subclasses
40
+ # @param request [Request]
41
+ # @param response [Response]
42
+ def after(request, response)
43
+ # Override in subclass
44
+ end
45
+ end
46
+ end
47
+ end