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/exe/tina4
ADDED
data/lib/tina4/api.rb
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Tina4
|
|
7
|
+
class API
|
|
8
|
+
attr_reader :base_url, :headers
|
|
9
|
+
|
|
10
|
+
def initialize(base_url, headers: {}, timeout: 30)
|
|
11
|
+
@base_url = base_url.chomp("/")
|
|
12
|
+
@headers = {
|
|
13
|
+
"Content-Type" => "application/json",
|
|
14
|
+
"Accept" => "application/json"
|
|
15
|
+
}.merge(headers)
|
|
16
|
+
@timeout = timeout
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def get(path, params: {}, headers: {})
|
|
20
|
+
uri = build_uri(path, params)
|
|
21
|
+
request = Net::HTTP::Get.new(uri)
|
|
22
|
+
apply_headers(request, headers)
|
|
23
|
+
execute(uri, request)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def post(path, body: nil, headers: {})
|
|
27
|
+
uri = build_uri(path)
|
|
28
|
+
request = Net::HTTP::Post.new(uri)
|
|
29
|
+
request.body = body.is_a?(String) ? body : JSON.generate(body) if body
|
|
30
|
+
apply_headers(request, headers)
|
|
31
|
+
execute(uri, request)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def put(path, body: nil, headers: {})
|
|
35
|
+
uri = build_uri(path)
|
|
36
|
+
request = Net::HTTP::Put.new(uri)
|
|
37
|
+
request.body = body.is_a?(String) ? body : JSON.generate(body) if body
|
|
38
|
+
apply_headers(request, headers)
|
|
39
|
+
execute(uri, request)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def patch(path, body: nil, headers: {})
|
|
43
|
+
uri = build_uri(path)
|
|
44
|
+
request = Net::HTTP::Patch.new(uri)
|
|
45
|
+
request.body = body.is_a?(String) ? body : JSON.generate(body) if body
|
|
46
|
+
apply_headers(request, headers)
|
|
47
|
+
execute(uri, request)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def delete(path, headers: {})
|
|
51
|
+
uri = build_uri(path)
|
|
52
|
+
request = Net::HTTP::Delete.new(uri)
|
|
53
|
+
apply_headers(request, headers)
|
|
54
|
+
execute(uri, request)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def upload(path, file_path, field_name: "file", extra_fields: {}, headers: {})
|
|
58
|
+
uri = build_uri(path)
|
|
59
|
+
boundary = "----Tina4Boundary#{SecureRandom.hex(16)}"
|
|
60
|
+
|
|
61
|
+
body = build_multipart_body(boundary, file_path, field_name, extra_fields)
|
|
62
|
+
|
|
63
|
+
request = Net::HTTP::Post.new(uri)
|
|
64
|
+
request.body = body
|
|
65
|
+
request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
66
|
+
headers.each { |k, v| request[k] = v }
|
|
67
|
+
execute(uri, request)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def build_uri(path, params = {})
|
|
73
|
+
url = "#{@base_url}#{path}"
|
|
74
|
+
uri = URI.parse(url)
|
|
75
|
+
unless params.empty?
|
|
76
|
+
query = URI.encode_www_form(params)
|
|
77
|
+
uri.query = uri.query ? "#{uri.query}&#{query}" : query
|
|
78
|
+
end
|
|
79
|
+
uri
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def apply_headers(request, extra_headers)
|
|
83
|
+
@headers.merge(extra_headers).each do |key, value|
|
|
84
|
+
request[key] = value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def execute(uri, request)
|
|
89
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
90
|
+
http.use_ssl = uri.scheme == "https"
|
|
91
|
+
http.open_timeout = @timeout
|
|
92
|
+
http.read_timeout = @timeout
|
|
93
|
+
|
|
94
|
+
response = http.request(request)
|
|
95
|
+
|
|
96
|
+
APIResponse.new(
|
|
97
|
+
status: response.code.to_i,
|
|
98
|
+
body: response.body,
|
|
99
|
+
headers: response.to_hash
|
|
100
|
+
)
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
APIResponse.new(
|
|
103
|
+
status: 0,
|
|
104
|
+
body: "",
|
|
105
|
+
headers: {},
|
|
106
|
+
error: e.message
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def build_multipart_body(boundary, file_path, field_name, extra_fields)
|
|
111
|
+
body = ""
|
|
112
|
+
extra_fields.each do |key, value|
|
|
113
|
+
body += "--#{boundary}\r\n"
|
|
114
|
+
body += "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n"
|
|
115
|
+
body += "#{value}\r\n"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
filename = File.basename(file_path)
|
|
119
|
+
body += "--#{boundary}\r\n"
|
|
120
|
+
body += "Content-Disposition: form-data; name=\"#{field_name}\"; filename=\"#{filename}\"\r\n"
|
|
121
|
+
body += "Content-Type: application/octet-stream\r\n\r\n"
|
|
122
|
+
body += File.binread(file_path)
|
|
123
|
+
body += "\r\n--#{boundary}--\r\n"
|
|
124
|
+
body
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class APIResponse
|
|
129
|
+
attr_reader :status, :body, :headers, :error
|
|
130
|
+
|
|
131
|
+
def initialize(status:, body:, headers:, error: nil)
|
|
132
|
+
@status = status
|
|
133
|
+
@body = body
|
|
134
|
+
@headers = headers
|
|
135
|
+
@error = error
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def success?
|
|
139
|
+
@status >= 200 && @status < 300
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def json
|
|
143
|
+
@json ||= JSON.parse(@body)
|
|
144
|
+
rescue JSON::ParserError
|
|
145
|
+
{}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def to_s
|
|
149
|
+
"APIResponse(status=#{@status})"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/tina4/auth.rb
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Tina4
|
|
8
|
+
module Auth
|
|
9
|
+
KEYS_DIR = ".keys"
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def setup(root_dir = Dir.pwd)
|
|
13
|
+
@keys_dir = File.join(root_dir, KEYS_DIR)
|
|
14
|
+
FileUtils.mkdir_p(@keys_dir)
|
|
15
|
+
ensure_keys
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def generate_token(payload, expires_in: 3600)
|
|
19
|
+
ensure_keys
|
|
20
|
+
now = Time.now.to_i
|
|
21
|
+
claims = payload.merge(
|
|
22
|
+
"iat" => now,
|
|
23
|
+
"exp" => now + expires_in,
|
|
24
|
+
"nbf" => now
|
|
25
|
+
)
|
|
26
|
+
require "jwt"
|
|
27
|
+
JWT.encode(claims, private_key, "RS256")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_token(token)
|
|
31
|
+
ensure_keys
|
|
32
|
+
require "jwt"
|
|
33
|
+
decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
34
|
+
{ valid: true, payload: decoded[0] }
|
|
35
|
+
rescue JWT::ExpiredSignature
|
|
36
|
+
{ valid: false, error: "Token expired" }
|
|
37
|
+
rescue JWT::DecodeError => e
|
|
38
|
+
{ valid: false, error: e.message }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def hash_password(password)
|
|
42
|
+
require "bcrypt"
|
|
43
|
+
BCrypt::Password.create(password)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def verify_password(password, hash)
|
|
47
|
+
require "bcrypt"
|
|
48
|
+
BCrypt::Password.new(hash) == password
|
|
49
|
+
rescue BCrypt::Errors::InvalidHash
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def auth_handler(&block)
|
|
54
|
+
if block_given?
|
|
55
|
+
@custom_handler = block
|
|
56
|
+
else
|
|
57
|
+
@custom_handler || method(:default_auth_handler)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def bearer_auth
|
|
62
|
+
lambda do |env|
|
|
63
|
+
auth_header = env["HTTP_AUTHORIZATION"] || ""
|
|
64
|
+
return false unless auth_header =~ /\ABearer\s+(.+)\z/i
|
|
65
|
+
|
|
66
|
+
token = Regexp.last_match(1)
|
|
67
|
+
|
|
68
|
+
# API_KEY bypass — matches tina4_python behavior
|
|
69
|
+
api_key = ENV["API_KEY"]
|
|
70
|
+
if api_key && !api_key.empty? && token == api_key
|
|
71
|
+
env["tina4.auth"] = { "api_key" => true }
|
|
72
|
+
return true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
result = validate_token(token)
|
|
76
|
+
if result[:valid]
|
|
77
|
+
env["tina4.auth"] = result[:payload]
|
|
78
|
+
true
|
|
79
|
+
else
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Default auth handler for secured routes (POST/PUT/PATCH/DELETE)
|
|
86
|
+
# Used automatically unless auth: false is passed
|
|
87
|
+
def default_secure_auth
|
|
88
|
+
@default_secure_auth ||= bearer_auth
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def private_key
|
|
92
|
+
@private_key ||= OpenSSL::PKey::RSA.new(File.read(private_key_path))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def public_key
|
|
96
|
+
@public_key ||= OpenSSL::PKey::RSA.new(File.read(public_key_path))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def ensure_keys
|
|
102
|
+
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
103
|
+
FileUtils.mkdir_p(@keys_dir)
|
|
104
|
+
unless File.exist?(private_key_path) && File.exist?(public_key_path)
|
|
105
|
+
generate_keys
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def generate_keys
|
|
110
|
+
Tina4::Debug.info("Generating RSA key pair for JWT authentication")
|
|
111
|
+
key = OpenSSL::PKey::RSA.generate(2048)
|
|
112
|
+
File.write(private_key_path, key.to_pem)
|
|
113
|
+
File.write(public_key_path, key.public_key.to_pem)
|
|
114
|
+
@private_key = nil
|
|
115
|
+
@public_key = nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def private_key_path
|
|
119
|
+
File.join(@keys_dir, "private.pem")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def public_key_path
|
|
123
|
+
File.join(@keys_dir, "public.pem")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def default_auth_handler(env)
|
|
127
|
+
auth_header = env["HTTP_AUTHORIZATION"] || ""
|
|
128
|
+
return true if auth_header.empty?
|
|
129
|
+
|
|
130
|
+
if auth_header =~ /\ABearer\s+(.+)\z/i
|
|
131
|
+
result = validate_token(Regexp.last_match(1))
|
|
132
|
+
result[:valid]
|
|
133
|
+
else
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/tina4/cli.rb
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "thor"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
desc "init [NAME]", "Initialize a new Tina4 project"
|
|
8
|
+
option :template, type: :string, default: "default", desc: "Project template"
|
|
9
|
+
def init(name = ".")
|
|
10
|
+
dir = name == "." ? Dir.pwd : File.join(Dir.pwd, name)
|
|
11
|
+
FileUtils.mkdir_p(dir)
|
|
12
|
+
|
|
13
|
+
create_project_structure(dir)
|
|
14
|
+
create_sample_files(dir, name == "." ? File.basename(Dir.pwd) : name)
|
|
15
|
+
|
|
16
|
+
puts "Tina4 project initialized in #{dir}"
|
|
17
|
+
puts "Run 'cd #{name} && bundle install && tina4 start' to get started" unless name == "."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "start", "Start the Tina4 web server"
|
|
21
|
+
option :port, type: :numeric, default: 7145, aliases: "-p"
|
|
22
|
+
option :host, type: :string, default: "0.0.0.0", aliases: "-h"
|
|
23
|
+
option :dev, type: :boolean, default: false, aliases: "-d", desc: "Enable dev mode with auto-reload"
|
|
24
|
+
def start
|
|
25
|
+
require_relative "../tina4"
|
|
26
|
+
|
|
27
|
+
root_dir = Dir.pwd
|
|
28
|
+
Tina4.initialize!(root_dir)
|
|
29
|
+
|
|
30
|
+
# Load route files
|
|
31
|
+
load_routes(root_dir)
|
|
32
|
+
|
|
33
|
+
if options[:dev]
|
|
34
|
+
Tina4::DevReload.start(root_dir: root_dir)
|
|
35
|
+
Tina4::ScssCompiler.compile_all(root_dir)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
app = Tina4::RackApp.new(root_dir: root_dir)
|
|
39
|
+
|
|
40
|
+
# Try Puma first (production-grade), fall back to WEBrick
|
|
41
|
+
begin
|
|
42
|
+
require "puma"
|
|
43
|
+
require "puma/configuration"
|
|
44
|
+
require "puma/launcher"
|
|
45
|
+
|
|
46
|
+
puma_host = options[:host]
|
|
47
|
+
puma_port = options[:port]
|
|
48
|
+
|
|
49
|
+
config = Puma::Configuration.new do |user_config|
|
|
50
|
+
user_config.bind "tcp://#{puma_host}:#{puma_port}"
|
|
51
|
+
user_config.app app
|
|
52
|
+
user_config.threads 0, 16
|
|
53
|
+
user_config.workers 0
|
|
54
|
+
user_config.environment "development"
|
|
55
|
+
user_config.log_requests false
|
|
56
|
+
user_config.quiet
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Tina4::Debug.info("Starting Puma server on http://#{puma_host}:#{puma_port}")
|
|
60
|
+
launcher = Puma::Launcher.new(config)
|
|
61
|
+
launcher.run
|
|
62
|
+
rescue LoadError
|
|
63
|
+
Tina4::Debug.info("Puma not found, falling back to WEBrick")
|
|
64
|
+
server = Tina4::WebServer.new(app, host: options[:host], port: options[:port])
|
|
65
|
+
server.start
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
desc "migrate", "Run database migrations"
|
|
70
|
+
option :create, type: :string, desc: "Create a new migration"
|
|
71
|
+
option :rollback, type: :numeric, desc: "Rollback N migrations"
|
|
72
|
+
def migrate
|
|
73
|
+
require_relative "../tina4"
|
|
74
|
+
Tina4.initialize!(Dir.pwd)
|
|
75
|
+
|
|
76
|
+
db = Tina4.database
|
|
77
|
+
unless db
|
|
78
|
+
puts "No database configured. Set DATABASE_URL in your .env file."
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
migration = Tina4::Migration.new(db)
|
|
83
|
+
|
|
84
|
+
if options[:create]
|
|
85
|
+
path = migration.create(options[:create])
|
|
86
|
+
puts "Created migration: #{path}"
|
|
87
|
+
elsif options[:rollback]
|
|
88
|
+
migration.rollback(options[:rollback])
|
|
89
|
+
puts "Rolled back #{options[:rollback]} migration(s)"
|
|
90
|
+
else
|
|
91
|
+
results = migration.run
|
|
92
|
+
if results.empty?
|
|
93
|
+
puts "No pending migrations"
|
|
94
|
+
else
|
|
95
|
+
results.each do |r|
|
|
96
|
+
status_icon = r[:status] == "success" ? "OK" : "FAIL"
|
|
97
|
+
puts " [#{status_icon}] #{r[:name]}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
desc "test", "Run inline tests"
|
|
104
|
+
def test
|
|
105
|
+
require_relative "../tina4"
|
|
106
|
+
Tina4.initialize!(Dir.pwd)
|
|
107
|
+
|
|
108
|
+
# Load test files
|
|
109
|
+
test_dirs = %w[tests test spec src/tests]
|
|
110
|
+
test_dirs.each do |dir|
|
|
111
|
+
test_dir = File.join(Dir.pwd, dir)
|
|
112
|
+
next unless Dir.exist?(test_dir)
|
|
113
|
+
Dir.glob(File.join(test_dir, "**/*_test.rb")).sort.each { |f| load f }
|
|
114
|
+
Dir.glob(File.join(test_dir, "**/test_*.rb")).sort.each { |f| load f }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Also load inline tests from routes
|
|
118
|
+
load_routes(Dir.pwd)
|
|
119
|
+
|
|
120
|
+
results = Tina4::Testing.run_all
|
|
121
|
+
exit(1) if results[:failed] > 0 || results[:errors] > 0
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
desc "version", "Show Tina4 version"
|
|
125
|
+
def version
|
|
126
|
+
require_relative "version"
|
|
127
|
+
puts "Tina4 Ruby v#{Tina4::VERSION}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
desc "routes", "List all registered routes"
|
|
131
|
+
def routes
|
|
132
|
+
require_relative "../tina4"
|
|
133
|
+
Tina4.initialize!(Dir.pwd)
|
|
134
|
+
load_routes(Dir.pwd)
|
|
135
|
+
|
|
136
|
+
puts "\nRegistered Routes:"
|
|
137
|
+
puts "-" * 60
|
|
138
|
+
Tina4::Router.routes.each do |route|
|
|
139
|
+
auth = route.auth_handler ? " [AUTH]" : ""
|
|
140
|
+
puts " #{route.method.ljust(8)} #{route.path}#{auth}"
|
|
141
|
+
end
|
|
142
|
+
puts "-" * 60
|
|
143
|
+
puts "Total: #{Tina4::Router.routes.length} routes\n"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
desc "seed", "Run all seed files in seeds/"
|
|
147
|
+
option :clear, type: :boolean, default: false, desc: "Clear tables before seeding"
|
|
148
|
+
def seed
|
|
149
|
+
require_relative "../tina4"
|
|
150
|
+
Tina4.initialize!(Dir.pwd)
|
|
151
|
+
load_routes(Dir.pwd)
|
|
152
|
+
Tina4.seed(seed_folder: "seeds", clear: options[:clear])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
desc "seed:create NAME", "Create a new seed file"
|
|
156
|
+
def seed_create(name)
|
|
157
|
+
dir = File.join(Dir.pwd, "seeds")
|
|
158
|
+
FileUtils.mkdir_p(dir)
|
|
159
|
+
|
|
160
|
+
existing = Dir.glob(File.join(dir, "*.rb")).select { |f| File.basename(f)[0] =~ /\d/ }.sort
|
|
161
|
+
numbers = existing.map { |f| File.basename(f).match(/^(\d+)/)[1].to_i }
|
|
162
|
+
next_num = numbers.empty? ? 1 : numbers.max + 1
|
|
163
|
+
|
|
164
|
+
clean_name = name.strip.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_|_$/, "")
|
|
165
|
+
filename = format("%03d_%s.rb", next_num, clean_name)
|
|
166
|
+
filepath = File.join(dir, filename)
|
|
167
|
+
|
|
168
|
+
File.write(filepath, <<~RUBY)
|
|
169
|
+
# Seed: #{name.strip}
|
|
170
|
+
#
|
|
171
|
+
# This file is executed by `tina4 seed`.
|
|
172
|
+
# Use Tina4.seed_orm or Tina4.seed_table to populate data.
|
|
173
|
+
#
|
|
174
|
+
# Examples:
|
|
175
|
+
# Tina4.seed_orm(User, count: 50)
|
|
176
|
+
# Tina4.seed_table("audit_log", { action: :string, created_at: :datetime }, count: 100)
|
|
177
|
+
RUBY
|
|
178
|
+
|
|
179
|
+
puts "Created seed file: #{filepath}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
desc "console", "Start an interactive console"
|
|
183
|
+
def console
|
|
184
|
+
require_relative "../tina4"
|
|
185
|
+
Tina4.initialize!(Dir.pwd)
|
|
186
|
+
load_routes(Dir.pwd)
|
|
187
|
+
|
|
188
|
+
require "irb"
|
|
189
|
+
IRB.start
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
def load_routes(root_dir)
|
|
195
|
+
route_dirs = %w[routes src/routes src/api api]
|
|
196
|
+
route_dirs.each do |dir|
|
|
197
|
+
route_dir = File.join(root_dir, dir)
|
|
198
|
+
next unless Dir.exist?(route_dir)
|
|
199
|
+
Dir.glob(File.join(route_dir, "**/*.rb")).sort.each { |f| load f }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Also load app.rb if it exists
|
|
203
|
+
app_file = File.join(root_dir, "app.rb")
|
|
204
|
+
load app_file if File.exist?(app_file)
|
|
205
|
+
|
|
206
|
+
index_file = File.join(root_dir, "index.rb")
|
|
207
|
+
load index_file if File.exist?(index_file)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def create_project_structure(dir)
|
|
211
|
+
%w[
|
|
212
|
+
routes templates public public/css public/js public/images
|
|
213
|
+
migrations src logs
|
|
214
|
+
].each do |subdir|
|
|
215
|
+
FileUtils.mkdir_p(File.join(dir, subdir))
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def create_sample_files(dir, project_name)
|
|
220
|
+
# app.rb
|
|
221
|
+
unless File.exist?(File.join(dir, "app.rb"))
|
|
222
|
+
File.write(File.join(dir, "app.rb"), <<~RUBY)
|
|
223
|
+
require "tina4"
|
|
224
|
+
|
|
225
|
+
Tina4.get "/" do |request, response|
|
|
226
|
+
response.html "<h1>Welcome to #{project_name}!</h1><p>Powered by Tina4 Ruby</p>"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
Tina4.get "/api/hello" do |request, response|
|
|
230
|
+
response.json({ message: "Hello from Tina4!", timestamp: Time.now.iso8601 })
|
|
231
|
+
end
|
|
232
|
+
RUBY
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Gemfile
|
|
236
|
+
unless File.exist?(File.join(dir, "Gemfile"))
|
|
237
|
+
File.write(File.join(dir, "Gemfile"), <<~RUBY)
|
|
238
|
+
source "https://rubygems.org"
|
|
239
|
+
gem "tina4ruby"
|
|
240
|
+
RUBY
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# .gitignore
|
|
244
|
+
unless File.exist?(File.join(dir, ".gitignore"))
|
|
245
|
+
File.write(File.join(dir, ".gitignore"), <<~TEXT)
|
|
246
|
+
.env
|
|
247
|
+
.keys/
|
|
248
|
+
logs/
|
|
249
|
+
sessions/
|
|
250
|
+
.queue/
|
|
251
|
+
*.db
|
|
252
|
+
vendor/
|
|
253
|
+
TEXT
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Dockerfile
|
|
257
|
+
unless File.exist?(File.join(dir, "Dockerfile"))
|
|
258
|
+
File.write(File.join(dir, "Dockerfile"), <<~DOCKERFILE)
|
|
259
|
+
# === Build Stage ===
|
|
260
|
+
FROM ruby:3.3-alpine AS builder
|
|
261
|
+
|
|
262
|
+
# Install build dependencies
|
|
263
|
+
RUN apk add --no-cache \\
|
|
264
|
+
build-base \\
|
|
265
|
+
libffi-dev \\
|
|
266
|
+
gcompat
|
|
267
|
+
|
|
268
|
+
WORKDIR /app
|
|
269
|
+
|
|
270
|
+
# Copy dependency definition first (layer caching)
|
|
271
|
+
COPY Gemfile Gemfile.lock* ./
|
|
272
|
+
|
|
273
|
+
# Install gems
|
|
274
|
+
RUN bundle config set --local without 'development test' && \\
|
|
275
|
+
bundle install --jobs 4 --retry 3
|
|
276
|
+
|
|
277
|
+
# Copy application code
|
|
278
|
+
COPY . .
|
|
279
|
+
|
|
280
|
+
# === Runtime Stage ===
|
|
281
|
+
FROM ruby:3.3-alpine
|
|
282
|
+
|
|
283
|
+
# Runtime packages only
|
|
284
|
+
RUN apk add --no-cache libffi gcompat
|
|
285
|
+
|
|
286
|
+
WORKDIR /app
|
|
287
|
+
|
|
288
|
+
# Copy installed gems
|
|
289
|
+
COPY --from=builder /usr/local/bundle /usr/local/bundle
|
|
290
|
+
|
|
291
|
+
# Copy application code
|
|
292
|
+
COPY --from=builder /app /app
|
|
293
|
+
|
|
294
|
+
EXPOSE 7145
|
|
295
|
+
|
|
296
|
+
# Swagger defaults (override with env vars in docker-compose/k8s if needed)
|
|
297
|
+
ENV SWAGGER_TITLE="Tina4 API"
|
|
298
|
+
ENV SWAGGER_VERSION="0.1.0"
|
|
299
|
+
ENV SWAGGER_DESCRIPTION="Auto-generated API documentation"
|
|
300
|
+
|
|
301
|
+
# Start the server on all interfaces
|
|
302
|
+
CMD ["bundle", "exec", "tina4", "start", "-p", "7145", "-h", "0.0.0.0"]
|
|
303
|
+
DOCKERFILE
|
|
304
|
+
puts " Created Dockerfile"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# .dockerignore
|
|
308
|
+
unless File.exist?(File.join(dir, ".dockerignore"))
|
|
309
|
+
File.write(File.join(dir, ".dockerignore"), <<~TEXT)
|
|
310
|
+
.git
|
|
311
|
+
.env
|
|
312
|
+
.keys/
|
|
313
|
+
logs/
|
|
314
|
+
sessions/
|
|
315
|
+
.queue/
|
|
316
|
+
*.db
|
|
317
|
+
*.gem
|
|
318
|
+
tmp/
|
|
319
|
+
spec/
|
|
320
|
+
vendor/bundle
|
|
321
|
+
TEXT
|
|
322
|
+
puts " Created .dockerignore"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Base template
|
|
326
|
+
templates_dir = File.join(dir, "templates")
|
|
327
|
+
unless File.exist?(File.join(templates_dir, "base.twig"))
|
|
328
|
+
File.write(File.join(templates_dir, "base.twig"), <<~HTML)
|
|
329
|
+
<!DOCTYPE html>
|
|
330
|
+
<html lang="en">
|
|
331
|
+
<head>
|
|
332
|
+
<meta charset="UTF-8">
|
|
333
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
334
|
+
<title>{% block title %}#{project_name}{% endblock %}</title>
|
|
335
|
+
<link rel="stylesheet" href="/css/tina4.min.css">
|
|
336
|
+
{% block head %}{% endblock %}
|
|
337
|
+
</head>
|
|
338
|
+
<body>
|
|
339
|
+
{% block content %}{% endblock %}
|
|
340
|
+
<script src="/js/tina4.js"></script>
|
|
341
|
+
<script src="/js/tina4helper.js"></script>
|
|
342
|
+
{% block scripts %}{% endblock %}
|
|
343
|
+
</body>
|
|
344
|
+
</html>
|
|
345
|
+
HTML
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|