fopost 0.2.0 → 0.3.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.
data/lib/fopost/client.rb CHANGED
@@ -3,12 +3,25 @@
3
3
  require 'fopost/http/client'
4
4
  require 'fopost/models'
5
5
  require 'fopost/resources/base'
6
+ require 'fopost/resources/account_groups'
6
7
  require 'fopost/resources/accounts'
7
8
  require 'fopost/resources/ads'
9
+ require 'fopost/resources/analytics'
10
+ require 'fopost/resources/blog'
11
+ require 'fopost/resources/google_ads'
8
12
  require 'fopost/resources/ai'
13
+ require 'fopost/resources/broadcasts'
14
+ require 'fopost/resources/contacts'
15
+ require 'fopost/resources/google_business'
9
16
  require 'fopost/resources/inbox'
17
+ require 'fopost/resources/activity'
18
+ require 'fopost/resources/knowledge'
10
19
  require 'fopost/resources/labels'
20
+ require 'fopost/resources/media'
11
21
  require 'fopost/resources/posts'
22
+ require 'fopost/resources/blogs'
23
+ require 'fopost/resources/validate'
24
+ require 'fopost/resources/whatsapp'
12
25
  require 'fopost/resources/workspaces'
13
26
 
14
27
  module Fopost
@@ -23,7 +36,9 @@ module Fopost
23
36
  class Client
24
37
  DEFAULT_BASE_URL = HTTP::Client::DEFAULT_BASE_URL
25
38
 
26
- attr_reader :posts, :accounts, :workspaces, :labels, :ai, :inbox, :ads
39
+ attr_reader :posts, :accounts, :account_groups, :workspaces, :labels, :ai, :inbox, :contacts, :broadcasts,
40
+ :sequences, :knowledge, :ads, :validate, :media, :activity, :google_business, :analytics,
41
+ :blog, :blogs, :whatsapp
27
42
 
28
43
  def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, timeout: HTTP::Client::DEFAULT_TIMEOUT,
29
44
  max_retries: HTTP::Client::DEFAULT_MAX_RETRIES, transport: nil, sleeper: nil)
@@ -44,11 +59,24 @@ module Fopost
44
59
 
45
60
  @posts = Resources::Posts.new(@http)
46
61
  @accounts = Resources::Accounts.new(@http)
62
+ @account_groups = Resources::AccountGroups.new(@http)
47
63
  @workspaces = Resources::Workspaces.new(@http)
48
64
  @labels = Resources::Labels.new(@http)
49
65
  @ai = Resources::Ai.new(@http)
50
66
  @inbox = Resources::Inbox.new(@http)
67
+ @contacts = Resources::Contacts.new(@http)
68
+ @broadcasts = Resources::Broadcasts.new(@http)
69
+ @sequences = Resources::Sequences.new(@http)
70
+ @knowledge = Resources::Knowledge.new(@http)
51
71
  @ads = Resources::Ads.new(@http)
72
+ @validate = Resources::Validate.new(@http)
73
+ @whatsapp = Resources::Whatsapp.new(@http)
74
+ @media = Resources::Media.new(@http)
75
+ @analytics = Resources::Analytics.new(@http)
76
+ @activity = Resources::Activity.new(@http)
77
+ @google_business = Resources::GoogleBusiness.new(@http)
78
+ @blog = Resources::Blog.new(@http)
79
+ @blogs = Resources::Blogs.new(@http)
52
80
  end
53
81
 
54
82
  def base_url
data/lib/fopost/errors.rb CHANGED
@@ -56,6 +56,30 @@ module Fopost
56
56
  # 404 — no such resource, or it is outside the key's reach.
57
57
  class NotFoundError < Error; end
58
58
 
59
+ # 413 — the file is over the account's upload ceiling. {#max_file_bytes} is
60
+ # the largest file it may upload; {#upgrade_url} is where a bigger one is unlocked.
61
+ class FileTooLargeError < Error
62
+ def max_file_bytes
63
+ integer_field('max_file_bytes')
64
+ end
65
+
66
+ def file_bytes
67
+ integer_field('file_bytes')
68
+ end
69
+
70
+ def upgrade_url
71
+ value = body.is_a?(Hash) ? (body['upgrade_url'] || body[:upgrade_url]) : nil
72
+ value.is_a?(String) ? value : nil
73
+ end
74
+
75
+ private
76
+
77
+ def integer_field(name)
78
+ value = body.is_a?(Hash) ? (body[name] || body[name.to_sym]) : nil
79
+ value.is_a?(Integer) ? value : nil
80
+ end
81
+ end
82
+
59
83
  # 429 — rate limit exceeded. {#retry_after} is in seconds when the API sends it.
