ecoportal-api-v2 1.1.8 → 2.0.5

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/.markdownlint.json +4 -0
  3. data/.rubocop.yml +1 -1
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +501 -374
  6. data/ecoportal-api-v2.gemspec +13 -12
  7. data/lib/ecoportal/api/common/concerns/benchmarkable.rb +47 -34
  8. data/lib/ecoportal/api/common/concerns/threadable.rb +41 -0
  9. data/lib/ecoportal/api/common/concerns.rb +1 -0
  10. data/lib/ecoportal/api/common/content/array_model.rb +6 -3
  11. data/lib/ecoportal/api/common/content/class_helpers.rb +12 -8
  12. data/lib/ecoportal/api/common/content/collection_model.rb +7 -7
  13. data/lib/ecoportal/api/common/content/wrapped_response.rb +11 -11
  14. data/lib/ecoportal/api/v2/page/component/reference_field.rb +17 -13
  15. data/lib/ecoportal/api/v2/page/component.rb +7 -4
  16. data/lib/ecoportal/api/v2/page/force.rb +1 -1
  17. data/lib/ecoportal/api/v2/page/stages.rb +5 -6
  18. data/lib/ecoportal/api/v2/page.rb +26 -22
  19. data/lib/ecoportal/api/v2/pages/page_stage/task.rb +63 -0
  20. data/lib/ecoportal/api/v2/pages/page_stage/tasks.rb +69 -0
  21. data/lib/ecoportal/api/v2/pages/page_stage.rb +30 -22
  22. data/lib/ecoportal/api/v2/pages/stages.rb +3 -4
  23. data/lib/ecoportal/api/v2/pages.rb +24 -14
  24. data/lib/ecoportal/api/v2/people.rb +2 -3
  25. data/lib/ecoportal/api/v2/registers.rb +28 -13
  26. data/lib/ecoportal/api/v2/s3/data.rb +27 -0
  27. data/lib/ecoportal/api/v2/s3/files/batch_upload.rb +110 -0
  28. data/lib/ecoportal/api/v2/s3/files/poll.rb +82 -0
  29. data/lib/ecoportal/api/v2/s3/files/poll_status.rb +52 -0
  30. data/lib/ecoportal/api/v2/s3/files.rb +132 -0
  31. data/lib/ecoportal/api/v2/s3/upload.rb +154 -0
  32. data/lib/ecoportal/api/v2/s3.rb +66 -0
  33. data/lib/ecoportal/api/v2.rb +10 -3
  34. data/lib/ecoportal/api/v2_version.rb +1 -1
  35. metadata +55 -54
