tina4ruby 0.5.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +360 -559
  4. data/exe/{tina4 → tina4ruby} +1 -0
  5. data/lib/tina4/ai.rb +312 -0
  6. data/lib/tina4/auth.rb +44 -3
  7. data/lib/tina4/auto_crud.rb +163 -0
  8. data/lib/tina4/cli.rb +242 -77
  9. data/lib/tina4/constants.rb +46 -0
  10. data/lib/tina4/cors.rb +74 -0
  11. data/lib/tina4/database/sqlite3_adapter.rb +139 -0
  12. data/lib/tina4/database.rb +43 -7
  13. data/lib/tina4/debug.rb +4 -79
  14. data/lib/tina4/dev_admin.rb +1162 -0
  15. data/lib/tina4/dev_mailbox.rb +191 -0
  16. data/lib/tina4/dev_reload.rb +9 -9
  17. data/lib/tina4/drivers/firebird_driver.rb +19 -3
  18. data/lib/tina4/drivers/mssql_driver.rb +3 -3
  19. data/lib/tina4/drivers/mysql_driver.rb +4 -4
  20. data/lib/tina4/drivers/postgres_driver.rb +9 -2
  21. data/lib/tina4/drivers/sqlite_driver.rb +1 -1
  22. data/lib/tina4/env.rb +42 -2
  23. data/lib/tina4/error_overlay.rb +252 -0
  24. data/lib/tina4/events.rb +90 -0
  25. data/lib/tina4/field_types.rb +4 -0
  26. data/lib/tina4/frond.rb +1336 -0
  27. data/lib/tina4/gallery/auth/meta.json +1 -0
  28. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
  29. data/lib/tina4/gallery/database/meta.json +1 -0
  30. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
  31. data/lib/tina4/gallery/error-overlay/meta.json +1 -0
  32. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
  33. data/lib/tina4/gallery/orm/meta.json +1 -0
  34. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
  35. data/lib/tina4/gallery/queue/meta.json +1 -0
  36. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
  37. data/lib/tina4/gallery/rest-api/meta.json +1 -0
  38. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
  39. data/lib/tina4/gallery/templates/meta.json +1 -0
  40. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
  41. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
  42. data/lib/tina4/health.rb +39 -0
  43. data/lib/tina4/html_element.rb +148 -0
  44. data/lib/tina4/localization.rb +2 -2
  45. data/lib/tina4/log.rb +203 -0
  46. data/lib/tina4/messenger.rb +484 -0
  47. data/lib/tina4/migration.rb +132 -29
  48. data/lib/tina4/orm.rb +337 -31
  49. data/lib/tina4/public/css/tina4.css +178 -1
  50. data/lib/tina4/public/css/tina4.min.css +1 -2
  51. data/lib/tina4/public/favicon.ico +0 -0
  52. data/lib/tina4/public/images/logo.svg +5 -0
  53. data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
  54. data/lib/tina4/public/js/frond.min.js +420 -0
  55. data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
  56. data/lib/tina4/public/js/tina4.min.js +93 -0
  57. data/lib/tina4/public/swagger/index.html +90 -0
  58. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
  59. data/lib/tina4/queue.rb +40 -4
  60. data/lib/tina4/queue_backends/lite_backend.rb +88 -0
  61. data/lib/tina4/rack_app.rb +314 -23
  62. data/lib/tina4/rate_limiter.rb +123 -0
  63. data/lib/tina4/request.rb +61 -15
  64. data/lib/tina4/response.rb +54 -24
  65. data/lib/tina4/response_cache.rb +134 -0
  66. data/lib/tina4/router.rb +90 -15
  67. data/lib/tina4/scss_compiler.rb +2 -2
  68. data/lib/tina4/seeder.rb +56 -61
  69. data/lib/tina4/service_runner.rb +303 -0
  70. data/lib/tina4/session.rb +85 -0
  71. data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
  72. data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
  73. data/lib/tina4/shutdown.rb +84 -0
  74. data/lib/tina4/sql_translation.rb +295 -0
  75. data/lib/tina4/template.rb +36 -6
  76. data/lib/tina4/templates/base.twig +2 -2
  77. data/lib/tina4/templates/errors/302.twig +14 -0
  78. data/lib/tina4/templates/errors/401.twig +9 -0
  79. data/lib/tina4/templates/errors/403.twig +22 -15
  80. data/lib/tina4/templates/errors/404.twig +22 -15
  81. data/lib/tina4/templates/errors/500.twig +31 -15
  82. data/lib/tina4/templates/errors/502.twig +9 -0
  83. data/lib/tina4/templates/errors/503.twig +12 -0
  84. data/lib/tina4/templates/errors/base.twig +37 -0
  85. data/lib/tina4/version.rb +1 -1
  86. data/lib/tina4/webserver.rb +28 -18
  87. data/lib/tina4.rb +57 -21
  88. metadata +51 -19
  89. data/lib/tina4/public/js/tina4.js +0 -134
  90. data/lib/tina4/public/js/tina4helper.js +0 -387
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
+
3
4
  require_relative "../lib/tina4/cli"
