tina4ruby 3.12.13 → 3.13.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65d79f819e3ac62c1e8d3447409b8c75530c239db2bb6a7df9066de4104495ed
4
- data.tar.gz: d0b0af5dd0e914b880cc64a7b9aca29e6f46de65dcec3104b18e01ad174509b8
3
+ metadata.gz: eb13055e35412e98279f173f9cda3b6dc3e5a2c6766db1c2439b74e9de3354e2
4
+ data.tar.gz: 907a9ba2b3a732927dfd965cb41a212fcb4e245688fa6fab2ae805728e71489f
5
5
  SHA512:
6
- metadata.gz: 2e59eac2e37d78d1f7752cd26680338ff404794f6ff23dbe9ab2a1570ce3f458200fd5f488fefb8ef6f5f0b1764aa355def94dbed13a50149df30b4ea798059a
7
- data.tar.gz: 7021e0596f5275b4daf0969f0690400e99222fe10f30547b31fc8ee747f266c88086fb577927953813ac53b52dad9d8ce95a7ecb7ae48bfd2c1aa50fa8ff5e47
6
+ metadata.gz: b261fd27af0fed8f591599c2acf376c0333286910d302773e228fe92cae1e24201260a240081f26bb9abba86f731e9c7790d8c1f26b86dff857d1a87d0b938b0
7
+ data.tar.gz: 821cb969b8b9822b39d9b6b8d0e2b0ca4a74c7a35febcb277b6e8b119df38dfb09af5acaf88fbcda1b3793a44f4477c636bc5b7223a14214029dbbd8eb9f4cce
data/lib/tina4/api.rb CHANGED
@@ -8,13 +8,33 @@ module Tina4
8
8
  class API
9
9
  attr_reader :base_url, :headers
10
10
 
11
- def initialize(base_url, headers: {}, timeout: 30)
11
+ # 3.13.1: added ergonomic kwargs (bearer_token, username, password,
12
+ # verify_ssl) so callers no longer need three follow-up setter calls.
13
+ # Cross-framework parity with the Python tina4_python.api.Api kwargs.
14
+ #
15
+ # api = Tina4::API.new("https://api.example.com", bearer_token: "sk-abc")
16
+ # api = Tina4::API.new("https://api.example.com", username: "u", password: "p")
17
+ # api = Tina4::API.new("https://api.example.com", headers: {"X-Tenant" => "acme"})
18
+ # api = Tina4::API.new("https://self-signed.local", verify_ssl: false)
19
+ #
20
+ # Bearer wins over basic-auth when both are passed.
21
+ def initialize(base_url, headers: {}, timeout: 30,
22
+ bearer_token: nil, username: nil, password: nil,
23
+ verify_ssl: nil)
12
24
  @base_url = base_url.chomp("/")
13
25
  @headers = {
14
26
  "Content-Type" => "application/json",
15
27
  "Accept" => "application/json"
16
28
  }.merge(headers)
17
29
  @timeout = timeout
30
+ @verify_ssl = verify_ssl
31
+
32
+ # Bearer wins over basic-auth when both passed
33
+ if bearer_token
34
+ set_bearer_token(bearer_token)
35
+ elsif username && password
36
+ set_basic_auth(username, password)
37
+ end
18
38
  end
19
39
 
20
40
  def get(path, params: {})
data/lib/tina4/auth.rb CHANGED
@@ -109,19 +109,25 @@ module Tina4
109
109
  end
110
110
 
111
111
 
112
- def valid_token(token) # -> bool
112
+ # Verify a JWT signature + expiry.
113
+ #
114
+ # 3.13.0: return type changed from `Boolean` to `Hash | nil`. The
115
+ # decoded payload is returned on success, nil on failure. Matches
116
+ # firebase/jwt-ruby and Python's Auth.valid_token in 3.13.0.
117
+ #
118
+ # Legacy `if Tina4::Auth.valid_token(t)` patterns keep working
119
+ # because a non-empty Hash is truthy and nil is falsy.
120
+ def valid_token(token)
113
121
  if use_hmac?
