tina4ruby 3.13.39 → 3.13.40
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/lib/tina4/auth.rb +5 -1
- data/lib/tina4/dev_admin.rb +79 -12
- data/lib/tina4/docstore.rb +753 -0
- data/lib/tina4/env.rb +7 -2
- data/lib/tina4/mcp.rb +92 -20
- data/lib/tina4/rack_app.rb +16 -4
- data/lib/tina4/swagger.rb +166 -32
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +3 -2
data/lib/tina4/env.rb
CHANGED
|
@@ -62,8 +62,13 @@ module Tina4
|
|
|
62
62
|
end
|
|
63
63
|
lines.concat([
|
|
64
64
|
"",
|
|
65
|
-
"
|
|
66
|
-
"
|
|
65
|
+
"Note: these may come from a .env file loaded by dotenv, not just",
|
|
66
|
+
"the runtime environment - check your image / build context (a .env",
|
|
67
|
+
"baked into a Docker image is loaded at startup) as well as k8s/CI env.",
|
|
68
|
+
"",
|
|
69
|
+
"FIX: run `tina4 env --migrate` to rewrite your .env automatically",
|
|
70
|
+
"(it renames every legacy name to its TINA4_ form in place).",
|
|
71
|
+
"Or rename manually. See https://tina4.com/release/3.12.0",
|
|
67
72
|
"Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
|
|
68
73
|
sep, ""
|
|
69
74
|
])
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -131,38 +131,71 @@ module Tina4
|
|
|
131
131
|
schema
|
|
132
132
|
end
|
|
133
133
|
|
|
134
|
-
#
|
|
134
|
+
# Informational only — whether the CONFIGURED host looks local.
|
|
135
|
+
#
|
|
136
|
+
# NOT the security gate. This reads TINA4_HOST_NAME (the configured bind
|
|
137
|
+
# address), which on a 0.0.0.0 bind looks "local" while still accepting
|
|
138
|
+
# remote clients. Trust decisions use {request_allowed?} with the RAW
|
|
139
|
+
# socket peer instead. Kept for diagnostics / back-compat.
|
|
135
140
|
def self.is_localhost?
|
|
136
141
|
host = ENV.fetch("TINA4_HOST_NAME", "localhost:7145").split(":").first
|
|
137
142
|
["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].include?(host)
|
|
138
143
|
end
|
|
139
144
|
|
|
140
|
-
#
|
|
145
|
+
# Whether an address is a loopback (in-process / same-host) peer.
|
|
146
|
+
#
|
|
147
|
+
# Operates on the RAW socket peer, never X-Forwarded-For. Empty means an
|
|
148
|
+
# in-process / synthetic request (no socket) and is trusted. The `::ffff:`
|
|
149
|
+
# IPv4-mapped prefix is stripped. NOTE: 0.0.0.0 is a BIND address, never a
|
|
150
|
+
# client address, so it is deliberately NOT loopback.
|
|
141
151
|
#
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
152
|
+
# Python master parity: tina4_python.mcp.is_loopback.
|
|
153
|
+
def self.is_loopback?(ip)
|
|
154
|
+
return true if ip.nil? || ip.to_s.empty?
|
|
155
|
+
|
|
156
|
+
addr = ip.to_s.strip.downcase
|
|
157
|
+
addr = addr[7..] if addr.start_with?("::ffff:")
|
|
158
|
+
addr == "::1" || addr == "localhost" || addr.start_with?("127.")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Capability gate — whether the MCP subsystem may run at all.
|
|
162
|
+
#
|
|
163
|
+
# Pure capability, host-INDEPENDENT (Python master parity):
|
|
164
|
+
# 1. TINA4_MCP set explicitly → use it (sysadmin override, any host).
|
|
165
|
+
# 2. Else TINA4_DEBUG truthy → MCP is a capability of this deployment.
|
|
151
166
|
# 3. Otherwise off.
|
|
152
167
|
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
168
|
+
# This NO LONGER consults the host. A debug box bound to 0.0.0.0 still "has"
|
|
169
|
+
# the capability, but {request_allowed?} is what decides whether a given
|
|
170
|
+
# CALLER may use it — loopback always, remote only with an explicit opt-in
|
|
171
|
+
# plus a valid token. Splitting capability from per-request authorisation
|
|
172
|
+
# closes the hole where a 0.0.0.0 bind auto-exposed DB/file tools to remote
|
|
173
|
+
# unauthenticated callers (pre-3.13.40 is_localhost? treated 0.0.0.0 local).
|
|
157
174
|
def self.mcp_enabled?
|
|
158
175
|
explicit = ENV["TINA4_MCP"]
|
|
159
176
|
if explicit && !explicit.empty?
|
|
160
177
|
return truthy?(explicit)
|
|
161
178
|
end
|
|
162
|
-
return false unless truthy?(ENV["TINA4_DEBUG"])
|
|
163
179
|
|
|
164
|
-
|
|
165
|
-
|
|
180
|
+
truthy?(ENV["TINA4_DEBUG"])
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Per-request authorisation — whether THIS caller may use MCP.
|
|
184
|
+
#
|
|
185
|
+
# @param remote_ip [String] raw socket peer (env["REMOTE_ADDR"]), never XFF.
|
|
186
|
+
# @param has_valid_token [Boolean] true when the request carried a token
|
|
187
|
+
# matching TINA4_MCP_TOKEN.
|
|
188
|
+
#
|
|
189
|
+
# Rules (Python master parity, tina4_python.mcp.is_request_allowed):
|
|
190
|
+
# - Capability off ({mcp_enabled?} false) → deny.
|
|
191
|
+
# - Loopback peer → allow.
|
|
192
|
+
# - Remote peer → only when TINA4_MCP_REMOTE is truthy AND a valid token
|
|
193
|
+
# was presented. No configured token ⇒ remote can never pass.
|
|
194
|
+
def self.request_allowed?(remote_ip, has_valid_token: false)
|
|
195
|
+
return false unless mcp_enabled?
|
|
196
|
+
return true if is_loopback?(remote_ip)
|
|
197
|
+
|
|
198
|
+
truthy?(ENV["TINA4_MCP_REMOTE"]) && has_valid_token
|
|
166
199
|
end
|
|
167
200
|
|
|
168
201
|
# Case-insensitive truthiness for env values: true/1/yes/on.
|
|
@@ -593,9 +626,27 @@ module Tina4
|
|
|
593
626
|
err = looks_like_prose.call(rel_path)
|
|
594
627
|
raise ArgumentError, "Invalid path #{rel_path.inspect}: #{err}" if err
|
|
595
628
|
resolved = File.expand_path(rel_path, project_root)
|
|
596
|
-
|
|
629
|
+
# Compare against root + separator, not a bare prefix: a plain
|
|
630
|
+
# start_with?(project_root) would also accept a sibling like
|
|
631
|
+
# "<root>-evil". expand_path already resolves ".." so a climb-out
|
|
632
|
+
# lands outside root and is rejected here.
|
|
633
|
+
root_prefix = "#{project_root.chomp(File::SEPARATOR)}#{File::SEPARATOR}"
|
|
634
|
+
unless resolved == project_root || resolved.start_with?(root_prefix)
|
|
597
635
|
raise ArgumentError, "Path escapes project directory: #{rel_path}"
|
|
598
636
|
end
|
|
637
|
+
# Belt-and-braces against symlink escapes: if the path already exists,
|
|
638
|
+
# canonicalise it and re-check containment. A symlink inside the tree
|
|
639
|
+
# pointing outside would otherwise slip past the textual check. New
|
|
640
|
+
# paths (parent not created yet) have no realpath and rely on the
|
|
641
|
+
# expand_path containment above.
|
|
642
|
+
if File.exist?(resolved)
|
|
643
|
+
real = File.realpath(resolved)
|
|
644
|
+
real_root = File.realpath(project_root)
|
|
645
|
+
real_prefix = "#{real_root.chomp(File::SEPARATOR)}#{File::SEPARATOR}"
|
|
646
|
+
unless real == real_root || real.start_with?(real_prefix)
|
|
647
|
+
raise ArgumentError, "Path escapes project directory: #{rel_path}"
|
|
648
|
+
end
|
|
649
|
+
end
|
|
599
650
|
resolved
|
|
600
651
|
end
|
|
601
652
|
|
|
@@ -663,7 +714,22 @@ module Tina4
|
|
|
663
714
|
db = Tina4.database
|
|
664
715
|
return { "error" => "No database connection" } if db.nil?
|
|
665
716
|
param_list = params.is_a?(String) ? JSON.parse(params) : params
|
|
666
|
-
|
|
717
|
+
param_list = [] unless param_list.is_a?(Array)
|
|
718
|
+
# Defense-in-depth: this tool is read-only. Strip comments, reject
|
|
719
|
+
# multiple statements, and require a leading SELECT/WITH so it can
|
|
720
|
+
# never mutate data even if reached (database_execute is the write
|
|
721
|
+
# surface, gated separately). Mirrors the Python master.
|
|
722
|
+
cleaned = sql.to_s.gsub(/--[^\r\n]*/, " ").gsub(%r{/\*.*?\*/}m, " ")
|
|
723
|
+
cleaned = cleaned.strip.sub(/[;\s]+\z/, "")
|
|
724
|
+
return { "error" => "database_query rejects multiple statements" } if cleaned.include?(";")
|
|
725
|
+
unless cleaned =~ /\A(select|with)\b/i
|
|
726
|
+
return { "error" => "database_query is read-only (SELECT/WITH only)" }
|
|
727
|
+
end
|
|
728
|
+
begin
|
|
729
|
+
result = db.fetch(cleaned, param_list)
|
|
730
|
+
rescue => e
|
|
731
|
+
return { "error" => (db.get_error rescue nil) || e.message }
|
|
732
|
+
end
|
|
667
733
|
{ "records" => result.to_a, "count" => result.count }
|
|
668
734
|
}, "Execute a read-only SQL query (SELECT)")
|
|
669
735
|
|
|
@@ -692,6 +758,12 @@ module Tina4
|
|
|
692
758
|
server.register_tool("database_columns", lambda { |table:|
|
|
693
759
|
db = Tina4.database
|
|
694
760
|
return { "error" => "No database connection" } if db.nil?
|
|
761
|
+
# Constrain the table name to a safe identifier (optionally
|
|
762
|
+
# schema-qualified) — defense-in-depth so it can never be abused for
|
|
763
|
+
# injection even if an adapter interpolates it. Parity with Python/PHP.
|
|
764
|
+
unless table.to_s =~ /\A[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?\z/
|
|
765
|
+
return { "error" => "Invalid table name" }
|
|
766
|
+
end
|
|
695
767
|
db.columns(table)
|
|
696
768
|
}, "Get column definitions for a table")
|
|
697
769
|
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -454,17 +454,26 @@ module Tina4
|
|
|
454
454
|
end
|
|
455
455
|
|
|
456
456
|
def serve_swagger_ui
|
|
457
|
+
# Honour the documented production on/off switch (TINA4_SWAGGER_ENABLED,
|
|
458
|
+
# else TINA4_DEBUG) — before v3.13.40 this was never checked and /swagger
|
|
459
|
+
# (the full API surface) was served unconditionally in production.
|
|
460
|
+
return [404, { "content-type" => "text/plain" }, ["Not Found"]] unless Tina4::Swagger.enabled?
|
|
461
|
+
|
|
462
|
+
# The UI assets load from a CDN by default (keeps the framework
|
|
463
|
+
# zero-dependency — no vendored ~1.4MB swagger-ui-dist). Air-gapped
|
|
464
|
+
# deployments point TINA4_SWAGGER_UI_CDN at a self-hosted mirror.
|
|
465
|
+
cdn = (ENV["TINA4_SWAGGER_UI_CDN"] || "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5").sub(%r{/+\z}, "")
|
|
457
466
|
html = <<~HTML
|
|
458
467
|
<!DOCTYPE html>
|
|
459
468
|
<html lang="en">
|
|
460
469
|
<head>
|
|
461
470
|
<meta charset="UTF-8">
|
|
462
471
|
<title>API Documentation</title>
|
|
463
|
-
<link rel="stylesheet" href="
|
|
472
|
+
<link rel="stylesheet" href="#{cdn}/swagger-ui.css">
|
|
464
473
|
</head>
|
|
465
474
|
<body>
|
|
466
475
|
<div id="swagger-ui"></div>
|
|
467
|
-
<script src="
|
|
476
|
+
<script src="#{cdn}/swagger-ui-bundle.js"></script>
|
|
468
477
|
<script>
|
|
469
478
|
SwaggerUIBundle({ url: '/swagger/openapi.json', dom_id: '#swagger-ui' });
|
|
470
479
|
</script>
|
|
@@ -475,8 +484,11 @@ module Tina4
|
|
|
475
484
|
end
|
|
476
485
|
|
|
477
486
|
def serve_openapi_json
|
|
478
|
-
|
|
479
|
-
|
|
487
|
+
return [404, { "content-type" => "application/json; charset=utf-8" }, ['{"error":"Not Found"}']] unless Tina4::Swagger.enabled?
|
|
488
|
+
|
|
489
|
+
# Regenerate per request — DevReload adds routes in-process (same PID), so
|
|
490
|
+
# a memoized spec would go stale until restart. Swagger.generate is cheap.
|
|
491
|
+
[200, { "content-type" => "application/json; charset=utf-8" }, [JSON.generate(Tina4::Swagger.generate)]]
|
|
480
492
|
end
|
|
481
493
|
|
|
482
494
|
def handle_403(path = "")
|
data/lib/tina4/swagger.rb
CHANGED
|
@@ -7,14 +7,28 @@ module Tina4
|
|
|
7
7
|
def generate(routes = [])
|
|
8
8
|
spec = base_spec
|
|
9
9
|
route_list = routes.empty? ? Tina4::Router.routes : routes
|
|
10
|
+
# Accumulators shared across routes: ORM models referenced
|
|
11
|
+
# (-> components.schemas), tags used (-> top-level tags[]), seen
|
|
12
|
+
# operationIds (de-dup — OpenAPI requires them unique).
|
|
13
|
+
ctx = { models: {}, used_tags: [], seen_ids: [] }
|
|
10
14
|
route_list.each do |route|
|
|
11
|
-
add_route_to_spec(spec, route)
|
|
15
|
+
add_route_to_spec(spec, route, ctx)
|
|
12
16
|
end
|
|
17
|
+
|
|
18
|
+
unless ctx[:models].empty?
|
|
19
|
+
spec["components"]["schemas"] = {}
|
|
20
|
+
ctx[:models].each do |name, klass|
|
|
21
|
+
spec["components"]["schemas"][name] = model_schema(klass)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
spec["tags"] = ctx[:used_tags].map { |t| { "name" => t } } unless ctx[:used_tags].empty?
|
|
25
|
+
|
|
13
26
|
spec
|
|
14
27
|
end
|
|
15
28
|
|
|
16
|
-
# TINA4_SWAGGER_ENABLED — defaults to TINA4_DEBUG.
|
|
17
|
-
#
|
|
29
|
+
# TINA4_SWAGGER_ENABLED — defaults to TINA4_DEBUG. Wired into RackApp's
|
|
30
|
+
# /swagger serving (v3.13.40), so it genuinely gates whether the docs are
|
|
31
|
+
# served — it was dead code before.
|
|
18
32
|
def enabled?
|
|
19
33
|
explicit = ENV["TINA4_SWAGGER_ENABLED"]
|
|
20
34
|
if explicit && !explicit.empty?
|
|
@@ -51,9 +65,7 @@ module Tina4
|
|
|
51
65
|
{
|
|
52
66
|
"openapi" => "3.0.3",
|
|
53
67
|
"info" => info,
|
|
54
|
-
"servers" =>
|
|
55
|
-
{ "url" => "/" }
|
|
56
|
-
],
|
|
68
|
+
"servers" => servers,
|
|
57
69
|
"paths" => {},
|
|
58
70
|
"components" => {
|
|
59
71
|
"securitySchemes" => {
|
|
@@ -67,48 +79,125 @@ module Tina4
|
|
|
67
79
|
}
|
|
68
80
|
end
|
|
69
81
|
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
# servers[] — TINA4_SWAGGER_SERVERS (comma-separated) for a multi-server
|
|
83
|
+
# list, else SWAGGER_DEV_URL, else the relative "/" default.
|
|
84
|
+
def servers
|
|
85
|
+
raw = ENV.fetch("TINA4_SWAGGER_SERVERS", "")
|
|
86
|
+
urls = raw.split(",").map(&:strip).reject(&:empty?)
|
|
87
|
+
return urls.map { |u| { "url" => u } } unless urls.empty?
|
|
88
|
+
|
|
89
|
+
dev = ENV["SWAGGER_DEV_URL"]
|
|
90
|
+
return [{ "url" => dev }] if dev && !dev.empty?
|
|
91
|
+
|
|
92
|
+
[{ "url" => "/" }]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Valid OpenAPI path-item methods. Anything else (e.g. "any", a WebSocket
|
|
96
|
+
# "ws") is not a valid key and would make the document spec-invalid.
|
|
97
|
+
HTTP_METHODS = %w[get post put patch delete head options trace].freeze
|
|
98
|
+
|
|
99
|
+
def add_route_to_spec(spec, route, ctx)
|
|
72
100
|
method = route.method.downcase
|
|
73
|
-
return
|
|
101
|
+
return unless HTTP_METHODS.include?(method)
|
|
102
|
+
|
|
103
|
+
path = convert_path(route.path)
|
|
104
|
+
meta = route.swagger_meta || {}
|
|
105
|
+
|
|
106
|
+
# ORM model -> components.schemas + $ref
|
|
107
|
+
ref = nil
|
|
108
|
+
if (model = meta[:model])
|
|
109
|
+
klass = model.is_a?(String) ? resolve_model(model) : model
|
|
110
|
+
if klass
|
|
111
|
+
name = klass.name.split("::").last
|
|
112
|
+
ctx[:models][name] ||= klass
|
|
113
|
+
ref = "#/components/schemas/#{name}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
tags = meta[:tags] || [extract_tag(route.path)]
|
|
118
|
+
tags.each { |t| ctx[:used_tags] << t unless ctx[:used_tags].include?(t) }
|
|
74
119
|
|
|
75
120
|
spec["paths"][path] ||= {}
|
|
76
121
|
operation = {
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
122
|
+
"operationId" => unique_operation_id(method, path, ctx[:seen_ids]),
|
|
123
|
+
"summary" => meta[:summary] || "#{method.upcase} #{route.path}",
|
|
124
|
+
"description" => meta[:description] || "",
|
|
125
|
+
"tags" => tags,
|
|
80
126
|
"parameters" => build_parameters(route),
|
|
81
|
-
"responses" =>
|
|
127
|
+
"responses" => meta[:responses] || model_or_default_responses(ref, meta[:model_list])
|
|
82
128
|
}
|
|
83
129
|
|
|
130
|
+
operation["deprecated"] = true if meta[:deprecated]
|
|
131
|
+
|
|
84
132
|
if route.auth_handler
|
|
85
133
|
operation["security"] = [{ "bearerAuth" => [] }]
|
|
86
134
|
end
|
|
87
135
|
|
|
88
|
-
if %w[post put patch].include?(method)
|
|
89
|
-
operation["requestBody"] =
|
|
90
|
-
elsif %w[post put patch].include?(method)
|
|
91
|
-
operation["requestBody"] = default_request_body
|
|
136
|
+
if %w[post put patch].include?(method)
|
|
137
|
+
operation["requestBody"] = build_request_body(method, meta, ref)
|
|
92
138
|
end
|
|
93
139
|
|
|
94
140
|
spec["paths"][path][method] = operation
|
|
95
141
|
end
|
|
96
142
|
|
|
143
|
+
def build_request_body(_method, meta, ref)
|
|
144
|
+
return meta[:request_body] if meta[:request_body]
|
|
145
|
+
|
|
146
|
+
schema = ref ? { "$ref" => ref } : { "type" => "object" }
|
|
147
|
+
content = { "schema" => schema }
|
|
148
|
+
content["example"] = meta[:example] if meta[:example]
|
|
149
|
+
{ "content" => { "application/json" => content } }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def model_or_default_responses(ref, model_list)
|
|
153
|
+
return default_responses unless ref
|
|
154
|
+
|
|
155
|
+
schema = model_list ? { "type" => "array", "items" => { "$ref" => ref } } : { "$ref" => ref }
|
|
156
|
+
{
|
|
157
|
+
"200" => {
|
|
158
|
+
"description" => "Successful response",
|
|
159
|
+
"content" => { "application/json" => { "schema" => schema } }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
97
164
|
def convert_path(path)
|
|
98
|
-
#
|
|
99
|
-
path.gsub(/\{(\w+)(?::\w+)?\}/, '{\1}')
|
|
165
|
+
# {id:int} -> {id}
|
|
166
|
+
p = path.gsub(/\{(\w+)(?::\w+)?\}/, '{\1}')
|
|
167
|
+
# splat *path -> {path}; bare /* -> /{wildcard} (a literal '*' segment
|
|
168
|
+
# or an orphaned splat param is invalid OpenAPI templating)
|
|
169
|
+
p = p.gsub(/\*(\w+)/, '{\1}')
|
|
170
|
+
p.gsub(%r{(?<=/)\*(?=/|$)}, "{wildcard}")
|
|
100
171
|
end
|
|
101
172
|
|
|
102
173
|
def extract_tag(path)
|
|
103
174
|
parts = path.split("/").reject(&:empty?)
|
|
104
|
-
parts.first
|
|
175
|
+
first = parts.first
|
|
176
|
+
return "default" if first.nil? || first.start_with?("{", "*")
|
|
177
|
+
|
|
178
|
+
first
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def unique_operation_id(method, path, seen)
|
|
182
|
+
base = method + path.gsub(%r{[/{}*]}) { |c| c == "*" ? "wildcard" : "_" }
|
|
183
|
+
base = base.gsub(/_+/, "_").chomp("_")
|
|
184
|
+
oid = base
|
|
185
|
+
n = 2
|
|
186
|
+
while seen.include?(oid)
|
|
187
|
+
oid = "#{base}_#{n}"
|
|
188
|
+
n += 1
|
|
189
|
+
end
|
|
190
|
+
seen << oid
|
|
191
|
+
oid
|
|
105
192
|
end
|
|
106
193
|
|
|
107
194
|
def build_parameters(route)
|
|
108
195
|
params = []
|
|
109
196
|
route.param_names.each do |param|
|
|
197
|
+
name = param[:name].to_s
|
|
198
|
+
name = "wildcard" if name == "*"
|
|
110
199
|
params << {
|
|
111
|
-
"name" =>
|
|
200
|
+
"name" => name,
|
|
112
201
|
"in" => "path",
|
|
113
202
|
"required" => true,
|
|
114
203
|
"schema" => param_schema(param[:type])
|
|
@@ -118,16 +207,71 @@ module Tina4
|
|
|
118
207
|
end
|
|
119
208
|
|
|
120
209
|
def param_schema(type)
|
|
121
|
-
case type
|
|
210
|
+
case type.to_s
|
|
122
211
|
when "int", "integer"
|
|
123
212
|
{ "type" => "integer" }
|
|
124
213
|
when "float", "number"
|
|
125
214
|
{ "type" => "number" }
|
|
215
|
+
when "uuid"
|
|
216
|
+
{ "type" => "string", "format" => "uuid" }
|
|
217
|
+
when "slug"
|
|
218
|
+
{ "type" => "string", "pattern" => "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
|
|
219
|
+
when "alpha"
|
|
220
|
+
{ "type" => "string", "pattern" => "^[A-Za-z]+$" }
|
|
221
|
+
when "alnum"
|
|
222
|
+
{ "type" => "string", "pattern" => "^[A-Za-z0-9]+$" }
|
|
223
|
+
else
|
|
224
|
+
{ "type" => "string" }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Build a components.schemas object from an ORM model's field definitions.
|
|
229
|
+
def model_schema(model_class)
|
|
230
|
+
props = {}
|
|
231
|
+
required = []
|
|
232
|
+
defs = model_class.respond_to?(:field_definitions) ? model_class.field_definitions : {}
|
|
233
|
+
defs.each do |name, opts|
|
|
234
|
+
props[name.to_s] = field_schema(opts)
|
|
235
|
+
required << name.to_s if opts[:nullable] == false
|
|
236
|
+
end
|
|
237
|
+
schema = { "type" => "object", "properties" => props.empty? ? {} : props }
|
|
238
|
+
schema["required"] = required unless required.empty?
|
|
239
|
+
schema
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def field_schema(opts)
|
|
243
|
+
schema = map_field_type(opts[:type])
|
|
244
|
+
schema["readOnly"] = true if opts[:primary_key] && opts[:auto_increment]
|
|
245
|
+
schema
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def map_field_type(type)
|
|
249
|
+
case type.to_s
|
|
250
|
+
when "integer"
|
|
251
|
+
{ "type" => "integer" }
|
|
252
|
+
when "float", "decimal", "numeric"
|
|
253
|
+
{ "type" => "number" }
|
|
254
|
+
when "boolean"
|
|
255
|
+
{ "type" => "boolean" }
|
|
256
|
+
when "datetime", "date", "timestamp"
|
|
257
|
+
{ "type" => "string", "format" => "date-time" }
|
|
258
|
+
when "blob"
|
|
259
|
+
{ "type" => "string", "format" => "byte" }
|
|
126
260
|
else
|
|
127
261
|
{ "type" => "string" }
|
|
128
262
|
end
|
|
129
263
|
end
|
|
130
264
|
|
|
265
|
+
def resolve_model(name)
|
|
266
|
+
Object.const_get(name)
|
|
267
|
+
rescue NameError
|
|
268
|
+
begin
|
|
269
|
+
Tina4.const_get(name)
|
|
270
|
+
rescue NameError
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
131
275
|
def default_responses
|
|
132
276
|
{
|
|
133
277
|
"200" => { "description" => "Successful response" },
|
|
@@ -137,16 +281,6 @@ module Tina4
|
|
|
137
281
|
"500" => { "description" => "Internal server error" }
|
|
138
282
|
}
|
|
139
283
|
end
|
|
140
|
-
|
|
141
|
-
def default_request_body
|
|
142
|
-
{
|
|
143
|
-
"content" => {
|
|
144
|
-
"application/json" => {
|
|
145
|
-
"schema" => { "type" => "object" }
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
end
|
|
150
284
|
end
|
|
151
285
|
end
|
|
152
286
|
end
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.13.
|
|
4
|
+
version: 3.13.40
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -278,6 +278,7 @@ files:
|
|
|
278
278
|
- lib/tina4/dev_admin.rb
|
|
279
279
|
- lib/tina4/dev_mailbox.rb
|
|
280
280
|
- lib/tina4/docs.rb
|
|
281
|
+
- lib/tina4/docstore.rb
|
|
281
282
|
- lib/tina4/drivers/firebird_driver.rb
|
|
282
283
|
- lib/tina4/drivers/mongodb_driver.rb
|
|
283
284
|
- lib/tina4/drivers/mssql_driver.rb
|