esirb 0.3.0 → 0.4.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.
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
- BASE_URL = "https://esi.evetech.net"
9
- DEFAULT_HEADERS = {
10
- "Accept" => "application/json",
11
- "Accept-Language" => "en",
12
- "If-Modified-Since" => "",
13
- "If-None-Match" => "",
14
- "X-Tenant" => "tranquility",
15
- "X-Compatibility-Date" => "2026-06-09"
16
- }.freeze
17
-
18
- attr_reader :token, :path, :tenant, :language, :cache, :cache_store, :raise_errors
19
-
20
- def initialize(token: nil, tenant: "tranquility", language: "en", cache: true, cache_store: nil, raise_errors: true)
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
- @tenant = tenant
23
- @language = language
24
- @cache = cache
25
- @cache_store = cache_store
26
- @raise_errors = raise_errors
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
- def connection
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
- c.headers = DEFAULT_HEADERS
36
- c.headers["X-Tenant"] = tenant if tenant
37
- c.headers["Accept-Language"] = language if language
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
- c.request :authorization, "Bearer", token if token
40
- c.request :json
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
- c.response :raise_error if raise_errors
43
- c.response :json, content_type: "application/json"
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 method_missing(m, *args, &block)
48
- @path = Path.new(client: self).send(m.to_sym, *args)
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
@@ -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