ecoportal-api-graphql 1.3.11 → 1.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.ai-assistance/code/filter_contract_matrix.md +177 -0
  3. data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +10 -0
  4. data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +11 -0
  5. data/.ai-assistance/skills/procedural-memory/SKILL.md +319 -0
  6. data/.ai-assistance/standards-version.json +21 -20
  7. data/CHANGELOG.md +32 -0
  8. data/CLAUDE.md +10 -0
  9. data/docs/self-docs/ARCHITECTURE.md +88 -0
  10. data/docs/self-docs/CHANGES.jsonl +7 -0
  11. data/docs/self-docs/CONVENTIONS.md +74 -0
  12. data/docs/self-docs/INTEGRATIONS.md +63 -0
  13. data/docs/self-docs/OVERVIEW.md +51 -0
  14. data/docs/self-docs/STATUS.md +69 -0
  15. data/docs/self-docs/self-docs-index.json +39 -0
  16. data/docs/worklog.md +0 -23
  17. data/lib/ecoportal/api/common/graphql/model/diffable.rb +54 -54
  18. data/lib/ecoportal/api/graphql/base/action.rb +43 -43
  19. data/lib/ecoportal/api/graphql/base/contractor_entity/member_changes.rb +67 -67
  20. data/lib/ecoportal/api/graphql/base/page/data_field/collection.rb +7 -0
  21. data/lib/ecoportal/api/graphql/base/page/data_field/contractor_entities.rb +28 -28
  22. data/lib/ecoportal/api/graphql/base/page/data_field/file_field.rb +25 -25
  23. data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +24 -24
  24. data/lib/ecoportal/api/graphql/base/page/section_collection.rb +85 -79
  25. data/lib/ecoportal/api/graphql/base/preset_view.rb +17 -17
  26. data/lib/ecoportal/api/graphql/base/register.rb +18 -18
  27. data/lib/ecoportal/api/graphql/compat/filter_translator.rb +63 -28
  28. data/lib/ecoportal/api/graphql/concerns/page_compat.rb +51 -51
  29. data/lib/ecoportal/api/graphql/concerns.rb +14 -14
  30. data/lib/ecoportal/api/graphql/file_upload/client.rb +181 -181
  31. data/lib/ecoportal/api/graphql/fragment/page.rb +85 -85
  32. data/lib/ecoportal/api/graphql/input/action/update.rb +14 -14
  33. data/lib/ecoportal/api/graphql/input/contractor_entity/update.rb +41 -41
  34. data/lib/ecoportal/api/graphql/input/location_structure/apply_commands.rb +47 -47
  35. data/lib/ecoportal/api/graphql/input/location_structure/draft/add_commands.rb +49 -49
  36. data/lib/ecoportal/api/graphql/input/location_structure/update_command.rb +27 -27
  37. data/lib/ecoportal/api/graphql/input/search_conf.rb +436 -367
  38. data/lib/ecoportal/api/graphql/interface/location_structure/command.rb +30 -30
  39. data/lib/ecoportal/api/graphql/logic/input.rb +26 -26
  40. data/lib/ecoportal/api/graphql_version.rb +1 -1
  41. metadata +10 -1
@@ -9,9 +9,12 @@ module Ecoportal
9
9
  # callers may also pass already-translated hashes or SearchConf instances directly.
10
10
  #
11
11
  # v2 → GraphQL translation table:
12
- # date_filter lbound/ubound → gte/lte (ES date range)
12
+ # date_filter lbound/ubound → gte/lte; Date FIELD (key 'date.zXXXX')
13
+ # range_date_filter + membranes.date path; system date → date_filter
13
14
  # text_filter {query, key} → match_filter {key, value} (partial text)
14
- # options_filter {chosen, key, mode}one_of_filter {key, values} (or none_of_filter)
15
+ # options_filter Select FIELD (key 'select_str.zXXXX')has_any_filter
16
+ # {key, path, field_key: 'selected', values} (doesnt_have_any_filter to
17
+ # exclude); system option key → one_of_filter/none_of_filter {key, values}
15
18
  # tag_filter passed through (same ES backend, same format)
