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/path.rb CHANGED
@@ -1,31 +1,107 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "erb"
4
+ require_relative "pagination"
5
+
3
6
  module Esirb
7
+ # An ESI endpoint path built by chaining method calls, sent with an HTTP verb method.
8
+ #
9
+ # Every call returns a new Path, so a partial path can be reused:
10
+ #
11
+ # character = client.characters(123)
12
+ # character.location.to_s # => "/characters/123/location"
13
+ # character.skills.to_s # => "/characters/123/skills"
4
14
  class Path
5
- attr_reader :client, :string
15
+ include Pagination
16
+
17
+ VERBS = %i[get head delete post put patch].freeze
18
+
19
+ # Method names that become path segments. Conversion hooks like to_ary and to_str are left to Ruby.
20
+ SEGMENT_NAME = /\A(?!to_)[a-z][a-z0-9_]*\z/
21
+ SEGMENT_TYPES = [String, Symbol, Integer].freeze
22
+
23
+ # ESI segments with hyphens, which can't be Ruby method names. Calling the underscored name
24
+ # (client.freelance_jobs) sends the hyphenated segment. Segments keep their spelling across
25
+ # compatibility dates, so this covers every date. `rake esi:check_segments` compares it with
26
+ # ESI's OpenAPI spec for each date.
27
+ HYPHENATED_SEGMENTS = %w[
28
+ access-lists
29
+ compatibility-dates
30
+ freelance-jobs
31
+ mercenary-dens
32
+ mercenary-tactical-operations
33
+ military-campaigns
34
+ paragon-hub
35
+ recommended-fit
36
+ sovereignty-hubs
37
+ ].to_h { |segment| [segment.tr("-", "_"), segment] }.freeze
38
+
39
+ NO_BODY = Object.new.freeze
40
+ private_constant :NO_BODY
41
+
42
+ def self.segment_name?(name)
43
+ SEGMENT_NAME.match?(name.to_s)
44
+ end
45
+
46
+ attr_reader :client
47
+
48
+ def initialize(client:, segments: [])
49
+ raise ArgumentError, "client is required" if client.nil?
6
50
 
7
- def initialize(client: nil)
8
51
  @client = client
9
- @string = ""
52
+ @segments = segments.freeze
53
+ @path = segments.map { |segment| "/#{segment}" }.join.freeze
54
+ freeze
10
55
  end
11
56
 
12
- %i[get head delete trace post put patch].each do |verb|
13
- define_method verb do |**args|
14
- client.connection.send(verb, @string) do |req|
15
- req.params = args[:params] if args[:params]
16
- req.body = args[:body] if args[:body]
17
- req.headers = args[:headers] if args[:headers]
57
+ # get, head, delete, post, put and patch send the request and return the Faraday response.
58
+ # params: become the query string, body: is sent as JSON, and headers: are added to the
59
+ # client's defaults, replacing any with the same name.
60
+ VERBS.each do |verb|
61
+ define_method verb do |params: nil, body: NO_BODY, headers: nil|
62
+ client.connection.public_send(verb, to_s) do |req|
63
+ req.params.update(params) if params
64
+ req.body = body unless body.equal?(NO_BODY)
65
+ req.headers.update(headers) if headers
18
66
  end
19
67
  end
20
68
  end
21
69
 
22
- def method_missing(m, *args, &block)
23
- @string += "/#{m}"
24
- args.each do |arg|
25
- @string += "/#{arg}"
70
+ # The path, e.g. "/characters/123/location".
71
+ def to_s
72
+ @path
73
+ end
74
+
75
+ # Adds a segment and its path params to a copy of this path, exactly as given. Use it for
76
+ # segments that can't be reached by method name.
77
+ def segment(name, *params)
78
+ self.class.new(client:, segments: @segments + [name, *params].map { |value| encode(value) })
79
+ end
80
+
81
+ def method_missing(name, *params, **kwargs)
82
+ return super unless self.class.segment_name?(name)
83
+ if kwargs.any?
84
+ raise ArgumentError, "path segments only take positional params; pass query params to the HTTP verb as params:"
85
+ end
86
+
87
+ segment(HYPHENATED_SEGMENTS.fetch(name.to_s, name), *params)
88
+ end
89
+
90
+ def respond_to_missing?(name, include_private = false)
91
+ self.class.segment_name?(name) || super
92
+ end
93
+
94
+ private
95
+
96
+ def encode(value)
97
+ unless SEGMENT_TYPES.any? { |type| value.is_a?(type) }
98
+ raise ArgumentError, "path segments must be strings, symbols or integers, got #{value.inspect}"
26
99
  end
