pickpoint 2.0.3
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/Gemfile +9 -0
- data/LICENSE +202 -0
- data/README.md +132 -0
- data/Rakefile +13 -0
- data/VERSION +1 -0
- data/lib/pickpoint/address.rb +17 -0
- data/lib/pickpoint/auth.rb +213 -0
- data/lib/pickpoint/client.rb +50 -0
- data/lib/pickpoint/config.rb +44 -0
- data/lib/pickpoint/devices.rb +103 -0
- data/lib/pickpoint/errors.rb +39 -0
- data/lib/pickpoint/geocoding.rb +123 -0
- data/lib/pickpoint/http.rb +53 -0
- data/lib/pickpoint/mint.rb +61 -0
- data/lib/pickpoint/routing.rb +40 -0
- data/lib/pickpoint/transport.rb +161 -0
- data/lib/pickpoint/version.rb +5 -0
- data/lib/pickpoint.rb +18 -0
- data/pickpoint.gemspec +38 -0
- metadata +70 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pickpoint
|
|
4
|
+
DEFAULT_BASE_URL = "https://api.pickpoint.io"
|
|
5
|
+
DEFAULT_MAX_RETRIES = 3
|
|
6
|
+
DEFAULT_RETRY_BASE = 1.0 # seconds
|
|
7
|
+
MIN_RETRY_BASE = 0.2
|
|
8
|
+
DEFAULT_TIMEOUT = 30.0
|
|
9
|
+
MAX_CONCURRENCY = 20
|
|
10
|
+
DEFAULT_CONCURRENCY = 20
|
|
11
|
+
CLIENT_AUTH_REFRESH_AT = 0.5
|
|
12
|
+
|
|
13
|
+
# Pair from POST /v2/client-tokens. +expires_at+ is unix epoch milliseconds.
|
|
14
|
+
ClientAuth = Struct.new(:access_token, :refresh_token, :expires_at, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
# Public-api client config. Provide exactly one of +api_key+ / +client_auth+ / +access_token+.
|
|
17
|
+
class Config
|
|
18
|
+
attr_accessor :api_key, :client_auth, :access_token, :base_url,
|
|
19
|
+
:max_retries, :retry_base, :timeout, :concurrency, :http_adapter
|
|
20
|
+
|
|
21
|
+
def initialize(
|
|
22
|
+
api_key: nil,
|
|
23
|
+
client_auth: nil,
|
|
24
|
+
access_token: nil,
|
|
25
|
+
base_url: nil,
|
|
26
|
+
max_retries: nil,
|
|
27
|
+
retry_base: nil,
|
|
28
|
+
timeout: nil,
|
|
29
|
+
concurrency: nil,
|
|
30
|
+
http_adapter: nil
|
|
31
|
+
)
|
|
32
|
+
@api_key = api_key
|
|
33
|
+
@client_auth = client_auth
|
|
34
|
+
@access_token = access_token
|
|
35
|
+
@base_url = base_url
|
|
36
|
+
@max_retries = max_retries
|
|
37
|
+
@retry_base = retry_base
|
|
38
|
+
@timeout = timeout
|
|
39
|
+
@concurrency = concurrency
|
|
40
|
+
# Optional callable: call(method, url, headers, body) -> [status, body]
|
|
41
|
+
@http_adapter = http_adapter
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Pickpoint
|
|
7
|
+
Device = Struct.new(
|
|
8
|
+
:uid, :id, :name, :status, :description, :tracks_count, :type, :secret,
|
|
9
|
+
:metadata, :created_at, :updated_at, :last_location,
|
|
10
|
+
keyword_init: true
|
|
11
|
+
) do
|
|
12
|
+
def self.from_hash(d)
|
|
13
|
+
d = d.transform_keys(&:to_s)
|
|
14
|
+
new(
|
|
15
|
+
id: (d["id"] || 0).to_i,
|
|
16
|
+
uid: (d["uid"] || "").to_s,
|
|
17
|
+
name: (d["name"] || "").to_s,
|
|
18
|
+
status: (d["status"] || "").to_s,
|
|
19
|
+
description: d["description"],
|
|
20
|
+
tracks_count: (d["tracksCount"] || 0).to_i,
|
|
21
|
+
type: (d["type"] || "").to_s,
|
|
22
|
+
secret: (d["secret"] || "").to_s,
|
|
23
|
+
metadata: d["metadata"],
|
|
24
|
+
created_at: (d["createdAt"] || "").to_s,
|
|
25
|
+
updated_at: (d["updatedAt"] || "").to_s,
|
|
26
|
+
last_location: d["lastLocation"]
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
DeviceInput = Struct.new(:name, :type, :description, :metadata, keyword_init: true) do
|
|
32
|
+
def to_h
|
|
33
|
+
out = { "name" => name, "type" => type }
|
|
34
|
+
out["description"] = description unless description.nil?
|
|
35
|
+
out["metadata"] = metadata unless metadata.nil?
|
|
36
|
+
out
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
DeviceListResult = Struct.new(:data, :total, keyword_init: true)
|
|
41
|
+
DeviceListQuery = Struct.new(:skip, :take, :search, :idle, keyword_init: true)
|
|
42
|
+
DeviceCommandResult = Struct.new(:delivered, keyword_init: true)
|
|
43
|
+
|
|
44
|
+
class DevicesService
|
|
45
|
+
def initialize(transport)
|
|
46
|
+
@t = transport
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def list(query = nil)
|
|
50
|
+
q = query || DeviceListQuery.new
|
|
51
|
+
params = {}
|
|
52
|
+
params["skip"] = q.skip.to_s if q.skip && q.skip.to_i.positive?
|
|
53
|
+
params["take"] = q.take.to_s if q.take && q.take.to_i.positive?
|
|
54
|
+
params["search"] = q.search if q.search && !q.search.empty?
|
|
55
|
+
params["idle"] = "1" if q.idle
|
|
56
|
+
|
|
57
|
+
raw = @t.do(Transport::RequestOpts.new(method: "GET", path: "/v2/devices", query: params))
|
|
58
|
+
body = JSON.parse(raw)
|
|
59
|
+
DeviceListResult.new(
|
|
60
|
+
data: Array(body["data"]).map { |x| Device.from_hash(x) },
|
|
61
|
+
total: (body["total"] || 0).to_i
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get(uid)
|
|
66
|
+
path = "/v2/devices/#{URI.encode_www_form_component(uid)}"
|
|
67
|
+
raw = @t.do(Transport::RequestOpts.new(method: "GET", path: path))
|
|
68
|
+
Device.from_hash(JSON.parse(raw))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def create(input)
|
|
72
|
+
raw = @t.do(Transport::RequestOpts.new(method: "POST", path: "/v2/devices", body: input.to_h))
|
|
73
|
+
Device.from_hash(JSON.parse(raw))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def update(uid, input)
|
|
77
|
+
path = "/v2/devices/#{URI.encode_www_form_component(uid)}"
|
|
78
|
+
raw = @t.do(Transport::RequestOpts.new(method: "PATCH", path: path, body: input.to_h))
|
|
79
|
+
Device.from_hash(JSON.parse(raw))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def delete(uid)
|
|
83
|
+
path = "/v2/devices/#{URI.encode_www_form_component(uid)}"
|
|
84
|
+
@t.do(Transport::RequestOpts.new(method: "DELETE", path: path))
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def command(uid, payload)
|
|
89
|
+
path = "/v2/devices/#{URI.encode_www_form_component(uid)}/command"
|
|
90
|
+
# pack("m0") = strict Base64 (no newlines); avoids the base64 gem (not default since 3.4)
|
|
91
|
+
b64 = [payload.to_s.b].pack("m0")
|
|
92
|
+
raw = @t.do(
|
|
93
|
+
Transport::RequestOpts.new(
|
|
94
|
+
method: "POST",
|
|
95
|
+
path: path,
|
|
96
|
+
body: { "payload" => b64 }
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
body = JSON.parse(raw)
|
|
100
|
+
DeviceCommandResult.new(delivered: (body["delivered"] || 0).to_i)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pickpoint
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class AuthError < Error; end
|
|
7
|
+
class NotFoundError < Error; end
|
|
8
|
+
class ConflictError < Error; end
|
|
9
|
+
class InvalidConfigError < Error; end
|
|
10
|
+
|
|
11
|
+
# Non-2xx public-api response (or transport failure after retries).
|
|
12
|
+
class APIError < Error
|
|
13
|
+
attr_reader :status, :code, :body
|
|
14
|
+
|
|
15
|
+
def initialize(status: 0, code: "", message: "", body: "")
|
|
16
|
+
@status = status
|
|
17
|
+
@code = code.to_s
|
|
18
|
+
@body = body.is_a?(String) ? body.b : body.to_s.b
|
|
19
|
+
msg = message.empty? ? "request failed (status=#{status} code=#{code})" : message
|
|
20
|
+
super("pickpoint: #{msg} (status=#{status} code=#{code})")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def auth?
|
|
24
|
+
%w[API_AUTH REFRESH_FAILED].include?(code)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def not_found?
|
|
28
|
+
code == "NOT_FOUND"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def conflict?
|
|
32
|
+
code == "CONFLICT"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def invalid_config?
|
|
36
|
+
code == "INVALID_CONFIG"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "thread"
|
|
5
|
+
|
|
6
|
+
module Pickpoint
|
|
7
|
+
class GeocodingService
|
|
8
|
+
def initialize(transport, concurrency)
|
|
9
|
+
@t = transport
|
|
10
|
+
@concurrency = concurrency
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def forward(query)
|
|
14
|
+
raw = @t.do(
|
|
15
|
+
Transport::RequestOpts.new(
|
|
16
|
+
method: "GET",
|
|
17
|
+
path: "/v2/geocode/forward",
|
|
18
|
+
query: stringify_query(query),
|
|
19
|
+
on_client_error: Transport::ON_EMPTY,
|
|
20
|
+
empty: "[]"
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
decode_json_array(raw)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reverse(query)
|
|
27
|
+
raw = @t.do(
|
|
28
|
+
Transport::RequestOpts.new(
|
|
29
|
+
method: "GET",
|
|
30
|
+
path: "/v2/geocode/reverse",
|
|
31
|
+
query: stringify_query(query),
|
|
32
|
+
on_client_error: Transport::ON_EMPTY,
|
|
33
|
+
empty: "null"
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
return nil if raw.nil? || raw.empty? || raw == "null"
|
|
37
|
+
|
|
38
|
+
JSON.parse(raw)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def lookup(query)
|
|
42
|
+
raw = @t.do(
|
|
43
|
+
Transport::RequestOpts.new(
|
|
44
|
+
method: "GET",
|
|
45
|
+
path: "/v2/address/lookup",
|
|
46
|
+
query: stringify_query(query),
|
|
47
|
+
on_client_error: Transport::ON_EMPTY,
|
|
48
|
+
empty: "[]"
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
decode_json_array(raw)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def forward_batch(queries)
|
|
55
|
+
run_batch(queries) { |q| forward(q) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reverse_batch(queries)
|
|
59
|
+
run_batch(queries) { |q| reverse(q) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def lookup_batch(queries)
|
|
63
|
+
run_batch(queries) { |q| lookup(q) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def stringify_query(query)
|
|
69
|
+
(query || {}).transform_keys(&:to_s).transform_values(&:to_s)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def decode_json_array(raw)
|
|
73
|
+
return [] if raw.nil? || raw.empty?
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
out = JSON.parse(raw)
|
|
77
|
+
rescue JSON::ParserError => e
|
|
78
|
+
raise APIError.new(code: "INVALID_JSON", message: e.message, body: raw)
|
|
79
|
+
end
|
|
80
|
+
return out if out.is_a?(Array)
|
|
81
|
+
return [] if out.nil?
|
|
82
|
+
|
|
83
|
+
[out]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def run_batch(inputs)
|
|
87
|
+
inputs = Array(inputs)
|
|
88
|
+
return [] if inputs.empty?
|
|
89
|
+
|
|
90
|
+
concurrency = [@concurrency.to_i, 1].max
|
|
91
|
+
out = Array.new(inputs.length)
|
|
92
|
+
first_err = nil
|
|
93
|
+
err_mutex = Mutex.new
|
|
94
|
+
queue = Queue.new
|
|
95
|
+
inputs.each_with_index { |q, i| queue << [i, q] }
|
|
96
|
+
|
|
97
|
+
workers = [concurrency, inputs.length].min.times.map do
|
|
98
|
+
Thread.new do
|
|
99
|
+
loop do
|
|
100
|
+
break if err_mutex.synchronize { first_err }
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
i, q = queue.pop(true)
|
|
104
|
+
rescue ThreadError
|
|
105
|
+
break
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
begin
|
|
109
|
+
out[i] = yield(q)
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
err_mutex.synchronize { first_err ||= e }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
workers.each(&:join)
|
|
118
|
+
raise first_err if first_err
|
|
119
|
+
|
|
120
|
+
out
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Pickpoint
|
|
7
|
+
# Thin Net::HTTP wrapper. Optional +adapter+ responds to
|
|
8
|
+
# +call(method, url, headers, body) -> [status, body_string]+.
|
|
9
|
+
class Http
|
|
10
|
+
def initialize(timeout:, adapter: nil)
|
|
11
|
+
@timeout = timeout
|
|
12
|
+
@adapter = adapter
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def request(method, url, headers: {}, body: nil)
|
|
16
|
+
return @adapter.call(method, url, headers, body) if @adapter
|
|
17
|
+
|
|
18
|
+
uri = URI(url)
|
|
19
|
+
req = build_request(method, uri, body)
|
|
20
|
+
headers.each { |k, v| req[k] = v }
|
|
21
|
+
|
|
22
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
23
|
+
http.use_ssl = uri.scheme == "https"
|
|
24
|
+
http.open_timeout = @timeout
|
|
25
|
+
http.read_timeout = @timeout
|
|
26
|
+
res = http.request(req)
|
|
27
|
+
[res.code.to_i, res.body.to_s]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def build_request(method, uri, body)
|
|
33
|
+
m = method.to_s.upcase
|
|
34
|
+
klass =
|
|
35
|
+
case m
|
|
36
|
+
when "GET" then Net::HTTP::Get
|
|
37
|
+
when "POST" then Net::HTTP::Post
|
|
38
|
+
when "PATCH" then Net::HTTP::Patch
|
|
39
|
+
when "PUT" then Net::HTTP::Put
|
|
40
|
+
when "DELETE" then Net::HTTP::Delete
|
|
41
|
+
else
|
|
42
|
+
raise InvalidConfigError, "unsupported HTTP method: #{m}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
req = klass.new(uri.request_uri)
|
|
46
|
+
unless body.nil?
|
|
47
|
+
req["Content-Type"] = "application/json"
|
|
48
|
+
req.body = body
|
|
49
|
+
end
|
|
50
|
+
req
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pickpoint
|
|
6
|
+
TokenPair = Struct.new(
|
|
7
|
+
:access_token, :refresh_token, :expires_at, :expires_in, :scopes,
|
|
8
|
+
keyword_init: true
|
|
9
|
+
) do
|
|
10
|
+
def self.from_hash(d)
|
|
11
|
+
d = d.transform_keys(&:to_s)
|
|
12
|
+
new(
|
|
13
|
+
access_token: (d["accessToken"] || "").to_s,
|
|
14
|
+
refresh_token: (d["refreshToken"] || "").to_s,
|
|
15
|
+
expires_at: (d["expiresAt"] || 0).to_i,
|
|
16
|
+
expires_in: (d["expiresIn"] || 0).to_i,
|
|
17
|
+
scopes: Array(d["scopes"])
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Mint a client-token pair with a secret API key (server-side).
|
|
25
|
+
def mint_client_tokens(cfg, scopes: nil, ttl_sec: nil)
|
|
26
|
+
raise InvalidConfigError, "mint_client_tokens requires api_key" if cfg.api_key.nil? || cfg.api_key.empty?
|
|
27
|
+
|
|
28
|
+
base = Transport.trim_slash(cfg.base_url || DEFAULT_BASE_URL)
|
|
29
|
+
timeout = cfg.timeout && cfg.timeout.positive? ? cfg.timeout : DEFAULT_TIMEOUT
|
|
30
|
+
http = Http.new(timeout: timeout, adapter: cfg.http_adapter)
|
|
31
|
+
|
|
32
|
+
payload = { "scopes" => scopes || [] }
|
|
33
|
+
payload["ttlSec"] = ttl_sec if ttl_sec && ttl_sec.positive?
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
status, raw = http.request(
|
|
37
|
+
"POST",
|
|
38
|
+
"#{base}/v2/client-tokens",
|
|
39
|
+
headers: {
|
|
40
|
+
"Accept" => "application/json",
|
|
41
|
+
"Content-Type" => "application/json",
|
|
42
|
+
"x-api-key" => cfg.api_key
|
|
43
|
+
},
|
|
44
|
+
body: JSON.generate(payload)
|
|
45
|
+
)
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
raise APIError.new(code: "NETWORK", message: "mint client tokens network error: #{e}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
unless status >= 200 && status < 300
|
|
51
|
+
raise APIError.new(
|
|
52
|
+
status: status,
|
|
53
|
+
code: "CLIENT_ERROR",
|
|
54
|
+
message: "mint client tokens failed (#{status})",
|
|
55
|
+
body: raw
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
TokenPair.from_hash(JSON.parse(raw))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pickpoint
|
|
6
|
+
class RoutingService
|
|
7
|
+
def initialize(transport)
|
|
8
|
+
@t = transport
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def route(body)
|
|
12
|
+
post("/v2/route", body)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def optimized(body)
|
|
16
|
+
post("/v2/route/optimized", body)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def matrix(body)
|
|
20
|
+
post("/v2/route/matrix", body)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def locate(body)
|
|
24
|
+
post("/v2/route/locate", body)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def elevation(body)
|
|
28
|
+
post("/v2/route/elevation", body)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def post(path, body)
|
|
34
|
+
raw = @t.do(Transport::RequestOpts.new(method: "POST", path: path, body: body))
|
|
35
|
+
return nil if raw.nil? || raw.empty?
|
|
36
|
+
|
|
37
|
+
JSON.parse(raw)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Pickpoint
|
|
7
|
+
module Transport
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def trim_slash(s)
|
|
11
|
+
s.to_s.sub(%r{/+\z}, "")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
ON_THROW = :throw
|
|
15
|
+
ON_EMPTY = :empty
|
|
16
|
+
|
|
17
|
+
RequestOpts = Struct.new(
|
|
18
|
+
:method,
|
|
19
|
+
:path,
|
|
20
|
+
:query,
|
|
21
|
+
:body,
|
|
22
|
+
:on_client_error,
|
|
23
|
+
:empty,
|
|
24
|
+
keyword_init: true
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
class Client
|
|
28
|
+
def initialize(base_url:, http:, auth:, max_retries:, retry_base:)
|
|
29
|
+
@base_url = base_url
|
|
30
|
+
@http = http
|
|
31
|
+
@auth = auth
|
|
32
|
+
@max_retries = max_retries
|
|
33
|
+
@retry_base = retry_base
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def do(opts)
|
|
37
|
+
attempt = 0
|
|
38
|
+
auth_retried = false
|
|
39
|
+
|
|
40
|
+
loop do
|
|
41
|
+
url = build_url(opts)
|
|
42
|
+
headers = {}
|
|
43
|
+
@auth.apply!(headers)
|
|
44
|
+
body = opts.body.nil? ? nil : JSON.generate(opts.body)
|
|
45
|
+
headers["Content-Type"] = "application/json" unless body.nil?
|
|
46
|
+
|
|
47
|
+
begin
|
|
48
|
+
status, raw = @http.request(opts.method || "GET", url, headers: headers, body: body)
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
raise APIError.new(code: "NETWORK", message: "network error: #{e}") if attempt >= @max_retries
|
|
51
|
+
|
|
52
|
+
sleep_backoff(attempt)
|
|
53
|
+
attempt += 1
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if status == 401
|
|
58
|
+
if !auth_retried && @auth.bearer? && @auth.refresh_after_unauthorized
|
|
59
|
+
auth_retried = true
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
raise APIError.new(status: status, code: "API_AUTH", message: "auth failed (401)", body: raw)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if [402, 403].include?(status)
|
|
66
|
+
raise APIError.new(status: status, code: "API_AUTH", message: "auth failed", body: raw)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return "" if status == 204
|
|
70
|
+
|
|
71
|
+
if status == 409
|
|
72
|
+
raise APIError.new(
|
|
73
|
+
status: 409,
|
|
74
|
+
code: "CONFLICT",
|
|
75
|
+
message: message_from_body(raw, 409),
|
|
76
|
+
body: raw
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if status == 400 || (status >= 404 && status < 500)
|
|
81
|
+
return opts.empty || "" if opts.on_client_error == ON_EMPTY
|
|
82
|
+
|
|
83
|
+
code = status == 404 ? "NOT_FOUND" : "CLIENT_ERROR"
|
|
84
|
+
raise APIError.new(
|
|
85
|
+
status: status,
|
|
86
|
+
code: code,
|
|
87
|
+
message: message_from_body(raw, status),
|
|
88
|
+
body: raw
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if status >= 500
|
|
93
|
+
if attempt >= @max_retries
|
|
94
|
+
raise APIError.new(
|
|
95
|
+
status: status,
|
|
96
|
+
code: "SERVER_ERROR",
|
|
97
|
+
message: "server error after retries",
|
|
98
|
+
body: raw
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
sleep_backoff(attempt)
|
|
102
|
+
attempt += 1
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
return raw if status >= 200 && status < 300
|
|
107
|
+
|
|
108
|
+
return opts.empty || "" if status >= 400 && status < 500 && opts.on_client_error == ON_EMPTY
|
|
109
|
+
|
|
110
|
+
raise APIError.new(
|
|
111
|
+
status: status,
|
|
112
|
+
code: "CLIENT_ERROR",
|
|
113
|
+
message: message_from_body(raw, status),
|
|
114
|
+
body: raw
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def build_url(opts)
|
|
122
|
+
q = (opts.query || {}).reject { |_k, v| v.nil? || v.to_s.empty? }
|
|
123
|
+
path = opts.path
|
|
124
|
+
path = "#{path}?#{URI.encode_www_form(q)}" if q.any?
|
|
125
|
+
"#{@base_url}#{path}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def message_from_body(raw, status)
|
|
129
|
+
begin
|
|
130
|
+
m = JSON.parse(raw)
|
|
131
|
+
if m.is_a?(Hash)
|
|
132
|
+
return m["message"].to_s if m["message"]
|
|
133
|
+
return m["error"].to_s if m["error"]
|
|
134
|
+
end
|
|
135
|
+
rescue JSON::ParserError
|
|
136
|
+
# fall through
|
|
137
|
+
end
|
|
138
|
+
STATUS_TEXT.fetch(status, "unknown")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def sleep_backoff(attempt)
|
|
142
|
+
base = @retry_base
|
|
143
|
+
base = DEFAULT_RETRY_BASE if base <= 0
|
|
144
|
+
base = [base, MIN_RETRY_BASE].max
|
|
145
|
+
max_delay = base * (2**[attempt, 16].min)
|
|
146
|
+
sleep(rand * max_delay)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
STATUS_TEXT = {
|
|
151
|
+
400 => "Bad Request",
|
|
152
|
+
401 => "Unauthorized",
|
|
153
|
+
403 => "Forbidden",
|
|
154
|
+
404 => "Not Found",
|
|
155
|
+
409 => "Conflict",
|
|
156
|
+
500 => "Internal Server Error",
|
|
157
|
+
502 => "Bad Gateway",
|
|
158
|
+
503 => "Service Unavailable"
|
|
159
|
+
}.freeze
|
|
160
|
+
end
|
|
161
|
+
end
|
data/lib/pickpoint.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pickpoint/version"
|
|
4
|
+
require_relative "pickpoint/errors"
|
|
5
|
+
require_relative "pickpoint/config"
|
|
6
|
+
require_relative "pickpoint/http"
|
|
7
|
+
require_relative "pickpoint/auth"
|
|
8
|
+
require_relative "pickpoint/transport"
|
|
9
|
+
require_relative "pickpoint/geocoding"
|
|
10
|
+
require_relative "pickpoint/address"
|
|
11
|
+
require_relative "pickpoint/routing"
|
|
12
|
+
require_relative "pickpoint/devices"
|
|
13
|
+
require_relative "pickpoint/mint"
|
|
14
|
+
require_relative "pickpoint/client"
|
|
15
|
+
|
|
16
|
+
# Official Ruby SDK for the Pickpoint public HTTP API.
|
|
17
|
+
module Pickpoint
|
|
18
|
+
end
|
data/pickpoint.gemspec
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/pickpoint/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "pickpoint"
|
|
7
|
+
spec.version = Pickpoint::VERSION
|
|
8
|
+
spec.authors = ["Pickpoint"]
|
|
9
|
+
spec.email = ["hello@pickpoint.io"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Official Ruby SDK for Pickpoint — geocoding, address search, routing, devices"
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
Idiomatic Ruby client for the Pickpoint public HTTP API: geocoding, address
|
|
14
|
+
search, routing, device registry, and client-token minting. No realtime
|
|
15
|
+
tracking client in this gem.
|
|
16
|
+
DESC
|
|
17
|
+
spec.homepage = "https://github.com/pickpoint/ruby-sdk"
|
|
18
|
+
spec.license = "Apache-2.0"
|
|
19
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
20
|
+
|
|
21
|
+
spec.metadata["homepage_uri"] = "https://pickpoint.io"
|
|
22
|
+
spec.metadata["documentation_uri"] = "https://pickpoint.io/docs"
|
|
23
|
+
spec.metadata["source_code_uri"] = "https://github.com/pickpoint/ruby-sdk"
|
|
24
|
+
spec.metadata["bug_tracker_uri"] = "https://github.com/pickpoint/ruby-sdk/issues"
|
|
25
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
26
|
+
|
|
27
|
+
spec.files = Dir.chdir(__dir__) do
|
|
28
|
+
`git ls-files -z 2>/dev/null`.split("\x0").reject do |f|
|
|
29
|
+
f.start_with?("test/", ".github/", ".git") || f.end_with?(".gem")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
# Fallback when not in a git repo yet
|
|
33
|
+
if spec.files.empty?
|
|
34
|
+
spec.files = Dir["lib/**/*", "LICENSE", "README.md", "VERSION", "pickpoint.gemspec"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
spec.require_paths = ["lib"]
|
|
38
|
+
end
|