114
- !hmac_decode(token, hmac_secret).nil?
122
+ hmac_decode(token, hmac_secret) # returns Hash payload or nil
115
123
  else
116
124
  ensure_keys
117
125
  require "jwt"
118
- JWT.decode(token, public_key, true, algorithm: "RS256")
119
- true
126
+ decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
127
+ decoded[0] # firebase/jwt-ruby returns [payload, header]
120
128
  end
121
- rescue JWT::ExpiredSignature
122
- false
123
- rescue JWT::DecodeError
124
- false
129
+ rescue JWT::ExpiredSignature, JWT::DecodeError
130
+ nil
125
131
  end
126
132
 
127
133
  def valid_token_detail(token)
@@ -101,6 +101,29 @@ module Tina4
101
101
  pool: pool)
102
102
  end
103
103
 
104
+ # Open a database connection — convention name matching SQLAlchemy
105
+ # engine.connect() and the cross-framework Database.get_connection()
106
+ # surface shipped in 3.13.x.
107
+ #
108
+ # The first argument may be either a URL (containing `://` or `sqlite:`)
109
+ # or an env-var name. Falls back to in-memory SQLite when no URL
110
+ # resolves — matches Python tina4_python's default behaviour.
111
+ #
112
+ # db = Tina4::Database.get_connection # from TINA4_DATABASE_URL
113
+ # db = Tina4::Database.get_connection("sqlite::memory:") # explicit URL
114
+ # db = Tina4::Database.get_connection("postgres://...", username: "u", password: "p")
115
+ def self.get_connection(url_or_env_key = "TINA4_DATABASE_URL", username: nil, password: nil, pool: nil)
116
+ if url_or_env_key.include?("://") || url_or_env_key.start_with?("sqlite:")
117
+ return new(url_or_env_key, username: username, password: password, pool: pool)
118
+ end
119
+
120
+ db = from_env(env_key: url_or_env_key, pool: pool)
121
+ return db if db
122
+
123
+ # Fallback: in-memory SQLite — matches Python parity.
124
+ new("sqlite::memory:", username: username, password: password, pool: pool)
125
+ end
126
+
104
127
  def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: nil)
105
128
  @connection_string = connection_string || ENV["TINA4_DATABASE_URL"]
106
129
  @username = username || ENV["TINA4_DATABASE_USERNAME"]
@@ -213,6 +236,19 @@ module Tina4
213
236
  end
214
237
  end
215
238
 
239
+ # Fetch rows and return the records array directly.
240
+ #
241
+ # Symmetric with fetch_one. Cross-framework parity with Python
242
+ # db.fetch_all() / PHP $db->fetchAll() / Node db.fetchAll().
243
+ #
244
+ # rows = db.fetch_all("SELECT * FROM users WHERE active = ?", [1])
245
+ # rows.each { |row| puts row["name"] }
246
+ #
247
+ # Returns [] (not nil) when no rows match.
248
+ def fetch_all(sql, params = [], limit: 100, offset: nil)
249
+ fetch(sql, params, limit: limit, offset: offset).records
250
+ end
251
+
216
252
  def fetch(sql, params = [], limit: 100, offset: nil)
217
253
  offset ||= 0
218
254
  drv = current_driver
data/lib/tina4/graphql.rb CHANGED
@@ -853,9 +853,103 @@ module Tina4
853
853
  !%w[false 0 no off].include?(val)
854
854
  end
855
855
 
