esirb 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +98 -0
- data/README.md +404 -20
- data/lib/esirb/client.rb +137 -32
- data/lib/esirb/compatibility_date.rb +41 -0
- data/lib/esirb/configuration.rb +59 -0
- data/lib/esirb/error_limit_guard.rb +62 -0
- data/lib/esirb/errors.rb +107 -0
- data/lib/esirb/pagination.rb +105 -0
- data/lib/esirb/path.rb +90 -14
- data/lib/esirb/raise_error.rb +27 -0
- data/lib/esirb/rate_limit.rb +58 -0
- data/lib/esirb/retry.rb +74 -0
- data/lib/esirb/vary_by_authorization.rb +19 -0
- data/lib/esirb/version.rb +1 -1
- data/lib/esirb.rb +9 -4
- data/lib/generators/esirb/install/install_generator.rb +35 -0
- data/lib/generators/esirb/install/templates/esirb.rb.tt +35 -0
- metadata +20 -10
- data/.rspec +0 -3
- data/.rubocop.yml +0 -8
- data/.ruby-version +0 -1
- data/Rakefile +0 -12
- data/sig/esirb.rbs +0 -4
data/lib/esirb/client.rb
CHANGED
|
@@ -2,50 +2,155 @@
|
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
4
|
require "faraday-http-cache"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
require_relative "configuration"
|
|
7
|
+
require_relative "compatibility_date"
|
|
8
|
+
require_relative "raise_error"
|
|
9
|
+
require_relative "retry"
|
|
10
|
+
require_relative "error_limit_guard"
|
|
11
|
+
require_relative "vary_by_authorization"
|
|
5
12
|
|
|
6
13
|
module Esirb
|
|
14
|
+
# Holds the ESI connection settings and starts building request paths.
|
|
7
15
|
class Client
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
# Sent after the app's own user_agent, so ESI can tell which library made the request.
|
|
17
|
+
USER_AGENT = "esirb/#{VERSION} (+https://github.com/sicks/esirb)".freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :token, :base_url, :compatibility_date, :tenant, :language, :cache_store, :user_agent,
|
|
20
|
+
:timeout, :open_timeout, :retries, :connection
|
|
21
|
+
|
|
22
|
+
# token: an ESI access token, or something that responds to call and returns one. A callable
|
|
23
|
+
# is asked before every request, so an app can refresh its token without building a new client.
|
|
24
|
+
#
|
|
25
|
+
# Other options default to Esirb.configuration. compatibility_date can't be nil: without one,
|
|
26
|
+
# ESI would use its oldest behavior.
|
|
27
|
+
#
|
|
28
|
+
# A block is called with the Faraday connection builder after the configured faraday hook, so
|
|
29
|
+
# it can add middleware or change the adapter for this client only.
|
|
30
|
+
def initialize(token: nil, **options, &block)
|
|
31
|
+
check_arguments(token, options)
|
|
32
|
+
|
|
21
33
|
@token = token
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
apply_settings(Esirb.configuration.to_h.merge(options))
|
|
35
|
+
# Built here rather than on first use, so threads sharing a client never race to build it.
|
|
36
|
+
@connection = build_connection(block)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Starts a path with a segment exactly as given, for segments that can't be reached by method name.
|
|
40
|
+
def segment(name, *params)
|
|
41
|
+
Path.new(client: self).segment(name, *params)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def method_missing(name, *, **)
|
|
45
|
+
return super unless Path.segment_name?(name)
|
|
46
|
+
|
|
47
|
+
Path.new(client: self).public_send(name, *, **)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def respond_to_missing?(name, include_private = false)
|
|
51
|
+
Path.segment_name?(name) || super
|
|
27
52
|
end
|
|
28
53
|
|
|
29
|
-
|
|
30
|
-
@connection ||= Faraday.new(BASE_URL) do |c|
|
|
31
|
-
if cache
|
|
32
|
-
c.use :http_cache, store: cache_store, strategy: Faraday::HttpCache::Strategies::ByUrl, serializer: Marshal
|
|
33
|
-
end
|
|
54
|
+
private
|
|
34
55
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
56
|
+
def build_connection(block)
|
|
57
|
+
# ESI reads array params as repeated keys (categories=a&categories=b), not Faraday's default
|
|
58
|
+
# categories[]=a&categories[]=b.
|
|
59
|
+
request = { timeout:, open_timeout:, params_encoder: Faraday::FlatParamsEncoder }.compact
|
|
60
|
+
Faraday.new(base_url, request:) do |c|
|
|
61
|
+
use_middleware(c)
|
|
62
|
+
# Last, so app middleware sits closest to the network and sees real requests, not cache hits.
|
|
63
|
+
[@faraday, block].compact.each { |hook| hook.call(c) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
38
66
|
|
|
39
|
-
|
|
40
|
-
|
|
67
|
+
def use_middleware(builder)
|
|
68
|
+
# Before the cache, so the token is part of each cache key.
|
|
69
|
+
builder.request :authorization, "Bearer", access_token if token
|
|
70
|
+
builder.headers.update(default_headers)
|
|
41
71
|
|
|
42
|
-
|
|
43
|
-
|
|
72
|
+
use_error_handling(builder)
|
|
73
|
+
builder.response :json
|
|
74
|
+
# Inside the JSON parser, so the cache stores raw response bodies.
|
|
75
|
+
use_cache(builder) if cache_store
|
|
76
|
+
|
|
77
|
+
builder.request :json
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# What the authorization middleware sends: the string itself, or a lambda it calls before each
|
|
81
|
+
# request. The lambda refuses a blank token rather than sending "Bearer " to ESI.
|
|
82
|
+
def access_token
|
|
83
|
+
return token unless token.respond_to?(:call)
|
|
84
|
+
|
|
85
|
+
lambda do
|
|
86
|
+
value = token.call
|
|
87
|
+
raise Error, "token callable returned #{value.inspect} instead of an access token" if value.to_s.strip.empty?
|
|
88
|
+
|
|
89
|
+
value
|
|
44
90
|
end
|
|
45
91
|
end
|
|
46
92
|
|
|
47
|
-
def
|
|
48
|
-
|
|
93
|
+
def check_arguments(token, options)
|
|
94
|
+
unknown = options.keys - Configuration::DEFAULTS.keys
|
|
95
|
+
raise ArgumentError, "unknown option#{"s" if unknown.size > 1}: #{unknown.join(", ")}" if unknown.any?
|
|
96
|
+
|
|
97
|
+
check_callable(:faraday, options[:faraday])
|
|
98
|
+
check_retries(options.fetch(:retries, 0))
|
|
99
|
+
check_callable(:token, token, allow_string: true)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def check_retries(retries)
|
|
103
|
+
return if retries.is_a?(Integer) && retries >= 0
|
|
104
|
+
|
|
105
|
+
raise ArgumentError, "retries must be an Integer of 0 or more, got #{retries.inspect}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def check_callable(name, value, allow_string: false)
|
|
109
|
+
return if value.nil? || value.respond_to?(:call) || (allow_string && value.is_a?(String))
|
|
110
|
+
|
|
111
|
+
expected = allow_string ? "a string or something that responds to call" : "something that responds to call"
|
|
112
|
+
raise ArgumentError, "#{name} must be #{expected}, got #{value.class}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Keys cache entries on the URL plus the headers in Vary: tenant, language, compatibility date
|
|
116
|
+
# and, through VaryByAuthorization, the token. Because every entry is per token, the store
|
|
117
|
+
# acts as a private cache and keeps ESI's `Cache-Control: private` responses too.
|
|
118
|
+
def use_cache(builder)
|
|
119
|
+
builder.use :http_cache, store: cache_store, strategy: Faraday::HttpCache::Strategies::ByVary,
|
|
120
|
+
shared_cache: false
|
|
121
|
+
builder.use VaryByAuthorization
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def apply_settings(settings)
|
|
125
|
+
(Configuration::DEFAULTS.keys - %i[user_agent compatibility_date]).each do |name|
|
|
126
|
+
instance_variable_set(:"@#{name}", settings[name])
|
|
127
|
+
end
|
|
128
|
+
@compatibility_date = CompatibilityDate.parse(settings[:compatibility_date])
|
|
129
|
+
app_user_agent = settings[:user_agent].to_s.strip
|
|
130
|
+
@user_agent = app_user_agent.empty? ? USER_AGENT : "#{app_user_agent} #{USER_AGENT}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Retry sits outside RaiseError, so it sees esirb's errors. ErrorLimitGuard sits inside Retry,
|
|
134
|
+
# so it checks and records every attempt; it needs the shared cache store.
|
|
135
|
+
def use_error_handling(builder)
|
|
136
|
+
builder.use Retry, retries: retries if retries.positive?
|
|
137
|
+
builder.use ErrorLimitGuard, store: cache_store, key: error_limit_key if cache_store
|
|
138
|
+
builder.use RaiseError
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# One per ESI host and tenant: ESI counts the error limit per source IP, not per token.
|
|
142
|
+
def error_limit_key
|
|
143
|
+
"esirb:error-limit:#{base_url}:#{tenant}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def default_headers
|
|
147
|
+
{
|
|
148
|
+
"Accept" => "application/json",
|
|
149
|
+
"Accept-Language" => language,
|
|
150
|
+
"X-Tenant" => tenant,
|
|
151
|
+
"X-Compatibility-Date" => compatibility_date,
|
|
152
|
+
"User-Agent" => user_agent
|
|
153
|
+
}.compact
|
|
49
154
|
end
|
|
50
155
|
end
|
|
51
156
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Esirb
|
|
6
|
+
# Checks the date sent as X-Compatibility-Date. Any date from ESI's oldest supported one to today
|
|
7
|
+
# works; ESI uses the latest published compatibility date on or before it. The oldest supported
|
|
8
|
+
# date isn't checked here, since CCP can raise it.
|
|
9
|
+
module CompatibilityDate
|
|
10
|
+
FORMAT = /\A\d{4}-\d{2}-\d{2}\z/
|
|
11
|
+
|
|
12
|
+
# Returns the date as a YYYY-MM-DD string, or raises ArgumentError.
|
|
13
|
+
def self.parse(value)
|
|
14
|
+
if value.nil?
|
|
15
|
+
raise ArgumentError, "compatibility_date can't be nil, since ESI would then use its oldest behavior. " \
|
|
16
|
+
'Pass that date ("2020-01-01") explicitly if that\'s what you want.'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
date = to_date(value)
|
|
20
|
+
raise ArgumentError, "compatibility_date #{date} is in the future; ESI's current date is #{today}" if date > today
|
|
21
|
+
|
|
22
|
+
date.strftime("%Y-%m-%d")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# ESI's date rolls over at 11:00 UTC.
|
|
26
|
+
def self.today
|
|
27
|
+
(Time.now.utc - (11 * 60 * 60)).to_date
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.to_date(value)
|
|
31
|
+
# DateTime is a Date too. to_date drops its time, so a later hour today isn't counted as the future.
|
|
32
|
+
return value.to_date if value.is_a?(Date)
|
|
33
|
+
|
|
34
|
+
text = value.to_s
|
|
35
|
+
return Date.iso8601(text) if text.match?(FORMAT) && Date.valid_date?(*text.split("-").map(&:to_i))
|
|
36
|
+
|
|
37
|
+
raise ArgumentError, "compatibility_date must be a YYYY-MM-DD date, got #{value.inspect}"
|
|
38
|
+
end
|
|
39
|
+
private_class_method :to_date
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A light Ruby client for EVE Online's ESI API.
|
|
4
|
+
module Esirb
|
|
5
|
+
# App-wide defaults for new clients. Set them once with Esirb.configure; options passed to
|
|
6
|
+
# Esirb::Client.new override them for that client.
|
|
7
|
+
class Configuration
|
|
8
|
+
DEFAULTS = {
|
|
9
|
+
base_url: "https://esi.evetech.net",
|
|
10
|
+
# Part of the API contract: a newer date can change response shapes. Only move it in a
|
|
11
|
+
# release that has been checked against that date.
|
|
12
|
+
compatibility_date: "2026-08-18",
|
|
13
|
+
tenant: "tranquility",
|
|
14
|
+
language: "en",
|
|
15
|
+
# Your app's name, version and contact info, e.g. "mappy/1.2 (admin@example.com)".
|
|
16
|
+
user_agent: nil,
|
|
17
|
+
# Where to cache responses, e.g. Rails.cache. Anything with read, write and delete works.
|
|
18
|
+
# nil turns caching off.
|
|
19
|
+
cache_store: nil,
|
|
20
|
+
# Seconds to wait for a response, and to open the connection. nil leaves it to the adapter.
|
|
21
|
+
timeout: 30,
|
|
22
|
+
open_timeout: 5,
|
|
23
|
+
# Times to retry a 502, 503 or 504, a timeout, a connection failure or a short 429, on GET,
|
|
24
|
+
# HEAD, PUT and DELETE. Retries stop when ESI's error limit is nearly used up. 0 turns them off.
|
|
25
|
+
retries: 2,
|
|
26
|
+
# Called with the Faraday connection builder after esirb's own setup, to add middleware,
|
|
27
|
+
# change the adapter or set other connection options.
|
|
28
|
+
faraday: nil
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
attr_accessor(*DEFAULTS.keys)
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
DEFAULTS.each { |name, value| public_send(:"#{name}=", value) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The settings, keyed like DEFAULTS.
|
|
38
|
+
def to_h
|
|
39
|
+
DEFAULTS.keys.to_h { |name| [name, public_send(name)] }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# The defaults new clients start from.
|
|
45
|
+
def configuration
|
|
46
|
+
@configuration ||= Configuration.new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Changes the defaults for clients created afterwards.
|
|
50
|
+
def configure
|
|
51
|
+
yield configuration
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Restores esirb's own defaults; mainly useful in tests.
|
|
55
|
+
def reset_configuration!
|
|
56
|
+
@configuration = Configuration.new
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "rate_limit"
|
|
6
|
+
|
|
7
|
+
module Esirb
|
|
8
|
+
# Stops sending requests while ESI's error limit is nearly used up. Once that limit runs out,
|
|
9
|
+
# every ESI route returns 420 until the error limit window resets, and repeatedly ignoring it can get an app
|
|
10
|
+
# banned (https://developers.eveonline.com/docs/services/esi/rate-limiting/).
|
|
11
|
+
#
|
|
12
|
+
# The limit is counted per source IP, not per token, so its state is kept once per ESI host in
|
|
13
|
+
# the cache store, where every client in every process sees it. Apps on several servers that
|
|
14
|
+
# share one store will pause together, which errs on the side of sending less.
|
|
15
|
+
class ErrorLimitGuard < Faraday::Middleware
|
|
16
|
+
# How long to pause after a 420 that doesn't say when the error limit window resets. The window is a minute.
|
|
17
|
+
DEFAULT_RESET = 60
|
|
18
|
+
|
|
19
|
+
def initialize(app, store:, key:)
|
|
20
|
+
super(app)
|
|
21
|
+
@store = store
|
|
22
|
+
@key = key
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(env)
|
|
26
|
+
raise_if_paused
|
|
27
|
+
response = @app.call(env)
|
|
28
|
+
record_error_limit(response.status, response.headers) unless cached?(response.env)
|
|
29
|
+
response
|
|
30
|
+
rescue ResponseError => e
|
|
31
|
+
record_error_limit(e.status, e.headers)
|
|
32
|
+
raise
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def raise_if_paused
|
|
38
|
+
state = @store.read(@key)
|
|
39
|
+
return unless state
|
|
40
|
+
|
|
41
|
+
seconds = (state["reset_at"] - Time.now.to_f).ceil
|
|
42
|
+
return unless seconds.positive? && state["remaining"] < RateLimit::ERROR_LIMIT_FLOOR
|
|
43
|
+
|
|
44
|
+
raise ErrorLimitReached.new(remaining: state["remaining"], retry_after: seconds)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Routes on the newer rate limit don't send error limit headers, and leave the state alone.
|
|
48
|
+
def record_error_limit(status, headers)
|
|
49
|
+
limit = RateLimit.new(headers)
|
|
50
|
+
remaining = status == 420 ? 0 : limit.error_limit_remaining
|
|
51
|
+
reset = limit.error_limit_reset || (DEFAULT_RESET if status == 420)
|
|
52
|
+
return if remaining.nil? || reset.nil?
|
|
53
|
+
|
|
54
|
+
@store.write(@key, { "remaining" => remaining, "reset_at" => Time.now.to_f + reset })
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A response served from the cache carries the error limit from when it was first fetched.
|
|
58
|
+
def cached?(env)
|
|
59
|
+
Array(env[:http_cache_trace]).include?(:fresh)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/esirb/errors.rb
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rate_limit"
|
|
4
|
+
|
|
5
|
+
module Esirb
|
|
6
|
+
# Base class for everything esirb raises.
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Raised when the connection to ESI fails, including TLS errors.
|
|
10
|
+
class ConnectionFailed < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when ESI doesn't answer in time.
|
|
13
|
+
class TimeoutError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised for an error status from ESI. ESI's error bodies look like {"error" => "System not found"}.
|
|
16
|
+
class ResponseError < Error
|
|
17
|
+
# The response's status, headers and parsed body, and the full Faraday response.
|
|
18
|
+
attr_reader :status, :headers, :body, :response
|
|
19
|
+
|
|
20
|
+
def self.for_status(status)
|
|
21
|
+
STATUS_ERRORS.fetch(status) { status >= 500 ? ServerError : ClientError }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(env)
|
|
25
|
+
@status = env.status
|
|
26
|
+
@headers = env.response_headers
|
|
27
|
+
@body = env.body
|
|
28
|
+
@response = env.response
|
|
29
|
+
super("#{status} #{error_message(env)} (#{env.method.to_s.upcase} #{env.url})")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# ESI's rate or error limit as of this response.
|
|
33
|
+
def rate_limit
|
|
34
|
+
RateLimit.new(headers)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def error_message(env)
|
|
40
|
+
message = body["error"] if body.is_a?(Hash)
|
|
41
|
+
return message if message.is_a?(String) && !message.empty?
|
|
42
|
+
|
|
43
|
+
env.reason_phrase.to_s.empty? ? "error" : env.reason_phrase
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Raised without sending the request, because ESI's error limit is nearly used up and the error
|
|
48
|
+
# limit window hasn't reset yet. Only happens with a cache_store; see ErrorLimitGuard.
|
|
49
|
+
class ErrorLimitReached < Error
|
|
50
|
+
# Seconds until ESI's error limit window resets.
|
|
51
|
+
attr_reader :retry_after
|
|
52
|
+
|
|
53
|
+
def initialize(remaining:, retry_after:)
|
|
54
|
+
@retry_after = retry_after
|
|
55
|
+
super("ESI's error limit is nearly used up (#{remaining} errors left), " \
|
|
56
|
+
"so requests are paused for #{retry_after} more seconds")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Raised by Path#get_all_pages when ESI refreshed the data while its pages were being fetched,
|
|
61
|
+
# twice in a row, so the pages can't be combined consistently. Trying again later usually works.
|
|
62
|
+
class PagesChanged < Error; end
|
|
63
|
+
|
|
64
|
+
# Any other 4xx status, and the parent of the classes for specific 4xx statuses.
|
|
65
|
+
class ClientError < ResponseError; end
|
|
66
|
+
|
|
67
|
+
class BadRequest < ClientError; end
|
|
68
|
+
class Unauthorized < ClientError; end
|
|
69
|
+
class Forbidden < ClientError; end
|
|
70
|
+
class NotFound < ClientError; end
|
|
71
|
+
|
|
72
|
+
# ESI's error limit (420) or rate limit (429).
|
|
73
|
+
class RateLimited < ClientError
|
|
74
|
+
# Seconds to wait before trying again, or nil if ESI didn't say.
|
|
75
|
+
def retry_after
|
|
76
|
+
value = status == 429 ? headers["Retry-After"] : headers["X-ESI-Error-Limit-Reset"]
|
|
77
|
+
Integer(value, exception: false) if value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# 420: too many errors in the error limit window. The limit is global, so every ESI route
|
|
82
|
+
# returns 420 until it resets. Never retried.
|
|
83
|
+
class ErrorLimited < RateLimited; end
|
|
84
|
+
|
|
85
|
+
# 429: a rate limit group's tokens are used up. Retried when Retry-After is short.
|
|
86
|
+
class TooManyRequests < RateLimited; end
|
|
87
|
+
|
|
88
|
+
# Any other 5xx status, and the parent of the classes for specific 5xx statuses.
|
|
89
|
+
class ServerError < ResponseError; end
|
|
90
|
+
|
|
91
|
+
# Usually transient on ESI's side, so esirb retries these (see the retries option).
|
|
92
|
+
class BadGateway < ServerError; end
|
|
93
|
+
class ServiceUnavailable < ServerError; end
|
|
94
|
+
class GatewayTimeout < ServerError; end
|
|
95
|
+
|
|
96
|
+
ResponseError::STATUS_ERRORS = {
|
|
97
|
+
400 => BadRequest,
|
|
98
|
+
401 => Unauthorized,
|
|
99
|
+
403 => Forbidden,
|
|
100
|
+
404 => NotFound,
|
|
101
|
+
420 => ErrorLimited,
|
|
102
|
+
429 => TooManyRequests,
|
|
103
|
+
502 => BadGateway,
|
|
104
|
+
503 => ServiceUnavailable,
|
|
105
|
+
504 => GatewayTimeout
|
|
106
|
+
}.freeze
|
|
107
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Esirb
|
|
6
|
+
# Helpers for ESI's three pagination styles, included in Path. Each route uses one of them; its
|
|
7
|
+
# entry in ESI's API Explorer shows which by its parameters (page, before/after, or from_id).
|
|
8
|
+
# Pages are fetched one at a time, and each goes through the cache and retries like any request.
|
|
9
|
+
# See https://developers.eveonline.com/docs/services/esi/pagination/x-pages/
|
|
10
|
+
module Pagination
|
|
11
|
+
# Every record from a route paginated with `page` and the X-Pages header, e.g. market orders.
|
|
12
|
+
#
|
|
13
|
+
# All pages of one resource should share a Last-Modified. If it changes partway through, ESI
|
|
14
|
+
# refreshed the data between pages, so this starts over once, and raises PagesChanged if it happens again.
|
|
15
|
+
def get_all_pages(params: {}, headers: nil)
|
|
16
|
+
2.times do
|
|
17
|
+
records = pages_unless_changed(params, headers)
|
|
18
|
+
return records if records
|
|
19
|
+
end
|
|
20
|
+
raise PagesChanged, "#{self} changed while its pages were being fetched, twice in a row"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Records from a route paginated with `before` and `after` cursors, e.g. freelance jobs,
|
|
24
|
+
# oldest first, as [records, after]. Save `after` and pass it back later to get only the records
|
|
25
|
+
# created or changed since. Without it, this fetches everything.
|
|
26
|
+
#
|
|
27
|
+
# Records can repeat across pages; each appears once, matched by its "id": the newest copy when
|
|
28
|
+
# following `after` toward newer records, and the one already fetched when following `before`
|
|
29
|
+
# toward older records.
|
|
30
|
+
def get_by_cursor(after: nil, params: {}, headers: nil)
|
|
31
|
+
after ? cursor_changes(after, params, headers) : cursor_everything(params, headers)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Records from a route paginated with `from_id`, i.e. wallet transactions, newest first. Pass
|
|
35
|
+
# `stop_at:` with the newest ID you already have to fetch only the records after it.
|
|
36
|
+
def get_from_id(id_key: "transaction_id", stop_at: nil, params: {}, headers: nil)
|
|
37
|
+
records = []
|
|
38
|
+
from_id = nil
|
|
39
|
+
loop do
|
|
40
|
+
page = from_id_page(from_id, id_key, params, headers)
|
|
41
|
+
stop_index = stop_at && page.index { |record| record[id_key] == stop_at }
|
|
42
|
+
return records + page.first(stop_index) if stop_index
|
|
43
|
+
return records if page.empty?
|
|
44
|
+
|
|
45
|
+
records.concat(page)
|
|
46
|
+
from_id = page.last[id_key]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def pages_unless_changed(params, headers)
|
|
53
|
+
first_page = get(params: params.merge(page: 1), headers:)
|
|
54
|
+
records = first_page.body.dup
|
|
55
|
+
(2..first_page.headers["X-Pages"].to_i).each do |page|
|
|
56
|
+
response = get(params: params.merge(page:), headers:)
|
|
57
|
+
return nil unless response.headers["Last-Modified"] == first_page.headers["Last-Modified"]
|
|
58
|
+
|
|
59
|
+
records.concat(response.body)
|
|
60
|
+
end
|
|
61
|
+
records
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Each page after the first starts with the from_id record itself, which is dropped.
|
|
65
|
+
def from_id_page(from_id, id_key, params, headers)
|
|
66
|
+
return get(params:, headers:).body unless from_id
|
|
67
|
+
|
|
68
|
+
get(params: params.merge(from_id:), headers:).body.reject { |record| record[id_key] == from_id }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Follows `before` until a page comes back empty, prepending older records.
|
|
72
|
+
def cursor_everything(params, headers)
|
|
73
|
+
records, cursor = cursor_page(params, headers)
|
|
74
|
+
after = cursor["after"]
|
|
75
|
+
while cursor["before"]
|
|
76
|
+
older, cursor = cursor_page(params.merge(before: cursor["before"]), headers)
|
|
77
|
+
break if older.empty?
|
|
78
|
+
|
|
79
|
+
ids = records.to_set { |record| record["id"] }
|
|
80
|
+
records = older.reject { |record| ids.include?(record["id"]) } + records
|
|
81
|
+
end
|
|
82
|
+
[records, after]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Follows `after` until a page comes back empty, replacing records that changed.
|
|
86
|
+
def cursor_changes(after, params, headers)
|
|
87
|
+
records = []
|
|
88
|
+
loop do
|
|
89
|
+
newer, cursor = cursor_page(params.merge(after:), headers)
|
|
90
|
+
return [records, after] if newer.empty? || cursor["after"].nil?
|
|
91
|
+
|
|
92
|
+
ids = newer.to_set { |record| record["id"] }
|
|
93
|
+
records = records.reject { |record| ids.include?(record["id"]) } + newer
|
|
94
|
+
after = cursor["after"]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A cursor page is {"cursor" => {...}, "<records>" => [...]}, where the records' key varies by route.
|
|
99
|
+
def cursor_page(params, headers)
|
|
100
|
+
body = get(params:, headers:).body
|
|
101
|
+
records = body.find { |key, value| key != "cursor" && value.is_a?(Array) }&.last || []
|
|
102
|
+
[records, body["cursor"] || {}]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|