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.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +5 -0
  3. data/CHANGELOG.md +31 -0
  4. data/CONTRIBUTING.md +45 -0
  5. data/{LICENSE.txt → LICENSE.md} +1 -1
  6. data/README.md +366 -0
  7. data/SECURITY.md +38 -0
  8. data/lib/tesla/api/alert_endpoints.rb +35 -0
  9. data/lib/tesla/api/charging_endpoints.rb +115 -0
  10. data/lib/tesla/api/climate_endpoints.rb +180 -0
  11. data/lib/tesla/api/closure_endpoints.rb +142 -0
  12. data/lib/tesla/api/command_endpoints.rb +63 -0
  13. data/lib/tesla/api/media_endpoints.rb +114 -0
  14. data/lib/tesla/api/navigation_endpoints.rb +51 -0
  15. data/lib/tesla/api/oauth_endpoints.rb +118 -0
  16. data/lib/tesla/api/partner_endpoints.rb +62 -0
  17. data/lib/tesla/api/security_endpoints.rb +123 -0
  18. data/lib/tesla/api/software_endpoints.rb +38 -0
  19. data/lib/tesla/api/user_endpoints.rb +40 -0
  20. data/lib/tesla/api/vehicle_endpoints.rb +174 -0
  21. data/lib/tesla/api.rb +39 -0
  22. data/lib/tesla/authenticator.rb +33 -0
  23. data/lib/tesla/bearer_token_authenticator.rb +38 -0
  24. data/lib/tesla/certificate_options.rb +51 -0
  25. data/lib/tesla/charge_state.rb +111 -0
  26. data/lib/tesla/charging_site.rb +67 -0
  27. data/lib/tesla/client.rb +336 -0
  28. data/lib/tesla/client_credentials.rb +230 -0
  29. data/lib/tesla/climate_state.rb +116 -0
  30. data/lib/tesla/configuration.rb +483 -0
  31. data/lib/tesla/connection.rb +361 -0
  32. data/lib/tesla/connection_pool.rb +186 -0
  33. data/lib/tesla/deep_copy.rb +41 -0
  34. data/lib/tesla/drive_state.rb +80 -0
  35. data/lib/tesla/errors/bad_gateway.rb +9 -0
  36. data/lib/tesla/errors/bad_request.rb +9 -0
  37. data/lib/tesla/errors/client_error.rb +9 -0
  38. data/lib/tesla/errors/command_failed.rb +42 -0
  39. data/lib/tesla/errors/device_unexpected_response.rb +12 -0
  40. data/lib/tesla/errors/error.rb +7 -0
  41. data/lib/tesla/errors/forbidden.rb +14 -0
  42. data/lib/tesla/errors/gateway_timeout.rb +9 -0
  43. data/lib/tesla/errors/http_error.rb +89 -0
  44. data/lib/tesla/errors/internal_server_error.rb +9 -0
  45. data/lib/tesla/errors/invalid_response.rb +33 -0
  46. data/lib/tesla/errors/locked.rb +12 -0
  47. data/lib/tesla/errors/method_not_allowed.rb +9 -0
  48. data/lib/tesla/errors/misdirected_request.rb +13 -0
  49. data/lib/tesla/errors/network_error.rb +30 -0
  50. data/lib/tesla/errors/not_acceptable.rb +9 -0
  51. data/lib/tesla/errors/not_found.rb +9 -0
  52. data/lib/tesla/errors/oauth_error.rb +53 -0
  53. data/lib/tesla/errors/payment_required.rb +12 -0
  54. data/lib/tesla/errors/precondition_failed.rb +13 -0
  55. data/lib/tesla/errors/request_timeout.rb +13 -0
  56. data/lib/tesla/errors/server_error.rb +9 -0
  57. data/lib/tesla/errors/service_unavailable.rb +9 -0
  58. data/lib/tesla/errors/too_many_redirects.rb +9 -0
  59. data/lib/tesla/errors/too_many_requests.rb +9 -0
  60. data/lib/tesla/errors/unauthorized.rb +9 -0
  61. data/lib/tesla/errors/unavailable_for_legal_reasons.rb +12 -0
  62. data/lib/tesla/errors/unprocessable_content.rb +12 -0
  63. data/lib/tesla/errors/vehicle_asleep.rb +32 -0
  64. data/lib/tesla/gui_settings.rb +60 -0
  65. data/lib/tesla/idempotence.rb +30 -0
  66. data/lib/tesla/identifiers.rb +27 -0
  67. data/lib/tesla/json_parsing.rb +38 -0
  68. data/lib/tesla/nearby_charging_sites.rb +35 -0
  69. data/lib/tesla/path_escaping.rb +30 -0
  70. data/lib/tesla/redacted_output.rb +161 -0
  71. data/lib/tesla/redirect_handler.rb +196 -0
  72. data/lib/tesla/region.rb +27 -0
  73. data/lib/tesla/request_builder.rb +138 -0
  74. data/lib/tesla/resource.rb +403 -0
  75. data/lib/tesla/response_parser.rb +92 -0
  76. data/lib/tesla/retry_after.rb +34 -0
  77. data/lib/tesla/retry_handler.rb +252 -0
  78. data/lib/tesla/settings.rb +115 -0
  79. data/lib/tesla/url_validation.rb +131 -0
  80. data/lib/tesla/user.rb +36 -0
  81. data/lib/tesla/vehicle.rb +90 -0
  82. data/lib/tesla/vehicle_config.rb +95 -0
  83. data/lib/tesla/vehicle_data.rb +67 -0
  84. data/lib/tesla/vehicle_state.rb +125 -0
  85. data/lib/tesla/version.rb +6 -0
  86. data/lib/tesla.rb +139 -0
  87. data/sig/manifest.yaml +10 -0
  88. data/sig/tesla.rbs +989 -0
  89. metadata +134 -118
  90. data/.document +0 -5
  91. data/.rvmrc +0 -1
  92. data/Gemfile +0 -13
  93. data/Gemfile.lock +0 -20
  94. data/README.rdoc +0 -19
  95. data/Rakefile +0 -47
  96. data/test/helper.rb +0 -19
  97. 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
@@ -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