856
+ # ── Class-level resolver registry — 3.13.1 ────────────────────────
857
+ #
858
+ # Resolvers registered via Tina4::GraphQL.resolve("Type", "field")
859
+ # accumulate here BEFORE any GraphQL instance exists. When `new` runs,
860
+ # the instance drains the registry into its schema. This is what makes
861
+ # the documented decorator-style pattern work at app-startup time:
862
+ # modules `Tina4::GraphQL.resolve(...)` at load time and register
863
+ # before the singleton is even constructed. Cross-framework parity
864
+ # with Python @GraphQL.resolve and PHP GraphQL::resolve.
865
+ @class_resolvers = {}
866
+ @default_instance = nil
867
+
868
+ class << self
869
+ attr_accessor :default_instance
870
+
871
+ def class_resolvers
872
+ @class_resolvers ||= {}
873
+ end
874
+
875
+ # Decorator-style resolver registration.
876
+ #
877
+ # Tina4::GraphQL.resolve("Query", "products") do |root, args, ctx|
878
+ # Product.all
879
+ # end
880
+ #
881
+ # Tina4::GraphQL.resolve("Mutation", "createProduct") do |root, args, ctx|
882
+ # Product.create(args["input"])
883
+ # end
884
+ #
885
+ # Tina4::GraphQL.resolve("Product", "reviews") do |product, args, ctx|
886
+ # Review.where("product_id = ?", [product["id"]])
887
+ # end
888
+ #
889
+ # Resolvers registered before any GraphQL instance accumulate in a
890
+ # class-level registry. new GraphQL drains them into its schema.
891
+ # Resolvers registered after .default_instance is set wire into
892
+ # the live schema immediately.
893
+ def resolve(type_name, field_name, &block)
894
+ class_resolvers[type_name] ||= {}
895
+ class_resolvers[type_name][field_name] = block
896
+
897
+ # If a default instance is active, attach immediately so post-startup
898
+ # registrations take effect without re-instantiation.
899
+ if @default_instance
900
+ @default_instance.send(:attach_resolver, type_name, field_name, block)
901
+ end
902
+ block
903
+ end
904
+
905
+ # Test-only — clear the class-level registry. Used by parity tests
906
+ # to avoid bleed-over between cases.
907
+ def _clear_class_resolvers!
908
+ @class_resolvers = {}
909
+ @default_instance = nil
910
+ end
911
+ end
912
+
856
913
  def initialize(schema = nil)
857
914
  @schema = schema || GraphQLSchema.new
858
915
  @executor = GraphQLExecutor.new(@schema)
916
+ @field_resolvers = {}
917
+
918
+ # Drain any resolvers registered via the class-level GraphQL.resolve()
919
+ # BEFORE this instance was constructed.
920
+ self.class.class_resolvers.each do |type_name, fields|
921
+ fields.each do |field_name, resolver|
922
+ attach_resolver(type_name, field_name, resolver)
923
+ end
924
+ end
925
+ end
926
+
927
+ # Wire a single resolver into the live schema.
928
+ def attach_resolver(type_name, field_name, resolver)
929
+ if type_name == "Query"
930
+ existing = @schema.queries[field_name] || {}
931
+ existing[:resolve] = resolver
932
+ existing[:args] ||= {}
933
+ existing[:type] ||= "String"
934
+ @schema.queries[field_name] = existing
935
+ elsif type_name == "Mutation"
936
+ existing = @schema.mutations[field_name] || {}
937
+ existing[:resolve] = resolver
938
+ existing[:args] ||= {}
939
+ existing[:type] ||= "String"
940
+ @schema.mutations[field_name] = existing
941
+ else
942
+ # Object-type field resolver — stash for the executor.
943
+ @field_resolvers[type_name] ||= {}
944
+ @field_resolvers[type_name][field_name] = resolver
945
+ end
946
+ end
947
+ private :attach_resolver
948
+
949
+ # Get the field resolver registered for an object type, if any.
950
+ # Used by the executor during nested field resolution.
951
+ def field_resolver(type_name, field_name)
952
+ @field_resolvers.dig(type_name, field_name)
859
953
  end
860
954
 
