tina4ruby 3.11.13 → 3.11.15

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
data/lib/tina4/wsdl.rb CHANGED
@@ -1,564 +1,564 @@
1
- # frozen_string_literal: true
2
-
3
- require "rexml/document"
4
-
5
- module Tina4
6
- # SOAP 1.1 / WSDL server — zero-dependency, mirrors tina4-python's wsdl module.
7
- #
8
- # Usage (class-based):
9
- #
10
- # class Calculator < Tina4::WSDL
11
- # wsdl_operation output: { Result: :int }
12
- # def add(a, b)
13
- # { Result: a.to_i + b.to_i }
14
- # end
15
- # end
16
- #
17
- # # In a route handler:
18
- # service = Calculator.new(request)
19
- # response.call(service.handle)
20
- #
21
- # Supported:
22
- # - WSDL 1.1 generation from Ruby type declarations
23
- # - SOAP 1.1 request/response handling via REXML
24
- # - Lifecycle hooks (on_request, on_result)
25
- # - Auto type mapping (Integer -> int, String -> string, Float -> double, etc.)
26
- # - XML escaping on all response values
27
- # - SOAP fault responses on errors
28
- #
29
- class WSDL
30
- NS_SOAP = "http://schemas.xmlsoap.org/wsdl/soap/"
31
- NS_WSDL = "http://schemas.xmlsoap.org/wsdl/"
32
- NS_XSD = "http://www.w3.org/2001/XMLSchema"
33
- NS_SOAP_ENV = "http://schemas.xmlsoap.org/soap/envelope/"
34
-
35
- RUBY_TO_XSD = {
36
- :int => "xsd:int",
37
- :integer => "xsd:int",
38
- :string => "xsd:string",
39
- :float => "xsd:double",
40
- :double => "xsd:double",
41
- :boolean => "xsd:boolean",
42
- :bool => "xsd:boolean",
43
- :date => "xsd:date",
44
- :datetime => "xsd:dateTime",
45
- :base64 => "xsd:base64Binary",
46
- Integer => "xsd:int",
47
- String => "xsd:string",
48
- Float => "xsd:double",
49
- TrueClass => "xsd:boolean",
50
- FalseClass => "xsd:boolean"
51
- }.freeze
52
-
53
- # ── Class-level DSL ──────────────────────────────────────────────────
54
-
55
- class << self
56
- # Registry of operations declared via wsdl_operation + def.
57
- # Each entry: { input: { name => type, ... }, output: { name => type, ... } }
58
- def wsdl_operations
59
- @wsdl_operations ||= {}
60
- end
61
-
62
- # Pending output hash waiting for the next method definition.
63
- def pending_wsdl_output
64
- @pending_wsdl_output
65
- end
66
-
67
- # Mark the next defined method as a WSDL operation.
68
- #
69
- # wsdl_operation output: { Result: :int }
70
- # def add(a, b) ...
71
- #
72
- # Input parameters are inferred from the method signature.
73
- # The +output+ hash maps response element names to XSD type symbols.
74
- def wsdl_operation(output: {})
75
- @pending_wsdl_output = output
76
- end
77
-
78
- # Hook into method definition to capture the pending operation.
79
- def method_added(method_name)
80
- super
81
- return unless @pending_wsdl_output
82
-
83
- output = @pending_wsdl_output
84
- @pending_wsdl_output = nil
85
-
86
- # Infer input parameter names from the method signature.
87
- params = instance_method(method_name).parameters
88
- input = {}
89
- params.each do |_kind, name|
90
- next if name.nil?
91
- input[name] = :string # default; callers can rely on type coercion
92
- end
93
-
94
- wsdl_operations[method_name.to_s] = { input: input, output: output }
95
- end
96
-
97
- # Ensure subclasses get their own copy of the operations registry.
98
- def inherited(subclass)
99
- super
100
- subclass.instance_variable_set(:@wsdl_operations, wsdl_operations.dup)
101
- end
102
- end
103
-
104
- # ── Instance ─────────────────────────────────────────────────────────
105
-
106
- attr_reader :request, :service_url
107
-
108
- def initialize(request = nil, service_url: "")
109
- @request = request
110
- @service_url = service_url.empty? ? infer_url : service_url
111
- @operations = discover_operations
112
- end
113
-
114
- # Main entry point. Returns WSDL XML on GET/?wsdl, or processes a SOAP
115
- # request on POST.
116
- def handle
117
- return generate_wsdl if @request.nil?
118
-
119
- method = if @request.respond_to?(:method)
120
- @request.method.to_s.upcase
121
- elsif @request.respond_to?(:body) && @request.body && !@request.body.to_s.empty?
122
- "POST"
123
- else
124
- "GET"
125
- end
126
-
127
- params = (@request.respond_to?(:params) ? @request.params : nil) || {}
128
- url = (@request.respond_to?(:url) ? @request.url : nil) || ""
129
-
130
- if method == "GET" || params.key?("wsdl") || params.key?(:wsdl) || url.end_with?("?wsdl")
131
- return generate_wsdl
132
- end
133
-
134
- body = if @request.respond_to?(:body)
135
- @request.body.is_a?(String) ? @request.body : @request.body.to_s
136
- else
137
- ""
138
- end
139
-
140
- process_soap(body)
141
- end
142
-
143
- # ── Lifecycle hooks (override in subclass) ───────────────────────────
144
-
145
- # Called before operation invocation. Override to validate/log.
146
- def on_request(request)
147
- # no-op
148
- end
149
-
150
- # Called after operation returns. Override to transform/audit.
151
- # Must return the (possibly modified) result.
152
- def on_result(result)
153
- result
154
- end
155
-
156
- # ── WSDL generation ──────────────────────────────────────────────────
157
-
158
- def generate_wsdl(endpoint_url = "")
159
- @service_url = endpoint_url unless endpoint_url.empty?
160
- service_name = self.class.name ? self.class.name.split("::").last : "AnonymousService"
161
- tns = "urn:#{service_name}"
162
-
163
- parts = []
164
- parts << '<?xml version="1.0" encoding="UTF-8"?>'
165
- parts << "<definitions name=\"#{service_name}\""
166
- parts << " targetNamespace=\"#{tns}\""
167
- parts << " xmlns:tns=\"#{tns}\""
168
- parts << " xmlns:soap=\"#{NS_SOAP}\""
169
- parts << " xmlns:xsd=\"#{NS_XSD}\""
170
- parts << " xmlns=\"#{NS_WSDL}\">"
171
- parts << ""
172
-
173
- # Types
174
- parts << " <types>"
175
- parts << " <xsd:schema targetNamespace=\"#{tns}\">"
176
-
177
- @operations.each do |op_name, meta|
178
- # Request element
179
- parts << " <xsd:element name=\"#{op_name}\">"
180
- parts << " <xsd:complexType>"
181
- parts << " <xsd:sequence>"
182
- meta[:input].each do |pname, ptype|
183
- xsd = xsd_type(ptype)
184
- parts << " <xsd:element name=\"#{pname}\" type=\"#{xsd}\"/>"
185
- end
186
- parts << " </xsd:sequence>"
187
- parts << " </xsd:complexType>"
188
- parts << " </xsd:element>"
189
-
190
- # Response element
191
- parts << " <xsd:element name=\"#{op_name}Response\">"
192
- parts << " <xsd:complexType>"
193
- parts << " <xsd:sequence>"
194
- meta[:output].each do |rname, rtype|
195
- xsd = xsd_type(rtype)
196
- parts << " <xsd:element name=\"#{rname}\" type=\"#{xsd}\"/>"
197
- end
198
- parts << " </xsd:sequence>"
199
- parts << " </xsd:complexType>"
200
- parts << " </xsd:element>"
201
- end
202
-
203
- parts << " </xsd:schema>"
204
- parts << " </types>"
205
- parts << ""
206
-
207
- # Messages
208
- @operations.each_key do |op_name|
209
- parts << " <message name=\"#{op_name}Input\">"
210
- parts << " <part name=\"parameters\" element=\"tns:#{op_name}\"/>"
211
- parts << " </message>"
212
- parts << " <message name=\"#{op_name}Output\">"
213
- parts << " <part name=\"parameters\" element=\"tns:#{op_name}Response\"/>"
214
- parts << " </message>"
215
- end
216
- parts << ""
217
-
218
- # PortType
219
- parts << " <portType name=\"#{service_name}PortType\">"
220
- @operations.each_key do |op_name|
221
- parts << " <operation name=\"#{op_name}\">"
222
- parts << " <input message=\"tns:#{op_name}Input\"/>"
223
- parts << " <output message=\"tns:#{op_name}Output\"/>"
224
- parts << " </operation>"
225
- end
226
- parts << " </portType>"
227
- parts << ""
228
-
229
- # Binding
230
- parts << " <binding name=\"#{service_name}Binding\" type=\"tns:#{service_name}PortType\">"
231
- parts << " <soap:binding style=\"document\" transport=\"http://schemas.xmlsoap.org/soap/http\"/>"
232
- @operations.each_key do |op_name|
233
- parts << " <operation name=\"#{op_name}\">"
234
- parts << " <soap:operation soapAction=\"#{tns}/#{op_name}\"/>"
235
- parts << ' <input><soap:body use="literal"/></input>'
236
- parts << ' <output><soap:body use="literal"/></output>'
237
- parts << " </operation>"
238
- end
239
- parts << " </binding>"
240
- parts << ""
241
-
242
- # Service
243
- parts << " <service name=\"#{service_name}\">"
244
- parts << " <port name=\"#{service_name}Port\" binding=\"tns:#{service_name}Binding\">"
245
- parts << " <soap:address location=\"#{@service_url}\"/>"
246
- parts << " </port>"
247
- parts << " </service>"
248
-
249
- parts << "</definitions>"
250
- parts.join("\n")
251
- end
252
-
253
- private
254
-
255
- # ── Auto-discovery ───────────────────────────────────────────────────
256
-
257
- def discover_operations
258
- self.class.wsdl_operations.dup
259
- end
260
-
261
- def infer_url
262
- return @request.url if @request && @request.respond_to?(:url)
263
- "/"
264
- end
265
-
266
- # ── SOAP request processing ──────────────────────────────────────────
267
-
268
- def process_soap(xml_body)
269
- on_request(@request)
270
-
271
- begin
272
- doc = REXML::Document.new(xml_body)
273
- rescue REXML::ParseException
274
- return soap_fault("Client", "Malformed XML")
275
- end
276
-
277
- # Find the SOAP Body element (namespace-agnostic)
278
- body_el = find_child(doc.root, "Body")
279
- return soap_fault("Client", "Missing SOAP Body") unless body_el
280
-
281
- # First child of Body is the operation element
282
- op_el = body_el.elements.first
283
- return soap_fault("Client", "Empty SOAP Body") unless op_el
284
-
285
- op_name = local_name(op_el)
286
-
287
- unless @operations.key?(op_name)
288
- return soap_fault("Client", "Unknown operation: #{op_name}")
289
- end
290
-
291
- meta = @operations[op_name]
292
-
293
- # Extract parameters from the operation element
294
- params = {}
295
- meta[:input].each do |param_name, param_type|
296
- child = find_child(op_el, param_name.to_s)
297
- if child
298
- value = child.text || ""
299
- params[param_name.to_s] = convert_value(value, param_type)
300
- end
301
- end
302
-
303
- begin
304
- result = send(op_name.to_sym, *meta[:input].keys.map { |k| params[k.to_s] })
305
- result = on_result(result)
306
- rescue StandardError => e
307
- return soap_fault("Server", e.message)
308
- end
309
-
310
- soap_response(op_name, result)
311
- end
312
-
313
- # ── XML helpers (REXML) ──────────────────────────────────────────────
314
-
315
- def find_child(parent, local)
316
- parent.each_element do |el|
317
- return el if local_name(el) == local
318
- end
319
- nil
320
- end
321
-
322
- def local_name(element)
323
- element.name # REXML already strips the prefix for .name
324
- end
325
-
326
- # ── Type conversion ──────────────────────────────────────────────────
327
-
328
- def convert_value(value, target_type)
329
- case target_type.to_s.downcase.to_sym
330
- when :int, :integer
331
- value.to_i
332
- when :float, :double
333
- value.to_f
334
- when :boolean, :bool
335
- %w[true 1 yes].include?(value.downcase)
336
- else
337
- value
338
- end
339
- end
340
-
341
- # ── Response builders ────────────────────────────────────────────────
342
-
343
- def soap_response(op_name, result)
344
- parts = []
345
- parts << '<?xml version="1.0" encoding="UTF-8"?>'
346
- parts << "<soap:Envelope xmlns:soap=\"#{NS_SOAP_ENV}\">"
347
- parts << "<soap:Body>"
348
- parts << "<#{op_name}Response>"
349
-
350
- if result.is_a?(Hash)
351
- result.each do |k, v|
352
- if v.nil?
353
- parts << "<#{k} xsi:nil=\"true\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"/>"
354
- elsif v.is_a?(Array)
355
- v.each { |item| parts << "<#{k}>#{escape_xml(item.to_s)}</#{k}>" }
356
- else
357
- parts << "<#{k}>#{escape_xml(v.to_s)}</#{k}>"
358
- end
359
- end
360
- end
361
-
362
- parts << "</#{op_name}Response>"
363
- parts << "</soap:Body>"
364
- parts << "</soap:Envelope>"
365
- parts.join("\n")
366
- end
367
-
368
- def soap_fault(code, message)
369
- '<?xml version="1.0" encoding="UTF-8"?>' \
370
- "<soap:Envelope xmlns:soap=\"#{NS_SOAP_ENV}\">" \
371
- "<soap:Body>" \
372
- "<soap:Fault>" \
373
- "<faultcode>#{code}</faultcode>" \
374
- "<faultstring>#{escape_xml(message)}</faultstring>" \
375
- "</soap:Fault>" \
376
- "</soap:Body>" \
377
- "</soap:Envelope>"
378
- end
379
-
380
- def escape_xml(s)
381
- s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
382
- end
383
-
384
- def xsd_type(ruby_type)
385
- return RUBY_TO_XSD[ruby_type] if RUBY_TO_XSD.key?(ruby_type)
386
-
387
- sym = ruby_type.to_s.downcase.to_sym
388
- RUBY_TO_XSD.fetch(sym, "xsd:string")
389
- end
390
-
391
- # ── Legacy wrapper ───────────────────────────────────────────────────
392
- # Keeps backward compatibility with the old Tina4::WSDL::Service API
393
- # used in demos and existing code.
394
-
395
- class Service
396
- attr_reader :name, :namespace, :operations
397
-
398
- def initialize(name:, namespace: "http://tina4.com/wsdl")
399
- @name = name
400
- @namespace = namespace
401
- @operations = {}
402
- end
403
-
404
- def add_operation(name, input_params: {}, output_params: {}, &handler)
405
- @operations[name.to_s] = {
406
- input: input_params,
407
- output: output_params,
408
- handler: handler
409
- }
410
- end
411
-
412
- def generate_wsdl(endpoint_url)
413
- xml = '<?xml version="1.0" encoding="UTF-8"?>'
414
- xml += "\n<definitions xmlns=\"http://schemas.xmlsoap.org/wsdl/\""
415
- xml += " xmlns:soap=\"http://schemas.xmlsoap.org/wsdl/soap/\""
416
- xml += " xmlns:tns=\"#{@namespace}\""
417
- xml += " xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\""
418
- xml += " name=\"#{@name}\" targetNamespace=\"#{@namespace}\">\n"
419
-
420
- # Types
421
- xml += " <types>\n <xsd:schema targetNamespace=\"#{@namespace}\">\n"
422
- @operations.each do |op_name, op|
423
- xml += _generate_elements(op_name, op[:input], "Request")
424
- xml += _generate_elements(op_name, op[:output], "Response")
425
- end
426
- xml += " </xsd:schema>\n </types>\n"
427
-
428
- # Messages
429
- @operations.each_key do |op_name|
430
- xml += " <message name=\"#{op_name}Request\">\n"
431
- xml += " <part name=\"parameters\" element=\"tns:#{op_name}Request\"/>\n"
432
- xml += " </message>\n"
433
- xml += " <message name=\"#{op_name}Response\">\n"
434
- xml += " <part name=\"parameters\" element=\"tns:#{op_name}Response\"/>\n"
435
- xml += " </message>\n"
436
- end
437
-
438
- # PortType
439
- xml += " <portType name=\"#{@name}PortType\">\n"
440
- @operations.each_key do |op_name|
441
- xml += " <operation name=\"#{op_name}\">\n"
442
- xml += " <input message=\"tns:#{op_name}Request\"/>\n"
443
- xml += " <output message=\"tns:#{op_name}Response\"/>\n"
444
- xml += " </operation>\n"
445
- end
446
- xml += " </portType>\n"
447
-
448
- # Binding
449
- xml += " <binding name=\"#{@name}Binding\" type=\"tns:#{@name}PortType\">\n"
450
- xml += " <soap:binding style=\"document\" transport=\"http://schemas.xmlsoap.org/soap/http\"/>\n"
451
- @operations.each_key do |op_name|
452
- xml += " <operation name=\"#{op_name}\">\n"
453
- xml += " <soap:operation soapAction=\"#{@namespace}/#{op_name}\"/>\n"
454
- xml += " <input><soap:body use=\"literal\"/></input>\n"
455
- xml += " <output><soap:body use=\"literal\"/></output>\n"
456
- xml += " </operation>\n"
457
- end
458
- xml += " </binding>\n"
459
-
460
- # Service
461
- xml += " <service name=\"#{@name}\">\n"
462
- xml += " <port name=\"#{@name}Port\" binding=\"tns:#{@name}Binding\">\n"
463
- xml += " <soap:address location=\"#{endpoint_url}\"/>\n"
464
- xml += " </port>\n"
465
- xml += " </service>\n"
466
- xml += "</definitions>"
467
- xml
468
- end
469
-
470
- def handle_soap_request(xml_body)
471
- doc = REXML::Document.new(xml_body)
472
-
473
- # Find Body element (namespace-agnostic)
474
- body_el = _find_child(doc.root, "Body")
475
- return _soap_fault("Unknown operation") unless body_el
476
-
477
- op_el = body_el.elements.first
478
- return _soap_fault("Unknown operation") unless op_el
479
-
480
- op_name = op_el.name
481
- return _soap_fault("Unknown operation") unless @operations.key?(op_name)
482
-
483
- operation = @operations[op_name]
484
-
485
- # Extract parameters
486
- params = {}
487
- operation[:input].each_key do |param_name|
488
- child = _find_child(op_el, param_name.to_s)
489
- params[param_name.to_s] = child.text if child
490
- end
491
-
492
- # Execute handler
493
- result = operation[:handler].call(params)
494
-
495
- # Build SOAP response
496
- _build_soap_response(op_name, result)
497
- rescue StandardError => e
498
- _soap_fault(e.message)
499
- end
500
-
501
- private
502
-
503
- def _find_child(parent, local)
504
- parent.each_element do |el|
505
- return el if el.name == local
506
- end
507
- nil
508
- end
509
-
510
- def _generate_elements(op_name, params, suffix)
511
- xml = " <xsd:element name=\"#{op_name}#{suffix}\">\n"
512
- xml += " <xsd:complexType><xsd:sequence>\n"
513
- params.each do |name, type|
514
- xsd_type = _ruby_to_xsd_type(type)
515
- xml += " <xsd:element name=\"#{name}\" type=\"xsd:#{xsd_type}\"/>\n"
516
- end
517
- xml += " </xsd:sequence></xsd:complexType>\n"
518
- xml += " </xsd:element>\n"
519
- xml
520
- end
521
-
522
- def _ruby_to_xsd_type(type)
523
- case type.to_s.downcase
524
- when "string" then "string"
525
- when "integer", "int" then "int"
526
- when "float", "double" then "double"
527
- when "boolean", "bool" then "boolean"
528
- when "date" then "date"
529
- when "datetime" then "dateTime"
530
- else "string"
531
- end
532
- end
533
-
534
- def _build_soap_response(op_name, result)
535
- xml = '<?xml version="1.0" encoding="UTF-8"?>'
536
- xml += "<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\""
537
- xml += " xmlns:tns=\"#{@namespace}\">"
538
- xml += "<soap:Body>"
539
- xml += "<tns:#{op_name}Response>"
540
- if result.is_a?(Hash)
541
- result.each { |k, v| xml += "<#{k}>#{_escape_xml(v.to_s)}</#{k}>" }
542
- else
543
- xml += "<result>#{_escape_xml(result.to_s)}</result>"
544
- end
545
- xml += "</tns:#{op_name}Response>"
546
- xml += "</soap:Body></soap:Envelope>"
547
- xml
548
- end
549
-
550
- def _soap_fault(message)
551
- '<?xml version="1.0" encoding="UTF-8"?>' \
552
- '<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">' \
553
- "<soap:Body><soap:Fault>" \
554
- "<faultcode>soap:Server</faultcode>" \
555
- "<faultstring>#{_escape_xml(message)}</faultstring>" \
556
- "</soap:Fault></soap:Body></soap:Envelope>"
557
- end
558
-
559
- def _escape_xml(s)
560
- s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
561
- end
562
- end
563
- end
564
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+
5
+ module Tina4
6
+ # SOAP 1.1 / WSDL server — zero-dependency, mirrors tina4-python's wsdl module.
7
+ #
8
+ # Usage (class-based):
9
+ #
10
+ # class Calculator < Tina4::WSDL
11
+ # wsdl_operation output: { Result: :int }
12
+ # def add(a, b)
13
+ # { Result: a.to_i + b.to_i }
14
+ # end
15
+ # end
16
+ #
17
+ # # In a route handler:
18
+ # service = Calculator.new(request)
19
+ # response.call(service.handle)
20
+ #
21
+ # Supported:
22
+ # - WSDL 1.1 generation from Ruby type declarations
23
+ # - SOAP 1.1 request/response handling via REXML
24
+ # - Lifecycle hooks (on_request, on_result)
25
+ # - Auto type mapping (Integer -> int, String -> string, Float -> double, etc.)
26
+ # - XML escaping on all response values
27
+ # - SOAP fault responses on errors
28
+ #
29
+ class WSDL
30
+ NS_SOAP = "http://schemas.xmlsoap.org/wsdl/soap/"
31
+ NS_WSDL = "http://schemas.xmlsoap.org/wsdl/"
32
+ NS_XSD = "http://www.w3.org/2001/XMLSchema"
33
+ NS_SOAP_ENV = "http://schemas.xmlsoap.org/soap/envelope/"
34
+
35
+ RUBY_TO_XSD = {
36
+ :int => "xsd:int",
37
+ :integer => "xsd:int",
38
+ :string => "xsd:string",
39
+ :float => "xsd:double",
40
+ :double => "xsd:double",
41
+ :boolean => "xsd:boolean",
42
+ :bool => "xsd:boolean",
43
+ :date => "xsd:date",
44
+ :datetime => "xsd:dateTime",
45
+ :base64 => "xsd:base64Binary",
46
+ Integer => "xsd:int",
47
+ String => "xsd:string",
48
+ Float => "xsd:double",
49
+ TrueClass => "xsd:boolean",
50
+ FalseClass => "xsd:boolean"
51
+ }.freeze
52
+
53
+ # ── Class-level DSL ──────────────────────────────────────────────────
54
+
55
+ class << self
56
+ # Registry of operations declared via wsdl_operation + def.
57
+ # Each entry: { input: { name => type, ... }, output: { name => type, ... } }
58
+ def wsdl_operations
59
+ @wsdl_operations ||= {}
60
+ end
61
+
62
+ # Pending output hash waiting for the next method definition.
63
+ def pending_wsdl_output
64
+ @pending_wsdl_output
65
+ end
66
+
67
+ # Mark the next defined method as a WSDL operation.
68
+ #
69
+ # wsdl_operation output: { Result: :int }
70
+ # def add(a, b) ...
71
+ #
72
+ # Input parameters are inferred from the method signature.
73
+ # The +output+ hash maps response element names to XSD type symbols.
74
+ def wsdl_operation(output: {})
75
+ @pending_wsdl_output = output
76
+ end
77
+
78
+ # Hook into method definition to capture the pending operation.
79
+ def method_added(method_name)
80
+ super
81
+ return unless @pending_wsdl_output
82
+
83
+ output = @pending_wsdl_output
84
+ @pending_wsdl_output = nil
85
+
86
+ # Infer input parameter names from the method signature.
87
+ params = instance_method(method_name).parameters
88
+ input = {}
89
+ params.each do |_kind, name|
90
+ next if name.nil?
91
+ input[name] = :string # default; callers can rely on type coercion
92
+ end
93
+
94
+ wsdl_operations[method_name.to_s] = { input: input, output: output }
95
+ end
96
+
97
+ # Ensure subclasses get their own copy of the operations registry.
98
+ def inherited(subclass)
99
+ super
100
+ subclass.instance_variable_set(:@wsdl_operations, wsdl_operations.dup)
101
+ end
102
+ end
103
+
104
+ # ── Instance ─────────────────────────────────────────────────────────
105
+
106
+ attr_reader :request, :service_url
107
+
108
+ def initialize(request = nil, service_url: "")
109
+ @request = request
110
+ @service_url = service_url.empty? ? infer_url : service_url
111
+ @operations = discover_operations
112
+ end
113
+
114
+ # Main entry point. Returns WSDL XML on GET/?wsdl, or processes a SOAP
115
+ # request on POST.
116
+ def handle
117
+ return generate_wsdl if @request.nil?
118
+
119
+ method = if @request.respond_to?(:method)
120
+ @request.method.to_s.upcase
121
+ elsif @request.respond_to?(:body) && @request.body && !@request.body.to_s.empty?
122
+ "POST"
123
+ else
124
+ "GET"
125
+ end
126
+
127
+ params = (@request.respond_to?(:params) ? @request.params : nil) || {}
128
+ url = (@request.respond_to?(:url) ? @request.url : nil) || ""
129
+
130
+ if method == "GET" || params.key?("wsdl") || params.key?(:wsdl) || url.end_with?("?wsdl")
131
+ return generate_wsdl
132
+ end
133
+
134
+ body = if @request.respond_to?(:body)
135
+ @request.body.is_a?(String) ? @request.body : @request.body.to_s
136
+ else
137
+ ""
138
+ end
139
+
140
+ process_soap(body)
141
+ end
142
+
143
+ # ── Lifecycle hooks (override in subclass) ───────────────────────────
144
+
145
+ # Called before operation invocation. Override to validate/log.
146
+ def on_request(request)
147
+ # no-op
148
+ end
149
+
150
+ # Called after operation returns. Override to transform/audit.
151
+ # Must return the (possibly modified) result.
152
+ def on_result(result)
153
+ result
154
+ end
155
+
156
+ # ── WSDL generation ──────────────────────────────────────────────────
157
+
158
+ def generate_wsdl(endpoint_url = "")
159
+ @service_url = endpoint_url unless endpoint_url.empty?
160
+ service_name = self.class.name ? self.class.name.split("::").last : "AnonymousService"
161
+ tns = "urn:#{service_name}"
162
+
163
+ parts = []
164
+ parts << '<?xml version="1.0" encoding="UTF-8"?>'
165
+ parts << "<definitions name=\"#{service_name}\""
166
+ parts << " targetNamespace=\"#{tns}\""
167
+ parts << " xmlns:tns=\"#{tns}\""
168
+ parts << " xmlns:soap=\"#{NS_SOAP}\""
169
+ parts << " xmlns:xsd=\"#{NS_XSD}\""
170
+ parts << " xmlns=\"#{NS_WSDL}\">"
171
+ parts << ""
172
+
173
+ # Types
174
+ parts << " <types>"
175
+ parts << " <xsd:schema targetNamespace=\"#{tns}\">"
176
+
177
+ @operations.each do |op_name, meta|
178
+ # Request element
179
+ parts << " <xsd:element name=\"#{op_name}\">"
180
+ parts << " <xsd:complexType>"
181
+ parts << " <xsd:sequence>"
182
+ meta[:input].each do |pname, ptype|
183
+ xsd = xsd_type(ptype)
184
+ parts << " <xsd:element name=\"#{pname}\" type=\"#{xsd}\"/>"
185
+ end
186
+ parts << " </xsd:sequence>"
187
+ parts << " </xsd:complexType>"
188
+ parts << " </xsd:element>"
189
+
190
+ # Response element
191
+ parts << " <xsd:element name=\"#{op_name}Response\">"
192
+ parts << " <xsd:complexType>"
193
+ parts << " <xsd:sequence>"
194
+ meta[:output].each do |rname, rtype|
195
+ xsd = xsd_type(rtype)
196
+ parts << " <xsd:element name=\"#{rname}\" type=\"#{xsd}\"/>"
197
+ end
198
+ parts << " </xsd:sequence>"
199
+ parts << " </xsd:complexType>"
200
+ parts << " </xsd:element>"
201
+ end
202
+
203
+ parts << " </xsd:schema>"
204
+ parts << " </types>"
205
+ parts << ""
206
+
207
+ # Messages
208
+ @operations.each_key do |op_name|
209
+ parts << " <message name=\"#{op_name}Input\">"
210
+ parts << " <part name=\"parameters\" element=\"tns:#{op_name}\"/>"
211
+ parts << " </message>"
212
+ parts << " <message name=\"#{op_name}Output\">"
213
+ parts << " <part name=\"parameters\" element=\"tns:#{op_name}Response\"/>"
214
+ parts << " </message>"
215
+ end
216
+ parts << ""
217
+
218
+ # PortType
219
+ parts << " <portType name=\"#{service_name}PortType\">"
220
+ @operations.each_key do |op_name|
221
+ parts << " <operation name=\"#{op_name}\">"
222
+ parts << " <input message=\"tns:#{op_name}Input\"/>"
223
+ parts << " <output message=\"tns:#{op_name}Output\"/>"
224
+ parts << " </operation>"
225
+ end
226
+ parts << " </portType>"
227
+ parts << ""
228
+
229
+ # Binding
230
+ parts << " <binding name=\"#{service_name}Binding\" type=\"tns:#{service_name}PortType\">"
231
+ parts << " <soap:binding style=\"document\" transport=\"http://schemas.xmlsoap.org/soap/http\"/>"
232
+ @operations.each_key do |op_name|
233
+ parts << " <operation name=\"#{op_name}\">"
234
+ parts << " <soap:operation soapAction=\"#{tns}/#{op_name}\"/>"
235
+ parts << ' <input><soap:body use="literal"/></input>'
236
+ parts << ' <output><soap:body use="literal"/></output>'
237
+ parts << " </operation>"
238
+ end
239
+ parts << " </binding>"
240
+ parts << ""
241
+
242
+ # Service
243
+ parts << " <service name=\"#{service_name}\">"
244
+ parts << " <port name=\"#{service_name}Port\" binding=\"tns:#{service_name}Binding\">"
245
+ parts << " <soap:address location=\"#{@service_url}\"/>"
246
+ parts << " </port>"
247
+ parts << " </service>"
248
+
249
+ parts << "</definitions>"
250
+ parts.join("\n")
251
+ end
252
+
253
+ private
254
+
255
+ # ── Auto-discovery ───────────────────────────────────────────────────
256
+
257
+ def discover_operations
258
+ self.class.wsdl_operations.dup
259
+ end
260
+
261
+ def infer_url
262
+ return @request.url if @request && @request.respond_to?(:url)
263
+ "/"
264
+ end
265
+
266
+ # ── SOAP request processing ──────────────────────────────────────────
267
+
268
+ def process_soap(xml_body)
269
+ on_request(@request)
270
+
271
+ begin
272
+ doc = REXML::Document.new(xml_body)
273
+ rescue REXML::ParseException
274
+ return soap_fault("Client", "Malformed XML")
275
+ end
276
+
277
+ # Find the SOAP Body element (namespace-agnostic)
278
+ body_el = find_child(doc.root, "Body")
279
+ return soap_fault("Client", "Missing SOAP Body") unless body_el
280
+
281
+ # First child of Body is the operation element
282
+ op_el = body_el.elements.first
283
+ return soap_fault("Client", "Empty SOAP Body") unless op_el
284
+
285
+ op_name = local_name(op_el)
286
+
287
+ unless @operations.key?(op_name)
288
+ return soap_fault("Client", "Unknown operation: #{op_name}")
289
+ end
290
+
291
+ meta = @operations[op_name]
292
+
293
+ # Extract parameters from the operation element
294
+ params = {}
295
+ meta[:input].each do |param_name, param_type|
296
+ child = find_child(op_el, param_name.to_s)
297
+ if child
298
+ value = child.text || ""
299
+ params[param_name.to_s] = convert_value(value, param_type)
300
+ end
301
+ end
302
+
303
+ begin
304
+ result = send(op_name.to_sym, *meta[:input].keys.map { |k| params[k.to_s] })
305
+ result = on_result(result)
306
+ rescue StandardError => e
307
+ return soap_fault("Server", e.message)
308
+ end
309
+
310
+ soap_response(op_name, result)
311
+ end
312
+
313
+ # ── XML helpers (REXML) ──────────────────────────────────────────────
314
+
315
+ def find_child(parent, local)
316
+ parent.each_element do |el|
317
+ return el if local_name(el) == local
318
+ end
319
+ nil
320
+ end
321
+
322
+ def local_name(element)
323
+ element.name # REXML already strips the prefix for .name
324
+ end
325
+
326
+ # ── Type conversion ──────────────────────────────────────────────────
327
+
328
+ def convert_value(value, target_type)
329
+ case target_type.to_s.downcase.to_sym
330
+ when :int, :integer
331
+ value.to_i
332
+ when :float, :double
333
+ value.to_f
334
+ when :boolean, :bool
335
+ %w[true 1 yes].include?(value.downcase)
336
+ else
337
+ value
338
+ end
339
+ end
340
+
341
+ # ── Response builders ────────────────────────────────────────────────
342
+
343
+ def soap_response(op_name, result)
344
+ parts = []
345
+ parts << '<?xml version="1.0" encoding="UTF-8"?>'
346
+ parts << "<soap:Envelope xmlns:soap=\"#{NS_SOAP_ENV}\">"
347
+ parts << "<soap:Body>"
348
+ parts << "<#{op_name}Response>"
349
+
350
+ if result.is_a?(Hash)
351
+ result.each do |k, v|
352
+ if v.nil?
353
+ parts << "<#{k} xsi:nil=\"true\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"/>"
354
+ elsif v.is_a?(Array)
355
+ v.each { |item| parts << "<#{k}>#{escape_xml(item.to_s)}</#{k}>" }
356
+ else
357
+ parts << "<#{k}>#{escape_xml(v.to_s)}</#{k}>"
358
+ end
359
+ end
360
+ end
361
+
362
+ parts << "</#{op_name}Response>"
363
+ parts << "</soap:Body>"
364
+ parts << "</soap:Envelope>"
365
+ parts.join("\n")
366
+ end
367
+
368
+ def soap_fault(code, message)
369
+ '<?xml version="1.0" encoding="UTF-8"?>' \
370
+ "<soap:Envelope xmlns:soap=\"#{NS_SOAP_ENV}\">" \
371
+ "<soap:Body>" \
372
+ "<soap:Fault>" \
373
+ "<faultcode>#{code}</faultcode>" \
374
+ "<faultstring>#{escape_xml(message)}</faultstring>" \
375
+ "</soap:Fault>" \
376
+ "</soap:Body>" \
377
+ "</soap:Envelope>"
378
+ end
379
+
380
+ def escape_xml(s)
381
+ s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
382
+ end
383
+
384
+ def xsd_type(ruby_type)
385
+ return RUBY_TO_XSD[ruby_type] if RUBY_TO_XSD.key?(ruby_type)
386
+
387
+ sym = ruby_type.to_s.downcase.to_sym
388
+ RUBY_TO_XSD.fetch(sym, "xsd:string")
389
+ end
390
+
391
+ # ── Legacy wrapper ───────────────────────────────────────────────────
392
+ # Keeps backward compatibility with the old Tina4::WSDL::Service API
393
+ # used in demos and existing code.
394
+
395
+ class Service
396
+ attr_reader :name, :namespace, :operations
397
+
398
+ def initialize(name:, namespace: "http://tina4.com/wsdl")
399
+ @name = name
400
+ @namespace = namespace
401
+ @operations = {}
402
+ end
403
+
404
+ def add_operation(name, input_params: {}, output_params: {}, &handler)
405
+ @operations[name.to_s] = {
406
+ input: input_params,
407
+ output: output_params,
408
+ handler: handler
409
+ }
410
+ end
411
+
412
+ def generate_wsdl(endpoint_url)
413
+ xml = '<?xml version="1.0" encoding="UTF-8"?>'
414
+ xml += "\n<definitions xmlns=\"http://schemas.xmlsoap.org/wsdl/\""
415
+ xml += " xmlns:soap=\"http://schemas.xmlsoap.org/wsdl/soap/\""
416
+ xml += " xmlns:tns=\"#{@namespace}\""
417
+ xml += " xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\""
418
+ xml += " name=\"#{@name}\" targetNamespace=\"#{@namespace}\">\n"
419
+
420
+ # Types
421
+ xml += " <types>\n <xsd:schema targetNamespace=\"#{@namespace}\">\n"
422
+ @operations.each do |op_name, op|
423
+ xml += _generate_elements(op_name, op[:input], "Request")
424
+ xml += _generate_elements(op_name, op[:output], "Response")
425
+ end
426
+ xml += " </xsd:schema>\n </types>\n"
427
+
428
+ # Messages
429
+ @operations.each_key do |op_name|
430
+ xml += " <message name=\"#{op_name}Request\">\n"
431
+ xml += " <part name=\"parameters\" element=\"tns:#{op_name}Request\"/>\n"
432
+ xml += " </message>\n"
433
+ xml += " <message name=\"#{op_name}Response\">\n"
434
+ xml += " <part name=\"parameters\" element=\"tns:#{op_name}Response\"/>\n"
435
+ xml += " </message>\n"
436
+ end
437
+
438
+ # PortType
439
+ xml += " <portType name=\"#{@name}PortType\">\n"
440
+ @operations.each_key do |op_name|
441
+ xml += " <operation name=\"#{op_name}\">\n"
442
+ xml += " <input message=\"tns:#{op_name}Request\"/>\n"
443
+ xml += " <output message=\"tns:#{op_name}Response\"/>\n"
444
+ xml += " </operation>\n"
445
+ end
446
+ xml += " </portType>\n"
447
+
448
+ # Binding
449
+ xml += " <binding name=\"#{@name}Binding\" type=\"tns:#{@name}PortType\">\n"
450
+ xml += " <soap:binding style=\"document\" transport=\"http://schemas.xmlsoap.org/soap/http\"/>\n"
451
+ @operations.each_key do |op_name|
452
+ xml += " <operation name=\"#{op_name}\">\n"
453
+ xml += " <soap:operation soapAction=\"#{@namespace}/#{op_name}\"/>\n"
454
+ xml += " <input><soap:body use=\"literal\"/></input>\n"
455
+ xml += " <output><soap:body use=\"literal\"/></output>\n"
456
+ xml += " </operation>\n"
457
+ end
458
+ xml += " </binding>\n"
459
+
460
+ # Service
461
+ xml += " <service name=\"#{@name}\">\n"
462
+ xml += " <port name=\"#{@name}Port\" binding=\"tns:#{@name}Binding\">\n"
463
+ xml += " <soap:address location=\"#{endpoint_url}\"/>\n"
464
+ xml += " </port>\n"
465
+ xml += " </service>\n"
466
+ xml += "</definitions>"
467
+ xml
468
+ end
469
+
470
+ def handle_soap_request(xml_body)
471
+ doc = REXML::Document.new(xml_body)
472
+
473
+ # Find Body element (namespace-agnostic)
474
+ body_el = _find_child(doc.root, "Body")
475
+ return _soap_fault("Unknown operation") unless body_el
476
+
477
+ op_el = body_el.elements.first
478
+ return _soap_fault("Unknown operation") unless op_el
479
+
480
+ op_name = op_el.name
481
+ return _soap_fault("Unknown operation") unless @operations.key?(op_name)
482
+
483
+ operation = @operations[op_name]
484
+
485
+ # Extract parameters
486
+ params = {}
487
+ operation[:input].each_key do |param_name|
488
+ child = _find_child(op_el, param_name.to_s)
489
+ params[param_name.to_s] = child.text if child
490
+ end
491
+
492
+ # Execute handler
493
+ result = operation[:handler].call(params)
494
+
495
+ # Build SOAP response
496
+ _build_soap_response(op_name, result)
497
+ rescue StandardError => e
498
+ _soap_fault(e.message)
499
+ end
500
+
501
+ private
502
+
503
+ def _find_child(parent, local)
504
+ parent.each_element do |el|
505
+ return el if el.name == local
506
+ end
507
+ nil
508
+ end
509
+
510
+ def _generate_elements(op_name, params, suffix)
511
+ xml = " <xsd:element name=\"#{op_name}#{suffix}\">\n"
512
+ xml += " <xsd:complexType><xsd:sequence>\n"
513
+ params.each do |name, type|
514
+ xsd_type = _ruby_to_xsd_type(type)
515
+ xml += " <xsd:element name=\"#{name}\" type=\"xsd:#{xsd_type}\"/>\n"
516
+ end
517
+ xml += " </xsd:sequence></xsd:complexType>\n"
518
+ xml += " </xsd:element>\n"
519
+ xml
520
+ end
521
+
522
+ def _ruby_to_xsd_type(type)
523
+ case type.to_s.downcase
524
+ when "string" then "string"
525
+ when "integer", "int" then "int"
526
+ when "float", "double" then "double"
527
+ when "boolean", "bool" then "boolean"
528
+ when "date" then "date"
529
+ when "datetime" then "dateTime"
530
+ else "string"
531
+ end
532
+ end
533
+
534
+ def _build_soap_response(op_name, result)
535
+ xml = '<?xml version="1.0" encoding="UTF-8"?>'
536
+ xml += "<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\""
537
+ xml += " xmlns:tns=\"#{@namespace}\">"
538
+ xml += "<soap:Body>"
539
+ xml += "<tns:#{op_name}Response>"
540
+ if result.is_a?(Hash)
541
+ result.each { |k, v| xml += "<#{k}>#{_escape_xml(v.to_s)}</#{k}>" }
542
+ else
543
+ xml += "<result>#{_escape_xml(result.to_s)}</result>"
544
+ end
545
+ xml += "</tns:#{op_name}Response>"
546
+ xml += "</soap:Body></soap:Envelope>"
547
+ xml
548
+ end
549
+
550
+ def _soap_fault(message)
551
+ '<?xml version="1.0" encoding="UTF-8"?>' \
552
+ '<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">' \
553
+ "<soap:Body><soap:Fault>" \
554
+ "<faultcode>soap:Server</faultcode>" \
555
+ "<faultstring>#{_escape_xml(message)}</faultstring>" \
556
+ "</soap:Fault></soap:Body></soap:Envelope>"
557
+ end
558
+
559
+ def _escape_xml(s)
560
+ s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
561
+ end
562
+ end
563
+ end
564
+ end