mxrb 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/mxrb +455 -10
- data/docs/de-DE/README.md +1 -0
- data/docs/de-DE/oql-sql.md +42 -0
- data/docs/de-DE/project-structure.md +201 -2
- data/docs/de-DE/runtime-ruby.md +104 -0
- data/docs/de-DE/runtime-schema-migrations.md +44 -0
- data/docs/de-DE/validation-matrix.md +42 -4
- data/docs/en-US/README.md +1 -0
- data/docs/en-US/oql-sql.md +41 -0
- data/docs/en-US/project-structure.md +192 -2
- data/docs/en-US/runtime-ruby.md +100 -0
- data/docs/en-US/runtime-schema-migrations.md +41 -0
- data/docs/en-US/validation-matrix.md +39 -4
- data/docs/pt-BR/README.md +1 -0
- data/docs/pt-BR/oql-sql.md +41 -0
- data/docs/pt-BR/project-structure.md +197 -2
- data/docs/pt-BR/runtime-ruby.md +108 -0
- data/docs/pt-BR/runtime-schema-migrations.md +40 -0
- data/docs/pt-BR/validation-matrix.md +42 -5
- data/lib/mxrb/cli/help.rb +426 -0
- data/lib/mxrb/cli/release.rb +137 -0
- data/lib/mxrb/compiler/compatibility_analyzer.rb +1 -1
- data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +8 -1
- data/lib/mxrb/compiler/domain_document_compiler.rb +3 -0
- data/lib/mxrb/compiler/gallery_bundle_compiler.rb +11 -1
- data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +76 -19
- data/lib/mxrb/compiler/legacy_custom_widget_compiler.rb +159 -0
- data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +43 -7
- data/lib/mxrb/compiler/legacy_page_builder.rb +1248 -23
- data/lib/mxrb/compiler/page_bundle_compiler.rb +13 -4
- data/lib/mxrb/compiler/web_bundle_builder.rb +5 -1
- data/lib/mxrb/compiler/web_shell_materializer.rb +42 -2
- data/lib/mxrb/compiler/widget_package_extractor.rb +3 -0
- data/lib/mxrb/domain_diagram/lifecycle.rb +272 -0
- data/lib/mxrb/domain_diagram.rb +409 -0
- data/lib/mxrb/dsl/builder.rb +43 -9
- data/lib/mxrb/environment.rb +226 -0
- data/lib/mxrb/exporter.rb +89 -15
- data/lib/mxrb/http/server.rb +107 -0
- data/lib/mxrb/initializer.rb +80 -9
- data/lib/mxrb/integrity/validator.rb +14 -0
- data/lib/mxrb/io/bson_codec.rb +42 -1
- data/lib/mxrb/io/mpr_file.rb +146 -3
- data/lib/mxrb/model/attribute.rb +3 -0
- data/lib/mxrb/model/entity.rb +28 -2
- data/lib/mxrb/model/microflow.rb +3 -1
- data/lib/mxrb/model/page.rb +126 -15
- data/lib/mxrb/model/project.rb +3 -0
- data/lib/mxrb/modeler/catalog.rb +178 -0
- data/lib/mxrb/oql/reverse_translator.rb +394 -0
- data/lib/mxrb/oql/server.rb +4 -8
- data/lib/mxrb/oql.rb +23 -1
- data/lib/mxrb/project_lifecycle.rb +5 -2
- data/lib/mxrb/ruby_app/exporter.rb +1749 -0
- data/lib/mxrb/ruby_app/preset.rb +506 -0
- data/lib/mxrb/ruby_app/session_manager.rb +122 -0
- data/lib/mxrb/ruby_app.rb +1361 -0
- data/lib/mxrb/runtime/access_control.rb +408 -0
- data/lib/mxrb/runtime/native.rb +1015 -93
- data/lib/mxrb/runtime/scheduler.rb +396 -0
- data/lib/mxrb/runtime/schema_migrator.rb +598 -0
- data/lib/mxrb/runtime/shared_store.rb +335 -0
- data/lib/mxrb/runtime/sqlite_store.rb +729 -0
- data/lib/mxrb/scaffold/generator.rb +11 -3
- data/lib/mxrb/uml/activity_diagram.rb +118 -0
- data/lib/mxrb/uml/class_diagram.rb +118 -0
- data/lib/mxrb/uml/sequence_diagram.rb +112 -0
- data/lib/mxrb/uml/server.rb +146 -0
- data/lib/mxrb/uml/support.rb +35 -0
- data/lib/mxrb/version.rb +1 -1
- data/lib/mxrb/web_ui/assets/abnfDiagram-N423BO3Z-C4JD3cEC.js +1 -0
- data/lib/mxrb/web_ui/assets/arc-3Z53kgFp.js +1 -0
- data/lib/mxrb/web_ui/assets/architecture-TIHT7OUA-BStBTLea.js +1 -0
- data/lib/mxrb/web_ui/assets/architectureDiagram-T3A2C74G-DBwqnM4J.js +36 -0
- data/lib/mxrb/web_ui/assets/array-BifhSqXX.js +1 -0
- data/lib/mxrb/web_ui/assets/blockDiagram-VBNYF7ZC-DLtuHt-j.js +132 -0
- data/lib/mxrb/web_ui/assets/c4Diagram-5PPSVZJV-ConSk_pe.js +10 -0
- data/lib/mxrb/web_ui/assets/channel-BhPvD7Dt.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-2GRJ4B5K-D2HpWsf_.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-4I5QYGJK-CiIdNwaF.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-5RXB4S5H-Bu_AaEkE.js +231 -0
- data/lib/mxrb/web_ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- data/lib/mxrb/web_ui/assets/chunk-6Q2QTUOP-wQkNmt_X.js +88 -0
- data/lib/mxrb/web_ui/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-GF5L2VYU-G8MmiBzU.js +206 -0
- data/lib/mxrb/web_ui/assets/chunk-I66GZJ75-CulcrLPh.js +127 -0
- data/lib/mxrb/web_ui/assets/chunk-JQJVKLGR-ukjzcdlL.js +156 -0
- data/lib/mxrb/web_ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-KBJHAD2P-Wm35lYov.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
- data/lib/mxrb/web_ui/assets/chunk-NSK5VX7P-D2nYrCY4.js +2 -0
- data/lib/mxrb/web_ui/assets/chunk-QR6OTTB3-BrdIzg4Q.js +62 -0
- data/lib/mxrb/web_ui/assets/chunk-RYQCIY6F-CCfZMcW6.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-UBXNYLIW-BPEVYsFK.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-W5SLKNZC-2E6OPhA4.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-WRU74C26-CBIvOyWp.js +70 -0
- data/lib/mxrb/web_ui/assets/chunk-XXDRQBXY-pH58XAyl.js +1 -0
- data/lib/mxrb/web_ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- data/lib/mxrb/web_ui/assets/classDiagram-JCYQIIEL-xiqo4D89.js +1 -0
- data/lib/mxrb/web_ui/assets/classDiagram-v2-OCEON4UE-xiqo4D89.js +1 -0
- data/lib/mxrb/web_ui/assets/cose-bilkent-JH36ORCC-t_flPpii.js +1 -0
- data/lib/mxrb/web_ui/assets/cynefin-VYW2F7L2-BIqktTEv.js +1 -0
- data/lib/mxrb/web_ui/assets/cynefinDiagram-MW4NZA55-C2d--lut.js +62 -0
- data/lib/mxrb/web_ui/assets/cytoscape.esm-B-NFISlW.js +321 -0
- data/lib/mxrb/web_ui/assets/dagre-Buvkdvvj.js +1 -0
- data/lib/mxrb/web_ui/assets/dagre-VZM6K2ZE-CSz2_tb5.js +4 -0
- data/lib/mxrb/web_ui/assets/defaultLocale-BFoDCU3G.js +1 -0
- data/lib/mxrb/web_ui/assets/diagram-7IWD3JNH-DCt7u7V_.js +30 -0
- data/lib/mxrb/web_ui/assets/diagram-B4RE2ZJO-DqZBqkMu.js +3 -0
- data/lib/mxrb/web_ui/assets/diagram-LBJQPF4R-ClIYbIsI.js +24 -0
- data/lib/mxrb/web_ui/assets/diagram-Q27KOJAE-DFxhvthJ.js +24 -0
- data/lib/mxrb/web_ui/assets/diagram-UB23O5K3-BSUTXptv.js +41 -0
- data/lib/mxrb/web_ui/assets/dist-D2qOEbeJ.js +1 -0
- data/lib/mxrb/web_ui/assets/domain-mGOZ1KCG.css +1 -0
- data/lib/mxrb/web_ui/assets/domain-qsjXhy4v.js +1 -0
- data/lib/mxrb/web_ui/assets/ebnfDiagram-BXEA7PRR-B5reICEi.js +1 -0
- data/lib/mxrb/web_ui/assets/erDiagram-JOGREHBK-Co5Mxn2p.js +85 -0
- data/lib/mxrb/web_ui/assets/eventmodeling-45OFAUF4-C1ylhSks.js +1 -0
- data/lib/mxrb/web_ui/assets/flowDiagram-UKHOOZJN-CHXaJIaS.js +1 -0
- data/lib/mxrb/web_ui/assets/ganttDiagram-PKOTCBZU-DPPPaWaz.js +292 -0
- data/lib/mxrb/web_ui/assets/gitGraph-TEB2WS4Q-qS77ywA9.js +1 -0
- data/lib/mxrb/web_ui/assets/gitGraphDiagram-DS77QQ5N-DpAv6-BT.js +106 -0
- data/lib/mxrb/web_ui/assets/graphlib-DS17s2tU.js +1 -0
- data/lib/mxrb/web_ui/assets/info-DKCQHKI2-C9VD1Y4e.js +1 -0
- data/lib/mxrb/web_ui/assets/infoDiagram-6WML65LV-Bk1kbcCy.js +2 -0
- data/lib/mxrb/web_ui/assets/init-C-OQMol4.js +1 -0
- data/lib/mxrb/web_ui/assets/ishikawaDiagram-WSZJBQD7-0gcISBFR.js +70 -0
- data/lib/mxrb/web_ui/assets/journeyDiagram-NVQOT4AX-Dt0RQnlh.js +139 -0
- data/lib/mxrb/web_ui/assets/jsx-runtime-DV0a5kSb.js +9 -0
- data/lib/mxrb/web_ui/assets/kanban-definition-27J2QSJJ-DIPQM7w9.js +89 -0
- data/lib/mxrb/web_ui/assets/katex-CXMH3UgJ.js +257 -0
- data/lib/mxrb/web_ui/assets/line-C0rYD-tL.js +1 -0
- data/lib/mxrb/web_ui/assets/linear-BPPxWorj.js +1 -0
- data/lib/mxrb/web_ui/assets/map-BaFkSB1l.js +1 -0
- data/lib/mxrb/web_ui/assets/mermaid-parser.core-B_UPzTxa.js +7 -0
- data/lib/mxrb/web_ui/assets/mindmap-definition-FAOFIHXS-CdJRSMuk.js +96 -0
- data/lib/mxrb/web_ui/assets/modeler-BNqHthwk.css +1 -0
- data/lib/mxrb/web_ui/assets/modeler-CQmqflbg.js +1 -0
- data/lib/mxrb/web_ui/assets/ordinal-BDEzSJ7C.js +1 -0
- data/lib/mxrb/web_ui/assets/packet-7NZHBO7P-CwQ4NSBs.js +1 -0
- data/lib/mxrb/web_ui/assets/path-fybaL0A-.js +1 -0
- data/lib/mxrb/web_ui/assets/pegDiagram-VL7TDLO6-D40S7ppx.js +1 -0
- data/lib/mxrb/web_ui/assets/pie-RZYD4A2V-CJroHMKh.js +1 -0
- data/lib/mxrb/web_ui/assets/pieDiagram-7S7Q4E2Y-QB54__BV.js +39 -0
- data/lib/mxrb/web_ui/assets/quadrantDiagram-CIZ2JOQS-DBhDS6ag.js +7 -0
- data/lib/mxrb/web_ui/assets/radar-I7S5WNFK-DdIUwd2d.js +1 -0
- data/lib/mxrb/web_ui/assets/railroad-3IZDKUUU-B1Lw6Xyq.js +1 -0
- data/lib/mxrb/web_ui/assets/railroad-abnf-AHOZXSZD-CQgQoeak.js +1 -0
- data/lib/mxrb/web_ui/assets/railroad-ebnf-EBAXGLYW-D7qEQ2x8.js +1 -0
- data/lib/mxrb/web_ui/assets/railroad-peg-LSFZ7HO6-C005Vxbb.js +1 -0
- data/lib/mxrb/web_ui/assets/railroadDiagram-AXF67PYL-BBP2qk7M.js +1 -0
- data/lib/mxrb/web_ui/assets/requirementDiagram-LRYGKXZP-D2cLsAoI.js +84 -0
- data/lib/mxrb/web_ui/assets/rough.esm-Dy-Kn_BL.js +1 -0
- data/lib/mxrb/web_ui/assets/sankeyDiagram-W5VNT64P-Bpi3FiP2.js +40 -0
- data/lib/mxrb/web_ui/assets/sequenceDiagram-SI44F4Z6-DCRcDlVe.js +162 -0
- data/lib/mxrb/web_ui/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
- data/lib/mxrb/web_ui/assets/src-CvxfUak2.js +1 -0
- data/lib/mxrb/web_ui/assets/stateDiagram-OKZ733FA-C2GCEoxl.js +1 -0
- data/lib/mxrb/web_ui/assets/stateDiagram-v2-UEYNNEHI-D2gWgedh.js +1 -0
- data/lib/mxrb/web_ui/assets/swimlanes-SLNWSIFB-CRI9irEW.js +2 -0
- data/lib/mxrb/web_ui/assets/swimlanesDiagram-ULZ7WXOC-C5Wb_-hZ.js +8 -0
- data/lib/mxrb/web_ui/assets/theme-_mxc7BdY.css +1 -0
- data/lib/mxrb/web_ui/assets/timeline-definition-Z64GVDOM-B1-mNg-h.js +120 -0
- data/lib/mxrb/web_ui/assets/treeView-QDETBFTQ-7CTvIwKq.js +1 -0
- data/lib/mxrb/web_ui/assets/treemap-6X3UGDF4-CtL22pq5.js +1 -0
- data/lib/mxrb/web_ui/assets/uml-C3KAFGTq.js +51 -0
- data/lib/mxrb/web_ui/assets/uml-Dn-qd01X.css +1 -0
- data/lib/mxrb/web_ui/assets/vennDiagram-T6HMQDX7-BzVnUEAy.js +34 -0
- data/lib/mxrb/web_ui/assets/wardley-OPB4EBWU-JNi-QNag.js +1 -0
- data/lib/mxrb/web_ui/assets/wardleyDiagram-T6FBY63Y-BUwmAQ-B.js +78 -0
- data/lib/mxrb/web_ui/assets/xychartDiagram-ELKLHX3M-DEQS8sKp.js +7 -0
- data/lib/mxrb/web_ui/domain.html +15 -0
- data/lib/mxrb/web_ui/modeler.html +16 -0
- data/lib/mxrb/web_ui/uml.html +31 -0
- data/lib/mxrb/web_ui.rb +89 -0
- data/lib/mxrb/widget_certification.rb +208 -0
- data/lib/mxrb/writer.rb +200 -24
- data/lib/mxrb.rb +26 -1
- metadata +156 -5
|
@@ -0,0 +1,1361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'monitor'
|
|
7
|
+
require 'uri'
|
|
8
|
+
require_relative 'ruby_app/session_manager'
|
|
9
|
+
require_relative 'http/server'
|
|
10
|
+
|
|
11
|
+
module Mxrb
|
|
12
|
+
# Runtime and reversible metadata contract for projects exported with
|
|
13
|
+
# `mxrb export --mode ruby`.
|
|
14
|
+
module RubyApp
|
|
15
|
+
# rubocop:disable Metrics
|
|
16
|
+
MANIFEST_PATH = File.join('.mxrb', 'ruby-app.json')
|
|
17
|
+
SOURCE_GLOBS = [
|
|
18
|
+
'app/**/*.rb', 'config/**/*', 'frontend/**/*', 'spec/**/*.rb',
|
|
19
|
+
'db/migrate/**/*', 'bin/**/*', '.rspec', 'config.ru',
|
|
20
|
+
'Gemfile', 'Rakefile', 'README.md', '.gitignore', '.env.example'
|
|
21
|
+
].freeze
|
|
22
|
+
SOURCE_EXCLUSIONS = %w[frontend/node_modules/ frontend/dist/].freeze
|
|
23
|
+
ARTIFACT_DIRECTORIES = %w[models dtos services pages].freeze
|
|
24
|
+
|
|
25
|
+
def self.application_files(root)
|
|
26
|
+
ARTIFACT_DIRECTORIES.flat_map do |directory|
|
|
27
|
+
Dir.glob(File.join(root, 'app', directory, '**', '*.rb'))
|
|
28
|
+
end.sort
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.source_bundle(root)
|
|
32
|
+
expanded = File.expand_path(root)
|
|
33
|
+
candidates = SOURCE_GLOBS.flat_map { Dir.glob(File.join(expanded, _1), File::FNM_DOTMATCH) }
|
|
34
|
+
.select { File.file?(_1) }
|
|
35
|
+
files = candidates.filter_map do |path|
|
|
36
|
+
relative = Pathname.new(path).relative_path_from(Pathname.new(expanded)).cleanpath.to_s
|
|
37
|
+
next if SOURCE_EXCLUSIONS.any? { relative.start_with?(_1) }
|
|
38
|
+
|
|
39
|
+
contents = File.binread(path)
|
|
40
|
+
{
|
|
41
|
+
path: relative, contents:, sha256: Digest::SHA256.hexdigest(contents),
|
|
42
|
+
mode: File.stat(path).mode & 0o777
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
files.uniq { _1.fetch(:path) }.sort_by { _1.fetch(:path) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.safe_source_path(root, relative)
|
|
49
|
+
clean = Pathname.new(relative.to_s).cleanpath
|
|
50
|
+
raise ValidationError, "unsafe embedded Ruby source path: #{relative}" \
|
|
51
|
+
if clean.absolute? || clean.to_s == '..' || clean.to_s.start_with?('../')
|
|
52
|
+
|
|
53
|
+
File.join(File.expand_path(root), clean.to_s)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.safe_source_mode(value, relative)
|
|
57
|
+
fallback = relative.to_s.start_with?('bin/') ? 0o755 : 0o644
|
|
58
|
+
mode = Integer(value || fallback)
|
|
59
|
+
raise ValidationError, "unsafe embedded Ruby source mode: #{mode}" unless mode.between?(0, 0o777)
|
|
60
|
+
|
|
61
|
+
mode
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.compile(root, target = nil, mendix_version: nil)
|
|
65
|
+
manifest = Manifest.load(root)
|
|
66
|
+
destination = File.expand_path(
|
|
67
|
+
target || ENV.fetch('MXRB_OUTPUT_PATH', File.join(root, 'build', manifest.mpr_name))
|
|
68
|
+
)
|
|
69
|
+
previous = ENV['MXRB_OUTPUT_PATH']
|
|
70
|
+
ENV['MXRB_OUTPUT_PATH'] = destination
|
|
71
|
+
load manifest.absolute_path('mendix_project')
|
|
72
|
+
transition(destination, mendix_version) if mendix_version
|
|
73
|
+
Synchronizer.new(root, destination, manifest:).synchronize!
|
|
74
|
+
destination
|
|
75
|
+
ensure
|
|
76
|
+
if previous
|
|
77
|
+
ENV['MXRB_OUTPUT_PATH'] = previous
|
|
78
|
+
else
|
|
79
|
+
ENV.delete('MXRB_OUTPUT_PATH')
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.transition(path, version)
|
|
84
|
+
project = Model::Project.open(path, readonly: false)
|
|
85
|
+
project.migrate_to!(version) unless project.mendix_version == version.to_s
|
|
86
|
+
ensure
|
|
87
|
+
project&.close
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Reads and validates paths before runtime or reverse compilation uses them.
|
|
91
|
+
class Manifest
|
|
92
|
+
attr_reader :root, :data
|
|
93
|
+
|
|
94
|
+
def self.load(root)
|
|
95
|
+
expanded = File.expand_path(root)
|
|
96
|
+
path = File.join(expanded, MANIFEST_PATH)
|
|
97
|
+
raise ArgumentError, "not an MXRB Ruby application: #{expanded}" unless File.file?(path)
|
|
98
|
+
|
|
99
|
+
new(expanded, JSON.parse(File.read(path)))
|
|
100
|
+
rescue JSON::ParserError => e
|
|
101
|
+
raise ArgumentError, "invalid MXRB Ruby application manifest: #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def initialize(root, data)
|
|
105
|
+
@root = root
|
|
106
|
+
@data = data
|
|
107
|
+
raise ArgumentError, 'MXRB Ruby application manifest must use mode ruby' unless data['mode'] == 'ruby'
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def mpr_name = data.fetch('source').fetch('name')
|
|
111
|
+
def modules = data.fetch('modules')
|
|
112
|
+
def coverage = data.fetch('coverage')
|
|
113
|
+
|
|
114
|
+
def absolute_path(key)
|
|
115
|
+
relative = data.fetch('round_trip').fetch(key)
|
|
116
|
+
clean = Pathname.new(relative).cleanpath
|
|
117
|
+
if clean.absolute? || clean.to_s.start_with?('../')
|
|
118
|
+
raise ArgumentError,
|
|
119
|
+
"unsafe Ruby application path: #{relative}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
File.join(root, clean.to_s)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Per-process registry populated by conventional app/**/*.rb files.
|
|
127
|
+
module Registry
|
|
128
|
+
ADAPTER_KINDS = %i[
|
|
129
|
+
app_service web_service import_xml import_mapping export_mapping document
|
|
130
|
+
].freeze
|
|
131
|
+
|
|
132
|
+
module_function
|
|
133
|
+
|
|
134
|
+
def reset!
|
|
135
|
+
@records = {}
|
|
136
|
+
@services = {}
|
|
137
|
+
@pages = {}
|
|
138
|
+
@adapters = {}
|
|
139
|
+
@java_custom_actions = {}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def register(kind, name, implementation)
|
|
143
|
+
collection(kind)[name] = implementation
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def fetch(kind, name) = collection(kind)[name]
|
|
147
|
+
def all(kind) = collection(kind).dup.freeze
|
|
148
|
+
|
|
149
|
+
def register_adapter(kind, implementation = nil, &block)
|
|
150
|
+
key = kind.to_sym
|
|
151
|
+
raise ArgumentError, "unsupported Ruby adapter #{kind}" unless ADAPTER_KINDS.include?(key)
|
|
152
|
+
|
|
153
|
+
callback = implementation || block
|
|
154
|
+
raise ArgumentError, 'adapter must respond to call' unless callback.respond_to?(:call)
|
|
155
|
+
|
|
156
|
+
register(:adapter, key, callback)
|
|
157
|
+
callback
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def adapters = all(:adapter)
|
|
161
|
+
|
|
162
|
+
def register_java_custom_action(name, implementation = nil, &block)
|
|
163
|
+
qualified_name = name.to_s
|
|
164
|
+
unless qualified_name.match?(/\A[A-Za-z_]\w*\.[A-Za-z_]\w*\z/)
|
|
165
|
+
raise ArgumentError, "Java Custom Action name must be qualified as Module.Action: #{name}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
callback = implementation || block
|
|
169
|
+
raise ArgumentError, 'Java Custom Action adapter must respond to call' unless callback.respond_to?(:call)
|
|
170
|
+
|
|
171
|
+
register(:java_custom_action, qualified_name, callback)
|
|
172
|
+
callback
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def java_custom_actions = all(:java_custom_action)
|
|
176
|
+
|
|
177
|
+
def collection(kind)
|
|
178
|
+
reset! unless defined?(@records) && @records
|
|
179
|
+
{
|
|
180
|
+
record: @records, service: @services, page: @pages, adapter: @adapters,
|
|
181
|
+
java_custom_action: @java_custom_actions
|
|
182
|
+
}.fetch(kind)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Base for generated persistent models.
|
|
187
|
+
class Record
|
|
188
|
+
LIFECYCLE_EVENTS = Runtime::Native::Store::LIFECYCLE_EVENTS
|
|
189
|
+
|
|
190
|
+
class << self
|
|
191
|
+
attr_reader :mendix_id, :attributes, :persistable, :lifecycle_callbacks
|
|
192
|
+
|
|
193
|
+
def inherited(child)
|
|
194
|
+
super
|
|
195
|
+
child.instance_variable_set(:@attributes, [])
|
|
196
|
+
child.instance_variable_set(:@lifecycle_callbacks, {})
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def mendix_name(value = nil, id: nil)
|
|
200
|
+
return @mendix_name unless value
|
|
201
|
+
|
|
202
|
+
@mendix_name = value.to_s
|
|
203
|
+
@mendix_id = id.to_s
|
|
204
|
+
Registry.register(:record, @mendix_name, self)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def persistence(value) = (@persistable = value == true)
|
|
208
|
+
|
|
209
|
+
def attribute(name, type:, mendix_name:, required: false, default: nil)
|
|
210
|
+
@attributes ||= []
|
|
211
|
+
@attributes << {
|
|
212
|
+
name: name.to_sym, type: type.to_sym,
|
|
213
|
+
mendix_name: mendix_name.to_s, required: required == true,
|
|
214
|
+
default: default
|
|
215
|
+
}
|
|
216
|
+
attr_accessor name
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def lifecycle(event, method_name = nil, &block)
|
|
220
|
+
event = event.to_sym
|
|
221
|
+
raise ArgumentError, "unknown lifecycle event #{event}" unless LIFECYCLE_EVENTS.include?(event)
|
|
222
|
+
raise ArgumentError, 'callback method or block is required' unless method_name || block
|
|
223
|
+
|
|
224
|
+
@lifecycle_callbacks ||= {}
|
|
225
|
+
(@lifecycle_callbacks[event] ||= []) << (method_name || block)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
LIFECYCLE_EVENTS.each do |event|
|
|
229
|
+
define_method(event) { |method_name = nil, &block| lifecycle(event, method_name, &block) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def from_native(value)
|
|
233
|
+
values = attributes.to_a.to_h do |attribute|
|
|
234
|
+
[attribute.fetch(:name), value.members[attribute.fetch(:mendix_name)]]
|
|
235
|
+
end
|
|
236
|
+
new(id: value.id, **values).tap { _1.instance_variable_set(:@native_value, value) }
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
attr_reader :id
|
|
241
|
+
|
|
242
|
+
def initialize(id: nil, **values)
|
|
243
|
+
@id = id
|
|
244
|
+
values.each { public_send("#{_1}=", _2) if respond_to?("#{_1}=") }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def to_h
|
|
248
|
+
values = self.class.attributes.to_a.to_h { [_1.fetch(:name), public_send(_1.fetch(:name))] }
|
|
249
|
+
{ id:, type: self.class.mendix_name, attributes: values }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def sync_to_native!
|
|
253
|
+
return self unless @native_value
|
|
254
|
+
|
|
255
|
+
self.class.attributes.to_a.each do |attribute|
|
|
256
|
+
@native_value.members[attribute.fetch(:mendix_name)] = public_send(attribute.fetch(:name))
|
|
257
|
+
end
|
|
258
|
+
self
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def run_lifecycle_callback(callback)
|
|
262
|
+
callback.is_a?(Proc) ? callback.call(self) : public_send(callback)
|
|
263
|
+
ensure
|
|
264
|
+
sync_to_native!
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Explicit non-persistent model. Its generated filename and class always
|
|
269
|
+
# end in `_dto` / `Dto`, avoiding ambiguous `_2` fallbacks.
|
|
270
|
+
class DTO < Record; end
|
|
271
|
+
|
|
272
|
+
# Base for generated services. The default body executes through MXRB's
|
|
273
|
+
# pure-Ruby native interpreter and can be replaced with idiomatic Ruby.
|
|
274
|
+
class Service
|
|
275
|
+
class << self
|
|
276
|
+
attr_reader :mendix_id
|
|
277
|
+
|
|
278
|
+
def mendix_name(value = nil, id: nil)
|
|
279
|
+
return @mendix_name unless value
|
|
280
|
+
|
|
281
|
+
@mendix_name = value.to_s
|
|
282
|
+
@mendix_id = id.to_s
|
|
283
|
+
Registry.register(:service, @mendix_name, self)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def initialize(application, context: nil)
|
|
288
|
+
@application = application
|
|
289
|
+
@context = context
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def call(**arguments) = native_call(arguments)
|
|
293
|
+
|
|
294
|
+
private
|
|
295
|
+
|
|
296
|
+
def native_call(arguments)
|
|
297
|
+
@application.native_call(self.class.mendix_name, arguments, context: @context)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Page metadata remains ordinary Ruby while the browser receives its JSON
|
|
302
|
+
# projection from the integrated backend.
|
|
303
|
+
class Page
|
|
304
|
+
class << self
|
|
305
|
+
attr_reader :mendix_id, :title, :widgets, :appearance_class,
|
|
306
|
+
:appearance_style, :data_source
|
|
307
|
+
|
|
308
|
+
def mendix_name(value = nil, id: nil)
|
|
309
|
+
return @mendix_name unless value
|
|
310
|
+
|
|
311
|
+
@mendix_name = value.to_s
|
|
312
|
+
@mendix_id = id.to_s
|
|
313
|
+
Registry.register(:page, @mendix_name, self)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def configure(title:, widgets: [], appearance_class: '', appearance_style: '', data_source: nil)
|
|
317
|
+
@title = title.to_s
|
|
318
|
+
@widgets = widgets.freeze
|
|
319
|
+
@appearance_class = appearance_class.to_s
|
|
320
|
+
@appearance_style = appearance_style.to_s
|
|
321
|
+
@data_source = data_source
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Loads generated Ruby classes and provides one shared in-memory backend.
|
|
327
|
+
class Application
|
|
328
|
+
attr_reader :root, :manifest, :environment
|
|
329
|
+
|
|
330
|
+
def initialize(root, environment: nil, process: ENV)
|
|
331
|
+
@root = File.expand_path(root)
|
|
332
|
+
@runtime_monitor = Monitor.new
|
|
333
|
+
@manifest = Manifest.load(@root)
|
|
334
|
+
@environment = if environment.is_a?(Environment)
|
|
335
|
+
environment
|
|
336
|
+
else
|
|
337
|
+
Environment.load(
|
|
338
|
+
environment, root: @root, process:
|
|
339
|
+
)
|
|
340
|
+
end
|
|
341
|
+
Registry.reset!
|
|
342
|
+
load_adapters
|
|
343
|
+
load_application_files
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def schema(context: nil)
|
|
347
|
+
result = {
|
|
348
|
+
mode: 'ruby', environment: environment.name, project: manifest.data.fetch('project'),
|
|
349
|
+
navigation: manifest.data.fetch('navigation', {}),
|
|
350
|
+
modules: manifest.modules, coverage: manifest.coverage
|
|
351
|
+
}
|
|
352
|
+
context ? secure_schema(result, context) : result
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def call_service(name, arguments = {}, synchronized_context: nil, context: nil)
|
|
356
|
+
runtime_synchronize do
|
|
357
|
+
implementation = Registry.fetch(:service, name.to_s)
|
|
358
|
+
raise NativeRuntimeError, "microflow #{name} not found" unless implementation || microflow_exists?(name)
|
|
359
|
+
|
|
360
|
+
authorize_document!(:microflow, name, :execute, context)
|
|
361
|
+
normalized = arguments.to_h.transform_values { deserialize(_1, context:) }
|
|
362
|
+
synchronize_context(synchronized_context, context:) if synchronized_context
|
|
363
|
+
next native_call(name, normalized, context:) unless implementation
|
|
364
|
+
|
|
365
|
+
keyword_arguments = normalized.transform_keys(&:to_sym)
|
|
366
|
+
implementation.new(self, context:).call(**keyword_arguments)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def invoke_service(name, arguments = nil, context: nil, **keyword_arguments)
|
|
371
|
+
runtime_synchronize do
|
|
372
|
+
arguments = keyword_arguments if arguments.nil?
|
|
373
|
+
bridge.interpreter.clear_effects!
|
|
374
|
+
invocation_arguments = arguments.to_h.dup
|
|
375
|
+
synchronized_context = invocation_arguments.delete('__mxrb_context')
|
|
376
|
+
active_context = synchronize_context(synchronized_context, context:) if synchronized_context
|
|
377
|
+
result = call_service(name, invocation_arguments, context:)
|
|
378
|
+
{
|
|
379
|
+
result: serialize(result, context:), effects: serialize(bridge.interpreter.effects, context:),
|
|
380
|
+
context: serialize(active_context, context:)
|
|
381
|
+
}
|
|
382
|
+
ensure
|
|
383
|
+
release_runtime_cache
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def rest_routes
|
|
388
|
+
@rest_routes ||= manifest.modules.flat_map do |mod|
|
|
389
|
+
mod.fetch('endpoints', []).flat_map do |endpoint|
|
|
390
|
+
endpoint.fetch('operations', []).map do |operation|
|
|
391
|
+
operation.merge(
|
|
392
|
+
'service' => endpoint.fetch('name'),
|
|
393
|
+
'enable_cors' => endpoint.fetch('enable_cors', false),
|
|
394
|
+
'requires_authentication' => endpoint.fetch('requires_authentication', false)
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def invoke_rest(route, path_parameters: {}, query: {}, body: nil, context: nil)
|
|
402
|
+
service = service_manifest(route.fetch('microflow'))
|
|
403
|
+
arguments = request_arguments(service, path_parameters, query, body, context:)
|
|
404
|
+
invoke_service(route.fetch('microflow'), arguments, context:).fetch(:result)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def native_call(name, arguments = {}, context: nil)
|
|
408
|
+
runtime_synchronize do
|
|
409
|
+
authorize_document!(:microflow, name, :execute, context)
|
|
410
|
+
serialize(
|
|
411
|
+
bridge.interpreter.call(name.to_s, arguments.to_h.transform_keys(&:to_s), context:), context:
|
|
412
|
+
)
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def records(name, context: nil, association: nil, context_type: nil, context_id: nil)
|
|
417
|
+
runtime_synchronize do
|
|
418
|
+
filter = [association, context_type, context_id]
|
|
419
|
+
if filter.any? && !filter.all? { !_1.to_s.empty? }
|
|
420
|
+
raise ArgumentError,
|
|
421
|
+
'association filter requires association, context_type, and context_id'
|
|
422
|
+
end
|
|
423
|
+
store = bridge.interpreter.store
|
|
424
|
+
values = if filter.all? { !_1.to_s.empty? }
|
|
425
|
+
parent = store.find(context_type.to_s, context_id.to_s)
|
|
426
|
+
parent ? store.retrieve_association(association.to_s, parent) : []
|
|
427
|
+
else
|
|
428
|
+
store.retrieve(name.to_s)
|
|
429
|
+
end
|
|
430
|
+
values = values.select { _1.entity == name.to_s }
|
|
431
|
+
values = access_control.filter_readable(name.to_s, values, context:) if context
|
|
432
|
+
values.map { serialize(_1, context:) }
|
|
433
|
+
ensure
|
|
434
|
+
release_runtime_cache
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def record(name, id, context: nil)
|
|
439
|
+
runtime_synchronize do
|
|
440
|
+
value = bridge.interpreter.store.find(name.to_s, id.to_s)
|
|
441
|
+
next unless value
|
|
442
|
+
next unless !context || access_control.entity_allowed?(name, action: :read, context:, record: value)
|
|
443
|
+
|
|
444
|
+
serialize(value, context:)
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def create_record(name, attributes = nil, context: nil, **keyword_attributes)
|
|
449
|
+
runtime_synchronize do
|
|
450
|
+
attributes = keyword_attributes if attributes.nil?
|
|
451
|
+
authorize_entity!(name, :create, context)
|
|
452
|
+
bridge.interpreter.store.transaction do
|
|
453
|
+
value = bridge.interpreter.store.create(name.to_s)
|
|
454
|
+
attributes.to_h.each do |member, member_value|
|
|
455
|
+
authorize_entity!(name, :write, context, member: member, record: value)
|
|
456
|
+
value.members[member.to_s] = member_value
|
|
457
|
+
end
|
|
458
|
+
bridge.interpreter.store.commit(value)
|
|
459
|
+
serialize(value, context:)
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def delete_record(name, id, context: nil)
|
|
465
|
+
runtime_synchronize do
|
|
466
|
+
value = bridge.interpreter.store.find(name.to_s, id.to_s)
|
|
467
|
+
next false unless value
|
|
468
|
+
|
|
469
|
+
authorize_entity!(name, :delete, context, record: value)
|
|
470
|
+
bridge.interpreter.store.delete(value)
|
|
471
|
+
true
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def update_record(name, id, attributes, context: nil)
|
|
476
|
+
runtime_synchronize do
|
|
477
|
+
value = bridge.interpreter.store.find(name.to_s, id.to_s)
|
|
478
|
+
next unless value
|
|
479
|
+
|
|
480
|
+
authorize_entity!(name, :write, context, record: value)
|
|
481
|
+
bridge.interpreter.store.transaction do
|
|
482
|
+
attributes.to_h.each do |member, member_value|
|
|
483
|
+
authorize_entity!(name, :write, context, member: member, record: value)
|
|
484
|
+
value.members[member.to_s] = deserialize(member_value)
|
|
485
|
+
end
|
|
486
|
+
bridge.interpreter.store.commit(value)
|
|
487
|
+
serialize(value, context:)
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def page(name, context: nil)
|
|
493
|
+
implementation = Registry.fetch(:page, name.to_s)
|
|
494
|
+
return unless implementation
|
|
495
|
+
|
|
496
|
+
authorize_document!(:page, name, :read, context)
|
|
497
|
+
|
|
498
|
+
{
|
|
499
|
+
name: implementation.mendix_name, id: implementation.mendix_id,
|
|
500
|
+
title: implementation.title, widgets: implementation.widgets,
|
|
501
|
+
appearance_class: implementation.appearance_class,
|
|
502
|
+
appearance_style: implementation.appearance_style,
|
|
503
|
+
data_source: implementation.data_source
|
|
504
|
+
}
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def access_control = (@access_control ||= bridge.access_control)
|
|
508
|
+
|
|
509
|
+
def session_manager
|
|
510
|
+
@session_manager ||= SessionManager.new(
|
|
511
|
+
access_control, users: environment['MXRB_USERS_JSON'],
|
|
512
|
+
tokens: environment['MXRB_AUTH_TOKENS'],
|
|
513
|
+
ttl: environment.fetch('MXRB_SESSION_TTL', '3600'),
|
|
514
|
+
store: shared_store
|
|
515
|
+
)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def start_scheduler = bridge.start_scheduler
|
|
519
|
+
|
|
520
|
+
def close
|
|
521
|
+
@bridge&.close
|
|
522
|
+
@shared_store&.close
|
|
523
|
+
@bridge = nil
|
|
524
|
+
@access_control = nil
|
|
525
|
+
@session_manager = nil
|
|
526
|
+
@shared_store = nil
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
private
|
|
530
|
+
|
|
531
|
+
def runtime_synchronize(&block)
|
|
532
|
+
(@runtime_monitor ||= Monitor.new).synchronize(&block)
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def microflow_exists?(name)
|
|
536
|
+
bridge.project.modules.any? do |mod|
|
|
537
|
+
mod.microflows.any? { "#{mod.name}.#{_1.name}" == name.to_s }
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def authorize_document!(kind, name, action, context)
|
|
542
|
+
return unless context
|
|
543
|
+
|
|
544
|
+
access_control.authorize!(name.to_s, kind:, action:, context:)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def authorize_entity!(name, action, context, member: nil, record: nil)
|
|
548
|
+
return unless context
|
|
549
|
+
|
|
550
|
+
access_control.authorize!(name.to_s, kind: :entity, action:, context:, member:, record:)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def secure_schema(schema, context)
|
|
554
|
+
secured = Marshal.load(Marshal.dump(schema))
|
|
555
|
+
allowed_pages = []
|
|
556
|
+
allowed_services = []
|
|
557
|
+
secured[:modules].each do |mod|
|
|
558
|
+
mod['services'] = mod.fetch('services', []).select do |service|
|
|
559
|
+
access_control.microflow_allowed?(service.fetch('name'), context:)
|
|
560
|
+
end
|
|
561
|
+
allowed_services.concat(mod['services'].flat_map { [_1['name'], _1['id']] }.compact)
|
|
562
|
+
mod['pages'] = mod.fetch('pages', []).select do |page|
|
|
563
|
+
access_control.page_allowed?(page.fetch('name'), context:)
|
|
564
|
+
end
|
|
565
|
+
allowed_pages.concat(mod['pages'].flat_map { [_1['name'], _1['id']] }.compact)
|
|
566
|
+
%w[models dtos].each do |key|
|
|
567
|
+
mod[key] = mod.fetch(key, []).select do |entity|
|
|
568
|
+
access_control.entity_allowed?(entity.fetch('name'), action: :read, context:)
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
secured[:navigation] = secure_navigation(secured[:navigation], allowed_pages, allowed_services)
|
|
573
|
+
secured
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def secure_navigation(navigation, pages, services)
|
|
577
|
+
result = navigation.to_h.transform_keys(&:to_sym)
|
|
578
|
+
result[:profiles] = Array(result[:profiles]).map do |profile|
|
|
579
|
+
profile = profile.to_h.transform_keys(&:to_sym)
|
|
580
|
+
profile[:home_page] = nil unless pages.include?(profile[:home_page])
|
|
581
|
+
profile[:sign_in_page] = nil unless pages.include?(profile[:sign_in_page])
|
|
582
|
+
profile[:home_microflow] = nil unless services.include?(profile[:home_microflow])
|
|
583
|
+
profile[:role_homes] = Array(profile[:role_homes]).select do |home|
|
|
584
|
+
page = home[:page] || home['page']
|
|
585
|
+
flow = home[:microflow] || home['microflow']
|
|
586
|
+
(!page || pages.include?(page)) && (!flow || services.include?(flow))
|
|
587
|
+
end
|
|
588
|
+
profile[:items] = secure_navigation_items(profile[:items], pages, services)
|
|
589
|
+
profile
|
|
590
|
+
end
|
|
591
|
+
result
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def secure_navigation_items(items, pages, services)
|
|
595
|
+
Array(items).filter_map do |item|
|
|
596
|
+
item = item.to_h.transform_keys(&:to_sym)
|
|
597
|
+
children = secure_navigation_items(item[:items], pages, services)
|
|
598
|
+
allowed = (!item[:page] || pages.include?(item[:page])) &&
|
|
599
|
+
(!item[:microflow] || services.include?(item[:microflow]))
|
|
600
|
+
next unless allowed || children.any?
|
|
601
|
+
|
|
602
|
+
item.merge(items: children)
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def service_manifest(name)
|
|
607
|
+
manifest.modules.flat_map { _1.fetch('services', []) }.find { _1.fetch('name') == name } ||
|
|
608
|
+
raise(NativeRuntimeError, "REST microflow #{name} not found")
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def request_arguments(service, path_parameters, query, body, context: nil)
|
|
612
|
+
supplied = path_parameters.to_h.merge(query.to_h).transform_keys(&:to_s)
|
|
613
|
+
supplied.merge!(body.transform_keys(&:to_s)) if body.is_a?(Hash)
|
|
614
|
+
parameters = service.fetch('parameters', [])
|
|
615
|
+
unresolved = parameters.reject { |parameter| request_value(supplied, parameter.fetch('name')).first }
|
|
616
|
+
if body && unresolved.one? && !body_matches_parameters?(body, parameters)
|
|
617
|
+
supplied[unresolved.first.fetch('name')] = body
|
|
618
|
+
end
|
|
619
|
+
parameters.to_h do |parameter|
|
|
620
|
+
found, value = request_value(supplied, parameter.fetch('name'))
|
|
621
|
+
if !found && parameter['required']
|
|
622
|
+
raise NativeRuntimeError, "missing REST argument #{parameter.fetch('name')}"
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
[parameter.fetch('name'), deserialize_rest_value(value, parameter, context:)]
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def request_value(values, name)
|
|
630
|
+
key = values.keys.find { _1.casecmp?(name.to_s) }
|
|
631
|
+
[!key.nil?, key && values[key]]
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def body_matches_parameters?(body, parameters)
|
|
635
|
+
body.is_a?(Hash) && body.keys.any? do |key|
|
|
636
|
+
parameters.any? { _1.fetch('name').casecmp?(key.to_s) }
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def deserialize_rest_value(value, parameter, context: nil)
|
|
641
|
+
entity = parameter['entity']
|
|
642
|
+
return deserialize(value) unless entity && value.is_a?(Hash) && !(value['id'] && value['type'])
|
|
643
|
+
|
|
644
|
+
authorize_entity!(entity, :create, context)
|
|
645
|
+
object = Runtime::Native::ObjectValue.new(entity:, id: SecureRandom.uuid, members: {})
|
|
646
|
+
value.each do |member, member_value|
|
|
647
|
+
authorize_entity!(entity, :write, context, member:, record: object)
|
|
648
|
+
object.members[member.to_s] = deserialize(member_value, context:)
|
|
649
|
+
end
|
|
650
|
+
object
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def load_application_files
|
|
654
|
+
RubyApp.application_files(root).each { load _1, true }
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def load_adapters
|
|
658
|
+
path = File.join(root, 'config', 'adapters.rb')
|
|
659
|
+
load path, true if File.file?(path)
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def bridge
|
|
663
|
+
@bridge ||= NativeBridge.new(
|
|
664
|
+
manifest.absolute_path('runtime_mpr'),
|
|
665
|
+
database: runtime_database_path,
|
|
666
|
+
record_hooks: Registry.all(:record), adapters: Registry.adapters,
|
|
667
|
+
java_custom_actions: Registry.java_custom_actions,
|
|
668
|
+
allow_destructive: environment['MXRB_ALLOW_DESTRUCTIVE_MIGRATIONS'].to_s.casecmp?('true'),
|
|
669
|
+
coordinator: shared_store,
|
|
670
|
+
scheduler_lease_ttl: environment.fetch('MXRB_SCHEDULER_LEASE_TTL', '300')
|
|
671
|
+
)
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def shared_store
|
|
675
|
+
@shared_store ||= begin
|
|
676
|
+
configured = environment['MXRB_SHARED_STORE_PATH'].to_s.strip
|
|
677
|
+
if %w[:memory: memory local].include?(configured.downcase)
|
|
678
|
+
Runtime::MemorySharedStore.new
|
|
679
|
+
else
|
|
680
|
+
path = configured.empty? ? default_shared_store_path : File.expand_path(configured, root)
|
|
681
|
+
Runtime::SQLiteSharedStore.new(path)
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def default_shared_store_path
|
|
687
|
+
File.join(root, '.mxrb', 'runtime', "#{environment.name}-shared.sqlite3")
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def runtime_database_path
|
|
691
|
+
configured = environment['MXRB_DATABASE_PATH'].to_s
|
|
692
|
+
return File.join(root, '.mxrb', 'runtime', "#{environment.name}.sqlite3") if configured.empty?
|
|
693
|
+
|
|
694
|
+
File.expand_path(configured, root)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def serialize(value, seen = {}, context: nil)
|
|
698
|
+
case value
|
|
699
|
+
when Runtime::Native::ObjectValue
|
|
700
|
+
key = [value.entity, value.id]
|
|
701
|
+
return { id: value.id, type: value.entity } if seen[key]
|
|
702
|
+
|
|
703
|
+
branch = seen.merge(key => true)
|
|
704
|
+
members = value.members
|
|
705
|
+
if context
|
|
706
|
+
members = members.select do |member, _member_value|
|
|
707
|
+
access_control.member_allowed?(
|
|
708
|
+
value.entity, member, action: :read, context:, record: value
|
|
709
|
+
)
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
{ id: value.id, type: value.entity, attributes: serialize(members, branch, context:) }
|
|
713
|
+
when Hash then value.to_h { [serialize(_1, seen, context:), serialize(_2, seen, context:)] }
|
|
714
|
+
when Array then value.map { serialize(_1, seen, context:) }
|
|
715
|
+
else value
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def deserialize(value = nil, context: nil, synchronize: false, **keyword_value)
|
|
720
|
+
value = keyword_value if value.nil? && !keyword_value.empty?
|
|
721
|
+
if value.is_a?(Hash) && value['id'] && value['type']
|
|
722
|
+
object = bridge.interpreter.store.find(value['type'].to_s, value['id'].to_s)
|
|
723
|
+
raise NativeRuntimeError, "object #{value['type']} #{value['id']} not found" unless object
|
|
724
|
+
|
|
725
|
+
value.fetch('attributes', {}).each do |member, member_value|
|
|
726
|
+
next unless synchronize
|
|
727
|
+
|
|
728
|
+
authorize_entity!(object.entity, :write, context, member:, record: object)
|
|
729
|
+
object.members[member.to_s] = deserialize(member_value, context:, synchronize:)
|
|
730
|
+
end
|
|
731
|
+
object
|
|
732
|
+
elsif value.is_a?(Hash)
|
|
733
|
+
value.to_h do
|
|
734
|
+
[deserialize(_1, context:, synchronize:), deserialize(_2, context:, synchronize:)]
|
|
735
|
+
end
|
|
736
|
+
elsif value.is_a?(Array)
|
|
737
|
+
value.map { deserialize(_1, context:, synchronize:) }
|
|
738
|
+
else
|
|
739
|
+
value
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def synchronize_context(value, context:)
|
|
744
|
+
store = bridge.interpreter.store
|
|
745
|
+
object = deserialize(value, context:)
|
|
746
|
+
return object unless object.is_a?(Runtime::Native::ObjectValue) && value.is_a?(Hash)
|
|
747
|
+
|
|
748
|
+
changes = value.fetch('attributes', {}).filter_map do |member, member_value|
|
|
749
|
+
resolved = deserialize(member_value, context:)
|
|
750
|
+
[member.to_s, resolved] unless context_values_equal?(object.members[member.to_s], resolved)
|
|
751
|
+
end
|
|
752
|
+
return object if changes.empty?
|
|
753
|
+
|
|
754
|
+
apply = lambda do
|
|
755
|
+
changes.each do |member, member_value|
|
|
756
|
+
authorize_entity!(object.entity, :write, context, member:, record: object)
|
|
757
|
+
object.members[member] = member_value
|
|
758
|
+
end
|
|
759
|
+
store.commit(object) if store.respond_to?(:commit)
|
|
760
|
+
object
|
|
761
|
+
end
|
|
762
|
+
return apply.call unless store.respond_to?(:transaction)
|
|
763
|
+
|
|
764
|
+
store.transaction { apply.call }
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def context_values_equal?(current, incoming)
|
|
768
|
+
if current.is_a?(Runtime::Native::ObjectValue) || incoming.is_a?(Runtime::Native::ObjectValue)
|
|
769
|
+
return current.is_a?(Runtime::Native::ObjectValue) &&
|
|
770
|
+
incoming.is_a?(Runtime::Native::ObjectValue) &&
|
|
771
|
+
current.entity == incoming.entity && current.id == incoming.id
|
|
772
|
+
end
|
|
773
|
+
if current.is_a?(Array) || incoming.is_a?(Array)
|
|
774
|
+
return false unless current.is_a?(Array) && incoming.is_a?(Array) && current.size == incoming.size
|
|
775
|
+
|
|
776
|
+
return current.zip(incoming).all? { context_values_equal?(_1, _2) }
|
|
777
|
+
end
|
|
778
|
+
if current.respond_to?(:iso8601) && incoming.is_a?(String)
|
|
779
|
+
return Time.parse(incoming).to_i == Time.parse(current.iso8601).to_i
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
current == incoming
|
|
783
|
+
rescue ArgumentError
|
|
784
|
+
false
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def release_runtime_cache
|
|
788
|
+
store = bridge.interpreter.store
|
|
789
|
+
store.release_cache! if store.respond_to?(:release_cache!)
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# Owns the source MPR and its pure-Ruby interpreter for one application.
|
|
794
|
+
class NativeBridge
|
|
795
|
+
attr_reader :access_control, :interpreter, :project, :scheduler, :store
|
|
796
|
+
|
|
797
|
+
def initialize(path, database:, record_hooks: {}, adapters: {}, java_custom_actions: {},
|
|
798
|
+
allow_destructive: false, coordinator: nil, scheduler_lease_ttl: 300)
|
|
799
|
+
FileUtils.mkdir_p(File.dirname(database))
|
|
800
|
+
@project = Model::Project.open(path)
|
|
801
|
+
@store = Runtime::SQLiteStore.new(@project, path: database, allow_destructive:)
|
|
802
|
+
@access_control = Runtime::AccessControl.new(@project)
|
|
803
|
+
@interpreter = Runtime::Native::Interpreter.new(
|
|
804
|
+
@project, store: @store, policy: @access_control, adapters:, java_custom_actions:
|
|
805
|
+
)
|
|
806
|
+
register_record_hooks(record_hooks)
|
|
807
|
+
@scheduler = Runtime::Scheduler.new(
|
|
808
|
+
@project,
|
|
809
|
+
executor: ->(name, **_metadata) { @interpreter.call(name) }, coordinator:,
|
|
810
|
+
lease_ttl: scheduler_lease_ttl
|
|
811
|
+
)
|
|
812
|
+
rescue StandardError
|
|
813
|
+
close
|
|
814
|
+
raise
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def start_scheduler
|
|
818
|
+
scheduler.start unless scheduler.jobs.empty?
|
|
819
|
+
scheduler
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def close
|
|
823
|
+
scheduler&.shutdown
|
|
824
|
+
store&.close
|
|
825
|
+
project&.close
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
private
|
|
829
|
+
|
|
830
|
+
def register_record_hooks(records)
|
|
831
|
+
records.each do |entity, implementation|
|
|
832
|
+
implementation.lifecycle_callbacks.to_h.each do |event, callbacks|
|
|
833
|
+
callbacks.each do |callback|
|
|
834
|
+
store.on(entity, event) do |value|
|
|
835
|
+
implementation.from_native(value).run_lifecycle_callback(callback)
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
end
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# Applies the reversible Ruby domain contract to a generated MPR. Anything
|
|
844
|
+
# outside this contract remains in the native sidecar and is never dropped.
|
|
845
|
+
class Synchronizer
|
|
846
|
+
def initialize(root, target, manifest: Manifest.load(root))
|
|
847
|
+
@root = File.expand_path(root)
|
|
848
|
+
@target = File.expand_path(target)
|
|
849
|
+
@manifest = manifest
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
def synchronize!
|
|
853
|
+
Registry.reset!
|
|
854
|
+
RubyApp.application_files(@root).each { load _1, true }
|
|
855
|
+
project = Model::Project.open(@target, readonly: false)
|
|
856
|
+
synchronize_entities(project)
|
|
857
|
+
project.close
|
|
858
|
+
project = nil
|
|
859
|
+
embed_sources!
|
|
860
|
+
@target
|
|
861
|
+
ensure
|
|
862
|
+
project&.close
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
private
|
|
866
|
+
|
|
867
|
+
def embed_sources!
|
|
868
|
+
files = RubyApp.source_bundle(@root)
|
|
869
|
+
mpr = IO::MprFile.open(@target, readonly: false)
|
|
870
|
+
mpr.transaction { mpr.write_ruby_app_sources(files) }
|
|
871
|
+
ensure
|
|
872
|
+
mpr&.close
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
def synchronize_entities(project)
|
|
876
|
+
source_entities = @manifest.modules.flat_map do |mod|
|
|
877
|
+
mod.fetch('models') + mod.fetch('dtos')
|
|
878
|
+
end
|
|
879
|
+
source_names = source_entities.map { _1.fetch('name') }
|
|
880
|
+
registered = Registry.all(:record)
|
|
881
|
+
|
|
882
|
+
(source_names - registered.keys).each do |name|
|
|
883
|
+
plan = project.plan_remove_entity(name)
|
|
884
|
+
raise ValidationError, plan.changes.join('; ') unless plan.safe?
|
|
885
|
+
|
|
886
|
+
plan.apply!
|
|
887
|
+
end
|
|
888
|
+
(registered.keys - source_names).each { add_entity(project, _1, registered.fetch(_1)) }
|
|
889
|
+
(source_names & registered.keys).each { synchronize_entity(project, _1, registered.fetch(_1)) }
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
def add_entity(project, name, implementation)
|
|
893
|
+
module_name, entity_name = qualified_parts(name)
|
|
894
|
+
attributes = implementation.attributes.to_a.map do |attribute|
|
|
895
|
+
reject_unsupported_required!(name, attribute)
|
|
896
|
+
{
|
|
897
|
+
name: attribute.fetch(:mendix_name), type: attribute.fetch(:type),
|
|
898
|
+
default: attribute[:default]
|
|
899
|
+
}
|
|
900
|
+
end
|
|
901
|
+
project.plan_add_entity(
|
|
902
|
+
module_name, name: entity_name, attributes:,
|
|
903
|
+
non_persistent: implementation.persistable == false
|
|
904
|
+
).apply!
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def synchronize_entity(project, name, implementation)
|
|
908
|
+
artifact = project.find_artifact(name, kind: :entity)
|
|
909
|
+
raise ValidationError, "entity #{name} is missing from generated MPR" unless artifact
|
|
910
|
+
|
|
911
|
+
model = artifact.metadata.fetch(:model)
|
|
912
|
+
expected_persistence = implementation.persistable != false
|
|
913
|
+
if model.persistable != expected_persistence
|
|
914
|
+
raise ValidationError,
|
|
915
|
+
"changing persistence for #{name} is outside the reversible Ruby contract"
|
|
916
|
+
end
|
|
917
|
+
synchronize_attributes(project, name, model, implementation.attributes.to_a)
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def synchronize_attributes(project, entity_name, entity, declarations)
|
|
921
|
+
declared = declarations.to_h { [_1.fetch(:mendix_name), _1] }
|
|
922
|
+
existing = entity.attributes.to_h { [_1.name, _1] }
|
|
923
|
+
(existing.keys - declared.keys).each do |name|
|
|
924
|
+
plan = project.plan_remove_attribute("#{entity_name}/#{name}")
|
|
925
|
+
raise ValidationError, plan.changes.join('; ') unless plan.safe?
|
|
926
|
+
|
|
927
|
+
plan.apply!
|
|
928
|
+
end
|
|
929
|
+
(declared.keys - existing.keys).each do |name|
|
|
930
|
+
declaration = declared.fetch(name)
|
|
931
|
+
reject_unsupported_required!(entity_name, declaration)
|
|
932
|
+
project.plan_add_attribute(
|
|
933
|
+
entity_name, name:, type: declaration.fetch(:type),
|
|
934
|
+
default: declaration[:default]
|
|
935
|
+
).apply!
|
|
936
|
+
end
|
|
937
|
+
(declared.keys & existing.keys).each do |name|
|
|
938
|
+
synchronize_attribute(project, entity_name, existing.fetch(name), declared.fetch(name))
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
def synchronize_attribute(project, entity_name, attribute, declaration)
|
|
943
|
+
if (attribute.required == true) != declaration.fetch(:required)
|
|
944
|
+
raise ValidationError,
|
|
945
|
+
"changing required for #{entity_name}/#{attribute.name} is outside the reversible Ruby contract"
|
|
946
|
+
end
|
|
947
|
+
updates = {}
|
|
948
|
+
declared_type = declaration.fetch(:type).to_sym
|
|
949
|
+
updates[:type] = declared_type if attribute.type != declared_type
|
|
950
|
+
current_default = attribute.default_value.to_s
|
|
951
|
+
declared_default = declaration[:default].to_s
|
|
952
|
+
updates[:default] = declaration[:default] if current_default != declared_default
|
|
953
|
+
return if updates.empty?
|
|
954
|
+
|
|
955
|
+
project.plan_change_attribute("#{entity_name}/#{attribute.name}", **updates).apply!
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
def reject_unsupported_required!(entity_name, declaration)
|
|
959
|
+
return unless declaration.fetch(:required)
|
|
960
|
+
|
|
961
|
+
raise ValidationError,
|
|
962
|
+
"adding required attribute #{entity_name}/#{declaration.fetch(:mendix_name)} " \
|
|
963
|
+
'is outside the reversible Ruby contract'
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
def qualified_parts(name)
|
|
967
|
+
parts = name.to_s.split('.', 2)
|
|
968
|
+
raise ValidationError, "Ruby model name must be Module.Entity: #{name}" unless parts.size == 2
|
|
969
|
+
|
|
970
|
+
parts
|
|
971
|
+
end
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
# Loopback HTTP server for the generated backend and browser frontend.
|
|
975
|
+
class Server
|
|
976
|
+
MAX_BODY_BYTES = 1_048_576
|
|
977
|
+
LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
|
|
978
|
+
|
|
979
|
+
attr_reader :host, :port, :application
|
|
980
|
+
|
|
981
|
+
def initialize(root, host: '127.0.0.1', port: 9292, logger: nil, environment: nil)
|
|
982
|
+
@host = host.to_s
|
|
983
|
+
raise ArgumentError, 'the Ruby application server must bind to loopback' unless LOOPBACK_HOSTS.include?(@host)
|
|
984
|
+
|
|
985
|
+
@port = Integer(port)
|
|
986
|
+
@application = Application.new(root, environment:)
|
|
987
|
+
@sessions = @application.session_manager
|
|
988
|
+
@logger = logger
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
def start
|
|
992
|
+
@server = Http::Server.new(host:, port:, logger: @logger) { dispatch(_1, _2) }
|
|
993
|
+
application.start_scheduler
|
|
994
|
+
@server.start { yield(_1) if block_given? }
|
|
995
|
+
ensure
|
|
996
|
+
application.close
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
def shutdown = @server&.shutdown
|
|
1000
|
+
|
|
1001
|
+
private
|
|
1002
|
+
|
|
1003
|
+
def dispatch(request, response)
|
|
1004
|
+
path = request.path
|
|
1005
|
+
method = request.request_method
|
|
1006
|
+
if method == 'GET' && path == '/api/health'
|
|
1007
|
+
return render_json(
|
|
1008
|
+
response, 200, ok: true, project: application.schema[:project],
|
|
1009
|
+
environment: application.environment.name
|
|
1010
|
+
)
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
if method == 'POST' && path == '/api/login'
|
|
1014
|
+
credentials = request_json(request)
|
|
1015
|
+
session = @sessions.login(credentials['username'], credentials['password'])
|
|
1016
|
+
return render_json(response, 200, { ok: true }.merge(session))
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
context = @sessions.authenticate(request['Authorization'])
|
|
1020
|
+
if method == 'GET' && path == '/api/session'
|
|
1021
|
+
return render_json(
|
|
1022
|
+
response, 200,
|
|
1023
|
+
user: context.user, roles: context.user_roles, module_roles: context.module_roles
|
|
1024
|
+
)
|
|
1025
|
+
end
|
|
1026
|
+
if method == 'POST' && path == '/api/logout'
|
|
1027
|
+
return render_json(response, 200, ok: @sessions.logout(request['Authorization']))
|
|
1028
|
+
end
|
|
1029
|
+
return render_json(response, 200, application.schema(context:)) if method == 'GET' && path == '/api/schema'
|
|
1030
|
+
return render_json(response, 200, application.schema(context:)[:navigation]) \
|
|
1031
|
+
if method == 'GET' && path == '/api/navigation'
|
|
1032
|
+
if method == 'GET' && path == '/api/pages'
|
|
1033
|
+
return render_json(
|
|
1034
|
+
response, 200,
|
|
1035
|
+
pages: application.schema(context:)[:modules].flat_map { _1.fetch('pages') }
|
|
1036
|
+
)
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
if (name = route_name(path, '/api/pages/')) && method == 'GET'
|
|
1040
|
+
page = application.page(name, context:)
|
|
1041
|
+
return render_json(response, page ? 200 : 404, page || error('not_found', "page #{name} not found"))
|
|
1042
|
+
end
|
|
1043
|
+
if (name = route_name(path, '/api/microflows/')) && method == 'POST'
|
|
1044
|
+
invocation = application.invoke_service(name, request_json(request), context:)
|
|
1045
|
+
return render_json(response, 200, { ok: true }.merge(invocation))
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
if (tail = route_name(path, '/api/entities/'))
|
|
1049
|
+
name, id = tail.split('/', 2)
|
|
1050
|
+
if method == 'GET' && id.nil?
|
|
1051
|
+
query = request.query.to_h
|
|
1052
|
+
records = application.records(
|
|
1053
|
+
name, context:,
|
|
1054
|
+
association: query['association'],
|
|
1055
|
+
context_type: query['context_type'],
|
|
1056
|
+
context_id: query['context_id']
|
|
1057
|
+
)
|
|
1058
|
+
return render_json(response, 200, records:)
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
if method == 'GET' && id
|
|
1062
|
+
record = application.record(name, id, context:)
|
|
1063
|
+
return render_json(
|
|
1064
|
+
response, record ? 200 : 404,
|
|
1065
|
+
record || error('not_found', "#{name} #{id} not found")
|
|
1066
|
+
)
|
|
1067
|
+
end
|
|
1068
|
+
return render_json(response, 201, application.create_record(name, request_json(request), context:)) \
|
|
1069
|
+
if method == 'POST' && id.nil?
|
|
1070
|
+
|
|
1071
|
+
if %w[PUT PATCH].include?(method) && id
|
|
1072
|
+
record = application.update_record(name, id, request_json(request), context:)
|
|
1073
|
+
return render_json(
|
|
1074
|
+
response, record ? 200 : 404,
|
|
1075
|
+
record || error('not_found', "#{name} #{id} not found")
|
|
1076
|
+
)
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
if method == 'DELETE' && id
|
|
1080
|
+
deleted = application.delete_record(name, id, context:)
|
|
1081
|
+
return render_json(response, deleted ? 200 : 404, ok: deleted)
|
|
1082
|
+
end
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
if (match = rest_route(method, path))
|
|
1086
|
+
route, path_parameters = match
|
|
1087
|
+
return render_json(response, 401, error('unauthorized', 'Authorization header required')) \
|
|
1088
|
+
if route['requires_authentication'] && request['Authorization'].to_s.empty?
|
|
1089
|
+
|
|
1090
|
+
body = request.body.to_s.empty? ? nil : request_json(request)
|
|
1091
|
+
result = application.invoke_rest(
|
|
1092
|
+
route, path_parameters:, query: request.query.to_h, body:, context:
|
|
1093
|
+
)
|
|
1094
|
+
cors(response) if route['enable_cors']
|
|
1095
|
+
status = Integer(route.fetch('success_status', 200))
|
|
1096
|
+
if status == 204
|
|
1097
|
+
response.status = status
|
|
1098
|
+
response.body = ''
|
|
1099
|
+
return
|
|
1100
|
+
end
|
|
1101
|
+
return render_json(response, status, result)
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
if method == 'OPTIONS' && application.rest_routes.any? { path_match(_1.fetch('path'), path) }
|
|
1105
|
+
cors(response)
|
|
1106
|
+
response.status = 204
|
|
1107
|
+
return
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
return render_static(request, response) if method == 'GET' && !path.start_with?('/api/')
|
|
1111
|
+
|
|
1112
|
+
render_json(response, 404, error('not_found', "route #{method} #{path} not found"))
|
|
1113
|
+
rescue JSON::ParserError, ArgumentError => e
|
|
1114
|
+
render_json(response, 400, error('invalid_request', e.message))
|
|
1115
|
+
rescue AuthenticationError => e
|
|
1116
|
+
render_json(response, 401, error('unauthorized', e.message))
|
|
1117
|
+
rescue Runtime::AuthorizationError => e
|
|
1118
|
+
render_json(response, 403, error('forbidden', e.message))
|
|
1119
|
+
rescue NativeRuntimeError => e
|
|
1120
|
+
render_json(response, 422, error('runtime_error', e.message))
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
def rest_route(method, path)
|
|
1124
|
+
application.rest_routes.each do |route|
|
|
1125
|
+
next unless route.fetch('method') == method
|
|
1126
|
+
|
|
1127
|
+
match = path_match(route.fetch('path'), path)
|
|
1128
|
+
return [route, match.named_captures] if match
|
|
1129
|
+
end
|
|
1130
|
+
nil
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
def path_match(template, path)
|
|
1134
|
+
names = []
|
|
1135
|
+
pattern = Regexp.escape(template).gsub(/\\\{([A-Za-z_]\w*)\\\}/) do
|
|
1136
|
+
names << Regexp.last_match(1)
|
|
1137
|
+
'([^/]+)'
|
|
1138
|
+
end
|
|
1139
|
+
match = %r{\A#{pattern}/?\z}.match(path)
|
|
1140
|
+
return unless match
|
|
1141
|
+
|
|
1142
|
+
values = names.zip(match.captures.map { URI.decode_www_form_component(_1) }).to_h
|
|
1143
|
+
Struct.new(:named_captures).new(values)
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
def cors(response)
|
|
1147
|
+
response['Access-Control-Allow-Origin'] = '*'
|
|
1148
|
+
response['Access-Control-Allow-Headers'] = 'Authorization, Content-Type'
|
|
1149
|
+
response['Access-Control-Allow-Methods'] = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
def route_name(path, prefix)
|
|
1153
|
+
return unless path.start_with?(prefix)
|
|
1154
|
+
|
|
1155
|
+
URI.decode_www_form_component(path.delete_prefix(prefix))
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
def request_json(request)
|
|
1159
|
+
body = request.body.to_s
|
|
1160
|
+
raise ArgumentError, 'request body exceeds 1 MiB' if body.bytesize > MAX_BODY_BYTES
|
|
1161
|
+
return {} if body.empty?
|
|
1162
|
+
|
|
1163
|
+
JSON.parse(body).tap do |payload|
|
|
1164
|
+
raise ArgumentError, 'JSON body must be an object' unless payload.is_a?(Hash)
|
|
1165
|
+
end
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
def render_static(request, response)
|
|
1169
|
+
relative = request.path == '/' ? 'index.html' : request.path.delete_prefix('/')
|
|
1170
|
+
clean = Pathname.new(relative).cleanpath
|
|
1171
|
+
return render_json(response, 404, error('not_found', 'asset not found')) \
|
|
1172
|
+
if clean.absolute? || clean.to_s.start_with?('../')
|
|
1173
|
+
|
|
1174
|
+
public_root = File.join(application.root, 'frontend', 'dist')
|
|
1175
|
+
path = File.join(public_root, clean.to_s)
|
|
1176
|
+
path = File.join(public_root, 'index.html') unless File.file?(path)
|
|
1177
|
+
response.status = File.file?(path) ? 200 : 404
|
|
1178
|
+
response['Content-Type'] = content_type(path)
|
|
1179
|
+
response.body = File.file?(path) ? File.binread(path) : 'Not found'
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
def content_type(path)
|
|
1183
|
+
case File.extname(path)
|
|
1184
|
+
when '.html' then 'text/html; charset=utf-8'
|
|
1185
|
+
when '.js' then 'text/javascript; charset=utf-8'
|
|
1186
|
+
when '.css' then 'text/css; charset=utf-8'
|
|
1187
|
+
when '.json' then 'application/json; charset=utf-8'
|
|
1188
|
+
else 'application/octet-stream'
|
|
1189
|
+
end
|
|
1190
|
+
end
|
|
1191
|
+
|
|
1192
|
+
def error(code, message) = { ok: false, error: { code:, message: } }
|
|
1193
|
+
|
|
1194
|
+
def render_json(response, status, payload)
|
|
1195
|
+
response.status = status
|
|
1196
|
+
response['Content-Type'] = 'application/json; charset=utf-8'
|
|
1197
|
+
response.body = JSON.generate(payload)
|
|
1198
|
+
end
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
# Exposes the same generic MXRB backend through the Rack protocol. Puma,
|
|
1202
|
+
# Sinatra and Rails presets mount this adapter instead of duplicating API
|
|
1203
|
+
# behavior or introducing a second application state.
|
|
1204
|
+
class RackAdapter
|
|
1205
|
+
Request = Struct.new(:path, :request_method, :body, :query, :headers) do
|
|
1206
|
+
def [](name) = headers[name]
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
# Minimal mutable response consumed by Server#dispatch.
|
|
1210
|
+
class Response
|
|
1211
|
+
attr_accessor :status, :body
|
|
1212
|
+
attr_reader :headers
|
|
1213
|
+
|
|
1214
|
+
def initialize
|
|
1215
|
+
@status = 200
|
|
1216
|
+
@body = ''
|
|
1217
|
+
@headers = {}
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
def [](name) = headers[name]
|
|
1221
|
+
|
|
1222
|
+
def []=(name, value)
|
|
1223
|
+
headers[name] = value
|
|
1224
|
+
end
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
attr_reader :root
|
|
1228
|
+
|
|
1229
|
+
def initialize(root)
|
|
1230
|
+
@root = File.expand_path(root)
|
|
1231
|
+
@server_mutex = Mutex.new
|
|
1232
|
+
end
|
|
1233
|
+
|
|
1234
|
+
def server
|
|
1235
|
+
@server || @server_mutex.synchronize { @server ||= Server.new(root, port: 0) }
|
|
1236
|
+
end
|
|
1237
|
+
|
|
1238
|
+
def call(environment)
|
|
1239
|
+
request = rack_request(environment)
|
|
1240
|
+
response = Response.new
|
|
1241
|
+
server.send(:dispatch, request, response)
|
|
1242
|
+
[response.status, response.headers, [response.body.to_s]]
|
|
1243
|
+
end
|
|
1244
|
+
|
|
1245
|
+
def close = @server&.application&.close
|
|
1246
|
+
|
|
1247
|
+
private
|
|
1248
|
+
|
|
1249
|
+
def rack_request(environment)
|
|
1250
|
+
input = environment['rack.input']
|
|
1251
|
+
body = input ? input.read.to_s : ''
|
|
1252
|
+
input.rewind if input.respond_to?(:rewind)
|
|
1253
|
+
query = URI.decode_www_form(environment['QUERY_STRING'].to_s).to_h
|
|
1254
|
+
headers = { 'Authorization' => environment['HTTP_AUTHORIZATION'].to_s }
|
|
1255
|
+
path = "#{environment['SCRIPT_NAME']}#{environment.fetch('PATH_INFO', '/')}"
|
|
1256
|
+
Request.new(
|
|
1257
|
+
path, environment.fetch('REQUEST_METHOD', 'GET'),
|
|
1258
|
+
body, query, headers
|
|
1259
|
+
)
|
|
1260
|
+
end
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
# Starts the Ruby API and Vite dev server as one supervised command.
|
|
1264
|
+
class Supervisor
|
|
1265
|
+
attr_reader :root, :host, :api_port, :frontend_port
|
|
1266
|
+
|
|
1267
|
+
def initialize(root, host: '127.0.0.1', api_port: 9292, frontend_port: 5173,
|
|
1268
|
+
npm: ENV.fetch('MXRB_NPM', 'npm'), frontend: true, environment: nil)
|
|
1269
|
+
@root = File.expand_path(root)
|
|
1270
|
+
@host = host.to_s
|
|
1271
|
+
@api_port = Integer(api_port)
|
|
1272
|
+
@frontend_port = Integer(frontend_port)
|
|
1273
|
+
@npm = npm.to_s
|
|
1274
|
+
@frontend = frontend
|
|
1275
|
+
@environment = if environment.is_a?(Environment)
|
|
1276
|
+
environment
|
|
1277
|
+
else
|
|
1278
|
+
Environment.load(
|
|
1279
|
+
environment, root: @root
|
|
1280
|
+
)
|
|
1281
|
+
end
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1284
|
+
def start
|
|
1285
|
+
if external_backend?
|
|
1286
|
+
@backend_pid = spawn_backend
|
|
1287
|
+
else
|
|
1288
|
+
@server = Server.new(root, host:, port: api_port, environment: @environment)
|
|
1289
|
+
@backend = Thread.new { @server.start }
|
|
1290
|
+
end
|
|
1291
|
+
@frontend_pid = spawn_frontend if @frontend
|
|
1292
|
+
yield(self) if block_given?
|
|
1293
|
+
if @frontend_pid
|
|
1294
|
+
Process.wait(@frontend_pid)
|
|
1295
|
+
elsif @backend_pid
|
|
1296
|
+
Process.wait(@backend_pid)
|
|
1297
|
+
else
|
|
1298
|
+
@backend.join
|
|
1299
|
+
end
|
|
1300
|
+
rescue Interrupt, Errno::ECHILD
|
|
1301
|
+
nil
|
|
1302
|
+
ensure
|
|
1303
|
+
shutdown
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
def shutdown
|
|
1307
|
+
@server&.shutdown
|
|
1308
|
+
terminate(@frontend_pid)
|
|
1309
|
+
terminate(@backend_pid)
|
|
1310
|
+
@backend&.join(2)
|
|
1311
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
1312
|
+
nil
|
|
1313
|
+
end
|
|
1314
|
+
|
|
1315
|
+
private
|
|
1316
|
+
|
|
1317
|
+
def external_backend?
|
|
1318
|
+
%w[flymetothemoon onrails].include?(
|
|
1319
|
+
Manifest.load(root).data.dig('ruby_stack', 'preset')
|
|
1320
|
+
)
|
|
1321
|
+
end
|
|
1322
|
+
|
|
1323
|
+
def spawn_backend
|
|
1324
|
+
Process.spawn(
|
|
1325
|
+
profile_environment.merge('HOST' => host, 'MXRB_SERVER_PORT' => api_port.to_s),
|
|
1326
|
+
'bundle', 'exec', 'puma', '-C', 'config/puma.rb', chdir: root
|
|
1327
|
+
)
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1330
|
+
def terminate(pid)
|
|
1331
|
+
return unless pid
|
|
1332
|
+
|
|
1333
|
+
Process.kill('TERM', pid)
|
|
1334
|
+
Process.wait(pid)
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
def spawn_frontend
|
|
1338
|
+
directory = File.join(root, 'frontend')
|
|
1339
|
+
package = File.join(directory, 'package.json')
|
|
1340
|
+
raise ArgumentError, "frontend package not found: #{directory}" unless File.file?(package)
|
|
1341
|
+
unless File.directory?(File.join(directory, 'node_modules'))
|
|
1342
|
+
raise ArgumentError, "frontend dependencies missing; run `#{@npm} install --prefix frontend`"
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
frontend_environment = profile_environment.select { _1.start_with?('VITE_') }
|
|
1346
|
+
frontend_environment['MXRB_ENV'] = @environment.name
|
|
1347
|
+
frontend_environment['MXRB_API_PORT'] = api_port.to_s
|
|
1348
|
+
Process.spawn(
|
|
1349
|
+
frontend_environment, @npm, 'run', 'dev', '--',
|
|
1350
|
+
'--host', host, '--port', frontend_port.to_s, chdir: directory
|
|
1351
|
+
)
|
|
1352
|
+
end
|
|
1353
|
+
|
|
1354
|
+
def profile_environment
|
|
1355
|
+
@environment.to_h.reject { |key, value| ENV[key] == value }
|
|
1356
|
+
.merge('MXRB_ENV' => @environment.name)
|
|
1357
|
+
end
|
|
1358
|
+
end
|
|
1359
|
+
# rubocop:enable Metrics
|
|
1360
|
+
end
|
|
1361
|
+
end
|