861
955
  # Execute a query string directly
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tina4 — The Intelligent Native Application 4ramework
4
+ # Copyright 2007 - current Tina4
5
+ # License: MIT https://opensource.org/licenses/MIT
6
+
7
+ module Tina4
8
+ # Base class for class-based background services managed by
9
+ # {Tina4::ServiceRunner}. Cross-framework parity with PHP `Tina4\Service`
10
+ # and the same shape the documentation has long taught.
11
+ #
12
+ # class EmailQueueWorker < Tina4::Service
13
+ # def run
14
+ # until should_stop?
15
+ # process_next_job
16
+ # sleep 1
17
+ # end
18
+ # end
19
+ # end
20
+ #
21
+ # Tina4::ServiceRunner.register_service("emails", EmailQueueWorker.new)
22
+ # Tina4::ServiceRunner.start
23
+ #
24
+ # Subclasses MUST override #run. Optionally override #stop for custom
25
+ # shutdown behaviour but always call `super` so the internal flag
26
+ # gets set — the default #should_stop? reads from it.
27
+ class Service
28
+ def initialize
29
+ @running = true
30
+ end
31
+
32
+ # Main work loop — subclasses MUST override.
33
+ def run
34
+ raise NotImplementedError, "#{self.class}#run must be implemented by the subclass"
35
+ end
36
+
37
+ # Signal this service to stop. The next `should_stop?` check returns true.
38
+ def stop
39
+ @running = false
40
+ end
41
+
42
+ # Returns true once #stop has been called. Use inside #run loops as
43
+ # the exit condition:
44
+ #
45
+ # def run
46
+ # until should_stop?
47
+ # # do work
48
+ # end
49
+ # end
50
+ def should_stop?
51
+ !@running
52
+ end
53
+
54
+ # Return a callable that ServiceRunner can register. Used by
55
+ # ServiceRunner.register_service under the hood.
56
+ def to_proc
57
+ method(:run).to_proc
58
+ end
59
+ end
60
+ end
@@ -48,6 +48,41 @@ module Tina4
48
48
  Tina4::Log.debug("Service registered: #{name}")
49
49
  end
50
50
 
51
+ # Register a class-based service (subclass of {Tina4::Service}) by name.
52
+ #
53
+ # Wraps the Service's #run method as the handler callable that
54
+ # ServiceRunner.start invokes. The service's #stop is also wired
55
+ # up so ServiceRunner.stop(name) shuts it down cleanly.
56
+ #
57
+ # class EmailQueueWorker < Tina4::Service
58
+ # def run
59
+ # until should_stop?
60
+ # # process work
61
+ # end
62
+ # end
63
+ # end
64
+ #
65
+ # Tina4::ServiceRunner.register_service("emails", EmailQueueWorker.new)
66
+ # Tina4::ServiceRunner.start
67
+ #
68
+ # Default options set daemon: true because Service subclasses manage
69
+ # their own loop inside #run. Override via `options`.
70
+ def register_service(name, service, options = {})
71
+ raise ArgumentError, "service must be a Tina4::Service instance" unless service.is_a?(Tina4::Service)
72
+
73
+ options = { daemon: true }.merge(options)
74
+ callable = service.method(:run)
75
+
76
+ @mutex.synchronize do
77
+ @registry[name.to_s] = {
78
+ handler: callable,
79
+ options: options,
80
+ instance: service,
81
+ }
82
+ end
83
+ Tina4::Log.debug("Service registered (class-based): #{name}")
84
+ end
85
+
51
86
  # Auto-discover service files from a directory.
52
87
  # Each file should call Tina4.service or Tina4::ServiceRunner.register.
53
88
  def discover(service_dir = nil)
