erupt-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/erupt/node.rb ADDED
@@ -0,0 +1,555 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "socket"
8
+ require "stringio"
9
+ require "uri"
10
+
11
+ module Erupt
12
+ # The acting user of the request being served: <tt>{"uid", "account", "name", "tenantId"}</tt>.
13
+ def self.current_user = Thread.current[:erupt_user]
14
+
15
+ # Erupt cloud node runtime: registers with the server, then answers the requests it forwards. The wire
16
+ # contract is erupt-cloud/PROTOCOL.md (Level 0). A node is a Rack application (+call(env)+), so it can be
17
+ # mounted in Rails / Sinatra / any Rack server, or served on its own with +serve+.
18
+ class Node
19
+ # Features whose endpoints this SDK does not serve yet: hidden from the UI instead of failing there
20
+ UNSUPPORTED_POWER = { "print" => false, "ai" => false }.freeze
21
+ # Rights the server decides per user; the node ANDs them onto the declared power
22
+ SERVER_POWER = %w[add edit delete viewDetails export importable].freeze
23
+ OPS = %w[EQ NEQ GT GTE LT LTE LIKE NOT_LIKE RANGE IN NOT_IN NULL NOT_NULL].freeze
24
+ MAX_PAGE_SIZE = 1000
25
+ POWER_TTL = 5
26
+
27
+ ROUTES = [
28
+ ["GET", %r{\A/erupt-api/build/([^/]+)\z}, :build],
29
+ ["POST", %r{\A/erupt-api/data/table/([^/]+)\z}, :table],
30
+ ["GET", %r{\A/erupt-api/data/init-value/([^/]+)\z}, :init_value],
31
+ ["GET", %r{\A/erupt-api/data/([^/]+)/([^/]+)\z}, :get],
32
+ ["POST", %r{\A/erupt-api/data/modify/([^/]+)\z}, :add],
33
+ ["POST", %r{\A/erupt-api/data/modify/([^/]+)/update\z}, :update],
34
+ ["POST", %r{\A/erupt-api/data/modify/([^/]+)/delete\z}, :delete],
35
+ ["POST", %r{\A/erupt-api/invoke/([^/]+)/([^/]+)/([^/]+)\z}, :invoke]
36
+ ].freeze
37
+ # invoke kind -> the Field attribute holding its code
38
+ INVOKED = { "options" => :fetch, "onchange" => :onchange, "autocomplete" => :complete, "button" => :click }.freeze
39
+
40
+ # Answered with a bare status
41
+ class Reply < StandardError
42
+ attr_reader :status
43
+
44
+ def initialize(status)
45
+ super(status.to_s)
46
+ @status = status
47
+ end
48
+ end
49
+
50
+ # Logs to $stderr unless a logger (Rails.logger, Logger.new(...)) is given.
51
+ class StderrLog
52
+ %i[info warn error].each { |level| define_method(level) { |msg| $stderr.puts("[erupt] #{level.upcase} #{msg}") } }
53
+ def debug(_msg) = nil
54
+ end
55
+
56
+ attr_reader :name, :address, :models
57
+
58
+ # +require_signature+ refuses server calls without a valid Erupt-Signature, so the plain access token alone
59
+ # no longer passes (servers sign since erupt 2.3.x).
60
+ def initialize(name, access_token, servers, address: nil, heartbeat: 15, require_signature: false, logger: nil)
61
+ raise ArgumentError, "access_token is required" if access_token.to_s.empty?
62
+
63
+ @name, @access_token, @require_signature = name, access_token, require_signature
64
+ @servers = Array(servers).map { |s| s.chomp("/") }
65
+ @address, @heartbeat, @log = address, heartbeat, logger || StderrLog.new
66
+ @models = {}
67
+ @tick = 0
68
+ @power_cache = {}
69
+ @lock = Mutex.new
70
+ end
71
+
72
+ def register(cls, repository)
73
+ model = cls.respond_to?(:erupt_model) ? cls.erupt_model : raise(ArgumentError, "#{cls} does not extend Erupt::Model")
74
+ model.validate!
75
+ raise ArgumentError, "erupt '#{model.name}' registered twice" if @models.key?(model.name)
76
+ raise ArgumentError, "#{model.name} has no primary key; only an operation form may omit it" if model.pk.nil?
77
+
78
+ repository.bind(model)
79
+ @models[model.name] = [model, repository]
80
+ self
81
+ end
82
+
83
+ # ---------------------------------------------------------------- Rack
84
+
85
+ def call(env)
86
+ status, body = begin
87
+ dispatch(env)
88
+ rescue Reply => e
89
+ [e.status, ""]
90
+ rescue Fail => e
91
+ [200, r(success: false, message: e.message)]
92
+ rescue StandardError => e # never leak internals: message only, the stack stays in the node log
93
+ @log.error("erupt node error: #{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}")
94
+ [500, { "path" => env["PATH_INFO"], "status" => 500, "error" => "erupt exception", "message" => e.message }]
95
+ ensure
96
+ Thread.current[:erupt_user] = Thread.current[:erupt_token] = nil
97
+ end
98
+ payload, type = body.is_a?(String) ? [body, "text/plain; charset=utf-8"] : [JSON.generate(body), "application/json; charset=utf-8"]
99
+ [status, { "content-type" => type, "content-length" => payload.bytesize.to_s }, [payload]]
100
+ end
101
+
102
+ # Serve until SIGINT / SIGTERM: HTTP first (the server probes /node-health while registering), then the
103
+ # heartbeat; deregisters on the way out. Mount the node in a Rack server instead and call +start+ / +stop+.
104
+ def serve(host: "0.0.0.0", port: 9090)
105
+ http = HttpServer.new(self, host, port)
106
+ start(port: port)
107
+ http.run
108
+ rescue SignalException
109
+ nil
110
+ ensure
111
+ stop
112
+ http&.close
113
+ end
114
+
115
+ # Start registering (and the heartbeat) for a node served by a Rack server listening on +port+.
116
+ def start(port:)
117
+ @instance_id = SecureRandom.hex(3)
118
+ @address = (@address || "http://#{local_ip(@servers.first)}:#{port}").chomp("/")
119
+ @heartbeat_thread = Thread.new { heartbeat_loop }
120
+ @log.info("erupt node '#{@name}' at #{@address} -> #{@servers.join(", ")}")
121
+ self
122
+ end
123
+
124
+ # Stop the heartbeat and deregister.
125
+ def stop
126
+ return unless @heartbeat_thread
127
+
128
+ @heartbeat_thread.kill
129
+ @heartbeat_thread = nil
130
+ deregister
131
+ end
132
+
133
+ # The config text set for this node on the erupt server (Node Config).
134
+ def config = server_call(:get, "/erupt-cloud-api/node-config/#{@name}", headers: { "Access-Token" => @access_token }, parse: false)
135
+
136
+ # The config text of this node's group on the erupt server.
137
+ def group_config = server_call(:get, "/erupt-cloud-api/node-group-config/#{@name}", headers: { "Access-Token" => @access_token }, parse: false)
138
+
139
+ # The acting user's full info from the erupt server (roles, org, post...), for the request being served.
140
+ def user_info
141
+ headers = { "Access-Token" => @access_token, "token" => Thread.current[:erupt_token].to_s }
142
+ text = server_call(:get, "/erupt-cloud-api/userinfo/#{@name}", headers: headers, parse: false)
143
+ text.to_s.empty? ? nil : JSON.parse(text)
144
+ end
145
+
146
+ private
147
+
148
+ def dispatch(env)
149
+ path, method = env["PATH_INFO"].to_s, env["REQUEST_METHOD"]
150
+ return [200, @name] if path == "/node-health"
151
+ raise Reply, 404 unless path.start_with?("/erupt-api/")
152
+ # server-to-server only: an Origin header means a browser reached the node directly
153
+ raise Reply, 403 unless env["HTTP_ORIGIN"].to_s.empty?
154
+
155
+ authenticate(env)
156
+ ROUTES.each do |verb, pattern, handler|
157
+ match = verb == method && pattern.match(path)
158
+ next unless match
159
+
160
+ name = unescape(match[1])
161
+ raise Reply, 404 if env["HTTP_ERUPT"] != name || !@models.key?(name)
162
+
163
+ parent = env["HTTP_ERUPTPARENT"] || query_param(env, "_eruptParent")
164
+ # nesting is not supported yet, so a parent can only vouch for itself
165
+ raise Reply, 404 if parent && !parent.empty? && parent[/[^.]*\z/] != name
166
+
167
+ user = env["HTTP_USER"].to_s
168
+ Thread.current[:erupt_user] = user.empty? ? nil : JSON.parse(user.unpack1("m"))
169
+ Thread.current[:erupt_token] = env["HTTP_TOKEN"]
170
+ model, repo = @models[name]
171
+ return [200, send(handler, model, repo, env, *match.captures.drop(1).map { |g| unescape(g) })]
172
+ end
173
+ raise Reply, 404
174
+ end
175
+
176
+ # A signed call must verify, and then stands for the token; its Erupt-Enforced is trusted only then.
177
+ def authenticate(env)
178
+ env["erupt.enforced"] = []
179
+ signature = env["HTTP_ERUPT_SIGNATURE"]
180
+ if signature.nil?
181
+ raise Reply, 403 if @require_signature || !OpenSSL.secure_compare(env["HTTP_ACCESS_TOKEN"].to_s, @access_token)
182
+
183
+ return
184
+ end
185
+ enforced = env["HTTP_ERUPT_ENFORCED"]
186
+ multipart = env["CONTENT_TYPE"].to_s.include?("multipart/form-data")
187
+ ok = Signature.verify(@access_token, env["REQUEST_METHOD"], path_and_query(env), env["HTTP_ERUPT_TIMESTAMP"], enforced,
188
+ env["HTTP_USER"], multipart ? nil : body(env), signature, Time.now.to_i)
189
+ raise Reply, 403 unless ok
190
+
191
+ env["erupt.enforced"] = enforced.to_s.split(",").reject(&:empty?)
192
+ end
193
+
194
+ # ---------------------------------------------------------------- endpoints
195
+
196
+ def build(model, _repo, env) = model.build_model(power(model, env), Erupt.current_user)
197
+
198
+ def table(model, repo, env)
199
+ require_power(model, env, "query")
200
+ page(model, repo, read_object(env), env)
201
+ end
202
+
203
+ def page(model, repo, body, env)
204
+ page_index = [int(body["pageIndex"] || 1), 1].max
205
+ page_size = int(body["pageSize"] || 10).clamp(1, MAX_PAGE_SIZE)
206
+ query = Query.new(offset: (page_index - 1) * page_size, limit: page_size)
207
+ # under a signed "query" the server checked the browser's conditions and may add its own (a dependent picker)
208
+ trusted = env["erupt.enforced"].include?("query")
209
+ Array(body["condition"]).each { |c| query.conditions.concat(conditions(model, c, trusted)) }
210
+ drilled = drill(model, env, query)
211
+ Array(body["sort"]).each do |s|
212
+ f = s.is_a?(Hash) && model.fields[s["field"]]
213
+ raise Fail, "illegal sort field '#{s["field"] if s.is_a?(Hash)}'" unless f && (f.sortable? || f.pk?)
214
+
215
+ query.sort << [f.name, s["direction"].to_s.upcase == "DESC"]
216
+ end
217
+ # a @LinkTree table shows the rows under the picked tree keys; with dependNode, none until one is picked
218
+ field, depend = model.link_tree
219
+ picked = body["linkTreeVal"]
220
+ query.conditions << condition(model.fields[field], "IN", picked) if field && !picked.nil?
221
+ # a @Vis view's own filter and order, then the DataProxy's
222
+ filters, sort = model.vis.fetch(body["vis"], [[], []])
223
+ query.sort = (sort.empty? ? model.order : sort).dup if query.sort.empty?
224
+ query.filters = drilled + filters + model.filters(query.conditions)
225
+ total, records = depend && picked.nil? ? [0, []] : repo.page(query)
226
+ list = rows(model, records)
227
+ model.hook(:after_fetch, list)
228
+ { "pageIndex" => page_index, "pageSize" => page_size, "total" => total, "totalPage" => (total + page_size - 1) / page_size,
229
+ "list" => list, "sort" => nil, "alert" => nil, "extraContent" => nil }
230
+ end
231
+
232
+ # A drill into this table from a row of another model (erupt-cloud/PROTOCOL.md §7.2): the rows whose
233
+ # joinColumn holds the source row's column; its linkCondition is returned as a filter.
234
+ def drill(model, env, query)
235
+ code = env["HTTP_DRILL"]
236
+ return [] if code.nil?
237
+
238
+ source, source_repo = @models[env["HTTP_DRILLSOURCEERUPT"].to_s]
239
+ link = source && source.drills[code]
240
+ raise Reply, 403 if link.nil?
241
+ raise Fail, "illegal erupt from drill '#{code}'" if link.erupt != model.name || !model.fields.key?(link.join)
242
+
243
+ row = source_repo.get(pk_of(source, env["HTTP_DRILLVALUE"])) or raise Fail, "record not found"
244
+ query.conditions << condition(model.fields[link.join], "EQ", row.key?(link.column) ? row[link.column] : row[link.column.to_sym])
245
+ link.condition.empty? ? [] : [[link.condition, []]]
246
+ end
247
+
248
+ def conditions(model, c, trusted = false)
249
+ c = {} unless c.is_a?(Hash)
250
+ f = model.fields[c["key"]]
251
+ raise Fail, "illegal search field '#{c["key"]}'" if f.nil? || (!trusted && (f.pk? || !f.search?))
252
+
253
+ op = c["expression"] || "EQ"
254
+ raise Fail, "illegal operator '#{op}'" unless OPS.include?(op)
255
+ return [condition(f, op, c["value"])] unless f.json?
256
+ # a JSON value (MULTI_CHOICE) holds every value searched for, matched as "<value>" in its text
257
+ raise Fail, "illegal operator '#{op}' for #{f.title}" unless %w[EQ IN NEQ NOT_IN].include?(op)
258
+
259
+ like = %w[EQ IN].include?(op) ? "LIKE" : "NOT_LIKE"
260
+ Array(c["value"]).map { |v| Cond.new(f.name, like, JSON.generate(v.to_s)) }
261
+ end
262
+
263
+ def condition(f, op, value)
264
+ return Cond.new(f.name, op) if %w[NULL NOT_NULL].include?(op)
265
+ raise Fail, "illegal operator '#{op}' for a reference" if f.reference? && !%w[EQ NEQ IN NOT_IN].include?(op)
266
+
267
+ case op
268
+ when "LIKE", "NOT_LIKE" then Cond.new(f.name, op, value.to_s)
269
+ when "RANGE"
270
+ raise Fail, "range needs two values" unless value.is_a?(Array) && value.size == 2
271
+
272
+ Cond.new(f.name, op, value.map { |v| coerce(f, v) })
273
+ when "IN", "NOT_IN" then Cond.new(f.name, op, (value.is_a?(Array) ? value : [value]).map { |v| coerce(f, v) })
274
+ else Cond.new(f.name, op, coerce(f, value))
275
+ end
276
+ end
277
+
278
+ def init_value(model, _repo, env)
279
+ require_power(model, env, "add")
280
+ record = model.fields.keys.to_h { |name| [name, nil] }
281
+ model.hook(:add_behavior, record)
282
+ rows(model, [record]).first
283
+ end
284
+
285
+ def get(model, repo, env, pk)
286
+ granted = power(model, env)
287
+ raise Reply, 403 unless granted["edit"] || granted["viewDetails"]
288
+
289
+ record = repo.get(pk_of(model, pk)) or raise Fail, "record not found"
290
+ model.hook(:edit_behavior, record)
291
+ rows(model, [record]).first
292
+ end
293
+
294
+ def add(model, repo, env)
295
+ require_power(model, env, "add")
296
+ rec = record(model, read_object(env))
297
+ model.hook(:validate, rec)
298
+ model.hook(:before_add, rec)
299
+ repo.add(rec)
300
+ model.hook(:after_add, rec)
301
+ r
302
+ end
303
+
304
+ def update(model, repo, env)
305
+ require_power(model, env, "edit")
306
+ body = read_object(env)
307
+ pk = pk_of(model, body[model.pk])
308
+ raise Fail, "record not found" if repo.get(pk).nil?
309
+
310
+ rec = record(model, body)
311
+ model.hook(:validate, rec)
312
+ model.hook(:before_update, rec)
313
+ repo.update(pk, rec)
314
+ model.hook(:after_update, rec)
315
+ r
316
+ end
317
+
318
+ def delete(model, repo, env)
319
+ require_power(model, env, "delete")
320
+ ids = read_json(env)
321
+ raise Fail, "a list of ids is required" unless ids.is_a?(Array)
322
+
323
+ pks = ids.map { |pk| pk_of(model, pk) }
324
+ # the hooks see each record as it was; reading them costs nothing without a proxy
325
+ records = model.proxies.empty? ? [] : pks.filter_map { |pk| repo.get(pk) }
326
+ records.each { |rec| model.hook(:before_delete, rec) }
327
+ repo.delete(pks)
328
+ records.each { |rec| model.hook(:after_delete, rec) }
329
+ r
330
+ end
331
+
332
+ # The node's own code (erupt-cloud/PROTOCOL.md §5.5): a row operation and its form values under the operation
333
+ # code, or a field's fetched options, onchange, auto-complete or button, with the form's values.
334
+ def invoke(model, repo, env, kind, name)
335
+ return operation(model, repo, env, name) if kind == "operation"
336
+ return r(data: operation_form_value(model, name)) if kind == "operation-form"
337
+
338
+ f = model.declared.find { |d| d.name == name }
339
+ fn = f && INVOKED.key?(kind) && f.public_send(INVOKED[kind])
340
+ raise Reply, 404 unless fn
341
+
342
+ body = read_object(env)
343
+ form = body["form"].is_a?(Hash) ? body["form"] : {}
344
+ return r(data: fn.call(form, body["value"] || "").to_a) if kind == "autocomplete"
345
+
346
+ answer = fn.call(form)
347
+ # {value => label} or [[value, label]], as the choices of a field
348
+ answer = answer.to_a.map { |v, label| { "value" => v.to_s, "label" => label } } if kind == "options" && f.type != EditType::TAGS
349
+ r(data: answer)
350
+ end
351
+
352
+ # Records to browser rows. A reference stays its key: the server resolves its label, the flattened
353
+ # <field>_<label> column and the tree (erupt-cloud/PROTOCOL.md §7.3).
354
+ def rows(model, records)
355
+ records.map do |rec|
356
+ model.fields.keys.to_h { |name| [name, Erupt.json_value(rec.key?(name) ? rec[name] : rec[name.to_sym])] }
357
+ end
358
+ end
359
+
360
+ def operation_of(model, code)
361
+ op = model.operations[code]
362
+ raise Reply, 404 if op.nil?
363
+ # a hidden operation does not exist for this user, exactly like the Java proxy filters it out
364
+ raise Reply, 403 unless op.visible?(Erupt.current_user)
365
+
366
+ op
367
+ end
368
+
369
+ def operation_form_value(model, code)
370
+ op = operation_of(model, code)
371
+ # none without a form: the server answers the browser a 404, and it opens an empty one
372
+ op.form_model.fields.keys.to_h { |name| [name, nil] } if op.form
373
+ end
374
+
375
+ def operation(model, repo, env, code)
376
+ op = operation_of(model, code)
377
+ body = read_object(env)
378
+ param = op.form && record(op.form_model, body["param"].is_a?(Hash) ? body["param"] : {})
379
+ selected = []
380
+ unless op.mode == RowOperationMode::BUTTON
381
+ ids = body["ids"]
382
+ raise Fail, "select at least one row" unless ids.is_a?(Array) && !ids.empty?
383
+ raise Fail, "select exactly one row" if op.mode == RowOperationMode::SINGLE && ids.size != 1
384
+
385
+ selected = ids.map { |pk| repo.get(pk_of(model, pk)) || raise(Fail, "record '#{pk}' not found") }
386
+ end
387
+ result = op.handler.call(OperationContext.new(rows: selected, param: param, repo: repo, model: model, user: Erupt.current_user))
388
+ result.is_a?(String) && !result.empty? ? r(data: result) : r(message: "Executed successfully")
389
+ end
390
+
391
+ def pk_of(model, value) = coerce(model.fields[model.pk], value)
392
+
393
+ def coerce(field, value)
394
+ field.coerce(value)
395
+ rescue ArgumentError, TypeError => e
396
+ raise Fail, e.message
397
+ end
398
+
399
+ def record(model, body)
400
+ model.fields.each_value.reject { |f| f.pk? || !f.form? }.to_h do |f|
401
+ value = begin
402
+ f.coerce(body[f.name])
403
+ rescue ArgumentError, TypeError => e
404
+ raise Fail, "#{f.title}: #{e.message}"
405
+ end
406
+ raise Fail, "#{f.title} cannot be empty" if f.not_null? && value.nil?
407
+
408
+ [f.name, value]
409
+ end
410
+ end
411
+
412
+ def r(success: true, message: nil, data: nil)
413
+ { "success" => success, "status" => success ? "SUCCESS" : "ERROR", "message" => message, "data" => data, "promptWay" => "MESSAGE" }
414
+ end
415
+
416
+ # ---------------------------------------------------------------- permissions (PROTOCOL.md §6.1)
417
+
418
+ def require_power(model, env, right)
419
+ raise Reply, 403 unless power(model, env)[right]
420
+ end
421
+
422
+ def power(model, env)
423
+ granted = model.power.merge(UNSUPPORTED_POWER)
424
+ # the server checked the user's rights and restricts the build's power itself: the model's own
425
+ # declaration is all that is left to apply here
426
+ return granted if env["erupt.enforced"]&.include?("power")
427
+
428
+ key = [env["HTTP_TOKEN"].to_s, model.name]
429
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
430
+ cached = @lock.synchronize { @power_cache[key] }
431
+ return cached.last if cached && cached.first > now
432
+
433
+ remote = begin
434
+ server_call(:get, "/erupt-cloud-api/erupt-power", query: { nodeName: @name, eruptName: model.name }, headers: { "token" => key.first })
435
+ rescue StandardError => e # fail closed: without the server's verdict nothing is writable
436
+ @log.warn("erupt-power unavailable: #{e.message}")
437
+ {}
438
+ end
439
+ remote = {} unless remote.is_a?(Hash)
440
+ SERVER_POWER.each { |right| granted[right] &&= !!remote[right] }
441
+ @lock.synchronize do
442
+ @power_cache.clear if @power_cache.size > 10_000
443
+ @power_cache[key] = [now + POWER_TTL, granted]
444
+ end
445
+ granted
446
+ end
447
+
448
+ # ---------------------------------------------------------------- server calls
449
+
450
+ def next_server = @servers[(@tick += 1) % @servers.size]
451
+
452
+ # GET with a query (JSON answer, or the text without +parse+), or POST a JSON / form body; raises unless the server answers 2xx.
453
+ def server_call(method, path, server: next_server, query: nil, json: nil, form: nil, headers: {}, timeout: 5, parse: true)
454
+ uri = URI(server + path)
455
+ uri.query = URI.encode_www_form(query) if query
456
+ request = method == :get ? Net::HTTP::Get.new(uri, headers) : Net::HTTP::Post.new(uri, headers)
457
+ if json
458
+ request.body = JSON.generate(json)
459
+ request["Content-Type"] = "application/json"
460
+ end
461
+ request.set_form_data(form) if form
462
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: timeout, read_timeout: timeout) do |http|
463
+ http.request(request)
464
+ end
465
+ raise "#{uri.path} answered HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
466
+
467
+ return response.body.to_s.dup.force_encoding(Encoding::UTF_8) unless parse
468
+
469
+ response.body.to_s.empty? ? nil : JSON.parse(response.body)
470
+ end
471
+
472
+ def node_info
473
+ { "instanceId" => @instance_id, "nodeName" => @name, "accessToken" => @access_token, "version" => "ruby-#{VERSION}",
474
+ "nodeAddress" => [@address], "eruptModules" => [], "erupts" => @models.keys, "resources" => {},
475
+ "capabilities" => ["invoke"] }
476
+ end
477
+
478
+ def heartbeat_loop
479
+ failing = false
480
+ loop do
481
+ server = next_server
482
+ begin
483
+ server_call(:post, "/erupt-cloud-api/register-node", server: server, json: node_info, timeout: 10)
484
+ @log.info("#{server} -> connection success") if failing
485
+ failing = false
486
+ sleep(@heartbeat)
487
+ rescue StandardError => e
488
+ failing = true
489
+ @log.error("#{server} -> register failed: #{e.message}")
490
+ sleep(@heartbeat / 2.0)
491
+ end
492
+ end
493
+ end
494
+
495
+ def deregister
496
+ server_call(:post, "/erupt-cloud-api/remove-instance-node", form: { nodeName: @name, accessToken: @access_token, locations: @address })
497
+ rescue StandardError => e
498
+ @log.error("deregister failed: #{e.message}")
499
+ end
500
+
501
+ def read_object(env)
502
+ body = read_json(env)
503
+ return {} if body.nil?
504
+ raise Fail, "a JSON object is required" unless body.is_a?(Hash)
505
+
506
+ body
507
+ end
508
+
509
+ def read_json(env)
510
+ text = body(env)
511
+ text.empty? ? nil : JSON.parse(text.dup.force_encoding(Encoding::UTF_8))
512
+ rescue JSON::ParserError
513
+ raise Fail, "malformed JSON body"
514
+ end
515
+
516
+ # The raw body, read once.
517
+ def body(env)
518
+ env["erupt.body"] ||= begin
519
+ input = env["rack.input"]
520
+ input&.rewind if input.respond_to?(:rewind)
521
+ input&.read.to_s.b
522
+ end
523
+ end
524
+
525
+ def int(value)
526
+ Integer(value)
527
+ rescue ArgumentError, TypeError
528
+ raise Fail, "illegal number '#{value}'"
529
+ end
530
+
531
+ # The request target as the server signed it: the raw path below the mount point, plus the query.
532
+ def path_and_query(env)
533
+ query = env["QUERY_STRING"].to_s
534
+ env["PATH_INFO"].to_s + (query.empty? ? "" : "?#{query}")
535
+ end
536
+
537
+ def unescape(text) = text.b.gsub(/%(\h\h)/) { Regexp.last_match(1).hex.chr }.force_encoding(Encoding::UTF_8)
538
+
539
+ def query_param(env, name)
540
+ URI.decode_www_form(env["QUERY_STRING"].to_s).assoc(name)&.last
541
+ rescue ArgumentError
542
+ nil
543
+ end
544
+
545
+ # The interface address the server would see us on.
546
+ def local_ip(server)
547
+ UDPSocket.open do |s|
548
+ s.connect(URI(server).host || "127.0.0.1", 80)
549
+ s.addr.last
550
+ end
551
+ rescue StandardError
552
+ "127.0.0.1"
553
+ end
554
+ end
555
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Erupt
4
+ # One search condition. +op+: EQ NEQ GT GTE LT LTE LIKE NOT_LIKE RANGE IN NOT_IN NULL NOT_NULL.
5
+ Cond = Struct.new(:field, :op, :value)
6
+
7
+ # What a repository is asked for; +sort+ is <tt>[[field, descending], ...]</tt>; +filters+ is
8
+ # <tt>[[sql, params], ...]</tt> of a @Vis view and DataProxy#before_fetch, to be ANDed as written: a repository that cannot
9
+ # apply them must refuse the query (raise), never ignore them.
10
+ Query = Struct.new(:conditions, :sort, :filters, :offset, :limit, keyword_init: true) do
11
+ def initialize(conditions: [], sort: [], filters: [], offset: 0, limit: 10) = super
12
+ end
13
+
14
+ # Storage adapter: implement these five methods to put a model on any storage. A record is a Hash keyed by
15
+ # field name. A repository only sees field names the node has already checked against the model, so it may
16
+ # use them as identifiers; values always travel as parameters. +page+ must apply the query's +filters+ or
17
+ # raise.
18
+ class Repository
19
+ attr_reader :model
20
+
21
+ def bind(model)
22
+ @model = model
23
+ end
24
+
25
+ # Returns <tt>[total, records]</tt>.
26
+ def page(query) = raise(NotImplementedError)
27
+ def get(pk) = raise(NotImplementedError)
28
+ def add(record) = raise(NotImplementedError)
29
+ def update(pk, record) = raise(NotImplementedError)
30
+ def delete(pks) = raise(NotImplementedError)
31
+
32
+ private
33
+
34
+ def keys(record) = record.to_h { |k, v| [k.to_s, v] }
35
+ end
36
+
37
+ # Records in memory, for tests and demos.
38
+ class MemoryRepository < Repository
39
+ def initialize(records = [])
40
+ super()
41
+ @lock = Mutex.new
42
+ @rows = {}
43
+ @seq = 0
44
+ @pending = records
45
+ end
46
+
47
+ def bind(model)
48
+ super
49
+ @pending.each { |record| add(record) }
50
+ end
51
+
52
+ def page(query)
53
+ raise ArgumentError, "MemoryRepository cannot apply SQL filters (a view filter, before_fetch)" unless query.filters.empty?
54
+
55
+ rows = @lock.synchronize { @rows.values.select { |r| query.conditions.all? { |c| match?(r[c.field], c) } } }
56
+ rows = rows.each_with_index.sort do |(a, i), (b, j)|
57
+ query.sort.lazy.map { |name, desc| compare(a[name], b[name]) * (desc ? -1 : 1) }.find(&:nonzero?) || i <=> j
58
+ end
59
+ [rows.size, rows.drop(query.offset).first(query.limit).map { |r, _| r.dup }]
60
+ end
61
+
62
+ def get(pk) = @lock.synchronize { @rows[pk]&.dup }
63
+
64
+ def add(record)
65
+ @lock.synchronize { @rows[@seq += 1] = keys(record).merge(model.pk => @seq) }
66
+ end
67
+
68
+ def update(pk, record)
69
+ @lock.synchronize { @rows[pk]&.merge!(keys(record).except(model.pk)) }
70
+ end
71
+
72
+ def delete(pks)
73
+ @lock.synchronize { pks.each { |pk| @rows.delete(pk) } }
74
+ end
75
+
76
+ private
77
+
78
+ # nil sorts last; false before true
79
+ def compare(a, b)
80
+ return (a.nil? ? 1 : 0) - (b.nil? ? 1 : 0) if a.nil? || b.nil?
81
+
82
+ a, b = [a, b].map { |v| v == true ? 1 : v == false ? 0 : v }
83
+ (a <=> b) || 0
84
+ end
85
+
86
+ def match?(value, cond)
87
+ expect = cond.value
88
+ case cond.op
89
+ when "NULL" then value.nil?
90
+ when "NOT_NULL" then !value.nil?
91
+ else !value.nil? && match_value?(value, cond.op, expect)
92
+ end
93
+ end
94
+
95
+ def match_value?(value, op, expect)
96
+ value = JSON.generate(value) if value.is_a?(Array) || value.is_a?(Hash) # a JSON value is matched as its text
97
+ case op
98
+ when "EQ" then value == expect
99
+ when "NEQ" then value != expect
100
+ when "LIKE" then value.to_s.downcase.include?(expect.to_s.downcase)
101
+ when "NOT_LIKE" then !value.to_s.downcase.include?(expect.to_s.downcase)
102
+ when "IN" then expect.include?(value)
103
+ when "NOT_IN" then !expect.include?(value)
104
+ when "RANGE" then compare(expect[0], value) <= 0 && compare(value, expect[1]) <= 0
105
+ when "GT" then compare(value, expect).positive?
106
+ when "GTE" then compare(value, expect) >= 0
107
+ when "LT" then compare(value, expect).negative?
108
+ when "LTE" then compare(value, expect) <= 0
109
+ else false
110
+ end
111
+ end
112
+ end
113
+ end