60
84
  class RateLimitError < Error
61
85
  attr_reader :retry_after
@@ -74,6 +98,7 @@ module Fopost
74
98
  402 => PaymentRequiredError,
75
99
  403 => PermissionDeniedError,
76
100
  404 => NotFoundError,
101
+ 413 => FileTooLargeError,
77
102
  422 => ValidationError,
78
103
  429 => RateLimitError
79
104
  }.freeze
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fopost
4
+ # A file handed to `posts.create(files: ...)`: a path, an IO, or a Hash of
5
+ # `filename:` plus `io:` or `data:` and `mime_type:`.
6
+ class FilePart
7
+ MIME_TYPES = {
8
+ 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif',
9
+ 'webp' => 'image/webp', 'heic' => 'image/heic', 'mp4' => 'video/mp4', 'mov' => 'video/quicktime',
10
+ 'webm' => 'video/webm', 'm4v' => 'video/x-m4v', 'avi' => 'video/x-msvideo'
11
+ }.freeze
12
+
13
+ attr_reader :filename, :mime_type
14
+
15
+ def self.from(value)
16
+ case value
17
+ when FilePart then value
18
+ when String then new(filename: File.basename(value), path: value)
19
+ when Hash then from_hash(value)
20
+ else
21
+ raise ArgumentError, "fopost: cannot read a file from #{value.inspect}" unless value.respond_to?(:read)
22
+
23
+ new(filename: value.respond_to?(:path) && value.path ? File.basename(value.path) : nil, io: value)
24
+ end
25
+ end
26
+
27
+ def self.from_hash(hash)
28
+ hash = hash.transform_keys(&:to_sym)
29
+ new(filename: hash[:filename], mime_type: hash[:mime_type], io: hash[:io], data: hash[:data])
30
+ end
31
+
32
+ def initialize(filename:, mime_type: nil, path: nil, io: nil, data: nil)
33
+ raise ArgumentError, 'fopost: a file needs a filename' if filename.nil? || filename.to_s.empty?
34
+ raise ArgumentError, "fopost: a file needs io: or data: (#{filename})" if path.nil? && io.nil? && data.nil?
35
+
36
+ @filename = filename.to_s
37
+ @path = path
38
+ @io = io
39
+ @data = data&.b
40
+ @mime_type = mime_type || guess_mime_type
41
+ end
42
+
43
+ def size
44
+ return @data.bytesize if @data
45
+ return File.size(@path) if @path
46
+ return @io.size if @io.respond_to?(:size) && @io.size
47
+
48
+ data.bytesize
49
+ end
50
+
51
+ # The bytes, read once and kept.
52
+ def data
53
+ @data ||= (@path ? File.binread(@path) : @io.read.to_s.b)
54
+ end
55
+
56
+ # The post media `type` the API expects for this file.
57
+ def media_type
58
+ return 'gif' if mime_type == 'image/gif'
59
+ return 'video' if mime_type.start_with?('video/')
60
+
61
+ 'image'
62
+ end
63
+
64
+ private
65
+
66
+ def guess_mime_type
67
+ extension = File.extname(@filename).delete_prefix('.').downcase
68
+ MIME_TYPES.fetch(extension) do
69
+ raise ArgumentError, "fopost: cannot guess the mime type of #{@filename}; pass mime_type:"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -7,6 +7,7 @@ require 'uri'
7
7
  require 'fopost/errors'
8
8
  require 'fopost/version'
9
9
  require 'fopost/http/net_http_transport'
10
+ require 'fopost/http/multipart'
10
11
 
11
12
  module Fopost
12
13
  module HTTP
@@ -45,14 +46,21 @@ module Fopost
45
46
  end
46
47
 
47
48
  # Send a request, retrying on 429, and return the decoded body.
48
- def request(method, path, json: nil, params: nil)
49
+ # `multipart` is `{ fields: {name => string}, parts: [Multipart::Part] }`.
50
+ def request(method, path, json: nil, params: nil, multipart: nil)
49
51
  url = build_url(path, params)
