wenmar 0.8.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.
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wenmar
4
+ class Error < StandardError
5
+ attr_reader :code, :message, :field_errors, :status, :request_id
6
+
7
+ def initialize(code:, message:, field_errors: {}, status: nil, request_id: nil)
8
+ @code = code
9
+ @message = message
10
+ @field_errors = field_errors
11
+ @status = status
12
+ @request_id = request_id
13
+ super("#{code}: #{message} (HTTP #{status})")
14
+ end
15
+
16
+ def self.from_response(response)
17
+ body = response.body
18
+ headers = response.respond_to?(:headers) ? (response.headers || {}) : {}
19
+
20
+ # Empty bodies can't be parsed; fall back to the status code so callers
21
+ # still get a meaningful error when the server omits the error envelope.
22
+ if body.nil? || (body.is_a?(String) && body.strip.empty?)
23
+ if (fallback = fallback_code_for_status(response.status))
24
+ return new(code: fallback, message: "HTTP #{response.status}", status: response.status, request_id: headers["X-Request-Id"])
25
+ end
26
+ return new(code: "unknown", message: "HTTP #{response.status} with empty body", status: response.status)
27
+ end
28
+
29
+ body = JSON.parse(body) if body.is_a?(String)
30
+ body = {} unless body.is_a?(Hash)
31
+
32
+ if body["error"].is_a?(Hash)
33
+ err = body["error"]
34
+ new(
35
+ code: err["code"] || "unknown",
36
+ message: err["message"] || "API error",
37
+ field_errors: field_errors_map(err["field_errors"] || err["details"] || {}),
38
+ status: response.status,
39
+ request_id: headers["X-Request-Id"]
40
+ )
41
+ elsif (fallback = fallback_code_for_status(response.status))
42
+ new(code: fallback, message: "HTTP #{response.status}", status: response.status, request_id: headers["X-Request-Id"])
43
+ else
44
+ new(code: "unknown", message: body.to_s, status: response.status)
45
+ end
46
+ rescue JSON::ParserError
47
+ new(code: "unknown", message: "Malformed error response", status: response.status)
48
+ end
49
+
50
+ def self.fallback_code_for_status(status)
51
+ {
52
+ 401 => "unauthorized",
53
+ 403 => "forbidden",
54
+ 404 => "not_found",
55
+ 409 => "conflict",
56
+ 422 => "validation_failed",
57
+ 429 => "rate_limited",
58
+ 500 => "internal_error",
59
+ 502 => "bad_gateway",
60
+ 503 => "service_unavailable",
61
+ 504 => "gateway_timeout",
62
+ 507 => "limit_exceeded"
63
+ }[status]
64
+ end
65
+
66
+ def self.field_errors_map(list)
67
+ case list
68
+ when Hash
69
+ list
70
+ when Array
71
+ list.each_with_object({}) do |item, memo|
72
+ if item.is_a?(Hash)
73
+ memo[item["field"] || item[:field]] = item["message"] || item[:message]
74
+ end
75
+ end
76
+ else
77
+ {}
78
+ end
79
+ end
80
+
81
+ # Extracts validation field errors from the field_errors hash.
82
+ # The Wenmar API sends: field_errors: { "first_name" => ["can't be blank"] }
83
+ # Returns nil if there are no field errors.
84
+ def field_errors_by_field
85
+ return nil if @field_errors.nil? || @field_errors.empty?
86
+
87
+ result = {}
88
+ @field_errors.each do |field, raw|
89
+ case raw
90
+ when Array
91
+ msgs = raw.map(&:to_s)
92
+ result[field] = msgs if msgs.any?
93
+ when String
94
+ result[field] = [raw]
95
+ end
96
+ end
97
+ result.empty? ? nil : result
98
+ end
99
+
100
+ # Reports whether the error is worth retrying.
101
+ def retryable?
102
+ return true if @code == "rate_limited"
103
+ return false if @code == "limit_exceeded"
104
+
105
+ @status >= 500 && @status != 507
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Wenmar
8
+ module OAuth
9
+ DEFAULT_CLIENT_ID = "wenmar-cli"
10
+
11
+ # POSTs a refresh_token grant to the Doorkeeper token endpoint and returns
12
+ # a fresh Wenmar::Token. Redirects are not followed: a 3xx from the token
13
+ # endpoint is a security risk, not a redirect to follow.
14
+ def self.refresh(base_url:, refresh_token:, client_id: DEFAULT_CLIENT_ID)
15
+ endpoint = "#{base_url.to_s.sub(%r{/+\z}, "")}/oauth/token"
16
+
17
+ conn = Faraday.new(url: endpoint) do |f|
18
+ f.options.timeout = 10
19
+ f.adapter Faraday.default_adapter
20
+ end
21
+
22
+ response = conn.post do |req|
23
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
24
+ req.headers["Accept"] = "application/json"
25
+ req.body = URI.encode_www_form({grant_type: "refresh_token", client_id: client_id, refresh_token: refresh_token})
26
+ end
27
+
28
+ unless response.success?
29
+ error = begin
30
+ JSON.parse(response.body)["error"]
31
+ rescue
32
+ nil
33
+ end
34
+ message = error ? "token refresh failed: #{error}" : "token refresh failed: HTTP #{response.status}"
35
+ raise TokenError, message
36
+ end
37
+
38
+ data = JSON.parse(response.body)
39
+ if data["access_token"].nil? || data["access_token"].empty?
40
+ raise TokenError, "refresh response missing access_token"
41
+ end
42
+
43
+ Token.new(
44
+ access_token: data["access_token"],
45
+ refresh_token: data["refresh_token"],
46
+ expires_at: data["expires_in"] ? Time.now + data["expires_in"].to_i : nil,
47
+ token_type: data["token_type"] || "Bearer"
48
+ )
49
+ rescue JSON::ParserError => e
50
+ raise TokenError, "parse refresh response: #{e.message}"
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Wenmar
6
+ class Paginator
7
+ include Enumerable
8
+
9
+ attr_reader :data, :links, :meta, :next_url, :client
10
+
11
+ def initialize(client, page)
12
+ @client = client
13
+ @data = page.fetch("data", page.fetch(:data, []))
14
+ @links = page.fetch("links", page.fetch(:links, {}))
15
+ @meta = page.fetch("meta", page.fetch(:meta, {}))
16
+ @page = page
17
+ @next_url = @links["next"] || @links[:next]
18
+ end
19
+
20
+ def has_next?
21
+ !@next_url.nil? && !@next_url.empty?
22
+ end
23
+
24
+ def next_page
25
+ return nil unless has_next?
26
+
27
+ unless same_origin?(@next_url, @client.base_url)
28
+ raise Wenmar::Error.new(
29
+ code: "invalid_pagination",
30
+ message: "pagination next URL is not same-origin as base URL",
31
+ field_errors: {},
32
+ status: 0
33
+ )
34
+ end
35
+
36
+ response = @client.send(:get_raw, @next_url)
37
+ @next_url = self.class.parse_link_header(response.headers["Link"], "next")
38
+ JSON.parse(response.body)
39
+ end
40
+
41
+ def each
42
+ return to_enum unless block_given?
43
+
44
+ page = self
45
+ loop do
46
+ page.data.each { |item| yield item }
47
+ page = page.next_page
48
+ break if page.nil?
49
+ end
50
+ end
51
+
52
+ def to_a(max = 1000)
53
+ result = []
54
+ page = self
55
+ loop do
56
+ result.concat(page.data)
57
+ break if result.size >= max
58
+
59
+ page = page.next_page
60
+ break if page.nil?
61
+ end
62
+ result.first(max)
63
+ end
64
+
65
+ def same_origin?(url, base_url)
66
+ parsed = URI.parse(url)
67
+ base = URI.parse(base_url)
68
+ parsed.scheme == base.scheme && parsed.host == base.host && parsed.port == base.port
69
+ rescue URI::InvalidURIError
70
+ false
71
+ end
72
+ private :same_origin?
73
+
74
+ def self.parse_link_header(header, rel)
75
+ return nil if header.nil? || header.empty?
76
+
77
+ header.split(",").each do |part|
78
+ match = part.match(/<([^>]+)>;\s*rel="#{rel}"/)
79
+ return match[1] if match
80
+ end
81
+ nil
82
+ end
83
+
84
+ def self.from_response(response, client)
85
+ link_header = response.headers["Link"]
86
+ next_url = parse_link_header(link_header, "next")
87
+ new(client, {"data" => [], "links" => {"next" => next_url}, "meta" => {}})
88
+ end
89
+ end
90
+ end