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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pickpoint
4
+ VERSION = File.read(File.expand_path("../../VERSION", __dir__)).strip
5
+ 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