diff --git a/spec/std/http/client/request_spec.cr b/spec/std/http/client/request_spec.cr new file mode 100644 index 000000000000..2829d3f19609 --- /dev/null +++ b/spec/std/http/client/request_spec.cr @@ -0,0 +1,227 @@ +require "spec" +require "http/client/request" + +class HTTP::Client + describe Request do + it "serialize GET" do + headers = HTTP::Headers.new + headers["Host"] = "host.example.org" + orignal_headers = headers.dup + request = Request.new "GET", "/", headers + + io = MemoryIO.new + request.to_io(io) + io.to_s.should eq("GET / HTTP/1.1\r\nHost: host.example.org\r\n\r\n") + headers.should eq(orignal_headers) + end + + it "serialize GET (with query params)" do + headers = HTTP::Headers.new + headers["Host"] = "host.example.org" + orignal_headers = headers.dup + request = Request.new "GET", "/greet?q=hello&name=world", headers + + io = MemoryIO.new + request.to_io(io) + io.to_s.should eq("GET /greet?q=hello&name=world HTTP/1.1\r\nHost: host.example.org\r\n\r\n") + headers.should eq(orignal_headers) + end + + it "serialize GET (with cookie)" do + headers = HTTP::Headers.new + headers["Host"] = "host.example.org" + orignal_headers = headers.dup + request = Request.new "GET", "/", headers + request.cookies << Cookie.new("foo", "bar") + + io = MemoryIO.new + request.to_io(io) + io.to_s.should eq("GET / HTTP/1.1\r\nHost: host.example.org\r\nCookie: foo=bar\r\n\r\n") + headers.should eq(orignal_headers) + end + + it "serialize GET (with cookies, from headers)" do + headers = HTTP::Headers.new + headers["Host"] = "host.example.org" + headers["Cookie"] = "foo=bar" + orignal_headers = headers.dup + + request = Request.new "GET", "/", headers + + io = MemoryIO.new + request.to_io(io) + io.to_s.should eq("GET / HTTP/1.1\r\nHost: host.example.org\r\nCookie: foo=bar\r\n\r\n") + + request.cookies["foo"].value.should eq "bar" # Force lazy initialization + + io.clear + request.to_io(io) + io.to_s.should eq("GET / HTTP/1.1\r\nHost: host.example.org\r\nCookie: foo=bar\r\n\r\n") + + request.cookies["foo"] = "baz" + request.cookies["quux"] = "baz" + + io.clear + request.to_io(io) + io.to_s.should eq("GET / HTTP/1.1\r\nHost: host.example.org\r\nCookie: foo=baz; quux=baz\r\n\r\n") + headers.should eq(orignal_headers) + end + + it "serialize POST (with body)" do + request = Request.new "POST", "/", body: "thisisthebody" + io = MemoryIO.new + request.to_io(io) + io.to_s.should eq("POST / HTTP/1.1\r\nContent-Length: 13\r\n\r\nthisisthebody") + end + + describe "keep-alive" do + it "is false by default in HTTP/1.0" do + request = Request.new "GET", "/", version: "HTTP/1.0" + request.keep_alive?.should be_false + end + + it "is true in HTTP/1.0 if `Connection: keep-alive` header is present" do + headers = HTTP::Headers.new + headers["Connection"] = "keep-alive" + orignal_headers = headers.dup + request = Request.new "GET", "/", headers: headers, version: "HTTP/1.0" + request.keep_alive?.should be_true + headers.should eq(orignal_headers) + end + + it "is true by default in HTTP/1.1" do + request = Request.new "GET", "/", version: "HTTP/1.1" + request.keep_alive?.should be_true + end + + it "is false in HTTP/1.1 if `Connection: close` header is present" do + headers = HTTP::Headers.new + headers["Connection"] = "close" + orignal_headers = headers.dup + request = Request.new "GET", "/", headers: headers, version: "HTTP/1.1" + request.keep_alive?.should be_false + headers.should eq(orignal_headers) + end + end + + describe "#path" do + it "returns parsed path" do + request = Request.new("GET", "/api/v3/some/resource?filter=hello&world=test") + request.path.should eq("/api/v3/some/resource") + end + + it "falls back to /" do + request = Request.new("GET", "/foo") + request.path = nil + request.path.should eq("/") + end + end + + describe "#path=" do + it "sets path" do + request = Request.new("GET", "/api/v3/some/resource?filter=hello&world=test") + request.path = "/api/v2/greet" + request.path.should eq("/api/v2/greet") + end + + it "updates @resource" do + request = Request.new("GET", "/api/v3/some/resource?filter=hello&world=test") + request.path = "/api/v2/greet" + request.resource.should eq("/api/v2/greet?filter=hello&world=test") + end + + it "updates serialized form" do + request = Request.new("GET", "/api/v3/some/resource?filter=hello&world=test") + request.path = "/api/v2/greet" + + io = MemoryIO.new + request.to_io(io) + io.to_s.should eq("GET /api/v2/greet?filter=hello&world=test HTTP/1.1\r\n\r\n") + end + end + + describe "#query" do + it "returns request's query" do + request = Request.new("GET", "/api/v3/some/resource?filter=hello&world=test") + request.query.should eq("filter=hello&world=test") + end + end + + describe "#query=" do + it "sets query" do + request = Request.new("GET", "/api/v3/some/resource?filter=hello&world=test") + request.query = "q=isearchforsomething&locale=de" + request.query.should eq("q=isearchforsomething&locale=de") + end + + it "updates @resource" do + request = Request.new("GET", "/api/v3/some/resource?filter=hello&world=test") + request.query = "q=isearchforsomething&locale=de" + request.resource.should eq("/api/v3/some/resource?q=isearchforsomething&locale=de") + end + + it "updates serialized form" do + request = Request.new("GET", "/api/v3/some/resource?filter=hello&world=test") + request.query = "q=isearchforsomething&locale=de" + + io = MemoryIO.new + request.to_io(io) + io.to_s.should eq("GET /api/v3/some/resource?q=isearchforsomething&locale=de HTTP/1.1\r\n\r\n") + end + end + + describe "#query_params" do + it "returns parsed HTTP::Params" do + request = Request.new("GET", "/api/v3/some/resource?foo=bar&foo=baz&baz=qux") + params = request.query_params + + params["foo"].should eq("bar") + params.fetch_all("foo").should eq(["bar", "baz"]) + params["baz"].should eq("qux") + end + + it "happily parses when query is not a canonical url-encoded string" do + request = Request.new("GET", "/api/v3/some/resource?{\"hello\":\"world\"}") + params = request.query_params + params["{\"hello\":\"world\"}"].should eq("") + params.to_s.should eq("%7B%22hello%22%3A%22world%22%7D=") + end + + it "affects #query when modified" do + request = Request.new("GET", "/api/v3/some/resource?foo=bar&foo=baz&baz=qux") + params = request.query_params + + params["foo"] = "not-bar" + request.query.should eq("foo=not-bar&foo=baz&baz=qux") + end + + it "updates @resource when modified" do + request = Request.new("GET", "/api/v3/some/resource?foo=bar&foo=baz&baz=qux") + params = request.query_params + + params["foo"] = "not-bar" + request.resource.should eq("/api/v3/some/resource?foo=not-bar&foo=baz&baz=qux") + end + + it "updates serialized form when modified" do + request = Request.new("GET", "/api/v3/some/resource?foo=bar&foo=baz&baz=qux") + params = request.query_params + + params["foo"] = "not-bar" + + io = MemoryIO.new + request.to_io(io) + io.to_s.should eq("GET /api/v3/some/resource?foo=not-bar&foo=baz&baz=qux HTTP/1.1\r\n\r\n") + end + + it "is affected when #query is modified" do + request = Request.new("GET", "/api/v3/some/resource?foo=bar&foo=baz&baz=qux") + params = request.query_params + + new_query = "foo=not-bar&foo=not-baz¬-baz=hello&name=world" + request.query = new_query + request.query_params.to_s.should eq(new_query) + end + end + end +end diff --git a/spec/std/http/server/handlers/deflate_handler_spec.cr b/spec/std/http/server/handlers/deflate_handler_spec.cr index 8610beec4882..1190e5b7b2b5 100644 --- a/spec/std/http/server/handlers/deflate_handler_spec.cr +++ b/spec/std/http/server/handlers/deflate_handler_spec.cr @@ -4,7 +4,7 @@ require "http/server" describe HTTP::DeflateHandler do it "doesn't deflates if doesn't have 'deflate' in Accept-Encoding header" do io = MemoryIO.new - request = HTTP::Request.new("GET", "/") + request = HTTP::Server::Request.new("GET", "/") response = HTTP::Server::Response.new(io) context = HTTP::Server::Context.new(request, response) @@ -21,7 +21,7 @@ describe HTTP::DeflateHandler do it "deflates if has deflate in 'deflate' Accept-Encoding header" do io = MemoryIO.new - request = HTTP::Request.new("GET", "/") + request = HTTP::Server::Request.new("GET", "/") request.headers["Accept-Encoding"] = "foo, deflate, other" response = HTTP::Server::Response.new(io) @@ -49,7 +49,7 @@ describe HTTP::DeflateHandler do it "deflates gzip if has deflate in 'deflate' Accept-Encoding header" do io = MemoryIO.new - request = HTTP::Request.new("GET", "/") + request = HTTP::Server::Request.new("GET", "/") request.headers["Accept-Encoding"] = "foo, gzip, other" response = HTTP::Server::Response.new(io) diff --git a/spec/std/http/server/handlers/error_handler_spec.cr b/spec/std/http/server/handlers/error_handler_spec.cr index d4b28df51181..30835d6b7c85 100644 --- a/spec/std/http/server/handlers/error_handler_spec.cr +++ b/spec/std/http/server/handlers/error_handler_spec.cr @@ -4,7 +4,7 @@ require "http/server" describe HTTP::ErrorHandler do it "rescues from exception" do io = MemoryIO.new - request = HTTP::Request.new("GET", "/") + request = HTTP::Server::Request.new("GET", "/") response = HTTP::Server::Response.new(io) context = HTTP::Server::Context.new(request, response) @@ -23,7 +23,7 @@ describe HTTP::ErrorHandler do it "can return a generic error message" do io = MemoryIO.new - request = HTTP::Request.new("GET", "/") + request = HTTP::Server::Request.new("GET", "/") response = HTTP::Server::Response.new(io) context = HTTP::Server::Context.new(request, response) diff --git a/spec/std/http/server/handlers/handler_spec.cr b/spec/std/http/server/handlers/handler_spec.cr index 6d8945757e90..b91855bd0c2c 100644 --- a/spec/std/http/server/handlers/handler_spec.cr +++ b/spec/std/http/server/handlers/handler_spec.cr @@ -10,7 +10,7 @@ end describe HTTP::Handler do it "responds with not found if there's no next handler" do io = MemoryIO.new - request = HTTP::Request.new("GET", "/") + request = HTTP::Server::Request.new("GET", "/") response = HTTP::Server::Response.new(io) context = HTTP::Server::Context.new(request, response) diff --git a/spec/std/http/server/handlers/log_handler_spec.cr b/spec/std/http/server/handlers/log_handler_spec.cr index 31496c064e02..b21bcaf28b2b 100644 --- a/spec/std/http/server/handlers/log_handler_spec.cr +++ b/spec/std/http/server/handlers/log_handler_spec.cr @@ -4,7 +4,7 @@ require "http/server" describe HTTP::LogHandler do it "logs" do io = MemoryIO.new - request = HTTP::Request.new("GET", "/") + request = HTTP::Server::Request.new("GET", "/") response = HTTP::Server::Response.new(io) context = HTTP::Server::Context.new(request, response) @@ -19,7 +19,7 @@ describe HTTP::LogHandler do it "does log errors" do io = MemoryIO.new - request = HTTP::Request.new("GET", "/") + request = HTTP::Server::Request.new("GET", "/") response = HTTP::Server::Response.new(io) context = HTTP::Server::Context.new(request, response) diff --git a/spec/std/http/server/handlers/static_file_handler_spec.cr b/spec/std/http/server/handlers/static_file_handler_spec.cr index c10f4528cad0..cf6e0abbe6ca 100644 --- a/spec/std/http/server/handlers/static_file_handler_spec.cr +++ b/spec/std/http/server/handlers/static_file_handler_spec.cr @@ -16,42 +16,42 @@ describe HTTP::StaticFileHandler do file_text = File.read "#{__DIR__}/static/test.txt" it "should serve a file" do - response = handle HTTP::Request.new("GET", "/test.txt") + response = handle HTTP::Server::Request.new("GET", "/test.txt") response.status_code.should eq(200) response.body.should eq(File.read("#{__DIR__}/static/test.txt")) end it "should list directory's entries" do - response = handle HTTP::Request.new("GET", "/") + response = handle HTTP::Server::Request.new("GET", "/") response.status_code.should eq(200) response.body.should match(/test.txt/) end it "should not serve a not found file" do - response = handle HTTP::Request.new("GET", "/not_found_file.txt") + response = handle HTTP::Server::Request.new("GET", "/not_found_file.txt") response.status_code.should eq(404) end it "should not serve a not found directory" do - response = handle HTTP::Request.new("GET", "/not_found_dir/") + response = handle HTTP::Server::Request.new("GET", "/not_found_dir/") response.status_code.should eq(404) end it "should not serve a file as directory" do - response = handle HTTP::Request.new("GET", "/test.txt/") + response = handle HTTP::Server::Request.new("GET", "/test.txt/") response.status_code.should eq(404) end it "should handle only GET and HEAD method" do %w(GET HEAD).each do |method| - response = handle HTTP::Request.new(method, "/test.txt") + response = handle HTTP::Server::Request.new(method, "/test.txt") response.status_code.should eq(200) end %w(POST PUT DELETE).each do |method| - response = handle HTTP::Request.new(method, "/test.txt") + response = handle HTTP::Server::Request.new(method, "/test.txt") response.status_code.should eq(404) - response = handle HTTP::Request.new(method, "/test.txt"), false + response = handle HTTP::Server::Request.new(method, "/test.txt"), false response.status_code.should eq(405) response.headers["Allow"].should eq("GET, HEAD") end @@ -59,14 +59,14 @@ describe HTTP::StaticFileHandler do it "should expand a request path" do %w(../test.txt ../../test.txt test.txt/../test.txt a/./b/../c/../../test.txt).each do |path| - response = handle HTTP::Request.new("GET", "/#{path}") + response = handle HTTP::Server::Request.new("GET", "/#{path}") response.status_code.should eq(302) response.headers["Location"].should eq("/test.txt") end # directory %w(.. ../ ../.. a/.. a/.././b/../).each do |path| - response = handle HTTP::Request.new("GET", "/#{path}") + response = handle HTTP::Server::Request.new("GET", "/#{path}") response.status_code.should eq(302) response.headers["Location"].should eq("/") end @@ -74,13 +74,13 @@ describe HTTP::StaticFileHandler do it "should unescape a request path" do %w(test%2Etxt %74%65%73%74%2E%74%78%74).each do |path| - response = handle HTTP::Request.new("GET", "/#{path}") + response = handle HTTP::Server::Request.new("GET", "/#{path}") response.status_code.should eq(200) response.body.should eq(file_text) end %w(%2E%2E/test.txt found%2F%2E%2E%2Ftest%2Etxt).each do |path| - response = handle HTTP::Request.new("GET", "/#{path}") + response = handle HTTP::Server::Request.new("GET", "/#{path}") response.status_code.should eq(302) response.headers["Location"].should eq("/test.txt") end @@ -88,7 +88,7 @@ describe HTTP::StaticFileHandler do it "should return 400" do %w(%00 test.txt%00).each do |path| - response = handle HTTP::Request.new("GET", "/#{path}") + response = handle HTTP::Server::Request.new("GET", "/#{path}") response.status_code.should eq(400) end end diff --git a/spec/std/http/server/handlers/websocket_handler_spec.cr b/spec/std/http/server/handlers/websocket_handler_spec.cr index 2511a80cda2e..50f349d7b62b 100644 --- a/spec/std/http/server/handlers/websocket_handler_spec.cr +++ b/spec/std/http/server/handlers/websocket_handler_spec.cr @@ -4,7 +4,7 @@ require "http/server" describe HTTP::WebSocketHandler do it "returns not found if the request is not an websocket upgrade" do io = MemoryIO.new - request = HTTP::Request.new("GET", "/") + request = HTTP::Server::Request.new("GET", "/") response = HTTP::Server::Response.new(io) context = HTTP::Server::Context.new(request, response) @@ -26,7 +26,7 @@ describe HTTP::WebSocketHandler do "Connection" => {{connection}}, "Sec-WebSocket-Key" => "dGhlIHNhbXBsZSBub25jZQ==", } - request = HTTP::Request.new("GET", "/", headers: headers) + request = HTTP::Server::Request.new("GET", "/", headers: headers) response = HTTP::Server::Response.new(io) context = HTTP::Server::Context.new(request, response) diff --git a/spec/std/http/request_spec.cr b/spec/std/http/server/request_spec.cr similarity index 97% rename from spec/std/http/request_spec.cr rename to spec/std/http/server/request_spec.cr index 7f2af10e8b9c..113cf094ab9b 100644 --- a/spec/std/http/request_spec.cr +++ b/spec/std/http/server/request_spec.cr @@ -1,7 +1,7 @@ require "spec" -require "http/request" +require "http/client/request" -module HTTP +class HTTP::Server describe Request do it "serialize GET" do headers = HTTP::Headers.new @@ -68,10 +68,10 @@ module HTTP end it "serialize POST (with body)" do - request = Request.new "POST", "/", body: "thisisthebody" + request = Request.new "POST", "/", body_io: MemoryIO.new("thisisthebody") io = MemoryIO.new request.to_io(io) - io.to_s.should eq("POST / HTTP/1.1\r\nContent-Length: 13\r\n\r\nthisisthebody") + io.to_s.should eq("POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\nd\r\nthisisthebody\r\n0\r\n\r\n") end it "parses GET" do diff --git a/spec/std/http/server/server_spec.cr b/spec/std/http/server/server_spec.cr index 1ed23d5cd5a0..4bf534fd7039 100644 --- a/spec/std/http/server/server_spec.cr +++ b/spec/std/http/server/server_spec.cr @@ -140,6 +140,18 @@ module HTTP io.to_s.should eq("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nHello\r\n0\r\n\r\n") end + it "closes request io when flushing" do + request_io = MemoryIO.new("hello") + response_io = MemoryIO.new + response = Response.new(response_io) + response.request_io = request_io + response.print("Hello") + response.flush + response_io.to_s.should eq("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nHello\r\n") + response.close + response_io.to_s.should eq("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nHello\r\n0\r\n\r\n") + end + it "wraps output" do io = MemoryIO.new response = Response.new(io) diff --git a/spec/std/oauth/signature_spec.cr b/spec/std/oauth/signature_spec.cr index f9496605ebb7..b9d3025b5843 100644 --- a/spec/std/oauth/signature_spec.cr +++ b/spec/std/oauth/signature_spec.cr @@ -16,7 +16,7 @@ describe OAuth::Signature do describe "base string" do it "computes without port in host" do - request = HTTP::Request.new "POST", "/some/path" + request = HTTP::Client::Request.new "POST", "/some/path" request.headers["Host"] = "some.host" tls = false ts = "1234" @@ -29,7 +29,7 @@ describe OAuth::Signature do end it "computes with port in host" do - request = HTTP::Request.new "POST", "/some/path" + request = HTTP::Client::Request.new "POST", "/some/path" request.headers["Host"] = "some.host:5678" tls = false ts = "1234" @@ -42,7 +42,7 @@ describe OAuth::Signature do end it "computes when TLS" do - request = HTTP::Request.new "POST", "/some/path" + request = HTTP::Client::Request.new "POST", "/some/path" request.headers["Host"] = "some.host" tls = true ts = "1234" @@ -57,7 +57,7 @@ describe OAuth::Signature do # https://dev.twitter.com/oauth/overview/creating-signatures it "does twitter sample" do - request = HTTP::Request.new "POST", "/1/statuses/update.json?include_entities=true", body: "status=Hello%20Ladies%20%2b%20Gentlemen%2c%20a%20signed%20OAuth%20request%21" + request = HTTP::Client::Request.new "POST", "/1/statuses/update.json?include_entities=true", body: "status=Hello%20Ladies%20%2b%20Gentlemen%2c%20a%20signed%20OAuth%20request%21" request.headers["Host"] = "api.twitter.com" request.headers["Content-type"] = "application/x-www-form-urlencoded" tls = true diff --git a/spec/std/oauth2/access_token_spec.cr b/spec/std/oauth2/access_token_spec.cr index d88a3c300efb..396c1b33c08d 100644 --- a/spec/std/oauth2/access_token_spec.cr +++ b/spec/std/oauth2/access_token_spec.cr @@ -42,7 +42,7 @@ class OAuth2::AccessToken it "authenticates request" do token = Bearer.new("access token", 3600, "refresh token") - request = HTTP::Request.new "GET", "/" + request = HTTP::Client::Request.new "GET", "/" token.authenticate request, false request.headers["Authorization"].should eq("Bearer access token") end @@ -112,7 +112,7 @@ class OAuth2::AccessToken headers["Host"] = "localhost:4000" token = Mac.new("3n2-YaAzH67YH9UJ-9CnJ_PS-vSy1MRLM-q7TZknPw", 3600, "hmac-sha-256", "i-pt1Lir-yAfUdXbt-AXM1gMupK7vDiOK1SZGWkASDc") - request = HTTP::Request.new "GET", "/some/resource.json", headers + request = HTTP::Client::Request.new "GET", "/some/resource.json", headers token.authenticate request, false auth = request.headers["Authorization"] (auth =~ /MAC id=".+?", nonce=".+?", ts=".+?", mac=".+?"/).should be_truthy diff --git a/src/http/client.cr b/src/http/client.cr index 22ed40ff7e15..58e6cd283c9a 100644 --- a/src/http/client.cr +++ b/src/http/client.cr @@ -286,8 +286,8 @@ class HTTP::Client # end # client.get "/" # ``` - def before_request(&callback : HTTP::Request ->) - before_request = @before_request ||= [] of (HTTP::Request ->) + def before_request(&callback : Request ->) + before_request = @before_request ||= [] of (Request ->) before_request << callback end @@ -439,10 +439,10 @@ class HTTP::Client # # ``` # client = HTTP::Client.new "www.example.com" - # response = client.exec HTTP::Request.new("GET", "/") + # response = client.exec HTTP::Client::Request.new("GET", "/") # response.body # => "..." # ``` - def exec(request : HTTP::Request) : HTTP::Client::Response + def exec(request : Request) : Response execute_callbacks(request) exec_internal(request) end @@ -461,11 +461,11 @@ class HTTP::Client # # ``` # client = HTTP::Client.new "www.example.com" - # client.exec(HTTP::Request.new("GET", "/")) do |response| + # client.exec(HTTP::Client::Request.new("GET", "/")) do |response| # response.body_io.gets # => "..." # end # ``` - def exec(request : HTTP::Request, &block) + def exec(request : Request, &block) execute_callbacks(request) exec_internal(request) do |response| yield response @@ -476,7 +476,7 @@ class HTTP::Client decompress = set_defaults request request.to_io(socket) socket.flush - HTTP::Client::Response.from_io(socket, ignore_body: request.ignore_body?, decompress: decompress) do |response| + Response.from_io(socket, ignore_body: request.ignore_body?, decompress: decompress) do |response| value = yield response response.body_io.try &.close close unless response.keep_alive? @@ -561,7 +561,7 @@ class HTTP::Client end private def new_request(method, path, headers, body) - HTTP::Request.new(method, path, headers, body).tap do |request| + Request.new(method, path, headers, body).tap do |request| request.headers["Host"] ||= host_header end end @@ -675,5 +675,6 @@ end require "socket" require "uri" require "base64" +require "./client/request" require "./client/response" require "./common" diff --git a/src/http/client/request.cr b/src/http/client/request.cr new file mode 100644 index 000000000000..14c1007b70cc --- /dev/null +++ b/src/http/client/request.cr @@ -0,0 +1,95 @@ +require "../common" +require "uri" +require "http/params" + +class HTTP::Client::Request + getter method : String + getter headers : Headers + getter body : String? + getter version : String + @cookies : Cookies? + @query_params : Params? + @uri : URI? + + def initialize(@method : String, @resource : String, headers : Headers? = nil, @body = nil, @version = "HTTP/1.1") + @headers = headers.try(&.dup) || Headers.new + if body = @body + @headers["Content-Length"] = body.bytesize.to_s + elsif @method == "POST" || @method == "PUT" + @headers["Content-Length"] = "0" + end + end + + # Returns a convenience wrapper around querying and setting cookie related + # headers, see `HTTP::Cookies`. + def cookies + @cookies ||= Cookies.from_headers(headers) + end + + # Returns a convenience wrapper around querying and setting query params, + # see `HTTP::Params`. + def query_params + @query_params ||= parse_query_params + end + + def resource + update_uri + @uri.try(&.full_path) || @resource + end + + def keep_alive? + HTTP.keep_alive?(self) + end + + def ignore_body? + @method == "HEAD" + end + + def to_io(io) + io << @method << " " << resource << " " << @version << "\r\n" + cookies = @cookies + headers = cookies ? cookies.add_request_headers(@headers) : @headers + HTTP.serialize_headers_and_body(io, headers, @body, nil, @version) + end + + # Lazily parses and return the request's path component. + def path + uri.path || "/" + end + + # Sets request's path component. + def path=(path) + uri.path = path + end + + # Lazily parses and returns the request's query component. + def query + update_uri + uri.query + end + + # Sets request's query component. + def query=(value) + uri.query = value + update_query_params + value + end + + private def uri + (@uri ||= URI.parse(@resource)).not_nil! + end + + private def parse_query_params + HTTP::Params.parse(uri.query || "") + end + + private def update_query_params + return unless @query_params + @query_params = parse_query_params + end + + private def update_uri + return unless @query_params + uri.query = query_params.to_s + end +end diff --git a/src/http/common.cr b/src/http/common.cr index ed04c261b04e..59ae12d0073b 100644 --- a/src/http/common.cr +++ b/src/http/common.cr @@ -272,8 +272,6 @@ module HTTP end end -require "./request" -require "./client/response" require "./headers" require "./content" require "./cookie" diff --git a/src/http/cookie.cr b/src/http/cookie.cr index 9553ecbde833..d1413660133d 100644 --- a/src/http/cookie.cr +++ b/src/http/cookie.cr @@ -133,7 +133,7 @@ module HTTP # Create a new instance by parsing the `Cookie` and `Set-Cookie` # headers in the given `HTTP::Headers`. # - # See `HTTP::Request#cookies` and `HTTP::Client::Response#cookies`. + # See `HTTP::Server::Request#cookies` and `HTTP::Client::Response#cookies`. def self.from_headers(headers) : self new.tap { |cookies| cookies.fill_from_headers(headers) } end diff --git a/src/http/server.cr b/src/http/server.cr index ffdbe3f0bd7a..c5876c239f47 100644 --- a/src/http/server.cr +++ b/src/http/server.cr @@ -10,7 +10,7 @@ require "./common" # An HTTP server. # # A server is given a handler that receives an `HTTP::Server::Context` that holds -# the `HTTP::Request` to process and must in turn configure and write to an `HTTP::Server::Response`. +# the `HTTP::Server::Request` to process and must in turn configure and write to an `HTTP::Server::Response`. # # The `HTTP::Server::Response` object has `status` and `headers` properties that can be # configured before writing the response body. Once response output is written, diff --git a/src/http/server/context.cr b/src/http/server/context.cr index 85ec07572282..f3591b5e1322 100644 --- a/src/http/server/context.cr +++ b/src/http/server/context.cr @@ -1,7 +1,7 @@ class HTTP::Server # Instances of this class are passed to an `HTTP::Server` handler. class Context - # The `HTTP::Request` to process. + # The `HTTP::Server::Request` to process. getter request : Request # The `HTTP::Server::Response` to configure and write to. diff --git a/src/http/request.cr b/src/http/server/request.cr similarity index 82% rename from src/http/request.cr rename to src/http/server/request.cr index 7865be2cd3ed..fe1bb0ad94f1 100644 --- a/src/http/request.cr +++ b/src/http/server/request.cr @@ -1,23 +1,23 @@ -require "./common" +require "../common" require "uri" require "http/params" -class HTTP::Request +class HTTP::Server::Request getter method : String getter headers : Headers - getter body : String? + getter body_io : IO? getter version : String @cookies : Cookies? @query_params : Params? @uri : URI? + @body : String? - def initialize(@method : String, @resource : String, headers : Headers? = nil, @body = nil, @version = "HTTP/1.1") + def initialize(@method : String, @resource : String, headers : Headers? = nil, @body_io = nil, @version = "HTTP/1.1") @headers = headers.try(&.dup) || Headers.new - if body = @body - @headers["Content-Length"] = body.bytesize.to_s - elsif @method == "POST" || @method == "PUT" - @headers["Content-Length"] = "0" - end + end + + def body + @body ||= (@body_io.try(&.gets_to_end) || "") end # Returns a convenience wrapper around querying and setting cookie related @@ -49,7 +49,7 @@ class HTTP::Request io << @method << " " << resource << " " << @version << "\r\n" cookies = @cookies headers = cookies ? cookies.add_request_headers(@headers) : @headers - HTTP.serialize_headers_and_body(io, headers, @body, nil, @version) + HTTP.serialize_headers_and_body(io, headers, nil, @body_io, @version) end # :nodoc: @@ -58,7 +58,7 @@ class HTTP::Request # Returns: # * nil: EOF # * BadRequest: bad request - # * HTTP::Request: successfully parsed + # * HTTP::Server::Request: successfully parsed def self.from_io(io) request_line = io.gets return unless request_line @@ -68,7 +68,7 @@ class HTTP::Request method, resource, http_version = parts HTTP.parse_headers_and_body(io) do |headers, body| - return new method, resource, headers, body.try &.gets_to_end, http_version + return new method, resource, headers, body, http_version end # Unexpected end of http request diff --git a/src/http/server/request_processor.cr b/src/http/server/request_processor.cr index 7f03b7e6f773..bff26b45a58e 100644 --- a/src/http/server/request_processor.cr +++ b/src/http/server/request_processor.cr @@ -19,17 +19,18 @@ class HTTP::Server::RequestProcessor begin until @wants_close - request = HTTP::Request.from_io(input) + request = HTTP::Server::Request.from_io(input) # EOF break unless request - if request.is_a?(HTTP::Request::BadRequest) + if request.is_a?(HTTP::Server::Request::BadRequest) response.respond_with_error("Bad Request", 400) response.close return end + response.request_io = request.body_io response.version = request.version response.reset response.headers["Connection"] = "keep-alive" if request.keep_alive? diff --git a/src/http/server/response.cr b/src/http/server/response.cr index c22f13abf3d4..a877c9283281 100644 --- a/src/http/server/response.cr +++ b/src/http/server/response.cr @@ -18,7 +18,7 @@ class HTTP::Server # The response headers (`HTTP::Headers`). These must be set before writing to the response. getter headers : HTTP::Headers - # The version of the HTTP::Request that created this response. + # The version of the HTTP::Server::Request that created this response. getter version : String # The `IO` to which output is written. This can be changed/wrapped to filter @@ -32,6 +32,10 @@ class HTTP::Server # body. If not set, the default value is 200 (OK). property status_code : Int32 + # Hold a reference to the request's IO: before writing anything + # into the response we must close this IO to advance the pointer in the socket + protected property request_io : IO? + # :nodoc: def initialize(@io : IO, @version = "HTTP/1.1") @headers = Headers.new @@ -116,6 +120,10 @@ class HTTP::Server end protected def write_headers + # Make sure to finish reading the request + # before writing anything to the response + request_io.try &.close + status_message = HTTP.default_status_message_for(@status_code) @io << @version << " " << @status_code << " " << status_message << "\r\n" headers.each do |name, values| diff --git a/src/http/web_socket/protocol.cr b/src/http/web_socket/protocol.cr index e035aef02652..9cd4863e697f 100644 --- a/src/http/web_socket/protocol.cr +++ b/src/http/web_socket/protocol.cr @@ -251,7 +251,7 @@ class HTTP::WebSocket::Protocol headers["Sec-WebSocket-Key"] = Base64.strict_encode(StaticArray(UInt8, 16).new { rand(256).to_u8 }) path = "/" if path.empty? - handshake = HTTP::Request.new("GET", path, headers) + handshake = HTTP::Client::Request.new("GET", path, headers) handshake.to_io(socket) handshake_response = HTTP::Client::Response.from_io(socket) unless handshake_response.status_code == 101 diff --git a/src/oauth2/access_token/access_token.cr b/src/oauth2/access_token/access_token.cr index c9eb4a313b7b..ae768c1b5384 100644 --- a/src/oauth2/access_token/access_token.cr +++ b/src/oauth2/access_token/access_token.cr @@ -51,7 +51,7 @@ abstract class OAuth2::AccessToken @expires_in = expires_in.to_i64 end - abstract def authenticate(request : HTTP::Request, tls) + abstract def authenticate(request : HTTP::Client::Request, tls) def authenticate(client : HTTP::Client) client.before_request do |request| diff --git a/src/oauth2/access_token/bearer.cr b/src/oauth2/access_token/bearer.cr index 95a7b5521954..fc6514b0cf7e 100644 --- a/src/oauth2/access_token/bearer.cr +++ b/src/oauth2/access_token/bearer.cr @@ -9,7 +9,7 @@ class OAuth2::AccessToken::Bearer < OAuth2::AccessToken "Bearer" end - def authenticate(request : HTTP::Request, tls) + def authenticate(request : HTTP::Client::Request, tls) request.headers["Authorization"] = "Bearer #{access_token}" end diff --git a/src/oauth2/access_token/mac.cr b/src/oauth2/access_token/mac.cr index 6da94c930c93..1d724d87cc18 100644 --- a/src/oauth2/access_token/mac.cr +++ b/src/oauth2/access_token/mac.cr @@ -20,7 +20,7 @@ class OAuth2::AccessToken::Mac < OAuth2::AccessToken "Mac" end - def authenticate(request : HTTP::Request, tls) + def authenticate(request : HTTP::Client::Request, tls) ts = Time.now.epoch nonce = "#{ts - @issued_at}:#{SecureRandom.hex}" method = request.method