mendix-ruby-bridge 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb1eb419fc9e16b0f10171eab510dc761911deb7cdde1b4428e9d239d605eeb9
4
- data.tar.gz: 72953444a53d27e636683de9d0578fff6da451c2dd76a65c79ceb76de13d3b9d
3
+ metadata.gz: 767361072737fe4fd5c02587af41776895fa64a24c143ec599ad54df874bd1f8
4
+ data.tar.gz: 50da6cebeb94f6fb0ab8ee1f5418488b2d9e148b7d30a1fb773eebda58b5af04
5
5
  SHA512:
6
- metadata.gz: 8c19e5e823cac18f93fe901340b856621af47b5aad2a85151d2387bbad230eb80f0f64e6a4fb9dcbb81939f3f593631385762311d2de7cf7bc6d71ffb49595c1
7
- data.tar.gz: 46c757f8cb088baf81bfb76c1718de09ba9b0d171ec316eba7a08b0b4e3d28b481605f0081e479404f6daf4138d1e2d6fd431728e2f248ea103dff1b0797c09b
6
+ metadata.gz: ea4cc6ba22ea7d9f075549da294d77e49afd478d851f2388c7685384783c83e64ab9f89c607ebe707d6204c1e01d2a5826fd13d8ec8692c0d16569bb217e0de3
7
+ data.tar.gz: 042d3e85502ddae7a63e8c553c3e6a720335b595fb8862d072257ede8d306a0fd67d6ecb8d6f66cd18d4ecec5dbd81425692d395ace4fde2b2f9f883b090cbeb
data/bin/setup-tools CHANGED
@@ -16,25 +16,10 @@ download_url="https://github.com/mendixlabs/mxcli/releases/download/v${version}/
16
16
 
17
17
  if [[ -x "$executable" ]]; then
18
18
  echo "mxcli $version is already installed"
19
- else
20
- mkdir -p "$install_dir"
21
- curl --fail --location "$download_url" --output "$executable"
22
- chmod +x "$executable"
23
- "$executable" --version
19
+ exit 0
24
20
  fi
25
21
 
