ecoportal-api-graphql 1.3.10 → 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/diff_pairing_engine.md +243 -0
- data/.ai-assistance/code/filter_contract_matrix.md +177 -0
- data/.ai-assistance/code/graphql_domain_knowledge.md +20 -10
- data/.ai-assistance/code/template_diff_pairing_domain.md +175 -0
- data/.ai-assistance/code/workflow-command-guide.md +28 -0
- data/.ai-assistance/projects/ooze-graphql-native-migration/INVENTORY.md +136 -0
- data/.ai-assistance/projects/ooze-graphql-native-migration/TODO.md +6 -1
- data/.ai-assistance/projects/qa-services-delivery/DECISIONS.md +93 -0
- data/.ai-assistance/projects/qa-services-delivery/INTENT.md +76 -0
- data/.ai-assistance/projects/qa-services-delivery/PHASE3-SCOPE.md +115 -0
- data/.ai-assistance/projects/qa-services-delivery/ROADMAP.md +99 -0
- data/.ai-assistance/projects/qa-services-delivery/TODO.md +81 -0
- data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +87 -0
- data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +108 -0
- data/.ai-assistance/projects/template-diff-deploy/INTENT.md +12 -0
- data/.ai-assistance/projects/template-diff-deploy/TODO.md +9 -0
- data/.ai-assistance/projects/template-maintenance/PHASE0-FINDINGS.md +93 -0
- data/.ai-assistance/projects/template-maintenance/README.md +14 -0
- data/.ai-assistance/skills/procedural-memory/SKILL.md +319 -0
- data/.ai-assistance/standards-version.json +21 -20
- data/CHANGELOG.md +119 -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 +256 -0
- data/ecoportal-api-graphql.gemspec +1 -1
- 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/data_field.rb +1 -1
- 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/builder/template_builder.rb +174 -0
- data/lib/ecoportal/api/graphql/builder.rb +17 -16
- 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/diff/change.rb +59 -0
- data/lib/ecoportal/api/graphql/diff/command_synthesizer.rb +329 -0
- data/lib/ecoportal/api/graphql/diff/cross_object_diff.rb +165 -0
- data/lib/ecoportal/api/graphql/diff/deploy.rb +121 -0
- data/lib/ecoportal/api/graphql/diff/id_resolver.rb +64 -0
- data/lib/ecoportal/api/graphql/diff/pairing/candidate.rb +32 -0
- data/lib/ecoportal/api/graphql/diff/pairing/engine.rb +173 -0
- data/lib/ecoportal/api/graphql/diff/pairing/ledger.rb +119 -0
- data/lib/ecoportal/api/graphql/diff/pairing/signals.rb +104 -0
- data/lib/ecoportal/api/graphql/diff/strategy.rb +113 -0
- data/lib/ecoportal/api/graphql/diff/version_diff.rb +332 -0
- data/lib/ecoportal/api/graphql/diff.rb +34 -0
- 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/fragment/pages/common_page_union.rb +1 -0
- 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/input/workflow_command/add_field.rb +27 -18
- 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/mutation/action/archive.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/action/create.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/action/update.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/contractor_entity/create.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/contractor_entity/destroy.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/contractor_entity/update.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/kickstand/fail_workflow.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/kickstand/start_workflow.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/kickstand/stop_workflow.rb +1 -1
- data/lib/ecoportal/api/graphql.rb +1 -0
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- data/tests/dump_template_model.rb +90 -0
- data/tests/validate_queries.rb +31 -9
- metadata +40 -3
|
@@ -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
|
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
module Ecoportal
|
|
2
|
-
module API
|
|
3
|
-
class GraphQL
|
|
4
|
-
class Fragment
|
|
5
|
-
# Shared fields for both BasicPage and PhasedPage (spread on BasePageInterface).
|
|
6
|
-
# Always include __typename so PageUnion can dispatch to the right concrete class.
|
|
7
|
-
fragment :PageFields, <<~GRAPHQL
|
|
8
|
-
fragment PageFields on BasePageInterface {
|
|
9
|
-
__typename
|
|
10
|
-
id
|
|
11
|
-
name
|
|
12
|
-
uid
|
|
13
|
-
icon
|
|
14
|
-
patchVer
|
|
15
|
-
state
|
|
16
|
-
archived
|
|
17
|
-
archivedAt { dateTime }
|
|
18
|
-
draft
|
|
19
|
-
externalId
|
|
20
|
-
timeZone
|
|
21
|
-
otherTags
|
|
22
|
-
taskPriority
|
|
23
|
-
sourceTemplateId
|
|
24
|
-
createdAt { dateTime }
|
|
25
|
-
updatedAt { dateTime }
|
|
26
|
-
creatorName
|
|
27
|
-
locations {
|
|
28
|
-
...LocationNode
|
|
29
|
-
}
|
|
30
|
-
workflow {
|
|
31
|
-
autoPageCreationEnabled
|
|
32
|
-
fields { key name type total missing }
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
GRAPHQL
|
|
36
|
-
|
|
37
|
-
# BasicPage-only fields — spread alongside PageFields for basic pages.
|
|
38
|
-
# BasicPage has sections directly (PhasedPage sections live under stages).
|
|
39
|
-
fragment :BasicPageFields, <<~GRAPHQL
|
|
40
|
-
fragment BasicPageFields on BasicPage {
|
|
41
|
-
sections {
|
|
42
|
-
__typename
|
|
43
|
-
... on ContentSection {
|
|
44
|
-
id
|
|
45
|
-
heading
|
|
46
|
-
}
|
|
47
|
-
... on SplitSection {
|
|
48
|
-
id
|
|
49
|
-
heading
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
GRAPHQL
|
|
54
|
-
|
|
55
|
-
# PhasedPage-only fields — spread alongside PageFields for phased pages.
|
|
56
|
-
# Provides lightweight stage index, current stage, and active stages.
|
|
57
|
-
# For full stage content (sections, data fields), use CommonPageUnion.
|
|
58
|
-
fragment :PhasedPageFields, <<~GRAPHQL
|
|
59
|
-
fragment PhasedPageFields on PhasedPage {
|
|
60
|
-
stagesIndex {
|
|
61
|
-
id
|
|
62
|
-
name
|
|
63
|
-
ordering
|
|
64
|
-
state
|
|
65
|
-
started
|
|
66
|
-
hasCompleteTask
|
|
67
|
-
hasReviewTask
|
|
68
|
-
}
|
|
69
|
-
currentStage {
|
|
70
|
-
id
|
|
71
|
-
name
|
|
72
|
-
ordering
|
|
73
|
-
state
|
|
74
|
-
}
|
|
75
|
-
activeStages {
|
|
76
|
-
id
|
|
77
|
-
name
|
|
78
|
-
ordering
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
GRAPHQL
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
class Fragment
|
|
5
|
+
# Shared fields for both BasicPage and PhasedPage (spread on BasePageInterface).
|
|
6
|
+
# Always include __typename so PageUnion can dispatch to the right concrete class.
|
|
7
|
+
fragment :PageFields, <<~GRAPHQL
|
|
8
|
+
fragment PageFields on BasePageInterface {
|
|
9
|
+
__typename
|
|
10
|
+
id
|
|
11
|
+
name
|
|
12
|
+
uid
|
|
13
|
+
icon
|
|
14
|
+
patchVer
|
|
15
|
+
state
|
|
16
|
+
archived
|
|
17
|
+
archivedAt { dateTime }
|
|
18
|
+
draft
|
|
19
|
+
externalId
|
|
20
|
+
timeZone
|
|
21
|
+
otherTags
|
|
22
|
+
taskPriority
|
|
23
|
+
sourceTemplateId
|
|
24
|
+
createdAt { dateTime }
|
|
25
|
+
updatedAt { dateTime }
|
|
26
|
+
creatorName
|
|
27
|
+
locations {
|
|
28
|
+
...LocationNode
|
|
29
|
+
}
|
|
30
|
+
workflow {
|
|
31
|
+
autoPageCreationEnabled
|
|
32
|
+
fields { key name type total missing }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
GRAPHQL
|
|
36
|
+
|
|
37
|
+
# BasicPage-only fields — spread alongside PageFields for basic pages.
|
|
38
|
+
# BasicPage has sections directly (PhasedPage sections live under stages).
|
|
39
|
+
fragment :BasicPageFields, <<~GRAPHQL
|
|
40
|
+
fragment BasicPageFields on BasicPage {
|
|
41
|
+
sections {
|
|
42
|
+
__typename
|
|
43
|
+
... on ContentSection {
|
|
44
|
+
id
|
|
45
|
+
heading
|
|
46
|
+
}
|
|
47
|
+
... on SplitSection {
|
|
48
|
+
id
|
|
49
|
+
heading
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
GRAPHQL
|
|
54
|
+
|
|
55
|
+
# PhasedPage-only fields — spread alongside PageFields for phased pages.
|
|
56
|
+
# Provides lightweight stage index, current stage, and active stages.
|
|
57
|
+
# For full stage content (sections, data fields), use CommonPageUnion.
|
|
58
|
+
fragment :PhasedPageFields, <<~GRAPHQL
|
|
59
|
+
fragment PhasedPageFields on PhasedPage {
|
|
60
|
+
stagesIndex {
|
|
61
|
+
id
|
|
62
|
+
name
|
|
63
|
+
ordering
|
|
64
|
+
state
|
|
65
|
+
started
|
|
66
|
+
hasCompleteTask
|
|
67
|
+
hasReviewTask
|
|
68
|
+
}
|
|
69
|
+
currentStage {
|
|
70
|
+
id
|
|
71
|
+
name
|
|
72
|
+
ordering
|
|
73
|
+
state
|
|
74
|
+
}
|
|
75
|
+
activeStages {
|
|
76
|
+
id
|
|
77
|
+
name
|
|
78
|
+
ordering
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
GRAPHQL
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
module Ecoportal
|
|
2
|
-
module API
|
|
3
|
-
class GraphQL
|
|
4
|
-
module Input
|
|
5
|
-
module Action
|
|
6
|
-
class Update < Ecoportal::API::GraphQL::Base::Action
|
|
7
|
-
passthrough :clientMutationId
|
|
8
|
-
passthrough :updateMode
|
|
9
|
-
end
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
end
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Input
|
|
5
|
+
module Action
|
|
6
|
+
class Update < Ecoportal::API::GraphQL::Base::Action
|
|
7
|
+
passthrough :clientMutationId
|
|
8
|
+
passthrough :updateMode
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
module Ecoportal
|
|
2
|
-
module API
|
|
3
|
-
class GraphQL
|
|
4
|
-
module Input
|
|
5
|
-
module ContractorEntity
|
|
6
|
-
class Update < Input::ContractorEntity::Create
|
|
7
|
-
passthrough :clientMutationId
|
|
8
|
-
class_resolver :id_diff_input, "Ecoportal::API::GraphQL::Input::IdDiff"
|
|
9
|
-
embeds_one :associatedPeopleIds, klass: :id_diff_input
|
|
10
|
-
embeds_one :leadContractorIds, klass: :id_diff_input
|
|
11
|
-
|
|
12
|
-
ID_DIFF_FIELDS = %i[associatedPeopleIds leadContractorIds].freeze
|
|
13
|
-
|
|
14
|
-
class << self
|
|
15
|
-
# Overrides Logic::Input.from_model to reshape array fields into
|
|
16
|
-
# IdDiffInput format: { additions: [...], removals: [...] }
|
|
17
|
-
# Array fields are stripped from the flat diff, so we compare
|
|
18
|
-
# doc vs original_doc directly rather than relying on the diff hash.
|
|
19
|
-
def from_model(model, **kargs)
|
|
20
|
-
diff = Logic::Input.from_model(model, **kargs)
|
|
21
|
-
|
|
22
|
-
ID_DIFF_FIELDS.each do |field|
|
|
23
|
-
key = field.to_s
|
|
24
|
-
current = Array(model.doc[key])
|
|
25
|
-
original = Array(model.original_doc[key])
|
|
26
|
-
next if current == original
|
|
27
|
-
|
|
28
|
-
diff ||= {}
|
|
29
|
-
diff[:id] ||= model.id
|
|
30
|
-
diff[field] = { additions: current - original, removals: original - current }
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
diff
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Input
|
|
5
|
+
module ContractorEntity
|
|
6
|
+
class Update < Input::ContractorEntity::Create
|
|
7
|
+
passthrough :clientMutationId
|
|
8
|
+
class_resolver :id_diff_input, "Ecoportal::API::GraphQL::Input::IdDiff"
|
|
9
|
+
embeds_one :associatedPeopleIds, klass: :id_diff_input
|
|
10
|
+
embeds_one :leadContractorIds, klass: :id_diff_input
|
|
11
|
+
|
|
12
|
+
ID_DIFF_FIELDS = %i[associatedPeopleIds leadContractorIds].freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Overrides Logic::Input.from_model to reshape array fields into
|
|
16
|
+
# IdDiffInput format: { additions: [...], removals: [...] }
|
|
17
|
+
# Array fields are stripped from the flat diff, so we compare
|
|
18
|
+
# doc vs original_doc directly rather than relying on the diff hash.
|
|
19
|
+
def from_model(model, **kargs)
|
|
20
|
+
diff = Logic::Input.from_model(model, **kargs)
|
|
21
|
+
|
|
22
|
+
ID_DIFF_FIELDS.each do |field|
|
|
23
|
+
key = field.to_s
|
|
24
|
+
current = Array(model.doc[key])
|
|
25
|
+
original = Array(model.original_doc[key])
|
|
26
|
+
next if current == original
|
|
27
|
+
|
|
28
|
+
diff ||= {}
|
|
29
|
+
diff[:id] ||= model.id
|
|
30
|
+
diff[field] = { additions: current - original, removals: original - current }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
diff
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|