tesla 0.0.1 → 0.1.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/.yardopts +5 -0
- data/CHANGELOG.md +31 -0
- data/CONTRIBUTING.md +45 -0
- data/{LICENSE.txt → LICENSE.md} +1 -1
- data/README.md +366 -0
- data/SECURITY.md +38 -0
- data/lib/tesla/api/alert_endpoints.rb +35 -0
- data/lib/tesla/api/charging_endpoints.rb +115 -0
- data/lib/tesla/api/climate_endpoints.rb +180 -0
- data/lib/tesla/api/closure_endpoints.rb +142 -0
- data/lib/tesla/api/command_endpoints.rb +63 -0
- data/lib/tesla/api/media_endpoints.rb +114 -0
- data/lib/tesla/api/navigation_endpoints.rb +51 -0
- data/lib/tesla/api/oauth_endpoints.rb +118 -0
- data/lib/tesla/api/partner_endpoints.rb +62 -0
- data/lib/tesla/api/security_endpoints.rb +123 -0
- data/lib/tesla/api/software_endpoints.rb +38 -0
- data/lib/tesla/api/user_endpoints.rb +40 -0
- data/lib/tesla/api/vehicle_endpoints.rb +174 -0
- data/lib/tesla/api.rb +39 -0
- data/lib/tesla/authenticator.rb +33 -0
- data/lib/tesla/bearer_token_authenticator.rb +38 -0
- data/lib/tesla/certificate_options.rb +51 -0
- data/lib/tesla/charge_state.rb +111 -0
- data/lib/tesla/charging_site.rb +67 -0
- data/lib/tesla/client.rb +336 -0
- data/lib/tesla/client_credentials.rb +230 -0
- data/lib/tesla/climate_state.rb +116 -0
- data/lib/tesla/configuration.rb +483 -0
- data/lib/tesla/connection.rb +361 -0
- data/lib/tesla/connection_pool.rb +186 -0
- data/lib/tesla/deep_copy.rb +41 -0
- data/lib/tesla/drive_state.rb +80 -0
- data/lib/tesla/errors/bad_gateway.rb +9 -0
- data/lib/tesla/errors/bad_request.rb +9 -0
- data/lib/tesla/errors/client_error.rb +9 -0
- data/lib/tesla/errors/command_failed.rb +42 -0
- data/lib/tesla/errors/device_unexpected_response.rb +12 -0
- data/lib/tesla/errors/error.rb +7 -0
- data/lib/tesla/errors/forbidden.rb +14 -0
- data/lib/tesla/errors/gateway_timeout.rb +9 -0
- data/lib/tesla/errors/http_error.rb +89 -0
- data/lib/tesla/errors/internal_server_error.rb +9 -0
- data/lib/tesla/errors/invalid_response.rb +33 -0
- data/lib/tesla/errors/locked.rb +12 -0
- data/lib/tesla/errors/method_not_allowed.rb +9 -0
- data/lib/tesla/errors/misdirected_request.rb +13 -0
- data/lib/tesla/errors/network_error.rb +30 -0
- data/lib/tesla/errors/not_acceptable.rb +9 -0
- data/lib/tesla/errors/not_found.rb +9 -0
- data/lib/tesla/errors/oauth_error.rb +53 -0
- data/lib/tesla/errors/payment_required.rb +12 -0
- data/lib/tesla/errors/precondition_failed.rb +13 -0
- data/lib/tesla/errors/request_timeout.rb +13 -0
- data/lib/tesla/errors/server_error.rb +9 -0
- data/lib/tesla/errors/service_unavailable.rb +9 -0
- data/lib/tesla/errors/too_many_redirects.rb +9 -0
- data/lib/tesla/errors/too_many_requests.rb +9 -0
- data/lib/tesla/errors/unauthorized.rb +9 -0
- data/lib/tesla/errors/unavailable_for_legal_reasons.rb +12 -0
- data/lib/tesla/errors/unprocessable_content.rb +12 -0
- data/lib/tesla/errors/vehicle_asleep.rb +32 -0
- data/lib/tesla/gui_settings.rb +60 -0
- data/lib/tesla/idempotence.rb +30 -0
- data/lib/tesla/identifiers.rb +27 -0
- data/lib/tesla/json_parsing.rb +38 -0
- data/lib/tesla/nearby_charging_sites.rb +35 -0
- data/lib/tesla/path_escaping.rb +30 -0
- data/lib/tesla/redacted_output.rb +161 -0
- data/lib/tesla/redirect_handler.rb +196 -0
- data/lib/tesla/region.rb +27 -0
- data/lib/tesla/request_builder.rb +138 -0
- data/lib/tesla/resource.rb +403 -0
- data/lib/tesla/response_parser.rb +92 -0
- data/lib/tesla/retry_after.rb +34 -0
- data/lib/tesla/retry_handler.rb +252 -0
- data/lib/tesla/settings.rb +115 -0
- data/lib/tesla/url_validation.rb +131 -0
- data/lib/tesla/user.rb +36 -0
- data/lib/tesla/vehicle.rb +90 -0
- data/lib/tesla/vehicle_config.rb +95 -0
- data/lib/tesla/vehicle_data.rb +67 -0
- data/lib/tesla/vehicle_state.rb +125 -0
- data/lib/tesla/version.rb +6 -0
- data/lib/tesla.rb +139 -0
- data/sig/manifest.yaml +10 -0
- data/sig/tesla.rbs +989 -0
- metadata +134 -118
- data/.document +0 -5
- data/.rvmrc +0 -1
- data/Gemfile +0 -13
- data/Gemfile.lock +0 -20
- data/README.rdoc +0 -19
- data/Rakefile +0 -47
- data/test/helper.rb +0 -19
- data/test/test_tesla.rb +0 -7
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "errors/invalid_response"
|
|
5
|
+
|
|
6
|
+
module Tesla
|
|
7
|
+
# Parses the JSON bodies of API responses, mixed into the API endpoints
|
|
8
|
+
# @api private
|
|
9
|
+
module JSONParsing
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Errors raised when the parsed JSON is not the expected shape: a key that is fetched is missing, or a value is
|
|
13
|
+
# not of the expected type
|
|
14
|
+
SHAPE_ERRORS = [KeyError, NoMethodError, TypeError].freeze
|
|
15
|
+
private_constant :SHAPE_ERRORS
|
|
16
|
+
|
|
17
|
+
# Parse a JSON response body, and read what the Fleet API answered with from it
|
|
18
|
+
#
|
|
19
|
+
# The Fleet API answers with a JSON object that carries what was asked for as its `response`, which is what is
|
|
20
|
+
# read from the body. The block reads the fields the caller needs from that, and an error it raises because the
|
|
21
|
+
# JSON is not the expected shape is reported as an invalid response with the body, rather than as a KeyError, a
|
|
22
|
+
# NoMethodError, or a TypeError, as a body without a `response` is.
|
|
23
|
+
#
|
|
24
|
+
# @api private
|
|
25
|
+
# @param body [String] the response body
|
|
26
|
+
# @yield [response] the `response` of the parsed JSON, to read the fields the caller needs
|
|
27
|
+
# @return [Object] the `response` of the parsed JSON, or what the block returns
|
|
28
|
+
# @raise [InvalidResponse] if the body is not JSON, or the JSON is not the expected shape
|
|
29
|
+
def parse_response(body)
|
|
30
|
+
response = JSON.parse(body).fetch("response")
|
|
31
|
+
block_given? ? yield(response) : response
|
|
32
|
+
rescue JSON::ParserError
|
|
33
|
+
raise InvalidResponse.new(body:)
|
|
34
|
+
rescue *SHAPE_ERRORS => e
|
|
35
|
+
raise InvalidResponse.new(body:, message: "The response body is not the expected JSON: #{e}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "charging_site"
|
|
4
|
+
require_relative "resource"
|
|
5
|
+
|
|
6
|
+
module Tesla
|
|
7
|
+
# The charging sites near a vehicle
|
|
8
|
+
# @api public
|
|
9
|
+
class NearbyChargingSites < Resource
|
|
10
|
+
# @!method superchargers
|
|
11
|
+
# The Superchargers near the vehicle, nearest first
|
|
12
|
+
# @api public
|
|
13
|
+
# @return [Array<ChargingSite>] the Superchargers
|
|
14
|
+
# @example
|
|
15
|
+
# nearby_charging_sites.superchargers.first.available_stalls
|
|
16
|
+
resource_list_attribute :superchargers, ChargingSite
|
|
17
|
+
|
|
18
|
+
# @!method destination_charging
|
|
19
|
+
# The destination chargers near the vehicle, nearest first
|
|
20
|
+
# @api public
|
|
21
|
+
# @return [Array<ChargingSite>] the destination chargers
|
|
22
|
+
# @example
|
|
23
|
+
# nearby_charging_sites.destination_charging.first.name
|
|
24
|
+
resource_list_attribute :destination_charging, ChargingSite
|
|
25
|
+
|
|
26
|
+
# @!method timestamp
|
|
27
|
+
# The time the vehicle reported the sites
|
|
28
|
+
# @api public
|
|
29
|
+
# @return [Time, nil] the time the vehicle reported the sites
|
|
30
|
+
# @raise [InvalidResponse] if the timestamp is not a number of milliseconds
|
|
31
|
+
# @example
|
|
32
|
+
# nearby_charging_sites.timestamp
|
|
33
|
+
time_attribute :timestamp
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi/escape"
|
|
4
|
+
|
|
5
|
+
module Tesla
|
|
6
|
+
# Escapes the values interpolated into request paths, mixed into the API endpoints
|
|
7
|
+
#
|
|
8
|
+
# A vehicle tag or the name of a command is given by the caller, and may come from somewhere the caller does not
|
|
9
|
+
# control, so it is escaped rather than interpolated into a path as it is. Without escaping, a value holding a
|
|
10
|
+
# slash could walk out of the endpoint it was meant for, one holding a question mark or a number sign could add a
|
|
11
|
+
# query string or truncate the path, and one holding a space or a character outside ASCII would raise
|
|
12
|
+
# URI::InvalidURIError when the request was built.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
module PathEscaping
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Escape a value interpolated into a request path
|
|
19
|
+
#
|
|
20
|
+
# CGI.escapeURIComponent percent-encodes everything but the unreserved characters of RFC 3986, which are the
|
|
21
|
+
# characters a path segment may hold as they are.
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
# @param value [Object] the value, which is read as a String
|
|
25
|
+
# @return [String] the value, with everything but the unreserved characters percent-encoded
|
|
26
|
+
def escape(value)
|
|
27
|
+
CGI.escapeURIComponent(value.to_s)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tesla
|
|
4
|
+
# Wraps the IO that receives HTTP debug output, redacting the credentials it would otherwise write in the clear
|
|
5
|
+
#
|
|
6
|
+
# Net::HTTP writes every request it sends and every response it reads to the debug output, headers and body alike,
|
|
7
|
+
# which carry the access token, the refresh token and the client secret of a request to the token endpoint, the
|
|
8
|
+
# tokens that endpoint answers with, the PIN of a command, and the credentials of a proxy.
|
|
9
|
+
# Those values are replaced before they reach the IO, so that debug output can be kept where the credentials
|
|
10
|
+
# should not be.
|
|
11
|
+
#
|
|
12
|
+
# Net::HTTP writes a response body as it reads it, a line for each read from the socket, so a credential the body
|
|
13
|
+
# carries can be split across two of those lines, where neither half matches the pattern that redacts it. A body
|
|
14
|
+
# sent in chunks is read a chunk at a time, each between the line that says how many bytes it is reading and the
|
|
15
|
+
# line that says how many it read, with the size of the next chunk and the line break that ends each one read in
|
|
16
|
+
# between, so a credential can be split across two chunks as well. The lines of a body are therefore held until
|
|
17
|
+
# the response is done, which Net::HTTP writes a line of its own for, and the parts of the body are joined into
|
|
18
|
+
# one line, written where its first part was read, before they are redacted; the lines around them are written as
|
|
19
|
+
# they were. A connection with a debug output asks for its responses uncompressed (see {Connection}), since a
|
|
20
|
+
# compressed body is written as the bytes it was sent as, which no pattern can find a credential in.
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
class RedactedOutput
|
|
24
|
+
# The value written in place of a credential
|
|
25
|
+
REDACTION = "[REDACTED]"
|
|
26
|
+
# The patterns of the credentials the library sends and receives, each capturing what introduces the value it
|
|
27
|
+
# redacts
|
|
28
|
+
#
|
|
29
|
+
# Net::HTTP dumps a request as one escaped string, in which a header is preceded by an escaped newline and its
|
|
30
|
+
# value runs to the next one. It dumps bodies the same way, so the patterns also cover the credentials a body
|
|
31
|
+
# carries: the code, the PKCE verifier, the refresh token, and the client secret a request to the token endpoint
|
|
32
|
+
# sends as form fields, the tokens its response carries as JSON, and the PIN or password a command sends as
|
|
33
|
+
# JSON.
|
|
34
|
+
#
|
|
35
|
+
# The credentials of a proxy are carried by the `Proxy-Authorization` header Net::HTTP sends them as, which is
|
|
36
|
+
# among the headers of a request sent through an `http://` proxy and is written again in the CONNECT request
|
|
37
|
+
# that opens a TLS connection through one. That CONNECT is dumped as the buffer it is sent as, with newlines of
|
|
38
|
+
# its own rather than escaped ones, so it is matched by a pattern of its own.
|
|
39
|
+
CREDENTIALS = [
|
|
40
|
+
/(\\n(?:Proxy-)?Authorization: )[^\\]*/i,
|
|
41
|
+
/(\r\n(?:Proxy-)?Authorization: )[^\r\n]*/i,
|
|
42
|
+
/(\b(?:client_secret|refresh_token|code|code_verifier)=)[^&\\"]*/,
|
|
43
|
+
/(\\"(?:access_token|refresh_token|id_token|pin|password)\\":\\")[^\\]*/
|
|
44
|
+
].freeze
|
|
45
|
+
private_constant :CREDENTIALS
|
|
46
|
+
|
|
47
|
+
# What Net::HTTP writes before the bytes it read from the socket, which it writes as an escaped string
|
|
48
|
+
READ = '-> "'
|
|
49
|
+
# What Net::HTTP writes before it reads a body, or a chunk of one, whether it reads a number of bytes or all
|
|
50
|
+
READING = "reading "
|
|
51
|
+
# What Net::HTTP writes once it has read a body, or a chunk of one
|
|
52
|
+
DONE = "read "
|
|
53
|
+
# The line break Net::HTTP reads after each chunk of a body sent in chunks, which ends the chunk rather than
|
|
54
|
+
# carrying a part of the body, as it is written escaped
|
|
55
|
+
CHUNK_END = '\r\n'
|
|
56
|
+
private_constant :READ, :READING, :DONE, :CHUNK_END
|
|
57
|
+
|
|
58
|
+
# The IO the redacted output is written to
|
|
59
|
+
# @api private
|
|
60
|
+
# @return [IO] the IO
|
|
61
|
+
# @example Get the IO
|
|
62
|
+
# output.output
|
|
63
|
+
attr_reader :output
|
|
64
|
+
|
|
65
|
+
# Initialize a new RedactedOutput
|
|
66
|
+
#
|
|
67
|
+
# @api private
|
|
68
|
+
# @param output [IO] the IO to write the redacted output to
|
|
69
|
+
# @return [RedactedOutput] a new instance
|
|
70
|
+
# @example Wrap an IO
|
|
71
|
+
# output = Tesla::RedactedOutput.new($stderr)
|
|
72
|
+
def initialize(output)
|
|
73
|
+
@output = output
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Write debug output, with credentials redacted
|
|
77
|
+
#
|
|
78
|
+
# The lines of a response body are held until the response is done, and written, with the parts of the body
|
|
79
|
+
# joined, before whatever is written next.
|
|
80
|
+
#
|
|
81
|
+
# @api private
|
|
82
|
+
# @param string [String] the debug output
|
|
83
|
+
# @return [RedactedOutput] self, so that writes can be chained as they can to an IO
|
|
84
|
+
# @example Write debug output
|
|
85
|
+
# output << '<- "GET / HTTP/1.1\r\nAuthorization: key\r\n\r\n"'
|
|
86
|
+
def <<(string)
|
|
87
|
+
if string.start_with?(READING)
|
|
88
|
+
lines = @lines ||= [] #: Array[String]
|
|
89
|
+
lines << string
|
|
90
|
+
@reading = true
|
|
91
|
+
elsif (lines = @lines)
|
|
92
|
+
hold(lines, string)
|
|
93
|
+
else
|
|
94
|
+
write(string)
|
|
95
|
+
end
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Hold a line of a body being read, or write the body once the response is done
|
|
102
|
+
#
|
|
103
|
+
# A line that is not a part of the body, nor one Net::HTTP writes between the parts of a body sent in chunks, says
|
|
104
|
+
# the response is done.
|
|
105
|
+
#
|
|
106
|
+
# @api private
|
|
107
|
+
# @param lines [Array<String>] the lines of the body held so far
|
|
108
|
+
# @param string [String] the debug output
|
|
109
|
+
# @return [void]
|
|
110
|
+
def hold(lines, string)
|
|
111
|
+
if @reading && string.start_with?(READ)
|
|
112
|
+
read(lines, string)
|
|
113
|
+
elsif string.start_with?(READ, DONE)
|
|
114
|
+
@reading = false
|
|
115
|
+
lines << string
|
|
116
|
+
else
|
|
117
|
+
flush(lines)
|
|
118
|
+
write(string)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Hold a part of a body, joining it to the parts read before it
|
|
123
|
+
#
|
|
124
|
+
# The line break that ends a chunk is held as a line of its own, so that it does not come between the parts of
|
|
125
|
+
# the body on either side of it.
|
|
126
|
+
#
|
|
127
|
+
# @api private
|
|
128
|
+
# @param lines [Array<String>] the lines of the body held so far
|
|
129
|
+
# @param string [String] the line Net::HTTP wrote for the part
|
|
130
|
+
# @return [void]
|
|
131
|
+
def read(lines, string)
|
|
132
|
+
part = string.delete_prefix(READ).chomp.delete_suffix('"')
|
|
133
|
+
body = @body
|
|
134
|
+
if part.eql?(CHUNK_END)
|
|
135
|
+
lines << string
|
|
136
|
+
elsif body
|
|
137
|
+
body << part
|
|
138
|
+
else
|
|
139
|
+
lines << (@body = part)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Write the lines of a body held so far, with its parts joined into one line
|
|
144
|
+
# @api private
|
|
145
|
+
# @param lines [Array<String>] the lines of the body held so far
|
|
146
|
+
# @return [void]
|
|
147
|
+
def flush(lines)
|
|
148
|
+
body = @body
|
|
149
|
+
lines.each { |line| write(line.equal?(body) ? %(#{READ}#{line}"\n) : line) }
|
|
150
|
+
@lines = @body = nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Write a string to the IO, with credentials redacted
|
|
154
|
+
# @api private
|
|
155
|
+
# @param string [String] the debug output
|
|
156
|
+
# @return [void]
|
|
157
|
+
def write(string)
|
|
158
|
+
output << CREDENTIALS.reduce(string) { |redacted, pattern| redacted.gsub(pattern, "\\1#{REDACTION}") }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "authenticator"
|
|
6
|
+
require_relative "connection"
|
|
7
|
+
require_relative "errors/too_many_redirects"
|
|
8
|
+
require_relative "request_builder"
|
|
9
|
+
require_relative "settings"
|
|
10
|
+
require_relative "url_validation"
|
|
11
|
+
|
|
12
|
+
module Tesla
|
|
13
|
+
# Handles HTTP redirects for API requests
|
|
14
|
+
# @api private
|
|
15
|
+
class RedirectHandler
|
|
16
|
+
include Settings
|
|
17
|
+
include URLValidation
|
|
18
|
+
|
|
19
|
+
# Default maximum number of redirects to follow
|
|
20
|
+
DEFAULT_MAX_REDIRECTS = 10
|
|
21
|
+
# HTTP status codes that preserve the request method and body
|
|
22
|
+
METHOD_PRESERVING_CODES = [307, 308].freeze
|
|
23
|
+
private_constant :METHOD_PRESERVING_CODES
|
|
24
|
+
|
|
25
|
+
# @!method max_redirects
|
|
26
|
+
# The maximum number of redirects to follow
|
|
27
|
+
# @api private
|
|
28
|
+
# @return [Integer] the maximum number of redirects to follow
|
|
29
|
+
# @example Get the maximum redirects
|
|
30
|
+
# handler.max_redirects
|
|
31
|
+
# @!method max_redirects=(max_redirects)
|
|
32
|
+
# Set the maximum number of redirects to follow
|
|
33
|
+
# @api private
|
|
34
|
+
# @param max_redirects [Integer] the maximum number of redirects to follow
|
|
35
|
+
# @return [void]
|
|
36
|
+
# @raise [ArgumentError] if it is not a whole number of times, in which case the maximum is left as it was
|
|
37
|
+
# @example Set the maximum redirects
|
|
38
|
+
# handler.max_redirects = 5
|
|
39
|
+
count_setting :max_redirects
|
|
40
|
+
|
|
41
|
+
# The connection for making requests
|
|
42
|
+
# @api private
|
|
43
|
+
# @return [Connection] the connection for making requests
|
|
44
|
+
# @example Get the connection
|
|
45
|
+
# handler.connection
|
|
46
|
+
attr_reader :connection
|
|
47
|
+
|
|
48
|
+
# The request builder for creating requests
|
|
49
|
+
# @api private
|
|
50
|
+
# @return [RequestBuilder] the request builder for creating requests
|
|
51
|
+
# @example Get the request builder
|
|
52
|
+
# handler.request_builder
|
|
53
|
+
attr_reader :request_builder
|
|
54
|
+
|
|
55
|
+
# Initialize a new RedirectHandler
|
|
56
|
+
#
|
|
57
|
+
# @api private
|
|
58
|
+
# @param connection [Connection] the connection for making requests
|
|
59
|
+
# @param request_builder [RequestBuilder] the request builder for creating requests
|
|
60
|
+
# @param max_redirects [Integer] the maximum number of redirects to follow
|
|
61
|
+
# @return [RedirectHandler] a new instance
|
|
62
|
+
# @example Create a redirect handler
|
|
63
|
+
# handler = Tesla::RedirectHandler.new(connection: conn, request_builder: builder)
|
|
64
|
+
def initialize(connection: Connection.new, request_builder: RequestBuilder.new,
|
|
65
|
+
max_redirects: DEFAULT_MAX_REDIRECTS)
|
|
66
|
+
@connection = connection
|
|
67
|
+
@request_builder = request_builder
|
|
68
|
+
self.max_redirects = max_redirects
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Handle redirects for an HTTP response
|
|
72
|
+
#
|
|
73
|
+
# A redirect to another scheme, host, or port is followed without the credentials of the request, as curl and
|
|
74
|
+
# browsers do, so that a redirect cannot send them to a host they were not meant for. The headers the caller
|
|
75
|
+
# asked for are dropped along with them, since a header of the caller's own may carry a credential too. A later
|
|
76
|
+
# redirect back to the original host does not restore either.
|
|
77
|
+
#
|
|
78
|
+
# A redirect that cannot be followed, such as 304 Not Modified or one whose Location header is missing, is not a
|
|
79
|
+
# valid URL, or is not an HTTP or HTTPS URL, is returned as it is, so that the caller raises the HTTPError of its
|
|
80
|
+
# status.
|
|
81
|
+
#
|
|
82
|
+
# A 307 or 308 redirect is followed with the method of the request and the body it was built from, which is
|
|
83
|
+
# given as it was given to {RequestBuilder#build}. One to another scheme, host, or port is not followed at all
|
|
84
|
+
# when the request has a body, and is returned as a redirect that cannot be followed is: a body can carry a
|
|
85
|
+
# credential of its own, such as the PIN of a command, and a command replayed to the host the redirect names
|
|
86
|
+
# would be carried out by whatever answers there.
|
|
87
|
+
#
|
|
88
|
+
# @api private
|
|
89
|
+
# @param response [Net::HTTPResponse] the HTTP response to handle
|
|
90
|
+
# @param request [Net::HTTPRequest] the original HTTP request
|
|
91
|
+
# @param authenticator [Authenticator] the authenticator for requests
|
|
92
|
+
# @param body [Hash, Array, String, nil] the body the request was built from
|
|
93
|
+
# @param headers [Hash{String => String}] the headers the request was built with
|
|
94
|
+
# @param redirect_count [Integer] the current redirect count
|
|
95
|
+
# @return [Net::HTTPResponse] the final HTTP response after following redirects
|
|
96
|
+
# @raise [TooManyRedirects] if the maximum number of redirects is exceeded
|
|
97
|
+
# @example Handle a response
|
|
98
|
+
# response = handler.handle(response: resp, request: req)
|
|
99
|
+
def handle(response:, request:, authenticator: Authenticator.new, body: nil, headers: {}, redirect_count: 0)
|
|
100
|
+
return response unless response.is_a?(Net::HTTPRedirection)
|
|
101
|
+
|
|
102
|
+
raise TooManyRedirects, "Too many redirects" if redirect_count >= max_redirects
|
|
103
|
+
|
|
104
|
+
new_uri = build_new_uri(response, request)
|
|
105
|
+
return response if new_uri.nil?
|
|
106
|
+
|
|
107
|
+
follow(response:, request:, uri: new_uri, authenticator:, body:, headers:, redirect_count:)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Send the request again to where a redirect leads
|
|
113
|
+
#
|
|
114
|
+
# A redirect to another origin that would replay the body is not followed, and its response is returned for the
|
|
115
|
+
# caller to raise the HTTPError of (see {#handle}).
|
|
116
|
+
#
|
|
117
|
+
# @api private
|
|
118
|
+
# @param response [Net::HTTPResponse] the redirect response
|
|
119
|
+
# @param request [Net::HTTPRequest] the request that was redirected
|
|
120
|
+
# @param uri [URI::HTTP] the URI the redirect leads to
|
|
121
|
+
# @param authenticator [Authenticator] the authenticator for requests
|
|
122
|
+
# @param body [Hash, Array, String, nil] the body the request was built from
|
|
123
|
+
# @param headers [Hash{String => String}] the headers the request was built with
|
|
124
|
+
# @param redirect_count [Integer] the current redirect count
|
|
125
|
+
# @return [Net::HTTPResponse] the final HTTP response after following redirects
|
|
126
|
+
# @raise [TooManyRedirects] if the maximum number of redirects is exceeded
|
|
127
|
+
def follow(response:, request:, uri:, authenticator:, body:, headers:, redirect_count:)
|
|
128
|
+
response_code = Integer(response.code)
|
|
129
|
+
unless same_origin?(request.uri, uri)
|
|
130
|
+
return response if replays_body?(response_code, body)
|
|
131
|
+
|
|
132
|
+
authenticator = Authenticator.new
|
|
133
|
+
headers = {} #: Hash[String, String]
|
|
134
|
+
end
|
|
135
|
+
new_request = build_request(request:, uri:, response_code:, authenticator:, body:, headers:)
|
|
136
|
+
new_response = connection.perform(request: new_request)
|
|
137
|
+
handle(response: new_response, request: new_request, authenticator:, body:, headers:,
|
|
138
|
+
redirect_count: redirect_count + 1)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Whether following a redirect would send the body of the request again
|
|
142
|
+
#
|
|
143
|
+
# Only a redirect that keeps the method carries the body; the rest are followed with a GET that has none.
|
|
144
|
+
#
|
|
145
|
+
# @api private
|
|
146
|
+
# @param response_code [Integer] the HTTP response code
|
|
147
|
+
# @param body [Hash, Array, String, nil] the body the request was built from
|
|
148
|
+
# @return [Boolean] whether the body would be sent again
|
|
149
|
+
def replays_body?(response_code, body)
|
|
150
|
+
method_preserving?(response_code) && !body.nil?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Whether a redirect keeps the method and body, rather than becoming a GET
|
|
154
|
+
# @api private
|
|
155
|
+
# @param response_code [Integer] the HTTP response code
|
|
156
|
+
# @return [Boolean] whether the method and body are kept
|
|
157
|
+
def method_preserving?(response_code)
|
|
158
|
+
METHOD_PRESERVING_CODES.include?(response_code)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Build a new URI from the redirect response
|
|
162
|
+
# @api private
|
|
163
|
+
# @param response [Net::HTTPResponse] the redirect response
|
|
164
|
+
# @param request [Net::HTTPRequest] the original request
|
|
165
|
+
# @return [URI::HTTP, nil] the new URI, or nil if the response has no Location header or one that is not an HTTP
|
|
166
|
+
# or HTTPS URL
|
|
167
|
+
def build_new_uri(response, request)
|
|
168
|
+
location = response["location"]
|
|
169
|
+
return if location.nil?
|
|
170
|
+
|
|
171
|
+
# If location is relative, it will join with the original URI, otherwise it will overwrite it
|
|
172
|
+
new_uri = URI.join(request.uri, location)
|
|
173
|
+
new_uri if new_uri.is_a?(URI::HTTP)
|
|
174
|
+
rescue URI::InvalidURIError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Build a new request for the redirect
|
|
179
|
+
# @api private
|
|
180
|
+
# @param request [Net::HTTPRequest] the original request
|
|
181
|
+
# @param uri [URI::Generic] the new URI
|
|
182
|
+
# @param response_code [Integer] the HTTP response code
|
|
183
|
+
# @param authenticator [Authenticator] the authenticator
|
|
184
|
+
# @param body [Hash, Array, String, nil] the body the original request was built from
|
|
185
|
+
# @param headers [Hash{String => String}] the headers the new request is built with
|
|
186
|
+
# @return [Net::HTTPRequest] the new request, with the method and body of the original for a 307 or 308, or a
|
|
187
|
+
# GET without a body otherwise
|
|
188
|
+
def build_request(request:, uri:, response_code:, authenticator:, body:, headers:)
|
|
189
|
+
if method_preserving?(response_code)
|
|
190
|
+
request_builder.build(http_method: request.method.downcase.to_sym, uri:, body:, authenticator:, headers:)
|
|
191
|
+
else
|
|
192
|
+
request_builder.build(http_method: :get, uri:, authenticator:, headers:)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
data/lib/tesla/region.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module Tesla
|
|
6
|
+
# The region of the Fleet API an account is served by
|
|
7
|
+
# @api public
|
|
8
|
+
class Region < Resource
|
|
9
|
+
inspect_with :region
|
|
10
|
+
|
|
11
|
+
# @!method region
|
|
12
|
+
# The name of the region, such as "na" or "eu"
|
|
13
|
+
# @api public
|
|
14
|
+
# @return [String, nil] the name of the region
|
|
15
|
+
# @example
|
|
16
|
+
# region.region
|
|
17
|
+
attribute :region
|
|
18
|
+
|
|
19
|
+
# @!method fleet_api_base_url
|
|
20
|
+
# The host the Fleet API serves the account at, which is the `host` to configure
|
|
21
|
+
# @api public
|
|
22
|
+
# @return [String, nil] the host the Fleet API serves the account at
|
|
23
|
+
# @example
|
|
24
|
+
# region.fleet_api_base_url
|
|
25
|
+
attribute :fleet_api_base_url
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "authenticator"
|
|
7
|
+
require_relative "version"
|
|
8
|
+
|
|
9
|
+
module Tesla
|
|
10
|
+
# Builds HTTP requests for the Tesla Fleet API
|
|
11
|
+
# @api private
|
|
12
|
+
class RequestBuilder
|
|
13
|
+
# The default 'User-Agent' HTTP header
|
|
14
|
+
#
|
|
15
|
+
# The Ruby it runs on is named along with the library and its version, so that the operators of the Fleet API
|
|
16
|
+
# can tell which client and which Ruby a request came from.
|
|
17
|
+
DEFAULT_USER_AGENT = "tesla/#{VERSION} (#{RUBY_ENGINE} #{RUBY_ENGINE_VERSION}; #{RUBY_PLATFORM})".freeze
|
|
18
|
+
# Content type for JSON request bodies, which the Fleet API requires of every request
|
|
19
|
+
APPLICATION_JSON = "application/json"
|
|
20
|
+
# Mapping of HTTP method symbols to Net::HTTP classes
|
|
21
|
+
HTTP_METHODS = {
|
|
22
|
+
get: Net::HTTP::Get,
|
|
23
|
+
post: Net::HTTP::Post,
|
|
24
|
+
put: Net::HTTP::Put,
|
|
25
|
+
patch: Net::HTTP::Patch,
|
|
26
|
+
delete: Net::HTTP::Delete
|
|
27
|
+
}.freeze
|
|
28
|
+
private_constant :HTTP_METHODS
|
|
29
|
+
|
|
30
|
+
# The 'User-Agent' HTTP header sent with requests
|
|
31
|
+
# @api private
|
|
32
|
+
# @return [String] the user agent
|
|
33
|
+
# @example Get or set the user agent
|
|
34
|
+
# builder.user_agent = "Custom User Agent"
|
|
35
|
+
attr_accessor :user_agent
|
|
36
|
+
|
|
37
|
+
# Initialize a new RequestBuilder
|
|
38
|
+
#
|
|
39
|
+
# @api private
|
|
40
|
+
# @param user_agent [String] the 'User-Agent' HTTP header sent with requests
|
|
41
|
+
# @return [RequestBuilder] a new instance
|
|
42
|
+
# @example Create a request builder
|
|
43
|
+
# builder = Tesla::RequestBuilder.new(user_agent: "Custom User Agent")
|
|
44
|
+
def initialize(user_agent: DEFAULT_USER_AGENT)
|
|
45
|
+
@user_agent = user_agent
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build an HTTP request
|
|
49
|
+
#
|
|
50
|
+
# The body may be a Hash or an Array, which is sent as JSON, or a String, which is sent as it is with whatever
|
|
51
|
+
# content type the headers name.
|
|
52
|
+
#
|
|
53
|
+
# Every request is sent with a content type of JSON unless the headers name another, since the Fleet API answers
|
|
54
|
+
# a request without one with a 406 Not Acceptable, whether or not the request has a body.
|
|
55
|
+
#
|
|
56
|
+
# The headers are added after the ones of the authenticator, so that a caller with a token of its own, such as
|
|
57
|
+
# the partner token an application is registered with, sends it in place of the access token of the client.
|
|
58
|
+
#
|
|
59
|
+
# @api private
|
|
60
|
+
# @param http_method [Symbol] the HTTP method (:get, :post, :put, :patch, :delete)
|
|
61
|
+
# @param uri [URI::Generic] the request URI
|
|
62
|
+
# @param params [Hash] query parameters to append to the URI
|
|
63
|
+
# @param body [Hash, Array, String, nil] the request body
|
|
64
|
+
# @param headers [Hash] additional headers for the request
|
|
65
|
+
# @param authenticator [Authenticator] the authenticator for the request
|
|
66
|
+
# @return [Net::HTTPRequest] the built HTTP request
|
|
67
|
+
# @raise [ArgumentError] if the HTTP method is not supported
|
|
68
|
+
# @example Build a GET request
|
|
69
|
+
# builder.build(http_method: :get, uri: URI("https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles"))
|
|
70
|
+
def build(http_method:, uri:, params: {}, body: nil, headers: {}, authenticator: Authenticator.new)
|
|
71
|
+
request = create_request(http_method:, uri:, params:)
|
|
72
|
+
add_authentication(request:, authenticator:)
|
|
73
|
+
add_headers(request:, headers:)
|
|
74
|
+
add_body(request:, body:)
|
|
75
|
+
request
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Create an HTTP request
|
|
81
|
+
# @api private
|
|
82
|
+
# @param http_method [Symbol] the HTTP method
|
|
83
|
+
# @param uri [URI::Generic] the request URI
|
|
84
|
+
# @param params [Hash] query parameters to append to the URI
|
|
85
|
+
# @return [Net::HTTPRequest] the created request
|
|
86
|
+
def create_request(http_method:, uri:, params:)
|
|
87
|
+
http_method_class = HTTP_METHODS[http_method]
|
|
88
|
+
|
|
89
|
+
raise ArgumentError, "Unsupported HTTP method: #{http_method}" unless http_method_class
|
|
90
|
+
|
|
91
|
+
http_method_class.new(add_query_params(uri, params))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Append query parameters to a URI
|
|
95
|
+
# @api private
|
|
96
|
+
# @param uri [URI::Generic] the URI
|
|
97
|
+
# @param params [Hash] the query parameters
|
|
98
|
+
# @return [URI::Generic] a new URI with the query parameters appended
|
|
99
|
+
def add_query_params(uri, params)
|
|
100
|
+
uri.dup.tap do |u|
|
|
101
|
+
u.query = URI.encode_www_form(params) unless params.empty?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Add headers to a request
|
|
106
|
+
# @api private
|
|
107
|
+
# @param request [Net::HTTPRequest] the request
|
|
108
|
+
# @param headers [Hash] additional headers
|
|
109
|
+
# @return [void]
|
|
110
|
+
def add_headers(request:, headers:)
|
|
111
|
+
{"User-Agent" => user_agent, "Content-Type" => APPLICATION_JSON}.merge(headers).each do |key, value|
|
|
112
|
+
request[key] = value
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Add a body to a request
|
|
117
|
+
# @api private
|
|
118
|
+
# @param request [Net::HTTPRequest] the request
|
|
119
|
+
# @param body [Hash, Array, String, nil] the request body
|
|
120
|
+
# @return [void]
|
|
121
|
+
def add_body(request:, body:)
|
|
122
|
+
return if body.nil?
|
|
123
|
+
|
|
124
|
+
request.body = String.try_convert(body) || JSON.generate(body)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Add authentication to a request
|
|
128
|
+
# @api private
|
|
129
|
+
# @param request [Net::HTTPRequest] the request
|
|
130
|
+
# @param authenticator [Authenticator] the authenticator
|
|
131
|
+
# @return [void]
|
|
132
|
+
def add_authentication(request:, authenticator:)
|
|
133
|
+
authenticator.header(request).each do |key, value|
|
|
134
|
+
request[key] = value
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|