pdfmonkey 0.8.1 → 1.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.
@@ -1,25 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
3
4
  require 'net/http'
4
5
 
5
6
  module Pdfmonkey
6
7
  class Adapter
8
+ HTTP_METHODS = %i[delete get post put].freeze
9
+
10
+ NETWORK_ERRORS = [
11
+ IOError,
12
+ SocketError,
13
+ Net::OpenTimeout,
14
+ Net::ReadTimeout,
15
+ Net::WriteTimeout,
16
+ Errno::ECONNREFUSED,
17
+ Errno::ECONNRESET,
18
+ Errno::EHOSTUNREACH,
19
+ Errno::EPIPE,
20
+ Errno::ETIMEDOUT,
21
+ OpenSSL::SSL::SSLError
22
+ ].freeze
23
+
7
24
  def initialize(config: Pdfmonkey.configuration)
8
25
  @config = config
26
+ @connection = nil
9
27
  end
10
28
 
11
- def call(method, resource)
12
- response = send_request(method, resource)
29
+ def call(method, resource, params: {}, path: nil, extract: :member)
30
+ response = send_request(method, resource, params: params, path: path)
13
31
 
14
32
  case response
15
33
  when Net::HTTPNoContent then true
16
- when Net::HTTPSuccess then extract_attributes(response, resource)
17
- else extract_errors(response)
34
+ when Net::HTTPSuccess then extract_data(response, resource, extract)
35
+ else raise_api_error(response)
18
36
  end
19
- rescue StandardError => e
20
- { errors: [e.message], status: 'error' }
37
+ rescue *NETWORK_ERRORS => e
38
+ reset_connection
39
+ raise ConnectionError, e.message
40
+ end
41
+
42
+ def close
43
+ close_connection
44
+ end
45
+
46
+ def inspect
47
+ "#<#{self.class}>"
21
48
  end
22
49
 
50
+ private attr_reader :config
51
+
23
52
  private def build_delete_request(uri, _resource)
24
53
  Net::HTTP::Delete.new(uri, headers)
25
54
  end
@@ -34,26 +63,78 @@ module Pdfmonkey
34
63
  request
35
64
  end
36
65
 
37
- private def extract_attributes(response, resource)
38
- member = resource.class.const_get('MEMBER')
39
- JSON.parse(response.body).fetch(member)
66
+ private def build_put_request(uri, resource)
67
+ request = Net::HTTP::Put.new(uri, headers)
68
+ request.body = resource.to_json
69
+ request
70
+ end
71
+
72
+ private def close_connection
73
+ @connection&.finish
74
+ rescue StandardError
75
+ nil
76
+ ensure
77
+ @connection = nil
78
+ end
79
+
80
+ private def connection
81
+ return @connection if @connection&.started?
82
+
83
+ close_connection
84
+
85
+ host_uri = URI(config.host)
86
+ http = Net::HTTP.new(host_uri.host, host_uri.port)
87
+ http.use_ssl = host_uri.scheme == 'https'
88
+ http.open_timeout = config.open_timeout
89
+ http.read_timeout = config.read_timeout
90
+ http.keep_alive_timeout = config.keep_alive_timeout
91
+ http.start
92
+ @connection = http
40
93
  end
41
94
 
42
- private def extract_errors(response)
43
- payload = JSON.parse(response.body)
44
- errors =
45
- if payload['error']
46
- [payload['error']]
47
- elsif payload['errors'].is_a?(Array)
48
- payload['errors'].map { |error| error['detail'] }
49
- elsif payload['errors'].is_a?(Hash)
50
- payload['errors']
95
+ private def extract_data(response, resource, extract)
96
+ body = JSON.parse(response.body.to_s)
97
+
98
+ case extract
99
+ when :member
100
+ resource_class = resource.is_a?(Class) ? resource : resource.class
101
+ member = resource_class::MEMBER
102
+ body.fetch(member) do
103
+ raise ApiError.new(
104
+ "Missing '#{member}' key in response",
105
+ errors: [response.body],
106
+ status_code: response.code.to_i
107
+ )
51
108
  end
109
+ when :collection
110
+ body
111
+ else
112
+ raise ArgumentError, "Unknown extract mode: #{extract.inspect}"
113
+ end
114
+ rescue JSON::ParserError
115
+ raise ApiError.new(
116
+ 'Invalid JSON in response body',
117
+ errors: [response.body],
118
+ status_code: response.code.to_i
119
+ )
120
+ end
52
121
 
53
- { errors: errors, status: 'error' }
122
+ private def format_error_message(errors)
123
+ case errors
124
+ when Array then errors.join(', ')
125
+ when Hash
126
+ errors.map { |field, messages| "#{field}: #{Array(messages).join(', ')}" }.join('; ')
127
+ else errors.to_s
128
+ end
54
129
  end