50
- body = json.nil? ? nil : JSON.generate(json)
52
+ request_headers = headers
53
+ body = nil
54
+ if multipart
55
+ body, request_headers['Content-Type'] = Multipart.encode(multipart[:fields] || {}, multipart[:parts] || [])
56
+ elsif !json.nil?
57
+ body = JSON.generate(json)
58
+ end
51
59
 
52
60
  attempt = 0
53
61
  loop do
54
62
  attempt += 1
55
- response = @transport.call(method: method.to_s.upcase, url: url, headers: headers, body: body)
63
+ response = @transport.call(method: method.to_s.upcase, url: url, headers: request_headers, body: body)
56
64
 
57
65
  if response.status == 429 && attempt < @max_retries
58
66
  wait = retry_after_seconds(response)
@@ -76,10 +84,31 @@ module Fopost
76
84
  request(:put, path, json: json)
77
85
  end
78
86
 
87
+ def patch(path, json = nil)
88
+ request(:patch, path, json: json)
89
+ end
90
+
79
91
  def delete(path, json = nil)
80
92
  request(:delete, path, json: json)
81
93
  end
82
94
 
95
+ def post_multipart(path, fields:, parts:)
96
+ request(:post, path, multipart: { fields: fields, parts: parts })
97
+ end
98
+
99
+ def put_multipart(path, fields:, parts:)
100
+ request(:put, path, multipart: { fields: fields, parts: parts })
101
+ end
102
+
103
+ # PUT raw bytes to a presigned URL. Sends only the headers given, never
104
+ # the API key, and returns nothing: the body is the storage backend's.
105
+ def put_raw(url, body, headers)
106
+ response = @transport.call(method: 'PUT', url: URI.parse(url.to_s), headers: headers, body: body)
107
+ return if response.success?
108
+
109
+ raise ErrorFactory.build(response.status, decode_body(response))
110
+ end
111
+
83
112
  # Peel the `{"data": ...}` envelope the API wraps most responses in. Some
84
113
  # endpoints (POST /posts, GET /posts/:id) return the resource bare, so the
85
114
  # envelope comes off only when it is actually there.
@@ -96,7 +125,10 @@ module Fopost
96
125
  query = (params || {}).compact
97
126
  unless query.empty?
98
127
  existing = uri.query.to_s
99
- encoded = URI.encode_www_form(query.map { |k, v| [k.to_s, v.to_s] })
128
+ # An Array repeats the bare parameter, which is how the API reads a
129
+ # multi-valued filter like `daily_metrics`.
130
+ pairs = query.flat_map { |k, v| Array(v).map { |item| [k.to_s, item.to_s] } }
131
+ encoded = URI.encode_www_form(pairs)
100
132
  uri.query = existing.empty? ? encoded : "#{existing}&#{encoded}"
101
133
  end
102
134
  uri
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Fopost
6
+ module HTTP
7
+ # Encodes a multipart/form-data body from string fields and file parts.
8
+ module Multipart
9
+ Part = Struct.new(:name, :filename, :mime_type, :data, keyword_init: true)
10
+
11
+ # Returns `[body, content_type]`; `body` is a binary String.
12
+ def self.encode(fields, parts)
13
+ boundary = "fopost#{SecureRandom.hex(16)}"
14
+ body = String.new(encoding: Encoding::BINARY)
15
+
16
+ fields.each do |name, value|
17
+ body << "--#{boundary}\r\n"
18
+ body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
19
+ body << value.to_s.b << "\r\n"
20
+ end
21
+
22
+ parts.each do |part|
23
+ body << "--#{boundary}\r\n"
24
+ body << "Content-Disposition: form-data; name=\"#{part.name}\"; filename=\"#{quote(part.filename)}\"\r\n"
25
+ body << "Content-Type: #{part.mime_type}\r\n\r\n"
26
+ body << part.data.b << "\r\n"
27
+ end
28
+
29
+ body << "--#{boundary}--\r\n"
30
+ [body, "multipart/form-data; boundary=#{boundary}"]
31
+ end
32
+
33
+ def self.quote(value)
34
+ value.to_s.gsub(/["\r\n]/, '"' => '%22', "\r" => '', "\n" => '')
35
+ end
36
+ end
37
+ end
38
+ end