pax_global_header00006660000000000000000000000064150764151660014525gustar00rootroot0000000000000052 comment=ef20d4d43bde8a2ed62fda11d2680ae8a8b8d40a protocol-http-0.55.0/000077500000000000000000000000001507641516600144325ustar00rootroot00000000000000protocol-http-0.55.0/.editorconfig000066400000000000000000000001511507641516600171040ustar00rootroot00000000000000root = true [*] indent_style = tab indent_size = 2 [*.{yml,yaml}] indent_style = space indent_size = 2 protocol-http-0.55.0/.github/000077500000000000000000000000001507641516600157725ustar00rootroot00000000000000protocol-http-0.55.0/.github/copilot-instructions.md000066400000000000000000000016431507641516600225330ustar00rootroot00000000000000# GitHub Copilot Instructions ## Use Agent Context When working on this project, consult the `agents.md` file for project-specific guidelines, architecture decisions, and development patterns. This file contains curated information that will help you make better decisions aligned with the project's goals and standards. If the file does not exist, you will need to install it, by running the following command: ```bash $ bundle install $ bundle exec bake agent:context:install ``` This command will set up the necessary context files that help you understand the project structure, dependencies, and conventions. ## Ignoring Files The `.gitignore` file is split into two sections, separated by a blank line. The first section is automatically generated, while the second section is user controlled. While working on pull requests, you should not add unrelated changes to the `.gitignore` file as part of the pull request. protocol-http-0.55.0/.github/workflows/000077500000000000000000000000001507641516600200275ustar00rootroot00000000000000protocol-http-0.55.0/.github/workflows/documentation-coverage.yaml000066400000000000000000000006511507641516600253570ustar00rootroot00000000000000name: Documentation Coverage on: [push, pull_request] permissions: contents: read env: COVERAGE: PartialSummary jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - name: Validate coverage timeout-minutes: 5 run: bundle exec bake decode:index:coverage lib protocol-http-0.55.0/.github/workflows/documentation.yaml000066400000000000000000000021311507641516600235610ustar00rootroot00000000000000name: 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: BUNDLE_WITH: maintenance jobs: generate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby 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 protocol-http-0.55.0/.github/workflows/rubocop.yaml000066400000000000000000000005321507641516600223640ustar00rootroot00000000000000name: RuboCop on: [push, pull_request] permissions: contents: read 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 protocol-http-0.55.0/.github/workflows/test-coverage.yaml000066400000000000000000000022031507641516600234600ustar00rootroot00000000000000name: Test Coverage on: [push, pull_request] permissions: contents: read env: COVERAGE: PartialSummary jobs: test: name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest strategy: matrix: os: - ubuntu - macos ruby: - ruby 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: ruby bundler-cache: true - uses: actions/download-artifact@v4 - name: Validate coverage timeout-minutes: 5 run: bundle exec bake covered:validate --paths */.covered.db \; protocol-http-0.55.0/.github/workflows/test-external.yaml000066400000000000000000000011101507641516600235030ustar00rootroot00000000000000name: Test External on: [push, pull_request] permissions: contents: read jobs: test: name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest strategy: matrix: os: - ubuntu - macos ruby: - "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 protocol-http-0.55.0/.github/workflows/test.yaml000066400000000000000000000016261507641516600216770ustar00rootroot00000000000000name: Test on: [push, pull_request] permissions: contents: read 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.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 protocol-http-0.55.0/.gitignore000066400000000000000000000001071507641516600164200ustar00rootroot00000000000000/agents.md /.context /.bundle /pkg /gems.locked /.covered.db /external protocol-http-0.55.0/.mailmap000066400000000000000000000000751507641516600160550ustar00rootroot00000000000000Dan Olson Thomas Morgan protocol-http-0.55.0/.rubocop.yml000066400000000000000000000022551507641516600167100ustar00rootroot00000000000000plugins: - rubocop-socketry AllCops: DisabledByDefault: true Layout/ConsistentBlankLineIndentation: Enabled: 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 Layout/EmptyLineAfterMagicComment: Enabled: true Layout/SpaceInsideBlockBraces: Enabled: true EnforcedStyle: no_space SpaceBeforeBlockParameters: false Layout/SpaceAroundBlockParameters: Enabled: true EnforcedStyleInsidePipes: no_space Style/FrozenStringLiteralComment: Enabled: true Style/StringLiterals: Enabled: true EnforcedStyle: double_quotes protocol-http-0.55.0/bake.rb000066400000000000000000000005541507641516600156650ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2020-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:update"].call end protocol-http-0.55.0/benchmark/000077500000000000000000000000001507641516600163645ustar00rootroot00000000000000protocol-http-0.55.0/benchmark/string.rb000066400000000000000000000011411507641516600202140ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2025, by Samuel Williams. def generator 100000.times do |i| yield "foo #{i}" end end def consumer_without_clear buffer = String.new generator do |chunk| buffer << chunk end return nil end def consumer_with_clear buffer = String.new generator do |chunk| buffer << chunk chunk.clear end return nil end require "benchmark" Benchmark.bm do |x| x.report("consumer_with_clear") do consumer_with_clear GC.start end x.report("consumer_without_clear") do consumer_without_clear GC.start end end protocol-http-0.55.0/config/000077500000000000000000000000001507641516600156775ustar00rootroot00000000000000protocol-http-0.55.0/config/external.yaml000066400000000000000000000015251507641516600204100ustar00rootroot00000000000000protocol-http1: url: https://github.com/socketry/protocol-http1.git command: bundle exec sus protocol-http2: url: https://github.com/socketry/protocol-http2.git command: bundle exec sus protocol-rack: url: https://github.com/socketry/protocol-rack.git command: bundle exec sus async-http: url: https://github.com/socketry/async-http.git command: bundle exec sus async-http-cache: url: https://github.com/socketry/async-http-cache.git command: bundle exec sus protocol-websocket: url: https://github.com/socketry/protocol-websocket.git command: bundle exec sus async-websocket: url: https://github.com/socketry/async-websocket.git command: bundle exec sus falcon: url: https://github.com/socketry/falcon.git command: bundle exec sus async-rest: url: https://github.com/socketry/async-rest.git command: bundle exec sus protocol-http-0.55.0/config/sus.rb000066400000000000000000000002311507641516600170320ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "covered/sus" include Covered::Sus protocol-http-0.55.0/context/000077500000000000000000000000001507641516600161165ustar00rootroot00000000000000protocol-http-0.55.0/context/design-overview.md000066400000000000000000000130171507641516600215570ustar00rootroot00000000000000# Design Overview This guide explains the high level design of `protocol-http` in the context of wider design patterns that can be used to implement HTTP clients and servers. ## Request/Response Model The main model we support is the request/response model. A client sends a request to a server which return response. The protocol is responsible for serializing the request and response objects. ```mermaid sequenceDiagram participant CA as Application participant Client participant Server participant SA as Application CA->>+Client: Request Client->>+Server: Request Server->>+SA: Request SA->>+Server: Response Server->>+Client: Response Client->>+CA: Response ``` We provide an interface for request and response objects. This provides performance, predictability and robustness. This model has proven itself over several years, handling a variety of different use cases. ~~~ ruby class Request attr :method attr :target attr :headers attr :body end class Response attr :status attr :headers attr :body end ~~~ One other advantage is that it's symmetrical between clients and servers with a clear mapping, i.e. the protocol is responsible for transiting requests from the client to the server, and responses from the server back to the client. This helps us separate and define request/response interfaces independently from protocol implementation. ### Client Design A request/response model implies that you create a request and receive a response back. This maps to a normal function call where the request is the argument and the response is the returned value. ~~~ ruby request = Request.new("GET", url) response = client.call(request) response.headers response.read ~~~ ## Stream Model An alternative model is the stream model. This model is more suitable for WebSockets and other persistent bi-directional channels. ```mermaid sequenceDiagram participant CA as Application participant Client participant Server participant SA as Application CA->>+Client: Stream Client->>+Server: Stream Server->>+SA: Stream ``` The interfaces for streaming can be implemented a bit differently, since a response is not returned but rather assigned to the stream, and the streaming occurs in the same execution context as the client or server handling the request. ~~~ ruby class Stream # Request details. attr :method attr :target attr :headers attr :response # Write the response and start streaming the output body. def respond(status, headers) response.status = status response.headers = headers end # Request body. attr_accessor :input # Response body. attr_accessor :output # Write to the response body. def write(...) @output.write(...) end # Read from the request body. def read @input.read end end class Response def initialize(method, target) @input = Body::Writable.new @output = Body::Writable.new end attr_accessor :status attr_accessor :headers # Prepare a stream for making a request. def request(method, target, headers) # Create a request stream suitable for writing into the buffered response: Stream.new(method, target, headers, self, @input, @output) end # Write to the request body. def write(...) @input.write(...) end # Read from the response body. def read @output.read end end ~~~ ### Client Design A stream model implies that you create a stream which contains both the request and response bodies. This maps to a normal function call where the argument is the stream and the returned value is ignored. ~~~ ruby response = Response.new stream = response.request("GET", url) client.call(stream) response.headers response.read ~~~ ## Differences The request/response model has a symmetrical design which naturally uses the return value for the result of executing the request. The result encapsulates the behaviour of how to read the response status, headers and body. Because of that, streaming input and output becomes a function of the result object itself. As in: ~~~ ruby def call(request) body = Body::Writable.new Fiber.schedule do while chunk = request.input.read body.write(chunk.reverse) end end return Response[200, headers, body] end input = Body::Writable.new response = call(... body ...) input.write("Hello World") input.close response.read -> "dlroW olleH" ~~~ The streaming model does not have the same symmetry, and instead opts for a uni-directional flow of information. ~~~ruby def call(stream) stream.respond(200, headers) Fiber.schedule do while chunk = stream.read stream.write(chunk.reverse) end end end input = Body::Writable.new response = Response.new(...input...) call(response.stream) input.write("Hello World") input.close response.read -> "dlroW olleH" ~~~ The value of this uni-directional flow is that it is natural for the stream to be taken out of the scope imposed by the nested `call(request)` model. However, the user must explicitly close the stream, since it's no longer scoped to the client and/or server. ## Interim Response Handling Interim responses are responses that are sent before the final response. They are used for things like `103 Early Hints` and `100 Continue`. These responses are sent before the final response, and are used to signal to the client that the server is still processing the request. ```ruby body = Body::Writable.new interim_response_callback = proc do |status, headers| if status == 100 # Continue sending the request body. body.write("Hello World") body.close end end response = client.post("/upload", {'expect' => '100-continue'}, body, interim_response: interim_response_callback) ``` protocol-http-0.55.0/context/getting-started.md000066400000000000000000000104051507641516600215450ustar00rootroot00000000000000# Getting Started This guide explains how to use `protocol-http` for building abstract HTTP interfaces. ## Installation Add the gem to your project: ~~~ bash $ bundle add protocol-http ~~~ ## Core Concepts `protocol-http` has several core concepts: - A {ruby Protocol::HTTP::Request} instance which represents an abstract HTTP request. Specific versions of HTTP may subclass this to track additional state. - A {ruby Protocol::HTTP::Response} instance which represents an abstract HTTP response. Specific versions of HTTP may subclass this to track additional state. - A {ruby Protocol::HTTP::Middleware} interface for building HTTP applications. - A {ruby Protocol::HTTP::Headers} interface for storing HTTP headers with semantics based on documented specifications (RFCs, etc). - A set of {ruby Protocol::HTTP::Body} classes which handle the internal request and response bodies, including bi-directional streaming. ## Integration This gem does not provide any specific client or server implementation, rather it's used by several other gems. - [Protocol::HTTP1](https://github.com/socketry/protocol-http1) & [Protocol::HTTP2](https://github.com/socketry/protocol-http2) which provide client and server implementations. - [Async::HTTP](https://github.com/socketry/async-http) which provides connection pooling and concurrency. ## Usage ### Request {ruby Protocol::HTTP::Request} represents an HTTP request which can be used both server and client-side. ``` ruby require 'protocol/http/request' # Short form (recommended): request = Protocol::HTTP::Request["GET", "/index.html", {"accept" => "text/html"}] # Long form: headers = Protocol::HTTP::Headers[["accept", "text/html"]] request = Protocol::HTTP::Request.new("http", "example.com", "GET", "/index.html", "HTTP/1.1", headers) # Access request properties request.method # => "GET" request.path # => "/index.html" request.headers # => Protocol::HTTP::Headers instance ``` ### Response {ruby Protocol::HTTP::Response} represents an HTTP response which can be used both server and client-side. ``` ruby require 'protocol/http/response' # Short form (recommended): response = Protocol::HTTP::Response[200, {"content-type" => "text/html"}, "Hello, World!"] # Long form: headers = Protocol::HTTP::Headers["content-type" => "text/html"] body = Protocol::HTTP::Body::Buffered.wrap("Hello, World!") response = Protocol::HTTP::Response.new("HTTP/1.1", 200, headers, body) # Access response properties response.status # => 200 response.headers # => Protocol::HTTP::Headers instance response.body # => Body instance # Status checking methods response.success? # => true (200-299) response.ok? # => true (200) response.redirection? # => false (300-399) response.failure? # => false (400-599) ``` ### Headers {ruby Protocol::HTTP::Headers} provides semantically meaningful interpretation of header values and implements case-normalising keys. #### Basic Usage ``` ruby require 'protocol/http/headers' headers = Protocol::HTTP::Headers.new # Assignment by title-case key: headers['Content-Type'] = "image/jpeg" # Lookup by lower-case (normalized) key: headers['content-type'] # => "image/jpeg" ``` #### Semantic Processing Many headers receive special semantic processing, automatically splitting comma-separated values and providing structured access: ``` ruby # Accept header with quality values: headers['Accept'] = 'text/html, application/json;q=0.8, */*;q=0.1' accept = headers['accept'] # => ["text/html", "application/json;q=0.8", "*/*;q=0.1"] # Access parsed media ranges with quality factors: accept.media_ranges.each do |range| puts "#{range.type}/#{range.subtype} (q=#{range.quality_factor})" end # text/html (q=1.0) # application/json (q=0.8) # */* (q=0.1) # Accept-Encoding automatically splits values: headers['Accept-Encoding'] = 'gzip, deflate, br;q=0.9' headers['accept-encoding'] # => ["gzip", "deflate", "br;q=0.9"] # Cache-Control splits directives: headers['Cache-Control'] = 'max-age=3600, no-cache, must-revalidate' headers['cache-control'] # => ["max-age=3600", "no-cache", "must-revalidate"] # Vary header normalizes field names to lowercase: headers['Vary'] = 'Accept-Encoding, User-Agent' headers['vary'] # => ["accept-encoding", "user-agent"] ``` protocol-http-0.55.0/context/headers.md000066400000000000000000000060741507641516600200620ustar00rootroot00000000000000# Headers This guide explains how to work with HTTP headers using `protocol-http`. ## Core Concepts `protocol-http` provides several core concepts for working with HTTP headers: - A {ruby Protocol::HTTP::Headers} class which represents a collection of HTTP headers with built-in security and policy features. - Header-specific classes like {ruby Protocol::HTTP::Header::Accept} and {ruby Protocol::HTTP::Header::Authorization} which provide specialized parsing and formatting. - Trailer security validation to prevent HTTP request smuggling attacks. ## Usage The {Protocol::HTTP::Headers} class provides a comprehensive interface for creating and manipulating HTTP headers: ```ruby require 'protocol/http' headers = Protocol::HTTP::Headers.new headers.add('content-type', 'text/html') headers.add('set-cookie', 'session=abc123') # Access headers content_type = headers['content-type'] # => "text/html" # Check if header exists headers.include?('content-type') # => true ``` ### Header Policies Different header types have different behaviors for merging, validation, and trailer handling: ```ruby # Some headers can be specified multiple times headers.add('set-cookie', 'first=value1') headers.add('set-cookie', 'second=value2') # Others are singletons and will raise errors if duplicated headers.add('content-length', '100') # headers.add('content-length', '200') # Would raise DuplicateHeaderError ``` ### Structured Headers Some headers have specialized classes for parsing and formatting: ```ruby # Accept header with media ranges accept = Protocol::HTTP::Header::Accept.new('text/html,application/json;q=0.9') media_ranges = accept.media_ranges # Authorization header auth = Protocol::HTTP::Header::Authorization.basic('username', 'password') # => "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" ``` ### Trailer Security HTTP trailers are headers that appear after the message body. For security reasons, only certain headers are allowed in trailers: ```ruby # Working with trailers headers = Protocol::HTTP::Headers.new([ ['content-type', 'text/html'], ['content-length', '1000'] ]) # Start trailer section headers.trailer! # These will be allowed (safe metadata) headers.add('etag', '"12345"') headers.add('date', Time.now.httpdate) # These will be silently ignored for security headers.add('authorization', 'Bearer token') # Ignored - credential leakage risk headers.add('connection', 'close') # Ignored - hop-by-hop header ``` The trailer security system prevents HTTP request smuggling by restricting which headers can appear in trailers: **Allowed headers** (return `true` for `trailer?`): - `date` - Response generation timestamps. - `digest` - Content integrity verification. - `etag` - Cache validation tags. - `server-timing` - Performance metrics. **Forbidden headers** (return `false` for `trailer?`): - `authorization` - Prevents credential leakage. - `connection`, `te`, `transfer-encoding` - Hop-by-hop headers that control connection behavior. - `cookie`, `set-cookie` - State information needed during initial processing. - `accept` - Content negotiation must occur before response generation. protocol-http-0.55.0/context/hypertext-references.md000066400000000000000000000100371507641516600226140ustar00rootroot00000000000000# Hypertext References This guide explains how to use `Protocol::HTTP::Reference` for constructing and manipulating hypertext references (URLs with parameters). ## Overview {ruby Protocol::HTTP::Reference} is used to construct "hypertext references" which consist of a path and URL-encoded parameters. References provide a rich API for URL construction, path manipulation, and parameter handling. ## Basic Construction ``` ruby require 'protocol/http/reference' # Simple reference with parameters: reference = Protocol::HTTP::Reference.new("/search", nil, nil, {q: 'kittens', limit: 10}) reference.to_s # => "/search?q=kittens&limit=10" # Parse existing URLs: reference = Protocol::HTTP::Reference.parse("/api/users?page=2&sort=name#results") reference.path # => "/api/users" reference.query # => "page=2&sort=name" reference.fragment # => "results" # To get parameters as a hash, decode the query string: parameters = Protocol::HTTP::URL.decode(reference.query) parameters # => {"page" => "2", "sort" => "name"} ``` ## Path Manipulation References support sophisticated path manipulation including relative path resolution: ``` ruby base = Protocol::HTTP::Reference.new("/api/v1/users") # Append paths: user_detail = base.with(path: "123") user_detail.to_s # => "/api/v1/users/123" # Relative path navigation: parent = user_detail.with(path: "../groups", pop: true) parent.to_s # => "/api/v1/groups" # Absolute path replacement: root = user_detail.with(path: "/status") root.to_s # => "/status" ``` ## Advanced Parameter Handling ``` ruby # Complex parameter structures: reference = Protocol::HTTP::Reference.new("/search", nil, nil, { filters: { category: "books", price: {min: 10, max: 50} }, tags: ["fiction", "mystery"] }) reference.to_s # => "/search?filters[category]=books&filters[price][min]=10&filters[price][max]=50&tags[]=fiction&tags[]=mystery" # Parameter merging: base = Protocol::HTTP::Reference.new("/api", nil, nil, {version: "v1", format: "json"}) extended = base.with(parameters: {detailed: true}, merge: true) extended.to_s # => "/api?version=v1&format=json&detailed=true" # Parameter replacement (using merge: false): replaced = base.with(parameters: {format: "xml"}, merge: false) replaced.to_s # => "/api?format=xml" ``` ## Merge Behavior and Query Strings The `merge` parameter controls both parameter handling and query string behavior: ``` ruby # Create a reference with both query string and parameters: ref = Protocol::HTTP::Reference.new("/api", "existing=query", nil, {version: "v1"}) ref.to_s # => "/api?existing=query&version=v1" # merge: true (default) - keeps existing query string: merged = ref.with(parameters: {new: "argument"}, merge: true) merged.to_s # => "/api?existing=query&version=v1&new=argument" # merge: false with new parameters - clears query string: replaced = ref.with(parameters: {new: "argument"}, merge: false) replaced.to_s # => "/api?new=argument" # merge: false without new parameters - keeps everything: unchanged = ref.with(path: "v2", merge: false) unchanged.to_s # => "/api/v2?existing=query&version=v1" ``` ## URL Encoding and Special Characters References handle URL encoding automatically: ``` ruby # Spaces and special characters: reference = Protocol::HTTP::Reference.new("/search", nil, nil, { q: "hello world", filter: "price > $10" }) reference.to_s # => "/search?q=hello%20world&filter=price%20%3E%20%2410" # Unicode support: unicode_ref = Protocol::HTTP::Reference.new("/files", nil, nil, { name: "résumé.pdf", emoji: "😀" }) unicode_ref.to_s # => "/files?name=r%C3%A9sum%C3%A9.pdf&emoji=%F0%9F%98%80" ``` ## Reference Merging References can be merged following RFC2396 URI resolution rules: ``` ruby base = Protocol::HTTP::Reference.new("/docs/guide/") relative = Protocol::HTTP::Reference.new("../api/reference.html") merged = base + relative merged.to_s # => "/docs/api/reference.html" # Absolute references override completely absolute = Protocol::HTTP::Reference.new("/completely/different/path") result = base + absolute result.to_s # => "/completely/different/path" ``` protocol-http-0.55.0/context/index.yaml000066400000000000000000000025651507641516600201210ustar00rootroot00000000000000# Automatically generated context index for Utopia::Project guides. # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`. --- description: Provides abstractions to handle HTTP protocols. metadata: documentation_uri: https://socketry.github.io/protocol-http/ source_code_uri: https://github.com/socketry/protocol-http.git files: - path: getting-started.md title: Getting Started description: This guide explains how to use `protocol-http` for building abstract HTTP interfaces. - path: message-body.md title: Message Body description: This guide explains how to work with HTTP request and response message bodies using `Protocol::HTTP::Body` classes. - path: headers.md title: Headers description: This guide explains how to work with HTTP headers using `protocol-http`. - path: middleware.md title: Middleware description: This guide explains how to build and use HTTP middleware with `Protocol::HTTP::Middleware`. - path: streaming.md title: Streaming description: This guide gives an overview of how to implement streaming requests and responses. - path: design-overview.md title: Design Overview description: This guide explains the high level design of `protocol-http` in the context of wider design patterns that can be used to implement HTTP clients and servers. protocol-http-0.55.0/context/message-body.md000066400000000000000000000167671507641516600210400ustar00rootroot00000000000000# Message Body This guide explains how to work with HTTP request and response message bodies using `Protocol::HTTP::Body` classes. ## Overview HTTP message bodies represent the actual (often stateful) data content of requests and responses. `Protocol::HTTP` provides a rich set of body classes for different use cases, from simple string content to streaming data and file serving. All body classes inherit from {ruby Protocol::HTTP::Body::Readable}, which provides a consistent interface for reading data in chunks. Bodies can be: - **Buffered**: All content stored in memory. - **Streaming**: Content generated or read on-demand. - **File-based**: Content read directly from files. - **Transforming**: Content modified as it flows through e.g. compression, encryption. ## Core Body Interface Every body implements the `Readable` interface: ``` ruby # Read the next chunk of data: chunk = body.read # => "Hello" or nil when finished # Check if body has data available without blocking: body.ready? # => true/false # Check if body is empty: body.empty? # => true/false # Close the body and release resources: body.close # Iterate through all chunks: body.each do |chunk| puts chunk end # Read entire body into a string: content = body.join ``` ## Buffered Bodies Use {ruby Protocol::HTTP::Body::Buffered} for content that's fully loaded in memory: ``` ruby # Create from string: body = Protocol::HTTP::Body::Buffered.new(["Hello", " ", "World"]) # Create from array of strings: chunks = ["First chunk", "Second chunk", "Third chunk"] body = Protocol::HTTP::Body::Buffered.new(chunks) # Wrap various types automatically: body = Protocol::HTTP::Body::Buffered.wrap("Simple string") body = Protocol::HTTP::Body::Buffered.wrap(["Array", "of", "chunks"]) # Access properties: body.length # => 13 (total size in bytes) body.empty? # => false body.ready? # => true (always ready) # Reading: first_chunk = body.read # => "Hello" second_chunk = body.read # => " " third_chunk = body.read # => "World" fourth_chunk = body.read # => nil (finished) # Rewind to beginning: body.rewind body.read # => "Hello" (back to start) ``` ### Buffered Body Features ``` ruby # Check if rewindable: body.rewindable? # => true for buffered bodies # Get all content as single string: content = body.join # => "Hello World" # Convert to array of chunks: chunks = body.to_a # => ["Hello", " ", "World"] # Write additional chunks: body.write("!") body.join # => "Hello World!" # Clear all content: body.clear body.empty? # => true ``` ## File Bodies Use {ruby Protocol::HTTP::Body::File} for serving files efficiently: ``` ruby require 'protocol/http/body/file' # Open a file: body = Protocol::HTTP::Body::File.open("/path/to/file.txt") # Create from existing File object: file = File.open("/path/to/image.jpg", "rb") body = Protocol::HTTP::Body::File.new(file) # Serve partial content (ranges): range = 100...200 # bytes 100-199 body = Protocol::HTTP::Body::File.new(file, range) # Properties: body.length # => file size or range size body.empty? # => false (unless zero-length file) body.ready? # => false (may block when reading) # File bodies read in chunks automatically: body.each do |chunk| # Process each chunk (typically 64KB) puts "Read #{chunk.bytesize} bytes" end ``` ### File Body Range Requests ``` ruby # Serve specific byte ranges (useful for HTTP range requests): file = File.open("large_video.mp4", "rb") # First 1MB: partial_body = Protocol::HTTP::Body::File.new(file, 0...1_048_576) # Custom block size for reading: body = Protocol::HTTP::Body::File.new(file, block_size: 8192) # 8KB chunks ``` ## Writable Bodies Use {ruby Protocol::HTTP::Body::Writable} for dynamic content generation: ``` ruby require 'protocol/http/body/writable' # Create a writable body: body = Protocol::HTTP::Body::Writable.new # Write data in another thread/fiber: Thread.new do body.write("First chunk\n") sleep 0.1 body.write("Second chunk\n") body.write("Final chunk\n") body.close_write # Signal no more data end # Read from main thread: body.each do |chunk| puts "Received: #{chunk}" end # Output: # Received: First chunk # Received: Second chunk # Received: Final chunk ``` ### Writable Body with Backpressure ``` ruby # Use SizedQueue to limit buffering: queue = Thread::SizedQueue.new(10) # Buffer up to 10 chunks body = Protocol::HTTP::Body::Writable.new(queue: queue) # Writing will block if queue is full: body.write("chunk 1") # ... write up to 10 chunks before blocking ``` ## Streaming Bodies Use {ruby Protocol::HTTP::Body::Streamable} for computed content: ``` ruby require 'protocol/http/body/streamable' # Generate content dynamically: body = Protocol::HTTP::Body::Streamable.new do |output| 10.times do |i| output.write("Line #{i}\n") # Could include delays, computation, database queries, etc. end end # Content is generated as it's read: body.each do |chunk| puts "Got: #{chunk}" end ``` ## Stream Bodies (IO Wrapper) Use {ruby Protocol::HTTP::Body::Stream} to wrap IO-like objects: ``` ruby require 'protocol/http/body/stream' # Wrap an IO object: io = StringIO.new("Hello\nWorld\nFrom\nStream") body = Protocol::HTTP::Body::Stream.new(io) # Read line by line: line1 = body.gets # => "Hello\n" line2 = body.gets # => "World\n" # Read specific amounts: data = body.read(5) # => "From\n" # Read remaining data: rest = body.read # => "Stream" ``` ## Body Transformations ### Compression Bodies ``` ruby require 'protocol/http/body/deflate' require 'protocol/http/body/inflate' # Compress a body: original = Protocol::HTTP::Body::Buffered.new(["Hello World"]) compressed = Protocol::HTTP::Body::Deflate.new(original) # Decompress a body: decompressed = Protocol::HTTP::Body::Inflate.new(compressed) content = decompressed.join # => "Hello World" ``` ### Wrapper Bodies Create custom body transformations: ``` ruby require 'protocol/http/body/wrapper' class UppercaseBody < Protocol::HTTP::Body::Wrapper def read if chunk = super chunk.upcase end end end # Use the wrapper: original = Protocol::HTTP::Body::Buffered.wrap("hello world") uppercase = UppercaseBody.new(original) content = uppercase.join # => "HELLO WORLD" ``` ## Life-cycle ### Initialization Bodies are typically initialized with the data they need to process. For example: ``` ruby body = Protocol::HTTP::Body::Buffered.wrap("Hello World") ``` ### Reading Once initialized, bodies can be read in chunks: ``` ruby body.each do |chunk| puts "Read #{chunk.bytesize} bytes" end ``` ### Closing It's important to close bodies when done to release resources: ``` ruby begin # ... read from the body ... rescue => error # Ignore. ensure # The body should always be closed: body.close(error) end ``` ## Advanced Usage ### Rewindable Bodies Make any body rewindable by buffering: ``` ruby require 'protocol/http/body/rewindable' # Wrap a non-rewindable body: file_body = Protocol::HTTP::Body::File.open("data.txt") rewindable = Protocol::HTTP::Body::Rewindable.new(file_body) # Read some data: first_chunk = rewindable.read # Rewind and read again: rewindable.rewind same_chunk = rewindable.read # Same as first_chunk ``` ### Head Bodies (Response without content) For HEAD requests that need content-length but no body: ``` ruby require 'protocol/http/body/head' # Create head body from another body: original = Protocol::HTTP::Body::File.open("large_file.zip") head_body = Protocol::HTTP::Body::Head.for(original) head_body.length # => size of original file head_body.read # => nil (no actual content) head_body.empty? # => true ``` protocol-http-0.55.0/context/middleware.md000066400000000000000000000101611507641516600205540ustar00rootroot00000000000000# Middleware This guide explains how to build and use HTTP middleware with `Protocol::HTTP::Middleware`. ## Overview The middleware interface provides a convenient wrapper for implementing HTTP middleware components that can process requests and responses. Middleware enables you to build composable HTTP applications by chaining multiple processing layers. A middleware instance generally needs to respond to two methods: - `call(request)` -> `response`. - `close()` (called when shutting down). ## Basic Middleware Interface You can implement middleware without using the `Middleware` class by implementing the interface directly: ``` ruby class SimpleMiddleware def initialize(delegate) @delegate = delegate end def call(request) # Process request here response = @delegate.call(request) # Process response here return response end def close @delegate&.close end end ``` ## Using the Middleware Class The `Protocol::HTTP::Middleware` class provides a convenient base for building middleware: ``` ruby require 'protocol/http/middleware' class LoggingMiddleware < Protocol::HTTP::Middleware def call(request) puts "Processing: #{request.method} #{request.path}" response = super # Calls @delegate.call(request) puts "Response: #{response.status}" return response end end # Use with a delegate: app = LoggingMiddleware.new(Protocol::HTTP::Middleware::HelloWorld) ``` ## Building Middleware Stacks Use `Protocol::HTTP::Middleware.build` to construct middleware stacks: ``` ruby require 'protocol/http/middleware' app = Protocol::HTTP::Middleware.build do use LoggingMiddleware use CompressionMiddleware run Protocol::HTTP::Middleware::HelloWorld end # Handle a request: request = Protocol::HTTP::Request["GET", "/"] response = app.call(request) ``` The builder works by: - `use` adds middleware to the stack - `run` specifies the final application (defaults to `NotFound`) - Middleware is chained in reverse order (last `use` wraps first) ## Block-Based Middleware Convert a block into middleware using `Middleware.for`: ``` ruby middleware = Protocol::HTTP::Middleware.for do |request| if request.path == '/health' Protocol::HTTP::Response[200, {}, ["OK"]] else # This would normally delegate, but this example doesn't have a delegate Protocol::HTTP::Response[404] end end request = Protocol::HTTP::Request["GET", "/health"] response = middleware.call(request) # => Response with status 200 ``` ## Built-in Middleware ### HelloWorld Always returns "Hello World!" response: ``` ruby app = Protocol::HTTP::Middleware::HelloWorld response = app.call(request) # => 200 "Hello World!" ``` ### NotFound Always returns 404 response: ``` ruby app = Protocol::HTTP::Middleware::NotFound response = app.call(request) # => 404 Not Found ``` ### Okay Always returns 200 response with no body: ``` ruby app = Protocol::HTTP::Middleware::Okay response = app.call(request) # => 200 OK ``` ## Real-World Middleware Examples ### Authentication Middleware ``` ruby class AuthenticationMiddleware < Protocol::HTTP::Middleware def initialize(delegate, api_key: nil) super(delegate) @api_key = api_key end def call(request) auth_header = request.headers['authorization'] unless auth_header == "Bearer #{@api_key}" return Protocol::HTTP::Response[401, {}, ["Unauthorized"]] end super end end # Usage: app = Protocol::HTTP::Middleware.build do use AuthenticationMiddleware, api_key: "secret123" run MyApplication end ``` ### Content Type Middleware ``` ruby class ContentTypeMiddleware < Protocol::HTTP::Middleware def call(request) response = super # Add content-type header if not present unless response.headers.include?('content-type') response.headers['content-type'] = 'text/plain' end response end end ``` ## Testing Middleware ``` ruby describe MyMiddleware do let(:app) {MyMiddleware.new(Protocol::HTTP::Middleware::Okay)} it "processes requests correctly" do request = Protocol::HTTP::Request["GET", "/test"] response = app.call(request) expect(response.status).to be == 200 end it "closes properly" do expect { app.close }.not.to raise_exception end end ``` protocol-http-0.55.0/context/streaming.md000066400000000000000000000062301507641516600204320ustar00rootroot00000000000000# Streaming This guide gives an overview of how to implement streaming requests and responses. ## Independent Uni-directional Streaming The request and response body work independently of each other can stream data in both directions. {ruby Protocol::HTTP::Body::Stream} provides an interface to merge these independent streams into an IO-like interface. ```ruby #!/usr/bin/env ruby require 'async' require 'async/http/client' require 'async/http/server' require 'async/http/endpoint' require 'protocol/http/body/stream' require 'protocol/http/body/writable' endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000') Async do server = Async::HTTP::Server.for(endpoint) do |request| output = Protocol::HTTP::Body::Writable.new stream = Protocol::HTTP::Body::Stream.new(request.body, output) Async do # Simple echo server: while chunk = stream.readpartial(1024) stream.write(chunk) end rescue EOFError # Ignore EOF errors. ensure stream.close end Protocol::HTTP::Response[200, {}, output] end server_task = Async{server.run} client = Async::HTTP::Client.new(endpoint) input = Protocol::HTTP::Body::Writable.new response = client.get("/", body: input) begin stream = Protocol::HTTP::Body::Stream.new(response.body, input) stream.write("Hello, ") stream.write("World!") stream.close_write while chunk = stream.readpartial(1024) puts chunk end rescue EOFError # Ignore EOF errors. ensure stream.close end ensure server_task.stop end ``` This approach works quite well, especially when the input and output bodies are independently compressed, decompressed, or chunked. However, some protocols, notably, WebSockets operate on the raw connection and don't require this level of abstraction. ## Bi-directional Streaming While WebSockets can work on the above streaming interface, it's a bit more convenient to use the streaming interface directly, which gives raw access to the underlying stream where possible. ```ruby #!/usr/bin/env ruby require 'async' require 'async/http/client' require 'async/http/server' require 'async/http/endpoint' require 'protocol/http/body/stream' require 'protocol/http/body/writable' endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000') Async do server = Async::HTTP::Server.for(endpoint) do |request| streamable = Protocol::HTTP::Body::Streamable. output = Protocol::HTTP::Body::Writable.new stream = Protocol::HTTP::Body::Stream.new(request.body, output) Async do # Simple echo server: while chunk = stream.readpartial(1024) stream.write(chunk) end rescue EOFError # Ignore EOF errors. ensure stream.close end Protocol::HTTP::Response[200, {}, output] end server_task = Async{server.run} client = Async::HTTP::Client.new(endpoint) input = Protocol::HTTP::Body::Writable.new response = client.get("/", body: input) begin stream = Protocol::HTTP::Body::Stream.new(response.body, input) stream.write("Hello, ") stream.write("World!") stream.close_write while chunk = stream.readpartial(1024) puts chunk end rescue EOFError # Ignore EOF errors. ensure stream.close end ensure server_task.stop end ``` protocol-http-0.55.0/context/url-parsing.md000066400000000000000000000077231507641516600207140ustar00rootroot00000000000000# URL Parsing This guide explains how to use `Protocol::HTTP::URL` for parsing and manipulating URL components, particularly query strings and parameters. ## Overview {ruby Protocol::HTTP::URL} provides utilities for parsing and manipulating URL components, particularly query strings and parameters. It offers robust encoding/decoding capabilities for complex parameter structures. While basic query parameter encoding follows the `application/x-www-form-urlencoded` standard, there is no universal standard for serializing complex nested structures (arrays, nested objects) in URLs. Different frameworks use varying conventions for these cases, and this implementation follows common patterns where possible. ## Basic Query Parameter Parsing ``` ruby require 'protocol/http/url' # Parse query parameters from a URL: reference = Protocol::HTTP::Reference.parse("/search?q=ruby&category=programming&page=2") parameters = Protocol::HTTP::URL.decode(reference.query) # => {"q" => "ruby", "category" => "programming", "page" => "2"} # Symbolize keys for easier access: parameters = Protocol::HTTP::URL.decode(reference.query, symbolize_keys: true) # => {:q => "ruby", :category => "programming", :page => "2"} ``` ## Complex Parameter Structures The URL module handles nested parameters, arrays, and complex data structures: ``` ruby # Array parameters: query = "tags[]=ruby&tags[]=programming&tags[]=web" parameters = Protocol::HTTP::URL.decode(query) # => {"tags" => ["ruby", "programming", "web"]} # Nested hash parameters: query = "user[name]=John&user[email]=john@example.com&user[preferences][theme]=dark" parameters = Protocol::HTTP::URL.decode(query) # => {"user" => {"name" => "John", "email" => "john@example.com", "preferences" => {"theme" => "dark"}}} # Mixed structures: query = "filters[categories][]=books&filters[categories][]=movies&filters[price][min]=10&filters[price][max]=100" parameters = Protocol::HTTP::URL.decode(query) # => {"filters" => {"categories" => ["books", "movies"], "price" => {"min" => "10", "max" => "100"}}} ``` ## Encoding Parameters to Query Strings ``` ruby # Simple parameters: parameters = {"search" => "protocol-http", "limit" => "20"} query = Protocol::HTTP::URL.encode(parameters) # => "search=protocol-http&limit=20" # Array parameters: parameters = {"tags" => ["ruby", "http", "protocol"]} query = Protocol::HTTP::URL.encode(parameters) # => "tags[]=ruby&tags[]=http&tags[]=protocol" # Nested parameters: parameters = { user: { profile: { name: "Alice", settings: { notifications: true, theme: "light" } } } } query = Protocol::HTTP::URL.encode(parameters) # => "user[profile][name]=Alice&user[profile][settings][notifications]=true&user[profile][settings][theme]=light" ``` ## URL Escaping and Unescaping ``` ruby # Escape special characters: Protocol::HTTP::URL.escape("hello world!") # => "hello%20world%21" # Escape path components (preserves path separators): Protocol::HTTP::URL.escape_path("/path/with spaces/file.html") # => "/path/with%20spaces/file.html" # Unescape percent-encoded strings: Protocol::HTTP::URL.unescape("hello%20world%21") # => "hello world!" # Handle Unicode characters: Protocol::HTTP::URL.escape("café") # => "caf%C3%A9" Protocol::HTTP::URL.unescape("caf%C3%A9") # => "café" ``` ## Scanning and Processing Query Strings For custom processing, you can scan query strings directly: ``` ruby query = "name=John&age=30&active=true" Protocol::HTTP::URL.scan(query) do |key, value| puts "#{key}: #{value}" end # Output: # name: John # age: 30 # active: true ``` ## Security and Limits The URL module includes built-in protection against deeply nested parameter attacks: ``` ruby # This will raise an error to prevent excessive nesting: begin Protocol::HTTP::URL.decode("a[b][c][d][e][f][g][h][i]=value") rescue ArgumentError => error puts error.message # => "Key length exceeded limit!" end # You can adjust the maximum nesting level: Protocol::HTTP::URL.decode("a[b][c]=value", 5) # Allow up to 5 levels of nesting ``` protocol-http-0.55.0/examples/000077500000000000000000000000001507641516600162505ustar00rootroot00000000000000protocol-http-0.55.0/examples/streaming/000077500000000000000000000000001507641516600202415ustar00rootroot00000000000000protocol-http-0.55.0/examples/streaming/bidirectional.rb000077500000000000000000000034351507641516600234060ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. require "async" require "async/http/client" require "async/http/server" require "async/http/endpoint" require "protocol/http/body/streamable" require "protocol/http/body/writable" require "protocol/http/body/stream" endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000") Async do server = Async::HTTP::Server.for(endpoint) do |request| output = Protocol::HTTP::Body::Streamable.response(request) do |stream| # Simple echo server: while chunk = stream.readpartial(1024) $stderr.puts "Server chunk: #{chunk.inspect}" stream.write(chunk) $stderr.puts "Server waiting for next chunk..." end $stderr.puts "Server done reading request." rescue EOFError $stderr.puts "Server EOF." # Ignore EOF errors. ensure $stderr.puts "Server closing stream." stream.close end Protocol::HTTP::Response[200, {}, output] end server_task = Async{server.run} client = Async::HTTP::Client.new(endpoint) streamable = Protocol::HTTP::Body::Streamable.request do |stream| stream.write("Hello, ") stream.write("World!") $stderr.puts "Client closing write..." stream.close_write $stderr.puts "Client reading response..." while chunk = stream.readpartial(1024) $stderr.puts "Client chunk: #{chunk.inspect}" puts chunk end $stderr.puts "Client done reading response." rescue EOFError $stderr.puts "Client EOF." # Ignore EOF errors. ensure $stderr.puts "Client closing stream: #{$!}" stream.close end $stderr.puts "Client sending request..." response = client.get("/", body: streamable) $stderr.puts "Client received response and streaming it..." streamable.stream(response.body) ensure server_task.stop end protocol-http-0.55.0/examples/streaming/bidirectional2.rb000077500000000000000000000032361507641516600234670ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. require "async" require "async/http/client" require "async/http/server" require "async/http/endpoint" require "protocol/http/body/streamable" require "protocol/http/body/writable" require "protocol/http/body/stream" endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000") Async do server = Async::HTTP::Server.for(endpoint) do |request| output = Protocol::HTTP::Body::Streamable.response(request) do |stream| $stderr.puts "Server writing chunks..." stream.write("Hello, ") stream.write("World!") $stderr.puts "Server reading chunks..." while chunk = stream.readpartial(1024) puts chunk end rescue EOFError $stderr.puts "Server EOF." # Ignore EOF errors. ensure $stderr.puts "Server closing stream." stream.close end Protocol::HTTP::Response[200, {}, output] end server_task = Async{server.run} client = Async::HTTP::Client.new(endpoint) streamable = Protocol::HTTP::Body::Streamable.request do |stream| # Simple echo client: while chunk = stream.readpartial(1024) $stderr.puts "Client chunk: #{chunk.inspect}" stream.write(chunk) $stderr.puts "Client waiting for next chunk..." end rescue EOFError $stderr.puts "Client EOF." # Ignore EOF errors. ensure $stderr.puts "Client closing stream." stream.close end $stderr.puts "Client sending request..." response = client.get("/", body: streamable) $stderr.puts "Client received response and streaming it..." streamable.stream(response.body) $stderr.puts "Client done streaming response." ensure server_task.stop end protocol-http-0.55.0/examples/streaming/gems.locked000066400000000000000000000026501507641516600223620ustar00rootroot00000000000000PATH remote: ../../../async-http specs: async-http (0.75.0) async (>= 2.10.2) async-pool (~> 0.7) io-endpoint (~> 0.11) io-stream (~> 0.4) protocol-http (~> 0.33) protocol-http1 (~> 0.20) protocol-http2 (~> 0.18) traces (>= 0.10) PATH remote: ../.. specs: protocol-http (0.33.0) GEM remote: https://rubygems.org/ specs: async (2.17.0) console (~> 1.26) fiber-annotation io-event (~> 1.6, >= 1.6.5) async-pool (0.8.1) async (>= 1.25) metrics traces console (1.27.0) fiber-annotation fiber-local (~> 1.1) json debug (1.9.2) irb (~> 1.10) reline (>= 0.3.8) fiber-annotation (0.2.0) fiber-local (1.1.0) fiber-storage fiber-storage (1.0.0) io-console (0.7.2) io-endpoint (0.13.1) io-event (1.6.5) io-stream (0.4.0) irb (1.14.0) rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.7.2) metrics (0.10.2) protocol-hpack (1.5.0) protocol-http1 (0.22.0) protocol-http (~> 0.22) protocol-http2 (0.18.0) protocol-hpack (~> 1.4) protocol-http (~> 0.18) psych (5.1.2) stringio rdoc (6.7.0) psych (>= 4.0.0) reline (0.5.10) io-console (~> 0.5) stringio (3.1.1) traces (0.13.1) PLATFORMS ruby x86_64-linux DEPENDENCIES async async-http! debug protocol-http! BUNDLED WITH 2.5.16 protocol-http-0.55.0/examples/streaming/gems.rb000066400000000000000000000003631507641516600215230ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. source "https://rubygems.org" gem "async" gem "async-http", path: "../../../async-http" gem "protocol-http", path: "../../" gem "debug" protocol-http-0.55.0/examples/streaming/simple.rb000077500000000000000000000026141507641516600220650ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. require "async" require "async/http/client" require "async/http/server" require "async/http/endpoint" require "protocol/http/body/streamable" require "protocol/http/body/writable" require "protocol/http/body/stream" endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000") Async do server = Async::HTTP::Server.for(endpoint) do |request| output = Protocol::HTTP::Body::Streamable.response(request) do |stream| $stderr.puts "Server sending text..." stream.write("Hello from server!") rescue EOFError $stderr.puts "Server EOF." # Ignore EOF errors. ensure $stderr.puts "Server closing stream." stream.close end Protocol::HTTP::Response[200, {}, output] end server_task = Async{server.run} client = Async::HTTP::Client.new(endpoint) streamable = Protocol::HTTP::Body::Streamable.request do |stream| while chunk = stream.readpartial(1024) $stderr.puts "Client chunk: #{chunk.inspect}" end rescue EOFError $stderr.puts "Client EOF." # Ignore EOF errors. ensure $stderr.puts "Client closing stream." stream.close end $stderr.puts "Client sending request..." response = client.get("/", body: streamable) $stderr.puts "Client received response and streaming it..." streamable.stream(response.body) ensure server_task.stop end protocol-http-0.55.0/examples/streaming/unidirectional.rb000077500000000000000000000023561507641516600236100ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. require "async" require "async/http/client" require "async/http/server" require "async/http/endpoint" require "protocol/http/body/stream" require "protocol/http/body/writable" endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000") Async do server = Async::HTTP::Server.for(endpoint) do |request| output = Protocol::HTTP::Body::Writable.new stream = Protocol::HTTP::Body::Stream.new(request.body, output) Async do # Simple echo server: while chunk = stream.readpartial(1024) stream.write(chunk) end rescue EOFError # Ignore EOF errors. ensure stream.close end Protocol::HTTP::Response[200, {}, output] end server_task = Async{server.run} client = Async::HTTP::Client.new(endpoint) input = Protocol::HTTP::Body::Writable.new response = client.get("/", body: input) begin stream = Protocol::HTTP::Body::Stream.new(response.body, input) stream.write("Hello, ") stream.write("World!") stream.close_write while chunk = stream.readpartial(1024) puts chunk end rescue EOFError # Ignore EOF errors. ensure stream.close end ensure server_task.stop end protocol-http-0.55.0/examples/streaming/unidirectional2.rb000077500000000000000000000025741507641516600236740ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. require "async" require "async/http/client" require "async/http/server" require "async/http/endpoint" require "protocol/http/body/stream" require "protocol/http/body/writable" def make_server(endpoint) Async::HTTP::Server.for(endpoint) do |request| output = Protocol::HTTP::Body::Writable.new stream = Protocol::HTTP::Body::Stream.new(request.body, output) Async do stream.write("Hello, ") stream.write("World!") stream.close_write # Simple echo server: $stderr.puts "Server reading chunks..." while chunk = stream.readpartial(1024) puts chunk end rescue EOFError # Ignore EOF errors. ensure stream.close end Protocol::HTTP::Response[200, {}, output] end end Async do |task| endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000") server_task = task.async{make_server(endpoint).run} client = Async::HTTP::Client.new(endpoint) input = Protocol::HTTP::Body::Writable.new response = client.get("/", body: input) begin stream = Protocol::HTTP::Body::Stream.new(response.body, input) $stderr.puts "Client echoing chunks..." while chunk = stream.readpartial(1024) stream.write(chunk) end rescue EOFError # Ignore EOF errors. ensure stream.close end ensure server_task.stop end protocol-http-0.55.0/fixtures/000077500000000000000000000000001507641516600163035ustar00rootroot00000000000000protocol-http-0.55.0/fixtures/protocol/000077500000000000000000000000001507641516600201445ustar00rootroot00000000000000protocol-http-0.55.0/fixtures/protocol/http/000077500000000000000000000000001507641516600211235ustar00rootroot00000000000000protocol-http-0.55.0/fixtures/protocol/http/body/000077500000000000000000000000001507641516600220605ustar00rootroot00000000000000protocol-http-0.55.0/fixtures/protocol/http/body/a_readable_body.rb000066400000000000000000000007631507641516600254670ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. module Protocol module HTTP module Body AReadableBody = Sus::Shared("a readable body") do with "#read" do it "after closing, returns nil" do body.close expect(body.read).to be_nil end end with "empty?" do it "returns true after closing" do body.close expect(body).to be(:empty?) end end end end end end protocol-http-0.55.0/fixtures/protocol/http/body/a_writable_body.rb000066400000000000000000000016371507641516600255420ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. module Protocol module HTTP module Body AWritableBody = Sus::Shared("a readable body") do with "#read" do it "after closing the write end, returns all chunks" do body.write("Hello ") body.write("World!") body.close_write expect(body.read).to be == "Hello " expect(body.read).to be == "World!" expect(body.read).to be_nil end end with "empty?" do it "returns false before writing" do expect(body).not.to be(:empty?) end it "returns true after all chunks are consumed" do body.write("Hello") body.close_write expect(body).not.to be(:empty?) expect(body.read).to be == "Hello" expect(body.read).to be_nil expect(body).to be(:empty?) end end end end end end protocol-http-0.55.0/gems.rb000066400000000000000000000011111507641516600157040ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. source "https://rubygems.org" # Specify your gem's dependencies in protocol-http.gemspec gemspec # gem "async-http", path: "../async-http" group :maintenance, optional: true do gem "bake-modernize" gem "bake-gem" gem "bake-releases" gem "agent-context" gem "utopia-project", "~> 0.18" end group :test do gem "covered" gem "sus" gem "decode" gem "rubocop" gem "rubocop-socketry" gem "sus-fixtures-async" gem "bake-test" gem "bake-test-external" end protocol-http-0.55.0/guides/000077500000000000000000000000001507641516600157125ustar00rootroot00000000000000protocol-http-0.55.0/guides/design-overview/000077500000000000000000000000001507641516600210275ustar00rootroot00000000000000protocol-http-0.55.0/guides/design-overview/readme.md000066400000000000000000000130171507641516600226100ustar00rootroot00000000000000# Design Overview This guide explains the high level design of `protocol-http` in the context of wider design patterns that can be used to implement HTTP clients and servers. ## Request/Response Model The main model we support is the request/response model. A client sends a request to a server which return response. The protocol is responsible for serializing the request and response objects. ```mermaid sequenceDiagram participant CA as Application participant Client participant Server participant SA as Application CA->>+Client: Request Client->>+Server: Request Server->>+SA: Request SA->>+Server: Response Server->>+Client: Response Client->>+CA: Response ``` We provide an interface for request and response objects. This provides performance, predictability and robustness. This model has proven itself over several years, handling a variety of different use cases. ~~~ ruby class Request attr :method attr :target attr :headers attr :body end class Response attr :status attr :headers attr :body end ~~~ One other advantage is that it's symmetrical between clients and servers with a clear mapping, i.e. the protocol is responsible for transiting requests from the client to the server, and responses from the server back to the client. This helps us separate and define request/response interfaces independently from protocol implementation. ### Client Design A request/response model implies that you create a request and receive a response back. This maps to a normal function call where the request is the argument and the response is the returned value. ~~~ ruby request = Request.new("GET", url) response = client.call(request) response.headers response.read ~~~ ## Stream Model An alternative model is the stream model. This model is more suitable for WebSockets and other persistent bi-directional channels. ```mermaid sequenceDiagram participant CA as Application participant Client participant Server participant SA as Application CA->>+Client: Stream Client->>+Server: Stream Server->>+SA: Stream ``` The interfaces for streaming can be implemented a bit differently, since a response is not returned but rather assigned to the stream, and the streaming occurs in the same execution context as the client or server handling the request. ~~~ ruby class Stream # Request details. attr :method attr :target attr :headers attr :response # Write the response and start streaming the output body. def respond(status, headers) response.status = status response.headers = headers end # Request body. attr_accessor :input # Response body. attr_accessor :output # Write to the response body. def write(...) @output.write(...) end # Read from the request body. def read @input.read end end class Response def initialize(method, target) @input = Body::Writable.new @output = Body::Writable.new end attr_accessor :status attr_accessor :headers # Prepare a stream for making a request. def request(method, target, headers) # Create a request stream suitable for writing into the buffered response: Stream.new(method, target, headers, self, @input, @output) end # Write to the request body. def write(...) @input.write(...) end # Read from the response body. def read @output.read end end ~~~ ### Client Design A stream model implies that you create a stream which contains both the request and response bodies. This maps to a normal function call where the argument is the stream and the returned value is ignored. ~~~ ruby response = Response.new stream = response.request("GET", url) client.call(stream) response.headers response.read ~~~ ## Differences The request/response model has a symmetrical design which naturally uses the return value for the result of executing the request. The result encapsulates the behaviour of how to read the response status, headers and body. Because of that, streaming input and output becomes a function of the result object itself. As in: ~~~ ruby def call(request) body = Body::Writable.new Fiber.schedule do while chunk = request.input.read body.write(chunk.reverse) end end return Response[200, headers, body] end input = Body::Writable.new response = call(... body ...) input.write("Hello World") input.close response.read -> "dlroW olleH" ~~~ The streaming model does not have the same symmetry, and instead opts for a uni-directional flow of information. ~~~ruby def call(stream) stream.respond(200, headers) Fiber.schedule do while chunk = stream.read stream.write(chunk.reverse) end end end input = Body::Writable.new response = Response.new(...input...) call(response.stream) input.write("Hello World") input.close response.read -> "dlroW olleH" ~~~ The value of this uni-directional flow is that it is natural for the stream to be taken out of the scope imposed by the nested `call(request)` model. However, the user must explicitly close the stream, since it's no longer scoped to the client and/or server. ## Interim Response Handling Interim responses are responses that are sent before the final response. They are used for things like `103 Early Hints` and `100 Continue`. These responses are sent before the final response, and are used to signal to the client that the server is still processing the request. ```ruby body = Body::Writable.new interim_response_callback = proc do |status, headers| if status == 100 # Continue sending the request body. body.write("Hello World") body.close end end response = client.post("/upload", {'expect' => '100-continue'}, body, interim_response: interim_response_callback) ``` protocol-http-0.55.0/guides/getting-started/000077500000000000000000000000001507641516600210175ustar00rootroot00000000000000protocol-http-0.55.0/guides/getting-started/readme.md000066400000000000000000000104051507641516600225760ustar00rootroot00000000000000# Getting Started This guide explains how to use `protocol-http` for building abstract HTTP interfaces. ## Installation Add the gem to your project: ~~~ bash $ bundle add protocol-http ~~~ ## Core Concepts `protocol-http` has several core concepts: - A {ruby Protocol::HTTP::Request} instance which represents an abstract HTTP request. Specific versions of HTTP may subclass this to track additional state. - A {ruby Protocol::HTTP::Response} instance which represents an abstract HTTP response. Specific versions of HTTP may subclass this to track additional state. - A {ruby Protocol::HTTP::Middleware} interface for building HTTP applications. - A {ruby Protocol::HTTP::Headers} interface for storing HTTP headers with semantics based on documented specifications (RFCs, etc). - A set of {ruby Protocol::HTTP::Body} classes which handle the internal request and response bodies, including bi-directional streaming. ## Integration This gem does not provide any specific client or server implementation, rather it's used by several other gems. - [Protocol::HTTP1](https://github.com/socketry/protocol-http1) & [Protocol::HTTP2](https://github.com/socketry/protocol-http2) which provide client and server implementations. - [Async::HTTP](https://github.com/socketry/async-http) which provides connection pooling and concurrency. ## Usage ### Request {ruby Protocol::HTTP::Request} represents an HTTP request which can be used both server and client-side. ``` ruby require 'protocol/http/request' # Short form (recommended): request = Protocol::HTTP::Request["GET", "/index.html", {"accept" => "text/html"}] # Long form: headers = Protocol::HTTP::Headers[["accept", "text/html"]] request = Protocol::HTTP::Request.new("http", "example.com", "GET", "/index.html", "HTTP/1.1", headers) # Access request properties request.method # => "GET" request.path # => "/index.html" request.headers # => Protocol::HTTP::Headers instance ``` ### Response {ruby Protocol::HTTP::Response} represents an HTTP response which can be used both server and client-side. ``` ruby require 'protocol/http/response' # Short form (recommended): response = Protocol::HTTP::Response[200, {"content-type" => "text/html"}, "Hello, World!"] # Long form: headers = Protocol::HTTP::Headers["content-type" => "text/html"] body = Protocol::HTTP::Body::Buffered.wrap("Hello, World!") response = Protocol::HTTP::Response.new("HTTP/1.1", 200, headers, body) # Access response properties response.status # => 200 response.headers # => Protocol::HTTP::Headers instance response.body # => Body instance # Status checking methods response.success? # => true (200-299) response.ok? # => true (200) response.redirection? # => false (300-399) response.failure? # => false (400-599) ``` ### Headers {ruby Protocol::HTTP::Headers} provides semantically meaningful interpretation of header values and implements case-normalising keys. #### Basic Usage ``` ruby require 'protocol/http/headers' headers = Protocol::HTTP::Headers.new # Assignment by title-case key: headers['Content-Type'] = "image/jpeg" # Lookup by lower-case (normalized) key: headers['content-type'] # => "image/jpeg" ``` #### Semantic Processing Many headers receive special semantic processing, automatically splitting comma-separated values and providing structured access: ``` ruby # Accept header with quality values: headers['Accept'] = 'text/html, application/json;q=0.8, */*;q=0.1' accept = headers['accept'] # => ["text/html", "application/json;q=0.8", "*/*;q=0.1"] # Access parsed media ranges with quality factors: accept.media_ranges.each do |range| puts "#{range.type}/#{range.subtype} (q=#{range.quality_factor})" end # text/html (q=1.0) # application/json (q=0.8) # */* (q=0.1) # Accept-Encoding automatically splits values: headers['Accept-Encoding'] = 'gzip, deflate, br;q=0.9' headers['accept-encoding'] # => ["gzip", "deflate", "br;q=0.9"] # Cache-Control splits directives: headers['Cache-Control'] = 'max-age=3600, no-cache, must-revalidate' headers['cache-control'] # => ["max-age=3600", "no-cache", "must-revalidate"] # Vary header normalizes field names to lowercase: headers['Vary'] = 'Accept-Encoding, User-Agent' headers['vary'] # => ["accept-encoding", "user-agent"] ``` protocol-http-0.55.0/guides/headers/000077500000000000000000000000001507641516600173255ustar00rootroot00000000000000protocol-http-0.55.0/guides/headers/readme.md000066400000000000000000000060741507641516600211130ustar00rootroot00000000000000# Headers This guide explains how to work with HTTP headers using `protocol-http`. ## Core Concepts `protocol-http` provides several core concepts for working with HTTP headers: - A {ruby Protocol::HTTP::Headers} class which represents a collection of HTTP headers with built-in security and policy features. - Header-specific classes like {ruby Protocol::HTTP::Header::Accept} and {ruby Protocol::HTTP::Header::Authorization} which provide specialized parsing and formatting. - Trailer security validation to prevent HTTP request smuggling attacks. ## Usage The {Protocol::HTTP::Headers} class provides a comprehensive interface for creating and manipulating HTTP headers: ```ruby require 'protocol/http' headers = Protocol::HTTP::Headers.new headers.add('content-type', 'text/html') headers.add('set-cookie', 'session=abc123') # Access headers content_type = headers['content-type'] # => "text/html" # Check if header exists headers.include?('content-type') # => true ``` ### Header Policies Different header types have different behaviors for merging, validation, and trailer handling: ```ruby # Some headers can be specified multiple times headers.add('set-cookie', 'first=value1') headers.add('set-cookie', 'second=value2') # Others are singletons and will raise errors if duplicated headers.add('content-length', '100') # headers.add('content-length', '200') # Would raise DuplicateHeaderError ``` ### Structured Headers Some headers have specialized classes for parsing and formatting: ```ruby # Accept header with media ranges accept = Protocol::HTTP::Header::Accept.new('text/html,application/json;q=0.9') media_ranges = accept.media_ranges # Authorization header auth = Protocol::HTTP::Header::Authorization.basic('username', 'password') # => "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" ``` ### Trailer Security HTTP trailers are headers that appear after the message body. For security reasons, only certain headers are allowed in trailers: ```ruby # Working with trailers headers = Protocol::HTTP::Headers.new([ ['content-type', 'text/html'], ['content-length', '1000'] ]) # Start trailer section headers.trailer! # These will be allowed (safe metadata) headers.add('etag', '"12345"') headers.add('date', Time.now.httpdate) # These will be silently ignored for security headers.add('authorization', 'Bearer token') # Ignored - credential leakage risk headers.add('connection', 'close') # Ignored - hop-by-hop header ``` The trailer security system prevents HTTP request smuggling by restricting which headers can appear in trailers: **Allowed headers** (return `true` for `trailer?`): - `date` - Response generation timestamps. - `digest` - Content integrity verification. - `etag` - Cache validation tags. - `server-timing` - Performance metrics. **Forbidden headers** (return `false` for `trailer?`): - `authorization` - Prevents credential leakage. - `connection`, `te`, `transfer-encoding` - Hop-by-hop headers that control connection behavior. - `cookie`, `set-cookie` - State information needed during initial processing. - `accept` - Content negotiation must occur before response generation. protocol-http-0.55.0/guides/links.yaml000066400000000000000000000002231507641516600177130ustar00rootroot00000000000000getting-started: order: 1 message-body: order: 2 headers: order: 3 middleware: order: 4 streaming: order: 7 design-overview: order: 10 protocol-http-0.55.0/guides/message-body/000077500000000000000000000000001507641516600202715ustar00rootroot00000000000000protocol-http-0.55.0/guides/message-body/readme.md000066400000000000000000000167671507641516600220710ustar00rootroot00000000000000# Message Body This guide explains how to work with HTTP request and response message bodies using `Protocol::HTTP::Body` classes. ## Overview HTTP message bodies represent the actual (often stateful) data content of requests and responses. `Protocol::HTTP` provides a rich set of body classes for different use cases, from simple string content to streaming data and file serving. All body classes inherit from {ruby Protocol::HTTP::Body::Readable}, which provides a consistent interface for reading data in chunks. Bodies can be: - **Buffered**: All content stored in memory. - **Streaming**: Content generated or read on-demand. - **File-based**: Content read directly from files. - **Transforming**: Content modified as it flows through e.g. compression, encryption. ## Core Body Interface Every body implements the `Readable` interface: ``` ruby # Read the next chunk of data: chunk = body.read # => "Hello" or nil when finished # Check if body has data available without blocking: body.ready? # => true/false # Check if body is empty: body.empty? # => true/false # Close the body and release resources: body.close # Iterate through all chunks: body.each do |chunk| puts chunk end # Read entire body into a string: content = body.join ``` ## Buffered Bodies Use {ruby Protocol::HTTP::Body::Buffered} for content that's fully loaded in memory: ``` ruby # Create from string: body = Protocol::HTTP::Body::Buffered.new(["Hello", " ", "World"]) # Create from array of strings: chunks = ["First chunk", "Second chunk", "Third chunk"] body = Protocol::HTTP::Body::Buffered.new(chunks) # Wrap various types automatically: body = Protocol::HTTP::Body::Buffered.wrap("Simple string") body = Protocol::HTTP::Body::Buffered.wrap(["Array", "of", "chunks"]) # Access properties: body.length # => 13 (total size in bytes) body.empty? # => false body.ready? # => true (always ready) # Reading: first_chunk = body.read # => "Hello" second_chunk = body.read # => " " third_chunk = body.read # => "World" fourth_chunk = body.read # => nil (finished) # Rewind to beginning: body.rewind body.read # => "Hello" (back to start) ``` ### Buffered Body Features ``` ruby # Check if rewindable: body.rewindable? # => true for buffered bodies # Get all content as single string: content = body.join # => "Hello World" # Convert to array of chunks: chunks = body.to_a # => ["Hello", " ", "World"] # Write additional chunks: body.write("!") body.join # => "Hello World!" # Clear all content: body.clear body.empty? # => true ``` ## File Bodies Use {ruby Protocol::HTTP::Body::File} for serving files efficiently: ``` ruby require 'protocol/http/body/file' # Open a file: body = Protocol::HTTP::Body::File.open("/path/to/file.txt") # Create from existing File object: file = File.open("/path/to/image.jpg", "rb") body = Protocol::HTTP::Body::File.new(file) # Serve partial content (ranges): range = 100...200 # bytes 100-199 body = Protocol::HTTP::Body::File.new(file, range) # Properties: body.length # => file size or range size body.empty? # => false (unless zero-length file) body.ready? # => false (may block when reading) # File bodies read in chunks automatically: body.each do |chunk| # Process each chunk (typically 64KB) puts "Read #{chunk.bytesize} bytes" end ``` ### File Body Range Requests ``` ruby # Serve specific byte ranges (useful for HTTP range requests): file = File.open("large_video.mp4", "rb") # First 1MB: partial_body = Protocol::HTTP::Body::File.new(file, 0...1_048_576) # Custom block size for reading: body = Protocol::HTTP::Body::File.new(file, block_size: 8192) # 8KB chunks ``` ## Writable Bodies Use {ruby Protocol::HTTP::Body::Writable} for dynamic content generation: ``` ruby require 'protocol/http/body/writable' # Create a writable body: body = Protocol::HTTP::Body::Writable.new # Write data in another thread/fiber: Thread.new do body.write("First chunk\n") sleep 0.1 body.write("Second chunk\n") body.write("Final chunk\n") body.close_write # Signal no more data end # Read from main thread: body.each do |chunk| puts "Received: #{chunk}" end # Output: # Received: First chunk # Received: Second chunk # Received: Final chunk ``` ### Writable Body with Backpressure ``` ruby # Use SizedQueue to limit buffering: queue = Thread::SizedQueue.new(10) # Buffer up to 10 chunks body = Protocol::HTTP::Body::Writable.new(queue: queue) # Writing will block if queue is full: body.write("chunk 1") # ... write up to 10 chunks before blocking ``` ## Streaming Bodies Use {ruby Protocol::HTTP::Body::Streamable} for computed content: ``` ruby require 'protocol/http/body/streamable' # Generate content dynamically: body = Protocol::HTTP::Body::Streamable.new do |output| 10.times do |i| output.write("Line #{i}\n") # Could include delays, computation, database queries, etc. end end # Content is generated as it's read: body.each do |chunk| puts "Got: #{chunk}" end ``` ## Stream Bodies (IO Wrapper) Use {ruby Protocol::HTTP::Body::Stream} to wrap IO-like objects: ``` ruby require 'protocol/http/body/stream' # Wrap an IO object: io = StringIO.new("Hello\nWorld\nFrom\nStream") body = Protocol::HTTP::Body::Stream.new(io) # Read line by line: line1 = body.gets # => "Hello\n" line2 = body.gets # => "World\n" # Read specific amounts: data = body.read(5) # => "From\n" # Read remaining data: rest = body.read # => "Stream" ``` ## Body Transformations ### Compression Bodies ``` ruby require 'protocol/http/body/deflate' require 'protocol/http/body/inflate' # Compress a body: original = Protocol::HTTP::Body::Buffered.new(["Hello World"]) compressed = Protocol::HTTP::Body::Deflate.new(original) # Decompress a body: decompressed = Protocol::HTTP::Body::Inflate.new(compressed) content = decompressed.join # => "Hello World" ``` ### Wrapper Bodies Create custom body transformations: ``` ruby require 'protocol/http/body/wrapper' class UppercaseBody < Protocol::HTTP::Body::Wrapper def read if chunk = super chunk.upcase end end end # Use the wrapper: original = Protocol::HTTP::Body::Buffered.wrap("hello world") uppercase = UppercaseBody.new(original) content = uppercase.join # => "HELLO WORLD" ``` ## Life-cycle ### Initialization Bodies are typically initialized with the data they need to process. For example: ``` ruby body = Protocol::HTTP::Body::Buffered.wrap("Hello World") ``` ### Reading Once initialized, bodies can be read in chunks: ``` ruby body.each do |chunk| puts "Read #{chunk.bytesize} bytes" end ``` ### Closing It's important to close bodies when done to release resources: ``` ruby begin # ... read from the body ... rescue => error # Ignore. ensure # The body should always be closed: body.close(error) end ``` ## Advanced Usage ### Rewindable Bodies Make any body rewindable by buffering: ``` ruby require 'protocol/http/body/rewindable' # Wrap a non-rewindable body: file_body = Protocol::HTTP::Body::File.open("data.txt") rewindable = Protocol::HTTP::Body::Rewindable.new(file_body) # Read some data: first_chunk = rewindable.read # Rewind and read again: rewindable.rewind same_chunk = rewindable.read # Same as first_chunk ``` ### Head Bodies (Response without content) For HEAD requests that need content-length but no body: ``` ruby require 'protocol/http/body/head' # Create head body from another body: original = Protocol::HTTP::Body::File.open("large_file.zip") head_body = Protocol::HTTP::Body::Head.for(original) head_body.length # => size of original file head_body.read # => nil (no actual content) head_body.empty? # => true ``` protocol-http-0.55.0/guides/middleware/000077500000000000000000000000001507641516600200275ustar00rootroot00000000000000protocol-http-0.55.0/guides/middleware/readme.md000066400000000000000000000101611507641516600216050ustar00rootroot00000000000000# Middleware This guide explains how to build and use HTTP middleware with `Protocol::HTTP::Middleware`. ## Overview The middleware interface provides a convenient wrapper for implementing HTTP middleware components that can process requests and responses. Middleware enables you to build composable HTTP applications by chaining multiple processing layers. A middleware instance generally needs to respond to two methods: - `call(request)` -> `response`. - `close()` (called when shutting down). ## Basic Middleware Interface You can implement middleware without using the `Middleware` class by implementing the interface directly: ``` ruby class SimpleMiddleware def initialize(delegate) @delegate = delegate end def call(request) # Process request here response = @delegate.call(request) # Process response here return response end def close @delegate&.close end end ``` ## Using the Middleware Class The `Protocol::HTTP::Middleware` class provides a convenient base for building middleware: ``` ruby require 'protocol/http/middleware' class LoggingMiddleware < Protocol::HTTP::Middleware def call(request) puts "Processing: #{request.method} #{request.path}" response = super # Calls @delegate.call(request) puts "Response: #{response.status}" return response end end # Use with a delegate: app = LoggingMiddleware.new(Protocol::HTTP::Middleware::HelloWorld) ``` ## Building Middleware Stacks Use `Protocol::HTTP::Middleware.build` to construct middleware stacks: ``` ruby require 'protocol/http/middleware' app = Protocol::HTTP::Middleware.build do use LoggingMiddleware use CompressionMiddleware run Protocol::HTTP::Middleware::HelloWorld end # Handle a request: request = Protocol::HTTP::Request["GET", "/"] response = app.call(request) ``` The builder works by: - `use` adds middleware to the stack - `run` specifies the final application (defaults to `NotFound`) - Middleware is chained in reverse order (last `use` wraps first) ## Block-Based Middleware Convert a block into middleware using `Middleware.for`: ``` ruby middleware = Protocol::HTTP::Middleware.for do |request| if request.path == '/health' Protocol::HTTP::Response[200, {}, ["OK"]] else # This would normally delegate, but this example doesn't have a delegate Protocol::HTTP::Response[404] end end request = Protocol::HTTP::Request["GET", "/health"] response = middleware.call(request) # => Response with status 200 ``` ## Built-in Middleware ### HelloWorld Always returns "Hello World!" response: ``` ruby app = Protocol::HTTP::Middleware::HelloWorld response = app.call(request) # => 200 "Hello World!" ``` ### NotFound Always returns 404 response: ``` ruby app = Protocol::HTTP::Middleware::NotFound response = app.call(request) # => 404 Not Found ``` ### Okay Always returns 200 response with no body: ``` ruby app = Protocol::HTTP::Middleware::Okay response = app.call(request) # => 200 OK ``` ## Real-World Middleware Examples ### Authentication Middleware ``` ruby class AuthenticationMiddleware < Protocol::HTTP::Middleware def initialize(delegate, api_key: nil) super(delegate) @api_key = api_key end def call(request) auth_header = request.headers['authorization'] unless auth_header == "Bearer #{@api_key}" return Protocol::HTTP::Response[401, {}, ["Unauthorized"]] end super end end # Usage: app = Protocol::HTTP::Middleware.build do use AuthenticationMiddleware, api_key: "secret123" run MyApplication end ``` ### Content Type Middleware ``` ruby class ContentTypeMiddleware < Protocol::HTTP::Middleware def call(request) response = super # Add content-type header if not present unless response.headers.include?('content-type') response.headers['content-type'] = 'text/plain' end response end end ``` ## Testing Middleware ``` ruby describe MyMiddleware do let(:app) {MyMiddleware.new(Protocol::HTTP::Middleware::Okay)} it "processes requests correctly" do request = Protocol::HTTP::Request["GET", "/test"] response = app.call(request) expect(response.status).to be == 200 end it "closes properly" do expect { app.close }.not.to raise_exception end end ``` protocol-http-0.55.0/guides/streaming/000077500000000000000000000000001507641516600177035ustar00rootroot00000000000000protocol-http-0.55.0/guides/streaming/readme.md000066400000000000000000000062301507641516600214630ustar00rootroot00000000000000# Streaming This guide gives an overview of how to implement streaming requests and responses. ## Independent Uni-directional Streaming The request and response body work independently of each other can stream data in both directions. {ruby Protocol::HTTP::Body::Stream} provides an interface to merge these independent streams into an IO-like interface. ```ruby #!/usr/bin/env ruby require 'async' require 'async/http/client' require 'async/http/server' require 'async/http/endpoint' require 'protocol/http/body/stream' require 'protocol/http/body/writable' endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000') Async do server = Async::HTTP::Server.for(endpoint) do |request| output = Protocol::HTTP::Body::Writable.new stream = Protocol::HTTP::Body::Stream.new(request.body, output) Async do # Simple echo server: while chunk = stream.readpartial(1024) stream.write(chunk) end rescue EOFError # Ignore EOF errors. ensure stream.close end Protocol::HTTP::Response[200, {}, output] end server_task = Async{server.run} client = Async::HTTP::Client.new(endpoint) input = Protocol::HTTP::Body::Writable.new response = client.get("/", body: input) begin stream = Protocol::HTTP::Body::Stream.new(response.body, input) stream.write("Hello, ") stream.write("World!") stream.close_write while chunk = stream.readpartial(1024) puts chunk end rescue EOFError # Ignore EOF errors. ensure stream.close end ensure server_task.stop end ``` This approach works quite well, especially when the input and output bodies are independently compressed, decompressed, or chunked. However, some protocols, notably, WebSockets operate on the raw connection and don't require this level of abstraction. ## Bi-directional Streaming While WebSockets can work on the above streaming interface, it's a bit more convenient to use the streaming interface directly, which gives raw access to the underlying stream where possible. ```ruby #!/usr/bin/env ruby require 'async' require 'async/http/client' require 'async/http/server' require 'async/http/endpoint' require 'protocol/http/body/stream' require 'protocol/http/body/writable' endpoint = Async::HTTP::Endpoint.parse('http://localhost:3000') Async do server = Async::HTTP::Server.for(endpoint) do |request| streamable = Protocol::HTTP::Body::Streamable. output = Protocol::HTTP::Body::Writable.new stream = Protocol::HTTP::Body::Stream.new(request.body, output) Async do # Simple echo server: while chunk = stream.readpartial(1024) stream.write(chunk) end rescue EOFError # Ignore EOF errors. ensure stream.close end Protocol::HTTP::Response[200, {}, output] end server_task = Async{server.run} client = Async::HTTP::Client.new(endpoint) input = Protocol::HTTP::Body::Writable.new response = client.get("/", body: input) begin stream = Protocol::HTTP::Body::Stream.new(response.body, input) stream.write("Hello, ") stream.write("World!") stream.close_write while chunk = stream.readpartial(1024) puts chunk end rescue EOFError # Ignore EOF errors. ensure stream.close end ensure server_task.stop end ``` protocol-http-0.55.0/lib/000077500000000000000000000000001507641516600152005ustar00rootroot00000000000000protocol-http-0.55.0/lib/protocol/000077500000000000000000000000001507641516600170415ustar00rootroot00000000000000protocol-http-0.55.0/lib/protocol/http.rb000066400000000000000000000005251507641516600203470ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2024, by Samuel Williams. require_relative "http/version" require_relative "http/headers" require_relative "http/request" require_relative "http/response" require_relative "http/middleware" # @namespace module Protocol # @namespace module HTTP end end protocol-http-0.55.0/lib/protocol/http/000077500000000000000000000000001507641516600200205ustar00rootroot00000000000000protocol-http-0.55.0/lib/protocol/http/accept_encoding.rb000066400000000000000000000045751507641516600234650ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require_relative "middleware" require_relative "body/buffered" require_relative "body/inflate" module Protocol module HTTP # A middleware that sets the accept-encoding header and decodes the response according to the content-encoding header. class AcceptEncoding < Middleware # The header used to request encodings. ACCEPT_ENCODING = "accept-encoding".freeze # The header used to specify encodings. CONTENT_ENCODING = "content-encoding".freeze # The default wrappers to use for decoding content. DEFAULT_WRAPPERS = { "gzip" => Body::Inflate.method(:for), "identity" => ->(body) {body}, # Identity means no encoding # There is no point including this: # 'identity' => ->(body){body}, } # Initialize the middleware with the given delegate and wrappers. # # @parameter delegate [Protocol::HTTP::Middleware] The delegate middleware. # @parameter wrappers [Hash] A hash of encoding names to wrapper functions. def initialize(delegate, wrappers = DEFAULT_WRAPPERS) super(delegate) @accept_encoding = wrappers.keys.join(", ") @wrappers = wrappers end # Set the accept-encoding header and decode the response body. # # @parameter request [Protocol::HTTP::Request] The request to modify. # @returns [Protocol::HTTP::Response] The response. def call(request) request.headers[ACCEPT_ENCODING] = @accept_encoding response = super if body = response.body and !body.empty? if content_encoding = response.headers[CONTENT_ENCODING] # Process encodings in reverse order and remove them when they are decoded: while name = content_encoding.last # Look up wrapper with case-insensitive matching: wrapper = @wrappers[name.downcase] if wrapper body = wrapper.call(body) # Remove the encoding we just processed: content_encoding.pop else # Unknown encoding - stop processing here: break end end # Update the response body: response.body = body # Remove the content-encoding header if we decoded all encodings: if content_encoding.empty? response.headers.delete(CONTENT_ENCODING) end end end return response end end end end protocol-http-0.55.0/lib/protocol/http/body.rb000066400000000000000000000004251507641516600213030ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. require_relative "body/readable" require_relative "body/writable" require_relative "body/wrapper" module Protocol module HTTP # @namespace module Body end end end protocol-http-0.55.0/lib/protocol/http/body/000077500000000000000000000000001507641516600207555ustar00rootroot00000000000000protocol-http-0.55.0/lib/protocol/http/body/buffered.rb000066400000000000000000000100661507641516600230670ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. # Copyright, 2020, by Bryan Powell. # Copyright, 2025, by William T. Nelson. require_relative "readable" module Protocol module HTTP module Body # A body which buffers all its contents. class Buffered < Readable # Tries to wrap an object in a {Buffered} instance. # # For compatibility, also accepts anything that behaves like an `Array(String)`. # # @parameter body [String | Array(String) | Readable | nil] the body to wrap. # @returns [Readable | nil] the wrapped body or nil if nil was given. def self.wrap(object) if object.is_a?(Readable) return object elsif object.is_a?(Array) return self.new(object) elsif object.is_a?(String) return self.new([object]) elsif object return self.read(object) end end # Read the entire body into a buffered representation. # # @parameter body [Readable] the body to read. # @returns [Buffered] the buffered body. def self.read(body) chunks = [] body.each do |chunk| chunks << chunk end self.new(chunks) end # Initialize the buffered body with some chunks. # # @parameter chunks [Array(String)] the chunks to buffer. # @parameter length [Integer] the length of the body, if known. def initialize(chunks = [], length = nil) @chunks = chunks @length = length @index = 0 end # @attribute [Array(String)] chunks the buffered chunks. attr :chunks # A rewindable body wraps some other body. Convert it to a buffered body. The buffered body will share the same chunks as the rewindable body. # # @returns [Buffered] the buffered body. def buffered self.class.new(@chunks) end # Finish the body, this is a no-op. # # @returns [Buffered] self. def finish self end # Ensure that future reads return `nil`, but allow for rewinding. # # @parameter error [Exception | Nil] the error that caused the body to be closed, if any. def close(error = nil) @index = @chunks.length return nil end # Clear the buffered chunks. def clear @chunks = [] @length = 0 @index = 0 end # The length of the body. Will compute and cache the length of the body, if it was not provided. def length @length ||= @chunks.inject(0) {|sum, chunk| sum + chunk.bytesize} end # @returns [Boolean] if the body is empty. def empty? @index >= @chunks.length end # Whether the body is ready to be read. # @returns [Boolean] a buffered response is always ready. def ready? true end # Read the next chunk from the buffered body. # # @returns [String | Nil] the next chunk or nil if there are no more chunks. def read return nil unless @chunks if chunk = @chunks[@index] @index += 1 return chunk.dup end end # Discard the body. Invokes {#close}. def discard # It's safe to call close here because there is no underlying stream to close: self.close end # Write a chunk to the buffered body. def write(chunk) @chunks << chunk end # Close the body for writing. This is a no-op. def close_write(error) # Nothing to do. end # Whether the body can be rewound. # # @returns [Boolean] if the body has chunks. def rewindable? @chunks != nil end # Rewind the body to the beginning, causing a subsequent read to return the first chunk. def rewind return false unless @chunks @index = 0 return true end # Inspect the buffered body. # # @returns [String] a string representation of the buffered body. def inspect if @chunks and @chunks.size > 0 "#<#{self.class} #{@index}/#{@chunks.size} chunks, #{self.length} bytes>" else "#<#{self.class} empty>" end end end end end end protocol-http-0.55.0/lib/protocol/http/body/completable.rb000066400000000000000000000037411507641516600235760ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require_relative "wrapper" module Protocol module HTTP module Body # Invokes a callback once the body has completed, either successfully or due to an error. class Completable < Wrapper # Wrap a message body with a callback. If the body is empty, the callback is invoked immediately. # # @parameter message [Request | Response] the message body. # @parameter block [Proc] the callback to invoke when the body is closed. def self.wrap(message, &block) if body = message&.body and !body.empty? message.body = self.new(message.body, block) else yield end end # Initialize the completable body with a callback. # # @parameter body [Readable] the body to wrap. # @parameter callback [Proc] the callback to invoke when the body is closed. def initialize(body, callback) super(body) @callback = callback end # @returns [Boolean] completable bodies are not rewindable. def rewindable? false end # Rewind the body, is not supported. def rewind false end # Close the body and invoke the callback. If an error is given, it is passed to the callback. # # The calback is only invoked once, and before `super` is invoked. def close(error = nil) if @callback @callback.call(error) @callback = nil end super end # Convert the body to a hash suitable for serialization. # # @returns [Hash] The body as a hash. def as_json(...) super.merge( callback: @callback&.to_s ) end # Inspect the completable body. # # @returns [String] a string representation of the completable body. def inspect callback_status = @callback ? "callback pending" : "callback completed" return "#{super} | #<#{self.class} #{callback_status}>" end end end end end protocol-http-0.55.0/lib/protocol/http/body/deflate.rb000066400000000000000000000074031507641516600227120ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require_relative "wrapper" require "zlib" module Protocol module HTTP module Body # A body which compresses or decompresses the contents using the DEFLATE or GZIP algorithm. class ZStream < Wrapper # The default compression level. DEFAULT_LEVEL = 7 # The DEFLATE window size. DEFLATE = -Zlib::MAX_WBITS # The GZIP window size. GZIP = Zlib::MAX_WBITS | 16 # The supported encodings. ENCODINGS = { "deflate" => DEFLATE, "gzip" => GZIP, } # Initialize the body with the given stream. # # @parameter body [Readable] the body to wrap. # @parameter stream [Zlib::Deflate | Zlib::Inflate] the stream to use for compression or decompression. def initialize(body, stream) super(body) @stream = stream @input_length = 0 @output_length = 0 end # Close the stream. # # @parameter error [Exception | Nil] the error that caused the stream to be closed. def close(error = nil) if stream = @stream @stream = nil stream.close unless stream.closed? end super end # The length of the output, if known. Generally, this is not known due to the nature of compression. def length # We don't know the length of the output until after it's been compressed. nil end # @attribute [Integer] input_length the total number of bytes read from the input. attr :input_length # @attribute [Integer] output_length the total number of bytes written to the output. attr :output_length # The compression ratio, according to the input and output lengths. # # @returns [Float] the compression ratio, e.g. 0.5 for 50% compression. def ratio if @input_length != 0 @output_length.to_f / @input_length.to_f else 1.0 end end # Convert the body to a hash suitable for serialization. # # @returns [Hash] The body as a hash. def as_json(...) super.merge( input_length: @input_length, output_length: @output_length, compression_ratio: (ratio * 100).round(2) ) end # Inspect the body, including the compression ratio. # # @returns [String] a string representation of the body. def inspect "#{super} | #<#{self.class} #{(ratio*100).round(2)}%>" end end # A body which compresses the contents using the DEFLATE or GZIP algorithm. class Deflate < ZStream # Create a new body which compresses the given body using the GZIP algorithm by default. # # @parameter body [Readable] the body to wrap. # @parameter window_size [Integer] the window size to use for compression. # @parameter level [Integer] the compression level to use. # @returns [Deflate] the wrapped body. def self.for(body, window_size = GZIP, level = DEFAULT_LEVEL) self.new(body, Zlib::Deflate.new(level, window_size)) end # Read a chunk from the underlying body and compress it. If the body is finished, the stream is flushed and finished, and the remaining data is returned. # # @returns [String | Nil] the compressed chunk or `nil` if the stream is closed. def read return if @stream.finished? # The stream might have been closed while waiting for the chunk to come in. if chunk = super @input_length += chunk.bytesize chunk = @stream.deflate(chunk, Zlib::SYNC_FLUSH) @output_length += chunk.bytesize return chunk elsif !@stream.closed? chunk = @stream.finish @output_length += chunk.bytesize return chunk.empty? ? nil : chunk end end end end end end protocol-http-0.55.0/lib/protocol/http/body/digestable.rb000066400000000000000000000044461507641516600234150ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2020-2025, by Samuel Williams. require_relative "wrapper" require "digest/sha2" module Protocol module HTTP module Body # Invokes a callback once the body has finished reading. class Digestable < Wrapper # Wrap a message body with a callback. If the body is empty, the callback is not invoked, as there is no data to digest. # # @parameter message [Request | Response] the message body. # @parameter digest [Digest] the digest to use. # @parameter block [Proc] the callback to invoke when the body is closed. def self.wrap(message, digest = Digest::SHA256.new, &block) if body = message&.body and !body.empty? message.body = self.new(message.body, digest, block) end end # Initialize the digestable body with a callback. # # @parameter body [Readable] the body to wrap. # @parameter digest [Digest] the digest to use. # @parameter callback [Block] The callback is invoked when the digest is complete. def initialize(body, digest = Digest::SHA256.new, callback = nil) super(body) @digest = digest @callback = callback end # @attribute [Digest] digest the digest object. attr :digest # Generate an appropriate ETag for the digest, assuming it is complete. If you call this method before the body is fully read, the ETag will be incorrect. # # @parameter weak [Boolean] If true, the ETag is marked as weak. # @returns [String] the ETag. def etag(weak: false) if weak "W/\"#{digest.hexdigest}\"" else "\"#{digest.hexdigest}\"" end end # Read the body and update the digest. When the body is fully read, the callback is invoked with `self` as the argument. # # @returns [String | Nil] the next chunk of data, or nil if the body is fully read. def read if chunk = super @digest.update(chunk) return chunk else @callback&.call(self) return nil end end # Convert the body to a hash suitable for serialization. # # @returns [Hash] The body as a hash. def as_json(...) super.merge( digest_class: @digest.class.name, callback: @callback&.to_s ) end end end end end protocol-http-0.55.0/lib/protocol/http/body/file.rb000066400000000000000000000071261507641516600222270ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require_relative "readable" module Protocol module HTTP module Body # A body which reads from a file. class File < Readable # The default block size. BLOCK_SIZE = 64*1024 # The default mode for opening files. MODE = ::File::RDONLY | ::File::BINARY # Open a file at the given path. # # @parameter path [String] the path to the file. def self.open(path, *arguments, **options) self.new(::File.open(path, MODE), *arguments, **options) end # Initialize the file body with the given file. # # @parameter file [::File] the file to read from. # @parameter range [Range] the range of bytes to read from the file. # @parameter size [Integer] the size of the file, if known. # @parameter block_size [Integer] the block size to use when reading from the file. def initialize(file, range = nil, size: file.size, block_size: BLOCK_SIZE) @file = file @range = range @block_size = block_size if range @file.seek(range.min) @offset = range.min @length = @remaining = range.size else @file.seek(0) @offset = 0 @length = @remaining = size end end # Close the file. # # @parameter error [Exception | Nil] the error that caused the file to be closed. def close(error = nil) @file.close @remaining = 0 super end # @attribute [::File] file the file to read from. attr :file # @attribute [Integer] the offset to read from. attr :offset # @attribute [Integer] the number of bytes to read. attr :length # @returns [Boolean] whether more data should be read. def empty? @remaining == 0 end # @returns [Boolean] whether the body is ready to be read, always true for files. def ready? true end # Returns a copy of the body, by duplicating the file descriptor, including the same range if specified. # # @returns [File] the duplicated body. def buffered self.class.new(@file.dup, @range, block_size: @block_size) end # Rewind the file to the beginning of the range. def rewind @file.seek(@offset) @remaining = @length end # @returns [Boolean] whether the body is rewindable, generally always true for seekable files. def rewindable? true end # Read the next chunk of data from the file. # # @returns [String | Nil] the next chunk of data, or nil if the file is fully read. def read if @remaining > 0 amount = [@remaining, @block_size].min if chunk = @file.read(amount) @remaining -= chunk.bytesize return chunk end end end # def stream? # true # end # def call(stream) # IO.copy_stream(@file, stream, @remaining) # ensure # stream.close # end # Read all the remaining data from the file and return it as a single string. # # @returns [String] the remaining data. def join return "" if @remaining == 0 buffer = @file.read(@remaining) @remaining = 0 return buffer end # Inspect the file body. # # @returns [String] a string representation of the file body. def inspect if @offset > 0 "#<#{self.class} #{@file.inspect} +#{@offset}, #{@remaining} bytes remaining>" else "#<#{self.class} #{@file.inspect}, #{@remaining} bytes remaining>" end end end end end end protocol-http-0.55.0/lib/protocol/http/body/head.rb000066400000000000000000000037061507641516600222110ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2020-2025, by Samuel Williams. # Copyright, 2025, by William T. Nelson. require_relative "readable" module Protocol module HTTP module Body # Represents a body suitable for HEAD requests, in other words, a body that is empty and has a known length. class Head < Readable # Create a head body for the given body, capturing its length and then closing it. # # If a body is provided, the length is determined from the body, and the body is closed. # If no body is provided, and the content length is provided, a head body is created with that length. # This is useful for creating a head body when you only know the content length but not the actual body, which may happen in adapters for HTTP applications where the application may not provide a body for HEAD requests, but the content length is known. # # @parameter body [Readable | Nil] the body to create a head for. # @parameter length [Integer | Nil] the content length of the body, if known. # @returns [Head | Nil] the head body, or nil if the body is nil. def self.for(body, length = nil) if body head = self.new(body.length) body.close return head elsif length return self.new(length) end return nil end # Initialize the head body with the given length. # # @parameter length [Integer] the length of the body. def initialize(length) @length = length end # @returns [Boolean] the body is empty. def empty? true end # @returns [Boolean] the body is ready. def ready? true end # @returns [Integer] the length of the body, if known. def length @length end # Inspect the head body. # # @returns [String] a string representation of the head body. def inspect "#<#{self.class} #{@length} bytes (empty)>" end end end end end protocol-http-0.55.0/lib/protocol/http/body/inflate.rb000066400000000000000000000032441507641516600227270ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "zlib" require_relative "deflate" module Protocol module HTTP module Body # A body which decompresses the contents using the DEFLATE or GZIP algorithm. class Inflate < ZStream # Create a new body which decompresses the given body using the GZIP algorithm by default. # # @parameter body [Readable] the body to wrap. # @parameter window_size [Integer] the window size to use for decompression. def self.for(body, window_size = GZIP) self.new(body, Zlib::Inflate.new(window_size)) end # Read from the underlying stream and inflate it. # # @returns [String | Nil] the inflated data, or nil if the stream is finished. def read if stream = @stream # Read from the underlying stream and inflate it: while chunk = super @input_length += chunk.bytesize # It's possible this triggers the stream to finish. chunk = stream.inflate(chunk) break unless chunk&.empty? end if chunk @output_length += chunk.bytesize elsif !stream.closed? chunk = stream.finish @output_length += chunk.bytesize end # If the stream is finished, we need to close it and potentially return nil: if stream.finished? @stream = nil stream.close while super # There is data left in the stream, so we need to keep reading until it's all consumed. end if chunk.empty? return nil end end return chunk end end end end end end protocol-http-0.55.0/lib/protocol/http/body/readable.rb000066400000000000000000000140301507641516600230370ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2024, by Samuel Williams. # Copyright, 2023, by Bruno Sutic. module Protocol module HTTP module Body # Represents a readable input streams. # # There are two major modes of operation: # # 1. Reading chunks using {read} (or {each}/{join}), until the body is empty, or # 2. Streaming chunks using {call}, which writes chunks to a provided output stream. # # In both cases, reading can fail, for example if the body represents a streaming upload, and the connection is lost. In this case, {read} will raise some kind of error, or the stream will be closed with an error. # # At any point, you can use {close} to close the stream and release any resources, or {discard} to read all remaining data without processing it which may allow the underlying connection to be reused (but can be slower). class Readable # Close the stream immediately. After invoking this method, the stream should be considered closed, and all internal resources should be released. # # If an error occured while handling the output, it can be passed as an argument. This may be propagated to the client, for example the client may be informed that the stream was not fully read correctly. # # Invoking {read} after {close} will return `nil`. # # @parameter error [Exception | Nil] The error that caused this stream to be closed, if any. def close(error = nil) end # Optimistically determine whether read (may) return any data. # # - If this returns true, then calling read will definitely return nil. # - If this returns false, then calling read may return nil. # # @return [Boolean] Whether the stream is empty. def empty? false end # Whether calling read will return a chunk of data without blocking. We prefer pessimistic implementation, and thus default to `false`. # # @return [Boolean] Whether the stream is ready (read will not block). def ready? false end # Whether the stream can be rewound using {rewind}. # # @return [Boolean] Whether the stream is rewindable. def rewindable? false end # Rewind the stream to the beginning. # # @returns [Boolean] Whether the stream was successfully rewound. def rewind false end # Return a buffered representation of this body. # # This method must return a buffered body if `#rewindable?`. # # @returns [Buffered | Nil] The buffered body. def buffered nil end # The total length of the body, if known. # # @returns [Integer | Nil] The total length of the body, or `nil` if the length is unknown. def length nil end # Read the next available chunk. # # @returns [String | Nil] The chunk of data, or `nil` if the stream has finished. # @raises [StandardError] If an error occurs while reading. def read nil end # Enumerate all chunks until finished, then invoke {close}. # # Closes the stream when finished or if an error occurs. # # @yields {|chunk| ...} The block to call with each chunk of data. # @parameter chunk [String | Nil] The chunk of data, or `nil` if the stream has finished. def each return to_enum unless block_given? begin while chunk = self.read yield chunk end rescue => error raise ensure self.close(error) end end # Read all remaining chunks into a single binary string using `#each`. # # @returns [String | Nil] The binary string containing all chunks of data, or `nil` if the stream has finished (or did not contain any data). def join buffer = String.new.force_encoding(Encoding::BINARY) self.each do |chunk| buffer << chunk end if buffer.empty? return nil else return buffer end end # Whether to prefer streaming the body using {call} rather than reading it using {read} or {each}. # # @returns [Boolean] Whether the body should be streamed. def stream? false end # Invoke the body with the given stream. # # The default implementation simply writes each chunk to the stream. If the body is not ready, it will be flushed after each chunk. Closes the stream when finished or if an error occurs. # # Write the body to the given stream. # # @parameter stream [IO | Object] An `IO`-like object that responds to `#read`, `#write` and `#flush`. # @returns [Boolean] Whether the ownership of the stream was transferred. def call(stream) self.each do |chunk| stream.write(chunk) # Flush the stream unless we are immediately expecting more data: unless self.ready? stream.flush end end ensure # TODO Should this invoke close_write(error) instead? stream.close end # Read all remaining chunks into a buffered body and close the underlying input. # # @returns [Buffered] The buffered body. def finish # Internally, this invokes `self.each` which then invokes `self.close`. Buffered.read(self) end # Discard the body as efficiently as possible. # # The default implementation simply reads all chunks until the body is empty. # # Useful for discarding the body when it is not needed, but preserving the underlying connection. def discard while chunk = self.read end end # Convert the body to a hash suitable for serialization. This won't include the contents of the body, but will include metadata such as the length, streamability, and readiness, etc. # # @returns [Hash] The body as a hash. def as_json(...) { class: self.class.name, length: self.length, stream: self.stream?, ready: self.ready?, empty: self.empty? } end # Convert the body to JSON. # # @returns [String] The body as JSON. def to_json(...) as_json.to_json(...) end end end end end protocol-http-0.55.0/lib/protocol/http/body/reader.rb000066400000000000000000000046421507641516600225520ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2024, by Samuel Williams. # Copyright, 2022, by Dan Olson. module Protocol module HTTP module Body # General operations for interacting with a request or response body. # # This module is included in both {Request} and {Response}. module Reader # Read chunks from the body. # # @yields {|chunk| ...} chunks from the body. def each(&block) if @body @body.each(&block) @body = nil end end # Reads the entire request/response body. # # @returns [String] the entire body as a string. def read if @body buffer = @body.join @body = nil return buffer end end # Gracefully finish reading the body. This will buffer the remainder of the body. # # @returns [Buffered] buffers the entire body. def finish if @body body = @body.finish @body = nil return body end end # Discard the body as efficiently as possible. def discard if body = @body @body = nil body.discard end return nil end # Buffer the entire request/response body. # # @returns [Reader] itself. def buffered! if @body @body = @body.finish end # TODO Should this return @body instead? It seems more useful. return self end # Write the body of the response to the given file path. # # @parameter path [String] the path to write the body to. # @parameter mode [Integer] the mode to open the file with. # @parameter options [Hash] additional options to pass to `File.open`. def save(path, mode = ::File::WRONLY|::File::CREAT|::File::TRUNC, **options) if @body ::File.open(path, mode, **options) do |file| self.each do |chunk| file.write(chunk) end end end end # Close the connection as quickly as possible. Discards body. May close the underlying connection if necessary to terminate the stream. # # @parameter error [Exception | Nil] the error that caused the stream to be closed, if any. def close(error = nil) if @body @body.close(error) @body = nil end end # Whether there is a body? # # @returns [Boolean] whether there is a body. def body? @body and !@body.empty? end end end end end protocol-http-0.55.0/lib/protocol/http/body/rewindable.rb000066400000000000000000000052701507641516600234220ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. # Copyright, 2025, by William T. Nelson. require_relative "wrapper" require_relative "buffered" module Protocol module HTTP module Body # A body which buffers all its contents as it is read. # # As the body is buffered in memory, you may want to ensure your server has sufficient (virtual) memory available to buffer the entire body. class Rewindable < Wrapper # Wrap the given message body in a rewindable body, if it is not already rewindable. # # @parameter message [Request | Response] the message to wrap. def self.wrap(message) if body = message.body if body.rewindable? body else message.body = self.new(body) end end end # Initialize the body with the given body. # # @parameter body [Readable] the body to wrap. def initialize(body) super(body) @chunks = [] @index = 0 end # @returns [Boolean] Whether the body is empty. def empty? (@index >= @chunks.size) && super end # @returns [Boolean] Whether the body is ready to be read. def ready? (@index < @chunks.size) || super end # A rewindable body wraps some other body. Convert it to a buffered body. The buffered body will share the same chunks as the rewindable body. # # @returns [Buffered] the buffered body. def buffered Buffered.new(@chunks) end # Read the next available chunk. This may return a buffered chunk if the stream has been rewound, or a chunk from the underlying stream, if available. # # @returns [String | Nil] The chunk of data, or `nil` if the stream has finished. def read if @index < @chunks.size chunk = @chunks[@index] @index += 1 else if chunk = super @chunks << -chunk @index += 1 end end # We dup them on the way out, so that if someone modifies the string, it won't modify the rewindability. return chunk end # Rewind the stream to the beginning. def rewind @index = 0 end # @returns [Boolean] Whether the stream is rewindable, which it is. def rewindable? true end # Convert the body to a hash suitable for serialization. # # @returns [Hash] The body as a hash. def as_json(...) super.merge( index: @index, chunks: @chunks.size ) end # Inspect the rewindable body. # # @returns [String] a string representation of the body. def inspect "#{super} | #<#{self.class} #{@index}/#{@chunks.size} chunks read>" end end end end end protocol-http-0.55.0/lib/protocol/http/body/stream.rb000066400000000000000000000305671507641516600226100ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. # Copyright, 2023, by Genki Takiuchi. # Copyright, 2025, by William T. Nelson. require_relative "buffered" module Protocol module HTTP module Body # The input stream is an IO-like object which contains the raw HTTP POST data. When applicable, its external encoding must be “ASCII-8BIT” and it must be opened in binary mode, for Ruby 1.9 compatibility. The input stream must respond to gets, each, read and rewind. class Stream # The default line separator, used by {gets}. NEWLINE = "\n" # Initialize the stream with the given input and output. # # @parameter input [Readable] The input stream. # @parameter output [Writable] The output stream. def initialize(input = nil, output = Buffered.new) @input = input @output = output raise ArgumentError, "Non-writable output!" unless output.respond_to?(:write) # Will hold remaining data in `#read`. @buffer = nil @closed = false @closed_read = false end # @attribute [Readable] The input stream. attr :input # @attribute [Writable] The output stream. attr :output # This provides a read-only interface for data, which is surprisingly tricky to implement correctly. module Reader # Read data from the underlying stream. # # If given a non-negative length, it will read at most that many bytes from the stream. If the stream is at EOF, it will return nil. # # If the length is not given, it will read all data until EOF, or return an empty string if the stream is already at EOF. # # If buffer is given, then the read data will be placed into buffer instead of a newly created String object. # # @parameter length [Integer] the amount of data to read # @parameter buffer [String] the buffer which will receive the data # @returns [String] a buffer containing the data def read(length = nil, buffer = nil) return "" if length == 0 buffer ||= String.new.force_encoding(Encoding::BINARY) # Take any previously buffered data and replace it into the given buffer. if @buffer buffer.replace(@buffer) @buffer = nil else buffer.clear end if length while buffer.bytesize < length and chunk = read_next buffer << chunk end # This ensures the subsequent `slice!` works correctly. buffer.force_encoding(Encoding::BINARY) # This will be at least one copy: @buffer = buffer.byteslice(length, buffer.bytesize) # This should be zero-copy: buffer.slice!(length, buffer.bytesize) if buffer.empty? return nil else return buffer end else while chunk = read_next buffer << chunk end return buffer end end # Read some bytes from the stream. # # If the length is given, at most length bytes will be read. Otherwise, one chunk of data from the underlying stream will be read. # # Will avoid reading from the underlying stream if there is buffered data available. # # @parameter length [Integer] The maximum number of bytes to read. def read_partial(length = nil, buffer = nil) if @buffer if buffer buffer.replace(@buffer) else buffer = @buffer end @buffer = nil else if chunk = read_next if buffer buffer.replace(chunk) else buffer = chunk end else buffer&.clear buffer = nil end end if buffer and length if buffer.bytesize > length # This ensures the subsequent `slice!` works correctly. buffer.force_encoding(Encoding::BINARY) @buffer = buffer.byteslice(length, buffer.bytesize) buffer.slice!(length, buffer.bytesize) end end return buffer end # Similar to {read_partial} but raises an `EOFError` if the stream is at EOF. # # @parameter length [Integer] The maximum number of bytes to read. # @parameter buffer [String] The buffer to read into. def readpartial(length, buffer = nil) read_partial(length, buffer) or raise EOFError, "End of file reached!" end # Iterate over each chunk of data from the input stream. # # @yields {|chunk| ...} Each chunk of data. def each(&block) return to_enum unless block_given? if @buffer yield @buffer @buffer = nil end while chunk = read_next yield chunk end end # Read data from the stream without blocking if possible. # # @parameter length [Integer] The maximum number of bytes to read. # @parameter buffer [String | Nil] The buffer to read into. def read_nonblock(length, buffer = nil, exception: nil) @buffer ||= read_next chunk = nil unless @buffer buffer&.clear return end if @buffer.bytesize > length chunk = @buffer.byteslice(0, length) @buffer = @buffer.byteslice(length, @buffer.bytesize) else chunk = @buffer @buffer = nil end if buffer buffer.replace(chunk) else buffer = chunk end return buffer end # Read data from the stream until encountering pattern. # # @parameter pattern [String] The pattern to match. # @parameter offset [Integer] The offset to start searching from. # @parameter chomp [Boolean] Whether to remove the pattern from the returned data. # @returns [String] The contents of the stream up until the pattern, which is consumed but not returned. def read_until(pattern, offset = 0, chomp: false) # We don't want to split on the pattern, so we subtract the size of the pattern. split_offset = pattern.bytesize - 1 @buffer ||= read_next return nil if @buffer.nil? until index = @buffer.index(pattern, offset) offset = @buffer.bytesize - split_offset offset = 0 if offset < 0 if chunk = read_next @buffer << chunk else return nil end end @buffer.freeze matched = @buffer.byteslice(0, index+(chomp ? 0 : pattern.bytesize)) @buffer = @buffer.byteslice(index+pattern.bytesize, @buffer.bytesize) return matched end # Read a single line from the stream. # # @parameter separator [String] The line separator, defaults to `\n`. # @parameter limit [Integer] The maximum number of bytes to read. # @parameter *options [Hash] Additional options, passed to {read_until}. def gets(separator = NEWLINE, limit = nil, chomp: false) # If the separator is an integer, it is actually the limit: if separator.is_a?(Integer) limit = separator separator = NEWLINE end # If no separator is given, this is the same as a read operation: if separator.nil? # I tried using `read(limit)` here but it will block until the limit is reached, which is not usually desirable behaviour. return read_partial(limit) end # We don't want to split on the separator, so we subtract the size of the separator: split_offset = separator.bytesize - 1 @buffer ||= read_next return nil if @buffer.nil? offset = 0 until index = @buffer.index(separator, offset) offset = @buffer.bytesize - split_offset offset = 0 if offset < 0 # If we have gone past the limit, we are done: if limit and offset >= limit @buffer.freeze matched = @buffer.byteslice(0, limit) @buffer = @buffer.byteslice(limit, @buffer.bytesize) return matched end # Read more data: if chunk = read_next @buffer << chunk else # No more data could be read, return the remaining data: buffer = @buffer @buffer = nil # Return nil for empty buffers, otherwise return the content: if buffer && !buffer.empty? return buffer else return nil end end end # Freeze the buffer, as this enables us to use byteslice without generating a hidden copy: @buffer.freeze if limit and index > limit line = @buffer.byteslice(0, limit) @buffer = @buffer.byteslice(limit, @buffer.bytesize) else line = @buffer.byteslice(0, index+(chomp ? 0 : separator.bytesize)) @buffer = @buffer.byteslice(index+separator.bytesize, @buffer.bytesize) end return line end end include Reader # Write data to the underlying stream. # # @parameter buffer [String] The data to write. # @raises [IOError] If the stream is not writable. # @returns [Integer] The number of bytes written. def write(buffer) if @output @output.write(buffer) return buffer.bytesize else raise IOError, "Stream is not writable, output has been closed!" end end # Write data to the stream using {write}. # # Provided for compatibility with IO-like objects. # # @parameter buffer [String] The data to write. # @parameter exception [Boolean] Whether to raise an exception if the write would block, currently ignored. # @returns [Integer] The number of bytes written. def write_nonblock(buffer, exception: nil) write(buffer) end # Write data to the stream using {write}. def << buffer write(buffer) end # Write lines to the stream. # # The current implementation buffers the lines and writes them in a single operation. # # @parameter arguments [Array(String)] The lines to write. # @parameter separator [String] The line separator, defaults to `\n`. def puts(*arguments, separator: NEWLINE) buffer = ::String.new arguments.each do |argument| buffer << argument << separator end write(buffer) end # Flush the output stream. # # This is currently a no-op. def flush end # Close the input body. # # If, while processing the data that was read from this stream, an error is encountered, it should be passed to this method. # # @parameter error [Exception | Nil] The error that was encountered, if any. def close_read(error = nil) if input = @input @input = nil @closed_read = true @buffer = nil input.close(error) end end # Close the output body. # # If, while generating the data that is written to this stream, an error is encountered, it should be passed to this method. # # @parameter error [Exception | Nil] The error that was encountered, if any. def close_write(error = nil) if output = @output @output = nil output.close_write(error) end end # Close the input and output bodies. # # @parameter error [Exception | Nil] The error that caused this stream to be closed, if any. def close(error = nil) self.close_read(error) self.close_write(error) return nil ensure @closed = true end # @returns [Boolean] Whether the stream has been closed. def closed? @closed end # Inspect the stream. # # @returns [String] a string representation of the stream. def inspect buffer_info = @buffer ? "#{@buffer.bytesize} bytes buffered" : "no buffer" status = [] status << "closed" if @closed status << "read-closed" if @closed_read status_info = status.empty? ? "open" : status.join(", ") return "#<#{self.class} #{buffer_info}, #{status_info}>" end # @returns [Boolean] Whether there are any output chunks remaining. def empty? @output.empty? end private # Read the next chunk of data from the input stream. # # @returns [String] The next chunk of data. # @raises [IOError] If the input stream was explicitly closed. def read_next if @input return @input.read elsif @closed_read raise IOError, "Stream is not readable, input has been closed!" end end end end end end protocol-http-0.55.0/lib/protocol/http/body/streamable.rb000066400000000000000000000154451507641516600234320ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require_relative "readable" require_relative "writable" require_relative "stream" module Protocol module HTTP module Body # A body that invokes a block that can read and write to a stream. # # In some cases, it's advantageous to directly read and write to the underlying stream if possible. For example, HTTP/1 upgrade requests, WebSockets, and similar. To handle that case, response bodies can implement {stream?} and return `true`. When {stream?} returns true, the body **should** be consumed by calling `call(stream)`. Server implementations may choose to always invoke `call(stream)` if it's efficient to do so. Bodies that don't support it will fall back to using {each}. # # When invoking `call(stream)`, the stream can be read from and written to, and closed. However, the stream is only guaranteed to be open for the duration of the `call(stream)` call. Once the method returns, the stream **should** be closed by the server. module Streamable # Generate a new streaming request body using the given block to generate the body. # # @parameter block [Proc] The block that generates the body. # @returns [RequestBody] The streaming request body. def self.request(&block) RequestBody.new(block) end # Generate a new streaming response body using the given block to generate the body. # # @parameter request [Request] The request. # @parameter block [Proc] The block that generates the body. # @returns [ResponseBody] The streaming response body. def self.response(request, &block) ResponseBody.new(block, request.body) end # A output stream that can be written to by a block. class Output # Schedule the block to be executed in a fiber. # # @parameter input [Readable] The input stream. # @parameter block [Proc] The block that generates the output. # @returns [Output] The output stream. def self.schedule(input, block) self.new(input, block).tap(&:schedule) end # Initialize the output stream with the given input and block. # # @parameter input [Readable] The input stream. # @parameter block [Proc] The block that generates the output. def initialize(input, block) @output = Writable.new @stream = Stream.new(input, @output) @block = block end # Schedule the block to be executed in a fiber. # # @returns [Fiber] The fiber. def schedule @fiber ||= Fiber.schedule do @block.call(@stream) end end # Read from the output stream (may block). def read @output.read end # Close the output stream. # # @parameter error [Exception | Nil] The error that caused this stream to be closed, if any. def close(error = nil) @output.close_write(error) end end # Raised when a streaming body is consumed more than once. class ConsumedError < StandardError end # A streaming body that can be read from and written to. class Body < Readable # Initialize the body with the given block and input. # # @parameter block [Proc] The block that generates the body. # @parameter input [Readable] The input stream, if known. def initialize(block, input = nil) @block = block @input = input @output = nil end # @returns [Boolean] Whether the body can be streamed, which is true. def stream? true end # Invokes the block in a fiber which yields chunks when they are available. def read # We are reading chunk by chunk, allocate an output stream and execute the block to generate the chunks: if @output.nil? if @block.nil? raise ConsumedError, "Streaming body has already been consumed!" end @output = Output.schedule(@input, @block) @block = nil end @output.read end # Invoke the block with the given stream. The block can read and write to the stream, and must close the stream when finishing. # # @parameter stream [Stream] The stream to read and write to. def call(stream) if @block.nil? raise ConsumedError, "Streaming block has already been consumed!" end block = @block @input = @output = @block = nil # Ownership of the stream is passed into the block, in other words, the block is responsible for closing the stream. block.call(stream) rescue => error # If, for some reason, the block raises an error, we assume it may not have closed the stream, so we close it here: stream.close raise end # Close the input. The streaming body will eventually read all the input. # # @parameter error [Exception | Nil] The error that caused this stream to be closed, if any. def close_input(error = nil) if input = @input @input = nil input.close(error) end end # Close the output, the streaming body will be unable to write any more output. # # @parameter error [Exception | Nil] The error that caused this stream to be closed, if any. def close_output(error = nil) if output = @output @output = nil output.close(error) end end # Inspect the streaming body. # # @returns [String] a string representation of the streaming body. def inspect if @block "#<#{self.class} block available, not consumed>" elsif @output "#<#{self.class} block consumed, output active>" else "#<#{self.class} block consumed, output closed>" end end end # A response body is used on the server side to generate the response body using a block. class ResponseBody < Body # Close will be invoked when all the output is written. def close(error = nil) self.close_output(error) end end # A request body is used on the client side to generate the request body using a block. # # As the response body isn't available until the request is sent, the response body must be {stream}ed into the request body. class RequestBody < Body # Initialize the request body with the given block. # # @parameter block [Proc] The block that generates the body. def initialize(block) super(block, Writable.new) end # Close will be invoked when all the input is read. def close(error = nil) self.close_input(error) end # Stream the response body into the block's input. def stream(body) body&.each do |chunk| @input.write(chunk) end rescue => error ensure @input.close_write(error) end end end end end end protocol-http-0.55.0/lib/protocol/http/body/wrapper.rb000066400000000000000000000044031507641516600227630ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2024, by Samuel Williams. require_relative "readable" module Protocol module HTTP module Body # Wrapping body instance. Typically you'd override `#read`. class Wrapper < Readable # Wrap the body of the given message in a new instance of this class. # # @parameter message [Request | Response] the message to wrap. # @returns [Wrapper | Nil] the wrapped body or `nil`` if the body was `nil`. def self.wrap(message) if body = message.body message.body = self.new(body) end end # Initialize the wrapper with the given body. # # @parameter body [Readable] The body to wrap. def initialize(body) @body = body end # @attribute [Readable] The wrapped body. attr :body # Close the body. # # @parameter error [Exception | Nil] The error that caused this stream to be closed, if any. def close(error = nil) @body.close(error) # It's a no-op: # super end # Forwards to the wrapped body. def empty? @body.empty? end # Forwards to the wrapped body. def ready? @body.ready? end # Forwards to the wrapped body. def buffered @body.buffered end # Forwards to the wrapped body. def rewind @body.rewind end # Forwards to the wrapped body. def rewindable? @body.rewindable? end # Forwards to the wrapped body. def length @body.length end # Forwards to the wrapped body. def read @body.read end # Forwards to the wrapped body. def discard @body.discard end # Convert the body to a hash suitable for serialization. # # @returns [Hash] The body as a hash. def as_json(...) { class: self.class.name, body: @body&.as_json } end # Convert the body to JSON. # # @returns [String] The body as JSON. def to_json(...) as_json.to_json(...) end # Inspect the wrapped body. The wrapper, by default, is transparent. # # @returns [String] a string representation of the wrapped body. def inspect @body.inspect end end end end end protocol-http-0.55.0/lib/protocol/http/body/writable.rb000066400000000000000000000124151507641516600231160ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2025, by Samuel Williams. require_relative "readable" module Protocol module HTTP module Body # A dynamic body which you can write to and read from. class Writable < Readable # An error indicating that the body has been closed and no further writes are allowed. class Closed < StandardError end # Initialize the writable body. # # @parameter length [Integer] The length of the response body if known. # @parameter queue [Thread::Queue] Specify a different queue implementation, e.g. `Thread::SizedQueue` to enable back-pressure. def initialize(length = nil, queue: Thread::Queue.new) @length = length @queue = queue @count = 0 @error = nil end # @attribute [Integer] The length of the response body if known. attr :length # Stop generating output; cause the next call to write to fail with the given error. Does not prevent existing chunks from being read. In other words, this indicates both that no more data will be or should be written to the body. # # @parameter error [Exception] The error that caused this body to be closed, if any. Will be raised on the next call to {read}. def close(error = nil) @error ||= error @queue.clear @queue.close super end # Whether the body is closed. A closed body can not be written to or read from. # # @returns [Boolean] Whether the body is closed. def closed? @queue.closed? end # @returns [Boolean] Whether the body is ready to be read from, without blocking. def ready? !@queue.empty? || @queue.closed? end # Indicates whether the body is empty. This can occur if the body has been closed, or if the producer has invoked {close_write} and the reader has consumed all available chunks. # # @returns [Boolean] Whether the body is empty. def empty? @queue.empty? && @queue.closed? end # Read the next available chunk. # # @returns [String | Nil] The next chunk, or `nil` if the body is finished. # @raises [Exception] If the body was closed due to an error. def read if @error raise @error end # This operation may result in @error being set. chunk = @queue.pop if @error raise @error end return chunk end # Write a single chunk to the body. Signal completion by calling {close_write}. # # @parameter chunk [String] The chunk to write. # @raises [Closed] If the body has been closed without error. # @raises [Exception] If the body has been closed due to an error. def write(chunk) if @queue.closed? raise(@error || Closed) end @queue.push(chunk) @count += 1 end # Signal that no more data will be written to the body. # # @parameter error [Exception] The error that caused this body to be closed, if any. def close_write(error = nil) @error ||= error @queue.close end # The output interface for writing chunks to the body. class Output # Initialize the output with the given writable body. # # @parameter writable [Writable] The writable body. def initialize(writable) @writable = writable @closed = false end # @returns [Boolean] Whether the output is closed for writing only. def closed? @closed || @writable.closed? end # Write a chunk to the body. def write(chunk) @writable.write(chunk) end alias << write # Close the output stream. # # If an error is given, the error will be used to close the body by invoking {close} with the error. Otherwise, only the write side of the body will be closed. # # @parameter error [Exception | Nil] The error that caused this stream to be closed, if any. def close(error = nil) @closed = true if error @writable.close(error) else @writable.close_write end end end # Create an output wrapper which can be used to write chunks to the body. # # If a block is given, and the block raises an error, the error will used to close the body by invoking {close} with the error. # # @yields {|output| ...} if a block is given. # @parameter output [Output] The output wrapper. # @returns [Output] The output wrapper. def output output = Output.new(self) unless block_given? return output end begin yield output rescue => error raise error ensure output.close(error) end end # Inspect the body. # # @returns [String] A string representation of the body. def inspect if @error "#<#{self.class} #{@count} chunks written, #{status}, error=#{@error}>" else "#<#{self.class} #{@count} chunks written, #{status}>" end end private # @returns [String] A string representation of the body's status. def status if @queue.empty? if @queue.closed? "closed" else "waiting" end else if @queue.closed? "closing" else "ready" end end end end end end end protocol-http-0.55.0/lib/protocol/http/content_encoding.rb000066400000000000000000000045071507641516600236730ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require_relative "middleware" require_relative "body/buffered" require_relative "body/deflate" module Protocol module HTTP # Encode a response according the the request's acceptable encodings. class ContentEncoding < Middleware # The default wrappers to use for encoding content. DEFAULT_WRAPPERS = { "gzip" => Body::Deflate.method(:for) } # The default content types to apply encoding to. DEFAULT_CONTENT_TYPES = %r{^(text/.*?)|(.*?/json)|(.*?/javascript)$} # Initialize the content encoding middleware. # # @parameter delegate [Middleware] The next middleware in the chain. # @parameter content_types [Regexp] The content types to apply encoding to. # @parameter wrappers [Hash] The encoding wrappers to use. def initialize(delegate, content_types = DEFAULT_CONTENT_TYPES, wrappers = DEFAULT_WRAPPERS) super(delegate) @content_types = content_types @wrappers = wrappers end # Encode the response body according to the request's acceptable encodings. # # @parameter request [Request] The request. # @returns [Response] The response. def call(request) response = super # Early exit if the response has already specified a content-encoding. return response if response.headers["content-encoding"] # This is a very tricky issue, so we avoid it entirely. # https://lists.w3.org/Archives/Public/ietf-http-wg/2014JanMar/1179.html return response if response.partial? body = response.body # If there is no response body, there is nothing to encode: return response if body.nil? or body.empty? # Ensure that caches are aware we are varying the response based on the accept-encoding request header: response.headers.add("vary", "accept-encoding") if accept_encoding = request.headers["accept-encoding"] if content_type = response.headers["content-type"] and @content_types =~ content_type accept_encoding.each do |name| if wrapper = @wrappers[name] response.headers["content-encoding"] = name body = wrapper.call(body) break end end response.body = body end end return response end end end end protocol-http-0.55.0/lib/protocol/http/cookie.rb000066400000000000000000000055231507641516600216230ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. # Copyright, 2022, by Herrick Fang. require_relative "quoted_string" module Protocol module HTTP # Represents an individual cookie key-value pair. class Cookie # Valid cookie name characters according to RFC 6265. # cookie-name = token (RFC 2616 defines token) VALID_COOKIE_KEY = /\A#{TOKEN}\z/.freeze # Valid cookie value characters according to RFC 6265. # cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) # cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E # Excludes control chars, whitespace, DQUOTE, comma, semicolon, and backslash VALID_COOKIE_VALUE = /\A[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*\z/.freeze # Initialize the cookie with the given name, value, and directives. # # @parameter name [String] The name of the cookie, e.g. "session_id". # @parameter value [String] The value of the cookie, e.g. "1234". # @parameter directives [Hash] The directives of the cookie, e.g. `{"path" => "/"}`. # @raises [ArgumentError] If the name or value contains invalid characters. def initialize(name, value, directives = nil) unless VALID_COOKIE_KEY.match?(name) raise ArgumentError, "Invalid cookie name: #{name.inspect}" end if value && !VALID_COOKIE_VALUE.match?(value) raise ArgumentError, "Invalid cookie value: #{value.inspect}" end @name = name @value = value @directives = directives end # @attribute [String] The name of the cookie. attr_accessor :name # @attribute [String] The value of the cookie. attr_accessor :value # @attribute [Hash] The directives of the cookie. attr_accessor :directives # Convert the cookie to a string. # # @returns [String] The string representation of the cookie. def to_s buffer = String.new buffer << @name << "=" << @value if @directives @directives.each do |key, value| buffer << ";" buffer << key if value != true buffer << "=" << value.to_s end end end return buffer end # Parse a string into a cookie. # # @parameter string [String] The string to parse. # @returns [Cookie] The parsed cookie. def self.parse(string) head, *directives = string.split(/\s*;\s*/) key, value = head.split("=", 2) directives = self.parse_directives(directives) self.new(key, value, directives) end # Parse a list of strings into a hash of directives. # # @parameter strings [Array(String)] The list of strings to parse. # @returns [Hash] The hash of directives. def self.parse_directives(strings) strings.collect do |string| key, value = string.split("=", 2) [key, value || true] end.to_h end end end end protocol-http-0.55.0/lib/protocol/http/error.rb000066400000000000000000000014341507641516600215000ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. module Protocol module HTTP # A generic, HTTP protocol error. class Error < StandardError end # Represents a bad request error (as opposed to a server error). # This is used to indicate that the request was malformed or invalid. module BadRequest end # Raised when a singleton (e.g. `content-length`) header is duplicated in a request or response. class DuplicateHeaderError < Error include BadRequest # @parameter key [String] The header key that was duplicated. def initialize(key) super("Duplicate singleton header key: #{key.inspect}") end # @attribute [String] key The header key that was duplicated. attr :key end end end protocol-http-0.55.0/lib/protocol/http/header/000077500000000000000000000000001507641516600212505ustar00rootroot00000000000000protocol-http-0.55.0/lib/protocol/http/header/accept.rb000066400000000000000000000103541507641516600230370ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. # Copyright, 2025, by William T. Nelson. require_relative "split" require_relative "../quoted_string" require_relative "../error" module Protocol module HTTP module Header # The `accept-content-type` header represents a list of content-types that the client can accept. class Accept < Array # Regular expression used to split values on commas, with optional surrounding whitespace, taking into account quoted strings. SEPARATOR = / (?: # Start non-capturing group "[^"\\]*" # Match quoted strings (no escaping of quotes within) | # OR [^,"]+ # Match non-quoted strings until a comma or quote )+ (?=,|\z) # Match until a comma or end of string /x ParseError = Class.new(Error) MEDIA_RANGE = /\A(?#{TOKEN})\/(?#{TOKEN})(?.*)\z/ PARAMETER = /\s*;\s*(?#{TOKEN})=((?#{TOKEN})|(?#{QUOTED_STRING}))/ # A single entry in the Accept: header, which includes a mime type and associated parameters. A media range can include wild cards, but a media type is a specific type and subtype. MediaRange = Struct.new(:type, :subtype, :parameters) do # Create a new media range. # # @parameter type [String] the type of the media range. # @parameter subtype [String] the subtype of the media range. # @parameter parameters [Hash] the parameters associated with the media range. def initialize(type, subtype = "*", parameters = {}) super(type, subtype, parameters) end # Compare the media range with another media range or a string, based on the quality factor. def <=> other other.quality_factor <=> self.quality_factor end private def parameters_string return "" if parameters == nil or parameters.empty? parameters.collect do |key, value| ";#{key.to_s}=#{QuotedString.quote(value.to_s)}" end.join end # The string representation of the media range, including the type, subtype, and any parameters. def to_s "#{type}/#{subtype}#{parameters_string}" end alias to_str to_s # The quality factor associated with the media range, which is used to determine the order of preference. # # @returns [Float] the quality factor, which defaults to 1.0 if not specified. def quality_factor parameters.fetch("q", 1.0).to_f end end # Parse the `accept` header value into a list of content types. # # @parameter value [String] the value of the header. def initialize(value = nil) if value super(value.scan(SEPARATOR).map(&:strip)) end end # Adds one or more comma-separated values to the header. # # The input string is split into distinct entries and appended to the array. # # @parameter value [String] the value or values to add, separated by commas. def << value self.concat(value.scan(SEPARATOR).map(&:strip)) end # Serializes the stored values into a comma-separated string. # # @returns [String] the serialized representation of the header values. def to_s join(",") end # Whether this header is acceptable in HTTP trailers. # @returns [Boolean] `false`, as Accept headers are used for response content negotiation. def self.trailer? false end # Parse the `accept` header. # # @returns [Array(Charset)] the list of content types and their associated parameters. def media_ranges self.map do |value| self.parse_media_range(value) end end private def parse_media_range(value) if match = value.match(MEDIA_RANGE) type = match[:type] subtype = match[:subtype] parameters = {} match[:parameters].scan(PARAMETER) do |key, value, quoted_value| if quoted_value value = QuotedString.unquote(quoted_value) end parameters[key] = value end return MediaRange.new(type, subtype, parameters) else raise ParseError, "Invalid media type: #{value.inspect}" end end end end end end protocol-http-0.55.0/lib/protocol/http/header/accept_charset.rb000066400000000000000000000022121507641516600245420ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require_relative "split" require_relative "../quoted_string" require_relative "../error" module Protocol module HTTP module Header # The `accept-charset` header represents a list of character sets that the client can accept. class AcceptCharset < Split ParseError = Class.new(Error) # https://tools.ietf.org/html/rfc7231#section-5.3.3 CHARSET = /\A(?#{TOKEN})(;q=(?#{QVALUE}))?\z/ Charset = Struct.new(:name, :q) do def quality_factor (q || 1.0).to_f end def <=> other other.quality_factor <=> self.quality_factor end end # Parse the `accept-charset` header value into a list of character sets. # # @returns [Array(Charset)] the list of character sets and their associated quality factors. def charsets self.map do |value| if match = value.match(CHARSET) Charset.new(match[:name], match[:q]) else raise ParseError.new("Could not parse character set: #{value.inspect}") end end end end end end end protocol-http-0.55.0/lib/protocol/http/header/accept_encoding.rb000066400000000000000000000023571507641516600247110ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require_relative "split" require_relative "../quoted_string" require_relative "../error" module Protocol module HTTP module Header # The `accept-encoding` header represents a list of encodings that the client can accept. class AcceptEncoding < Split ParseError = Class.new(Error) # https://tools.ietf.org/html/rfc7231#section-5.3.1 QVALUE = /0(\.[0-9]{0,3})?|1(\.[0]{0,3})?/ # https://tools.ietf.org/html/rfc7231#section-5.3.4 ENCODING = /\A(?#{TOKEN})(;q=(?#{QVALUE}))?\z/ Encoding = Struct.new(:name, :q) do def quality_factor (q || 1.0).to_f end def <=> other other.quality_factor <=> self.quality_factor end end # Parse the `accept-encoding` header value into a list of encodings. # # @returns [Array(Charset)] the list of character sets and their associated quality factors. def encodings self.map do |value| if match = value.match(ENCODING) Encoding.new(match[:name], match[:q]) else raise ParseError.new("Could not parse encoding: #{value.inspect}") end end end end end end end protocol-http-0.55.0/lib/protocol/http/header/accept_language.rb000066400000000000000000000025641507641516600247060ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require_relative "split" require_relative "../quoted_string" require_relative "../error" module Protocol module HTTP module Header # The `accept-language` header represents a list of languages that the client can accept. class AcceptLanguage < Split ParseError = Class.new(Error) # https://tools.ietf.org/html/rfc3066#section-2.1 NAME = /\*|[A-Z]{1,8}(-[A-Z0-9]{1,8})*/i # https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9 QVALUE = /0(\.[0-9]{0,6})?|1(\.[0]{0,6})?/ # https://greenbytes.de/tech/webdav/rfc7231.html#quality.values LANGUAGE = /\A(?#{NAME})(\s*;\s*q=(?#{QVALUE}))?\z/ Language = Struct.new(:name, :q) do def quality_factor (q || 1.0).to_f end def <=> other other.quality_factor <=> self.quality_factor end end # Parse the `accept-language` header value into a list of languages. # # @returns [Array(Charset)] the list of character sets and their associated quality factors. def languages self.map do |value| if match = value.match(LANGUAGE) Language.new(match[:name], match[:q]) else raise ParseError.new("Could not parse language: #{value.inspect}") end end end end end end end protocol-http-0.55.0/lib/protocol/http/header/authorization.rb000066400000000000000000000024011507641516600244720ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. # Copyright, 2024, by Earlopain. module Protocol module HTTP module Header # Used for basic authorization. # # ~~~ ruby # headers.add('authorization', Authorization.basic("my_username", "my_password")) # ~~~ # # TODO Support other authorization mechanisms, e.g. bearer token. class Authorization < String # Splits the header into the credentials. # # @returns [Tuple(String, String)] The username and password. def credentials self.split(/\s+/, 2) end # Generate a new basic authorization header, encoding the given username and password. # # @parameter username [String] The username. # @parameter password [String] The password. # @returns [Authorization] The basic authorization header. def self.basic(username, password) strict_base64_encoded = ["#{username}:#{password}"].pack("m0") self.new( "Basic #{strict_base64_encoded}" ) end # Whether this header is acceptable in HTTP trailers. # @returns [Boolean] `false`, as authorization headers are used for request authentication. def self.trailer? false end end end end end protocol-http-0.55.0/lib/protocol/http/header/cache_control.rb000066400000000000000000000114621507641516600244040ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2020-2025, by Samuel Williams. # Copyright, 2023, by Thomas Morgan. require_relative "split" module Protocol module HTTP module Header # Represents the `cache-control` header, which is a list of cache directives. class CacheControl < Split # The `private` directive indicates that the response is intended for a single user and must not be stored by shared caches. PRIVATE = "private" # The `public` directive indicates that the response may be stored by any cache, even if it would normally be considered non-cacheable. PUBLIC = "public" # The `no-cache` directive indicates that caches must revalidate the response with the origin server before serving it to clients. NO_CACHE = "no-cache" # The `no-store` directive indicates that caches must not store the response under any circumstances. NO_STORE = "no-store" # The `max-age` directive indicates the maximum amount of time, in seconds, that a response is considered fresh. MAX_AGE = "max-age" # The `s-maxage` directive is similar to `max-age` but applies only to shared caches. If both `s-maxage` and `max-age` are present, `s-maxage` takes precedence in shared caches. S_MAXAGE = "s-maxage" # The `static` directive is a custom directive often used to indicate that the resource is immutable or rarely changes, allowing longer caching periods. STATIC = "static" # The `dynamic` directive is a custom directive used to indicate that the resource is generated dynamically and may change frequently, requiring shorter caching periods. DYNAMIC = "dynamic" # The `streaming` directive is a custom directive used to indicate that the resource is intended for progressive or chunked delivery, such as live video streams. STREAMING = "streaming" # The `must-revalidate` directive indicates that once a response becomes stale, caches must not use it to satisfy subsequent requests without revalidating it with the origin server. MUST_REVALIDATE = "must-revalidate" # The `proxy-revalidate` directive is similar to `must-revalidate` but applies only to shared caches. PROXY_REVALIDATE = "proxy-revalidate" # Initializes the cache control header with the given value. The value is expected to be a comma-separated string of cache directives. # # @parameter value [String | Nil] the raw Cache-Control header value. def initialize(value = nil) super(value&.downcase) end # Adds a directive to the Cache-Control header. The value will be normalized to lowercase before being added. # # @parameter value [String] the directive to add. def << value super(value.downcase) end # @returns [Boolean] whether the `static` directive is present. def static? self.include?(STATIC) end # @returns [Boolean] whether the `dynamic` directive is present. def dynamic? self.include?(DYNAMIC) end # @returns [Boolean] whether the `streaming` directive is present. def streaming? self.include?(STREAMING) end # @returns [Boolean] whether the `private` directive is present. def private? self.include?(PRIVATE) end # @returns [Boolean] whether the `public` directive is present. def public? self.include?(PUBLIC) end # @returns [Boolean] whether the `no-cache` directive is present. def no_cache? self.include?(NO_CACHE) end # @returns [Boolean] whether the `no-store` directive is present. def no_store? self.include?(NO_STORE) end # @returns [Boolean] whether the `must-revalidate` directive is present. def must_revalidate? self.include?(MUST_REVALIDATE) end # @returns [Boolean] whether the `proxy-revalidate` directive is present. def proxy_revalidate? self.include?(PROXY_REVALIDATE) end # @returns [Integer | Nil] the value of the `max-age` directive in seconds, or `nil` if the directive is not present or invalid. def max_age find_integer_value(MAX_AGE) end # @returns [Integer | Nil] the value of the `s-maxage` directive in seconds, or `nil` if the directive is not present or invalid. def s_maxage find_integer_value(S_MAXAGE) end private # Finds and parses an integer value from a directive. # # @parameter value_name [String] the directive name to search for (e.g., "max-age"). # @returns [Integer | Nil] the parsed integer value, or `nil` if not found or invalid. def find_integer_value(value_name) if value = self.find{|value| value.start_with?(value_name)} _, age = value.split("=", 2) if age =~ /\A[0-9]+\z/ return Integer(age) end end end end end end end protocol-http-0.55.0/lib/protocol/http/header/connection.rb000066400000000000000000000046661507641516600237500ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. # Copyright, 2024, by Thomas Morgan. require_relative "split" module Protocol module HTTP module Header # Represents the `connection` HTTP header, which controls options for the current connection. # # The `connection` header is used to specify control options such as whether the connection should be kept alive, closed, or upgraded to a different protocol. class Connection < Split # The `keep-alive` directive indicates that the connection should remain open for future requests or responses, avoiding the overhead of opening a new connection. KEEP_ALIVE = "keep-alive" # The `close` directive indicates that the connection should be closed after the current request and response are complete. CLOSE = "close" # The `upgrade` directive indicates that the connection should be upgraded to a different protocol, as specified in the `Upgrade` header. UPGRADE = "upgrade" # Initializes the connection header with the given value. The value is expected to be a comma-separated string of directives. # # @parameter value [String | Nil] the raw `connection` header value. def initialize(value = nil) super(value&.downcase) end # Adds a directive to the `connection` header. The value will be normalized to lowercase before being added. # # @parameter value [String] the directive to add. def << value super(value.downcase) end # @returns [Boolean] whether the `keep-alive` directive is present and the connection is not marked for closure with the `close` directive. def keep_alive? self.include?(KEEP_ALIVE) && !close? end # @returns [Boolean] whether the `close` directive is present, indicating that the connection should be closed after the current request and response. def close? self.include?(CLOSE) end # @returns [Boolean] whether the `upgrade` directive is present, indicating that the connection should be upgraded to a different protocol. def upgrade? self.include?(UPGRADE) end # Whether this header is acceptable in HTTP trailers. # Connection headers control the current connection and must not appear in trailers. # @returns [Boolean] `false`, as connection headers are hop-by-hop and forbidden in trailers. def self.trailer? false end end end end end protocol-http-0.55.0/lib/protocol/http/header/cookie.rb000066400000000000000000000026471507641516600230570ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require_relative "multiple" require_relative "../cookie" module Protocol module HTTP module Header # The `cookie` header contains stored HTTP cookies previously sent by the server with the `set-cookie` header. # # It is used by clients to send key-value pairs representing stored cookies back to the server. class Cookie < Multiple # Parses the `cookie` header into a hash of cookie names and their corresponding cookie objects. # # @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects. def to_h cookies = self.collect do |string| HTTP::Cookie.parse(string) end cookies.map{|cookie| [cookie.name, cookie]}.to_h end # Whether this header is acceptable in HTTP trailers. # Cookie headers should not appear in trailers as they contain state information needed early in processing. # @returns [Boolean] `false`, as cookie headers are needed during initial request processing. def self.trailer? false end end # The `set-cookie` header sends cookies from the server to the user agent. # # It is used to store cookies on the client side, which are then sent back to the server in subsequent requests using the `cookie` header. class SetCookie < Cookie end end end end protocol-http-0.55.0/lib/protocol/http/header/date.rb000066400000000000000000000021521507641516600225120ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "time" module Protocol module HTTP module Header # The `date` header represents the date and time at which the message was originated. # # This header is typically included in HTTP responses and follows the format defined in RFC 9110. class Date < String # Replaces the current value of the `date` header with the specified value. # # @parameter value [String] the new value for the `date` header. def << value replace(value) end # Converts the `date` header value to a `Time` object. # # @returns [Time] the parsed time object corresponding to the `date` header value. def to_time ::Time.parse(self) end # Whether this header is acceptable in HTTP trailers. # Date headers can safely appear in trailers as they provide metadata about response generation. # @returns [Boolean] `true`, as date headers are metadata that can be computed after response generation. def self.trailer? true end end end end end protocol-http-0.55.0/lib/protocol/http/header/digest.rb000066400000000000000000000045511507641516600230610ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require_relative "split" require_relative "../quoted_string" require_relative "../error" module Protocol module HTTP module Header # The `digest` header provides a digest of the message body for integrity verification. # # This header allows servers to send cryptographic hashes of the response body, enabling clients to verify data integrity. Multiple digest algorithms can be specified, and the header is particularly useful as a trailer since the digest can only be computed after the entire message body is available. # # ## Examples # # ```ruby # digest = Digest.new("sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=") # digest << "md5=9bb58f26192e4ba00f01e2e7b136bbd8" # puts digest.to_s # # => "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=, md5=9bb58f26192e4ba00f01e2e7b136bbd8" # ``` class Digest < Split ParseError = Class.new(Error) # https://tools.ietf.org/html/rfc3230#section-4.3.2 ENTRY = /\A(?[a-zA-Z0-9][a-zA-Z0-9\-]*)\s*=\s*(?.*)\z/ # A single digest entry in the Digest header. Entry = Struct.new(:algorithm, :value) do # Create a new digest entry. # # @parameter algorithm [String] the digest algorithm (e.g., "sha-256", "md5"). # @parameter value [String] the base64-encoded or hex-encoded digest value. def initialize(algorithm, value) super(algorithm.downcase, value) end # Convert the entry to its string representation. # # @returns [String] the formatted digest string. def to_s "#{algorithm}=#{value}" end end # Parse the `digest` header value into a list of digest entries. # # @returns [Array(Entry)] the list of digest entries with their algorithms and values. def entries self.map do |value| if match = value.match(ENTRY) Entry.new(match[:algorithm], match[:value]) else raise ParseError.new("Could not parse digest value: #{value.inspect}") end end end # Whether this header is acceptable in HTTP trailers. # @returns [Boolean] `true`, as digest headers contain integrity hashes that can only be calculated after the entire message body is available. def self.trailer? true end end end end end protocol-http-0.55.0/lib/protocol/http/header/etag.rb000066400000000000000000000023661507641516600225240ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2020-2025, by Samuel Williams. module Protocol module HTTP module Header # The `etag` header represents the entity tag for a resource. # # The `etag` header provides a unique identifier for a specific version of a resource, typically used for cache validation or conditional requests. It can be either a strong or weak validator as defined in RFC 9110. class ETag < String # Replaces the current value of the `etag` header with the specified value. # # @parameter value [String] the new value for the `etag` header. def << value replace(value) end # Checks whether the `etag` is a weak validator. # # Weak validators indicate semantically equivalent content but may not be byte-for-byte identical. # # @returns [Boolean] whether the `etag` is weak. def weak? self.start_with?("W/") end # Whether this header is acceptable in HTTP trailers. # ETag headers can safely appear in trailers as they provide cache validation metadata. # @returns [Boolean] `true`, as ETag headers are metadata that can be computed after response generation. def self.trailer? true end end end end end protocol-http-0.55.0/lib/protocol/http/header/etags.rb000066400000000000000000000054151507641516600227050ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2020-2025, by Samuel Williams. # Copyright, 2023, by Thomas Morgan. require_relative "split" module Protocol module HTTP module Header # The `etags` header represents a list of entity tags (ETags) for resources. # # The `etags` header is used for conditional requests to compare the current version of a resource with previously stored versions. It supports both strong and weak validators, as well as the wildcard character (`*`) to indicate a match for any resource. class ETags < Split # Checks if the `etags` header contains the wildcard (`*`) character. # # The wildcard character matches any resource version, regardless of its actual value. # # @returns [Boolean] whether the wildcard is present. def wildcard? self.include?("*") end # Checks if the specified ETag matches the `etags` header. # # This method returns `true` if the wildcard is present or if the exact ETag is found in the list. Note that this implementation is not strictly compliant with the RFC-specified format. # # @parameter etag [String] the ETag to compare against the `etags` header. # @returns [Boolean] whether the specified ETag matches. def match?(etag) wildcard? || self.include?(etag) end # Checks for a strong match with the specified ETag, useful with the `if-match` header. # # A strong match requires that the ETag in the header list matches the specified ETag and that neither is a weak validator. # # @parameter etag [String] the ETag to compare against the `etags` header. # @returns [Boolean] whether a strong match is found. def strong_match?(etag) wildcard? || (!weak_tag?(etag) && self.include?(etag)) end # Checks for a weak match with the specified ETag, useful with the `if-none-match` header. # # A weak match allows for semantically equivalent content, including weak validators and their strong counterparts. # # @parameter etag [String] the ETag to compare against the `etags` header. # @returns [Boolean] whether a weak match is found. def weak_match?(etag) wildcard? || self.include?(etag) || self.include?(opposite_tag(etag)) end private # Converts a weak tag to its strong counterpart or vice versa. # # @parameter etag [String] the ETag to convert. # @returns [String] the opposite form of the provided ETag. def opposite_tag(etag) weak_tag?(etag) ? etag[2..-1] : "W/#{etag}" end # Checks if the given ETag is a weak validator. # # @parameter tag [String] the ETag to check. # @returns [Boolean] whether the tag is weak. def weak_tag?(tag) tag&.start_with? "W/" end end end end end protocol-http-0.55.0/lib/protocol/http/header/multiple.rb000066400000000000000000000025711507641516600234350ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. module Protocol module HTTP module Header # Represents headers that can contain multiple distinct values separated by newline characters. # # This isn't a specific header but is used as a base for headers that store multiple values, such as cookies. The values are split and stored as an array internally, and serialized back to a newline-separated string when needed. class Multiple < Array # Initializes the multiple header with the given value. As the header key-value pair can only contain one value, the value given here is added to the internal array, and subsequent values can be added using the `<<` operator. # # @parameter value [String] the raw header value. def initialize(value) super() self << value end # Serializes the stored values into a newline-separated string. # # @returns [String] the serialized representation of the header values. def to_s join("\n") end # Whether this header is acceptable in HTTP trailers. # This is a base class for headers with multiple values, default is to disallow in trailers. # @returns [Boolean] `false`, as most multiple-value headers should not appear in trailers by default. def self.trailer? false end end end end end protocol-http-0.55.0/lib/protocol/http/header/priority.rb000066400000000000000000000040731507641516600234620ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. require_relative "split" module Protocol module HTTP module Header # Represents the `priority` header, used to indicate the relative importance of an HTTP request. # # The `priority` header allows clients to express their preference for how resources should be prioritized by the server. It supports directives like `u=` to specify the urgency level of a request, and `i` to indicate whether a response can be delivered incrementally. The urgency levels range from 0 (highest priority) to 7 (lowest priority), while the `i` directive is a boolean flag. class Priority < Split # Initialize the priority header with the given value. # # @parameter value [String | Nil] the value of the priority header, if any. The value should be a comma-separated string of directives. def initialize(value = nil) super(value&.downcase) end # Add a value to the priority header. # # @parameter value [String] the directive to add to the header. def << value super(value.downcase) end # The default urgency level if not specified. DEFAULT_URGENCY = 3 # The urgency level, if specified using `u=`. 0 is the highest priority, and 7 is the lowest. # # Note that when duplicate Dictionary keys are encountered, all but the last instance are ignored. # # @returns [Integer | Nil] the urgency level if specified, or `nil` if not present. def urgency(default = DEFAULT_URGENCY) if value = self.reverse_find{|value| value.start_with?("u=")} _, level = value.split("=", 2) return Integer(level) end return default end # Checks if the response should be delivered incrementally. # # The `i` directive, when present, indicates that the response can be delivered incrementally as data becomes available. # # @returns [Boolean] whether the request should be delivered incrementally. def incremental? self.include?("i") end end end end end protocol-http-0.55.0/lib/protocol/http/header/server_timing.rb000066400000000000000000000057121507641516600244570ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require_relative "split" require_relative "../quoted_string" require_relative "../error" module Protocol module HTTP module Header # The `server-timing` header communicates performance metrics about the request-response cycle to the client. # # This header allows servers to send timing information about various server-side operations, which can be useful for performance monitoring and debugging. Each metric can include a name, optional duration, and optional description. # # ## Examples # # ```ruby # server_timing = ServerTiming.new("db;dur=53.2") # server_timing << "cache;dur=12.1;desc=\"Redis lookup\"" # puts server_timing.to_s # # => "db;dur=53.2, cache;dur=12.1;desc=\"Redis lookup\"" # ``` class ServerTiming < Split ParseError = Class.new(Error) # https://www.w3.org/TR/server-timing/ METRIC = /\A(?[a-zA-Z0-9][a-zA-Z0-9_\-]*)(;(?.*))?\z/ PARAMETER = /(?dur|desc)=((?#{TOKEN})|(?#{QUOTED_STRING}))/ # A single metric in the Server-Timing header. Metric = Struct.new(:name, :duration, :description) do # Create a new server timing metric. # # @parameter name [String] the name of the metric. # @parameter duration [Float | Nil] the duration in milliseconds. # @parameter description [String | Nil] the description of the metric. def initialize(name, duration = nil, description = nil) super(name, duration, description) end # Convert the metric to its string representation. # # @returns [String] the formatted metric string. def to_s result = name.dup result << ";dur=#{duration}" if duration result << ";desc=\"#{description}\"" if description result end end # Parse the `server-timing` header value into a list of metrics. # # @returns [Array(Metric)] the list of metrics with their names, durations, and descriptions. def metrics self.map do |value| if match = value.match(METRIC) name = match[:name] parameters = match[:parameters] || "" duration = nil description = nil parameters.scan(PARAMETER) do |key, value, quoted_value| value = QuotedString.unquote(quoted_value) if quoted_value case key when "dur" duration = value.to_f when "desc" description = value end end Metric.new(name, duration, description) else raise ParseError.new("Could not parse server timing metric: #{value.inspect}") end end end # Whether this header is acceptable in HTTP trailers. # @returns [Boolean] `true`, as server-timing headers contain performance metrics that are typically calculated during response generation. def self.trailer? true end end end end end protocol-http-0.55.0/lib/protocol/http/header/split.rb000066400000000000000000000040111507641516600227240ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. module Protocol module HTTP module Header # Represents headers that can contain multiple distinct values separated by commas. # # This isn't a specific header class is a utility for handling headers with comma-separated values, such as `accept`, `cache-control`, and other similar headers. The values are split and stored as an array internally, and serialized back to a comma-separated string when needed. class Split < Array # Regular expression used to split values on commas, with optional surrounding whitespace. COMMA = /\s*,\s*/ # Initializes a `Split` header with the given value. If the value is provided, it is split into distinct entries and stored as an array. # # @parameter value [String | Nil] the raw header value containing multiple entries separated by commas, or `nil` for an empty header. def initialize(value = nil) if value super(value.split(COMMA)) else super() end end # Adds one or more comma-separated values to the header. # # The input string is split into distinct entries and appended to the array. # # @parameter value [String] the value or values to add, separated by commas. def << value self.concat(value.split(COMMA)) end # Serializes the stored values into a comma-separated string. # # @returns [String] the serialized representation of the header values. def to_s join(",") end # Whether this header is acceptable in HTTP trailers. # This is a base class for comma-separated headers, default is to disallow in trailers. # @returns [Boolean] `false`, as most comma-separated headers should not appear in trailers by default. def self.trailer? false end protected def reverse_find(&block) reverse_each do |value| return value if block.call(value) end return nil end end end end end protocol-http-0.55.0/lib/protocol/http/header/te.rb000066400000000000000000000077421507641516600222170ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require_relative "split" require_relative "../quoted_string" require_relative "../error" module Protocol module HTTP module Header # The `te` header indicates the transfer encodings the client is willing to accept. AKA `accept-transfer-encoding`. How we ended up with `te` instead of `accept-transfer-encoding` is a mystery lost to time. # # The `te` header allows a client to indicate which transfer encodings it can handle, and in what order of preference using quality factors. class TE < Split ParseError = Class.new(Error) # Transfer encoding token pattern TOKEN = /[!#$%&'*+\-.0-9A-Z^_`a-z|~]+/ # Quality value pattern (0.0 to 1.0) QVALUE = /0(\.[0-9]{0,3})?|1(\.[0]{0,3})?/ # Pattern for parsing transfer encoding with optional quality factor TRANSFER_CODING = /\A(?#{TOKEN})(\s*;\s*q=(?#{QVALUE}))?\z/ # The `chunked` transfer encoding CHUNKED = "chunked" # The `gzip` transfer encoding GZIP = "gzip" # The `deflate` transfer encoding DEFLATE = "deflate" # The `compress` transfer encoding COMPRESS = "compress" # The `identity` transfer encoding IDENTITY = "identity" # The `trailers` pseudo-encoding indicates willingness to accept trailer fields TRAILERS = "trailers" # A single transfer coding entry with optional quality factor TransferCoding = Struct.new(:name, :q) do def quality_factor (q || 1.0).to_f end def <=> other other.quality_factor <=> self.quality_factor end def to_s if q && q != 1.0 "#{name};q=#{q}" else name.to_s end end end # Initializes the TE header with the given value. The value is split into distinct entries and converted to lowercase for normalization. # # @parameter value [String | Nil] the raw header value containing transfer encodings separated by commas. def initialize(value = nil) super(value&.downcase) end # Adds one or more comma-separated values to the TE header. The values are converted to lowercase for normalization. # # @parameter value [String] the value or values to add, separated by commas. def << value super(value.downcase) end # Parse the `te` header value into a list of transfer codings with quality factors. # # @returns [Array(TransferCoding)] the list of transfer codings and their associated quality factors. def transfer_codings self.map do |value| if match = value.match(TRANSFER_CODING) TransferCoding.new(match[:name], match[:q]) else raise ParseError.new("Could not parse transfer coding: #{value.inspect}") end end end # @returns [Boolean] whether the `chunked` encoding is accepted. def chunked? self.any? {|value| value.start_with?(CHUNKED)} end # @returns [Boolean] whether the `gzip` encoding is accepted. def gzip? self.any? {|value| value.start_with?(GZIP)} end # @returns [Boolean] whether the `deflate` encoding is accepted. def deflate? self.any? {|value| value.start_with?(DEFLATE)} end # @returns [Boolean] whether the `compress` encoding is accepted. def compress? self.any? {|value| value.start_with?(COMPRESS)} end # @returns [Boolean] whether the `identity` encoding is accepted. def identity? self.any? {|value| value.start_with?(IDENTITY)} end # @returns [Boolean] whether trailers are accepted. def trailers? self.any? {|value| value.start_with?(TRAILERS)} end # Whether this header is acceptable in HTTP trailers. # TE headers negotiate transfer encodings and must not appear in trailers. # @returns [Boolean] `false`, as TE headers are hop-by-hop and control message framing. def self.trailer? false end end end end end protocol-http-0.55.0/lib/protocol/http/header/trailer.rb000066400000000000000000000014621507641516600232420ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require_relative "split" module Protocol module HTTP module Header # Represents headers that can contain multiple distinct values separated by commas. # # This isn't a specific header class is a utility for handling headers with comma-separated values, such as `accept`, `cache-control`, and other similar headers. The values are split and stored as an array internally, and serialized back to a comma-separated string when needed. class Trailer < Split # Whether this header is acceptable in HTTP trailers. # @returns [Boolean] `false`, as Trailer headers control trailer processing and must appear before the message body. def self.trailer? false end end end end end protocol-http-0.55.0/lib/protocol/http/header/transfer_encoding.rb000066400000000000000000000051711507641516600252730ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require_relative "split" module Protocol module HTTP module Header # The `transfer-encoding` header indicates the encoding transformations that have been applied to the message body. # # The `transfer-encoding` header is used to specify the form of encoding used to safely transfer the message body between the sender and receiver. class TransferEncoding < Split # The `chunked` transfer encoding allows a server to send data of unknown length by breaking it into chunks. CHUNKED = "chunked" # The `gzip` transfer encoding compresses the message body using the gzip algorithm. GZIP = "gzip" # The `deflate` transfer encoding compresses the message body using the deflate algorithm. DEFLATE = "deflate" # The `compress` transfer encoding compresses the message body using the compress algorithm. COMPRESS = "compress" # The `identity` transfer encoding indicates no transformation has been applied. IDENTITY = "identity" # Initializes the transfer encoding header with the given value. The value is split into distinct entries and converted to lowercase for normalization. # # @parameter value [String | Nil] the raw header value containing transfer encodings separated by commas. def initialize(value = nil) super(value&.downcase) end # Adds one or more comma-separated values to the transfer encoding header. The values are converted to lowercase for normalization. # # @parameter value [String] the value or values to add, separated by commas. def << value super(value.downcase) end # @returns [Boolean] whether the `chunked` encoding is present. def chunked? self.include?(CHUNKED) end # @returns [Boolean] whether the `gzip` encoding is present. def gzip? self.include?(GZIP) end # @returns [Boolean] whether the `deflate` encoding is present. def deflate? self.include?(DEFLATE) end # @returns [Boolean] whether the `compress` encoding is present. def compress? self.include?(COMPRESS) end # @returns [Boolean] whether the `identity` encoding is present. def identity? self.include?(IDENTITY) end # Whether this header is acceptable in HTTP trailers. # Transfer-Encoding headers control message framing and must not appear in trailers. # @returns [Boolean] `false`, as Transfer-Encoding headers are hop-by-hop and must precede the message body. def self.trailer? false end end end end end protocol-http-0.55.0/lib/protocol/http/header/vary.rb000066400000000000000000000021571507641516600225630ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2020-2025, by Samuel Williams. require_relative "split" module Protocol module HTTP module Header # Represents the `vary` header, which specifies the request headers a server considers when determining the response. # # The `vary` header is used in HTTP responses to indicate which request headers affect the selected response. It allows caches to differentiate stored responses based on specific request headers. class Vary < Split # Initializes a `Vary` header with the given value. The value is split into distinct entries and converted to lowercase for normalization. # # @parameter value [String] the raw header value containing request header names separated by commas. def initialize(value) super(value.downcase) end # Adds one or more comma-separated values to the `vary` header. The values are converted to lowercase for normalization. # # @parameter value [String] the value or values to add, separated by commas. def << value super(value.downcase) end end end end end protocol-http-0.55.0/lib/protocol/http/headers.rb000066400000000000000000000307741507641516600217730ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. require_relative "error" require_relative "header/split" require_relative "header/multiple" require_relative "header/cookie" require_relative "header/connection" require_relative "header/cache_control" require_relative "header/etag" require_relative "header/etags" require_relative "header/vary" require_relative "header/authorization" require_relative "header/date" require_relative "header/priority" require_relative "header/trailer" require_relative "header/server_timing" require_relative "header/digest" require_relative "header/accept" require_relative "header/accept_charset" require_relative "header/accept_encoding" require_relative "header/accept_language" require_relative "header/transfer_encoding" require_relative "header/te" module Protocol module HTTP # @namespace module Header end # Headers are an array of key-value pairs. Some header keys represent multiple values. class Headers Split = Header::Split Multiple = Header::Multiple TRAILER = "trailer" # Construct an instance from a headers Array or Hash. No-op if already an instance of `Headers`. If the underlying array is frozen, it will be duped. # # @return [Headers] an instance of headers. def self.[] headers if headers.nil? return self.new end if headers.is_a?(self) if headers.frozen? return headers.dup else return headers end end fields = headers.to_a if fields.frozen? fields = fields.dup end return self.new(fields) end # Initialize the headers with the specified fields. # # @parameter fields [Array] An array of `[key, value]` pairs. # @parameter tail [Integer | Nil] The index of the trailer start in the @fields array. def initialize(fields = [], tail = nil, indexed: nil, policy: POLICY) @fields = fields # Marks where trailer start in the @fields array: @tail = tail # The cached index of headers: @indexed = nil @policy = policy end # @attribute [Hash] The policy for the headers. attr :policy # Set the policy for the headers. # # The policy is used to determine how headers are merged and normalized. For example, if a header is specified multiple times, the policy will determine how the values are merged. # # @parameter policy [Hash] The policy for the headers. def policy=(policy) @policy = policy @indexed = nil end # Initialize a copy of the headers. # # @parameter other [Headers] The headers to copy. def initialize_dup(other) super @fields = @fields.dup @indexed = @indexed.dup end # Clear all headers. def clear @fields.clear @tail = nil @indexed = nil end # Flatten trailer into the headers, in-place. def flatten! if @tail self.delete(TRAILER) @tail = nil end return self end # Flatten trailer into the headers, returning a new instance of {Headers}. def flatten self.dup.flatten! end # @attribute [Array] An array of `[key, value]` pairs. attr :fields # @attribute [Integer | Nil] The index where trailers begin. attr :tail # @returns [Array] The fields of the headers. def to_a @fields end # @returns [Boolean] Whether there are any trailers. def trailer? @tail != nil end # Record the current headers, and prepare to add trailers. # # This method is typically used after headers are sent to capture any additional headers which should then be sent as trailers. # # A sender that intends to generate one or more trailer fields in a message should generate a trailer header field in the header section of that message to indicate which fields might be present in the trailers. # # @parameter names [Array] The trailer header names which will be added later. # @yields {|name, value| ...} the trailing headers if a block is given. # @returns An enumerator which is suitable for iterating over trailers. def trailer!(&block) @tail ||= @fields.size return trailer(&block) end # Enumerate all headers in the trailer, if there are any. def trailer(&block) return to_enum(:trailer) unless block_given? if @tail @fields.drop(@tail).each(&block) end end # Freeze the headers, and ensure the indexed hash is generated. def freeze return if frozen? # Ensure @indexed is generated: self.to_h @fields.freeze @indexed.freeze super end # @returns [Boolean] Whether the headers are empty. def empty? @fields.empty? end # Enumerate all header keys and values. # # @yields {|key, value| ...} # @parameter key [String] The header key. # @parameter value [String] The header value. def each(&block) @fields.each(&block) end # @returns [Boolean] Whether the headers include the specified key. def include? key self[key] != nil end alias key? include? # @returns [Array] All the keys of the headers. def keys self.to_h.keys end # Extract the specified keys from the headers. # # @parameter keys [Array] The keys to extract. def extract(keys) deleted, @fields = @fields.partition do |field| keys.include?(field.first.downcase) end if @indexed keys.each do |key| @indexed.delete(key) end end return deleted end # Add the specified header key value pair. # # @parameter key [String] the header key. # @parameter value [String] the header value to assign. def add(key, value) # The value MUST be a string, so we convert it to a string to prevent errors later on. value = value.to_s if @indexed merge_into(@indexed, key.downcase, value) end @fields << [key, value] end alias []= add # Set the specified header key to the specified value, replacing any existing header keys with the same name. # # @parameter key [String] the header key to replace. # @parameter value [String] the header value to assign. def set(key, value) # TODO This could be a bit more efficient: self.delete(key) self.add(key, value) end # Merge the headers into this instance. def merge!(headers) headers.each do |key, value| self.add(key, value) end return self end # Merge the headers into a new instance of {Headers}. def merge(headers) self.dup.merge!(headers) end # The policy for various headers, including how they are merged and normalized. POLICY = { # Headers which may only be specified once: "content-disposition" => false, "content-length" => false, "content-type" => false, "expect" => false, "from" => false, "host" => false, "location" => false, "max-forwards" => false, "range" => false, "referer" => false, "retry-after" => false, "server" => false, "transfer-encoding" => Header::TransferEncoding, "user-agent" => false, "trailer" => Header::Trailer, # Custom headers: "connection" => Header::Connection, "cache-control" => Header::CacheControl, "te" => Header::TE, "vary" => Header::Vary, "priority" => Header::Priority, # Headers specifically for proxies: "via" => Split, "x-forwarded-for" => Split, # Authorization headers: "authorization" => Header::Authorization, "proxy-authorization" => Header::Authorization, # Cache validations: "etag" => Header::ETag, "if-match" => Header::ETags, "if-none-match" => Header::ETags, "if-range" => false, # Headers which may be specified multiple times, but which can't be concatenated: "www-authenticate" => Multiple, "proxy-authenticate" => Multiple, # Custom headers: "set-cookie" => Header::SetCookie, "cookie" => Header::Cookie, # Date headers: # These headers include a comma as part of the formatting so they can't be concatenated. "date" => Header::Date, "expires" => Header::Date, "last-modified" => Header::Date, "if-modified-since" => Header::Date, "if-unmodified-since" => Header::Date, # Accept headers: "accept" => Header::Accept, "accept-charset" => Header::AcceptCharset, "accept-encoding" => Header::AcceptEncoding, "accept-language" => Header::AcceptLanguage, # Performance headers: "server-timing" => Header::ServerTiming, # Content integrity headers: "digest" => Header::Digest, }.tap{|hash| hash.default = Split} # Delete all header values for the given key, and return the merged value. # # @parameter key [String] The header key. # @returns [String | Array | Object] The merged header value. def delete(key) deleted, @fields = @fields.partition do |field| field.first.downcase == key end if deleted.empty? return nil end if @indexed return @indexed.delete(key) elsif policy = @policy[key] (key, value), *tail = deleted merged = policy.new(value) tail.each{|k,v| merged << v} return merged else key, value = deleted.last return value end end # Merge the value into the hash according to the policy for the given key. # # @parameter hash [Hash] The hash to merge into. # @parameter key [String] The header key. # @parameter value [String] The raw header value. protected def merge_into(hash, key, value, trailer = @tail) if policy = @policy[key] # Check if we're adding to trailers and this header is allowed: if trailer && !policy.trailer? return false end if current_value = hash[key] current_value << value else hash[key] = policy.new(value) end else # By default, headers are not allowed in trailers: if trailer return false end if hash.key?(key) raise DuplicateHeaderError, key end hash[key] = value end end # Get the value of the specified header key. # # @parameter key [String] The header key. # @returns [String | Array | Object] The header value. def [] key to_h[key] end # Compute a hash table of headers, where the keys are normalized to lower case and the values are normalized according to the policy for that header. # # @returns [Hash] A hash table of `{key, value}` pairs. def to_h unless @indexed @indexed = {} @fields.each_with_index do |(key, value), index| trailer = (@tail && index >= @tail) merge_into(@indexed, key.downcase, value, trailer) end end return @indexed end alias as_json to_h # Inspect the headers. # # @returns [String] A string representation of the headers. def inspect "#<#{self.class} #{@fields.inspect}>" end # Compare this object to another object. May depend on the order of the fields. # # @returns [Boolean] Whether the other object is equal to this one. def == other case other when Hash to_h == other when Headers @fields == other.fields else @fields == other end end # Used for merging objects into a sequential list of headers. Normalizes header keys and values. class Merged include Enumerable # Construct a merged list of headers. # # @parameter *all [Array] An array of all headers to merge. def initialize(*all) @all = all end # @returns [Array] A list of all headers, in the order they were added, as `[key, value]` pairs. def fields each.to_a end # @returns [Headers] A new instance of {Headers} containing all the merged headers. def flatten Headers.new(fields) end # Clear the references to all headers. def clear @all.clear end # Add a new set of headers to the merged list. # # @parameter headers [Headers | Array | Hash] A list of headers to add. def << headers @all << headers return self end # Enumerate all headers in the merged list. # # @yields {|key, value| ...} The header key and value. # @parameter key [String] The header key (lower case). # @parameter value [String] The header value. def each(&block) return to_enum unless block_given? @all.each do |headers| headers.each do |key, value| yield key.to_s.downcase, value.to_s end end end end end end end protocol-http-0.55.0/lib/protocol/http/methods.rb000066400000000000000000000066051507641516600220170ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. module Protocol module HTTP # Provides a convenient interface for commonly supported HTTP methods. # # | Method Name | Request Body | Response Body | Safe | Idempotent | Cacheable | # | ----------- | ------------ | ------------- | ---- | ---------- | --------- | # | GET | Optional | Yes | Yes | Yes | Yes | # | HEAD | Optional | No | Yes | Yes | Yes | # | POST | Yes | Yes | No | No | Yes | # | PUT | Yes | Yes | No | Yes | No | # | DELETE | Optional | Yes | No | Yes | No | # | CONNECT | Optional | Yes | No | No | No | # | OPTIONS | Optional | Yes | Yes | Yes | No | # | TRACE | No | Yes | Yes | Yes | No | # | PATCH | Yes | Yes | No | No | No | # # These methods are defined in this module using lower case names. They are for convenience only and you should not overload those methods. # # See for more details. class Methods # The GET method requests a representation of the specified resource. Requests using GET should only retrieve data. GET = "GET" # The HEAD method asks for a response identical to a GET request, but without the response body. HEAD = "HEAD" # The POST method submits an entity to the specified resource, often causing a change in state or side effects on the server. POST = "POST" # The PUT method replaces all current representations of the target resource with the request payload. PUT = "PUT" # The DELETE method deletes the specified resource. DELETE = "DELETE" # The CONNECT method establishes a tunnel to the server identified by the target resource. CONNECT = "CONNECT" # The OPTIONS method describes the communication options for the target resource. OPTIONS = "OPTIONS" # The TRACE method performs a message loop-back test along the path to the target resource. TRACE = "TRACE" # The PATCH method applies partial modifications to a resource. PATCH = "PATCH" # Check if the given name is a valid HTTP method, according to this module. # # Note that this method only knows about the methods defined in this module, however there are many other methods defined in different specifications. # # @returns [Boolean] True if the name is a valid HTTP method. def self.valid?(name) const_defined?(name) rescue NameError # Ruby will raise an exception if the name is not valid for a constant. return false end # Enumerate all HTTP methods. # @yields {|name, value| ...} # @parameter name [Symbol] The name of the method, e.g. `:GET`. # @parameter value [String] The value of the method, e.g. `"GET"`. def self.each return to_enum(:each) unless block_given? constants.each do |name| yield name.downcase, const_get(name) end end self.each do |name, method| define_method(name) do |*arguments, **options| self.call( Request[method, *arguments, **options] ) end end end end end protocol-http-0.55.0/lib/protocol/http/middleware.rb000066400000000000000000000061401507641516600224630ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require_relative "methods" require_relative "headers" require_relative "request" require_relative "response" module Protocol module HTTP # The middleware interface provides a convenient wrapper for implementing HTTP middleware. # # A middleware instance generally needs to respond to two methods: # # - `call(request)` -> `response` # - `close()` # # The call method is called for each request. The close method is called when the server is shutting down. # # You do not need to use the Middleware class to implement middleware. You can implement the interface directly. class Middleware < Methods # Convert a block to a middleware delegate. # # @parameter block [Proc] The block to convert to a middleware delegate. # @returns [Middleware] The middleware delegate. def self.for(&block) # Add a close method to the block. def block.close end return self.new(block) end # Initialize the middleware with the given delegate. # # @parameter delegate [Object] The delegate object. A delegate is used for passing along requests that are not handled by *this* middleware. def initialize(delegate) @delegate = delegate end # @attribute [Object] The delegate object that is used for passing along requests that are not handled by *this* middleware. attr :delegate # Close the middleware. Invokes the close method on the delegate. def close @delegate.close end # Call the middleware with the given request. Invokes the call method on the delegate. def call(request) @delegate.call(request) end # A simple middleware that always returns a 200 response. module Okay # Close the middleware - idempotent no-op. def self.close end # Call the middleware with the given request, always returning a 200 response. # # @parameter request [Request] The request object. # @returns [Response] The response object, which always contains a 200 status code. def self.call(request) Response[200] end end # A simple middleware that always returns a 404 response. module NotFound # Close the middleware - idempotent no-op. def self.close end # Call the middleware with the given request, always returning a 404 response. This middleware is useful as a default. # # @parameter request [Request] The request object. # @returns [Response] The response object, which always contains a 404 status code. def self.call(request) Response[404] end end # A simple middleware that always returns "Hello World!". module HelloWorld # Close the middleware - idempotent no-op. def self.close end # Call the middleware with the given request. # # @parameter request [Request] The request object. # @returns [Response] The response object, whihc always contains "Hello World!". def self.call(request) Response[200, Headers["content-type" => "text/plain"], ["Hello World!"]] end end end end end protocol-http-0.55.0/lib/protocol/http/middleware/000077500000000000000000000000001507641516600221355ustar00rootroot00000000000000protocol-http-0.55.0/lib/protocol/http/middleware/builder.rb000066400000000000000000000034341507641516600241140ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2024, by Samuel Williams. require_relative "../middleware" module Protocol module HTTP class Middleware # A convenient interface for constructing middleware stacks. class Builder # Initialize the builder with the given default application. # # @parameter default_app [Object] The default application to use if no middleware is specified. def initialize(default_app = NotFound) @use = [] @app = default_app end # Use the given middleware with the given arguments and options. # # @parameter middleware [Class | Object] The middleware class to use. # @parameter arguments [Array] The arguments to pass to the middleware constructor. # @parameter options [Hash] The options to pass to the middleware constructor. # @parameter block [Proc] The block to pass to the middleware constructor. def use(middleware, *arguments, **options, &block) @use << proc {|app| middleware.new(app, *arguments, **options, &block)} end # Specify the (default) middleware application to use. # # @parameter app [Middleware] The application to use if no middleware is able to handle the request. def run(app) @app = app end # Convert the builder to an application by chaining the middleware together. # # @returns [Middleware] The application. def to_app @use.reverse.inject(@app) {|app, use| use.call(app)} end end # Build a middleware application using the given block. def self.build(&block) builder = Builder.new if block_given? if block.arity == 0 builder.instance_exec(&block) else yield builder end end return builder.to_app end end end end protocol-http-0.55.0/lib/protocol/http/peer.rb000066400000000000000000000017271507641516600213070ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2025, by Samuel Williams. module Protocol module HTTP # Provide a well defined, cached representation of a peer (address). class Peer # Create a new peer object for the given IO object, using the remote address if available. # # @returns [Peer | Nil] The peer object, or nil if the remote address is not available. def self.for(io) if address = io.remote_address return new(address) end end # Initialize the peer with the given address. # # @parameter address [Addrinfo] The remote address of the peer. def initialize(address) @address = address if address.ip? @ip_address = @address.ip_address end end # @attribute [Addrinfo] The remote address of the peer. attr :address # @attribute [String] The IP address of the peer, if available. attr :ip_address alias remote_address address end end end protocol-http-0.55.0/lib/protocol/http/quoted_string.rb000066400000000000000000000024431507641516600232370ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. module Protocol module HTTP # According to https://tools.ietf.org/html/rfc7231#appendix-C TOKEN = /[!#$%&'*+\-.^_`|~0-9A-Z]+/i QUOTED_STRING = /"(?:.(?!(?. It should already match the QUOTED_STRING pattern above by the parser. def self.unquote(value, normalize_whitespace = true) value = value[1...-1] value.gsub!(/\\(.)/, '\1') if normalize_whitespace # LWS = [CRLF] 1*( SP | HT ) value.gsub!(/[\r\n]+\s+/, " ") end return value end QUOTES_REQUIRED = /[()<>@,;:\\"\/\[\]?={} \t]/ # Quote a string for HTTP header values if required. # # @raises [ArgumentError] if the value contains invalid characters like control characters or newlines. def self.quote(value, force = false) # Check if quoting is required: if value =~ QUOTES_REQUIRED or force "\"#{value.gsub(/["\\]/, '\\\\\0')}\"" else value end end end end endprotocol-http-0.55.0/lib/protocol/http/request.rb000066400000000000000000000154771507641516600220530ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require_relative "body/buffered" require_relative "body/reader" require_relative "headers" require_relative "methods" module Protocol module HTTP # Represents an HTTP request which can be used both server and client-side. # # ~~~ ruby # require 'protocol/http' # # # Long form: # Protocol::HTTP::Request.new("http", "example.com", "GET", "/index.html", "HTTP/1.1", Protocol::HTTP::Headers[["accept", "text/html"]]) # # # Short form: # Protocol::HTTP::Request["GET", "/index.html", {"accept" => "text/html"}] # ~~~ class Request prepend Body::Reader # Initialize the request. # # @parameter scheme [String | Nil] The request scheme, usually `"http"` or `"https"`. # @parameter authority [String | Nil] The request authority, usually a hostname and port number, e.g. `"example.com:80"`. # @parameter method [String | Nil] The request method, usually one of `"GET"`, `"HEAD"`, `"POST"`, `"PUT"`, `"DELETE"`, `"CONNECT"` or `"OPTIONS"`, etc. # @parameter path [String | Nil] The request path, usually a path and query string, e.g. `"/index.html"`, `"/search?q=hello"`, etc. # @parameter version [String | Nil] The request version, usually `"http/1.0"`, `"http/1.1"`, `"h2"`, or `"h3"`. # @parameter headers [Headers] The request headers, usually containing metadata associated with the request such as the `"user-agent"`, `"accept"` (content type), `"accept-language"`, etc. # @parameter body [Body::Readable] The request body. # @parameter protocol [String | Array(String) | Nil] The request protocol, usually empty, but occasionally `"websocket"` or `"webtransport"`. # @parameter interim_response [Proc] A callback which is called when an interim response is received. def initialize(scheme = nil, authority = nil, method = nil, path = nil, version = nil, headers = Headers.new, body = nil, protocol = nil, interim_response = nil) @scheme = scheme @authority = authority @method = method @path = path @version = version @headers = headers @body = body @protocol = protocol @interim_response = interim_response end # @attribute [String] the request scheme, usually `"http"` or `"https"`. attr_accessor :scheme # @attribute [String] the request authority, usually a hostname and port number, e.g. `"example.com:80"`. attr_accessor :authority # @attribute [String] the request method, usually one of `"GET"`, `"HEAD"`, `"POST"`, `"PUT"`, `"DELETE"`, `"CONNECT"` or `"OPTIONS"`, etc. attr_accessor :method # @attribute [String] the request path, usually a path and query string, e.g. `"/index.html"`, `"/search?q=hello"`, however it can be any [valid request target](https://www.rfc-editor.org/rfc/rfc9110#target.resource). attr_accessor :path # @attribute [String] the request version, usually `"http/1.0"`, `"http/1.1"`, `"h2"`, or `"h3"`. attr_accessor :version # @attribute [Headers] the request headers, usually containing metadata associated with the request such as the `"user-agent"`, `"accept"` (content type), `"accept-language"`, etc. attr_accessor :headers # @attribute [Body::Readable] the request body. It should only be read once (it may not be idempotent). attr_accessor :body # @attribute [String | Array(String) | Nil] the request protocol, usually empty, but occasionally `"websocket"` or `"webtransport"`. In HTTP/1, it is used to request a connection upgrade, and in HTTP/2 it is used to indicate a specfic protocol for the stream. attr_accessor :protocol # @attribute [Proc] a callback which is called when an interim response is received. attr_accessor :interim_response # A request that is generated by a server, may choose to include the peer (address) associated with the request. It should be implemented by a sub-class. # # @returns [Peer | Nil] The peer (address) associated with the request. def peer nil end # Send the request to the given connection. def call(connection) connection.call(self) end # Send an interim response back to the origin of this request, if possible. def send_interim_response(status, headers) @interim_response&.call(status, headers) end # Register a callback to be called when an interim response is received. # # @yields {|status, headers| ...} The callback to be called when an interim response is received. # @parameter status [Integer] The HTTP status code, e.g. `100`, `101`, etc. # @parameter headers [Hash] The headers, e.g. `{"link" => "; rel=stylesheet"}`, etc. def on_interim_response(&block) if interim_response = @interim_response @interim_response = ->(status, headers) do block.call(status, headers) interim_response.call(status, headers) end else @interim_response = block end end # Whether this is a HEAD request: no body is expected in the response. def head? @method == Methods::HEAD end # Whether this is a CONNECT request: typically used to establish a tunnel. def connect? @method == Methods::CONNECT end # A short-cut method which exposes the main request variables that you'd typically care about. # # @parameter method [String] The HTTP method, e.g. `"GET"`, `"POST"`, etc. # @parameter path [String] The path, e.g. `"/index.html"`, `"/search?q=hello"`, etc. # @parameter headers [Hash] The headers, e.g. `{"accept" => "text/html"}`, etc. # @parameter body [String | Array(String) | Body::Readable] The body, e.g. `"Hello, World!"`, etc. See {Body::Buffered.wrap} for more information about . def self.[](method, path = nil, _headers = nil, _body = nil, scheme: nil, authority: nil, headers: _headers, body: _body, protocol: nil, interim_response: nil) path = path&.to_s body = Body::Buffered.wrap(body) headers = Headers[headers] self.new(scheme, authority, method, path, nil, headers, body, protocol, interim_response) end # Whether the request can be replayed without side-effects. def idempotent? @method != Methods::POST && (@body.nil? || @body.empty?) end # Convert the request to a hash, suitable for serialization. # # @returns [Hash] The request as a hash. def as_json(...) { scheme: @scheme, authority: @authority, method: @method, path: @path, version: @version, headers: @headers&.as_json, body: @body&.as_json, protocol: @protocol } end # Convert the request to JSON. # # @returns [String] The request as JSON. def to_json(...) as_json.to_json(...) end # Summarize the request as a string. # # @returns [String] The request as a string. def to_s "#{@scheme}://#{@authority}: #{@method} #{@path} #{@version}" end end end end protocol-http-0.55.0/lib/protocol/http/response.rb000066400000000000000000000134711507641516600222110ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require_relative "body/buffered" require_relative "body/reader" require_relative "headers" module Protocol module HTTP # Represents an HTTP response which can be used both server and client-side. # # ~~~ ruby # require 'protocol/http' # # # Long form: # Protocol::HTTP::Response.new("http/1.1", 200, Protocol::HTTP::Headers[["content-type", "text/html"]], Protocol::HTTP::Body::Buffered.wrap("Hello, World!")) # # # Short form: # Protocol::HTTP::Response[200, {"content-type" => "text/html"}, ["Hello, World!"]] # ~~~ class Response prepend Body::Reader # Create a new response. # # @parameter version [String | Nil] The HTTP version, e.g. `"HTTP/1.1"`. If `nil`, the version may be provided by the server sending the response. # @parameter status [Integer] The HTTP status code, e.g. `200`, `404`, etc. # @parameter headers [Hash] The headers, e.g. `{"content-type" => "text/html"}`, etc. # @parameter body [Body::Readable] The body, e.g. `"Hello, World!"`, etc. # @parameter protocol [String | Array(String)] The protocol, e.g. `"websocket"`, etc. def initialize(version = nil, status = 200, headers = Headers.new, body = nil, protocol = nil) @version = version @status = status @headers = headers @body = body @protocol = protocol end # @attribute [String | Nil] The HTTP version, usually one of `"HTTP/1.1"`, `"HTTP/2"`, etc. attr_accessor :version # @attribute [Integer] The HTTP status code, e.g. `200`, `404`, etc. attr_accessor :status # @attribute [Hash] The headers, e.g. `{"content-type" => "text/html"}`, etc. attr_accessor :headers # @attribute [Body::Readable] The body, e.g. `"Hello, World!"`, etc. attr_accessor :body # @attribute [String | Array(String) | Nil] The protocol, e.g. `"websocket"`, etc. attr_accessor :protocol # A response that is generated by a client, may choose to include the peer (address) associated with the response. It should be implemented by a sub-class. # # @returns [Peer | Nil] The peer (address) associated with the response. def peer nil end # Whether the response is considered a hijack: the connection has been taken over by the application and the server should not send any more data. def hijack? false end # Whether the status is 100 (continue). def continue? @status == 100 end # Whether the status is considered informational. def informational? @status and @status >= 100 && @status < 200 end # Whether the status is considered final. Note that 101 is considered final. def final? # 101 is effectively a final status. @status and @status >= 200 || @status == 101 end # Whether the status is 200 (ok). def ok? @status == 200 end # Whether the status is considered successful. def success? @status and @status >= 200 && @status < 300 end # Whether the status is 206 (partial content). def partial? @status == 206 end # Whether the status is considered a redirection. def redirection? @status and @status >= 300 && @status < 400 end # Whether the status is 304 (not modified). def not_modified? @status == 304 end # Whether the status is 307 (temporary redirect) and should preserve the method of the request when following the redirect. def preserve_method? @status == 307 || @status == 308 end # Whether the status is considered a failure. def failure? @status and @status >= 400 && @status < 600 end # Whether the status is 400 (bad request). def bad_request? @status == 400 end # Whether the status is 500 (internal server error). def internal_server_error? @status == 500 end # @deprecated Use {#internal_server_error?} instead. alias server_failure? internal_server_error? # A short-cut method which exposes the main response variables that you'd typically care about. It follows the same order as the `Rack` response tuple, but also includes the protocol. # # ~~~ ruby # Response[200, {"content-type" => "text/html"}, ["Hello, World!"]] # ~~~ # # @parameter status [Integer] The HTTP status code, e.g. `200`, `404`, etc. # @parameter headers [Hash] The headers, e.g. `{"content-type" => "text/html"}`, etc. # @parameter body [String | Array(String) | Body::Readable] The body, e.g. `"Hello, World!"`, etc. See {Body::Buffered.wrap} for more information about . def self.[](status, _headers = nil, _body = nil, headers: _headers, body: _body, protocol: nil) body = Body::Buffered.wrap(body) headers = Headers[headers] self.new(nil, status, headers, body, protocol) end # Create a response for the given exception. # # @parameter exception [Exception] The exception to generate the response for. def self.for_exception(exception) Response[500, Headers["content-type" => "text/plain"], ["#{exception.class}: #{exception.message}"]] end # Convert the response to a hash suitable for serialization. # # @returns [Hash] The response as a hash. def as_json(...) { version: @version, status: @status, headers: @headers&.as_json, body: @body&.as_json, protocol: @protocol } end # Convert the response to JSON. # # @returns [String] The response as JSON. def to_json(...) as_json.to_json(...) end # Summarise the response as a string. # # @returns [String] The response as a string. def to_s "#{@status} #{@version}" end # Implicit conversion to an array. # # @returns [Array] The response as an array, e.g. `[status, headers, body]`. def to_ary return @status, @headers, @body end end end end protocol-http-0.55.0/lib/protocol/http/version.rb000066400000000000000000000002511507641516600220300ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. module Protocol module HTTP VERSION = "0.55.0" end end protocol-http-0.55.0/license.md000066400000000000000000000027221507641516600164010ustar00rootroot00000000000000# MIT License Copyright, 2018-2025, by Samuel Williams. Copyright, 2019, by Yuta Iwama. Copyright, 2020, by Olle Jonsson. Copyright, 2020, by Bryan Powell. Copyright, 2020-2023, by Bruno Sutic. Copyright, 2022, by Herrick Fang. Copyright, 2022, by Dan Olson. Copyright, 2023, by Genki Takiuchi. Copyright, 2023-2024, by Thomas Morgan. Copyright, 2023, by Marcelo Junior. Copyright, 2024, by Earlopain. Copyright, 2025, by William T. Nelson. 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. protocol-http-0.55.0/protocol-http.gemspec000066400000000000000000000016621507641516600206220ustar00rootroot00000000000000# frozen_string_literal: true require_relative "lib/protocol/http/version" Gem::Specification.new do |spec| spec.name = "protocol-http" spec.version = Protocol::HTTP::VERSION spec.summary = "Provides abstractions to handle HTTP protocols." spec.authors = ["Samuel Williams", "Thomas Morgan", "Bruno Sutic", "Herrick Fang", "William T. Nelson", "Bryan Powell", "Dan Olson", "Earlopain", "Genki Takiuchi", "Marcelo Junior", "Olle Jonsson", "Yuta Iwama"] spec.license = "MIT" spec.cert_chain = ["release.cert"] spec.signing_key = File.expand_path("~/.gem/release.pem") spec.homepage = "https://github.com/socketry/protocol-http" spec.metadata = { "documentation_uri" => "https://socketry.github.io/protocol-http/", "source_code_uri" => "https://github.com/socketry/protocol-http.git", } spec.files = Dir.glob(["{context,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) spec.required_ruby_version = ">= 3.2" end protocol-http-0.55.0/readme.md000066400000000000000000000126231507641516600162150ustar00rootroot00000000000000# Protocol::HTTP Provides abstractions for working with the HTTP protocol. [![Development Status](https://github.com/socketry/protocol-http/workflows/Test/badge.svg)](https://github.com/socketry/protocol-http/actions?workflow=Test) ## Features - General abstractions for HTTP requests and responses. - Symmetrical interfaces for client and server. - Light-weight middleware model for building applications. ## Usage Please see the [project documentation](https://socketry.github.io/protocol-http/) for more details. - [Getting Started](https://socketry.github.io/protocol-http/guides/getting-started/index) - This guide explains how to use `protocol-http` for building abstract HTTP interfaces. - [Message Body](https://socketry.github.io/protocol-http/guides/message-body/index) - This guide explains how to work with HTTP request and response message bodies using `Protocol::HTTP::Body` classes. - [Headers](https://socketry.github.io/protocol-http/guides/headers/index) - This guide explains how to work with HTTP headers using `protocol-http`. - [Middleware](https://socketry.github.io/protocol-http/guides/middleware/index) - This guide explains how to build and use HTTP middleware with `Protocol::HTTP::Middleware`. - [Streaming](https://socketry.github.io/protocol-http/guides/streaming/index) - This guide gives an overview of how to implement streaming requests and responses. - [Design Overview](https://socketry.github.io/protocol-http/guides/design-overview/index) - This guide explains the high level design of `protocol-http` in the context of wider design patterns that can be used to implement HTTP clients and servers. ## Releases Please see the [project releases](https://socketry.github.io/protocol-http/releases/index) for all releases. ### v0.55.0 - **Breaking**: Move `Protocol::HTTP::Header::QuotedString` to `Protocol::HTTP::QuotedString` for better reusability. - **Breaking**: Handle cookie key/value pairs using `QuotedString` as per RFC 6265. - Don't use URL encoding for cookie key/value. - **Breaking**: Remove `Protocol::HTTP::URL` and `Protocol::HTTP::Reference` – replaced by `Protocol::URL` gem. - `Protocol::HTTP::URL` -\> `Protocol::URL::Encoding`. - `Protocol::HTTP::Reference` -\> `Protocol::URL::Reference`. ### v0.54.0 - Introduce rich support for `Header::Digest`, `Header::ServerTiming`, `Header::TE`, `Header::Trailer` and `Header::TransferEncoding`. - [Improved HTTP Trailer Security](https://socketry.github.io/protocol-http/releases/index#improved-http-trailer-security) ### v0.53.0 - Improve consistency of Body `#inspect`. - Improve `as_json` support for Body wrappers. ### v0.52.0 - Add `Protocol::HTTP::Headers#to_a` method that returns the fields array, providing compatibility with standard Ruby array conversion pattern. - Expose `tail` in `Headers.new` so that trailers can be accurately reproduced. - Add agent context. ### v0.51.0 - `Protocol::HTTP::Headers` now raise a `DuplicateHeaderError` when a duplicate singleton header (e.g. `content-length`) is added. - `Protocol::HTTP::Headers#add` now coerces the value to a string when adding a header, ensuring consistent behaviour. - `Protocol::HTTP::Body::Head.for` now accepts an optional `length` parameter, allowing it to create a head body even when the body is not provided, based on the known content length. ### v0.50.0 - Drop support for Ruby v3.1. ### v0.48.0 - Add support for parsing `accept`, `accept-charset`, `accept-encoding` and `accept-language` headers into structured values. ### v0.46.0 - Add support for `priority:` header. ### v0.33.0 - Clarify behaviour of streaming bodies and copy `Protocol::Rack::Body::Streaming` to `Protocol::HTTP::Body::Streamable`. - Copy `Async::HTTP::Body::Writable` to `Protocol::HTTP::Body::Writable`. ### v0.31.0 - Ensure chunks are flushed if required, when streaming. ## See Also - [protocol-http1](https://github.com/socketry/protocol-http1) — HTTP/1 client/server implementation using this interface. - [protocol-http2](https://github.com/socketry/protocol-http2) — HTTP/2 client/server implementation using this interface. - [protocol-url](https://github.com/socketry/protocol-url) — URL parsing and manipulation library. - [async-http](https://github.com/socketry/async-http) — Asynchronous HTTP client and server, supporting multiple HTTP protocols & TLS. - [async-websocket](https://github.com/socketry/async-websocket) — Asynchronous client and server WebSockets. ## 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. protocol-http-0.55.0/release.cert000066400000000000000000000033141507641516600167320ustar00rootroot00000000000000-----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----- protocol-http-0.55.0/releases.md000066400000000000000000000214071507641516600165630ustar00rootroot00000000000000# Releases ## v0.55.0 - **Breaking**: Move `Protocol::HTTP::Header::QuotedString` to `Protocol::HTTP::QuotedString` for better reusability. - **Breaking**: Handle cookie key/value pairs using `QuotedString` as per RFC 6265. - Don't use URL encoding for cookie key/value. - **Breaking**: Remove `Protocol::HTTP::URL` and `Protocol::HTTP::Reference` – replaced by `Protocol::URL` gem. - `Protocol::HTTP::URL` -\> `Protocol::URL::Encoding`. - `Protocol::HTTP::Reference` -\> `Protocol::URL::Reference`. ## v0.54.0 - Introduce rich support for `Header::Digest`, `Header::ServerTiming`, `Header::TE`, `Header::Trailer` and `Header::TransferEncoding`. ### Improved HTTP Trailer Security This release introduces significant security improvements for HTTP trailer handling, addressing potential HTTP request smuggling vulnerabilities by implementing a restrictive-by-default policy for trailer headers. - **Security-by-default**: HTTP trailers are now validated and restricted by default to prevent HTTP request smuggling attacks. - Only safe headers are permitted in trailers: - `date` - Response generation timestamps (safe metadata) - `digest` - Content integrity verification (safe metadata) - `etag` - Cache validation tags (safe metadata) - `server-timing` - Performance metrics (safe metadata) - All other trailers are ignored by default. If you are using this library for gRPC, you will need to use a custom policy to allow the `grpc-status` and `grpc-message` trailers: ``` ruby module GRPCStatus def self.new(value) Integer(value) end def self.trailer? true end end module GRPCMessage def self.new(value) value end def self.trailer? true end end GRPC_POLICY = Protocol::HTTP::Headers::POLICY.dup GRPC_POLICY['grpc-status'] = GRPCStatus GRPC_POLICY['grpc-message'] = GRPCMessage # Reinterpret the headers using the new policy: response.headers.policy = GRPC_POLICY response.headers['grpc-status'] # => 0 response.headers['grpc-message'] # => "OK" ``` ## v0.53.0 - Improve consistency of Body `#inspect`. - Improve `as_json` support for Body wrappers. ## v0.52.0 - Add `Protocol::HTTP::Headers#to_a` method that returns the fields array, providing compatibility with standard Ruby array conversion pattern. - Expose `tail` in `Headers.new` so that trailers can be accurately reproduced. - Add agent context. ## v0.51.0 - `Protocol::HTTP::Headers` now raise a `DuplicateHeaderError` when a duplicate singleton header (e.g. `content-length`) is added. - `Protocol::HTTP::Headers#add` now coerces the value to a string when adding a header, ensuring consistent behaviour. - `Protocol::HTTP::Body::Head.for` now accepts an optional `length` parameter, allowing it to create a head body even when the body is not provided, based on the known content length. ## v0.50.0 - Drop support for Ruby v3.1. ## v0.48.0 - Add support for parsing `accept`, `accept-charset`, `accept-encoding` and `accept-language` headers into structured values. ## v0.46.0 - Add support for `priority:` header. ## v0.33.0 - Clarify behaviour of streaming bodies and copy `Protocol::Rack::Body::Streaming` to `Protocol::HTTP::Body::Streamable`. - Copy `Async::HTTP::Body::Writable` to `Protocol::HTTP::Body::Writable`. ## v0.31.0 - Ensure chunks are flushed if required, when streaming. ## v0.30.0 ### `Request[]` and `Response[]` Keyword Arguments The `Request[]` and `Response[]` methods now support keyword arguments as a convenient way to set various positional arguments. ``` ruby # Request keyword arguments: client.get("/", headers: {"accept" => "text/html"}, authority: "example.com") # Response keyword arguments: def call(request) return Response[200, headers: {"content-Type" => "text/html"}, body: "Hello, World!"] ``` ### Interim Response Handling The `Request` class now exposes a `#interim_response` attribute which can be used to handle interim responses both on the client side and server side. On the client side, you can pass a callback using the `interim_response` keyword argument which will be invoked whenever an interim response is received: ``` ruby client = ... response = client.get("/index", interim_response: proc{|status, headers| ...}) ``` On the server side, you can send an interim response using the `#send_interim_response` method: ``` ruby def call(request) if request.headers["expect"] == "100-continue" # Send an interim response: request.send_interim_response(100) end # ... end ``` ## v0.29.0 - Introduce `rewind` and `rewindable?` methods for body rewinding capabilities. - Add support for output buffer in `read_partial`/`readpartial` methods. - `Reader#buffered!` now returns `self` for method chaining. ## v0.28.0 - Add convenient `Reader#buffered!` method to buffer the body. - Modernize gem infrastructure with RuboCop integration. ## v0.27.0 - Expand stream interface to support `gets`/`puts` operations. - Skip empty key/value pairs in header processing. - Prefer lowercase method names for consistency. - Add `as_json` support to avoid default Rails implementation. - Use `@callback` to track invocation state. - Drop `base64` gem dependency. ## v0.26.0 - Prefer connection `close` over `keep-alive` when both are present. - Add support for `#readpartial` method. - Add `base64` dependency. ## v0.25.0 - Introduce explicit support for informational responses (1xx status codes). - Add `cache-control` support for `must-revalidate`, `proxy-revalidate`, and `s-maxage` directives. - Add `#strong_match?` and `#weak_match?` methods to `ETags` header. - Fix `last-modified`, `if-modified-since` and `if-unmodified-since` headers to use proper `Date` parsing. - Improve date/expires header parsing. - Add tests for `Stream#close_read`. - Check if input is closed before raising `IOError`. - Ensure saved files truncate existing file by default. ## v0.24.0 - Add output stream `#<<` as alias for `#write`. - Add support for `Headers#include?` and `#key?` methods. - Fix URL unescape functionality. - Fix cookie parsing issues. - Fix superclass mismatch in `Protocol::HTTP::Middleware::Builder`. - Allow trailers without explicit `trailer` header. - Fix cookie handling and Ruby 2 keyword arguments. ## v0.23.0 - Improve argument handling. - Rename `path` parameter to `target` to better match RFCs. ## v0.22.0 - Rename `trailers` to `trailer` for consistency. ## v0.21.0 - Streaming interface improvements. - Rename `Streamable` to `Completable`. ## v0.20.0 - Improve `Authorization` header implementation. ## v0.19.0 - Expose `Body#ready?` for more efficient response handling. ## v0.18.0 - Add `#trailers` method which enumerates trailers without marking tail. - Don't clear trailers in `#dup`, move functionality to `flatten!`. - All requests and responses must have mutable headers instance. ## v0.17.0 - Remove deferred headers due to complexity. - Remove deprecated `Headers#slice!`. - Add support for static, dynamic and streaming content to `cache-control` model. - Initial support for trailers. - Add support for `Response#not_modified?`. ## v0.16.0 - Add support for `if-match` and `if-none-match` headers. - Revert `Request#target` change for HTTP/2 compatibility. ## v0.15.0 - Prefer `Request#target` over `Request#path`. - Add body implementation to support HEAD requests. - Add support for computing digest on buffered body. - Add `Headers#set(key, value)` to replace existing values. - Add support for `vary` header. - Add support for `no-cache` & `no-store` cache directives. ## v0.14.0 - Add `Cacheable` body for buffering and caching responses. - Add support for `cache-control` header. ## v0.13.0 - Add support for `connection` header. - Fix handling of keyword arguments. ## v0.12.0 - Improved handling of `cookie` header. - Add `Headers#clear` method. ## v0.11.0 - Ensure `Body#call` invokes `stream.close` when done. ## v0.10.0 - Allow user to specify size for character devices. ## v0.9.1 - Add support for `authorization` header. ## v0.8.0 - Remove `reason` from `Response`. ## v0.7.0 - Explicit path handling in `Reference#with`. ## v0.6.0 - Initial version with basic HTTP protocol support. ## v0.5.1 - Fix path splitting behavior when path is empty. - Add `connect` method. - Support protocol in `[]` constructor. - Incorporate middleware functionality. ## v0.4.0 - Add `Request`, `Response` and `Body` classes from `async-http`. - Allow deletion of non-existent header fields. ## v0.3.0 - **Initial release** of `protocol-http` gem. - Initial implementation of HTTP/2 flow control. - Support for connection preface and settings frames. - Initial headers support. - Implementation of `Connection`, `Client` & `Server` classes. - HTTP/2 protocol framing and headers. protocol-http-0.55.0/test/000077500000000000000000000000001507641516600154115ustar00rootroot00000000000000protocol-http-0.55.0/test/protocol/000077500000000000000000000000001507641516600172525ustar00rootroot00000000000000protocol-http-0.55.0/test/protocol/http/000077500000000000000000000000001507641516600202315ustar00rootroot00000000000000protocol-http-0.55.0/test/protocol/http/accept_encoding.rb000066400000000000000000000135651507641516600236750ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http/accept_encoding" describe Protocol::HTTP::AcceptEncoding do let(:delegate) do proc do |request| Protocol::HTTP::Response[200, Protocol::HTTP::Headers["content-type" => "text/plain"], ["Hello World!"]] end end let(:middleware) {Protocol::HTTP::AcceptEncoding.new(delegate)} with "known encodings" do it "can decode gzip responses" do # Mock a response with gzip encoding gzip_delegate = proc do |request| Protocol::HTTP::Response[200, Protocol::HTTP::Headers[ "content-type" => "text/plain", "content-encoding" => "gzip" ], ["Hello World!"] ] end gzip_middleware = Protocol::HTTP::AcceptEncoding.new(gzip_delegate) request = Protocol::HTTP::Request["GET", "/"] response = gzip_middleware.call(request) expect(response.headers).not.to have_keys("content-encoding") expect(response.body).to be_a(Protocol::HTTP::Body::Inflate) end end with "unknown encodings" do it "preserves unknown content-encoding headers" do # Mock a response with brotli encoding (not in DEFAULT_WRAPPERS) br_delegate = proc do |request| Protocol::HTTP::Response[200, Protocol::HTTP::Headers[ "content-type" => "text/plain", "content-encoding" => "br" ], ["Hello World!"] # This would actually be brotli-encoded in reality ] end br_middleware = Protocol::HTTP::AcceptEncoding.new(br_delegate) request = Protocol::HTTP::Request["GET", "/"] response = br_middleware.call(request) # The bug: this currently fails because content-encoding gets removed # when the middleware encounters an unknown encoding expect(response.headers).to have_keys("content-encoding") expect(response.headers["content-encoding"]).to be == ["br"] # The body should remain untouched since we can't decode it expect(response.body).not.to be_a(Protocol::HTTP::Body::Inflate) end it "preserves mixed known and unknown encodings" do # Mock a response with multiple encodings where some are unknown mixed_delegate = proc do |request| Protocol::HTTP::Response[200, Protocol::HTTP::Headers[ "content-type" => "text/plain", "content-encoding" => "gzip, br" # gzip is known, br is unknown ], ["Hello World!"] ] end mixed_middleware = Protocol::HTTP::AcceptEncoding.new(mixed_delegate) request = Protocol::HTTP::Request["GET", "/"] response = mixed_middleware.call(request) # The bug: this currently fails because the entire content-encoding # header gets removed when ANY unknown encoding is present expect(response.headers).to have_keys("content-encoding") expect(response.headers["content-encoding"]).to be == ["gzip", "br"] # The body should remain untouched since we can't decode the br part expect(response.body).not.to be_a(Protocol::HTTP::Body::Inflate) end it "handles case-insensitive encoding names" do # Mock a response with uppercase encoding name uppercase_delegate = proc do |request| Protocol::HTTP::Response[200, Protocol::HTTP::Headers[ "content-type" => "text/plain", "content-encoding" => "GZIP" ], ["Hello World!"] ] end uppercase_middleware = Protocol::HTTP::AcceptEncoding.new(uppercase_delegate) request = Protocol::HTTP::Request["GET", "/"] response = uppercase_middleware.call(request) # This might also be a bug - encoding names should be case-insensitive # but the current implementation uses exact string matching expect(response.headers).not.to have_keys("content-encoding") expect(response.body).to be_a(Protocol::HTTP::Body::Inflate) end end with "issue #86 - transparent proxy scenario" do it "preserves unknown content-encoding when acting as transparent proxy" do # This test simulates the exact scenario described in issue #86 # where a transparent proxy fetches content with brotli encoding # but the AcceptEncoding middleware doesn't know about brotli # Mock upstream server that returns brotli-encoded content upstream_delegate = proc do |request| # Simulate a server responding with brotli encoding Protocol::HTTP::Response[200, Protocol::HTTP::Headers[ "content-type" => "text/html", "content-encoding" => "br" # Server chose brotli ], [""] # This would be actual brotli data ] end # Proxy middleware that only knows about gzip proxy_middleware = Protocol::HTTP::AcceptEncoding.new(upstream_delegate) # Client request that accepts both gzip and brotli request = Protocol::HTTP::Request["GET", "/some/resource"] response = proxy_middleware.call(request) # BUG: The content-encoding header should be preserved # so the client knows the content is still brotli-encoded expect(response.headers).to have_keys("content-encoding") expect(response.headers["content-encoding"]).to be == ["br"] # The body should remain untouched since proxy can't decode brotli expect(response.body).not.to be_a(Protocol::HTTP::Body::Inflate) expect(response.read).to be == "" end end with "empty or identity encodings" do it "handles identity encoding correctly" do identity_delegate = proc do |request| Protocol::HTTP::Response[200, Protocol::HTTP::Headers[ "content-type" => "text/plain", "content-encoding" => "identity" ], ["Hello World!"] ] end identity_middleware = Protocol::HTTP::AcceptEncoding.new(identity_delegate) request = Protocol::HTTP::Request["GET", "/"] response = identity_middleware.call(request) # Identity encoding means no encoding, so header should be removed expect(response.headers).not.to have_keys("content-encoding") expect(response.body).not.to be_a(Protocol::HTTP::Body::Inflate) end end end protocol-http-0.55.0/test/protocol/http/body/000077500000000000000000000000001507641516600211665ustar00rootroot00000000000000protocol-http-0.55.0/test/protocol/http/body/buffered.rb000066400000000000000000000111031507641516600232710ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. # Copyright, 2020-2023, by Bruno Sutic. require "protocol/http/body/buffered" require "protocol/http/body/a_readable_body" describe Protocol::HTTP::Body::Buffered do let(:source) {["Hello", "World"]} let(:body) {subject.wrap(source)} it_behaves_like Protocol::HTTP::Body::AReadableBody with ".wrap" do with "an instance of Protocol::HTTP::Body::Readable as a source" do let(:source) {Protocol::HTTP::Body::Readable.new} it "returns the body" do expect(body).to be == source end end with "an instance of an Array as a source" do let(:source) {["Hello", "World"]} it "returns instance initialized with the array" do expect(body).to be_a(subject) end end with "source that responds to #each" do let(:source) {["Hello", "World"].each} it "buffers the content into an array before initializing" do expect(body).to be_a(subject) expect(body.read).to be == "Hello" expect(body.read).to be == "World" end end with "an instance of a String as a source" do let(:source) {"Hello World"} it "returns instance initialized with the String" do expect(body).to be_a(subject) expect(body.read).to be == "Hello World" end end end with "#length" do it "returns sum of chunks' bytesize" do expect(body.length).to be == 10 end end with "#empty?" do it "returns false when there are chunks left" do expect(body.empty?).to be == false body.read expect(body.empty?).to be == false end it "returns true when there are no chunks left" do body.read body.read expect(body.empty?).to be == true end it "returns false when rewinded" do body.read body.read body.rewind expect(body.empty?).to be == false end end with "#ready?" do it "is ready when chunks are available" do expect(body).to be(:ready?) end end with "#finish" do it "returns self" do expect(body.finish).to be == body end end with "#call" do let(:output) {Protocol::HTTP::Body::Buffered.new} let(:stream) {Protocol::HTTP::Body::Stream.new(nil, output)} it "can stream data" do body.call(stream) expect(output).not.to be(:empty?) expect(output.chunks).to be == source end end with "#read" do it "retrieves chunks of content" do expect(body.read).to be == "Hello" expect(body.read).to be == "World" expect(body.read).to be == nil end # with "large content" do # let(:content) {Array.new(5) {|i| "#{i}" * (1*1024*1024)}} # it "allocates expected amount of memory" do # expect do # subject.read until subject.empty? # end.to limit_allocations(size: 0) # end # end end with "#rewind" do it "is rewindable" do expect(body).to be(:rewindable?) end it "positions the cursor to the beginning" do expect(body.read).to be == "Hello" body.rewind expect(body.read).to be == "Hello" end end with "#buffered" do let(:buffered_body) {body.buffered} it "returns a buffered body" do expect(buffered_body).to be_a(subject) expect(buffered_body.read).to be == "Hello" expect(buffered_body.read).to be == "World" end it "doesn't affect the original body" do expect(buffered_body.join).to be == "HelloWorld" expect(buffered_body).to be(:empty?) expect(body).not.to be(:empty?) end end with "#inspect" do let(:body) {subject.new} it "generates string representation for empty body" do expect(body.inspect).to be == "#" end end with "#each" do with "a block" do it "iterates over chunks" do result = [] body.each{|chunk| result << chunk} expect(result).to be == source end end with "no block" do it "returns an enumerator" do expect(body.each).to be_a(Enumerator) end it "can be chained with enumerator methods" do result = [] body.each.with_index do |chunk, index| if index.zero? result << chunk.upcase else result << chunk.downcase end end expect(result).to be == ["HELLO", "world"] end end end with "#clear" do it "clears all chunks and resets length" do body.clear expect(body.chunks).to be(:empty?) expect(body.read).to be == nil expect(body.length).to be == 0 end end with "#inspect" do it "can be inspected" do expect(body.inspect).to be =~ /\d+ chunks, \d+ bytes/ end end with "#discard" do it "closes the body" do expect(body).to receive(:close) expect(body.discard).to be == nil end end end protocol-http-0.55.0/test/protocol/http/body/completable.rb000066400000000000000000000056521507641516600240120ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "protocol/http/body/completable" require "protocol/http/body/buffered" require "protocol/http/request" describe Protocol::HTTP::Body::Completable do let(:body) {Protocol::HTTP::Body::Buffered.new} let(:callback) {Proc.new{}} let(:completable) {subject.new(body, callback)} it "can trigger callback when finished reading" do expect(callback).to receive(:call) expect(completable.read).to be_nil completable.close end AnImmediateCallback = Sus::Shared("an immediate callback") do it "invokes block immediately" do invoked = false wrapped = subject.wrap(message) do invoked = true end expect(invoked).to be == true expect(message.body).to be_equal(body) end end ADeferredCallback = Sus::Shared("a deferred callback") do it "invokes block when body is finished reading" do invoked = false wrapped = subject.wrap(message) do invoked = true end expect(invoked).to be == false expect(message.body).to be_equal(wrapped) wrapped.join expect(invoked).to be == true end end with ".wrap" do let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)} with "empty body" do it_behaves_like AnImmediateCallback end with "nil body" do let(:body) {nil} it_behaves_like AnImmediateCallback end with "non-empty body" do let(:body) {Protocol::HTTP::Body::Buffered.wrap("Hello World")} it_behaves_like ADeferredCallback end end with "#finish" do it "invokes callback once" do expect(callback).to receive(:call) 2.times do completable.finish end end it "doesn't break #read after finishing" do completable.finish expect(completable.read).to be_nil end end with "#rewindable?" do it "is not rewindable" do # Because completion can only happen once, we can't rewind the body. expect(body).to be(:rewindable?) expect(completable).not.to be(:rewindable?) expect(completable.rewind).to be == false end end with "#close" do let(:events) {Array.new} let(:callback) {Proc.new{events << :close}} it "invokes callback once" do completable1 = subject.new(body, proc{events << :close1}) completable2 = subject.new(completable1, proc{events << :close2}) completable2.close expect(events).to be == [:close2, :close1] end end with "#as_json" do it "includes callback information" do completable = subject.new(body, proc{events << :close}) expect(completable.as_json).to have_keys( class: be == "Protocol::HTTP::Body::Completable", callback: be =~ /Proc/ ) end it "shows nil when no callback" do completable = subject.new(body, nil) expect(completable.as_json).to have_keys( class: be == "Protocol::HTTP::Body::Completable", callback: be == nil ) end end end protocol-http-0.55.0/test/protocol/http/body/deflate.rb000066400000000000000000000034401507641516600231200ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http/body/buffered" require "protocol/http/body/deflate" require "protocol/http/body/inflate" require "securerandom" describe Protocol::HTTP::Body::Deflate do let(:body) {Protocol::HTTP::Body::Buffered.new} let(:compressed_body) {Protocol::HTTP::Body::Deflate.for(body)} let(:decompressed_body) {Protocol::HTTP::Body::Inflate.for(compressed_body)} it "should round-trip data" do body.write("Hello World!") expect(decompressed_body.join).to be == "Hello World!" end let(:data) {"Hello World!" * 10_000} it "should round-trip data" do body.write(data) expect(decompressed_body.read).to be == data expect(decompressed_body.read).to be == nil expect(compressed_body.ratio).to be < 1.0 expect(decompressed_body.ratio).to be > 1.0 end it "should round-trip chunks" do 10.times do body.write("Hello World!") end 10.times do expect(decompressed_body.read).to be == "Hello World!" end expect(decompressed_body.read).to be == nil end with "#length" do it "should be unknown" do expect(compressed_body).to have_attributes( length: be_nil, ) expect(decompressed_body).to have_attributes( length: be_nil, ) end end with "#inspect" do it "can generate string representation" do expect(compressed_body.inspect).to be == "# | #" end end with "#as_json" do it "includes compression information" do expect(compressed_body.as_json).to have_keys( class: be == "Protocol::HTTP::Body::Deflate", input_length: be == 0, output_length: be == 0, compression_ratio: be == 100.0 ) end end end protocol-http-0.55.0/test/protocol/http/body/digestable.rb000066400000000000000000000043011507641516600236140ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2020-2025, by Samuel Williams. require "protocol/http/body/digestable" require "protocol/http/body/buffered" describe Protocol::HTTP::Body::Digestable do let(:source) {Protocol::HTTP::Body::Buffered.new} let(:body) {subject.new(source)} with ".wrap" do let(:source) {Protocol::HTTP::Body::Buffered.wrap("HelloWorld")} let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)} it "can wrap a message" do Protocol::HTTP::Body::Digestable.wrap(message) do |digestable| expect(digestable).to have_attributes( digest: be == "872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4", ) end expect(message.body.join).to be == "HelloWorld" end end with "#digest" do def before source.write "Hello" source.write "World" super end it "can compute digest" do 2.times {body.read} expect(body.digest).to be == "872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4" end it "can recompute digest" do expect(body.read).to be == "Hello" expect(body.digest).to be == "185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969" expect(body.read).to be == "World" expect(body.digest).to be == "872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4" expect(body.etag).to be == '"872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4"' expect(body.etag(weak: true)).to be == 'W/"872e4e50ce9990d8b041330c47c9ddd11bec6b503ae9386a99da8584e9bb12c4"' end end with "#as_json" do it "includes digest information" do expect(body.as_json).to have_keys( class: be == "Protocol::HTTP::Body::Digestable", digest_class: be == "Digest::SHA256", callback: be == nil ) end with "callback" do let(:callback) {proc {puts "digest complete"}} let(:body) {subject.new(source, Digest::SHA256.new, callback)} it "includes callback information" do expect(body.as_json).to have_keys( class: be == "Protocol::HTTP::Body::Digestable", digest_class: be == "Digest::SHA256", callback: be =~ /Proc/ ) end end end end protocol-http-0.55.0/test/protocol/http/body/file.rb000066400000000000000000000050601507641516600224330ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http/body/file" describe Protocol::HTTP::Body::File do let(:path) {File.expand_path("file_spec.txt", __dir__)} let(:body) {subject.open(path)} after do @body&.close end # with '#stream?' do # it "should be streamable" do # expect(body).to be(:stream?) # end # end with "#join" do it "should read entire file" do expect(body.join).to be == "Hello World" end end with "#close" do it "should close file" do body.close expect(body).to be(:empty?) expect(body.file).to be(:closed?) end end with "#rewindable?" do it "should be rewindable" do expect(body).to be(:rewindable?) end end with "#rewind" do it "should rewind file" do expect(body.read).to be == "Hello World" expect(body).to be(:empty?) body.rewind expect(body).not.to be(:empty?) expect(body.read).to be == "Hello World" end end with "#buffered" do it "should return a new instance" do buffered = body.buffered expect(buffered).to be_a(Protocol::HTTP::Body::File) expect(buffered).not.to be_equal(body) ensure buffered&.close end end with "#inspect" do it "generates a string representation" do expect(body.inspect).to be =~ /Protocol::HTTP::Body::File (.*?), \d+ bytes remaining/ end with "range" do let(:body) {subject.new(File.open(path), 5..10)} it "shows offset when present" do expect(body.inspect).to be =~ /Protocol::HTTP::Body::File (.*?) \+5, \d+ bytes remaining/ end end end with "entire file" do it "should read entire file" do expect(body.read).to be == "Hello World" end it "should use binary encoding" do expect(::File).to receive(:open).with(path, ::File::RDONLY | ::File::BINARY) chunk = body.read expect(chunk.encoding).to be == Encoding::BINARY end with "#ready?" do it "should be ready" do expect(body).to be(:ready?) end end end with "partial file" do let(:body) {subject.open(path, 2...4)} it "should read specified range" do expect(body.read).to be == "ll" end end with "#call" do let(:output) {StringIO.new} it "can stream output" do body.call(output) expect(output.string).to be == "Hello World" end with "/dev/zero" do it "can stream partial output" do skip unless File.exist?("/dev/zero") body = subject.open("/dev/zero", 0...10) body.call(output) expect(output.string).to be == "\x00" * 10 end end end end protocol-http-0.55.0/test/protocol/http/body/file_spec.txt000066400000000000000000000000131507641516600236520ustar00rootroot00000000000000Hello Worldprotocol-http-0.55.0/test/protocol/http/body/head.rb000066400000000000000000000027711507641516600224230ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "protocol/http/body/head" require "protocol/http/body/buffered" describe Protocol::HTTP::Body::Head do with "zero length" do let(:body) {subject.new(0)} it "should be ready" do expect(body).to be(:ready?) end it "should be empty" do expect(body).to be(:empty?) end with "#join" do it "should be nil" do expect(body.join).to be_nil end end end with "non-zero length" do let(:body) {subject.new(1)} it "should be empty" do expect(body).to be(:empty?) end with "#read" do it "should be nil" do expect(body.join).to be_nil end end with "#join" do it "should be nil" do expect(body.join).to be_nil end end end with ".for" do with "body" do let(:source) {Protocol::HTTP::Body::Buffered.wrap("!")} let(:body) {subject.for(source)} it "captures length and closes existing body" do expect(source).to receive(:close) expect(body).to have_attributes(length: be == 1) body.close end end with "content length" do let(:body) {subject.for(nil, 42)} it "uses the content length if no body is provided" do expect(body).to have_attributes(length: be == 42) expect(body).to be(:empty?) expect(body).to be(:ready?) end end end with ".for with nil body" do it "returns nil when body is nil" do body = subject.for(nil) expect(body).to be_nil end end end protocol-http-0.55.0/test/protocol/http/body/inflate.rb000066400000000000000000000021331507641516600231340ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2024, by Samuel Williams. require "protocol/http/body/buffered" require "protocol/http/body/deflate" require "protocol/http/body/inflate" require "securerandom" describe Protocol::HTTP::Body::Inflate do let(:sample) {"The quick brown fox jumps over the lazy dog."} let(:chunks) {[sample] * 1024} let(:body) {Protocol::HTTP::Body::Buffered.new(chunks)} let(:deflate_body) {Protocol::HTTP::Body::Deflate.for(body)} let(:compressed_chunks) {deflate_body.join.each_char.to_a} let(:compressed_body_chunks) {compressed_chunks} let(:compressed_body) {Protocol::HTTP::Body::Buffered.new(compressed_body_chunks)} let(:decompressed_body) {subject.for(compressed_body)} it "can decompress a body" do expect(decompressed_body.join).to be == chunks.join end with "incomplete input" do let(:compressed_body_chunks) {compressed_chunks.first(compressed_chunks.size/2)} it "raises error when input is incomplete" do expect{decompressed_body.join}.to raise_exception(Zlib::BufError) end end end protocol-http-0.55.0/test/protocol/http/body/readable.rb000066400000000000000000000035211507641516600232530ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "protocol/http/body/stream" require "protocol/http/body/readable" describe Protocol::HTTP::Body::Readable do let(:body) {subject.new} it "might not be empty" do expect(body).not.to be(:empty?) end it "should not be ready" do expect(body).not.to be(:ready?) end with "#buffered" do it "is unable to buffer by default" do expect(body.buffered).to be_nil end end with "#finish" do it "should return empty buffered representation" do expect(body.finish).to be(:empty?) end end with "#call" do let(:output) {Protocol::HTTP::Body::Buffered.new} let(:stream) {Protocol::HTTP::Body::Stream.new(nil, output)} it "can stream (empty) data" do body.call(stream) expect(output).to be(:empty?) end it "flushes the stream if it is not ready" do chunks = ["Hello World"] mock(body) do |mock| mock.replace(:read) do chunks.pop end mock.replace(:ready?) do false end end expect(stream).to receive(:flush) body.call(stream) end end with "#join" do it "should be nil" do expect(body.join).to be_nil end end with "#discard" do it "should read all chunks" do expect(body).to receive(:read).and_return(nil) expect(body.discard).to be_nil end end with "#as_json" do it "generates a JSON representation" do expect(body.as_json).to have_keys( class: be == subject.name, length: be_nil, stream: be == false, ready: be == false, empty: be == false, ) end it "generates a JSON string" do expect(JSON.dump(body)).to be == body.to_json end end with "#rewindable?" do it "is not rewindable" do expect(body).not.to be(:rewindable?) expect(body.rewind).to be == false end end end protocol-http-0.55.0/test/protocol/http/body/reader.rb000066400000000000000000000032131507641516600227540ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022, by Dan Olson. # Copyright, 2023-2025, by Samuel Williams. require "protocol/http/body/reader" require "protocol/http/body/buffered" require "tempfile" class TestReader include Protocol::HTTP::Body::Reader def initialize(body) @body = body end attr :body end describe Protocol::HTTP::Body::Reader do let(:body) {Protocol::HTTP::Body::Buffered.wrap("thequickbrownfox")} let(:reader) {TestReader.new(body)} with "#finish" do it "returns a buffered representation" do expect(reader.finish).to be == body end end with "#discard" do it "discards the body" do expect(body).to receive(:discard) expect(reader.discard).to be_nil end end with "#buffered!" do it "buffers the body" do expect(reader.buffered!).to be_equal(reader) expect(reader.body).to be == body end end with "#close" do it "closes the underlying body" do expect(body).to receive(:close) reader.close expect(reader).not.to be(:body?) end end with "#save" do it "saves to the provided filename" do Tempfile.create do |file| reader.save(file.path) expect(File.read(file.path)).to be == "thequickbrownfox" end end it "saves by truncating an existing file if it exists" do Tempfile.create do |file| File.write(file.path, "hello" * 100) reader.save(file.path) expect(File.read(file.path)).to be == "thequickbrownfox" end end it "mirrors the interface of File.open" do Tempfile.create do |file| reader.save(file.path, "w") expect(File.read(file.path)).to be == "thequickbrownfox" end end end end protocol-http-0.55.0/test/protocol/http/body/reader_spec.txt000066400000000000000000000000201507641516600241730ustar00rootroot00000000000000thequickbrownfoxprotocol-http-0.55.0/test/protocol/http/body/rewindable.rb000066400000000000000000000052051507641516600236310ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http/body/rewindable" require "protocol/http/request" describe Protocol::HTTP::Body::Rewindable do let(:source) {Protocol::HTTP::Body::Buffered.new} let(:body) {subject.new(source)} it "can write and read data" do 3.times do |i| source.write("Hello World #{i}") expect(body.read).to be == "Hello World #{i}" end end it "can write and read data multiple times" do 3.times do |i| source.write("Hello World #{i}") end 3.times do body.rewind expect(body).to be(:ready?) expect(body.read).to be == "Hello World 0" end end it "can buffer data in order" do 3.times do |i| source.write("Hello World #{i}") end 2.times do body.rewind 3.times do |i| expect(body.read).to be == "Hello World #{i}" end end end with ".wrap" do with "a buffered body" do let(:body) {Protocol::HTTP::Body::Buffered.new} let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)} it "returns the body" do expect(subject.wrap(message)).to be == body end end with "a non-rewindable body" do let(:body) {Protocol::HTTP::Body::Readable.new} let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)} it "returns a new rewindable body" do expect(subject.wrap(message)).to be_a(Protocol::HTTP::Body::Rewindable) end end end with "#buffered" do it "can generate buffered representation" do 3.times do |i| source.write("Hello World #{i}") end expect(body.buffered).to be(:empty?) # Read one chunk into the internal buffer: body.read expect(body.buffered.chunks).to be == ["Hello World 0"] end end with "#empty?" do it "can read and re-read the body" do source.write("Hello World") expect(body).not.to be(:empty?) expect(body.read).to be == "Hello World" expect(body).to be(:empty?) body.rewind expect(body.read).to be == "Hello World" expect(body).to be(:empty?) end end with "#rewindable?" do it "is rewindable" do expect(body).to be(:rewindable?) end end with "#inspect" do it "can generate string representation" do expect(body.inspect).to be == "# | #" end end with "#as_json" do it "includes rewind tracking information" do expect(body.as_json).to have_keys( class: be == "Protocol::HTTP::Body::Rewindable", index: be == 0, chunks: be == 0 ) end end end protocol-http-0.55.0/test/protocol/http/body/stream.rb000066400000000000000000000213541507641516600230130ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2025, by Samuel Williams. require "protocol/http/body/stream" require "protocol/http/body/buffered" describe Protocol::HTTP::Body::Stream do let(:input) {Protocol::HTTP::Body::Buffered.new(["Hello", "World"])} let(:output) {Protocol::HTTP::Body::Buffered.new} let(:stream) {subject.new(input, output)} with "no input" do let(:input) {nil} it "should be empty" do expect(stream).to be(:empty?) end it "should read nothing" do expect(stream.read).to be == "" end end with "#empty?" do it "should be empty" do expect(stream).to be(:empty?) end end with "#read" do it "should read from the input" do expect(stream.read(5)).to be == "Hello" end it "can handle zero-length read" do expect(stream.read(0)).to be == "" end it "can read the entire input" do expect(stream.read).to be == "HelloWorld" end it "should read from the input into the given buffer" do buffer = String.new expect(stream.read(5, buffer)).to be == "Hello" expect(buffer).to be == "Hello" expect(stream.read(5, buffer)).to be == "World" expect(buffer).to be == "World" expect(stream.read(5, buffer)).to be == nil expect(buffer).to be == "" end it "can read partial input" do expect(stream.read(2)).to be == "He" expect(stream.read(2)).to be == "ll" expect(stream.read(2)).to be == "oW" expect(stream.read(2)).to be == "or" expect(stream.read(2)).to be == "ld" expect(stream.read(2)).to be == nil end it "can read partial input into the given buffer" do buffer = String.new expect(stream.read(100, buffer)).to be == "HelloWorld" expect(buffer).to be == "HelloWorld" expect(stream.read(2, buffer)).to be == nil expect(buffer).to be == "" end end with "#read_nonblock" do it "should read from the input" do expect(stream.read_nonblock(5)).to be == "Hello" expect(stream.read_nonblock(5)).to be == "World" expect(stream.read_nonblock(5)).to be == nil end it "should read from the input into the given buffer" do buffer = String.new expect(stream.read_nonblock(5, buffer)).to be == "Hello" expect(buffer).to be == "Hello" expect(stream.read_nonblock(5, buffer)).to be == "World" expect(buffer).to be == "World" expect(stream.read_nonblock(5, buffer)).to be == nil expect(buffer).to be == "" end it "can read input into the given buffer" do buffer = String.new expect(stream.read_nonblock(100, buffer)).to be == "Hello" expect(buffer).to be == "Hello" expect(stream.read_nonblock(100, buffer)).to be == "World" expect(buffer).to be == "World" expect(stream.read_nonblock(2, buffer)).to be == nil expect(buffer).to be == "" end it "can read partial input" do expect(stream.read_nonblock(2)).to be == "He" expect(stream.read_nonblock(2)).to be == "ll" expect(stream.read_nonblock(2)).to be == "o" expect(stream.read_nonblock(2)).to be == "Wo" expect(stream.read_nonblock(2)).to be == "rl" expect(stream.read_nonblock(2)).to be == "d" expect(stream.read_nonblock(2)).to be == nil end end with "#read_partial" do it "can read partial input" do expect(stream.read_partial(2)).to be == "He" expect(stream.read_partial(2)).to be == "ll" expect(stream.read_partial(2)).to be == "o" expect(stream.read_partial(2)).to be == "Wo" expect(stream.read_partial(2)).to be == "rl" expect(stream.read_partial(2)).to be == "d" expect(stream.read_partial(2)).to be == nil end it "can read partial input with buffer" do buffer = String.new expect(stream.read_partial(2, buffer)).to be == "He" expect(buffer).to be == "He" expect(stream.read_partial(2, buffer)).to be == "ll" expect(buffer).to be == "ll" expect(stream.read_partial(2, buffer)).to be == "o" expect(buffer).to be == "o" expect(stream.read_partial(2, buffer)).to be == "Wo" expect(buffer).to be == "Wo" expect(stream.read_partial(2, buffer)).to be == "rl" expect(buffer).to be == "rl" expect(stream.read_partial(2, buffer)).to be == "d" expect(buffer).to be == "d" expect(stream.read_partial(2, buffer)).to be == nil expect(buffer).to be == "" end end with "#readpartial" do it "can read partial input" do expect(stream.readpartial(20)).to be == "Hello" expect(stream.readpartial(20)).to be == "World" expect{stream.readpartial(20)}.to raise_exception(EOFError) end it "can read partial input with buffer" do buffer = String.new expect(stream.readpartial(20, buffer)).to be == "Hello" expect(buffer).to be == "Hello" expect(stream.readpartial(20, buffer)).to be == "World" expect(buffer).to be == "World" expect{stream.readpartial(20, buffer)}.to raise_exception(EOFError) expect(buffer).to be == "" end end with "#each" do it "can iterate over input" do chunks = [] stream.each do |chunk| chunks << chunk end expect(chunks).to be == ["Hello", "World"] end it "can iterate over input with buffer" do expect(stream.read(2)).to be == "He" chunks = [] stream.each do |chunk| chunks << chunk end expect(chunks).to be == ["llo", "World"] end it "can return an enumerator" do expect(stream.each.to_a).to be == ["Hello", "World"] end end with "#read_until" do it "can read until a pattern is encountered" do expect(stream.read_until("o")).to be == "Hello" expect(stream.read_until("d")).to be == "World" end it "can read until a pattern which isn't encountered" do expect(stream.read_until("X")).to be_nil end end with "#gets" do let(:input) {Protocol::HTTP::Body::Buffered.new(["Hello\nWorld\n"])} it "can read lines" do expect(stream.gets).to be == "Hello\n" expect(stream.gets).to be == "World\n" expect(stream.gets).to be == nil end it "can read up until the limit" do expect(stream.gets("X", 2)).to be == "He" end it "can read lines with limit" do expect(stream.gets(2)).to be == "He" expect(stream.gets(6)).to be == "llo\n" expect(stream.gets(2)).to be == "Wo" expect(stream.gets(6)).to be == "rld\n" expect(stream.gets(2)).to be == nil end it "can read lines and chomp separators" do expect(stream.gets(chomp: true)).to be == "Hello" expect(stream.gets(chomp: true)).to be == "World" expect(stream.gets(chomp: true)).to be == nil end it "can read without separator" do expect(stream.gets(nil, 4)).to be == "Hell" expect(stream.gets(nil, 4)).to be == "o\nWo" expect(stream.gets(nil, 4)).to be == "rld\n" expect(stream.gets(nil, 4)).to be == nil end with "several chunks" do let(:input) {Protocol::HTTP::Body::Buffered.new(["Hello ", "World\n"])} it "can read lines" do expect(stream.gets).to be == "Hello World\n" expect(stream.gets).to be == nil end end with "incomplete line at the end" do let(:input) {Protocol::HTTP::Body::Buffered.new(["Hello\nWorld"])} it "returns the remaining buffer when there is no more data to read" do expect(stream.gets).to be == "Hello\n" expect(stream.gets).to be == "World" expect(stream.gets).to be == nil end end end with "#close_read" do it "should close the input" do stream.read(1) stream.close_read expect{stream.read(1)}.to raise_exception(IOError) end end with "#write" do it "should write to the output" do expect(stream.write("Hello")).to be == 5 expect(stream.write("World")).to be == 5 expect(output.chunks).to be == ["Hello", "World"] end end with "#<<" do it "should write to the output" do stream << "Hello" stream << "World" expect(output.chunks).to be == ["Hello", "World"] end end with "#write_nonblock" do it "should write to the output" do stream.write_nonblock("Hello") stream.write_nonblock("World") expect(output.chunks).to be == ["Hello", "World"] end end with "#puts" do it "should write lines to the output" do stream.puts("Hello", "World") stream.puts("Goodbye") expect(output.chunks).to be == ["Hello\nWorld\n", "Goodbye\n"] end end with "#close_write" do it "should close the input" do stream.close_write expect{stream.write("X")}.to raise_exception(IOError) end end with "#flush" do it "can be flushed" do # For streams, this is a no-op since buffering is handled by the output body. stream.flush end end with "#close" do it "can can be closed" do stream.close expect(stream).to be(:closed?) end it "can be closed multiple times" do stream.close stream.close expect(stream).to be(:closed?) end end with "IO.copy_stream" do let(:output) {StringIO.new} it "can copy input to output" do ::IO.copy_stream(stream, output) expect(output.string).to be == "HelloWorld" end end end protocol-http-0.55.0/test/protocol/http/body/streamable.rb000066400000000000000000000146611507641516600236420ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2025, by Samuel Williams. require "protocol/http/body/streamable" require "sus/fixtures/async" describe Protocol::HTTP::Body::Streamable do include Sus::Fixtures::Async::ReactorContext let(:block) do proc do |stream| stream.write("Hello") stream.write("World") stream.close end end let(:body) {subject.request(&block)} with ".request" do it "can create a new body" do body = subject.request(&block) expect(body).to be_a(Protocol::HTTP::Body::Streamable::RequestBody) end end with ".response" do let(:request) {Protocol::HTTP::Request.new("GET", "/")} it "can create a new body" do body = subject.response(request, &block) expect(body).to be_a(Protocol::HTTP::Body::Streamable::Body) end end with "#stream?" do it "should be streamable" do expect(body).to be(:stream?) end end with "#read" do it "can read the body" do expect(body.read).to be == "Hello" expect(body.read).to be == "World" expect(body.read).to be == nil end end with "#close_write" do let(:block) do proc do |stream| stream.close_write end end it "can close the output body" do expect(body.read).to be == nil end end with "#each" do it "can read the body" do chunks = [] body.each{|chunk| chunks << chunk} expect(chunks).to be == ["Hello", "World"] end end with "#call" do it "can read the body" do stream = StringIO.new body.call(stream) expect(stream.string).to be == "HelloWorld" end it "will fail if invoked twice" do stream = StringIO.new body.call(stream) expect do body.call(stream) end.to raise_exception(Protocol::HTTP::Body::Streamable::ConsumedError) end it "will fail if trying to read after streaming" do stream = StringIO.new body.call(stream) expect do body.read end.to raise_exception(Protocol::HTTP::Body::Streamable::ConsumedError) end end with "#inspect" do it "shows block available when not consumed" do expect(body.inspect).to be == "#" end it "shows output active after reading starts" do # Start reading to create @output body.read expect(body.inspect).to be == "#" end it "shows output closed after completion" do # Consume the body, then close output to trigger final else state body.read body.close_output expect(body.inspect).to be == "#" end with "a block that raises an error" do let(:block) do proc do |stream| stream.write("Hello") raise "Oh no... a wild error appeared!" ensure stream.close end end it "closes the stream if an error occurs" do stream = StringIO.new expect do body.call(stream) end.to raise_exception(RuntimeError, message: be =~ /Oh no... a wild error appeared!/) expect(stream.string).to be == "Hello" end end end with "#close" do it "can close the body" do expect(body.read).to be == "Hello" body.close end it "can raise an error on the block" do expect(body.read).to be == "Hello" body.close(RuntimeError.new("Oh no!")) end end with "nested fiber" do let(:block) do proc do |stream| Fiber.new do stream.write("Hello") end.resume end end it "can read a chunk" do expect(body.read).to be == "Hello" end end with "buffered input" do let(:input) {Protocol::HTTP::Body::Buffered.new(["Hello", " ", "World"])} let(:block) do proc do |stream| while chunk = stream.read_partial stream.write(chunk) end end end let(:response) {Protocol::HTTP::Response[200, body: input]} let(:body) {subject.response(response, &block)} it "can read from input" do expect(body.read).to be == "Hello" expect(body.read).to be == " " expect(body.read).to be == "World" end it "can stream to output" do output = StringIO.new stream = Protocol::HTTP::Body::Stream.new(input, output) body.call(stream) expect(output.string).to be == "Hello World" end with "#close" do it "can close the body" do expect(input).not.to receive(:close) expect(body.read).to be == "Hello" body.close end end end with "#stream" do let(:block) do proc do |stream| while chunk = stream.read_partial stream.write(chunk) end rescue => error ensure stream.close(error) end end it "can stream to output" do input = Protocol::HTTP::Body::Buffered.new(["Hello", " ", "World"]) body.stream(input) expect(body.read).to be == "Hello" expect(body.read).to be == " " expect(body.read).to be == "World" body.close end it "can stream to output with an error" do input = Protocol::HTTP::Body::Buffered.new(["Hello", " ", "World"]) mock(input) do |mock| mock.replace(:read) do raise "Oh no!" end end body.stream(input) expect do body.read end.to raise_exception(RuntimeError, message: be =~ /Oh no!/) end end with "streaming in a different task" do let(:block) do proc do |stream| while chunk = stream.read_partial stream.write(chunk) end rescue => error ensure stream.close(error) end end let(:input) {Protocol::HTTP::Body::Writable.new} let(:output) {Protocol::HTTP::Body::Writable.new} before do parent = Async::Task.current @input_task = parent.async do body.stream(input) end @output_task = parent.async do while chunk = body.read output.write(chunk) end rescue => error ensure output.close_write(error) end end after do @input_task&.wait @output_task&.wait end it "can stream a chunk" do input.write("Hello") input.close_write expect(output.read).to be == "Hello" end it "can stream multiple chunks" do input.write("Hello") input.write(" ") input.write("World") input.close_write expect(output.read).to be == "Hello" expect(output.read).to be == " " expect(output.read).to be == "World" end it "can stream an error" do input.write("Hello") input.close_write(RuntimeError.new("Oh no!")) expect do output.read end.to raise_exception(RuntimeError, message: be =~ /Oh no!/) end end end protocol-http-0.55.0/test/protocol/http/body/wrapper.rb000066400000000000000000000052111507641516600231720ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2024, by Samuel Williams. require "protocol/http/body/wrapper" require "protocol/http/body/buffered" require "protocol/http/request" require "json" require "stringio" describe Protocol::HTTP::Body::Wrapper do let(:source) {Protocol::HTTP::Body::Buffered.new} let(:body) {subject.new(source)} with "#stream?" do it "should not be streamable" do expect(body).not.to be(:stream?) end end it "should proxy close" do expect(source).to receive(:close).and_return(nil) body.close end it "should proxy empty?" do expect(source).to receive(:empty?).and_return(true) expect(body.empty?).to be == true end it "should proxy ready?" do expect(source).to receive(:ready?).and_return(true) expect(body.ready?).to be == true end it "should proxy length" do expect(source).to receive(:length).and_return(1) expect(body.length).to be == 1 end it "should proxy read" do expect(source).to receive(:read).and_return("!") expect(body.read).to be == "!" end it "should proxy inspect" do expect(source).to receive(:inspect).and_return("!") expect(body.inspect).to be(:include?, "!") end with ".wrap" do let(:message) {Protocol::HTTP::Request.new(nil, nil, "GET", "/", nil, Protocol::HTTP::Headers.new, body)} it "should wrap body" do subject.wrap(message) expect(message.body).to be_a(Protocol::HTTP::Body::Wrapper) end end with "#buffered" do it "should proxy buffered" do expect(source).to receive(:buffered).and_return(true) expect(body.buffered).to be == true end end with "#rewindable?" do it "should proxy rewindable?" do expect(source).to receive(:rewindable?).and_return(true) expect(body.rewindable?).to be == true end end with "#rewind" do it "should proxy rewind" do expect(source).to receive(:rewind).and_return(true) expect(body.rewind).to be == true end end with "#as_json" do it "generates a JSON representation" do expect(body.as_json).to have_keys( class: be == "Protocol::HTTP::Body::Wrapper", body: be == source.as_json ) end it "generates a JSON string" do expect(JSON.dump(body)).to be == body.to_json end end with "#each" do it "should invoke close correctly" do expect(body).to receive(:close) body.each{} end end with "#stream" do let(:stream) {StringIO.new} it "should invoke close correctly" do expect(body).to receive(:close) body.call(stream) end end with "#discard" do it "should proxy discard" do expect(source).to receive(:discard).and_return(nil) expect(body.discard).to be_nil end end end protocol-http-0.55.0/test/protocol/http/body/writable.rb000066400000000000000000000100061507641516600233210ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024, by Samuel Williams. require "protocol/http/body/writable" require "protocol/http/body/deflate" require "protocol/http/body/a_writable_body" describe Protocol::HTTP::Body::Writable do let(:body) {subject.new} it_behaves_like Protocol::HTTP::Body::AWritableBody with "#length" do it "should be unspecified by default" do expect(body.length).to be_nil end end with "#closed?" do it "should not be closed by default" do expect(body).not.to be(:closed?) end end with "#ready?" do it "should be ready if chunks are available" do expect(body).not.to be(:ready?) body.write("Hello") expect(body).to be(:ready?) end it "should be ready if closed" do body.close expect(body).to be(:ready?) end end with "#empty?" do it "should be empty if closed with no pending chunks" do expect(body).not.to be(:empty?) body.close_write expect(body).to be(:empty?) end it "should become empty when pending chunks are read" do body.write("Hello") body.close_write expect(body).not.to be(:empty?) body.read expect(body).to be(:empty?) end it "should not be empty if chunks are available" do body.write("Hello") expect(body).not.to be(:empty?) end end with "#write" do it "should write chunks" do body.write("Hello") body.write("World") expect(body.read).to be == "Hello" expect(body.read).to be == "World" end it "can't write to closed body" do body.close expect do body.write("Hello") end.to raise_exception(Protocol::HTTP::Body::Writable::Closed) end it "can write and read data" do 3.times do |i| body.write("Hello World #{i}") expect(body.read).to be == "Hello World #{i}" end end it "can buffer data in order" do 3.times do |i| body.write("Hello World #{i}") end 3.times do |i| expect(body.read).to be == "Hello World #{i}" end end end with "#join" do it "can join chunks" do 3.times do |i| body.write("#{i}") end body.close_write expect(body.join).to be == "012" end end with "#each" do it "can read all data in order" do 3.times do |i| body.write("Hello World #{i}") end body.close_write 3.times do |i| chunk = body.read expect(chunk).to be == "Hello World #{i}" end end it "can propagate failures" do body.write("Beep boop") # This will cause a failure. expect do body.each do |chunk| raise RuntimeError.new("It was too big!") end end.to raise_exception(RuntimeError, message: be =~ /big/) expect do body.write("Beep boop") # This will fail. end.to raise_exception(RuntimeError, message: be =~ /big/) end it "can propagate failures in nested bodies" do nested = ::Protocol::HTTP::Body::Deflate.for(body) body.write("Beep boop") # This will cause a failure. expect do nested.each do |chunk| raise RuntimeError.new("It was too big!") end end.to raise_exception(RuntimeError, message: be =~ /big/) expect do body.write("Beep boop") # This will fail. end.to raise_exception(RuntimeError, message: be =~ /big/) end it "will stop after finishing" do body.write("Hello World!") body.close_write expect(body).not.to be(:empty?) body.each do |chunk| expect(chunk).to be == "Hello World!" end expect(body).to be(:empty?) end end with "#output" do it "can be used to write data" do body.output do |output| output.write("Hello World!") end expect(body.output).to be(:closed?) expect(body.read).to be == "Hello World!" expect(body.read).to be_nil end it "can propagate errors" do expect do body.output do |output| raise "Oops!" end end.to raise_exception(RuntimeError, message: be =~ /Oops/) expect(body).to be(:closed?) expect do body.read end.to raise_exception(RuntimeError, message: be =~ /Oops/) end end end protocol-http-0.55.0/test/protocol/http/content_encoding.rb000066400000000000000000000060051507641516600240770ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http/accept_encoding" require "protocol/http/content_encoding" describe Protocol::HTTP::ContentEncoding do with "complete text/plain response" do let(:middleware) {subject.new(Protocol::HTTP::Middleware::HelloWorld)} let(:accept_encoding) {Protocol::HTTP::AcceptEncoding.new(middleware)} it "can request resource without compression" do response = middleware.get("/index") expect(response).to be(:success?) expect(response.headers).not.to have_keys("content-encoding") expect(response.headers["vary"]).to be(:include?, "accept-encoding") expect(response.read).to be == "Hello World!" end it "can request a resource with the identity encoding" do response = accept_encoding.get("/index", {"accept-encoding" => "identity"}) expect(response).to be(:success?) expect(response.headers).not.to have_keys("content-encoding") expect(response.headers["vary"]).to be(:include?, "accept-encoding") expect(response.read).to be == "Hello World!" end it "can request resource with compression" do response = accept_encoding.get("/index", {"accept-encoding" => "gzip"}) expect(response).to be(:success?) expect(response.headers["vary"]).to be(:include?, "accept-encoding") expect(response.body).to be_a(Protocol::HTTP::Body::Inflate) expect(response.read).to be == "Hello World!" end end with "partial response" do let(:app) do proc do |request| Protocol::HTTP::Response[206, Protocol::HTTP::Headers["content-type" => "text/plain"], ["Hello World!"]] end end let(:client) {subject.new(app)} it "can request resource with compression" do response = client.get("/index", {"accept-encoding" => "gzip"}) expect(response).to be(:success?) expect(response.headers).not.to have_keys("content-encoding") expect(response.read).to be == "Hello World!" end end with "existing content encoding" do let(:app) do app = ->(request){Protocol::HTTP::Response[200, Protocol::HTTP::Headers["content-type" => "text/plain", "content-encoding" => "identity"], ["Hello World!"]] } end let(:client) {subject.new(app)} it "does not compress response" do response = client.get("/index", {"accept-encoding" => "gzip"}) expect(response).to be(:success?) expect(response.headers).to have_keys("content-encoding") expect(response.headers["content-encoding"]).to be == ["identity"] expect(response.read).to be == "Hello World!" end end with "nil body" do let(:app) do app = ->(request){Protocol::HTTP::Response[200, Protocol::HTTP::Headers["content-type" => "text/plain"], nil] } end let(:client) {subject.new(app)} it "does not compress response" do response = client.get("/index", {"accept-encoding" => "gzip"}) expect(response).to be(:success?) expect(response.headers).not.to have_keys("content-encoding") expect(response.read).to be == nil end end end protocol-http-0.55.0/test/protocol/http/cookie.rb000066400000000000000000000062761507641516600220420ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http/cookie" describe Protocol::HTTP::Cookie do describe "#initialize" do it "accepts valid cookie names" do cookie = Protocol::HTTP::Cookie.new("session_id", "123") expect(cookie.name).to be == "session_id" expect(cookie.value).to be == "123" end it "accepts valid cookie values with allowed characters" do # Test cookie-octet range: !#$%&'()*+-./0-9:;<=>?@A-Z[]^_`a-z{|}~ cookie = Protocol::HTTP::Cookie.new("test", "abc123!#$%&'()*+-./:") expect(cookie.value).to be == "abc123!#$%&'()*+-./:" end it "rejects cookie names with invalid characters" do expect do Protocol::HTTP::Cookie.new("session id", "123") end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie name/) end it "rejects cookie names with semicolon" do expect do Protocol::HTTP::Cookie.new("session;id", "123") end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie name/) end it "rejects cookie values with control characters" do expect do Protocol::HTTP::Cookie.new("session", "123\n456") end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/) end it "rejects cookie values with semicolon" do expect do Protocol::HTTP::Cookie.new("session", "123;456") end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/) end it "rejects cookie values with comma" do expect do Protocol::HTTP::Cookie.new("session", "123,456") end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/) end it "rejects cookie values with backslash" do expect do Protocol::HTTP::Cookie.new("session", "123\\456") end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/) end it "rejects cookie values with double quote" do expect do Protocol::HTTP::Cookie.new("session", '"quoted"') end.to raise_exception(ArgumentError, message: be =~ /Invalid cookie value/) end it "accepts nil value" do cookie = Protocol::HTTP::Cookie.new("session", nil) expect(cookie.value).to be_nil end end describe "#to_s" do it "returns cookie name and value" do cookie = Protocol::HTTP::Cookie.new("session", "abc123") expect(cookie.to_s).to be == "session=abc123" end it "includes directives" do cookie = Protocol::HTTP::Cookie.new("session", "123", {"path" => "/", "secure" => true}) expect(cookie.to_s).to be == "session=123;path=/;secure" end end describe ".parse" do it "parses simple cookie" do cookie = Protocol::HTTP::Cookie.parse("session=123") expect(cookie.name).to be == "session" expect(cookie.value).to be == "123" end it "parses cookie with equals in value" do cookie = Protocol::HTTP::Cookie.parse("session=123==") expect(cookie.name).to be == "session" expect(cookie.value).to be == "123==" end it "parses cookie with directives" do cookie = Protocol::HTTP::Cookie.parse("session=123; path=/; secure") expect(cookie.name).to be == "session" expect(cookie.value).to be == "123" expect(cookie.directives).to be == {"path" => "/", "secure" => true} end end end protocol-http-0.55.0/test/protocol/http/header/000077500000000000000000000000001507641516600214615ustar00rootroot00000000000000protocol-http-0.55.0/test/protocol/http/header/accept.rb000066400000000000000000000043431507641516600232510ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http/header/accept" describe Protocol::HTTP::Header::Accept::MediaRange do it "should have default quality_factor of 1.0" do media_range = subject.new("text/plain", nil) expect(media_range.quality_factor).to be == 1.0 end with "#to_s" do it "can convert to string" do media_range = subject.new("text", "plain", {"q" => "0.5"}) expect(media_range.to_s).to be == "text/plain;q=0.5" end end end describe Protocol::HTTP::Header::Accept do let(:header) {subject.new(description)} let(:media_ranges) {header.media_ranges.sort} with "text/plain, text/html;q=0.5, text/xml;q=0.25" do it "can parse media ranges" do expect(header.length).to be == 3 expect(media_ranges[0]).to have_attributes( type: be == "text", subtype: be == "plain", quality_factor: be == 1.0 ) expect(media_ranges[1]).to have_attributes( type: be == "text", subtype: be == "html", quality_factor: be == 0.5 ) expect(media_ranges[2]).to have_attributes( type: be == "text", subtype: be == "xml", quality_factor: be == 0.25 ) end it "can convert to string" do expect(header.to_s).to be == "text/plain,text/html;q=0.5,text/xml;q=0.25" end end with "foobar" do it "fails to parse" do expect{media_ranges}.to raise_exception(Protocol::HTTP::Header::Accept::ParseError) end end with "text/html;q=0.25, text/xml;q=0.5, text/plain" do it "should order based on quality factor" do expect(media_ranges.collect(&:to_s)).to be == %w{text/plain text/xml;q=0.5 text/html;q=0.25} end end with "text/html, text/plain;q=0.8, text/xml;q=0.6, application/json" do it "should order based on quality factor" do expect(media_ranges.collect(&:to_s)).to be == %w{text/html application/json text/plain;q=0.8 text/xml;q=0.6} end end with "*/*" do it "should accept wildcard media range" do expect(media_ranges[0].to_s).to be == "*/*" end end with "text/html;schema=\"example.org\";q=0.5" do it "should parse parameters" do expect(media_ranges[0].parameters).to have_keys( "schema" => be == "example.org", "q" => be == "0.5", ) end end end protocol-http-0.55.0/test/protocol/http/header/accept_charset.rb000066400000000000000000000041301507641516600247540ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http/header/accept_charset" describe Protocol::HTTP::Header::AcceptCharset::Charset do it "should have default quality_factor of 1.0" do charset = subject.new("utf-8", nil) expect(charset.quality_factor).to be == 1.0 end end describe Protocol::HTTP::Header::AcceptCharset do let(:header) {subject.new(description)} let(:charsets) {header.charsets.sort} with "utf-8, iso-8859-1;q=0.5, windows-1252;q=0.25" do it "can parse charsets" do expect(header.length).to be == 3 expect(charsets[0].name).to be == "utf-8" expect(charsets[0].quality_factor).to be == 1.0 expect(charsets[1].name).to be == "iso-8859-1" expect(charsets[1].quality_factor).to be == 0.5 expect(charsets[2].name).to be == "windows-1252" expect(charsets[2].quality_factor).to be == 0.25 end end with "windows-1252;q=0.25, iso-8859-1;q=0.5, utf-8" do it "should order based on quality factor" do expect(charsets.collect(&:name)).to be == %w{utf-8 iso-8859-1 windows-1252} end end with "us-ascii,iso-8859-1;q=0.8,windows-1252;q=0.6,utf-8" do it "should order based on quality factor" do expect(charsets.collect(&:name)).to be == %w{us-ascii utf-8 iso-8859-1 windows-1252} end end with "*;q=0" do it "should accept wildcard charset" do expect(charsets[0].name).to be == "*" expect(charsets[0].quality_factor).to be == 0 end end with "utf-8, iso-8859-1;q=0.5, windows-1252;q=0.5" do it "should preserve relative order" do expect(charsets[0].name).to be == "utf-8" expect(charsets[1].name).to be == "iso-8859-1" expect(charsets[2].name).to be == "windows-1252" end end it "should not accept invalid input" do bad_values = [ # Invalid quality factor: "utf-8;f=1", # Invalid parameter: "us-ascii;utf-8", # Invalid use of separator: ";", # Empty charset (we ignore this one): # "," ] bad_values.each do |value| expect{subject.new(value).charsets}.to raise_exception(subject::ParseError) end end end protocol-http-0.55.0/test/protocol/http/header/accept_encoding.rb000066400000000000000000000040051507641516600251120ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http/header/accept_encoding" describe Protocol::HTTP::Header::AcceptEncoding::Encoding do it "should have default quality_factor of 1.0" do encoding = subject.new("utf-8", nil) expect(encoding.quality_factor).to be == 1.0 end end describe Protocol::HTTP::Header::AcceptEncoding do let(:header) {subject.new(description)} let(:encodings) {header.encodings.sort} with "gzip, deflate;q=0.5, identity;q=0.25" do it "can parse charsets" do expect(header.length).to be == 3 expect(encodings[0].name).to be == "gzip" expect(encodings[0].quality_factor).to be == 1.0 expect(encodings[1].name).to be == "deflate" expect(encodings[1].quality_factor).to be == 0.5 expect(encodings[2].name).to be == "identity" expect(encodings[2].quality_factor).to be == 0.25 end end with "identity;q=0.25, deflate;q=0.5, gzip" do it "should order based on quality factor" do expect(encodings.collect(&:name)).to be == %w{gzip deflate identity} end end with "br,deflate;q=0.8,identity;q=0.6,gzip" do it "should order based on quality factor" do expect(encodings.collect(&:name)).to be == %w{br gzip deflate identity} end end with "*;q=0" do it "should accept wildcard encoding" do expect(encodings[0].name).to be == "*" expect(encodings[0].quality_factor).to be == 0 end end with "br, gzip;q=0.5, deflate;q=0.5" do it "should preserve relative order" do expect(encodings[0].name).to be == "br" expect(encodings[1].name).to be == "gzip" expect(encodings[2].name).to be == "deflate" end end it "should not accept invalid input" do bad_values = [ # Invalid quality factor: "br;f=1", # Invalid parameter: "br;gzip", # Invalid use of separator: ";", # Empty (we ignore this one): # "," ] bad_values.each do |value| expect{subject.new(value).encodings}.to raise_exception(subject::ParseError) end end end protocol-http-0.55.0/test/protocol/http/header/accept_language.rb000066400000000000000000000045771507641516600251250ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http/header/accept_language" describe Protocol::HTTP::Header::AcceptLanguage::Language do it "should have default quality_factor of 1.0" do language = subject.new("utf-8", nil) expect(language.quality_factor).to be == 1.0 end end describe Protocol::HTTP::Header::AcceptLanguage do let(:header) {subject.new(description)} let(:languages) {header.languages.sort} with "da, en-gb;q=0.5, en;q=0.25" do it "can parse languages" do expect(header.length).to be == 3 expect(languages[0].name).to be == "da" expect(languages[0].quality_factor).to be == 1.0 expect(languages[1].name).to be == "en-gb" expect(languages[1].quality_factor).to be == 0.5 expect(languages[2].name).to be == "en" expect(languages[2].quality_factor).to be == 0.25 end end with "en-gb;q=0.25, en;q=0.5, en-us" do it "should order based on quality factor" do expect(languages.collect(&:name)).to be == %w{en-us en en-gb} end end with "en-us,en-gb;q=0.8,en;q=0.6,es-419" do it "should order based on quality factor" do expect(languages.collect(&:name)).to be == %w{en-us es-419 en-gb en} end end with "*;q=0" do it "should accept wildcard language" do expect(languages[0].name).to be == "*" expect(languages[0].quality_factor).to be == 0 end end with "en, de;q=0.5, jp;q=0.5" do it "should preserve relative order" do expect(languages[0].name).to be == "en" expect(languages[1].name).to be == "de" expect(languages[2].name).to be == "jp" end end with "de, en-US; q=0.7, en ; q=0.3" do it "should parse with optional whitespace" do expect(languages[0].name).to be == "de" expect(languages[1].name).to be == "en-US" expect(languages[2].name).to be == "en" end end with "en;q=0.123456" do it "accepts quality factors with up to 6 decimal places" do expect(languages[0].name).to be == "en" expect(languages[0].quality_factor).to be == 0.123456 end end it "should not accept invalid input" do bad_values = [ # Invalid quality factor: "en;f=1", # Invalid parameter: "de;fr", # Invalid use of separator: ";", # Empty (we ignore this one): # "," ] bad_values.each do |value| expect{subject.new(value).languages}.to raise_exception(subject::ParseError) end end end protocol-http-0.55.0/test/protocol/http/header/authorization.rb000066400000000000000000000011331507641516600247040ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http/header/authorization" require "protocol/http/headers" describe Protocol::HTTP::Header::Authorization do with "basic username/password" do let(:header) {subject.basic("samuel", "password")} it "should generate correct authorization header" do expect(header).to be == "Basic c2FtdWVsOnBhc3N3b3Jk" end with "#credentials" do it "can split credentials" do expect(header.credentials).to be == ["Basic", "c2FtdWVsOnBhc3N3b3Jk"] end end end end protocol-http-0.55.0/test/protocol/http/header/cache_control.rb000066400000000000000000000034251507641516600246150ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. # Copyright, 2023, by Thomas Morgan. require "protocol/http/header/cache_control" describe Protocol::HTTP::Header::CacheControl do let(:header) {subject.new(description)} with "max-age=60, s-maxage=30, public" do it "correctly parses cache header" do expect(header).to have_attributes( public?: be == true, private?: be == false, max_age: be == 60, s_maxage: be == 30, ) end end with "max-age=-10, s-maxage=0x22" do it "gracefully handles invalid values" do expect(header).to have_attributes( max_age: be == nil, s_maxage: be == nil, ) end end with "no-cache, no-store" do it "correctly parses cache header" do expect(header).to have_attributes( no_cache?: be == true, no_store?: be == true, ) end end with "static" do it "correctly parses cache header" do expect(header).to have_attributes( static?: be == true, ) end end with "dynamic" do it "correctly parses cache header" do expect(header).to have_attributes( dynamic?: be == true, ) end end with "streaming" do it "correctly parses cache header" do expect(header).to have_attributes( streaming?: be == true, ) end end with "must-revalidate" do it "correctly parses cache header" do expect(header).to have_attributes( must_revalidate?: be == true, ) end end with "proxy-revalidate" do it "correctly parses cache header" do expect(header).to have_attributes( proxy_revalidate?: be == true, ) end end with "#<<" do let(:header) {subject.new} it "can append values" do header << "max-age=60" expect(header).to have_attributes( max_age: be == 60, ) end end end protocol-http-0.55.0/test/protocol/http/header/connection.rb000066400000000000000000000024331507641516600241470ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2024, by Samuel Williams. # Copyright, 2024, by Thomas Morgan. require "protocol/http/headers" require "protocol/http/cookie" describe Protocol::HTTP::Header::Connection do let(:header) {subject.new(description)} with "close" do it "should indiciate connection will be closed" do expect(header).to be(:close?) end it "should indiciate connection will not be keep-alive" do expect(header).not.to be(:keep_alive?) end end with "keep-alive" do it "should indiciate connection will not be closed" do expect(header).not.to be(:close?) end it "should indiciate connection is not keep-alive" do expect(header).to be(:keep_alive?) end end with "close, keep-alive" do it "should prioritize close over keep-alive" do expect(header).to be(:close?) expect(header).not.to be(:keep_alive?) end end with "upgrade" do it "should indiciate connection can be upgraded" do expect(header).to be(:upgrade?) end end with "#<<" do let(:header) {subject.new} it "can append values" do header << "close" expect(header).to be(:close?) header << "upgrade" expect(header).to be(:upgrade?) expect(header.to_s).to be == "close,upgrade" end end end protocol-http-0.55.0/test/protocol/http/header/cookie.rb000066400000000000000000000026421507641516600232630ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2021-2025, by Samuel Williams. # Copyright, 2022, by Herrick Fang. require "protocol/http/header/cookie" describe Protocol::HTTP::Header::Cookie do let(:header) {subject.new(description)} let(:cookies) {header.to_h} with "session=123; secure" do it "can parse cookies" do expect(cookies).to have_keys("session") session = cookies["session"] expect(session).to have_attributes( name: be == "session", value: be == "123", ) expect(session.directives).to have_keys("secure") end end with "session=123; path=/; secure" do it "can parse cookies" do session = cookies["session"] expect(session).to have_attributes( name: be == "session", value: be == "123", directives: be == {"path" => "/", "secure" => true}, ) end it "has string representation" do session = cookies["session"] expect(session.to_s).to be == "session=123;path=/;secure" end end with "session=abc123; secure" do it "can parse cookies" do expect(cookies).to have_keys("session") session = cookies["session"] expect(session).to have_attributes( name: be == "session", value: be == "abc123", ) expect(session.directives).to have_keys("secure") end it "has string representation" do session = cookies["session"] expect(session.to_s).to be == "session=abc123;secure" end end end protocol-http-0.55.0/test/protocol/http/header/date.rb000066400000000000000000000030071507641516600227230ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "protocol/http/header/date" describe Protocol::HTTP::Header::Date do let(:header) {subject.new(description)} with "Wed, 21 Oct 2015 07:28:00 GMT" do it "can parse time" do time = header.to_time expect(time).to be_a(::Time) expect(time).to have_attributes( year: be == 2015, month: be == 10, mday: be == 21, hour: be == 7, min: be == 28, sec: be == 0 ) end end with "#<<" do let(:header) {subject.new} it "can replace values" do header << "Wed, 21 Oct 2015 07:28:00 GMT" expect(header.to_time).to have_attributes( year: be == 2015, month: be == 10, mday: be == 21 ) header << "Wed, 22 Oct 2015 07:28:00 GMT" expect(header.to_time).to have_attributes( year: be == 2015, month: be == 10, mday: be == 22 ) end end describe Protocol::HTTP::Headers do let(:headers) {subject[[ ["Date", "Wed, 21 Oct 2015 07:28:00 GMT"], ["Expires", "Wed, 21 Oct 2015 07:28:00 GMT"], ["Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT"], ["If-Modified-Since", "Wed, 21 Oct 2015 07:28:00 GMT"], ["If-Unmodified-Since", "Wed, 21 Oct 2015 07:28:00 GMT"] ]] } it "should parse date headers" do # When you convert headers into a hash, the policy is applied (i.e. conversion to Date instances): headers.to_h.each do |key, value| expect(value).to be_a(Protocol::HTTP::Header::Date) end end end end protocol-http-0.55.0/test/protocol/http/header/digest.rb000066400000000000000000000107411507641516600232700ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http/header/digest" require "sus" describe Protocol::HTTP::Header::Digest do let(:header) {subject.new(description)} with "empty header" do let(:header) {subject.new} it "should be empty" do expect(header.to_s).to be == "" end it "should be an array" do expect(header).to be_a(Array) end it "should return empty entries" do expect(header.entries).to be == [] end end with "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" do it "can parse a single entry" do entries = header.entries expect(entries.size).to be == 1 expect(entries.first.algorithm).to be == "sha-256" expect(entries.first.value).to be == "X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" end end with "sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=, md5=9bb58f26192e4ba00f01e2e7b136bbd8" do it "can parse multiple entries" do entries = header.entries expect(entries.size).to be == 2 expect(entries[0].algorithm).to be == "sha-256" expect(entries[1].algorithm).to be == "md5" end end with "SHA-256=abc123" do it "normalizes algorithm to lowercase" do entries = header.entries expect(entries.first.algorithm).to be == "sha-256" end end with "sha-256 = X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" do it "handles whitespace around equals sign" do entries = header.entries expect(entries.first.algorithm).to be == "sha-256" expect(entries.first.value).to be == "X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" end end with "invalid-format-no-equals" do it "raises ParseError for invalid format" do expect do header.entries end.to raise_exception(Protocol::HTTP::Header::Digest::ParseError) end end with "#<<" do let(:header) {subject.new} it "can add entries from string" do header << "sha-256=abc123" header << "md5=def456" expect(header.size).to be == 2 entries = header.entries expect(entries[0].algorithm).to be == "sha-256" expect(entries[1].algorithm).to be == "md5" end it "can add multiple entries at once" do header << "sha-256=abc123, md5=def456" expect(header.size).to be == 2 entries = header.entries expect(entries[0].algorithm).to be == "sha-256" expect(entries[1].algorithm).to be == "md5" end end with "inherited Split behavior" do let(:header) {subject.new} it "behaves as an array" do header << "sha-256=abc123" expect(header.size).to be == 1 expect(header.first).to be == "sha-256=abc123" end it "can be enumerated" do header << "sha-256=abc123, md5=def456" values = [] header.each {|value| values << value} expect(values).to be == ["sha-256=abc123", "md5=def456"] end it "supports array methods" do header << "sha-256=abc123, md5=def456" expect(header.length).to be == 2 expect(header.empty?).to be == false end end with "trailer support" do it "should be allowed as a trailer" do expect(subject.trailer?).to be == true end end with "algorithm edge cases" do it "handles hyphenated algorithms" do header = subject.new("sha-256=abc123") entries = header.entries expect(entries.first.algorithm).to be == "sha-256" end it "handles numeric algorithms" do header = subject.new("md5=def456") entries = header.entries expect(entries.first.algorithm).to be == "md5" end end with "value edge cases" do it "handles empty values" do header = subject.new("sha-256=") entries = header.entries expect(entries.first.value).to be == "" end it "handles values with special characters" do header = subject.new("sha-256=abc+def/123==") entries = header.entries expect(entries.first.value).to be == "abc+def/123==" end end end describe Protocol::HTTP::Header::Digest::Entry do it "can create entry directly" do entry = subject.new("sha-256", "abc123") expect(entry.algorithm).to be == "sha-256" expect(entry.value).to be == "abc123" expect(entry.to_s).to be == "sha-256=abc123" end it "normalizes algorithm to lowercase" do entry = subject.new("SHA-256", "abc123") expect(entry.algorithm).to be == "sha-256" end it "handles complex algorithm names" do entry = subject.new("sha-384", "complex-value") expect(entry.algorithm).to be == "sha-384" expect(entry.to_s).to be == "sha-384=complex-value" end it "handles base64 padding in values" do entry = subject.new("md5", "abc123==") expect(entry.value).to be == "abc123==" end end protocol-http-0.55.0/test/protocol/http/header/etag.rb000066400000000000000000000011441507641516600227260ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "protocol/http/header/etag" describe Protocol::HTTP::Header::ETag do let(:header) {subject.new(description)} with 'W/"abcd"' do it "is weak" do expect(header).to be(:weak?) end end with '"abcd"' do it "is not weak" do expect(header).not.to be(:weak?) end end with "#<<" do let(:header) {subject.new} it "can replace values" do header << '"abcd"' expect(header).not.to be(:weak?) header << 'W/"abcd"' expect(header).to be(:weak?) end end end protocol-http-0.55.0/test/protocol/http/header/etags.rb000066400000000000000000000025461507641516600231200ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2020-2025, by Samuel Williams. # Copyright, 2023, by Thomas Morgan. require "protocol/http/header/etags" describe Protocol::HTTP::Header::ETags do let(:header) {subject.new(description)} with "*" do it "is a wildcard" do expect(header).to be(:wildcard?) end it "matches anything" do expect(header).to be(:match?, '"anything"') end end with '"abcd"' do it "is not a wildcard" do expect(header).not.to be(:wildcard?) end it "matches itself" do expect(header).to be(:match?, '"abcd"') end it "strongly matches only another strong etag" do expect(header).to be(:strong_match?, '"abcd"') expect(header).not.to be(:strong_match?, 'W/"abcd"') end it "weakly matches both weak and strong etags" do expect(header).to be(:weak_match?, '"abcd"') expect(header).to be(:weak_match?, 'W/"abcd"') end it "does not match anything else" do expect(header).not.to be(:match?, '"anything else"') end end with 'W/"abcd"' do it "never strongly matches" do expect(header).not.to be(:strong_match?, '"abcd"') expect(header).not.to be(:strong_match?, 'W/"abcd"') end it "weakly matches both weak and strong etags" do expect(header).to be(:weak_match?, '"abcd"') expect(header).to be(:weak_match?, 'W/"abcd"') end end end protocol-http-0.55.0/test/protocol/http/header/multiple.rb000066400000000000000000000012311507641516600236360ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2020-2025, by Samuel Williams. require "protocol/http/header/multiple" describe Protocol::HTTP::Header::Multiple do let(:header) {subject.new(description)} with "first-value" do it "can add several values" do header << "second-value" header << "third-value" expect(header).to be == ["first-value", "second-value", "third-value"] expect(header).to have_attributes( to_s: be == "first-value\nsecond-value\nthird-value" ) end end with ".trailer?" do it "is not allowed in trailers by default" do expect(subject).not.to be(:trailer?) end end end protocol-http-0.55.0/test/protocol/http/header/priority.rb000066400000000000000000000030661507641516600236740ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2025, by Samuel Williams. require "protocol/http/header/priority" describe Protocol::HTTP::Header::Priority do let(:header) {subject.new(description)} with "u=1, i" do it "correctly parses priority header" do expect(header).to have_attributes( urgency: be == 1, incremental?: be == true, ) end end with "u=0" do it "correctly parses priority header" do expect(header).to have_attributes( urgency: be == 0, incremental?: be == false, ) end end with "i" do it "correctly parses incremental flag" do expect(header).to have_attributes( # Default urgency level is used: urgency: be == 3, incremental?: be == true, ) end end with "u=6" do it "correctly parses urgency level" do expect(header).to have_attributes( urgency: be == 6, ) end end with "u=9, i" do it "gracefully handles non-standard urgency levels" do expect(header).to have_attributes( # Non-standard value is preserved urgency: be == 9, incremental?: be == true, ) end end with "u=2, u=5" do it "prioritizes the last urgency directive" do expect(header).to have_attributes( urgency: be == 5, ) end end with "#<<" do let(:header) {subject.new} it "can append values" do header << "u=4" expect(header).to have_attributes( urgency: be == 4, ) end it "can append incremental flag" do header << "i" expect(header).to have_attributes( incremental?: be == true, ) end end end protocol-http-0.55.0/test/protocol/http/header/server_timing.rb000066400000000000000000000152341507641516600246700ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http/header/server_timing" require "sus" describe Protocol::HTTP::Header::ServerTiming do let(:header) {subject.new(description)} with "empty header" do let(:header) {subject.new} it "should be empty" do expect(header.to_s).to be == "" end it "should be an array" do expect(header).to be_a(Array) end it "should return empty metrics" do expect(header.metrics).to be == [] end end with "db;dur=53.2" do it "can parse metric with duration" do metrics = header.metrics expect(metrics.size).to be == 1 expect(metrics.first.name).to be == "db" expect(metrics.first.duration).to be == 53.2 expect(metrics.first.description).to be_nil end end with 'db;dur="53.2"' do it "can parse metric with quoted duration" do metrics = header.metrics expect(metrics.size).to be == 1 expect(metrics.first.name).to be == "db" expect(metrics.first.duration).to be == 53.2 expect(metrics.first.description).to be_nil end end with 'cache;desc="Redis lookup"' do it "can parse metric with description" do metrics = header.metrics expect(metrics.size).to be == 1 expect(metrics.first.name).to be == "cache" expect(metrics.first.duration).to be_nil expect(metrics.first.description).to be == "Redis lookup" end end with 'app;dur=12.7;desc="Application logic"' do it "can parse metric with duration and description" do metrics = header.metrics expect(metrics.first.name).to be == "app" expect(metrics.first.duration).to be == 12.7 expect(metrics.first.description).to be == "Application logic" end end with 'app;dur="12.7";desc="Application logic"' do it "can parse metric with quoted duration and quoted description" do metrics = header.metrics expect(metrics.first.name).to be == "app" expect(metrics.first.duration).to be == 12.7 expect(metrics.first.description).to be == "Application logic" end end with "db;dur=45.3, app;dur=12.7;desc=\"Application logic\", cache;desc=\"Cache miss\"" do it "can parse multiple metrics" do metrics = header.metrics expect(metrics.size).to be == 3 expect(metrics[0].name).to be == "db" expect(metrics[0].duration).to be == 45.3 expect(metrics[0].description).to be_nil expect(metrics[1].name).to be == "app" expect(metrics[1].duration).to be == 12.7 expect(metrics[1].description).to be == "Application logic" expect(metrics[2].name).to be == "cache" expect(metrics[2].duration).to be_nil expect(metrics[2].description).to be == "Cache miss" end end with "cache-hit" do it "can parse metric with name only" do metrics = header.metrics expect(metrics.first.name).to be == "cache-hit" expect(metrics.first.duration).to be_nil expect(metrics.first.description).to be_nil end end with "invalid;unknown=param" do it "ignores unknown parameters" do metrics = header.metrics expect(metrics.first.name).to be == "invalid" expect(metrics.first.duration).to be_nil expect(metrics.first.description).to be_nil end end with "invalid-metric-name!" do it "raises ParseError for invalid metric name" do expect do header.metrics end.to raise_exception(Protocol::HTTP::Header::ServerTiming::ParseError) end end with "#<<" do let(:header) {subject.new} it "can add metrics from string" do header << "db;dur=25.5" header << "cache;dur=5.2;desc=\"Hit\"" expect(header.size).to be == 2 metrics = header.metrics expect(metrics[0].name).to be == "db" expect(metrics[1].name).to be == "cache" end it "can add multiple metrics at once" do header << "db;dur=25.5, cache;desc=\"Hit\"" expect(header.size).to be == 2 metrics = header.metrics expect(metrics[0].name).to be == "db" expect(metrics[1].name).to be == "cache" end end with "inherited Split behavior" do let(:header) {subject.new} it "behaves as an array" do header << "db;dur=25.5" expect(header.size).to be == 1 expect(header.first).to be == "db;dur=25.5" end it "can be enumerated" do header << "db;dur=25.5, cache;desc=\"Hit\"" values = [] header.each {|value| values << value} expect(values).to be == ["db;dur=25.5", "cache;desc=\"Hit\""] end it "supports array methods" do header << "db;dur=25.5, cache;desc=\"Hit\"" expect(header.length).to be == 2 expect(header.empty?).to be == false end end with "trailer support" do it "should be allowed as a trailer" do expect(subject.trailer?).to be == true end end with "cache_hit" do it "can parse metric with underscore in name" do metrics = header.metrics expect(metrics.first.name).to be == "cache_hit" end end with "test;desc=unquoted-value" do it "can parse unquoted description" do metrics = header.metrics expect(metrics.first.description).to be == "unquoted-value" end end with 'test;desc=""' do it "can parse empty quoted description" do metrics = header.metrics expect(metrics.first.description).to be == "" end end with "test;dur=123;desc=mixed;unknown=ignored" do it "ignores unknown parameters and processes known ones" do metrics = header.metrics expect(metrics.first.name).to be == "test" expect(metrics.first.duration).to be == 123.0 expect(metrics.first.description).to be == "mixed" end end with "test;dur=0" do it "can parse zero duration" do metrics = header.metrics expect(metrics.first.duration).to be == 0.0 end end with "test;dur=123.456789" do it "preserves decimal precision" do metrics = header.metrics expect(metrics.first.duration).to be == 123.456789 end end end describe Protocol::HTTP::Header::ServerTiming::Metric do it "can create metric directly" do metric = subject.new("test", 123.45, "Test metric") expect(metric.name).to be == "test" expect(metric.duration).to be == 123.45 expect(metric.description).to be == "Test metric" expect(metric.to_s).to be == "test;dur=123.45;desc=\"Test metric\"" end it "can create metric with name only" do metric = subject.new("cache") expect(metric.name).to be == "cache" expect(metric.duration).to be_nil expect(metric.description).to be_nil expect(metric.to_s).to be == "cache" end it "can create metric with duration only" do metric = subject.new("test", 123.45, nil) expect(metric.to_s).to be == "test;dur=123.45" end it "can create metric with description only" do metric = subject.new("test", nil, "description") expect(metric.to_s).to be == "test;desc=\"description\"" end it "handles nil values correctly" do metric = subject.new("test", nil, nil) expect(metric.to_s).to be == "test" end end protocol-http-0.55.0/test/protocol/http/header/te.rb000066400000000000000000000057541507641516600224310ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http/header/te" describe Protocol::HTTP::Header::TE do let(:header) {subject.new(description)} with "chunked" do it "detects chunked encoding" do expect(header).to be(:chunked?) end end with "gzip" do it "detects gzip encoding" do expect(header).to be(:gzip?) end end with "deflate" do it "detects deflate encoding" do expect(header).to be(:deflate?) end end with "trailers" do it "detects trailers acceptance" do expect(header).to be(:trailers?) end end with "compress" do it "detects compress encoding" do expect(header).to be(:compress?) end end with "identity" do it "detects identity encoding" do expect(header).to be(:identity?) end end with "gzip;q=0.8, chunked;q=1.0" do it "parses quality factors" do codings = header.transfer_codings expect(codings.length).to be == 2 expect(codings[0].name).to be == "gzip" expect(codings[0].quality_factor).to be == 0.8 expect(codings[1].name).to be == "chunked" expect(codings[1].quality_factor).to be == 1.0 end it "contains expected encodings" do expect(header).to be(:gzip?) expect(header).to be(:chunked?) end end with "gzip;q=0.5, deflate;q=0.8" do it "handles multiple quality factors" do codings = header.transfer_codings.sort expect(codings[0].name).to be == "deflate" # higher quality first expect(codings[1].name).to be == "gzip" end end with "empty header value" do let(:header) {subject.new} it "handles empty TE header" do expect(header).to be(:empty?) expect(header).not.to be(:chunked?) end end with "#<<" do let(:header) {subject.new} it "can add encodings" do header << "gzip" expect(header).to be(:gzip?) header << "chunked;q=0.9" expect(header).to be(:chunked?) end end with "error handling" do it "raises ParseError for invalid transfer coding" do header = subject.new("invalid@encoding") expect do header.transfer_codings end.to raise_exception(Protocol::HTTP::Header::TE::ParseError) end end with ".trailer?" do it "should be forbidden in trailers" do expect(subject).not.to be(:trailer?) end end end describe Protocol::HTTP::Header::TE::TransferCoding do it "handles quality factor conversion" do coding = subject.new("gzip", "0.8") expect(coding.quality_factor).to be == 0.8 end it "defaults quality factor to 1.0" do coding = subject.new("gzip", nil) expect(coding.quality_factor).to be == 1.0 end it "serializes with quality factor" do coding = subject.new("gzip", "0.8") expect(coding.to_s).to be == "gzip;q=0.8" end it "serializes without quality factor when 1.0" do coding = subject.new("gzip", nil) expect(coding.to_s).to be == "gzip" end it "compares by quality factor" do high = subject.new("gzip", "0.9") low = subject.new("deflate", "0.5") expect(high <=> low).to be == -1 # high quality first end end protocol-http-0.55.0/test/protocol/http/header/trailer.rb000066400000000000000000000032251507641516600234520ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http/header/trailer" describe Protocol::HTTP::Header::Trailer do let(:header) {subject.new(description)} with "etag" do it "contains etag header" do expect(header).to be(:include?, "etag") end it "has one header" do expect(header.length).to be == 1 end end with "etag, content-md5" do it "contains multiple headers" do expect(header).to be(:include?, "etag") expect(header).to be(:include?, "content-md5") end it "has correct count" do expect(header.length).to be == 2 end end with "etag, content-md5, expires" do it "handles three headers" do expect(header).to be(:include?, "etag") expect(header).to be(:include?, "content-md5") expect(header).to be(:include?, "expires") end it "serializes correctly" do expect(header.to_s).to be == "etag,content-md5,expires" end end with "etag , content-md5 , expires" do it "strips whitespace" do expect(header.length).to be == 3 expect(header).to be(:include?, "etag") expect(header).to be(:include?, "content-md5") end end with "empty header value" do let(:header) {subject.new} it "handles empty trailer" do expect(header).to be(:empty?) expect(header.to_s).to be == "" end end with "#<<" do let(:header) {subject.new("etag")} it "can add headers" do header << "content-md5, expires" expect(header.length).to be == 3 expect(header).to be(:include?, "expires") end end with ".trailer?" do it "should be forbidden in trailers" do expect(subject).not.to be(:trailer?) end end end protocol-http-0.55.0/test/protocol/http/header/transfer_encoding.rb000066400000000000000000000027761507641516600255140ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http/header/transfer_encoding" describe Protocol::HTTP::Header::TransferEncoding do let(:header) {subject.new(description)} with "chunked" do it "detects chunked encoding" do expect(header).to be(:chunked?) end end with "gzip" do it "detects gzip encoding" do expect(header).to be(:gzip?) end end with "deflate" do it "detects deflate encoding" do expect(header).to be(:deflate?) end end with "compress" do it "detects compress encoding" do expect(header).to be(:compress?) end end with "identity" do it "detects identity encoding" do expect(header).to be(:identity?) end end with "gzip, chunked" do it "handles multiple encodings" do expect(header.length).to be == 2 expect(header).to be(:include?, "gzip") expect(header).to be(:include?, "chunked") expect(header).to be(:gzip?) expect(header).to be(:chunked?) end end with "empty header value" do let(:header) {subject.new} it "handles empty transfer encoding" do expect(header).to be(:empty?) expect(header).not.to be(:chunked?) end end with "#<<" do let(:header) {subject.new} it "can add encodings" do header << "gzip" expect(header).to be(:gzip?) header << "chunked" expect(header).to be(:chunked?) end end with ".trailer?" do it "should be forbidden in trailers" do expect(subject).not.to be(:trailer?) end end end protocol-http-0.55.0/test/protocol/http/header/vary.rb000066400000000000000000000015421507641516600227710ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "protocol/http/header/vary" describe Protocol::HTTP::Header::Vary do let(:header) {subject.new(description)} with "#<<" do it "can append normalised header names" do header << "Accept-Language" expect(header).to be(:include?, "accept-language") end end with "accept-language" do it "should be case insensitive" do expect(header).to be(:include?, "accept-language") end it "should not have unspecific keys" do expect(header).not.to be(:include?, "user-agent") end end with "Accept-Language" do it "should be case insensitive" do expect(header).to be(:include?, "accept-language") end it "uses normalised lower case keys" do expect(header).not.to be(:include?, "Accept-Language") end end end protocol-http-0.55.0/test/protocol/http/headers.rb000066400000000000000000000274131507641516600222000ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. require "protocol/http/headers" require "protocol/http/cookie" describe Protocol::HTTP::Headers do let(:fields) do [ ["Content-Type", "text/html"], ["connection", "Keep-Alive"], ["Set-Cookie", "hello=world"], ["Accept", "*/*"], ["set-cookie", "foo=bar"], ] end let(:headers) {subject[fields]} with ".new" do it "can construct headers with trailers" do headers = subject.new(fields, 4) expect(headers).to be(:trailer?) expect(headers.trailer.to_a).to be == [ ["set-cookie", "foo=bar"], ] end end with ".[]" do it "can be constructed from frozen array" do self.fields.freeze expect(headers.fields).not.to be(:frozen?) end end with "#keys" do it "should return keys" do expect(headers.keys).to be == ["content-type", "connection", "set-cookie", "accept"] end end with "#trailer?" do it "should not be a trailer" do expect(headers).not.to be(:trailer?) expect(headers.tail).to be_nil end end with "#merge" do it "should merge headers" do other = subject[[ # This will be appended: ["Set-Cookie", "goodbye=world"], ]] merged = headers.merge(other) expect(merged.to_h).to be == { "content-type" => "text/html", "set-cookie" => ["hello=world", "foo=bar", "goodbye=world"], "accept" => ["*/*"], "connection" => ["keep-alive"] } end it "can't merge singleton headers" do other = subject[[ ["content-type", "text/plain"], ]] # This doesn't fail as we haven't built an internal index yet: merged = headers.merge(other) expect do # Once we build the index, it will fail: merged.to_h end.to raise_exception(Protocol::HTTP::DuplicateHeaderError) end end with "#extract" do it "can extract named fields" do # Force the headers to be indexed: headers.to_h expect(headers.extract(["content-type", "set-cookie"])).to be == [ ["Content-Type", "text/html"], ["Set-Cookie", "hello=world"], ["set-cookie", "foo=bar"], ] end end with "#clear" do it "should clear headers" do headers.clear expect(headers.fields).to be(:empty?) end end with "#freeze" do it "can't modify frozen headers" do headers.freeze expect(headers.fields).to be == fields expect(headers.fields).to be(:frozen?) expect(headers.to_h).to be(:frozen?) end it "returns duplicated headers if they are frozen" do headers.freeze expect(subject[headers]).not.to be(:frozen?) end end with "#dup" do it "should not modify source object" do headers = self.headers.dup headers["field"] = "value" expect(self.headers).not.to be(:include?, "field") end end with "#empty?" do it "shouldn't be empty" do expect(headers).not.to be(:empty?) end end with "#include?" do it "should include? named fields" do expect(headers).to be(:include?, "set-cookie") end end with "#key?" do it "should key? named fields" do expect(headers).to be(:key?, "set-cookie") end end with "#fields" do it "should add fields in order" do expect(headers.fields).to be == fields end it "can enumerate fields" do headers.each.with_index do |field, index| expect(field).to be == fields[index] end end end with "#to_a" do it "should return the fields array" do expect(headers.to_a).to be == fields end it "should return the same object as fields" do expect(headers.to_a).to be_equal(headers.fields) end it "should return an array" do expect(headers.to_a).to be_a(Array) end end with "#to_h" do it "should generate array values for duplicate keys" do expect(headers.to_h["set-cookie"]).to be == ["hello=world", "foo=bar"] end end with "#inspect" do it "should generate a string representation" do expect(headers.inspect).to be == "#" end end with "#[]" do it "can lookup fields" do expect(headers["content-type"]).to be == "text/html" end end with "#[]=" do it "can add field with a String value" do headers["Content-Length"] = "1" expect(headers.fields.last).to be == ["Content-Length", "1"] expect(headers["content-length"]).to be == "1" end it "can add field with an Integer value" do headers["Content-Length"] = 1 expect(headers.fields.last).to be == ["Content-Length", "1"] expect(headers["content-length"]).to be == "1" end it "can add field with indexed hash" do expect(headers.to_h).not.to be(:empty?) headers["Content-Length"] = "1" expect(headers["content-length"]).to be == "1" end end with "#add" do it "can add field" do headers.add("Content-Length", 1) expect(headers.fields.last).to be == ["Content-Length", "1"] expect(headers["content-length"]).to be == "1" end end with "#set" do it "can replace an existing field" do headers.add("accept-encoding", "gzip,deflate") headers.set("accept-encoding", "gzip") expect(headers["accept-encoding"]).to be == ["gzip"] end end with "#extract" do it "can extract key's that don't exist" do expect(headers.extract("foo")).to be(:empty?) end it "can extract single key" do expect(headers.extract("content-type")).to be == [["Content-Type", "text/html"]] end end with "#==" do it "can compare with array" do expect(headers).to be == fields end it "can compare with itself" do expect(headers).to be == headers end it "can compare with hash" do expect(headers).not.to be == {} end end with "#delete" do it "can delete case insensitive fields" do expect(headers.delete("content-type")).to be == "text/html" expect(headers.fields).to be == fields[1..-1] end it "can delete non-existant fields" do expect(headers.delete("transfer-encoding")).to be_nil end end with "#merge" do it "can merge content-length" do headers.merge!("content-length" => 2) expect(headers["content-length"]).to be == "2" end end with "#trailer!" do it "can add trailer" do headers.add("trailer", "etag") count = headers.fields.size trailer = headers.trailer! expect(headers.tail).to be == count headers.add("etag", "abcd") expect(trailer.to_h).to be == {"etag" => "abcd"} end it "can add trailer without explicit header" do trailer = headers.trailer! headers.add("etag", "abcd") expect(trailer.to_h).to be == {"etag" => "abcd"} end with "forbidden trailers" do let(:headers) {subject.new} forbidden_trailers = %w[ accept accept-charset accept-encoding accept-language authorization proxy-authorization www-authenticate proxy-authenticate connection content-length transfer-encoding te upgrade trailer host expect range content-type content-encoding content-range cookie set-cookie x-foo-bar ] forbidden_trailers.each do |key| it "can't add a #{key.inspect} header in the trailer", unique: key do trailer = headers.trailer! headers.add(key, "example") expect(headers).not.to be(:include?, key) end end end with "permitted trailers" do let(:headers) {subject.new} permitted_trailers = [ "date", "digest", "etag", "server-timing", ] permitted_trailers.each do |key| it "can add a #{key.inspect} header in the trailer", unique: key do trailer = headers.trailer! headers.add(key, "example") expect(headers).to be(:include?, key) end end end end with "#trailer" do it "can enumerate trailer" do headers.add("trailer", "etag") headers.trailer! headers.add("etag", "abcd") expect(headers.trailer.to_h).to be == {"etag" => "abcd"} end end with "custom policy" do let(:headers) {subject.new} # Create a custom header class that allows trailers let(:grpc_status_class) do Class.new(String) do def self.trailer? true end end end it "can set custom policy to allow additional trailer headers" do # Create custom policy that allows grpc-status as trailer custom_policy = Protocol::HTTP::Headers::POLICY.dup custom_policy["grpc-status"] = grpc_status_class # Set the custom policy headers.policy = custom_policy # Enable trailers headers.trailer! # Add grpc-status header (should be allowed with custom policy) headers.add("grpc-status", "0") # Verify it appears in trailers expect(headers).to be(:include?, "grpc-status") trailer_headers = {} headers.trailer do |key, value| trailer_headers[key] = value end expect(trailer_headers["grpc-status"]).to be == "0" end it "policy= clears indexed cache" do # Add some headers first headers.add("content-type", "text/html") # Force indexing hash1 = headers.to_h expect(hash1).to be(:include?, "content-type") # Change policy new_policy = {} headers.policy = new_policy # Add another header headers.add("x-custom", "value") # Verify cache was cleared and rebuilt hash2 = headers.to_h expect(hash2).to be(:include?, "content-type") expect(hash2).to be(:include?, "x-custom") end it "can read policy attribute" do original_policy = headers.policy expect(original_policy).to be == Protocol::HTTP::Headers::POLICY # Set new policy custom_policy = {"custom" => String} headers.policy = custom_policy # Verify policy was changed expect(headers.policy).to be == custom_policy expect(headers.policy).not.to be == original_policy end end with "#flatten!" do it "can flatten trailer" do headers.add("trailer", "etag") trailer = headers.trailer! headers.add("etag", "abcd") headers.flatten! expect(headers).not.to have_keys("trailer") expect(headers).to have_keys("etag") end end with "#flatten" do it "can flatten trailer" do headers.add("trailer", "etag") trailer = headers.trailer! headers.add("etag", "abcd") copy = headers.flatten expect(headers).to have_keys("trailer") expect(headers).to have_keys("etag") expect(copy).not.to have_keys("trailer") expect(copy).to have_keys("etag") end end with "set-cookie" do it "can extract parsed cookies" do expect(headers["set-cookie"]).to be_a(Protocol::HTTP::Header::Cookie) end end with "connection" do it "can extract connection options" do expect(headers["connection"]).to be_a(Protocol::HTTP::Header::Connection) end it "should normalize to lower case" do expect(headers["connection"]).to be == ["keep-alive"] end end end describe Protocol::HTTP::Headers::Merged do let(:merged) do Protocol::HTTP::Headers::Merged.new( Protocol::HTTP::Headers["content-type" => "text/html"], Protocol::HTTP::Headers["content-encoding" => "gzip"] ) end with "#flatten" do let(:flattened) {merged.flatten} it "can combine all headers" do expect(flattened).to be_a Protocol::HTTP::Headers expect(flattened.fields).to be == [ ["content-type", "text/html"], ["content-encoding", "gzip"] ] end end with "#clear" do it "can clear all headers" do merged.clear expect(merged.flatten).to be(:empty?) end end with "#each" do it "can iterate over all headers" do expect(merged.each.to_a).to be == [ ["content-type", "text/html"], ["content-encoding", "gzip"] ] end end with "non-normalized case" do let(:merged) do Protocol::HTTP::Headers::Merged.new( Protocol::HTTP::Headers["Content-Type" => "text/html"], Protocol::HTTP::Headers["Content-Encoding" => "gzip"] ) end it "can normalize case" do expect(merged.each.to_a).to be == [ ["content-type", "text/html"], ["content-encoding", "gzip"] ] end end end protocol-http-0.55.0/test/protocol/http/headers/000077500000000000000000000000001507641516600216445ustar00rootroot00000000000000protocol-http-0.55.0/test/protocol/http/headers/merged.rb000066400000000000000000000015341507641516600234370ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. require "protocol/http/headers" describe Protocol::HTTP::Headers::Merged do let(:fields) do [ ["Content-Type", "text/html"], ["Set-Cookie", "hello=world"], ["Accept", "*/*"], ["content-length", 10], ] end let(:merged) {subject.new(fields)} let(:headers) {Protocol::HTTP::Headers.new(merged)} with "#each" do it "should yield keys as lower case" do merged.each do |key, value| expect(key).to be == key.downcase end end it "should yield values as strings" do merged.each do |key, value| expect(value).to be_a(String) end end end with "#<<" do it "can append fields" do merged << [["Accept", "image/jpeg"]] expect(headers["accept"]).to be == ["*/*", "image/jpeg"] end end end protocol-http-0.55.0/test/protocol/http/http.rb000066400000000000000000000004041507641516600215330ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. require "protocol/http" describe Protocol::HTTP do it "has a version number" do expect(Protocol::HTTP::VERSION).to be =~ /\d+\.\d+\.\d+/ end end protocol-http-0.55.0/test/protocol/http/methods.rb000066400000000000000000000026451507641516600222300ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http/methods" ValidMethod = Sus::Shared("valid method") do |name| it "defines #{name} method" do expect(Protocol::HTTP::Methods.constants).to be(:include?, name.to_sym) end it "has correct value" do expect(Protocol::HTTP::Methods.const_get(name)).to be == name.to_s end it "is a valid method" do expect(Protocol::HTTP::Methods).to be(:valid?, name) end end describe Protocol::HTTP::Methods do it "defines several methods" do expect(subject.constants).not.to be(:empty?) end it_behaves_like ValidMethod, "GET" it_behaves_like ValidMethod, "POST" it_behaves_like ValidMethod, "PUT" it_behaves_like ValidMethod, "PATCH" it_behaves_like ValidMethod, "DELETE" it_behaves_like ValidMethod, "HEAD" it_behaves_like ValidMethod, "OPTIONS" it_behaves_like ValidMethod, "TRACE" it_behaves_like ValidMethod, "CONNECT" it "defines exactly 9 methods" do expect(subject.constants.length).to be == 9 end with ".valid?" do with "FOOBAR" do it "is not a valid method" do expect(subject).not.to be(:valid?, description) end end with "GETEMALL" do it "is not a valid method" do expect(subject).not.to be(:valid?, description) end end with "Accept:" do it "is not a valid method" do expect(subject).not.to be(:valid?, description) end end end end protocol-http-0.55.0/test/protocol/http/middleware.rb000066400000000000000000000026511507641516600226770ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2025, by Samuel Williams. require "protocol/http/middleware" describe Protocol::HTTP::Middleware do it "can wrap a block" do middleware = subject.for do |request| Protocol::HTTP::Response[200] end request = Protocol::HTTP::Request["GET", "/"] response = middleware.call(request) expect(response).to have_attributes( status: be == 200, ) end it "can invoke delegate" do request = :request delegate = subject.new(nil) expect(delegate).to(receive(:call) do |request| expect(request).to be_equal(request) end.and_return(nil)) middleware = subject.new(delegate) middleware.call(request) end it "can close delegate" do delegate = subject.new(nil) expect(delegate).to receive(:close).and_return(nil) middleware = subject.new(delegate) middleware.close end end describe Protocol::HTTP::Middleware::Okay do let(:middleware) {subject} it "responds with 200" do request = Protocol::HTTP::Request["GET", "/"] response = middleware.call(request) expect(response).to have_attributes( status: be == 200, ) end end describe Protocol::HTTP::Middleware::NotFound do let(:middleware) {subject} it "responds with 404" do request = Protocol::HTTP::Request["GET", "/"] response = middleware.call(request) expect(response).to have_attributes( status: be == 404, ) end end protocol-http-0.55.0/test/protocol/http/middleware/000077500000000000000000000000001507641516600223465ustar00rootroot00000000000000protocol-http-0.55.0/test/protocol/http/middleware/builder.rb000066400000000000000000000020311507641516600243150ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2019-2024, by Samuel Williams. require "protocol/http/middleware" require "protocol/http/middleware/builder" describe Protocol::HTTP::Middleware::Builder do it "can make an app" do app = Protocol::HTTP::Middleware.build do run Protocol::HTTP::Middleware::HelloWorld end expect(app).to be_equal(Protocol::HTTP::Middleware::HelloWorld) end it "defaults to not found" do app = Protocol::HTTP::Middleware.build do end expect(app).to be_equal(Protocol::HTTP::Middleware::NotFound) end it "can instantiate middleware" do app = Protocol::HTTP::Middleware.build do use Protocol::HTTP::Middleware end expect(app).to be_a(Protocol::HTTP::Middleware) end it "provides the builder as an argument" do current_self = self app = Protocol::HTTP::Middleware.build do |builder| builder.use Protocol::HTTP::Middleware expect(self).to be_equal(current_self) end expect(app).to be_a(Protocol::HTTP::Middleware) end end protocol-http-0.55.0/test/protocol/http/peer.rb000066400000000000000000000007511507641516600215140ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2024-2025, by Samuel Williams. require "protocol/http/peer" require "socket" describe Protocol::HTTP::Peer do it "can be created from IO" do address = Addrinfo.tcp("192.168.1.1", 80) io = Socket.new(:AF_INET, :SOCK_STREAM) expect(io).to receive(:remote_address).and_return(address) peer = Protocol::HTTP::Peer.for(io) expect(peer).to have_attributes( address: be_equal(address), ) end end protocol-http-0.55.0/test/protocol/http/quoted_string.rb000066400000000000000000000020131507641516600234410ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2025, by Samuel Williams. require "protocol/http/quoted_string" describe Protocol::HTTP::QuotedString do with ".unquote" do it "ignores linear whitespace" do quoted_string = subject.unquote(%Q{"Hello\r\n World"}) expect(quoted_string).to be == "Hello World" end end with ".quote" do it "doesn't quote a string that has no special characters" do quoted_string = subject.quote("Hello") expect(quoted_string).to be == "Hello" end it "quotes a string with a space" do quoted_string = subject.quote("Hello World") expect(quoted_string).to be == %Q{"Hello World"} end it "quotes a string with a double quote" do quoted_string = subject.quote(%Q{Hello "World"}) expect(quoted_string).to be == %Q{"Hello \\"World\\""} end it "quotes a string with a backslash" do quoted_string = subject.quote(%Q{Hello \\World}) expect(quoted_string).to be == %Q{"Hello \\\\World"} end end end protocol-http-0.55.0/test/protocol/http/request.rb000066400000000000000000000101231507641516600222430ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "protocol/http/request" require "json" describe Protocol::HTTP::Request do let(:headers) {Protocol::HTTP::Headers.new} let(:body) {nil} with ".[]" do let(:body) {Protocol::HTTP::Body::Buffered.wrap("Hello, World!")} let(:headers) {Protocol::HTTP::Headers[{"accept" => "text/html"}]} it "creates a new request" do request = subject["GET", "/index.html", headers] expect(request).to have_attributes( scheme: be_nil, authority: be_nil, method: be == "GET", path: be == "/index.html", version: be_nil, headers: be == headers, body: be_nil, protocol: be_nil ) end it "creates a new request with keyword arguments" do request = subject["GET", "/index.html", scheme: "http", authority: "localhost", headers: headers, body: body] expect(request).to have_attributes( scheme: be == "http", authority: be == "localhost", method: be == "GET", path: be == "/index.html", version: be_nil, headers: be == headers, body: be == body, protocol: be_nil ) end it "converts header hash to headers instance" do request = subject["GET", "/index.html", {"accept" => "text/html"}] expect(request).to have_attributes( headers: be == headers, ) end it "converts array body to buffered body" do request = subject["GET", "/index.html", headers: headers, body: ["Hello, World!"]] expect(request).to have_attributes( body: be_a(Protocol::HTTP::Body::Buffered) ) end it "can accept no arguments" do request = subject["GET"] expect(request).to have_attributes( method: be == "GET", path: be_nil, ) end it "converts path to string" do request = subject["GET", :index] expect(request).to have_attributes( method: be == "GET", path: be == "index", ) end end with "simple GET request" do let(:request) {subject.new("http", "localhost", "GET", "/index.html", "HTTP/1.0", headers, body)} it "should have attributes" do expect(request).to have_attributes( scheme: be == "http", authority: be == "localhost", method: be == "GET", path: be == "/index.html", version: be == "HTTP/1.0", headers: be == headers, body: be == body, protocol: be_nil, peer: be_nil, ) end with "#as_json" do it "generates a JSON representation" do expect(request.as_json).to be == { scheme: "http", authority: "localhost", method: "GET", path: "/index.html", version: "HTTP/1.0", headers: headers.as_json, body: nil, protocol: nil } end it "generates a JSON string" do expect(JSON.dump(request)).to be == request.to_json end end it "should not be HEAD" do expect(request).not.to be(:head?) end it "should not be CONNECT" do expect(request).not.to be(:connect?) end it "should be idempotent" do expect(request).to be(:idempotent?) end it "should have a string representation" do expect(request.to_s).to be == "http://localhost: GET /index.html HTTP/1.0" end it "can apply the request to a connection" do connection = proc{|request| request} expect(connection).to receive(:call).with(request) request.call(connection) end end with "interim response" do let(:request) {subject.new("http", "localhost", "GET", "/index.html", "HTTP/1.0", headers, body)} it "should call block" do request.on_interim_response do |status, headers| expect(status).to be == 100 expect(headers).to be == {} end request.send_interim_response(100, {}) end it "calls multiple blocks" do sequence = [] request.on_interim_response do |status, headers| sequence << 1 expect(status).to be == 100 expect(headers).to be == {} end request.on_interim_response do |status, headers| sequence << 2 expect(status).to be == 100 expect(headers).to be == {} end request.send_interim_response(100, {}) expect(sequence).to be == [2, 1] end end end protocol-http-0.55.0/test/protocol/http/response.rb000066400000000000000000000173741507641516600224300ustar00rootroot00000000000000# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "protocol/http/response" require "protocol/http/request" describe Protocol::HTTP::Response do let(:headers) {Protocol::HTTP::Headers.new} let(:body) {nil} InformationalResponse = Sus::Shared("informational response") do it "should be informational" do expect(response).to be(:informational?) expect(response.as_json).to have_keys(status: be_within(100...200)) end it "should not be a failure" do expect(response).not.to be(:failure?) end end SuccessfulResponse = Sus::Shared("successful response") do it "should be successful" do expect(response).to be(:success?) expect(response.as_json).to have_keys(status: be_within(200...300)) end it "should be final" do expect(response).to be(:final?) end it "should not be informational" do expect(response).not.to be(:informational?) end it "should not be a failure" do expect(response).not.to be(:failure?) end end RedirectionResponse = Sus::Shared("redirection response") do it "should be final" do expect(response).to be(:final?) end it "should be a redirection" do expect(response).to be(:redirection?) expect(response.as_json).to have_keys(status: be_within(300...400)) end it "should not be informational" do expect(response).not.to be(:informational?) end it "should not be a failure" do expect(response).not.to be(:failure?) end end FailureResponse = Sus::Shared("failure response") do it "should not be successful" do expect(response).not.to be(:success?) end it "should be final" do expect(response).to be(:final?) end it "should not be informational" do expect(response).not.to be(:informational?) end it "should be a failure" do expect(response).to be(:failure?) expect(response.as_json).to have_keys(status: be_within(400...600)) end end RedirectUsingOriginalMethod = Sus::Shared("redirect using original method") do it "should preserve the method when following the redirect" do expect(response).to be(:preserve_method?) end end RedirectUsingGetAllowed = Sus::Shared("redirect using get allowed") do it "should not preserve the method when following the redirect" do expect(response).not.to be(:preserve_method?) end end with "100 Continue" do let(:response) {subject.new("HTTP/1.1", 100, headers)} it "should have attributes" do expect(response).to have_attributes( version: be == "HTTP/1.1", status: be == 100, headers: be == headers, body: be == nil, protocol: be == nil ) end with "#as_json" do it "generates a JSON representation" do expect(response.as_json).to have_keys( version: be == "HTTP/1.1", status: be == 100, headers: be == headers.as_json, body: be == nil, protocol: be == nil, ) end it "generates a JSON string" do expect(JSON.dump(response)).to be == response.to_json end end it_behaves_like InformationalResponse it "should be a continue" do expect(response).to be(:continue?) end it "should have a String representation" do expect(response.to_s).to be == "100 HTTP/1.1" end it "should have an Array representation" do expect(response.to_ary).to be == [100, headers, nil] end end with "301 Moved Permanently" do let(:response) {subject.new("HTTP/1.1", 301, headers, body)} it_behaves_like RedirectionResponse it_behaves_like RedirectUsingGetAllowed end with "302 Moved Permanently" do let(:response) {subject.new("HTTP/1.1", 301, headers, body)} it_behaves_like RedirectionResponse it_behaves_like RedirectUsingGetAllowed end with "307 Temporary Redirect" do let(:response) {subject.new("HTTP/1.1", 307, headers, body)} it_behaves_like RedirectionResponse it_behaves_like RedirectUsingOriginalMethod end with "308 Permanent Redirect" do let(:response) {subject.new("HTTP/1.1", 308, headers, body)} it_behaves_like RedirectionResponse it_behaves_like RedirectUsingOriginalMethod end with "200 OK" do let(:body) {Protocol::HTTP::Body::Buffered.wrap("Hello, World!")} let(:response) {subject.new("HTTP/1.0", 200, headers, body)} it "should have attributes" do expect(response).to have_attributes( version: be == "HTTP/1.0", status: be == 200, headers: be == headers, body: be == body, protocol: be_nil, peer: be_nil, ) end with "#as_json" do it "generates a JSON representation" do expect(response.as_json).to have_keys( version: be == "HTTP/1.0", status: be == 200, headers: be == headers.as_json, body: be == body.as_json, protocol: be == nil, ) end it "generates a JSON string" do expect(JSON.dump(response)).to be == response.to_json end end it_behaves_like SuccessfulResponse it "should be ok" do expect(response).to be(:ok?) end it "should not be a redirection" do expect(response).not.to be(:redirection?) end it "should not be a hijack" do expect(response).not.to be(:hijack?) end it "should not be a continue" do expect(response).not.to be(:continue?) end it "should have a String representation" do expect(response.to_s).to be == "200 HTTP/1.0" end it "should have an Array representation" do expect(response.to_ary).to be == [200, headers, body] end end with "400 Bad Request" do let(:response) {subject.new("HTTP/1.1", 400, headers, body)} it_behaves_like FailureResponse it "should be a bad request" do expect(response).to be(:bad_request?) end end with "500 Internal Server Error" do let(:response) {subject.new("HTTP/1.1", 500, headers, body)} it_behaves_like FailureResponse it "should be an internal server error" do expect(response).to be(:internal_server_error?) end end with ".for_exception" do let(:exception) {StandardError.new("Something went wrong")} let(:response) {subject.for_exception(exception)} it "should have a 500 status" do expect(response.status).to be == 500 expect(response.body.read).to be =~ /Something went wrong/ end end with "unmodified cached response" do let(:response) {subject.new("HTTP/1.1", 304, headers, body)} it "should have attributes" do expect(response).to have_attributes( version: be == "HTTP/1.1", status: be == 304, headers: be == headers, body: be == body, protocol: be == nil ) end it "should not be successful" do expect(response).not.to be(:success?) end it "should be a redirection" do expect(response).to be(:redirection?) end it "should be not modified" do expect(response).to be(:not_modified?) end end with ".[]" do let(:body) {Protocol::HTTP::Body::Buffered.wrap("Hello, World!")} let(:headers) {Protocol::HTTP::Headers[{"accept" => "text/html"}]} it "creates a new response" do response = subject[200, headers] expect(response).to have_attributes( version: be_nil, status: be == 200, headers: be == headers, body: be_nil, protocol: be_nil ) end it "creates a new response with keyword arguments" do response = subject[200, headers: headers, body: body] expect(response).to have_attributes( version: be_nil, status: be == 200, headers: be == headers, body: be == body, protocol: be_nil ) end it "converts header hash to headers instance" do response = subject[200, {"accept" => "text/html"}] expect(response).to have_attributes( headers: be == headers, ) end it "converts array body to buffered body" do response = subject[200, headers: headers, body: ["Hello, World!"]] expect(response).to have_attributes( body: be_a(Protocol::HTTP::Body::Buffered) ) end end end