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.
- checksums.yaml +4 -4
- data/.ai-assistance/code/filter_contract_matrix.md +177 -0
- data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +10 -0
- data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +11 -0
- data/.ai-assistance/skills/procedural-memory/SKILL.md +319 -0
- data/.ai-assistance/standards-version.json +21 -20
- data/CHANGELOG.md +32 -0
- data/CLAUDE.md +10 -0
- data/docs/self-docs/ARCHITECTURE.md +88 -0
- data/docs/self-docs/CHANGES.jsonl +7 -0
- data/docs/self-docs/CONVENTIONS.md +74 -0
- data/docs/self-docs/INTEGRATIONS.md +63 -0
- data/docs/self-docs/OVERVIEW.md +51 -0
- data/docs/self-docs/STATUS.md +69 -0
- data/docs/self-docs/self-docs-index.json +39 -0
- data/docs/worklog.md +0 -23
- data/lib/ecoportal/api/common/graphql/model/diffable.rb +54 -54
- data/lib/ecoportal/api/graphql/base/action.rb +43 -43
- data/lib/ecoportal/api/graphql/base/contractor_entity/member_changes.rb +67 -67
- data/lib/ecoportal/api/graphql/base/page/data_field/collection.rb +7 -0
- data/lib/ecoportal/api/graphql/base/page/data_field/contractor_entities.rb +28 -28
- data/lib/ecoportal/api/graphql/base/page/data_field/file_field.rb +25 -25
- data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +24 -24
- data/lib/ecoportal/api/graphql/base/page/section_collection.rb +85 -79
- data/lib/ecoportal/api/graphql/base/preset_view.rb +17 -17
- data/lib/ecoportal/api/graphql/base/register.rb +18 -18
- data/lib/ecoportal/api/graphql/compat/filter_translator.rb +63 -28
- data/lib/ecoportal/api/graphql/concerns/page_compat.rb +51 -51
- data/lib/ecoportal/api/graphql/concerns.rb +14 -14
- data/lib/ecoportal/api/graphql/file_upload/client.rb +181 -181
- data/lib/ecoportal/api/graphql/fragment/page.rb +85 -85
- data/lib/ecoportal/api/graphql/input/action/update.rb +14 -14
- data/lib/ecoportal/api/graphql/input/contractor_entity/update.rb +41 -41
- data/lib/ecoportal/api/graphql/input/location_structure/apply_commands.rb +47 -47
- data/lib/ecoportal/api/graphql/input/location_structure/draft/add_commands.rb +49 -49
- data/lib/ecoportal/api/graphql/input/location_structure/update_command.rb +27 -27
- data/lib/ecoportal/api/graphql/input/search_conf.rb +436 -367
- data/lib/ecoportal/api/graphql/interface/location_structure/command.rb +30 -30
- data/lib/ecoportal/api/graphql/logic/input.rb +26 -26
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- 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 (
|
|
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
|
|
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
|
-
#
|
|
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]
|
|
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} →
|
|
74
|
-
#
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|