data/lib/tina4/test.rb ADDED
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tina4 — The Intelligent Native Application 4ramework
4
+ # Copyright 2007 - current Tina4
5
+ # License: MIT https://opensource.org/licenses/MIT
6
+
7
+ module Tina4
8
+ # Tina4 xUnit-style test base class — class-based test suites with HTTP
9
+ # helpers and positional assertions. Zero external dependencies.
10
+ #
11
+ # Documentation chapter 18 has long described:
12
+ #
13
+ # class UserApiTest < Tina4::Test
14
+ # def test_health
15
+ # resp = get("/health")
16
+ # assert_equal_value(resp.status, 200)
17
+ # end
18
+ # end
19
+ #
20
+ # Until 3.13.0 this class did not exist — examples crashed with
21
+ # "uninitialized constant Tina4::Test". Ruby parity of the
22
+ # Python `tina4_python.test.Test` and PHP `Tina4\Test` classes.
23
+ #
24
+ # The class has a built-in runner (no Minitest/RSpec required):
25
+ #
26
+ # results = Tina4::Test.run_all # discovers all subclasses
27
+ # # => { passed: 12, failed: 0, errors: 0, details: [...] }
28
+ #
29
+ # Or run a single suite class:
30
+ #
31
+ # UserApiTest.run!
32
+ #
33
+ # HTTP helpers (get/post/put/patch/delete) delegate to TestClient.
34
+ # Positional assertions match the Python (actual, expected, message)
35
+ # shape used throughout the cross-framework docs.
36
+ class Test
37
+ # Class-level registry so run_all can discover every subclass without
38
+ # filesystem scanning.
39
+ @subclasses = []
40
+
41
+ class << self
42
+ attr_reader :subclasses
43
+
44
+ def inherited(subclass)
45
+ super
46
+ Test.subclasses << subclass
47
+ end
48
+
49
+ # Run every test method (`test_*`) on the calling subclass. Returns
50
+ # a hash with passed/failed/errors counts and per-test details.
51
+ def run!
52
+ instance_methods(false).grep(/\Atest_/).sort.each_with_object(
53
+ { passed: 0, failed: 0, errors: 0, details: [] }
54
+ ) do |method, results|
55
+ suite = new
56
+ begin
57
+ suite.send(:set_up)
58
+ suite.send(method)
59
+ suite.send(:tear_down)
60
+ results[:passed] += 1
61
+ results[:details] << { suite: name, test: method, status: "passed" }
62
+ rescue AssertionError => e
63
+ results[:failed] += 1
64
+ results[:details] << { suite: name, test: method, status: "failed", message: e.message }
65
+ rescue StandardError => e
66
+ results[:errors] += 1
67
+ results[:details] << { suite: name, test: method, status: "error", message: "#{e.class}: #{e.message}" }
68
+ end
69
+ end
70
+ end
71
+
72
+ # Discover and run every Tina4::Test subclass.
73
+ def run_all(quiet: false)
74
+ results = { passed: 0, failed: 0, errors: 0, details: [] }
75
+ Test.subclasses.each do |klass|
76
+ out = klass.run!
77
+ results[:passed] += out[:passed]
78
+ results[:failed] += out[:failed]
79
+ results[:errors] += out[:errors]
80
+ results[:details].concat(out[:details])
81
+ end
82
+ unless quiet
83
+ puts "Tina4 Test results: #{results[:passed]} passed, #{results[:failed]} failed, #{results[:errors]} errors"
84
+ end
85
+ results
86
+ end
87
+ end
88
+
89
+ # ── Lifecycle hooks ──────────────────────────────────────────────
90
+ # snake_case Tina4 idiom; override in subclasses.
91
+
92
+ def set_up
93
+ end
94
+
95
+ def tear_down
96
+ end
97
+
98
+ # ── HTTP test client (lazy) ───────────────────────────────────────
99
+
100
+ def test_client
101
+ @test_client ||= Tina4::TestClient.new
102
+ end
103
+
104
+ def get(path, headers: nil)
105
+ test_client.get(path, headers: headers)
106
+ end
107
+
108
+ def post(path, json: nil, body: nil, headers: nil)
109
+ test_client.post(path, json: json, body: body, headers: headers)
110
+ end
111
+
112
+ def put(path, json: nil, body: nil, headers: nil)
113
+ test_client.put(path, json: json, body: body, headers: headers)
114
+ end
115
+
116
+ def patch(path, json: nil, body: nil, headers: nil)
117
+ test_client.patch(path, json: json, body: body, headers: headers)
118
+ end
119
+
120
+ def delete(path, headers: nil)
121
+ test_client.delete(path, headers: headers)
122
+ end
123
+
124
+ # ── Positional assertions — (actual, expected, message) shape ─────
125
+
126
+ def assert_equal_value(actual, expected, message = nil)
127
+ return if actual == expected
128
+
129
+ raise AssertionError, message || "Expected #{expected.inspect}, got #{actual.inspect}"
130
+ end
131
+
132
+ def assert_not_equal_value(actual, expected, message = nil)
133
+ return unless actual == expected
134
+
135
+ raise AssertionError, message || "Expected #{actual.inspect} != #{expected.inspect}, but they are equal"
136
+ end
137
+
138
+ def assert_true(value, message = nil)
139
+ return if value
140
+
141
+ raise AssertionError, message || "Expected truthy, got #{value.inspect}"
142
+ end
143
+
144
+ def assert_false(value, message = nil)
145
+ return unless value
146
+
147
+ raise AssertionError, message || "Expected falsy, got #{value.inspect}"
148
+ end
149
+
150
+ def assert_nil_value(value, message = nil)
151
+ return if value.nil?
152
+
153
+ raise AssertionError, message || "Expected nil, got #{value.inspect}"
154
+ end
155
+
156
+ def assert_not_nil_value(value, message = nil)
157
+ return unless value.nil?
158
+
159
+ raise AssertionError, message || "Expected non-nil, got nil"
160
+ end
161
+
162
+ def assert_raises(expected_class, message = nil)
163
+ raise ArgumentError, "Block required" unless block_given?
164
+
165
+ begin
166
+ yield
167
+ rescue StandardError => e
168
+ return if e.is_a?(expected_class)
169
+
170
+ raise AssertionError,
171
+ message || "Expected #{expected_class}, got #{e.class}: #{e.message}"
172
+ end
173
+ raise AssertionError, message || "Expected #{expected_class} to be raised, but nothing was"
174
+ end
175
+ end
176
+
177
+ # AssertionError raised by Tina4::Test assertion helpers on failure.
178
+ class AssertionError < StandardError; end
179
+ end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.12.13"
4
+ VERSION = "3.13.1"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -39,6 +39,7 @@ require_relative "tina4/background"
39
39
  require_relative "tina4/localization"
