fetch-api 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c44fa15b798e01607f5c81e771ad55d24c56abea4aaa1829de0046aa2a761a2
4
- data.tar.gz: d8f8c324b7685c23d613ea1f00f5621bc23ef6e682aa66c0c181bb57c035d094
3
+ metadata.gz: bda47bde351ccf1a6f948922ec3feacc448084da4c00314ed2917cbf5f3e3b90
4
+ data.tar.gz: b4dcd409c7ed44f98cf5d40c3dca16fc0daa0e4ed255f1b965694cfa42d0d4dd
5
5
  SHA512:
6
- metadata.gz: 89fc2a039bac8f9dea78f86a6af9d195dff47f1e47fdfbf86026364a916be5dba60961daccd4d24a56f321f95823e24148c9e32df58b50736b7aba03e9ae24cf
7
- data.tar.gz: d60ae6beb1b1736cb4a5e71b5049742771a8905565475121f02e5d23b0691905575cb6f1ab55afcc8f7a97f2ed38fee6d169aa6f1f4220a9513ab01c3b97adc3
6
+ metadata.gz: 3cea9f26869b527d4dc03afdb8a4a6dd57d72c5cae14d42399feef89c5f9ccc56bf9a2afcfea6875b0f3aead7360b7af77b449bba0839b5faa239f0a1073910d
7
+ data.tar.gz: 6a37f3138b20dd12228a29960959a834e0e2306230c21b8783831231569ae79cdd909a402055bdba920617c091f6660f9a521ebcbcf244ad0716301114e042a2
data/README.md CHANGED
@@ -50,6 +50,12 @@ Options for `fetch` method:
50
50
  - `body`: Request body (default: `nil`)
51
51
  - `redirect`: Follow redirects (one of `:follow`, `:error`, `:manual`, default: `:follow`)
52
52
 
53
+ When following redirects, `fetch` behaves like the Fetch Standard:
54
+
55
+ - Gives up with `Fetch::RedirectError` after 20 redirects
56
+ - Changes the method to `GET` and drops the body on 301 / 302 (for `POST`) and 303 (for anything but `GET` / `HEAD`)
57
+ - Removes the `Authorization` header when redirected to a different origin
58
+
53
59
  Methods of `Fetch::Response` object:
54
60
 
55
61
  - `body`: Response body (String)
