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.
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FizzyApiClient
4
+ class PaginatedResponse < Response
5
+ def next_page?
6
+ !next_page_url.nil?
7
+ end
8
+ end
9
+ 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