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.
- checksums.yaml +7 -0
- data/.simplecov +36 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +846 -0
- data/Rakefile +45 -0
- data/lib/wip/client.rb +84 -0
- data/lib/wip/configuration.rb +89 -0
- data/lib/wip/error.rb +76 -0
- data/lib/wip/http_client.rb +221 -0
- data/lib/wip/models/base.rb +160 -0
- data/lib/wip/models/collection.rb +81 -0
- data/lib/wip/models/comment.rb +28 -0
- data/lib/wip/models/concerns/reactable.rb +25 -0
- data/lib/wip/models/project.rb +48 -0
- data/lib/wip/models/reaction.rb +32 -0
- data/lib/wip/models/todo.rb +57 -0
- data/lib/wip/models/user.rb +51 -0
- data/lib/wip/resources/base.rb +80 -0
- data/lib/wip/resources/comments.rb +93 -0
- data/lib/wip/resources/projects.rb +42 -0
- data/lib/wip/resources/reactions.rb +74 -0
- data/lib/wip/resources/todos.rb +47 -0
- data/lib/wip/resources/uploads.rb +111 -0
- data/lib/wip/resources/users.rb +61 -0
- data/lib/wip/resources/viewer.rb +52 -0
- data/lib/wip/version.rb +5 -0
- data/lib/wip-ruby.rb +1 -0
- data/lib/wip.rb +51 -0
- data/sig/wip/ruby.rbs +6 -0
- data/test_examples.rb +435 -0
- metadata +119 -0
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
|