27
100
 
28
- self
101
+ text = value.to_s
102
+ raise ArgumentError, "path segments can't be #{text.inspect}" if ["", ".", ".."].include?(text)
103
+
104
+ ERB::Util.url_encode(text)
29
105
  end
30
106
  end
31
107
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require_relative "errors"
5
+
6
+ module Esirb
7
+ # Raises esirb's errors in place of Faraday's: a ResponseError subclass for error statuses, and
8
+ # ConnectionFailed, TimeoutError or Error for failures with no response. The Faraday error is
9
+ # kept as the exception's cause.
10
+ class RaiseError < Faraday::Middleware
11
+ def call(env)
12
+ super
13
+ rescue Faraday::TimeoutError => e
14
+ raise Esirb::TimeoutError, e.message
15
+ rescue Faraday::ConnectionFailed, Faraday::SSLError => e
16
+ raise Esirb::ConnectionFailed, e.message
17
+ rescue Faraday::Error => e
18
+ raise Esirb::Error, e.message
19
+ end
20
+
21
+ def on_complete(env)
22
+ return unless env.status.to_i >= 400
23
+
24
+ raise ResponseError.for_status(env.status), env
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Esirb
6
+ # ESI's limits as reported on one response. A route uses either the per-group rate limit
7
+ # (X-Ratelimit-*) or the older error limit (X-ESI-Error-Limit-*), never both, so the other
8
+ # set of values is nil. See https://developers.eveonline.com/docs/services/esi/rate-limiting/
9
+ #
10
+ # Esirb::RateLimit.new(response).remaining # => 1195
11
+ class RateLimit
12
+ WINDOW_UNITS = { "s" => 1, "m" => 60, "h" => 60 * 60 }.freeze
13
+ # With fewer errors than this left in the error limit window, esirb stops retrying, and with a
14
+ # cache store stops sending requests: running out blocks every ESI route until the window resets.
15
+ ERROR_LIMIT_FLOOR = 20
16
+ # Each reader and the header its whole-number value comes from.
17
+ HEADERS_BY_READER = {
18
+ remaining: "X-Ratelimit-Remaining",
19
+ used: "X-Ratelimit-Used",
20
+ error_limit_remaining: "X-ESI-Error-Limit-Remain",
21
+ error_limit_reset: "X-ESI-Error-Limit-Reset"
22
+ }.freeze
23
+
24
+ # group, limit (tokens per window), window (seconds), remaining and used (what this request
25
+ # cost) come from the rate limit; error_limit_remaining and error_limit_reset (seconds until
26
+ # the window resets) from the error limit.
27
+ attr_reader :group, :limit, :window, :remaining, :used, :error_limit_remaining, :error_limit_reset
28
+
29
+ # Takes a response (or anything with headers) or the headers themselves.
30
+ def initialize(source)
31
+ headers = Faraday::Utils::Headers.new.update(source.respond_to?(:headers) ? source.headers : source.to_h)
32
+
33
+ @group = headers["X-Ratelimit-Group"]
34
+ @limit, @window = parse_limit(headers["X-Ratelimit-Limit"])
35
+ HEADERS_BY_READER.each { |reader, header| instance_variable_set(:"@#{reader}", integer(headers[header])) }
36
+ end
37
+
38
+ # Whether the error limit is nearly used up. Routes on the newer rate limit don't send error
39
+ # limit headers, so this is false for them.
40
+ def error_limit_low?
41
+ !error_limit_remaining.nil? && error_limit_remaining < ERROR_LIMIT_FLOOR
42
+ end
43
+
44
+ private
45
+
46
+ # "150/15m" => [150, 900]
47
+ def parse_limit(value)
48
+ match = value.to_s.match(%r{\A(\d+)/(\d+)([smh])\z})
49
+ return [nil, nil] unless match
50
+
51
+ [match[1].to_i, match[2].to_i * WINDOW_UNITS.fetch(match[3])]
52
+ end
53
+
54
+ def integer(value)
55
+ Integer(value, exception: false) if value
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require_relative "errors"
5
+
6
+ module Esirb
7
+ # Retries requests that failed for reasons that usually clear up on their own, up to `retries`
8
+ # times in all, whatever the reason. Sits outside RaiseError, so it sees esirb's errors.
9
+ class Retry < Faraday::Middleware
10
+ # Usually transient: ESI tends to recover within a second or two.
11
+ TRANSIENT = [BadGateway, ServiceUnavailable, GatewayTimeout, TimeoutError, ConnectionFailed].freeze
12
+ # Safe to repeat. POST isn't, since it might do something twice, like sending a mail.
13
+ METHODS = %i[delete get head put].freeze
14
+ # Longest Retry-After worth waiting for, in seconds. Anything longer is raised for the app to handle.
15
+ MAX_WAIT = 10
16
+
17
+ # A method of its own, so specs can skip the wait.
18
+ def self.pause(seconds)
19
+ sleep(seconds)
20
+ end
21
+
22
+ def initialize(app, retries:)
23
+ super(app)
24
+ @retries = retries
25
+ end
26
+
27
+ # body is the original request body: after a failure, env.body holds the response's.
28
+ def call(env, body: env.body, attempt: 0)
29
+ env.body = body
30
+ @app.call(env)
31
+ rescue Error => e
32
+ wait = wait_for(env, e, attempt)
33
+ raise unless wait
34
+
35
+ self.class.pause(wait)
36
+ call(env, body:, attempt: attempt + 1)
37
+ end
38
+
39
+ private
40
+
41
+ # Seconds to wait before retrying, or nil to raise the error.
42
+ def wait_for(env, error, attempt)
43
+ return unless retry_allowed?(env, error, attempt)
44
+ return retry_after(error) if error.is_a?(TooManyRequests)
45
+ return unless TRANSIENT.any? { |error_class| error.is_a?(error_class) }
46
+
47
+ retry_after_header?(error) ? retry_after(error) : backoff(attempt)
48
+ end
49
+
50
+ def retry_allowed?(env, error, attempt)
51
+ attempt < @retries && METHODS.include?(env.method) && !error_limit_low?(error)
52
+ end
53
+
54
+ # Each failed retry is another error, so stop when the error limit is nearly used up.
55
+ def error_limit_low?(error)
56
+ error.is_a?(ResponseError) && error.rate_limit.error_limit_low?
57
+ end
58
+
59
+ # About 0.5s, then 1s, then 2s..., plus up to 50% so many clients don't retry in step.
60
+ def backoff(attempt)
61
+ 0.5 * (2**attempt) * (1 + (rand * 0.5))
62
+ end
63
+
64
+ def retry_after_header?(error)
65
+ error.is_a?(ResponseError) && !error.headers["Retry-After"].nil?
66
+ end
67
+
68
+ # ESI's Retry-After, or nil if it's missing or too long to wait for.
69
+ def retry_after(error)
70
+ seconds = Float(error.headers["Retry-After"], exception: false) if retry_after_header?(error)
71
+ seconds if seconds && seconds <= MAX_WAIT
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Esirb
6
+ # Adds Authorization to a response's Vary header when the request carried a token, so the
7
+ # ByVary cache strategy keys authenticated responses by token. ESI's Vary doesn't name it,
8
+ # which would let a cache store shared between clients serve one character's data to another.
9
+ class VaryByAuthorization < Faraday::Middleware
10
+ def on_complete(env)
11
+ return unless env.request_headers["Authorization"]
12
+
13
+ names = env.response_headers["Vary"].to_s.split(/[\s,]+/).reject(&:empty?)
14
+ return if names.include?("*") || names.any? { |name| name.casecmp?("Authorization") }
15
+
16
+ env.response_headers["Vary"] = [*names, "Authorization"].join(", ")
17
+ end
18
+ end
19
+ end
data/lib/esirb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Esirb
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/esirb.rb CHANGED
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "esirb/version"
4
+ require_relative "esirb/rate_limit"
5
+ require_relative "esirb/errors"
6
+ require_relative "esirb/configuration"
7
+ require_relative "esirb/compatibility_date"
8
+ require_relative "esirb/raise_error"
9
+ require_relative "esirb/retry"
10
+ require_relative "esirb/error_limit_guard"
11
+ require_relative "esirb/vary_by_authorization"
4
12
  require_relative "esirb/client"