4
5
  Tina4::CLI.start(ARGV)
data/lib/tina4/ai.rb ADDED
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ # Detect AI coding assistants and scaffold Tina4 context files.
5
+ #
6
+ # Usage:
7
+ # tools = Tina4::AI.detect_ai("/path/to/project")
8
+ # files = Tina4::AI.install_ai_context("/path/to/project")
9
+ #
10
+ module AI
11
+ AI_TOOLS = {
12
+ "claude-code" => {
13
+ description: "Claude Code (Anthropic CLI)",
14
+ detect: ->(root) { Dir.exist?(File.join(root, ".claude")) || File.exist?(File.join(root, "CLAUDE.md")) },
15
+ config_dir: ".claude",
16
+ context_file: "CLAUDE.md"
17
+ },
18
+ "cursor" => {
19
+ description: "Cursor IDE",
20
+ detect: ->(root) { Dir.exist?(File.join(root, ".cursor")) || File.exist?(File.join(root, ".cursorules")) },
21
+ config_dir: ".cursor",
22
+ context_file: ".cursorules"
23
+ },
24
+ "copilot" => {
25
+ description: "GitHub Copilot",
26
+ detect: ->(root) { File.exist?(File.join(root, ".github", "copilot-instructions.md")) || Dir.exist?(File.join(root, ".github")) },
27
+ config_dir: ".github",
28
+ context_file: ".github/copilot-instructions.md"
29
+ },
30
+ "windsurf" => {
31
+ description: "Windsurf (Codeium)",
32
+ detect: ->(root) { File.exist?(File.join(root, ".windsurfrules")) },
33
+ config_dir: nil,
34
+ context_file: ".windsurfrules"
35
+ },
36
+ "aider" => {
37
+ description: "Aider",
38
+ detect: ->(root) { File.exist?(File.join(root, ".aider.conf.yml")) || File.exist?(File.join(root, "CONVENTIONS.md")) },
39
+ config_dir: nil,
40
+ context_file: "CONVENTIONS.md"
41
+ },
42
+ "cline" => {
43
+ description: "Cline (VS Code)",
44
+ detect: ->(root) { File.exist?(File.join(root, ".clinerules")) },
45
+ config_dir: nil,
46
+ context_file: ".clinerules"
47
+ },
48
+ "codex" => {
49
+ description: "OpenAI Codex CLI",
50
+ detect: ->(root) { File.exist?(File.join(root, "AGENTS.md")) || File.exist?(File.join(root, "codex.md")) },
51
+ config_dir: nil,
52
+ context_file: "AGENTS.md"
53
+ }
54
+ }.freeze
55
+
56
+ class << self
57
+ # Detect which AI coding tools are present in the project.
58
+ #
59
+ # @param root [String] project root directory (default: current directory)
60
+ # @return [Array<Hash>] each hash has :name, :description, :config_file, :status
61
+ def detect_ai(root = ".")
62
+ root = File.expand_path(root)
63
+ AI_TOOLS.map do |name, tool|
64
+ installed = tool[:detect].call(root)
65
+ {
66
+ name: name,
67
+ description: tool[:description],
68
+ config_file: tool[:context_file],
69
+ status: installed ? "detected" : "not detected"
70
+ }
71
+ end
72
+ end
73
+
74
+ # Return just the names of detected AI tools.
75
+ #
76
+ # @param root [String] project root directory
77
+ # @return [Array<String>]
78
+ def detect_ai_names(root = ".")
79
+ detect_ai(root).select { |t| t[:status] == "detected" }.map { |t| t[:name] }
80
+ end
81
+
82
+ # Install Tina4 context files for detected (or all) AI tools.
83
+ #
84
+ # @param root [String] project root directory
85
+ # @param tools [Array<String>, nil] specific tools to install for (nil = auto-detect)
86
+ # @param force [Boolean] overwrite existing context files
87
+ # @return [Array<String>] list of files created/updated
88
+ def install_ai_context(root = ".", tools: nil, force: false)
89
+ root = File.expand_path(root)
90
+ created = []
91
+
92
+ tool_names = tools || detect_ai_names(root)
93
+ context = generate_context
94
+
95
+ tool_names.each do |tool_name|
96
+ tool = AI_TOOLS[tool_name]
97
+ next unless tool
98
+
99
+ files = install_for_tool(root, tool_name, tool, context, force)
100
+ created.concat(files)
101
+ end
102
+
103
+ created
104
+ end
105
+
106
+ # Install Tina4 context for ALL known AI tools (not just detected ones).
107
+ #
108
+ # @param root [String] project root directory
109
+ # @param force [Boolean] overwrite existing context files
110
+ # @return [Array<String>] list of files created/updated
111
+ def install_all(root = ".", force: false)
112
+ root = File.expand_path(root)
113
+ created = []
114
+ context = generate_context
115
+
116
+ AI_TOOLS.each do |tool_name, tool|
117
+ files = install_for_tool(root, tool_name, tool, context, force)
118
+ created.concat(files)
119
+ end
120
+
121
+ created
122
+ end
123
+
124
+ # Generate a human-readable status report of AI tool detection.
125
+ #
126
+ # @param root [String] project root directory
127
+ # @return [String]
128
+ def status_report(root = ".")
129
+ tools = detect_ai(root)
130
+ installed = tools.select { |t| t[:status] == "detected" }
131
+ missing = tools.reject { |t| t[:status] == "detected" }
132
+
133
+ lines = ["\nTina4 AI Context Status\n"]
134
+
135
+ if installed.any?
136
+ lines << "Detected AI tools:"
137
+ installed.each { |t| lines << " + #{t[:description]} (#{t[:name]})" }
138
+ else
139
+ lines << "No AI coding tools detected."
140
+ end
141
+
142
+ if missing.any?
143
+ lines << "\nNot detected (install context with `tina4ruby ai --all`):"
144
+ missing.each { |t| lines << " - #{t[:description]} (#{t[:name]})" }
145
+ end
146
+
147
+ lines << ""
148
+ lines.join("\n")
149
+ end
150
+
151
+ private
152
+
153
+ def install_for_tool(root, name, tool, context, force)
154
+ created = []
155
+ context_path = File.join(root, tool[:context_file])
156
+
157
+ # Create config directory if needed
158
+ if tool[:config_dir]
159
+ FileUtils.mkdir_p(File.join(root, tool[:config_dir]))
160
+ end
161
+
162
+ # Ensure parent directory exists for the context file
163
+ FileUtils.mkdir_p(File.dirname(context_path))
164
+
165
+ if !File.exist?(context_path) || force
166
+ File.write(context_path, context)
167
+ rel_path = context_path.sub("#{root}/", "")
168
+ created << rel_path
169
+ end
170
+
171
+ # Install Claude Code skills if it's Claude
172
+ if name == "claude-code"
173
+ skills = install_claude_skills(root, force)
174
+ created.concat(skills)
175
+ end
176
+
177
+ created
178
+ end
179
+
180
+ def install_claude_skills(root, force)
181
+ created = []
182
+
183
+ # Determine the framework root (where lib/tina4/ lives)
184
+ framework_root = File.expand_path("../../..", __FILE__)
185
+
186
+ # Copy .skill files from the framework's skills/ directory to project root
187
+ skills_source = File.join(framework_root, "skills")
188
+ if Dir.exist?(skills_source)
189
+ Dir.glob(File.join(skills_source, "*.skill")).each do |skill_file|
190
+ target = File.join(root, File.basename(skill_file))
191
+ if !File.exist?(target) || force
192
+ FileUtils.cp(skill_file, target)
193
+ created << File.basename(skill_file)
194
+ end
195
+ end
196
+ end
197
+
198
+ # Copy skill directories from .claude/skills/ in the framework to the project
199
+ framework_skills_dir = File.join(framework_root, ".claude", "skills")
200
+ if Dir.exist?(framework_skills_dir)
201
+ target_skills_dir = File.join(root, ".claude", "skills")
202
+ FileUtils.mkdir_p(target_skills_dir)
203
+ Dir.children(framework_skills_dir).each do |entry|
204
+ skill_dir = File.join(framework_skills_dir, entry)
205
+ next unless File.directory?(skill_dir)
206
+
207
+ target_dir = File.join(target_skills_dir, entry)
208
+ if !Dir.exist?(target_dir) || force
209
+ FileUtils.rm_rf(target_dir) if Dir.exist?(target_dir)
210
+ FileUtils.cp_r(skill_dir, target_dir)
211
+ rel_path = target_dir.sub("#{root}/", "")
212
+ created << rel_path
213
+ end
214
+ end
215
+ end
216
+
217
+ created
218
+ end
219
+
220
+ def generate_context
221
+ <<~CONTEXT
222
+ # Tina4 Ruby -- AI Context
223
+
224
+ This project uses **Tina4 Ruby**, a lightweight, batteries-included web framework
225
+ with zero third-party dependencies for core features.
226
+
227
+ **Documentation:** https://tina4.com
228
+
229
+ ## Quick Start
230
+
231
+ ```bash
232
+ tina4ruby init . # Scaffold project
233
+ tina4ruby start # Start dev server on port 7147
234
+ tina4ruby migrate # Run database migrations
235
+ tina4ruby test # Run test suite
236
+ tina4ruby routes # List all registered routes
237
+ ```
238
+
239
+ ## Project Structure
240
+
241
+ ```
242
+ routes/ -- Route handlers (auto-discovered, one per resource)
243
+ src/routes/ -- Alternative route location
244
+ templates/ -- Twig/ERB templates (extends base template)
245
+ public/ -- Static assets served at /
246
+ scss/ -- SCSS files (auto-compiled to public/css/)
247
+ migrations/ -- SQL migration files (sequential numbered)
248
+ seeds/ -- Database seeder scripts
249
+ spec/ -- RSpec test files
250
+ ```
251
+
252
+ ## Built-in Features (No External Gems Needed for Core)
253
+
254
+ | Feature | Module |
255
+ |---------|--------|
256
+ | Routing | Tina4::Router |
257
+ | ORM | Tina4::ORM |
258
+ | Database | Tina4::Database |
259
+ | Templates | Tina4::Template |
260
+ | JWT Auth | Tina4::Auth |
261
+ | REST API Client | Tina4::API |
262
+ | GraphQL | Tina4::GraphQL |
263
+ | WebSocket | Tina4::WebSocket |
264
+ | SOAP/WSDL | Tina4::WSDL |
265
+ | Email (SMTP+IMAP) | Tina4::Messenger |
266
+ | Background Queue | Tina4::Queue |
267
+ | SCSS Compilation | Tina4::ScssCompiler |
268
+ | Migrations | Tina4::Migration |
269
+ | Seeder | Tina4::FakeData |
270
+ | i18n | Tina4::Localization |
271
+ | Swagger/OpenAPI | Tina4::Swagger |
272
+ | Sessions | Tina4::Session |
273
+ | Middleware | Tina4::Middleware |
274
+
275
+ ## Key Conventions
276
+
277
+ 1. **Routes use block handlers** with `|request, response|` params
278
+ 2. **GET routes are public**, POST/PUT/PATCH/DELETE require auth by default
279
+ 3. **Use `auth: false`** to make write routes public, `secure_get` to protect GET routes
280
+ 4. **Every template extends a base template** -- no standalone HTML pages
281
+ 5. **No inline styles** -- use SCSS with CSS variables
282
+ 6. **All schema changes via migrations** -- never create tables in route code
283
+ 7. **Service pattern** -- complex logic goes in service classes, routes stay thin
284
+ 8. **Use built-in features** -- never install gems for things Tina4 already provides
285
+
286
+ ## Common Patterns
287
+
288
+ ### Route
289
+ ```ruby
290
+ Tina4.get "/api/widgets" do |request, response|
291
+ response.json({ widgets: Widget.all })
292
+ end
293
+
294
+ Tina4.post "/api/widgets", auth: false do |request, response|
295
+ widget = Widget.create(request.body)
296
+ response.json({ created: true }, 201)
297
+ end
298
+ ```
299
+
300
+ ### ORM Model
301
+ ```ruby
302
+ class Widget < Tina4::ORM
303
+ integer_field :id, primary_key: true, auto_increment: true
304
+ string_field :name
305
+ numeric_field :price
306
+ end
307
+ ```
308
+ CONTEXT
309
+ end
310
+ end
311
+ end
312
+ end
data/lib/tina4/auth.rb CHANGED
@@ -15,7 +15,7 @@ module Tina4
15
15
  ensure_keys