@@ -104,13 +110,13 @@ res = fetch('http://example.com', **{
104
110
 
105
111
  ### Connection pooling
106
112
 
107
- Fetch::API automatically pools and reuses connections to the same origin. Connections are closed after a certain amount of time has passed since the last use, and a new connection is used the next time. Different connections are returned for different threads (in other words, Fetch::API is thread-safe).
113
+ Fetch::API automatically pools and reuses connections to the same origin. Connections are closed after a certain amount of time has passed since the last use, and a new connection is used the next time. The pool is shared by all threads and a connection is never used by more than one thread at a time (in other words, Fetch::API is thread-safe). A forked process does not reuse the connections of its parent. The number of connections is not limited: as many connections as concurrent requests are opened, and they stay in the pool until they expire.
108
114
 
109
115
  These values can be configured as follows (in seconds):
110
116
 
111
117
  ``` ruby
112
118
  Fetch.configure do |config|
113
- config.connection_max_idle_time = 30 # default
119
+ config.connection_max_idle_time = 30 # default (Float::INFINITY to keep connections forever)
114
120
  end
115
121
  ```
116
122
 
@@ -145,7 +151,7 @@ end
145
151
 
146
152
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
147
153
 
148
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
154
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then push a `v*` tag for the version. GitHub Actions will push the `.gem` file to [rubygems.org](https://rubygems.org).
149
155
 
150
156
  ## Contributing
151
157
 
data/lib/fetch/client.rb CHANGED
@@ -15,12 +15,70 @@ module Fetch
15
15
  class Client
16
16
  include Singleton
17
17
 
18
- def fetch(resource, method:, headers:, body:, redirect:, redirected: false)
19
- uri = URI.parse(resource)
18
+ MAX_REDIRECTS = 20
19
+ REDIRECT_STATUSES = [301, 302, 303, 307, 308]
20
+ REDIRECT_MODES = %w(follow error manual)
21
+
22
+ BODY_HEADERS = %w(
23
+ content-encoding
24
+ content-language
25
+ content-length
26
+ content-location
27
+ content-type
28
+ )
29
+
30
+ def initialize
31
+ @pool = ConnectionPool.new
32
+ end
33
+
34
+ def fetch(resource, method:, headers:, body:, redirect:)
35
+ raise ArgumentError, "invalid redirect option: #{redirect.inspect}" unless REDIRECT_MODES.include?(redirect.to_s)
36
+
37
+ uri = URI.parse(resource)
38
+ method = method.to_s.upcase
39
+ headers = Headers.new(headers)
40
+
41
+ raise ArgumentError, "not an HTTP URL: #{uri}" unless uri.is_a?(URI::HTTP)
42
+
43
+ MAX_REDIRECTS.succ.times do |redirect_count|
44
+ res = request(uri, method, headers, body)
45
+ response = to_response(uri, res, redirect_count > 0)
46
+ location = res['Location']
47
+
48
+ return response if !REDIRECT_STATUSES.include?(response.status) || redirect.to_s == 'manual'
49
+
50
+ raise RedirectError, location ? "redirected to #{location}" : 'redirected without a location' if redirect.to_s == 'error'
51
+
52
+ return response unless location
53
+
54
+ redirected_uri = resolve(uri, location)
55
+
56
+ if rewrite_to_get?(response.status, method)
57
+ method = 'GET'
58
+ body = nil
59
+
60
+ BODY_HEADERS.each do |name|
61
+ headers.delete name
62
+ end
63
+ end
64
+
65
+ headers.delete 'authorization' unless redirected_uri.origin == uri.origin
66
+
67
+ rewind body
68
+
69
+ uri = redirected_uri
70
+ end
71
+
72
+ raise RedirectError, "too many redirects (more than #{MAX_REDIRECTS})"
73
+ end
74
+
75
+ private
76
+
77
+ def request(uri, method, headers, body)
20
78
  req = Net::HTTP.const_get(method.capitalize).new(uri)
21
79
 
22
80
  headers.each do |k, v|
23
- req[k.to_s] = v.to_s
81
+ req[k] = v
24
82
  end
25
83
 
26
84
  case body
@@ -41,42 +99,42 @@ module Fetch
41
99
  req.body = body
42
100
  end
43
101
 
44
- # @type var uri: URI::HTTP
45
- res = pool.with_connection(uri) { _1.request(req) }
46
-
47
- case res
48
- when Net::HTTPRedirection
49
- case redirect.to_s
50
- when 'follow'
51
- # @type var location: String
52
- location = res['Location']
53
-
54
- fetch(location, method:, headers:, body:, redirect:, redirected: true)
55
- when 'error'
56
- raise RedirectError, "redirected to #{res['Location']}"
57
- when 'manual'
58
- to_response(resource, res, redirected)
59
- else
60
- raise ArgumentError, "invalid redirect option: #{redirect.inspect}"
61
- end
102
+ @pool.with_connection(uri) { _1.request(req) }
103
+ end
104
+
105
+ # Like browsers, percent-encodes the characters that are not allowed in a URL instead of rejecting them.
106
+ def resolve(uri, location)
107
+ redirected_uri = URI.join(uri, location.b.gsub(/[^\x21-\x7e]/n) { format('%%%02X', _1.ord) })
108
+
109
+ raise RedirectError, "redirected to unsupported URL: #{location}" unless redirected_uri.is_a?(URI::HTTP) && !redirected_uri.host.to_s.empty?
110
+
111
+ redirected_uri
112
+ rescue URI::InvalidURIError
113
+ raise RedirectError, "redirected to invalid URL: #{location}"
114
+ end
115
+
116
+ def rewrite_to_get?(status, method)
117
+ case status
118
+ when 301, 302
119
+ method == 'POST'
120
+ when 303
121
+ !%w(GET HEAD).include?(method)
62
122
  else
63
- to_response(resource, res, redirected)
123
+ false
64
124
  end
65
125
  end
66
126
 
67
- private
127
+ def rewind(body)
128
+ return unless body.is_a?(FormData)
68
129
 
69
- def pool
70
- if pool = Thread.current.thread_variable_get(:fetch_connection_pool)
71
- pool
72
- else
73
- Thread.current.thread_variable_set(:fetch_connection_pool, ConnectionPool.new)
130
+ body.each do |_, v|
131
+ v.rewind if v.is_a?(File)
74
132
  end
75
133
  end
76
134
 
77
- def to_response(url, res, redirected)
135
+ def to_response(uri, res, redirected)
78
136
  Response.new(
79
- url: url.to_str,
137
+ url: uri.to_s,
80
138
  status: res.code.to_i,
81
139
  headers: Headers.new(res.each),
82
140
  body: res.body,
@@ -1,77 +1,129 @@
1
1
  require_relative '../fetch'
2
2
 
3
- require 'thread'
4
-
5
3
  module Fetch
6
4
  class ConnectionPool
5
+ SWEEP_INTERVAL = 1
6
+
7
7
  def initialize
8
8
  @connections = {}
9
9
  @mutex = Mutex.new
10
-
11
- @sweeper = Thread.new {
12
- loop do
13
- sweep
14
-
15
- if @connections.empty?
16
- Thread.stop
17
- else
18
- sleep 1
19
- end
20
- end
21
- }
10
+ @pid = Process.pid
22
11
  end
23
12
 
13
+ # The connection goes back to the pool only if the block completes. Otherwise it may still have a response in
14
+ # flight, which must not leak into the next request.
24
15
  def with_connection(uri, &block)
25
- conn = checkout(uri)
16
+ conn = checkout(uri)
17
+ completed = false
26
18
 
27
19
  begin
28
- block.call(conn)
20
+ result = block.call(conn)
21
+ completed = true
22
+
23
+ result
29
24
  ensure
30
- checkin uri, conn
25
+ completed ? checkin(uri, conn) : close([conn])
31
26
  end
32
27
  end
33
28
 
34
29
  private
35
30
 
36
31
  def checkout(uri)
37
- if entry = @mutex.synchronize { @connections.delete(uri.origin) }
38
- entry.first
39
- else
40
- # @type var host: String
41
- host = uri.host
32
+ expired, entry = synchronize { [sweep, @connections[uri.origin]&.pop] }
33
+
34
+ close expired
42
35
 
43
- Net::HTTP.new(host, uri.port).tap {|http|
44
- http.use_ssl = uri.scheme == 'https'
36
+ entry&.first || connect(uri)
37
+ end
45
38
 
46
- Fetch.config.on_connection_create.call http, uri
39
+ def checkin(uri, conn)
40
+ synchronize do
41
+ (@connections[uri.origin] ||= []) << [conn, now]
47
42
 
48
- http.start
49
- }
43
+ start_sweeper unless @sweeper&.alive?
50
44
  end
51
45
  end
52
46
 
53
- def checkin(uri, conn)
54
- @mutex.synchronize do
55
- @connections[uri.origin] = [conn, Time.now]
47
+ def connect(uri)
48
+ # @type var host: String
49
+ host = uri.host
50
+
51
+ Net::HTTP.new(host, uri.port).tap {|http|
52
+ http.use_ssl = uri.scheme == 'https'
53
+
54
+ Fetch.config.on_connection_create.call http, uri
55
+
56
+ http.start
57
+ }
58
+ end
59
+
60
+ def close(conns)
61
+ conns.each do |conn|
62
+ conn.finish
63
+ rescue StandardError
64
+ # The connection is discarded anyway.
56
65
  end
66
+ end
57
67
 
58
- @sweeper.wakeup
68
+ def now
69
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
70
  end
60
71
 
61
- def sweep
72
+ # Connections inherited from the parent process share their sockets with it, so a forked process starts over.
73
+ def synchronize(&block)
62
74
  @mutex.synchronize do
63
- @connections.each do |origin, (conn, last_used)|
64
- if last_used + Fetch.config.connection_max_idle_time < Time.now
65
- begin
66
- conn.finish
67
- rescue IOError
68
- # do nothing
69
- end
70
-
71
- @connections.delete origin
75
+ unless @pid == Process.pid
76
+ @connections = {}
77
+ @pid = Process.pid
78
+ end
79
+
80
+ block.call
81
+ end
82
+ end
83
+
84
+ # The sweeper retires once the pool is empty. It clears @sweeper under the lock so that a concurrent checkin never
85
+ # mistakes a retiring sweeper for a running one.
86
+ def start_sweeper
87
+ @sweeper = Thread.new {
88
+ loop do
89
+ # @type var expired: Array[Net::HTTP]
90
+ expired = []
91
+ retired = false
92
+
93
+ synchronize do
94
+ expired = sweep
95
+ retired = @connections.empty?
96
+ @sweeper = nil if retired
72
97
  end
98
+
99
+ close expired
100
+
101
+ break if retired
102
+
103
+ sleep SWEEP_INTERVAL
73
104
  end
105
+ }
106
+ end
107
+
108
+ # Removes the expired connections from the pool and returns them. They are closed outside the lock because closing
109
+ # may take a while.
110
+ def sweep
111
+ max_idle_time = Fetch.config.connection_max_idle_time
112
+ current = now
113
+
114
+ expired = @connections.flat_map {|_, entries|
115
+ expired, idle = entries.partition {|_, last_used| current - last_used > max_idle_time }
116
+
117
+ entries.replace idle
118
+
119
+ expired.map(&:first)
120
+ }
121
+
122
+ @connections.delete_if do |_, entries|
123
+ entries.empty?
74
124
  end
125
+
126
+ expired
75
127
  end
76
128
  end
77
129
  end
data/lib/fetch/headers.rb CHANGED
@@ -35,7 +35,7 @@ module Fetch
35
35
  end
36
36
 
37
37
  def set(key, value)
38
- @data[key.to_s.downcase] = [value]
38
+ @data[key.to_s.downcase] = [value.to_s]
39
39
  end
40
40
 
41
41
  def values
@@ -41,8 +41,8 @@ module Fetch
41
41
  @entries.map(&:last)
42
42
  end
43
43
 
44
- def each(&)
45
- @entries.each(&)
44
+ def each(&block)
45
+ block ? @entries.each(&block) : @entries.each
46
46
  end
47
47
 
48
48
  private
@@ -1,5 +1,5 @@
1
1
  require 'json'
2
- require 'rack/utils'
2
+ require 'net/http/status'
3
3
 
4
4
  module Fetch
5
5
  class Response
@@ -18,7 +18,7 @@ module Fetch
18
18
  end
19
19
 
20
20
  def status_text
21
- Rack::Utils::HTTP_STATUS_CODES[status]
21
+ Net::HTTP::STATUS_CODES[status]
22
22
  end
23
23
 
24
24
  def json(**json_parse_options)
data/lib/fetch/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Fetch
2
- VERSION = '0.6.0'
2
+ VERSION = '0.7.0'
3
3
  end
data/sig/fetch/api.rbs CHANGED
@@ -1,5 +1,5 @@
1
1
  module Fetch
2
- class API
2
+ module API
3
3
  def self?.fetch: (
4
4
  string,
5
5
  ?method: String | Symbol,
data/sig/fetch/client.rbs CHANGED
@@ -2,19 +2,32 @@ module Fetch
2
2
  class Client
3
3
  include Singleton
4
4
 
5
+ MAX_REDIRECTS: Integer
6
+ REDIRECT_STATUSES: Array[Integer]
7
+ REDIRECT_MODES: Array[String]
8
+ BODY_HEADERS: Array[String]
9
+
10
+ type body = (String | FormData | URLSearchParams)?
11
+
12
+ @pool: ConnectionPool
13
+
14
+ def self.instance: () -> Client
15
+
5
16
  def fetch: (
6
17
  string,
7
18
  method: String | Symbol,
8
19
  headers: _Each[[_ToS, _ToS]],
9
- body: (String | FormData | URLSearchParams)?,
10
- redirect: 'follow' | 'error' | 'manual' | :follow | :error | :manual,
11
- ?redirected: bool
20
+ body: body,
21
+ redirect: 'follow' | 'error' | 'manual' | :follow | :error | :manual
12
22
  ) -> Response
13
23
 
14
24
  private
15
25
 
16
26
  def initialize: () -> void
17
- def pool: () -> ConnectionPool
18
- def to_response: (string, Net::HTTPResponse, bool) -> Response
27
+ def request: (URI::HTTP, String, Headers, body) -> Net::HTTPResponse
28
+ def resolve: (URI::HTTP, String) -> URI::HTTP
29
+ def rewrite_to_get?: (Integer, String) -> bool
30
+ def rewind: (body) -> void
31
+ def to_response: (URI::HTTP, Net::HTTPResponse, bool) -> Response
19
32
  end
20
33
  end
data/sig/fetch/config.rbs CHANGED
@@ -1,7 +1,7 @@
1
1
  module Fetch
2
2
  class Config
3
- attr_accessor connection_max_idle_time: Integer
3
+ attr_accessor connection_max_idle_time: Integer | Float
4
4
  attr_accessor on_connection_create: ^(Net::HTTP, URI::HTTP) -> void
5
- attr_accessor json_parse_options: json_options
5
+ attr_accessor json_parse_options: JSON::options
6
6
  end
7
7
  end
@@ -1,5 +1,12 @@
1
1
  module Fetch
2
2
  class ConnectionPool
3
+ SWEEP_INTERVAL: Integer | Float
4
+
5
+ @connections: Hash[String, Array[[Net::HTTP, Float]]]
6
+ @mutex: Thread::Mutex
7
+ @pid: Integer
8
+ @sweeper: Thread?
9
+
3
10
  def initialize: () -> void
4
11
  def with_connection: [T] (URI::HTTP) { (Net::HTTP) -> T } -> T
5
12
 
@@ -7,6 +14,11 @@ module Fetch
7
14
 
8
15
  def checkout: (URI::HTTP) -> Net::HTTP
9
16
  def checkin: (URI::HTTP, Net::HTTP) -> void
10
- def sweep: () -> void
17
+ def connect: (URI::HTTP) -> Net::HTTP
18
+ def close: (Array[Net::HTTP]) -> void
19
+ def now: () -> Float
20
+ def synchronize: [T] () { () -> T } -> T
21
+ def start_sweeper: () -> void
22
+ def sweep: () -> Array[Net::HTTP]
11
23
  end
12
24
  end
@@ -2,7 +2,9 @@ module Fetch
2
2
  class Headers
3
3
  include Enumerable[[String, String]]
4
4
 
5
- def initialize: (_Each[[_ToS, _ToS]]) -> void
5
+ @data: Hash[String, Array[String]]
6
+
7
+ def initialize: (?_Each[[_ToS, _ToS]]) -> void
6
8
  def append: (_ToS, _ToS) -> void
7
9
  def delete: (_ToS) -> void
8
10
  def entries: () -> Array[[String, String]]
@@ -1,7 +1,9 @@
1
1
  module Fetch
2
- class MultiMap[KIN, KOUT, VIN, VOUT]
2
+ class MultiMap[KIN, KOUT < Comparable, VIN, VOUT]
3
3
  include Enumerable[[KOUT, VOUT]]
4
4
 
5
+ @entries: Array[[KOUT, VOUT]]
6
+
5
7
  def initialize: () -> void
6
8
  def append: (KIN, VIN) -> void
7
9
  def delete: (KIN) -> void
@@ -8,7 +8,7 @@ module Fetch
8
8
 
9
9
  def initialize: (url: String, status: Integer, headers: Headers, body: String?, redirected: bool) -> void
10
10
  def ok: () -> bool
11
- def status_text: () -> String
12
- def json: (**json_options) -> untyped
11
+ def status_text: () -> String?
12
+ def json: (**JSON::options) -> untyped
13
13
  end
14
14
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fetch-api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima
8
- autorequire:
9
- bindir: exe
8
+ bindir: bin
10
9
  cert_chain: []
11
- date: 2024-09-09 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: json
@@ -52,20 +51,6 @@ dependencies:
52
51
  - - ">="
53
52
  - !ruby/object:Gem::Version
54
53
  version: '0'
55
- - !ruby/object:Gem::Dependency
56
- name: rack
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - ">="
60
- - !ruby/object:Gem::Version
61
- version: '0'
62
- type: :runtime
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - ">="
67
- - !ruby/object:Gem::Version
68
- version: '0'
69
54
  - !ruby/object:Gem::Dependency
70
55
  name: singleton
71
56
  requirement: !ruby/object:Gem::Requirement
@@ -94,7 +79,6 @@ dependencies:
94
79
  - - ">="
95
80
  - !ruby/object:Gem::Version
96
81
  version: '0'
97
- description:
98
82
  email:
99
83
  - ursm@ursm.jp
100
84
  executables: []
@@ -134,7 +118,6 @@ licenses:
134
118
  metadata:
135
119
  homepage_uri: https://github.com/ursm/fetch-api
136
120
  source_code_uri: https://github.com/ursm/fetch-api.git
137
- post_install_message:
138
121
  rdoc_options: []
139
122
  require_paths:
140
123
  - lib
@@ -142,15 +125,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
142
125
  requirements:
143
126
  - - ">="
144
127
  - !ruby/object:Gem::Version
145
- version: 3.1.0
128
+ version: 3.3.0
146
129
  required_rubygems_version: !ruby/object:Gem::Requirement
147
130
  requirements:
148
131
  - - ">="
149
132
  - !ruby/object:Gem::Version
150
133
  version: '0'
151
134
  requirements: []
152
- rubygems_version: 3.5.16
153
- signing_key:
135
+ rubygems_version: 4.0.20
154
136
  specification_version: 4
155
137
  summary: Something like the Fetch API for Ruby
156
138
  test_files: []