tina4 0.2.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 +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +662 -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 +243 -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_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/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/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 +142 -0
- data/lib/tina4/scss_compiler.rb +131 -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 +25 -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 +233 -0
- metadata +303 -0
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,243 @@
|
|
|
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 "console", "Start an interactive console"
|
|
147
|
+
def console
|
|
148
|
+
require_relative "../tina4"
|
|
149
|
+
Tina4.initialize!(Dir.pwd)
|
|
150
|
+
load_routes(Dir.pwd)
|
|
151
|
+
|
|
152
|
+
require "irb"
|
|
153
|
+
IRB.start
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def load_routes(root_dir)
|
|
159
|
+
route_dirs = %w[routes src/routes src/api api]
|
|
160
|
+
route_dirs.each do |dir|
|
|
161
|
+
route_dir = File.join(root_dir, dir)
|
|
162
|
+
next unless Dir.exist?(route_dir)
|
|
163
|
+
Dir.glob(File.join(route_dir, "**/*.rb")).sort.each { |f| load f }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Also load app.rb if it exists
|
|
167
|
+
app_file = File.join(root_dir, "app.rb")
|
|
168
|
+
load app_file if File.exist?(app_file)
|
|
169
|
+
|
|
170
|
+
index_file = File.join(root_dir, "index.rb")
|
|
171
|
+
load index_file if File.exist?(index_file)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def create_project_structure(dir)
|
|
175
|
+
%w[
|
|
176
|
+
routes templates public public/css public/js public/images
|
|
177
|
+
migrations src logs
|
|
178
|
+
].each do |subdir|
|
|
179
|
+
FileUtils.mkdir_p(File.join(dir, subdir))
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def create_sample_files(dir, project_name)
|
|
184
|
+
# app.rb
|
|
185
|
+
unless File.exist?(File.join(dir, "app.rb"))
|
|
186
|
+
File.write(File.join(dir, "app.rb"), <<~RUBY)
|
|
187
|
+
require "tina4"
|
|
188
|
+
|
|
189
|
+
Tina4.get "/" do |request, response|
|
|
190
|
+
response.html "<h1>Welcome to #{project_name}!</h1><p>Powered by Tina4 Ruby</p>"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
Tina4.get "/api/hello" do |request, response|
|
|
194
|
+
response.json({ message: "Hello from Tina4!", timestamp: Time.now.iso8601 })
|
|
195
|
+
end
|
|
196
|
+
RUBY
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Gemfile
|
|
200
|
+
unless File.exist?(File.join(dir, "Gemfile"))
|
|
201
|
+
File.write(File.join(dir, "Gemfile"), <<~RUBY)
|
|
202
|
+
source "https://rubygems.org"
|
|
203
|
+
gem "tina4"
|
|
204
|
+
RUBY
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# .gitignore
|
|
208
|
+
unless File.exist?(File.join(dir, ".gitignore"))
|
|
209
|
+
File.write(File.join(dir, ".gitignore"), <<~TEXT)
|
|
210
|
+
.env
|
|
211
|
+
.keys/
|
|
212
|
+
logs/
|
|
213
|
+
sessions/
|
|
214
|
+
.queue/
|
|
215
|
+
*.db
|
|
216
|
+
vendor/
|
|
217
|
+
TEXT
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Base template
|
|
221
|
+
templates_dir = File.join(dir, "templates")
|
|
222
|
+
unless File.exist?(File.join(templates_dir, "base.twig"))
|
|
223
|
+
File.write(File.join(templates_dir, "base.twig"), <<~HTML)
|
|
224
|
+
<!DOCTYPE html>
|
|
225
|
+
<html lang="en">
|
|
226
|
+
<head>
|
|
227
|
+
<meta charset="UTF-8">
|
|
228
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
229
|
+
<title>{% block title %}#{project_name}{% endblock %}</title>
|
|
230
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
231
|
+
{% block head %}{% endblock %}
|
|
232
|
+
</head>
|
|
233
|
+
<body>
|
|
234
|
+
{% block content %}{% endblock %}
|
|
235
|
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
236
|
+
{% block scripts %}{% endblock %}
|
|
237
|
+
</body>
|
|
238
|
+
</html>
|
|
239
|
+
HTML
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
data/lib/tina4/crud.rb
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Crud
|
|
5
|
+
class << self
|
|
6
|
+
def generate_table(records, table_name: "data", primary_key: "id", editable: true)
|
|
7
|
+
return "<p>No records found.</p>" if records.nil? || records.empty?
|
|
8
|
+
|
|
9
|
+
columns = records.first.keys
|
|
10
|
+
|
|
11
|
+
html = <<~HTML
|
|
12
|
+
<div class="table-responsive">
|
|
13
|
+
<table class="table table-striped table-hover" id="crud-#{table_name}">
|
|
14
|
+
<thead class="table-dark"><tr>
|
|
15
|
+
HTML
|
|
16
|
+
|
|
17
|
+
columns.each do |col|
|
|
18
|
+
html += "<th>#{col}</th>"
|
|
19
|
+
end
|
|
20
|
+
html += "<th>Actions</th>" if editable
|
|
21
|
+
html += "</tr></thead><tbody>"
|
|
22
|
+
|
|
23
|
+
records.each do |row|
|
|
24
|
+
pk_value = row[primary_key.to_sym] || row[primary_key.to_s]
|
|
25
|
+
html += "<tr data-id=\"#{pk_value}\">"
|
|
26
|
+
columns.each do |col|
|
|
27
|
+
value = row[col]
|
|
28
|
+
if editable
|
|
29
|
+
html += "<td contenteditable=\"true\" data-field=\"#{col}\">#{value}</td>"
|
|
30
|
+
else
|
|
31
|
+
html += "<td>#{value}</td>"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
if editable
|
|
35
|
+
html += "<td>"
|
|
36
|
+
html += "<button class=\"btn btn-sm btn-primary me-1\" onclick=\"crudSave('#{table_name}', '#{pk_value}')\">Save</button>"
|
|
37
|
+
html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudDelete('#{table_name}', '#{pk_value}')\">Delete</button>"
|
|
38
|
+
html += "</td>"
|
|
39
|
+
end
|
|
40
|
+
html += "</tr>"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
html += "</tbody></table></div>"
|
|
44
|
+
|
|
45
|
+
if editable
|
|
46
|
+
html += crud_javascript(table_name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
html
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def generate_form(fields, action: "/", method: "POST", table_name: "data")
|
|
53
|
+
html = "<form action=\"#{action}\" method=\"#{method}\" class=\"needs-validation\" novalidate>"
|
|
54
|
+
html += "<input type=\"hidden\" name=\"_method\" value=\"#{method}\">" if %w[PUT PATCH DELETE].include?(method.upcase)
|
|
55
|
+
|
|
56
|
+
fields.each do |field|
|
|
57
|
+
name = field[:name]
|
|
58
|
+
type = field[:type] || :string
|
|
59
|
+
label = field[:label] || name.to_s.capitalize
|
|
60
|
+
value = field[:value] || ""
|
|
61
|
+
required = field[:required] || false
|
|
62
|
+
|
|
63
|
+
html += "<div class=\"mb-3\">"
|
|
64
|
+
html += "<label for=\"#{name}\" class=\"form-label\">#{label}</label>"
|
|
65
|
+
|
|
66
|
+
case type.to_sym
|
|
67
|
+
when :text
|
|
68
|
+
html += "<textarea class=\"form-control\" id=\"#{name}\" name=\"#{name}\" #{'required' if required}>#{value}</textarea>"
|
|
69
|
+
when :boolean
|
|
70
|
+
checked = value ? "checked" : ""
|
|
71
|
+
html += "<div class=\"form-check\">"
|
|
72
|
+
html += "<input class=\"form-check-input\" type=\"checkbox\" id=\"#{name}\" name=\"#{name}\" #{checked}>"
|
|
73
|
+
html += "</div>"
|
|
74
|
+
when :select
|
|
75
|
+
html += "<select class=\"form-select\" id=\"#{name}\" name=\"#{name}\" #{'required' if required}>"
|
|
76
|
+
(field[:options] || []).each do |opt|
|
|
77
|
+
selected = opt[:value].to_s == value.to_s ? "selected" : ""
|
|
78
|
+
html += "<option value=\"#{opt[:value]}\" #{selected}>#{opt[:label]}</option>"
|
|
79
|
+
end
|
|
80
|
+
html += "</select>"
|
|
81
|
+
when :date
|
|
82
|
+
html += "<input type=\"date\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
|
|
83
|
+
when :integer, :number
|
|
84
|
+
html += "<input type=\"number\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
|
|
85
|
+
else
|
|
86
|
+
html += "<input type=\"text\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
|
|
87
|
+
end
|
|
88
|
+
html += "</div>"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
html += "<button type=\"submit\" class=\"btn btn-primary\">Submit</button>"
|
|
92
|
+
html += "</form>"
|
|
93
|
+
html
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def crud_javascript(table_name)
|
|
99
|
+
<<~JS
|
|
100
|
+
<script>
|
|
101
|
+
function crudSave(table, id) {
|
|
102
|
+
const row = document.querySelector(`tr[data-id="${id}"]`);
|
|
103
|
+
const cells = row.querySelectorAll('td[data-field]');
|
|
104
|
+
const data = {};
|
|
105
|
+
cells.forEach(cell => { data[cell.dataset.field] = cell.textContent; });
|
|
106
|
+
fetch(`/api/${table}/${id}`, {
|
|
107
|
+
method: 'PUT',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify(data)
|
|
110
|
+
}).then(r => r.json()).then(d => { alert('Saved!'); }).catch(e => alert('Error: ' + e));
|
|
111
|
+
}
|
|
112
|
+
function crudDelete(table, id) {
|
|
113
|
+
if (!confirm('Delete this record?')) return;
|
|
114
|
+
fetch(`/api/${table}/${id}`, { method: 'DELETE' })
|
|
115
|
+
.then(r => r.json())
|
|
116
|
+
.then(d => { document.querySelector(`tr[data-id="${id}"]`).remove(); })
|
|
117
|
+
.catch(e => alert('Error: ' + e));
|
|
118
|
+
}
|
|
119
|
+
</script>
|
|
120
|
+
JS
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|