13
+ require_relative "esirb/pagination"
5
14
  require_relative "esirb/path"
6
-
7
- module Esirb
8
- class Error < StandardError; end
9
- end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "esirb"
5
+
6
+ module Esirb
7
+ module Generators
8
+ # rails generate esirb:install
9
+ #
10
+ # Writes config/initializers/esirb.rb with esirb's settings and a note on each. Rails finds this
11
+ # by its path, so esirb has no Rails dependency, and nothing here loads outside a Rails generator.
12
+ class InstallGenerator < Rails::Generators::Base
13
+ source_root File.expand_path("templates", __dir__)
14
+ desc "Creates config/initializers/esirb.rb with esirb's settings for ESI."
15
+
16
+ def create_initializer
17
+ template "esirb.rb.tt", "config/initializers/esirb.rb"
18
+ end
19
+
20
+ private
21
+
22
+ # For the User-Agent placeholder, e.g. "my-app" for a MyApp application.
23
+ def app_name
24
+ application = Rails.application if Rails.respond_to?(:application)
25
+ return "my-app" unless application
26
+
27
+ application.class.module_parent_name.underscore.dasherize
28
+ end
29
+
30
+ def defaults
31
+ Configuration::DEFAULTS
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Settings for esirb, the client for EVE Online's ESI API. Options passed to Esirb::Client.new
4
+ # override these for that client. See https://github.com/sicks/esirb#configuration
5
+ Esirb.configure do |config|
6
+ # CCP asks every app using ESI to identify itself, ideally with a contact email. esirb adds its
7
+ # own name after this.
8
+ config.user_agent = "<%= app_name %>/1.0 (you@example.com)"
9
+
10
+ # The ESI behavior to use. A newer date can change response shapes, so check your code against
11
+ # it before moving it. Dates are listed at https://esi.evetech.net/meta/compatibility-dates
12
+ config.compatibility_date = "<%= defaults[:compatibility_date] %>"
13
+
14
+ # Where to cache ESI responses, shared by every client. Without a store nothing is cached, and
15
+ # esirb can't pause requests when ESI's error limit is nearly used up. Keep entries for longer
16
+ # than a day, so static data like universe types can be revalidated cheaply, e.g.
17
+ # ActiveSupport::Cache.lookup_store(:solid_cache_store, namespace: "esirb", expires_in: 2.days)
18
+ config.cache_store = Rails.cache
19
+
20
+ # Seconds to wait for a response, and to open the connection.
21
+ # config.timeout = <%= defaults[:timeout] %>
22
+ # config.open_timeout = <%= defaults[:open_timeout] %>
23
+
24
+ # Times to retry a 502, 503 or 504, a timeout, a connection failure or a short 429, on GET, HEAD,
25
+ # PUT and DELETE. 0 turns retries off.
26
+ # config.retries = <%= defaults[:retries] %>
27
+
28
+ # ESI's tenant and response language.
29
+ # config.tenant = "<%= defaults[:tenant] %>"
30
+ # config.language = "<%= defaults[:language] %>"
31
+
32
+ # Add Faraday middleware, change the adapter or set other connection options, e.g. logging in
33
+ # development. headers: false keeps access tokens out of the log.
34
+ # config.faraday = ->(f) { f.response :logger, Rails.logger, headers: false } if Rails.env.development?
35
+ end
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: esirb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - sicks
8
- bindir: exe
8
+ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -37,32 +37,42 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.7'
40
+ description: A light client for EVE Online's ESI API. Build any endpoint's path from
41
+ method calls, with caching, retries, rate and error limit handling, and pagination
42
+ helpers built in.
40
43
  email:
