onetime 0.5.0 → 0.6.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/CHANGES.txt +66 -0
- data/README.md +220 -74
- data/lib/onetime/client.rb +100 -0
- data/lib/onetime/configuration.rb +156 -0
- data/lib/onetime/errors.rb +160 -0
- data/lib/onetime/resources/receipts.rb +70 -0
- data/lib/onetime/resources/secrets.rb +134 -0
- data/lib/onetime/response.rb +72 -0
- data/lib/onetime/transport.rb +224 -0
- data/lib/onetime/version.rb +11 -0
- data/lib/onetime.rb +33 -0
- metadata +44 -90
- data/Rakefile +0 -50
- data/VERSION +0 -1
- data/bin/onetime +0 -224
- data/gem-public_cert.pem +0 -20
- data/lib/onetime/api.rb +0 -136
- data/onetime.gemspec +0 -56
- data.tar.gz.sig +0 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onetime
|
|
4
|
+
# Base class for every error raised by this library.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the client is misconfigured (bad base_url, missing
|
|
8
|
+
# credentials for an authenticated call, unsupported api_version, ...).
|
|
9
|
+
class ConfigurationError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when an operation is not available on the configured API
|
|
12
|
+
# version (e.g. asking the v1 API for a secret's status).
|
|
13
|
+
class UnsupportedOperationError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised for transport-level failures: DNS, connection refused, reset
|
|
16
|
+
# connections, TLS errors. Wraps the underlying exception in #cause.
|
|
17
|
+
class TransportError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when a request exceeds the configured open/read timeout.
|
|
20
|
+
class TimeoutError < TransportError; end
|
|
21
|
+
|
|
22
|
+
# Base class for errors returned by the API (HTTP status >= 400).
|
|
23
|
+
#
|
|
24
|
+
# Carries the structured fields from the ADR-013 wire format
|
|
25
|
+
# ({ error:, error_type:, ... }) as well as the legacy v1 { message: }
|
|
26
|
+
# shape, so callers get a consistent interface across API versions.
|
|
27
|
+
class APIError < Error
|
|
28
|
+
attr_reader :http_status, :error_type, :code, :field, :error_key,
|
|
29
|
+
:retry_after, :entitlement, :body, :response
|
|
30
|
+
|
|
31
|
+
def initialize(message = nil, http_status: nil, error_type: nil, code: nil,
|
|
32
|
+
field: nil, error_key: nil, retry_after: nil, entitlement: nil,
|
|
33
|
+
body: nil, response: nil)
|
|
34
|
+
super(message)
|
|
35
|
+
@http_status = http_status
|
|
36
|
+
@error_type = error_type
|
|
37
|
+
@code = code
|
|
38
|
+
@field = field
|
|
39
|
+
@error_key = error_key
|
|
40
|
+
@retry_after = retry_after
|
|
41
|
+
@entitlement = entitlement
|
|
42
|
+
@body = body
|
|
43
|
+
@response = response
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class BadRequestError < APIError; end # 400 / FormError
|
|
48
|
+
class AuthenticationError < APIError; end # 401
|
|
49
|
+
class ForbiddenError < APIError; end # 403 / Forbidden, GuestRoutesDisabled
|
|
50
|
+
class EntitlementError < ForbiddenError; end # EntitlementRequired
|
|
51
|
+
class NotFoundError < APIError; end # 404 / RecordNotFound
|
|
52
|
+
class ConflictError < APIError; end # 409
|
|
53
|
+
class RateLimitError < APIError; end # 429 / LimitExceeded
|
|
54
|
+
class ServerError < APIError; end # 5xx / ServerError
|
|
55
|
+
|
|
56
|
+
# The operation is only available to an authenticated account. The server
|
|
57
|
+
# reports this as a field/key rather than a status of its own (historically
|
|
58
|
+
# a 400 FormError with field "requires_account"), which made it look like a
|
|
59
|
+
# malformed request body. It is an authentication problem, so it subclasses
|
|
60
|
+
# AuthenticationError; #field is preserved for callers that need it.
|
|
61
|
+
class AccountRequiredError < AuthenticationError; end
|
|
62
|
+
|
|
63
|
+
# Builds the appropriate APIError subclass from an HTTP response.
|
|
64
|
+
module Errors
|
|
65
|
+
module_function
|
|
66
|
+
|
|
67
|
+
# Used only when the server sends no message of its own.
|
|
68
|
+
ACCOUNT_REQUIRED_MESSAGE =
|
|
69
|
+
"This operation requires an authenticated account: send your " \
|
|
70
|
+
"customer extid and API token (see Onetime::Client.new)."
|
|
71
|
+
|
|
72
|
+
# Maps the ADR-013 error_type (the machine-readable class name the
|
|
73
|
+
# server sends) to a client exception class. Falls through to status
|
|
74
|
+
# code mapping when the type is absent or unrecognized.
|
|
75
|
+
ERROR_TYPE_MAP = {
|
|
76
|
+
"FormError" => BadRequestError,
|
|
77
|
+
"RequiresAccount" => AccountRequiredError,
|
|
78
|
+
"AccountRequired" => AccountRequiredError,
|
|
79
|
+
"RecordNotFound" => NotFoundError,
|
|
80
|
+
"NotFound" => NotFoundError,
|
|
81
|
+
"Forbidden" => ForbiddenError,
|
|
82
|
+
"GuestRoutesDisabled" => ForbiddenError,
|
|
83
|
+
"EntitlementRequired" => EntitlementError,
|
|
84
|
+
"LimitExceeded" => RateLimitError,
|
|
85
|
+
"ServerError" => ServerError,
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
# The account requirement can arrive in any of several slots depending on
|
|
89
|
+
# the endpoint and API version: as the FormError `field`, as the i18n
|
|
90
|
+
# `error_key`, or as a `code`. Matching all of them keeps the mapping
|
|
91
|
+
# stable across server versions.
|
|
92
|
+
ACCOUNT_REQUIRED_KEYS = %w[error_type field error_key code].freeze
|
|
93
|
+
# Matches the bare token as well as prefixed codes such as
|
|
94
|
+
# "GUEST_CONCEAL_REQUIRES_ACCOUNT" (underscores are word characters, so
|
|
95
|
+
# \b is no help here).
|
|
96
|
+
ACCOUNT_REQUIRED_PATTERN =
|
|
97
|
+
/(?:\A|[^a-z0-9])(requires[_-]?account|account[_-]?required)(?:\z|[^a-z0-9])/i
|
|
98
|
+
|
|
99
|
+
STATUS_MAP = {
|
|
100
|
+
400 => BadRequestError,
|
|
101
|
+
401 => AuthenticationError,
|
|
102
|
+
403 => ForbiddenError,
|
|
103
|
+
404 => NotFoundError,
|
|
104
|
+
409 => ConflictError,
|
|
105
|
+
422 => BadRequestError,
|
|
106
|
+
429 => RateLimitError,
|
|
107
|
+
}.freeze
|
|
108
|
+
|
|
109
|
+
# @param response [Onetime::Response]
|
|
110
|
+
# @return [Onetime::APIError]
|
|
111
|
+
def from_response(response)
|
|
112
|
+
body = response.data.is_a?(Hash) ? response.data : {}
|
|
113
|
+
status = response.http_status
|
|
114
|
+
|
|
115
|
+
account = account_required?(body)
|
|
116
|
+
klass = account ? AccountRequiredError : error_class(body["error_type"], status)
|
|
117
|
+
message = body["error"] || body["message"] ||
|
|
118
|
+
(account ? ACCOUNT_REQUIRED_MESSAGE : default_message(status))
|
|
119
|
+
|
|
120
|
+
klass.new(
|
|
121
|
+
message,
|
|
122
|
+
http_status: status,
|
|
123
|
+
error_type: body["error_type"],
|
|
124
|
+
code: body["code"],
|
|
125
|
+
field: body["field"],
|
|
126
|
+
error_key: body["error_key"],
|
|
127
|
+
retry_after: body["retry_after"],
|
|
128
|
+
entitlement: body["entitlement"],
|
|
129
|
+
body: response.data,
|
|
130
|
+
response: response,
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# True when the body says the operation needs an authenticated account,
|
|
135
|
+
# in whichever slot this server version reports it.
|
|
136
|
+
def account_required?(body)
|
|
137
|
+
ACCOUNT_REQUIRED_KEYS.any? do |key|
|
|
138
|
+
ACCOUNT_REQUIRED_PATTERN.match?(body[key].to_s)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def error_class(error_type, status)
|
|
143
|
+
ERROR_TYPE_MAP[error_type] ||
|
|
144
|
+
STATUS_MAP[status] ||
|
|
145
|
+
(status.to_i >= 500 ? ServerError : APIError)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def default_message(status)
|
|
149
|
+
case status
|
|
150
|
+
when 400 then "Bad request"
|
|
151
|
+
when 401 then "Authentication failed"
|
|
152
|
+
when 403 then "Forbidden"
|
|
153
|
+
when 404 then "Not found"
|
|
154
|
+
when 429 then "Rate limit exceeded"
|
|
155
|
+
when 500..599 then "Server error (#{status})"
|
|
156
|
+
else "Request failed (#{status})"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onetime
|
|
4
|
+
module Resources
|
|
5
|
+
# Receipt operations (creator-facing secret metadata): show, recent,
|
|
6
|
+
# burn, update.
|
|
7
|
+
#
|
|
8
|
+
# "Receipt" is the modern vocabulary; the v1 API also accepts the legacy
|
|
9
|
+
# /private and /metadata aliases, but this client uses the canonical
|
|
10
|
+
# /receipt paths that both versions support.
|
|
11
|
+
class Receipts
|
|
12
|
+
def initialize(client)
|
|
13
|
+
@client = client
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Show a receipt by its key.
|
|
17
|
+
def show(key, guest: false)
|
|
18
|
+
identifier = key.to_s
|
|
19
|
+
case version
|
|
20
|
+
when :v1
|
|
21
|
+
@client.request(:get, "/receipt/#{identifier}")
|
|
22
|
+
when :v2
|
|
23
|
+
path = guest ? "/guest/receipt/#{identifier}" : "/receipt/#{identifier}"
|
|
24
|
+
@client.request(:get, path)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# List the authenticated customer's recent receipts.
|
|
29
|
+
def recent
|
|
30
|
+
@client.request(:get, "/receipt/recent")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Burn (destroy) an unread secret by its receipt key.
|
|
34
|
+
def burn(key, passphrase: nil, continue: true, guest: false)
|
|
35
|
+
identifier = key.to_s
|
|
36
|
+
case version
|
|
37
|
+
when :v1
|
|
38
|
+
form = compact(passphrase: passphrase, continue: continue)
|
|
39
|
+
@client.request(:post, "/receipt/#{identifier}/burn", form: form)
|
|
40
|
+
when :v2
|
|
41
|
+
path = guest ? "/guest/receipt/#{identifier}/burn" : "/receipt/#{identifier}/burn"
|
|
42
|
+
@client.request(:post, path, body: compact(passphrase: passphrase, continue: continue))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Update a receipt's memo. (v2 only)
|
|
47
|
+
def update(key, memo:)
|
|
48
|
+
require_version!(:v2, "receipts.update")
|
|
49
|
+
@client.request(:patch, "/receipt/#{key}", body: { memo: memo })
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def version
|
|
55
|
+
@client.api_version
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def compact(**attrs)
|
|
59
|
+
attrs.reject { |_, v| v.nil? }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def require_version!(expected, operation)
|
|
63
|
+
return if version == expected
|
|
64
|
+
|
|
65
|
+
raise UnsupportedOperationError,
|
|
66
|
+
"#{operation} is only available on API #{expected}; client is configured for #{version}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onetime
|
|
4
|
+
module Resources
|
|
5
|
+
# Secret operations: conceal, generate, reveal, show, status.
|
|
6
|
+
#
|
|
7
|
+
# The request/response contracts differ between API versions:
|
|
8
|
+
# - v1 uses flat form-encoded params and legacy endpoint names
|
|
9
|
+
# (/share, /generate, POST /secret/:key for reveal).
|
|
10
|
+
# - v2 nests create params under a `secret` object, uses JSON bodies,
|
|
11
|
+
# and exposes richer REST endpoints (/secret/conceal, /secret/:id, ...).
|
|
12
|
+
#
|
|
13
|
+
# The version-specific branches are kept side by side in each method so
|
|
14
|
+
# the differences are easy to audit.
|
|
15
|
+
class Secrets
|
|
16
|
+
def initialize(client)
|
|
17
|
+
@client = client
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Conceal (share) a secret value you already have.
|
|
21
|
+
#
|
|
22
|
+
# @param secret [String] the secret content
|
|
23
|
+
# @param ttl [Integer, nil] time-to-live in seconds
|
|
24
|
+
# @param passphrase [String, nil] passphrase required to reveal
|
|
25
|
+
# @param recipient [String, Array<String>, nil] email recipient(s)
|
|
26
|
+
# @param share_domain [String, nil] custom share domain
|
|
27
|
+
# @param guest [Boolean] use the anonymous /guest/* endpoint (v2)
|
|
28
|
+
def conceal(secret:, ttl: nil, passphrase: nil, recipient: nil, share_domain: nil, guest: false)
|
|
29
|
+
case version
|
|
30
|
+
when :v1
|
|
31
|
+
form = compact(secret: secret, ttl: ttl, passphrase: passphrase,
|
|
32
|
+
recipient: recipient, share_domain: share_domain)
|
|
33
|
+
@client.request(:post, "/share", form: form)
|
|
34
|
+
when :v2
|
|
35
|
+
path = guest ? "/guest/secret/conceal" : "/secret/conceal"
|
|
36
|
+
@client.request(:post, path, body: { secret: secret_payload(
|
|
37
|
+
secret: secret, ttl: ttl, passphrase: passphrase,
|
|
38
|
+
recipient: recipient, share_domain: share_domain
|
|
39
|
+
) })
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
alias share conceal
|
|
43
|
+
|
|
44
|
+
# Generate a random secret value server-side.
|
|
45
|
+
def generate(ttl: nil, passphrase: nil, recipient: nil, share_domain: nil, guest: false)
|
|
46
|
+
case version
|
|
47
|
+
when :v1
|
|
48
|
+
form = compact(ttl: ttl, passphrase: passphrase,
|
|
49
|
+
recipient: recipient, share_domain: share_domain)
|
|
50
|
+
@client.request(:post, "/generate", form: form)
|
|
51
|
+
when :v2
|
|
52
|
+
path = guest ? "/guest/secret/generate" : "/secret/generate"
|
|
53
|
+
@client.request(:post, path, body: { secret: secret_payload(
|
|
54
|
+
ttl: ttl, passphrase: passphrase,
|
|
55
|
+
recipient: recipient, share_domain: share_domain
|
|
56
|
+
) })
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Reveal (consume) a secret by its key. This is a one-time action.
|
|
61
|
+
#
|
|
62
|
+
# @param key [String] the secret identifier
|
|
63
|
+
# @param passphrase [String, nil] passphrase if one was set
|
|
64
|
+
# @param continue [Boolean] confirm the destructive reveal
|
|
65
|
+
def reveal(key, passphrase: nil, continue: true, guest: false)
|
|
66
|
+
identifier = extract_secret_key(key)
|
|
67
|
+
case version
|
|
68
|
+
when :v1
|
|
69
|
+
form = compact(passphrase: passphrase, continue: continue)
|
|
70
|
+
@client.request(:post, "/secret/#{identifier}", form: form)
|
|
71
|
+
when :v2
|
|
72
|
+
path = guest ? "/guest/secret/#{identifier}/reveal" : "/secret/#{identifier}/reveal"
|
|
73
|
+
@client.request(:post, path, body: compact(passphrase: passphrase, continue: continue))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Show a secret's metadata without revealing its value. (v2 only)
|
|
78
|
+
def show(key, guest: false)
|
|
79
|
+
require_version!(:v2, "secrets.show")
|
|
80
|
+
identifier = extract_secret_key(key)
|
|
81
|
+
path = guest ? "/guest/secret/#{identifier}" : "/secret/#{identifier}"
|
|
82
|
+
@client.request(:get, path)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Check a single secret's status. (v2 only)
|
|
86
|
+
def status(key)
|
|
87
|
+
require_version!(:v2, "secrets.status")
|
|
88
|
+
@client.request(:get, "/secret/#{extract_secret_key(key)}/status")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check the status of multiple secrets in one call. (v2 only)
|
|
92
|
+
#
|
|
93
|
+
# @param keys [Array<String>, String] identifiers (array or CSV string)
|
|
94
|
+
def status_list(keys)
|
|
95
|
+
require_version!(:v2, "secrets.status_list")
|
|
96
|
+
identifiers = keys.is_a?(Array) ? keys.join(",") : keys.to_s
|
|
97
|
+
@client.request(:post, "/secret/status", body: { identifiers: identifiers })
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def version
|
|
103
|
+
@client.api_version
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# v2 create endpoints expect params nested under a `secret` object.
|
|
107
|
+
def secret_payload(**attrs)
|
|
108
|
+
compact(**attrs)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def compact(**attrs)
|
|
112
|
+
attrs.reject { |_, v| v.nil? }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Accept either a bare key or a full secret URL (e.g. the link a user
|
|
116
|
+
# was given). Mirrors the convenience of the historical CLI.
|
|
117
|
+
def extract_secret_key(key)
|
|
118
|
+
str = key.to_s
|
|
119
|
+
if (match = str.match(%r{/secret/([a-zA-Z0-9]+)}))
|
|
120
|
+
match[1]
|
|
121
|
+
else
|
|
122
|
+
str
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def require_version!(expected, operation)
|
|
127
|
+
return if version == expected
|
|
128
|
+
|
|
129
|
+
raise UnsupportedOperationError,
|
|
130
|
+
"#{operation} is only available on API #{expected}; client is configured for #{version}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onetime
|
|
4
|
+
# A thin, indifferent-access wrapper around a parsed API response.
|
|
5
|
+
#
|
|
6
|
+
# The parsed JSON body is exposed via #[], #dig, #fetch and #to_h, all of
|
|
7
|
+
# which accept either String or Symbol keys. The raw body string and HTTP
|
|
8
|
+
# status are also available.
|
|
9
|
+
#
|
|
10
|
+
# res = client.secrets.conceal(secret: "hi")
|
|
11
|
+
# res[:record][:receipt]["identifier"]
|
|
12
|
+
# res.dig("record", "secret", "secret_value")
|
|
13
|
+
# res.success?
|
|
14
|
+
class Response
|
|
15
|
+
attr_reader :http_status, :headers, :raw_body, :data
|
|
16
|
+
|
|
17
|
+
def initialize(http_status:, headers:, raw_body:, data:)
|
|
18
|
+
@http_status = http_status
|
|
19
|
+
@headers = headers || {}
|
|
20
|
+
@raw_body = raw_body
|
|
21
|
+
@data = data
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Indifferent key access. Returns nil for non-Hash bodies.
|
|
25
|
+
def [](key)
|
|
26
|
+
return nil unless data.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
data[normalize(key)]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Indifferent, deep access mirroring Hash#dig.
|
|
32
|
+
def dig(*keys)
|
|
33
|
+
keys.reduce(data) do |memo, key|
|
|
34
|
+
case memo
|
|
35
|
+
when Hash then memo[normalize(key)]
|
|
36
|
+
when Array then memo[key]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fetch(key, *default, &block)
|
|
42
|
+
raise TypeError, "response body is not a Hash" unless data.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
data.fetch(normalize(key), *default, &block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def key?(key)
|
|
48
|
+
data.is_a?(Hash) && data.key?(normalize(key))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 2xx status.
|
|
52
|
+
def success?
|
|
53
|
+
(200..299).cover?(http_status.to_i)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Alias used by the legacy Onetime::API compatibility shim.
|
|
57
|
+
def code
|
|
58
|
+
http_status
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_h
|
|
62
|
+
data
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Bodies are parsed with string keys; coerce symbol lookups to strings.
|
|
68
|
+
def normalize(key)
|
|
69
|
+
key.is_a?(Symbol) ? key.to_s : key
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
require_relative "version"
|
|
8
|
+
require_relative "response"
|
|
9
|
+
require_relative "errors"
|
|
10
|
+
|
|
11
|
+
module Onetime
|
|
12
|
+
# Zero-dependency HTTP transport built on stdlib Net::HTTP.
|
|
13
|
+
#
|
|
14
|
+
# Responsibilities:
|
|
15
|
+
# - build the request URL, headers and body (form for v1, JSON for v2)
|
|
16
|
+
# - apply HTTP Basic auth when credentials are configured
|
|
17
|
+
# - retry idempotent requests with exponential backoff
|
|
18
|
+
# - parse the JSON response and map error statuses to exceptions
|
|
19
|
+
#
|
|
20
|
+
# It intentionally has no knowledge of API versions or resources; callers
|
|
21
|
+
# pass fully-qualified paths (e.g. "/api/v2/secret/conceal").
|
|
22
|
+
class Transport
|
|
23
|
+
IDEMPOTENT_METHODS = %i[get head].freeze
|
|
24
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
25
|
+
RETRYABLE_ERRORS = [
|
|
26
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
|
|
27
|
+
Errno::ETIMEDOUT, EOFError, SocketError, IOError,
|
|
28
|
+
Net::OpenTimeout, Net::ReadTimeout
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
METHOD_CLASSES = {
|
|
32
|
+
get: Net::HTTP::Get,
|
|
33
|
+
post: Net::HTTP::Post,
|
|
34
|
+
patch: Net::HTTP::Patch,
|
|
35
|
+
put: Net::HTTP::Put,
|
|
36
|
+
delete: Net::HTTP::Delete,
|
|
37
|
+
head: Net::HTTP::Head,
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
def initialize(config)
|
|
41
|
+
@config = config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Execute an HTTP request.
|
|
45
|
+
#
|
|
46
|
+
# @param method [Symbol] :get, :post, :patch, ...
|
|
47
|
+
# @param path [String] fully-qualified path including the /api/vN prefix
|
|
48
|
+
# @param query [Hash, nil] query-string parameters
|
|
49
|
+
# @param body [Hash, nil] request body serialized as JSON
|
|
50
|
+
# @param form [Hash, nil] request body serialized as form-urlencoded
|
|
51
|
+
# @param headers [Hash] extra request headers
|
|
52
|
+
# @param raise_on_error [Boolean] raise APIError for status >= 400
|
|
53
|
+
# @return [Onetime::Response]
|
|
54
|
+
def request(method, path, query: nil, body: nil, form: nil, headers: {}, raise_on_error: true)
|
|
55
|
+
uri = build_uri(path, query)
|
|
56
|
+
attempt = 0
|
|
57
|
+
|
|
58
|
+
begin
|
|
59
|
+
response = perform(method, uri, body: body, form: form, headers: headers)
|
|
60
|
+
|
|
61
|
+
if response.http_status >= 400
|
|
62
|
+
if retryable_status?(response.http_status) && retry_allowed?(method, attempt)
|
|
63
|
+
attempt += 1
|
|
64
|
+
backoff(attempt)
|
|
65
|
+
raise Retry
|
|
66
|
+
end
|
|
67
|
+
raise Errors.from_response(response) if raise_on_error
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
response
|
|
71
|
+
rescue Retry
|
|
72
|
+
retry
|
|
73
|
+
rescue *RETRYABLE_ERRORS => e
|
|
74
|
+
if retry_allowed?(method, attempt)
|
|
75
|
+
attempt += 1
|
|
76
|
+
backoff(attempt)
|
|
77
|
+
retry
|
|
78
|
+
end
|
|
79
|
+
raise wrap_transport_error(e)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Encodes a Hash as application/x-www-form-urlencoded, expanding Array
|
|
84
|
+
# values into repeated `key[]=` pairs (Rack's array convention, which
|
|
85
|
+
# the v1 API relies on for `recipient`).
|
|
86
|
+
def self.encode_form(hash)
|
|
87
|
+
pairs = []
|
|
88
|
+
hash.each do |key, value|
|
|
89
|
+
next if value.nil?
|
|
90
|
+
|
|
91
|
+
if value.is_a?(Array)
|
|
92
|
+
value.each { |v| pairs << ["#{key}[]", v.to_s] }
|
|
93
|
+
else
|
|
94
|
+
pairs << [key.to_s, value.to_s]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
URI.encode_www_form(pairs)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Sentinel used to trigger a retry from within the begin/rescue.
|
|
103
|
+
Retry = Class.new(StandardError)
|
|
104
|
+
private_constant :Retry
|
|
105
|
+
|
|
106
|
+
def perform(method, uri, body:, form:, headers:)
|
|
107
|
+
request = build_request(method, uri, body: body, form: form, headers: headers)
|
|
108
|
+
log(:debug) { "#{method.to_s.upcase} #{uri}" }
|
|
109
|
+
|
|
110
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
111
|
+
http.use_ssl = uri.scheme == "https"
|
|
112
|
+
http.open_timeout = @config.open_timeout
|
|
113
|
+
http.read_timeout = @config.timeout
|
|
114
|
+
|
|
115
|
+
raw = http.request(request)
|
|
116
|
+
build_response(raw)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_request(method, uri, body:, form:, headers:)
|
|
120
|
+
klass = METHOD_CLASSES.fetch(method) do
|
|
121
|
+
raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
|
|
122
|
+
end
|
|
123
|
+
request = klass.new(uri)
|
|
124
|
+
|
|
125
|
+
default_headers.merge(headers).each { |k, v| request[k] = v }
|
|
126
|
+
|
|
127
|
+
unless @config.anonymous?
|
|
128
|
+
# HTTP Basic: the customer extid occupies the username slot,
|
|
129
|
+
# the API token occupies the password slot.
|
|
130
|
+
request.basic_auth(@config.customer, @config.api_token)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if form
|
|
134
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
135
|
+
request.body = self.class.encode_form(form)
|
|
136
|
+
elsif body
|
|
137
|
+
request["Content-Type"] = "application/json"
|
|
138
|
+
request.body = JSON.generate(body)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
request
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def build_response(raw)
|
|
145
|
+
status = raw.code.to_i
|
|
146
|
+
raw_body = raw.body.to_s
|
|
147
|
+
data = parse_body(raw, raw_body)
|
|
148
|
+
|
|
149
|
+
Response.new(
|
|
150
|
+
http_status: status,
|
|
151
|
+
headers: raw.to_hash,
|
|
152
|
+
raw_body: raw_body,
|
|
153
|
+
data: data,
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def parse_body(raw, raw_body)
|
|
158
|
+
return nil if raw_body.empty?
|
|
159
|
+
|
|
160
|
+
content_type = raw["content-type"].to_s
|
|
161
|
+
return raw_body unless content_type.include?("json")
|
|
162
|
+
|
|
163
|
+
JSON.parse(raw_body)
|
|
164
|
+
rescue JSON::ParserError
|
|
165
|
+
# A non-JSON body on an otherwise-JSON endpoint: surface it raw rather
|
|
166
|
+
# than blowing up, so callers can still inspect it.
|
|
167
|
+
raw_body
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def build_uri(path, query)
|
|
171
|
+
uri = URI.join(ensure_trailing_slash(@config.base_url), path.sub(%r{\A/}, ""))
|
|
172
|
+
if query && !query.empty?
|
|
173
|
+
uri.query = self.class.encode_form(query)
|
|
174
|
+
end
|
|
175
|
+
uri
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# URI.join treats the base as a directory only when it ends in "/".
|
|
179
|
+
def ensure_trailing_slash(url)
|
|
180
|
+
url.end_with?("/") ? url : "#{url}/"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def default_headers
|
|
184
|
+
{
|
|
185
|
+
"Accept" => "application/json",
|
|
186
|
+
"User-Agent" => @config.user_agent || default_user_agent,
|
|
187
|
+
"X-Onetime-Client" => "ruby:#{RUBY_VERSION}/#{Onetime::VERSION}",
|
|
188
|
+
}.merge(@config.default_headers)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Identifies the SDK, not the gem: "onetime-ruby" tells the service which
|
|
192
|
+
# of the per-language clients is calling. The gem itself is `onetime`.
|
|
193
|
+
def default_user_agent
|
|
194
|
+
"onetime-ruby/#{Onetime::VERSION} (Ruby/#{RUBY_VERSION})"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def retry_allowed?(method, attempt)
|
|
198
|
+
IDEMPOTENT_METHODS.include?(method) && attempt < @config.max_retries
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def retryable_status?(status)
|
|
202
|
+
RETRYABLE_STATUSES.include?(status)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Exponential backoff: 0.5s, 1s, 2s, ...
|
|
206
|
+
def backoff(attempt)
|
|
207
|
+
sleep(0.5 * (2**(attempt - 1)))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def wrap_transport_error(error)
|
|
211
|
+
if error.is_a?(Net::OpenTimeout) || error.is_a?(Net::ReadTimeout)
|
|
212
|
+
TimeoutError.new("Request timed out: #{error.message}")
|
|
213
|
+
else
|
|
214
|
+
TransportError.new("Transport failure: #{error.message}")
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def log(level)
|
|
219
|
+
return unless @config.logger
|
|
220
|
+
|
|
221
|
+
@config.logger.public_send(level, "[onetime] #{yield}")
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onetime
|
|
4
|
+
# Library version. Source of truth for the gemspec, the release workflow's
|
|
5
|
+
# tag check, and the X-Onetime-Client / User-Agent request headers.
|
|
6
|
+
#
|
|
7
|
+
# Note for anyone comparing against RubyGems: 0.5.1 (2013) and earlier are
|
|
8
|
+
# the command-line tool that shipped under this gem name. 0.6.0 is the
|
|
9
|
+
# cleaned-up client library.
|
|
10
|
+
VERSION = "0.6.0"
|
|
11
|
+
end
|