pug-sh 0.0.1
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 +247 -0
- data/lib/pug/batcher.rb +384 -0
- data/lib/pug/client.rb +248 -0
- data/lib/pug/connect.rb +291 -0
- data/lib/pug/countries.rb +19 -0
- data/lib/pug/errors.rb +16 -0
- data/lib/pug/event_builder.rb +522 -0
- data/lib/pug/gen/buf/validate/validate_pb.rb +59 -0
- data/lib/pug/gen/common/events/v1/api_events_pb.rb +26 -0
- data/lib/pug/gen/common/events/v1/app_events_pb.rb +33 -0
- data/lib/pug/gen/common/events/v1/auth_events_pb.rb +32 -0
- data/lib/pug/gen/common/events/v1/billing_events_pb.rb +41 -0
- data/lib/pug/gen/common/events/v1/chat_events_pb.rb +56 -0
- data/lib/pug/gen/common/events/v1/commerce_events_pb.rb +38 -0
- data/lib/pug/gen/common/events/v1/discovery_events_pb.rb +31 -0
- data/lib/pug/gen/common/events/v1/error_events_pb.rb +26 -0
- data/lib/pug/gen/common/events/v1/file_events_pb.rb +29 -0
- data/lib/pug/gen/common/events/v1/form_events_pb.rb +27 -0
- data/lib/pug/gen/common/events/v1/integration_events_pb.rb +27 -0
- data/lib/pug/gen/common/events/v1/invitation_events_pb.rb +26 -0
- data/lib/pug/gen/common/events/v1/media_events_pb.rb +36 -0
- data/lib/pug/gen/common/events/v1/navigation_events_pb.rb +31 -0
- data/lib/pug/gen/common/events/v1/notification_events_pb.rb +28 -0
- data/lib/pug/gen/common/events/v1/options_pb.rb +25 -0
- data/lib/pug/gen/common/events/v1/social_events_pb.rb +25 -0
- data/lib/pug/gen/common/events/v1/support_events_pb.rb +33 -0
- data/lib/pug/gen/common/events/v1/workspace_events_pb.rb +30 -0
- data/lib/pug/gen/common/v1/filter_schema_pb.rb +29 -0
- data/lib/pug/gen/common/v1/filters_pb.rb +27 -0
- data/lib/pug/gen/common/v1/property_value_pb.rb +24 -0
- data/lib/pug/gen/common/v1/time_pb.rb +25 -0
- data/lib/pug/gen/sdk/devices/v1/devices_pb.rb +35 -0
- data/lib/pug/gen/sdk/events/v1/events_pb.rb +30 -0
- data/lib/pug/gen/sdk/profiles/v1/profiles_pb.rb +28 -0
- data/lib/pug/gen/shared/activity/v1/activity_pb.rb +46 -0
- data/lib/pug/gen/shared/insights/v1/insights_pb.rb +69 -0
- data/lib/pug/gen/shared/profiles/v1/profiles_pb.rb +43 -0
- data/lib/pug/log.rb +35 -0
- data/lib/pug/proto.rb +22 -0
- data/lib/pug/reads.rb +118 -0
- data/lib/pug/rpc.rb +73 -0
- data/lib/pug/validator.rb +475 -0
- data/lib/pug/version.rb +5 -0
- data/lib/pug/well_known.rb +33 -0
- data/lib/pug-sh.rb +3 -0
- data/lib/pug.rb +5 -0
- metadata +122 -0
data/lib/pug/client.rb
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "batcher"
|
|
7
|
+
require_relative "connect"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
require_relative "event_builder"
|
|
10
|
+
require_relative "log"
|
|
11
|
+
require_relative "reads"
|
|
12
|
+
require_relative "rpc"
|
|
13
|
+
require_relative "validator"
|
|
14
|
+
|
|
15
|
+
module Pug
|
|
16
|
+
class Client
|
|
17
|
+
DEFAULT_ENDPOINT = "https://api.pugs.dev"
|
|
18
|
+
DEFAULT_TIMEOUT = 10
|
|
19
|
+
RESERVED_PREFIX = "cookieless-"
|
|
20
|
+
TRACK_OPTIONS = %w[timestamp session_id location].freeze
|
|
21
|
+
IDENTIFY_OPTIONS = %w[anonymous_id device_id].freeze
|
|
22
|
+
|
|
23
|
+
class NotSerializable < StandardError; end
|
|
24
|
+
OMITTED = Object.new.freeze
|
|
25
|
+
private_constant :NotSerializable, :OMITTED
|
|
26
|
+
|
|
27
|
+
attr_reader :profiles, :activity, :insights
|
|
28
|
+
|
|
29
|
+
def initialize(api_key:, endpoint: nil, batch: nil, on_error: nil, logger: nil, timeout: nil)
|
|
30
|
+
unless api_key.is_a?(String) && !api_key.empty?
|
|
31
|
+
raise ArgumentError, "[Pug SDK] api_key is required and must be a non-empty String"
|
|
32
|
+
end
|
|
33
|
+
# A trailing newline from a secrets file would fail every request.
|
|
34
|
+
unless api_key.ascii_only? && api_key.match?(/\A[!-~]+\z/)
|
|
35
|
+
raise ArgumentError, "[Pug SDK] api_key must be printable ASCII without spaces (check for a trailing newline)"
|
|
36
|
+
end
|
|
37
|
+
unless api_key.start_with?("prv_")
|
|
38
|
+
raise ArgumentError, "[Pug SDK] api_key must be a private key (prv_…) for the server SDK"
|
|
39
|
+
end
|
|
40
|
+
raise ArgumentError, "[Pug SDK] on_error must be callable" unless on_error.nil? || on_error.respond_to?(:call)
|
|
41
|
+
unless on_error.nil? || takes_two?(on_error)
|
|
42
|
+
raise ArgumentError, "[Pug SDK] on_error must take two arguments, (error, events)"
|
|
43
|
+
end
|
|
44
|
+
raise ArgumentError, "[Pug SDK] batch must be a Hash" unless batch.nil? || batch.is_a?(Hash)
|
|
45
|
+
unless logger.nil? || %i[warn error].all? { |level| logger.respond_to?(level) }
|
|
46
|
+
raise ArgumentError, "[Pug SDK] logger must respond to warn and error, like a Logger (wrap an IO in Logger.new)"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
endpoint = endpoint.to_s
|
|
50
|
+
endpoint = DEFAULT_ENDPOINT if endpoint.empty?
|
|
51
|
+
unless http_url?(endpoint)
|
|
52
|
+
raise ArgumentError, "[Pug SDK] endpoint must be an absolute http(s) URL, got #{endpoint.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@log = Log.new(logger)
|
|
56
|
+
@connect = Connect.new(endpoint, api_key, timeout: resolve_timeout(timeout))
|
|
57
|
+
@rpc = RPC.new(@connect)
|
|
58
|
+
@events = EventBuilder.new(@log)
|
|
59
|
+
@batcher = Batcher.new(method(:send_batch), batch, on_error, @log)
|
|
60
|
+
@session_id = SecureRandom.uuid_v7
|
|
61
|
+
@closed = false
|
|
62
|
+
@profiles = Profiles.new(@rpc)
|
|
63
|
+
@activity = Activity.new(@rpc)
|
|
64
|
+
@insights = Insights.new(@rpc)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def track(distinct_id, kind, properties = OMITTED, options = nil, **keywords)
|
|
68
|
+
return @log.warn("track() called after close().") if @closed
|
|
69
|
+
|
|
70
|
+
distinct_id = id_string(distinct_id, "track", "distinct_id")
|
|
71
|
+
return if distinct_id.nil?
|
|
72
|
+
# The server rejects a whole batch over one such id, taking other users' events with it.
|
|
73
|
+
if distinct_id.start_with?(RESERVED_PREFIX)
|
|
74
|
+
return @log.error("track() distinct_id must not start with the reserved '#{RESERVED_PREFIX}' prefix.")
|
|
75
|
+
end
|
|
76
|
+
return @log.error("track() requires a non-empty event kind String.") unless kind?(kind)
|
|
77
|
+
|
|
78
|
+
properties, options = split_options(properties, options, keywords, TRACK_OPTIONS, "track")
|
|
79
|
+
return @log.error("track() properties must be a Hash.") unless properties.nil? || properties.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
session_id = options["session_id"]
|
|
82
|
+
return @log.error("track() session_id must be a String.") unless session_id.nil? || session_id.is_a?(String)
|
|
83
|
+
|
|
84
|
+
session_id = @session_id if session_id.nil? || session_id.empty?
|
|
85
|
+
event, bytesize = @events.build(kind, session_id, distinct_id, properties,
|
|
86
|
+
timestamp: options["timestamp"], location: options["location"])
|
|
87
|
+
@batcher.enqueue(event, bytesize) if event
|
|
88
|
+
nil
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
@log.error("Unexpected error in track(#{kind.inspect}): #{Log.describe(e)}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def identify(external_id, traits = OMITTED, options = nil, **keywords)
|
|
94
|
+
return @log.warn("identify() called after close().") if @closed
|
|
95
|
+
|
|
96
|
+
external_id = id_string(external_id, "identify", "external_id")
|
|
97
|
+
return if external_id.nil?
|
|
98
|
+
|
|
99
|
+
traits, options = split_options(traits, options, keywords, IDENTIFY_OPTIONS, "identify")
|
|
100
|
+
return @log.error("identify() traits must be a Hash.") unless traits.nil? || traits.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
request = identify_request(external_id, traits, options)
|
|
103
|
+
return if request.nil?
|
|
104
|
+
|
|
105
|
+
violations = Validator.validate(request)
|
|
106
|
+
return @log.error("Invalid identify request: #{Validator.format(violations)}") unless violations.empty?
|
|
107
|
+
|
|
108
|
+
@rpc.unary(:identify, request)
|
|
109
|
+
nil
|
|
110
|
+
rescue NotSerializable => e
|
|
111
|
+
@log.error("identify() traits must be JSON-serializable: #{e.message}")
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
@log.error("Failed to identify #{external_id.inspect}: #{Log.describe(e)}")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def flush(timeout: nil)
|
|
117
|
+
@batcher.flush(timeout: wait_timeout(timeout, "flush"))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def close(timeout: nil)
|
|
121
|
+
@closed = true
|
|
122
|
+
@batcher.close(timeout: wait_timeout(timeout, "close"))
|
|
123
|
+
ensure
|
|
124
|
+
@connect.close
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The default would print the API key and every buffered event.
|
|
128
|
+
def inspect = "#<#{self.class.name}>"
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def send_batch(events)
|
|
133
|
+
response = @rpc.unary(:batch_create, RPC::EventsV1::BatchCreateRequest.new(events: events))
|
|
134
|
+
# A refused event isn't an RPC error, so this is the only sign it was lost.
|
|
135
|
+
return unless response.dropped.positive?
|
|
136
|
+
|
|
137
|
+
@log.warn("Server dropped #{response.dropped} of #{events.size} event(s): #{response.dropped_by_reason.to_h}")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def identify_request(external_id, traits, options)
|
|
141
|
+
ids = IDENTIFY_OPTIONS.filter_map do |name|
|
|
142
|
+
value = options[name]
|
|
143
|
+
[name.to_sym, id_string(value, "identify", name, integers: false)] unless value.nil? || value == ""
|
|
144
|
+
end.to_h
|
|
145
|
+
return if ids.value?(nil)
|
|
146
|
+
|
|
147
|
+
request = { external_id: external_id, **ids }
|
|
148
|
+
request[:traits] = to_struct(traits) unless traits.nil?
|
|
149
|
+
RPC::SdkProfilesV1::IdentifyRequest.new(request)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def resolve_timeout(timeout)
|
|
153
|
+
return DEFAULT_TIMEOUT if timeout.nil?
|
|
154
|
+
return timeout if Connect.timeout?(timeout)
|
|
155
|
+
|
|
156
|
+
@log.warn("timeout must be a positive number of seconds; using the default #{DEFAULT_TIMEOUT}.")
|
|
157
|
+
DEFAULT_TIMEOUT
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# A timeout that isn't a number waits without one, as close must still drain; one already
|
|
161
|
+
# past doesn't wait at all.
|
|
162
|
+
def wait_timeout(timeout, method)
|
|
163
|
+
return if timeout.nil? || timeout == Float::INFINITY
|
|
164
|
+
return [timeout.to_f, 0].max if timeout.is_a?(Numeric) && timeout.real? && timeout.finite?
|
|
165
|
+
|
|
166
|
+
@log.warn("#{method}() timeout must be a number of seconds; waiting without one.")
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Lambdas and methods check their arity, so one taking fewer arguments would fail every call.
|
|
171
|
+
def takes_two?(callable)
|
|
172
|
+
callable = callable.method(:call) unless callable.is_a?(Proc) || callable.is_a?(Method)
|
|
173
|
+
return true if callable.is_a?(Proc) && !callable.lambda?
|
|
174
|
+
|
|
175
|
+
arity = callable.arity
|
|
176
|
+
arity.negative? ? -arity - 1 <= 2 : arity == 2
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def http_url?(endpoint)
|
|
180
|
+
uri = URI.parse(endpoint)
|
|
181
|
+
uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
182
|
+
rescue URI::InvalidURIError
|
|
183
|
+
false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# An Integer passes as an id too, since a Rails record's id is one. Unlike property values,
|
|
187
|
+
# ids aren't repaired: a mangled id would be a different person.
|
|
188
|
+
def id_string(value, method, name, integers: true)
|
|
189
|
+
value = value.to_s if integers && value.is_a?(Integer)
|
|
190
|
+
unless value.is_a?(String) && !value.empty?
|
|
191
|
+
return @log.error("#{method}() requires a non-empty #{name} String#{" or Integer" if integers}.")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
utf8 = if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(value.encoding)
|
|
195
|
+
value.dup.force_encoding(Encoding::UTF_8)
|
|
196
|
+
else
|
|
197
|
+
value.encode(Encoding::UTF_8)
|
|
198
|
+
end
|
|
199
|
+
return utf8 if utf8.valid_encoding?
|
|
200
|
+
|
|
201
|
+
@log.error("#{method}() #{name} must be valid UTF-8.")
|
|
202
|
+
rescue EncodingError
|
|
203
|
+
@log.error("#{method}() #{name} must be valid UTF-8.")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def kind?(kind)
|
|
207
|
+
(kind.is_a?(String) || kind.is_a?(Symbol)) && !kind.empty?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Braceless keywords arrive as one Hash, so track(id, kind, plan: "pro") and
|
|
211
|
+
# track(id, kind, timestamp: t) look alike. A keyword named after an option sets that option;
|
|
212
|
+
# any other is data, properties or traits, unless the data came first in an argument of its own.
|
|
213
|
+
def split_options(data, options, keywords, known, method)
|
|
214
|
+
named = keywords
|
|
215
|
+
if data.equal?(OMITTED)
|
|
216
|
+
named, data = keywords.partition { |key, _| known.include?(key.to_s) }.map(&:to_h)
|
|
217
|
+
data = nil if data.empty?
|
|
218
|
+
end
|
|
219
|
+
[data, options_hash(options, known, method).merge(options_hash(named, known, method))]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def options_hash(options, known, method)
|
|
223
|
+
return {} if options.nil?
|
|
224
|
+
|
|
225
|
+
unless options.is_a?(Hash)
|
|
226
|
+
@log.warn("Ignoring #{method}() options: expected a Hash, got #{options.class}")
|
|
227
|
+
return {}
|
|
228
|
+
end
|
|
229
|
+
options.each_with_object({}) do |(key, value), out|
|
|
230
|
+
if known.include?(key.to_s)
|
|
231
|
+
out[key.to_s] = value
|
|
232
|
+
else
|
|
233
|
+
@log.warn("Ignoring unknown #{method}() option #{key.inspect}")
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# A JSON round trip accepts what JSON does (symbol keys, nested arrays) and refuses what a
|
|
239
|
+
# Struct can't carry, such as NaN. A Struct's numbers are doubles, so an integer past 2^53
|
|
240
|
+
# would arrive changed.
|
|
241
|
+
def to_struct(traits)
|
|
242
|
+
ready = EventBuilder.json_ready(traits, max_integer: EventBuilder::MAX_SAFE_FLOAT)
|
|
243
|
+
::Google::Protobuf::Struct.from_hash(JSON.parse(JSON.generate(ready)))
|
|
244
|
+
rescue StandardError => e
|
|
245
|
+
raise NotSerializable, e.message
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
data/lib/pug/connect.rb
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "zlib"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
require_relative "version"
|
|
10
|
+
|
|
11
|
+
module Pug
|
|
12
|
+
# A Connect protocol client (https://connectrpc.com/docs/protocol) for the two call shapes the
|
|
13
|
+
# SDK uses: unary, and server streaming for profiles.list. Binary protobuf over HTTP/1.1, like
|
|
14
|
+
# sdk-node. Every failure surfaces as a Pug::Error carrying a Connect code.
|
|
15
|
+
class Connect
|
|
16
|
+
USER_AGENT = "pug-ruby/#{VERSION}".freeze
|
|
17
|
+
UNARY_CONTENT_TYPE = "application/proto"
|
|
18
|
+
STREAM_CONTENT_TYPE = "application/connect+proto"
|
|
19
|
+
CONTENT_TYPE = %r{\Aapplication/(?<stream>connect\+)?(?:(?<json>json)(?:; ?charset=utf-?8)?|(?<proto>proto))\z}i
|
|
20
|
+
END_STREAM = 0b10
|
|
21
|
+
COMPRESSED = 0b01
|
|
22
|
+
|
|
23
|
+
# Carries an exception raised by the caller's block past the transport's rescue clauses, which
|
|
24
|
+
# would otherwise mistake, say, the caller's IOError for a network failure. Whoever passed
|
|
25
|
+
# the block re-raises the original.
|
|
26
|
+
class Passthrough < Exception # rubocop:disable Lint/InheritException
|
|
27
|
+
attr_reader :error
|
|
28
|
+
|
|
29
|
+
def initialize(error)
|
|
30
|
+
@error = error
|
|
31
|
+
super(error.message)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
CODES = %i[canceled unknown invalid_argument deadline_exceeded not_found already_exists permission_denied
|
|
36
|
+
resource_exhausted failed_precondition aborted out_of_range unimplemented internal unavailable
|
|
37
|
+
data_loss unauthenticated].to_h { |code| [code.to_s, code] }.freeze
|
|
38
|
+
|
|
39
|
+
# https://connectrpc.com/docs/protocol/#http-to-error-code: 400 is internal and 404
|
|
40
|
+
# unimplemented on purpose.
|
|
41
|
+
HTTP_CODES = { 400 => :internal, 401 => :unauthenticated, 403 => :permission_denied, 404 => :unimplemented,
|
|
42
|
+
429 => :unavailable, 502 => :unavailable, 503 => :unavailable, 504 => :unavailable }.freeze
|
|
43
|
+
|
|
44
|
+
def self.timeout?(value)
|
|
45
|
+
value.is_a?(Numeric) && value.real? && value.positive? && value.finite?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(endpoint, api_key, timeout:)
|
|
49
|
+
@uri = URI(endpoint)
|
|
50
|
+
@base_path = @uri.path.to_s.chomp("/")
|
|
51
|
+
@api_key = api_key
|
|
52
|
+
@timeout = timeout
|
|
53
|
+
@pool = Pool.new(@uri)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def unary(procedure, request, response_class)
|
|
57
|
+
response = call(procedure, request.class.encode(request), UNARY_CONTENT_TYPE, @timeout) do |http, post|
|
|
58
|
+
http.request(post)
|
|
59
|
+
end
|
|
60
|
+
raise unary_error(response) unless response.code == "200"
|
|
61
|
+
|
|
62
|
+
check_content_type(response, stream: false)
|
|
63
|
+
decode(response_class, response.body.to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The deadline covers the whole stream, including the time the caller's block takes.
|
|
67
|
+
def server_stream(procedure, request, response_class, timeout: nil)
|
|
68
|
+
timeout ||= @timeout
|
|
69
|
+
body = envelope(0, request.class.encode(request))
|
|
70
|
+
deadline = monotonic + timeout
|
|
71
|
+
call(procedure, body, STREAM_CONTENT_TYPE, timeout) do |http, post|
|
|
72
|
+
http.request(post) do |response|
|
|
73
|
+
raise http_error(response) unless response.code == "200"
|
|
74
|
+
|
|
75
|
+
check_content_type(response, stream: true)
|
|
76
|
+
check_stream_encoding(response)
|
|
77
|
+
read_stream(http, response, response_class, deadline) do |message|
|
|
78
|
+
yield message
|
|
79
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
80
|
+
raise Passthrough, e
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def close
|
|
88
|
+
@pool.close
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The default would print the API key.
|
|
92
|
+
def inspect = "#<#{self.class.name}>"
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def call(procedure, body, content_type, timeout)
|
|
97
|
+
post = Net::HTTP::Post.new("#{@base_path}/#{procedure}")
|
|
98
|
+
post["Content-Type"] = content_type
|
|
99
|
+
post["Connect-Protocol-Version"] = "1"
|
|
100
|
+
post["Connect-Timeout-Ms"] = (timeout * 1000).ceil.to_s
|
|
101
|
+
post["User-Agent"] = USER_AGENT
|
|
102
|
+
post["X-Api-Key"] = @api_key
|
|
103
|
+
post.body = body
|
|
104
|
+
@pool.with(timeout) { |http| yield http, post }
|
|
105
|
+
rescue Timeout::Error => e
|
|
106
|
+
raise Error.new("request timed out: #{e.message}", code: :deadline_exceeded)
|
|
107
|
+
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
108
|
+
raise Error.new("#{e.class}: #{e.message}", code: :unavailable)
|
|
109
|
+
rescue Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error => e
|
|
110
|
+
raise Error.new("#{e.class}: #{e.message}", code: :internal)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def read_stream(http, response, response_class, deadline)
|
|
114
|
+
buffer = "".b
|
|
115
|
+
ended = false
|
|
116
|
+
response.read_body do |chunk|
|
|
117
|
+
buffer << chunk
|
|
118
|
+
while buffer.bytesize >= 5
|
|
119
|
+
flags, length = buffer.unpack("CN")
|
|
120
|
+
break if buffer.bytesize < 5 + length
|
|
121
|
+
|
|
122
|
+
data = buffer.byteslice(5, length)
|
|
123
|
+
buffer = buffer.byteslice((5 + length)..)
|
|
124
|
+
if ended
|
|
125
|
+
raise Error.new("protocol error: received extra message after EndStreamResponse", code: :invalid_argument)
|
|
126
|
+
end
|
|
127
|
+
if flags.anybits?(COMPRESSED)
|
|
128
|
+
raise Error.new("protocol error: received a compressed message it did not ask for", code: :internal)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
if flags.anybits?(END_STREAM)
|
|
132
|
+
end_stream(data)
|
|
133
|
+
ended = true
|
|
134
|
+
else
|
|
135
|
+
yield decode(response_class, data)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
next if ended
|
|
139
|
+
|
|
140
|
+
remaining = deadline - monotonic
|
|
141
|
+
raise Error.new("request timed out", code: :deadline_exceeded) unless remaining.positive?
|
|
142
|
+
|
|
143
|
+
http.read_timeout = remaining
|
|
144
|
+
end
|
|
145
|
+
raise Error.new("protocol error: premature end of stream", code: :data_loss) unless buffer.empty?
|
|
146
|
+
raise Error.new("protocol error: missing EndStreamResponse", code: :invalid_argument) unless ended
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def end_stream(data)
|
|
150
|
+
invalid = Error.new("invalid end stream", code: :unknown)
|
|
151
|
+
parsed = parse_json(data)
|
|
152
|
+
raise invalid unless parsed.is_a?(Hash)
|
|
153
|
+
raise wire_error(parsed["error"], invalid) unless parsed["error"].nil?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def unary_error(response)
|
|
157
|
+
fallback = http_error(response)
|
|
158
|
+
match = CONTENT_TYPE.match(response["Content-Type"].to_s)
|
|
159
|
+
return fallback unless match && match[:json]
|
|
160
|
+
|
|
161
|
+
wire_error(parse_json(response.body.to_s), fallback)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def wire_error(json, fallback)
|
|
165
|
+
return fallback unless json.is_a?(Hash)
|
|
166
|
+
|
|
167
|
+
message = json["message"]
|
|
168
|
+
return fallback unless message.nil? || message.is_a?(String)
|
|
169
|
+
|
|
170
|
+
Error.new(message.to_s, code: CODES.fetch(json["code"].to_s, fallback.code))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def parse_json(data)
|
|
174
|
+
JSON.parse(data.dup.force_encoding(Encoding::UTF_8))
|
|
175
|
+
rescue JSON::ParserError, EncodingError
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def check_content_type(response, stream:)
|
|
180
|
+
type = response["Content-Type"].to_s
|
|
181
|
+
match = CONTENT_TYPE.match(type)
|
|
182
|
+
return if match && match[:proto] && !match[:stream] == !stream
|
|
183
|
+
|
|
184
|
+
raise Error.new("unsupported content type #{type}", code: match ? :internal : :unknown)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def check_stream_encoding(response)
|
|
188
|
+
encoding = response["Connect-Content-Encoding"]
|
|
189
|
+
return if encoding.nil? || encoding.casecmp?("identity")
|
|
190
|
+
|
|
191
|
+
raise Error.new("unsupported response encoding \"#{encoding}\"", code: :internal)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def decode(response_class, data)
|
|
195
|
+
response_class.decode(data)
|
|
196
|
+
rescue ::Google::Protobuf::ParseError => e
|
|
197
|
+
raise Error.new("failed to decode #{response_class.descriptor.name}: #{e.message}", code: :internal)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def envelope(flags, data)
|
|
201
|
+
[flags, data.bytesize].pack("CN") + data
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Net::HTTP doesn't follow redirects, so one means the endpoint is misconfigured, as a 404
|
|
205
|
+
# does: unimplemented, which nothing retries. A proxy may have answered instead of a Connect
|
|
206
|
+
# server, so the status gets the redirect target or the start of the body.
|
|
207
|
+
def http_error(response)
|
|
208
|
+
status = response.code.to_i
|
|
209
|
+
redirect = (300..399).cover?(status)
|
|
210
|
+
detail = redirect ? response["Location"]&.then { |url| "redirected to #{url}" } : snippet(response.body)
|
|
211
|
+
code = redirect ? :unimplemented : HTTP_CODES.fetch(status, :unknown)
|
|
212
|
+
Error.new(["HTTP #{status}", detail].compact.join(": "), code: code)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def snippet(body)
|
|
216
|
+
text = body.to_s.byteslice(0, 512).force_encoding(Encoding::UTF_8).scrub("").gsub(/\s+/, " ").strip
|
|
217
|
+
text[0, 120] unless text.empty?
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def monotonic
|
|
221
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Keep-alive connections, one per concurrent caller. Net::HTTP sessions aren't thread-safe,
|
|
225
|
+
# so each is checked out by one thread at a time.
|
|
226
|
+
class Pool
|
|
227
|
+
MAX_IDLE = 4
|
|
228
|
+
|
|
229
|
+
def initialize(uri)
|
|
230
|
+
@uri = uri
|
|
231
|
+
@idle = []
|
|
232
|
+
@lock = Mutex.new
|
|
233
|
+
@pid = Process.pid
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def with(timeout)
|
|
237
|
+
http = checkout
|
|
238
|
+
http.open_timeout = timeout
|
|
239
|
+
http.read_timeout = timeout
|
|
240
|
+
http.write_timeout = timeout
|
|
241
|
+
http.start unless http.started?
|
|
242
|
+
result = yield http
|
|
243
|
+
checkin(http)
|
|
244
|
+
http = nil
|
|
245
|
+
result
|
|
246
|
+
ensure
|
|
247
|
+
discard(http) if http
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def close
|
|
251
|
+
connections = @lock.synchronize do
|
|
252
|
+
forked
|
|
253
|
+
@idle.slice!(0..)
|
|
254
|
+
end
|
|
255
|
+
connections.each { |http| discard(http) }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private
|
|
259
|
+
|
|
260
|
+
def checkout
|
|
261
|
+
@lock.synchronize do
|
|
262
|
+
forked
|
|
263
|
+
@idle.pop
|
|
264
|
+
end || Net::HTTP.new(@uri.hostname, @uri.port).tap { |http| http.use_ssl = @uri.scheme == "https" }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def checkin(http)
|
|
268
|
+
keep = @lock.synchronize do
|
|
269
|
+
forked
|
|
270
|
+
@idle.size < MAX_IDLE && @idle.push(http)
|
|
271
|
+
end
|
|
272
|
+
discard(http) unless keep
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# A forked child must not reuse, or even close, its parent's sockets: a TLS close_notify
|
|
276
|
+
# would be written into the parent's live connection. They are dropped instead.
|
|
277
|
+
def forked
|
|
278
|
+
return if @pid == Process.pid
|
|
279
|
+
|
|
280
|
+
@pid = Process.pid
|
|
281
|
+
@idle = []
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def discard(http)
|
|
285
|
+
http.finish if http.started?
|
|
286
|
+
rescue StandardError
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pug
|
|
4
|
+
# Mirrors internal/geo/countries.go in the pug server. The server stores $country unvalidated
|
|
5
|
+
# and only the choropleth filters it, so a bad code becomes a permanent rollup dimension that
|
|
6
|
+
# is simply missing from the map. "XX", Cloudflare's unknown, passes a two-letter check.
|
|
7
|
+
COUNTRY_CODES = %w[
|
|
8
|
+
AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ
|
|
9
|
+
BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR
|
|
10
|
+
CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
|
|
11
|
+
GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU
|
|
12
|
+
ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ
|
|
13
|
+
LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ
|
|
14
|
+
MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF
|
|
15
|
+
PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI
|
|
16
|
+
SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR
|
|
17
|
+
TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS XK YE YT ZA ZM ZW
|
|
18
|
+
].to_set.freeze
|
|
19
|
+
end
|
data/lib/pug/errors.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pug
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :code
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, code: nil)
|
|
8
|
+
@code = code
|
|
9
|
+
super(code ? [code, message].reject { |part| part.to_s.empty? }.join(": ") : message)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class ClosedError < Error; end
|
|
14
|
+
|
|
15
|
+
class QueueOverflowError < Error; end
|
|
16
|
+
end
|