55
130
 
56
131
  private def headers
132
+ if config.private_key.nil? || config.private_key.to_s.strip.empty?
133
+ raise Pdfmonkey::Error,
134
+ 'No API key configured. Set ENV["PDFMONKEY_PRIVATE_KEY"] or ' \
135
+ 'use Pdfmonkey.configure { |c| c.private_key = "..." }'
136
+ end
137
+
57
138
  {
58
139
  'Authorization' => "Bearer #{config.private_key}",
59
140
  'Content-Type' => 'application/json',
@@ -61,23 +142,65 @@ module Pdfmonkey
61
142
  }
62
143
  end
63
144
 
64
- private def send_request(method, resource)
65
- uri = URI(url_for(resource))
66
- request = send("build_#{method}_request", uri, resource)
67
- http = Net::HTTP.new(uri.host, uri.port)
68
- http.use_ssl = (uri.scheme == 'https')
69
- http.request(request)
145
+ private def parse_error_body(response)
146
+ payload = JSON.parse(response.body.to_s)
147
+
148
+ if payload['error']
149
+ [payload['error']]
150
+ elsif payload['errors'].is_a?(Array)
151
+ extract_error_messages(payload['errors'])
152
+ elsif payload['errors'].is_a?(Hash)
153
+ payload['errors']
154
+ else
155
+ [response.body]
156
+ end
157
+ rescue JSON::ParserError
158
+ [response.body]
70
159
  end
71
160
 
72
- private def url_for(resource)
73
- collection = resource.class.const_get('COLLECTION')
74
- endpoint = "#{config.host}/#{config.namespace}/#{collection}"
75
- endpoint += "/#{resource.id}" if resource.id
76
- endpoint
161
+ private def extract_error_messages(errors)
162
+ errors.filter_map do |error|
163
+ next error.to_s unless error.is_a?(Hash)
164
+
165
+ error['detail'] || error['message'] || error.to_json
166
+ end
167
+ end
168
+
169
+ private def raise_api_error(response)
170
+ errors = parse_error_body(response)
171
+
172
+ raise ApiError.new(
173
+ format_error_message(errors),
174
+ errors: errors,
175
+ status_code: response.code.to_i
176
+ )
77
177
  end
78
178
 
79
- private
179
+ private def reset_connection
180
+ close_connection
181
+ end
182
+
183
+ private def send_request(method, resource, params: {}, path: nil)
184
+ raise ArgumentError, "Unsupported HTTP method: #{method.inspect}" unless HTTP_METHODS.include?(method)
185
+
186
+ uri = URI(url_for(resource, params: params, path: path))
187
+ request = send("build_#{method}_request", uri, resource)
188
+ connection.request(request)
189
+ end
80
190
 
81
- attr_reader :config
191
+ private def url_for(resource, params: {}, path: nil)
192
+ if path
193
+ endpoint = "#{config.host}/#{config.namespace}/#{path}"
194
+ else
195
+ resource_class = resource.is_a?(Class) ? resource : resource.class
196
+ collection = resource_class::COLLECTION
197
+ endpoint = "#{config.host}/#{config.namespace}/#{collection}"
198
+ endpoint += "/#{resource.id}" if !resource.is_a?(Class) && resource.id
199
+ end
200
+
201
+ endpoint += "?#{URI.encode_www_form(params)}" unless params.empty?
202
+
203
+ endpoint
204
+ end
82
205
  end
83
206
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfmonkey
4
+ class Collection
5
+ include Enumerable
6
+
7
+ attr_reader :items, :current_page, :total_pages,
8
+ :next_page_number, :prev_page_number
9
+
10
+ def initialize(items:, meta:, page_fetcher:)
11
+ @items = items.dup.freeze
12
+ @current_page = meta['current_page']
13
+ @total_pages = meta['total_pages']
14
+ @next_page_number = meta['next_page']
15
+ @prev_page_number = meta['prev_page']
16
+ @page_fetcher = page_fetcher
17
+ end
18
+
19
+ def each(&)
20
+ items.each(&)
21
+ end
22
+
23
+ def next_page
24
+ return unless next_page_number
25
+
26
+ page_fetcher.call(next_page_number)
27
+ end
28
+
29
+ def prev_page
30
+ return unless prev_page_number
31
+
32
+ page_fetcher.call(prev_page_number)
33
+ end
34
+
35
+ private attr_reader :page_fetcher
36
+ end
37
+ end
@@ -2,16 +2,25 @@
2
2
 
3
3
  module Pdfmonkey
4
4
  class Configuration
5
- attr_accessor :host
6
- attr_accessor :namespace
7
- attr_accessor :private_key
8
- attr_accessor :user_agent
5
+ attr_accessor :host, :keep_alive_timeout, :namespace,
6
+ :open_timeout, :poll_interval, :private_key, :read_timeout
9
7
 
