ecoportal-api-v2 1.1.7 → 2.0.0

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/.markdownlint.json +4 -0
  3. data/.rubocop.yml +54 -15
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +485 -373
  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 +85 -79
  11. data/lib/ecoportal/api/common/content/class_helpers.rb +34 -31
  12. data/lib/ecoportal/api/common/content/collection_model.rb +77 -65
  13. data/lib/ecoportal/api/common/content/double_model.rb +105 -87
  14. data/lib/ecoportal/api/common/content/wrapped_response.rb +11 -11
  15. data/lib/ecoportal/api/v2/page/component/reference_field.rb +17 -13
  16. data/lib/ecoportal/api/v2/page/component.rb +67 -68
  17. data/lib/ecoportal/api/v2/page/components.rb +9 -9
  18. data/lib/ecoportal/api/v2/page/force.rb +6 -7
  19. data/lib/ecoportal/api/v2/page/stages.rb +5 -6
  20. data/lib/ecoportal/api/v2/page.rb +35 -33
  21. data/lib/ecoportal/api/v2/pages/page_stage.rb +22 -20
  22. data/lib/ecoportal/api/v2/pages.rb +18 -14
  23. data/lib/ecoportal/api/v2/people.rb +2 -3
  24. data/lib/ecoportal/api/v2/registers.rb +28 -13
  25. data/lib/ecoportal/api/v2/s3/data.rb +27 -0
  26. data/lib/ecoportal/api/v2/s3/files/batch_upload.rb +110 -0
  27. data/lib/ecoportal/api/v2/s3/files/poll.rb +82 -0
  28. data/lib/ecoportal/api/v2/s3/files/poll_status.rb +52 -0
  29. data/lib/ecoportal/api/v2/s3/files.rb +132 -0
  30. data/lib/ecoportal/api/v2/s3/upload.rb +154 -0
  31. data/lib/ecoportal/api/v2/s3.rb +66 -0
  32. data/lib/ecoportal/api/v2.rb +10 -3
  33. data/lib/ecoportal/api/v2_version.rb +1 -1
  34. metadata +53 -54
@@ -2,26 +2,29 @@ require 'base64'
2
2
  module Ecoportal
3
3
  module API
4
4
  class V2
5
- # @attr_reader client [Common::Client] a `Common::Client` object that holds the configuration of the api connection.
5
+ # @attr_reader client [Common::Client] a `Common::Client` object that
6
+ # holds the configuration of the api connection.
6
7
  class Registers
7
8
  extend Common::BaseClass
8
9
  include Enumerable
9
10
  include Common::Content::DocHelpers
10
11
 
11
- class_resolver :register_class, "Ecoportal::API::V2::Registers::Register"
12
+ class_resolver :register_class, "Ecoportal::API::V2::Registers::Register"
12
13
  class_resolver :register_search_result_class, "Ecoportal::API::V2::Registers::PageResult"
13
14
  class_resolver :register_search_results, "Ecoportal::API::V2::Registers::SearchResults"
14
15
 
15
16
  attr_reader :client
16
17
 
17
- # @param client [Common::Client] a `Common::Client` object that holds the configuration of the api connection.
18
+ # @param client [Common::Client] a `Common::Client` object that
19
+ # holds the configuration of the api connection.
18
20
  # @return [Registers] an instance object ready to make registers api requests.
19
21
  def initialize(client)
20
22
  @client = client
21
23
  end
22
24
 
23
25
  def each(params: {}, &block)
24
- return to_enum(:each) unless block
26
+ return to_enum(:each, params: params) unless block
27
+
25
28
  get.each(&block)
26
29
  end
27
30
 
@@ -29,7 +32,11 @@ module Ecoportal
29
32
  # @return [Enumerable<Register>] an `Enumerable` with all schemas already wrapped as `Register` objects.
30
33
  def get
31
34
  response = client.get("/templates")
32
- Common::Content::WrappedResponse.new(response, register_class, key: "registers")
35
+ Common::Content::WrappedResponse.new(
36
+ response,
37
+ register_class,
38
+ key: "registers"
39
+ )
33
40
  end
