forem-ruby 0.1.0.beta1
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/LICENSE +21 -0
- data/forem-ruby.gemspec +17 -0
- data/lib/forem/api_operations/create.rb +47 -0
- data/lib/forem/api_operations/delete.rb +88 -0
- data/lib/forem/api_operations/list.rb +70 -0
- data/lib/forem/api_operations/request.rb +83 -0
- data/lib/forem/api_operations/retrieve.rb +43 -0
- data/lib/forem/api_operations/save.rb +53 -0
- data/lib/forem/api_operations/update.rb +47 -0
- data/lib/forem/api_requestor.rb +283 -0
- data/lib/forem/api_resource.rb +77 -0
- data/lib/forem/client.rb +279 -0
- data/lib/forem/configuration.rb +74 -0
- data/lib/forem/connection_manager.rb +75 -0
- data/lib/forem/errors.rb +118 -0
- data/lib/forem/forem_object.rb +264 -0
- data/lib/forem/forem_response.rb +50 -0
- data/lib/forem/list_object.rb +171 -0
- data/lib/forem/resources/admin_concept.rb +169 -0
- data/lib/forem/resources/admin_user.rb +152 -0
- data/lib/forem/resources/agent_session.rb +110 -0
- data/lib/forem/resources/analytics.rb +151 -0
- data/lib/forem/resources/article.rb +256 -0
- data/lib/forem/resources/billboard.rb +76 -0
- data/lib/forem/resources/comment.rb +43 -0
- data/lib/forem/resources/concept.rb +192 -0
- data/lib/forem/resources/follow.rb +78 -0
- data/lib/forem/resources/follower.rb +56 -0
- data/lib/forem/resources/health_check.rb +70 -0
- data/lib/forem/resources/organization.rb +79 -0
- data/lib/forem/resources/page.rb +52 -0
- data/lib/forem/resources/podcast_episode.rb +36 -0
- data/lib/forem/resources/profile_image.rb +44 -0
- data/lib/forem/resources/reaction.rb +61 -0
- data/lib/forem/resources/reading_list.rb +29 -0
- data/lib/forem/resources/recommended_articles_list.rb +45 -0
- data/lib/forem/resources/request_redirect.rb +60 -0
- data/lib/forem/resources/segment.rb +103 -0
- data/lib/forem/resources/survey.rb +96 -0
- data/lib/forem/resources/tag.rb +27 -0
- data/lib/forem/resources/trend.rb +80 -0
- data/lib/forem/resources/user.rb +229 -0
- data/lib/forem/resources/video.rb +28 -0
- data/lib/forem/services/admin_concept_service.rb +138 -0
- data/lib/forem/services/admin_user_service.rb +114 -0
- data/lib/forem/services/agent_session_service.rb +87 -0
- data/lib/forem/services/analytics_service.rb +93 -0
- data/lib/forem/services/article_service.rb +233 -0
- data/lib/forem/services/base_service.rb +45 -0
- data/lib/forem/services/billboard_service.rb +91 -0
- data/lib/forem/services/comment_service.rb +51 -0
- data/lib/forem/services/concept_service.rb +143 -0
- data/lib/forem/services/follow_service.rb +61 -0
- data/lib/forem/services/follower_service.rb +34 -0
- data/lib/forem/services/health_check_service.rb +46 -0
- data/lib/forem/services/organization_service.rb +103 -0
- data/lib/forem/services/page_service.rb +107 -0
- data/lib/forem/services/podcast_episode_service.rb +34 -0
- data/lib/forem/services/profile_image_service.rb +32 -0
- data/lib/forem/services/reaction_service.rb +72 -0
- data/lib/forem/services/reading_list_service.rb +35 -0
- data/lib/forem/services/recommended_articles_list_service.rb +87 -0
- data/lib/forem/services/request_redirect_service.rb +118 -0
- data/lib/forem/services/segment_service.rb +83 -0
- data/lib/forem/services/survey_service.rb +48 -0
- data/lib/forem/services/tag_service.rb +32 -0
- data/lib/forem/services/trend_service.rb +70 -0
- data/lib/forem/services/user_service.rb +61 -0
- data/lib/forem/services/video_service.rb +33 -0
- data/lib/forem/util.rb +43 -0
- data/lib/forem/version.rb +4 -0
- data/lib/forem.rb +91 -0
- metadata +111 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# lib/forem/configuration.rb
|
|
2
|
+
module Forem
|
|
3
|
+
# Holds all configuration settings for the Forem API client.
|
|
4
|
+
#
|
|
5
|
+
# Instances are typically created and mutated through {Forem.configure}.
|
|
6
|
+
#
|
|
7
|
+
# @example Setting up configuration
|
|
8
|
+
# Forem.configure do |config|
|
|
9
|
+
# config.api_key = "my_api_key"
|
|
10
|
+
# config.api_base = "https://dev.to"
|
|
11
|
+
# end
|
|
12
|
+
class Configuration
|
|
13
|
+
# @!attribute [rw] api_key
|
|
14
|
+
# @return [String, nil] the API key used to authenticate requests.
|
|
15
|
+
# Corresponds to the `api-key` HTTP header sent with every request.
|
|
16
|
+
# Defaults to +nil+.
|
|
17
|
+
|
|
18
|
+
# @!attribute [rw] api_base
|
|
19
|
+
# @return [String] the base URL of the Forem instance.
|
|
20
|
+
# Defaults to <tt>"https://dev.to"</tt>.
|
|
21
|
+
|
|
22
|
+
# @!attribute [rw] api_version
|
|
23
|
+
# @return [String] the API version string included in Accept headers.
|
|
24
|
+
# Defaults to <tt>"v1"</tt>.
|
|
25
|
+
|
|
26
|
+
# @!attribute [rw] open_timeout
|
|
27
|
+
# @return [Integer] number of seconds to wait while opening a TCP
|
|
28
|
+
# connection to the server before raising a timeout error.
|
|
29
|
+
# Defaults to +30+.
|
|
30
|
+
|
|
31
|
+
# @!attribute [rw] read_timeout
|
|
32
|
+
# @return [Integer] number of seconds to wait for a response from the
|
|
33
|
+
# server after the connection has been established before raising a
|
|
34
|
+
# timeout error. Defaults to +80+.
|
|
35
|
+
|
|
36
|
+
# @!attribute [rw] max_network_retries
|
|
37
|
+
# @return [Integer] maximum number of automatic retries on transient
|
|
38
|
+
# network errors or rate-limit responses. Defaults to +1+.
|
|
39
|
+
|
|
40
|
+
# @!attribute [rw] log_level
|
|
41
|
+
# @return [Integer, nil] the Logger severity level (e.g. Logger::DEBUG)
|
|
42
|
+
# used when a custom {#logger} is configured. Defaults to +nil+
|
|
43
|
+
# (logging disabled).
|
|
44
|
+
|
|
45
|
+
# @!attribute [rw] logger
|
|
46
|
+
# @return [Logger, nil] a custom Logger instance to receive debug output.
|
|
47
|
+
# Defaults to +nil+.
|
|
48
|
+
attr_accessor :api_key, :api_base, :api_version, :open_timeout, :read_timeout,
|
|
49
|
+
:max_network_retries, :log_level, :logger
|
|
50
|
+
|
|
51
|
+
# Create a new Configuration with default values.
|
|
52
|
+
#
|
|
53
|
+
# @return [Configuration] a configuration object pre-populated with
|
|
54
|
+
# library defaults.
|
|
55
|
+
def initialize
|
|
56
|
+
@api_key = nil
|
|
57
|
+
@api_base = "https://dev.to"
|
|
58
|
+
@api_version = "v1"
|
|
59
|
+
@open_timeout = 30
|
|
60
|
+
@read_timeout = 80
|
|
61
|
+
@max_network_retries = 1
|
|
62
|
+
@log_level = nil
|
|
63
|
+
@logger = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Support duplicating a configuration via +dup+.
|
|
67
|
+
#
|
|
68
|
+
# @param source [Configuration] the configuration being duplicated
|
|
69
|
+
# @return [void]
|
|
70
|
+
def initialize_dup(source)
|
|
71
|
+
super
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module Forem
|
|
5
|
+
# Manages a pool of persistent Net::HTTP connections keyed by host and port.
|
|
6
|
+
#
|
|
7
|
+
# {ConnectionManager} is used internally by {APIRequestor} to reuse
|
|
8
|
+
# keep-alive TCP connections across multiple requests to the same server,
|
|
9
|
+
# reducing connection-setup overhead.
|
|
10
|
+
#
|
|
11
|
+
# Each {APIRequestor} instance owns exactly one ConnectionManager. Connections
|
|
12
|
+
# are lazily created on first use and kept open with a 30-second keep-alive
|
|
13
|
+
# timeout.
|
|
14
|
+
#
|
|
15
|
+
# @example Creating a connection manager (internal use)
|
|
16
|
+
# manager = Forem::ConnectionManager.new
|
|
17
|
+
# uri = URI("https://dev.to/api/articles")
|
|
18
|
+
# conn = manager.connection_for(uri)
|
|
19
|
+
class ConnectionManager
|
|
20
|
+
# Create a new, empty ConnectionManager with no active connections.
|
|
21
|
+
#
|
|
22
|
+
# @return [ConnectionManager]
|
|
23
|
+
def initialize
|
|
24
|
+
@connections = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Return a Net::HTTP connection for the given URI, creating one if needed.
|
|
28
|
+
#
|
|
29
|
+
# Connections are keyed by <tt>"host:port"</tt> so the same object is
|
|
30
|
+
# reused for every request to the same server. SSL is enabled automatically
|
|
31
|
+
# when the URI scheme is <tt>"https"</tt>.
|
|
32
|
+
#
|
|
33
|
+
# @param uri [URI] the parsed URI whose host and port identify the server.
|
|
34
|
+
# @param open_timeout [Integer] seconds to wait while opening the TCP
|
|
35
|
+
# connection. Defaults to +30+.
|
|
36
|
+
# @param read_timeout [Integer] seconds to wait for a response after the
|
|
37
|
+
# connection is established. Defaults to +80+.
|
|
38
|
+
# @return [Net::HTTP] a (possibly already-started) HTTP connection object.
|
|
39
|
+
#
|
|
40
|
+
# @example
|
|
41
|
+
# uri = URI("https://dev.to/api/articles")
|
|
42
|
+
# conn = manager.connection_for(uri, open_timeout: 10, read_timeout: 30)
|
|
43
|
+
def connection_for(uri, open_timeout: 30, read_timeout: 80)
|
|
44
|
+
key = "#{uri.host}:#{uri.port}"
|
|
45
|
+
return @connections[key] if @connections[key]
|
|
46
|
+
|
|
47
|
+
conn = Net::HTTP.new(uri.host, uri.port)
|
|
48
|
+
conn.use_ssl = uri.scheme == "https"
|
|
49
|
+
conn.open_timeout = open_timeout
|
|
50
|
+
conn.read_timeout = read_timeout
|
|
51
|
+
conn.keep_alive_timeout = 30
|
|
52
|
+
@connections[key] = conn
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Close all open connections and remove them from the pool.
|
|
56
|
+
#
|
|
57
|
+
# Gracefully handles connections that are already closed by swallowing
|
|
58
|
+
# any {IOError} raised during shutdown. After this call the manager is
|
|
59
|
+
# empty and new connections will be created on the next {#connection_for}
|
|
60
|
+
# call.
|
|
61
|
+
#
|
|
62
|
+
# @return [void]
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# manager.clear
|
|
66
|
+
def clear
|
|
67
|
+
@connections.each_value do |conn|
|
|
68
|
+
conn.finish if conn.started?
|
|
69
|
+
rescue IOError
|
|
70
|
+
# already closed
|
|
71
|
+
end
|
|
72
|
+
@connections.clear
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/forem/errors.rb
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module Forem
|
|
2
|
+
# Base error class for all errors raised by the forem-ruby library.
|
|
3
|
+
#
|
|
4
|
+
# Every error exposes the raw HTTP context (status code, body, headers) so
|
|
5
|
+
# callers can inspect the upstream response without re-issuing the request.
|
|
6
|
+
#
|
|
7
|
+
# @example Rescuing a specific subclass
|
|
8
|
+
# begin
|
|
9
|
+
# Forem::Article.retrieve(99999999)
|
|
10
|
+
# rescue Forem::NotFoundError => e
|
|
11
|
+
# puts "#{e.http_status}: #{e.message}"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Rescuing any Forem error
|
|
15
|
+
# rescue Forem::ForemError => e
|
|
16
|
+
# logger.error(e.message)
|
|
17
|
+
# end
|
|
18
|
+
class ForemError < StandardError
|
|
19
|
+
# @return [Integer, nil] the HTTP status code returned by the server
|
|
20
|
+
# (e.g. +401+, +404+, +429+), or +nil+ if no HTTP response was received.
|
|
21
|
+
attr_reader :http_status
|
|
22
|
+
|
|
23
|
+
# @return [String, nil] the raw HTTP response body as a string, or +nil+.
|
|
24
|
+
attr_reader :http_body
|
|
25
|
+
|
|
26
|
+
# @return [Hash, nil] a hash of HTTP response headers, or +nil+.
|
|
27
|
+
attr_reader :http_headers
|
|
28
|
+
|
|
29
|
+
# @return [String, nil] an optional machine-readable error code extracted
|
|
30
|
+
# from the API response body, or +nil+.
|
|
31
|
+
attr_reader :code
|
|
32
|
+
|
|
33
|
+
# Initialize a new ForemError.
|
|
34
|
+
#
|
|
35
|
+
# @param message [String, nil] a human-readable description of the error.
|
|
36
|
+
# @param http_status [Integer, nil] the HTTP status code from the response.
|
|
37
|
+
# @param http_body [String, nil] the raw HTTP response body.
|
|
38
|
+
# @param http_headers [Hash, nil] a hash of HTTP response headers.
|
|
39
|
+
# @param code [String, nil] a machine-readable error code from the API.
|
|
40
|
+
# @return [ForemError]
|
|
41
|
+
def initialize(message = nil, http_status: nil, http_body: nil, http_headers: nil, code: nil)
|
|
42
|
+
@http_status = http_status
|
|
43
|
+
@http_body = http_body
|
|
44
|
+
@http_headers = http_headers
|
|
45
|
+
@code = code
|
|
46
|
+
super(message)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Raised when the server responds with HTTP 401 Unauthorized.
|
|
51
|
+
#
|
|
52
|
+
# Typically indicates a missing or invalid API key.
|
|
53
|
+
#
|
|
54
|
+
# @see https://developers.forem.com/api/v1
|
|
55
|
+
class AuthenticationError < ForemError; end
|
|
56
|
+
|
|
57
|
+
# Raised when the server responds with HTTP 403 Forbidden.
|
|
58
|
+
#
|
|
59
|
+
# Indicates the authenticated user does not have permission to perform
|
|
60
|
+
# the requested action.
|
|
61
|
+
#
|
|
62
|
+
# @see https://developers.forem.com/api/v1
|
|
63
|
+
class AuthorizationError < ForemError; end
|
|
64
|
+
|
|
65
|
+
# Raised when the server responds with HTTP 404 Not Found.
|
|
66
|
+
#
|
|
67
|
+
# The requested resource does not exist on the server.
|
|
68
|
+
#
|
|
69
|
+
# @see https://developers.forem.com/api/v1
|
|
70
|
+
class NotFoundError < ForemError; end
|
|
71
|
+
|
|
72
|
+
# Raised when the server responds with HTTP 409 Conflict.
|
|
73
|
+
#
|
|
74
|
+
# Usually indicates a duplicate resource or a state conflict (e.g. trying
|
|
75
|
+
# to publish an article that is already published).
|
|
76
|
+
#
|
|
77
|
+
# @see https://developers.forem.com/api/v1
|
|
78
|
+
class ConflictError < ForemError; end
|
|
79
|
+
|
|
80
|
+
# Raised when the server responds with HTTP 422 Unprocessable Entity.
|
|
81
|
+
#
|
|
82
|
+
# The request parameters failed server-side validation.
|
|
83
|
+
#
|
|
84
|
+
# @see https://developers.forem.com/api/v1
|
|
85
|
+
class InvalidRequestError < ForemError; end
|
|
86
|
+
|
|
87
|
+
# Raised when the server responds with HTTP 429 Too Many Requests.
|
|
88
|
+
#
|
|
89
|
+
# The client has exceeded its request quota. The library will automatically
|
|
90
|
+
# retry rate-limited requests up to {Configuration#max_network_retries}
|
|
91
|
+
# times, honoring integer +Retry-After+ seconds when supplied and otherwise
|
|
92
|
+
# using exponential back-off.
|
|
93
|
+
#
|
|
94
|
+
# @see https://developers.forem.com/api/v1
|
|
95
|
+
class RateLimitError < ForemError
|
|
96
|
+
# Return the number of seconds requested by the server before retrying.
|
|
97
|
+
#
|
|
98
|
+
# @return [Integer, nil] +Retry-After+ seconds when the normalized header
|
|
99
|
+
# contains only ASCII decimal digits, or +nil+ otherwise.
|
|
100
|
+
def retry_after
|
|
101
|
+
value = http_headers&.fetch("retry-after", nil)
|
|
102
|
+
Integer(value, 10) if value.is_a?(String) && value.match?(/\A[0-9]+\z/)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Raised when the server returns any HTTP 5xx error or an unrecognised
|
|
107
|
+
# non-2xx status code not covered by a more specific subclass.
|
|
108
|
+
#
|
|
109
|
+
# @see https://developers.forem.com/api/v1
|
|
110
|
+
class APIError < ForemError; end
|
|
111
|
+
|
|
112
|
+
# Raised when a network-level error prevents the library from reaching the
|
|
113
|
+
# Forem server at all (e.g. DNS failure, connection refused, timeout).
|
|
114
|
+
#
|
|
115
|
+
# The library will automatically retry up to
|
|
116
|
+
# {Configuration#max_network_retries} times before re-raising.
|
|
117
|
+
class APIConnectionError < ForemError; end
|
|
118
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
module Forem
|
|
2
|
+
# Base object class for all Forem API objects. Provides dynamic attribute
|
|
3
|
+
# access from API response data.
|
|
4
|
+
#
|
|
5
|
+
# Attributes are stored internally as a plain +Hash+ with string keys.
|
|
6
|
+
# They can be read and written using either method-call syntax
|
|
7
|
+
# (<tt>obj.title</tt>) or hash-subscript syntax (<tt>obj["title"]</tt>).
|
|
8
|
+
# Nested hashes are recursively converted to {ForemObject} instances via
|
|
9
|
+
# {Util.convert_to_forem_object}.
|
|
10
|
+
#
|
|
11
|
+
# @example Constructing from an API response hash
|
|
12
|
+
# obj = Forem::ForemObject.construct_from({ "id" => 1, "title" => "Hello" })
|
|
13
|
+
# obj.id #=> 1
|
|
14
|
+
# obj.title #=> "Hello"
|
|
15
|
+
# obj["title"] #=> "Hello"
|
|
16
|
+
#
|
|
17
|
+
# @example Writing attributes
|
|
18
|
+
# obj.title = "Updated"
|
|
19
|
+
# obj["title"] = "Updated again"
|
|
20
|
+
class ForemObject
|
|
21
|
+
include APIOperations::Request
|
|
22
|
+
|
|
23
|
+
# @return [APIRequestor, nil] the requestor that produced this object,
|
|
24
|
+
# used by instance methods (e.g. {APIOperations::Save#save}) when no
|
|
25
|
+
# explicit +:requestor+ option is supplied.
|
|
26
|
+
attr_accessor :requestor
|
|
27
|
+
|
|
28
|
+
# Wrap a custom-path GET-array endpoint as a page-based {ListObject}.
|
|
29
|
+
#
|
|
30
|
+
# Used by resource methods that hit a non-standard path (e.g.
|
|
31
|
+
# +/api/articles/me/published+) but otherwise behave like
|
|
32
|
+
# {APIOperations::List#list}: page-based pagination with
|
|
33
|
+
# +page+/+per_page+ query params, returning a JSON array of resource
|
|
34
|
+
# objects.
|
|
35
|
+
#
|
|
36
|
+
# The returned ListObject re-uses this same helper for {#next_page} and
|
|
37
|
+
# {#previous_page}, so pagination works without any further wiring.
|
|
38
|
+
#
|
|
39
|
+
# @param path [String] the API path to GET.
|
|
40
|
+
# @param params [Hash] query parameters (filters and pagination).
|
|
41
|
+
# @param opts [Hash] per-request options including +:requestor+.
|
|
42
|
+
# @return [ListObject]
|
|
43
|
+
def self.paginated_list(path, params = {}, opts = {})
|
|
44
|
+
requestor = opts[:requestor]
|
|
45
|
+
resp = request(:get, path, params, opts)
|
|
46
|
+
per_page = (params[:per_page] || params["per_page"] || 30).to_i
|
|
47
|
+
page = (params[:page] || params["page"] || 1).to_i
|
|
48
|
+
klass = self
|
|
49
|
+
data = (resp.parsed_body || []).map { |item| klass.construct_from(item, requestor: requestor) }
|
|
50
|
+
|
|
51
|
+
fetcher = lambda do |direction, state, extra|
|
|
52
|
+
target = direction == :next ? state.current_page + 1 : state.current_page - 1
|
|
53
|
+
return nil if target < 1
|
|
54
|
+
new_params = state.filters.merge(page: target, per_page: state.per_page).merge(extra)
|
|
55
|
+
klass.paginated_list(path, new_params, { requestor: state.requestor })
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
ListObject.new(
|
|
59
|
+
data: data,
|
|
60
|
+
current_page: page,
|
|
61
|
+
per_page: per_page,
|
|
62
|
+
resource_class: klass,
|
|
63
|
+
filters: params.reject { |k, _| [:page, :per_page, "page", "per_page"].include?(k) },
|
|
64
|
+
requestor: requestor,
|
|
65
|
+
fetcher: fetcher
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Wrap a cursor-based ("after") GET-array endpoint as a {ListObject}.
|
|
70
|
+
#
|
|
71
|
+
# Used by endpoints (e.g. survey poll votes) that use an +after+-style
|
|
72
|
+
# cursor — successive pages are fetched by passing the last seen ID as
|
|
73
|
+
# the cursor. Cursor pagination is forward-only; {ListObject#previous_page}
|
|
74
|
+
# returns +nil+.
|
|
75
|
+
#
|
|
76
|
+
# @param path [String] the API path to GET.
|
|
77
|
+
# @param params [Hash] query parameters (filters and per_page).
|
|
78
|
+
# @param opts [Hash] per-request options including +:requestor+.
|
|
79
|
+
# @param cursor_param [Symbol] the query-string key for the cursor
|
|
80
|
+
# (default +:after+).
|
|
81
|
+
# @param cursor_from [Proc] a callable returning the cursor value for an
|
|
82
|
+
# item (default extracts +"id"+).
|
|
83
|
+
# @return [ListObject]
|
|
84
|
+
def self.cursor_list(path, params = {}, opts = {}, cursor_param: :after, cursor_from: ->(item) { item["id"] })
|
|
85
|
+
requestor = opts[:requestor]
|
|
86
|
+
resp = request(:get, path, params, opts)
|
|
87
|
+
per_page = (params[:per_page] || params["per_page"] || 30).to_i
|
|
88
|
+
klass = self
|
|
89
|
+
data = (resp.parsed_body || []).map { |item| klass.construct_from(item, requestor: requestor) }
|
|
90
|
+
|
|
91
|
+
fetcher = lambda do |direction, state, extra|
|
|
92
|
+
return nil unless direction == :next
|
|
93
|
+
return nil if state.data.empty?
|
|
94
|
+
next_cursor = cursor_from.call(state.data.last)
|
|
95
|
+
new_params = state.filters.merge(cursor_param => next_cursor, per_page: state.per_page).merge(extra)
|
|
96
|
+
klass.cursor_list(path, new_params, { requestor: state.requestor },
|
|
97
|
+
cursor_param: cursor_param, cursor_from: cursor_from)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
ListObject.new(
|
|
101
|
+
data: data,
|
|
102
|
+
current_page: nil,
|
|
103
|
+
per_page: per_page,
|
|
104
|
+
resource_class: klass,
|
|
105
|
+
filters: params.reject { |k, _| [cursor_param, cursor_param.to_s, :per_page, "per_page"].include?(k) },
|
|
106
|
+
requestor: requestor,
|
|
107
|
+
fetcher: fetcher
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Initialise a new object, optionally pre-populating it with values.
|
|
112
|
+
#
|
|
113
|
+
# @param values [Hash] initial attribute hash. Keys are coerced to strings.
|
|
114
|
+
# @return [ForemObject]
|
|
115
|
+
def initialize(values = {})
|
|
116
|
+
@values = {}
|
|
117
|
+
@requestor = nil
|
|
118
|
+
update_attributes(values)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Construct a new {ForemObject} from an API response hash.
|
|
122
|
+
#
|
|
123
|
+
# This is the preferred factory method — it is called by {Util} and
|
|
124
|
+
# resource class methods rather than {#initialize} directly.
|
|
125
|
+
#
|
|
126
|
+
# @param values [Hash] the API response data. Nested Hashes and Arrays
|
|
127
|
+
# are recursively converted via {Util.convert_to_forem_object}.
|
|
128
|
+
# @param requestor [APIRequestor, nil] the requestor to attach so that
|
|
129
|
+
# subsequent instance methods (e.g. +article.save+) can issue
|
|
130
|
+
# follow-up calls without having to be passed a +:requestor+ opt
|
|
131
|
+
# explicitly.
|
|
132
|
+
# @return [ForemObject] a new object populated with the given attributes.
|
|
133
|
+
#
|
|
134
|
+
# @example
|
|
135
|
+
# obj = Forem::ForemObject.construct_from({ "id" => 42, "user" => { "name" => "Alice" } })
|
|
136
|
+
# obj.user.name #=> "Alice"
|
|
137
|
+
def self.construct_from(values, requestor: nil)
|
|
138
|
+
obj = new
|
|
139
|
+
obj.send(:update_attributes, values)
|
|
140
|
+
obj.requestor = requestor
|
|
141
|
+
obj
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Read an attribute by key.
|
|
145
|
+
#
|
|
146
|
+
# @param key [String, Symbol] the attribute name. Symbols are coerced to
|
|
147
|
+
# strings before lookup.
|
|
148
|
+
# @return [Object, nil] the stored value, or +nil+ if the key is absent.
|
|
149
|
+
#
|
|
150
|
+
# @example
|
|
151
|
+
# obj["id"] #=> 42
|
|
152
|
+
# obj[:title] #=> "Hello"
|
|
153
|
+
def [](key)
|
|
154
|
+
@values[key.to_s]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Write an attribute by key.
|
|
158
|
+
#
|
|
159
|
+
# The value is passed through {Util.convert_to_forem_object} so nested
|
|
160
|
+
# Hashes become {ForemObject} instances automatically.
|
|
161
|
+
#
|
|
162
|
+
# @param key [String, Symbol] the attribute name. Symbols are coerced to
|
|
163
|
+
# strings.
|
|
164
|
+
# @param value [Object] the value to store.
|
|
165
|
+
# @return [Object] the stored (possibly converted) value.
|
|
166
|
+
#
|
|
167
|
+
# @example
|
|
168
|
+
# obj["title"] = "New title"
|
|
169
|
+
# obj[:count] = 5
|
|
170
|
+
def []=(key, value)
|
|
171
|
+
@values[key.to_s] = Util.convert_to_forem_object(value)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Recursively convert this object to a plain Ruby Hash.
|
|
175
|
+
#
|
|
176
|
+
# Nested {ForemObject} instances are converted via their own +#to_hash+;
|
|
177
|
+
# Arrays whose elements are {ForemObject} instances are mapped similarly.
|
|
178
|
+
#
|
|
179
|
+
# @return [Hash{String => Object}] a plain Hash representation of all
|
|
180
|
+
# attributes.
|
|
181
|
+
#
|
|
182
|
+
# @example
|
|
183
|
+
# obj.to_hash #=> { "id" => 1, "title" => "Hello" }
|
|
184
|
+
def to_hash
|
|
185
|
+
@values.transform_values do |v|
|
|
186
|
+
case v
|
|
187
|
+
when ForemObject then v.to_hash
|
|
188
|
+
when Array then v.map { |e| e.is_a?(ForemObject) ? e.to_hash : e }
|
|
189
|
+
else v
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Compare two {ForemObject} instances by their internal value hash.
|
|
195
|
+
#
|
|
196
|
+
# @param other [Object] the object to compare against.
|
|
197
|
+
# @return [Boolean] +true+ if +other+ is a {ForemObject} with identical
|
|
198
|
+
# attribute values.
|
|
199
|
+
def ==(other)
|
|
200
|
+
other.is_a?(ForemObject) && @values == other.instance_variable_get(:@values)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Allow +respond_to?+ checks for dynamic attribute accessors.
|
|
204
|
+
#
|
|
205
|
+
# Returns +true+ for any key currently stored in the internal values hash,
|
|
206
|
+
# as well as the corresponding setter (e.g. +title=+).
|
|
207
|
+
#
|
|
208
|
+
# @param method [Symbol] the method name being queried.
|
|
209
|
+
# @param include_private [Boolean] whether to include private methods.
|
|
210
|
+
# @return [Boolean]
|
|
211
|
+
def respond_to_missing?(method, include_private = false)
|
|
212
|
+
name = method.to_s
|
|
213
|
+
name = name.chomp("=")
|
|
214
|
+
@values.key?(name) || super
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Dynamic getter/setter for API attributes.
|
|
218
|
+
#
|
|
219
|
+
# * <tt>obj.title</tt> — returns <tt>@values["title"]</tt>
|
|
220
|
+
# * <tt>obj.title = x</tt> — delegates to {#[]=}
|
|
221
|
+
#
|
|
222
|
+
# Raises +NoMethodError+ for names that are neither setters nor present in
|
|
223
|
+
# the values hash.
|
|
224
|
+
#
|
|
225
|
+
# @param method [Symbol] the missing method name.
|
|
226
|
+
# @param args [Array] arguments (used only for setter calls).
|
|
227
|
+
# @return [Object] the attribute value for getters.
|
|
228
|
+
# @raise [NoMethodError] if the attribute does not exist in the values hash
|
|
229
|
+
# and it is not a setter call.
|
|
230
|
+
def method_missing(method, *args)
|
|
231
|
+
name = method.to_s
|
|
232
|
+
if name.end_with?("=")
|
|
233
|
+
attr = name.chomp("=")
|
|
234
|
+
self[attr] = args[0]
|
|
235
|
+
elsif @values.key?(name)
|
|
236
|
+
@values[name]
|
|
237
|
+
else
|
|
238
|
+
super
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Return a human-readable string representation of the object.
|
|
243
|
+
#
|
|
244
|
+
# @return [String] the class name, object ID, and internal values hash.
|
|
245
|
+
def inspect
|
|
246
|
+
"#<#{self.class}:0x#{object_id.to_s(16)} #{@values.inspect}>"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
# Bulk-update internal attributes from a hash.
|
|
252
|
+
#
|
|
253
|
+
# Each value is passed through {Util.convert_to_forem_object} so nested
|
|
254
|
+
# structures are converted recursively.
|
|
255
|
+
#
|
|
256
|
+
# @param values [Hash] attribute key/value pairs to merge.
|
|
257
|
+
# @return [void]
|
|
258
|
+
def update_attributes(values)
|
|
259
|
+
values.each do |k, v|
|
|
260
|
+
@values[k.to_s] = Util.convert_to_forem_object(v)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Forem
|
|
4
|
+
# Wraps a raw HTTP response from the Forem API.
|
|
5
|
+
#
|
|
6
|
+
# {ForemResponse} is an internal value object created by {APIRequestor} after
|
|
7
|
+
# every successful round-trip to the server. It normalises the Net::HTTP
|
|
8
|
+
# response into a simple struct and provides lazy JSON parsing via
|
|
9
|
+
# {#parsed_body}.
|
|
10
|
+
#
|
|
11
|
+
# @example Inspecting a raw response (inside a custom requestor)
|
|
12
|
+
# resp = requestor.request(:get, "/api/articles/1")
|
|
13
|
+
# resp.http_status #=> 200
|
|
14
|
+
# resp.parsed_body #=> { "id" => 1, "title" => "Hello" }
|
|
15
|
+
class ForemResponse
|
|
16
|
+
# @return [Integer] the HTTP status code of the response (e.g. +200+, +404+).
|
|
17
|
+
attr_reader :http_status
|
|
18
|
+
|
|
19
|
+
# @return [String] the raw, unparsed HTTP response body as a string.
|
|
20
|
+
attr_reader :http_body
|
|
21
|
+
|
|
22
|
+
# @return [Hash] a hash of downcased HTTP response header names to their
|
|
23
|
+
# string values (e.g. <tt>{ "content-type" => "application/json" }</tt>).
|
|
24
|
+
attr_reader :http_headers
|
|
25
|
+
|
|
26
|
+
# Create a new ForemResponse.
|
|
27
|
+
#
|
|
28
|
+
# @param http_status [Integer] the HTTP status code.
|
|
29
|
+
# @param http_body [String] the raw response body.
|
|
30
|
+
# @param http_headers [Hash] the response headers.
|
|
31
|
+
# @return [ForemResponse]
|
|
32
|
+
def initialize(http_status:, http_body:, http_headers:)
|
|
33
|
+
@http_status = http_status
|
|
34
|
+
@http_body = http_body
|
|
35
|
+
@http_headers = http_headers
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Parse the response body as JSON, caching the result after the first call.
|
|
39
|
+
#
|
|
40
|
+
# Returns +nil+ when the body is absent or blank (e.g. HTTP 204 No Content).
|
|
41
|
+
#
|
|
42
|
+
# @return [Hash, Array, nil] the decoded JSON value, or +nil+ if the body
|
|
43
|
+
# is empty.
|
|
44
|
+
# @raise [JSON::ParserError] if the body is non-empty but not valid JSON.
|
|
45
|
+
def parsed_body
|
|
46
|
+
return nil if http_body.nil? || http_body.empty?
|
|
47
|
+
@parsed_body ||= JSON.parse(http_body)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|