Class: Ecoportal::API::GraphQL::FileUpload::Client

Inherits:
Object
  • Object
show all
Includes:
Concerns::Threadable
Defined in:
lib/ecoportal/api/graphql/file_upload/client.rb

Overview

Uploads local files to the org file manager, natively.

★ Rewritten 2026-07-30 from a CAPTURED web upload (see the har-scrub skill). The real flow is three steps, all GraphQL except the storage POST:

1. `FileSignature` (GraphQL) -> presigned policy + credentials
2. multipart POST  (S3)      -> 204; fields, in order: key, AWSAccessKeyId, policy,
                              signature, content-type,
                              x-amz-server-side-encryption, file
3. `uploadFile`    (GraphQL) -> the FileContainer; its `id` is what page mutations want

The previous implementation was a port of ecoportal-api-v2's REST S3 code and did POST /api/v2/<org>/s3/files + polling. The platform does not use those endpoints for this flow — no register-over-REST, no poll. That is also why no X-ECOPORTAL-API-KEY is needed here: every ecoPortal call is GraphQL, on the session token this client already holds.

★ Extended 2026-09 with #upload_image/#upload_all_images — steps 1-2 above (the presign + S3 POST) are IDENTICAL for an Image Gallery image; only step 3 differs (uploadImage -> a TempImage, not uploadFile -> a FileContainer). Confirmed against the live ecoPortal server source (read-only, /tmp/work/ecoPortal) that an Image Gallery image is genuinely NOT a FileContainer — see Mutation::Image::Upload's and Base::Page::DataField::ImageGallery's own headers for the full evidence trail. #presign_and_store! is the shared internal path both #upload_one and #upload_one_image call; #run_batch is the shared batch-with- progress-callback loop both #upload_all and #upload_all_images call.

Single file, FileContainer (File-type fields):

id = api.file_upload.upload('/path/report.pdf')
page.components.get_by_name('Report').file_container_ids = [id]

Single file, TempImage (Image Gallery fields):

source_id = api.file_upload.upload_image('/path/photo.jpg')
page.components.get_by_name('Site Photos').add_source_images([{source_id: source_id, file_name: 'photo.jpg'}])

Many files, concurrently, with per-file error isolation (nothing raises out) — same shape for both flows:

results = api.file_upload.upload_all(paths, threads: 4) do |r|
puts r.success? ? "#{r.file} -> #{r.container_id}" : "#{r.file} FAILED: #{r.error}"
end
results.select(&:error?)

Instrumentation / middleware — hooks fire per stage, per file, for EITHER flow (the stage names are shared: :register fires once whether the registration mutation was uploadFile or uploadImage):

client = api.file_upload
client.on(:signature) { |creds|         logger.info "presigned #{creds.endpoint}" }
client.on(:storage)   { |key, response| logger.info "S3 #{response.code} #{key}" }
client.on(:register)  { |payload|       logger.info "registered #{payload.item&.id}" }

Defined Under Namespace

Classes: Error, ImageResult, MissingLocalFile, RegistrationFailed, Result, StorageUploadFailed

Constant Summary collapse

STAGES =
%i[signature storage register].freeze
DEFAULT_ENCRYPTION =

Fallback only — the real value is a condition inside the returned policy.

'AES256'.freeze
DEFAULT_MIME =
'application/octet-stream'.freeze
DEFAULT_IMAGE_TYPE =
'image_gallery'.freeze
MAX_THREADS =
8

Instance Method Summary collapse

Constructor Details

#initialize(graphql_client) ⇒ Client



120
121
122
123
# File 'lib/ecoportal/api/graphql/file_upload/client.rb', line 120

def initialize(graphql_client)
  @graphql = graphql_client
  @hooks   = {}
end

Instance Method Details

#on(stage, &block) ⇒ Object

Register a stage hook. Called for every file, in that file's own thread — keep it thread-safe (or wrap it in your own mutex). Fires identically for #upload and #upload_image — the STAGE names are shared between both flows.

Raises:

  • (ArgumentError)


129
130
131
132
133
134
135
# File 'lib/ecoportal/api/graphql/file_upload/client.rb', line 129

def on(stage, &block)
  stage = stage.to_sym
  raise ArgumentError, "unknown stage #{stage.inspect}; expected one of #{STAGES.join(', ')}" unless STAGES.include?(stage)

  mutex(:hooks).synchronize { (@hooks[stage] ||= []) << block }
  self
end

#refresh_credentials! ⇒ Object

Force the next upload to presign again (e.g. after a policy expiry).



175
176
177
178
# File 'lib/ecoportal/api/graphql/file_upload/client.rb', line 175

def refresh_credentials!
  mutex(:credentials).synchronize { @credentials = nil }
  self
end

#upload(file_path, **kargs) ⇒ String

Returns the file container id.

Raises:

  • (Error) —

    on any failure — use #upload_all for non-raising per-file isolation.



139
140
141
142
143
144
# File 'lib/ecoportal/api/graphql/file_upload/client.rb', line 139

def upload(file_path, **kargs)
  result = upload_one(file_path, **kargs)
  raise result.error if result.error?

  result.container_id
end

#upload_all(file_paths, threads: 4, **kargs, &block) ⇒ Array<Result>

Uploads many files with bounded concurrency. Never raises for a single file: each Result carries its own error, and the block is called as each finishes.



163
164
165
# File 'lib/ecoportal/api/graphql/file_upload/client.rb', line 163

def upload_all(file_paths, threads: 4, **kargs, &block)
  run_batch(file_paths, threads: threads, on_result: block) { |file| upload_one(file, **kargs) }
end

#upload_all_images(file_paths, threads: 4, **kargs, &block) ⇒ Array<ImageResult>

Sibling to #upload_all for the image-upload pipeline — identical concurrency, isolation and progress-callback semantics.



170
171
172
# File 'lib/ecoportal/api/graphql/file_upload/client.rb', line 170

def upload_all_images(file_paths, threads: 4, **kargs, &block)
  run_batch(file_paths, threads: threads, on_result: block) { |file| upload_one_image(file, **kargs) }
end

#upload_image(file_path, **kargs) ⇒ String

Returns the new TempImage id (the sourceId an Image Gallery ImageInput write needs — see Base::Page::DataField::ImageGallery# add_source_images).

Raises:



151
152
153
154
155
156
# File 'lib/ecoportal/api/graphql/file_upload/client.rb', line 151

def upload_image(file_path, **kargs)
  result = upload_one_image(file_path, **kargs)
  raise result.error if result.error?

  result.temp_image_id
end