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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +162 -0
- data/lib/wenmar/auth.rb +98 -0
- data/lib/wenmar/client.rb +222 -0
- data/lib/wenmar/config.rb +34 -0
- data/lib/wenmar/credentials.rb +124 -0
- data/lib/wenmar/error.rb +108 -0
- data/lib/wenmar/oauth.rb +53 -0
- data/lib/wenmar/pagination.rb +90 -0
- data/lib/wenmar/resources.rb +2588 -0
- data/lib/wenmar/token.rb +54 -0
- data/lib/wenmar/version.rb +5 -0
- data/lib/wenmar.rb +15 -0
- metadata +97 -0
data/lib/wenmar/error.rb
ADDED
|
@@ -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
|
data/lib/wenmar/oauth.rb
ADDED
|
@@ -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
|