10
8
  def initialize
11
9
  @host = 'https://api.pdfmonkey.io'
10
+ @keep_alive_timeout = 30
12
11
  @namespace = 'api/v1'
13
- @private_key = ENV['PDFMONKEY_PRIVATE_KEY']
14
- @user_agent = 'Ruby'
12
+ @open_timeout = 30
13
+ @poll_interval = 0.5
14
+ @private_key = ENV.fetch('PDFMONKEY_PRIVATE_KEY', nil)
15
+ @read_timeout = 30
16
+ end
17
+
18
+ def user_agent
19
+ "pdfmonkey-ruby/#{Pdfmonkey::VERSION}"
20
+ end
21
+
22
+ def inspect
23
+ "#<#{self.class} host=#{host.inspect} namespace=#{namespace.inspect}>"
15
24
  end
16
25
  end
17
26
 
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfmonkey
4
+ class CurrentUser < Resource
5
+ ATTRIBUTES = %i[
6
+ auth_token
7
+ available_documents
8
+ block_resources
9
+ created_at
10
+ current_plan
11
+ current_plan_interval
12
+ desired_name
13
+ email
14
+ errors
15
+ id
16
+ lang
17
+ paying_customer
18
+ share_links
19
+ trial_ends_on
20
+ updated_at
21
+ ].freeze
22
+
23
+ COLLECTION = 'current_users'
24
+ MEMBER = 'current_user'
25
+
26
+ def_delegators :attributes, *ATTRIBUTES
27
+
28
+ def self.fetch
29
+ adapter = Pdfmonkey::Adapter.new
30
+ attrs = adapter.call(:get, self, path: 'current_user')
31
+ resource_attrs = attrs.transform_keys(&:to_sym)
32
+ resource_attrs.delete(:adapter)
33
+ new(adapter: adapter, **resource_attrs)
34
+ end
35
+ end
36
+ end
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'forwardable'
4
- require 'json'
5
- require 'ostruct'
6
-
7
3
  module Pdfmonkey
8
- class Document
9
- extend Forwardable
4
+ class Document < Resource
5
+ include Fetchable
6
+ include Creatable
7
+ include Updatable
8
+ include Deletable
10
9
 
11
10
  ATTRIBUTES = %i[
12
11
  app_id
@@ -20,6 +19,7 @@ module Pdfmonkey
20
19
  generation_logs
21
20
  id
22
21
  meta
22
+ output_type
23
23
  payload
24
24
  preview_url
25
25
  public_share_link
@@ -28,78 +28,106 @@ module Pdfmonkey
28
28
  ].freeze
29
29
 
30
30
  COMPLETE_STATUSES = %w[error failure success].freeze
31
+ FAILURE_STATUSES = %w[error failure].freeze
31
32
  COLLECTION = 'documents'
32
33
  MEMBER = 'document'
33
34
 
34
- attr_reader :attributes
35
35
  def_delegators :attributes, *ATTRIBUTES
36
36
 
37
- def self.delete(document_id)
38
- new(id: document_id).delete!
37
+ def self.generate!(*args, document_template_id: nil, payload: nil, meta: nil)
38
+ document_template_id, payload, meta =
39
+ resolve_document_args(args, document_template_id, payload, meta, __method__)
40
+ validate_template_id!(document_template_id)
41
+
42
+ generate(document_template_id: document_template_id, payload: payload, meta: meta)
43
+ .send(:poll_until_done!)
39
44
  end
40
45
 
41
- def self.fetch(document_id)
42
- new(id: document_id).reload!
46
+ def self.generate(*args, document_template_id: nil, payload: nil, meta: nil)
47
+ create_document('pending', args, document_template_id, payload, meta, __method__)
43
48
  end
44
49
 
45
- def self.generate!(document_template_id, payload, meta = {})
46
- document = generate(document_template_id, payload, meta)
47
- document.reload! until document.done?
48
- document
50
+ def self.create_draft(*args, document_template_id: nil, payload: nil, meta: nil)
51
+ create_document('draft', args, document_template_id, payload, meta, __method__)
49
52
  end
50
53
 
51
- def self.generate(template_id, payload, meta = {})
52
- document = new(
53
- document_template_id: template_id,
54
- meta: meta.to_json,
55
- payload: payload.to_json,
56
- status: 'pending')
54
+ private_class_method def self.create_document(status, args, document_template_id, payload, meta, method_name)
55
+ document_template_id, payload, meta =
56
+ resolve_document_args(args, document_template_id, payload, meta, method_name)
57
+ validate_template_id!(document_template_id)
58
+
59
+ new(
60
+ document_template_id: document_template_id,
61
+ meta: json_encode(meta),
62
+ payload: json_encode(payload),
63
+ status: status
64
+ ).save
65
+ end
57
66
 
