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/README.md
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
# Tina4 Ruby
|
|
2
|
+
|
|
3
|
+
**Simple. Fast. Human. This is not a framework.**
|
|
4
|
+
|
|
5
|
+
A lightweight, zero-configuration, Windows-friendly Ruby web framework. If you know [tina4_python](https://tina4.com) or tina4_php, you'll feel right at home.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install tina4
|
|
11
|
+
tina4 init myapp
|
|
12
|
+
cd myapp
|
|
13
|
+
bundle install
|
|
14
|
+
tina4 start
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Your app is now running at `http://localhost:7145`.
|
|
18
|
+
|
|
19
|
+
## Routing
|
|
20
|
+
|
|
21
|
+
Register routes using a clean Ruby DSL:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "tina4"
|
|
25
|
+
|
|
26
|
+
# GET request
|
|
27
|
+
Tina4.get "/hello" do |request, response|
|
|
28
|
+
response.json({ message: "Hello World!" })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# POST request
|
|
32
|
+
Tina4.post "/api/users" do |request, response|
|
|
33
|
+
data = request.json_body
|
|
34
|
+
response.json({ created: true, name: data["name"] }, 201)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Path parameters with type constraints
|
|
38
|
+
Tina4.get "/api/users/{id:int}" do |request, response|
|
|
39
|
+
user_id = request.params["id"] # auto-cast to Integer
|
|
40
|
+
response.json({ user_id: user_id })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Tina4.get "/files/{path:path}" do |request, response|
|
|
44
|
+
response.json({ path: request.params["path"] })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# PUT, PATCH, DELETE
|
|
48
|
+
Tina4.put "/api/users/{id:int}" do |request, response|
|
|
49
|
+
response.json({ updated: true })
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Tina4.delete "/api/users/{id:int}" do |request, response|
|
|
53
|
+
response.json({ deleted: true })
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Match any HTTP method
|
|
57
|
+
Tina4.any "/webhook" do |request, response|
|
|
58
|
+
response.json({ method: request.method })
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Auth Defaults
|
|
63
|
+
|
|
64
|
+
Tina4 Ruby matches tina4_python's auth behavior:
|
|
65
|
+
|
|
66
|
+
- **GET** routes are **public** by default
|
|
67
|
+
- **POST/PUT/PATCH/DELETE** routes are **secured** by default (require `Authorization: Bearer <token>`)
|
|
68
|
+
- Use `auth: false` to make a write route public (equivalent to tina4_python's `@noauth()`)
|
|
69
|
+
- Set `API_KEY` in `.env` to allow API key bypass (token matches `API_KEY` → access granted)
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
# POST is secured by default — requires Bearer token
|
|
73
|
+
Tina4.post "/api/users" do |request, response|
|
|
74
|
+
response.json({ created: true })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Make a POST route public (no auth required)
|
|
78
|
+
Tina4.post "/api/webhook", auth: false do |request, response|
|
|
79
|
+
response.json({ received: true })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Custom auth handler
|
|
83
|
+
custom_auth = lambda do |env|
|
|
84
|
+
env["HTTP_X_API_KEY"] == "my-secret"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
Tina4.post "/api/custom", auth: custom_auth do |request, response|
|
|
88
|
+
response.json({ ok: true })
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Secured Routes
|
|
93
|
+
|
|
94
|
+
For explicitly securing GET routes (which are public by default):
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
Tina4.secure_get "/api/profile" do |request, response|
|
|
98
|
+
response.json({ user: "authenticated" })
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
Tina4.secure_post "/api/admin/action" do |request, response|
|
|
102
|
+
response.json({ success: true })
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Route Groups
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
Tina4.group "/api/v1" do
|
|
110
|
+
get("/users") { |req, res| res.json(users) }
|
|
111
|
+
post("/users") { |req, res| res.json({ created: true }) }
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Request Object
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
Tina4.post "/example" do |request, response|
|
|
119
|
+
request.method # "POST"
|
|
120
|
+
request.path # "/example"
|
|
121
|
+
request.params # merged path + query params
|
|
122
|
+
request.headers # HTTP headers hash
|
|
123
|
+
request.cookies # parsed cookies
|
|
124
|
+
request.body # raw body string
|
|
125
|
+
request.json_body # parsed JSON body (hash)
|
|
126
|
+
request.bearer_token # extracted Bearer token
|
|
127
|
+
request.ip # client IP address
|
|
128
|
+
request.files # uploaded files
|
|
129
|
+
request.session # lazy-loaded session
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Response Object
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# JSON response
|
|
137
|
+
response.json({ key: "value" })
|
|
138
|
+
response.json({ key: "value" }, 201) # custom status
|
|
139
|
+
|
|
140
|
+
# HTML response
|
|
141
|
+
response.html("<h1>Hello</h1>")
|
|
142
|
+
|
|
143
|
+
# Template rendering
|
|
144
|
+
response.render("pages/home.twig", { title: "Welcome" })
|
|
145
|
+
|
|
146
|
+
# Redirect
|
|
147
|
+
response.redirect("/dashboard")
|
|
148
|
+
response.redirect("/login", 301) # permanent redirect
|
|
149
|
+
|
|
150
|
+
# Plain text
|
|
151
|
+
response.text("OK")
|
|
152
|
+
|
|
153
|
+
# File download
|
|
154
|
+
response.file("path/to/document.pdf")
|
|
155
|
+
|
|
156
|
+
# Custom headers
|
|
157
|
+
response.add_header("X-Custom", "value")
|
|
158
|
+
|
|
159
|
+
# Cookies
|
|
160
|
+
response.set_cookie("theme", "dark", max_age: 86400)
|
|
161
|
+
response.delete_cookie("theme")
|
|
162
|
+
|
|
163
|
+
# CORS headers (auto-added by RackApp)
|
|
164
|
+
response.add_cors_headers
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Templates (Twig)
|
|
168
|
+
|
|
169
|
+
Tina4 uses a Twig-compatible template engine. Templates go in `templates/` or `src/templates/`.
|
|
170
|
+
|
|
171
|
+
### Base template (`templates/base.twig`)
|
|
172
|
+
|
|
173
|
+
```twig
|
|
174
|
+
<!DOCTYPE html>
|
|
175
|
+
<html>
|
|
176
|
+
<head>
|
|
177
|
+
<title>{% block title %}My App{% endblock %}</title>
|
|
178
|
+
</head>
|
|
179
|
+
<body>
|
|
180
|
+
{% block content %}{% endblock %}
|
|
181
|
+
</body>
|
|
182
|
+
</html>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Child template (`templates/home.twig`)
|
|
186
|
+
|
|
187
|
+
```twig
|
|
188
|
+
{% extends "base.twig" %}
|
|
189
|
+
|
|
190
|
+
{% block title %}Home{% endblock %}
|
|
191
|
+
|
|
192
|
+
{% block content %}
|
|
193
|
+
<h1>Hello {{ name }}!</h1>
|
|
194
|
+
|
|
195
|
+
{% if items %}
|
|
196
|
+
<ul>
|
|
197
|
+
{% for item in items %}
|
|
198
|
+
<li>{{ loop.index }}. {{ item | capitalize }}</li>
|
|
199
|
+
{% endfor %}
|
|
200
|
+
</ul>
|
|
201
|
+
{% else %}
|
|
202
|
+
<p>No items found.</p>
|
|
203
|
+
{% endif %}
|
|
204
|
+
{% endblock %}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Rendering
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
Tina4.get "/home" do |request, response|
|
|
211
|
+
response.render("home.twig", {
|
|
212
|
+
name: "Alice",
|
|
213
|
+
items: ["apple", "banana", "cherry"]
|
|
214
|
+
})
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Filters
|
|
219
|
+
|
|
220
|
+
```twig
|
|
221
|
+
{{ name | upper }} {# ALICE #}
|
|
222
|
+
{{ name | lower }} {# alice #}
|
|
223
|
+
{{ name | capitalize }} {# Alice #}
|
|
224
|
+
{{ "hello world" | title }} {# Hello World #}
|
|
225
|
+
{{ " hi " | trim }} {# hi #}
|
|
226
|
+
{{ items | length }} {# 3 #}
|
|
227
|
+
{{ items | join(", ") }} {# a, b, c #}
|
|
228
|
+
{{ missing | default("N/A") }} {# N/A #}
|
|
229
|
+
{{ html | escape }} {# <b>hi</b> #}
|
|
230
|
+
{{ text | nl2br }} {# line<br>break #}
|
|
231
|
+
{{ 3.14159 | round(2) }} {# 3.14 #}
|
|
232
|
+
{{ data | json_encode }} {# {"key":"value"} #}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Includes
|
|
236
|
+
|
|
237
|
+
```twig
|
|
238
|
+
{% include "partials/header.twig" %}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Variables and Math
|
|
242
|
+
|
|
243
|
+
```twig
|
|
244
|
+
{% set greeting = "Hello" %}
|
|
245
|
+
{{ greeting ~ " " ~ name }} {# string concatenation #}
|
|
246
|
+
{{ price * quantity }} {# math #}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Comments
|
|
250
|
+
|
|
251
|
+
```twig
|
|
252
|
+
{# This is a comment and won't be rendered #}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Database
|
|
256
|
+
|
|
257
|
+
Multi-database support with a unified API:
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
# SQLite (default, zero-config)
|
|
261
|
+
db = Tina4::Database.new("sqlite://app.db")
|
|
262
|
+
|
|
263
|
+
# PostgreSQL
|
|
264
|
+
db = Tina4::Database.new("postgresql://localhost:5432/mydb")
|
|
265
|
+
|
|
266
|
+
# MySQL
|
|
267
|
+
db = Tina4::Database.new("mysql://localhost:3306/mydb")
|
|
268
|
+
|
|
269
|
+
# MSSQL
|
|
270
|
+
db = Tina4::Database.new("mssql://localhost:1433/mydb")
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Querying
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
# Fetch multiple rows
|
|
277
|
+
result = db.fetch("SELECT * FROM users WHERE age > ?", [18])
|
|
278
|
+
result.each { |row| puts row[:name] }
|
|
279
|
+
|
|
280
|
+
# Fetch one row
|
|
281
|
+
user = db.fetch_one("SELECT * FROM users WHERE id = ?", [1])
|
|
282
|
+
|
|
283
|
+
# Pagination
|
|
284
|
+
result = db.fetch("SELECT * FROM users", [], limit: 10, skip: 20)
|
|
285
|
+
|
|
286
|
+
# Insert
|
|
287
|
+
db.insert("users", { name: "Alice", email: "alice@example.com" })
|
|
288
|
+
|
|
289
|
+
# Update
|
|
290
|
+
db.update("users", { name: "Alice Updated" }, { id: 1 })
|
|
291
|
+
|
|
292
|
+
# Delete
|
|
293
|
+
db.delete("users", { id: 1 })
|
|
294
|
+
|
|
295
|
+
# Raw SQL
|
|
296
|
+
db.execute("CREATE INDEX idx_email ON users(email)")
|
|
297
|
+
|
|
298
|
+
# Transactions
|
|
299
|
+
db.transaction do |tx|
|
|
300
|
+
tx.insert("accounts", { name: "Savings", balance: 1000 })
|
|
301
|
+
tx.update("accounts", { balance: 500 }, { id: 1 })
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Introspection
|
|
305
|
+
db.tables # ["users", "posts", ...]
|
|
306
|
+
db.table_exists?("users") # true
|
|
307
|
+
db.columns("users") # [{name: "id", type: "INTEGER", ...}, ...]
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### DatabaseResult
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
result = db.fetch("SELECT * FROM users")
|
|
314
|
+
result.count # number of rows
|
|
315
|
+
result.empty? # true/false
|
|
316
|
+
result.first # first row hash
|
|
317
|
+
result.to_array # array of hashes
|
|
318
|
+
result.to_json # JSON string
|
|
319
|
+
result.to_csv # CSV text
|
|
320
|
+
result.to_paginate # { records_total:, record_count:, data: }
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
## ORM
|
|
324
|
+
|
|
325
|
+
Define models with a field DSL:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
class User < Tina4::ORM
|
|
329
|
+
integer_field :id, primary_key: true, auto_increment: true
|
|
330
|
+
string_field :name, nullable: false
|
|
331
|
+
string_field :email, length: 255
|
|
332
|
+
integer_field :age, default: 0
|
|
333
|
+
datetime_field :created_at
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Set the database connection
|
|
337
|
+
Tina4.database = Tina4::Database.new("sqlite://app.db")
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### CRUD Operations
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
# Create
|
|
344
|
+
user = User.new(name: "Alice", email: "alice@example.com")
|
|
345
|
+
user.save
|
|
346
|
+
|
|
347
|
+
# Or create in one step
|
|
348
|
+
user = User.create(name: "Bob", email: "bob@example.com")
|
|
349
|
+
|
|
350
|
+
# Read
|
|
351
|
+
user = User.find(1) # by primary key
|
|
352
|
+
users = User.where("age > ?", [18]) # with conditions
|
|
353
|
+
all_users = User.all # all records
|
|
354
|
+
all_users = User.all(limit: 10, order_by: "name")
|
|
355
|
+
|
|
356
|
+
# Update
|
|
357
|
+
user = User.find(1)
|
|
358
|
+
user.name = "Alice Updated"
|
|
359
|
+
user.save
|
|
360
|
+
|
|
361
|
+
# Delete
|
|
362
|
+
user.delete
|
|
363
|
+
|
|
364
|
+
# Load into existing instance
|
|
365
|
+
user = User.new
|
|
366
|
+
user.id = 1
|
|
367
|
+
user.load
|
|
368
|
+
|
|
369
|
+
# Serialization
|
|
370
|
+
user.to_hash # { id: 1, name: "Alice", ... }
|
|
371
|
+
user.to_json # '{"id":1,"name":"Alice",...}'
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Field Types
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
integer_field :id
|
|
378
|
+
string_field :name, length: 255
|
|
379
|
+
text_field :bio
|
|
380
|
+
float_field :score
|
|
381
|
+
decimal_field :price, precision: 10, scale: 2
|
|
382
|
+
boolean_field :active
|
|
383
|
+
date_field :birthday
|
|
384
|
+
datetime_field :created_at
|
|
385
|
+
timestamp_field :updated_at
|
|
386
|
+
blob_field :avatar
|
|
387
|
+
json_field :metadata
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
## Migrations
|
|
391
|
+
|
|
392
|
+
```bash
|
|
393
|
+
# Create a migration
|
|
394
|
+
tina4 migrate --create "create users table"
|
|
395
|
+
|
|
396
|
+
# Run pending migrations
|
|
397
|
+
tina4 migrate
|
|
398
|
+
|
|
399
|
+
# Rollback
|
|
400
|
+
tina4 migrate --rollback 1
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Migration files are plain SQL in `migrations/`:
|
|
404
|
+
|
|
405
|
+
```sql
|
|
406
|
+
-- migrations/20260313120000_create_users_table.sql
|
|
407
|
+
CREATE TABLE users (
|
|
408
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
409
|
+
name TEXT NOT NULL,
|
|
410
|
+
email TEXT UNIQUE,
|
|
411
|
+
age INTEGER DEFAULT 0,
|
|
412
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
413
|
+
);
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
## Authentication
|
|
417
|
+
|
|
418
|
+
JWT RS256 tokens with auto-generated RSA keys:
|
|
419
|
+
|
|
420
|
+
```ruby
|
|
421
|
+
# Generate a token
|
|
422
|
+
token = Tina4::Auth.generate_token({ user_id: 42, role: "admin" })
|
|
423
|
+
|
|
424
|
+
# Validate a token
|
|
425
|
+
result = Tina4::Auth.validate_token(token)
|
|
426
|
+
if result[:valid]
|
|
427
|
+
payload = result[:payload]
|
|
428
|
+
puts payload["user_id"] # 42
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Password hashing (bcrypt)
|
|
432
|
+
hash = Tina4::Auth.hash_password("secret123")
|
|
433
|
+
Tina4::Auth.verify_password("secret123", hash) # true
|
|
434
|
+
Tina4::Auth.verify_password("wrong", hash) # false
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### Protecting Routes
|
|
438
|
+
|
|
439
|
+
```ruby
|
|
440
|
+
# Built-in Bearer auth
|
|
441
|
+
Tina4.secure_get "/api/profile" do |request, response|
|
|
442
|
+
# Only runs if valid JWT Bearer token is provided
|
|
443
|
+
response.json({ user: "authenticated" })
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Custom auth handler
|
|
447
|
+
custom_auth = lambda do |env|
|
|
448
|
+
api_key = env["HTTP_X_API_KEY"]
|
|
449
|
+
api_key == "my-secret-key"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
Tina4.secure_get "/api/data", auth: custom_auth do |request, response|
|
|
453
|
+
response.json({ data: "protected" })
|
|
454
|
+
end
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
## Sessions
|
|
458
|
+
|
|
459
|
+
```ruby
|
|
460
|
+
Tina4.post "/login" do |request, response|
|
|
461
|
+
request.session["user_id"] = 42
|
|
462
|
+
request.session["role"] = "admin"
|
|
463
|
+
request.session.save
|
|
464
|
+
response.json({ logged_in: true })
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
Tina4.get "/profile" do |request, response|
|
|
468
|
+
user_id = request.session["user_id"]
|
|
469
|
+
response.json({ user_id: user_id })
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
Tina4.post "/logout" do |request, response|
|
|
473
|
+
request.session.destroy
|
|
474
|
+
response.json({ logged_out: true })
|
|
475
|
+
end
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Session backends: `:file` (default), `:redis`, `:mongo`.
|
|
479
|
+
|
|
480
|
+
## Middleware
|
|
481
|
+
|
|
482
|
+
```ruby
|
|
483
|
+
# Run before every request
|
|
484
|
+
Tina4.before do |request, response|
|
|
485
|
+
puts "Request: #{request.method} #{request.path}"
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Run after every request
|
|
489
|
+
Tina4.after do |request, response|
|
|
490
|
+
puts "Response: #{response.status}"
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Pattern matching
|
|
494
|
+
Tina4.before("/api") do |request, response|
|
|
495
|
+
# Only runs for paths starting with /api
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
Tina4.before(/\/admin\/.*/) do |request, response|
|
|
499
|
+
# Regex pattern matching
|
|
500
|
+
return false unless request.session["role"] == "admin" # halts request
|
|
501
|
+
end
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
## Swagger / OpenAPI
|
|
505
|
+
|
|
506
|
+
Auto-generated API documentation at `/swagger`:
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
Tina4.get "/api/users", swagger_meta: {
|
|
510
|
+
summary: "List all users",
|
|
511
|
+
tags: ["Users"],
|
|
512
|
+
description: "Returns a paginated list of users"
|
|
513
|
+
} do |request, response|
|
|
514
|
+
response.json(users)
|
|
515
|
+
end
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Visit `http://localhost:7145/swagger` for the interactive Swagger UI.
|
|
519
|
+
|
|
520
|
+
## REST API Client
|
|
521
|
+
|
|
522
|
+
```ruby
|
|
523
|
+
api = Tina4::API.new("https://api.example.com", headers: {
|
|
524
|
+
"Authorization" => "Bearer sk-abc123"
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
# GET
|
|
528
|
+
response = api.get("/users", params: { page: 1 })
|
|
529
|
+
puts response.json # parsed response body
|
|
530
|
+
|
|
531
|
+
# POST
|
|
532
|
+
response = api.post("/users", body: { name: "Alice" })
|
|
533
|
+
puts response.success? # true for 2xx status
|
|
534
|
+
puts response.status # 201
|
|
535
|
+
|
|
536
|
+
# PUT, PATCH, DELETE
|
|
537
|
+
api.put("/users/1", body: { name: "Updated" })
|
|
538
|
+
api.patch("/users/1", body: { name: "Patched" })
|
|
539
|
+
api.delete("/users/1")
|
|
540
|
+
|
|
541
|
+
# File upload
|
|
542
|
+
api.upload("/files", "path/to/file.pdf")
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
## Environment Variables
|
|
546
|
+
|
|
547
|
+
Tina4 auto-creates and loads `.env` files:
|
|
548
|
+
|
|
549
|
+
```env
|
|
550
|
+
PROJECT_NAME=My App
|
|
551
|
+
VERSION=1.0.0
|
|
552
|
+
SECRET=my-jwt-secret
|
|
553
|
+
API_KEY=your-api-key-here
|
|
554
|
+
DATABASE_URL=sqlite://app.db
|
|
555
|
+
TINA4_DEBUG_LEVEL=[TINA4_LOG_DEBUG]
|
|
556
|
+
ENVIRONMENT=development
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
`API_KEY` enables a static bearer token bypass — any request with `Authorization: Bearer <API_KEY>` is granted access without JWT validation.
|
|
560
|
+
|
|
561
|
+
Supports environment-specific files: `.env.development`, `.env.production`, `.env.test`.
|
|
562
|
+
|
|
563
|
+
## CLI Commands
|
|
564
|
+
|
|
565
|
+
```bash
|
|
566
|
+
tina4 init [NAME] # Scaffold a new project
|
|
567
|
+
tina4 start # Start the web server (default port 7145)
|
|
568
|
+
tina4 start -p 3000 # Custom port
|
|
569
|
+
tina4 start -d # Dev mode with auto-reload
|
|
570
|
+
tina4 migrate # Run pending migrations
|
|
571
|
+
tina4 migrate --create "desc" # Create a migration
|
|
572
|
+
tina4 migrate --rollback 1 # Rollback migrations
|
|
573
|
+
tina4 test # Run inline tests
|
|
574
|
+
tina4 routes # List all registered routes
|
|
575
|
+
tina4 console # Interactive Ruby console
|
|
576
|
+
tina4 version # Show version
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
## Project Structure
|
|
580
|
+
|
|
581
|
+
```
|
|
582
|
+
myapp/
|
|
583
|
+
├── app.rb # Entry point
|
|
584
|
+
├── .env # Environment config
|
|
585
|
+
├── Gemfile
|
|
586
|
+
├── migrations/ # SQL migrations
|
|
587
|
+
├── routes/ # Auto-discovered route files
|
|
588
|
+
├── templates/ # Twig/ERB templates
|
|
589
|
+
├── public/ # Static files (CSS, JS, images)
|
|
590
|
+
│ ├── css/
|
|
591
|
+
│ ├── js/
|
|
592
|
+
│ └── images/
|
|
593
|
+
├── src/ # Application code
|
|
594
|
+
└── logs/ # Log files
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
Routes in `routes/` are auto-discovered at startup:
|
|
598
|
+
|
|
599
|
+
```ruby
|
|
600
|
+
# routes/users.rb
|
|
601
|
+
Tina4.get "/api/users" do |request, response|
|
|
602
|
+
response.json(User.all.map(&:to_hash))
|
|
603
|
+
end
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
## Auto-Discovery
|
|
607
|
+
|
|
608
|
+
Tina4 automatically loads:
|
|
609
|
+
- Route files from `routes/`, `src/routes/`, `src/api/`, `api/`
|
|
610
|
+
- `app.rb` and `index.rb` from the project root
|
|
611
|
+
|
|
612
|
+
## Full Example App
|
|
613
|
+
|
|
614
|
+
```ruby
|
|
615
|
+
# app.rb
|
|
616
|
+
require "tina4"
|
|
617
|
+
|
|
618
|
+
# Database
|
|
619
|
+
Tina4.database = Tina4::Database.new("sqlite://app.db")
|
|
620
|
+
|
|
621
|
+
# Model
|
|
622
|
+
class Todo < Tina4::ORM
|
|
623
|
+
integer_field :id, primary_key: true, auto_increment: true
|
|
624
|
+
string_field :title, nullable: false
|
|
625
|
+
boolean_field :done, default: false
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# Routes
|
|
629
|
+
Tina4.get "/" do |request, response|
|
|
630
|
+
response.render("home.twig", { todos: Todo.all.map(&:to_hash) })
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
Tina4.get "/api/todos" do |request, response|
|
|
634
|
+
response.json(Todo.all.map(&:to_hash))
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
Tina4.post "/api/todos" do |request, response|
|
|
638
|
+
todo = Todo.create(title: request.json_body["title"])
|
|
639
|
+
response.json(todo.to_hash, 201)
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
Tina4.put "/api/todos/{id:int}" do |request, response|
|
|
643
|
+
todo = Todo.find(request.params["id"])
|
|
644
|
+
todo.done = request.json_body["done"]
|
|
645
|
+
todo.save
|
|
646
|
+
response.json(todo.to_hash)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
Tina4.delete "/api/todos/{id:int}" do |request, response|
|
|
650
|
+
Todo.find(request.params["id"]).delete
|
|
651
|
+
response.json({ deleted: true })
|
|
652
|
+
end
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
## Requirements
|
|
656
|
+
|
|
657
|
+
- Ruby >= 3.1.0
|
|
658
|
+
- Works on Windows, macOS, and Linux
|
|
659
|
+
|
|
660
|
+
## License
|
|
661
|
+
|
|
662
|
+
MIT
|
data/exe/tina4
ADDED