16
19
  # register_filter passed through
17
20
  # and_filter / or_filter passed through
@@ -43,12 +46,18 @@ module Ecoportal
43
46
  # exact one: it matches the field's `.exact` keyword subfield (backend
44
47
  # NewEp::Es::Filters::Pages::IsFilter → ExactFilter on `membranes.<type>.exact`),
45
48
  # whereas match_filter/contains_filter hit the analyzed `.value` and miss exact SKUs.
46
- # Requires `path` (membranes.<type>) derived from the key prefix when not given.
49
+ # A register FIELD key ('plain_text.zXXXX') needs the nested membranes.<type> path;
50
+ # a SYSTEM key (external_id/id/state/creator_id/source) must NOT carry a path — the
51
+ # backend routes it to Global::ExactFilter which joins [@path,@key] into the term key,
52
+ # so a spurious 'membranes.external_id' yields term 'membranes.external_id.external_id'
53
+ # → 0 results (proof: pages/is_filter.rb:36-55 passes path: @path to Global::ExactFilter;
54
+ # global/exact_filter.rb:18 builds the term as [@path, @key].compact.join(".")).
47
55
  # CAVEAT: case-insensitive and only the first 64 chars are significant (values are
48
56
  # lowercased + truncated at index time). No case-sensitive/>64-char exact exists.
49
57
  def translate_exact_filter(filter)
50
58
  key = filter['key'] || filter[:key]
51
- path = filter['path'] || filter[:path] || membrane_path_for(key)
59
+ path = filter['path'] || filter[:path]
60
+ path ||= membrane_path_for(key) if membrane_field?(key)
52
61
  value = filter['value'] || filter[:value] || filter['query'] || filter[:query]
53
62
  { operation: 'is_filter', params: { key: key, path: path, value: value }.compact }
54
63
  end
@@ -59,6 +68,14 @@ module Ecoportal
59
68
  prefix.empty? ? nil : "membranes.#{prefix}"
60
69
  end
61
70
 
71
+ # A register FIELD key carries a `<type>.<hash>` shape (e.g. 'date.zab1bddc3',
72
+ # 'select_str.z223beb6d'); system/top-level keys (created_at, id, external_id) have
73
+ # no field-type prefix. Field filters need the nested membranes.<type> path (and,
74
+ # for options, a field_key), which system filters do not.
75
+ def membrane_field?(key)
76
+ key.to_s.include?('.')
77
+ end
78
+
62
79
  # v2 text_filter {query, key} → GraphQL match_filter (partial/full-text on key).
63
80
  def translate_text_filter(filter)
64
81
  {
@@ -70,34 +87,52 @@ module Ecoportal
70
87
  }
71
88
  end
72
89
 
73
- # v2 options_filter {chosen, key, mode} → one_of_filter (match any chosen) or
74
- # none_of_filter (exclude) both take {key, values}.
90
+ # v2 options_filter {chosen, key, mode} → GraphQL.
91
+ # A register Select FIELD (key like 'select_str.zXXXX') filters via
92
+ # has_any_filter / doesnt_have_any_filter over its nested path
93
+ # (membranes.select_str) with field_key 'selected' and the chosen option ids as
94
+ # `values` (backend NewEp::Es::Filters::Pages::*::HasAnyFilter). A system/top-level
95
+ # option key (e.g. 'id') keeps one_of_filter / none_of_filter with {key, values}.
75
96
  def translate_options_filter(filter)
76
- mode = (filter['mode'] || filter[:mode]).to_s.downcase
77
- op = %w[none exclude not not_in].include?(mode) ? 'none_of_filter' : 'one_of_filter'
78
- {
79
- operation: op,
80
- params: {
81
- key: filter['key'] || filter[:key],
82
- values: Array(filter['chosen'] || filter[:chosen])
83
- }
84
- }
97
+ key = filter['key'] || filter[:key]
98
+ values = Array(filter['chosen'] || filter[:chosen])
99
+ exclude = exclude_mode?(filter)
100
+
101
+ unless membrane_field?(key)
102
+ op = exclude ? 'none_of_filter' : 'one_of_filter'
103
+ return { operation: op, params: { key: key, values: values } }
104
+ end
105
+
106
+ op = exclude ? 'doesnt_have_any_filter' : 'has_any_filter'
107
+ path = filter['path'] || filter[:path] || membrane_path_for(key)
108
+ field_key = filter['field_key'] || filter[:field_key] || 'selected'
109
+ { operation: op, params: { key: key, path: path, field_key: field_key, values: values } }
85
110
  end
