diff --git a/gems.rb b/gems.rb index dbbd8bd9..26cdf5a1 100644 --- a/gems.rb +++ b/gems.rb @@ -44,7 +44,4 @@ gem "localhost" gem "rack-test" - - # Optional dependency: - gem "thread-local" end diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md new file mode 100644 index 00000000..7d56fbae --- /dev/null +++ b/guides/getting-started/readme.md @@ -0,0 +1,151 @@ +# Getting Started + +This guide explains how to get started with `Async::HTTP`. + +## Installation + +Add the gem to your project: + +~~~ bash +$ bundle add async-http +~~~ + +## Core Concepts + +- {ruby Async::HTTP::Client} is the main class for making HTTP requests. +- {ruby Async::HTTP::Internet} provides a simple interface for making requests to any server "on the internet". +- {ruby Async::HTTP::Server} is the main class for handling HTTP requests. +- {ruby Async::HTTP::Endpoint} can parse HTTP URLs in order to create a client or server. +- [`protocol-http`](https://github.com/socketry/protocol-http) provides the abstract HTTP protocol interfaces. + +## Usage + +### Making a Request + +To make a request, create an instance of {ruby Async::HTTP::Internet} and call the appropriate method: + +~~~ ruby +require 'async/http/internet' + +Sync do + Async::HTTP::Internet.get("https://httpbin.org/get") do |response| + puts response.read + end +end +~~~ + +The following methods are supported: + +~~~ ruby +Async::HTTP::Internet.methods(false) +# => [:patch, :options, :connect, :post, :get, :delete, :head, :trace, :put] +~~~ + +Using a block will automatically close the response when the block completes. If you want to keep the response open, you can manage it manually: + +~~~ ruby +require 'async/http' + +Sync do + response = Async::HTTP::Internet.get("https://httpbin.org/get") + puts response.read +ensure + response&.close +end +~~~ + +As responses are streamed, you must ensure it is closed when you are finished with it. + +#### Persistence + +By default, {ruby Async::HTTP::Internet} will create a {ruby Async::HTTP::Client} for each remote host you communicate with, and will keep those connections open for as long as possible. This is useful for reducing the latency of subsequent requests to the same host. When you exit the event loop, the connections will be closed automatically. + +### Downloading a File + +~~~ ruby +require 'async/http' +require 'async/http/internet/instance' + +Sync do + # Issue a GET request to Google: + response = Async::HTTP::Internet.get("https://www.google.com/search?q=kittens") + + # Save the response body to a local file: + response.save("/tmp/search.html") +ensure + response&.close +end +~~~ + +### Posting Data + +To post data, use the `post` method: + +~~~ ruby +require 'async/http' +require 'async/http/internet/instance' + +data = {'life' => 42} + +Sync do + # Prepare the request: + headers = [['accept', 'application/json']] + body = JSON.dump(data) + + # Issues a POST request: + response = Async::HTTP::Internet.post("https://httpbin.org/anything", headers, body) + + # Save the response body to a local file: + pp JSON.parse(response.read) +ensure + response&.close +end +~~~ + +For more complex scenarios, including HTTP APIs, consider using [async-rest](https://github.com/socketry/async-rest) instead. + +### Timeouts + +To set a timeout for a request, use the `Task#with_timeout` method: + +~~~ ruby +require 'async/http' +require 'async/http/internet/instance' + +Sync do |task| + # Request will timeout after 2 seconds + task.with_timeout(2) do + response = Async::HTTP::Internet.get "https://httpbin.org/delay/10" + ensure + response&.close + end +rescue Async::TimeoutError + puts "The request timed out" +end +~~~ + +### Making a Server + +To create a server, use an instance of {ruby Async::HTTP::Server}: + +~~~ ruby +require 'async/http' + +endpoint = Async::HTTP::Endpoint.parse('http://localhost:9292') + +Sync do |task| + Async(transient: true) do + server = Async::HTTP::Server.for(endpoint) do |request| + ::Protocol::HTTP::Response[200, {}, ["Hello World"]] + end + + server.run + end + + client = Async::HTTP::Client.new(endpoint) + response = client.get("/") + puts response.read +ensure + response&.close +end +~~~ diff --git a/guides/links.yaml b/guides/links.yaml index d1b7ca52..89fed4cd 100644 --- a/guides/links.yaml +++ b/guides/links.yaml @@ -1,2 +1,4 @@ -mocking: - order: 5 +getting-started: + order: 0 +testing: + order: 1 diff --git a/guides/mocking/readme.md b/guides/testing/readme.md similarity index 89% rename from guides/mocking/readme.md rename to guides/testing/readme.md index 124d2710..a9014543 100644 --- a/guides/mocking/readme.md +++ b/guides/testing/readme.md @@ -1,6 +1,8 @@ -# Mocking +# Testing -This guide explains how to modify `Async::HTTP::Client` for mocking responses in tests. +This guide explains how to use `Async::HTTP` clients and servers in your tests. + +In general, you should avoid making real HTTP requests in your tests. Instead, you should use a mock server or a fake client. ## Mocking HTTP Responses diff --git a/lib/async/http/internet.rb b/lib/async/http/internet.rb index 37fd379f..37ee38f1 100644 --- a/lib/async/http/internet.rb +++ b/lib/async/http/internet.rb @@ -39,7 +39,7 @@ def client_for(endpoint) # @parameter url [String] The URL to request, e.g. `https://www.codeotaku.com`. # @parameter headers [Hash | Protocol::HTTP::Headers] The headers to send with the request. # @parameter body [String | Protocol::HTTP::Body] The body to send with the request. - def call(method, url, headers = nil, body = nil) + def call(method, url, headers = nil, body = nil, &block) endpoint = Endpoint[url] client = self.client_for(endpoint) @@ -48,7 +48,15 @@ def call(method, url, headers = nil, body = nil) request = ::Protocol::HTTP::Request.new(endpoint.scheme, endpoint.authority, method, endpoint.path, nil, headers, body) - return client.call(request) + response = client.call(request) + + return response unless block_given? + + begin + yield response + ensure + response.close + end end def close @@ -60,8 +68,8 @@ def close end ::Protocol::HTTP::Methods.each do |name, verb| - define_method(verb.downcase) do |url, headers = nil, body = nil| - self.call(verb, url, headers, body) + define_method(verb.downcase) do |url, headers = nil, body = nil, &block| + self.call(verb, url, headers, body, &block) end end diff --git a/lib/async/http/internet/instance.rb b/lib/async/http/internet/instance.rb index 8581fedb..ec16ee63 100644 --- a/lib/async/http/internet/instance.rb +++ b/lib/async/http/internet/instance.rb @@ -4,13 +4,24 @@ # Copyright, 2021-2023, by Samuel Williams. require_relative '../internet' -require 'thread/local' + +::Thread.attr_accessor :async_http_internet_instance module Async module HTTP class Internet - # Provide access to a shared thread-local instance. - extend ::Thread::Local + # The global instance of the internet. + def self.instance + ::Thread.current.async_http_internet_instance ||= self.new + end + + class << self + ::Protocol::HTTP::Methods.each do |name, verb| + define_method(verb.downcase) do |url, headers = nil, body = nil, &block| + self.instance.call(verb, url, headers, body, &block) + end + end + end end end end diff --git a/readme.md b/readme.md index 1a22fe6b..79fc3e22 100644 --- a/readme.md +++ b/readme.md @@ -4,358 +4,13 @@ An asynchronous client and server implementation of HTTP/1.0, HTTP/1.1 and HTTP/ [![Development Status](https://github.com/socketry/async-http/workflows/Test/badge.svg)](https://github.com/socketry/async-http/actions?workflow=Test) -## Installation - -Add this line to your application's Gemfile: - -``` ruby -gem 'async-http' -``` - -And then execute: - - $ bundle - -Or install it yourself as: - - $ gem install async-http - ## Usage -Please see the [project documentation](https://socketry.github.io/async-http/) or serve it locally using `bake utopia:project:serve`. - -### Post JSON data - -Here is an example showing how to post a data structure as JSON to a remote resource: - -``` ruby -#!/usr/bin/env ruby - -require 'json' -require 'async' -require 'async/http/internet' - -data = {'life' => 42} - -Async do - # Make a new internet: - internet = Async::HTTP::Internet.new - - # Prepare the request: - headers = [['accept', 'application/json']] - body = [JSON.dump(data)] - - # Issues a POST request: - response = internet.post("https://httpbin.org/anything", headers, body) - - # Save the response body to a local file: - pp JSON.parse(response.read) -ensure - # The internet is closed for business: - internet.close -end -``` - -Consider using [async-rest](https://github.com/socketry/async-rest) instead. - -### Multiple Requests - -To issue multiple requests concurrently, you should use a barrier, e.g. - -``` ruby -#!/usr/bin/env ruby - -require 'async' -require 'async/barrier' -require 'async/http/internet' - -TOPICS = ["ruby", "python", "rust"] - -Async do - internet = Async::HTTP::Internet.new - barrier = Async::Barrier.new - - # Spawn an asynchronous task for each topic: - TOPICS.each do |topic| - barrier.async do - response = internet.get "https://www.google.com/search?q=#{topic}" - puts "Found #{topic}: #{response.read.scan(topic).size} times." - end - end - - # Ensure we wait for all requests to complete before continuing: - barrier.wait -ensure - internet&.close -end -``` - -#### Limiting Requests - -If you need to limit the number of simultaneous requests, use a semaphore. - -``` ruby -#!/usr/bin/env ruby - -require 'async' -require 'async/barrier' -require 'async/semaphore' -require 'async/http/internet' - -TOPICS = ["ruby", "python", "rust"] - -Async do - internet = Async::HTTP::Internet.new - barrier = Async::Barrier.new - semaphore = Async::Semaphore.new(2, parent: barrier) - - # Spawn an asynchronous task for each topic: - TOPICS.each do |topic| - semaphore.async do - response = internet.get "https://www.google.com/search?q=#{topic}" - puts "Found #{topic}: #{response.read.scan(topic).size} times." - end - end - - # Ensure we wait for all requests to complete before continuing: - barrier.wait -ensure - internet&.close -end -``` - -### Persistent Connections - -To keep connections alive, install the `thread-local` gem, -require `async/http/internet/instance`, and use the `instance`, e.g. - -``` ruby -#!/usr/bin/env ruby - -require 'async' -require 'async/http/internet/instance' - -Async do - internet = Async::HTTP::Internet.instance - response = internet.get "https://www.google.com/search?q=test" - puts "Found #{response.read.size} results." -end -``` - -### Downloading a File - -Here is an example showing how to download a file and save it to a local path: - -``` ruby -#!/usr/bin/env ruby - -require 'async' -require 'async/http/internet' - -Async do - # Make a new internet: - internet = Async::HTTP::Internet.new - - # Issues a GET request to Google: - response = internet.get("https://www.google.com/search?q=kittens") - - # Save the response body to a local file: - response.save("/tmp/search.html") -ensure - # The internet is closed for business: - internet.close -end -``` - -### Basic Client/Server - -Here is a basic example of a client/server running in the same reactor: - -``` ruby -#!/usr/bin/env ruby - -require 'async' -require 'async/http/server' -require 'async/http/client' -require 'async/http/endpoint' -require 'async/http/protocol/response' - -endpoint = Async::HTTP::Endpoint.parse('http://127.0.0.1:9294') - -app = lambda do |request| - Protocol::HTTP::Response[200, {}, ["Hello World"]] -end - -server = Async::HTTP::Server.new(app, endpoint) -client = Async::HTTP::Client.new(endpoint) - -Async do |task| - server_task = task.async do - server.run - end - - response = client.get("/") - - puts response.status - puts response.read - - server_task.stop -end -``` - -### Advanced Verification - -You can hook into SSL certificate verification to improve server verification. - -``` ruby -require 'async' -require 'async/http' - -# These are generated from the certificate chain that the server presented. -trusted_fingerprints = { - "dac9024f54d8f6df94935fb1732638ca6ad77c13" => true, - "e6a3b45b062d509b3382282d196efe97d5956ccb" => true, - "07d63f4c05a03f1c306f9941b8ebf57598719ea2" => true, - "e8d994f44ff20dc78dbff4e59d7da93900572bbf" => true, -} - -Async do - endpoint = Async::HTTP::Endpoint.parse("https://www.codeotaku.com/index") - - # This is a quick hack/POC: - ssl_context = endpoint.ssl_context - - ssl_context.verify_callback = proc do |verified, store_context| - certificate = store_context.current_cert - fingerprint = OpenSSL::Digest::SHA1.new(certificate.to_der).to_s - - if trusted_fingerprints.include? fingerprint - true - else - Console.logger.warn("Untrusted Certificate Fingerprint"){fingerprint} - false - end - end - - endpoint = endpoint.with(ssl_context: ssl_context) - - client = Async::HTTP::Client.new(endpoint) - - response = client.get(endpoint.path) - - pp response.status, response.headers.fields, response.read -end -``` - -### Timeouts - -Here's a basic example with a timeout: - -``` ruby -#!/usr/bin/env ruby - -require 'async/http/internet' - -Async do |task| - internet = Async::HTTP::Internet.new - - # Request will timeout after 2 seconds - task.with_timeout(2) do - response = internet.get "https://httpbin.org/delay/10" - end -rescue Async::TimeoutError - puts "The request timed out" -ensure - internet&.close -end -``` - -## Performance - -On a 4-core 8-thread i7, running `ab` which uses discrete (non-keep-alive) connections: - - $ ab -c 8 -t 10 http://127.0.0.1:9294/ - This is ApacheBench, Version 2.3 <$Revision: 1757674 $> - Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ - Licensed to The Apache Software Foundation, http://www.apache.org/ - - Benchmarking 127.0.0.1 (be patient) - Completed 5000 requests - Completed 10000 requests - Completed 15000 requests - Completed 20000 requests - Completed 25000 requests - Completed 30000 requests - Completed 35000 requests - Completed 40000 requests - Completed 45000 requests - Completed 50000 requests - Finished 50000 requests - - - Server Software: - Server Hostname: 127.0.0.1 - Server Port: 9294 - - Document Path: / - Document Length: 13 bytes - - Concurrency Level: 8 - Time taken for tests: 1.869 seconds - Complete requests: 50000 - Failed requests: 0 - Total transferred: 2450000 bytes - HTML transferred: 650000 bytes - Requests per second: 26755.55 [#/sec] (mean) - Time per request: 0.299 [ms] (mean) - Time per request: 0.037 [ms] (mean, across all concurrent requests) - Transfer rate: 1280.29 [Kbytes/sec] received - - Connection Times (ms) - min mean[+/-sd] median max - Connect: 0 0 0.0 0 0 - Processing: 0 0 0.2 0 6 - Waiting: 0 0 0.2 0 6 - Total: 0 0 0.2 0 6 - - Percentage of the requests served within a certain time (ms) - 50% 0 - 66% 0 - 75% 0 - 80% 0 - 90% 0 - 95% 1 - 98% 1 - 99% 1 - 100% 6 (longest request) - -On a 4-core 8-thread i7, running `wrk`, which uses 8 keep-alive connections: - - $ wrk -c 8 -d 10 -t 8 http://127.0.0.1:9294/ - Running 10s test @ http://127.0.0.1:9294/ - 8 threads and 8 connections - Thread Stats Avg Stdev Max +/- Stdev - Latency 217.69us 0.99ms 23.21ms 97.39% - Req/Sec 12.18k 1.58k 17.67k 83.21% - 974480 requests in 10.10s, 60.41MB read - Requests/sec: 96485.00 - Transfer/sec: 5.98MB - -According to these results, the cost of handling connections is quite high, while general throughput seems pretty decent. - -## Semantic Model - -### Scheme - -HTTP/1 has an implicit scheme determined by the kind of connection made to the server (either `http` or `https`), while HTTP/2 models this explicitly and the client indicates this in the request using the `:scheme` pseudo-header (typically `https`). To normalize this, `Async::HTTP::Client` and `Async::HTTP::Server` have a default scheme which is used if none is supplied. - -### Version - -HTTP/1 has an explicit version while HTTP/2 does not expose the version in any way. +Please see the [project documentation](https://socketry.github.io/async-http/) for more details. -### Reason + - [Getting Started](https://socketry.github.io/async-http/guides/getting-started/index) - This guide explains how to get started with `Async::HTTP`. -HTTP/1 responses contain a reason field which is largely irrelevant. HTTP/2 does not support this field. + - [Testing](https://socketry.github.io/async-http/guides/testing/index) - This guide explains how to use `Async::HTTP` clients and servers in your tests. ## Contributing