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.
- checksums.yaml +7 -0
- data/README.md +273 -0
- data/lib/erupt/defaults.json +1577 -0
- data/lib/erupt/http_server.rb +115 -0
- data/lib/erupt/model.rb +499 -0
- data/lib/erupt/node.rb +555 -0
- data/lib/erupt/repository.rb +113 -0
- data/lib/erupt/schema.json +2137 -0
- data/lib/erupt/schema.rb +68 -0
- data/lib/erupt/signature.rb +30 -0
- data/lib/erupt/sql.rb +226 -0
- data/lib/erupt/types.rb +300 -0
- data/lib/erupt/version.rb +5 -0
- data/lib/erupt-sdk.rb +4 -0
- data/lib/erupt.rb +12 -0
- data/sig/erupt/types.rbs +408 -0
- metadata +56 -0
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
|