tina4ruby 3.2.1 → 3.10.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 +4 -4
- data/README.md +19 -20
- data/lib/tina4/auth.rb +137 -27
- data/lib/tina4/auto_crud.rb +55 -3
- data/lib/tina4/cli.rb +75 -2
- data/lib/tina4/cors.rb +1 -1
- data/lib/tina4/database.rb +131 -28
- data/lib/tina4/database_result.rb +122 -8
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/frond.rb +148 -2
- data/lib/tina4/localization.rb +1 -1
- data/lib/tina4/middleware.rb +349 -1
- data/lib/tina4/migration.rb +132 -11
- data/lib/tina4/orm.rb +17 -8
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
- data/lib/tina4/public/js/tina4js.min.js +47 -0
- data/lib/tina4/query_builder.rb +374 -0
- data/lib/tina4/queue.rb +128 -90
- data/lib/tina4/queue_backends/lite_backend.rb +42 -7
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
- data/lib/tina4/rack_app.rb +194 -18
- data/lib/tina4/request.rb +14 -1
- data/lib/tina4/response.rb +26 -0
- data/lib/tina4/router.rb +127 -0
- data/lib/tina4/service_runner.rb +1 -1
- data/lib/tina4/session.rb +6 -1
- data/lib/tina4/session_handlers/database_handler.rb +66 -0
- data/lib/tina4/swagger.rb +1 -1
- data/lib/tina4/template.rb +10 -1
- data/lib/tina4/validator.rb +174 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +23 -4
- data/lib/tina4/websocket_backplane.rb +118 -0
- data/lib/tina4.rb +64 -4
- metadata +12 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b43c80adf7c00f71f0c14d64b4660f612b0124008b6a4821f431bae1d25c88d8
|
|
4
|
+
data.tar.gz: ca4230e2b17265849c05e5118c095f7359a7728036e299dc2813a56c53f85c47
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8200644fd078c8c4cb635610bef5392daddda3f86d1d7366973d16ffbd9457eb9e710db6cba8430384ff6b052b55dbbff0129c3ca31f878e067aab608e053ed0
|
|
7
|
+
data.tar.gz: 900b1b9f2fa791c1ebfcd5f0e492d73ade490c7c48126d3b2b1ff71f5d6a41ae8991552d69ae2ae3b4bdbff884a6aebfd3f37d01b6fa2bb163d2779dea53a19d
|
data/README.md
CHANGED
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
Laravel joy. Ruby speed. 10x less code. Zero third-party dependencies.
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://rubygems.org/gems/tina4"><img src="https://img.shields.io/gem/v/tina4?color=7b1fa2&label=RubyGems" alt="Gem"></a>
|
|
14
|
+
<img src="https://img.shields.io/badge/tests-1%2C578%20passing-brightgreen" alt="Tests">
|
|
15
|
+
<img src="https://img.shields.io/badge/features-38-blue" alt="Features">
|
|
16
|
+
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero Deps">
|
|
17
|
+
<a href="https://tina4.com"><img src="https://img.shields.io/badge/docs-tina4.com-7b1fa2" alt="Docs"></a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
12
20
|
<p align="center">
|
|
13
21
|
<a href="https://tina4.com">Documentation</a> •
|
|
14
22
|
<a href="#getting-started">Getting Started</a> •
|
|
@@ -17,14 +25,6 @@
|
|
|
17
25
|
<a href="https://tina4.com">tina4.com</a>
|
|
18
26
|
</p>
|
|
19
27
|
|
|
20
|
-
<p align="center">
|
|
21
|
-
<img src="https://img.shields.io/badge/tests-1577%20passing-brightgreen" alt="Tests">
|
|
22
|
-
<img src="https://img.shields.io/badge/carbonah-A%2B%20rated-00cc44" alt="Carbonah A+">
|
|
23
|
-
<img src="https://img.shields.io/badge/zero--dep-core-blue" alt="Zero Dependencies">
|
|
24
|
-
<img src="https://img.shields.io/badge/ruby-3.1%2B-blue" alt="Ruby 3.1+">
|
|
25
|
-
<img src="https://img.shields.io/badge/license-MIT-lightgrey" alt="MIT License">
|
|
26
|
-
</p>
|
|
27
|
-
|
|
28
28
|
---
|
|
29
29
|
|
|
30
30
|
## Quick Start
|
|
@@ -87,16 +87,16 @@ Every feature is built from scratch -- no gem install, no node_modules, no third
|
|
|
87
87
|
| **Templates** | Frond engine (Twig-compatible), inheritance, partials, 35+ filters, macros, fragment caching, sandboxing |
|
|
88
88
|
| **ORM** | Active Record, typed fields with validation, soft delete, relationships (`has_one`/`has_many`/`belongs_to`), scopes, result caching, multi-database |
|
|
89
89
|
| **Database** | SQLite, PostgreSQL, MySQL, MSSQL, Firebird -- unified adapter interface, query caching (TINA4_DB_CACHE=true for 4x speedup) |
|
|
90
|
-
| **Auth** | Zero-dep JWT (RS256), sessions (file
|
|
91
|
-
| **API** | Swagger/OpenAPI auto-generation, GraphQL with ORM auto-schema and GraphiQL IDE |
|
|
92
|
-
| **Background** |
|
|
90
|
+
| **Auth** | Zero-dep JWT (HS256/RS256), sessions (file/Redis/Valkey/MongoDB/database), password hashing, form tokens |
|
|
91
|
+
| **API** | Swagger/OpenAPI auto-generation, GraphQL with ORM auto-schema and GraphiQL IDE, WSDL/SOAP with auto WSDL |
|
|
92
|
+
| **Background** | Queue (SQLite/RabbitMQ/Kafka/MongoDB) with priority, delayed jobs, retry, batch processing |
|
|
93
93
|
| **Real-time** | Native WebSocket (RFC 6455), per-path routing, connection manager |
|
|
94
94
|
| **Frontend** | tina4-css (~24 KB), frond.js helper, SCSS compiler, live reload, CSS hot-reload |
|
|
95
95
|
| **DX** | Dev admin dashboard, error overlay, request inspector, AI tool integration, Carbonah green benchmarks |
|
|
96
96
|
| **Data** | Migrations with rollback, 50+ fake data generators, ORM and table seeders |
|
|
97
97
|
| **Other** | REST client, localization (6 languages), cache (memory/Redis/file), event system, inline testing, messenger (.env driven), configurable error pages |
|
|
98
98
|
|
|
99
|
-
**1,
|
|
99
|
+
**1,578 tests across 38 built-in features. Zero dependencies. All Carbonah benchmarks rated A+.**
|
|
100
100
|
|
|
101
101
|
For full documentation visit **[tina4.com](https://tina4.com)**.
|
|
102
102
|
|
|
@@ -358,7 +358,7 @@ db = Tina4::Database.new("mysql://localhost:3306/mydb", username: "user", passwo
|
|
|
358
358
|
db = Tina4::Database.new("mssql://localhost:1433/mydb", username: "sa", password: "pass")
|
|
359
359
|
db = Tina4::Database.new("firebird://localhost:3050/path/to/db", username: "SYSDBA", password: "masterkey")
|
|
360
360
|
|
|
361
|
-
result = db.fetch("SELECT * FROM users WHERE age > ?", [18], limit: 20,
|
|
361
|
+
result = db.fetch("SELECT * FROM users WHERE age > ?", [18], limit: 20, offset: 0)
|
|
362
362
|
row = db.fetch_one("SELECT * FROM users WHERE id = ?", [1])
|
|
363
363
|
db.insert("users", { name: "Alice", email: "alice@test.com" })
|
|
364
364
|
db.commit
|
|
@@ -381,8 +381,8 @@ end
|
|
|
381
381
|
### JWT Authentication
|
|
382
382
|
|
|
383
383
|
```ruby
|
|
384
|
-
token = Tina4::Auth.
|
|
385
|
-
result = Tina4::Auth.
|
|
384
|
+
token = Tina4::Auth.get_token({ user_id: 42 })
|
|
385
|
+
result = Tina4::Auth.valid_token(token)
|
|
386
386
|
payload = Tina4::Auth.get_payload(token)
|
|
387
387
|
```
|
|
388
388
|
|
|
@@ -400,11 +400,10 @@ Backends: file (default), Redis, MongoDB. Set via `TINA4_SESSION_HANDLER` in `.e
|
|
|
400
400
|
### Queues
|
|
401
401
|
|
|
402
402
|
```ruby
|
|
403
|
-
|
|
404
|
-
|
|
403
|
+
queue = Tina4::Queue.new(topic: "emails")
|
|
404
|
+
queue.produce("emails", { to: "alice@example.com" })
|
|
405
405
|
|
|
406
|
-
|
|
407
|
-
consumer.each { |msg| send_email(msg.data) }
|
|
406
|
+
queue.consume("emails") { |msg| send_email(msg.payload) }
|
|
408
407
|
```
|
|
409
408
|
|
|
410
409
|
### GraphQL
|
|
@@ -607,7 +606,7 @@ DATABASE_USERNAME=
|
|
|
607
606
|
DATABASE_PASSWORD=
|
|
608
607
|
TINA4_DEBUG=true # Enable dev toolbar, error overlay
|
|
609
608
|
TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
|
|
610
|
-
|
|
609
|
+
TINA4_LOCALE=en # en, fr, af, zh, ja, es
|
|
611
610
|
TINA4_SESSION_HANDLER=SessionFileHandler
|
|
612
611
|
SWAGGER_TITLE=My API
|
|
613
612
|
```
|
data/lib/tina4/auth.rb
CHANGED
|
@@ -12,27 +12,130 @@ module Tina4
|
|
|
12
12
|
def setup(root_dir = Dir.pwd)
|
|
13
13
|
@keys_dir = File.join(root_dir, KEYS_DIR)
|
|
14
14
|
FileUtils.mkdir_p(@keys_dir)
|
|
15
|
-
ensure_keys
|
|
15
|
+
ensure_keys unless use_hmac?
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
# ── HS256 helpers (stdlib only, no gem) ──────────────────────
|
|
19
|
+
|
|
20
|
+
# Returns true when SECRET env var is set and no RSA keys exist in .keys/
|
|
21
|
+
def use_hmac?
|
|
22
|
+
secret = ENV["SECRET"]
|
|
23
|
+
return false if secret.nil? || secret.empty?
|
|
24
|
+
|
|
25
|
+
# If RSA keys already exist on disk, prefer RS256 for backward compat
|
|
26
|
+
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
27
|
+
!(File.exist?(File.join(@keys_dir, "private.pem")) &&
|
|
28
|
+
File.exist?(File.join(@keys_dir, "public.pem")))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def hmac_secret
|
|
32
|
+
ENV["SECRET"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Base64url-encode without padding (JWT spec)
|
|
36
|
+
def base64url_encode(data)
|
|
37
|
+
Base64.urlsafe_encode64(data, padding: false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Base64url-decode (handles missing padding)
|
|
41
|
+
def base64url_decode(str)
|
|
42
|
+
# Add back padding
|
|
43
|
+
remainder = str.length % 4
|
|
44
|
+
str += "=" * ((4 - remainder) % 4) if remainder != 0
|
|
45
|
+
Base64.urlsafe_decode64(str)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build a JWT using HS256 with Ruby's OpenSSL::HMAC (no gem needed)
|
|
49
|
+
def hmac_encode(claims, secret)
|
|
50
|
+
header = { "alg" => "HS256", "typ" => "JWT" }
|
|
51
|
+
segments = [
|
|
52
|
+
base64url_encode(JSON.generate(header)),
|
|
53
|
+
base64url_encode(JSON.generate(claims))
|
|
54
|
+
]
|
|
55
|
+
signing_input = segments.join(".")
|
|
56
|
+
signature = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
|
|
57
|
+
segments << base64url_encode(signature)
|
|
58
|
+
segments.join(".")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Decode and verify a JWT signed with HS256. Returns the payload hash or nil.
|
|
62
|
+
def hmac_decode(token, secret)
|
|
63
|
+
parts = token.split(".")
|
|
64
|
+
return nil unless parts.length == 3
|
|
65
|
+
|
|
66
|
+
header_json = base64url_decode(parts[0])
|
|
67
|
+
header = JSON.parse(header_json)
|
|
68
|
+
return nil unless header["alg"] == "HS256"
|
|
69
|
+
|
|
70
|
+
# Verify signature
|
|
71
|
+
signing_input = "#{parts[0]}.#{parts[1]}"
|
|
72
|
+
expected_sig = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
|
|
73
|
+
actual_sig = base64url_decode(parts[2])
|
|
74
|
+
|
|
75
|
+
# Constant-time comparison to prevent timing attacks
|
|
76
|
+
return nil unless OpenSSL.fixed_length_secure_compare(expected_sig, actual_sig)
|
|
77
|
+
|
|
78
|
+
payload = JSON.parse(base64url_decode(parts[1]))
|
|
79
|
+
|
|
80
|
+
# Check expiry
|
|
81
|
+
now = Time.now.to_i
|
|
82
|
+
return nil if payload["exp"] && now >= payload["exp"]
|
|
83
|
+
return nil if payload["nbf"] && now < payload["nbf"]
|
|
84
|
+
|
|
85
|
+
payload
|
|
86
|
+
rescue ArgumentError, JSON::ParserError, OpenSSL::HMACError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# ── Token API (auto-selects HS256 or RS256) ─────────────────
|
|
91
|
+
|
|
92
|
+
def get_token(payload, expires_in: 3600)
|
|
20
93
|
now = Time.now.to_i
|
|
21
94
|
claims = payload.merge(
|
|
22
95
|
"iat" => now,
|
|
23
96
|
"exp" => now + expires_in,
|
|
24
97
|
"nbf" => now
|
|
25
98
|
)
|
|
26
|
-
|
|
27
|
-
|
|
99
|
+
|
|
100
|
+
if use_hmac?
|
|
101
|
+
hmac_encode(claims, hmac_secret)
|
|
102
|
+
else
|
|
103
|
+
ensure_keys
|
|
104
|
+
require "jwt"
|
|
105
|
+
JWT.encode(claims, private_key, "RS256")
|
|
106
|
+
end
|
|
28
107
|
end
|
|
29
108
|
|
|
30
109
|
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
110
|
+
def valid_token(token)
|
|
111
|
+
if use_hmac?
|
|
112
|
+
hmac_decode(token, hmac_secret)
|
|
113
|
+
else
|
|
114
|
+
ensure_keys
|
|
115
|
+
require "jwt"
|
|
116
|
+
decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
117
|
+
decoded[0]
|
|
118
|
+
end
|
|
119
|
+
rescue JWT::ExpiredSignature
|
|
120
|
+
nil
|
|
121
|
+
rescue JWT::DecodeError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def valid_token_detail(token)
|
|
126
|
+
if use_hmac?
|
|
127
|
+
payload = hmac_decode(token, hmac_secret)
|
|
128
|
+
if payload
|
|
129
|
+
{ valid: true, payload: payload }
|
|
130
|
+
else
|
|
131
|
+
{ valid: false, error: "Invalid or expired token" }
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
ensure_keys
|
|
135
|
+
require "jwt"
|
|
136
|
+
decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
137
|
+
{ valid: true, payload: decoded[0] }
|
|
138
|
+
end
|
|
36
139
|
rescue JWT::ExpiredSignature
|
|
37
140
|
{ valid: false, error: "Token expired" }
|
|
38
141
|
rescue JWT::DecodeError => e
|
|
@@ -53,34 +156,36 @@ module Tina4
|
|
|
53
156
|
|
|
54
157
|
|
|
55
158
|
def get_payload(token)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
159
|
+
parts = token.split(".")
|
|
160
|
+
return nil unless parts.length == 3
|
|
161
|
+
|
|
162
|
+
payload_json = base64url_decode(parts[1])
|
|
163
|
+
JSON.parse(payload_json)
|
|
164
|
+
rescue ArgumentError, JSON::ParserError
|
|
60
165
|
nil
|
|
61
166
|
end
|
|
62
167
|
|
|
63
168
|
def refresh_token(token, expires_in: 3600)
|
|
64
|
-
|
|
65
|
-
return nil unless
|
|
169
|
+
payload = valid_token(token)
|
|
170
|
+
return nil unless payload
|
|
66
171
|
|
|
67
|
-
payload =
|
|
68
|
-
|
|
172
|
+
payload = payload.reject { |k, _| %w[iat exp nbf].include?(k) }
|
|
173
|
+
get_token(payload, expires_in: expires_in)
|
|
69
174
|
end
|
|
70
175
|
|
|
71
176
|
def authenticate_request(headers)
|
|
72
177
|
auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || ""
|
|
73
|
-
return
|
|
178
|
+
return nil unless auth_header =~ /\ABearer\s+(.+)\z/i
|
|
74
179
|
|
|
75
180
|
token = Regexp.last_match(1)
|
|
76
181
|
|
|
77
182
|
# API_KEY bypass — matches tina4_python behavior
|
|
78
183
|
api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
79
184
|
if api_key && !api_key.empty? && token == api_key
|
|
80
|
-
return {
|
|
185
|
+
return { "api_key" => true }
|
|
81
186
|
end
|
|
82
187
|
|
|
83
|
-
|
|
188
|
+
valid_token(token)
|
|
84
189
|
end
|
|
85
190
|
|
|
86
191
|
def validate_api_key(provided, expected: nil)
|
|
@@ -107,15 +212,15 @@ module Tina4
|
|
|
107
212
|
token = Regexp.last_match(1)
|
|
108
213
|
|
|
109
214
|
# API_KEY bypass — matches tina4_python behavior
|
|
110
|
-
api_key = ENV["API_KEY"]
|
|
215
|
+
api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
|
|
111
216
|
if api_key && !api_key.empty? && token == api_key
|
|
112
217
|
env["tina4.auth"] = { "api_key" => true }
|
|
113
218
|
return true
|
|
114
219
|
end
|
|
115
220
|
|
|
116
|
-
|
|
117
|
-
if
|
|
118
|
-
env["tina4.auth"] =
|
|
221
|
+
payload = valid_token(token)
|
|
222
|
+
if payload
|
|
223
|
+
env["tina4.auth"] = payload
|
|
119
224
|
true
|
|
120
225
|
else
|
|
121
226
|
false
|
|
@@ -129,6 +234,10 @@ module Tina4
|
|
|
129
234
|
@default_secure_auth ||= bearer_auth
|
|
130
235
|
end
|
|
131
236
|
|
|
237
|
+
# Legacy aliases
|
|
238
|
+
alias_method :create_token, :get_token
|
|
239
|
+
alias_method :validate_token, :valid_token_detail
|
|
240
|
+
|
|
132
241
|
def private_key
|
|
133
242
|
@private_key ||= OpenSSL::PKey::RSA.new(File.read(private_key_path))
|
|
134
243
|
end
|
|
@@ -140,6 +249,8 @@ module Tina4
|
|
|
140
249
|
private
|
|
141
250
|
|
|
142
251
|
def ensure_keys
|
|
252
|
+
return if use_hmac?
|
|
253
|
+
|
|
143
254
|
@keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
|
|
144
255
|
FileUtils.mkdir_p(@keys_dir)
|
|
145
256
|
unless File.exist?(private_key_path) && File.exist?(public_key_path)
|
|
@@ -169,8 +280,7 @@ module Tina4
|
|
|
169
280
|
return true if auth_header.empty?
|
|
170
281
|
|
|
171
282
|
if auth_header =~ /\ABearer\s+(.+)\z/i
|
|
172
|
-
|
|
173
|
-
result[:valid]
|
|
283
|
+
!valid_token(Regexp.last_match(1)).nil?
|
|
174
284
|
else
|
|
175
285
|
false
|
|
176
286
|
end
|
data/lib/tina4/auto_crud.rb
CHANGED
|
@@ -21,10 +21,36 @@ module Tina4
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Build a sample request body from ORM field definitions.
|
|
25
|
+
def build_example(model_class)
|
|
26
|
+
example = {}
|
|
27
|
+
return example unless model_class.respond_to?(:field_definitions)
|
|
28
|
+
|
|
29
|
+
model_class.field_definitions.each do |name, opts|
|
|
30
|
+
next if opts[:primary_key] && opts[:auto_increment]
|
|
31
|
+
|
|
32
|
+
case opts[:type]
|
|
33
|
+
when :integer
|
|
34
|
+
example[name.to_s] = 0
|
|
35
|
+
when :numeric, :float, :decimal
|
|
36
|
+
example[name.to_s] = 0.0
|
|
37
|
+
when :boolean
|
|
38
|
+
example[name.to_s] = true
|
|
39
|
+
when :datetime
|
|
40
|
+
example[name.to_s] = "2024-01-01T00:00:00"
|
|
41
|
+
else
|
|
42
|
+
example[name.to_s] = "string"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
example
|
|
46
|
+
end
|
|
47
|
+
|
|
24
48
|
# Generate REST endpoints for a single model class
|
|
25
49
|
def generate_routes_for(model_class, prefix: "/api")
|
|
26
50
|
table = model_class.table_name
|
|
27
51
|
pk = model_class.primary_key_field || :id
|
|
52
|
+
pretty_name = table.to_s.split("_").map(&:capitalize).join(" ")
|
|
53
|
+
example_body = build_example(model_class)
|
|
28
54
|
|
|
29
55
|
# GET /api/{table} -- list all with pagination, filtering, sorting
|
|
30
56
|
Tina4::Router.add_route("GET", "#{prefix}/#{table}", proc { |req, res|
|
|
@@ -63,7 +89,7 @@ module Tina4
|
|
|
63
89
|
rescue => e
|
|
64
90
|
res.json({ error: e.message }, status: 500)
|
|
65
91
|
end
|
|
66
|
-
})
|
|
92
|
+
}, swagger_meta: { summary: "List all #{pretty_name}", tags: [table.to_s] })
|
|
67
93
|
|
|
68
94
|
# GET /api/{table}/{id} -- get single record
|
|
69
95
|
Tina4::Router.add_route("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
@@ -78,7 +104,7 @@ module Tina4
|
|
|
78
104
|
rescue => e
|
|
79
105
|
res.json({ error: e.message }, status: 500)
|
|
80
106
|
end
|
|
81
|
-
})
|
|
107
|
+
}, swagger_meta: { summary: "Get #{pretty_name} by ID", tags: [table.to_s] })
|
|
82
108
|
|
|
83
109
|
# POST /api/{table} -- create record
|
|
84
110
|
Tina4::Router.add_route("POST", "#{prefix}/#{table}", proc { |req, res|
|
|
@@ -93,6 +119,19 @@ module Tina4
|
|
|
93
119
|
rescue => e
|
|
94
120
|
res.json({ error: e.message }, status: 500)
|
|
95
121
|
end
|
|
122
|
+
}, swagger_meta: {
|
|
123
|
+
summary: "Create #{pretty_name}",
|
|
124
|
+
tags: [table.to_s],
|
|
125
|
+
request_body: {
|
|
126
|
+
"description" => "#{pretty_name} data",
|
|
127
|
+
"required" => true,
|
|
128
|
+
"content" => {
|
|
129
|
+
"application/json" => {
|
|
130
|
+
"schema" => { "type" => "object" },
|
|
131
|
+
"example" => example_body
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
96
135
|
})
|
|
97
136
|
|
|
98
137
|
# PUT /api/{table}/{id} -- update record
|
|
@@ -118,6 +157,19 @@ module Tina4
|
|
|
118
157
|
rescue => e
|
|
119
158
|
res.json({ error: e.message }, status: 500)
|
|
120
159
|
end
|
|
160
|
+
}, swagger_meta: {
|
|
161
|
+
summary: "Update #{pretty_name}",
|
|
162
|
+
tags: [table.to_s],
|
|
163
|
+
request_body: {
|
|
164
|
+
"description" => "#{pretty_name} data",
|
|
165
|
+
"required" => true,
|
|
166
|
+
"content" => {
|
|
167
|
+
"application/json" => {
|
|
168
|
+
"schema" => { "type" => "object" },
|
|
169
|
+
"example" => example_body
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
121
173
|
})
|
|
122
174
|
|
|
123
175
|
# DELETE /api/{table}/{id} -- delete record
|
|
@@ -137,7 +189,7 @@ module Tina4
|
|
|
137
189
|
rescue => e
|
|
138
190
|
res.json({ error: e.message }, status: 500)
|
|
139
191
|
end
|
|
140
|
-
})
|
|
192
|
+
}, swagger_meta: { summary: "Delete #{pretty_name}", tags: [table.to_s] })
|
|
141
193
|
end
|
|
142
194
|
|
|
143
195
|
def clear!
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -5,7 +5,7 @@ require "fileutils"
|
|
|
5
5
|
|
|
6
6
|
module Tina4
|
|
7
7
|
class CLI
|
|
8
|
-
COMMANDS = %w[init start migrate seed seed:create test version routes console generate ai help].freeze
|
|
8
|
+
COMMANDS = %w[init start migrate migrate:status migrate:rollback seed seed:create test version routes console generate ai help].freeze
|
|
9
9
|
|
|
10
10
|
def self.start(argv)
|
|
11
11
|
new.run(argv)
|
|
@@ -17,6 +17,8 @@ module Tina4
|
|
|
17
17
|
when "init" then cmd_init(argv)
|
|
18
18
|
when "start" then cmd_start(argv)
|
|
19
19
|
when "migrate" then cmd_migrate(argv)
|
|
20
|
+
when "migrate:status" then cmd_migrate_status(argv)
|
|
21
|
+
when "migrate:rollback" then cmd_migrate_rollback(argv)
|
|
20
22
|
when "seed" then cmd_seed(argv)
|
|
21
23
|
when "seed:create" then cmd_seed_create(argv)
|
|
22
24
|
when "test" then cmd_test(argv)
|
|
@@ -180,6 +182,75 @@ module Tina4
|
|
|
180
182
|
end
|
|
181
183
|
end
|
|
182
184
|
|
|
185
|
+
# ── migrate:status ─────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def cmd_migrate_status(_argv)
|
|
188
|
+
require_relative "../tina4"
|
|
189
|
+
Tina4.initialize!(Dir.pwd)
|
|
190
|
+
|
|
191
|
+
db = Tina4.database
|
|
192
|
+
unless db
|
|
193
|
+
puts "No database configured. Set DATABASE_URL in your .env file."
|
|
194
|
+
return
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
migration = Tina4::Migration.new(db)
|
|
198
|
+
info = migration.status
|
|
199
|
+
|
|
200
|
+
puts "\nMigration Status"
|
|
201
|
+
puts "-" * 60
|
|
202
|
+
|
|
203
|
+
if info[:completed].any?
|
|
204
|
+
puts "\nCompleted:"
|
|
205
|
+
info[:completed].each { |name| puts " [OK] #{name}" }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if info[:pending].any?
|
|
209
|
+
puts "\nPending:"
|
|
210
|
+
info[:pending].each { |name| puts " [ ] #{name}" }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
if info[:completed].empty? && info[:pending].empty?
|
|
214
|
+
puts " No migrations found."
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
puts "-" * 60
|
|
218
|
+
puts " Completed: #{info[:completed].length} Pending: #{info[:pending].length}\n"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# ── migrate:rollback ───────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
def cmd_migrate_rollback(argv)
|
|
224
|
+
options = { steps: 1 }
|
|
225
|
+
parser = OptionParser.new do |opts|
|
|
226
|
+
opts.banner = "Usage: tina4ruby migrate:rollback [options]"
|
|
227
|
+
opts.on("-n", "--steps N", Integer, "Number of batches to rollback (default: 1)") { |v| options[:steps] = v }
|
|
228
|
+
end
|
|
229
|
+
parser.parse!(argv)
|
|
230
|
+
|
|
231
|
+
require_relative "../tina4"
|
|
232
|
+
Tina4.initialize!(Dir.pwd)
|
|
233
|
+
|
|
234
|
+
db = Tina4.database
|
|
235
|
+
unless db
|
|
236
|
+
puts "No database configured. Set DATABASE_URL in your .env file."
|
|
237
|
+
return
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
migration = Tina4::Migration.new(db)
|
|
241
|
+
results = migration.rollback(options[:steps])
|
|
242
|
+
|
|
243
|
+
if results.empty?
|
|
244
|
+
puts "Nothing to rollback."
|
|
245
|
+
else
|
|
246
|
+
results.each do |r|
|
|
247
|
+
status_icon = r[:status] == "rolled_back" ? "OK" : "FAIL"
|
|
248
|
+
puts " [#{status_icon}] #{r[:name]}"
|
|
249
|
+
end
|
|
250
|
+
puts "Rolled back #{results.length} migration(s)."
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
183
254
|
# ── seed ──────────────────────────────────────────────────────────────
|
|
184
255
|
|
|
185
256
|
def cmd_seed(argv)
|
|
@@ -446,6 +517,8 @@ module Tina4
|
|
|
446
517
|
init [NAME] Initialize a new Tina4 project
|
|
447
518
|
start Start the Tina4 web server
|
|
448
519
|
migrate Run database migrations
|
|
520
|
+
migrate:status Show migration status (completed and pending)
|
|
521
|
+
migrate:rollback Rollback the last batch of migrations
|
|
449
522
|
seed Run all seed files in seeds/
|
|
450
523
|
seed:create NAME Create a new seed file
|
|
451
524
|
test Run inline tests
|
|
@@ -482,7 +555,7 @@ module Tina4
|
|
|
482
555
|
# ── shared helpers ────────────────────────────────────────────────────
|
|
483
556
|
|
|
484
557
|
def load_routes(root_dir)
|
|
485
|
-
route_dirs = %w[
|
|
558
|
+
route_dirs = %w[src/routes routes src/api api src/orm orm]
|
|
486
559
|
route_dirs.each do |dir|
|
|
487
560
|
route_dir = File.join(root_dir, dir)
|
|
488
561
|
next unless Dir.exist?(route_dir)
|
data/lib/tina4/cors.rb
CHANGED
|
@@ -52,7 +52,7 @@ module Tina4
|
|
|
52
52
|
{
|
|
53
53
|
origins: ENV["TINA4_CORS_ORIGINS"] || "*",
|
|
54
54
|
methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
55
|
-
headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,
|
|
55
|
+
headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
|
|
56
56
|
max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
|
|
57
57
|
credentials: ENV["TINA4_CORS_CREDENTIALS"] || "false"
|
|
58
58
|
}.freeze
|