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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +80 -0
- data/LICENSE.txt +21 -0
- data/README.md +768 -0
- data/exe/tina4 +4 -0
- data/lib/tina4/api.rb +152 -0
- data/lib/tina4/auth.rb +139 -0
- data/lib/tina4/cli.rb +349 -0
- data/lib/tina4/crud.rb +124 -0
- data/lib/tina4/database.rb +135 -0
- data/lib/tina4/database_result.rb +89 -0
- data/lib/tina4/debug.rb +83 -0
- data/lib/tina4/dev.rb +15 -0
- data/lib/tina4/dev_reload.rb +68 -0
- data/lib/tina4/drivers/firebird_driver.rb +94 -0
- data/lib/tina4/drivers/mssql_driver.rb +112 -0
- data/lib/tina4/drivers/mysql_driver.rb +90 -0
- data/lib/tina4/drivers/postgres_driver.rb +99 -0
- data/lib/tina4/drivers/sqlite_driver.rb +85 -0
- data/lib/tina4/env.rb +55 -0
- data/lib/tina4/field_types.rb +84 -0
- data/lib/tina4/graphql.rb +837 -0
- data/lib/tina4/localization.rb +100 -0
- data/lib/tina4/middleware.rb +59 -0
- data/lib/tina4/migration.rb +124 -0
- data/lib/tina4/orm.rb +168 -0
- data/lib/tina4/public/css/tina4.css +2286 -0
- data/lib/tina4/public/css/tina4.min.css +2 -0
- data/lib/tina4/public/js/tina4.js +134 -0
- data/lib/tina4/public/js/tina4helper.js +387 -0
- data/lib/tina4/queue.rb +117 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -0
- data/lib/tina4/queue_backends/lite_backend.rb +79 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -0
- data/lib/tina4/rack_app.rb +150 -0
- data/lib/tina4/request.rb +158 -0
- data/lib/tina4/response.rb +172 -0
- data/lib/tina4/router.rb +148 -0
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -0
- data/lib/tina4/scss/tina4css/_badges.scss +22 -0
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -0
- data/lib/tina4/scss/tina4css/_cards.scss +49 -0
- data/lib/tina4/scss/tina4css/_forms.scss +156 -0
- data/lib/tina4/scss/tina4css/_grid.scss +81 -0
- data/lib/tina4/scss/tina4css/_modals.scss +84 -0
- data/lib/tina4/scss/tina4css/_nav.scss +149 -0
- data/lib/tina4/scss/tina4css/_reset.scss +94 -0
- data/lib/tina4/scss/tina4css/_tables.scss +54 -0
- data/lib/tina4/scss/tina4css/_typography.scss +55 -0
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -0
- data/lib/tina4/scss/tina4css/_variables.scss +117 -0
- data/lib/tina4/scss/tina4css/base.scss +1 -0
- data/lib/tina4/scss/tina4css/colors.scss +48 -0
- data/lib/tina4/scss/tina4css/tina4.scss +17 -0
- data/lib/tina4/scss_compiler.rb +131 -0
- data/lib/tina4/seeder.rb +529 -0
- data/lib/tina4/session.rb +145 -0
- data/lib/tina4/session_handlers/file_handler.rb +55 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -0
- data/lib/tina4/session_handlers/redis_handler.rb +43 -0
- data/lib/tina4/swagger.rb +123 -0
- data/lib/tina4/template.rb +478 -0
- data/lib/tina4/templates/base.twig +26 -0
- data/lib/tina4/templates/errors/403.twig +22 -0
- data/lib/tina4/templates/errors/404.twig +22 -0
- data/lib/tina4/templates/errors/500.twig +22 -0
- data/lib/tina4/testing.rb +213 -0
- data/lib/tina4/version.rb +5 -0
- data/lib/tina4/webserver.rb +101 -0
- data/lib/tina4/websocket.rb +167 -0
- data/lib/tina4/wsdl.rb +164 -0
- data/lib/tina4.rb +259 -0
- data/lib/tina4ruby.rb +4 -0
- metadata +324 -0
data/lib/tina4/queue.rb
ADDED
|
@@ -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
|