86
111
 
112
+ # v2 date_filter → GraphQL. A register Date FIELD (key like 'date.zXXXX') filters
113
+ # via range_date_filter on its nested path (membranes.date); a system/top-level date
114
+ # (created_at/updated_at) uses date_filter. Both share range params (gte/lte/time_zone)
115
+ # — range_date_filter additionally carries `path` (backend BaseRangeDateFilter < DateFilter).
87
116
  def translate_date_filter(filter)
88
- key = filter['key'] || filter[:key]
89
- {
90
- operation: 'date_filter',
91
- params: {
92
- key: key,
93
- time_zone: filter['time_zone'] || filter[:time_zone] || 'UTC'
94
- }.tap do |params|
95
- lbound = filter['lbound'] || filter[:lbound]
96
- ubound = filter['ubound'] || filter[:ubound]
97
- params[:gte] = lbound if lbound
98
- params[:lte] = ubound if ubound
99
- end
100
- }
117
+ key = filter['key'] || filter[:key]
118
+ field = membrane_field?(key)
119
+ path = filter['path'] || filter[:path]
120
+ path ||= membrane_path_for(key) if field
121
+
122
+ params = { key: key, time_zone: filter['time_zone'] || filter[:time_zone] || 'UTC' }
123
+ params[:path] = path if path
124
+ lbound = filter['lbound'] || filter[:lbound]
125
+ ubound = filter['ubound'] || filter[:ubound]
126
+ params[:gte] = lbound if lbound
127
+ params[:lte] = ubound if ubound
128
+
129
+ { operation: field ? 'range_date_filter' : 'date_filter', params: params }
130
+ end
131
+
132
+ # "exclude" style modes map to the negative filter operation.
133
+ def exclude_mode?(filter)
134
+ mode = (filter['mode'] || filter[:mode]).to_s.downcase
135
+ %w[none exclude not not_in].include?(mode)
101
136
  end
102
137
  end
103
138
  end
