tina4ruby 3.13.39 → 3.13.41
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/queue.rb +31 -5
- data/lib/tina4/queue_backends/lite_backend.rb +158 -20
- data/lib/tina4/queue_backends/mongo_backend.rb +76 -2
- 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/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.41
|
|
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
|