58
- document.send(:save)
67
+ private_class_method def self.validate_template_id!(template_id)
68
+ return unless template_id.nil? || template_id.to_s.strip.empty?
69
+
70
+ raise ArgumentError, 'document_template_id is required'
59
71
  end
60
72
 
61
- def initialize(adapter: Pdfmonkey::Adapter.new, **attributes)
62
- @adapter = adapter
63
- @attributes = OpenStruct.new(ATTRIBUTES.zip([]).to_h)
64
- update(attributes)
73
+ private_class_method def self.json_encode(value)
74
+ case value
75
+ when nil then nil
76
+ when String then value
77
+ else value.to_json
78
+ end
65
79
  end
66
80
 
67
- def delete!
68
- adapter.call(:delete, self)
81
+ private_class_method def self.resolve_document_args(args, kw_template_id, kw_payload, meta, method_name)
82
+ if args.any?
83
+ warn "[PDFMonkey] Positional arguments for Document.#{method_name} are deprecated. " \
84
+ 'Use keyword arguments instead: ' \
85
+ "Document.#{method_name}(document_template_id:, payload:, meta:)",
86
+ uplevel: 2
87
+ [args[0], args[1], args[2] || meta]
88
+ else
89
+ [kw_template_id, kw_payload, meta]
90
+ end
69
91
  end
70
92
 
71
- def done?
72
- COMPLETE_STATUSES.include?(status)
93
+ def self.list_cards(**)
94
+ Pdfmonkey::DocumentCard.list(**)
73
95
  end
74
96
 
75
- def reload!
76
- attributes = adapter.call(:get, self)
77
- update(attributes)
78
- self
97
+ def self.fetch_card(id)
98
+ Pdfmonkey::DocumentCard.fetch(id)
79
99
  end
80
100
 
81
- def to_json
82
- attrs = attributes.to_h
83
- attrs.delete(:errors)
101
+ def self.fetch_full(id)
102
+ fetch(id)
103
+ end
84
104
 
85
- { document: attrs }.to_json
105
+ def generate
106
+ update!(status: 'pending')
86
107
  end
87
108
 
88
- private def save
89
- attributes = adapter.call(:post, self)
90
- update(attributes)
91
- self
109
+ def generate!
110
+ generate
111
+ poll_until_done!
92
112
  end
93
113
 
94
- private def update(new_attributes)
95
- new_attributes.each do |key, value|
96
- sym_key = key.to_sym
97
- attributes[sym_key] = value if ATTRIBUTES.include?(sym_key)
98
- end
114
+ def done?
115
+ COMPLETE_STATUSES.include?(status)
99
116
  end
100
117
 
101
- private
118
+ private def poll_until_done!
119
+ until done?
120
+ sleep(Pdfmonkey.configuration.poll_interval)
121
+ reload!
122
+ end
102
123
 
103
- attr_reader :adapter
124
+ if FAILURE_STATUSES.include?(status)
125
+ message = 'Document generation failed'
126
+ message += ": #{failure_cause}" if failure_cause
127
+ raise Pdfmonkey::GenerationError.new(message, document: self)
128
+ end
129
+
130
+ self
131
+ end
104
132
  end
105
133
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfmonkey
4
+ class DocumentCard < Resource
5
+ include Fetchable
6
+ include Listable
7
+
8
+ ATTRIBUTES = %i[
9
+ app_id
10
+ created_at
11
+ document_template_id
12
+ document_template_identifier
13
+ download_url
14
+ errors
15
+ failure_cause
16
+ filename
17
+ id
18
+ meta
19
+ output_type
20
+ preview_url
21
+ public_share_link
22
+ status
23
+ updated_at
24
+ ].freeze
25
+
26
+ COLLECTION = 'document_cards'
27
+ MEMBER = 'document_card'
28
+
29
+ FILTERS = {
30
+ document_template_id: 'q[document_template_id]',
31
+ status: 'q[status]',
32
+ workspace_id: 'q[workspace_id]',
33
+ updated_since: 'q[updated_since]'
34
+ }.freeze
35
+
36
+ def_delegators :attributes, *ATTRIBUTES
37
+
38
+ def self.list(page: 1, **)
39
+ super
40
+ end
41
+
42
+ def to_document
43
+ Pdfmonkey::Document.fetch(id)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfmonkey
4
+ class Engine < Resource
5
+ include Listable
6
+
7
+ ATTRIBUTES = %i[
8
+ deprecated_on
9
+ errors
10
+ id
11
+ name
12
+ ].freeze
13
+
14
+ COLLECTION = 'pdf_engines'
15
+ MEMBER = 'pdf_engine'
16
+
17
+ def_delegators :attributes, *ATTRIBUTES
18
+ end
19
+ end