tina4ruby 0.4.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 (74) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +768 -0
  5. data/exe/tina4 +4 -0
  6. data/lib/tina4/api.rb +152 -0
  7. data/lib/tina4/auth.rb +139 -0
  8. data/lib/tina4/cli.rb +349 -0
  9. data/lib/tina4/crud.rb +124 -0
  10. data/lib/tina4/database.rb +135 -0
  11. data/lib/tina4/database_result.rb +89 -0
  12. data/lib/tina4/debug.rb +83 -0
  13. data/lib/tina4/dev.rb +15 -0
  14. data/lib/tina4/dev_reload.rb +68 -0
  15. data/lib/tina4/drivers/firebird_driver.rb +94 -0
  16. data/lib/tina4/drivers/mssql_driver.rb +112 -0
  17. data/lib/tina4/drivers/mysql_driver.rb +90 -0
  18. data/lib/tina4/drivers/postgres_driver.rb +99 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +85 -0
  20. data/lib/tina4/env.rb +55 -0
  21. data/lib/tina4/field_types.rb +84 -0
  22. data/lib/tina4/graphql.rb +837 -0
  23. data/lib/tina4/localization.rb +100 -0
  24. data/lib/tina4/middleware.rb +59 -0
  25. data/lib/tina4/migration.rb +124 -0
  26. data/lib/tina4/orm.rb +168 -0
  27. data/lib/tina4/public/css/tina4.css +2286 -0
  28. data/lib/tina4/public/css/tina4.min.css +2 -0
  29. data/lib/tina4/public/js/tina4.js +134 -0
  30. data/lib/tina4/public/js/tina4helper.js +387 -0
  31. data/lib/tina4/queue.rb +117 -0
  32. data/lib/tina4/queue_backends/kafka_backend.rb +80 -0
  33. data/lib/tina4/queue_backends/lite_backend.rb +79 -0
  34. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -0
  35. data/lib/tina4/rack_app.rb +150 -0
  36. data/lib/tina4/request.rb +158 -0
  37. data/lib/tina4/response.rb +172 -0
  38. data/lib/tina4/router.rb +148 -0
  39. data/lib/tina4/scss/tina4css/_alerts.scss +34 -0
  40. data/lib/tina4/scss/tina4css/_badges.scss +22 -0
  41. data/lib/tina4/scss/tina4css/_buttons.scss +69 -0
  42. data/lib/tina4/scss/tina4css/_cards.scss +49 -0
  43. data/lib/tina4/scss/tina4css/_forms.scss +156 -0
  44. data/lib/tina4/scss/tina4css/_grid.scss +81 -0
  45. data/lib/tina4/scss/tina4css/_modals.scss +84 -0
  46. data/lib/tina4/scss/tina4css/_nav.scss +149 -0
  47. data/lib/tina4/scss/tina4css/_reset.scss +94 -0
  48. data/lib/tina4/scss/tina4css/_tables.scss +54 -0
  49. data/lib/tina4/scss/tina4css/_typography.scss +55 -0
  50. data/lib/tina4/scss/tina4css/_utilities.scss +197 -0
  51. data/lib/tina4/scss/tina4css/_variables.scss +117 -0
  52. data/lib/tina4/scss/tina4css/base.scss +1 -0
  53. data/lib/tina4/scss/tina4css/colors.scss +48 -0
  54. data/lib/tina4/scss/tina4css/tina4.scss +17 -0
  55. data/lib/tina4/scss_compiler.rb +131 -0
  56. data/lib/tina4/seeder.rb +529 -0
  57. data/lib/tina4/session.rb +145 -0
  58. data/lib/tina4/session_handlers/file_handler.rb +55 -0
  59. data/lib/tina4/session_handlers/mongo_handler.rb +49 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +43 -0
  61. data/lib/tina4/swagger.rb +123 -0
  62. data/lib/tina4/template.rb +478 -0
  63. data/lib/tina4/templates/base.twig +26 -0
  64. data/lib/tina4/templates/errors/403.twig +22 -0
  65. data/lib/tina4/templates/errors/404.twig +22 -0
  66. data/lib/tina4/templates/errors/500.twig +22 -0
  67. data/lib/tina4/testing.rb +213 -0
  68. data/lib/tina4/version.rb +5 -0
  69. data/lib/tina4/webserver.rb +101 -0
  70. data/lib/tina4/websocket.rb +167 -0
  71. data/lib/tina4/wsdl.rb +164 -0
  72. data/lib/tina4.rb +259 -0
  73. data/lib/tina4ruby.rb +4 -0
  74. metadata +324 -0
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "securerandom"
4
+
5
+ module Tina4
6
+ class QueueMessage
7
+ attr_reader :id, :topic, :payload, :created_at, :attempts
8
+ attr_accessor :status
9
+
10
+ def initialize(topic:, payload:, id: nil)
11
+ @id = id || SecureRandom.uuid
12
+ @topic = topic
13
+ @payload = payload
14
+ @created_at = Time.now
15
+ @attempts = 0
16
+ @status = :pending
17
+ end
18
+
19
+ def to_hash
20
+ {
21
+ id: @id,
22
+ topic: @topic,
23
+ payload: @payload,
24
+ created_at: @created_at.iso8601,
25
+ attempts: @attempts,
26
+ status: @status
27
+ }
28
+ end
29
+
30
+ def to_json(*_args)
31
+ JSON.generate(to_hash)
32
+ end
33
+
34
+ def increment_attempts!
35
+ @attempts += 1
36
+ end
37
+ end
38
+
39
+ class Producer
40
+ def initialize(backend: nil)
41
+ @backend = backend || Tina4::QueueBackends::LiteBackend.new
42
+ end
43
+
44
+ def publish(topic, payload)
45
+ message = QueueMessage.new(topic: topic, payload: payload)
46
+ @backend.enqueue(message)
47
+ Tina4::Debug.debug("Message published to #{topic}: #{message.id}")
48
+ message
49
+ end
50
+
51
+ def publish_batch(topic, payloads)
52
+ payloads.map { |p| publish(topic, p) }
53
+ end
54
+ end
55
+
56
+ class Consumer
57
+ def initialize(topic:, backend: nil, max_retries: 3)
58
+ @topic = topic
59
+ @backend = backend || Tina4::QueueBackends::LiteBackend.new
60
+ @max_retries = max_retries
61
+ @handlers = []
62
+ @running = false
63
+ end
64
+
65
+ def on_message(&block)
66
+ @handlers << block
67
+ end
68
+
69
+ def start(poll_interval: 1)
70
+ @running = true
71
+ Tina4::Debug.info("Consumer started for topic: #{@topic}")
72
+
73
+ while @running
74
+ message = @backend.dequeue(@topic)
75
+ if message
76
+ process_message(message)
77
+ else
78
+ sleep(poll_interval)
79
+ end
80
+ end
81
+ end
82
+
83
+ def stop
84
+ @running = false
85
+ Tina4::Debug.info("Consumer stopped for topic: #{@topic}")
86
+ end
87
+
88
+ def process_one
89
+ message = @backend.dequeue(@topic)
90
+ process_message(message) if message
91
+ end
92
+
93
+ private
94
+
95
+ def process_message(message)
96
+ message.increment_attempts!
97
+ message.status = :processing
98
+
99
+ @handlers.each do |handler|
100
+ handler.call(message)
101
+ end
102
+
103
+ message.status = :completed
104
+ @backend.acknowledge(message)
105
+ rescue => e
106
+ Tina4::Debug.error("Queue message failed: #{message.id} - #{e.message}")
107
+ message.status = :failed
108
+
109
+ if message.attempts < @max_retries
110
+ message.status = :pending
111
+ @backend.requeue(message)
112
+ else
113
+ @backend.dead_letter(message)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module QueueBackends
5
+ class KafkaBackend
6
+ def initialize(options = {})
7
+ require "rdkafka"
8
+ @brokers = options[:brokers] || "localhost:9092"
9
+ @group_id = options[:group_id] || "tina4_consumer_group"
10
+
11
+ producer_config = {
12
+ "bootstrap.servers" => @brokers
13
+ }
14
+ @producer = Rdkafka::Config.new(producer_config).producer
15
+
16
+ consumer_config = {
17
+ "bootstrap.servers" => @brokers,
18
+ "group.id" => @group_id,
19
+ "auto.offset.reset" => "earliest",
20
+ "enable.auto.commit" => "false"
21
+ }
22
+ @consumer = Rdkafka::Config.new(consumer_config).consumer
23
+ @subscribed_topics = []
24
+ rescue LoadError
25
+ raise "Kafka backend requires the 'rdkafka' gem. Install with: gem install rdkafka"
26
+ end
27
+
28
+ def enqueue(message)
29
+ @producer.produce(
30
+ topic: message.topic,
31
+ payload: message.to_json,
32
+ key: message.id
33
+ ).wait
34
+ end
35
+
36
+ def dequeue(topic)
37
+ unless @subscribed_topics.include?(topic)
38
+ @consumer.subscribe(topic)
39
+ @subscribed_topics << topic
40
+ end
41
+
42
+ msg = @consumer.poll(1000)
43
+ return nil unless msg
44
+
45
+ data = JSON.parse(msg.payload)
46
+ @last_message = msg
47
+
48
+ Tina4::QueueMessage.new(
49
+ topic: data["topic"],
50
+ payload: data["payload"],
51
+ id: data["id"]
52
+ )
53
+ rescue Rdkafka::RdkafkaError
54
+ nil
55
+ end
56
+
57
+ def acknowledge(_message)
58
+ @consumer.commit if @last_message
59
+ end
60
+
61
+ def requeue(message)
62
+ enqueue(message)
63
+ end
64
+
65
+ def dead_letter(message)
66
+ dead_msg = Tina4::QueueMessage.new(
67
+ topic: "#{message.topic}.dead_letter",
68
+ payload: message.payload,
69
+ id: message.id
70
+ )
71
+ enqueue(dead_msg)
72
+ end
73
+
74
+ def close
75
+ @producer&.close
76
+ @consumer&.close
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "fileutils"
4
+
5
+ module Tina4
6
+ module QueueBackends
7
+ class LiteBackend
8
+ def initialize(options = {})
9
+ @dir = options[:dir] || File.join(Dir.pwd, ".queue")
10
+ @dead_letter_dir = File.join(@dir, "dead_letter")
11
+ FileUtils.mkdir_p(@dir)
12
+ FileUtils.mkdir_p(@dead_letter_dir)
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def enqueue(message)
17
+ @mutex.synchronize do
18
+ topic_dir = topic_path(message.topic)
19
+ FileUtils.mkdir_p(topic_dir)
20
+ path = File.join(topic_dir, "#{message.id}.json")
21
+ File.write(path, message.to_json)
22
+ end
23
+ end
24
+
25
+ def dequeue(topic)
26
+ @mutex.synchronize do
27
+ dir = topic_path(topic)
28
+ return nil unless Dir.exist?(dir)
29
+
30
+ files = Dir.glob(File.join(dir, "*.json")).sort_by { |f| File.mtime(f) }
31
+ return nil if files.empty?
32
+
33
+ file = files.first
34
+ data = JSON.parse(File.read(file))
35
+ File.delete(file)
36
+
37
+ Tina4::QueueMessage.new(
38
+ topic: data["topic"],
39
+ payload: data["payload"],
40
+ id: data["id"]
41
+ )
42
+ end
43
+ end
44
+
45
+ def acknowledge(message)
46
+ # File already deleted on dequeue
47
+ end
48
+
49
+ def requeue(message)
50
+ enqueue(message)
51
+ end
52
+
53
+ def dead_letter(message)
54
+ path = File.join(@dead_letter_dir, "#{message.id}.json")
55
+ File.write(path, message.to_json)
56
+ end
57
+
58
+ def size(topic)
59
+ dir = topic_path(topic)
60
+ return 0 unless Dir.exist?(dir)
61
+ Dir.glob(File.join(dir, "*.json")).length
62
+ end
63
+
64
+ def topics
65
+ return [] unless Dir.exist?(@dir)
66
+ Dir.children(@dir)
67
+ .reject { |d| d == "dead_letter" }
68
+ .select { |d| File.directory?(File.join(@dir, d)) }
69
+ end
70
+
71
+ private
72
+
73
+ def topic_path(topic)
74
+ safe_topic = topic.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
75
+ File.join(@dir, safe_topic)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module QueueBackends
5
+ class RabbitmqBackend
6
+ def initialize(options = {})
7
+ require "bunny"
8
+ @connection = Bunny.new(
9
+ host: options[:host] || "localhost",
10
+ port: options[:port] || 5672,
11
+ username: options[:username] || "guest",
12
+ password: options[:password] || "guest",
13
+ vhost: options[:vhost] || "/"
14
+ )
15
+ @connection.start
16
+ @channel = @connection.create_channel
17
+ @queues = {}
18
+ @exchanges = {}
19
+ rescue LoadError
20
+ raise "RabbitMQ backend requires the 'bunny' gem. Install with: gem install bunny"
21
+ end
22
+
23
+ def enqueue(message)
24
+ queue = get_queue(message.topic)
25
+ queue.publish(message.to_json, persistent: true)
26
+ end
27
+
28
+ def dequeue(topic)
29
+ queue = get_queue(topic)
30
+ delivery_info, _properties, payload = queue.pop
31
+ return nil unless payload
32
+
33
+ data = JSON.parse(payload)
34
+ msg = Tina4::QueueMessage.new(
35
+ topic: data["topic"],
36
+ payload: data["payload"],
37
+ id: data["id"]
38
+ )
39
+ @last_delivery_tag = delivery_info.delivery_tag
40
+ msg
41
+ end
42
+
43
+ def acknowledge(_message)
44
+ @channel.acknowledge(@last_delivery_tag) if @last_delivery_tag
45
+ end
46
+
47
+ def requeue(message)
48
+ enqueue(message)
49
+ end
50
+
51
+ def dead_letter(message)
52
+ dlq = get_queue("#{message.topic}.dead_letter")
53
+ dlq.publish(message.to_json, persistent: true)
54
+ end
55
+
56
+ def size(topic)
57
+ queue = get_queue(topic)
58
+ queue.message_count
59
+ end
60
+
61
+ def close
62
+ @channel&.close
63
+ @connection&.close
64
+ end
65
+
66
+ private
67
+
68
+ def get_queue(topic)
69
+ @queues[topic] ||= @channel.queue(topic, durable: true)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Tina4
5
+ class RackApp
6
+ STATIC_DIRS = %w[public src/public src/assets assets].freeze
7
+
8
+ # Pre-built frozen responses for zero-allocation fast paths
9
+ CORS_HEADERS = {
10
+ "access-control-allow-origin" => "*",
11
+ "access-control-allow-methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS",
12
+ "access-control-allow-headers" => "Content-Type, Authorization, Accept",
13
+ "access-control-max-age" => "86400"
14
+ }.freeze
15
+
16
+ OPTIONS_RESPONSE = [204, CORS_HEADERS, [""]].freeze
17
+
18
+ def initialize(root_dir: Dir.pwd)
19
+ @root_dir = root_dir
20
+ # Pre-compute static roots at boot (not per-request)
21
+ @static_roots = STATIC_DIRS.map { |d| File.join(root_dir, d) }
22
+ .select { |d| Dir.exist?(d) }
23
+ .freeze
24
+ end
25
+
26
+ def call(env)
27
+ method = env["REQUEST_METHOD"]
28
+ path = env["PATH_INFO"] || "/"
29
+
30
+ # Fast-path: OPTIONS preflight (zero allocation)
31
+ return OPTIONS_RESPONSE if method == "OPTIONS"
32
+
33
+ # Fast-path: API routes skip static file + swagger checks entirely
34
+ unless path.start_with?("/api/")
35
+ # Swagger
36
+ if path == "/swagger" || path == "/swagger/"
37
+ return serve_swagger_ui
38
+ end
39
+ if path == "/swagger/openapi.json"
40
+ return serve_openapi_json
41
+ end
42
+
43
+ # Static files (only for non-API paths)
44
+ static_response = try_static(path)
45
+ return static_response if static_response
46
+ end
47
+
48
+ # Route matching
49
+ result = Tina4::Router.find_route(path, method)
50
+ if result
51
+ route, path_params = result
52
+ handle_route(env, route, path_params)
53
+ else
54
+ handle_404(path)
55
+ end
56
+ rescue => e
57
+ handle_500(e)
58
+ end
59
+
60
+ private
61
+
62
+ def handle_route(env, route, path_params)
63
+ # Auth check
64
+ if route.auth_handler
65
+ auth_result = route.auth_handler.call(env)
66
+ return handle_403 unless auth_result
67
+ end
68
+
69
+ request = Tina4::Request.new(env, path_params)
70
+ response = Tina4::Response.new
71
+
72
+ # Execute handler
73
+ result = route.handler.call(request, response)
74
+
75
+ # Skip auto_detect if handler already returned the response object
76
+ final_response = result.equal?(response) ? result : Tina4::Response.auto_detect(result, response)
77
+ final_response.to_rack
78
+ end
79
+
80
+ def try_static(path)
81
+ return nil if path.include?("..")
82
+
83
+ @static_roots.each do |root|
84
+ full_path = File.join(root, path)
85
+ if File.file?(full_path)
86
+ return serve_static_file(full_path)
87
+ end
88
+
89
+ # Only try index.html for directory-like paths
90
+ if path.end_with?("/") || !path.include?(".")
91
+ index_path = File.join(full_path, "index.html")
92
+ if File.file?(index_path)
93
+ return serve_static_file(index_path)
94
+ end
95
+ end
96
+ end
97
+ nil
98
+ end
99
+
100
+ def serve_static_file(full_path)
101
+ ext = File.extname(full_path).downcase
102
+ content_type = Tina4::Response::MIME_TYPES[ext] || "application/octet-stream"
103
+ [200, { "content-type" => content_type }, [File.binread(full_path)]]
104
+ end
105
+
106
+ def serve_swagger_ui
107
+ html = <<~HTML
108
+ <!DOCTYPE html>
109
+ <html lang="en">
110
+ <head>
111
+ <meta charset="UTF-8">
112
+ <title>API Documentation</title>
113
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
114
+ </head>
115
+ <body>
116
+ <div id="swagger-ui"></div>
117
+ <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
118
+ <script>
119
+ SwaggerUIBundle({ url: '/swagger/openapi.json', dom_id: '#swagger-ui' });
120
+ </script>
121
+ </body>
122
+ </html>
123
+ HTML
124
+ [200, { "content-type" => "text/html; charset=utf-8" }, [html]]
125
+ end
126
+
127
+ def serve_openapi_json
128
+ spec = Tina4::Swagger.generate
129
+ [200, { "content-type" => "application/json; charset=utf-8" }, [JSON.generate(spec)]]
130
+ end
131
+
132
+ def handle_403
133
+ body = Tina4::Template.render_error(403) rescue "403 Forbidden"
134
+ [403, { "content-type" => "text/html" }, [body]]
135
+ end
136
+
137
+ def handle_404(path)
138
+ Tina4::Debug.warning("404 Not Found: #{path}")
139
+ body = Tina4::Template.render_error(404) rescue "404 Not Found"
140
+ [404, { "content-type" => "text/html" }, [body]]
141
+ end
142
+
143
+ def handle_500(error)
144
+ Tina4::Debug.error("500 Internal Server Error: #{error.message}")
145
+ Tina4::Debug.error(error.backtrace&.first(10)&.join("\n"))
146
+ body = Tina4::Template.render_error(500) rescue "500 Internal Server Error: #{error.message}"
147
+ [500, { "content-type" => "text/html" }, [body]]
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+ require "uri"
3
+ require "json"
4
+
5
+ module Tina4
6
+ class Request
7
+ attr_reader :env, :method, :path, :query_string, :content_type,
8
+ :path_params, :ip
9
+
10
+ def initialize(env, path_params = {})
11
+ @env = env
12
+ @method = env["REQUEST_METHOD"]
13
+ @path = env["PATH_INFO"] || "/"
14
+ @query_string = env["QUERY_STRING"] || ""
15
+ @content_type = env["CONTENT_TYPE"] || ""
16
+ @ip = env["REMOTE_ADDR"] || "127.0.0.1"
17
+ @path_params = path_params
18
+
19
+ # Lazy-initialized fields (nil = not yet computed)
20
+ @headers = nil
21
+ @cookies = nil
22
+ @session = nil
23
+ @body = nil
24
+ @params = nil
25
+ @files = nil
26
+ @json_body = nil
27
+ end
28
+
29
+ # Lazy accessors — only compute when needed
30
+ def headers
31
+ @headers ||= extract_headers
32
+ end
33
+
34
+ def cookies
35
+ @cookies ||= parse_cookies
36
+ end
37
+
38
+ def session
39
+ @session ||= @env["tina4.session"] || {}
40
+ end
41
+
42
+ def body
43
+ @body ||= read_body
44
+ end
45
+
46
+ def files
47
+ @files ||= extract_files
48
+ end
49
+
50
+ def params
51
+ @params ||= build_params
52
+ end
53
+
54
+ def [](key)
55
+ params[key.to_s] || params[key.to_sym] || @path_params[key.to_sym]
56
+ end
57
+
58
+ def header(name)
59
+ headers[name.to_s.downcase.gsub("-", "_")]
60
+ end
61
+
62
+ def json_body
63
+ @json_body ||= begin
64
+ JSON.parse(body)
65
+ rescue JSON::ParserError, TypeError
66
+ {}
67
+ end
68
+ end
69
+
70
+ def bearer_token
71
+ auth = header("authorization") || ""
72
+ auth.sub(/\ABearer\s+/i, "") if auth =~ /\ABearer\s+/i
73
+ end
74
+
75
+ private
76
+
77
+ def extract_headers
78
+ h = {}
79
+ @env.each do |key, value|
80
+ if key.start_with?("HTTP_")
81
+ h[key[5..].downcase] = value
82
+ end
83
+ end
84
+ h
85
+ end
86
+
87
+ def parse_cookies
88
+ cookie_str = @env["HTTP_COOKIE"]
89
+ return {} unless cookie_str && !cookie_str.empty?
90
+
91
+ result = {}
92
+ cookie_str.split(";").each do |pair|
93
+ key, value = pair.strip.split("=", 2)
94
+ result[key] = value if key
95
+ end
96
+ result
97
+ end
98
+
99
+ def read_body
100
+ input = @env["rack.input"]
101
+ return "" unless input
102
+ input.rewind if input.respond_to?(:rewind)
103
+ data = input.read || ""
104
+ input.rewind if input.respond_to?(:rewind)
105
+ data
106
+ end
107
+
108
+ def build_params
109
+ p = {}
110
+
111
+ # Query string params
112
+ parse_query_string(@query_string, p) unless @query_string.empty?
113
+
114
+ # Body params
115
+ if @content_type.include?("application/json")
116
+ json_body.each { |k, v| p[k.to_s] = v }
117
+ elsif @content_type.include?("application/x-www-form-urlencoded")
118
+ parse_query_string(body, p)
119
+ end
120
+
121
+ # Path params (highest priority)
122
+ @path_params.each { |k, v| p[k.to_s] = v }
123
+ p
124
+ end
125
+
126
+ def parse_query_string(qs, target = {})
127
+ return target if qs.nil? || qs.empty?
128
+ qs.split("&").each do |pair|
129
+ key, value = pair.split("=", 2)
130
+ target[URI.decode_www_form_component(key.to_s)] = URI.decode_www_form_component(value.to_s)
131
+ end
132
+ target
133
+ end
134
+
135
+ def extract_files
136
+ result = {}
137
+ return result unless @content_type.include?("multipart/form-data")
138
+ begin
139
+ form_hash = @env["rack.request.form_hash"]
140
+ if form_hash
141
+ form_hash.each do |key, value|
142
+ if value.is_a?(Hash) && value[:tempfile]
143
+ result[key] = {
144
+ filename: value[:filename],
145
+ type: value[:type],
146
+ tempfile: value[:tempfile],
147
+ size: value[:tempfile].size
148
+ }
149
+ end
150
+ end
151
+ end
152
+ rescue StandardError
153
+ # Multipart parsing failed
154
+ end
155
+ result
156
+ end
157
+ end
158
+ end