41
44
  - esirb@sicks.dev
42
45
  executables: []
43
46
  extensions: []
44
47
  extra_rdoc_files: []
45
48
  files:
46
- - ".rspec"
47
- - ".rubocop.yml"
48
- - ".ruby-version"
49
49
  - CHANGELOG.md
50
50
  - LICENSE.txt
51
51
  - README.md
52
- - Rakefile
53
52
  - lib/esirb.rb
54
53
  - lib/esirb/client.rb
54
+ - lib/esirb/compatibility_date.rb
55
+ - lib/esirb/configuration.rb
56
+ - lib/esirb/error_limit_guard.rb
57
+ - lib/esirb/errors.rb
58
+ - lib/esirb/pagination.rb
55
59
  - lib/esirb/path.rb
60
+ - lib/esirb/raise_error.rb
61
+ - lib/esirb/rate_limit.rb
62
+ - lib/esirb/retry.rb
63
+ - lib/esirb/vary_by_authorization.rb
56
64
  - lib/esirb/version.rb
57
- - sig/esirb.rbs
65
+ - lib/generators/esirb/install/install_generator.rb
66
+ - lib/generators/esirb/install/templates/esirb.rb.tt
58
67
  homepage: https://github.com/sicks/esirb
59
68
  licenses:
60
69
  - MIT