16
16
  end
17
17
 
18
- def generate_token(payload, expires_in: 3600)
18
+ def create_token(payload, expires_in: 3600)
19
19
  ensure_keys
20
20
  now = Time.now.to_i
21
21
  claims = payload.merge(
@@ -27,6 +27,7 @@ module Tina4
27
27
  JWT.encode(claims, private_key, "RS256")
28
28
  end
29
29
 
30
+
30
31
  def validate_token(token)
31
32
  ensure_keys
32
33
  require "jwt"
@@ -43,13 +44,53 @@ module Tina4
43
44
  BCrypt::Password.create(password)
44
45
  end
45
46
 
46
- def verify_password(password, hash)
47
+ def check_password(password, hash)
47
48
  require "bcrypt"
48
49
  BCrypt::Password.new(hash) == password
49
50
  rescue BCrypt::Errors::InvalidHash
50
51
  false
51
52
  end
52
53
 
54
+
55
+ def get_payload(token)
56
+ require "jwt"
57
+ decoded = JWT.decode(token, nil, false)
58
+ decoded[0]
59
+ rescue JWT::DecodeError
60
+ nil
61
+ end
62
+
63
+ def refresh_token(token, expires_in: 3600)
64
+ result = validate_token(token)
65
+ return nil unless result[:valid]
66
+
67
+ payload = result[:payload].reject { |k, _| %w[iat exp nbf].include?(k) }
68
+ create_token(payload, expires_in: expires_in)
69
+ end
70
+
71
+ def authenticate_request(headers)
72
+ auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || ""
73
+ return { valid: false, error: "No authorization header" } unless auth_header =~ /\ABearer\s+(.+)\z/i
74
+
75
+ token = Regexp.last_match(1)
76
+
77
+ # API_KEY bypass — matches tina4_python behavior
78
+ api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
79
+ if api_key && !api_key.empty? && token == api_key
80
+ return { valid: true, payload: { "api_key" => true } }
81
+ end
82
+
83
+ validate_token(token)
84
+ end
85
+
86
+ def validate_api_key(provided, expected: nil)
87
+ expected ||= ENV["TINA4_API_KEY"] || ENV["API_KEY"]
88
+ return false if expected.nil? || expected.empty?
89
+ return false if provided.nil? || provided.empty?
90
+
91
+ provided == expected
92
+ end
93
+
53
94
  def auth_handler(&block)
54
95
  if block_given?
55
96
  @custom_handler = block
@@ -107,7 +148,7 @@ module Tina4
107
148
  end
108
149
 
109
150
  def generate_keys
110
- Tina4::Debug.info("Generating RSA key pair for JWT authentication")
151
+ Tina4::Log.info("Generating RSA key pair for JWT authentication")
111
152
  key = OpenSSL::PKey::RSA.generate(2048)
112
153
  File.write(private_key_path, key.to_pem)
113
154
  File.write(public_key_path, key.public_key.to_pem)
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Tina4
5
+ module AutoCrud
6
+ class << self
7
+ # Track registered model classes
8
+ def models
9
+ @models ||= []
10
+ end
11
+
12
+ # Register a model for auto-CRUD
13
+ def register(model_class)
14
+ models << model_class unless models.include?(model_class)
15
+ end
16
+
17
+ # Generate REST endpoints for all registered models
18
+ def generate_routes(prefix: "/api")
19
+ models.each do |model_class|
20
+ generate_routes_for(model_class, prefix: prefix)
21
+ end
22
+ end
23
+
24
+ # Generate REST endpoints for a single model class
25
+ def generate_routes_for(model_class, prefix: "/api")
26
+ table = model_class.table_name
27
+ pk = model_class.primary_key_field || :id
28
+
29
+ # GET /api/{table} -- list all with pagination, filtering, sorting
30
+ Tina4::Router.add_route("GET", "#{prefix}/#{table}", proc { |req, res|
31
+ begin
32
+ limit = (req.query["limit"] || 10).to_i
33
+ offset = (req.query["offset"] || 0).to_i
34
+ order_by = parse_sort(req.query["sort"])
35
+
36
+ # Filter support: ?filter[field]=value
37
+ filter_conditions = []
38
+ filter_values = []
39
+ req.query.each do |key, value|
40
+ if key =~ /\Afilter\[(\w+)\]\z/
41
+ filter_conditions << "#{$1} = ?"
42
+ filter_values << value
43
+ end
44
+ end
45
+
46
+ if filter_conditions.empty?
47
+ records = model_class.all(limit: limit, offset: offset, order_by: order_by)
48
+ total = model_class.count
49
+ else
50
+ where_clause = filter_conditions.join(" AND ")
51
+ records = model_class.where(where_clause, filter_values)
52
+ total = records.length
53
+ # Apply manual pagination for filtered results
54
+ records = records.slice(offset, limit) || []
55
+ end
56
+
57
+ res.json({
58
+ data: records.map { |r| r.to_h },
59
+ total: total,
60
+ limit: limit,
61
+ offset: offset
62
+ })
63
+ rescue => e
64
+ res.json({ error: e.message }, status: 500)
65
+ end
66
+ })
67
+
68
+ # GET /api/{table}/{id} -- get single record
69
+ Tina4::Router.add_route("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
70
+ begin
71
+ id = req.params["id"]
72
+ record = model_class.find(id.to_i)
73
+ if record
74
+ res.json({ data: record.to_h })
75
+ else
76
+ res.json({ error: "Not found" }, status: 404)
77
+ end
78
+ rescue => e
79
+ res.json({ error: e.message }, status: 500)
80
+ end
81
+ })
82
+
83
+ # POST /api/{table} -- create record
84
+ Tina4::Router.add_route("POST", "#{prefix}/#{table}", proc { |req, res|
85
+ begin
86
+ attributes = req.body_parsed
87
+ record = model_class.create(attributes)
88
+ if record.persisted?
89
+ res.json({ data: record.to_h }, status: 201)
90
+ else
91
+ res.json({ errors: record.errors }, status: 422)
92
+ end
93
+ rescue => e
94
+ res.json({ error: e.message }, status: 500)
95
+ end
96
+ })
97
+
98
+ # PUT /api/{table}/{id} -- update record
99
+ Tina4::Router.add_route("PUT", "#{prefix}/#{table}/{id}", proc { |req, res|
100
+ begin
101
+ id = req.params["id"]
102
+ record = model_class.find(id.to_i)
103
+ unless record
104
+ next res.json({ error: "Not found" }, status: 404)
105
+ end
106
+
107
+ attributes = req.body_parsed
108
+ attributes.each do |key, value|
109
+ setter = "#{key}="
110
+ record.__send__(setter, value) if record.respond_to?(setter)
111
+ end
112
+
113
+ if record.save
114
+ res.json({ data: record.to_h })
115
+ else
116
+ res.json({ errors: record.errors }, status: 422)
117
+ end
118
+ rescue => e
119
+ res.json({ error: e.message }, status: 500)
120
+ end
121
+ })
122
+
123
+ # DELETE /api/{table}/{id} -- delete record
124
+ Tina4::Router.add_route("DELETE", "#{prefix}/#{table}/{id}", proc { |req, res|
125
+ begin
126
+ id = req.params["id"]
127
+ record = model_class.find(id.to_i)
128
+ unless record
129
+ next res.json({ error: "Not found" }, status: 404)
130
+ end
131
+
132
+ if record.delete
133
+ res.json({ message: "Deleted" })
134
+ else
135
+ res.json({ error: "Delete failed" }, status: 500)
136
+ end
137
+ rescue => e
138
+ res.json({ error: e.message }, status: 500)
139
+ end
140
+ })
141
+ end
142
+
143
+ def clear!
144
+ @models = []
145
+ end
146
+
147
+ private
148
+
149
+ # Parse sort parameter: "-name,created_at" => "name DESC, created_at ASC"
150
+ def parse_sort(sort_str)
151
+ return nil if sort_str.nil? || sort_str.empty?
152
+ sort_str.split(",").map do |field|
153
+ field = field.strip
154
+ if field.start_with?("-")
155
+ "#{field[1..-1]} DESC"
156
+ else
157
+ "#{field} ASC"
158
+ end
159
+ end.join(", ")
160
+ end
161
+ end
162
+ end
163
+ end