tina4 0.3.0 → 0.5.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/lib/tina4/version.rb +1 -1
  3. data/lib/tina4ruby.rb +4 -0
  4. metadata +14 -258
  5. data/CHANGELOG.md +0 -73
  6. data/LICENSE.txt +0 -21
  7. data/README.md +0 -662
  8. data/exe/tina4 +0 -4
  9. data/lib/tina4/api.rb +0 -152
  10. data/lib/tina4/auth.rb +0 -139
  11. data/lib/tina4/cli.rb +0 -243
  12. data/lib/tina4/crud.rb +0 -124
  13. data/lib/tina4/database.rb +0 -135
  14. data/lib/tina4/database_result.rb +0 -89
  15. data/lib/tina4/debug.rb +0 -83
  16. data/lib/tina4/dev_reload.rb +0 -68
  17. data/lib/tina4/drivers/firebird_driver.rb +0 -94
  18. data/lib/tina4/drivers/mssql_driver.rb +0 -112
  19. data/lib/tina4/drivers/mysql_driver.rb +0 -90
  20. data/lib/tina4/drivers/postgres_driver.rb +0 -99
  21. data/lib/tina4/drivers/sqlite_driver.rb +0 -85
  22. data/lib/tina4/env.rb +0 -55
  23. data/lib/tina4/field_types.rb +0 -84
  24. data/lib/tina4/graphql.rb +0 -837
  25. data/lib/tina4/localization.rb +0 -100
  26. data/lib/tina4/middleware.rb +0 -59
  27. data/lib/tina4/migration.rb +0 -124
  28. data/lib/tina4/orm.rb +0 -168
  29. data/lib/tina4/queue.rb +0 -117
  30. data/lib/tina4/queue_backends/kafka_backend.rb +0 -80
  31. data/lib/tina4/queue_backends/lite_backend.rb +0 -79
  32. data/lib/tina4/queue_backends/rabbitmq_backend.rb +0 -73
  33. data/lib/tina4/rack_app.rb +0 -150
  34. data/lib/tina4/request.rb +0 -158
  35. data/lib/tina4/response.rb +0 -172
  36. data/lib/tina4/router.rb +0 -142
  37. data/lib/tina4/scss_compiler.rb +0 -131
  38. data/lib/tina4/session.rb +0 -145
  39. data/lib/tina4/session_handlers/file_handler.rb +0 -55
  40. data/lib/tina4/session_handlers/mongo_handler.rb +0 -49
  41. data/lib/tina4/session_handlers/redis_handler.rb +0 -43
  42. data/lib/tina4/swagger.rb +0 -123
  43. data/lib/tina4/template.rb +0 -478
  44. data/lib/tina4/templates/base.twig +0 -25
  45. data/lib/tina4/templates/errors/403.twig +0 -22
  46. data/lib/tina4/templates/errors/404.twig +0 -22
  47. data/lib/tina4/templates/errors/500.twig +0 -22
  48. data/lib/tina4/testing.rb +0 -213
  49. data/lib/tina4/webserver.rb +0 -101
  50. data/lib/tina4/websocket.rb +0 -167
  51. data/lib/tina4/wsdl.rb +0 -164
  52. data/lib/tina4.rb +0 -234