26
- echo "Installing desktop dependencies (gtk3, webkit2-gtk)..."
27
- if command -v pacman &>/dev/null; then
28
- sudo pacman -S --needed --noconfirm gtk3 gobject-introspection webkit2gtk-4.1
29
- gem install gtk3 webkit2-gtk
30
- elif command -v apt-get &>/dev/null; then
31
- sudo apt-get install -y libgtk-3-dev libgirepository1.0-dev libwebkit2gtk-4.1-dev
32
- gem install gtk3 webkit2-gtk
33
- elif command -v dnf &>/dev/null; then
34
- sudo dnf install -y gtk3-devel gobject-introspection-devel webkit2gtk4.1-devel
35
- gem install gtk3 webkit2-gtk
36
- else
37
- echo "Package manager not detected; installing via gem (system libs must already be present)..."
38
- gem install gtk3 webkit2-gtk
39
- fi
40
- echo "Desktop dependencies installed."
22
+ mkdir -p "$install_dir"
23
+ curl --fail --location "$download_url" --output "$executable"
24
+ chmod +x "$executable"
25
+ "$executable" --version
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MendixBridge
4
+ # Builds ALTER PAGE / ALTER SNIPPET MDL statements from a structured list of
5
+ # operations. Each operation is a Hash with an "op" key and operation-
6
+ # specific fields:
7
+ #
8
+ # { "op" => "set", "widget" => "btnSave",
9
+ # "props" => { "Caption" => "Save & Close", "ButtonStyle" => "Success" } }
10
+ #
11
+ # { "op" => "insert_after", "widget" => "tbEmail",
12
+ # "body" => "textbox tbPhone (Label: 'Phone', Attribute: Phone)" }
13
+ #
14
+ # { "op" => "insert_before","widget" => "btnCancel",
15
+ # "body" => "actionbutton btnBack (Caption: 'Back', Action: close_page)" }
16
+ #
17
+ # { "op" => "drop", "widgets" => ["tbUnused", "lblOld"] }
18
+ #
19
+ # { "op" => "replace", "widget" => "tbName",
20
+ # "body" => "textarea taName (Label: 'Name', Attribute: Name)" }
21
+ #
22
+ # { "op" => "set_layout", "layout" => "Atlas_Core.Atlas_Sidebar_Full" }
23
+ #
24
+ # { "op" => "add_variable", "name" => "counter",
25
+ # "type" => "Integer", "default" => "0" }
26
+ #
27
+ # { "op" => "drop_variable","name" => "counter" }
28
+ #
29
+ # Validation errors are returned as an array of strings from `validate`.
30
+ # `build` raises `ArgumentError` when there are errors.
31
+ module AlterPageBuilder
32
+ VALID_OPS = %w[
33
+ set insert_after insert_before drop replace set_layout add_variable drop_variable
34
+ ].freeze
35
+
36
+ IDENTIFIER = /\A[A-Za-z_]\w*\z/
37
+ QN_PATTERN = /\A[A-Za-z_]\w*\.[A-Za-z_]\w*\z/
38
+ LAYOUT_QN = /\A[A-Za-z_]\w*\.[A-Za-z_]\w*\z/
39
+
40
+ # Build an ALTER PAGE MDL string from `qn` and an array of `operations`.
41
+ # Raises ArgumentError if any operation is invalid.
42
+ # Pass `known_names:` (array of widget name strings from the page tree) to
43
+ # enable widget-name existence checks. Omit it to skip those checks.
44
+ def self.build(qn:, operations:, known_names: nil)
45
+ errors = validate(operations, known_names: known_names)
46
+ raise ArgumentError, errors.join("; ") if errors.any?
47
+
48
+ lines = operations.map { |op| build_operation(op) }.compact
49
+ "ALTER PAGE #{qn} {\n#{lines.join("\n")}\n};\n"
50
+ end
51
+
52
+ # Returns an array of human-readable error strings, or [] if valid.
53
+ def self.validate(operations, known_names: nil)
54
+ return ["operations must be an array"] unless operations.is_a?(Array)
55
+
56
+ errors = []
57
+ operations.each_with_index do |op, i|
58
+ prefix = "operation[#{i}]"
59
+ unless op.is_a?(Hash)
60
+ errors << "#{prefix}: must be a hash"
61
+ next
62
+ end
63
+
64
+ kind = op["op"].to_s
65
+ unless VALID_OPS.include?(kind)
66
+ errors << "#{prefix}: unknown op '#{kind}' (valid: #{VALID_OPS.join(", ")})"
67
+ next
68
+ end
69
+
70
+ case kind
71
+ when "set"
72
+ errors << "#{prefix}: 'widget' required" unless op["widget"].is_a?(String)
73
+ errors << "#{prefix}: 'props' must be a non-empty hash" unless
74
+ op["props"].is_a?(Hash) && op["props"].any?
75
+ check_widget_exists(prefix, op["widget"], known_names, errors)
76
+
77
+ when "insert_after", "insert_before"
78
+ errors << "#{prefix}: 'widget' required" unless op["widget"].is_a?(String)
79
+ errors << "#{prefix}: 'body' required (MDL widget definition)" unless
80
+ op["body"].is_a?(String) && !op["body"].strip.empty?
81
+ check_widget_exists(prefix, op["widget"], known_names, errors)
82
+
83
+ when "drop"
84
+ unless op["widgets"].is_a?(Array) && op["widgets"].all? { |w| w.is_a?(String) }
85
+ errors << "#{prefix}: 'widgets' must be an array of strings"
86
+ next
87
+ end
88
+ op["widgets"].each { |w| check_widget_exists(prefix, w, known_names, errors) }
89
+
90
+ when "replace"
91
+ errors << "#{prefix}: 'widget' required" unless op["widget"].is_a?(String)
92
+ errors << "#{prefix}: 'body' required (MDL widget definition)" unless
93
+ op["body"].is_a?(String) && !op["body"].strip.empty?
94
+ check_widget_exists(prefix, op["widget"], known_names, errors)
95
+
96
+ when "set_layout"
97
+ unless op["layout"].is_a?(String) && op["layout"].match?(LAYOUT_QN)
98
+ errors << "#{prefix}: 'layout' must be a qualified name (Module.Layout)"
99
+ end
100
+
101
+ when "add_variable"
102
+ errors << "#{prefix}: 'name' must be a valid identifier" unless
103
+ op["name"].is_a?(String) && op["name"].match?(IDENTIFIER)
104
+ errors << "#{prefix}: 'type' required" unless op["type"].is_a?(String)
105
+
106
+ when "drop_variable"
107
+ errors << "#{prefix}: 'name' must be a valid identifier" unless
108
+ op["name"].is_a?(String) && op["name"].match?(IDENTIFIER)
109
+ end
110
+ end
111
+
112
+ errors
113
+ end
114
+
115
+ # -------------------------------------------------------------------------
116
+ private_class_method def self.check_widget_exists(prefix, name, known_names, errors)
117
+ return unless known_names && name.is_a?(String)
118
+
119
+ unless known_names.include?(name)
120
+ errors << "#{prefix}: widget '#{name}' not found in page (known: #{known_names.sort.join(", ")})"
121
+ end
122
+ end
123
+
124
+ private_class_method def self.build_operation(op)
125
+ case op["op"]
126
+ when "set"
127
+ props = render_props(op["props"])
128
+ " SET (#{props}) ON #{op["widget"]};"
129
+
130
+ when "insert_after"
131
+ " INSERT AFTER #{op["widget"]} {\n#{indent_body(op["body"])}\n };"
132
+
133
+ when "insert_before"
134
+ " INSERT BEFORE #{op["widget"]} {\n#{indent_body(op["body"])}\n };"
135
+
136
+ when "drop"
137
+ names = Array(op["widgets"]).join(", ")
138
+ " DROP WIDGET #{names};"
139
+
140
+ when "replace"
141
+ " REPLACE #{op["widget"]} WITH {\n#{indent_body(op["body"])}\n };"
142
+
143
+ when "set_layout"
144
+ " SET Layout #{op["layout"]};"
145
+
146
+ when "add_variable"
147
+ default = op["default"] ? " = '#{escape_mdl(op["default"].to_s)}'" : ""
148
+ " ADD VARIABLES $#{op["name"]}: #{op["type"]}#{default};"
149
+
150
+ when "drop_variable"
151
+ " DROP VARIABLES $#{op["name"]};"
152
+ end
153
+ end
154
+
155
+ private_class_method def self.render_props(props)
156
+ props.map do |key, value|
157
+ rendered =
158
+ case value
159
+ when String then value.match?(/\A[A-Za-z_$][\w.$\/]*\z/) ? value : "'#{escape_mdl(value)}'"
160
+ when Integer, Float then value.to_s
161
+ when TrueClass, FalseClass then value.to_s
162
+ else "'#{escape_mdl(value.to_s)}'"
163
+ end
164
+ "#{key}: #{rendered}"
165
+ end.join(", ")
166
+ end
167
+
168
+ private_class_method def self.indent_body(body)
169
+ body.to_s.strip.lines.map { |line| " #{line.rstrip}" }.join("\n")
170
+ end
171
+
172
+ private_class_method def self.escape_mdl(str)
173
+ str.to_s.gsub("'", "''")
174
+ end
175
+ end
176
+ end
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "net/http"
4
5
  require "open3"
