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,283 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Forem
|
|
6
|
+
# Executes authenticated HTTP requests against the Forem API.
|
|
7
|
+
#
|
|
8
|
+
# {APIRequestor} is the single entry-point for all network I/O in the
|
|
9
|
+
# library. It builds requests (including authentication headers), delegates
|
|
10
|
+
# transport to a {ConnectionManager}, parses the response, maps HTTP error
|
|
11
|
+
# status codes to typed {ForemError} subclasses, and applies automatic
|
|
12
|
+
# retry logic for transient failures.
|
|
13
|
+
#
|
|
14
|
+
# Each {Forem::Client} owns its own {APIRequestor}, built from the
|
|
15
|
+
# client's {Configuration}. There is no global default requestor — every
|
|
16
|
+
# call must originate from a specific client instance (or pass an
|
|
17
|
+
# explicit +:requestor+ option to a class-level resource method).
|
|
18
|
+
#
|
|
19
|
+
# @example Constructing directly (uncommon — prefer {Forem::Client.new})
|
|
20
|
+
# config = Forem::Configuration.new
|
|
21
|
+
# config.api_key = "my_key"
|
|
22
|
+
# requestor = Forem::APIRequestor.new(config: config)
|
|
23
|
+
# requestor.request(:get, "/api/articles")
|
|
24
|
+
class APIRequestor
|
|
25
|
+
# Create a new APIRequestor.
|
|
26
|
+
#
|
|
27
|
+
# @param config [Configuration] the configuration to use for this
|
|
28
|
+
# requestor.
|
|
29
|
+
# @return [APIRequestor]
|
|
30
|
+
def initialize(config:)
|
|
31
|
+
@config = config
|
|
32
|
+
@connection_manager = ConnectionManager.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Execute an HTTP request against the Forem API.
|
|
36
|
+
#
|
|
37
|
+
# Builds the request, attaches authentication headers, sends it, and
|
|
38
|
+
# returns a {ForemResponse}. On HTTP 4xx/5xx responses the method raises
|
|
39
|
+
# the appropriate {ForemError} subclass. Transient failures
|
|
40
|
+
# ({APIConnectionError}, {RateLimitError}, server 5xx) are automatically
|
|
41
|
+
# retried up to {Configuration#max_network_retries} times. Rate-limit
|
|
42
|
+
# retries honor integer +Retry-After+ seconds; other retries use
|
|
43
|
+
# exponential back-off.
|
|
44
|
+
#
|
|
45
|
+
# @param method [Symbol] the HTTP verb — +:get+, +:post+, +:put+, or
|
|
46
|
+
# +:delete+.
|
|
47
|
+
# @param path [String] the API path relative to {Configuration#api_base}
|
|
48
|
+
# (e.g. +"/api/articles"+).
|
|
49
|
+
# @param params [Hash] query parameters for GET requests, or the JSON
|
|
50
|
+
# request body for POST/PUT requests. Defaults to +{}+.
|
|
51
|
+
# @param opts [Hash] per-request overrides.
|
|
52
|
+
# @option opts [String] :api_key override the API key for this request.
|
|
53
|
+
# @option opts [String] :api_base override the base URL for this request.
|
|
54
|
+
# @option opts [APIRequestor] :requestor an alternative requestor to use
|
|
55
|
+
# (consumed by higher-level helpers before reaching this method).
|
|
56
|
+
# @return [ForemResponse] the parsed response wrapper.
|
|
57
|
+
# @raise [AuthenticationError] on HTTP 401.
|
|
58
|
+
# @raise [AuthorizationError] on HTTP 403.
|
|
59
|
+
# @raise [NotFoundError] on HTTP 404.
|
|
60
|
+
# @raise [ConflictError] on HTTP 409.
|
|
61
|
+
# @raise [InvalidRequestError] on HTTP 422.
|
|
62
|
+
# @raise [RateLimitError] on HTTP 429.
|
|
63
|
+
# @raise [APIError] on other HTTP 4xx/5xx responses.
|
|
64
|
+
# @raise [APIConnectionError] when a network-level error prevents the
|
|
65
|
+
# request from reaching the server and retries are exhausted.
|
|
66
|
+
#
|
|
67
|
+
# @example Fetching articles with pagination
|
|
68
|
+
# resp = requestor.request(:get, "/api/articles", { page: 2, per_page: 10 })
|
|
69
|
+
# resp.http_status #=> 200
|
|
70
|
+
# resp.parsed_body #=> [{ "id" => 1, ... }, ...]
|
|
71
|
+
#
|
|
72
|
+
# @see https://developers.forem.com/api/v1
|
|
73
|
+
def request(method, path, params = {}, opts = {})
|
|
74
|
+
api_key = opts.delete(:api_key) || @config.api_key
|
|
75
|
+
api_base = opts.delete(:api_base) || @config.api_base
|
|
76
|
+
extra_headers = opts.delete(:headers) || {}
|
|
77
|
+
uri = URI("#{api_base}#{path}")
|
|
78
|
+
|
|
79
|
+
retries_left = @config.max_network_retries
|
|
80
|
+
begin
|
|
81
|
+
response = execute_request(method, uri, params, api_key, extra_headers)
|
|
82
|
+
handle_error_response(response) if response.http_status >= 400
|
|
83
|
+
response
|
|
84
|
+
rescue Forem::APIConnectionError
|
|
85
|
+
if retries_left > 0
|
|
86
|
+
retries_left -= 1
|
|
87
|
+
sleep backoff_duration(@config.max_network_retries - retries_left)
|
|
88
|
+
retry
|
|
89
|
+
end
|
|
90
|
+
raise
|
|
91
|
+
rescue Forem::RateLimitError, Forem::APIError => e
|
|
92
|
+
if retries_left > 0 && retryable_error?(e)
|
|
93
|
+
retries_left -= 1
|
|
94
|
+
retry_count = @config.max_network_retries - retries_left
|
|
95
|
+
sleep retry_delay(e, retry_count)
|
|
96
|
+
retry
|
|
97
|
+
end
|
|
98
|
+
raise
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Send the HTTP request through the connection manager and return a
|
|
105
|
+
# {ForemResponse}.
|
|
106
|
+
#
|
|
107
|
+
# @param method [Symbol] the HTTP verb.
|
|
108
|
+
# @param uri [URI] the fully-qualified request URI.
|
|
109
|
+
# @param params [Hash] parameters/body payload.
|
|
110
|
+
# @param api_key [String, nil] the API key to attach.
|
|
111
|
+
# @return [ForemResponse]
|
|
112
|
+
# @raise [APIConnectionError] on any network-level exception.
|
|
113
|
+
def execute_request(method, uri, params, api_key, extra_headers = {})
|
|
114
|
+
http = @connection_manager.connection_for(uri, open_timeout: @config.open_timeout, read_timeout: @config.read_timeout)
|
|
115
|
+
request = build_request(method, uri, params, api_key, extra_headers)
|
|
116
|
+
|
|
117
|
+
begin
|
|
118
|
+
http_response = http.request(request)
|
|
119
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT, Errno::EHOSTUNREACH, SocketError => e
|
|
120
|
+
raise APIConnectionError.new("Connection to #{uri.host} failed: #{e.message}")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
ForemResponse.new(
|
|
124
|
+
http_status: http_response.code.to_i,
|
|
125
|
+
http_body: http_response.body,
|
|
126
|
+
http_headers: headers_to_hash(http_response)
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Build a Net::HTTPRequest object for the given method and parameters.
|
|
131
|
+
#
|
|
132
|
+
# Sets +api-key+, +Accept+, and +User-Agent+ headers on every request.
|
|
133
|
+
# POST and PUT requests encode +params+ as a JSON body and set
|
|
134
|
+
# +Content-Type: application/json+. GET requests append +params+ as a
|
|
135
|
+
# URL query string.
|
|
136
|
+
#
|
|
137
|
+
# @param method [Symbol] the HTTP verb (+:get+, +:post+, +:put+, +:delete+).
|
|
138
|
+
# @param uri [URI] the request URI (modified in-place for GET params).
|
|
139
|
+
# @param params [Hash] the parameters or body payload.
|
|
140
|
+
# @param api_key [String, nil] the API key value.
|
|
141
|
+
# @return [Net::HTTPRequest] the fully-configured request object.
|
|
142
|
+
# @raise [ArgumentError] if +method+ is not one of the supported verbs.
|
|
143
|
+
def build_request(method, uri, params, api_key, extra_headers = {})
|
|
144
|
+
req = case method
|
|
145
|
+
when :get
|
|
146
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
147
|
+
Net::HTTP::Get.new(uri)
|
|
148
|
+
when :post
|
|
149
|
+
r = Net::HTTP::Post.new(uri)
|
|
150
|
+
r.body = JSON.generate(params) unless params.empty?
|
|
151
|
+
r["Content-Type"] = "application/json"
|
|
152
|
+
r
|
|
153
|
+
when :put
|
|
154
|
+
r = Net::HTTP::Put.new(uri)
|
|
155
|
+
r.body = JSON.generate(params) unless params.empty?
|
|
156
|
+
r["Content-Type"] = "application/json"
|
|
157
|
+
r
|
|
158
|
+
when :delete
|
|
159
|
+
Net::HTTP::Delete.new(uri)
|
|
160
|
+
else
|
|
161
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
req["api-key"] = api_key if api_key
|
|
165
|
+
req["Accept"] = "application/vnd.forem.api-v1+json"
|
|
166
|
+
req["User-Agent"] = "forem-ruby/#{Forem::VERSION} ruby/#{RUBY_VERSION}"
|
|
167
|
+
extra_headers.each { |name, value| req[name.to_s] = value.to_s }
|
|
168
|
+
req
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Raise the appropriate {ForemError} subclass for a non-2xx response.
|
|
172
|
+
#
|
|
173
|
+
# @param response [ForemResponse] the error response.
|
|
174
|
+
# @return [void]
|
|
175
|
+
# @raise [ForemError] always raises a subclass matching the HTTP status.
|
|
176
|
+
def handle_error_response(response)
|
|
177
|
+
message = extract_error_message(response)
|
|
178
|
+
kwargs = {
|
|
179
|
+
http_status: response.http_status,
|
|
180
|
+
http_body: response.http_body,
|
|
181
|
+
http_headers: response.http_headers,
|
|
182
|
+
code: extract_error_code(response),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
error_class = case response.http_status
|
|
186
|
+
when 401 then AuthenticationError
|
|
187
|
+
when 403 then AuthorizationError
|
|
188
|
+
when 404 then NotFoundError
|
|
189
|
+
when 409 then ConflictError
|
|
190
|
+
when 422 then InvalidRequestError
|
|
191
|
+
when 429 then RateLimitError
|
|
192
|
+
else APIError
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
raise error_class.new(message, **kwargs)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Extract a human-readable error message from an error response.
|
|
199
|
+
#
|
|
200
|
+
# Looks for +error+ (string) or +errors+ (array) keys in the JSON body.
|
|
201
|
+
# Falls back to the raw body string if parsing fails or the expected keys
|
|
202
|
+
# are absent.
|
|
203
|
+
#
|
|
204
|
+
# @param response [ForemResponse] the error response.
|
|
205
|
+
# @return [String] the best available error message.
|
|
206
|
+
def extract_error_message(response)
|
|
207
|
+
body = response.parsed_body
|
|
208
|
+
return response.http_body unless body.is_a?(Hash)
|
|
209
|
+
|
|
210
|
+
if body["error"]
|
|
211
|
+
body["error"]
|
|
212
|
+
elsif body["errors"].is_a?(Array)
|
|
213
|
+
body["errors"].join(", ")
|
|
214
|
+
else
|
|
215
|
+
response.http_body
|
|
216
|
+
end
|
|
217
|
+
rescue JSON::ParserError
|
|
218
|
+
response.http_body
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Extract a machine-readable error code from an error response.
|
|
222
|
+
#
|
|
223
|
+
# @param response [ForemResponse] the error response.
|
|
224
|
+
# @return [String, nil] the +error_code+ value, or +nil+ when unavailable.
|
|
225
|
+
def extract_error_code(response)
|
|
226
|
+
body = response.parsed_body
|
|
227
|
+
body["error_code"] if body.is_a?(Hash)
|
|
228
|
+
rescue JSON::ParserError
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Convert a Net::HTTPResponse header enumerable into a plain Hash.
|
|
233
|
+
#
|
|
234
|
+
# @param http_response [Net::HTTPResponse] the raw response object.
|
|
235
|
+
# @return [Hash{String => String}] downcased header names mapped to values.
|
|
236
|
+
def headers_to_hash(http_response)
|
|
237
|
+
h = {}
|
|
238
|
+
http_response.each_header { |k, v| h[k] = v }
|
|
239
|
+
h
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Determine whether a given error is eligible for an automatic retry.
|
|
243
|
+
#
|
|
244
|
+
# {RateLimitError} is always retryable. {APIError} is retryable only when
|
|
245
|
+
# the HTTP status is 500 or greater (server errors).
|
|
246
|
+
#
|
|
247
|
+
# @param error [ForemError] the error to evaluate.
|
|
248
|
+
# @return [Boolean] +true+ if the request should be retried.
|
|
249
|
+
def retryable_error?(error)
|
|
250
|
+
case error
|
|
251
|
+
when RateLimitError then true
|
|
252
|
+
when APIError then error.http_status >= 500
|
|
253
|
+
else false
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Calculate the delay for a retryable HTTP error.
|
|
258
|
+
#
|
|
259
|
+
# Rate-limit responses with an integer +Retry-After+ header use the
|
|
260
|
+
# server-provided delay. All other retryable errors retain the standard
|
|
261
|
+
# jittered exponential back-off.
|
|
262
|
+
#
|
|
263
|
+
# @param error [ForemError] the retryable error.
|
|
264
|
+
# @param retry_count [Integer] the 1-based retry count.
|
|
265
|
+
# @return [Numeric] seconds to sleep.
|
|
266
|
+
def retry_delay(error, retry_count)
|
|
267
|
+
retry_after = error.retry_after if error.is_a?(RateLimitError)
|
|
268
|
+
retry_after || backoff_duration(retry_count)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Calculate the sleep duration before the next retry attempt.
|
|
272
|
+
#
|
|
273
|
+
# Uses truncated exponential back-off with jitter:
|
|
274
|
+
# <tt>(0.5 * 2^retry_count) + rand(0..0.5)</tt> seconds.
|
|
275
|
+
#
|
|
276
|
+
# @param retry_count [Integer] how many retries have already been attempted
|
|
277
|
+
# (1-based: pass 1 for the first retry).
|
|
278
|
+
# @return [Float] seconds to sleep before retrying.
|
|
279
|
+
def backoff_duration(retry_count)
|
|
280
|
+
(0.5 * (2**retry_count)) + rand * 0.5
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Forem
|
|
2
|
+
# Base class for all Forem API resource objects (articles, users, etc.).
|
|
3
|
+
#
|
|
4
|
+
# {APIResource} extends {ForemObject} with the concepts of a canonical
|
|
5
|
+
# resource path and the ability to refresh an instance from the API.
|
|
6
|
+
# Concrete resource classes must define a +RESOURCE_PATH+ constant
|
|
7
|
+
# (e.g. <tt>"/api/articles"</tt>).
|
|
8
|
+
#
|
|
9
|
+
# Resource classes mix in {APIOperations::Request} so that both the class
|
|
10
|
+
# and its instances can issue authenticated HTTP requests. Class methods
|
|
11
|
+
# require an explicit +:requestor+ option (normally injected by a
|
|
12
|
+
# {Forem::Client} via its service objects); instance methods fall back to
|
|
13
|
+
# the requestor stored on the object at construction time.
|
|
14
|
+
#
|
|
15
|
+
# @example Defining a resource subclass
|
|
16
|
+
# class Forem::Article < Forem::APIResource
|
|
17
|
+
# RESOURCE_PATH = "/api/articles"
|
|
18
|
+
# extend APIOperations::List
|
|
19
|
+
# extend APIOperations::Retrieve
|
|
20
|
+
# end
|
|
21
|
+
class APIResource < ForemObject
|
|
22
|
+
# Return the API collection path for this resource class.
|
|
23
|
+
#
|
|
24
|
+
# Delegates to the +RESOURCE_PATH+ constant that every concrete subclass
|
|
25
|
+
# must define.
|
|
26
|
+
#
|
|
27
|
+
# @return [String] the collection path, e.g. <tt>"/api/articles"</tt>.
|
|
28
|
+
# @raise [NameError] if the subclass has not defined +RESOURCE_PATH+.
|
|
29
|
+
#
|
|
30
|
+
# @example
|
|
31
|
+
# Forem::Article.resource_path #=> "/api/articles"
|
|
32
|
+
def self.resource_path
|
|
33
|
+
self::RESOURCE_PATH
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Return the API path for this specific resource instance.
|
|
37
|
+
#
|
|
38
|
+
# Combines {.resource_path} with the instance's +id+ attribute.
|
|
39
|
+
#
|
|
40
|
+
# @return [String] the instance path, e.g. <tt>"/api/articles/42"</tt>.
|
|
41
|
+
# @raise [InvalidRequestError] if the instance does not have an +id+
|
|
42
|
+
# attribute (i.e. the object was not constructed from a full API
|
|
43
|
+
# response).
|
|
44
|
+
#
|
|
45
|
+
# @example
|
|
46
|
+
# article.resource_url #=> "/api/articles/42"
|
|
47
|
+
def resource_url
|
|
48
|
+
id = self["id"]
|
|
49
|
+
raise InvalidRequestError.new("Could not determine resource ID") unless id
|
|
50
|
+
"#{self.class.resource_path}/#{id}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Reload this resource instance from the API, replacing all attributes
|
|
54
|
+
# with the latest server data.
|
|
55
|
+
#
|
|
56
|
+
# @param opts [Hash] per-request options forwarded to {APIRequestor#request}.
|
|
57
|
+
# @option opts [String] :api_key override the API key for this request.
|
|
58
|
+
# @option opts [APIRequestor] :requestor a custom requestor to use.
|
|
59
|
+
# @return [self] the same instance, now populated with refreshed data.
|
|
60
|
+
# @raise [InvalidRequestError] if the instance has no +id+.
|
|
61
|
+
# @raise [NotFoundError] if the resource no longer exists on the server.
|
|
62
|
+
# @raise [ForemError] for other API-level errors.
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# article = client.articles.retrieve(42)
|
|
66
|
+
# # ... time passes ...
|
|
67
|
+
# article.refresh #=> same article object with updated attributes
|
|
68
|
+
#
|
|
69
|
+
# @see https://developers.forem.com/api/v1
|
|
70
|
+
def refresh(opts = {})
|
|
71
|
+
resp = request(:get, resource_url, {}, opts)
|
|
72
|
+
@values = {}
|
|
73
|
+
send(:update_attributes, resp.parsed_body)
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/forem/client.rb
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
module Forem
|
|
2
|
+
# Client for making authenticated requests to the Forem API.
|
|
3
|
+
#
|
|
4
|
+
# Use this when you need per-instance configuration (e.g., different API keys
|
|
5
|
+
# for different Forem instances). For simple single-instance use, configure
|
|
6
|
+
# the global {Forem.api_key} instead.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic client usage
|
|
9
|
+
# client = Forem::Client.new("your-api-key")
|
|
10
|
+
# articles = client.articles.list(per_page: 10)
|
|
11
|
+
#
|
|
12
|
+
# @example Connecting to a custom Forem instance
|
|
13
|
+
# client = Forem::Client.new("key", api_base: "https://my-forem.com")
|
|
14
|
+
# user = client.users.me
|
|
15
|
+
#
|
|
16
|
+
# @see https://developers.forem.com/api/v1
|
|
17
|
+
class Client
|
|
18
|
+
# @return [Configuration] the configuration object for this client
|
|
19
|
+
attr_reader :config
|
|
20
|
+
|
|
21
|
+
# @return [APIRequestor] the requestor used to make HTTP calls
|
|
22
|
+
attr_reader :requestor
|
|
23
|
+
|
|
24
|
+
# Create a new Forem API client.
|
|
25
|
+
#
|
|
26
|
+
# @param api_key [String] your Forem API key
|
|
27
|
+
# @param api_base [String] base URL for the Forem API (default: "https://dev.to")
|
|
28
|
+
# @param opts [Hash] additional configuration options passed to {Configuration}
|
|
29
|
+
# @return [Client] a new client instance
|
|
30
|
+
#
|
|
31
|
+
# @example
|
|
32
|
+
# client = Forem::Client.new("my-secret-api-key")
|
|
33
|
+
#
|
|
34
|
+
# @example With a custom Forem instance
|
|
35
|
+
# client = Forem::Client.new("my-key", api_base: "https://community.example.com")
|
|
36
|
+
def initialize(api_key, api_base: "https://dev.to", **opts)
|
|
37
|
+
@config = Configuration.new
|
|
38
|
+
@config.api_key = api_key
|
|
39
|
+
@config.api_base = api_base
|
|
40
|
+
opts.each { |k, v| @config.send(:"#{k}=", v) if @config.respond_to?(:"#{k}=") }
|
|
41
|
+
@requestor = APIRequestor.new(config: @config)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Access the Articles API.
|
|
45
|
+
#
|
|
46
|
+
# @return [Services::ArticleService] the articles service
|
|
47
|
+
# @see https://developers.forem.com/api/v1#tag/articles
|
|
48
|
+
#
|
|
49
|
+
# @example
|
|
50
|
+
# client.articles.list(per_page: 5)
|
|
51
|
+
# client.articles.retrieve(12345)
|
|
52
|
+
def articles; @articles ||= Services::ArticleService.new(@requestor); end
|
|
53
|
+
|
|
54
|
+
# Access the Users API.
|
|
55
|
+
#
|
|
56
|
+
# @return [Services::UserService] the users service
|
|
57
|
+
# @see https://developers.forem.com/api/v1#tag/users
|
|
58
|
+
#
|
|
59
|
+
# @example
|
|
60
|
+
# client.users.me
|
|
61
|
+
# client.users.retrieve(42)
|
|
62
|
+
def users; @users ||= Services::UserService.new(@requestor); end
|
|
63
|
+
|
|
64
|
+
# Access the Comments API.
|
|
65
|
+
#
|
|
66
|
+
# @return [Services::CommentService] the comments service
|
|
67
|
+
# @see https://developers.forem.com/api/v1#tag/comments
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# client.comments.list(a_id: 123)
|
|
71
|
+
# client.comments.retrieve("abc123")
|
|
72
|
+
def comments; @comments ||= Services::CommentService.new(@requestor); end
|
|
73
|
+
|
|
74
|
+
# Access the Organizations API.
|
|
75
|
+
#
|
|
76
|
+
# @return [Services::OrganizationService] the organizations service
|
|
77
|
+
# @see https://developers.forem.com/api/v1#tag/organizations
|
|
78
|
+
#
|
|
79
|
+
# @example
|
|
80
|
+
# client.organizations.list
|
|
81
|
+
# client.organizations.retrieve(7)
|
|
82
|
+
def organizations; @organizations ||= Services::OrganizationService.new(@requestor); end
|
|
83
|
+
|
|
84
|
+
# Access the Tags API.
|
|
85
|
+
#
|
|
86
|
+
# @return [Services::TagService] the tags service
|
|
87
|
+
# @see https://developers.forem.com/api/v1#tag/tags
|
|
88
|
+
#
|
|
89
|
+
# @example
|
|
90
|
+
# client.tags.list(per_page: 20)
|
|
91
|
+
def tags; @tags ||= Services::TagService.new(@requestor); end
|
|
92
|
+
|
|
93
|
+
# Access the Follows API.
|
|
94
|
+
#
|
|
95
|
+
# @return [Services::FollowService] the follows service
|
|
96
|
+
# @see https://developers.forem.com/api/v1#tag/follows
|
|
97
|
+
#
|
|
98
|
+
# @example
|
|
99
|
+
# client.follows.list
|
|
100
|
+
# client.follows.create(followable_type: "User", followable_id: 99)
|
|
101
|
+
def follows; @follows ||= Services::FollowService.new(@requestor); end
|
|
102
|
+
|
|
103
|
+
# Access the Followers API.
|
|
104
|
+
#
|
|
105
|
+
# @return [Services::FollowerService] the followers service
|
|
106
|
+
# @see https://developers.forem.com/api/v1#tag/followers
|
|
107
|
+
#
|
|
108
|
+
# @example
|
|
109
|
+
# client.followers.list(per_page: 50)
|
|
110
|
+
def followers; @followers ||= Services::FollowerService.new(@requestor); end
|
|
111
|
+
|
|
112
|
+
# Access the Reading List API.
|
|
113
|
+
#
|
|
114
|
+
# @return [Services::ReadingListService] the reading list service
|
|
115
|
+
# @see https://developers.forem.com/api/v1#tag/readinglist
|
|
116
|
+
#
|
|
117
|
+
# @example
|
|
118
|
+
# client.reading_list.list(page: 1)
|
|
119
|
+
def reading_list; @reading_list ||= Services::ReadingListService.new(@requestor); end
|
|
120
|
+
|
|
121
|
+
# Access the Podcast Episodes API.
|
|
122
|
+
#
|
|
123
|
+
# @return [Services::PodcastEpisodeService] the podcast episodes service
|
|
124
|
+
# @see https://developers.forem.com/api/v1#tag/podcast-episodes
|
|
125
|
+
#
|
|
126
|
+
# @example
|
|
127
|
+
# client.podcast_episodes.list(username: "someshow")
|
|
128
|
+
def podcast_episodes; @podcast_episodes ||= Services::PodcastEpisodeService.new(@requestor); end
|
|
129
|
+
|
|
130
|
+
# Access the Videos API.
|
|
131
|
+
#
|
|
132
|
+
# @return [Services::VideoService] the videos service
|
|
133
|
+
# @see https://developers.forem.com/api/v1#tag/videos
|
|
134
|
+
#
|
|
135
|
+
# @example
|
|
136
|
+
# client.videos.list(page: 1)
|
|
137
|
+
def videos; @videos ||= Services::VideoService.new(@requestor); end
|
|
138
|
+
|
|
139
|
+
# Access the Profile Images API.
|
|
140
|
+
#
|
|
141
|
+
# @return [Services::ProfileImageService] the profile images service
|
|
142
|
+
# @see https://developers.forem.com/api/v1#tag/profile-images
|
|
143
|
+
#
|
|
144
|
+
# @example
|
|
145
|
+
# client.profile_images.retrieve("jsmith")
|
|
146
|
+
def profile_images; @profile_images ||= Services::ProfileImageService.new(@requestor); end
|
|
147
|
+
|
|
148
|
+
# Access the Billboards API.
|
|
149
|
+
#
|
|
150
|
+
# @return [Services::BillboardService] the billboards service
|
|
151
|
+
# @see https://developers.forem.com/api/v1#tag/billboards
|
|
152
|
+
#
|
|
153
|
+
# @example
|
|
154
|
+
# client.billboards.list
|
|
155
|
+
# client.billboards.retrieve(3)
|
|
156
|
+
def billboards; @billboards ||= Services::BillboardService.new(@requestor); end
|
|
157
|
+
|
|
158
|
+
# Access the Pages API.
|
|
159
|
+
#
|
|
160
|
+
# @return [Services::PageService] the pages service
|
|
161
|
+
# @see https://developers.forem.com/api/v1#tag/pages
|
|
162
|
+
#
|
|
163
|
+
# @example
|
|
164
|
+
# client.pages.list
|
|
165
|
+
# client.pages.create(title: "About", slug: "about", body_markdown: "...")
|
|
166
|
+
def pages; @pages ||= Services::PageService.new(@requestor); end
|
|
167
|
+
|
|
168
|
+
# Access the Segments API.
|
|
169
|
+
#
|
|
170
|
+
# @return [Services::SegmentService] the segments service
|
|
171
|
+
# @see https://developers.forem.com/api/v1#tag/segments
|
|
172
|
+
#
|
|
173
|
+
# @example
|
|
174
|
+
# client.segments.list
|
|
175
|
+
# client.segments.retrieve(5)
|
|
176
|
+
def segments; @segments ||= Services::SegmentService.new(@requestor); end
|
|
177
|
+
|
|
178
|
+
# Access the Reactions API.
|
|
179
|
+
#
|
|
180
|
+
# @return [Services::ReactionService] the reactions service
|
|
181
|
+
# @see https://developers.forem.com/api/v1#tag/reactions
|
|
182
|
+
#
|
|
183
|
+
# @example
|
|
184
|
+
# client.reactions.create(reactable_type: "Article", reactable_id: 1, category: "like")
|
|
185
|
+
# client.reactions.toggle(reactable_type: "Article", reactable_id: 1, category: "like")
|
|
186
|
+
def reactions; @reactions ||= Services::ReactionService.new(@requestor); end
|
|
187
|
+
|
|
188
|
+
# Access the Recommended Articles Lists API.
|
|
189
|
+
#
|
|
190
|
+
# @return [Services::RecommendedArticlesListService] the recommended articles lists service
|
|
191
|
+
# @see https://developers.forem.com/api/v1#tag/articles
|
|
192
|
+
#
|
|
193
|
+
# @example
|
|
194
|
+
# client.recommended_articles_lists.list
|
|
195
|
+
# client.recommended_articles_lists.retrieve(2)
|
|
196
|
+
def recommended_articles_lists; @recommended_articles_lists ||= Services::RecommendedArticlesListService.new(@requestor); end
|
|
197
|
+
|
|
198
|
+
# Access the Agent Sessions API.
|
|
199
|
+
#
|
|
200
|
+
# @return [Services::AgentSessionService] the agent sessions service
|
|
201
|
+
# @see https://developers.forem.com/api/v1
|
|
202
|
+
#
|
|
203
|
+
# @example
|
|
204
|
+
# client.agent_sessions.list
|
|
205
|
+
# client.agent_sessions.presign(filename: "upload.jpg")
|
|
206
|
+
def agent_sessions; @agent_sessions ||= Services::AgentSessionService.new(@requestor); end
|
|
207
|
+
|
|
208
|
+
# Access the Surveys API.
|
|
209
|
+
#
|
|
210
|
+
# @return [Services::SurveyService] the surveys service
|
|
211
|
+
# @see https://developers.forem.com/api/v1
|
|
212
|
+
#
|
|
213
|
+
# @example
|
|
214
|
+
# client.surveys.list
|
|
215
|
+
# client.surveys.retrieve(8)
|
|
216
|
+
def surveys; @surveys ||= Services::SurveyService.new(@requestor); end
|
|
217
|
+
|
|
218
|
+
# Access the Analytics API.
|
|
219
|
+
#
|
|
220
|
+
# @return [Services::AnalyticsService] the analytics service
|
|
221
|
+
# @see https://developers.forem.com/api/v1#tag/analytics
|
|
222
|
+
#
|
|
223
|
+
# @example
|
|
224
|
+
# client.analytics.totals(username: "jsmith")
|
|
225
|
+
# client.analytics.historical(username: "jsmith", start: "2024-01-01")
|
|
226
|
+
def analytics; @analytics ||= Services::AnalyticsService.new(@requestor); end
|
|
227
|
+
|
|
228
|
+
# Access the Health Checks API.
|
|
229
|
+
#
|
|
230
|
+
# @return [Services::HealthCheckService] the health check service
|
|
231
|
+
# @see https://developers.forem.com/api/v1#tag/health-checks
|
|
232
|
+
#
|
|
233
|
+
# @example
|
|
234
|
+
# client.health_checks.app
|
|
235
|
+
# client.health_checks.database
|
|
236
|
+
def health_checks; @health_checks ||= Services::HealthCheckService.new(@requestor); end
|
|
237
|
+
|
|
238
|
+
# Access the Admin Users API.
|
|
239
|
+
#
|
|
240
|
+
# @return [Services::AdminUserService] the admin users service
|
|
241
|
+
# @see https://developers.forem.com/api/v1#tag/users
|
|
242
|
+
#
|
|
243
|
+
# @example
|
|
244
|
+
# client.admin_users.create(email: "new@example.com", name: "New User")
|
|
245
|
+
def admin_users; @admin_users ||= Services::AdminUserService.new(@requestor); end
|
|
246
|
+
|
|
247
|
+
# Access the Trends API.
|
|
248
|
+
#
|
|
249
|
+
# @return [Services::TrendService] the trends service
|
|
250
|
+
#
|
|
251
|
+
# @example
|
|
252
|
+
# client.trends.list
|
|
253
|
+
def trends; @trends ||= Services::TrendService.new(@requestor); end
|
|
254
|
+
|
|
255
|
+
# Access the Concepts API.
|
|
256
|
+
#
|
|
257
|
+
# @return [Services::ConceptService] the concepts service
|
|
258
|
+
#
|
|
259
|
+
# @example
|
|
260
|
+
# client.concepts.list
|
|
261
|
+
def concepts; @concepts ||= Services::ConceptService.new(@requestor); end
|
|
262
|
+
|
|
263
|
+
# Access the Admin Concepts API.
|
|
264
|
+
#
|
|
265
|
+
# @return [Services::AdminConceptService] the admin concepts service
|
|
266
|
+
#
|
|
267
|
+
# @example
|
|
268
|
+
# client.admin_concepts.list
|
|
269
|
+
def admin_concepts; @admin_concepts ||= Services::AdminConceptService.new(@requestor); end
|
|
270
|
+
|
|
271
|
+
# Access the Admin Request Redirects API.
|
|
272
|
+
#
|
|
273
|
+
# @return [Services::RequestRedirectService] the request redirects service
|
|
274
|
+
#
|
|
275
|
+
# @example
|
|
276
|
+
# client.request_redirects.list
|
|
277
|
+
def request_redirects; @request_redirects ||= Services::RequestRedirectService.new(@requestor); end
|
|
278
|
+
end
|
|
279
|
+
end
|