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.

Single file:

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

Many files, concurrently, with per-file error isolation (nothing raises out):

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:

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 "container #{payload.item&.id}" }

Defined Under Namespace

Classes: Error, 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
MAX_THREADS =
8

Instance Method Summary collapse

Constructor Details

#initialize(graphql_client) ⇒ Client

Returns a new instance of Client.



80
81
82
83
# File 'lib/ecoportal/api/graphql/file_upload/client.rb', line 80

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).

Parameters:

  • stage (:signature, :storage, :register)

Raises:

  • (ArgumentError)


88
89
90
91
92
93
94
# File 'lib/ecoportal/api/graphql/file_upload/client.rb', line 88

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).



135
136
137
138
# File 'lib/ecoportal/api/graphql/file_upload/client.rb', line 135

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

#upload(file_path, **kargs) ⇒ String

Returns the file container id.

Returns:

  • (String)

    the file container id.

Raises:

  • (Error)

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



98
99
100
101
102
103
# File 'lib/ecoportal/api/graphql/file_upload/client.rb', line 98

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.

Parameters:

  • threads (Integer) (defaults to: 4)

    max concurrent uploads (1 = inline, deterministic).

Returns:

  • (Array<Result>)

    one per input file; order is not guaranteed when threaded.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/ecoportal/api/graphql/file_upload/client.rb', line 110

def upload_all(file_paths, threads: 4, **kargs, &block)
  files   = Array(file_paths).flatten.compact
  max     = threads.to_i.clamp(1, MAX_THREADS)
  results = []
  spawned = []

  # Presign ONCE for the batch (the policy is time-boxed but reusable) and warm it
  # here so N threads don't race for the first one.
  credentials

  with_preserved_thread_globals do
    files.each do |file|
      new_thread(spawned, max: max) do
        result = upload_one(file, **kargs)
        mutex(:results).synchronize { results << result }
        block&.call(result)
      end
    end
  end

  spawned.each(&:join)
  results
end