@@ -1,79 +0,0 @@
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
@@ -1,73 +0,0 @@
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
@@ -1,150 +0,0 @@
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
data/lib/tina4/request.rb DELETED
@@ -1,158 +0,0 @@
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
@@ -1,172 +0,0 @@
1
- # frozen_string_literal: true
2
- require "json"
3
- require "uri"
4
-
5
- module Tina4
6
- class Response
7
- MIME_TYPES = {
8
- ".html" => "text/html", ".htm" => "text/html",
9
- ".css" => "text/css", ".js" => "application/javascript",
10
- ".json" => "application/json", ".xml" => "application/xml",
11
- ".txt" => "text/plain", ".csv" => "text/csv",
12
- ".png" => "image/png", ".jpg" => "image/jpeg",
13
- ".jpeg" => "image/jpeg", ".gif" => "image/gif",
14
- ".svg" => "image/svg+xml", ".ico" => "image/x-icon",
15
- ".webp" => "image/webp", ".pdf" => "application/pdf",
16
- ".zip" => "application/zip", ".woff" => "font/woff",
17
- ".woff2" => "font/woff2", ".ttf" => "font/ttf",
18
- ".eot" => "application/vnd.ms-fontobject",
19
- ".mp3" => "audio/mpeg", ".mp4" => "video/mp4",
20
- ".webm" => "video/webm"
21
- }.freeze
22
-
23
- # Pre-frozen header values
24
- JSON_CONTENT_TYPE = "application/json; charset=utf-8"
25
- HTML_CONTENT_TYPE = "text/html; charset=utf-8"
26
- TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
27
- XML_CONTENT_TYPE = "application/xml; charset=utf-8"
28
-
29
- attr_accessor :status, :headers, :body, :cookies
30
-
31
- def initialize
32
- @status = 200
33
- @headers = { "content-type" => HTML_CONTENT_TYPE }
34
- @body = ""
35
- @cookies = nil # Lazy — most responses have no cookies
36
- end
37
-
38
- def json(data, status_or_opts = nil, status: nil)
39
- @status = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
40
- @headers["content-type"] = JSON_CONTENT_TYPE
41
- @body = data.is_a?(String) ? data : JSON.generate(data)
42
- self
43
- end
44
-
45
- def html(content, status_or_opts = nil, status: nil)
46
- @status = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
47
- @headers["content-type"] = HTML_CONTENT_TYPE
48
- @body = content.to_s
49
- self
50
- end
51
-
52
- def text(content, status_or_opts = nil, status: nil)
53
- @status = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
54
- @headers["content-type"] = TEXT_CONTENT_TYPE
55
- @body = content.to_s
56
- self
57
- end
58
-
59
- def xml(content, status: 200)
60
- @status = status
61
- @headers["content-type"] = XML_CONTENT_TYPE
62
- @body = content.to_s
63
- self
64
- end
65
-
66
- def csv(content, filename: "export.csv", status: 200)
67
- @status = status
68
- @headers["content-type"] = "text/csv"
69
- @headers["content-disposition"] = "attachment; filename=\"#{filename}\""
70
- @body = content.to_s
71
- self
72
- end
73
-
74
- def redirect(url, status: 302)
75
- @status = status
76
- @headers["location"] = url
77
- @body = ""
78
- self
79
- end
80
-
81
- def file(path, content_type: nil, download: false)
82
- unless ::File.exist?(path)
83
- @status = 404
84
- @body = "File not found"
85
- return self
86
- end
87
- ext = ::File.extname(path).downcase
88
- @headers["content-type"] = content_type || MIME_TYPES[ext] || "application/octet-stream"
89
- if download
90
- @headers["content-disposition"] = "attachment; filename=\"#{::File.basename(path)}\""
91
- end
92
- @body = ::File.binread(path)
93
- self
94
- end
95
-
96
- def render(template_path, data = {}, status: 200)
97
- @status = status
98
- @headers["content-type"] = HTML_CONTENT_TYPE
99
- @body = Tina4::Template.render(template_path, data)
100
- self
101
- end
102
-
103
- def set_cookie(name, value, opts = {})
104
- cookie = "#{name}=#{URI.encode_www_form_component(value)}"
105
- cookie += "; Path=#{opts[:path] || '/'}"
106
- cookie += "; HttpOnly" if opts.fetch(:http_only, true)
107
- cookie += "; Secure" if opts[:secure]
108
- cookie += "; SameSite=#{opts[:same_site] || 'Lax'}"
109
- cookie += "; Max-Age=#{opts[:max_age]}" if opts[:max_age]
110
- cookie += "; Expires=#{opts[:expires].httpdate}" if opts[:expires]
111
- @cookies ||= []
112
- @cookies << cookie
113
- self
114
- end
115
-
116
- def delete_cookie(name, path: "/")
117
- set_cookie(name, "", max_age: 0, path: path)
118
- end
119
-
120
- def add_header(key, value)
121
- @headers[key] = value
122
- self
123
- end
124
-
125
- def add_cors_headers(origin: "*", methods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
126
- headers_list: "Content-Type, Authorization, Accept", credentials: false)
127
- @headers["access-control-allow-origin"] = origin
128
- @headers["access-control-allow-methods"] = methods
129
- @headers["access-control-allow-headers"] = headers_list
130
- @headers["access-control-allow-credentials"] = "true" if credentials
131
- @headers["access-control-max-age"] = "86400"
132
- self
133
- end
134
-
135
- def to_rack
136
- # Fast path: no cookies (99% of API responses)
137
- if @cookies.nil? || @cookies.empty?
138
- return [@status, @headers, [@body.to_s]]
139
- end
140
-
141
- # Cookie path
142
- final_headers = @headers.dup
143
- final_headers["set-cookie"] = @cookies.join("\n")
144
- [@status, final_headers, [@body.to_s]]
145
- end
146
-
147
- def self.auto_detect(result, response)
148
- case result
149
- when Tina4::Response
150
- result
151
- when Hash, Array
152
- response.json(result)
153
- when String
154
- if result.start_with?("<")
155
- response.html(result)
156
- else
157
- response.text(result)
158
- end
159
- when Integer
160
- response.status = result
161
- response.body = ""
162
- response
163
- when NilClass
164
- response.status = 204
165
- response.body = ""
166
- response
167
- else
168
- response.json(result.respond_to?(:to_hash) ? result.to_hash : { data: result.to_s })
169
- end
170
- end
171
- end
172
- end