34
41
 
35
42
  # Gets all the oozes/pages of `register_id` matching the `options`
@@ -41,18 +48,21 @@ module Ecoportal
41
48
  # @yield [result] something to do with search page-result.
42
49
  # @yieldparam result [Ecoportal::V2::Registers::PageResult] a page result.
43
50
  # @return [Ecoportal::API::V2::Registers, Ecoportal::API::V2::Registers::SearchResults]
44
- def search(register_id, options = {})
51
+ def search(register_id, options = {}) # rubocop:disable Metrics/AbcSize
45
52
  only_first = options.delete(:only_first)
46
53
  options = build_options(options)
47
54
 
48
55
  if only_first
49
56
  response = client.get("/registers/#{register_id}/search", params: options)
50
57
  raise "Request failed - Status #{response.status}: #{response.body}" unless response.success?
58
+
51
59
  return register_search_results.new(response.body["data"])
52
60
  end
53
61
 
54
62
  cursor_id = nil
55
- results = 0; total = nil
63
+ results = 0
64
+ total = nil
65
+
56
66
  loop do
57
67
  options.update(cursor_id: cursor_id) if cursor_id
58
68
  response = client.get("/registers/#{register_id}/search", params: options)
@@ -61,12 +71,14 @@ module Ecoportal
61
71
  data = response.body["data"]
62
72
  total ||= data["total"]
63
73
  if total != data["total"]
64
- msg = "Change of total in search results. Probably due to changes that affect the filter (register: #{register_id}):"
74
+ msg = "Change of total in search results. "
75
+ msg << "Probably due to changes that affect the filter"
76
+ msg << "(register: #{register_id}):"
65
77
  print_search_status(msg, total, results, cursor_id, data, options)
66
78
  #total = data["total"]
67
79
  end
68
80
 
69
- unless total == 0
81
+ unless total&.zero?
70
82
  results += data["results"].length
71
83
  print_progress(results, total)
72
84
  end
@@ -77,12 +89,15 @@ module Ecoportal
77
89
  end
78
90
 
79
91
  break if total <= results
92
+
80
93
  unless data["cursor_id"]
81
- msg = "Possible error... finishing search for lack of cursor_id in response:"
94
+ msg = "Possible error... finishing search for lack of cursor_id in response:"
82
95
  print_search_status(msg, total, results, cursor_id, data, options)
83
96
  end
97
+
84
98
  break unless (cursor_id = data["cursor_id"])
85
99
  end
100
+
86
101
  self
87
102
  end
88
103
 
@@ -96,14 +111,14 @@ module Ecoportal
96
111
  options.each do |key, value|
97
112
  if key == :filters && value.any?
98
113
  ret[key] = {filters: value}.to_json
99
- else
100
- ret[key] = value if key
114
+ elsif key
115
+ ret[key] = value
101
116
  end
102
117
  end
103
118
  end
104
119
  end
105
120
 
106
- def print_search_status(msg, total, results, cursor_id, data, options)
121
+ def print_search_status(msg, total, results, cursor_id, data, options) # rubocop:disable Metrics/ParameterLists
107
122
  msg += "\n"
108
123
  msg += " • Original total: #{total}\n"
109
124
  msg += " • Current total: #{data&.dig("total")}\n"
@@ -0,0 +1,27 @@
1
+ module Ecoportal
2
+ module API
3
+ class V2
4
+ class S3
5
+ class Data < Common::Content::DoubleModel
6
+ passthrough :s3_endpoint, :AWSAccessKeyId
7
+ passthrough :policy, :signature
8
+ passthrough :user_tmpdir
9
+
10
+ def x_amz_server_side_encryption
11
+ doc['x-amz-server-side-encryption']
12
+ end
13
+
14
+ def [](key)
15
+ doc[key.to_s]
16
+ end
17
+
18
+ def user_id
19
+ return unless user_tmpdir
20
+
21
+ user_tmpdir.split('uploads/').last
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -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