61
70
  metadata:
62
71
  allowed_push_host: https://rubygems.org
63
- homepage_uri: https://github.com/sicks/esirb
64
72
  source_code_uri: https://github.com/sicks/esirb
65
73
  changelog_uri: https://github.com/sicks/esirb/blob/main/CHANGELOG.md
74
+ bug_tracker_uri: https://github.com/sicks/esirb/issues
75
+ rubygems_mfa_required: 'true'
66
76
  rdoc_options: []
67
77
  require_paths:
68
78
  - lib
@@ -70,7 +80,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
70
80
  requirements:
71
81
  - - ">="
72
82
  - !ruby/object:Gem::Version
73
- version: 3.1.0
83
+ version: 3.3.0
74
84
  required_rubygems_version: !ruby/object:Gem::Requirement
75
85
  requirements:
76
86
  - - ">="
@@ -79,5 +89,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
89
  requirements: []
80
90
  rubygems_version: 4.0.15
81
91
  specification_version: 4
82
- summary: Ruby interface for Eve Online's Swagger API
92
+ summary: Ruby client for EVE Online's ESI API
83
93
  test_files: []
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
data/.rubocop.yml DELETED
@@ -1,8 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 3.1
3
-
4
- Style/StringLiterals:
5
- EnforcedStyle: double_quotes
6
-
7
- Style/StringLiteralsInInterpolation:
8
- EnforcedStyle: double_quotes
data/.ruby-version DELETED
@@ -1 +0,0 @@
1
- ruby-3.4.7
data/Rakefile DELETED
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- require "rspec/core/rake_task"
5
-
6
- RSpec::Core::RakeTask.new(:spec)
7
-
8
- require "rubocop/rake_task"
9
-
10
- RuboCop::RakeTask.new
11
-
12
- task default: %i[spec rubocop]
data/sig/esirb.rbs DELETED
@@ -1,4 +0,0 @@
1
- module Esirb
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end