diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 3f674834..2dbe1292 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -1,6 +1,6 @@ # Getting Started -This guide explains how to get started with `Async::HTTP`. +This guide explains how to make HTTP requests and serve HTTP responses with `Async::HTTP`. ## Installation @@ -12,137 +12,172 @@ $ 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. +`Async::HTTP` provides several interfaces for different kinds of HTTP applications: -## Usage +- ruby:`Async::HTTP::Internet` makes requests to arbitrary hosts and manages a client for each remote endpoint. +- ruby:`Async::HTTP::Client` manages persistent connections to a specific endpoint. +- ruby:`Async::HTTP::Server` accepts connections and dispatches requests to an HTTP application. +- ruby:`Async::HTTP::Endpoint` describes how a client connects or a server listens, including the URL, protocol, and TLS configuration. +- [`protocol-http`](https://github.com/socketry/protocol-http) provides the shared request, response, header, and body interfaces. -### Making a Request +Use `Internet` for general-purpose requests to different hosts. Use `Client` when your application repeatedly communicates with one endpoint or needs endpoint-specific configuration. -To make a request, use {ruby Async::HTTP::Internet} and call the appropriate method: +## Making a Request + +The shared ruby:`Async::HTTP::Internet` instance provides a convenient starting point. Run asynchronous HTTP operations inside `Sync`, which creates or reuses the event loop while returning the block result directly: ~~~ ruby -require 'async/http/internet/instance' +require "async/http/internet/instance" Sync do Async::HTTP::Internet.get("https://httpbin.org/get") do |response| + puts "Status: #{response.status}" puts response.read end end ~~~ -The following methods are supported: +Passing a block automatically closes the response when the block exits, including when an exception is raised. Responses are streamed, so callers that do not use the block form must close the response explicitly. ~~~ ruby -Async::HTTP::Internet.methods(false) -# => [:patch, :options, :connect, :post, :get, :delete, :head, :trace, :put] +require "async/http/internet/instance" + +Sync do + response = Async::HTTP::Internet.get("https://httpbin.org/get") + puts response.read +ensure + response&.close +end ~~~ -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: +Convenience methods are provided for `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH`, and `QUERY` requests. + +### Connection Persistence + +`Internet` creates a ruby:`Async::HTTP::Client` for each remote endpoint and reuses its persistent connections. The underlying async pools are bound to the event loop and are closed when that event loop exits. + +An explicitly created `Internet` can also be closed early when an application wants to release all cached clients before the event loop exits: ~~~ ruby -require 'async/http/internet/instance' +require "async/http/internet" Sync do - response = Async::HTTP::Internet.get("https://httpbin.org/get") - puts response.read + internet = Async::HTTP::Internet.new + + internet.get("https://example.com") do |response| + puts response.status + end ensure - response&.close + internet&.close end ~~~ -As responses are streamed, you must ensure it is closed when you are finished with it. +## Working with Responses -#### Persistence +A response contains a status, headers, and a streaming body. Check the status before processing content, and use header names in lower case: -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. +~~~ ruby +require "async/http/internet/instance" + +Sync do + Async::HTTP::Internet.get("https://httpbin.org/json") do |response| + if response.success? + puts response.headers["content-type"] + puts response.read + else + warn "Request failed with status #{response.status}." + end + end +end +~~~ + +For larger responses, process the body incrementally rather than reading it into one string. See the [`protocol-http` message body documentation](https://socketry.github.io/protocol-http/guides/message-body/) for the complete body interface. ### Downloading a File +Use `response.save` to stream a response directly to a file: + ~~~ ruby -require 'async/http/internet/instance' +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 + Async::HTTP::Internet.get("https://example.com/archive.zip") do |response| + raise "Download failed with status #{response.status}." unless response.success? + + response.save("archive.zip") + end end ~~~ -### Posting Data +## Posting JSON -To post data, use the `post` method: +Pass headers and a body after the request target. The body may be a string or a compatible `protocol-http` body object. ~~~ ruby -require 'async/http/internet/instance' +require "async/http/internet/instance" +require "json" -data = {'life' => 42} +data = {life: 42} +headers = [ + ["accept", "application/json"], + ["content-type", "application/json"], +] 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 + Async::HTTP::Internet.post("https://httpbin.org/anything", headers, JSON.dump(data)) do |response| + raise "Request failed with status #{response.status}." unless response.success? + + puts JSON.pretty_generate(JSON.parse(response.read)) + end end ~~~ -For more complex scenarios, including HTTP APIs, consider using [async-rest](https://github.com/socketry/async-rest) instead. +For resource-oriented HTTP APIs, consider using [`async-rest`](https://github.com/socketry/async-rest), which builds on `Async::HTTP`. -### Timeouts +## Applying a Timeout -To set a timeout for a request, use the `Task#with_timeout` method: +Networks can stall indefinitely, so impose a timeout around operations that must complete within a fixed duration: ~~~ ruby -require 'async/http/internet/instance' +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 + Async::HTTP::Internet.get("https://httpbin.org/delay/10") do |response| + puts response.read + end end rescue Async::TimeoutError - puts "The request timed out" + warn "The request timed out." end ~~~ -### Making a Server +The response block still closes the response if the timeout interrupts the request while its body is being processed. + +## Making a Server -To create a server, use an instance of {ruby Async::HTTP::Server}: +ruby:`Async::HTTP::Server` accepts an application that maps each request to a ruby:`Protocol::HTTP::Response`. The following example starts a local server, makes one request, and then releases both client and server resources: ~~~ ruby -require 'async/http' +require "async/http" -endpoint = Async::HTTP::Endpoint.parse('http://localhost:9292') +endpoint = Async::HTTP::Endpoint.parse("http://localhost:9292") +server = Async::HTTP::Server.for(endpoint) do |request| + Protocol::HTTP::Response[200, {"content-type" => "text/plain"}, ["Hello World"]] +end -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 +Sync do + server_task = server.run - client = Async::HTTP::Client.new(endpoint) - response = client.get("/") - puts response.read + Async::HTTP::Client.open(endpoint) do |client| + response = client.get("/") + puts response.read + ensure + response&.close + end ensure - response&.close + server_task&.stop end ~~~ + +Use Falcon when you need to host a Rack application or deploy an HTTP server in production. Use `Async::HTTP::Server` directly when building a protocol-level server or embedding HTTP handling into another asynchronous application. diff --git a/guides/testing/readme.md b/guides/testing/readme.md index a9014543..7b01a002 100644 --- a/guides/testing/readme.md +++ b/guides/testing/readme.md @@ -1,77 +1,205 @@ # Testing -This guide explains how to use `Async::HTTP` clients and servers in your tests. +This guide explains how to test `Async::HTTP` clients and servers without depending on external HTTP services. -In general, you should avoid making real HTTP requests in your tests. Instead, you should use a mock server or a fake client. +Real network services make tests slower and less deterministic. Prefer one of these approaches: -## Mocking HTTP Responses +- Use [`sus-fixtures-protocol-http`](https://socketry.github.io/sus-fixtures-protocol-http/guides/getting-started/) to exercise HTTP middleware directly without a client or server. +- Use `sus-fixtures-async-http` to run an application with a managed local server and client. +- Use ruby:`Async::HTTP::Mock::Endpoint` when testing a client that expects to connect to a particular remote endpoint. -The mocking feature of `Async::HTTP` uses a real server running in a separate task, and routes all requests to it. This allows you to intercept requests and return custom responses, but still use the real HTTP client. +## Testing Middleware Directly -In order to enable this feature, you must create an instance of {ruby Async::HTTP::Mock::Endpoint} which will handle the requests. +When a test only needs to construct requests and inspect responses, `sus-fixtures-protocol-http` can call `Protocol::HTTP` middleware in-process without starting a client or server. Add the fixture to your test dependencies: + +~~~ bash +$ bundle add sus --group test +$ bundle add sus-fixtures-protocol-http --group test +~~~ + +Include `MiddlewareContext` and provide the middleware under test: ~~~ ruby -require 'async/http' -require 'async/http/mock' +require "sus/fixtures/protocol/http/middleware_context" + +describe "My HTTP application" do + include Sus::Fixtures::Protocol::HTTP::MiddlewareContext + + let(:middleware) do + Protocol::HTTP::Middleware.for do |request| + Protocol::HTTP::Response[200, {}, ["Hello #{request.path}"]] + end + end + + it "handles a request directly" do + response = client.get("/world") + + expect(response.status).to be == 200 + expect(response.read).to be == "Hello /world" + end +end +~~~ + +The fixture closes the final request, response, and middleware after each test. Use `sus-fixtures-async-http` when the test needs a real client/server exchange. -mock_endpoint = Async::HTTP::Mock::Endpoint.new +## Testing with a Client and Server -Sync do - # Start a background server: - server_task = Async(transient: true) do - mock_endpoint.run do |request| - # Respond to the request: - ::Protocol::HTTP::Response[200, {}, ["Hello, World"]] +The `ServerContext` fixture manages an ephemeral listening endpoint, server task, and connected client. Add the fixture to your test dependencies: + +~~~ bash +$ bundle add sus --group test +$ bundle add sus-fixtures-async-http --group test +~~~ + +Define the application under test and make requests through the provided `client`: + +~~~ ruby +require "sus/fixtures/async/http" + +describe "My HTTP application" do + include Sus::Fixtures::Async::HTTP::ServerContext + + let(:app) do + Protocol::HTTP::Middleware.for do |request| + case request.path + when "/health" + Protocol::HTTP::Response[ + 200, + {"content-type" => "application/json"}, + ['{"status":"ok"}'], + ] + else + Protocol::HTTP::Response[404, {}, ["Not Found"]] + end end end - endpoint = Async::HTTP::Endpoint.parse("https://www.google.com") - mocked_endpoint = mock_endpoint.wrap(endpoint) - client = Async::HTTP::Client.new(mocked_endpoint) + it "serves the health endpoint" do + response = client.get("/health") + + expect(response).to be(:success?) + expect(response.headers["content-type"]).to be == "application/json" + expect(response.read).to be == '{"status":"ok"}' + ensure + response&.close + end - response = client.get("/") - puts response.read - # => "Hello, World" + it "returns not found for unknown paths" do + response = client.get("/missing") + expect(response.status).to be == 404 + ensure + response&.close + end end ~~~ -## Transparent Mocking +The fixture closes the client, stops the server, and releases the bound endpoint after each test. Override `app`, `url`, `protocol`, `endpoint_options`, or `retries` to configure a scenario. -Using your test framework's mocking capabilities, you can easily replace the `Async::HTTP::Client#new` with a method that returns a client with a mocked endpoint. +### Testing HTTP/2 -### Sus Integration +Override `protocol` when behavior must be verified with a specific HTTP version: ~~~ ruby -require 'async/http' -require 'async/http/mock' -require 'sus/fixtures/async/reactor_context' +describe "My HTTP/2 application" do + include Sus::Fixtures::Async::HTTP::ServerContext + + let(:protocol) {Async::HTTP::Protocol::HTTP2} + + it "responds using HTTP/2" do + response = client.get("/") + expect(response.version).to be == "HTTP/2" + ensure + response&.close + end +end +~~~ + +Test normal behavior without forcing a protocol unless the distinction is relevant to the feature under test. -include Sus::Fixtures::Async::ReactorContext +## Testing a Client with a Mock Endpoint -let(:mock_endpoint) {Async::HTTP::Mock::Endpoint.new} +ruby:`Async::HTTP::Mock::Endpoint` connects the real client and server protocol implementations through a local socket pair. It does not open a network port, but requests still exercise serialization, connection handling, and response bodies. + +Use ruby:`Async::HTTP::Mock::Endpoint#wrap` to preserve the scheme and authority expected by the client: + +~~~ ruby +require "async/http" +require "async/http/mock" +require "sus/fixtures/async/reactor_context" -def before - super +describe "A remote service client" do + include Sus::Fixtures::Async::ReactorContext - # Mock the HTTP client: - mock(Async::HTTP::Client) do |mock| - mock.wrap(:new) do |original, endpoint| - original.call(mock_endpoint.wrap(endpoint)) + it "handles a successful response" do + mock_endpoint = Async::HTTP::Mock::Endpoint.new + server_task = Async do + mock_endpoint.run do |request| + Protocol::HTTP::Response[200, {}, ["Authority: #{request.authority}"]] + end end + + remote_endpoint = Async::HTTP::Endpoint.parse("https://api.example.com") + client = Async::HTTP::Client.new(mock_endpoint.wrap(remote_endpoint)) + response = client.get("/status") + + expect(response.read).to be == "Authority: api.example.com" + ensure + response&.close + client&.close + server_task&.stop end +end +~~~ + +Return different statuses, headers, bodies, delays, or malformed behavior from the mock server to exercise client error handling. + +## Transparently Replacing Client Endpoints + +Some applications construct ruby:`Async::HTTP::Client` internally. A test can wrap the constructor so those clients connect to a mock endpoint while retaining the original endpoint metadata and client options: + +~~~ ruby +require "async/http" +require "async/http/mock" +require "sus/fixtures/async/reactor_context" + +describe "A client created by application code" do + include Sus::Fixtures::Async::ReactorContext + + let(:mock_endpoint) {Async::HTTP::Mock::Endpoint.new} - # Run the mock server: - Async(transient: true) do - mock_endpoint.run do |request| - ::Protocol::HTTP::Response[200, {}, ["Hello, World"]] + def before + super + + replacement_endpoint = mock_endpoint + mock(Async::HTTP::Client) do |wrapper| + wrapper.wrap(:new) do |original, endpoint, **options| + original.call(replacement_endpoint.wrap(endpoint), **options) + end + end + + @server_task = Async do + mock_endpoint.run do |request| + Protocol::HTTP::Response[200, {}, ["Hello, World"]] + end end end -end - -it "should perform a web request" do - client = Async::HTTP::Client.new(Async::HTTP::Endpoint.parse("https://www.google.com")) - response = client.get("/") - # The response is mocked: - expect(response.read).to be == "Hello, World" + + def after(error = nil) + @server_task&.stop + super + end + + it "routes the request through the mock endpoint" do + endpoint = Async::HTTP::Endpoint.parse("https://api.example.com") + client = Async::HTTP::Client.new(endpoint, retries: 1) + response = client.get("/") + + expect(response.read).to be == "Hello, World" + ensure + response&.close + client&.close + end end ~~~ + +Always accept and forward `**options` when wrapping the constructor so the test does not silently change client configuration.