6
+ require "socket"
5
7
  require "tempfile"
6
8
  require "time"
9
+ require "timeout"
7
10
  require "webrick"
8
11
 
9
12
  module MendixBridge
@@ -40,6 +43,11 @@ module MendixBridge
40
43
  @docker_running = false
41
44
  @auth_user = nil
42
45
  @run_cmd_override = run_cmd
46
+ @xas_log = []
47
+ @xas_mutex = Mutex.new
48
+ @database_mutex = Mutex.new
49
+ @xas_proxy_enabled = false
50
+ @xas_target_port = nil
43
51
  validate!
44
52
  @server = WEBrick::HTTPServer.new(
45
53
  BindAddress: bind,
@@ -86,6 +94,7 @@ module MendixBridge
86
94
  end
87
95
  @server.mount_proc("/api/git") { |request, response| git_route(request, response) }
88
96
  @server.mount_proc("/api/page") { |request, response| page_route(request, response) }
97
+ @server.mount_proc("/api/alter") { |request, response| alter_route(request, response) }
89
98
  @server.mount_proc("/api/flow") { |request, response| flow_route(request, response) }
90
99
  @server.mount_proc("/api/drafts") { |_request, response| drafts(response) }
91
100
  @server.mount_proc("/api/apply") { |request, response| apply_draft_route(request, response) }
@@ -99,6 +108,8 @@ module MendixBridge
99
108
  @server.mount_proc("/api/marketplace/install") do |request, response|
100
109
  marketplace_install(request, response)
101
110
  end
111
+ @server.mount_proc("/api/xas") { |request, response| xas_control(request, response) }
112
+ @server.mount_proc("/xas") { |request, response| xas_proxy(request, response) }
102
113
  @server.mount(
103
114
  "/",
104
115
  WEBrick::HTTPServlet::FileHandler,
@@ -327,13 +338,49 @@ module MendixBridge
327
338
 
328
339
  result =
329
340
  case [request.request_method, action]
330
- when ["GET", "status"] then workflow.status
331
- when ["GET", "branches"] then { "branches" => workflow.branches, "current" => workflow.status["branch"] }
332
- when ["GET", "stash"] then { "stash" => workflow.stash_list.lines.map(&:strip).reject(&:empty?) }
333
- when ["POST", "fetch"] then workflow.fetch && workflow.status
334
- when ["POST", "switch"] then workflow.switch(payload.fetch("branch"), studio_closed: closed)
335
- when ["POST", "create"] then workflow.create(payload.fetch("branch"), studio_closed: closed)
336
- when ["POST", "commit"] then workflow.commit(payload.fetch("message"), studio_closed: closed)
341
+ when ["GET", "status"] then workflow.status
342
+ when ["GET", "branches"]
343
+ {
344
+ "branches" => workflow.branches,
345
+ "current" => workflow.status["branch"],
346
+ "remotes" => workflow.remote_names
347
+ }
348
+ when ["GET", "tags"] then { "tags" => workflow.tags }
349
+ when ["GET", "worktrees"] then { "worktrees" => workflow.worktrees, "root" => workflow.root }
350
+ when ["GET", "stash"] then { "stash" => workflow.stash_list.lines.map(&:strip).reject(&:empty?) }
351
+ when ["GET", "log"]
352
+ max = [[(request.query["max"] || "200").to_i, 1].max, 500].min
353
+ { "commits" => workflow.log(max:) }
354
+ when ["GET", "file-status"] then { "files" => workflow.file_status }
355
+ when ["POST", "fetch"] then workflow.fetch && workflow.status
356
+ when ["POST", "switch"] then workflow.switch(payload.fetch("branch"), studio_closed: closed)
357
+ when ["POST", "create"]
358
+ workflow.create(
359
+ payload.fetch("branch"),
360
+ studio_closed: closed,
361
+ start_point: payload["start_point"],
362
+ carry_changes: payload["carry_changes"] == true
363
+ )
364
+ when ["POST", "command"]
365
+ workflow.terminal_command(payload.fetch("command"), studio_closed: closed)
366
+ when ["POST", "commit"] then workflow.commit(payload.fetch("message"), studio_closed: closed)
367
+ when ["POST", "commit-staged"] then workflow.commit_staged(payload.fetch("message"), studio_closed: closed)
368
+ when ["POST", "stage"] then workflow.stage(payload.fetch("path"))
369
+ when ["POST", "unstage"] then workflow.unstage(payload.fetch("path"))
370
+ when ["POST", "discard"] then workflow.discard(payload.fetch("path"))
371
+ when ["POST", "push"] then workflow.push
372
+ when ["POST", "remote"] then workflow.add_remote(payload.fetch("name"), payload.fetch("url"))
373
+ when ["POST", "pull"] then workflow.pull(studio_closed: closed)
374
+ when ["POST", "cherry-pick"] then workflow.cherry_pick(payload.fetch("sha"), studio_closed: closed)
375
+ when ["POST", "revert"] then workflow.revert_commit(payload.fetch("sha"), studio_closed: closed)
376
+ when ["POST", "reset"] then workflow.reset_to(payload.fetch("sha"), mode: payload.fetch("mode", "mixed"), studio_closed: closed)
377
+ when ["POST", "tag"]
378
+ workflow.create_tag(payload.fetch("name"), sha: payload["sha"], message: payload["message"])
379
+ when ["POST", "delete-tag"] then workflow.delete_tag(payload.fetch("name"))
380
+ when ["POST", "delete-branch"]
381
+ workflow.delete_branch(payload.fetch("name"), force: payload["force"] == true)
382
+ when ["POST", "merge"] then workflow.merge(payload.fetch("branch"), studio_closed: closed)
383
+ when ["POST", "rebase"] then workflow.rebase(payload.fetch("branch"), studio_closed: closed)
337
384
  when ["POST", "stash"]
338
385
  workflow.stash_push(
339
386
  studio_closed: closed,
@@ -462,7 +509,7 @@ module MendixBridge
462
509
  persist_page_draft(qn, content, mdl, ok, message)
463
510
  json(
464
511
  response,
465
- { ok:, mdl:, message: ok ? "Page MDL validated and draft saved." : message },
512
+ { ok:, mdl:, message: ok ? "Page changes validated and saved." : message },
466
513
  status: ok ? 200 : 422
467
514
  )
468
515
  rescue KeyError => error
@@ -489,7 +536,7 @@ module MendixBridge
489
536
  persist_draft("flow-plans.json", qn, "body" => body, "mdl" => mdl, "valid" => ok, "message" => message)
490
537
  json(
491
538
  response,
492
- { ok:, mdl:, message: ok ? "Flow MDL validated and draft saved." : message },
539
+ { ok:, mdl:, message: ok ? "Flow changes validated and saved." : message },
493
540
  status: ok ? 200 : 422
494
541
  )
495
542
  rescue KeyError => error
@@ -521,8 +568,9 @@ module MendixBridge
521
568
  response,
522
569
  {
523
570
  entities: read.call("visual-plans.json"),
524
- pages: read.call("page-plans.json"),
525
- flows: read.call("flow-plans.json")
571
+ pages: read.call("page-plans.json"),
572
+ flows: read.call("flow-plans.json"),
573
+ alters: read.call("alter-plans.json")
526
574
  }
527
575
  )
528
576
  end
@@ -760,6 +808,14 @@ module MendixBridge
760
808
  status: 422
761
809
  )
762
810
  end
811
+ startup_error = ensure_local_database_available(project)
812
+ if startup_error
813
+ return json(
814
+ response,
815
+ { ok: false, message: startup_error },
816
+ status: 503
817
+ )
818
+ end
763
819
  [@mxcli, "sql", "-p", project, "--json", "--dsn", dsn, query]
764
820
  end
765
821
 
@@ -804,6 +860,78 @@ module MendixBridge
804
860
  "postgres://#{CGI.escape(user)}:#{CGI.escape(pass)}@#{host}:#{port}/#{name}?sslmode=disable"
805
861
  end
806
862
 
863
+ # SQL does not require the Mendix runtime. For a project-local database,
864
+ # start only its PostgreSQL Compose service on demand and keep it running
865
+ # between queries. External databases are contacted directly.
866
+ def ensure_local_database_available(project)
867
+ env_path = docker_env_path
868
+ return "No database connection configured." unless env_path
869
+
870
+ env = parse_env_file(env_path)
871
+ return nil if env["DB_MODE"].to_s == "external"
872
+
873
+ port = env.fetch("DB_PORT", "5432").to_i
874
+ return "Invalid local database port." unless port.positive?
875
+ docker_dir = File.dirname(env_path)
876
+ compose_path = File.join(docker_dir, "docker-compose.yml")
877
+ return "Local database definition is missing: #{compose_path}" unless File.file?(compose_path)
878
+ return nil if postgres_compose_ready?(docker_dir, env_path, compose_path, env)
879
+
880
+ @database_mutex.synchronize do
881
+ return nil if postgres_compose_ready?(docker_dir, env_path, compose_path, env)
882
+
883
+ output, error, status = Open3.capture3(
884
+ "docker", "compose",
885
+ "--env-file", env_path,
886
+ "-f", compose_path,
887
+ "up", "-d", "db",
888
+ chdir: docker_dir
889
+ )
890
+ unless status.success?
891
+ detail = clean_mxcli_error(error.empty? ? output : error)
892
+ return "Could not start the local PostgreSQL service: #{detail}"
893
+ end
894
+
895
+ begin
896
+ Timeout.timeout(30) do
897
+ sleep 0.25 until postgres_compose_ready?(docker_dir, env_path, compose_path, env)
898
+ end
899
+ rescue Timeout::Error
900
+ return "PostgreSQL was started but did not become ready on localhost:#{port} within 30 seconds."
901
+ end
902
+ end
903
+ nil
904
+ rescue Errno::ENOENT
905
+ "Docker is required to start the project-local PostgreSQL service."
906
+ rescue StandardError => error
907
+ "Could not prepare the local database: #{error.message}"
908
+ end
909
+
910
+ def tcp_reachable?(host, port)
911
+ Socket.tcp(host, port, connect_timeout: 0.25) { |socket| socket.close }
912
+ true
913
+ rescue SystemCallError, IOError
914
+ false
915
+ end
916
+
917
+ def postgres_compose_ready?(docker_dir, env_path, compose_path, env)
918
+ return false unless tcp_reachable?("127.0.0.1", env.fetch("DB_PORT", "5432").to_i)
919
+
920
+ _output, _error, status = Open3.capture3(
921
+ "docker", "compose",
922
+ "--env-file", env_path,
923
+ "-f", compose_path,
924
+ "exec", "-T", "db",
925
+ "pg_isready",
926
+ "-U", env.fetch("DB_USER", "mendix"),
927
+ "-d", env.fetch("DB_NAME", "mendix"),
928
+ chdir: docker_dir
929
+ )
930
+ status.success?
931
+ rescue Errno::ENOENT
932
+ false
933
+ end
934
+
807
935
  def parse_query_rows(stdout)
808
936
  out = stdout.to_s.strip
809
937
  return [] if out.empty?
@@ -903,6 +1031,95 @@ module MendixBridge
903
1031
  json(response, { error: "invalid JSON payload" }, status: 400)
904
1032
  end
905
1033
 
1034
+ # ---- ALTER PAGE / ALTER SNIPPET --------------------------------------
1035
+ #
1036
+ # GET /api/alter?qn=Module.Page — widget tree + names for the page
1037
+ # POST /api/alter — apply operations, check & save draft
1038
+ #
1039
+ # POST body:
1040
+ # { "qn": "Module.Page",
1041
+ # "operations": [
1042
+ # { "op": "set", "widget": "btnSave",
1043
+ # "props": { "Caption": "Save & Close", "ButtonStyle": "Success" } },
1044
+ # { "op": "insert_after", "widget": "tbEmail",
1045
+ # "body": "textbox tbPhone (Label: 'Phone', Attribute: Phone)" },
1046
+ # { "op": "drop", "widgets": ["tbUnused"] },
1047
+ # { "op": "replace", "widget": "tbName",
1048
+ # "body": "textarea taName (Label: 'Name', Attribute: Name)" },
1049
+ # { "op": "set_layout", "layout": "Atlas_Core.Atlas_Sidebar_Full" },
1050
+ # { "op": "add_variable", "name": "counter",
1051
+ # "type": "Integer", "default": "0" },
1052
+ # { "op": "drop_variable", "name": "legacyVar" }
1053
+ # ]
1054
+ # }
1055
+
1056
+ def alter_route(request, response)
1057
+ case request.request_method
1058
+ when "GET" then alter_info(request, response)
1059
+ when "POST" then alter_apply(request, response)
1060
+ else json(response, { error: "method not allowed" }, status: 405)
1061
+ end
1062
+ end
1063
+
1064
+ def alter_info(request, response)
1065
+ qn = request.query["qn"].to_s
1066
+ return json(response, { error: "qn required" }, status: 400) if qn.empty?
1067
+
1068
+ detail = page_detail(qn)
1069
+ return json(response, { error: "unknown page '#{qn}'" }, status: 404) unless detail
1070
+
1071
+ widget_tree = detail["widget_tree"] || []
1072
+ widget_names = detail["widget_names"] || MdlParser.flat_widget_names(widget_tree)
1073
+
1074
+ json(response, {
1075
+ qn:,
1076
+ widget_tree:,
1077
+ widget_names:,
1078
+ layout: detail["layout"],
1079
+ title: detail["title"]
1080
+ })
1081
+ end
1082
+
1083
+ def alter_apply(request, response)
1084
+ payload = JSON.parse(request.body.to_s)
1085
+ qn = payload.fetch("qn").to_s
1086
+ operations = payload.fetch("operations")
1087
+
1088
+ detail = page_detail(qn)
1089
+ return json(response, { error: "unknown page '#{qn}'" }, status: 404) unless detail
1090
+
1091
+ widget_tree = detail["widget_tree"] || []
1092
+ known_names = detail["widget_names"] || MdlParser.flat_widget_names(widget_tree)
1093
+
1094
+ # Validate before generating MDL
1095
+ errors = AlterPageBuilder.validate(operations, known_names: known_names)
1096
+ if errors.any?
1097
+ return json(response, { ok: false, errors: }, status: 422)
1098
+ end
1099
+
1100
+ mdl = AlterPageBuilder.build(qn:, operations:, known_names: known_names)
1101
+
1102
+ ok, message = check_mdl(mdl)
1103
+ persist_draft(
1104
+ "alter-plans.json", qn,
1105
+ "operations" => operations,
1106
+ "mdl" => mdl,
1107
+ "valid" => ok,
1108
+ "message" => message
1109
+ )
1110
+ json(
1111
+ response,
1112
+ { ok:, mdl:, message: ok ? "Page changes validated and saved." : message },
1113
+ status: ok ? 200 : 422
1114
+ )
1115
+ rescue KeyError => e
1116
+ json(response, { error: "missing parameter: #{e.key}" }, status: 400)
1117
+ rescue JSON::ParserError
1118
+ json(response, { error: "invalid JSON payload" }, status: 400)
1119
+ rescue ArgumentError => e
1120
+ json(response, { ok: false, errors: [e.message] }, status: 422)
1121
+ end
1122
+
906
1123
  # ---- app run/stop/log ------------------------------------------------
907
1124
 
908
1125
  def app_route(request, response)
@@ -1394,6 +1611,141 @@ module MendixBridge
1394
1611
  end
1395
1612
  end
1396
1613
 
1614
+ # ---- XAS proxy / interceptor -----------------------------------------
1615
+ #
1616
+ # When enabled, the bridge sits between the browser and the Mendix runtime,
1617
+ # logging every /xas/ call so you can inspect actions, operationIds, and
1618
+ # the data the client reads or writes. The runtime stays unmodified — all
1619
+ # requests are forwarded transparently and the original response is returned
1620
+ # verbatim. Enable with POST /api/xas/enable; browser/Studio Pro must then
1621
+ # point at the bridge port instead of the Mendix runtime port.
1622
+
1623
+ def xas_proxy(request, response)
1624
+ target_port = mendix_runtime_port
1625
+ unless @xas_proxy_enabled && target_port
1626
+ return json(
1627
+ response,
1628
+ { error: "XAS proxy not enabled — POST /api/xas/enable first" },
1629
+ status: 503
1630
+ )
1631
+ end
1632
+
1633
+ body = request.body.to_s
1634
+ payload = body.empty? ? {} : JSON.parse(body)
1635
+ action = payload["action"].to_s
1636
+ operation_id = payload.dig("params", "operationId").to_s
1637
+
1638
+ uri = URI("http://127.0.0.1:#{target_port}/xas/")
1639
+ http = Net::HTTP.new(uri.host, uri.port)
1640
+ http.read_timeout = 30
1641
+
1642
+ proxy_req = Net::HTTP::Post.new(uri.path)
1643
+ %w[cookie x-csrf-token content-type].each do |h|
1644
+ proxy_req[h] = request[h] if request[h]
1645
+ end
1646
+ proxy_req.body = body
1647
+
1648
+ proxy_res = http.request(proxy_req)
1649
+ res_body = proxy_res.body.to_s
1650
+ res_status = proxy_res.code.to_i
1651
+
1652
+ entry = {
1653
+ "at" => Time.now.iso8601,
1654
+ "action" => action,
1655
+ "operation_id" => operation_id.empty? ? nil : operation_id,
1656
+ "params" => payload["params"],
1657
+ "status" => res_status,
1658
+ "response_size" => res_body.bytesize
1659
+ }
1660
+ if res_body.bytesize < 16_384
1661
+ begin
1662
+ entry["response"] = JSON.parse(res_body)
1663
+ rescue JSON::ParserError
1664
+ nil
1665
+ end
1666
+ end
1667
+
1668
+ @xas_mutex.synchronize do
1669
+ @xas_log << entry
1670
+ @xas_log = @xas_log.last(500)
1671
+ end
1672
+
1673
+ response.status = res_status
1674
+ response["content-type"] = proxy_res["content-type"] || "application/json"
1675
+ response["cache-control"] = "no-store"
1676
+ response.body = res_body
1677
+ rescue JSON::ParserError
1678
+ json(response, { error: "invalid JSON in request body" }, status: 400)
1679
+ rescue Errno::ECONNREFUSED
1680
+ json(response, { error: "Mendix runtime not reachable on port #{target_port}" }, status: 502)
1681
+ rescue Net::ReadTimeout
1682
+ json(response, { error: "Mendix runtime timed out" }, status: 504)
1683
+ end
1684
+
1685
+ # GET /api/xas/log — query the intercepted call log
1686
+ # GET /api/xas/status — proxy enabled state + log size
1687
+ # POST /api/xas/enable — start proxying (optional body: {"port": 8080})
1688
+ # POST /api/xas/disable — stop proxying
1689
+ # POST /api/xas/clear — wipe the log
1690
+ def xas_control(request, response)
1691
+ action = request.path.delete_prefix("/api/xas").sub(%r{\A/}, "")
1692
+
1693
+ case [request.request_method, action]
1694
+ when ["GET", "log"]
1695
+ limit = request.query["limit"].to_s.empty? ? 50 : [[request.query["limit"].to_i, 1].max, 500].min
1696
+ entries = @xas_mutex.synchronize { @xas_log.dup }
1697
+ entries = entries.select { |e| e["action"] == request.query["action"] } if request.query["action"].to_s != ""
1698
+ entries = entries.select { |e| e["at"] >= request.query["since"] } if request.query["since"].to_s != ""
1699
+ json(response, { entries: entries.last(limit), total: @xas_log.size })
1700
+
1701
+ when ["GET", "status"]
1702
+ json(response, {
1703
+ enabled: @xas_proxy_enabled,
1704
+ target_port: @xas_target_port || mendix_runtime_port,
1705
+ log_size: @xas_mutex.synchronize { @xas_log.size }
1706
+ })
1707
+
1708
+ when ["POST", "enable"]
1709
+ payload = request.body.to_s.empty? ? {} : JSON.parse(request.body.to_s)
1710
+ port = payload["port"]&.to_i.then { |p| p&.positive? ? p : nil } || mendix_runtime_port
1711
+ unless port
1712
+ return json(
1713
+ response,
1714
+ { ok: false, message: "Cannot determine Mendix runtime port. Pass {\"port\": 8080} or set APP_PORT in .docker/.env." },
1715
+ status: 422
1716
+ )
1717
+ end
1718
+ @xas_target_port = port
1719
+ @xas_proxy_enabled = true
1720
+ json(response, { ok: true, message: "XAS proxy enabled → port #{port}. Point your browser at http://localhost:#{@port} instead of :#{port}." })
1721
+
1722
+ when ["POST", "disable"]
1723
+ @xas_proxy_enabled = false
1724
+ json(response, { ok: true, message: "XAS proxy disabled." })
1725
+
1726
+ when ["POST", "clear"]
1727
+ @xas_mutex.synchronize { @xas_log.clear }
1728
+ json(response, { ok: true, message: "XAS log cleared." })
1729
+
1730
+ else
1731
+ json(response, { error: "not found" }, status: 404)
1732
+ end
1733
+ rescue JSON::ParserError
1734
+ json(response, { error: "invalid JSON payload" }, status: 400)
1735
+ end
1736
+
1737
+ # Resolves the Mendix runtime port: explicit override > .docker/.env APP_PORT > 8080.
1738
+ def mendix_runtime_port
1739
+ return @xas_target_port if @xas_target_port
1740
+
1741
+ path = docker_env_path
1742
+ return 8080 unless path
1743
+
1744
+ env = parse_env_file(path)
1745
+ port = env["APP_PORT"].to_i
1746
+ port.positive? ? port : 8080
1747
+ end
1748
+
1397
1749
  def json(response, payload, status: 200)
1398
1750
  response.status = status
1399
1751
  response["content-type"] = "application/json; charset=utf-8"