diff --git a/.editorconfig b/.editorconfig index 269d98a..a6e7d26 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,3 +4,6 @@ root = true indent_style = tab indent_size = 2 +[*.{yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.github/workflows/documentation-coverage.yaml b/.github/workflows/documentation-coverage.yaml new file mode 100644 index 0000000..8d801c5 --- /dev/null +++ b/.github/workflows/documentation-coverage.yaml @@ -0,0 +1,25 @@ +name: Documentation Coverage + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + COVERAGE: PartialSummary + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - name: Validate coverage + timeout-minutes: 5 + run: bundle exec bake decode:index:coverage lib diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml new file mode 100644 index 0000000..e47c6b3 --- /dev/null +++ b/.github/workflows/documentation.yaml @@ -0,0 +1,58 @@ +name: Documentation + +on: + push: + branches: + - main + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment: +concurrency: + group: "pages" + cancel-in-progress: true + +env: + CONSOLE_OUTPUT: XTerm + BUNDLE_WITH: maintenance + +jobs: + generate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - name: Installing packages + run: sudo apt-get install wget + + - name: Generate documentation + timeout-minutes: 5 + run: bundle exec bake utopia:project:static --force no + + - name: Upload documentation artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs + + deploy: + runs-on: ubuntu-latest + + environment: + name: github-pages + url: ${{steps.deployment.outputs.page_url}} + + needs: generate + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/rubocop.yaml b/.github/workflows/rubocop.yaml new file mode 100644 index 0000000..287c06d --- /dev/null +++ b/.github/workflows/rubocop.yaml @@ -0,0 +1,24 @@ +name: RuboCop + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ruby + bundler-cache: true + + - name: Run RuboCop + timeout-minutes: 10 + run: bundle exec rubocop diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml new file mode 100644 index 0000000..e6dc5c3 --- /dev/null +++ b/.github/workflows/test-coverage.yaml @@ -0,0 +1,59 @@ +name: Test Coverage + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + COVERAGE: PartialSummary + +jobs: + test: + name: ${{matrix.ruby}} on ${{matrix.os}} + runs-on: ${{matrix.os}}-latest + + strategy: + matrix: + os: + - ubuntu + - macos + + ruby: + - "3.4" + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{matrix.ruby}} + bundler-cache: true + + - name: Run tests + timeout-minutes: 5 + run: bundle exec bake test + + - uses: actions/upload-artifact@v4 + with: + include-hidden-files: true + if-no-files-found: error + name: coverage-${{matrix.os}}-${{matrix.ruby}} + path: .covered.db + + validate: + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - uses: actions/download-artifact@v4 + + - name: Validate coverage + timeout-minutes: 5 + run: bundle exec bake covered:validate --paths */.covered.db \; diff --git a/.github/workflows/test-external.yaml b/.github/workflows/test-external.yaml new file mode 100644 index 0000000..c9cc200 --- /dev/null +++ b/.github/workflows/test-external.yaml @@ -0,0 +1,37 @@ +name: Test External + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + +jobs: + test: + name: ${{matrix.ruby}} on ${{matrix.os}} + runs-on: ${{matrix.os}}-latest + + strategy: + matrix: + os: + - ubuntu + - macos + + ruby: + - "3.1" + - "3.2" + - "3.3" + - "3.4" + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{matrix.ruby}} + bundler-cache: true + + - name: Run tests + timeout-minutes: 10 + run: bundle exec bake test:external diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..5d597fa --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,51 @@ +name: Test + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + +jobs: + test: + name: ${{matrix.ruby}} on ${{matrix.os}} + runs-on: ${{matrix.os}}-latest + continue-on-error: ${{matrix.experimental}} + + strategy: + matrix: + os: + - ubuntu + - macos + + ruby: + - "3.1" + - "3.2" + - "3.3" + - "3.4" + + experimental: [false] + + include: + - os: ubuntu + ruby: truffleruby + experimental: true + - os: ubuntu + ruby: jruby + experimental: true + - os: ubuntu + ruby: head + experimental: true + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{matrix.ruby}} + bundler-cache: true + + - name: Run tests + timeout-minutes: 10 + run: bundle exec bake test diff --git a/.gitignore b/.gitignore index 5b8da26..09a72e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,5 @@ /.bundle/ -/.yardoc -/Gemfile.lock -/_yardoc/ -/coverage/ -/doc/ /pkg/ -/spec/reports/ -/tmp/ - -# rspec failure tracking -.rspec_status -.covered.db +/gems.locked +/.covered.db +/external diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..ca19b6b --- /dev/null +++ b/.mailmap @@ -0,0 +1,2 @@ +Flavio Fernandes +Korbin Hoffman \ No newline at end of file diff --git a/.rspec b/.rspec deleted file mode 100644 index 8fbe32d..0000000 --- a/.rspec +++ /dev/null @@ -1,3 +0,0 @@ ---format documentation ---warnings ---require spec_helper \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..3b8d476 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,53 @@ +AllCops: + DisabledByDefault: true + +Layout/IndentationStyle: + Enabled: true + EnforcedStyle: tabs + +Layout/InitialIndentation: + Enabled: true + +Layout/IndentationWidth: + Enabled: true + Width: 1 + +Layout/IndentationConsistency: + Enabled: true + EnforcedStyle: normal + +Layout/BlockAlignment: + Enabled: true + +Layout/EndAlignment: + Enabled: true + EnforcedStyleAlignWith: start_of_line + +Layout/BeginEndAlignment: + Enabled: true + EnforcedStyleAlignWith: start_of_line + +Layout/ElseAlignment: + Enabled: true + +Layout/DefEndAlignment: + Enabled: true + +Layout/CaseIndentation: + Enabled: true + +Layout/CommentIndentation: + Enabled: true + +Layout/EmptyLinesAroundClassBody: + Enabled: true + +Layout/EmptyLinesAroundModuleBody: + Enabled: true + +Style/FrozenStringLiteralComment: + Enabled: true + +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6d5ec67..0000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: ruby -dist: trusty -cache: bundler - -script: bundle exec rspec - -matrix: - include: - - rvm: 2.5 - - rvm: 2.6 - - rvm: 2.7 - - rvm: jruby-head - env: JRUBY_OPTS="--debug -X+O" - - rvm: truffleruby - - rvm: ruby-head - allow_failures: - - rvm: ruby-head - - rvm: truffleruby - - rvm: jruby-head diff --git a/Gemfile b/Gemfile deleted file mode 100644 index 8614585..0000000 --- a/Gemfile +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -source 'https://rubygems.org' - -gemspec - -# gem "faraday", git: "https://github.com/lostisland/faraday.git" -# gem "async-http", path: "../async-http" diff --git a/README.md b/README.md deleted file mode 100644 index 288d9a4..0000000 --- a/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# Async::HTTP::Faraday - -Provides an adaptor for [Faraday] to perform async HTTP requests. If you are designing a new library, you should probably just use `Async::HTTP::Client` directly. - -[![Build Status](https://travis-ci.com/socketry/async-http-faraday.svg?branch=master)](https://travis-ci.com/socketry/async-http-faraday) -[![Code Climate](https://codeclimate.com/github/socketry/async-http-faraday.svg)](https://codeclimate.com/github/socketry/async-http-faraday) -[![Coverage Status](https://coveralls.io/repos/socketry/async-http-faraday/badge.svg)](https://coveralls.io/r/socketry/async-http-faraday) - -[async]: https://github.com/socketry/async -[async-io]: https://github.com/socketry/async-io -[Faraday]: https://github.com/lostisland/faraday - -## Installation - -Add this line to your application's Gemfile: - -```ruby -gem 'async-http-faraday' -``` - -And then execute: - - $ bundle - -Or install it yourself as: - - $ gem install async-http-faraday - -## Usage - -Here is how you set faraday to use `Async::HTTP`: - -```ruby -require 'async/http/faraday' - -# Make it the global default: -Faraday.default_adapter = :async_http - -# Per connection: -conn = Faraday.new(...) do |faraday| - faraday.adapter :async_http -end -``` - -Here is how you make a request: - -```ruby -Async do - response = conn.get("/index") -end -``` - -### Default - -To make this the default adaptor: - -```ruby -require 'async/http/faraday/default' -``` - -## Contributing - -1. Fork it -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create new Pull Request - -## License - -Released under the MIT license. - -Copyright, 2015, by [Samuel G. D. Williams](http://www.codeotaku.com/samuel-williams). - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/async-http-faraday.gemspec b/async-http-faraday.gemspec index f556ffd..e7c2b65 100644 --- a/async-http-faraday.gemspec +++ b/async-http-faraday.gemspec @@ -1,29 +1,30 @@ +# frozen_string_literal: true -require_relative 'lib/async/http/faraday/version' +require_relative "lib/async/http/faraday/version" Gem::Specification.new do |spec| - spec.name = "async-http-faraday" - spec.version = Async::HTTP::Faraday::VERSION - spec.authors = ["Samuel Williams"] - spec.email = ["samuel.williams@oriontransfer.co.nz"] - - spec.summary = "Provides an adaptor between async-http and faraday." - spec.homepage = "https://github.com/socketry/async-http" - - spec.files = `git ls-files -z`.split("\x0").reject do |f| - f.match(%r{^(test|spec|features)/}) - end - spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } - spec.require_paths = ["lib"] + spec.name = "async-http-faraday" + spec.version = Async::HTTP::Faraday::VERSION + + spec.summary = "Provides an adaptor between async-http and faraday." + spec.authors = ["Samuel Williams", "Igor Sidorov", "Andreas Garnaes", "Genki Takiuchi", "Nikolaos Anastopoulos", "Olle Jonsson", "Benoit Daloze", "Denis Talakevich", "Flavio Fernandes", "Jacob Frautschi"] + spec.license = "MIT" + + spec.cert_chain = ["release.cert"] + spec.signing_key = File.expand_path("~/.gem/release.pem") + + spec.homepage = "https://github.com/socketry/async-http-faraday" - spec.add_dependency("async-http", "~> 0.42") - spec.add_dependency("faraday") + spec.metadata = { + "documentation_uri" => "https://socketry.github.io/async-http-faraday/", + "funding_uri" => "https://github.com/sponsors/ioquatix", + "source_code_uri" => "https://github.com/socketry/async-http.git", + } - spec.add_development_dependency "async-rspec", "~> 1.2" + spec.files = Dir.glob(["{examples,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) - spec.add_development_dependency "bake-bundler" + spec.required_ruby_version = ">= 3.1" - spec.add_development_dependency "covered" - spec.add_development_dependency "bundler" - spec.add_development_dependency "rspec", "~> 3.6" + spec.add_dependency "async-http", "~> 0.42" + spec.add_dependency "faraday" end diff --git a/bake.rb b/bake.rb new file mode 100644 index 0000000..ada44f4 --- /dev/null +++ b/bake.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024-2025, by Samuel Williams. + +# Update the project documentation with the new version number. +# +# @parameter version [String] The new version number. +def after_gem_release_version_increment(version) + context["releases:update"].call(version) + context["utopia:project:readme:update"].call +end diff --git a/config/sus.rb b/config/sus.rb new file mode 100644 index 0000000..546c2af --- /dev/null +++ b/config/sus.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024-2025, by Samuel Williams. + +require "covered/sus" +include Covered::Sus diff --git a/examples/topics.rb b/examples/topics.rb index 12bf4b7..4afe33c 100644 --- a/examples/topics.rb +++ b/examples/topics.rb @@ -1,16 +1,19 @@ #!/usr/bin/env ruby # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2020-2025, by Samuel Williams. + $LOAD_PATH.unshift File.expand_path("../lib", __dir__) -require 'async' -require 'faraday' -require 'async/http/faraday' +require "async" +require "faraday" +require "async/http/faraday" # Async.logger.debug! module TestAsync - URL = 'https://www.google.com/search' + URL = "https://www.google.com/search" TOPICS = %W{ruby python lisp javascript cobol} def self.fetch_topics_async diff --git a/gems.rb b/gems.rb new file mode 100644 index 0000000..bda2eff --- /dev/null +++ b/gems.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2018-2025, by Samuel Williams. + +source "https://rubygems.org" + +gemspec + +group :maintenance, optional: true do + gem "bake-modernize" + gem "bake-gem" + gem "bake-releases" + + gem "utopia-project" +end + +group :test do + gem "sus" + gem "covered" + gem "decode" + gem "rubocop" + + gem "bake-test" + gem "bake-test-external" + + gem "faraday-multipart" + + gem "sus-fixtures-async" + gem "sus-fixtures-async-http" +end diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md new file mode 100644 index 0000000..a847bee --- /dev/null +++ b/guides/getting-started/readme.md @@ -0,0 +1,61 @@ +# Getting Started + +This guide explains how to use use `Async::HTTP::Faraday` as a drop-in replacement for improved concurrency. + +## Installation + +Add the gem to your project: + +~~~ bash +$ bundle add async-http-faraday +~~~ + +## Usage + +The simplest way to use `Async::HTTP::Faraday` is to set it as the default adapter for Faraday. This will make all requests asynchronous. + +~~~ ruby +require 'async/http/faraday/default' +~~~ + +This will configure `Faraday.default_adapter`. + +### Custom Connection + +You can configure a custom connection to use the async adapter: + +``` ruby +# Per connection: +connection = Faraday.new(...) do |builder| + builder.adapter :async_http +end +``` + +Here is how you make a request: + +``` ruby +response = connection.get("/index") +``` + +### Thread Safety + +By default, the faraday adapter uses a per-thread persistent client cache. This is safe to use in multi-threaded environments, in other words, if you have a single global faraday connection, and use that everywhere, it will be thread-safe. However, a consequence of that is you may experience elevated memory usage if you have many threads, as each thread will have its own connection pool. This is a desirable share-nothing architecture which helps to isolate problems, but if you don't use a multi-threaded environment, you may want to avoid the overhead. You can do this by configuring the `clients` option: + +~~~ruby +connection = Faraday.new(...) do |builder| + # The default `clients:` is `Async::HTTP::Faraday::PerThreadPersistentClients`. + builder.adapter :async_http, clients: Async::HTTP::Faraday::PersistentClients +end +~~~ + +The value of isolation cannot be overstated - if you can design you program using a share-nothing (between threads) architecture, you will have a much easier time debugging and reasoning about your program, however this comes at the cost of increased resource usage. + +Alternatively, if you do not want to cache client connections, you can use the `Async::HTTP::Faraday::Clients` interface, which closes the connection after each request: + +~~~ruby +connection = Faraday.new(...) do |builder| + builder.adapter :async_http, clients: Async::HTTP::Faraday::Clients +end +~~~ + +This will reduce memory usage but increase the latency of every request. diff --git a/lib/async/http/faraday.rb b/lib/async/http/faraday.rb index fcc3d7a..700c986 100644 --- a/lib/async/http/faraday.rb +++ b/lib/async/http/faraday.rb @@ -1,26 +1,17 @@ # frozen_string_literal: true -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Released under the MIT License. +# Copyright, 2018-2025, by Samuel Williams. require_relative "faraday/version" -require_relative "faraday/adapter" +require_relative "faraday/register" -Faraday::Adapter.register_middleware :async_http => Async::HTTP::Faraday::Adapter +# @namespace +module Async + # @namespace + module HTTP + # @namespace + module Faraday + end + end +end diff --git a/lib/async/http/faraday/adapter.rb b/lib/async/http/faraday/adapter.rb index a7d8bb6..089b787 100644 --- a/lib/async/http/faraday/adapter.rb +++ b/lib/async/http/faraday/adapter.rb @@ -1,39 +1,103 @@ # frozen_string_literal: true -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Released under the MIT License. +# Copyright, 2018-2025, by Samuel Williams. +# Copyright, 2018, by Andreas Garnaes. +# Copyright, 2019, by Denis Talakevich. +# Copyright, 2019-2020, by Igor Sidorov. +# Copyright, 2023, by Genki Takiuchi. +# Copyright, 2023, by Flavio Fernandes. +# Copyright, 2024, by Jacob Frautschi. +# Copyright, 2025, by Nikolaos Anastopoulos. -require 'faraday' -require 'faraday/adapter' -require 'kernel/sync' -require 'async/http/internet' +require "faraday" +require "faraday/adapter" -require_relative 'agent' +require "async/barrier" +require "kernel/sync" + +require "async/http/client" +require "async/http/proxy" + +require_relative "clients" module Async module HTTP module Faraday - # Detect whether we can use persistent connections: - PERSISTENT = ::Faraday::Connection.instance_methods.include?(:close) + # This is a simple wrapper around Faraday's body that allows it to be read in chunks. + class BodyReadWrapper < ::Protocol::HTTP::Body::Readable + # Create a new wrapper around the given body. + # + # The body must respond to `#read` and `#close` and is often an instance of `IO` or `Faraday::Multipart::CompositeReadIO`. + # + # @parameter body [Interface(:read)] The input body to wrap. + # @parameter block_size [Integer] The size of the blocks to read from the body. + def initialize(body, block_size: 4096) + @body = body + @block_size = block_size + end + + # Close the body if possible. + def close(error = nil) + @body.close if @body.respond_to?(:close) + ensure + super + end + + # Read from the body in chunks. + def read + @body.read(@block_size) + end + end + + # Implement the Faraday parallel manager interface, using Async. + class ParallelManager + # Create a new parallel manager. + def initialize(options = {}) + @options = options + @barrier = nil + end + + # @deprecated Please update your Faraday version! + def run + if $VERBOSE + warn "Please update your Faraday version!", uplevel: 2 + end + end + + # Run the given block asynchronously, using the barrier if available. + def async(&block) + if @barrier + @barrier.async(&block) + else + Sync(&block) + end + end + + # Execute the given block which can perform multiple concurrent requests, waiting for them all to complete. + def execute(&block) + Sync do + @barrier = Async::Barrier.new + + yield + + @barrier.wait + ensure + @barrier&.stop + end + end + end + # An adapter that allows Faraday to use Async::HTTP as the underlying HTTP client. class Adapter < ::Faraday::Adapter + self.supports_parallel = true + + # Create a new parallel manager, which is used to handle multiple concurrent requests. + def self.setup_parallel_manager(**options) + ParallelManager.new(options) + end + + # The exceptions that are considered connection errors and result in a `Faraday::ConnectionFailed` exception. CONNECTION_EXCEPTIONS = [ Errno::EADDRNOTAVAIL, Errno::ECONNABORTED, @@ -46,57 +110,167 @@ class Adapter < ::Faraday::Adapter IOError, SocketError ].freeze - - def initialize(*arguments, **options, &block) + + # Create a Farady compatible adapter. + # + # @parameter timeout [Integer] The timeout for requests. + # @parameter options [Hash] Additional options to pass to the underlying Async::HTTP::Client. + def initialize(...) super - @internet = Async::HTTP::Internet.new - @persistent = PERSISTENT && options.fetch(:persistent, true) - @timeout = options[:timeout] + @timeout = @connection_options.delete(:timeout) + @read_timeout = @connection_options.delete(:read_timeout) + + if clients = @connection_options.delete(:clients) + @clients = clients.call(**@connection_options, &@config_block) + else + @clients = PerThreadPersistentClients.new(**@connection_options, &@config_block) + end end + # @attribute [Numeric | Nil] The maximum time to send a request and wait for a response. + attr :timeout + + # @attribute [Numeric | Nil] The maximum time to wait for an individual IO operation. + attr :read_timeout + + # Close all clients. def close - @internet.close + # The order of operations here is to avoid a race condition between iterating over clients (#close may yield) and creating new clients. + @clients.close end + # Make a request using the adapter. + # + # @parameter env [Faraday::Env] The environment to make the request in. + # @raises [Faraday::TimeoutError] If the request times out. + # @raises [Faraday::SSLError] If there is an SSL error. + # @raises [Faraday::ConnectionFailed] If there is a connection error. def call(env) super - parent = Async::Task.current? + # For compatibility with the default adapter: + env.url.path = "/" if env.url.path.empty? - Sync do - with_timeout do - response = @internet.call(env[:method].to_s.upcase, env[:url].to_s, env[:request_headers], env[:body] || []) - - save_response(env, response.status, response.read, response.headers) + if parallel_manager = env.parallel_manager + parallel_manager.async do + perform_request(env) + env.response.finish(env) end - ensure - # If we are the top level task, even if we are persistent, we must close the connection: - if parent.nil? || !@persistent - @internet.close + else + perform_request(env) + end + + @app.call(env) + end + + private + + def perform_request(env) + with_client(env) do |endpoint, client| + if body = env.body + # We need to ensure the body is wrapped in a Readable object so that it can be read in chunks: + # Faraday's body only responds to `#read`. + if body.is_a?(::Protocol::HTTP::Body::Readable) + # Good to go + elsif body.respond_to?(:read) + body = BodyReadWrapper.new(body) + else + body = ::Protocol::HTTP::Body::Buffered.wrap(body) + end + end + + if headers = env.request_headers + headers = ::Protocol::HTTP::Headers[headers] + end + + method = env.method.to_s.upcase + + request = ::Protocol::HTTP::Request.new(endpoint.scheme, endpoint.authority, method, endpoint.path, nil, headers, body) + + with_timeout(env.request.timeout || @timeout) do + if env.stream_response? + response = env.stream_response do |&on_data| + response = client.call(request) + + save_response(env, response.status, nil, response.headers, finished: false) + + response.each do |chunk| + on_data.call(chunk) + end + + response + end + else + response = client.call(request) + end + + save_response(env, response.status, encoded_body(response), response.headers) end end return @app.call(env) - rescue Errno::ETIMEDOUT, Async::TimeoutError => e - raise ::Faraday::TimeoutError, e - rescue OpenSSL::SSL::SSLError => e - raise ::Faraday::SSLError, e - rescue *CONNECTION_EXCEPTIONS => e - raise ::Faraday::ConnectionFailed, e + rescue Errno::ETIMEDOUT, Async::TimeoutError => error + raise ::Faraday::TimeoutError, error + rescue OpenSSL::SSL::SSLError => error + raise ::Faraday::SSLError, error + rescue *CONNECTION_EXCEPTIONS => error + raise ::Faraday::ConnectionFailed, error end - - private - - def with_timeout(task: Async::Task.current) - if @timeout - task.with_timeout(@timeout, ::Faraday::TimeoutError) do + + def with_client(env) + Sync do + endpoint = Endpoint.new(env.url, timeout: @read_timeout) + + if proxy = env.request.proxy + proxy_endpoint = Endpoint.new(proxy.uri) + + @clients.with_proxied_client(proxy_endpoint, endpoint) do |client| + yield endpoint, client + end + else + @clients.with_client(endpoint) do |client| + yield endpoint, client + end + end + end + end + + def with_timeout(timeout = @timeout, task: Async::Task.current) + if timeout + task.with_timeout(timeout, ::Faraday::TimeoutError) do yield end else yield end end + + def encoded_body(response) + body = response.read + return "" if body.nil? + content_type = response.headers["content-type"] + return body unless content_type + params = extract_type_parameters(content_type) + if charset = params["charset"] + body = body.dup if body.frozen? + body.force_encoding(charset) + end + body + rescue ArgumentError + nil + end + + def extract_type_parameters(content_type) + result = {} + list = content_type.split(";") + list.shift + list.each do |param| + key, value = *param.split("=", 2) + result[key.strip] = value.strip + end + result + end end end end diff --git a/lib/async/http/faraday/agent.rb b/lib/async/http/faraday/agent.rb deleted file mode 100644 index f3aa338..0000000 --- a/lib/async/http/faraday/agent.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -# Copyright, 2020, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -begin - require 'sawyer/agent' - - # This is a nasty hack until https://github.com/lostisland/sawyer/pull/67 is resolved: - unless Sawyer::Agent.instance_methods.include?(:close) - class Sawyer::Agent - def close - @conn.close if @conn.respond_to?(:close) - end - end - end -rescue LoadError - # Ignore. -end diff --git a/lib/async/http/faraday/clients.rb b/lib/async/http/faraday/clients.rb new file mode 100644 index 0000000..3e8d1a8 --- /dev/null +++ b/lib/async/http/faraday/clients.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024-2025, by Samuel Williams. + +require "faraday" +require "faraday/adapter" +require "kernel/sync" + +require "async/http/client" +require "async/http/proxy" + +module Async + module HTTP + module Faraday + # An interface for creating and managing HTTP clients. + class Clients + # Create a new instance of the class. + def self.call(...) + new(...) + end + + # Create a new interface for managing HTTP clients. + # + # @parameter options [Hash] The options to create the clients with. + # @parameter block [Proc] An optional block to call with the client before it is used. + def initialize(**options, &block) + @options = options + @block = block + end + + # Close all clients. + def close + end + + # Make a new client for the given endpoint. + # + # @parameter endpoint [IO::Endpoint::Generic] The endpoint to create the client for. + def make_client(endpoint) + client = Client.new(endpoint, **@options) + + return @block&.call(client) || client + end + + # Get a client for the given endpoint. + # + # @parameter endpoint [IO::Endpoint::Generic] The endpoint to get the client for. + # @yields {|client| ...} A client for the given endpoint. + def with_client(endpoint) + client = make_client(endpoint) + + yield client + ensure + client&.close + end + + # Get a client for the given proxy endpoint and endpoint. + # + # @parameter proxy_endpoint [IO::Endpoint::Generic] The proxy endpoint to use. + # @parameter endpoint [IO::Endpoint::Generic] The endpoint to get the client for. + # @yields {|client| ...} A client for the given endpoint. + def with_proxied_client(proxy_endpoint, endpoint) + client = make_client(proxy_endpoint) + proxied_client = client.proxied_client(endpoint) + + yield proxied_client + ensure + proxied_client&.close + client&.close + end + end + + # An interface for creating and managing persistent HTTP clients. + class PersistentClients < Clients + # Create a new instance of the class. + def initialize(...) + super + + @clients = {} + end + + # Close all clients. + def close + super + + clients = @clients.values + @clients.clear + + clients.each(&:close) + end + + # Lookup or create a client for the given endpoint. + # + # @parameter endpoint [IO::Endpoint::Generic] The endpoint to create the client for. + def make_client(endpoint) + key = host_key(endpoint) + + fetch(key) do + super + end + end + + # Get a client for the given endpoint. If a client already exists for the host, it will be reused. + # + # @yields {|client| ...} A client for the given endpoint. + def with_client(endpoint) + yield make_client(endpoint) + end + + # Get a client for the given proxy endpoint and endpoint. If a client already exists for the host, it will be reused. + # + # @parameter proxy_endpoint [IO::Endpoint::Generic] The proxy endpoint to use. + # @parameter endpoint [IO::Endpoint::Generic] The endpoint to get the client for. + def with_proxied_client(proxy_endpoint, endpoint) + key = [host_key(proxy_endpoint), host_key(endpoint)] + + proxied_client = fetch(key) do + make_client(proxy_endpoint).proxied_client(endpoint) + end + + yield proxied_client + end + + private + + def fetch(key) + @clients.fetch(key) do + @clients[key] = yield + end + end + + def host_key(endpoint) + url = endpoint.url.dup + + url.path = "" + url.fragment = nil + url.query = nil + + return url + end + end + + # An interface for creating and managing per-thread persistent HTTP clients. + class PerThreadPersistentClients + # Create a new instance of the class. + # + # @parameter options [Hash] The options to create the clients with. + # @parameter block [Proc] An optional block to call with the client before it is used. + def initialize(**options, &block) + @options = options + @block = block + + @key = :"#{self.class}_#{object_id}" + end + + # Get a client for the given endpoint. If a client already exists for the host, it will be reused. + # + # The client instance will be will be cached per-thread. + # + # @yields {|client| ...} A client for the given endpoint. + def with_client(endpoint, &block) + clients.with_client(endpoint, &block) + end + + # Get a client for the given proxy endpoint and endpoint. If a client already exists for the host, it will be reused. + # + # The client instance will be will be cached per-thread. + # + # @parameter proxy_endpoint [IO::Endpoint::Generic] The proxy endpoint to use. + # @parameter endpoint [IO::Endpoint::Generic] The endpoint to get the client for. + def with_proxied_client(proxy_endpoint, endpoint, &block) + clients.with_proxied_client(proxy_endpoint, endpoint, &block) + end + + # Close all clients. + # + # This will close all clients associated with all threads. + def close + Thread.list.each do |thread| + if clients = thread[@key] + clients.close + + thread[@key] = nil + end + end + end + + private + + def make_clients + PersistentClients.new(**@options, &@block) + end + + def clients + thread = Thread.current + + return thread.thread_variable_get(@key) || thread.thread_variable_set(@key, make_clients) + end + end + end + end +end diff --git a/lib/async/http/faraday/default.rb b/lib/async/http/faraday/default.rb index 7826931..fb9b941 100644 --- a/lib/async/http/faraday/default.rb +++ b/lib/async/http/faraday/default.rb @@ -1,25 +1,9 @@ # frozen_string_literal: true -# Copyright, 2020, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Released under the MIT License. +# Copyright, 2020-2025, by Samuel Williams. -require_relative 'adapter' +require_relative "register" +# Set the default adapter to use Async::HTTP. ::Faraday.default_adapter = :async_http diff --git a/lib/async/http/faraday/register.rb b/lib/async/http/faraday/register.rb new file mode 100644 index 0000000..93a0839 --- /dev/null +++ b/lib/async/http/faraday/register.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require_relative "adapter" + +Faraday::Adapter.register_middleware :async_http => Async::HTTP::Faraday::Adapter diff --git a/lib/async/http/faraday/version.rb b/lib/async/http/faraday/version.rb index 4390055..5e4d203 100644 --- a/lib/async/http/faraday/version.rb +++ b/lib/async/http/faraday/version.rb @@ -1,29 +1,12 @@ # frozen_string_literal: true -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Released under the MIT License. +# Copyright, 2018-2025, by Samuel Williams. module Async module HTTP module Faraday - VERSION = "0.9.0" + VERSION = "0.21.0" end end end diff --git a/license.md b/license.md new file mode 100644 index 0000000..e0feeee --- /dev/null +++ b/license.md @@ -0,0 +1,30 @@ +# MIT License + +Copyright, 2018-2025, by Samuel Williams. +Copyright, 2018, by Andreas Garnaes. +Copyright, 2019, by Denis Talakevich. +Copyright, 2019-2020, by Igor Sidorov. +Copyright, 2020-2021, by Olle Jonsson. +Copyright, 2020, by Benoit Daloze. +Copyright, 2023, by Genki Takiuchi. +Copyright, 2023, by Flavio Fernandes. +Copyright, 2024, by Jacob Frautschi. +Copyright, 2025, by Nikolaos Anastopoulos. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..c863014 --- /dev/null +++ b/readme.md @@ -0,0 +1,57 @@ +# Async::HTTP::Faraday + +Provides an adaptor for [Faraday](https://github.com/lostisland/faraday) to perform async HTTP requests. If you are designing a new library, you should probably just use `Async::HTTP::Client` directly. However, for existing projects and libraries that use Faraday as an abstract interface, this can be a drop-in replacement to improve concurrency. It should be noted that the default `Net::HTTP` adapter works perfectly okay with Async, however it does not use persistent connections by default. + + - Persistent connections by default. + - Supports HTTP/1 and HTTP/2 (and HTTP/3 in the future). + +[![Development Status](https://github.com/socketry/async-http-faraday/workflows/Test/badge.svg)](https://github.com/socketry/async-http-faraday/actions?workflow=Test) + +## Usage + +Please see the [project documentation](https://socketry.github.io/async-http-faraday/) for more details. + + - [Getting Started](https://socketry.github.io/async-http-faraday/guides/getting-started/index) - This guide explains how to use use `Async::HTTP::Faraday` as a drop-in replacement for improved concurrency. + +## Releases + +Please see the [project releases](https://socketry.github.io/async-http-faraday/releases/index) for all releases. + +### v0.21.0 + + - [Improved support for `timeout` and `read_timeout`.](https://socketry.github.io/async-http-faraday/releases/index#improved-support-for-timeout-and-read_timeout.) + +### v0.20.0 + + - Implement the new response streaming interface, which provides the initial response status code and headers before streaming the response body. + - An empty response now sets the response body to an empty string rather than `nil` as required by the Faraday specification. + +### v0.19.0 + + - [Support `in_parallel`.](https://socketry.github.io/async-http-faraday/releases/index#support-in_parallel.) + +### v0.18.0 + + - [Support for `config_block` returning a middleware wrapper.](https://socketry.github.io/async-http-faraday/releases/index#support-for-config_block-returning-a-middleware-wrapper.) + +### v0.17.0 + + - [Introduced a per-thread `Client` cache.](https://socketry.github.io/async-http-faraday/releases/index#introduced-a-per-thread-client-cache.) + +## Contributing + +We welcome contributions to this project. + +1. Fork it. +2. Create your feature branch (`git checkout -b my-new-feature`). +3. Commit your changes (`git commit -am 'Add some feature'`). +4. Push to the branch (`git push origin my-new-feature`). +5. Create new Pull Request. + +### Developer Certificate of Origin + +In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. + +### Community Guidelines + +This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. diff --git a/release.cert b/release.cert new file mode 100644 index 0000000..d98e595 --- /dev/null +++ b/release.cert @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 +ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK +CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz +MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd +MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj +bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB +igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 +9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW +sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE +e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN +XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss +RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn +tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM +zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW +xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O +BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs +aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs +aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE +cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl +xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ +c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp +8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws +JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP +eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt +Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 +voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= +-----END CERTIFICATE----- diff --git a/releases.md b/releases.md new file mode 100644 index 0000000..0ac1a16 --- /dev/null +++ b/releases.md @@ -0,0 +1,94 @@ +# Releases + +## v0.21.0 + +### Improved support for `timeout` and `read_timeout`. + +Previously, only a per-connection `timeout` was supported, but now: + +1. `timeout` can be set per request too. +2. `read_timeout` can be set per adapter and is assigned to `IO#timeout` if available. + +This improves compatibility with existing code that uses `timeout` and `read_timeout`. + +## v0.20.0 + + - Implement the new response streaming interface, which provides the initial response status code and headers before streaming the response body. + - An empty response now sets the response body to an empty string rather than `nil` as required by the Faraday specification. + +## v0.19.0 + +### Support `in_parallel`. + +The adapter now supports the `in_parallel` method, which allows multiple requests to be made concurrently. + +``` ruby +adapter = Faraday.new(bound_url) do |builder| + builder.adapter :async_http +end + +response1 = response2 = response3 = nil + +adapter.in_parallel do + response1 = adapter.get("/index") + response2 = adapter.get("/index") + response3 = adapter.get("/index") +end + +puts response1.body # => "Hello World" +puts response2.body # => "Hello World" +puts response3.body # => "Hello World" +``` + +This is primarily for compatibility with existing code. If you are designing a new library, you should just use `Async` directly: + +``` ruby +Async do + response1 = Async{adapter.get("/index")} + response2 = Async{adapter.get("/index")} + response3 = Async{adapter.get("/index")} + + puts response1.wait.body # => "Hello World" + puts response2.wait.body # => "Hello World" + puts response3.wait.body # => "Hello World" +end +``` + +## v0.18.0 + +### Support for `config_block` returning a middleware wrapper. + +The `config_block` provided to the adapter must now return `nil`, `client` or a middleware wrapper around `client`. + +``` ruby +Faraday.new do |builder| + builder.adapter :async_http do |client| + # Option 1 (same as returning `nil`), use client as is: + client # Use `client` as is. + + # Option 2, wrap client in a middleware: + Async::HTTP::Middleware::LocationRedirector.new(client) + end +end +``` + +## v0.17.0 + +### Introduced a per-thread `Client` cache. + +The default adapter now uses a per-thread client cache internally, to improve compatibility with existing code that shares a single `Faraday::Connection` instance across multiple threads. + +``` ruby +adapter = Faraday.new do |builder| + builder.adapter :async_http +end + +3.times do + Thread.new do + Async do + # Each thread has it's own client cache. + adapter.get('http://example.com') + end + end +end +``` diff --git a/spec/async/http/faraday/adapter_spec.rb b/spec/async/http/faraday/adapter_spec.rb deleted file mode 100644 index 540cfd5..0000000 --- a/spec/async/http/faraday/adapter_spec.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true - -# Copyright, 2017, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require 'async/http/faraday' -require 'async/http/server' -require 'async/http/endpoint' - -require 'async' - -RSpec.describe Async::HTTP::Faraday::Adapter do - include_context Async::RSpec::Reactor - - let(:endpoint) { - Async::HTTP::Endpoint.parse('http://127.0.0.1:9294') - } - - def run_server(response = Protocol::HTTP::Response[204], response_delay: nil) - Async do |task| - begin - server_task = task.async do - app = Proc.new do - if response_delay - task.sleep(response_delay) - end - - response - end - - Async::HTTP::Server.new(app, endpoint).run - end - - yield - ensure - server_task.stop - end - end.wait - end - - def get_response(url = endpoint.url, path = '/index', adapter_options: {}) - connection = Faraday.new(url: url) do |faraday| - faraday.response :logger - faraday.adapter :async_http, adapter_options - end - - connection.get(path) - - ensure - connection&.close - end - - it "client can get resource" do - run_server(Protocol::HTTP::Response[200, {}, ['Hello World']]) do - expect(get_response.body).to eq 'Hello World' - end - end - - it "works without top level reactor" do - response = get_response("https://www.google.com", "/search?q=ruby") - - expect(response).to be_success - end - - it "can get remote resource" do - Async do - response = get_response('http://www.google.com', '/search?q=cats') - - expect(response).to be_success - end - end - - it 'properly handles chunked responses' do - large_response_size = 65536 - - run_server(Protocol::HTTP::Response[200, {}, ['.' * large_response_size]]) do - expect(get_response.body.size).to eq large_response_size - end - end - - it 'properly handles no content responses' do - run_server(Protocol::HTTP::Response[204]) do - expect(get_response.body).to be_nil - end - end - - it 'closes connection automatically if persistent option is set to false' do - run_server do - expect do - get_response(adapter_options: { persistent: false }) - end.not_to raise_error - end - end - - it 'raises an exception if request times out' do - delay = 0.1 - - run_server(response_delay: delay) do - expect do - get_response(adapter_options: {timeout: delay / 2}) - end.to raise_error(Faraday::TimeoutError) - - expect do - get_response(adapter_options: {timeout: delay * 2}) - end.not_to raise_error - end - end - - it 'wraps underlying exceptions into Faraday analogs' do - expect { get_response(endpoint.url, '/index') }.to raise_error(Faraday::ConnectionFailed) - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index 4721a48..0000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require 'async/rspec' -require 'covered/rspec' - -RSpec.configure do |config| - config.disable_monkey_patching! - - # Enable flags like --only-failures and --next-failure - config.example_status_persistence_file_path = ".rspec_status" - - config.expect_with :rspec do |c| - c.syntax = :expect - end -end diff --git a/test/async/http/faraday/adapter.rb b/test/async/http/faraday/adapter.rb new file mode 100644 index 0000000..6da4a35 --- /dev/null +++ b/test/async/http/faraday/adapter.rb @@ -0,0 +1,292 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2018-2025, by Samuel Williams. +# Copyright, 2018, by Andreas Garnaes. +# Copyright, 2019, by Denis Talakevich. +# Copyright, 2019-2020, by Igor Sidorov. +# Copyright, 2023, by Genki Takiuchi. +# Copyright, 2024, by Jacob Frautschi. +# Copyright, 2025, by Nikolaos Anastopoulos. + +require "async/http/faraday" + +require "sus/fixtures/async/reactor_context" +require "sus/fixtures/async/http/server_context" + +require "faraday" +require "faraday/multipart" + +require "protocol/http/body/file" + +describe Async::HTTP::Faraday::Adapter do + def get_response(url = bound_url, path = "/index", adapter_options: {}) + connection = Faraday.new(url) do |builder| + builder.adapter :async_http, **adapter_options + end + + connection.get(path) + ensure + connection&.close + end + + with "#timeout" do + it "can set timeout" do + connection = Faraday.new do |builder| + builder.adapter :async_http, timeout: 0.05 + end + + adapter = connection.builder.app + + expect(adapter).to have_attributes( + timeout: be == 0.05 + ) + end + end + + with "#read_timeout" do + it "can set read timeout" do + connection = Faraday.new do |builder| + builder.adapter :async_http, read_timeout: 0.05 + end + + adapter = connection.builder.app + + expect(adapter).to have_attributes( + read_timeout: be == 0.05 + ) + end + end + + with "a local http server" do + include Sus::Fixtures::Async::ReactorContext + include Sus::Fixtures::Async::HTTP::ServerContext + + with "basic http server" do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[200, {}, ["Hello World"]] + end + end + + it "client can get resource" do + expect(get_response.body).to be == "Hello World" + end + + it "can make several requests on several threads" do + connection = Faraday.new(bound_url) do |builder| + builder.adapter :async_http + end + + threads = [] + + 10.times do + threads << Thread.new do + 10.times do + response = connection.get("/index") + expect(response).to be(:success?) + end + end + end + + threads.each(&:join) + end + end + + with "utf-8 response body" do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[200, {"content-type" => "text/html; charset=utf-8"}, ["こんにちは世界"]] + end + end + + it "client can get responce with respect to content-type encoding" do + body = get_response.body + + expect(body.encoding).to be == Encoding::UTF_8 + expect(body).to be == "こんにちは世界" + end + end + + with "a large response body" do + let(:large_response_size) {65536} + + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[200, {}, ["." * large_response_size]] + end + end + + it "properly handles chunked responses" do + expect(get_response.body.bytesize).to be == large_response_size + end + end + + with "a no content response" do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[204] + end + end + + it "properly handles no content responses" do + expect(get_response.body).to be == "" + end + end + + with "a slow response" do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + sleep(0.1) + Protocol::HTTP::Response[200, {}, ["Hello World"]] + end + end + + it "client can get resource" do + expect(get_response.body).to be == "Hello World" + end + + it "raises an exception if request times out" do + expect do + get_response(adapter_options: {timeout: 0.05}) + end.to raise_exception(Faraday::TimeoutError) + end + end + + with "a post request" do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[200, {}, [request.body.read]] + end + end + + it "can post data" do + response = Faraday.new do |builder| + builder.adapter :async_http + end.post(bound_url, "Hello World") + + expect(response.body).to be == "Hello World" + end + + it "can use a url-encoded body" do + response = Faraday.new do |builder| + builder.request :url_encoded + builder.adapter :async_http + end.post(bound_url, text: "Hello World") + + expect(response.body).to be == "text=Hello+World" + end + + it "can use a ::Protocol::HTTP::Body::Readable body" do + readable = ::Protocol::HTTP::Body::File.new(File.open(__FILE__, "r"), 0...128) + + response = Faraday.new do |builder| + builder.adapter :async_http + end.post(bound_url, readable) + + expect(response.body).to be == File.read(__FILE__, 128) + end + end + + with "a config block" do + it "invokes the config block" do + config_block_invoked = false + + adapter = Faraday.new do |builder| + builder.adapter :async_http do |client| + config_block_invoked = true; client + end + end + + adapter.get(bound_url) + + expect(config_block_invoked).to be == true + end + end + + with "a streaming response" do + let(:app) do + Protocol::HTTP::Middleware.for do |request| + body = ::Async::HTTP::Body::Writable.new + + Async do + 3.times do |i| + body.write("chunk#{i}") + end + ensure + body.close_write + end + + Protocol::HTTP::Response[200, {}, body] + end + end + + it "can stream response" do + client = Faraday.new do |builder| + builder.adapter :async_http + end + + streamed = [] + env = nil + + response = client.get(bound_url) do |request| + request.options.on_data = proc do |chunk, size, block_env| + streamed << [chunk, size] + env ||= block_env + end + end + + expect(response.body).to be(:empty?) + expect(streamed).to be == [["chunk0", 6], ["chunk1", 12], ["chunk2", 18]] + expect(env).to be_a(Faraday::Env) + expect(env).to have_attributes(status: be == 200) + end + end + end + + with "a remote http server" do + it "can get remote resource" do + Sync do + response = get_response("http://www.google.com", "/search?q=cats") + + expect(response).to be(:success?) + end + end + + it "works without top level reactor" do + response = get_response("https://www.google.com", "/search?q=ruby") + + expect(response).to be(:success?) + end + + it "works without initial url and trailing slash (compatiblisity to the original behaviour)" do + response = Faraday.new do |builder| + builder.adapter :async_http + end.get "https://www.google.com" + + expect(response).to be(:success?) + end + + it "can use a multi-part post body" do + connection = Faraday.new do |builder| + builder.request :multipart + builder.adapter :async_http + end + + response = connection.post("https://httpbin.org/post") do |request| + request.body = {"myfile" => Faraday::Multipart::FilePart.new(StringIO.new("file content"), "text/plain", "file.txt")} + end + + body = JSON.parse(response.body) + expect(body["files"]["myfile"]).to be == "file content" + end + end + + with "no server" do + it "wraps underlying exceptions into Faraday analogs" do + expect do + get_response("http://localhost:1", "/index") + end.to raise_exception(Faraday::ConnectionFailed) + end + end +end diff --git a/test/async/http/faraday/adapter/parallel.rb b/test/async/http/faraday/adapter/parallel.rb new file mode 100644 index 0000000..d540429 --- /dev/null +++ b/test/async/http/faraday/adapter/parallel.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024-2025, by Samuel Williams. + +require "async/http/faraday" + +require "sus/fixtures/async/reactor_context" +require "sus/fixtures/async/http/server_context" + +describe Async::HTTP::Faraday::Adapter do + with "a local http server" do + include Sus::Fixtures::Async::ReactorContext + include Sus::Fixtures::Async::HTTP::ServerContext + + + let(:app) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[200, {}, ["Hello World"]] + end + end + + it "client can get resource" do + adapter = Faraday.new(bound_url) do |builder| + builder.adapter :async_http + end + + response1 = response2 = response3 = nil + + adapter.in_parallel do + response1 = adapter.get("/index") + response2 = adapter.get("/index") + response3 = adapter.get("/index") + end + + expect(response1.body).to be == "Hello World" + expect(response2.body).to be == "Hello World" + expect(response3.body).to be == "Hello World" + ensure + adapter&.close + end + end +end diff --git a/test/async/http/faraday/adapter/proxy.rb b/test/async/http/faraday/adapter/proxy.rb new file mode 100644 index 0000000..a44206f --- /dev/null +++ b/test/async/http/faraday/adapter/proxy.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2021-2025, by Samuel Williams. + +require "async/http/faraday" + +require "sus/fixtures/async/reactor_context" +require "sus/fixtures/async/http/server_context" + +PROXY_URL = ENV.key?("PROXY_URL") + +if PROXY_URL + describe Async::HTTP::Faraday::Adapter do + include Sus::Fixtures::Async::ReactorContext + + def get_response(url = endpoint.url, path = "/index", adapter_options: {}) + connection = Faraday.new(url, proxy: PROXY_URL) do |builder| + builder.response :logger + builder.adapter :async_http, **adapter_options + end + + connection.get(path) + end + + it "can get remote resource via proxy" do + response = get_response("https://www.google.com", "/search?q=cats") + + expect(response).to be(:success?) + end + end +end diff --git a/test/async/http/faraday/clients.rb b/test/async/http/faraday/clients.rb new file mode 100644 index 0000000..a4a90e6 --- /dev/null +++ b/test/async/http/faraday/clients.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024-2025, by Samuel Williams. + +require "async/http/faraday/clients" +require "async/http/middleware/location_redirector" + +describe Async::HTTP::Faraday::PersistentClients do + let(:clients) {subject.new} + + with "a block" do + let(:clients) do + subject.new do |client| + Async::HTTP::Middleware::LocationRedirector.new(client) + end + end + + it "can wrap the client with middleware" do + endpoint = Async::HTTP::Endpoint.parse("http://example.com") + client = clients.make_client(endpoint) + + expect(client).to be_a(Async::HTTP::Middleware::LocationRedirector) + end + end + + with "#make_client" do + it "caches the client" do + endpoint = Async::HTTP::Endpoint.parse("http://example.com") + client = clients.make_client(endpoint) + + expect(clients.make_client(endpoint)).to be_equal(client) + end + end + + with "#with_client" do + it "caches the client" do + endpoint = Async::HTTP::Endpoint.parse("http://example.com") + + clients.with_client(endpoint) do |client| + clients.with_client(endpoint) do |other| + expect(other).to be_equal(client) + end + end + end + end + + with "#with_proxied_client" do + it "caches the client" do + endpoint = Async::HTTP::Endpoint.parse("http://example.com") + proxy_endpoint = Async::HTTP::Endpoint.parse("http://proxy.example.com") + + clients.with_proxied_client(proxy_endpoint, endpoint) do |client| + clients.with_proxied_client(proxy_endpoint, endpoint) do |other| + expect(other).to be_equal(client) + end + end + end + end +end