wip-ruby 0.2.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/Rakefile ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ t.warning = false
11
+ end
12
+
13
+ namespace :test do
14
+ Rake::TestTask.new(:unit) do |t|
15
+ t.libs << "test"
16
+ t.libs << "lib"
17
+ t.test_files = FileList["test/unit/**/*_test.rb"]
18
+ t.warning = false
19
+ end
20
+
21
+ Rake::TestTask.new(:integration) do |t|
22
+ t.libs << "test"
23
+ t.libs << "lib"
24
+ t.test_files = FileList["test/integration/**/*_test.rb"]
25
+ t.warning = false
26
+ end
27
+ end
28
+
29
+ desc "Run tests with coverage report"
30
+ task :coverage do
31
+ ENV["COVERAGE"] = "true"
32
+ Rake::Task[:test].invoke
33
+ end
34
+
35
+ desc "Record VCR cassettes (requires WIP_API_KEY)"
36
+ task :record_cassettes do
37
+ unless ENV["WIP_API_KEY"]
38
+ abort "ERROR: WIP_API_KEY environment variable required"
39
+ end
40
+
41
+ ENV["VCR"] = "all"
42
+ ruby "test/support/cassette_recorder.rb"
43
+ end
44
+
45
+ task default: :test
data/lib/wip/client.rb ADDED
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "http_client"
4
+ require_relative "resources/todos"
5
+ require_relative "resources/users"
6
+ require_relative "resources/projects"
7
+ require_relative "resources/uploads"
8
+ require_relative "resources/viewer"
9
+ require_relative "resources/comments"
10
+ require_relative "resources/reactions"
11
+
12
+ module Wip
13
+ # Main client class for interacting with the WIP API
14
+ #
15
+ # @example Basic usage
16
+ # Wip.configure do |config|
17
+ # config.api_key = "your-api-key"
18
+ # end
19
+ #
20
+ # client = Wip::Client.new
21
+ # client.viewer.me
22
+ # client.todos.create(body: "Shipped new feature!")
23
+ #
24
+ # @example Working with comments and reactions
25
+ # # Add a comment to a todo
26
+ # client.comments.create(
27
+ # commentable_type: "Todo",
28
+ # commentable_id: "todo_123",
29
+ # body: "Great work!"
30
+ # )
31
+ #
32
+ # # React to a todo
33
+ # client.reactions.react_to_todo("todo_123")
34
+ class Client
35
+ # @return [Configuration] The configuration instance
36
+ attr_reader :config
37
+
38
+ # @return [HTTPClient] The HTTP client instance
39
+ attr_reader :http_client
40
+
41
+ # Initialize a new API client
42
+ # @param config [Configuration] The configuration instance
43
+ def initialize(config = Wip.configuration)
44
+ @config = config
45
+ @config.validate!
46
+ @http_client = HTTPClient.new(config)
47
+ end
48
+
49
+ # @return [Resources::Todos] The todos resource
50
+ def todos
51
+ @todos ||= Resources::Todos.new(http_client)
52
+ end
53
+
54
+ # @return [Resources::Users] The users resource
55
+ def users
56
+ @users ||= Resources::Users.new(http_client)
57
+ end
58
+
59
+ # @return [Resources::Projects] The projects resource
60
+ def projects
61
+ @projects ||= Resources::Projects.new(http_client)
62
+ end
63
+
64
+ # @return [Resources::Uploads] The uploads resource
65
+ def uploads
66
+ @uploads ||= Resources::Uploads.new(http_client)
67
+ end
68
+
69
+ # @return [Resources::Viewer] The viewer resource
70
+ def viewer
71
+ @viewer ||= Resources::Viewer.new(http_client)
72
+ end
73
+
74
+ # @return [Resources::Comments] The comments resource
75
+ def comments
76
+ @comments ||= Resources::Comments.new(http_client)
77
+ end
78
+
79
+ # @return [Resources::Reactions] The reactions resource
80
+ def reactions
81
+ @reactions ||= Resources::Reactions.new(http_client)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wip
4
+ # Configuration class for the Wip API client
5
+ # @api private
6
+ class Configuration
7
+ # Default API endpoint
8
+ DEFAULT_BASE_URL = "https://api.wip.co"
9
+ # Default request timeout in seconds
10
+ DEFAULT_TIMEOUT = 10
11
+ # Default number of retries for failed requests
12
+ DEFAULT_MAX_RETRIES = 2
13
+ # Maximum allowed retries
14
+ MAX_RETRIES = 5
15
+ # Minimum allowed timeout value
16
+ MIN_TIMEOUT = 1
17
+ # Maximum allowed timeout value
18
+ MAX_TIMEOUT = 60
19
+
20
+ # @return [String] API key for authentication
21
+ attr_accessor :api_key
22
+
23
+ # @return [String] Base URL for API requests
24
+ attr_accessor :base_url
25
+
26
+ # @return [Integer] Request timeout in seconds (1-60)
27
+ attr_accessor :timeout
28
+
29
+ # @return [Integer] Number of retries for failed requests (0-5)
30
+ attr_accessor :max_retries
31
+
32
+ # @return [Logger, nil] Logger instance for debugging
33
+ attr_accessor :logger
34
+
35
+ def initialize
36
+ @base_url = DEFAULT_BASE_URL
37
+ @timeout = DEFAULT_TIMEOUT
38
+ @max_retries = DEFAULT_MAX_RETRIES
39
+ @logger = nil
40
+ end
41
+
42
+ # Validates the configuration
43
+ # @raise [Error::ConfigurationError] if configuration is invalid
44
+ def validate!
45
+ validate_api_key!
46
+ validate_base_url!
47
+ validate_timeout!
48
+ validate_max_retries!
49
+ validate_logger!
50
+ end
51
+
52
+ private
53
+
54
+ def validate_api_key!
55
+ return if api_key&.is_a?(String) && !api_key.strip.empty?
56
+
57
+ raise Error::ConfigurationError, "API key is required and must be a non-empty string"
58
+ end
59
+
60
+ def validate_base_url!
61
+ return if base_url&.is_a?(String) && !base_url.strip.empty?
62
+
63
+ raise Error::ConfigurationError, "Base URL is required and must be a non-empty string"
64
+ end
65
+
66
+ def validate_timeout!
67
+ return if timeout.is_a?(Integer) && timeout.between?(MIN_TIMEOUT, MAX_TIMEOUT)
68
+
69
+ raise Error::ConfigurationError,
70
+ "Timeout must be an integer between #{MIN_TIMEOUT} and #{MAX_TIMEOUT} seconds"
71
+ end
72
+
73
+ def validate_max_retries!
74
+ return if max_retries.is_a?(Integer) && max_retries.between?(0, MAX_RETRIES)
75
+
76
+ raise Error::ConfigurationError,
77
+ "Max retries must be an integer between 0 and #{MAX_RETRIES}"
78
+ end
79
+
80
+ def validate_logger!
81
+ return unless logger
82
+ return if logger.respond_to?(:debug) && logger.respond_to?(:info) &&
83
+ logger.respond_to?(:warn) && logger.respond_to?(:error)
84
+
85
+ raise Error::ConfigurationError,
86
+ "Logger must respond to :debug, :info, :warn, and :error"
87
+ end
88
+ end
89
+ end
data/lib/wip/error.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wip
4
+ # Base error class for all Wip errors
5
+ class Error < StandardError
6
+ # @return [String] The error message
7
+ attr_reader :message
8
+
9
+ # @return [Integer, nil] The HTTP status code (if applicable)
10
+ attr_reader :status_code
11
+
12
+ # @return [Hash, nil] The raw response data (if applicable)
13
+ attr_reader :response_data
14
+
15
+ def initialize(message = nil, status_code: nil, response_data: nil)
16
+ @message = message
17
+ @status_code = status_code
18
+ @response_data = response_data
19
+ super(message)
20
+ end
21
+
22
+ # Configuration related errors
23
+ class ConfigurationError < Error; end
24
+
25
+ # Network related errors
26
+ class NetworkError < Error; end
27
+ class TimeoutError < NetworkError; end
28
+ class ConnectionError < NetworkError; end
29
+
30
+ # API related errors
31
+ class APIError < Error; end
32
+ class NotFoundError < APIError; end
33
+ class UnauthorizedError < APIError; end
34
+ class ForbiddenError < APIError; end
35
+ class RateLimitError < APIError; end
36
+ class ValidationError < APIError; end
37
+ class ServerError < APIError; end
38
+
39
+ # Upload related errors
40
+ class UploadError < Error; end
41
+
42
+ # Maps HTTP status codes to error classes
43
+ # @api private
44
+ STATUS_CODE_TO_ERROR = {
45
+ 400 => ValidationError,
46
+ 401 => UnauthorizedError,
47
+ 403 => ForbiddenError,
48
+ 404 => NotFoundError,
49
+ 422 => ValidationError,
50
+ 429 => RateLimitError,
51
+ 500 => ServerError,
52
+ 502 => ServerError,
53
+ 503 => ServerError,
54
+ 504 => ServerError
55
+ }.freeze
56
+
57
+ # Creates an error from an HTTP response
58
+ # @param response [Hash] The HTTP response
59
+ # @return [Error] The appropriate error instance
60
+ # @api private
61
+ def self.from_response(response)
62
+ status = response[:status]
63
+ data = response[:body]
64
+
65
+ # Extract error message from response body
66
+ message = if data.is_a?(Hash)
67
+ data["error"] || data["message"] || "Unknown error"
68
+ else
69
+ data.to_s
70
+ end
71
+
72
+ error_class = STATUS_CODE_TO_ERROR[status] || APIError
73
+ error_class.new(message, status_code: status, response_data: data)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+ require_relative "version"
7
+
8
+ module Wip
9
+ # HTTP client for making API requests
10
+ # @api private
11
+ class HTTPClient
12
+ # Response object wrapping the HTTP response
13
+ # @api private
14
+ class Response
15
+ # @return [Integer] The HTTP status code
16
+ attr_reader :status
17
+
18
+ # @return [Hash] The response headers
19
+ attr_reader :headers
20
+
21
+ # @return [Hash] The parsed response body
22
+ attr_reader :body
23
+
24
+ # Initialize a new response
25
+ # @param status [Integer] The HTTP status code
26
+ # @param headers [Hash] The response headers
27
+ # @param body [Hash] The parsed response body
28
+ def initialize(status:, headers:, body:)
29
+ @status = status
30
+ @headers = headers
31
+ @body = body
32
+ end
33
+
34
+ # Check if the response indicates a resource was created
35
+ # @return [Boolean] True if status is 201 Created
36
+ def created?
37
+ status == 201
38
+ end
39
+
40
+ # Extract the resource from the response based on status and endpoint conventions
41
+ # @param resource_key [Symbol, String] The expected resource key for 201 responses
42
+ # @return [Hash] The resource data
43
+ def extract_resource(resource_key = nil)
44
+ if created? && resource_key
45
+ body[resource_key.to_s] || body[resource_key.to_sym]
46
+ else
47
+ body
48
+ end
49
+ end
50
+ end
51
+
52
+ # Default middleware stack for the Faraday connection
53
+ # NOTE: Response middleware is processed in REVERSE order of definition
54
+ MIDDLEWARE = proc do |f|
55
+ # Encode request bodies as JSON
56
+ f.request :json
57
+
58
+ # Raise errors for non-2xx responses (defined before :json so it runs AFTER parsing)
59
+ f.response :raise_error
60
+
61
+ # Parse response bodies as JSON (runs before :raise_error in response processing)
62
+ f.response :json, content_type: /\bjson$/
63
+
64
+ # Retry failed requests with exponential backoff
65
+ f.request :retry, {
66
+ # Default retry configuration
67
+ max: config.max_retries,
68
+ interval: 0.05, # 50ms
69
+ interval_randomness: 0.5, # Randomize interval by +/- 50%
70
+ backoff_factor: 2, # Exponential backoff
71
+
72
+ # Exceptions to retry (including default ones)
73
+ exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [
74
+ Errno::ETIMEDOUT,
75
+ Timeout::Error
76
+ ],
77
+
78
+ # Only retry idempotent methods (PATCH is idempotent per HTTP spec)
79
+ methods: %i[delete get head options patch put],
80
+
81
+ # Status codes that trigger a retry
82
+ retry_statuses: [408, 429, 500, 502, 503, 504],
83
+
84
+ # Callback for logging retries
85
+ # Signature: (retry_count, exception, env) in faraday-retry 2.x
86
+ retry_block: lambda { |*args|
87
+ # Silently handle retries - logging is optional
88
+ # Different faraday-retry versions have different signatures
89
+ begin
90
+ retry_count, exception, env = args
91
+ logger = env&.request&.context&.dig(:config)&.logger
92
+ return unless logger
93
+
94
+ logger.warn(
95
+ "[Wip] Retry #{retry_count} for #{env.method.upcase} #{env.url}: " \
96
+ "#{exception&.class&.name}"
97
+ )
98
+ rescue StandardError
99
+ # Ignore logging errors during retry - don't break the retry flow
100
+ end
101
+ }
102
+ }
103
+
104
+ # Add logging if configured
105
+ if f.options.context&.config&.logger
106
+ f.response :logger, f.options.context.config.logger, {
107
+ headers: true,
108
+ bodies: true,
109
+ log_level: :debug,
110
+ formatter: proc { |*args| "[Wip] #{args.last}" }
111
+ }
112
+ end
113
+
114
+ # Use Net::HTTP adapter (default and most compatible)
115
+ f.adapter Faraday.default_adapter
116
+ end
117
+
118
+ # @return [Configuration] The configuration instance
119
+ attr_reader :config
120
+
121
+ # Initialize a new HTTP client
122
+ # @param config [Configuration] The configuration instance
123
+ def initialize(config)
124
+ @config = config
125
+ end
126
+
127
+ # Make a GET request
128
+ # @param path [String] The path to request
129
+ # @param params [Hash] Query parameters
130
+ # @return [Response] The response wrapper
131
+ def get(path, params = {})
132
+ request(:get, path, params: params)
133
+ end
134
+
135
+ # Make a POST request
136
+ # @param path [String] The path to request
137
+ # @param body [Hash] The request body
138
+ # @return [Response] The response wrapper
139
+ def post(path, body = {})
140
+ request(:post, path, body: body)
141
+ end
142
+
143
+ # Make a PUT request
144
+ # @param path [String] The path to request
145
+ # @param body [Hash] The request body
146
+ # @return [Response] The response wrapper
147
+ def put(path, body = {})
148
+ request(:put, path, body: body)
149
+ end
150
+
151
+ # Make a PATCH request
152
+ # @param path [String] The path to request
153
+ # @param body [Hash] The request body
154
+ # @return [Response] The response wrapper
155
+ def patch(path, body = {})
156
+ request(:patch, path, body: body)
157
+ end
158
+
159
+ # Make a DELETE request
160
+ # @param path [String] The path to request
161
+ # @param params [Hash] Query parameters
162
+ # @return [Response] The response wrapper
163
+ def delete(path, params = {})
164
+ request(:delete, path, params: params)
165
+ end
166
+
167
+ private
168
+
169
+ # Make an HTTP request
170
+ # @param method [Symbol] The HTTP method
171
+ # @param path [String] The path to request
172
+ # @param params [Hash] Query parameters
173
+ # @param body [Hash] The request body
174
+ # @return [Response] The response wrapper
175
+ def request(method, path, params: {}, body: nil)
176
+ # Add API key to query parameters
177
+ params = params.merge(api_key: config.api_key)
178
+
179
+ response = connection.run_request(method, path, body, nil) do |req|
180
+ req.params.merge!(params) unless params.empty?
181
+ req.options.timeout = config.timeout
182
+ req.options.open_timeout = config.timeout
183
+ end
184
+
185
+ Response.new(
186
+ status: response.status,
187
+ headers: response.headers,
188
+ body: response.body
189
+ )
190
+ rescue Faraday::ConnectionFailed, Faraday::SSLError => e
191
+ raise Error::ConnectionError, "Connection failed: #{e.message}"
192
+ rescue Faraday::TimeoutError => e
193
+ raise Error::TimeoutError, "Request timed out: #{e.message}"
194
+ rescue Faraday::ClientError, Faraday::ServerError => e
195
+ if e.response
196
+ response_hash = {
197
+ status: e.response[:status],
198
+ body: e.response[:body]
199
+ }
200
+ raise Error.from_response(response_hash)
201
+ else
202
+ raise Error::NetworkError, "HTTP error: #{e.message}"
203
+ end
204
+ rescue Faraday::Error => e
205
+ raise Error::NetworkError, "Request failed: #{e.message}"
206
+ end
207
+
208
+ # Returns a Faraday connection instance
209
+ # @return [Faraday::Connection] The connection instance
210
+ def connection
211
+ @connection ||= Faraday.new(url: config.base_url) do |f|
212
+ # Add our middleware stack
213
+ instance_exec(f, &MIDDLEWARE)
214
+
215
+ # Set user agent
216
+ f.headers["User-Agent"] = "wip-ruby/#{Wip::VERSION} Faraday/#{Faraday::VERSION}"
217
+ f.options.context = { config: config }
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Wip
6
+ module Models
7
+ # Base class for all API models
8
+ class Base
9
+ class << self
10
+ # Define an attribute with optional type coercion
11
+ # @param name [Symbol] The attribute name
12
+ # @param type [Class, nil] The type to coerce to
13
+ def attribute(name, type: nil)
14
+ attributes[name] = type
15
+
16
+ # Define getter
17
+ define_method(name) do
18
+ @attributes[name]
19
+ end
20
+
21
+ # Define predicate method for boolean attributes
22
+ if type == :boolean
23
+ define_method("#{name}?") do
24
+ !!@attributes[name]
25
+ end
26
+ end
27
+ end
28
+
29
+ # @return [Hash] Map of attribute names to their types
30
+ def attributes
31
+ @attributes ||= {}
32
+ end
33
+
34
+ # Create a new instance from a JSON response
35
+ # @param json [Hash] The JSON response
36
+ # @return [Base] The new instance
37
+ def from_json(json)
38
+ new(json)
39
+ end
40
+ end
41
+
42
+ # @return [Hash] The coerced attributes
43
+ attr_reader :attributes
44
+
45
+ # @return [Hash] The raw, uncoerced attributes from the API
46
+ attr_reader :raw_attributes
47
+
48
+ # Initialize a new model
49
+ # @param attributes [Hash] The model attributes
50
+ def initialize(attributes = {})
51
+ @raw_attributes = attributes.dup
52
+ @attributes = {}
53
+ update_attributes(attributes)
54
+ end
55
+
56
+ # Update the model's attributes
57
+ # @param attributes [Hash] The new attributes
58
+ # @return [void]
59
+ def update_attributes(attributes)
60
+ @raw_attributes.merge!(attributes)
61
+ self.class.attributes.each do |name, type|
62
+ str_key = name.to_s
63
+ sym_key = name.to_sym
64
+ # Use key? to properly handle false/nil values
65
+ has_key = attributes.key?(str_key) || attributes.key?(sym_key)
66
+ next unless has_key
67
+
68
+ value = attributes.key?(str_key) ? attributes[str_key] : attributes[sym_key]
69
+ # Store nil values explicitly to distinguish "not set" from "explicitly null"
70
+ @attributes[name] = value.nil? ? nil : coerce_value(value, type)
71
+ end
72
+ end
73
+
74
+ # Get a raw attribute value by key
75
+ # @param key [String, Symbol] The attribute key
76
+ # @return [Object, nil] The raw attribute value
77
+ def [](key)
78
+ raw_attributes[key.to_s] || raw_attributes[key.to_sym]
79
+ end
80
+
81
+ # Check if a raw attribute exists
82
+ # @param key [String, Symbol] The attribute key
83
+ # @return [Boolean] Whether the attribute exists
84
+ def attribute?(key)
85
+ raw_attributes.key?(key.to_s) || raw_attributes.key?(key.to_sym)
86
+ end
87
+
88
+ # Get all unknown attributes (those not defined via attribute)
89
+ # @return [Hash] The unknown attributes
90
+ def unknown_attributes
91
+ known_keys = self.class.attributes.keys.flat_map { |k| [k.to_s, k.to_sym] }
92
+ raw_attributes.reject { |k, _| known_keys.include?(k) }
93
+ end
94
+
95
+ # @return [String] A human-readable representation of the model showing all attributes and their values
96
+ def inspect
97
+ "#<#{self.class} " + self.class.attributes.keys.map { |attr| "#{attr}: #{public_send(attr).inspect}" }.join(", ") + ">"
98
+ end
99
+
100
+ private
101
+
102
+ # Coerce a value to the specified type
103
+ # @param value [Object] The value to coerce
104
+ # @param type [Class, nil] The type to coerce to
105
+ # @return [Object] The coerced value
106
+ # @raise [ArgumentError] If the value cannot be coerced to the specified type
107
+ def coerce_value(value, type)
108
+ case type
109
+ when :boolean
110
+ !!value
111
+ when :time
112
+ return value if value.is_a?(Time)
113
+ begin
114
+ Time.parse(value)
115
+ rescue ArgumentError, TypeError
116
+ raise ArgumentError, "Invalid time value: #{value.inspect}"
117
+ end
118
+ when :integer
119
+ coerce_integer(value)
120
+ when :float
121
+ coerce_float(value)
122
+ when :string
123
+ value.to_s
124
+ when Array
125
+ return [] unless value.is_a?(Array)
126
+
127
+ value.map { |v| coerce_value(v, type.first) }
128
+ when Class
129
+ value.is_a?(type) ? value : type.from_json(value)
130
+ else
131
+ value
132
+ end
133
+ end
134
+
135
+ # Coerce a value to an integer with strict validation
136
+ # @param value [Object] The value to coerce
137
+ # @return [Integer] The coerced integer
138
+ # @raise [ArgumentError] If the value is not a valid integer
139
+ def coerce_integer(value)
140
+ return value if value.is_a?(Integer)
141
+ return value.to_i if value.is_a?(Float)
142
+ return value.to_i if value.is_a?(String) && value.match?(/\A-?\d+\z/)
143
+
144
+ raise ArgumentError, "Invalid integer value: #{value.inspect}"
145
+ end
146
+
147
+ # Coerce a value to a float with strict validation
148
+ # @param value [Object] The value to coerce
149
+ # @return [Float] The coerced float
150
+ # @raise [ArgumentError] If the value is not a valid float
151
+ def coerce_float(value)
152
+ return value.to_f if value.is_a?(Integer)
153
+ return value if value.is_a?(Float)
154
+ return value.to_f if value.is_a?(String) && value.match?(/\A-?\d+(\.\d+)?\z/)
155
+
156
+ raise ArgumentError, "Invalid float value: #{value.inspect}"
157
+ end
158
+ end
159
+ end
160
+ end