@@ -0,0 +1,110 @@
1
+ module Ecoportal
2
+ module API
3
+ class V2
4
+ class S3
5
+ class Files
6
+ # Class service to upload multiple files in one go
7
+ class BatchUpload
8
+ include Ecoportal::API::Common::Concerns::Benchmarkable
9
+ include Ecoportal::API::Common::Concerns::Threadable
10
+
11
+ SIZE_UNITS = 1_024 # KB
12
+ MIN_UNIT = 1
13
+
14
+ attr_reader :files, :files_api
15
+
16
+ FileResult = Struct.new(:file) do
17
+ attr_accessor :poll, :s3_file_reference, :error
18
+
19
+ def error?
20
+ !error.nil?
21
+ end
22
+
23
+ def success?
24
+ poll&.success?
25
+ end
26
+
27
+ def container_id
28
+ return unless success?
29
+ poll.container_id
30
+ end
31
+ end
32
+
33
+ # @param files [Array<String>] the files to be uploaded
34
+ # @param files_api [API::S3::Files] the api object.
35
+ def initialize(files, files_api:)
36
+ @files = [files].flatten.compact
37
+ @files_api = files_api
38
+ end
39
+
40
+ # Do the actual upload of the file
41
+ def upload!(benchmarking: false, threads: 1, **kargs) # rubocop:disable Metrics/AbcSize
42
+ bench = benchmarking && benchmark_enabled?
43
+ bench_mth = "#{self.class}##{__method__}"
44
+ thr_children = []
45
+ benchmarking("#{bench_mth}.#{files.count}_files", print: bench) do
46
+ with_preserved_thread_globals(report: false) do
47
+ files.each do |file|
48
+ new_thread(thr_children, max: threads) do
49
+ file_results << (result = FileResult.new(file))
50
+
51
+ s3_ref = nil
52
+
53
+ kbytes = bench ? file_size(file) : 1
54
+
55
+ benchmarking("#{bench_mth}.s3_upload (KB)", units: kbytes, print: bench) do
56
+ s3_ref = result.s3_file_reference = s3_api.upload_file(file)
57
+ end
58
+
59
+ benchmarking("##{bench_mth}.ep_poll (KB)", units: kbytes, print: bench) do
60
+ files_api.poll!(s3_ref, **kargs) do |poll|
61
+ result.poll = poll
62
+ end
63
+ end
64
+ yield(result) if block_given?
65
+ rescue *rescued_errors => err # each_file
66
+ result.error = err
67
+ yield(result) if block_given?
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ thr_children.each(&:join)
74
+ file_results
75
+ ensure
76
+ puts benchmark_summary(:all) if bench
77
+ end
78
+
79
+ private
80
+
81
+ def file_size(file, bytes: SIZE_UNITS)
82
+ return 1 unless File.exist?(file)
83
+
84
+ units = (File.size(file) / bytes).round(10)
85
+ [MIN_UNIT, units].max
86
+ end
87
+
88
+ def s3_api
89
+ files_api.s3_api
90
+ end
91
+
92
+ def file_results
93
+ @file_results ||= []
94
+ end
95
+
96
+ def rescued_errors
97
+ @rescued_errors ||= [
98
+ Ecoportal::API::V2::S3::MissingLocalFile,
99
+ Ecoportal::API::V2::S3::CredentialsGetFailed,
100
+ Ecoportal::API::V2::S3::Files::FailedPollRequest,
101
+ Ecoportal::API::V2::S3::Files::CantCheckStatus,
102
+ Ecoportal::API::Errors::TimeOut
103
+ ].freeze
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,82 @@
1
+ module Ecoportal
2
+ module API
3
+ class V2
4
+ class S3
5
+ class Files
6
+ class Poll < Common::Content::DoubleModel
7
+ passthrough :poll_url
8
+ embeds_one :status, nullable: true, klass: "Ecoportal::API::V2::S3::Files::PollStatus"
9
+
10
+ def poll_id
11
+ return @poll_id if instance_variable_defined?(:@poll_id)
12
+ return @poll_id = nil unless (uri = parsed_poll_url)
13
+
14
+ @poll_id = uri.path.split('/poll/').last
15
+ end
16
+ alias_method :id, :poll_id
17
+
18
+ # The final File eP container id
19
+ def container_id
20
+ return unless status?
21
+
22
+ status.container_id
23
+ end
24
+
25
+ def status?
26
+ !doc['status'].nil?
27
+ end
28
+
29
+ def status=(value)
30
+ case value
31
+ when NilClass
32
+ doc["status"] = nil
33
+ when Ecoportal::API::V2::S3::Files::PollStatus
34
+ doc["status"] = JSON.parse(value.to_json)
35
+ when Hash
36
+ doc["status"] = value
37
+ else
38
+ # TODO
39
+ raise "Invalid set on status: Need nil, PollStatus or Hash; got #{value.class}"
40
+ end
41
+
42
+ remove_instance_variable("@status") if defined?(@status)
43
+ end
44
+
45
+ def pending?
46
+ !complete?
47
+ end
48
+
49
+ def complete?
50
+ status&.complete?
51
+ end
52
+
53
+ def success?
54
+ status&.success?
55
+ end
56
+
57
+ def failed?
58
+ status&.failed?
59
+ end
60
+
61
+ def timeout?
62
+ status&.timeout?
63
+ end
64
+
65
+ private
66
+
67
+ def parsed_poll_url
68
+ return nil unless poll_url
69
+
70
+ require 'uri'
71
+ URI.parse(poll_url)
72
+ end
73
+
74
+ def callbacks
75
+ @callbacks ||= []
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,52 @@
1
+ module Ecoportal
2
+ module API
3
+ class V2
4
+ class S3
5
+ class Files
6
+ class PollStatus < Common::Content::DoubleModel
7
+ class FileContainer < Common::Content::DoubleModel
8
+ passkey :id
9
+ passthrough :file_container_id # same as :id :)
10
+ passthrough :name, :label
11
+ passthrough :file_size, :content_type
12
+ passthrough :tags
13
+ passboolean :archived
14
+ passthrough :active_person, :user_name
15
+ passdate :created_at, :updated_at, :file_update_at
16
+ end
17
+
18
+ # PollStatus
19
+ passthrough :status
20
+ embeds_one :file, klass: FileContainer
21
+
22
+ def container_id
23
+ return unless success?
24
+
25
+ file&.id
26
+ end
27
+
28
+ def complete?
29
+ success? || failed?
30
+ end
31
+
32
+ def timeout?
33
+ statut == 'timeout'
34
+ end
35
+
36
+ def pending?
37
+ status == "pending"
38
+ end
39
+
40
+ def success?
41
+ status == "success"
42
+ end
43
+
44
+ def failed?
45
+ status == "failed"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,132 @@
1
+ module Ecoportal
2
+ module API
3
+ class V2
4
+ # @attr_reader client [Common::Client] a `Common::Client` object that
5
+ # holds the configuration of the api connection.
6
+ class S3
7
+ class Files
8
+ class FailedPollRequest < StandardError; end
9
+ class CantCheckStatus < StandardError; end
10
+
11
+ extend Common::BaseClass
12
+
13
+ POLL_TIMEOUT = 240
14
+ DELAY_STATUS_CHECK = 5
15
+
16
+ class_resolver :poll_class, "Ecoportal::API::V2::S3::Files::Poll"
17
+ class_resolver :poll_status_class, "Ecoportal::API::V2::S3::Files::PollStatus"
18
+
19
+ attr_reader :client, :s3_api
20
+
21
+ # @param client [Common::Client] a `Common::Client` object that holds the configuration of the api connection.
22
+ # @param s3_api [V2::S3] the parent S3 api object.
23
+ # @return [Schemas] an instance object ready to make schema api requests.
24
+ def initialize(client, s3_api:)
25
+ @client = client
26
+ @s3_api = s3_api
27
+ end
28
+
29
+ # Helper to upload multiple files at once
30
+ def upload!(files, **kargs, &block)
31
+ BatchUpload.new(files, files_api: self).upload!(**kargs, &block)
32
+ end
33
+
34
+ # Tell ecoPortal to register the file in an actual File Container
35
+ # @note this is essential to do for the file to be attachable.
36
+ # - eP will scan the file and give create the proper data model that
37
+ # refers to the file (FileContainer).
38
+ # - FileContainers also have a file versioning sytem.
39
+ # - A file container belongs only to one single organization.
40
+ # @yield [poll] block early, when the poll is created
41
+ # @yieldparam poll [Ecoportal::API::V2::S3::Files::Poll, NilClass] the creatd poll
42
+ # @param s3_file_reference [Hash]
43
+ # @return [Ecoportal::API::V2::S3::Files::PollStatus, NilClass]
44
+ # when finalized (success, failed or timeout)
45
+ def poll!(
46
+ s3_file_reference,
47
+ check_delay: DELAY_STATUS_CHECK,
48
+ raise_timeout: false
49
+ )
50
+ raise ArgumentError, "Block required. None given" unless block_given?
51
+
52
+ poll = create_poll(s3_file_reference)
53
+ yield(poll) if block_given?
54
+
55
+ wait_for_poll_completion(poll.id, check_delay: check_delay).tap do |status|
56
+ poll.status = status
57
+
58
+ next unless raise_timeout
59
+ next if status&.complete?
60
+
61
+ msg = "File poll `#{poll.id}` not complete. "
62
+ msg << "Probably timeout after #{POLL_TIMEOUT} seconds. "
63
+ msg << "Current status: #{poll.status.status}"
64
+
65
+ raise Ecoportal::API::Errors::TimeOut, msg
66
+ end
67
+ end
68
+
69
+ # Create Poll Request to ecoPortal so it registers the file
70
+ # in an actual File Container
71
+ # @param s3_file_reference [Hash]
72
+ # @return [Ecoportal::API::V2::S3::Files::Poll, NilClass]
73
+ def create_poll(s3_file_reference)
74
+ response = client.post("/s3/files", data: s3_file_reference)
75
+ body = body_data(response.body)
76
+
77
+ return poll_class.new(body) if response.success?
78
+
79
+ msg = "Could not create poll request for\n"
80
+ msg << JSON.pretty_generate(s3_file_reference)
81
+ msg << "\n - Error #{response.status}: #{body}"
82
+ raise FailedPollRequest, msg
83
+ end
84
+
85
+ # Check progress on the File Poll status
86
+ # @param poll_id [String]
87
+ def poll_status(poll_id)
88
+ response = client.get("/s3/files/poll/#{poll_id}")
89
+ body = body_data(response.body)
90
+
91
+ return poll_status_class.new(body) if response.success?
92
+
93
+ msg = "Could not get status for poll '#{poll_id}' "
94
+ msg << "- Error #{response.status}: #{body}"
95
+ raise CantCheckStatus, msg
96
+ end
97
+
98
+ private
99
+
100
+ def body_data(body)
101
+ return body unless body.is_a?(Hash)
102
+ return body unless body.key?("data")
103
+
104
+ body["data"]
105
+ end
106
+
107
+ # @note timeout library is evil. So we make poor-man timeout.
108
+ # https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/
109
+ def wait_for_poll_completion(poll_id, check_delay: DELAY_STATUS_CHECK)
110
+ before = Time.now
111
+
112
+ loop do
113
+ status = poll_status(poll_id)
114
+ break status if status&.complete?
115
+
116
+ if Time.now >= before + POLL_TIMEOUT
117
+ status&.status = 'timeout'
118
+ break status
119
+ end
120
+
121
+ sleep(check_delay)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ require 'ecoportal/api/v2/s3/files/poll'
131
+ require 'ecoportal/api/v2/s3/files/poll_status'
132
+ require 'ecoportal/api/v2/s3/files/batch_upload'
@@ -0,0 +1,154 @@
1
+ module Ecoportal
2
+ module API
3
+ class V2
4
+ class S3
5
+ class Upload
6
+ class MissingFile < ArgumentError; end
7
+
8
+ DEFAULT_MIME_TYPE = 'application/octet-stream'.freeze
9
+ EXPECTED_CODE = "204".freeze # No Content
10
+ MAX_RETRIES = 5
11
+
12
+ attr_reader :filename
13
+
14
+ def initialize(credentials, file:)
15
+ msg = "Expecting #{credentials_class}. Given: #{credentials.class}"
16
+ raise ArgumentError, msg unless credentials.is_a?(credentials_class)
17
+
18
+ @credentials = credentials
19
+ @filename = file
20
+ end
21
+
22
+ def upload!
23
+ require_deps!
24
+
25
+ @response = Net::HTTP.start(
26
+ uri.hostname,
27
+ uri.port,
28
+ use_ssl: true
29
+ ) do |https|
30
+ https.max_retries = self.class::MAX_RETRIES
31
+ https.request(request)
32
+ end
33
+
34
+ return unless success?
35
+
36
+ yield(s3_file_reference) if block_given?
37
+ s3_file_reference
38
+ end
39
+
40
+ def failed?
41
+ return false unless response
42
+
43
+ response.code != EXPECTED_CODE
44
+ end
45
+
46
+ def success?
47
+ return false unless response
48
+
49
+ response.code == EXPECTED_CODE
50
+ end
51
+
52
+ def s3_file_reference
53
+ return unless success?
54
+
55
+ @s3_file_reference ||= {
56
+ 'filename' => file_basename,
57
+ 'filetype' => mime_type,
58
+ 'filesize' => file_size,
59
+ 'path' => s3_file_path
60
+ }
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :credentials
66
+ attr_reader :response
67
+
68
+ def request
69
+ Net::HTTP::Post.new(uri).tap do |req|
70
+ req.set_form form_data, 'multipart/form-data'
71
+ end
72
+ end
73
+
74
+ def uri
75
+ @uri ||= URI(credentials[:s3_endpoint])
76
+ end
77
+
78
+ def form_data
79
+ [
80
+ ['key', s3_file_path],
81
+ *aws_params,
82
+ *file_params
83
+ ]
84
+ end
85
+
86
+ def s3_file_path
87
+ "#{user_tmpdir}/#{file_basename}"
88
+ end
89
+
90
+ def user_tmpdir
91
+ credentials[:user_tmpdir]
92
+ end
93
+
94
+ def aws_params
95
+ params = %i[AWSAccessKeyId policy signature x-amz-server-side-encryption]
96
+ params.map do |key|
97
+ [key.to_s, credentials[key]]
98
+ end
99
+ end
100
+
101
+ def file_params
102
+ [
103
+ ['content-type', mime_type],
104
+ ['file', io_stream]
105
+ ]
106
+ end
107
+
108
+ def mime_type
109
+ @mime_type ||= lib_mime_type || DEFAULT_MIME_TYPE
110
+ end
111
+
112
+ def lib_mime_type
113
+ MIME::Types.type_for(file_extension).first&.content_type
114
+ end
115
+
116
+ def io_stream
117
+ require_file!
118
+ File.open(file_fullname)
119
+ end
120
+
121
+ def credentials_class
122
+ Ecoportal::API::V2::S3::Data
123
+ end
124
+
125
+ def file_size
126
+ @file_size ||= File.size(file_fullname)
127
+ end
128
+
129
+ def file_extension
130
+ @file_extension ||= File.extname(filename)[1..]
131
+ end
132
+
133
+ def file_basename
134
+ @file_basename ||= File.basename(filename)
135
+ end
136
+
137
+ def file_fullname
138
+ @file_fullname ||= File.expand_path(filename)
139
+ end
140
+
141
+ def require_file!
142
+ msg = "File '#{file_basename}' doesn't exist"
143
+ raise MissingFile, msg unless File.exist?(file_fullname)
144
+ end
145
+
146
+ def require_deps!
147
+ require 'net/http'
148
+ require 'mime/types'
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,66 @@
1
+ module Ecoportal
2
+ module API
3
+ class V2
4
+ # @attr_reader client [Common::Client] a `Common::Client` object that holds the configuration of the api connection.
5
+ class S3
6
+ class MissingLocalFile < ArgumentError; end
7
+ class CredentialsGetFailed < StandardError; end
8
+
9
+ extend Common::BaseClass
10
+
11
+ class_resolver :data_class, "Ecoportal::API::V2::S3::Data"
12
+ class_resolver :files_class, "Ecoportal::API::V2::S3::Files"
13
+
14
+ attr_reader :client
15
+
16
+ # @param client [Common::Client] a `Common::Client` object that holds the configuration of the api connection.
17
+ # @return [Schemas] an instance object ready to make schema api requests.
18
+ def initialize(client)
19
+ @client = client
20
+ end
21
+
22
+ # Gets the S3 credentials to upload files
23
+ # @return [Ecoportal::API::V2::S3::Data]
24
+ def data
25
+ response = client.get("/s3/data")
26
+ body = body_data(response.body)
27
+
28
+ return data_class.new(body) if response.success?
29
+
30
+ msg = "Could not get S3 credentials - Error #{response.status}: #{body}"
31
+ raise CredentialsGetFailed, msg
32
+ end
33
+
34
+ # @param file [String] full path to existing local file
35
+ # @param credentials [Ecoportal::API::V2::S3::Data, NilClass]
36
+ # @return [Hash, NilClass] with the s3_file_reference on success, and `nil` otherwise
37
+ def upload_file(file, credentials: data, &block)
38
+ msg = "The file '#{file}' does not exist"
39
+ raise MissingLocalFile, msg unless File.exist?(file)
40
+
41
+ credentials ||= data
42
+ Upload.new(credentials, file: file).upload!(&block)
43
+ end
44
+
45
+ # Obtain specific object for eP file api requests.
46
+ # @return [Files] an instance object ready to make eP file api requests.
47
+ def files
48
+ files_class.new(client, s3_api: self)
49
+ end
50
+
51
+ private
52
+
53
+ def body_data(body)
54
+ return body unless body.is_a?(Hash)
55
+ return body unless body.key?("data")
56
+
57
+ body["data"]
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ require 'ecoportal/api/v2/s3/data'
65
+ require 'ecoportal/api/v2/s3/upload'
66
+ require 'ecoportal/api/v2/s3/files'
@@ -8,10 +8,10 @@ module Ecoportal
8
8
  extend Common::BaseClass
