diff --git a/context/index.yaml b/context/index.yaml index 15e43a1..78d6f97 100644 --- a/context/index.yaml +++ b/context/index.yaml @@ -10,3 +10,8 @@ files: title: Getting Started description: This guide explains how to get started with `io-endpoint`, a library that provides a separation of concerns interface for network I/O endpoints. +- path: named-endpoints.md + title: Named Endpoints + description: This guide explains how to use `IO::Endpoint::NamedEndpoints` to manage + multiple endpoints by name, enabling scenarios like running the same application + on different protocols or ports. diff --git a/context/named-endpoints.md b/context/named-endpoints.md new file mode 100644 index 0000000..53819b9 --- /dev/null +++ b/context/named-endpoints.md @@ -0,0 +1,230 @@ +# Named Endpoints + +This guide explains how to use `IO::Endpoint::NamedEndpoints` to manage multiple endpoints by name, enabling scenarios like running the same application on different protocols or ports. + +## Overview + +`NamedEndpoints` is a collection of endpoints that can be accessed by symbolic names. Unlike {ruby IO::Endpoint::CompositeEndpoint}, which treats endpoints as an ordered list for failover, `NamedEndpoints` allows you to: + +- **Access endpoints by name**: Use symbolic keys like `:http1` or `:http2` instead of array indices. +- **Run multiple configurations**: Serve the same application on different protocols, ports, or transports simultaneously. +- **Iterate over endpoints**: Process all endpoints while maintaining their names for configuration lookup. + +## When to Use NamedEndpoints + +Use `NamedEndpoints` when you need to: + +- Run the same server application on multiple endpoints with different configurations (e.g., HTTP/1 and HTTP/2). +- Access endpoints by symbolic names rather than position. +- Bind multiple endpoints and create servers for each one. +- Manage a collection of endpoints where each has a specific role or configuration. + +If you need failover behavior (trying endpoints in order until one succeeds), use {ruby IO::Endpoint::CompositeEndpoint} instead. + +## Creating Named Endpoints + +### Using the Constructor + +Create a `NamedEndpoints` instance by passing a hash of endpoints: + +```ruby +require "io/endpoint" + +http1_endpoint = IO::Endpoint.tcp("localhost", 8080) +http2_endpoint = IO::Endpoint.tcp("localhost", 8090) + +named = IO::Endpoint::NamedEndpoints.new( + http1: http1_endpoint, + http2: http2_endpoint +) +``` + +### Using the Factory Method + +The `IO::Endpoint.named` factory method provides a convenient way to create named endpoints: + +```ruby +require "io/endpoint" + +named = IO::Endpoint.named( + http1: IO::Endpoint.tcp("localhost", 8080), + http2: IO::Endpoint.tcp("localhost", 8090), + https: IO::Endpoint.ssl("localhost", 8443) +) +``` + +## Accessing Endpoints + +Access endpoints by their names using the `[]` operator: + +```ruby +named = IO::Endpoint.named( + http1: IO::Endpoint.tcp("localhost", 8080), + http2: IO::Endpoint.tcp("localhost", 8090) +) + +# Access by name +http1 = named[:http1] +http2 = named[:http2] + +# Returns nil if not found +missing = named[:nonexistent] # => nil +``` + +## Iterating Over Endpoints + +### Using `each` + +The `each` method yields both the name and endpoint: + +```ruby +named = IO::Endpoint.named( + http1: IO::Endpoint.tcp("localhost", 8080), + http2: IO::Endpoint.tcp("localhost", 8090) +) + +named.each do |name, endpoint| + puts "Endpoint #{name} is bound to #{endpoint}" +end +``` + +To map over endpoint values, use `endpoints.values.map`: + +```ruby +protocols = named.endpoints.values.map do |endpoint| + endpoint.protocol.to_s +end + +# => ["HTTP1", "HTTP2"] +``` + +## Binding Endpoints + +To bind endpoints, iterate over the collection and bind each endpoint individually, or use the `bound` method to create a new collection with all endpoints bound. + +The `bound` method creates a new `NamedEndpoints` instance where all endpoints are bound: + +```ruby +named = IO::Endpoint.named( + http1: IO::Endpoint.tcp("localhost", 8080), + http2: IO::Endpoint.tcp("localhost", 8090) +) + +bound_named = named.bound(reuse_address: true) + +# All endpoints are now bound +bound_named.each do |name, bound_endpoint| + server = bound_endpoint.sockets.first + server.listen(10) +end +``` + +## Connecting to Endpoints + +To connect to a specific endpoint, access it by name and call `connect` on that endpoint: + +```ruby +named = IO::Endpoint.named( + primary: IO::Endpoint.tcp("server1.example.com", 80), + secondary: IO::Endpoint.tcp("server2.example.com", 80) +) + +# Connect to a specific endpoint by name +named[:primary].connect do |socket| + socket.write("GET / HTTP/1.1\r\n\r\n") + response = socket.read + puts response +end +``` + +If you need failover behavior (trying endpoints in order until one succeeds), use {ruby IO::Endpoint::CompositeEndpoint} instead. + +## Real-World Example: Multi-Protocol Server + +Here's a complete example of using `NamedEndpoints` with Falcon to run the same application on HTTP/1 and HTTP/2: + +```ruby +#!/usr/bin/env falcon-host +require "falcon/environment/server" +require "falcon/environment/rack" +require "falcon/composite_server" +require "io/endpoint" +require "io/endpoint/named_endpoints" + +# Define HTTP/1 endpoint configuration +http1 = environment do + include Falcon::Environment::Server + scheme "http" + protocol {Async::HTTP::Protocol::HTTP1} + + endpoint do + Async::HTTP::Endpoint.for(scheme, "localhost", port: 8080, protocol: protocol) + end +end + +# Define HTTP/2 endpoint configuration +http2 = environment do + include Falcon::Environment::Server + scheme "http" + protocol {Async::HTTP::Protocol::HTTP2} + + endpoint do + Async::HTTP::Endpoint.for(scheme, "localhost", port: 8090, protocol: protocol) + end +end + +# Main service +service "multi-protocol" do + include Falcon::Environment::Rack + + protocol_http1 {http1.with(middleware: self.middleware).evaluator} + protocol_http2 {http2.with(middleware: self.middleware).evaluator} + + # Combine endpoints using NamedEndpoints + endpoint do + IO::Endpoint::NamedEndpoints.new( + protocol_http1: protocol_http1.endpoint, + protocol_http2: protocol_http2.endpoint + ) + end + + # Create servers for each named endpoint + make_server do |bound_endpoint| + servers = {} + + bound_endpoint.each do |name, endpoint| + servers[name.to_s] = self[name].make_server(endpoint) + end + + Falcon::CompositeServer.new(servers) + end +end +``` + +This configuration allows the same Rack application to be served on both HTTP/1 (port 8080) and HTTP/2 (port 8090) simultaneously. + +## Closing Endpoints + +The `close` method closes all endpoints in the collection: + +```ruby +named = IO::Endpoint.named( + http1: IO::Endpoint.tcp("localhost", 8080), + http2: IO::Endpoint.tcp("localhost", 8090) +) + +# ... use endpoints ... + +named.close # Closes all endpoints +``` + +## Comparison with CompositeEndpoint + +| Feature | NamedEndpoints | CompositeEndpoint | +|---------|---------------|-------------------| +| **Access** | By symbolic name (`[:http1]`) | By index (`[0]`) | +| **Use Case** | Multiple configurations | Failover/load balancing | +| **Iteration** | Yields name and endpoint | Yields endpoint only | +| **Connect Behavior** | Tries all until success | Tries in order until success | + +Choose `NamedEndpoints` when you need to identify endpoints by name and manage different configurations. Choose `CompositeEndpoint` when you need failover behavior with endpoints tried in order. diff --git a/guides/links.yaml b/guides/links.yaml index f34eb93..0116d83 100644 --- a/guides/links.yaml +++ b/guides/links.yaml @@ -1,3 +1,6 @@ getting-started: order: 1 +named-endpoints: + order: 2 + diff --git a/guides/named-endpoints/readme.md b/guides/named-endpoints/readme.md new file mode 100644 index 0000000..53819b9 --- /dev/null +++ b/guides/named-endpoints/readme.md @@ -0,0 +1,230 @@ +# Named Endpoints + +This guide explains how to use `IO::Endpoint::NamedEndpoints` to manage multiple endpoints by name, enabling scenarios like running the same application on different protocols or ports. + +## Overview + +`NamedEndpoints` is a collection of endpoints that can be accessed by symbolic names. Unlike {ruby IO::Endpoint::CompositeEndpoint}, which treats endpoints as an ordered list for failover, `NamedEndpoints` allows you to: + +- **Access endpoints by name**: Use symbolic keys like `:http1` or `:http2` instead of array indices. +- **Run multiple configurations**: Serve the same application on different protocols, ports, or transports simultaneously. +- **Iterate over endpoints**: Process all endpoints while maintaining their names for configuration lookup. + +## When to Use NamedEndpoints + +Use `NamedEndpoints` when you need to: + +- Run the same server application on multiple endpoints with different configurations (e.g., HTTP/1 and HTTP/2). +- Access endpoints by symbolic names rather than position. +- Bind multiple endpoints and create servers for each one. +- Manage a collection of endpoints where each has a specific role or configuration. + +If you need failover behavior (trying endpoints in order until one succeeds), use {ruby IO::Endpoint::CompositeEndpoint} instead. + +## Creating Named Endpoints + +### Using the Constructor + +Create a `NamedEndpoints` instance by passing a hash of endpoints: + +```ruby +require "io/endpoint" + +http1_endpoint = IO::Endpoint.tcp("localhost", 8080) +http2_endpoint = IO::Endpoint.tcp("localhost", 8090) + +named = IO::Endpoint::NamedEndpoints.new( + http1: http1_endpoint, + http2: http2_endpoint +) +``` + +### Using the Factory Method + +The `IO::Endpoint.named` factory method provides a convenient way to create named endpoints: + +```ruby +require "io/endpoint" + +named = IO::Endpoint.named( + http1: IO::Endpoint.tcp("localhost", 8080), + http2: IO::Endpoint.tcp("localhost", 8090), + https: IO::Endpoint.ssl("localhost", 8443) +) +``` + +## Accessing Endpoints + +Access endpoints by their names using the `[]` operator: + +```ruby +named = IO::Endpoint.named( + http1: IO::Endpoint.tcp("localhost", 8080), + http2: IO::Endpoint.tcp("localhost", 8090) +) + +# Access by name +http1 = named[:http1] +http2 = named[:http2] + +# Returns nil if not found +missing = named[:nonexistent] # => nil +``` + +## Iterating Over Endpoints + +### Using `each` + +The `each` method yields both the name and endpoint: + +```ruby +named = IO::Endpoint.named( + http1: IO::Endpoint.tcp("localhost", 8080), + http2: IO::Endpoint.tcp("localhost", 8090) +) + +named.each do |name, endpoint| + puts "Endpoint #{name} is bound to #{endpoint}" +end +``` + +To map over endpoint values, use `endpoints.values.map`: + +```ruby +protocols = named.endpoints.values.map do |endpoint| + endpoint.protocol.to_s +end + +# => ["HTTP1", "HTTP2"] +``` + +## Binding Endpoints + +To bind endpoints, iterate over the collection and bind each endpoint individually, or use the `bound` method to create a new collection with all endpoints bound. + +The `bound` method creates a new `NamedEndpoints` instance where all endpoints are bound: + +```ruby +named = IO::Endpoint.named( + http1: IO::Endpoint.tcp("localhost", 8080), + http2: IO::Endpoint.tcp("localhost", 8090) +) + +bound_named = named.bound(reuse_address: true) + +# All endpoints are now bound +bound_named.each do |name, bound_endpoint| + server = bound_endpoint.sockets.first + server.listen(10) +end +``` + +## Connecting to Endpoints + +To connect to a specific endpoint, access it by name and call `connect` on that endpoint: + +```ruby +named = IO::Endpoint.named( + primary: IO::Endpoint.tcp("server1.example.com", 80), + secondary: IO::Endpoint.tcp("server2.example.com", 80) +) + +# Connect to a specific endpoint by name +named[:primary].connect do |socket| + socket.write("GET / HTTP/1.1\r\n\r\n") + response = socket.read + puts response +end +``` + +If you need failover behavior (trying endpoints in order until one succeeds), use {ruby IO::Endpoint::CompositeEndpoint} instead. + +## Real-World Example: Multi-Protocol Server + +Here's a complete example of using `NamedEndpoints` with Falcon to run the same application on HTTP/1 and HTTP/2: + +```ruby +#!/usr/bin/env falcon-host +require "falcon/environment/server" +require "falcon/environment/rack" +require "falcon/composite_server" +require "io/endpoint" +require "io/endpoint/named_endpoints" + +# Define HTTP/1 endpoint configuration +http1 = environment do + include Falcon::Environment::Server + scheme "http" + protocol {Async::HTTP::Protocol::HTTP1} + + endpoint do + Async::HTTP::Endpoint.for(scheme, "localhost", port: 8080, protocol: protocol) + end +end + +# Define HTTP/2 endpoint configuration +http2 = environment do + include Falcon::Environment::Server + scheme "http" + protocol {Async::HTTP::Protocol::HTTP2} + + endpoint do + Async::HTTP::Endpoint.for(scheme, "localhost", port: 8090, protocol: protocol) + end +end + +# Main service +service "multi-protocol" do + include Falcon::Environment::Rack + + protocol_http1 {http1.with(middleware: self.middleware).evaluator} + protocol_http2 {http2.with(middleware: self.middleware).evaluator} + + # Combine endpoints using NamedEndpoints + endpoint do + IO::Endpoint::NamedEndpoints.new( + protocol_http1: protocol_http1.endpoint, + protocol_http2: protocol_http2.endpoint + ) + end + + # Create servers for each named endpoint + make_server do |bound_endpoint| + servers = {} + + bound_endpoint.each do |name, endpoint| + servers[name.to_s] = self[name].make_server(endpoint) + end + + Falcon::CompositeServer.new(servers) + end +end +``` + +This configuration allows the same Rack application to be served on both HTTP/1 (port 8080) and HTTP/2 (port 8090) simultaneously. + +## Closing Endpoints + +The `close` method closes all endpoints in the collection: + +```ruby +named = IO::Endpoint.named( + http1: IO::Endpoint.tcp("localhost", 8080), + http2: IO::Endpoint.tcp("localhost", 8090) +) + +# ... use endpoints ... + +named.close # Closes all endpoints +``` + +## Comparison with CompositeEndpoint + +| Feature | NamedEndpoints | CompositeEndpoint | +|---------|---------------|-------------------| +| **Access** | By symbolic name (`[:http1]`) | By index (`[0]`) | +| **Use Case** | Multiple configurations | Failover/load balancing | +| **Iteration** | Yields name and endpoint | Yields endpoint only | +| **Connect Behavior** | Tries all until success | Tries in order until success | + +Choose `NamedEndpoints` when you need to identify endpoints by name and manage different configurations. Choose `CompositeEndpoint` when you need failover behavior with endpoints tried in order. diff --git a/lib/io/endpoint/named_endpoints.rb b/lib/io/endpoint/named_endpoints.rb new file mode 100644 index 0000000..87c0109 --- /dev/null +++ b/lib/io/endpoint/named_endpoints.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023-2025, by Samuel Williams. + +require_relative "generic" + +module IO::Endpoint + # A named endpoints collection is a hash of endpoints that can be accessed by name. + # + # Unlike {CompositeEndpoint}, which treats endpoints as an ordered list for failover, `NamedEndpoints` allows you to access endpoints by symbolic names, making it useful for scenarios where you need to run the same application on multiple endpoints with different configurations (e.g., HTTP/1 and HTTP/2 on different ports). + class NamedEndpoints + # Initialize a new named endpoints collection. + # @parameter endpoints [Hash(Symbol, Generic)] A hash mapping endpoint names to endpoint instances. + def initialize(endpoints) + @endpoints = endpoints + end + + # @attribute [Hash(Symbol, Generic)] The endpoints hash mapping names to endpoint instances. + attr :endpoints + + # Access an endpoint by its name. + # @parameter key [Symbol] The name of the endpoint to access. + # @returns [Generic, nil] The endpoint with the given name, or nil if not found. + def [] key + @endpoints[key] + end + + # Enumerate all endpoints with their names. + # @yields {|name, endpoint| ...} For each endpoint, yields the name and endpoint. + # @parameter name [Symbol] The name of the endpoint. + # @parameter endpoint [Generic] The endpoint. + def each(&block) + @endpoints.each(&block) + end + + # Create a new named endpoints instance with all endpoints bound. + # @parameter options [Hash] Options to pass to each endpoint's bound method. + # @returns [NamedEndpoints] A new instance with bound endpoints. + def bound(**options) + self.class.new( + @endpoints.transform_values{|endpoint| endpoint.bound(**options)} + ) + end + + # Create a new named endpoints instance with all endpoints connected. + # @parameter options [Hash] Options to pass to each endpoint's connected method. + # @returns [NamedEndpoints] A new instance with connected endpoints. + def connected(**options) + self.class.new( + @endpoints.transform_values{|endpoint| endpoint.connected(**options)} + ) + end + + # Close all endpoints in the collection. + # Calls `close` on each endpoint value. + # @returns [void] + def close + @endpoints.each_value(&:close) + end + end + + # Create a named endpoints collection from keyword arguments. + # @parameter endpoints [Hash(Symbol, Generic)] Named endpoints as keyword arguments. + # @returns [NamedEndpoints] A new named endpoints instance. + # @example Create a named endpoints collection + # endpoints = IO::Endpoint.named( + # http1: IO::Endpoint.tcp("localhost", 8080), + # http2: IO::Endpoint.tcp("localhost", 8090) + # ) + def self.named(**endpoints) + NamedEndpoints.new(endpoints) + end +end diff --git a/readme.md b/readme.md index ca054c7..e2d9a7b 100644 --- a/readme.md +++ b/readme.md @@ -10,6 +10,8 @@ Please see the [project documentation](https://socketry.github.io/io-endpoint) f - [Getting Started](https://socketry.github.io/io-endpointguides/getting-started/index) - This guide explains how to get started with `io-endpoint`, a library that provides a separation of concerns interface for network I/O endpoints. + - [Named Endpoints](https://socketry.github.io/io-endpointguides/named-endpoints/index) - This guide explains how to use `IO::Endpoint::NamedEndpoints` to manage multiple endpoints by name, enabling scenarios like running the same application on different protocols or ports. + ## Releases Please see the [project releases](https://socketry.github.io/io-endpointreleases/index) for all releases. diff --git a/releases.md b/releases.md index f6c69df..14df3a7 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + +- Added `IO::Endpoint::NamedEndpoints` for accessing endpoints by symbolic names, useful for running applications on multiple endpoints with different configurations. + ## v0.16.0 - Improved error handling in `#connect` for more robust connection handling. diff --git a/test/io/endpoint/named_endpoints.rb b/test/io/endpoint/named_endpoints.rb new file mode 100644 index 0000000..e9ce272 --- /dev/null +++ b/test/io/endpoint/named_endpoints.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "io/endpoint/host_endpoint" +require "io/endpoint/shared_endpoint" +require "io/endpoint/named_endpoints" + +describe IO::Endpoint::NamedEndpoints do + let(:endpoint1) {IO::Endpoint.tcp("localhost", 0)} + let(:endpoint2) {IO::Endpoint.tcp("localhost", 0)} + let(:endpoints) {{http1: endpoint1, http2: endpoint2}} + let(:named_endpoints) {subject.new(endpoints)} + + with "#initialize" do + it "can be initialized with a hash of endpoints" do + expect(named_endpoints).to be_a(subject) + expect(named_endpoints.endpoints).to be == endpoints + end + end + + with "#[]" do + it "can access endpoints by key" do + expect(named_endpoints[:http1]).to be == endpoint1 + expect(named_endpoints[:http2]).to be == endpoint2 + expect(named_endpoints[:nonexistent]).to be_nil + end + end + + with "#each" do + it "can enumerate endpoints with names" do + results = [] + named_endpoints.each do |name, endpoint| + results << [name, endpoint] + end + + expect(results).to have_attributes(size: be == 2) + expect(results[0]).to be == [:http1, endpoint1] + expect(results[1]).to be == [:http2, endpoint2] + end + + it "returns an enumerator when no block is given" do + enumerator = named_endpoints.each + expect(enumerator).to be_a(Enumerator) + expect(enumerator.to_a).to have_attributes(size: be == 2) + end + end + + with "#bound" do + it "creates a new instance with all endpoints bound" do + bound_named = named_endpoints.bound + expect(bound_named).to be_a(subject) + expect(bound_named).not.to be == named_endpoints + + # Check that endpoints are bound + bound_named.each do |name, bound_endpoint| + expect(bound_endpoint).to respond_to(:sockets) + end + ensure + bound_named&.each{|name, endpoint| endpoint.close} + end + + it "propagates options to bound endpoints" do + bound_named = named_endpoints.bound(backlog: 5) + expect(bound_named).to be_a(subject) + ensure + bound_named&.each{|name, endpoint| endpoint.close} + end + end + + with "#connected" do + it "creates a new instance with all endpoints connected" do + bound = endpoint1.bound + server = bound.sockets.first + server.listen(1) + + thread = Thread.new do + loop do + peer, address = server.accept + peer.close + rescue + break + end + end + + client_endpoint = IO::Endpoint.tcp("localhost", server.local_address.ip_port) + named = subject.new({primary: client_endpoint}) + + connected_named = named.connected + expect(connected_named).to be_a(subject) + expect(connected_named).not.to be == named + + # Check that endpoints are connected + connected_named.each do |name, connected_endpoint| + expect(connected_endpoint).to respond_to(:socket) + end + ensure + bound&.close + thread&.kill + thread&.join + end + end + + with "#close" do + it "closes all endpoints" do + bound1 = endpoint1.bound + bound2 = endpoint2.bound + + named = subject.new({ + http1: bound1, + http2: bound2 + }) + + expect(bound1.sockets).not.to be(:empty?) + expect(bound2.sockets).not.to be(:empty?) + + named.close + + expect(bound1.sockets).to be(:empty?) + expect(bound2.sockets).to be(:empty?) + end + end + + with "#endpoints" do + it "returns the endpoints hash" do + expect(named_endpoints.endpoints).to be == endpoints + end + end +end + +describe IO::Endpoint do + with ".named" do + let(:endpoint1) {IO::Endpoint.tcp("localhost", 0)} + let(:endpoint2) {IO::Endpoint.tcp("localhost", 0)} + + it "can create NamedEndpoints from keyword arguments" do + named = subject.named(http1: endpoint1, http2: endpoint2) + expect(named).to be_a(IO::Endpoint::NamedEndpoints) + expect(named[:http1]).to be == endpoint1 + expect(named[:http2]).to be == endpoint2 + end + end +end