fizzy-api-client 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +469 -0
- data/examples/README.md +102 -0
- data/examples/demo.rb +636 -0
- data/examples/sydney.jpg +0 -0
- data/fizzy-api-client.gemspec +31 -0
- data/lib/fizzy_api_client/client.rb +125 -0
- data/lib/fizzy_api_client/colors.rb +91 -0
- data/lib/fizzy_api_client/configuration.rb +34 -0
- data/lib/fizzy_api_client/connection.rb +215 -0
- data/lib/fizzy_api_client/error.rb +59 -0
- data/lib/fizzy_api_client/multipart.rb +59 -0
- data/lib/fizzy_api_client/pagination.rb +9 -0
- data/lib/fizzy_api_client/request.rb +50 -0
- data/lib/fizzy_api_client/resources/boards.rb +51 -0
- data/lib/fizzy_api_client/resources/cards.rb +212 -0
- data/lib/fizzy_api_client/resources/columns.rb +78 -0
- data/lib/fizzy_api_client/resources/comments.rb +45 -0
- data/lib/fizzy_api_client/resources/direct_uploads.rb +88 -0
- data/lib/fizzy_api_client/resources/identity.rb +12 -0
- data/lib/fizzy_api_client/resources/notifications.rb +29 -0
- data/lib/fizzy_api_client/resources/reactions.rb +31 -0
- data/lib/fizzy_api_client/resources/steps.rb +51 -0
- data/lib/fizzy_api_client/resources/tags.rb +11 -0
- data/lib/fizzy_api_client/resources/users.rb +75 -0
- data/lib/fizzy_api_client/response.rb +61 -0
- data/lib/fizzy_api_client/version.rb +5 -0
- data/lib/fizzy_api_client.rb +51 -0
- metadata +117 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FizzyApiClient
|
|
4
|
+
class Client
|
|
5
|
+
include Resources::Identity
|
|
6
|
+
include Resources::Boards
|
|
7
|
+
include Resources::Cards
|
|
8
|
+
include Resources::Columns
|
|
9
|
+
include Resources::Comments
|
|
10
|
+
include Resources::Steps
|
|
11
|
+
include Resources::Reactions
|
|
12
|
+
include Resources::Tags
|
|
13
|
+
include Resources::Users
|
|
14
|
+
include Resources::Notifications
|
|
15
|
+
include Resources::DirectUploads
|
|
16
|
+
|
|
17
|
+
attr_accessor :api_token, :base_url, :timeout, :open_timeout, :user_agent, :logger
|
|
18
|
+
attr_reader :account_slug
|
|
19
|
+
|
|
20
|
+
def initialize(api_token: nil, account_slug: nil, base_url: nil, timeout: nil,
|
|
21
|
+
open_timeout: nil, user_agent: nil, logger: nil)
|
|
22
|
+
config = FizzyApiClient.configuration
|
|
23
|
+
|
|
24
|
+
@api_token = api_token || config.api_token
|
|
25
|
+
@base_url = base_url || config.base_url
|
|
26
|
+
@timeout = timeout || config.timeout
|
|
27
|
+
@open_timeout = open_timeout || config.open_timeout
|
|
28
|
+
@user_agent = user_agent || config.user_agent
|
|
29
|
+
@logger = logger || config.logger
|
|
30
|
+
self.account_slug = account_slug || config.account_slug
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def account_slug=(value)
|
|
34
|
+
@account_slug = normalize_slug(value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def connection
|
|
40
|
+
@connection ||= Connection.new(client_config)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reset_connection!
|
|
44
|
+
@connection = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def client_config
|
|
48
|
+
OpenStruct.new(
|
|
49
|
+
api_token: api_token,
|
|
50
|
+
base_url: base_url,
|
|
51
|
+
timeout: timeout,
|
|
52
|
+
open_timeout: open_timeout,
|
|
53
|
+
user_agent: user_agent,
|
|
54
|
+
logger: logger
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def effective_account_slug(override = nil)
|
|
59
|
+
slug = override || account_slug
|
|
60
|
+
raise ConfigurationError, "account_slug is required" unless slug
|
|
61
|
+
|
|
62
|
+
slug
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize_slug(slug)
|
|
66
|
+
return nil if slug.nil?
|
|
67
|
+
|
|
68
|
+
slug.to_s.sub(%r{^/}, "")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def paginate(path, params: {}, account_slug: nil, auto_paginate: false, page: nil)
|
|
72
|
+
slug = effective_account_slug(account_slug)
|
|
73
|
+
full_path = "/#{slug}#{path}"
|
|
74
|
+
|
|
75
|
+
params = params.merge(page: page) if page
|
|
76
|
+
|
|
77
|
+
response = connection.get(full_path, params: params)
|
|
78
|
+
|
|
79
|
+
return nil if response.no_content?
|
|
80
|
+
|
|
81
|
+
if auto_paginate
|
|
82
|
+
fetch_all_pages(response, params)
|
|
83
|
+
else
|
|
84
|
+
response.body
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def fetch_all_pages(initial_response, _params)
|
|
89
|
+
results = initial_response.body || []
|
|
90
|
+
paginated = PaginatedResponse.new(
|
|
91
|
+
status: initial_response.status,
|
|
92
|
+
headers: initial_response.headers,
|
|
93
|
+
raw_body: initial_response.raw_body
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
while paginated.next_page?
|
|
97
|
+
next_url = paginated.next_page_url
|
|
98
|
+
uri = URI.parse(next_url)
|
|
99
|
+
response = connection.get(uri.path, params: parse_query(uri.query))
|
|
100
|
+
results.concat(response.body) if response.body.is_a?(Array)
|
|
101
|
+
|
|
102
|
+
paginated = PaginatedResponse.new(
|
|
103
|
+
status: response.status,
|
|
104
|
+
headers: response.headers,
|
|
105
|
+
raw_body: response.raw_body
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
results
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def parse_query(query_string)
|
|
113
|
+
return {} unless query_string
|
|
114
|
+
|
|
115
|
+
params = {}
|
|
116
|
+
query_string.split("&").each do |pair|
|
|
117
|
+
key, value = pair.split("=")
|
|
118
|
+
key = URI.decode_www_form_component(key)
|
|
119
|
+
value = URI.decode_www_form_component(value) if value
|
|
120
|
+
params[key] = value
|
|
121
|
+
end
|
|
122
|
+
params
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FizzyApiClient
|
|
4
|
+
# Color mappings for column colors.
|
|
5
|
+
#
|
|
6
|
+
# Provides friendly named colors that map to the underlying CSS variable values
|
|
7
|
+
# expected by the Fizzy API.
|
|
8
|
+
#
|
|
9
|
+
# @example Using named colors
|
|
10
|
+
# client.create_column(board_id: 'board_1', name: 'Review', color: :blue)
|
|
11
|
+
# client.update_column('board_id', 'col_id', color: :lime)
|
|
12
|
+
#
|
|
13
|
+
# @example Using CSS variable directly (still supported)
|
|
14
|
+
# client.create_column(board_id: 'board_1', name: 'Review', color: 'var(--color-card-4)')
|
|
15
|
+
#
|
|
16
|
+
module Colors
|
|
17
|
+
# Color name to CSS variable mapping
|
|
18
|
+
MAPPING = {
|
|
19
|
+
blue: "var(--color-card-default)",
|
|
20
|
+
gray: "var(--color-card-1)",
|
|
21
|
+
tan: "var(--color-card-2)",
|
|
22
|
+
yellow: "var(--color-card-3)",
|
|
23
|
+
lime: "var(--color-card-4)",
|
|
24
|
+
aqua: "var(--color-card-5)",
|
|
25
|
+
violet: "var(--color-card-6)",
|
|
26
|
+
purple: "var(--color-card-7)",
|
|
27
|
+
pink: "var(--color-card-8)"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# All available color names
|
|
31
|
+
NAMES = MAPPING.keys.freeze
|
|
32
|
+
|
|
33
|
+
# Individual color constants for convenience
|
|
34
|
+
BLUE = MAPPING[:blue]
|
|
35
|
+
GRAY = MAPPING[:gray]
|
|
36
|
+
TAN = MAPPING[:tan]
|
|
37
|
+
YELLOW = MAPPING[:yellow]
|
|
38
|
+
LIME = MAPPING[:lime]
|
|
39
|
+
AQUA = MAPPING[:aqua]
|
|
40
|
+
VIOLET = MAPPING[:violet]
|
|
41
|
+
PURPLE = MAPPING[:purple]
|
|
42
|
+
PINK = MAPPING[:pink]
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Resolves a color value to its CSS variable representation.
|
|
46
|
+
#
|
|
47
|
+
# Accepts:
|
|
48
|
+
# - Symbol color names (:blue, :lime, etc.)
|
|
49
|
+
# - String color names ("blue", "lime", etc.)
|
|
50
|
+
# - CSS variable strings (passed through unchanged)
|
|
51
|
+
# - nil (returns nil)
|
|
52
|
+
#
|
|
53
|
+
# @param color [Symbol, String, nil] the color to resolve
|
|
54
|
+
# @return [String, nil] the CSS variable value or nil
|
|
55
|
+
# @raise [ArgumentError] if the color name is not recognized
|
|
56
|
+
#
|
|
57
|
+
# @example
|
|
58
|
+
# Colors.resolve(:blue) #=> "var(--color-card-default)"
|
|
59
|
+
# Colors.resolve("lime") #=> "var(--color-card-4)"
|
|
60
|
+
# Colors.resolve("var(--color-card-3)") #=> "var(--color-card-3)"
|
|
61
|
+
# Colors.resolve(nil) #=> nil
|
|
62
|
+
#
|
|
63
|
+
def resolve(color)
|
|
64
|
+
return nil if color.nil?
|
|
65
|
+
|
|
66
|
+
# If it's already a CSS variable, pass through unchanged
|
|
67
|
+
return color.to_s if color.to_s.start_with?("var(")
|
|
68
|
+
|
|
69
|
+
# Convert to symbol for lookup
|
|
70
|
+
color_sym = color.to_s.downcase.to_sym
|
|
71
|
+
|
|
72
|
+
unless MAPPING.key?(color_sym)
|
|
73
|
+
raise ArgumentError, "Unknown color: #{color.inspect}. Valid colors: #{NAMES.join(', ')}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
MAPPING[color_sym]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Checks if a color name is valid.
|
|
80
|
+
#
|
|
81
|
+
# @param color [Symbol, String] the color name to check
|
|
82
|
+
# @return [Boolean] true if valid
|
|
83
|
+
#
|
|
84
|
+
def valid?(color)
|
|
85
|
+
return true if color.to_s.start_with?("var(")
|
|
86
|
+
|
|
87
|
+
MAPPING.key?(color.to_s.downcase.to_sym)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FizzyApiClient
|
|
4
|
+
class Configuration
|
|
5
|
+
DEFAULT_BASE_URL = "https://app.fizzy.do"
|
|
6
|
+
DEFAULT_TIMEOUT = 30
|
|
7
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
8
|
+
|
|
9
|
+
attr_reader :account_slug
|
|
10
|
+
attr_accessor :api_token, :base_url, :timeout, :open_timeout, :user_agent, :logger
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@api_token = ENV["FIZZY_API_TOKEN"]
|
|
14
|
+
@base_url = ENV["FIZZY_BASE_URL"] || DEFAULT_BASE_URL
|
|
15
|
+
@timeout = DEFAULT_TIMEOUT
|
|
16
|
+
@open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
17
|
+
@user_agent = "FizzyApiClient/#{VERSION}"
|
|
18
|
+
@logger = nil
|
|
19
|
+
self.account_slug = ENV["FIZZY_ACCOUNT"]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def account_slug=(value)
|
|
23
|
+
@account_slug = normalize_slug(value)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def normalize_slug(slug)
|
|
29
|
+
return nil if slug.nil?
|
|
30
|
+
|
|
31
|
+
slug.to_s.sub(%r{^/}, "")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FizzyApiClient
|
|
4
|
+
class Connection
|
|
5
|
+
def initialize(config)
|
|
6
|
+
@config = config
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def get(path, params: {}, headers: {})
|
|
10
|
+
request(:get, path, params: params, headers: headers)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def post(path, body: {}, headers: {})
|
|
14
|
+
request(:post, path, body: body, headers: headers)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def put(path, body: {}, headers: {})
|
|
18
|
+
request(:put, path, body: body, headers: headers)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def patch(path, body: {}, headers: {})
|
|
22
|
+
request(:patch, path, body: body, headers: headers)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delete(path, params: {}, headers: {})
|
|
26
|
+
request(:delete, path, params: params, headers: headers)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Follow a Location header and return the resource
|
|
30
|
+
def follow_location(response)
|
|
31
|
+
return response unless response.created_with_location?
|
|
32
|
+
|
|
33
|
+
location = response.location
|
|
34
|
+
# Strip .json suffix if present for cleaner path
|
|
35
|
+
path = location.sub(/\.json\z/, "")
|
|
36
|
+
get(path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def post_multipart(path, fields: {}, files: {}, headers: {})
|
|
40
|
+
request_multipart(:post, path, fields: fields, files: files, headers: headers)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def put_multipart(path, fields: {}, files: {}, headers: {})
|
|
44
|
+
request_multipart(:put, path, fields: fields, files: files, headers: headers)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def request(method, path, params: {}, body: {}, headers: {})
|
|
50
|
+
uri = build_uri(path, params)
|
|
51
|
+
http = build_http(uri)
|
|
52
|
+
req = build_request(method, uri, body, headers)
|
|
53
|
+
|
|
54
|
+
log_request(method, uri)
|
|
55
|
+
start_time = Time.now
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
http_response = http.request(req)
|
|
59
|
+
elapsed_ms = ((Time.now - start_time) * 1000).round
|
|
60
|
+
|
|
61
|
+
response = build_response(http_response)
|
|
62
|
+
log_response(response.status, uri, elapsed_ms)
|
|
63
|
+
|
|
64
|
+
handle_errors(response, method, path)
|
|
65
|
+
response
|
|
66
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
67
|
+
log_error(e)
|
|
68
|
+
raise TimeoutError, "Request timed out: #{e.message}"
|
|
69
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
|
|
70
|
+
log_error(e)
|
|
71
|
+
raise ConnectionError, "Connection failed: #{e.message}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def request_multipart(method, path, fields:, files:, headers:)
|
|
76
|
+
uri = build_uri(path, {})
|
|
77
|
+
http = build_http(uri)
|
|
78
|
+
|
|
79
|
+
body, content_type = Multipart.build(fields, files)
|
|
80
|
+
|
|
81
|
+
req = build_multipart_request(method, uri, body, content_type, headers)
|
|
82
|
+
|
|
83
|
+
log_request(method, uri)
|
|
84
|
+
start_time = Time.now
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
http_response = http.request(req)
|
|
88
|
+
elapsed_ms = ((Time.now - start_time) * 1000).round
|
|
89
|
+
|
|
90
|
+
response = build_response(http_response)
|
|
91
|
+
log_response(response.status, uri, elapsed_ms)
|
|
92
|
+
|
|
93
|
+
handle_errors(response, method, path)
|
|
94
|
+
response
|
|
95
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
96
|
+
log_error(e)
|
|
97
|
+
raise TimeoutError, "Request timed out: #{e.message}"
|
|
98
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
|
|
99
|
+
log_error(e)
|
|
100
|
+
raise ConnectionError, "Connection failed: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_uri(path, params)
|
|
105
|
+
base = URI.parse(@config.base_url)
|
|
106
|
+
request = Request.new(path: path, params: params)
|
|
107
|
+
|
|
108
|
+
URI.parse("#{base.scheme}://#{base.host}:#{base.port}#{request.full_path}")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_http(uri)
|
|
112
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
113
|
+
http.use_ssl = uri.scheme == "https"
|
|
114
|
+
http.open_timeout = @config.open_timeout
|
|
115
|
+
http.read_timeout = @config.timeout
|
|
116
|
+
http
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_request(method, uri, body, headers)
|
|
120
|
+
req = case method
|
|
121
|
+
when :get then Net::HTTP::Get.new(uri.request_uri)
|
|
122
|
+
when :post then Net::HTTP::Post.new(uri.request_uri)
|
|
123
|
+
when :put then Net::HTTP::Put.new(uri.request_uri)
|
|
124
|
+
when :patch then Net::HTTP::Patch.new(uri.request_uri)
|
|
125
|
+
when :delete then Net::HTTP::Delete.new(uri.request_uri)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
apply_default_headers(req)
|
|
129
|
+
apply_custom_headers(req, headers)
|
|
130
|
+
|
|
131
|
+
if %i[post put patch].include?(method) && !body.empty?
|
|
132
|
+
req.body = body.to_json
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
req
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_multipart_request(method, uri, body, content_type, headers)
|
|
139
|
+
req = case method
|
|
140
|
+
when :post then Net::HTTP::Post.new(uri.request_uri)
|
|
141
|
+
when :put then Net::HTTP::Put.new(uri.request_uri)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
apply_default_headers(req, skip_content_type: true)
|
|
145
|
+
apply_custom_headers(req, headers)
|
|
146
|
+
req["Content-Type"] = content_type
|
|
147
|
+
req.body = body
|
|
148
|
+
|
|
149
|
+
req
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def apply_default_headers(req, skip_content_type: false)
|
|
153
|
+
req["Accept"] = "application/json"
|
|
154
|
+
req["Content-Type"] = "application/json" unless skip_content_type
|
|
155
|
+
req["User-Agent"] = @config.user_agent
|
|
156
|
+
req["Authorization"] = "Bearer #{@config.api_token}" if @config.api_token
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def apply_custom_headers(req, headers)
|
|
160
|
+
headers.each { |key, value| req[key] = value }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def build_response(http_response)
|
|
164
|
+
headers = {}
|
|
165
|
+
http_response.each_header { |key, value| headers[key] = value }
|
|
166
|
+
|
|
167
|
+
Response.new(
|
|
168
|
+
status: http_response.code.to_i,
|
|
169
|
+
headers: headers,
|
|
170
|
+
raw_body: http_response.body
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def handle_errors(response, method, path)
|
|
175
|
+
return if response.success? || response.not_modified?
|
|
176
|
+
|
|
177
|
+
error_class = error_class_for_status(response.status)
|
|
178
|
+
raise error_class.new(
|
|
179
|
+
status: response.status,
|
|
180
|
+
body: response.body,
|
|
181
|
+
method: method,
|
|
182
|
+
path: path
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def error_class_for_status(status)
|
|
187
|
+
case status
|
|
188
|
+
when 401 then AuthenticationError
|
|
189
|
+
when 403 then ForbiddenError
|
|
190
|
+
when 404 then NotFoundError
|
|
191
|
+
when 422 then ValidationError
|
|
192
|
+
when 500..599 then ServerError
|
|
193
|
+
else ApiError
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def log_request(method, uri)
|
|
198
|
+
return unless @config.logger
|
|
199
|
+
|
|
200
|
+
@config.logger.debug("[FizzyApiClient] #{method.to_s.upcase} #{uri}")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def log_response(status, uri, elapsed_ms)
|
|
204
|
+
return unless @config.logger
|
|
205
|
+
|
|
206
|
+
@config.logger.debug("[FizzyApiClient] #{status} #{uri} (#{elapsed_ms}ms)")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def log_error(error)
|
|
210
|
+
return unless @config.logger
|
|
211
|
+
|
|
212
|
+
@config.logger.error("[FizzyApiClient] #{error.class}: #{error.message}")
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FizzyApiClient
|
|
4
|
+
# Base error class
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Configuration errors (missing config)
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Network failures
|
|
11
|
+
class ConnectionError < Error; end
|
|
12
|
+
|
|
13
|
+
# Request timeout
|
|
14
|
+
class TimeoutError < Error; end
|
|
15
|
+
|
|
16
|
+
# API returned an error response
|
|
17
|
+
class ApiError < Error
|
|
18
|
+
attr_reader :status, :body, :method, :path
|
|
19
|
+
|
|
20
|
+
def initialize(message: nil, status: nil, body: nil, method: nil, path: nil)
|
|
21
|
+
@status = status
|
|
22
|
+
@body = body
|
|
23
|
+
@method = method
|
|
24
|
+
@path = path
|
|
25
|
+
|
|
26
|
+
error_message = build_message(message)
|
|
27
|
+
super(error_message)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def build_message(message)
|
|
33
|
+
parts = []
|
|
34
|
+
parts << message if message
|
|
35
|
+
parts << "Status: #{status}" if status
|
|
36
|
+
parts << extract_api_error_message if body.is_a?(Hash)
|
|
37
|
+
parts.join(" - ")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def extract_api_error_message
|
|
41
|
+
body[:error] || body["error"] || body[:errors] || body["errors"]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# 401 Unauthorized
|
|
46
|
+
class AuthenticationError < ApiError; end
|
|
47
|
+
|
|
48
|
+
# 403 Forbidden
|
|
49
|
+
class ForbiddenError < ApiError; end
|
|
50
|
+
|
|
51
|
+
# 404 Not Found
|
|
52
|
+
class NotFoundError < ApiError; end
|
|
53
|
+
|
|
54
|
+
# 422 Unprocessable Entity
|
|
55
|
+
class ValidationError < ApiError; end
|
|
56
|
+
|
|
57
|
+
# 500+ Server errors
|
|
58
|
+
class ServerError < ApiError; end
|
|
59
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FizzyApiClient
|
|
4
|
+
module Multipart
|
|
5
|
+
class << self
|
|
6
|
+
def build(fields, files = {})
|
|
7
|
+
boundary = generate_boundary
|
|
8
|
+
body = build_body(fields, files, boundary)
|
|
9
|
+
content_type = "multipart/form-data; boundary=#{boundary}"
|
|
10
|
+
|
|
11
|
+
[body, content_type]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def generate_boundary
|
|
17
|
+
"----FizzyApiClient#{SecureRandom.hex(16)}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build_body(fields, files, boundary)
|
|
21
|
+
parts = []
|
|
22
|
+
|
|
23
|
+
# Add regular fields
|
|
24
|
+
fields.each do |key, value|
|
|
25
|
+
parts << field_part(key, value, boundary)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Add file fields
|
|
29
|
+
files.each do |key, file_info|
|
|
30
|
+
parts << file_part(key, file_info, boundary)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Close with final boundary
|
|
34
|
+
parts << "--#{boundary}--\r\n"
|
|
35
|
+
|
|
36
|
+
parts.join
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def field_part(key, value, boundary)
|
|
40
|
+
"--#{boundary}\r\n" \
|
|
41
|
+
"Content-Disposition: form-data; name=\"#{key}\"\r\n" \
|
|
42
|
+
"\r\n" \
|
|
43
|
+
"#{value}\r\n"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def file_part(key, file_info, boundary)
|
|
47
|
+
filename = file_info[:filename]
|
|
48
|
+
content_type = file_info[:content_type] || "application/octet-stream"
|
|
49
|
+
content = file_info[:content]
|
|
50
|
+
|
|
51
|
+
"--#{boundary}\r\n" \
|
|
52
|
+
"Content-Disposition: form-data; name=\"#{key}\"; filename=\"#{filename}\"\r\n" \
|
|
53
|
+
"Content-Type: #{content_type}\r\n" \
|
|
54
|
+
"\r\n" \
|
|
55
|
+
"#{content}\r\n"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FizzyApiClient
|
|
4
|
+
class Request
|
|
5
|
+
attr_reader :path, :params, :path_params
|
|
6
|
+
|
|
7
|
+
def initialize(path:, params: {}, path_params: {})
|
|
8
|
+
@path = interpolate_path(path, path_params)
|
|
9
|
+
@params = params
|
|
10
|
+
@path_params = path_params
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def full_path
|
|
14
|
+
return path if params.empty?
|
|
15
|
+
|
|
16
|
+
query_string = build_query_string(params)
|
|
17
|
+
"#{path}?#{query_string}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def wrap_payload(resource_name, data)
|
|
21
|
+
{ resource_name => data }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def interpolate_path(path, path_params)
|
|
27
|
+
result = path.dup
|
|
28
|
+
path_params.each do |key, value|
|
|
29
|
+
result.gsub!(":#{key}", value.to_s)
|
|
30
|
+
end
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_query_string(params)
|
|
35
|
+
parts = []
|
|
36
|
+
|
|
37
|
+
params.each do |key, value|
|
|
38
|
+
if value.is_a?(Array)
|
|
39
|
+
value.each do |v|
|
|
40
|
+
parts << "#{key}[]=#{URI.encode_www_form_component(v.to_s)}"
|
|
41
|
+
end
|
|
42
|
+
else
|
|
43
|
+
parts << "#{key}=#{URI.encode_www_form_component(value.to_s)}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
parts.join("&")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FizzyApiClient
|
|
4
|
+
module Resources
|
|
5
|
+
module Boards
|
|
6
|
+
def boards(page: nil, auto_paginate: false, account_slug: nil)
|
|
7
|
+
paginate("/boards", account_slug: account_slug, auto_paginate: auto_paginate, page: page)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def board(board_id, account_slug: nil)
|
|
11
|
+
slug = effective_account_slug(account_slug)
|
|
12
|
+
response = connection.get("/#{slug}/boards/#{board_id}")
|
|
13
|
+
response.body
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create_board(name:, all_access: nil, auto_postpone_period: nil, public_description: nil, account_slug: nil)
|
|
17
|
+
slug = effective_account_slug(account_slug)
|
|
18
|
+
|
|
19
|
+
payload = { name: name }
|
|
20
|
+
payload[:all_access] = all_access unless all_access.nil?
|
|
21
|
+
payload[:auto_postpone_period] = auto_postpone_period unless auto_postpone_period.nil?
|
|
22
|
+
payload[:public_description] = public_description unless public_description.nil?
|
|
23
|
+
|
|
24
|
+
response = connection.post("/#{slug}/boards", body: { board: payload })
|
|
25
|
+
response = connection.follow_location(response)
|
|
26
|
+
response.body
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def update_board(board_id, name: nil, all_access: nil, auto_postpone_period: nil,
|
|
30
|
+
public_description: nil, user_ids: nil, account_slug: nil)
|
|
31
|
+
slug = effective_account_slug(account_slug)
|
|
32
|
+
|
|
33
|
+
payload = {}
|
|
34
|
+
payload[:name] = name unless name.nil?
|
|
35
|
+
payload[:all_access] = all_access unless all_access.nil?
|
|
36
|
+
payload[:auto_postpone_period] = auto_postpone_period unless auto_postpone_period.nil?
|
|
37
|
+
payload[:public_description] = public_description unless public_description.nil?
|
|
38
|
+
payload[:user_ids] = user_ids unless user_ids.nil?
|
|
39
|
+
|
|
40
|
+
response = connection.put("/#{slug}/boards/#{board_id}", body: { board: payload })
|
|
41
|
+
response.no_content? ? nil : response.body
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete_board(board_id, account_slug: nil)
|
|
45
|
+
slug = effective_account_slug(account_slug)
|
|
46
|
+
response = connection.delete("/#{slug}/boards/#{board_id}")
|
|
47
|
+
response.no_content? ? nil : response.body
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|