diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml new file mode 100644 index 0000000..81f3c65 --- /dev/null +++ b/.github/workflows/coverage.yaml @@ -0,0 +1,57 @@ +name: Coverage + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + COVERAGE: PartialSummary + +jobs: + test: + name: ${{matrix.ruby}} on ${{matrix.os}} + runs-on: ${{matrix.os}}-latest + + strategy: + matrix: + os: + - ubuntu + - macos + + ruby: + - "3.2" + + steps: + - uses: actions/checkout@v3 + - 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@v2 + with: + name: coverage-${{matrix.os}}-${{matrix.ruby}} + path: .covered.db + + validate: + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.2" + bundler-cache: true + + - uses: actions/download-artifact@v3 + + - name: Validate coverage + timeout-minutes: 5 + run: bundle exec bake covered:validate --paths */.covered.db \; diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml new file mode 100644 index 0000000..3d483fc --- /dev/null +++ b/.github/workflows/documentation.yaml @@ -0,0 +1,61 @@ +name: Documentation + +on: + push: + branches: + - main + + # Allows you to run this workflow manually from the Actions tab: + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment: +concurrency: + group: "pages" + cancel-in-progress: true + +env: + CONSOLE_OUTPUT: XTerm + BUNDLE_WITH: maintenance + +jobs: + generate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.2" + 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@v1 + 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@v1 diff --git a/.github/workflows/test-external.yaml b/.github/workflows/test-external.yaml new file mode 100644 index 0000000..cbff675 --- /dev/null +++ b/.github/workflows/test-external.yaml @@ -0,0 +1,36 @@ +name: Test External + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + +jobs: + test: + name: ${{matrix.ruby}} on ${{matrix.os}} + runs-on: ${{matrix.os}}-latest + + strategy: + matrix: + os: + - ubuntu + - macos + + ruby: + - "3.0" + - "3.1" + - "3.2" + + steps: + - uses: actions/checkout@v3 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{matrix.ruby}} + bundler-cache: true + + - name: Run tests + timeout-minutes: 10 + run: bundle exec bake test:external diff --git a/.github/workflows/development.yml b/.github/workflows/test.yaml similarity index 72% rename from .github/workflows/development.yml rename to .github/workflows/test.yaml index 3cf1d52..942ede7 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/test.yaml @@ -1,9 +1,16 @@ -name: Development +name: Test on: [push, pull_request] +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + jobs: test: + name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest continue-on-error: ${{matrix.experimental}} @@ -14,13 +21,11 @@ jobs: - macos ruby: - - 2.5 - - 2.6 - - 2.7 - - 3.0 + - "3.0" + - "3.1" + - "3.2" experimental: [false] - env: [""] include: - os: ubuntu @@ -34,12 +39,12 @@ jobs: experimental: true steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} bundler-cache: true - name: Run tests - timeout-minutes: 5 - run: ${{matrix.env}} bundle exec rspec + timeout-minutes: 10 + run: bundle exec bake test diff --git a/.gitignore b/.gitignore index 81ae7ae..ae7250a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,7 @@ /.bundle/ -/.yardoc -/_yardoc/ -/coverage/ -/doc/ /pkg/ -/spec/reports/ -/tmp/ - -# rspec failure tracking -.rspec_status /gems.locked -.covered.db +/.covered.db +/external + +/fuzz/request/output diff --git a/.rspec b/.rspec deleted file mode 100644 index 8fbe32d..0000000 --- a/.rspec +++ /dev/null @@ -1,3 +0,0 @@ ---format documentation ---warnings ---require spec_helper \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index e91fe9e..0000000 --- a/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# Protocol::HTTP1 - -Provides a low-level implementation of the HTTP/1 protocol. - -[![Development Status](https://github.com/socketry/protocol-http1/workflows/Development/badge.svg)](https://github.com/socketry/protocol-http1/actions?workflow=Development) - -## Installation - -Add this line to your application's Gemfile: - -``` ruby -gem 'protocol-http1' -``` - -And then execute: - - $ bundle - -Or install it yourself as: - - $ gem install protocol-http1 - -## Usage - -Here is a basic HTTP/1.1 client: - -``` ruby -require 'async' -require 'async/io/stream' -require 'async/http/endpoint' -require 'protocol/http1/connection' - -Async do - endpoint = Async::HTTP::Endpoint.parse("https://www.google.com/search?q=kittens", alpn_protocols: ["http/1.1"]) - - peer = endpoint.connect - - puts "Connected to #{peer} #{peer.remote_address.inspect}" - - # IO Buffering... - stream = Async::IO::Stream.new(peer) - client = Protocol::HTTP1::Connection.new(stream) - - def client.read_line - @stream.read_until(Protocol::HTTP1::Connection::CRLF) or raise EOFError - end - - puts "Writing request..." - client.write_request("www.google.com", "GET", "/search?q=kittens", "HTTP/1.1", [["Accept", "*/*"]]) - client.write_body(nil) - - puts "Reading response..." - response = client.read_response("GET") - - puts "Got response: #{response.inspect}" - - puts "Closing client..." - client.close -end -``` - -## Contributing - -1. Fork it -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create new Pull Request - -## License - -Released under the MIT license. - -Copyright, 2019, by [Samuel G. D. Williams](http://www.codeotaku.com/samuel-williams). - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/config/external.yaml b/config/external.yaml new file mode 100644 index 0000000..0ab0782 --- /dev/null +++ b/config/external.yaml @@ -0,0 +1,3 @@ +async-http: + url: https://github.com/socketry/async-http.git + command: bundle exec rspec diff --git a/config/sus.rb b/config/sus.rb new file mode 100644 index 0000000..bb035bd --- /dev/null +++ b/config/sus.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023, by Samuel Williams. + +require 'covered/sus' +include Covered::Sus diff --git a/examples/early-hints/server.rb b/examples/early-hints/server.rb new file mode 100755 index 0000000..a992e13 --- /dev/null +++ b/examples/early-hints/server.rb @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022, by Samuel Williams. + +$LOAD_PATH.unshift File.expand_path("../../lib", __dir__) + +require 'async' +require 'async/http/endpoint' + +RESPONSE = <<~HTTP.split(/\r?\n/).join("\r\n") +HTTP/1.1 103 Early Hints +Link: ; rel=preload; as=style +Link: ; rel=preload; as=script + +HTTP/1.1 200 OK +Content-Length: 11 +Connection: close +Content-Type: text/plain + +Hello World +HTTP + +p RESPONSE + +Async do + endpoint = Async::HTTP::Endpoint.parse("http://localhost:3000") + + endpoint.accept do |peer| + peer.write(RESPONSE) + end +end diff --git a/examples/http1/request.rb b/examples/http1/request.rb index 5007ca3..ad36c14 100644 --- a/examples/http1/request.rb +++ b/examples/http1/request.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2019-2022, by Samuel Williams. + $LOAD_PATH.unshift File.expand_path("../../lib", __dir__) require 'async' diff --git a/fixtures/connection_context.rb b/fixtures/connection_context.rb new file mode 100644 index 0000000..deb7b56 --- /dev/null +++ b/fixtures/connection_context.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. + +require 'protocol/http1/connection' + +require 'socket' + +ConnectionContext = Sus::Shared("a connection") do + let(:sockets) {Socket.pair(Socket::PF_UNIX, Socket::SOCK_STREAM)} + + let(:client) {Protocol::HTTP1::Connection.new(sockets.first)} + let(:server) {Protocol::HTTP1::Connection.new(sockets.last)} +end diff --git a/fuzz/request/bake.rb b/fuzz/request/bake.rb index 759d5c6..156cf6d 100644 --- a/fuzz/request/bake.rb +++ b/fuzz/request/bake.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2022, by Samuel Williams. # Run the fuzz test. def run diff --git a/fuzz/request/script.rb b/fuzz/request/script.rb index 56e7c76..86f00ab 100755 --- a/fuzz/request/script.rb +++ b/fuzz/request/script.rb @@ -1,4 +1,8 @@ #!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2022, by Samuel Williams. require 'socket' require_relative '../../lib/protocol/http1' diff --git a/gems.rb b/gems.rb index bb7f0ab..417ca24 100644 --- a/gems.rb +++ b/gems.rb @@ -1,10 +1,26 @@ # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. + source "https://rubygems.org" gemspec group :maintenance, optional: true do gem "bake-modernize" - gem "bake-bundler" + gem "bake-gem" + + gem "bake-github-pages" + gem "utopia-project" +end + +group :test do + gem "covered" + gem "sus" + + gem "bake-test" + gem "bake-test-external" + + gem "stringio", "~> 3.0.7" end diff --git a/lib/protocol/http1.rb b/lib/protocol/http1.rb index 19b31de..3edf1aa 100644 --- a/lib/protocol/http1.rb +++ b/lib/protocol/http1.rb @@ -1,24 +1,7 @@ # frozen_string_literal: true -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Released under the MIT License. +# Copyright, 2019-2022, by Samuel Williams. require_relative 'http1/version' require_relative 'http1/connection' diff --git a/lib/protocol/http1/body/chunked.rb b/lib/protocol/http1/body/chunked.rb index 9fc38a0..c31177b 100644 --- a/lib/protocol/http1/body/chunked.rb +++ b/lib/protocol/http1/body/chunked.rb @@ -1,24 +1,7 @@ # frozen_string_literal: true -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Released under the MIT License. +# Copyright, 2019-2022, by Samuel Williams. require 'protocol/http/body/readable' @@ -52,12 +35,20 @@ def close(error = nil) super end + VALID_CHUNK_LENGTH = /\A[0-9a-fA-F]+\z/ + # Follows the procedure outlined in https://tools.ietf.org/html/rfc7230#section-4.1.3 def read return nil if @finished + length, extensions = read_line.split(";", 2) + + unless length =~ VALID_CHUNK_LENGTH + raise BadRequest, "Invalid chunk length: #{length.dump}" + end + # It is possible this line contains chunk extension, so we use `to_i` to only consider the initial integral part: - length = read_line.to_i(16) + length = Integer(length, 16) if length == 0 @finished = true diff --git a/lib/protocol/http1/body/fixed.rb b/lib/protocol/http1/body/fixed.rb index 6e1ba5d..a0afa1a 100644 --- a/lib/protocol/http1/body/fixed.rb +++ b/lib/protocol/http1/body/fixed.rb @@ -1,24 +1,7 @@ # frozen_string_literal: true -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. require 'protocol/http/body/readable' @@ -40,6 +23,7 @@ def empty? end def close(error = nil) + # If we are closing the body without fully reading it, the underlying connection is now in an undefined state. if @remaining != 0 @stream.close end @@ -47,14 +31,16 @@ def close(error = nil) super end + # @raises EOFError if the stream is closed before the expected length is read. def read if @remaining > 0 + # `readpartial` will raise `EOFError` if the stream is closed/finished: if chunk = @stream.readpartial(@remaining) @remaining -= chunk.bytesize return chunk - else - raise EOFError, "Stream closed with #{@remaining} bytes remaining!" + # else + # raise EOFError, "Stream closed with #{@remaining} bytes remaining!" end end end diff --git a/lib/protocol/http1/body/remainder.rb b/lib/protocol/http1/body/remainder.rb index 53c5dca..9467b0c 100644 --- a/lib/protocol/http1/body/remainder.rb +++ b/lib/protocol/http1/body/remainder.rb @@ -1,24 +1,7 @@ # frozen_string_literal: true -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Released under the MIT License. +# Copyright, 2019-2022, by Samuel Williams. require 'protocol/http/body/readable' @@ -47,7 +30,8 @@ def close(error = nil) # TODO this is a bit less efficient in order to maintain compatibility with `IO`. def read @stream.readpartial(BLOCK_SIZE) - rescue EOFError + rescue EOFError, IOError + # I noticed that in some cases you will get EOFError, and in other cases IOError!? return nil end diff --git a/lib/protocol/http1/connection.rb b/lib/protocol/http1/connection.rb index 24329a5..045a8ec 100644 --- a/lib/protocol/http1/connection.rb +++ b/lib/protocol/http1/connection.rb @@ -1,24 +1,9 @@ # frozen_string_literal: true -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. +# Copyright, 2019, by Brian Morearty. +# Copyright, 2020, by Bruno Sutic. require 'protocol/http/headers' @@ -47,7 +32,7 @@ module HTTP1 UPGRADE = 'upgrade' # HTTP/1.x request line parser: - TOKEN = /[!#$%&'*+-\.^_`|~0-9a-zA-Z]+/.freeze + TOKEN = /[!#$%&'*+\-\.\^_`|~0-9a-zA-Z]+/.freeze REQUEST_LINE = /\A(#{TOKEN}) ([^\s]+) (HTTP\/\d.\d)\z/.freeze # HTTP/1.x header parser: @@ -79,12 +64,6 @@ def initialize(stream, persistent = true) # The number of requests processed. attr :count - def upgrade?(headers) - if upgrade = headers[UPGRADE] - return upgrade - end - end - def persistent?(version, method, headers) if method == HTTP::Methods::CONNECT return false @@ -297,7 +276,7 @@ def write_fixed_length_body(body, length, head) chunk_length += chunk.bytesize if chunk_length > length - raise Error, "Trying to write #{chunk_length} bytes, but content length was #{length} bytes!" + raise ContentLengthError, "Trying to write #{chunk_length} bytes, but content length was #{length} bytes!" end @stream.write(chunk) @@ -307,7 +286,7 @@ def write_fixed_length_body(body, length, head) @stream.flush if chunk_length != length - raise Error, "Wrote #{chunk_length} bytes, but content length was #{length} bytes!" + raise ContentLengthError, "Wrote #{chunk_length} bytes, but content length was #{length} bytes!" end end @@ -334,7 +313,7 @@ def write_chunked_body(body, head, trailer = nil) @stream.flush unless body.ready? end - if trailer + if trailer&.any? @stream.write("0\r\n") write_headers(trailer) @stream.write("\r\n") @@ -366,17 +345,26 @@ def write_body_and_close(body, head) end def write_body(version, body, head = false, trailer = nil) + # HTTP/1.0 cannot in any case handle trailers. + if version == HTTP10 # or te: trailers was not present (strictly speaking not required.) + trailer = nil + end + + # While writing the body, we don't know if trailers will be added. We must choose a different body format depending on whether there is the chance of trailers, even if trailer.any? is currently false. + # + # Below you notice `and trailer.nil?`. I tried this but content-length is more important than trailers. + if body.nil? write_connection_header(version) write_empty_body(body) - elsif length = body.length and trailer.nil? + elsif length = body.length # and trailer.nil? write_connection_header(version) write_fixed_length_body(body, length, head) elsif body.empty? # Even thought this code is the same as the first clause `body.nil?`, HEAD responses have an empty body but still carry a content length. `write_fixed_length_body` takes care of this appropriately. write_connection_header(version) write_empty_body(body) - elsif @persistent and version == HTTP11 + elsif version == HTTP11 write_connection_header(version) # We specifically ensure that non-persistent connections do not use chunked response, so that hijacking works as expected. write_chunked_body(body, head, trailer) @@ -407,13 +395,21 @@ def read_tunnel_body read_remainder_body end - def read_upgrade_body(protocol) - read_remainder_body - end - HEAD = "HEAD" CONNECT = "CONNECT" + VALID_CONTENT_LENGTH = /\A\d+\z/ + + def extract_content_length(headers) + if content_length = headers.delete(CONTENT_LENGTH) + if content_length =~ VALID_CONTENT_LENGTH + yield Integer(content_length, 10) + else + raise BadRequest, "Invalid content length: #{content_length}" + end + end + end + def read_response_body(method, status, headers) # RFC 7230 3.3.3 # 1. Any response to a HEAD request and any response with a 1xx @@ -422,19 +418,16 @@ def read_response_body(method, status, headers) # header fields, regardless of the header fields present in the # message, and thus cannot contain a message body. if method == HTTP::Methods::HEAD - if content_length = headers.delete(CONTENT_LENGTH) - length = Integer(content_length) - + extract_content_length(headers) do |length| if length > 0 return read_head_body(length) - elsif length == 0 - return nil else - raise BadRequest, "Invalid content length: #{content_length}" + return nil end - else - return nil end + + # There is no body for a HEAD request if there is no content length: + return nil end if (status >= 100 and status < 200) or status == 204 or status == 304 @@ -506,14 +499,11 @@ def read_body(headers, remainder = false) # the recipient times out before the indicated number of octets are # received, the recipient MUST consider the message to be # incomplete and close the connection. - if content_length = headers.delete(CONTENT_LENGTH) - length = Integer(content_length) + extract_content_length(headers) do |length| if length > 0 return read_fixed_body(length) - elsif length == 0 - return nil else - raise BadRequest, "Invalid content length: #{content_length}" + return nil end end diff --git a/lib/protocol/http1/error.rb b/lib/protocol/http1/error.rb index 7d74930..87e1881 100644 --- a/lib/protocol/http1/error.rb +++ b/lib/protocol/http1/error.rb @@ -1,24 +1,7 @@ # frozen_string_literal: true -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. require 'protocol/http/error' @@ -30,6 +13,10 @@ class Error < HTTP::Error class InvalidRequest < Error end + # The specified content length and the given content's length do not match. + class ContentLengthError < Error + end + # The request was parsed correctly, but was invalid for some other reason. class BadRequest < Error end diff --git a/lib/protocol/http1/reason.rb b/lib/protocol/http1/reason.rb index b80acdd..7366317 100644 --- a/lib/protocol/http1/reason.rb +++ b/lib/protocol/http1/reason.rb @@ -1,24 +1,7 @@ # frozen_string_literal: true -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Released under the MIT License. +# Copyright, 2019-2022, by Samuel Williams. require 'protocol/http/error' diff --git a/lib/protocol/http1/version.rb b/lib/protocol/http1/version.rb index 70a904d..dd7b3bd 100644 --- a/lib/protocol/http1/version.rb +++ b/lib/protocol/http1/version.rb @@ -1,27 +1,10 @@ # frozen_string_literal: true -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. module Protocol module HTTP1 - VERSION = "0.14.1" + VERSION = "0.15.1" end end diff --git a/license.md b/license.md new file mode 100644 index 0000000..d473a69 --- /dev/null +++ b/license.md @@ -0,0 +1,24 @@ +# MIT License + +Copyright, 2019-2023, by Samuel Williams. +Copyright, 2019, by Brian Morearty. +Copyright, 2020, by Olle Jonsson. +Copyright, 2020, by Bruno Sutic. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/protocol-http1.gemspec b/protocol-http1.gemspec index 683e6a8..e2ef0b0 100644 --- a/protocol-http1.gemspec +++ b/protocol-http1.gemspec @@ -1,3 +1,4 @@ +# frozen_string_literal: true require_relative "lib/protocol/http1/version" @@ -6,20 +7,17 @@ Gem::Specification.new do |spec| spec.version = Protocol::HTTP1::VERSION spec.summary = "A low level implementation of the HTTP/1 protocol." - spec.authors = ["Samuel Williams"] + spec.authors = ["Samuel Williams", "Brian Morearty", "Bruno Sutic", "Olle Jonsson"] spec.license = "MIT" + spec.cert_chain = ['release.cert'] + spec.signing_key = File.expand_path('~/.gem/release.pem') + spec.homepage = "https://github.com/socketry/protocol-http1" - spec.files = Dir.glob('{lib}/**/*', File::FNM_DOTMATCH, base: __dir__) + spec.files = Dir.glob(['{lib}/**/*', '*.md'], File::FNM_DOTMATCH, base: __dir__) - spec.required_ruby_version = ">= 2.4" + spec.required_ruby_version = ">= 2.7.6b" spec.add_dependency "protocol-http", "~> 0.22" - - spec.add_development_dependency "bundler" - spec.add_development_dependency "covered" - spec.add_development_dependency "rspec", "~> 3.0" - spec.add_development_dependency "rspec-files", "~> 1.0" - spec.add_development_dependency "rspec-memory", "~> 1.0" end diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..7ac8c33 --- /dev/null +++ b/readme.md @@ -0,0 +1,78 @@ +# Protocol::HTTP1 + +Provides a low-level implementation of the HTTP/1 protocol. + +[![Development Status](https://github.com/socketry/protocol-http1/workflows/Test/badge.svg)](https://github.com/socketry/protocol-http1/actions?workflow=Test) + +## Installation + +Add this line to your application's Gemfile: + +``` ruby +gem 'protocol-http1' +``` + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install protocol-http1 + +## Usage + +Here is a basic HTTP/1.1 client: + +``` ruby +require 'async' +require 'async/io/stream' +require 'async/http/endpoint' +require 'protocol/http1/connection' + +Async do + endpoint = Async::HTTP::Endpoint.parse("https://www.google.com/search?q=kittens", alpn_protocols: ["http/1.1"]) + + peer = endpoint.connect + + puts "Connected to #{peer} #{peer.remote_address.inspect}" + + # IO Buffering... + stream = Async::IO::Stream.new(peer) + client = Protocol::HTTP1::Connection.new(stream) + + def client.read_line + @stream.read_until(Protocol::HTTP1::Connection::CRLF) or raise EOFError + end + + puts "Writing request..." + client.write_request("www.google.com", "GET", "/search?q=kittens", "HTTP/1.1", [["Accept", "*/*"]]) + client.write_body(nil) + + puts "Reading response..." + response = client.read_response("GET") + + puts "Got response: #{response.inspect}" + + puts "Closing client..." + client.close +end +``` + +## 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 + +This project uses the [Developer Certificate of Origin](https://developercertificate.org/). All contributors to this project must agree to this document to have their contributions accepted. + +### Contributor Covenant + +This project is governed by [Contributor Covenant](https://www.contributor-covenant.org/). All contributors and participants agree to abide by its terms. diff --git a/release.cert b/release.cert new file mode 100644 index 0000000..d98e595 --- /dev/null +++ b/release.cert @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 +ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK +CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz +MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd +MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj +bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB +igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 +9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW +sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE +e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN +XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss +RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn +tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM +zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW +xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O +BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs +aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs +aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE +cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl +xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ +c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp +8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws +JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP +eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt +Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 +voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= +-----END CERTIFICATE----- diff --git a/spec/protocol/http1/body/chunked_spec.rb b/spec/protocol/http1/body/chunked_spec.rb deleted file mode 100644 index 5cabffc..0000000 --- a/spec/protocol/http1/body/chunked_spec.rb +++ /dev/null @@ -1,100 +0,0 @@ -# frozen_string_literal: true - -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require_relative '../connection_context' - -require 'protocol/http1/body/chunked' - -RSpec.describe Protocol::HTTP1::Body::Chunked do - include_context RSpec::Memory - include_context RSpec::Files::Buffer - - let(:content) {"Hello World"} - let(:postfix) {nil} - let(:headers) {Protocol::HTTP::Headers.new} - subject {described_class.new(buffer, headers)} - - before do - buffer.write "#{content.bytesize.to_s(16)}\r\n#{content}\r\n0\r\n#{postfix}\r\n" - buffer.seek(0) - end - - describe "#empty?" do - it "returns whether EOF was reached" do - expect(subject.empty?).to be == false - end - end - - describe "#stop" do - it "closes the stream" do - subject.close(EOFError) - expect(buffer).to be_closed - end - - it "marks body as finished" do - subject.close(EOFError) - expect(subject).to be_empty - end - end - - describe "#read" do - it "retrieves chunks of content" do - expect(subject.read).to be == "Hello World" - expect(subject.read).to be == nil - expect(subject.read).to be == nil - end - - it "updates number of bytes retrieved" do - subject.read - subject.read # realizes there are no more chunks - expect(subject).to be_empty - end - - context "with large stream" do - let!(:content) {"a" * 1024 * 10} - - xit "allocates expected amount of memory" do - subject - - expect do - while chunk = subject.read - chunk.clear - end - end.to limit_allocations.of(String, size: 0).of(Hash, count: 8) - end - end - - context "with trailer" do - let(:postfix) {"ETag: abcd\r\n"} - - it "can read trailing etag" do - headers.add('trailer', 'etag') - - expect(subject.read).to be == "Hello World" - expect(headers['etag']).to be_nil - - expect(subject.read).to be == nil - expect(headers['etag']).to be == 'abcd' - end - end - end -end diff --git a/spec/protocol/http1/body/fixed_spec.rb b/spec/protocol/http1/body/fixed_spec.rb deleted file mode 100644 index 74b86bf..0000000 --- a/spec/protocol/http1/body/fixed_spec.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require 'protocol/http1/body/fixed' - -RSpec.describe Protocol::HTTP1::Body::Fixed do - include_context RSpec::Memory - include_context RSpec::Files::Buffer - - let(:content) {"Hello World"} - subject! {described_class.new(buffer, content.bytesize)} - - before do - buffer.write content - buffer.seek(0) - end - - describe "#empty?" do - it "returns whether EOF was reached" do - expect(subject.empty?).to be == false - end - end - - describe "#stop" do - it "closes the stream" do - subject.close(EOFError) - expect(buffer).to be_closed - end - - it "doesn't close the stream when EOF was reached" do - subject.read - subject.close(EOFError) - expect(buffer).not_to be_closed - end - end - - describe "#read" do - it "retrieves chunks of content" do - expect(subject.read).to be == "Hello World" - expect(subject.read).to be == nil - end - - it "updates number of bytes retrieved" do - subject.read - expect(subject).to be_empty - end - - context "when provided length is smaller than stream size" do - subject {described_class.new(buffer, 5)} - - it "retrieves content up to provided length" do - expect(subject.read).to be == "Hello" - expect(subject.read).to be == nil - end - - it "updates number of bytes retrieved" do - subject.read - expect(subject).to be_empty - end - end - - context "when provided lengthis larger than stream size" do - subject {described_class.new(buffer, 20)} - - it "retrieves content up to provided length" do - expect do - subject.read - subject.read - end.to raise_error(EOFError) - end - end - - context "with large stream" do - let(:content) {"a" * 5*1024*1024} - - it "allocates expected amount of memory" do - expect do - subject.read.clear until subject.empty? - end.to limit_allocations(size: 0) - end - end - end - - describe "#join" do - it "returns all content" do - expect(subject.join).to be == "Hello World" - expect(subject.join).to be == "" - end - - it "updates number of bytes retrieved" do - subject.read - expect(subject).to be_empty - end - end -end diff --git a/spec/protocol/http1/body/remainder_spec.rb b/spec/protocol/http1/body/remainder_spec.rb deleted file mode 100644 index 775540c..0000000 --- a/spec/protocol/http1/body/remainder_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require 'protocol/http1/body/remainder' - -RSpec.describe Protocol::HTTP1::Body::Remainder do - include_context RSpec::Memory - include_context RSpec::Files::Buffer - - let(:content) {"Hello World"} - subject! {described_class.new(buffer)} - - before do - buffer.write content - buffer.seek(0) - end - - describe "#empty?" do - it "returns whether EOF was reached" do - expect(subject.empty?).to be == false - end - end - - describe "#stop" do - it "closes the stream" do - subject.close(EOFError) - expect(buffer).to be_closed - end - - it "closes the stream when EOF was reached" do - subject.read - subject.close(EOFError) - expect(buffer).to be_closed - end - end - - describe "#read" do - it "retrieves chunks of content" do - expect(subject.read).to be == "Hello World" - expect(subject.read).to be == nil - end - - it "updates number of bytes retrieved" do - subject.read - expect(subject).to be_empty - end - - context "with large stream" do - let(:content) {"a" * 5*1024*1024} - - it "allocates expected amount of memory" do - expect do - subject.read.clear until subject.empty? - end.to limit_allocations(size: 0) - end - end - end - - describe "#join" do - it "returns all content" do - expect(subject.join).to be == "Hello World" - expect(subject.join).to be == "" - end - - it "updates number of bytes retrieved" do - subject.read - expect(subject).to be_empty - end - end -end diff --git a/spec/protocol/http1/connection_context.rb b/spec/protocol/http1/connection_context.rb deleted file mode 100644 index 81f0bb2..0000000 --- a/spec/protocol/http1/connection_context.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require 'protocol/http1/connection' - -require 'socket' - -RSpec.shared_context Protocol::HTTP1::Connection do - let(:sockets) {Socket.pair(Socket::PF_UNIX, Socket::SOCK_STREAM)} - - let(:client) {Protocol::HTTP1::Connection.new(sockets.first)} - let(:server) {Protocol::HTTP1::Connection.new(sockets.last)} -end diff --git a/spec/protocol/http1/connection_spec.rb b/spec/protocol/http1/connection_spec.rb deleted file mode 100644 index ddf06a0..0000000 --- a/spec/protocol/http1/connection_spec.rb +++ /dev/null @@ -1,324 +0,0 @@ -# frozen_string_literal: true - -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require 'protocol/http1/connection' -require 'protocol/http/body/buffered' - -require_relative 'connection_context' - -RSpec.describe Protocol::HTTP1::Connection do - include_context Protocol::HTTP1::Connection - - describe '#read_request' do - it "reads request without body" do - client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n" - client.stream.close - - authority, method, target, version, headers, body = server.read_request - - expect(authority).to be == 'localhost' - expect(method).to be == 'GET' - expect(target).to be == '/' - expect(version).to be == 'HTTP/1.1' - expect(headers).to be == {} - expect(body).to be_nil - end - - it "reads request without body after closing connection" do - client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nAccept: */*\r\nHeader-0: value 1\r\n\r\n" - client.stream.close - - authority, method, target, version, headers, body = server.read_request - - expect(authority).to be == 'localhost' - expect(method).to be == 'GET' - expect(target).to be == '/' - expect(version).to be == 'HTTP/1.1' - expect(headers).to be == {'accept' => ['*/*'], 'header-0' => ["value 1"]} - expect(body).to be_nil - end - - it "reads request with fixed body" do - client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 11\r\n\r\nHello World" - client.stream.close - - authority, method, target, version, headers, body = server.read_request - - expect(authority).to be == 'localhost' - expect(method).to be == 'GET' - expect(target).to be == '/' - expect(version).to be == 'HTTP/1.1' - expect(headers).to be == {} - expect(body.join).to be == "Hello World" - end - - it "reads request with chunked body" do - client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\nb\r\nHello World\r\n0\r\n\r\n" - client.stream.close - - authority, method, target, version, headers, body = server.read_request - - expect(authority).to be == 'localhost' - expect(method).to be == 'GET' - expect(target).to be == '/' - expect(version).to be == 'HTTP/1.1' - expect(headers).to be == {} - expect(body.join).to be == "Hello World" - expect(server).to be_persistent(version, method, headers) - end - - it "fails with broken request" do - client.stream.write "Accept: */*\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n" - client.stream.close - - expect do - server.read_request - end.to raise_error(Protocol::HTTP1::InvalidRequest) - end - - it "fails with missing version" do - client.stream.write "GET foo\r\n" - client.stream.close - - expect do - server.read_request - end.to raise_error(Protocol::HTTP1::InvalidRequest) - end - end - - describe '#persistent?' do - describe "HTTP 1.0" do - it "should not be persistent by default" do - expect(server).not_to be_persistent("HTTP/1.0", "GET", {}) - end - - it "should be persistent if connection: keep-alive is set" do - headers = Protocol::HTTP::Headers[ - "connection" => "keep-alive" - ] - - expect(server).to be_persistent("HTTP/1.0", "GET", headers) - end - - it "should allow case-insensitive 'connection' value" do - headers = Protocol::HTTP::Headers[ - "connection" => "Keep-Alive" - ] - - expect(server).to be_persistent("HTTP/1.0", "GET", headers) - end - end - - describe "HTTP 1.1" do - it "should be persistent by default" do - expect(server).to be_persistent("HTTP/1.1", "GET", {}) - end - - it "should not be persistent if connection: close is set" do - headers = Protocol::HTTP::Headers[ - "connection" => "close" - ] - - expect(server).not_to be_persistent("HTTP/1.1", "GET", headers) - end - - it "should allow case-insensitive 'connection' value" do - headers = Protocol::HTTP::Headers[ - "connection" => "Close" - ] - - expect(server).not_to be_persistent("HTTP/1.1", "GET", headers) - end - end - end - - describe '#read_response' do - it "should read successful response" do - server.stream.write("HTTP/1.1 200 Hello\r\nContent-Length: 0\r\n\r\n") - server.stream.close - - version, status, reason, headers, body = client.read_response("GET") - - expect(version).to be == 'HTTP/1.1' - expect(status).to be == 200 - expect(reason).to be == "Hello" - expect(headers).to be == {} - expect(body).to be_nil - end - end - - describe '#read_response_body' do - context "with GET" do - it "should ignore body for informational responses" do - expect(client.read_response_body("GET", 100, {'content-length' => '10'})).to be_nil - end - end - - context "with HEAD" do - it "can read length of head response" do - body = client.read_response_body("HEAD", 200, {'content-length' => 3773}) - - expect(body).to be_kind_of ::Protocol::HTTP::Body::Head - expect(body.length).to be == 3773 - expect(body.read).to be nil - end - - it "ignores zero length body" do - body = client.read_response_body("HEAD", 200, {'content-length' => 0}) - - expect(body).to be_nil - end - end - end - - describe '#write_chunked_body' do - let(:chunks) {["Hello", "World"]} - let(:body) {::Protocol::HTTP::Body::Buffered.wrap(chunks)} - - it "can generate and read chunked response" do - server.write_chunked_body(body, false) - server.close - - headers = client.read_headers - expect(headers).to be == [['transfer-encoding', 'chunked']] - - body = client.read_body(headers, false) - expect(body.join).to be == chunks.join - end - - it "can generate and read trailer" do - chunks = ["Hello", "World"] - - server.write_headers({'trailer' => 'etag'}) - server.write_chunked_body(body, false, {'etag' => 'abcd'}) - server.close - - headers = client.read_headers - expect(headers).to be == [['trailer', 'etag'], ['transfer-encoding', 'chunked']] - - body = client.read_body(headers, false) - expect(body.join).to be == chunks.join - - expect(headers).to include('etag') - end - end - - describe '#write_fixed_length_body' do - let(:chunks) {["Hello", "World"]} - let(:body) {::Protocol::HTTP::Body::Buffered.wrap(chunks)} - - it "can generate and read chunked response" do - server.write_fixed_length_body(body, 10, false) - server.close - - headers = client.read_headers - expect(headers).to be == [['content-length', '10']] - - body = client.read_body(headers, false) - expect(body.join).to be == chunks.join - end - end - - describe '#write_body' do - let(:body) {double} - - it "can write empty body" do - expect(body).to receive(:empty?).and_return(true) - expect(body).to receive(:length).and_return(false) - - expect(server).to receive(:write_empty_body) - server.write_body("HTTP/1.0", body) - end - - it "can write fixed length body" do - expect(body).to receive(:length).and_return(1024) - - expect(server).to receive(:write_fixed_length_body) - server.write_body("HTTP/1.0", body) - end - - it "can write chunked body" do - expect(server.persistent).to be true - - expect(body).to receive(:empty?).and_return(false) - expect(body).to receive(:length).and_return(nil) - - expect(server).to receive(:write_chunked_body) - server.write_body("HTTP/1.1", body) - end - - it "can write fixed length body for HTTP/1.1" do - expect(body).to receive(:length).and_return(1024) - - expect(server).to receive(:write_fixed_length_body) - server.write_body("HTTP/1.1", body) - end - - it "can write closed body" do - expect(server.persistent).to be true - - expect(body).to receive(:empty?).and_return(false) - expect(body).to receive(:length).and_return(nil) - - expect(server).to receive(:write_body_and_close) - server.write_body("HTTP/1.0", body) - end - end - - context 'bad requests' do - it "should fail with negative content length" do - client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: -1\r\n\r\nHello World" - client.stream.close - - expect do - server.read_request - end.to raise_error(Protocol::HTTP1::BadRequest) - end - - it "should fail with invalid headers" do - client.stream.write "GET / HTTP/1.1\r\nHost: \000localhost\r\n\r\nHello World" - client.stream.close - - expect do - server.read_request - end.to raise_error(Protocol::HTTP1::BadHeader) - end - end - - context 'bad responses' do - it 'should fail if headers contain \r characters' do - expect do - server.write_headers( - [["id", "5\rSet-Cookie: foo-bar"]] - ) - end.to raise_error(Protocol::HTTP1::BadHeader) - end - - it 'should fail if headers contain \n characters' do - expect do - server.write_headers( - [["id", "5\nSet-Cookie: foo-bar"]] - ) - end.to raise_error(Protocol::HTTP1::BadHeader) - end - end -end diff --git a/spec/protocol/http1/hijack_spec.rb b/spec/protocol/http1/hijack_spec.rb deleted file mode 100644 index 3201b75..0000000 --- a/spec/protocol/http1/hijack_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require 'protocol/http1/connection' -require_relative 'connection_context' - -RSpec.describe Protocol::HTTP1::Connection do - include_context Protocol::HTTP1::Connection - - describe '#hijack' do - let(:response_version) {Protocol::HTTP1::Connection::HTTP10} - let(:response_headers) {Hash.new('upgrade' => 'websocket')} - let(:body) {double} - let(:text) {"Hello World!"} - - it "should not be persistent after hijack" do - server_wrapper = server.hijack! - expect(server.persistent).to be false - end - - it "should use non-chunked output" do - expect(body).to receive(:ready?).and_return(false) - expect(body).to receive(:empty?).and_return(false) - expect(body).to receive(:length).and_return(nil) - expect(body).to receive(:each).and_return(nil) - - expect(server).to receive(:write_body_and_close).and_call_original - server.write_response(response_version, 101, response_headers) - server.write_body(response_version, body) - - server_stream = server.hijack! - - version, status, reason, headers, body = client.read_response("GET") - - expect(version).to be == response_version - expect(status).to be == 101 - expect(headers).to be == response_headers - expect(body).to be_nil # due to 101 status - - client_stream = client.hijack! - - client_stream.write(text) - client_stream.close - - expect(server_stream.read).to be == text - end - end -end diff --git a/spec/protocol/http1/upgrade_spec.rb b/spec/protocol/http1/upgrade_spec.rb deleted file mode 100644 index 96ee480..0000000 --- a/spec/protocol/http1/upgrade_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require 'protocol/http1/connection' -require_relative 'connection_context' - -RSpec.describe Protocol::HTTP1::Connection do - include_context Protocol::HTTP1::Connection - - describe '#upgrade' do - let(:protocol) {'binary'} - let(:request_version) {Protocol::HTTP1::Connection::HTTP10} - - it "should upgrade connection" do - client.write_request("testing.com", "GET", "/", request_version, []) - stream = client.write_upgrade_body(protocol) - - stream.write "Hello World" - stream.close_write - - authority, method, path, version, headers, body = server.read_request - - expect(version).to be == request_version - expect(headers['upgrade']).to be == [protocol] - expect(body).to be_nil - - stream = server.hijack! - expect(stream.read).to be == "Hello World" - end - end -end diff --git a/spec/protocol/http1_spec.rb b/spec/protocol/http1_spec.rb deleted file mode 100644 index b630f9c..0000000 --- a/spec/protocol/http1_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require 'protocol/http1/version' - -RSpec.describe Protocol::HTTP1 do - it "has a version number" do - expect(Protocol::HTTP1::VERSION).not_to be nil - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index b0519d0..0000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require 'rspec/memory' -require 'rspec/files' - -require 'covered/rspec' - -RSpec.configure do |config| - # Enable flags like --only-failures and --next-failure - config.example_status_persistence_file_path = ".rspec_status" - - # Disable RSpec exposing methods globally on `Module` and `main` - config.disable_monkey_patching! - - config.expect_with :rspec do |c| - c.syntax = :expect - end -end diff --git a/test/protocol/http1.rb b/test/protocol/http1.rb new file mode 100644 index 0000000..9af0357 --- /dev/null +++ b/test/protocol/http1.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. + +require 'protocol/http1/version' + +describe Protocol::HTTP1 do + it "has a version number" do + expect(Protocol::HTTP1::VERSION).not.to be_nil + end +end diff --git a/test/protocol/http1/body/chunked.rb b/test/protocol/http1/body/chunked.rb new file mode 100644 index 0000000..4a5f9e1 --- /dev/null +++ b/test/protocol/http1/body/chunked.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. + +require 'protocol/http1/body/chunked' +require 'connection_context' + +describe Protocol::HTTP1::Body::Chunked do + let(:content) {"Hello World"} + let(:postfix) {nil} + let(:headers) {Protocol::HTTP::Headers.new} + let(:buffer) {StringIO.new("#{content.bytesize.to_s(16)}\r\n#{content}\r\n0\r\n#{postfix}\r\n")} + let(:body) {subject.new(buffer, headers)} + + with "#inspect" do + it "can be inspected" do + expect(body.inspect).to be =~ /0 bytes read in 0 chunks/ + end + end + + with "#empty?" do + it "returns whether EOF was reached" do + expect(body.empty?).to be == false + end + end + + with "#stop" do + it "closes the stream" do + body.close(EOFError) + expect(buffer).to be(:closed?) + end + + it "marks body as finished" do + body.close(EOFError) + expect(body).to be(:empty?) + end + end + + with "#read" do + it "retrieves chunks of content" do + expect(body.read).to be == "Hello World" + expect(body.read).to be == nil + expect(body.read).to be == nil + end + + it "updates number of bytes retrieved" do + body.read + body.read # realizes there are no more chunks + expect(body).to be(:empty?) + end + + with "trailer" do + let(:postfix) {"ETag: abcd\r\n"} + + it "can read trailing etag" do + headers.add('trailer', 'etag') + + expect(body.read).to be == "Hello World" + expect(headers['etag']).to be_nil + + expect(body.read).to be == nil + expect(headers['etag']).to be == 'abcd' + end + end + + with "bad trailers" do + let(:postfix) {":ETag abcd\r\n"} + + it "raises error" do + headers.add('trailer', 'etag') + + expect(body.read).to be == "Hello World" + expect(headers['etag']).to be_nil + + expect{body.read}.to raise_exception(Protocol::HTTP1::BadHeader) + end + end + end +end diff --git a/test/protocol/http1/body/fixed.rb b/test/protocol/http1/body/fixed.rb new file mode 100644 index 0000000..f21aacf --- /dev/null +++ b/test/protocol/http1/body/fixed.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. + +require 'protocol/http1/body/fixed' + +describe Protocol::HTTP1::Body::Fixed do + let(:content) {"Hello World"} + let(:buffer) {StringIO.new(content)} + let(:body) {subject.new(buffer, content.bytesize)} + + with "#inspect" do + it "can be inspected" do + expect(body.inspect).to be =~ /length=11 remaining=11/ + end + end + + with "#empty?" do + it "returns whether EOF was reached" do + expect(body.empty?).to be == false + end + end + + with "#stop" do + it "closes the stream" do + body.close(EOFError) + expect(buffer).to be(:closed?) + end + + it "doesn't close the stream when EOF was reached" do + body.read + body.close(EOFError) + expect(buffer).not.to be(:closed?) + end + end + + with "#read" do + it "retrieves chunks of content" do + expect(body.read).to be == "Hello World" + expect(body.read).to be == nil + end + + it "updates number of bytes retrieved" do + body.read + expect(body).to be(:empty?) + end + + with "length smaller than stream size" do + let(:body) {subject.new(buffer, 5)} + + it "retrieves content up to provided length" do + expect(body.read).to be == "Hello" + expect(body.read).to be == nil + end + + it "updates number of bytes retrieved" do + body.read + expect(body).to be(:empty?) + end + end + + with "length larger than stream size" do + let(:body) {subject.new(buffer, 20)} + + it "retrieves content up to provided length" do + expect do + body.read + body.read + end.to raise_exception(EOFError) + end + end + end + + with "#join" do + it "returns all content" do + expect(body.join).to be == "Hello World" + expect(body.join).to be == "" + end + + it "updates number of bytes retrieved" do + body.read + expect(body).to be(:empty?) + end + end +end diff --git a/test/protocol/http1/body/remainder.rb b/test/protocol/http1/body/remainder.rb new file mode 100644 index 0000000..3e9c34d --- /dev/null +++ b/test/protocol/http1/body/remainder.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. + +require 'protocol/http1/body/remainder' + +describe Protocol::HTTP1::Body::Remainder do + let(:content) {"Hello World"} + let(:buffer) {StringIO.new(content)} + let(:body) {subject.new(buffer)} + + with "#inspect" do + it "can be inspected" do + expect(body.inspect).to be =~ /open/ + end + end + + with "#empty?" do + it "returns whether EOF was reached" do + expect(body.empty?).to be == false + end + end + + with "#stop" do + it "closes the stream" do + body.close(EOFError) + expect(buffer).to be(:closed?) + end + + it "closes the stream when EOF was reached" do + body.read + body.close(EOFError) + expect(buffer).to be(:closed?) + end + end + + with "#read" do + it "retrieves chunks of content" do + expect(body.read).to be == "Hello World" + expect(body.read).to be == nil + end + + it "updates number of bytes retrieved" do + body.read + expect(body).to be(:empty?) + end + end + + with "#call" do + it "streams the content" do + stream = StringIO.new + body.call(stream) + expect(stream.string).to be == "Hello World" + end + end + + with "#join" do + it "returns all content" do + expect(body.join).to be == "Hello World" + expect(body.join).to be == "" + end + + it "updates number of bytes retrieved" do + body.read + expect(body).to be(:empty?) + end + end +end diff --git a/test/protocol/http1/connection.rb b/test/protocol/http1/connection.rb new file mode 100644 index 0000000..081fa1b --- /dev/null +++ b/test/protocol/http1/connection.rb @@ -0,0 +1,492 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. +# Copyright, 2019, by Brian Morearty. +# Copyright, 2020, by Bruno Sutic. + +require 'protocol/http1/connection' +require 'protocol/http/body/buffered' + +require 'connection_context' + +describe Protocol::HTTP1::Connection do + include_context ConnectionContext + + with '#read_request' do + it "reads request without body" do + client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n" + client.stream.close + + authority, method, target, version, headers, body = server.read_request + + expect(authority).to be == 'localhost' + expect(method).to be == 'GET' + expect(target).to be == '/' + expect(version).to be == 'HTTP/1.1' + expect(headers).to be == {} + expect(body).to be_nil + end + + it "reads request without body after closing connection" do + client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nAccept: */*\r\nHeader-0: value 1\r\n\r\n" + client.stream.close + + authority, method, target, version, headers, body = server.read_request + + expect(authority).to be == 'localhost' + expect(method).to be == 'GET' + expect(target).to be == '/' + expect(version).to be == 'HTTP/1.1' + expect(headers).to be == {'accept' => ['*/*'], 'header-0' => ["value 1"]} + expect(body).to be_nil + end + + it "reads request with fixed body" do + client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 11\r\n\r\nHello World" + client.stream.close + + authority, method, target, version, headers, body = server.read_request + + expect(authority).to be == 'localhost' + expect(method).to be == 'GET' + expect(target).to be == '/' + expect(version).to be == 'HTTP/1.1' + expect(headers).to be == {} + expect(body.join).to be == "Hello World" + end + + it "reads request with chunked body" do + client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\nb\r\nHello World\r\n0\r\n\r\n" + client.stream.close + + authority, method, target, version, headers, body = server.read_request + + expect(authority).to be == 'localhost' + expect(method).to be == 'GET' + expect(target).to be == '/' + expect(version).to be == 'HTTP/1.1' + expect(headers).to be == {} + expect(body.join).to be == "Hello World" + expect(server).to be(:persistent?, version, method, headers) + end + + it "reads request with CONNECT method" do + client.stream.write "CONNECT localhost:443 HTTP/1.1\r\nHost: localhost\r\n\r\n" + client.stream.close + + authority, method, target, version, headers, body = server.read_request + + expect(authority).to be == 'localhost' + expect(method).to be == 'CONNECT' + expect(target).to be == 'localhost:443' + expect(version).to be == 'HTTP/1.1' + expect(headers).to be == {} + expect(body).to be_a(Protocol::HTTP1::Body::Remainder) + expect(server).not.to be(:persistent?, version, method, headers) + end + + it "fails with broken request" do + client.stream.write "Accept: */*\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n" + client.stream.close + + expect do + server.read_request + end.to raise_exception(Protocol::HTTP1::InvalidRequest) + end + + it "fails with missing version" do + client.stream.write "GET foo\r\n" + client.stream.close + + expect do + server.read_request + end.to raise_exception(Protocol::HTTP1::InvalidRequest) + end + end + + with '#write_response' do + it "fails to write a response with invalid header name" do + invalid_header_names = [ + 'foo bar', + 'foo:bar', + 'foo: bar', + 'foo bar:baz', + 'foo\r\nbar', + 'foo\nbar', + 'foo\rbar', + ] + + invalid_header_names.each do |name| + expect(name).not.to be =~ Protocol::HTTP1::VALID_FIELD_NAME + + expect do + server.write_response("HTTP/1.1", 200, {name => 'baz'}, []) + end.to raise_exception(Protocol::HTTP1::BadHeader) + end + end + end + + with '#persistent?' do + describe "HTTP 1.0" do + it "should not be persistent by default" do + expect(server).not.to be(:persistent?, "HTTP/1.0", "GET", {}) + end + + it "should be persistent if connection: keep-alive is set" do + headers = Protocol::HTTP::Headers[ + "connection" => "keep-alive" + ] + + expect(server).to be(:persistent?, "HTTP/1.0", "GET", headers) + end + + it "should allow case-insensitive 'connection' value" do + headers = Protocol::HTTP::Headers[ + "connection" => "Keep-Alive" + ] + + expect(server).to be(:persistent?, "HTTP/1.0", "GET", headers) + end + end + + describe "HTTP 1.1" do + it "should be persistent by default" do + expect(server).to be(:persistent?, "HTTP/1.1", "GET", {}) + end + + it "should not be persistent if connection: close is set" do + headers = Protocol::HTTP::Headers[ + "connection" => "close" + ] + + expect(server).not.to be(:persistent?, "HTTP/1.1", "GET", headers) + end + + it "should allow case-insensitive 'connection' value" do + headers = Protocol::HTTP::Headers[ + "connection" => "Close" + ] + + expect(server).not.to be(:persistent?, "HTTP/1.1", "GET", headers) + end + end + end + + with '#read_response' do + it "should read successful response" do + server.stream.write("HTTP/1.1 200 Hello\r\nContent-Length: 0\r\n\r\n") + server.stream.close + + version, status, reason, headers, body = client.read_response("GET") + + expect(version).to be == 'HTTP/1.1' + expect(status).to be == 200 + expect(reason).to be == "Hello" + expect(headers).to be == {} + expect(body).to be_nil + end + end + + with '#read_response_body' do + with "GET" do + it "should ignore body for informational responses" do + expect(client.read_response_body("GET", 100, {'content-length' => '10'})).to be_nil + end + + it "should handle non-chunked transfer-encoding" do + body = client.read_response_body("GET", 200, {'transfer-encoding' => ['identity']}) + expect(body).to be_a(::Protocol::HTTP1::Body::Remainder) + end + + it "should be an error if both transfer-encoding and content-length is set" do + expect do + client.read_response_body("GET", 200, {'content-length' => '10', 'transfer-encoding' => ['chunked']}) + end.to raise_exception(Protocol::HTTP1::BadRequest) + end + end + + with "HEAD" do + it "can read length of head response" do + body = client.read_response_body("HEAD", 200, {'content-length' => '3773'}) + + expect(body).to be_a ::Protocol::HTTP::Body::Head + expect(body.length).to be == 3773 + expect(body.read).to be_nil + end + + it "ignores zero length body" do + body = client.read_response_body("HEAD", 200, {'content-length' => '0'}) + + expect(body).to be_nil + end + + it "raises error if content-length is invalid" do + expect do + client.read_response_body("HEAD", 200, {'content-length' => 'foo'}) + end.to raise_exception(Protocol::HTTP1::BadRequest) + end + end + + with "CONNECT" do + it "should ignore body for informational responses" do + expect(client.read_response_body("CONNECT", 200, {})).to be_a(Protocol::HTTP1::Body::Remainder) + end + end + end + + with '#write_chunked_body' do + let(:chunks) {["Hello", "World"]} + let(:body) {::Protocol::HTTP::Body::Buffered.wrap(chunks)} + + it "can generate and read chunked response" do + server.write_chunked_body(body, false) + server.close + + headers = client.read_headers + expect(headers).to be == [['transfer-encoding', 'chunked']] + + body = client.read_body(headers, false) + expect(body.join).to be == chunks.join + end + + it "can generate and read trailer" do + chunks = ["Hello", "World"] + + server.write_headers({'trailer' => 'etag'}) + server.write_chunked_body(body, false, {'etag' => 'abcd'}) + server.close + + headers = client.read_headers + expect(headers).to be == [['trailer', 'etag'], ['transfer-encoding', 'chunked']] + + body = client.read_body(headers, false) + expect(body.join).to be == chunks.join + + expect(headers).to have_keys('etag') + end + + with "HEAD request" do + it "can generate and read chunked response" do + server.write_chunked_body(body, true) + server.close + + headers = client.read_headers + expect(headers).to be == [["transfer-encoding", "chunked"]] + + body = client.read_response_body("HEAD", 200, headers) + expect(body).to be_nil + end + end + end + + with '#write_fixed_length_body' do + let(:chunks) {["Hello", "World"]} + let(:body) {::Protocol::HTTP::Body::Buffered.wrap(chunks)} + + it "can generate a valid response" do + server.write_fixed_length_body(body, 10, false) + server.close + + headers = client.read_headers + expect(headers).to be == [['content-length', '10']] + + body = client.read_body(headers, false) + expect(body.join).to be == chunks.join + end + + with "a length smaller than stream size" do + it "raises an error" do + expect do + server.write_fixed_length_body(body, 100, false) + end.to raise_exception(Protocol::HTTP1::ContentLengthError) + end + end + + with "a length larger than stream size" do + it "raises an error" do + expect do + server.write_fixed_length_body(body, 1, false) + end.to raise_exception(Protocol::HTTP1::ContentLengthError) + end + end + + with "HEAD request" do + it "can generate a valid response" do + server.write_fixed_length_body(body, 10, true) + server.close + + headers = client.read_headers + expect(headers).to be == [['content-length', '10']] + + body = client.read_response_body("HEAD", 200, headers) + expect(body).to be_a(Protocol::HTTP::Body::Head) + expect(body.length).to be == 10 + expect(body.read).to be_nil + end + end + end + + with '#write_upgrade_body' do + let(:body) {::Protocol::HTTP::Body::Buffered.new(["Hello ", "World!"])} + + it "can generate and read upgrade response" do + server.write_upgrade_body("text", body) + server.close + + headers = client.read_headers + expect(headers).to have_keys( + 'connection' => be == ['upgrade'], + 'upgrade' => be == ['text'] + ) + + body = client.read_body(headers, true) + expect(body.join).to be == "Hello World!" + end + end + + with '#write_tunnel_body' do + let(:body) {::Protocol::HTTP::Body::Buffered.new(["Hello ", "World!"])} + + it "can generate and read tunnel response" do + server.write_tunnel_body("HTTP/1.1", body) + server.close + + headers = client.read_headers + expect(headers).to have_keys( + 'connection' => be == ['close'], + ) + + body = client.read_body(headers, true) + expect(body.join).to be == "Hello World!" + end + end + + with '#write_body_and_close' do + let(:body) {::Protocol::HTTP::Body::Buffered.new(["Hello ", "World!"])} + + it "can generate and write response" do + server.write_body_and_close(body, false) + server.close + + headers = client.read_headers + expect(headers).to be(:empty?) + + body = client.read_body(headers, true) + expect(body.join).to be == "Hello World!" + end + + with "HEAD request" do + it "can generate and write response" do + server.write_body_and_close(body, true) + server.close + + headers = client.read_headers + expect(headers).to be(:empty?) + + body = client.read_response_body("HEAD", 200, headers) + expect(body).to be_nil + end + end + end + + with '#write_body' do + let(:body) {Protocol::HTTP::Body::Buffered.new} + + it "can write empty body" do + expect(body).to receive(:empty?).and_return(true) + expect(body).to receive(:length).and_return(false) + + expect(server).to receive(:write_empty_body) + server.write_body("HTTP/1.0", body) + + headers = client.read_headers + expect(headers).to be == [["connection", "keep-alive"], ["content-length", "0"]] + + body = client.read_body(headers, true) + expect(body).to be_nil + end + + it "can write nil body" do + expect(server).to receive(:write_empty_body) + server.write_body("HTTP/1.0", nil) + server.close + + headers = client.read_headers + expect(headers).to be == [["connection", "keep-alive"], ["content-length", "0"]] + + body = client.read_body(headers, true) + expect(body).to be_nil + end + + it "can write fixed length body" do + expect(body).to receive(:length).and_return(1024) + expect(server).to receive(:write_fixed_length_body).and_return(true) + server.write_body("HTTP/1.0", body) + end + + it "can write chunked body" do + expect(server.persistent).to be == true + + expect(body).to receive(:empty?).and_return(false) + expect(body).to receive(:length).and_return(nil) + + expect(server).to receive(:write_chunked_body) + server.write_body("HTTP/1.1", body) + end + + it "can write fixed length body for HTTP/1.1" do + expect(body).to receive(:length).and_return(1024) + expect(server).to receive(:write_fixed_length_body).and_return(true) + server.write_body("HTTP/1.1", body) + end + + it "can write closed body" do + expect(server.persistent).to be == true + + expect(body).to receive(:empty?).and_return(false) + expect(body).to receive(:length).and_return(nil) + + expect(server).to receive(:write_body_and_close) + server.write_body("HTTP/1.0", body) + end + end + + with 'bad requests' do + it "should fail with negative content length" do + client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: -1\r\n\r\nHello World" + client.stream.close + + expect do + server.read_request + end.to raise_exception(Protocol::HTTP1::BadRequest) + end + + it "should fail with invalid headers" do + client.stream.write "GET / HTTP/1.1\r\nHost: \000localhost\r\n\r\nHello World" + client.stream.close + + expect do + server.read_request + end.to raise_exception(Protocol::HTTP1::BadHeader) + end + end + + with 'bad responses' do + it 'should fail if headers contain \r characters' do + expect do + server.write_headers( + [["id", "5\rSet-Cookie: foo-bar"]] + ) + end.to raise_exception(Protocol::HTTP1::BadHeader) + end + + it 'should fail if headers contain \n characters' do + expect do + server.write_headers( + [["id", "5\nSet-Cookie: foo-bar"]] + ) + end.to raise_exception(Protocol::HTTP1::BadHeader) + end + end +end diff --git a/test/protocol/http1/connection/bad.rb b/test/protocol/http1/connection/bad.rb new file mode 100644 index 0000000..be595f9 --- /dev/null +++ b/test/protocol/http1/connection/bad.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. + +require 'protocol/http1/connection' +require 'connection_context' + +describe Protocol::HTTP1::Connection do + include_context ConnectionContext + + def before + super + + client.stream.write(input) + client.stream.close + end + + with "invalid hexadecimal content-length" do + def input + <<~HTTP.gsub("\n", "\r\n") + POST / HTTP/1.1 + Host: a.com + Content-Length: 0x10 + Connection: close + + 0123456789abcdef + HTTP + end + + it "should fail to parse the request body" do + expect do + server.read_request + end.to raise_exception(Protocol::HTTP1::BadRequest) + end + end + + with "invalid +integer content-length" do + def input + <<~HTTP.gsub("\n", "\r\n") + POST / HTTP/1.1 + Host: a.com + Content-Length: +16 + Connection: close + + 0123456789abcdef + HTTP + end + + it "should fail to parse the request body" do + expect do + server.read_request + end.to raise_exception(Protocol::HTTP1::BadRequest) + end + end + + with "invalid -integer content-length" do + def input + <<~HTTP.gsub("\n", "\r\n") + POST / HTTP/1.1 + Host: a.com + Content-Length: -16 + Connection: close + + 0123456789abcdef + HTTP + end + + it "should fail to parse the request body" do + expect do + server.read_request + end.to raise_exception(Protocol::HTTP1::BadRequest) + end + end + + with "invalid hexidecimal chunk size" do + def input + <<~HTTP.gsub("\n", "\r\n") + POST / HTTP/1.1 + Host: a.com + Transfer-Encoding: chunked + Connection: close + + 0x10 + 0123456789abcdef + 0 + HTTP + end + + it "should fail to parse the request body" do + authority, method, target, version, headers, body = server.read_request + + expect(body).to be_a(Protocol::HTTP1::Body::Chunked) + + expect do + body.read + end.to raise_exception(Protocol::HTTP1::BadRequest) + end + end + + with "invalid +integer chunk size" do + def input + <<~HTTP.gsub("\n", "\r\n") + POST / HTTP/1.1 + Host: a.com + Transfer-Encoding: chunked + Connection: close + + +10 + 0123456789abcdef + 0 + HTTP + end + + it "should fail to parse the request body" do + authority, method, target, version, headers, body = server.read_request + + expect(body).to be_a(Protocol::HTTP1::Body::Chunked) + + expect do + body.read + end.to raise_exception(Protocol::HTTP1::BadRequest) + end + end +end diff --git a/test/protocol/http1/hijack.rb b/test/protocol/http1/hijack.rb new file mode 100644 index 0000000..cb29384 --- /dev/null +++ b/test/protocol/http1/hijack.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. + +require 'protocol/http1/connection' +require 'connection_context' + +describe Protocol::HTTP1::Connection do + include_context ConnectionContext + + with '#hijack' do + let(:response_version) {Protocol::HTTP1::Connection::HTTP10} + let(:response_headers) {Hash.new('upgrade' => 'websocket')} + let(:body) {Protocol::HTTP::Body::Buffered.new} + let(:text) {"Hello World!"} + + it "should not be persistent after hijack" do + server_wrapper = server.hijack! + expect(server.persistent).to be == false + end + + it "should use non-chunked output" do + expect(body).to receive(:ready?).and_return(false) + expect(body).to receive(:empty?).and_return(false) + expect(body).to receive(:length).and_return(nil) + expect(body).to receive(:each).and_return(nil) + + expect(server).to receive(:write_body_and_close) + server.write_response(response_version, 101, response_headers) + server.write_body(response_version, body) + + server_stream = server.hijack! + + version, status, reason, headers, body = client.read_response("GET") + + expect(version).to be == response_version + expect(status).to be == 101 + expect(headers).to be == response_headers + expect(body).to be_nil # due to 101 status + + client_stream = client.hijack! + + client_stream.write(text) + client_stream.close + + expect(server_stream.read).to be == text + end + end +end diff --git a/test/protocol/http1/trailer.rb b/test/protocol/http1/trailer.rb new file mode 100644 index 0000000..727d4c1 --- /dev/null +++ b/test/protocol/http1/trailer.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2023, by Samuel Williams. + +require 'protocol/http1/connection' +require 'protocol/http/body/buffered' + +require 'connection_context' + +describe Protocol::HTTP1::Connection do + include_context ConnectionContext + + let(:chunks) {["Hello", "World"]} + let(:body) {::Protocol::HTTP::Body::Buffered.wrap(chunks)} + + let(:trailer) {Hash.new} + + with 'trailers' do + it "ignores trailers with HTTP/1.0" do + expect(server).to receive(:write_fixed_length_body) + server.write_body("HTTP/1.0", body, false, trailer) + end + + it "ignores trailers with content length" do + expect(server).to receive(:write_fixed_length_body) + server.write_body("HTTP/1.1", body, false, trailer) + end + + it "uses chunked encoding when given trailers without content length" do + expect(body).to receive(:length).and_return(nil) + trailer['foo'] = 'bar' + + server.write_response("HTTP/1.1", 200, {}) + server.write_body("HTTP/1.1", body, false, trailer) + + version, status, reason, headers, body = client.read_response("GET") + + expect(version).to be == 'HTTP/1.1' + expect(status).to be == 200 + expect(headers).to be == {} + + # Read all of the response body, including trailers: + body.join + + # Headers are updated: + expect(headers).to be == {'foo' => ['bar']} + end + end +end diff --git a/test/protocol/http1/upgrade.rb b/test/protocol/http1/upgrade.rb new file mode 100644 index 0000000..8c34a7c --- /dev/null +++ b/test/protocol/http1/upgrade.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2023, by Samuel Williams. + +require 'protocol/http1/connection' +require 'connection_context' + +describe Protocol::HTTP1::Connection do + include_context ConnectionContext + + with '#upgrade' do + let(:protocol) {'binary'} + let(:request_version) {Protocol::HTTP1::Connection::HTTP10} + + it "should upgrade connection" do + client.write_request("testing.com", "GET", "/", request_version, []) + stream = client.write_upgrade_body(protocol) + + stream.write "Hello World" + stream.close_write + + authority, method, path, version, headers, body = server.read_request + + expect(version).to be == request_version + expect(headers['upgrade']).to be == [protocol] + expect(body).to be_nil + + stream = server.hijack! + expect(stream.read).to be == "Hello World" + end + end +end