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.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/forem-ruby.gemspec +17 -0
  4. data/lib/forem/api_operations/create.rb +47 -0
  5. data/lib/forem/api_operations/delete.rb +88 -0
  6. data/lib/forem/api_operations/list.rb +70 -0
  7. data/lib/forem/api_operations/request.rb +83 -0
  8. data/lib/forem/api_operations/retrieve.rb +43 -0
  9. data/lib/forem/api_operations/save.rb +53 -0
  10. data/lib/forem/api_operations/update.rb +47 -0
  11. data/lib/forem/api_requestor.rb +283 -0
  12. data/lib/forem/api_resource.rb +77 -0
  13. data/lib/forem/client.rb +279 -0
  14. data/lib/forem/configuration.rb +74 -0
  15. data/lib/forem/connection_manager.rb +75 -0
  16. data/lib/forem/errors.rb +118 -0
  17. data/lib/forem/forem_object.rb +264 -0
  18. data/lib/forem/forem_response.rb +50 -0
  19. data/lib/forem/list_object.rb +171 -0
  20. data/lib/forem/resources/admin_concept.rb +169 -0
  21. data/lib/forem/resources/admin_user.rb +152 -0
  22. data/lib/forem/resources/agent_session.rb +110 -0
  23. data/lib/forem/resources/analytics.rb +151 -0
  24. data/lib/forem/resources/article.rb +256 -0
  25. data/lib/forem/resources/billboard.rb +76 -0
  26. data/lib/forem/resources/comment.rb +43 -0
  27. data/lib/forem/resources/concept.rb +192 -0
  28. data/lib/forem/resources/follow.rb +78 -0
  29. data/lib/forem/resources/follower.rb +56 -0
  30. data/lib/forem/resources/health_check.rb +70 -0
  31. data/lib/forem/resources/organization.rb +79 -0
  32. data/lib/forem/resources/page.rb +52 -0
  33. data/lib/forem/resources/podcast_episode.rb +36 -0
  34. data/lib/forem/resources/profile_image.rb +44 -0
  35. data/lib/forem/resources/reaction.rb +61 -0
  36. data/lib/forem/resources/reading_list.rb +29 -0
  37. data/lib/forem/resources/recommended_articles_list.rb +45 -0
  38. data/lib/forem/resources/request_redirect.rb +60 -0
  39. data/lib/forem/resources/segment.rb +103 -0
  40. data/lib/forem/resources/survey.rb +96 -0
  41. data/lib/forem/resources/tag.rb +27 -0
  42. data/lib/forem/resources/trend.rb +80 -0
  43. data/lib/forem/resources/user.rb +229 -0
  44. data/lib/forem/resources/video.rb +28 -0
  45. data/lib/forem/services/admin_concept_service.rb +138 -0
  46. data/lib/forem/services/admin_user_service.rb +114 -0
  47. data/lib/forem/services/agent_session_service.rb +87 -0
  48. data/lib/forem/services/analytics_service.rb +93 -0
  49. data/lib/forem/services/article_service.rb +233 -0
  50. data/lib/forem/services/base_service.rb +45 -0
  51. data/lib/forem/services/billboard_service.rb +91 -0
  52. data/lib/forem/services/comment_service.rb +51 -0
  53. data/lib/forem/services/concept_service.rb +143 -0
  54. data/lib/forem/services/follow_service.rb +61 -0
  55. data/lib/forem/services/follower_service.rb +34 -0
  56. data/lib/forem/services/health_check_service.rb +46 -0
  57. data/lib/forem/services/organization_service.rb +103 -0
  58. data/lib/forem/services/page_service.rb +107 -0
  59. data/lib/forem/services/podcast_episode_service.rb +34 -0
  60. data/lib/forem/services/profile_image_service.rb +32 -0
  61. data/lib/forem/services/reaction_service.rb +72 -0
  62. data/lib/forem/services/reading_list_service.rb +35 -0
  63. data/lib/forem/services/recommended_articles_list_service.rb +87 -0
  64. data/lib/forem/services/request_redirect_service.rb +118 -0
  65. data/lib/forem/services/segment_service.rb +83 -0
  66. data/lib/forem/services/survey_service.rb +48 -0
  67. data/lib/forem/services/tag_service.rb +32 -0
  68. data/lib/forem/services/trend_service.rb +70 -0
  69. data/lib/forem/services/user_service.rb +61 -0
  70. data/lib/forem/services/video_service.rb +33 -0
  71. data/lib/forem/util.rb +43 -0
  72. data/lib/forem/version.rb +4 -0
  73. data/lib/forem.rb +91 -0
  74. 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
@@ -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