@@ -1,51 +1,51 @@
1
- module Ecoportal
2
- module API
3
- class GraphQL
4
- module Concerns
5
- # v2-compatibility accessors for page model objects.
6
- # Include in Interface::BasePage so all concrete page classes inherit them.
7
- # These aliases allow eco-helpers scripts to work against GraphQL page models
8
- # using the same method names they used with ecoportal-api-v2.
9
- module PageCompat
10
- # v2 called this externalId (camelCase) or external_id (snake_case)
11
- def external_id
12
- externalId
13
- end
14
-
15
- # v2 called sourceTemplateId "template_id"
16
- def template_id
17
- sourceTemplateId
18
- end
19
-
20
- # v2 compatibility: dataFields were called "components" in APIv2
21
- def components
22
- respond_to?(:dataFields) ? dataFields : []
23
- end
24
-
25
- # Whether the page has any stages (PhasedPage check)
26
- def stages?
27
- respond_to?(:stages) && !stages.nil? && !stages.empty?
28
- end
29
-
30
- # Clears dirty tracking — call after a successful update.
31
- # Mirrors v2's ooze.consolidate! which resets the change baseline.
32
- def consolidate!
33
- @original_doc = doc.dup if respond_to?(:doc)
34
- self
35
- end
36
-
37
- # v2 validate stub — always returns nil (no client-side validation in GraphQL path).
38
- # Server-side errors are returned in the mutation payload's errors field.
39
- def validate
40
- nil
41
- end
42
-
43
- # Dirty check — returns true if there are pending changes.
44
- def dirty?
45
- !as_update.nil?
46
- end
47
- end
48
- end
49
- end
50
- end
51
- end
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Concerns
5
+ # v2-compatibility accessors for page model objects.
6
+ # Include in Interface::BasePage so all concrete page classes inherit them.
7
+ # These aliases allow eco-helpers scripts to work against GraphQL page models
8
+ # using the same method names they used with ecoportal-api-v2.
9
+ module PageCompat
10
+ # v2 called this externalId (camelCase) or external_id (snake_case)
11
+ def external_id
12
+ externalId
13
+ end
14
+
15
+ # v2 called sourceTemplateId "template_id"
16
+ def template_id
17
+ sourceTemplateId
18
+ end
19
+
20
+ # v2 compatibility: dataFields were called "components" in APIv2
21
+ def components
22
+ respond_to?(:dataFields) ? dataFields : []
23
+ end
24
+
25
+ # Whether the page has any stages (PhasedPage check)
26
+ def stages?
27
+ respond_to?(:stages) && !stages.nil? && !stages.empty?
28
+ end
29
+
30
+ # Clears dirty tracking — call after a successful update.
31
+ # Mirrors v2's ooze.consolidate! which resets the change baseline.
32
+ def consolidate!
33
+ @original_doc = doc.dup if respond_to?(:doc)
34
+ self
35
+ end
36
+
37
+ # v2 validate stub — always returns nil (no client-side validation in GraphQL path).
38
+ # Server-side errors are returned in the mutation payload's errors field.
39
+ def validate
40
+ nil
41
+ end
42
+
43
+ # Dirty check — returns true if there are pending changes.
44
+ def dirty?
45
+ !as_update.nil?
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,14 +1,14 @@
1
- module Ecoportal
2
- module API
3
- class GraphQL
4
- module Concerns
5
- end
6
- end
7
- end
8
- end
9
-
10
- require_relative 'concerns/deprecation'
11
- require_relative 'concerns/fragment_definitions'
12
- require_relative 'concerns/snake_camel_access'
13
- require_relative 'concerns/page_compat'
14
- require_relative 'concerns/data_field_access'
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Concerns
5
+ end
6
+ end
7
+ end
8
+ end
9
+
10
+ require_relative 'concerns/deprecation'
11
+ require_relative 'concerns/fragment_definitions'
12
+ require_relative 'concerns/snake_camel_access'
13
+ require_relative 'concerns/page_compat'
14
+ require_relative 'concerns/data_field_access'
@@ -1,181 +1,181 @@
1
- require 'net/http'
2
- require 'uri'
3
- require 'json'
4
- require 'mime/types'
5
-
6
- module Ecoportal
7
- module API
8
- class GraphQL
9
- module FileUpload
10
- # Orchestrates the full file upload workflow:
11
- #
12
- # Step 1 — Get S3 pre-signed credentials via GraphQL
13
- # Step 2 — Upload file directly to S3 (multipart form POST)
14
- # Step 3 — Register the upload with EcoPortal (REST POST /v2/s3/files)
15
- # Step 4 — Poll until the FileContainer is created
16
- #
17
- # Returns a fileContainerId string on success, suitable for use in
18
- # DataField::FileField#file_container_ids= or DataField::ImageGallery#file_container_ids=
19
- #
20
- # Usage:
21
- # container_id = api.file_upload.upload('/path/to/report.pdf')
22
- # page.components.get_by_name('Attachment').file_container_ids = [container_id]
23
- # api.pages.update(page)
24
- #
25
- # Requires the gem to have been initialised with email/pass (OAuth Bearer token).
26
- class Client
27
- POLL_INTERVAL = 2 # seconds between poll attempts
28
- POLL_MAX_WAIT = 120 # seconds before giving up
29
-
30
- def initialize(graphql_client)
31
- @graphql_client = graphql_client
32
- @http_client = graphql_client.client.http_client
33
- @org_id = @http_client.org_id
34
- @host = @http_client.host
35
- end
36
-
37
- # Upload a file and return the fileContainerId.
38
- # Raises RuntimeError on failure.
39
- def upload(file_path)
40
- raise ArgumentError, "File not found: #{file_path}" unless File.exist?(file_path)
41
-
42
- creds = fetch_s3_credentials
43
- s3_key = direct_upload_to_storage(file_path, creds)
44
- poll_id = register_upload(file_path, s3_key)
45
- poll_for_container_id(poll_id)
46
- end
47
-
48
- private
49
-
50
- # Step 1: GraphQL query → S3 presigned credentials
51
- def fetch_s3_credentials
52
- Query::FileUploadSignature.new(@graphql_client.client).query
53
- end
54
-
55
- # Step 2: Direct multipart POST to S3
56
- def direct_upload_to_storage(file_path, creds)
57
- filename = File.basename(file_path)
58
- s3_key = "#{creds.upload_prefix}#{filename}"
59
- mime_type = detect_mime_type(file_path)
60
- endpoint = URI(creds.endpoint)
61
-
62
- File.open(file_path, 'rb') do |file|
63
- Net::HTTP.start(endpoint.host, endpoint.port,
64
- use_ssl: endpoint.scheme == 'https') do |http|
65
- boundary = "EcoPortalUpload#{SecureRandom.hex(8)}"
66
- body = build_multipart(boundary, {
67
- s3_key: s3_key, access_key: creds.access_key,
68
- policy: creds.policy, signature: creds.signature,
69
- mime_type: mime_type, filename: filename
70
- }, file)
71
- request = Net::HTTP::Post.new(endpoint.path.empty? ? '/' : endpoint.path)
72
- request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
73
- request.body = body
74
- response = http.request(request)
75
- raise "S3 upload failed (#{response.code}): #{response.body}" unless response.code.to_i < 300
76
- end
77
- end
78
-
79
- s3_key
80
- end
81
-
82
- # Step 3: Register with EcoPortal backend (REST v2)
83
- def register_upload(file_path, s3_key)
84
- filename = File.basename(file_path)
85
- mime_type = detect_mime_type(file_path)
86
- filesize = File.size(file_path)
87
-
88
- body = { filename: filename, filetype: mime_type, filesize: filesize, path: s3_key }
89
- response = upload_post('/s3/files', body)
90
- raise "File registration failed: #{response.body}" unless response.code.to_i < 300
91
-
92
- JSON.parse(response.body)['id']
93
- end
94
-
95
- # Step 4: Poll until FileContainer is created; return fileContainerId
96
- def poll_for_container_id(poll_id)
97
- deadline = Time.now + POLL_MAX_WAIT
98
- loop do
99
- raise 'File upload timed out' if Time.now > deadline
100
-
101
- response = upload_get("/s3/files/poll/#{poll_id}")
102
- raise "Poll failed: #{response.body}" unless response.code.to_i < 300
103
-
104
- data = JSON.parse(response.body)
105
- status = data['status'] || data.dig('file', 'status')
106
-
107
- return data.dig('file', 'file_container_id') if data['status'] == 'success'
108
- raise "File upload failed: #{data.inspect}" if status == 'failed'
109
-
110
- sleep POLL_INTERVAL
111
- end
112
- end
113
-
114
- # --- REST helpers (v2 endpoints, Bearer token auth) ---
115
-
116
- def bearer_token
117
- @bearer_token ||= @http_client.send(:key_token)
118
- end
119
-
120
- def upload_base_url
121
- "https://#{@host}/api/v2/#{@org_id}"
122
- end
123
-
124
- def upload_get(path)
125
- uri = URI("#{upload_base_url}#{path}")
126
- request = Net::HTTP::Get.new(uri)
127
- request['Authorization'] = "Bearer #{bearer_token}"
128
- request['Accept'] = 'application/json'
129
- Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
130
- end
131
-
132
- def upload_post(path, body)
133
- uri = URI("#{upload_base_url}#{path}")
134
- request = Net::HTTP::Post.new(uri)
135
- request['Authorization'] = "Bearer #{bearer_token}"
136
- request['Content-Type'] = 'application/json'
137
- request['Accept'] = 'application/json'
138
- request.body = JSON.generate(body)
139
- Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
140
- end
141
-
142
- # --- Multipart builder ---
143
-
144
- def build_multipart(boundary, opts, file_io)
145
- parts = {
146
- 'key' => opts[:s3_key],
147
- 'AWSAccessKeyId' => opts[:access_key],
148
- 'policy' => opts[:policy],
149
- 'signature' => opts[:signature],
150
- 'x-amz-server-side-encryption' => 'AES256',
151
- 'Content-Type' => opts[:mime_type]
152
- }
153
- filename = opts[:filename]
154
- mime_type = opts[:mime_type]
155
-
156
- body = +''
157
- parts.each do |name, value|
158
- body << "--#{boundary}\r\n"
159
- body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
160
- body << "#{value}\r\n"
161
- end
162
- # File field last
163
- body << "--#{boundary}\r\n"
164
- body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
165
- body << "Content-Type: #{mime_type}\r\n\r\n"
166
- body << file_io.read
167
- body << "\r\n--#{boundary}--\r\n"
168
- body
169
- end
170
-
171
- def detect_mime_type(file_path)
172
- types = MIME::Types.type_for(file_path)
173
- types.first&.to_s || 'application/octet-stream'
174
- rescue StandardError
175
- 'application/octet-stream'
176
- end
177
- end
178
- end
179
- end
180
- end
181
- end
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'mime/types'
5
+
6
+ module Ecoportal
7
+ module API
8
+ class GraphQL
9
+ module FileUpload
10
+ # Orchestrates the full file upload workflow:
11
+ #
12
+ # Step 1 — Get S3 pre-signed credentials via GraphQL
13
+ # Step 2 — Upload file directly to S3 (multipart form POST)
14
+ # Step 3 — Register the upload with EcoPortal (REST POST /v2/s3/files)
15
+ # Step 4 — Poll until the FileContainer is created
16
+ #
17
+ # Returns a fileContainerId string on success, suitable for use in
18
+ # DataField::FileField#file_container_ids= or DataField::ImageGallery#file_container_ids=
19
+ #
20
+ # Usage:
21
+ # container_id = api.file_upload.upload('/path/to/report.pdf')
22
+ # page.components.get_by_name('Attachment').file_container_ids = [container_id]
23
+ # api.pages.update(page)
24
+ #
25
+ # Requires the gem to have been initialised with email/pass (OAuth Bearer token).
26
+ class Client
27
+ POLL_INTERVAL = 2 # seconds between poll attempts
28
+ POLL_MAX_WAIT = 120 # seconds before giving up
29
+
30
+ def initialize(graphql_client)
31
+ @graphql_client = graphql_client
32
+ @http_client = graphql_client.client.http_client
33
+ @org_id = @http_client.org_id
34
+ @host = @http_client.host
35
+ end
36
+
37
+ # Upload a file and return the fileContainerId.
38
+ # Raises RuntimeError on failure.
39
+ def upload(file_path)
40
+ raise ArgumentError, "File not found: #{file_path}" unless File.exist?(file_path)
41
+
42
+ creds = fetch_s3_credentials
43
+ s3_key = direct_upload_to_storage(file_path, creds)
44
+ poll_id = register_upload(file_path, s3_key)
45
+ poll_for_container_id(poll_id)
46
+ end
47
+
48
+ private
49
+
50
+ # Step 1: GraphQL query → S3 presigned credentials
51
+ def fetch_s3_credentials
52
+ Query::FileUploadSignature.new(@graphql_client.client).query
53
+ end
54
+
55
+ # Step 2: Direct multipart POST to S3
56
+ def direct_upload_to_storage(file_path, creds)
57
+ filename = File.basename(file_path)
58
+ s3_key = "#{creds.upload_prefix}#{filename}"
59
+ mime_type = detect_mime_type(file_path)
60
+ endpoint = URI(creds.endpoint)
61
+
62
+ File.open(file_path, 'rb') do |file|
63
+ Net::HTTP.start(endpoint.host, endpoint.port,
64
+ use_ssl: endpoint.scheme == 'https') do |http|
65
+ boundary = "EcoPortalUpload#{SecureRandom.hex(8)}"
66
+ body = build_multipart(boundary, {
67
+ s3_key: s3_key, access_key: creds.access_key,
68
+ policy: creds.policy, signature: creds.signature,
69
+ mime_type: mime_type, filename: filename
70
+ }, file)
71
+ request = Net::HTTP::Post.new(endpoint.path.empty? ? '/' : endpoint.path)
72
+ request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
73
+ request.body = body
74
+ response = http.request(request)
75
+ raise "S3 upload failed (#{response.code}): #{response.body}" unless response.code.to_i < 300
76
+ end
77
+ end
78
+
79
+ s3_key
80
+ end
81
+
82
+ # Step 3: Register with EcoPortal backend (REST v2)
83
+ def register_upload(file_path, s3_key)
84
+ filename = File.basename(file_path)
85
+ mime_type = detect_mime_type(file_path)
86
+ filesize = File.size(file_path)
87
+
88
+ body = { filename: filename, filetype: mime_type, filesize: filesize, path: s3_key }
89
+ response = upload_post('/s3/files', body)
90
+ raise "File registration failed: #{response.body}" unless response.code.to_i < 300
91
+
92
+ JSON.parse(response.body)['id']
93
+ end
94
+
95
+ # Step 4: Poll until FileContainer is created; return fileContainerId
96
+ def poll_for_container_id(poll_id)
97
+ deadline = Time.now + POLL_MAX_WAIT
98
+ loop do
99
+ raise 'File upload timed out' if Time.now > deadline
100
+
101
+ response = upload_get("/s3/files/poll/#{poll_id}")
102
+ raise "Poll failed: #{response.body}" unless response.code.to_i < 300
103
+
104
+ data = JSON.parse(response.body)
105
+ status = data['status'] || data.dig('file', 'status')
106
+
107
+ return data.dig('file', 'file_container_id') if data['status'] == 'success'
108
+ raise "File upload failed: #{data.inspect}" if status == 'failed'
109
+
110
+ sleep POLL_INTERVAL
111
+ end
112
+ end
113
+
114
+ # --- REST helpers (v2 endpoints, Bearer token auth) ---
115
+
116
+ def bearer_token
117
+ @bearer_token ||= @http_client.send(:key_token)
118
+ end
119
+
120
+ def upload_base_url
121
+ "https://#{@host}/api/v2/#{@org_id}"
122
+ end
123
+
124
+ def upload_get(path)
125
+ uri = URI("#{upload_base_url}#{path}")
126
+ request = Net::HTTP::Get.new(uri)
127
+ request['Authorization'] = "Bearer #{bearer_token}"
128
+ request['Accept'] = 'application/json'
129
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
130
+ end
131
+
132
+ def upload_post(path, body)
133
+ uri = URI("#{upload_base_url}#{path}")
134
+ request = Net::HTTP::Post.new(uri)
135
+ request['Authorization'] = "Bearer #{bearer_token}"
136
+ request['Content-Type'] = 'application/json'
137
+ request['Accept'] = 'application/json'
138
+ request.body = JSON.generate(body)
139
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
140
+ end
141
+
142
+ # --- Multipart builder ---
143
+
144
+ def build_multipart(boundary, opts, file_io)
145
+ parts = {
146
+ 'key' => opts[:s3_key],
147
+ 'AWSAccessKeyId' => opts[:access_key],
148
+ 'policy' => opts[:policy],
149
+ 'signature' => opts[:signature],
150
+ 'x-amz-server-side-encryption' => 'AES256',
151
+ 'Content-Type' => opts[:mime_type]
152
+ }
153
+ filename = opts[:filename]
154
+ mime_type = opts[:mime_type]
155
+
156
+ body = +''
157
+ parts.each do |name, value|
158
+ body << "--#{boundary}\r\n"
159
+ body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
160
+ body << "#{value}\r\n"
161
+ end
162
+ # File field last
163
+ body << "--#{boundary}\r\n"
164
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
165
+ body << "Content-Type: #{mime_type}\r\n\r\n"
166
+ body << file_io.read
167
+ body << "\r\n--#{boundary}--\r\n"
168
+ body
169
+ end
170
+
171
+ def detect_mime_type(file_path)
172
+ types = MIME::Types.type_for(file_path)
173
+ types.first&.to_s || 'application/octet-stream'
174
+ rescue StandardError
175
+ 'application/octet-stream'
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end