9
9
  include Common::Logging
10
10
 
11
- VERSION = "v2"
11
+ VERSION = "v2".freeze
12
12
 
13
13
  class << self
14
- def v2key (ukey, gkey)
14
+ def v2key(ukey, gkey)
15
15
  Base64.urlsafe_encode64({
16
16
  organization: gkey,
17
17
  user: ukey
@@ -22,6 +22,7 @@ module Ecoportal
22
22
  class_resolver :people_class, "Ecoportal::API::V2::People"
23
23
  class_resolver :registers_class, "Ecoportal::API::V2::Registers"
24
24
  class_resolver :pages_class, "Ecoportal::API::V2::Pages"
25
+ class_resolver :s3_class, "Ecoportal::API::V2::S3"
25
26
 
26
27
  attr_reader :client, :logger
27
28
 
@@ -64,6 +65,12 @@ module Ecoportal
64
65
  pages_class.new(client)
65
66
  end
66
67
 
68
+ # Obtain specific object for file api requests.
69
+ # @return [S3] an instance object ready to make files api requests.
70
+ def s3 # rubocop:disable Naming/VariableNumber
71
+ s3_class.new(client)
72
+ end
73
+
67
74
  private
68
75
 
69
76
  def get_key(api_key: nil, user_key: nil, org_key: nil)
@@ -72,7 +79,6 @@ module Ecoportal
72
79
  raise "You need to provide either an api_key or user_key" unless user_key
73
80
  raise "You need to provide an org_key as well (not just a user_key)" unless org_key
74
81
  end
75
-
76
82
  end
77
83
  end
78
84
  end
@@ -80,3 +86,4 @@ end
80
86
  require 'ecoportal/api/v2/people'
81
87
  require 'ecoportal/api/v2/pages'
82
88
  require 'ecoportal/api/v2/registers'
89
+ require 'ecoportal/api/v2/s3'
@@ -1,5 +1,5 @@
1
1
  module Ecoportal
2
2
  module API
3
- GEM2_VERSION = "1.1.8".freeze
3
+ GEM2_VERSION = '2.0.5'.freeze
4
4
  end
5
5
  end