40
40
  require_relative "tina4/container"
41
41
  require_relative "tina4/queue"
42
+ require_relative "tina4/service"
42
43
  require_relative "tina4/service_runner"
43
44
  require_relative "tina4/events"
44
45
  require_relative "tina4/plan"
@@ -54,6 +55,7 @@ require_relative "tina4/response_cache"
54
55
  require_relative "tina4/html_element"
55
56
  require_relative "tina4/error_overlay"
56
57
  require_relative "tina4/test_client"
58
+ require_relative "tina4/test"
57
59
  require_relative "tina4/docs"
58
60
  require_relative "tina4/mcp"
59
61
 
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.12.13
4
+ version: 3.13.1
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-05-29 00:00:00.000000000 Z
11
+ date: 2026-06-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -382,6 +382,7 @@ files:
382
382
  - lib/tina4/scss/tina4css/tina4.scss
383
383
  - lib/tina4/scss_compiler.rb
384
384
  - lib/tina4/seeder.rb
385
+ - lib/tina4/service.rb
385
386
  - lib/tina4/service_runner.rb
386
387
  - lib/tina4/session.rb
387
388
  - lib/tina4/session_handlers/database_handler.rb
@@ -402,6 +403,7 @@ files:
402
403
  - lib/tina4/templates/errors/502.twig
403
404
  - lib/tina4/templates/errors/503.twig
404
405
  - lib/tina4/templates/errors/base.twig
406
+ - lib/tina4/test.rb
405
407
  - lib/tina4/test_client.rb
406
408
  - lib/tina4/testing.rb
407
409
  - lib/tina4/validator.rb