diff --git a/.gitignore b/.gitignore index 40d8d0f05..7292d94a5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ .DS_Store *.key *.crt +*.swp docs/build/ -docs/site/ \ No newline at end of file +docs/site/ diff --git a/.travis.yml b/.travis.yml index 70f319b50..cb42ec219 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ language: julia os: - linux - - osx +# - osx julia: - 0.6 - nightly diff --git a/README.md b/README.md index a6fd21ed0..3a912cf01 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # HTTP -*Performant, robust HTTP client and server functionality for Julia* +*HTTP client and server functionality for Julia* | **Documentation** | **PackageEvaluator** | **Build Status** | |:-------------------------------------------------------------------------------:|:---------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------:| @@ -22,6 +22,9 @@ julia> Pkg.add("HTTP") ## Project Status +The package is new and not yet tested in production systems. +Please try it out and report your experiance. + The package is tested against Julia 0.6 & current master on Linux, OS X, and Windows. ## Contributing and Questions diff --git a/REQUIRE b/REQUIRE index aea7cf2f0..1aca9608d 100644 --- a/REQUIRE +++ b/REQUIRE @@ -1,2 +1,3 @@ julia 0.6 -MbedTLS 0.4.0 +MbedTLS 0.5.2 +IniFile diff --git a/docs/src/index.md b/docs/src/index.md index 883be666e..f55d0519a 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,55 +1,219 @@ # HTTP.jl Documentation -`HTTP.jl` provides a pure Julia library for HTTP functionality. +`HTTP.jl` is a Julia library for HTTP Messages. + +[`HTTP.request`](@ref) sends a HTTP Request Message and +returns a Response Message. + +```julia +r = HTTP.request("GET", "http://httpbin.org/ip") +println(r.status) +println(String(r.body)) +``` + +[`HTTP.open`](@ref) sends a HTTP Request Message and +opens an `IO` stream from which the Response can be read. + +```julia +HTTP.open("GET", "https://tinyurl.com/bach-cello-suite-1-ogg") do http + open(`vlc -q --play-and-exit --intf dummy -`, "w") do vlc + write(vlc, http) + end +end +``` + ```@contents ``` ## Requests -Note that the HTTP methods of POST, DELETE, PUT, etc. all follow the same format as `HTTP.get`, documented below. + + ```@docs +HTTP.request(::String,::HTTP.URIs.URI,::Array{Pair{String,String},1},::Any) +HTTP.open HTTP.get -HTTP.Client -HTTP.Connection +HTTP.put +HTTP.post +HTTP.head ``` -### HTTP request errors +Request functions may throw the following exceptions: + ```@docs -HTTP.ConnectError -HTTP.SendError -HTTP.ClosedError -HTTP.ReadError -HTTP.RedirectError HTTP.StatusError +HTTP.ParsingError +HTTP.IOError ``` +``` +Base.DNSError +``` + ## Server / Handlers + ```@docs -HTTP.serve -HTTP.Server +HTTP.listen +HTTP.Servers.serve +HTTP.Servers.Server HTTP.Handler HTTP.HandlerFunction HTTP.Router HTTP.register! ``` -## HTTP Types + +## URIs + ```@docs HTTP.URI -HTTP.Request -HTTP.RequestOptions -HTTP.Response +HTTP.URIs.escapeuri +HTTP.URIs.unescapeuri +HTTP.URIs.splitpath +Base.isvalid(::HTTP.URIs.URI) +``` + + +## Cookies + +```@docs HTTP.Cookie -HTTP.FIFOBuffer ``` -## HTTP Utilities + +## Utilities + ```@docs -HTTP.parse -HTTP.escape -HTTP.unescape -HTTP.splitpath -HTTP.isvalid HTTP.sniff -HTTP.escapeHTML +HTTP.Strings.escapehtml +``` + +# HTTP.jl Internal Architecture + +```@docs +HTTP.Layer +HTTP.stack +``` + + +## Request Execution Layers + +```@docs +HTTP.RedirectLayer +HTTP.BasicAuthLayer +HTTP.CookieLayer +HTTP.CanonicalizeLayer +HTTP.MessageLayer +HTTP.AWS4AuthLayer +HTTP.RetryLayer +HTTP.ExceptionLayer +HTTP.ConnectionPoolLayer +HTTP.TimeoutLayer +HTTP.StreamLayer +``` + +## Parser + +*Source: `Parsers.jl`* + +```@docs +HTTP.Parsers.Parser +``` + + +## Messages +*Source: `Messages.jl`* + +```@docs +HTTP.Messages +``` + + +## Streams +*Source: `Streams.jl`* + +```@docs +HTTP.Streams.Stream +``` + + +## Connections + +*Source: `ConnectionPool.jl`* + +```@docs +HTTP.ConnectionPool +``` + + +# Internal Interfaces + +## Parser Interface + +```@docs +HTTP.Parsers.Message +HTTP.Parsers.parseheaders +HTTP.Parsers.parsebody +HTTP.Parsers.reset! +HTTP.Parsers.messagestarted +HTTP.Parsers.headerscomplete +HTTP.Parsers.bodycomplete +HTTP.Parsers.messagecomplete +HTTP.Parsers.messagehastrailing +``` + +## Messages Interface + +```@docs +HTTP.Messages.Request +HTTP.Messages.Response +HTTP.Messages.iserror +HTTP.Messages.isredirect +HTTP.Messages.ischunked +HTTP.Messages.issafe +HTTP.Messages.isidempotent +HTTP.Messages.header +HTTP.Messages.hasheader +HTTP.Messages.setheader +HTTP.Messages.defaultheader +HTTP.Messages.appendheader +HTTP.Messages.readheaders +HTTP.Messages.readstartline! +HTTP.Messages.headerscomplete(::HTTP.Messages.Response) +HTTP.Messages.readtrailers +HTTP.Messages.writestartline +HTTP.Messages.writeheaders +Base.write(::IO,::HTTP.Messages.Message) +``` + +## IOExtras Interface + +```@docs +HTTP.IOExtras +HTTP.IOExtras.unread! +HTTP.IOExtras.startwrite(::IO) +HTTP.IOExtras.isioerror +``` + + +## Streams Interface + +```@docs +HTTP.Streams.closebody +HTTP.Streams.isaborted +``` + + +## Connection Pooling Interface + +```@docs +HTTP.ConnectionPool.Connection +HTTP.ConnectionPool.Transaction +HTTP.ConnectionPool.pool +HTTP.ConnectionPool.getconnection +HTTP.IOExtras.unread!(::HTTP.ConnectionPool.Transaction,::SubArray{UInt8,1,Array{UInt8,1},Tuple{UnitRange{Int64}},true}) +HTTP.IOExtras.startwrite(::HTTP.ConnectionPool.Transaction) +HTTP.IOExtras.closewrite(::HTTP.ConnectionPool.Transaction) +HTTP.IOExtras.startread(::HTTP.ConnectionPool.Transaction) +HTTP.IOExtras.closeread(::HTTP.ConnectionPool.Transaction) ``` diff --git a/docs/src/layers.monopic b/docs/src/layers.monopic new file mode 100644 index 000000000..49c4568b2 Binary files /dev/null and b/docs/src/layers.monopic differ diff --git a/src/AWS4AuthRequest.jl b/src/AWS4AuthRequest.jl new file mode 100644 index 000000000..936de153d --- /dev/null +++ b/src/AWS4AuthRequest.jl @@ -0,0 +1,155 @@ +module AWS4AuthRequest + +using ..Base64 +using ..Dates +using MbedTLS: digest, MD_SHA256, MD_MD5 +import ..Layer, ..request, ..Headers +using ..URIs +using ..Pairs: getkv, setkv, rmkv +import ..@debug, ..DEBUG_LEVEL + + +""" + request(AWS4AuthLayer, ::URI, ::Request, body) -> HTTP.Response + +Add a [AWS Signature Version 4](http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html) +`Authorization` header to a `Request`. + + +Credentials are read from environment variables `AWS_ACCESS_KEY_ID`, +`AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN`. +""" + +abstract type AWS4AuthLayer{Next <: Layer} <: Layer end +export AWS4AuthLayer + +function request(::Type{AWS4AuthLayer{Next}}, + url::URI, req, body; kw...) where Next + + @static if VERSION > v"0.7.0-DEV.2915" + if !haskey(kw, :aws_access_key_id) && + !haskey(ENV, "AWS_ACCESS_KEY_ID") + kw = merge(dot_aws_credentials(), kw) + end + end + + sign_aws4!(req.method, url, req.headers, req.body; kw...) + + return request(Next, url, req, body; kw...) +end + + +function sign_aws4!(method::String, + url::URI, + headers::Headers, + body::Vector{UInt8}; + body_sha256::Vector{UInt8}=digest(MD_SHA256, body), + body_md5::Vector{UInt8}=digest(MD_MD5, body), + t::DateTime=now(Dates.UTC), + aws_service::String=String(split(url.host, ".")[1]), + aws_region::String=String(split(url.host, ".")[2]), + aws_access_key_id::String=ENV["AWS_ACCESS_KEY_ID"], + aws_secret_access_key::String=ENV["AWS_SECRET_ACCESS_KEY"], + aws_session_token::String=get(ENV, "AWS_SESSION_TOKEN", ""), + kw...) + + + # ISO8601 date/time strings for time of request... + date = Dates.format(t,"yyyymmdd") + datetime = Dates.format(t,"yyyymmddTHHMMSSZ") + + # Authentication scope... + scope = [date, aws_region, aws_service, "aws4_request"] + + # Signing key generated from today's scope string... + signing_key = string("AWS4", aws_secret_access_key) + for element in scope + signing_key = digest(MD_SHA256, element, signing_key) + end + + # Authentication scope string... + scope = join(scope, "/") + + # SHA256 hash of content... + content_hash = bytes2hex(body_sha256) + + # HTTP headers... + rmkv(headers, "Authorization") + setkv(headers, "x-amz-content-sha256", content_hash) + setkv(headers, "x-amz-date", datetime) + setkv(headers, "Content-MD5", base64encode(body_md5)) + if aws_session_token != "" + setkv(headers, "x-amz-security-token", aws_session_token) + end + + # Sort and lowercase() Headers to produce canonical form... + canonical_headers = ["$(lowercase(k)):$(strip(v))" for (k,v) in headers] + signed_headers = join(sort([lowercase(k) for (k,v) in headers]), ";") + + # Sort Query String... + query = queryparams(url.query) + query = Pair[k => query[k] for k in sort(collect(keys(query)))] + + # Create hash of canonical request... + canonical_form = string(method, "\n", + aws_service == "s3" ? url.path + : escapepath(url.path), "\n", + escapeuri(query), "\n", + join(sort(canonical_headers), "\n"), "\n\n", + signed_headers, "\n", + content_hash) + @debug 3 "AWS4 canonical_form: $canonical_form" + + canonical_hash = bytes2hex(digest(MD_SHA256, canonical_form)) + + # Create and sign "String to Sign"... + string_to_sign = "AWS4-HMAC-SHA256\n$datetime\n$scope\n$canonical_hash" + signature = bytes2hex(digest(MD_SHA256, string_to_sign, signing_key)) + + @debug 3 "AWS4 string_to_sign: $string_to_sign" + @debug 3 "AWS4 signature: $signature" + + # Append Authorization header... + setkv(headers, "Authorization", string( + "AWS4-HMAC-SHA256 ", + "Credential=$aws_access_key_id/$scope, ", + "SignedHeaders=$signed_headers, ", + "Signature=$signature" + )) +end + +@static if VERSION > v"0.7.0-DEV.2915" + +using IniFile + +credentials = NamedTuple() + +""" +Load Credentials from [AWS CLI ~/.aws/credentials file] +(http://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html). +""" + +function dot_aws_credentials()::NamedTuple + + global credentials + if !isempty(credentials) + return credentials + end + + f = get(ENV, "AWS_CONFIG_FILE", joinpath(homedir(), ".aws", "credentials")) + p = get(ENV, "AWS_DEFAULT_PROFILE", get(ENV, "AWS_PROFILE", "default")) + + if !isfile(f) + return NamedTuple() + end + + ini = read(Inifile(), f) + + credentials = ( + aws_access_key_id = String(get(ini, p, "aws_access_key_id")), + aws_secret_access_key = String(get(ini, p, "aws_secret_access_key"))) +end + +end + +end # module AWS4AuthRequest diff --git a/src/BasicAuthRequest.jl b/src/BasicAuthRequest.jl new file mode 100644 index 000000000..cb64d5be6 --- /dev/null +++ b/src/BasicAuthRequest.jl @@ -0,0 +1,34 @@ +module BasicAuthRequest + +using ..Base64 + +import ..Layer, ..request +using ..URIs +using ..Pairs: getkv, setkv +import ..@debug, ..DEBUG_LEVEL + + +""" + request(BasicAuthLayer, method, ::URI, headers, body) -> HTTP.Response + +Add `Authorization: Basic` header using credentials from url userinfo. +""" + +abstract type BasicAuthLayer{Next <: Layer} <: Layer end +export BasicAuthLayer + +function request(::Type{BasicAuthLayer{Next}}, + method::String, url::URI, headers, body; kw...) where Next + + userinfo = url.userinfo + + if !isempty(userinfo) && getkv(headers, "Authorization", "") == "" + @debug 1 "Adding Authorization: Basic header." + setkv(headers, "Authorization", "Basic $(base64encode(userinfo))") + end + + return request(Next, method, url, headers, body; kw...) +end + + +end # module BasicAuthRequest diff --git a/src/CanonicalizeRequest.jl b/src/CanonicalizeRequest.jl new file mode 100644 index 000000000..ac435dfd2 --- /dev/null +++ b/src/CanonicalizeRequest.jl @@ -0,0 +1,33 @@ +module CanonicalizeRequest + +import ..Layer, ..request +using ..Messages +using ..Strings.tocameldash! + + +""" + request(CanonicalizeLayer, method, ::URI, headers, body) -> HTTP.Response + +Rewrite request and response headers in Canonical-Camel-Dash-Format. +""" + +abstract type CanonicalizeLayer{Next <: Layer} <: Layer end +export CanonicalizeLayer + +function request(::Type{CanonicalizeLayer{Next}}, + method::String, url, headers, body; kw...) where Next + + headers = canonicalizeheaders(headers) + + res = request(Next, method, url, headers, body; kw...) + + res.headers = canonicalizeheaders(res.headers) + + return res +end + + +canonicalizeheaders(h::T) where T = T([tocameldash!(k) => v for (k,v) in h]) + + +end # module CanonicalizeRequest diff --git a/src/ConnectionPool.jl b/src/ConnectionPool.jl new file mode 100644 index 000000000..5ffd2660c --- /dev/null +++ b/src/ConnectionPool.jl @@ -0,0 +1,597 @@ +""" +This module provides the [`getconnection`](@ref) function with support for: +- Opening TCP and SSL connections. +- Reusing connections for multiple Request/Response Messages, +- Pipelining Request/Response Messages. i.e. allowing a new Request to be + sent before previous Responses have been read. + +This module defines a [`Connection`](@ref) +struct to manage pipelining and connection reuse and a +[`Transaction`](@ref)`<: IO` struct to manage a single +pipelined request. Methods are provided for `eof`, `readavailable`, +`unsafe_write` and `close`. +This allows the `Transaction` object to act as a proxy for the +`TCPSocket` or `SSLContext` that it wraps. + +The [`pool`](@ref) is a collection of open +`Connection`s. The `request` function calls `getconnection` to +retrieve a connection from the `pool`. When the `request` function +has written a Request Message it calls `closewrite` to signal that +the `Connection` can be reused for writing (to send the next Request). +When the `request` function has read the Response Message it calls +`closeread` to signal that the `Connection` can be reused for +reading. +""" + +module ConnectionPool + +export Connection, Transaction, + getconnection, getparser, getrawstream, inactiveseconds + +using ..IOExtras + +import ..@debug, ..@debugshow, ..DEBUG_LEVEL, ..taskid +import ..@require, ..precondition_error, ..@ensure, ..postcondition_error +using MbedTLS: SSLConfig, SSLContext, setup!, associate!, hostname!, handshake! + +import ..Parser + + +const default_connection_limit = 8 +const default_pipeline_limit = 16 +const nolimit = typemax(Int) + +const nobytes = view(UInt8[], 1:0) +const ByteView = typeof(nobytes) +byteview(bytes::ByteView) = bytes +byteview(bytes)::ByteView = view(bytes, 1:length(bytes)) + + +""" + Connection{T <: IO} + +A `TCPSocket` or `SSLContext` connection to a HTTP `host` and `port`. + +Fields: +- `host::String` +- `port::String`, exactly as specified in the URI (i.e. may be empty). +- `pipeline_limit`, number of requests to send before waiting for responses. +- `peerport`, remote TCP port number (used for debug messages). +- `localport`, local TCP port number (used for debug messages). +- `io::T`, the `TCPSocket` or `SSLContext. +- `excess::ByteView`, left over bytes read from the connection after + the end of a response message. These bytes are probably the start of the + next response message. +- `sequence`, number of most recent `Transaction`. +- `writecount`, number of Messages that have been written. +- `writedone`, signal that `writecount` was incremented. +- `readcount`, number of Messages that have been read. +- `readdone`, signal that `readcount` was incremented. +- `timestamp`, time data was last recieved. +- `parser::Parser`, reuse a `Parser` when this `Connection` is reused. +""" + +mutable struct Connection{T <: IO} + host::String + port::String + pipeline_limit::Int + peerport::UInt16 + localport::UInt16 + io::T + excess::ByteView + sequence::Int + writecount::Int + writebusy::Bool + writedone::Condition + readcount::Int + readbusy::Bool + readdone::Condition + timestamp::Float64 + parser::Parser +end + + +""" +A single pipelined HTTP Request/Response transaction`. + +Fields: + - `c`, the shared [`Connection`](@ref) used for this `Transaction`. + - `sequence::Int`, identifies this `Transaction` among the others that share `c`. +""" + +struct Transaction{T <: IO} <: IO + c::Connection{T} + sequence::Int +end + + +Connection(host::AbstractString, port::AbstractString, + pipeline_limit::Int, io::T) where T <: IO = + Connection{T}(host, port, pipeline_limit, + peerport(io), localport(io), + io, nobytes, + -1, + 0, false, Condition(), + 0, false, Condition(), + 0, Parser()) + +Transaction(c::Connection{T}) where T <: IO = + Transaction{T}(c, (c.sequence += 1)) + +function client_transaction(c) + t = Transaction(c) + startwrite(t) + return t +end + + +getparser(t::Transaction) = t.c.parser + +getrawstream(t::Transaction) = t.c.io + + +inactiveseconds(t::Transaction) = inactiveseconds(t.c) + +function inactiveseconds(c::Connection)::Float64 + if !c.readbusy && !c.writebusy + return Float64(0) + end + return time() - c.timestamp +end + + +Base.unsafe_write(t::Transaction, p::Ptr{UInt8}, n::UInt) = + unsafe_write(t.c.io, p, n) + +Base.isopen(c::Connection) = isopen(c.io) + +Base.isopen(t::Transaction) = isopen(t.c) && + t.c.readcount <= t.sequence && + t.c.writecount <= t.sequence + +function Base.eof(t::Transaction) + @require isreadable(t) || !isopen(t) + if nb_available(t) > 0 + return false + end ;@debug 4 "eof(::Transaction) -> eof($typeof(c.io)): $t" + return eof(t.c.io) +end + +Base.nb_available(t::Transaction) = nb_available(t.c) +Base.nb_available(c::Connection) = + !isempty(c.excess) ? length(c.excess) : nb_available(c.io) + + +Base.isreadable(t::Transaction) = t.c.readbusy && t.c.readcount == t.sequence + +Base.iswritable(t::Transaction) = t.c.writebusy && t.c.writecount == t.sequence + + +function Base.readavailable(t::Transaction)::ByteView + @require isreadable(t) + if !isempty(t.c.excess) + bytes = t.c.excess + @debug 4 "↩️ read $(length(bytes))-bytes from excess buffer." + t.c.excess = nobytes + else + bytes = byteview(readavailable(t.c.io)) + @debug 4 "⬅️ read $(length(bytes))-bytes from $(typeof(t.c.io))" + end + t.c.timestamp = time() + return bytes +end + + + +@static if !isdefined(Base, :copyto!) + const copyto! = copy! +end + +function Base.readbytes!(t::Transaction, a::Vector{UInt8}, nb::Int) + + if !isempty(t.c.excess) + l = length(t.c.excess) + copyto!(a, 1, t.c.excess, 1, min(l, nb)) + if l > nb + t.c.excess = view(t.c.excess, nb+1:l) + return nb + else + t.c.excess = nobytes + return l + readbytes!(t.c.io, view(a, l+1:nb)) + end + end + + return readbytes!(t.c.io, a, nb) +end + + +""" + unread!(::Transaction, bytes) + +Push bytes back into a connection's `excess` buffer +(to be returned by the next read). +""" + +function IOExtras.unread!(t::Transaction, bytes::ByteView) + @require isreadable(t) + t.c.excess = bytes + return +end + + +""" + startwrite(::Transaction) + +Wait for prior pending writes to complete. +""" + +function IOExtras.startwrite(t::Transaction) + @require !iswritable(t) ;t.c.writecount != t.sequence && + @debug 1 "⏳ Wait write: $t" + while t.c.writecount != t.sequence + wait(t.c.writedone) + end ;@debug 2 "👁 Start write:$t" + t.c.writebusy = true + @ensure iswritable(t) + return +end + + +""" + closewrite(::Transaction) + +Signal that an entire Request Message has been written to the `Transaction`. +""" + +function IOExtras.closewrite(t::Transaction) + @require iswritable(t) + + t.c.writebusy = false + t.c.writecount += 1 ;@debug 2 "🗣 Write done: $t" + notify(t.c.writedone) + notify(poolcondition) + + @ensure !iswritable(t) + return +end + + +""" + startread(::Transaction) + +Wait for prior pending reads to complete. +""" + +function IOExtras.startread(t::Transaction) + @require !isreadable(t) ;t.c.readcount != t.sequence && + @debug 1 "⏳ Wait read: $t" + t.c.timestamp = time() + while t.c.readcount != t.sequence + wait(t.c.readdone) + end ;@debug 2 "👁 Start read: $t" + t.c.readbusy = true + @ensure isreadable(t) + return +end + + +""" + closeread(::Transaction) + +Signal that an entire Response Message has been read from the `Transaction`. + +Increment `readcount` and wake up tasks waiting in `startread`. +""" + +function IOExtras.closeread(t::Transaction) + @require isreadable(t) + + t.c.readbusy = false + t.c.readcount += 1 + notify(t.c.readdone) ;@debug 2 "✉️ Read done: $t" + notify(poolcondition) + + @ensure !isreadable(t) + return +end + +function Base.close(t::Transaction) + close(t.c) + if iswritable(t) + closewrite(t) + end + if isreadable(t) + closeread(t) + end + return +end + +function Base.close(c::Connection) + close(c.io) + if nb_available(c) > 0 + purge(c) + end + notify(poolcondition) + return +end + + +""" + purge(::Connection) + +Remove unread data from a `Connection`. +""" + +function purge(c::Connection) + @require !isopen(c.io) + while !eof(c.io) + readavailable(c.io) + end + c.excess = nobytes + @ensure nb_available(c) == 0 +end + + +""" +The `pool` is a collection of open `Connection`s. The `request` +function calls `getconnection` to retrieve a connection from the +`pool`. When the `request` function has written a Request Message +it calls `closewrite` to signal that the `Connection` can be reused +for writing (to send the next Request). When the `request` function +has read the Response Message it calls `closeread` to signal that +the `Connection` can be reused for reading. +""" + +const pool = Vector{Connection}() +const poollock = ReentrantLock() +const poolcondition = Condition() + +""" + closeall() + +Close all connections in `pool`. +""" + +function closeall() + + lock(poollock) + for c in pool + close(c) + end + empty!(pool) + unlock(poollock) + notify(poolcondition) + return +end + + +""" + findwritable(type, host, port) -> Vector{Connection} + +Find `Connections` in the `pool` that are ready for writing. +""" + +function findwritable(T::Type, + host::AbstractString, + port::AbstractString, + pipeline_limit::Int, + reuse_limit::Int) + + filter(c->(!c.writebusy && + typeof(c.io) == T && + c.host == host && + c.port == port && + c.pipeline_limit == pipeline_limit && + c.writecount < reuse_limit && + c.writecount - c.readcount < pipeline_limit + 1 && + isopen(c.io)), pool) +end + + +""" + findoverused(type, host, port, reuse_limit) -> Vector{Connection} + +Find `Connections` in the `pool` that are over the reuse limit +and have no more active readers. +""" + +function findoverused(T::Type, + host::AbstractString, + port::AbstractString, + reuse_limit::Int) + + filter(c->(typeof(c.io) == T && + c.host == host && + c.port == port && + c.readcount >= reuse_limit && + !c.readbusy && + isopen(c.io)), pool) +end + + +""" + findall(type, host, port) -> Vector{Connection} + +Find all `Connections` in the `pool` for `host` and `port`. +""" + +function findall(T::Type, + host::AbstractString, + port::AbstractString, + pipeline_limit::Int) + + filter(c->(typeof(c.io) == T && + c.host == host && + c.port == port && + c.pipeline_limit == pipeline_limit && + isopen(c.io)), pool) +end + + +""" + purge() + +Remove closed connections from `pool`. +""" +function purge() + isdeletable(c) = !isopen(c.io) && (@debug 1 "🗑 Deleted: $c"; true) + deleteat!(pool, map(isdeletable, pool)) +end + + +""" + getconnection(type, host, port) -> Connection + +Find a reusable `Connection` in the `pool`, +or create a new `Connection` if required. +""" + +function getconnection(::Type{Transaction{T}}, + host::AbstractString, + port::AbstractString; + connection_limit::Int=default_connection_limit, + pipeline_limit::Int=default_pipeline_limit, + reuse_limit::Int=nolimit, + kw...)::Transaction{T} where T <: IO + + while true + + lock(poollock) + try + + # Close connections that have reached the reuse limit... + if reuse_limit != nolimit + for c in findoverused(T, host, port, reuse_limit) + close(c) + end + end + + # Remove closed connections from `pool`... + purge() + + # Try to find a connection with no active readers or writers... + writable = findwritable(T, host, port, pipeline_limit, reuse_limit) + idle = filter(c->!c.readbusy, writable) + if !isempty(idle) + c = rand(idle) ;@debug 2 "♻️ Idle: $c" + return client_transaction(c) + end + + # If there are not too many connections to this host:port, + # create a new connection... + busy = findall(T, host, port, pipeline_limit) + if length(busy) < connection_limit + io = getconnection(T, host, port; kw...) + c = Connection(host, port, pipeline_limit, io) + push!(pool, c) ;@debug 1 "🔗 New: $c" + return client_transaction(c) + end + + # Share a connection that has active readers... + if !isempty(writable) + c = rand(writable) ;@debug 2 "⇆ Shared: $c" + return client_transaction(c) + end + + finally + unlock(poollock) + end + + # Wait for `closewrite` or `close` to signal that a connection is ready. + wait(poolcondition) + end +end + + +function getconnection(::Type{TCPSocket}, + host::AbstractString, + port::AbstractString; + kw...)::TCPSocket + + p::UInt = isempty(port) ? UInt(80) : parse(UInt, port) + @debug 2 "TCP connect: $host:$p..." + connect(getaddrinfo(host), p) +end + +const nosslconfig = SSLConfig() + +function getconnection(::Type{SSLContext}, + host::AbstractString, + port::AbstractString; + require_ssl_verification::Bool=true, + sslconfig::SSLConfig=nosslconfig, + kw...)::SSLContext + + if sslconfig === nosslconfig + sslconfig = SSLConfig(require_ssl_verification) + end + + port = isempty(port) ? "443" : port + @debug 2 "SSL connect: $host:$port..." + io = SSLContext() + setup!(io, sslconfig) + associate!(io, getconnection(TCPSocket, host, port)) + hostname!(io, host) + handshake!(io) + return io +end + + +function Base.show(io::IO, c::Connection) + nwaiting = nb_available(tcpsocket(c.io)) + print( + io, + tcpstatus(c), " ", + lpad(c.writecount,3),"↑", c.writebusy ? "🔒 " : " ", + lpad(c.readcount,3), "↓", c.readbusy ? "🔒 " : " ", + c.host, ":", + c.port != "" ? c.port : Int(c.peerport), ":", Int(c.localport), + " ≣", c.pipeline_limit, + length(c.excess) > 0 ? " $(length(c.excess))-byte excess" : "", + inactiveseconds(c) > 5 ? + " inactive $(round(inactiveseconds(c),1))s" : "", + nwaiting > 0 ? " $nwaiting bytes waiting" : "", + DEBUG_LEVEL > 1 ? " $(Base._fd(tcpsocket(c.io)))" : "") +end + +Base.show(io::IO, t::Transaction) = print(io, "T$(rpad(t.sequence,2)) ", t.c) + + +function tcpstatus(c::Connection) + s = Base.uv_status_string(tcpsocket(c.io)) + if s == "connecting" return "🔜🔗" + elseif s == "open" return "🔗 " + elseif s == "active" return "🔁 " + elseif s == "paused" return "⏸ " + elseif s == "closing" return "🔜💀" + elseif s == "closed" return "💀 " + else + return s + end +end + +function showpool(io::IO) + lock(poollock) + println(io, "ConnectionPool[") + for c in pool + println(io, " $c") + end + println(io, "]\n") + unlock(poollock) +end + +function showpoolhtml(io::IO) + lock(poollock) + println(io, "") + for c in pool + print(io, "") + for x in split("$c") + print(io, "") + end + println(io, "") + end + println(io, "
$x
") + unlock(poollock) +end + +end # module ConnectionPool diff --git a/src/ConnectionRequest.jl b/src/ConnectionRequest.jl new file mode 100644 index 000000000..e75bad3cc --- /dev/null +++ b/src/ConnectionRequest.jl @@ -0,0 +1,47 @@ +module ConnectionRequest + +import ..Layer, ..request +using ..URIs +using ..Messages +using ..IOExtras +using ..ConnectionPool +using MbedTLS.SSLContext +import ..@debug, ..DEBUG_LEVEL + + +""" + request(ConnectionPoolLayer, ::URI, ::Request, body) -> HTTP.Response + +Retrieve an `IO` connection from the [`ConnectionPool`](@ref). + +Close the connection if the request throws an exception. +Otherwise leave it open so that it can be reused. + +`IO` related exceptions from `Base` are wrapped in `HTTP.IOError`. +See [`isioerror`](@ref). +""" + +abstract type ConnectionPoolLayer{Next <: Layer} <: Layer end +export ConnectionPoolLayer + +function request(::Type{ConnectionPoolLayer{Next}}, url::URI, req, body; + socket_type::Type=TCPSocket, kw...) where Next + + IOType = ConnectionPool.Transaction{sockettype(url, socket_type)} + io = getconnection(IOType, url.host, url.port; kw...) + + try + return request(Next, io, req, body; kw...) + catch e + @debug 1 "❗️ ConnectionLayer $e. Closing: $io" + close(io) + rethrow(isioerror(e) ? IOError(e) : e) + end +end + + +sockettype(url::URI, default) = url.scheme in ("wss", "https") ? SSLContext : + default + + +end # module ConnectionRequest diff --git a/src/CookieRequest.jl b/src/CookieRequest.jl new file mode 100644 index 000000000..e127f5087 --- /dev/null +++ b/src/CookieRequest.jl @@ -0,0 +1,75 @@ +module CookieRequest + +import ..Layer, ..request +using ..URIs +using ..Cookies +using ..Pairs: getkv, setkv +import ..@debug, ..DEBUG_LEVEL + + +const default_cookiejar = Dict{String, Set{Cookie}}() + + +""" + request(CookieLayer, method, ::URI, headers, body) -> HTTP.Response + +Add locally stored Cookies to the request headers. +Store new Cookies found in the response headers. +""" + +abstract type CookieLayer{Next <: Layer} <: Layer end +export CookieLayer + +function request(::Type{CookieLayer{Next}}, + method::String, url::URI, headers, body; + cookiejar::Dict{String, Set{Cookie}}=default_cookiejar, + kw...) where Next + + hostcookies = get!(cookiejar, url.host, Set{Cookie}()) + + cookies = getcookies(hostcookies, url) + if !isempty(cookies) + setkv(headers, "Cookie", string(getkv(headers, "Cookie", ""), cookies)) + end + + res = request(Next, method, url, headers, body; kw...) + + setcookies(hostcookies, url.host, res.headers) + + return res +end + + +function getcookies(cookies, url) + + tosend = Vector{Cookie}() + expired = Vector{Cookie}() + + # Check if cookies should be added to outgoing request based on host... + for cookie in cookies + if Cookies.shouldsend(cookie, url.scheme == "https", + url.host, url.path) + t = cookie.expires + if t != Dates.DateTime() && t < Dates.now(Dates.UTC) + @debug 1 "Deleting expired Cookie: $cookie.name" + push!(expired, cookie) + else + @debug 1 "Sending Cookie: $cookie.name to $url.host" + push!(tosend, cookie) + end + end + end + setdiff!(cookies, expired) + return tosend +end + + +function setcookies(cookies, host, headers) + for (k,v) in filter(x->x[1]=="Set-Cookie", headers) + @debug 1 "Set-Cookie: $v (from $host)" + push!(cookies, Cookies.readsetcookie(host, v)) + end +end + + +end # module CookieRequest diff --git a/src/ExceptionRequest.jl b/src/ExceptionRequest.jl new file mode 100644 index 000000000..4af8a6357 --- /dev/null +++ b/src/ExceptionRequest.jl @@ -0,0 +1,45 @@ +module ExceptionRequest + +export StatusError + +import ..Layer, ..request +import ..HTTP +using ..Messages.iserror + + +""" + request(ExceptionLayer, ::URI, ::Request, body) -> HTTP.Response + +Throw a `StatusError` if the request returns an error response status. +""" + +abstract type ExceptionLayer{Next <: Layer} <: Layer end +export ExceptionLayer + +function request(::Type{ExceptionLayer{Next}}, a...; kw...) where Next + + res = request(Next, a...; kw...) + + if iserror(res) + throw(StatusError(res.status, res)) + end + + return res +end + + +""" +The `Response` has a `4xx`, `5xx` or unrecognised status code. + +Fields: + - `status::Int16`, the response status code. + - `response` the [`HTTP.Response`](@ref) +""" + +struct StatusError <: Exception + status::Int16 + response::HTTP.Response +end + + +end # module ExceptionRequest diff --git a/src/HTTP.jl b/src/HTTP.jl index 04b96b2bd..c3bb0c011 100644 --- a/src/HTTP.jl +++ b/src/HTTP.jl @@ -1,83 +1,574 @@ -__precompile__(true) +__precompile__() module HTTP -export Request, Response, FIFOBuffer - using MbedTLS +import MbedTLS.SSLContext -const TLS = MbedTLS -import Base.== +const DEBUG_LEVEL = 0 -const DEBUG = false -const PARSING_DEBUG = false +include("compat.jl") +include("debug.jl") + +include("Pairs.jl") +include("Strings.jl") +include("IOExtras.jl") ;import .IOExtras.IOError +include("URIs.jl") ;using .URIs +include("utils.jl") +include("fifobuffer.jl") ;using .FIFOBuffers +include("cookies.jl") ;using .Cookies +include("multipart.jl") +include("Parsers.jl") ;import .Parsers: Parser, Headers, Header, + ParsingError, ByteView +include("ConnectionPool.jl") +include("Messages.jl") ;using .Messages +include("Streams.jl") ;using .Streams + + +""" + + HTTP.request(method, url [, headers [, body]]; ]) -> HTTP.Response + +Send a HTTP Request Message and recieve a HTTP Response Message. + +e.g. +```julia +r = HTTP.request("GET", "http://httpbin.org/ip") +println(r.status) +println(String(r.body)) +``` + +`headers` can be any collection where +`[string(k) => string(v) for (k,v) in headers]` yields `Vector{Pair}`. +e.g. a `Dict()`, a `Vector{Tuple}`, a `Vector{Pair}` or an iterator. + +`body` can take a number of forms: + + - a `String`, a `Vector{UInt8}` or any `T` accepted by `write(::IO, ::T)` + - a collection of `String` or `AbstractVector{UInt8}` or `IO` streams + or items of any type `T` accepted by `write(::IO, ::T...)` + - a readable `IO` stream or any `IO`-like type `T` for which + `eof(T)` and `readavailable(T)` are defined. + +The `HTTP.Response` struct contains: + + - `status::Int16` e.g. `200` + - `headers::Vector{Pair{String,String}}` + e.g. ["Server" => "Apache", "Content-Type" => "text/html"] + - `body::Vector{UInt8}`, the Response Body bytes + (empty if a `response_stream` was specified in the `request`). + +Functions `HTTP.get`, `HTTP.put`, `HTTP.post` and `HTTP.head` are defined as +shorthand for `HTTP.request("GET", ...)`, etc. + +`HTTP.request` and `HTTP.open` also accept optional keyword parameters. + +e.g. +```julia +HTTP.request("GET", "http://httpbin.org/ip"; retries=4, cookies=true) + +HTTP.get("http://s3.us-east-1.amazonaws.com/"; aws_authorization=true) + +conf = (readtimeout = 10, + pipeline_limit = 4, + retry = false, + redirect = false) + +HTTP.get("http://httpbin.org/ip"; conf..) +HTTP.put("http://httpbin.org/put", [], "Hello"; conf..) +``` + + +Streaming options + + - `response_stream = nothing`, a writeable `IO` stream or any `IO`-like + type `T` for which `write(T, AbstractVector{UInt8})` is defined. + - `verbose = 0`, set to `1` or `2` for extra message logging. + + +Connection Pool options + + - `connection_limit = 8`, number of concurrent connections to each host:port. + - `pipeline_limit = 16`, number of concurrent requests per connection. + - `reuse_limit = nolimit`, number of times a connection is reused after the + first request. + - `socket_type = TCPSocket` + + +Timeout options + + - `readtimeout = 60`, close the connection if no data is recieved for this many + seconds. Use `readtimeout = 0` to disable. + + +Retry options + + - `retry = true`, retry idempotent requests in case of error. + - `retries = 4`, number of times to retry. + - `retry_non_idempotent = false`, retry non-idempotent requests too. e.g. POST. -if VERSION > v"0.7.0-DEV.2338" - using Base64 -end -@static if VERSION >= v"0.7.0-DEV.2915" - using Unicode +Redirect options + + - `redirect = true`, follow 3xx redirect responses. + - `redirect_limit = 3`, number of times to redirect. + - `forwardheaders = false`, forward original headers on redirect. + + +Status Exception options + + - `statusexception = true`, throw `HTTP.StatusError` for response status >= 300. + + +SSLContext options + + - `require_ssl_verification = false`, pass `MBEDTLS_SSL_VERIFY_REQUIRED` to + the mbed TLS library. + ["... peer must present a valid certificate, handshake is aborted if + verification failed."](https://tls.mbed.org/api/ssl_8h.html#a5695285c9dbfefec295012b566290f37) + - `sslconfig = SSLConfig(require_ssl_verification)` + + +Basic Authenticaiton options + + - basicauthorization=false, add `Authorization: Basic` header using credentials + from url userinfo. + + +AWS Authenticaiton options + + - `awsauthorization = false`, enable AWS4 Authentication. + - `aws_service = split(url.host, ".")[1]` + - `aws_region = split(url.host, ".")[2]` + - `aws_access_key_id = ENV["AWS_ACCESS_KEY_ID"]` + - `aws_secret_access_key = ENV["AWS_SECRET_ACCESS_KEY"]` + - `aws_session_token = get(ENV, "AWS_SESSION_TOKEN", "")` + - `body_sha256 = digest(MD_SHA256, body)`, + - `body_md5 = digest(MD_MD5, body)`, + + +Cookie options + + - `cookies = false`, enable cookies. + - `cookiejar::Dict{String, Set{Cookie}}=default_cookiejar` + + +Cananoincalization options + + - `canonicalizeheaders = false`, rewrite request and response headers in + Canonical-Camel-Dash-Format. + + +## Request Body Examples + +String body: +```julia +HTTP.request("POST", "http://httpbin.org/post", [], "post body data") +``` + +Stream body from file: +```julia +io = open("post_data.txt", "r") +HTTP.request("POST", "http://httpbin.org/post", [], io) +``` + +Generator body: +```julia +chunks = ("chunk\$i" for i in 1:1000) +HTTP.request("POST", "http://httpbin.org/post", [], chunks) +``` + +Collection body: +```julia +chunks = [preamble_chunk, data_chunk, checksum(data_chunk)] +HTTP.request("POST", "http://httpbin.org/post", [], chunks) +``` + +`open() do io` body: +```julia +HTTP.open("POST", "http://httpbin.org/post") do io + write(io, preamble_chunk) + write(io, data_chunk) + write(io, checksum(data_chunk)) end +``` -macro uninit(expr) - if !isdefined(Base, :uninitialized) - splice!(expr.args, 2) - end - return esc(expr) + +## Response Body Examples + +String body: +```julia +r = HTTP.request("GET", "http://httpbin.org/get") +println(String(r.body)) +``` + +Stream body to file: +```julia +io = open("get_data.txt", "w") +r = HTTP.request("GET", "http://httpbin.org/get", response_stream=io) +close(io) +println(read("get_data.txt")) +``` + +Stream body through buffer: +```julia +io = BufferStream() +@async while !eof(io) + bytes = readavailable(io)) + println("GET data: \$bytes") end +r = HTTP.request("GET", "http://httpbin.org/get", response_stream=io) +close(io) +``` -if !isdefined(Base, :pairs) - pairs(x) = x +Stream body through `open() do io`: +```julia +r = HTTP.open("GET", "http://httpbin.org/stream/10") do io + while !eof(io) + println(String(readavailable(io))) + end end -if !isdefined(Base, :Nothing) - const Nothing = Void - const Cvoid = Void +using HTTP.IOExtras + +HTTP.open("GET", "https://tinyurl.com/bach-cello-suite-1-ogg") do http + n = 0 + r = startread(http) + l = parse(Int, header(r, "Content-Length")) + open(`vlc -q --play-and-exit --intf dummy -`, "w") do vlc + while !eof(http) + bytes = readavailable(http) + write(vlc, bytes) + n += length(bytes) + println("streamed \$n-bytes \$((100*n)÷l)%\\u1b[1A") + end + end end +``` -if VERSION < v"0.7.0-DEV.2575" - const Dates = Base.Dates -else - import Dates + +## Request and Response Body Examples + +String bodies: +```julia +r = HTTP.request("POST", "http://httpbin.org/post", [], "post body data") +println(String(r.body)) +``` + +Stream bodies from and to files: +```julia +in = open("foo.png", "r") +out = open("foo.jpg", "w") +HTTP.request("POST", "http://convert.com/png2jpg", [], in, response_stream=out) +``` + +Stream bodies through: `open() do io`: +```julia +using HTTP.IOExtras + +HTTP.open("POST", "http://music.com/play") do io + write(io, JSON.json([ + "auth" => "12345XXXX", + "song_id" => 7, + ])) + r = startread(io) + @show r.status + while !eof(io) + bytes = readavailable(io)) + play_audio(bytes) + end end +``` +""" + +request(method::String, url::URI, headers::Headers, body; kw...)::Response = + request(HTTP.stack(;kw...), method, url, headers, body; kw...) + +const nobody = UInt8[] + +request(method, url, headers=Header[], body=nobody; kw...)::Response = + request(string(method), URI(url), mkheaders(headers), body; kw...) -struct ParsingError <: Exception - msg::String + +""" + HTTP.open(method, url, [,headers]) do io + write(io, body) + [startread(io) -> HTTP.Response] + while !eof(io) + readavailable(io) -> AbstractVector{UInt8} + end + end -> HTTP.Response + +The `HTTP.open` API allows the Request Body to be written to (and/or the +Response Body to be read from) an `IO` stream. + + +e.g. Streaming an audio file to the `vlc` player: +```julia +HTTP.open("GET", "https://tinyurl.com/bach-cello-suite-1-ogg") do http + open(`vlc -q --play-and-exit --intf dummy -`, "w") do vlc + write(vlc, http) + end end -Base.show(io::IO, p::ParsingError) = println("HTTP.ParsingError: ", p.msg) +``` +""" -const CRLF = "\r\n" +open(f::Function, method::String, url, headers=Header[]; kw...)::Response = + request(method, url, headers, nothing; iofunction=f, kw...) -include("consts.jl") -include("utils.jl") -include("uri.jl") -using .URIs -include("fifobuffer.jl") -using .FIFOBuffers -include("cookies.jl") -using .Cookies -include("multipart.jl") -include("types.jl") -include("parser.jl") -include("sniff.jl") +""" + HTTP.get(url [, headers]; ) -> HTTP.Response -include("client.jl") -include("handlers.jl") -using .Handlers -include("server.jl") -using .Nitrogen -include("precompile.jl") +Shorthand for `HTTP.request("GET", ...)`. See [`HTTP.request`](@ref). +""" + +get(u, a...; kw...) = request("GET", u, a...; kw...) + + +""" + HTTP.put(url, headers, body; ) -> HTTP.Response + +Shorthand for `HTTP.request("PUT", ...)`. See [`HTTP.request`](@ref). +""" + +put(u, h, b; kw...) = request("PUT", u, h, b; kw...) + + +""" + HTTP.post(url, headers, body; ) -> HTTP.Response + +Shorthand for `HTTP.request("POST", ...)`. See [`HTTP.request`](@ref). +""" + +post(u, h, b; kw...) = request("POST", u, h, b; kw...) + + +""" + HTTP.head(url; ) -> HTTP.Response + +Shorthand for `HTTP.request("HEAD", ...)`. See [`HTTP.request`](@ref). +""" + +head(u; kw...) = request("HEAD", u; kw...) + -function __init__() - global const DEFAULT_CLIENT = Client() + +""" + +## Request Execution Stack + +The Request Execution Stack is separated into composable layers. + +Each layer is defined by a nested type `Layer{Next}` where the `Next` +parameter defines the next layer in the stack. +The `request` method for each layer takes a `Layer{Next}` type as +its first argument and dispatches the request to the next layer +using `request(Next, ...)`. + +The example below defines three layers and three stacks each with +a different combination of layers. + + +```julia +abstract type Layer end +abstract type Layer1{Next <: Layer} <: Layer end +abstract type Layer2{Next <: Layer} <: Layer end +abstract type Layer3 <: Layer end + +request(::Type{Layer1{Next}}, data) where Next = "L1", request(Next, data) +request(::Type{Layer2{Next}}, data) where Next = "L2", request(Next, data) +request(::Type{Layer3}, data) = "L3", data + +const stack1 = Layer1{Layer2{Layer3}} +const stack2 = Layer2{Layer1{Layer3}} +const stack3 = Layer1{Layer3} +``` + +```julia +julia> request(stack1, "foo") +("L1", ("L2", ("L3", "foo"))) + +julia> request(stack2, "bar") +("L2", ("L1", ("L3", "bar"))) + +julia> request(stack3, "boo") +("L1", ("L3", "boo")) +``` + +This stack definition pattern gives the user flexibility in how layers are +combined but still allows Julia to do whole-stack comiple time optimistations. + +e.g. the `request(stack1, "foo")` call above is optimised down to a single +function: +```julia +julia> code_typed(request, (Type{stack1}, String))[1].first +CodeInfo(:(begin + return (Core.tuple)("L1", (Core.tuple)("L2", (Core.tuple)("L3", data))) +end)) +``` +""" + +abstract type Layer end +include("RedirectRequest.jl"); using .RedirectRequest +include("BasicAuthRequest.jl"); using .BasicAuthRequest +include("AWS4AuthRequest.jl"); using .AWS4AuthRequest +include("CookieRequest.jl"); using .CookieRequest +include("CanonicalizeRequest.jl"); using .CanonicalizeRequest +include("TimeoutRequest.jl"); using .TimeoutRequest +include("MessageRequest.jl"); using .MessageRequest +include("ExceptionRequest.jl"); using .ExceptionRequest + import .ExceptionRequest.StatusError +include("RetryRequest.jl"); using .RetryRequest +include("ConnectionRequest.jl"); using .ConnectionRequest +include("StreamRequest.jl"); using .StreamRequest + +""" +The `stack()` function returns the default HTTP Layer-stack type. +This type is passed as the first parameter to the [`HTTP.request`](@ref) function. + +`stack()` accepts optional keyword arguments to enable/disable specific layers +in the stack: +`request(method, args...; kw...) request(stack(;kw...), args...; kw...)` + + +The minimal request execution stack is: + +```julia +stack = MessageLayer{ConnectionPoolLayer{StreamLayer}} +``` + +The figure below illustrates the full request exection stack and its +relationship with [`HTTP.Response`](@ref), [`HTTP.Parser`](@ref), +[`HTTP.Stream`](@ref) and the [`HTTP.ConnectionPool`](@ref). + +``` + ┌────────────────────────────────────────────────────────────────────────────┐ + │ ┌───────────────────┐ │ + │ HTTP.jl Request Execution Stack │ HTTP.ParsingError ├ ─ ─ ─ ─ ┐ │ + │ └───────────────────┘ │ + │ ┌───────────────────┐ │ │ + │ │ HTTP.IOError ├ ─ ─ ─ │ + │ └───────────────────┘ │ │ │ + │ ┌───────────────────┐ │ + │ │ HTTP.StatusError │─ ─ │ │ │ + │ └───────────────────┘ │ │ + │ ┌───────────────────┐ │ │ │ + │ request(method, url, headers, body) -> │ HTTP.Response │ │ │ + │ ────────────────────────── └─────────▲─────────┘ │ │ │ + │ ║ ║ │ │ + │ ┌────────────────────────────────────────────────────────────┐ │ │ │ + │ │ request(RedirectLayer, method, ::URI, ::Headers, body) │ │ │ + │ ├────────────────────────────────────────────────────────────┤ │ │ │ + │ │ request(BasicAuthLayer, method, ::URI, ::Headers, body) │ │ │ + │ ├────────────────────────────────────────────────────────────┤ │ │ │ + │ │ request(CookieLayer, method, ::URI, ::Headers, body) │ │ │ + │ ├────────────────────────────────────────────────────────────┤ │ │ │ + │ │ request(CanonicalizeLayer, method, ::URI, ::Headers, body) │ │ │ + │ ├────────────────────────────────────────────────────────────┤ │ │ │ + │ │ request(MessageLayer, method, ::URI, ::Headers, body) │ │ │ + │ ├────────────────────────────────────────────────────────────┤ │ │ │ + │ │ request(AWS4AuthLayer, ::URI, ::Request, body) │ │ │ + │ ├────────────────────────────────────────────────────────────┤ │ │ │ + │ │ request(RetryLayer, ::URI, ::Request, body) │ │ │ + │ ├────────────────────────────────────────────────────────────┤ │ │ │ + │ │ request(ExceptionLayer, ::URI, ::Request, body) ├ ─ ┘ │ + │ ├────────────────────────────────────────────────────────────┤ │ │ │ +┌┼───┤ request(ConnectionPoolLayer, ::URI, ::Request, body) ├ ─ ─ ─ │ +││ ├────────────────────────────────────────────────────────────┤ │ │ +││ │ request(TimeoutLayer, ::IO, ::Request, body) │ │ +││ ├────────────────────────────────────────────────────────────┤ │ │ +││ │ request(StreamLayer, ::IO, ::Request, body) │ │ +││ └──────────────┬───────────────────┬─────────────────────────┘ │ │ +│└──────────────────┼────────║──────────┼───────────────║─────────────────────┘ +│ │ ║ │ ║ │ +│┌──────────────────▼───────────────┐ │ ┌──────────────────────────────────┐ +││ HTTP.Request │ │ │ HTTP.Response │ │ +││ │ │ │ │ +││ method::String ◀───┼──▶ status::Int │ │ +││ target::String │ │ │ headers::Vector{Pair} │ +││ headers::Vector{Pair} │ │ │ body::Vector{UInt8} │ │ +││ body::Vector{UInt8} │ │ │ │ +│└──────────────────▲───────────────┘ │ └───────────────▲────────────────┼─┘ +│┌──────────────────┴────────║──────────▼───────────────║──┴──────────────────┐ +││ HTTP.Stream <:IO ║ ╔══════╗ ║ │ │ +││ ┌───────────────────────────┐ ║ ┌──▼─────────────────────────┐ │ +││ │ startwrite(::Stream) │ ║ │ startread(::Stream) │ │ │ +││ │ write(::Stream, body) │ ║ │ read(::Stream) -> body │ │ +││ │ ... │ ║ │ ... │ │ │ +││ │ closewrite(::Stream) │ ║ │ closeread(::Stream) │ │ +││ └───────────────────────────┘ ║ └────────────────────────────┘ │ │ +│└───────────────────────────║────────┬──║──────║───────║──┬──────────────────┘ +│┌──────────────────────────────────┐ │ ║ ┌────▼───────║──▼────────────────┴─┐ +││ HTTP.Messages │ │ ║ │ HTTP.Parser │ +││ │ │ ║ │ │ +││ writestartline(::IO, ::Request) │ │ ║ │ parseheaders(bytes) do h::Pair │ +││ writeheaders(::IO, ::Request) │ │ ║ │ parsebody(bytes) -> bytes │ +│└──────────────────────────────────┘ │ ║ └──────────────────────────────────┘ +│ ║ │ ║ +│┌───────────────────────────║────────┼──║────────────────────────────────────┐ +└▶ HTTP.ConnectionPool ║ │ ║ │ + │ ┌──────────────▼────────┐ ┌───────────────────────┐ │ + │ getconnection() -> │ HTTP.Transaction <:IO │ │ HTTP.Transaction <:IO │ │ + │ └───────────────────────┘ └───────────────────────┘ │ + │ ║ ╲│╱ ║ ╲│╱ │ + │ ║ │ ║ │ │ + │ ┌───────────▼───────────┐ ┌───────────▼───────────┐ │ + │ pool: [│ HTTP.Connection │,│ HTTP.Connection │...]│ + │ └───────────┬───────────┘ └───────────┬───────────┘ │ + │ ║ │ ║ │ │ + │ ┌───────────▼───────────┐ ┌───────────▼───────────┐ │ + │ │ Base.TCPSocket <:IO │ │MbedTLS.SSLContext <:IO│ │ + │ └───────────────────────┘ └───────────┬───────────┘ │ + │ ║ ║ │ │ + │ ║ ║ ┌───────────▼───────────┐ │ + │ ║ ║ │ Base.TCPSocket <:IO │ │ + │ ║ ║ └───────────────────────┘ │ + └───────────────────────────║───────────║────────────────────────────────────┘ + ║ ║ + ┌───────────────────────────║───────────║──────────────┐ ┏━━━━━━━━━━━━━━━━━━┓ + │ HTTP Server ▼ │ ┃ data flow: ════▶ ┃ + │ Request Response │ ┃ reference: ────▶ ┃ + └──────────────────────────────────────────────────────┘ ┗━━━━━━━━━━━━━━━━━━┛ +``` +*See `docs/src/layers`[`.monopic`](http://monodraw.helftone.com).* +""" + +function stack(;redirect=true, + basic_authorization=false, + aws_authorization=false, + cookies=false, + canonicalize_headers=false, + retry=true, + status_exception=true, + readtimeout=0, + kw...) + + NoLayer = Union + + (redirect ? RedirectLayer : NoLayer){ + (basic_authorization ? BasicAuthLayer : NoLayer){ + (cookies ? CookieLayer : NoLayer){ + (canonicalize_headers ? CanonicalizeLayer : NoLayer){ + MessageLayer{ + (aws_authorization ? AWS4AuthLayer : NoLayer){ + (retry ? RetryLayer : NoLayer){ + (status_exception ? ExceptionLayer : NoLayer){ + ConnectionPoolLayer{ + (readtimeout > 0 ? TimeoutLayer : NoLayer){ + StreamLayer + }}}}}}}}}} end + +include("client.jl") +include("sniff.jl") +include("Handlers.jl") ;using .Handlers +include("Servers.jl") ;using .Servers.listen + +include("WebSockets.jl") ;using .WebSockets + +include("precompile.jl") + end # module -try - HTTP.parse(HTTP.Response, "HTTP/1.1 200 OK\r\n\r\n") - HTTP.parse(HTTP.Request, "GET / HTTP/1.1\r\n\r\n") - HTTP.get(HTTP.Client(nothing), "www.google.com") -end diff --git a/src/handlers.jl b/src/Handlers.jl similarity index 79% rename from src/handlers.jl rename to src/Handlers.jl index 33173d194..79ae08a21 100644 --- a/src/handlers.jl +++ b/src/Handlers.jl @@ -1,20 +1,9 @@ module Handlers -if !isdefined(Base, :Nothing) - const Nothing = Void - const Cvoid = Void -end - -function val(v) - @static if VERSION < v"0.7.0-DEV.1395" - Val{v}() - else - Val(v) - end -end - export handle, Handler, HandlerFunction, Router, register! +import ..Nothing, ..Cvoid, ..Val + using HTTP """ @@ -48,7 +37,7 @@ end handle(h::HandlerFunction, req, resp) = h.func(req, resp) "A default 404 Handler" -const FourOhFour = HandlerFunction((req, resp) -> Response(404)) +const FourOhFour = HandlerFunction((req, resp) -> HTTP.Response(404)) """ Router(h::Handler) @@ -76,20 +65,15 @@ struct Router <: Handler end end -const SCHEMES = Dict{String, Val}("http" => val(:http), "https" => val(:https)) -const METHODS = Dict{String, Val}() -for m in instances(HTTP.Method) - METHODS[string(m)] = val(Symbol(m)) -end -const EMPTYVAL = val(()) +const SCHEMES = Dict{String, Val}("http" => Val(:http), "https" => Val(:https)) +const EMPTYVAL = Val(()) """ HTTP.register!(r::Router, url, handler) -HTTP.register!(r::Router, m::Union{HTTP.Method, String}, url, handler) +HTTP.register!(r::Router, m::String, url, handler) Function to map request urls matching `url` and an optional method `m` to another `handler::HTTP.Handler`. URLs are registered one at a time, and multiple urls can map to the same handler. -Methods can be passed as a string `"GET"` or enum object directly `HTTP.GET`. The URL can be passed as a String or `HTTP.URI` object directly. Requests can be routed based on: method, scheme, hostname, or path. The following examples show how various urls will direct how a request is routed by a server: @@ -102,17 +86,16 @@ The following examples show how various urls will direct how a request is routed - `"/gmail/userId/*/inbox`: match any request matching the path pattern, "*" is used as a wildcard that matches any value between the two "/" """ register!(r::Router, url, handler) = register!(r, "", url, handler) -register!(r::Router, m::HTTP.Method, url, handler) = register!(r, string(m), url, handler) function register!(r::Router, method::String, url, handler) - m = isempty(method) ? Any : typeof(METHODS[method]) + m = isempty(method) ? Any : typeof(Val(Symbol(method))) # get scheme, host, split path into strings & vals uri = url isa String ? HTTP.URI(url) : url - s = HTTP.scheme(uri) - sch = HTTP.hasscheme(uri) ? typeof(get!(SCHEMES, s, val(s))) : Any - h = HTTP.hashostname(uri) ? Val{Symbol(HTTP.hostname(uri))} : Any + s = uri.scheme + sch = !isempty(s) ? typeof(get!(SCHEMES, s, Val(s))) : Any + h = !isempty(uri.host) ? Val{Symbol(uri.host)} : Any hand = handler isa Function ? HandlerFunction(handler) : handler - register!(r, m, sch, h, HTTP.path(uri), hand) + register!(r, m, sch, h, uri.path, hand) end function splitsegments(r::Router, h::Handler, segments) @@ -121,7 +104,7 @@ function splitsegments(r::Router, h::Handler, segments) if s == "*" #TODO: or variable, keep track of variable types and store in handler T = Any else - v = val(Symbol(s)) + v = Val(Symbol(s)) r.segments[s] = v T = typeof(v) end @@ -142,12 +125,12 @@ end function handle(r::Router, req, resp) # get the url/path of the request - m = val(Symbol(HTTP.method(req))) - uri = HTTP.uri(req) + m = Val(Symbol(req.method)) # get scheme, host, split path into strings and get Vals - s = get(SCHEMES, HTTP.scheme(uri), EMPTYVAL) - h = val(Symbol(HTTP.hostname(uri))) - p = HTTP.path(uri) + uri = HTTP.URI(req.target) + s = get(SCHEMES, uri.scheme, EMPTYVAL) + h = Val(Symbol(uri.host)) + p = uri.path segments = split(p, '/'; keep=false) # dispatch to the most specific handler, given the path vals = (get(r.segments, s, EMPTYVAL) for s in segments) diff --git a/src/IOExtras.jl b/src/IOExtras.jl new file mode 100644 index 000000000..7b9750ef6 --- /dev/null +++ b/src/IOExtras.jl @@ -0,0 +1,114 @@ +""" +This module defines extensions to the `Base.IO` interface to support: + - an `unread!` function for pushing excess bytes back into a stream, + - `startwrite`, `closewrite`, `startread` and `closeread` for streams + with transactional semantics. +""" + +module IOExtras + +export IOError, isioerror, + unread!, + startwrite, closewrite, startread, closeread, + tcpsocket, localport, peerport + +""" + isioerror(exception) + +Is `exception` caused by a possibly recoverable IO error. +""" + +isioerror(e) = false +isioerror(::Base.EOFError) = true +isioerror(::Base.UVError) = true +isioerror(e::ArgumentError) = e.msg == "stream is closed or unusable" + + +""" +The request terminated with due to an IO-related error. + +Fields: + - `e`, the error. +""" + +struct IOError <: Exception + e +end + +Base.show(io::IO, e::IOError) = print(io, "IOError(", e.e, ")") + + +""" + unread!(::IO, bytes) + +Push bytes back into a connection (to be returned by the next read). +""" + + +function unread!(io::IOBuffer, bytes) + l = length(bytes) + if l == 0 + return + end + + @assert bytes == io.data[io.ptr - l:io.ptr-1] + + if io.seekable + seek(io, io.ptr - (l + 1)) + return + end + + println("WARNING: Can't unread! non-seekable IOBuffer") + println(" Discarding $(length(bytes)) bytes!") + @assert false + return +end + + +function unread!(io, bytes) + if length(bytes) == 0 + return + end + println("WARNING: No unread! method for $(typeof(io))!") + println(" Discarding $(length(bytes)) bytes!") + return +end + + + +""" + startwrite(::IO) + closewrite(::IO) + startread(::IO) + closeread(::IO) + +Signal start/end of write or read operations. +""" + +startwrite(io) = nothing +closewrite(io) = nothing +startread(io) = nothing +closeread(io) = nothing + + +using MbedTLS.SSLContext +tcpsocket(io::SSLContext)::TCPSocket = io.bio +tcpsocket(io::TCPSocket)::TCPSocket = io + +localport(io) = try !isopen(tcpsocket(io)) ? 0 : + VERSION > v"0.7.0-DEV" ? + getsockname(tcpsocket(io))[2] : + Base._sockname(tcpsocket(io), true)[2] + catch + 0 + end + +peerport(io) = try !isopen(tcpsocket(io)) ? 0 : + VERSION > v"0.7.0-DEV" ? + getpeername(tcpsocket(io))[2] : + Base._sockname(tcpsocket(io), false)[2] + catch + 0 + end + +end diff --git a/src/MessageRequest.jl b/src/MessageRequest.jl new file mode 100644 index 000000000..96dd945d5 --- /dev/null +++ b/src/MessageRequest.jl @@ -0,0 +1,69 @@ +module MessageRequest + +export body_is_a_stream, body_was_streamed + +import ..Layer, ..request +using ..URIs +using ..Messages +import ..Messages.bodylength +using ..Headers +using ..Form + + +""" + request(MessageLayer, method, ::URI, headers, body) -> HTTP.Response + +Construct a [`Request`](@ref) object and set mandatory headers. +""" + +struct MessageLayer{Next <: Layer} <: Layer end +export MessageLayer + +function request(::Type{MessageLayer{Next}}, + method::String, url::URI, headers::Headers, body; + http_version=v"1.1", + target=resource(url), + parent=nothing, iofunction=nothing, kw...) where Next + + defaultheader(headers, "Host" => url.host) + + if !hasheader(headers, "Content-Length") && + !hasheader(headers, "Transfer-Encoding") && + !hasheader(headers, "Upgrade") + l = bodylength(body) + if l != unknown_length + setheader(headers, "Content-Length" => string(l)) + elseif method == "GET" && iofunction isa Function + setheader(headers, "Content-Length" => "0") + end + end + + req = Request(method, target, headers, bodybytes(body); + parent=parent, version=http_version) + + return request(Next, url, req, body; iofunction=iofunction, kw...) +end + + +bodylength(body) = unknown_length +bodylength(body::AbstractVector{UInt8}) = length(body) +bodylength(body::AbstractString) = sizeof(body) +bodylength(body::Form) = length(body) +bodylength(body::Vector{T}) where T <: AbstractString = sum(sizeof, body) +bodylength(body::Vector{T}) where T <: AbstractArray{UInt8,1} = sum(length, body) +bodylength(body::IOBuffer) = nb_available(body) +bodylength(body::Vector{IOBuffer}) = sum(nb_available, body) + + +const body_is_a_stream = UInt8[] +const body_was_streamed = Vector{UInt8}("[Message Body was streamed]") +bodybytes(body) = body_is_a_stream +bodybytes(body::Vector{UInt8}) = body +bodybytes(body::IOBuffer) = read(body) +bodybytes(body::AbstractVector{UInt8}) = Vector{UInt8}(body) +bodybytes(body::AbstractString) = Vector{UInt8}(body) +bodybytes(body::Vector) = length(body) == 1 ? bodybytes(body[1]) : + body_is_a_stream + + +end # module MessageRequest diff --git a/src/Messages.jl b/src/Messages.jl new file mode 100644 index 000000000..fc0e09793 --- /dev/null +++ b/src/Messages.jl @@ -0,0 +1,675 @@ +""" +The `Messages` module defines structs that represent [`HTTP.Request`](@ref) +and [`HTTP.Response`](@ref) Messages. + +The `Response` struct has a `request` field that points to the corresponding +`Request`; and the `Request` struct has a `response` field. +The `Request` struct also has a `parent` field that points to a `Response` +in the case of HTTP Redirect. + + +The Messages module defines `IO` `read` and `write` methods for Messages +but it does not deal with URIs, creating connections, or executing requests. + +The `read` methods throw `EOFError` exceptions if input data is incomplete. +and call parser functions that may throw `HTTP.ParsingError` exceptions. +The `read` and `write` methods may also result in low level `IO` exceptions. + + +### Sending Messages + +Messages are formatted and written to an `IO` stream by +[`Base.write(::IO,::HTTP.Messages.Message)`](@ref) and or +[`HTTP.Messages.writeheaders`](@ref). + + +### Receiving Messages + +Messages are parsed from `IO` stream data by +[`HTTP.Messages.readheaders`](@ref). +This function calls [`HTTP.Messages.appendheader`](@ref) and +[`HTTP.Messages.readstartline!`](@ref). + +The `read` methods rely on [`HTTP.IOExtras.unread!`](@ref) to push excess +data back to the input stream. + + +### Headers + +Headers are represented by `Vector{Pair{String,String}}`. As compared to +`Dict{String,String}` this allows [repeated header fields and preservation of +order](https://tools.ietf.org/html/rfc7230#section-3.2.2). + +Header values can be accessed by name using +[`HTTP.Messages.header`](@ref) and +[`HTTP.Messages.setheader`](@ref) (case-insensitive). + +The [`HTTP.Messages.appendheader`](@ref) function handles combining +multi-line values, repeated header fields and special handling of +multiple `Set-Cookie` headers. + +### Bodies + +The `HTTP.Message` structs represent the Message Body as `Vector{UInt8}`. + +Streaming of request and response bodies is handled by the +[`HTTP.StreamLayer`](@ref) and the [`HTTP.Stream`](@ref) `<: IO` stream. +""" + + +module Messages + +export Message, Request, Response, HeaderSizeError, + reset!, + iserror, isredirect, ischunked, issafe, isidempotent, + header, hasheader, setheader, defaultheader, appendheader, + mkheaders, readheaders, headerscomplete, readtrailers, writeheaders, + readstartline!, writestartline, + bodylength, unknown_length, + load + +import ..HTTP + +using ..Pairs +using ..IOExtras +using ..Parsers +import ..Parsers: headerscomplete, reset! + +const unknown_length = typemax(Int) + + +abstract type Message end + +""" + Response <: Message + +Represents a HTTP Response Message. + +- `version::VersionNumber` + [RFC7230 2.6](https://tools.ietf.org/html/rfc7230#section-2.6) + +- `status::Int16` + [RFC7230 3.1.2](https://tools.ietf.org/html/rfc7230#section-3.1.2) + [RFC7231 6](https://tools.ietf.org/html/rfc7231#section-6) + +- `headers::Vector{Pair{String,String}}` + [RFC7230 3.2](https://tools.ietf.org/html/rfc7230#section-3.2) + +- `body::Vector{UInt8}` + [RFC7230 3.3](https://tools.ietf.org/html/rfc7230#section-3.3) + +- `request`, the `Request` that yielded this `Response`. +""" + +mutable struct Response <: Message + version::VersionNumber + status::Int16 + headers::Headers + body::Vector{UInt8} + request::Message + + function Response(status::Int=0, headers=[]; body=UInt8[], request=nothing) + r = new() + r.version = v"1.1" + r.status = status + r.headers = mkheaders(headers) + r.body = body + if request != nothing + r.request = request + end + return r + end +end + +Response(bytes) = parse(Response, bytes) + +function reset!(r::Response) + r.version = v"1.1" + r.status = 0 + if !isempty(r.headers) + empty!(r.headers) + end + if !isempty(r.body) + empty!(r.body) + end +end + + +""" + Request <: Message + +Represents a HTTP Request Message. + +- `method::String` + [RFC7230 3.1.1](https://tools.ietf.org/html/rfc7230#section-3.1.1) + +- `target::String` + [RFC7230 5.3](https://tools.ietf.org/html/rfc7230#section-5.3) + +- `version::VersionNumber` + [RFC7230 2.6](https://tools.ietf.org/html/rfc7230#section-2.6) + +- `headers::Vector{Pair{String,String}}` + [RFC7230 3.2](https://tools.ietf.org/html/rfc7230#section-3.2) + +- `body::Vector{UInt8}` + [RFC7230 3.3](https://tools.ietf.org/html/rfc7230#section-3.3) + +- `response`, the `Response` to this `Request` + +- `parent`, the `Response` (if any) that led to this request + (e.g. in the case of a redirect). + [RFC7230 6.4](https://tools.ietf.org/html/rfc7231#section-6.4) +""" + +mutable struct Request <: Message + method::String + target::String + version::VersionNumber + headers::Headers + body::Vector{UInt8} + response::Response + parent +end + +Request() = Request("", "") + +function Request(method::String, target, headers=[], body=UInt8[]; + version=v"1.1", parent=nothing) + r = Request(method, + target == "" ? "/" : target, + version, + mkheaders(headers), + body, + Response(), + parent) + r.response.request = r + return r +end + +Request(bytes) = parse(Request, bytes) + +mkheaders(h::Headers) = h +mkheaders(h)::Headers = Header[string(k) => string(v) for (k,v) in h] + +""" + issafe(::Request) + +https://tools.ietf.org/html/rfc7231#section-4.2.1 +""" + +issafe(r::Request) = r.method in ["GET", "HEAD", "OPTIONS", "TRACE"] + + +""" + isidempotent(::Request) + +https://tools.ietf.org/html/rfc7231#section-4.2.2 +""" + +isidempotent(r::Request) = issafe(r) || r.method in ["PUT", "DELETE"] + + +""" + iserror(::Response) + +Does this `Response` have an error status? +""" + +iserror(r::Response) = r.status != 0 && r.status != 100 && r.status != 101 && + (r.status < 200 || r.status >= 300) && !isredirect(r) + + +""" + isredirect(::Response) + +Does this `Response` have a redirect status? +""" +isredirect(r::Response) = r.status in (301, 302, 307, 308) + + +""" + statustext(::Response) -> String + +`String` representation of a HTTP status code. e.g. `200 => "OK"`. +""" + +statustext(r::Response) = Base.get(STATUS_MESSAGES, r.status, "Unknown Code") + + +""" + header(::Message, key [, default=""]) -> String + +Get header value for `key` (case-insensitive). +""" +header(m::Message, k, d="") = header(m.headers, k, d) +header(h::Headers, k::String, d::String="") = getbyfirst(h, k, k => d, lceq)[2] +lceq(a,b) = lowercase(a) == lowercase(b) + + +""" + hasheader(::Message, key) -> Bool + +Does header value for `key` exist (case-insensitive)? +""" +hasheader(m, k::String) = header(m, k) != "" + + +""" + hasheader(::Message, key, value) -> Bool + +Does header for `key` match `value` (both case-insensitive)? +""" +hasheader(m, k::String, v::String) = lowercase(header(m, k)) == lowercase(v) + + +""" + setheader(::Message, key => value) + +Set header `value` for `key` (case-insensitive). +""" +setheader(m::Message, v) = setheader(m.headers, v) +setheader(h::Headers, v::Pair) = setbyfirst(h, Pair{String,String}(v), lceq) + + +""" + defaultheader(::Message, key => value) + +Set header `value` for `key` if it is not already set. +""" + +function defaultheader(m, v::Pair) + if header(m, first(v)) == "" + setheader(m, v) + end + return +end + + +""" + ischunked(::Message) + +Does the `Message` have a "Transfer-Encoding: chunked" header? +""" + +ischunked(m) = hasheader(m, "Transfer-Encoding", "chunked") + + +""" + appendheader(::Message, key => value) + +Append a header value to `message.headers`. + +If `key` is `""` the `value` is appended to the value of the previous header. + +If `key` is the same as the previous header, the `value` is [appended to the +value of the previous header with a comma +delimiter](https://stackoverflow.com/a/24502264) + +`Set-Cookie` headers are not comma-combined because [cookies often contain +internal commas](https://tools.ietf.org/html/rfc6265#section-3). +""" + +function appendheader(m::Message, header::Pair{String,String}) + c = m.headers + k,v = header + if k == "" + c[end] = c[end][1] => string(c[end][2], v) + elseif k != "Set-Cookie" && length(c) > 0 && k == c[end][1] + c[end] = c[end][1] => string(c[end][2], ", ", v) + else + push!(m.headers, header) + end + return +end + + +""" + httpversion(::Message) + +e.g. `"HTTP/1.1"` +""" + +httpversion(m::Message) = "HTTP/$(m.version.major).$(m.version.minor)" + + +""" + writestartline(::IO, ::Message) + +e.g. `"GET /path HTTP/1.1\\r\\n"` or `"HTTP/1.1 200 OK\\r\\n"` +""" + +function writestartline(io::IO, r::Request) + write(io, "$(r.method) $(r.target) $(httpversion(r))\r\n") + return +end + +function writestartline(io::IO, r::Response) + write(io, "$(httpversion(r)) $(r.status) $(statustext(r))\r\n") + return +end + + +""" + writeheaders(::IO, ::Message) + +Write `Message` start line and +a line for each "name: value" pair and a trailing blank line. +""" + +function writeheaders(io::IO, m::Message) + writestartline(io, m) # FIXME To avoid fragmentation, maybe + for (name, value) in m.headers # buffer header before sending to `io` + write(io, "$name: $value\r\n") + end + write(io, "\r\n") + return +end + + +""" + write(::IO, ::Message) + +Write start line, headers and body of HTTP Message. +""" + +function Base.write(io::IO, m::Message) + writeheaders(io, m) + write(io, m.body) + return +end + + +function Base.String(m::Message) + io = IOBuffer() + write(io, m) + String(take!(io)) +end + + +#Like https://github.com/JuliaIO/FileIO.jl/blob/v0.6.1/src/FileIO.jl#L19 ? +function load(m::Message) + if hasheader(m, "Content-Type", "ISO-8859-1") + return iso8859_1_to_utf8(m.body) + else + String(m.body) + end +end + + +""" + readstartline!(::Parsers.Message, ::Message) + +Read the start-line metadata from Parser into a `::Message` struct. +""" + +function readstartline!(m::Parsers.Message, r::Response) + r.version = VersionNumber(m.major, m.minor) + r.status = m.status + return +end + +function readstartline!(m::Parsers.Message, r::Request) + r.version = VersionNumber(m.major, m.minor) + r.method = m.method + r.target = m.target + return +end + + +""" +Arbitrary limit to protect against denial of service attacks. +""" +const header_size_limit = 0x10000 + +struct HeaderSizeError <: Exception end + +""" + readheaders(::IO, ::Parser, ::Message) + +Read headers (and startline) from an `IO` stream into a `Message` struct. +Throw `EOFError` if input is incomplete. +""" + +function readheaders(io::IO, parser::Parser, message::Message) + + n = 0 + while !headerscomplete(parser) && !eof(io) + bytes = readavailable(io) + n += length(bytes) + excess = parseheaders(parser, bytes) do h + appendheader(message, h) + end + unread!(io, excess) + n -= length(excess) + if n > header_size_limit + throw(HeaderSizeError()) + end + end + if !headerscomplete(parser) + throw(EOFError()) + end + readstartline!(parser.message, message) + return message +end + + +""" + headerscomplete(::Message) + +Have the headers been read into this `Message`? +""" + +headerscomplete(r::Response) = r.status != 0 && r.status != 100 +headerscomplete(r::Request) = r.method != "" + + +""" + readtrailers(::IO, ::Parser, ::Message) + +Read trailers from an `IO` stream into a `Message` struct. +""" + +function readtrailers(io::IO, parser::Parser, message::Message) + if messagehastrailing(parser) + readheaders(io, parser, message) + end + return message +end + + +""" +"The presence of a message body in a response depends on both the + request method to which it is responding and the response status code. + Responses to the HEAD request method never include a message body []. + 2xx (Successful) responses to a CONNECT request method (Section 4.3.6 of + [RFC7231]) switch to tunnel mode instead of having a message body. + All 1xx (Informational), 204 (No Content), and 304 (Not Modified) + responses do not include a message body. All other responses do + include a message body, although the body might be of zero length." +[RFC7230 3.3](https://tools.ietf.org/html/rfc7230#section-3.3) +""" + +bodylength(r::Response)::Int = + r.request.method == "HEAD" ? 0 : + r.status in [204, 304] ? 0 : + (l = header(r, "Content-Length")) != "" ? parse(Int, l) : + unknown_length + + +""" +"The presence of a message body in a request is signaled by a + Content-Length or Transfer-Encoding header field. Request message + framing is independent of method semantics, even if the method does + not define any use for a message body." +[RFC7230 3.3](https://tools.ietf.org/html/rfc7230#section-3.3) +""" + +bodylength(r::Request)::Int = + ischunked(r) ? unknown_length : + parse(Int, header(r, "Content-Length", "0")) + + +""" + readbody(::IO, ::Parser) -> Vector{UInt8} + +Read message body from an `IO` stream. +""" + +function readbody(io::IO, parser::Parser, m::Message) + if ischunked(m) + body = IOBuffer() + while !bodycomplete(parser) && !eof(io) + data, excess = parsebody(parser, readavailable(io)) + write(body, data) + unread!(io, excess) + end + m.body = take!(body) + else + l = bodylength(m) + m.body = read(io, l) + if l != unknown_length && length(m.body) < l + throw(EOFError()) + end + end +end + + +function Base.parse(::Type{T}, str::AbstractString) where T <: Message + bytes = IOBuffer(str) + p = Parser() + r = Request() + m::T = T == Request ? r : r.response + readheaders(bytes, p, m) + readbody(bytes, p, m) + readtrailers(bytes, p, m) + if ischunked(m) && !messagecomplete(p) + throw(EOFError()) + end + return m +end + + +""" + set_show_max(x) + +Set the maximum number of body bytes to be displayed by `show(::IO, ::Message)` +""" + +set_show_max(x) = global body_show_max = x +body_show_max = 1000 + + +""" + bodysummary(bytes) + +The first chunk of the Message Body (for display purposes). +""" +bodysummary(bytes) = view(bytes, 1:min(length(bytes), body_show_max)) + +function compactstartline(m::Message) + b = IOBuffer() + writestartline(b, m) + strip(String(take!(b))) +end + +function Base.show(io::IO, m::Message) + if get(io, :compact, false) + print(io, compactstartline(m)) + if m isa Response + print(io, " <= (", compactstartline(m.request::Request), ")") + end + return + end + println(io, typeof(m), ":") + println(io, "\"\"\"") + writeheaders(io, m) + summary = bodysummary(m.body) + write(io, summary) + if length(m.body) > length(summary) + println(io, "\n⋮\n$(length(m.body))-byte body") + end + print(io, "\"\"\"") + return +end + + +const STATUS_MESSAGES = (()->begin + @assert ccall(:jl_generating_output, Cint, ()) == 1 + v = fill("Unknown Code", 530) + v[100] = "Continue" + v[101] = "Switching Protocols" + v[102] = "Processing" # RFC 2518 => obsoleted by RFC 4918 + v[200] = "OK" + v[201] = "Created" + v[202] = "Accepted" + v[203] = "Non-Authoritative Information" + v[204] = "No Content" + v[205] = "Reset Content" + v[206] = "Partial Content" + v[207] = "Multi-Status" # RFC 4918 + v[300] = "Multiple Choices" + v[301] = "Moved Permanently" + v[302] = "Moved Temporarily" + v[303] = "See Other" + v[304] = "Not Modified" + v[305] = "Use Proxy" + v[307] = "Temporary Redirect" + v[400] = "Bad Request" + v[401] = "Unauthorized" + v[402] = "Payment Required" + v[403] = "Forbidden" + v[404] = "Not Found" + v[405] = "Method Not Allowed" + v[406] = "Not Acceptable" + v[407] = "Proxy Authentication Required" + v[408] = "Request Time-out" + v[409] = "Conflict" + v[410] = "Gone" + v[411] = "Length Required" + v[412] = "Precondition Failed" + v[413] = "Request Entity Too Large" + v[414] = "Request-URI Too Large" + v[415] = "Unsupported Media Type" + v[416] = "Requested Range Not Satisfiable" + v[417] = "Expectation Failed" + v[418] = "I'm a teapot" # RFC 2324 + v[422] = "Unprocessable Entity" # RFC 4918 + v[423] = "Locked" # RFC 4918 + v[424] = "Failed Dependency" # RFC 4918 + v[425] = "Unordered Collection" # RFC 4918 + v[426] = "Upgrade Required" # RFC 2817 + v[428] = "Precondition Required" # RFC 6585 + v[429] = "Too Many Requests" # RFC 6585 + v[431] = "Request Header Fields Too Large" # RFC 6585 + v[440] = "Login Timeout" + v[444] = "nginx error: No Response" + v[495] = "nginx error: SSL Certificate Error" + v[496] = "nginx error: SSL Certificate Required" + v[497] = "nginx error: HTTP -> HTTPS" + v[499] = "nginx error or Antivirus intercepted request or ArcGIS error" + v[500] = "Internal Server Error" + v[501] = "Not Implemented" + v[502] = "Bad Gateway" + v[503] = "Service Unavailable" + v[504] = "Gateway Time-out" + v[505] = "HTTP Version Not Supported" + v[506] = "Variant Also Negotiates" # RFC 2295 + v[507] = "Insufficient Storage" # RFC 4918 + v[509] = "Bandwidth Limit Exceeded" + v[510] = "Not Extended" # RFC 2774 + v[511] = "Network Authentication Required" # RFC 6585 + v[520] = "CloudFlare Server Error: Unknown" + v[521] = "CloudFlare Server Error: Connection Refused" + v[522] = "CloudFlare Server Error: Connection Timeout" + v[523] = "CloudFlare Server Error: Origin Server Unreachable" + v[524] = "CloudFlare Server Error: Connection Timeout" + v[525] = "CloudFlare Server Error: Connection Failed" + v[526] = "CloudFlare Server Error: Invalid SSL Ceritificate" + v[527] = "CloudFlare Server Error: Railgun Error" + v[530] = "Site Frozen" + return v +end)() + + +end # module Messages diff --git a/src/Pairs.jl b/src/Pairs.jl new file mode 100644 index 000000000..b48f71b60 --- /dev/null +++ b/src/Pairs.jl @@ -0,0 +1,89 @@ +module Pairs + +export defaultbyfirst, setbyfirst, getbyfirst, setkv, getkv, rmkv + + +""" + setbyfirst(collection, item) -> item + +Set `item` in a `collection`. +If `first() of an exisiting item matches `first(item)` it is replaced. +Otherwise the new `item` is inserted at the end of the `collection`. +""" + +function setbyfirst(c, item, eq = ==) + k = first(item) + if (i = findfirst(x->eq(first(x), k), c)) > 0 + c[i] = item + else + push!(c, item) + end + return item +end + + +""" + getbyfirst(collection, key [, default]) -> item + +Get `item` from collection where `first(item)` matches `key`. +""" + +function getbyfirst(c, k, default=nothing, eq = ==) + i = findfirst(x->eq(first(x), k), c) + return i > 0 ? c[i] : default +end + + +""" + defaultbyfirst(collection, item) + +If `first(item)` does not match match `first()` of any existing items, +insert the new `item` at the end of the `collection`. +""" + +function defaultbyfirst(c, item, eq = ==) + k = first(item) + if (i = findfirst(x->eq(first(x), k), c)) == 0 + push!(c, item) + end + return +end + + +""" + setkv(collection, key, value) + +Set `value` for `key` in collection of key/value `Pairs`. +""" + +setkv(c, k, v) = setbyfirst(c, k => v) + + +""" + getkv(collection, key [, default]) -> value + +Get `value` for `key` in collection of key/value `Pairs`, +where `first(item) == key` and `value = item[2]` +""" + +function getkv(c, k, default=nothing) + i = findfirst(x->first(x) == k, c) + return i > 0 ? c[i][2] : default +end + + +""" + rmkv(collection, key) + +Remove `key` from `collection` of key/value `Pairs`. +""" + +function rmkv(c, k, default=nothing) + i = findfirst(x->first(x) == k, c) + if i > 0 + deleteat!(c, i) + end + return +end + +end # module Pairs diff --git a/src/Parsers.jl b/src/Parsers.jl new file mode 100644 index 000000000..13a874351 --- /dev/null +++ b/src/Parsers.jl @@ -0,0 +1,896 @@ +# Based on src/http/ngx_http_parse.c from NGINX copyright Igor Sysoev +# +# Additional changes are licensed under the same terms as NGINX and +# copyright Joyent, Inc. and other Node contributors. All rights reserved. +# +# 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. +# + + +module Parsers + +export Parser, Header, Headers, ByteView, nobytes, + reset!, + parseheaders, parsebody, + messagestarted, headerscomplete, bodycomplete, messagecomplete, + messagehastrailing, + ParsingError + +using ..URIs.parseurlchar + +import MbedTLS.SSLContext + +import ..@debug, ..@debugshow, ..DEBUG_LEVEL +import ..@require, ..precondition_error + +include("consts.jl") +include("parseutils.jl") + + +const strict = false # See macro @errifstrict + + +const nobytes = view(UInt8[], 1:0) +const ByteView = typeof(nobytes) +const Header = Pair{String,String} +const Headers = Vector{Header} + +""" + - `method::String`: the HTTP method + [RFC7230 3.1.1](https://tools.ietf.org/html/rfc7230#section-3.1.1) + - `major` and `minor`: HTTP version + [RFC7230 2.6](https://tools.ietf.org/html/rfc7230#section-2.6) + - `target::String`: request target + [RFC7230 5.3](https://tools.ietf.org/html/rfc7230#section-5.3) + - `status::Int`: response status + [RFC7230 3.1.2](https://tools.ietf.org/html/rfc7230#section-3.1.2) +""" + +mutable struct Message + method::String + major::Int16 + minor::Int16 + target::String + status::Int32 + + Message() = reset!(new()) +end + +function reset!(m::Message) + m.method = "" + m.major = 0 + m.minor = 0 + m.target = "" + m.status = 0 + return m +end + + +""" +The parser separates a raw HTTP Message into its component parts. + +If the input data is invalid the Parser throws a `ParsingError`. + +The parser processes a single HTTP Message. If the input stream contains +multiple Messages the Parser stops at the end of the first Message. +The `parseheaders` and `parsebody` functions return a `SubArray` containing the +unuses portion of the input. + +The Parser does not interpret the Message Headers except as needed +to parse the Message Body. It is beyond the scope of the Parser to deal +with repeated header fields, multi-line values, cookies or case normalization. + +The Parser has no knowledge of the high-level `Request` and `Response` structs +defined in `Messages.jl`. The Parser has it's own low level +[`Message`](@ref) struct that represents both Request and Response +Messages. +""" + +mutable struct Parser + + # state + state::UInt8 + chunk_length::UInt64 + trailing::Bool + fieldbuffer::IOBuffer + valuebuffer::IOBuffer + + # output + message::Message + + function Parser() + p = new() + p.fieldbuffer = IOBuffer() + p.valuebuffer = IOBuffer() + p.message = Message() + return reset!(p) + end +end + + +""" + reset!(::Parser) + +Revert `Parser` to unconfigured state. +""" + +function reset!(p::Parser) + p.state = s_start_req_or_res + p.chunk_length = 0 + p.trailing = false + truncate(p.fieldbuffer, 0) + truncate(p.valuebuffer, 0) + reset!(p.message) + return p +end + + +""" + messagestarted(::Parser) + +Has the `Parser` begun processng a Message? +""" + +messagestarted(p::Parser) = p.state != s_start_req_or_res + + +""" + headerscomplete(::Parser) + +Has the `Parser` processed the entire Message Header? +""" + +headerscomplete(p::Parser) = p.state > s_headers_done + + +""" + bodycomplete(::Parser) + +Has the `Parser` processed the Message Body? +""" + +bodycomplete(p::Parser) = p.state == s_message_done || + p.state == s_trailer_start + + +""" + messagecomplete(::Parser) + +Has the `Parser` processed the entire Message? +""" + +messagecomplete(p::Parser) = p.state >= s_message_done + + +""" + messagehastrailing(::Parser) + +Is the `Parser` ready to process trailing headers? +""" +messagehastrailing(p::Parser) = p.trailing + + +isrequest(p::Parser) = p.message.status == 0 + + +""" +The [`Parser`] input was invalid. + +Fields: + - `code`, internal error code + - `state`, internal parsing state. + - `status::Int32`, HTTP response status. + - `msg::String`, error message. +""" + +struct ParsingError <: Exception + code::Symbol + state::UInt8 + status::Int32 + msg::String +end + +function ParsingError(p::Parser, code::Symbol) + ParsingError(code, p.state, p.message.status, "") +end + +function Base.show(io::IO, e::ParsingError) + println(io, string("HTTP.ParsingError: ", + get(ERROR_MESSAGES, e.code, "?"), ", ", + ParsingStateCode(e.state), ", ", + e.status, + e.msg == "" ? "" : "\n", + e.msg)) +end + + +macro err(code) + esc(:(parser.state = p_state; throw(ParsingError(parser, $code)))) +end + +macro errorif(cond, err) + esc(:($cond && @err($err))) +end + +macro errorifstrict(cond) + strict ? esc(:(@errorif($cond, :HPE_STRICT))) : :() +end + +macro passert(cond) + DEBUG_LEVEL > 1 ? esc(:(@assert $cond)) : :() +end + +macro methodstate(meth, i, char) + return esc(:(Int($meth) << Int(16) | Int($i) << Int(8) | Int($char))) +end + +function parse_token(bytes, len, p, buffer; allowed='a') + start = p + while p <= len + @inbounds ch = Char(bytes[p]) + if !istoken(ch) && ch != allowed + break + end + p += 1 + end + @passert p <= len + 1 + + write(buffer, view(bytes, start:p-1)) + + if p > len + return len, false + else + return p, true + end +end + + +""" + parseheaders(::Parser, bytes) do h::Pair{String,String} ... -> excess + +Read headers from `bytes`, passing each field/value pair to `f`. +Returns a `SubArray` containing bytes not parsed. + +e.g. +``` +excess = parseheaders(p, bytes) do (k,v) + println("\$k: \$v") +end +``` +""" + +function parseheaders(f, p, bytes) + v = Vector{UInt8}(bytes) + parseheaders(f, p, view(v, 1:length(v))) +end + +function parseheaders(onheader::Function #=f(::Pair{String,String}) =#, + parser::Parser, bytes::ByteView)::ByteView + + @require !isempty(bytes) + @require messagehastrailing(parser) || !headerscomplete(parser) + + len = length(bytes) + p_state = parser.state + @debug 3 "parseheaders(parser.state=$(ParsingStateCode(p_state))), " * + "$len-bytes:\n" * escapelines(String(collect(bytes))) * ")" + + p = 0 + while p < len && p_state <= s_headers_done + + @debug 4 string("top of while($p < $len) \"", + Base.escape_string(string(Char(bytes[p+1]))), "\" ", + ParsingStateCode(p_state)) + p += 1 + @inbounds ch = Char(bytes[p]) + + if p_state == s_start_req_or_res + (ch == CR || ch == LF) && continue + + p_state = s_start_req + p -= 1 + + elseif p_state == s_res_first_http_major + @errorif(!isnum(ch), :HPE_INVALID_VERSION) + parser.message.major = Int16(ch - '0') + p_state = s_res_http_major + + # major HTTP version or dot + elseif p_state == s_res_http_major + if ch == '.' + p_state = s_res_first_http_minor + continue + end + @errorif(!isnum(ch), :HPE_INVALID_VERSION) + parser.message.major *= Int16(10) + parser.message.major += Int16(ch - '0') + @errorif(parser.message.major > 999, :HPE_INVALID_VERSION) + + # first digit of minor HTTP version + elseif p_state == s_res_first_http_minor + @errorif(!isnum(ch), :HPE_INVALID_VERSION) + parser.message.minor = Int16(ch - '0') + p_state = s_res_http_minor + + # minor HTTP version or end of request line + elseif p_state == s_res_http_minor + if ch == ' ' + p_state = s_res_first_status_code + continue + end + @errorif(!isnum(ch), :HPE_INVALID_VERSION) + parser.message.minor *= Int16(10) + parser.message.minor += Int16(ch - '0') + @errorif(parser.message.minor > 999, :HPE_INVALID_VERSION) + + elseif p_state == s_res_first_status_code + if !isnum(ch) + ch == ' ' && continue + @err(:HPE_INVALID_STATUS) + end + parser.message.status = Int32(ch - '0') + p_state = s_res_status_code + + elseif p_state == s_res_status_code + if !isnum(ch) + if ch == ' ' + p_state = s_res_status_start + elseif ch == CR + p_state = s_res_line_almost_done + elseif ch == LF + p_state = s_header_field_start + else + @err(:HPE_INVALID_STATUS) + end + else + parser.message.status *= Int32(10) + parser.message.status += Int32(ch - '0') + @errorif(parser.message.status > 999, :HPE_INVALID_STATUS) + end + + elseif p_state == s_res_status_start + if ch == CR + p_state = s_res_line_almost_done + elseif ch == LF + p_state = s_header_field_start + else + p_state = s_res_status + end + + elseif p_state == s_res_status + if ch == CR + p_state = s_res_line_almost_done + elseif ch == LF + p_state = s_header_field_start + end + + elseif p_state == s_res_line_almost_done + @errorifstrict(ch != LF) + p_state = s_header_field_start + + elseif p_state == s_start_req + (ch == CR || ch == LF) && continue + + @errorif(!istoken(ch), :HPE_INVALID_METHOD) + + p_state = s_req_method + p -= 1 + + elseif p_state == s_req_method + + p, complete = parse_token(bytes, len, p, parser.valuebuffer) + + if complete + parser.message.method = take!(parser.valuebuffer) + @inbounds ch = Char(bytes[p]) + if parser.message.method == "HTTP" && ch == '/' + p_state = s_res_first_http_major + elseif ch == ' ' + p_state = s_req_spaces_before_target + else + @err(:HPE_INVALID_METHOD) + end + end + + elseif p_state == s_req_spaces_before_target + ch == ' ' && continue + if parser.message.method == "CONNECT" + p_state = s_req_server_start + p -= 1 + elseif ch == '*' + p_state = s_req_target_wildcard + else + p_state = s_req_target_start + p -= 1 + end + + elseif p_state == s_req_target_wildcard + + if @anyeq(ch, ' ', CR, LF) + parser.message.target = "*" + p_state = s_req_http_start + else + @err(:HPE_INVALID_TARGET) + end + + elseif @anyeq(p_state, s_req_target_start, + s_req_server_start, + s_req_server, + s_req_server_with_at, + s_req_path, + s_req_query_string_start, + s_req_query_string, + s_req_fragment_start, + s_req_fragment, + s_req_schema, + s_req_schema_slash, + s_req_schema_slash_slash) + start = p + while p <= len + @inbounds ch = Char(bytes[p]) + if @anyeq(ch, ' ', CR, LF) + @errorif(@anyeq(p_state, s_req_schema, s_req_schema_slash, + s_req_schema_slash_slash, + s_req_server_start), + :HPE_INVALID_TARGET) + if ch == ' ' + p_state = s_req_http_start + else + parser.message.major = Int16(0) + parser.message.minor = Int16(9) + p_state = ifelse(ch == CR, s_req_line_almost_done, + s_header_field_start) + end + break + end + p_state = parseurlchar(p_state, ch, strict) + @errorif(p_state == s_dead, :HPE_INVALID_TARGET) + p += 1 + end + @passert p <= len + 1 + + write(parser.valuebuffer, view(bytes, start:p-1)) + + if p_state >= s_req_http_start + parser.message.target = take!(parser.valuebuffer) + @debugshow 4 parser.message.target + end + + p = min(p, len) + + elseif p_state == s_req_http_start + if ch == 'H' + p_state = s_req_http_H + elseif ch == ' ' + else + @err(:HPE_INVALID_CONSTANT) + end + + elseif p_state == s_req_http_H + @errorifstrict(ch != 'T') + p_state = s_req_http_HT + + elseif p_state == s_req_http_HT + @errorifstrict(ch != 'T') + p_state = s_req_http_HTT + + elseif p_state == s_req_http_HTT + @errorifstrict(ch != 'P') + p_state = s_req_http_HTTP + + elseif p_state == s_req_http_HTTP + @errorifstrict(ch != '/') + p_state = s_req_first_http_major + + # first digit of major HTTP version + elseif p_state == s_req_first_http_major + @errorif(ch < '1' || ch > '9', :HPE_INVALID_VERSION) + parser.message.major = Int16(ch - '0') + p_state = s_req_http_major + + # major HTTP version or dot + elseif p_state == s_req_http_major + if ch == '.' + p_state = s_req_first_http_minor + elseif !isnum(ch) + @err(:HPE_INVALID_VERSION) + else + parser.message.major *= Int16(10) + parser.message.major += Int16(ch - '0') + @errorif(parser.message.major > 999, :HPE_INVALID_VERSION) + end + + # first digit of minor HTTP version + elseif p_state == s_req_first_http_minor + @errorif(!isnum(ch), :HPE_INVALID_VERSION) + parser.message.minor = Int16(ch - '0') + p_state = s_req_http_minor + + # minor HTTP version or end of request line + elseif p_state == s_req_http_minor + if ch == CR + p_state = s_req_line_almost_done + elseif ch == LF + p_state = s_header_field_start + else + # FIXME allow spaces after digit? + @errorif(!isnum(ch), :HPE_INVALID_VERSION) + parser.message.minor *= Int16(10) + parser.message.minor += Int16(ch - '0') + @errorif(parser.message.minor > 999, :HPE_INVALID_VERSION) + end + + # end of request line + elseif p_state == s_req_line_almost_done + @errorif(ch != LF, :HPE_LF_EXPECTED) + p_state = s_header_field_start + + elseif p_state == s_header_field_start || + p_state == s_trailer_start + if ch == CR + p_state = s_headers_almost_done + elseif ch == LF + # they might be just sending \n instead of \r\n so this would be + # the second \n to denote the end of headers + p_state = s_headers_almost_done + p -= 1 + else + c = (!strict && ch == ' ') ? ' ' : tokens[Int(ch)+1] + @errorif(c == Char(0), :HPE_INVALID_HEADER_TOKEN) + p_state = s_header_field + p -= 1 + end + + elseif p_state == s_header_field + + p, complete = parse_token(bytes, len, p, parser.fieldbuffer; + allowed = ' ') + if complete + @inbounds ch = Char(bytes[p]) + @errorif(ch != ':', :HPE_INVALID_HEADER_TOKEN) + p_state = s_header_value_discard_ws + end + + elseif p_state == s_header_value_discard_ws + (ch == ' ' || ch == '\t') && continue + if ch == CR + p_state = s_header_value_discard_ws_almost_done + continue + end + if ch == LF + p_state = s_header_value_discard_lws + continue + end + p_state = s_header_value_start + p -= 1 + elseif p_state == s_header_value_start + p_state = s_header_value + c = lower(ch) + + write(parser.valuebuffer, bytes[p]) + + elseif p_state == s_header_value + start = p + while p <= len + @inbounds ch = Char(bytes[p]) + @debug 4 Base.escape_string(string('\'', ch, '\'')) + if ch == CR + p_state = s_header_almost_done + break + elseif ch == LF + p_state = s_header_value_lws + break + elseif strict && !isheaderchar(ch) + @err(:HPE_INVALID_HEADER_TOKEN) + end + + c = lower(ch) + + @debugshow 4 h + crlf = findfirst(x->(x == bCR || x == bLF), view(bytes, p:len)) + p = crlf == 0 ? len : p + crlf - 2 + + p += 1 + end + @passert p <= len + 1 + + write(parser.valuebuffer, view(bytes, start:p-1)) + + if p_state != s_header_value + onheader(String(take!(parser.fieldbuffer)) => + String(take!(parser.valuebuffer))) + end + + p = min(p, len) + + elseif p_state == s_header_almost_done + @errorif(ch != LF, :HPE_LF_EXPECTED) + p_state = s_header_value_lws + + elseif p_state == s_header_value_lws + p -= 1 + if ch == ' ' || ch == '\t' + p_state = s_header_value_start + else + # finished the header + p_state = s_header_field_start + end + + elseif p_state == s_header_value_discard_ws_almost_done + @errorifstrict(ch != LF) + p_state = s_header_value_discard_lws + + elseif p_state == s_header_value_discard_lws + if ch == ' ' || ch == '\t' + p_state = s_header_value_discard_ws + else + # header value was empty + p_state = s_header_field_start + onheader(String(take!(parser.fieldbuffer)) => "") + p -= 1 + end + + elseif p_state == s_headers_almost_done + @errorifstrict(ch != LF) + p -= 1 + if parser.trailing + # End of a chunked request + p_state = s_message_done + else + p_state = s_headers_done + end + + elseif p_state == s_headers_done + @errorifstrict(ch != LF) + + p_state = s_body_start + else + @err :HPE_INVALID_INTERNAL_STATE + end + end + + @assert p <= len + @assert p == len || + p_state == s_message_done || + p_state == s_body_start + + + # Consume trailing end of line after message. + if p_state == s_message_done + while p < len + ch = Char(bytes[p + 1]) + if ch != CR && ch != LF + break + end + p += 1 + end + end + + @debug 3 "parseheaders() exiting $(ParsingStateCode(p_state))" + + parser.state = p_state + return view(bytes, p+1:len) +end + + +""" + parsebody(::Parser, bytes) -> data, excess + +Parse body data from `bytes`. +Returns decoded `data` and `excess` bytes not parsed. +""" + +function parsebody(p, bytes) + v = Vector{UInt8}(bytes) + parsebody(p, view(v, 1:length(v))) +end + +function parsebody(parser::Parser, bytes::ByteView)::Tuple{ByteView,ByteView} + + @require !isempty(bytes) + @require headerscomplete(parser) + + if parser.state == s_body_start + parser.state = s_chunk_size_start + end + + len = length(bytes) + p_state = parser.state + @debug 3 "parsebody(parser.state=$(ParsingStateCode(p_state))), " * + "$len-bytes:\n" * escapelines(String(collect(bytes))) * ")" + + result = nobytes + + p = 0 + while p < len && result == nobytes && p_state != s_trailer_start + + @debug 4 string("top of while($p < $len) \"", + Base.escape_string(string(Char(bytes[p+1]))), "\" ", + ParsingStateCode(p_state)) + p += 1 + @inbounds ch = Char(bytes[p]) + + if p_state == s_chunk_size_start + + unhex_val = unhex[Int(ch)+1] + @errorif(unhex_val == -1, :HPE_INVALID_CHUNK_SIZE) + + parser.chunk_length = unhex_val + p_state = s_chunk_size + + elseif p_state == s_chunk_size + if ch == CR + p_state = s_chunk_size_almost_done + else + unhex_val = unhex[Int(ch)+1] + if unhex_val == -1 + if ch == ';' || ch == ' ' + p_state = s_chunk_parameters + continue + end + @err(:HPE_INVALID_CHUNK_SIZE) + end + t = parser.chunk_length + t *= UInt64(16) + t += UInt64(unhex_val) + + # Overflow? Test against a conservative limit for simplicity. + @debugshow 4 Int(parser.chunk_length) + if div(typemax(UInt64) - 16, 16) < t + @err(:HPE_INVALID_CONTENT_LENGTH) + end + parser.chunk_length = t + end + + elseif p_state == s_chunk_parameters + # just ignore this?. FIXME check for overflow? + if ch == CR + p_state = s_chunk_size_almost_done + end + + elseif p_state == s_chunk_size_almost_done + @errorifstrict(ch != LF) + + if parser.chunk_length == 0 + parser.trailing = true + p_state = s_trailer_start + else + p_state = s_chunk_data + end + + elseif p_state == s_chunk_data + to_read = Int(min(parser.chunk_length, len - p + 1)) + + @passert parser.chunk_length != 0 + + @passert result == nobytes + result = view(bytes, p:p + to_read - 1) + parser.chunk_length -= to_read + p += to_read - 1 + + if parser.chunk_length == 0 + p_state = s_chunk_data_almost_done + end + + elseif p_state == s_chunk_data_almost_done + @passert parser.chunk_length == 0 + @errorifstrict(ch != CR) + p_state = s_chunk_data_done + + elseif p_state == s_chunk_data_done + @errorifstrict(ch != LF) + p_state = s_chunk_size_start + + else + @err :HPE_INVALID_INTERNAL_STATE + end + end + + @assert p <= len + @assert p == len || + result != nobytes || + p_state == s_trailer_start + + @debug 3 "parsebody() exiting $(ParsingStateCode(p_state))" + + parser.state = p_state + return result, view(bytes, p+1:len) +end + + +const ERROR_MESSAGES = Dict( + :HPE_INVALID_VERSION => "invalid HTTP version", + :HPE_INVALID_STATUS => "invalid HTTP status code", + :HPE_INVALID_METHOD => "invalid HTTP method", + :HPE_INVALID_TARGET => "invalid HTTP Request Target", + :HPE_LF_EXPECTED => "LF character expected", + :HPE_INVALID_HEADER_TOKEN => "invalid character in header", + :HPE_INVALID_CONTENT_LENGTH => "invalid character in content-length header", + :HPE_INVALID_CHUNK_SIZE => "invalid character in chunk size header", + :HPE_INVALID_CONSTANT => "invalid constant string", + :HPE_INVALID_INTERNAL_STATE => "encountered unexpected internal state", + :HPE_STRICT => "strict mode assertion failed", +) + + +""" +Tokens as defined by rfc 2616. Also lowercases them. + token = 1* + separators = "(" | ")" | "<" | ">" | "@" + | "," | ";" | ":" | "\" | <"> + | "/" | "[" | "]" | "?" | "=" + | "{" | "}" | SP | HT +""" + +const tokens = Char[ +#= 0 nul 1 soh 2 stx 3 etx 4 eot 5 enq 6 ack 7 bel =# + 0, 0, 0, 0, 0, 0, 0, 0, +#= 8 bs 9 ht 10 nl 11 vt 12 np 13 cr 14 so 15 si =# + 0, 0, 0, 0, 0, 0, 0, 0, +#= 16 dle 17 dc1 18 dc2 19 dc3 20 dc4 21 nak 22 syn 23 etb =# + 0, 0, 0, 0, 0, 0, 0, 0, +#= 24 can 25 em 26 sub 27 esc 28 fs 29 gs 30 rs 31 us =# + 0, 0, 0, 0, 0, 0, 0, 0, +#= 32 sp 33 ! 34 " 35 # 36 $ 37 % 38 & 39 ' =# + 0, '!', 0, '#', '$', '%', '&', '\'', +#= 40 ( 41 ) 42 * 43 + 44 , 45 - 46 . 47 / =# + 0, 0, '*', '+', 0, '-', '.', 0, +#= 48 0 49 1 50 2 51 3 52 4 53 5 54 6 55 7 =# + '0', '1', '2', '3', '4', '5', '6', '7', +#= 56 8 57 9 58 : 59 ; 60 < 61 = 62 > 63 ? =# + '8', '9', 0, 0, 0, 0, 0, 0, +#= 64 @ 65 A 66 B 67 C 68 D 69 E 70 F 71 G =# + 0, 'a', 'b', 'c', 'd', 'e', 'f', 'g', +#= 72 H 73 I 74 J 75 K 76 L 77 M 78 N 79 O =# + 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', +#= 80 P 81 Q 82 R 83 S 84 T 85 U 86 V 87 W =# + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', +#= 88 X 89 Y 90 Z 91 [ 92 \ 93 ] 94 ^ 95 _ =# + 'x', 'y', 'z', 0, 0, 0, '^', '_', +#= 96 ` 97 a 98 b 99 c 100 d 101 e 102 f 103 g =# + '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', +#= 104 h 105 i 106 j 107 k 108 l 109 m 110 n 111 o =# + 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', +#= 112 p 113 q 114 r 115 s 116 t 117 u 118 v 119 w =# + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', +#= 120 x 121 y 122 z 123 { 124 | 125 } 126 ~ 127 del =# + 'x', 'y', 'z', 0, '|', 0, '~', 0 ] + +istoken(c) = tokens[UInt8(c)+1] != Char(0) + + +const unhex = Int8[ + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 + ,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 + ,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 + , 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,-1,-1,-1,-1,-1,-1 + ,-1,10,11,12,13,14,15,-1,-1,-1,-1,-1,-1,-1,-1,-1 + ,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 + ,-1,10,11,12,13,14,15,-1,-1,-1,-1,-1,-1,-1,-1,-1 + ,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 +] + + +Base.show(io::IO, p::Parser) = print(io, "Parser(", + "state=", ParsingStateCode(p.state), ", ", + "trailing=", p.trailing, ", ", + "message=", p.message, ")") + + +end # module Parsers diff --git a/src/RedirectRequest.jl b/src/RedirectRequest.jl new file mode 100644 index 000000000..4ceff56c3 --- /dev/null +++ b/src/RedirectRequest.jl @@ -0,0 +1,57 @@ +module RedirectRequest + +import ..Layer, ..request +using ..URIs +using ..Messages +using ..Pairs: setkv +using ..Header +import ..@debug, ..DEBUG_LEVEL + + +""" + request(RedirectLayer, method, ::URI, headers, body) -> HTTP.Response + +Redirects the request in the case of 3xx response status. +""" + +abstract type RedirectLayer{Next <: Layer} <: Layer end +export RedirectLayer + +function request(::Type{RedirectLayer{Next}}, + method::String, url::URI, headers, body; + redirect_limit=3, forwardheaders=false, kw...) where Next + count = 0 + while true + + res = request(Next, method, url, headers, body; kw...) + + if (count == redirect_limit + || !isredirect(res) + || (location = header(res, "Location")) == "" + || method == "HEAD") #FIXME why not redirect HEAD? + return res + end + + + @static if VERSION > v"0.7.0-DEV.2338" + kw = merge(merge(NamedTuple(), kw), (parent = res,)) + else + setkv(kw, :parent, res) + end + url = absuri(location, url) + if forwardheaders + headers = filter(h->!(h[1] in ("Host", "Cookie")), headers) + else + headers = Header[] + end + + @debug 1 "➡️ Redirect: $url" + + count += 1 + end + + @assert false "Unreachable!" +end + + +end # module RedirectRequest diff --git a/src/RetryRequest.jl b/src/RetryRequest.jl new file mode 100644 index 000000000..59d3af365 --- /dev/null +++ b/src/RetryRequest.jl @@ -0,0 +1,78 @@ +module RetryRequest + +import ..HTTP +import ..Layer, ..request +using ..IOExtras +using ..MessageRequest +using ..Messages +import ..@debug, ..DEBUG_LEVEL + + +""" + request(RetryLayer, ::URI, ::Request, body) -> HTTP.Response + +Retry the request if it throws a recoverable exception. + +`Base.retry` and `Base.ExponentialBackOff` implement a randomised exponentially +increasing delay is introduced between attempts to avoid exacerbating network +congestion. + +Methods of `isrecoverable(e)` define which exception types lead to a retry. +e.g. `HTTP.IOError`, `Base.DNSError`, `Base.EOFError` and `HTTP.StatusError` +(if status is ``5xx`). +""" + +abstract type RetryLayer{Next <: Layer} <: Layer end +export RetryLayer + +function request(::Type{RetryLayer{Next}}, url, req, body; + retries::Int=4, retry_non_idempotent::Bool=false, + kw...) where Next + + retry_request = Base.retry(request, + delays=ExponentialBackOff(n = retries), + check=(s,ex)->begin + retry = isrecoverable(ex, req, retry_non_idempotent) + if retry + @debug 1 "🔄 Retry $ex: $(sprint(showcompact, req))" + reset!(req.response) + else + @debug 1 "🚷 No Retry: $(no_retry_reason(ex, req))" + end + return s, retry + end) + + retry_request(Next, url, req, body; kw...) +end + + +isrecoverable(e) = false +isrecoverable(e::IOError) = true +isrecoverable(e::Base.DNSError) = true +isrecoverable(e::HTTP.StatusError) = e.status == 403 || # Forbidden + e.status == 408 || # Timeout + e.status >= 500 # Server Error + +isrecoverable(e, req, retry_non_idempotent) = + isrecoverable(e) && + !(req.body === body_was_streamed) && + !(req.response.body === body_was_streamed) && + (retry_non_idempotent || isidempotent(req)) + # "MUST NOT automatically retry a request with a non-idempotent method" + # https://tools.ietf.org/html/rfc7230#section-6.3.1 + + +function no_retry_reason(ex, req) + buf = IOBuffer() + showcompact(buf, req) + print(buf, ", ", + ex isa HTTP.StatusError ? "HTTP $(ex.status): " : + !isrecoverable(ex) ? "$ex not recoverable, " : "", + (req.body === body_was_streamed) ? "request streamed, " : "", + (req.response.body === body_was_streamed) ? "response streamed, " : "", + !isidempotent(req) ? "$(req.method) non-idempotent" : "") + return String(take!(buf)) +end + + +end # module RetryRequest diff --git a/src/Servers.jl b/src/Servers.jl new file mode 100644 index 000000000..65fdc523a --- /dev/null +++ b/src/Servers.jl @@ -0,0 +1,475 @@ +module Servers + +using ..IOExtras +using ..Streams +using ..Messages +using ..Parsers +using ..ConnectionPool +import ..@info, ..@warn, ..@error, ..@debug, ..@debugshow, ..DEBUG_LEVEL +using MbedTLS: SSLConfig, SSLContext, setup!, associate!, hostname!, handshake! + + +if !isdefined(Base, :Nothing) + const Nothing = Void + const Cvoid = Void +end + +import ..Dates + +@static if !isdefined(Base, :Distributed) + using Distributed +end + +using ..HTTP, ..Handlers + +export Server, ServerOptions, serve +#TODO: + # add in "events" handling + # dealing w/ cookies + # reverse proxy? + # auto-compression + # health report of server + # http authentication subrequests? + # ip access control lists? + # JWT authentication? + # live activity monitoring + # live reconfigure? + # memory/performance profiling for thousands of concurrent requests? + # fault tolerance? + # handle IPv6? + # flv & mp4 streaming? + # URL rewriting? + # bandwidth throttling + # IP address-based geolocation + # user-tracking + # WebDAV + # FastCGI + # default handler: + # handle all common http requests/etc. + # just map straight to filesystem + # special case OPTIONS method like go? + # buffer re-use for server/client wire-reading + # easter egg (response 418) +mutable struct ServerOptions + tlsconfig::HTTP.MbedTLS.SSLConfig + readtimeout::Float64 + ratelimit::Rational{Int} + support100continue::Bool + chunksize::Union{Nothing, Int} + logbody::Bool +end + +abstract type Scheme end +struct http <: Scheme end +struct https <: Scheme end + +ServerOptions(; tlsconfig::HTTP.MbedTLS.SSLConfig=HTTP.MbedTLS.SSLConfig(true), + readtimeout::Float64=180.0, + ratelimit::Rational{Int64}=Int64(5)//Int64(1), + support100continue::Bool=true, + chunksize::Union{Nothing, Int}=nothing, + logbody::Bool=true) = + ServerOptions(tlsconfig, readtimeout, ratelimit, support100continue, chunksize, logbody) + +""" + Server(handler, logger::IO=STDOUT; kwargs...) + +An http/https server. Supports listening on a `host` and `port` via the `HTTP.serve(server, host, port)` function. +`handler` is a function of the form `f(::Request, ::Response) -> HTTP.Response`, i.e. it takes both a `Request` and pre-built `Response` +objects as inputs and returns the, potentially modified, `Response`. `logger` indicates where logging output should be directed. +When `HTTP.serve` is called, it aims to "never die", catching and recovering from all internal errors. To forcefully stop, one can obviously +kill the julia process, interrupt (ctrl/cmd+c) if main task, or send the kill signal over a server in channel like: +`put!(server.in, HTTP.KILL)`. + +Supported keyword arguments include: + * `cert`: if https, the cert file to use, as passed to `HTTP.MbedTLS.SSLConfig(cert, key)` + * `key`: if https, the key file to use, as passed to `HTTP.MbedTLS.SSLConfig(cert, key)` + * `tlsconfig`: pass in an already-constructed `HTTP.MbedTLS.SSLConfig` instance + * `readtimeout`: how long a client connection will be left open without receiving any bytes + * `ratelimit`: a `Rational{Int}` of the form `5//1` indicating how many `messages//second` should be allowed per client IP address; requests exceeding the rate limit will be dropped + * `support100continue`: a `Bool` indicating whether `Expect: 100-continue` headers should be supported for delayed request body sending; default = `true` + * `logbody`: whether the Response body should be logged when `verbose=true` logging is enabled; default = `true` +""" +mutable struct Server{T <: Scheme, H <: HTTP.Handler} + handler::H + logger::IO + in::Channel{Any} + out::Channel{Any} + options::ServerOptions + + Server{T, H}(handler::H, logger::IO=STDOUT, ch=Channel(1), ch2=Channel(1), + options=ServerOptions()) where {T, H} = + new{T, H}(handler, logger, ch, ch2, options) +end + + +mutable struct RateLimit + allowance::Float64 + lastcheck::Dates.DateTime +end + +function update!(rl::RateLimit, ratelimit) + current = Dates.now() + timepassed = float(Dates.value(current - rl.lastcheck)) / 1000.0 + rl.lastcheck = current + rl.allowance += timepassed * ratelimit + return nothing +end + +function check_rate_limit(tcp; + ratelimits=nothing, + ratelimit::Rational{Int64}=Int64(5)//Int64(1), kw...) + ip = getsockname(tcp)[1] + rate = Float64(ratelimit.num) + rl = get!(ratelimits, ip, RateLimit(rate, Dates.now())) + update!(rl, ratelimit) + if rl.allowance > rate + @warn "throttling $ip" + rl.allowance = rate + end + if rl.allowance < 1.0 + @warn "discarding connection from $ip due to rate limiting" + return false + else + rl.allowance -= 1.0 + end + return true +end + + +@enum Signals KILL + +function serve(server::Server{T, H}, host, port, verbose) where {T, H} + + tcpserver = Ref{Base.TCPServer}() + + @async begin + while !isassigned(tcpserver) + sleep(1) + end + while true + val = take!(server.in) + val == KILL && close(tcpserver[]) + end + end + + listen(host, port; + tcpref=tcpserver, + ssl=(T == https), + sslconfig=server.options.tlsconfig, + verbose=verbose, + tcpisvalid=server.options.ratelimit > 0 ? check_rate_limit : + (tcp; kw...) -> true, + ratelimits=Dict{IPAddr, RateLimit}(), + ratelimit=server.options.ratelimit) do http + + request = http.message + request.body = read(http) + + response = request.response + + handle(server.handler, request, response) + + startwrite(http) + write(http, response.body) + end + + return +end + +Server(h::Function, l::IO=STDOUT; cert::String="", key::String="", args...) = Server(HTTP.HandlerFunction(h), l; cert=cert, key=key, args...) +function Server(handler::H=HTTP.HandlerFunction((req, rep) -> HTTP.Response("Hello World!")), + logger::IO=STDOUT; + cert::String="", + key::String="", + args...) where {H <: HTTP.Handler} + if cert != "" && key != "" + server = Server{https, H}(handler, logger, Channel(1), Channel(1), ServerOptions(; tlsconfig=HTTP.MbedTLS.SSLConfig(cert, key), args...)) + else + server = Server{http, H}(handler, logger, Channel(1), Channel(1), ServerOptions(; args...)) + end + return server +end + +""" + HTTP.serve([server,] host::IPAddr, port::Int; verbose::Bool=true, kwargs...) + +Start a server listening on the provided `host` and `port`. `verbose` indicates whether server activity should be logged. +Optional keyword arguments allow construction of `Server` on the fly if the `server` argument isn't provided directly. +See `?HTTP.Server` for more details on server construction and supported keyword arguments. +By default, `HTTP.serve` aims to "never die", catching and recovering from all internal errors. Two methods for stopping +`HTTP.serve` include interrupting (ctrl/cmd+c) if blocking on the main task, or sending the kill signal via the server's in channel +(`put!(server.in, HTTP.KILL)`). +""" +function serve end + +serve(server::Server, host=ip"127.0.0.1", port=8081; verbose::Bool=true) = serve(server, host, port, verbose) +function serve(host::IPAddr, port::Int, + handler=(req, rep) -> HTTP.Response("Hello World!"), + logger::I=STDOUT; + cert::String="", + key::String="", + verbose::Bool=true, + args...) where {I} + server = Server(handler, logger; cert=cert, key=key, args...) + return serve(server, host, port, verbose) +end +serve(; host::IPAddr=ip"127.0.0.1", + port::Int=8081, + handler=(req, rep) -> HTTP.Response("Hello World!"), + logger::IO=STDOUT, + cert::String="", + key::String="", + verbose::Bool=true, + args...) = + serve(host, port, handler, logger; cert=cert, key=key, verbose=verbose, args...) + + + + +function getsslcontext(tcp, sslconfig) + ssl = SSLContext() + setup!(ssl, sslconfig) + associate!(ssl, tcp) + handshake!(ssl) + return ssl +end + +const nosslconfig = SSLConfig() + +const nolimit = typemax(Int) + + +""" + HTTP.listen(host="localhost", port=8081; ) do http::HTTP.Stream + ... + end + +Listen for HTTP connections and execute the `do` function for each request. + +Optional keyword arguments: + - `ssl::Bool = false`, use https. + - `require_ssl_verification = true`, pass `MBEDTLS_SSL_VERIFY_REQUIRED` to + the mbed TLS library. + ["... peer must present a valid certificate, handshake is aborted if + verification failed."](https://tls.mbed.org/api/ssl_8h.html#a5695285c9dbfefec295012b566290f37) + - `sslconfig = SSLConfig(require_ssl_verification)` + - `pipeline_limit = 16`, number of concurrent requests per connection. + - `reuse_limit = nolimit`, number of times a connection is allowed to be reused + after the first request. + - `tcpisvalid::Function (::TCPSocket) -> Bool`, check accepted connection before + processing requests. e.g. to implement source IP filtering, rate-limiting, + etc. + - `tcpref::Ref{Base.TCPServer}`, this reference is set to the underlying + `TCPServer`. e.g. to allow closing the server. + +e.g. +``` + HTTP.listen() do http + @show http.message + @show header(http, "Content-Type") + while !eof(http) + println("body data: ", String(readavailable(http))) + end + setstatus(http, 404) + setheader(http, "Foo-Header" => "bar") + startwrite(http) + write(http, "response body") + write(http, "more response body") + end +``` +""" + +listen(f, host, port; kw...) = listen(f, string(host), Int(port); kw...) + +function listen(f::Function, + host::String="127.0.0.1", port::Int=8081; + ssl::Bool=false, + require_ssl_verification::Bool=true, + sslconfig::SSLConfig=nosslconfig, + pipeline_limit::Int=ConnectionPool.default_pipeline_limit, + tcpisvalid::Function=(tcp; kw...)->true, + tcpref::Ref{Base.TCPServer}=Ref{Base.TCPServer}(), + kw...) + + if sslconfig === nosslconfig + sslconfig = SSLConfig(require_ssl_verification) + end + + @info "Listening on: $host:$port" + tcpserver = Base.listen(getaddrinfo(host), port) + + tcpref[] = tcpserver + + try + while isopen(tcpserver) + try + io = accept(tcpserver) + catch e + if e isa Base.UVError + @warn "$e" + break + else + rethrow(e) + end + end + if !tcpisvalid(io; kw...) + close(io) + continue + end + io = ssl ? getsslcontext(io, sslconfig) : io + let io = Connection(host, string(port), pipeline_limit, io) + @info "Accept: $io" + @async try + handle_connection(f, io; kw...) + catch e + @error "Error: $io" e + finally + close(io) + @info "Closed: $io" + end + end + end + catch e + if typeof(e) <: InterruptException + @warn "Interrupted: listen($host,$port)" + else + rethrow(e) + end + finally + close(tcpserver) + end + + return +end + + +""" +Start a timeout monitor task to close the `Connection` if it is inactive. +Create a `Transaction` object for each HTTP Request received. +""" + +function handle_connection(f::Function, c::Connection; + reuse_limit::Int=nolimit, + readtimeout::Int=0, kw...) + + wait_for_timeout = Ref{Bool}(true) + if readtimeout > 0 + @async while wait_for_timeout[] + @show inactiveseconds(c) + if inactiveseconds(c) > readtimeout + @warn "Timeout: $c" + writeheaders(c.io, Response(408, ["Connection" => "close"])) + close(c) + break + end + sleep(8 + rand() * 4) + end + end + + try + count = 0 + while isopen(c) + io = Transaction(c) + handle_transaction(f, io; final_transaction=(count == reuse_limit), + kw...) + if count == reuse_limit + close(c) + end + count += 1 + end + finally + wait_for_timeout[] = false + end + return +end + + +""" +Create a `HTTP.Stream` and parse the Request headers from a `HTTP.Transaction`. +If there is a parse error, send an error Response. +Otherwise, execute stream processing function `f`. +""" + +function handle_transaction(f::Function, t::Transaction; + final_transaction::Bool=false, + verbose::Bool=false, kw...) + + request = HTTP.Request() + http = Streams.Stream(request, ConnectionPool.getparser(t), t) + + try + startread(http) + catch e + if e isa EOFError && !messagestarted(http.parser) + return + elseif e isa HTTP.ParsingError + @error e + status = e.code == :HPE_INVALID_VERSION ? 505 : + e.code == :HPE_INVALID_METHOD ? 405 : 400 + write(t, Response(status, body = HTTP.Parsers.ERROR_MESSAGES[e.code])) + close(t) + return + elseif e isa HeaderSizeError + write(t, Response(413)) + close(t) + return + else + rethrow(e) + end + end + + if verbose + @info http.message + end + + response = request.response + response.status = 200 + if final_transaction || hasheader(request, "Connection", "close") + setheader(response, "Connection" => "close") + end + + @async try + handle_stream(f, http) + catch e + if isioerror(e) + @warn e + else + @error e + end + close(t) + end + return +end + + +""" +Execute stream processing function `f`. +If there is an error and the stream is still open, +send a 500 response with the error message. + +Close the `Stream` for read and write (in case `f` has not already done so). +""" + +function handle_stream(f::Function, http::Stream) + + try + f(http) + catch e + if isopen(http) && !iswritable(http) + @error e + http.message.response.status = 500 + startwrite(http) + write(http, sprint(showerror, e)) + else + rethrow(e) + end + end + + closeread(http) + closewrite(http) + return +end + + +end # module diff --git a/src/StreamRequest.jl b/src/StreamRequest.jl new file mode 100644 index 000000000..fa431db29 --- /dev/null +++ b/src/StreamRequest.jl @@ -0,0 +1,127 @@ +module StreamRequest + +import ..Layer, ..request +using ..IOExtras +using ..Parsers +using ..Messages +using ..Streams +import ..ConnectionPool +using ..MessageRequest +import ..@debug, ..DEBUG_LEVEL, ..printlncompact + + +""" + request(StreamLayer, ::IO, ::Request, body) -> HTTP.Response + +Create a [`Stream`](@ref) to send a `Request` and `body` to an `IO` +stream and read the response. + +Sens the `Request` body in a background task and begins reading the response +immediately so that the transmission can be aborted if the `Response` status +indicates that the server does not wish to receive the message body. +[RFC7230 6.5](https://tools.ietf.org/html/rfc7230#section-6.5). +""" + +abstract type StreamLayer <: Layer end +export StreamLayer + +function request(::Type{StreamLayer}, io::IO, request::Request, body; + response_stream=nothing, + iofunction=nothing, + verbose::Int=0, + kw...)::Response + + verbose == 1 && printlncompact(request) + verbose >= 2 && println(request) + + response = request.response + http = Stream(response, ConnectionPool.getparser(io), io) + startwrite(http) + + aborted = false + try + + @sync begin + if iofunction == nothing + @async writebody(http, request, body) + yield() + startread(http) + readbody(http, response, response_stream) + else + iofunction(http) + end + + if isaborted(http) + close(io) + aborted = true + end + end + + catch e + if aborted && + e isa CompositeException && + (ex = first(e.exceptions).ex; isioerror(ex)) + @debug 1 "⚠️ $(response.status) abort exception excpeted: $ex" + else + rethrow(e) + end + end + + closewrite(http) + closeread(http) + + verbose == 1 && printlncompact(response) + verbose >= 2 && println(response) + + return response +end + + +function writebody(http::Stream, req::Request, body) + + if req.body === body_is_a_stream + writebodystream(http, req, body) + closebody(http) + else + write(http, req.body) + end + + if isidempotent(req) + closewrite(http) + else + @debug 2 "🔒 $(req.method) non-idempotent, " * + "holding write lock: $(http.stream)" + # "A user agent SHOULD NOT pipeline requests after a + # non-idempotent method, until the final response + # status code for that method has been received" + # https://tools.ietf.org/html/rfc7230#section-6.3.2 + end +end + +function writebodystream(http, req, body) + for chunk in body + writechunk(http, req, chunk) + end +end + +function writebodystream(http, req, body::IO) + req.body = body_was_streamed + write(http, body) +end + +writechunk(http, req, body::IO) = writebodystream(http, req, body) +writechunk(http, req, body) = write(http, body) + + +function readbody(http::Stream, res::Response, response_stream) + if response_stream == nothing + res.body = read(http) + else + res.body = body_was_streamed + write(response_stream, http) + close(response_stream) + end +end + + +end # module StreamRequest diff --git a/src/Streams.jl b/src/Streams.jl new file mode 100644 index 000000000..186f0d146 --- /dev/null +++ b/src/Streams.jl @@ -0,0 +1,306 @@ +module Streams + +export Stream, closebody, isaborted, + header, hasheader, + setstatus, setheader + +import ..HTTP +using ..IOExtras +using ..Parsers +using ..Messages +import ..Messages: header, hasheader, setheader, + writeheaders, writestartline +import ..ConnectionPool: getrawstream, byteview +import ..@require, ..precondition_error +import ..@ensure, ..postcondition_error +import ..@debug, ..DEBUG_LEVEL + + +mutable struct Stream{M <: Message, S <: IO} <: IO + message::M + parser::Parser + stream::S + writechunked::Bool + readchunked::Bool + ntoread::Int +end + + +""" + Stream(::IO, ::Request, ::Parser) + +Creates a `HTTP.Stream` that wraps an existing `IO` stream. + + - `startwrite(::Stream)` sends the `Request` headers to the `IO` stream. + - `write(::Stream, body)` sends the `body` (or a chunk of the body). + - `closewrite(::Stream)` sends the final `0` chunk (if needed) and calls + `closewrite` on the `IO` stream. When the `IO` stream is a + [`HTTP.ConnectionPool.Transaction`](@ref), calling `closewrite` releases + the [`HTTP.ConnectionPool.Connection`](@ref) back into the pool for use by the + next pipelined request. + + - `startread(::Stream)` calls `startread` on the `IO` stream then + reads and parses the `Response` headers. When the `IO` stream is a + [`HTTP.ConnectionPool.Transaction`](@ref), calling `startread` waits for other + pipelined responses to be read from the [`HTTP.ConnectionPool.Connection`](@ref). + - `eof(::Stream)` and `readavailable(::Stream)` parse the body from the `IO` + stream. + - `closeread(::Stream)` reads the trailers and calls `closeread` on the `IO` + stream. When the `IO` stream is a [`HTTP.ConnectionPool.Transaction`](@ref), + calling `closeread` releases the readlock and allows the next pipelined + response to be read by another `Stream` that is waiting in `startread`. + If the `Parser` has not recieved a complete response, `closeread` throws + an `EOFError`. +""" + +Stream(r::M, p::Parser, io::S) where {M, S} = + Stream{M,S}(r, p, io, false, false, 0) + +header(http::Stream, a...) = header(http.message, a...) +setstatus(http::Stream, status) = (http.message.response.status = status) +setheader(http::Stream, a...) = setheader(http.message.response, a...) +getrawstream(http::Stream) = getrawstream(http.stream) + +IOExtras.isopen(http::Stream) = isopen(http.stream) + +# Writing HTTP Messages + +messagetowrite(http::Stream{Response}) = http.message.request +messagetowrite(http::Stream{Request}) = http.message.response + + +IOExtras.iswritable(http::Stream) = iswritable(http.stream) + +function IOExtras.startwrite(http::Stream) + if !iswritable(http.stream) + startwrite(http.stream) + end + m = messagetowrite(http) + if !hasheader(m, "Content-Length") && + !hasheader(m, "Transfer-Encoding") && + !hasheader(m, "Upgrade") + http.writechunked = true + setheader(m, "Transfer-Encoding" => "chunked") + else + http.writechunked = ischunked(m) + end + writeheaders(http.stream, m) +end + + +function Base.unsafe_write(http::Stream, p::Ptr{UInt8}, n::UInt) + if !iswritable(http) && isopen(http.stream) + startwrite(http) + end + if !http.writechunked + return unsafe_write(http.stream, p, n) + end + return write(http.stream, hex(n), "\r\n") + + unsafe_write(http.stream, p, n) + + write(http.stream, "\r\n") +end + + +""" + closebody(::Stream) + +Write the final `0` chunk if needed. +""" + +function closebody(http::Stream) + if http.writechunked + write(http.stream, "0\r\n\r\n") + http.writechunked = false + end +end + + +function IOExtras.closewrite(http::Stream{Response}) + if !iswritable(http) + return + end + closebody(http) + closewrite(http.stream) +end + +function IOExtras.closewrite(http::Stream{Request}) + @require iswritable(http) + + closebody(http) + closewrite(http.stream) + + if hasheader(http.message, "Connection", "close") || + http.message.version < v"1.1" && + !hasheader(http.message, "Connection", "keep-alive") + + @debug 1 "✋ \"Connection: close\": $(http.stream)" + close(http.stream) + end +end + + +# Reading HTTP Messages + +IOExtras.isreadable(http::Stream) = isreadable(http.stream) + +function IOExtras.startread(http::Stream) + + startread(http.stream) + + reset!(http.parser) + readheaders(http.stream, http.parser, http.message) + handle_continue(http) + + http.readchunked = ischunked(http.message) + http.ntoread = bodylength(http.message) + + return http.message +end + + +""" +100 Continue +https://tools.ietf.org/html/rfc7230#section-5.6 +https://tools.ietf.org/html/rfc7231#section-6.2.1 +""" + +function handle_continue(http::Stream{Response}) + if http.message.status == 100 + @debug 1 "✅ Continue: $(http.stream)" + reset!(http.parser) + readheaders(http.stream, http.parser, http.message) + end + +end + +function handle_continue(http::Stream{Request}) + if hasheader(http.message, "Expect", "100-continue") + if !iswritable(http.stream) + startwrite(http.stream) + end + @debug 1 "✅ Continue: $(http.stream)" + writeheaders(http.stream, Response(100)) + end +end + + +function Base.eof(http::Stream) + if !headerscomplete(http.message) + startread(http) + end + if http.ntoread == 0 + return true + end + if eof(http.stream) + return true + end + return false +end + + +function Base.readavailable(http::Stream)::ByteView + @require headerscomplete(http.message) + + if http.ntoread == 0 + return nobytes + end + if nb_available(http.stream) > http.ntoread + bytes = byteview(read(http.stream, http.ntoread)) + else + bytes = readavailable(http.stream) + end + l = length(bytes) + if l == 0 + return nobytes + end + if http.readchunked + bytes, excess = parsebody(http.parser, bytes) + unread!(http, excess) + if bodycomplete(http.parser) + http.ntoread = 0 + end + end + if http.ntoread != unknown_length + http.ntoread -= length(bytes) + end + @ensure http.ntoread >= 0 + return bytes +end + + +IOExtras.unread!(http::Stream, excess) = unread!(http.stream, excess) + + +function Base.read(http::Stream) + buf = IOBuffer() + write(buf, http) + return take!(buf) +end + + +""" + isaborted(::Stream{Response}) + +Has the server signaled that it does not wish to receive the message body? + +"If [the response] indicates the server does not wish to receive the + message body and is closing the connection, the client SHOULD + immediately cease transmitting the body and close the connection." +[RFC7230, 6.5](https://tools.ietf.org/html/rfc7230#section-6.5) +""" + +function isaborted(http::Stream{Response}) + + if iswritable(http.stream) && + iserror(http.message) && + hasheader(http.message, "Connection", "close") + @debug 1 "✋ Abort on $(sprint(writestartline, http.message)): " * + "$(http.stream)" + @debug 2 "✋ $(http.message)" + return true + end + return false +end + + +function IOExtras.closeread(http::Stream{Response}) + + # Discard body bytes that were not read... + while !eof(http) + readavailable(http) + end + + # Read trailers... + if bodycomplete(http.parser) && !messagecomplete(http.parser) + readtrailers(http.stream, http.parser, http.message) + end + + if http.ntoread != unknown_length && http.ntoread > 0 + # Error if Message is not complete... + close(http.stream) + throw(EOFError()) + elseif hasheader(http.message, "Connection", "close") + # Close conncetion if server sent "Connection: close"... + @debug 1 "✋ \"Connection: close\": $(http.stream)" + close(http.stream) + elseif isreadable(http.stream) + closeread(http.stream) + end + + return http.message +end + + +function IOExtras.closeread(http::Stream{Request}) + if http.ntoread != unknown_length && http.ntoread > 0 + # Error if Message is not complete... + close(http.stream) + throw(EOFError()) + end + if isreadable(http) + closeread(http.stream) + end +end + + +end #module Streams diff --git a/src/Strings.jl b/src/Strings.jl new file mode 100644 index 000000000..53cb08163 --- /dev/null +++ b/src/Strings.jl @@ -0,0 +1,68 @@ +module Strings + +export escapehtml, tocameldash!, iso8859_1_to_utf8 + +""" +escapeHTML(i::String) + +Returns a string with special HTML characters escaped: &, <, >, ", ' +""" + +function escapehtml(i::AbstractString) + # Refer to http://stackoverflow.com/a/7382028/3822752 for spec. links + o = replace(i, "&", "&") + o = replace(o, "\"", """) + o = replace(o, "'", "'") + o = replace(o, "<", "<") + o = replace(o, ">", ">") + return o +end + + +""" + tocameldash!(s::String) + +Ensure the first character and characters that follow a '-' are uppercase. +""" + +function tocameldash!(s::String) + toUpper = UInt8('A') - UInt8('a') + bytes = Vector{UInt8}(s) + upper = true + for i = 1:length(bytes) + @inbounds b = bytes[i] + if upper + islower(b) && (bytes[i] = b + toUpper) + else + isupper(b) && (bytes[i] = lower(b)) + end + upper = b == UInt8('-') + end + return s +end + +@inline islower(b::UInt8) = UInt8('a') <= b <= UInt8('z') +@inline isupper(b::UInt8) = UInt8('A') <= b <= UInt8('Z') +@inline lower(c::UInt8) = c | 0x20 + + +""" + iso8859_1_to_utf8(bytes) + +Convert from ISO8859_1 to UTF8. +""" + +function iso8859_1_to_utf8(bytes::Vector{UInt8}) + io = IOBuffer() + for b in bytes + if b < 0x80 + write(io, b) + else + write(io, 0xc0 | b >> 6) + write(io, 0x80 | b & 0x3f) + end + end + return String(take!(io)) +end + +end # module Strings diff --git a/src/TimeoutRequest.jl b/src/TimeoutRequest.jl new file mode 100644 index 000000000..39a17a309 --- /dev/null +++ b/src/TimeoutRequest.jl @@ -0,0 +1,39 @@ +module TimeoutRequest + +import ..Layer, ..request +using ..ConnectionPool +import ..@debug, ..DEBUG_LEVEL + + +""" + request(TimeoutLayer, ::IO, ::Request, body) -> HTTP.Response + +Close `IO` if no data has been received for `timeout` seconds. +""" + +abstract type TimeoutLayer{Next <: Layer} <: Layer end +export TimeoutLayer + +function request(::Type{TimeoutLayer{Next}}, io::IO, req, body; + readtimeout::Int=60, kw...) where Next + + wait_for_timeout = Ref{Bool}(true) + + @async while wait_for_timeout[] + if isreadable(io) && inactiveseconds(io) > readtimeout + close(io) + @debug 1 "💥 Read inactive > $(readtimeout)s: $io" + break + end + sleep(8 + rand() * 4) + end + + try + return request(Next, io, req, body; kw...) + finally + wait_for_timeout[] = false + end +end + + +end # module TimeoutRequest diff --git a/src/URIs.jl b/src/URIs.jl new file mode 100644 index 000000000..b116d636a --- /dev/null +++ b/src/URIs.jl @@ -0,0 +1,277 @@ +module URIs + +import Base.== + +import ..@require, ..precondition_error +import ..@ensure, ..postcondition_error + + +include("urlparser.jl") + +export URI, + resource, queryparams, absuri, + escapeuri, unescapeuri, escapepath + + +""" + HTTP.URI(; scheme="", host="", port="", etc...) + HTTP.URI(str) = parse(HTTP.URI, str::String) + +A type representing a valid uri. Can be constructed from distinct parts using the various +supported keyword arguments. With a raw, already-encoded uri string, use `parse(HTTP.URI, str)` +to parse the `HTTP.URI` directly. The `HTTP.URI` constructors will automatically escape any provided +`query` arguments, typically provided as `"key"=>"value"::Pair` or `Dict("key"=>"value")`. +Note that multiple values for a single query key can provided like `Dict("key"=>["value1", "value2"])`. + +The `URI` struct stores the compelte URI in the `uri::String` field and the +component parts in the following `SubString` fields: + * `scheme`, e.g. `"http"` or `"https"` + * `userinfo`, e.g. `"username:password"` + * `host` e.g. `"julialang.org"` + * `port` e.g. `"80"` or `""` + * `path` e.g `"/"` + * `query` e.g. `"Foo=1&Bar=2"` + * `fragment` + +The `HTTP.resource(::URI)` function returns a target-resource string for the URI +[RFC7230 5.3](https://tools.ietf.org/html/rfc7230#section-5.3). +e.g. `"\$path?\$query#\$fragment"`. + +The `HTTP.queryparams(::URI)` function returns a `Dict` containing the `query`. +""" + +struct URI + uri::String + scheme::SubString + userinfo::SubString + host::SubString + port::SubString + path::SubString + query::SubString + fragment::SubString +end + + +URI(uri::URI) = uri + +const emptyuri = (()->begin + uri = "" + empty = SubString(uri) + return URI(uri, empty, empty, empty, empty, empty, empty, empty) +end)() + +URI(;kw...) = merge(emptyuri; kw...) + +function Base.merge(uri::URI; scheme::AbstractString=uri.scheme, + userinfo::AbstractString=uri.userinfo, + host::AbstractString=uri.host, + port::Union{Integer,AbstractString}=uri.port, + path::AbstractString=uri.path, + query=uri.query, + fragment::AbstractString=uri.fragment) + + @require isempty(host) || host[end] != '/' + @require scheme in uses_authority || isempty(host) + @require !isempty(host) || isempty(port) + @require !(scheme in ["http", "https"]) || isempty(path) || path[1] == '/' + @require !isempty(path) || !isempty(query) || isempty(fragment) + + ports = string(port) + querys = query isa String ? query : escapeuri(query) + + str = uristring(scheme, userinfo, host, ports, path, querys, fragment) + result = parse(URI, str) + + if uri === emptyuri + @ensure result.scheme == scheme + @ensure result.userinfo == userinfo + @ensure result.host == host + @ensure result.port == ports + @ensure result.path == path + @ensure result.query == querys + end + + return result +end + + +URI(str::AbstractString) = Base.parse(URI, str) + +function Base.parse(::Type{URI}, str::AbstractString) + + uri = http_parser_parse_url(str) + + @ensure uristring(uri) == str + return uri +end + + +==(a::URI,b::URI) = a.scheme == b.scheme && + a.host == b.host && + normalport(a) == normalport(b) && + a.path == b.path && + a.query == b.query && + a.fragment == b.fragment && + a.userinfo == b.userinfo + +# "request-target" per https://tools.ietf.org/html/rfc7230#section-5.3 +resource(uri::URI) = string( isempty(uri.path) ? "/" : uri.path, + !isempty(uri.query) ? "?" : "", uri.query, + !isempty(uri.fragment) ? "#" : "", uri.fragment) + +normalport(uri::URI) = uri.scheme == "http" && uri.port == "80" || + uri.scheme == "https" && uri.port == "443" ? + "" : uri.port + +hoststring(h) = ':' in h ? "[$h]" : h + +Base.show(io::IO, uri::URI) = print(io, "HTTP.URI(\"", uri, "\")") + +Base.print(io::IO, u::URI) = print(io, u.uri) + +Base.string(u::URI) = u.uri + +nouserinfo(ui) = isempty(ui) && !(ui === blank_userinfo) + +function formaturi(io::IO, + scheme::AbstractString, + userinfo::AbstractString, + host::AbstractString, + port::AbstractString, + path::AbstractString, + query::AbstractString, + fragment::AbstractString) + + isempty(scheme) || print(io, scheme, scheme in uses_authority ? + "://" : ":") + nouserinfo(userinfo) || print(io, userinfo, "@") + isempty(host) || print(io, hoststring(host)) + isempty(port) || print(io, ":", port) + isempty(path) || print(io, path) + isempty(query) || print(io, "?", query) + isempty(fragment) || print(io, "#", fragment) + + return io +end + +uristring(a...) = String(take!(formaturi(IOBuffer(), a...))) + +uristring(u::URI) = uristring(u.scheme, u.userinfo, u.host, u.port, + u.path, u.query, u.fragment) + +queryparams(uri::URI) = queryparams(uri.query) + +function queryparams(q::AbstractString) + Dict(unescapeuri(k) => unescapeuri(v) + for (k,v) in ([split(e, "=")..., ""][1:2] + for e in split(q, "&", keep=false))) +end + + +# Validate known URI formats +const uses_authority = ["https", "http", "ws", "wss", "hdfs", "ftp", "gopher", "nntp", "telnet", "imap", "wais", "file", "mms", "shttp", "snews", "prospero", "rtsp", "rtspu", "rsync", "svn", "svn+ssh", "sftp" ,"nfs", "git", "git+ssh", "ldap", "s3"] +const non_hierarchical = ["gopher", "hdl", "mailto", "news", "telnet", "wais", "imap", "snews", "sip", "sips"] +const uses_query = ["http", "wais", "imap", "https", "shttp", "mms", "gopher", "rtsp", "rtspu", "sip", "sips", "ldap"] +const uses_fragment = ["hdfs", "ftp", "hdl", "http", "gopher", "news", "nntp", "wais", "https", "shttp", "snews", "file", "prospero"] + +"checks if a `HTTP.URI` is valid" +function Base.isvalid(uri::URI) + sch = uri.scheme + isempty(sch) && throw(ArgumentError("can not validate relative URI")) + if ((sch in non_hierarchical) && (search(uri.path, '/') > 1)) || # path hierarchy not allowed + (!(sch in uses_query) && !isempty(uri.query)) || # query component not allowed + (!(sch in uses_fragment) && !isempty(uri.fragment)) || # fragment identifier component not allowed + (!(sch in uses_authority) && (!isempty(uri.host) || ("" != uri.port) || !isempty(uri.userinfo))) # authority component not allowed + return false + end + return true +end + + +# RFC3986 Unreserved Characters (and '~' Unsafe per RFC1738). +@inline issafe(c::Char) = c == '-' || + c == '.' || + c == '_' || + (isascii(c) && isalnum(c)) + +utf8_chars(str::AbstractString) = (Char(c) for c in Vector{UInt8}(str)) + +"percent-encode a string, dict, or pair for a uri" +function escapeuri end + +escapeuri(c::Char) = string('%', uppercase(hex(c,2))) +escapeuri(str::AbstractString, safe::Function=issafe) = + join(safe(c) ? c : escapeuri(c) for c in utf8_chars(str)) + +escapeuri(bytes::Vector{UInt8}) = bytes +escapeuri(v::Number) = escapeuri(string(v)) +escapeuri(v::Symbol) = escapeuri(string(v)) +@static if VERSION < v"0.7.0-DEV.3017" +escapeuri(v::Nullable) = Base.isnull(v) ? "" : escapeuri(get(v)) +end + +escapeuri(key, value) = string(escapeuri(key), "=", escapeuri(value)) +escapeuri(key, values::Vector) = escapeuri(key => v for v in values) +escapeuri(query) = join((escapeuri(k, v) for (k,v) in query), "&") + +"unescape a percent-encoded uri/url" +function unescapeuri(str) + contains(str, "%") || return str + out = IOBuffer() + i = 1 + while !done(str, i) + c, i = next(str, i) + if c == '%' + c1, i = next(str, i) + c, i = next(str, i) + write(out, Base.parse(UInt8, string(c1, c), 16)) + else + write(out, c) + end + end + return String(take!(out)) +end + +ispathsafe(c::Char) = c == '/' || issafe(c) +escapepath(path) = escapeuri(path, ispathsafe) + + +""" +Splits the path into components +See: http://tools.ietf.org/html/rfc3986#section-3.3 +""" +function splitpath(p::AbstractString) + elems = String[] + len = length(p) + len > 1 || return elems + start_ind = i = ifelse(p[1] == '/', 2, 1) + while true + c = p[i] + if c == '/' + push!(elems, p[start_ind:i-1]) + start_ind = i + 1 + elseif i == len + push!(elems, p[start_ind:i]) + end + i += 1 + (i > len || c in ('?', '#')) && break + end + return elems +end + +absuri(u, context) = absuri(URI(u), URI(context)) + +function absuri(uri::URI, context::URI) + + if !isempty(uri.host) + return uri + end + + @assert !isempty(context.scheme) + @assert !isempty(context.host) + @assert isempty(uri.port) + + return merge(context; path=uri.path, query=uri.query) +end + +end # module diff --git a/src/WebSockets.jl b/src/WebSockets.jl new file mode 100644 index 000000000..8ebc4fbe3 --- /dev/null +++ b/src/WebSockets.jl @@ -0,0 +1,296 @@ +module WebSockets + +using ..Base64 +using MbedTLS: digest, MD_SHA1, SSLContext +import ..HTTP +using ..IOExtras +using ..Streams +import ..ConnectionPool +using HTTP.header +import ..@debug, ..DEBUG_LEVEL, ..@require, ..precondition_error + + + +const WS_FINAL = 0x80 +const WS_CONTINUATION = 0x00 +const WS_TEXT = 0x01 +const WS_BINARY = 0x02 +const WS_CLOSE = 0x08 +const WS_PING = 0x09 +const WS_PONG = 0x0A + +const WS_MASK = 0x80 + + +struct WebSocketError <: Exception + status::Int16 + message::String +end + + +struct WebSocketHeader + opcode::UInt8 + final::Bool + length::UInt + hasmask::Bool + mask::UInt32 +end + + +mutable struct WebSocket{T <: IO} <: IO + io::T + frame_type::UInt8 + server::Bool + rxpayload::Vector{UInt8} + txpayload::Vector{UInt8} + txclosed::Bool + rxclosed::Bool +end + +function WebSocket(io::T; server=false, binary=false) where T <: IO + WebSocket{T}(io, binary ? WS_BINARY : WS_TEXT, server, + UInt8[], UInt8[], false, false) +end + + + +# Handshake + + +function check_upgrade(http) + + if !hasheader(http, "Upgrade", "websocket") + throw(WebSocketError(0, "Expected \"Upgrade: websocket\"!\n" * + "$(http.message)")) + end + + if !hasheader(http, "Connection", "upgrade") + throw(WebSocketError(0, "Expected \"Connection: upgrade\"!\n" * + "$(http.message)")) + end +end + + +function accept_hash(key) + hashkey = "$(key)258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + return base64encode(digest(MD_SHA1, hashkey)) +end + + +function open(f::Function, url; binary=false, verbose=false, kw...) + + key = base64encode(rand(UInt8, 16)) + + headers = [ + "Upgrade" => "websocket", + "Connection" => "Upgrade", + "Sec-WebSocket-Key" => key, + "Sec-WebSocket-Version" => "13" + ] + + HTTP.open("GET", url, headers; + reuse_limit=0, verbose=verbose ? 2 : 0, kw...) do http + + startread(http) + + status = http.message.status + if status != 101 + return + end + + check_upgrade(http) + + if header(http, "Sec-WebSocket-Accept") != accept_hash(key) + throw(WebSocketError(0, "Invalid Sec-WebSocket-Accept\n" * + "$(http.message)")) + end + + io = ConnectionPool.getrawstream(http) + f(WebSocket(io; binary=binary)) + end +end + + +function listen(f::Function, + host::String="localhost", port::UInt16=UInt16(8081); + binary=false, verbose=false, kw...) + + HTTP.listen(host, port; verbose=verbose) do http + + check_upgrade(http) + if !hasheader(http, "Sec-WebSocket-Version", "13") + throw(WebSocketError(0, "Expected \"Sec-WebSocket-Version: 13\"!\n" * + "$(http.message)")) + end + + setstatus(http, 101) + setheader(http, "Upgrade" => "websocket") + setheader(http, "Connection" => "Upgrade") + key = header(http, "Sec-WebSocket-Key") + setheader(http, "Sec-WebSocket-Accept" => accept_hash(key)) + + startwrite(http) + + io = ConnectionPool.getrawstream(http) + f(WebSocket(io; binary=binary, server=true)) + end +end + + +# Sending Frames + + +function Base.unsafe_write(ws::WebSocket, p::Ptr{UInt8}, n::UInt) + return wswrite(ws, unsafe_wrap(Array, p, n)) +end + + +function Base.write(ws::WebSocket, x1, x2, xs...) + local n::Int = 0 + n += wswrite(ws, ws.frame_type, x1) + xs = (x2, xs...) + l = length(xs) + for i in 1:l + n += wswrite(ws, i == l ? WS_FINAL : WS_CONTINUATION, xs[i]) + end + return n +end + + +function IOExtras.closewrite(ws::WebSocket) + @require !ws.txclosed + opcode = WS_FINAL | WS_CLOSE + @debug 1 "WebSocket ⬅️ $(WebSocketHeader(opcode, 0x00))" + write(ws.io, opcode, 0x00) + ws.txclosed = true +end + + +wslength(l) = l < 0x7E ? (UInt8(l), UInt8[]) : + l <= 0xFFFF ? (0x7E, reinterpret(UInt8, [hton(UInt16(l))])) : + (0x7F, reinterpret(UInt8, [hton(UInt64(l))])) + + +wswrite(ws::WebSocket, x) = wswrite(ws, WS_FINAL | ws.frame_type, x) + +wswrite(ws::WebSocket, opcode::UInt8, x) = wswrite(ws, opcode, Vector{UInt8}(x)) + +function wswrite(ws::WebSocket, opcode::UInt8, bytes::Vector{UInt8}) + + n = length(bytes) + len, extended_len = wslength(n) + len |= WS_MASK + mask = mask!(ws.txpayload, bytes, n) + + @debug 1 "WebSocket ⬅️ $(WebSocketHeader(opcode, len, extended_len, mask))" + write(ws.io, opcode, len, extended_len, mask) + + @debug 2 " ⬅️ $(ws.txpayload[1:n])" + unsafe_write(ws.io, pointer(ws.txpayload), n) +end + + +function mask!(to, from, l, mask=rand(UInt8, 4)) + if length(to) < l + resize!(to, l) + end + for i in 1:l + to[i] = from[i] ⊻ mask[((i-1) % 4)+1] + end + return mask +end + + +function Base.close(ws::WebSocket) + if !ws.txclosed + closewrite(ws) + end + while !ws.rxclosed + readframe(ws) + end +end + + +Base.isopen(ws::WebSocket) = !ws.rxclosed + + + +# Receiving Frames + +Base.eof(ws::WebSocket) = eof(ws.io) + +Base.readavailable(ws::WebSocket) = collect(readframe(ws)) + + +function readheader(io::IO) + b = UInt8[0,0] + read!(io, b) + len = b[2] & ~WS_MASK + WebSocketHeader( + b[1] & 0x0F, + b[1] & WS_FINAL > 0, + len == 0x7F ? UInt(ntoh(read(io, UInt64))) : + len == 0x7E ? UInt(ntoh(read(io, UInt16))) : UInt(len), + b[2] & WS_MASK > 0, + b[2] & WS_MASK > 0 ? read(io, UInt32) : UInt32(0)) +end + + +function readframe(ws::WebSocket) + h = readheader(ws.io) + @debug 1 "WebSocket ➡️ $h" + + if h.length > 0 + if length(ws.rxpayload) < h.length + resize!(ws.rxpayload, h.length) + end + unsafe_read(ws.io, pointer(ws.rxpayload), h.length) + @debug 2 " ➡️ \"$(String(ws.rxpayload[1:h.length]))\"" + end + + if h.opcode == WS_CLOSE + ws.rxclosed = true + if h.length >= 2 + status = UInt16(ws.rxpayload[1]) << 8 | ws.rxpayload[2] + if status != 1000 + message = String(ws.rxpayload[3:h.length]) + throw(WebSocketError(status, message)) + end + end + return UInt8[] + elseif h.opcode == WS_PING + write(ws.io, [WS_PONG, 0x00]) + wswrite(ws, WS_FINAL | WS_PONG, ws.rxpayload) + return readframe(ws) + else + l = Int(h.length) + if h.hasmask + mask!(ws.rxpayload, ws.rxpayload, l, reinterpret(UInt8, [h.mask])) + end + return view(ws.rxpayload, 1:l) + end +end + +function WebSocketHeader(bytes...) + io = IOBuffer() + write(io, bytes...) + seek(io, 0) + return readheader(io) +end + +function Base.show(io::IO, h::WebSocketHeader) + print(io, "WebSocketHeader(", + h.opcode == WS_CONTINUATION ? "CONTINUATION" : + h.opcode == WS_TEXT ? "TEXT" : + h.opcode == WS_BINARY ? "BINARY" : + h.opcode == WS_CLOSE ? "CLOSE" : + h.opcode == WS_PING ? "PING" : + h.opcode == WS_PONG ? "PONG" : h.opcode, + h.final ? " | FINAL, " : ", ", + h.length > 0 ? "$(Int(h.length))-byte payload" : "", + h.hasmask ? ", mask = $(hex(h.mask))" : "", + ")") +end + + +end # module WebSockets diff --git a/src/client.jl b/src/client.jl index 710d250fd..d7c838172 100644 --- a/src/client.jl +++ b/src/client.jl @@ -1,30 +1,10 @@ -@enum ConnectionState Busy Idle Dead +using .Pairs -""" - HTTP.Connection - -Represents a persistent client connection to a remote host; only created -when a server response includes the "Connection: keep-alive" header. An open and non-idle connection -will be reused when sending subsequent requests to the same host. -""" -mutable struct Connection{I <: IO} - id::Int - socket::I - state::ConnectionState - parser::Parser -end - -Connection(tcp::IO) = Connection(0, tcp, Busy, Parser()) -Connection(id::Int, tcp::IO) = Connection(id, tcp, Busy, Parser()) -busy!(conn::Connection) = (conn.state == Dead || (conn.state = Busy); return nothing) -idle!(conn::Connection) = (conn.state == Dead || (conn.state = Idle); return nothing) -dead!(conn::Connection) = (conn.state == Dead || (conn.state = Dead; close(conn.socket)); return nothing) """ - HTTP.Client([logger::IO]; args...) + HTTP.Client(;args...) A type to facilitate connections to remote hosts, send HTTP requests, and manage state between requests. -Takes an optional `logger` IO argument where client activity is recorded (defaults to `STDOUT`). Additional keyword arguments can be passed that will get transmitted with each HTTP request: * `chunksize::Int`: if a request body is larger than `chunksize`, the "chunked-transfer" http mechanism will be used and chunks will be sent no larger than `chunksize`; default = `nothing` @@ -42,481 +22,137 @@ Additional keyword arguments can be passed that will get transmitted with each H * `logbody::Bool`: whether the request body should be logged when `verbose=true` is passed; default = `true` """ mutable struct Client - # connection pools for keep-alive; key is host - poollock::ReentrantLock - httppool::Dict{String, Vector{Connection{TCPSocket}}} - httpspool::Dict{String, Vector{Connection{TLS.SSLContext}}} # cookies are stored in-memory per host and automatically sent when appropriate cookies::Dict{String, Set{Cookie}} - # buffer::Vector{UInt8} #TODO: create a fixed size buffer for reading bytes off the wire and having http_parser use, this should keep allocations down, need to make sure MbedTLS supports blocking readbytes! - logger::Option{IO} # global request settings - options::RequestOptions - connectioncount::Int -end - -Client(logger::Option{IO}, options::RequestOptions) = Client(ReentrantLock(), - Dict{String, Vector{Connection{TCPSocket}}}(), - Dict{String, Vector{Connection{TLS.SSLContext}}}(), - Dict{String, Set{Cookie}}(), - logger, options, 1) - -# this is where we provide all the default request options -const DEFAULT_OPTIONS = :((nothing, true, Inf, Inf, nothing, 5, true, false, 3, true, true, false, true, true)) - -@eval begin - Client(logger::Option{IO}; args...) = Client(logger, RequestOptions($(DEFAULT_OPTIONS)...; args...)) - Client(; args...) = Client(nothing, RequestOptions($(DEFAULT_OPTIONS)...; args...)) + options::(VERSION > v"0.7.0-DEV.2338" ? NamedTuple : Vector{Pair{Symbol,Any}}) end -function setclient!(client::Client) - global const DEFAULT_CLIENT = client -end - -Base.haskey(::Type{http}, client, host) = haskey(client.httppool, host) -Base.haskey(::Type{https}, client, host) = haskey(client.httpspool, host) - -getconnections(::Type{http}, client, host) = client.httppool[host] -getconnections(::Type{https}, client, host) = client.httpspool[host] - -setconnection!(::Type{http}, client, host, conn) = push!(get!(client.httppool, host, Connection[]), conn) -setconnection!(::Type{https}, client, host, conn) = push!(get!(client.httpspool, host, Connection[]), conn) - -backtrace() = sprint(Base.show_backtrace, catch_backtrace()) - -""" -Abstract error type that all other HTTP errors subtype, including: - - * `HTTP.ConnectError`: thrown if a valid connection cannot be opened to the requested host/port - * `HTTP.SendError`: thrown if a request is not able to be sent to the server - * `HTTP.ClosedError`: thrown during sending or receiving if the connection to the server has been closed - * `HTTP.ReadError`: thrown if an I/O error occurs when receiving a response from a server - * `HTTP.RedirectError`: thrown if the number of http redirects exceeds the http request option `maxredirects` - * `HTTP.StatusError`: thrown if a non-successful http status code is returned from the server, never thrown if `statusraise=false` is passed as a request option -""" -abstract type HTTPError <: Exception end - -function Base.show(io::IO, e::HTTPError) - println(io, "$(typeof(e)):") - println(io, "Exception: $(e.e)") - print(io, e.msg) -end -"An HTTP error thrown if a valid connection cannot be opened to the requested host/port" -struct ConnectError <: HTTPError - e::Exception - msg::String -end -"An HTTP error thrown if a request is not able to be sent to the server" -struct SendError <: HTTPError - e::Exception - msg::String -end -"An HTTP error thrown during sending or receiving if the connection to the server has been closed" -struct ClosedError <: HTTPError - e::Exception - msg::String -end -"An HTTP error thrown if an I/O error occurs when receiving a response from a server" -struct ReadError <: HTTPError - e::Exception - msg::String -end -"An HTTP error thrown if the number of http redirects exceeds the http request option `maxredirects`" -struct RedirectError <: HTTPError - maxredirects::Int -end -function Base.show(io::IO, err::RedirectError) - print(io, "RedirectError: more than $(err.maxredirects) redirects attempted") -end -"An HTTP error thrown if a non-successful http status code is returned from the server, never thrown if `statusraise=false` is passed as a request option" -struct StatusError <: HTTPError - status::Int - response::Response -end -function Base.show(io::IO, err::StatusError) - print(io, "HTTP.StatusError: received a '$(err.status) - $(Base.get(STATUS_CODES, err.status, "Unknown Code"))' status in response") +if VERSION > v"0.7.0-DEV.2338" +Client(;options...) = Client(Dict{String, Set{Cookie}}(), merge(NamedTuple(), options)) +else +Client(;options...) = Client(Dict{String, Set{Cookie}}(), options) end +global const DEFAULT_CLIENT = Client() -initTLS!(::Type{http}, hostname, opts, socket) = socket +# build Request +function request(client::Client, method, url::URI; + headers::Dict=Dict(), + body="", + enablechunked::Bool=true, + stream::Bool=false, + verbose::Bool=false, + args...) -function initTLS!(::Type{https}, hostname, opts, socket) - stream = TLS.SSLContext() - TLS.setup!(stream, get(opts, :tlsconfig, TLS.SSLConfig(!opts.insecure::Bool))::TLS.SSLConfig) - TLS.associate!(stream, socket) - TLS.hostname!(stream, hostname) - TLS.handshake!(stream) - return stream -end + # Add default values from client options to args... + if VERSION > v"0.7.0-DEV.2338" + args = merge(client.options, args) + getarg = Base.get + else + for option in client.options + defaultbyfirst(args, option) + end + getarg = getkv + end + newargs = Pair{Symbol,Any}[] -function stalebytes!(c::TCPSocket) - !isopen(c) && return - nb_available(c) > 0 && readavailable(c) - return -end -stalebytes!(c::TLS.SSLContext) = stalebytes!(c.bio) + if getarg(args, :chunksize, nothing) != nothing + Base.depwarn( + "The chunksize= option is deprecated and has no effect.\n" * + "Use a HTTP.open and pass chunks of the desired size to `write`.", + :chunksize) + end -function connect(client, sch, hostname, port, opts, verbose) - @lock client.poollock begin - logger = client.logger - if haskey(sch, client, hostname) - @log "checking if any existing connections to '$hostname' are re-usable" - conns = getconnections(sch, client, hostname) - inds = Int[] - i = 1 - while i <= length(conns) - c = conns[i] - # read off any stale bytes left over from a possible error in a previous request - # this will also trigger any sockets that timed out to be set to closed - stalebytes!(c.socket) - if !isopen(c.socket) || c.state == Dead - @log "found dead connection #$(c.id) to delete" - dead!(c) - push!(inds, i) - elseif c.state == Idle - @log "found re-usable connection #$(c.id)" - busy!(c) - try - deleteat!(conns, sort!(unique(inds))) - end - return c - end - i += 1 - end - try - deleteat!(conns, sort!(unique(inds))) - end + if getarg(args, :connecttimeout, Inf) != Inf + Base.depwarn( + "The connecttimeout= is deprecated and has no effect.\n" * + "See https://github.com/JuliaWeb/HTTP.jl/issues/114\n", + :connecttimeout) end - # if no re-usable connection was found, make a new connection - try - # EH: throws DNSError, OutOfMemoryError, or SystemError; retry once, but otherwise, we can't do much - ip = @retry Base.getaddrinfo(hostname) - # EH: throws error, ArgumentError for out-of-range port, UVError; retry if UVError - tcp = @retryif Base.UVError @timeout(opts.connecttimeout::Float64, - Base.connect(ip, Base.parse(Int, port)), error("connect timeout")) - socket = initTLS!(sch, hostname, opts, tcp) - conn = Connection(client.connectioncount, socket) - client.connectioncount += 1 - setconnection!(sch, client, hostname, conn) - @log "created new connection #$(conn.id) to '$hostname'" - return conn - catch e - throw(ConnectError(e, backtrace())) + + if getarg(args, :tlsconfig, nothing) != nothing + Base.depwarn( + "The tlsconfig= option is deprecated. Use sslconfig=::MbedTLS.SSLConfig", + :tlsconfig) + setkv(newargs, :sslconfig, getarg(args, :tlsconfig)) end + + if getarg(args, :allowredirects, nothing) != nothing + Base.depwarn( + "The allowredirects= option is deprecated. Use redirect=::Bool", + :allowredirects) + setkv(newargs, :redirect, getarg(args, :allowredirects)) end -end -function addcookies!(client, host, req, verbose) - logger = client.logger - # check if cookies should be added to outgoing request based on host - if haskey(client.cookies, host) - cookies = client.cookies[host] - tosend = Set{Cookie}() - expired = Set{Cookie}() - for (i, cookie) in enumerate(cookies) - if Cookies.shouldsend(cookie, scheme(uri(req)) == "https", host, path(uri(req))) - cookie.expires != Dates.DateTime() && cookie.expires < Dates.now(Dates.UTC) && (push!(expired, cookie); @log("deleting expired cookie: " * cookie.name); continue) - push!(tosend, cookie) - end - end - setdiff!(client.cookies[host], expired) - if length(tosend) > 0 - @log "adding cached cookies for host to request header: " * join(map(x->x.name, tosend), ", ") - req.headers["Cookie"] = string(Base.get(req.headers, "Cookie", ""), [c for c in tosend]) - end + if getarg(args, :managecookies, nothing) != nothing + Base.depwarn( + "The managecookies= option is deprecated. Use cookies=::Bool", + :managecookies) + setkv(newargs, :cookies, getarg(args, :managecookies)) end -end + setkv(newargs, :cookiejar, client.cookies) -function connectandsend(client, ::Type{sch}, hostname, port, req, opts, verbose) where sch - logger = client.logger - conn = connect(client, sch, hostname, port, opts, verbose) - opts.managecookies::Bool && addcookies!(client, hostname, req, verbose) - try - @log "sending request over the wire\n" - reqstr = string(req, opts) - verbose && (println(client.logger, "HTTP.Request:\n"); println(client.logger, reqstr)) - # EH: throws ArgumentError if socket is closed, UVError; retry if UVError, - @retryif Base.UVError write(conn.socket, reqstr) - !isopen(conn.socket) && throw(CLOSED_ERROR) - catch e - @log backtrace() - typeof(e) <: ArgumentError && throw(ClosedError(e, backtrace())) - throw(SendError(e, backtrace())) + if getarg(args, :statusraise, nothing) != nothing + Base.depwarn( + "The statusraise= options is deprecated. Use status_exception=::Bool", + :statusraise) + setkv(newargs, :status_exception, getarg(args, :statusraise)) end - return conn -end -function redirect(response, client, req, opts, stream, history, retry, verbose) - logger = client.logger - @log "checking for location to redirect" - key = haskey(response.headers, "Location") ? "Location" : "" - if key != "" - push!(history, response) - length(history) > opts.maxredirects::Int && throw(RedirectError(opts.maxredirects::Int)) - newuri = URIs.URL(response.headers[key]) - u = uri(req) - newuri = !isempty(hostname(newuri)) ? newuri : URIs.URI(scheme=scheme(u), hostname=hostname(u), port=port(u), path=path(newuri), query=query(u)) - if opts.forwardheaders::Bool - h = headers(req) - delete!(h, "Host") - delete!(h, "Cookie") - else - h = Headers() - end - redirectreq = Request(req.method, newuri, h, req.body) - @log "redirecting to $(newuri)" - return request(client, redirectreq, opts, stream, history, retry, verbose) + if getarg(args, :insecure, nothing) != nothing + Base.depwarn( + "The insecure= option is deprecated. Use require_ssl_verification=::Bool", + :insecure) + setkv(newargs, :require_ssl_verification, !getarg(args, :insecure)) end -end -const CLOSED_ERROR = ClosedError(ErrorException(""), "error receiving response; connection was closed prematurely") -function getbytes(socket, tm) - try - # EH: returns UInt8[] when socket is closed, error when socket is not readable, AssertionErrors, UVError; - buffer = @retry @timeout(tm, readavailable(socket), error("read timeout")) - return buffer, CLOSED_ERROR - catch e - isa(e, InterruptException) && throw(e) - return UInt8[], ReadError(e, backtrace()) + m = string(method) + h = [k => v for (k,v) in headers] + if stream + setkv(newargs, :response_stream, BufferStream()) end -end -function processresponse!(client, conn, response, host, method, maintask, stream, tm, canonicalizeheaders, verbose) - logger = client.logger - while true - buffer, err = getbytes(conn.socket, tm) - @log "received bytes from the wire, processing" - # EH: throws a couple of "shouldn't get here" errors; probably not much we can do - errno, headerscomplete, messagecomplete, upgrade = HTTP.parse!(response, conn.parser, buffer; host=host, method=method, maintask=maintask, canonicalizeheaders=canonicalizeheaders) - @log "parsed bytes received from wire" - if length(buffer) == 0 && !isopen(conn.socket) && !messagecomplete - @log "socket closed before full response received" - dead!(conn) - close(response.body) - # retry the entire request - return false, err - end - if errno != HPE_OK - dead!(conn) - throw(ParsingError("error parsing response: $(ParsingErrorCodeMap[errno])\nCurrent response buffer contents: $(String(buffer))")) - elseif messagecomplete - http_should_keep_alive(conn.parser, response) || (@log("closing connection (no keep-alive)"); dead!(conn)) - # idle! on a Dead will stay Dead - idle!(conn) - return true, StatusError(status(response), response) - elseif stream && headerscomplete - @log "processing the rest of response asynchronously" - response.body.task = @async processresponse!(client, conn, response, host, method, maintask, false, tm, canonicalizeheaders, false) - return true, StatusError(status(response), response) - end + if isa(body, Dict) + body = HTTP.Form(body) + setbyfirst(h, "Content-Type" => + "multipart/form-data; boundary=$(body.boundary)") + setkv(newargs, :bodylength, length(body)) end -end -function request(client::Client, req::Request, opts::RequestOptions, stream::Bool, history::Vector{Response}, retry::Int, verbose::Bool) - retry = max(0, retry) # ensure non-negative - update!(opts, client.options) - verbose && not(client.logger) && (client.logger = STDOUT) - logger = client.logger - @log "using request options:\n\t" * join((s=>getfield(opts, s) for s in fieldnames(typeof(opts))), "\n\t") - u = uri(req) - host = hostname(u) - sch = scheme(u) == "http" ? http : https - @log "making $(method(req)) request for host: '$host' and resource: '$(resource(u))'" - # maybe allow retrying for all kinds of errors? - p = port(u) - conn = @retryif ClosedError 4 connectandsend(client, sch, host, ifelse(p == "", "80", p), req, opts, verbose) - - response = Response(stream ? 2^24 : FIFOBuffers.DEFAULT_MAX, req) - reset!(conn.parser) - success, err = processresponse!(client, conn, response, host, HTTP.method(req), current_task(), stream, opts.readtimeout::Float64, opts.canonicalizeheaders::Bool, verbose) - if !success - retry >= opts.retries::Int && throw(err) - return request(client, req, opts, stream, history, retry + 1, verbose) + if body != "" + Base.depwarn( + "The body= option is deprecated. Use request(method, uri, headers, body)", + :body) end - @log "received response: $response" - opts.managecookies::Bool && !isempty(response.cookies) && (@log("caching received cookies for host: " * join(map(x->x.name, response.cookies), ", ")); union!(get!(client.cookies, host, Set{Cookie}()), response.cookies)) - response.history = history - if opts.allowredirects::Bool && req.method != HEAD && (300 <= status(response) < 400) - return redirect(response, client, req, opts, stream, history, retry, verbose) + + if !enablechunked && isa(body, IO) + body = read(body) end - if (200 <= status(response) < 300) || !opts.statusraise::Bool - return response + + if VERSION > v"0.7.0-DEV.2338" + args = merge(args, newargs) else - retry >= opts.retries::Int && throw(err) - return request(client, req, opts, stream, history, retry + 1, verbose) + for newarg in newargs + defaultbyfirst(args, newarg) + end end -end - -request(req::Request; - opts::RequestOptions=RequestOptions(), - stream::Bool=false, - history::Vector{Response}=Response[], - retry::Int=0, - verbose::Bool=false, - args...) = - request(DEFAULT_CLIENT, req, RequestOptions(opts; args...), stream, history, retry, verbose) -request(client::Client, req::Request; - opts::RequestOptions=RequestOptions(), - stream::Bool=false, - history::Vector{Response}=Response[], - retry::Int=0, - verbose::Bool=false, - args...) = - request(client, req, RequestOptions(opts; args...), stream, history, retry, verbose) -# build Request -function request(client::Client, method, uri::URI; - headers::Dict=Headers(), - body=FIFOBuffers.EMPTYBODY, - stream::Bool=false, - verbose::Bool=false, - args...) - opts = RequestOptions(; args...) - not(client.logger) && (client.logger = STDOUT) - client.logger != STDOUT && (verbose = true) - req = Request(method, uri, headers, body; options=opts, verbose=verbose, logger=client.logger) - return request(client, req; opts=opts, stream=stream, verbose=verbose) + return request(m, url, h, body; args...) end -request(uri::AbstractString; verbose::Bool=false, query="", args...) = request(DEFAULT_CLIENT, GET, URIs.URL(uri; query=query); verbose=verbose, args...) -request(uri::URI; verbose::Bool=false, args...) = request(DEFAULT_CLIENT, GET, uri; verbose=verbose, args...) -request(method, uri::String; verbose::Bool=false, query="", args...) = request(DEFAULT_CLIENT, convert(HTTP.Method, method), URIs.URL(uri; query=query); verbose=verbose, args...) -request(method, uri::URI; verbose::Bool=false, args...) = request(DEFAULT_CLIENT, convert(HTTP.Method, method), uri; verbose=verbose, args...) for f in [:get, :post, :put, :delete, :head, :trace, :options, :patch, :connect] - f_str = uppercase(string(f)) - meth = convert(Method, f_str) + meth = f_str = uppercase(string(f)) @eval begin - @doc """ - $($f)(uri; kwargs...) -> Response - $($f)(client::HTTP.Client, uri; kwargs...) -> Response - -Build and execute an http "$($f_str)" request. Query parameters can be passed via the `query` keyword argument as a `Dict`. Multiple -query parameters with the same key can be passed like `Dict("key1"=>["value1", "value2"], "key2"=>...)`. -Returns a `Response` object that includes the resulting status code (`HTTP.status(r)` and `HTTP.statustext(r)`), -response headers (`HTTP.headers(r)`), cookies (`HTTP.cookies(r)`), response history if redirects were involved -(`HTTP.history(r)`), and response body (`HTTP.body(r)` or `String(r)` or `take!(r)`). - -The body or payload for a request can be given through the `body` keyword arugment. -The body can be given as a `String`, `Vector{UInt8}`, `IO`, `HTTP.FIFOBuffer` or `Dict` argument type. -See examples below for how to use an `HTTP.FIFOBuffer` for asynchronous streaming uploads. - -If the body is provided as a `Dict`, the request body will be uploaded using the multipart/form-data encoding. -The key-value pairs in the Dict will constitute the name and value of each multipart boundary chunk. -Files and other large data arguments can be provided as values as IO arguments: either an `IOStream` such as returned via `open(file)`, -an `IOBuffer` for in-memory data, or even an `HTTP.FIFOBuffer`. For complete control over the multipart details, an -`HTTP.Multipart` type is provided to support setting the `Content-Type`, `filename`, and `Content-Transfer-Encoding` if desired. See `?HTTP.Multipart` for more details. - -Additional keyword arguments supported, include: - - * `headers::Dict`: headers given as Dict to be sent with the request - * `body`: a request body can be given as a `String`, `Vector{UInt8}`, `IO`, `HTTP.FIFOBuffer` or `Dict`; see example below for how to utilize `HTTP.FIFOBuffer` for "streaming" request bodies; a `Dict` argument will be converted to a multipart form upload - * `stream::Bool=false`: enable response body streaming; depending on the response body size, the request will return before the full body has been received; as the response body is read, additional bytes will be recieved and put in the response body. Readers should read until `eof(response.body) == true`; see below for an example of response streaming - * `chunksize::Int`: if a request body is larger than `chunksize`, the "chunked-transfer" http mechanism will be used and chunks will be sent no larger than `chunksize`; default = `nothing` - * `connecttimeout::Float64`: sets a timeout on how long to wait when trying to connect to a remote host; default = Inf. Note that while setting a timeout will affect the actual program control flow, there are current lower-level limitations that mean underlying resources may not actually be freed until their own timeouts occur (i.e. libuv sockets only timeout after 75 seconds, with no option to configure) - * `readtimeout::Float64`: sets a timeout on how long to wait when receiving a response from a remote host; default = Int - * `tlsconfig::TLS.SSLConfig`: a valid `TLS.SSLConfig` which will be used to initialize every https connection; default = `nothing` - * `maxredirects::Int`: the maximum number of redirects that will automatically be followed for an http request; default = 5 - * `allowredirects::Bool`: whether redirects should be allowed to be followed at all; default = `true` - * `forwardheaders::Bool`: whether user-provided headers should be forwarded on redirects; default = `false` - * `retries::Int`: # of times a request will be tried before throwing an error; default = 3 - * `managecookies::Bool`: whether the request client should automatically store and add cookies from/to requests (following appropriate host-specific & expiration rules); default = `true` - * `statusraise::Bool`: whether an `HTTP.StatusError` should be raised on a non-2XX response status code; default = `true` - * `insecure::Bool`: whether an "https" connection should allow insecure connections (no TLS verification); default = `false` - * `canonicalizeheaders::Bool`: whether header field names should be canonicalized in responses, e.g. `content-type` is canonicalized to `Content-Type`; default = `true` - * `logbody::Bool`: whether the request body should be logged when `verbose=true` is passed; default = `true` - -Simple request example: -```julia -julia> resp = HTTP.get("http://httpbin.org/ip") -HTTP.Response: -\"\"\" -HTTP/1.1 200 OK -Connection: keep-alive -X-Powered-By: Flask -Content-Length: 32 -Via: 1.1 vegur -Access-Control-Allow-Credentials: true -X-Processed-Time: 0.000903129577637 -Date: Wed, 23 Aug 2017 23:35:59 GMT -Content-Type: application/json -Access-Control-Allow-Origin: * -Server: meinheld/0.6.1 -Content-Length: 32 - -{ - "origin": "50.207.241.62" -} -\"\"\" - - -julia> String(resp) -"{\n \"origin\": \"65.130.216.45\"\n}\n" -``` - -Response streaming example (asynchronous download): -```julia -julia> r = HTTP.get("http://httpbin.org/stream/100"; stream=true) -HTTP.Response: -\"\"\" -HTTP/1.1 200 OK -Connection: keep-alive -X-Powered-By: Flask -Transfer-Encoding: chunked -Via: 1.1 vegur -Access-Control-Allow-Credentials: true -X-Processed-Time: 0.000981092453003 -Date: Wed, 23 Aug 2017 23:36:56 GMT -Content-Type: application/json -Access-Control-Allow-Origin: * -Server: meinheld/0.6.1 - -[HTTP.Response body of 27415 bytes] -Content-Length: 27390 - -{"id": 0, "origin": "50.207.241.62", "args": {}, "url": "http://httpbin.org/stream/100", "headers": {"Connection": "close", "User-Agent": "HTTP.jl/0.0.0", "Host": "httpbin.org", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,application/json"}} -{"id": 1, "origin": "50.207.241.62", "args": {}, "url": "http://httpbin.org/stream/100", "headers": {"Connection": "close", "User-Agent": "HTTP.jl/0.0.0", "Host": "httpbin.org", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,application/json"}} -{"id": 2, "origin": "50.207.241.62", "args": {}, "url": "http://httpbin.org/stream/100", "headers": {"Connection": "close", "User-Agent": "HTTP.jl/0.0.0", "Host": "httpbin.org", " -⋮ -\"\"\" - -julia> body = HTTP.body(r) -HTTP.FIFOBuffers.FIFOBuffer(27390, 1048576, 27390, 1, 27391, -1, 27390, UInt8[0x7b, 0x22, 0x69, 0x64, 0x22, 0x3a, 0x20, 0x30, 0x2c, 0x20 … 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x22, 0x7d, 0x7d, 0x0a], Condition(Any[]), Task (done) @0x0000000112d84250, true) - -julia> while true - println(String(readavailable(body))) - eof(body) && break - end -{"id": 0, "origin": "50.207.241.62", "args": {}, "url": "http://httpbin.org/stream/100", "headers": {"Connection": "close", "User-Agent": "HTTP.jl/0.0.0", "Host": "httpbin.org", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,application/json"}} -{"id": 1, "origin": "50.207.241.62", "args": {}, "url": "http://httpbin.org/stream/100", "headers": {"Connection": "close", "User-Agent": "HTTP.jl/0.0.0", "Host": "httpbin.org", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,application/json"}} -{"id": 2, "origin": "50.207.241.62", "args": {}, "url": "http://httpbin.org/stream/100", "headers": {"Connection": "close", "User-Agent": "HTTP.jl/0.0.0", "Host": "httpbin.org", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,application/json"}} -{"id": 3, "origin": "50.207.241.62", "args": {}, "url": "http://httpbin.org/stream/100", "headers": {"Connection": "close", "User-Agent": "HTTP.jl/0.0.0", "Host": "httpbin.org", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,application/json"}} -... -``` - -Request streaming example (asynchronous upload): -```julia -# create a FIFOBuffer for sending our request body -f = HTTP.FIFOBuffer() -# write initial data -write(f, "hey") -# start an HTTP.post asynchronously -t = @async HTTP.post("http://httpbin.org/post"; body=f) -write(f, " there ") # as we write to f, it triggers another chunk to be sent in our async request -write(f, "sailor") -close(f) # setting eof on f causes the async request to send a final chunk and return the response - -resp = wait(t) # get our response by getting the result of our asynchronous task -``` - """ function $(f) end - ($f)(uri::AbstractString; verbose::Bool=false, query="", args...) = request(DEFAULT_CLIENT, $meth, URIs.URL(uri; query=query, isconnect=$(f_str == "CONNECT")); verbose=verbose, args...) - ($f)(uri::URI; verbose::Bool=false, args...) = request(DEFAULT_CLIENT, $meth, uri; verbose=verbose, args...) - ($f)(client::Client, uri::AbstractString; query="", args...) = request(client, $meth, URIs.URL(uri; query=query, isconnect=$(f_str == "CONNECT")); args...) - ($f)(client::Client, uri::URI; args...) = request(client, $meth, uri; args...) + ($f)(client::Client, url::URI; kw...) = request(client, $meth, url; kw...) + ($f)(client::Client, url::AbstractString; kw...) = request(client, $meth, URI(url); kw...) + ($f)(url::URI; kw...) = request(DEFAULT_CLIENT, $meth, url; kw...) + ($f)(url::AbstractString; kw...) = request(DEFAULT_CLIENT, $meth, URI(url); kw...) end end -function download(uri::AbstractString, file; threshold::Int=50000000, verbose::Bool=false, query="", args...) - res = request(GET, uri; verbose=verbose, query=query, stream=true, args...) +function download(url::AbstractString, file; threshold::Int=50000000, verbose::Bool=false, query="", args...) + res = request(GET, url; verbose=verbose, query=query, stream=true, args...) body = HTTP.body(res) file = Base.get(HTTP.headers(res), "Content-Encoding", "") == "gzip" ? string(file, ".gz") : file threshold_step = threshold diff --git a/src/compat.jl b/src/compat.jl new file mode 100644 index 000000000..1e875ad10 --- /dev/null +++ b/src/compat.jl @@ -0,0 +1,31 @@ +@static if VERSION >= v"0.7.0-DEV.2915" + + using Base64 + import Dates + +else # Julia v0.6 + + eval(:(module Base64 end)) + const Dates = Base.Dates + + pairs(x) = [k => v for (k,v) in x] + + Base.SubString(s) = SubString(s, 1) + + using MicroLogging +end + +macro uninit(expr) + if !isdefined(Base, :uninitialized) + splice!(expr.args, 2) + end + return esc(expr) +end + +if !isdefined(Base, :Nothing) + const Nothing = Void + const Cvoid = Void +end + +# https://github.com/JuliaLang/julia/pull/25535 +Base.String(x::SubArray{UInt8,1}) = String(Vector{UInt8}(x)) diff --git a/src/consts.jl b/src/consts.jl index 5d17679f5..90bf94b86 100644 --- a/src/consts.jl +++ b/src/consts.jl @@ -1,260 +1,23 @@ -const STATUS_CODES = Dict( - 100 => "Continue", - 101 => "Switching Protocols", - 102 => "Processing", # RFC 2518 => obsoleted by RFC 4918 - 200 => "OK", - 201 => "Created", - 202 => "Accepted", - 203 => "Non-Authoritative Information", - 204 => "No Content", - 205 => "Reset Content", - 206 => "Partial Content", - 207 => "Multi-Status", # RFC 4918 - 300 => "Multiple Choices", - 301 => "Moved Permanently", - 302 => "Moved Temporarily", - 303 => "See Other", - 304 => "Not Modified", - 305 => "Use Proxy", - 307 => "Temporary Redirect", - 400 => "Bad Request", - 401 => "Unauthorized", - 402 => "Payment Required", - 403 => "Forbidden", - 404 => "Not Found", - 405 => "Method Not Allowed", - 406 => "Not Acceptable", - 407 => "Proxy Authentication Required", - 408 => "Request Time-out", - 409 => "Conflict", - 410 => "Gone", - 411 => "Length Required", - 412 => "Precondition Failed", - 413 => "Request Entity Too Large", - 414 => "Request-URI Too Large", - 415 => "Unsupported Media Type", - 416 => "Requested Range Not Satisfiable", - 417 => "Expectation Failed", - 418 => "I'm a teapot", # RFC 2324 - 422 => "Unprocessable Entity", # RFC 4918 - 423 => "Locked", # RFC 4918 - 424 => "Failed Dependency", # RFC 4918 - 425 => "Unordered Collection", # RFC 4918 - 426 => "Upgrade Required", # RFC 2817 - 428 => "Precondition Required", # RFC 6585 - 429 => "Too Many Requests", # RFC 6585 - 431 => "Request Header Fields Too Large", # RFC 6585 - 440 => "Login Timeout", - 444 => "nginx error: No Response", - 495 => "nginx error: SSL Certificate Error", - 496 => "nginx error: SSL Certificate Required", - 497 => "nginx error: HTTP -> HTTPS", - 499 => "nginx error or Antivirus intercepted request or ArcGIS error", - 500 => "Internal Server Error", - 501 => "Not Implemented", - 502 => "Bad Gateway", - 503 => "Service Unavailable", - 504 => "Gateway Time-out", - 505 => "HTTP Version Not Supported", - 506 => "Variant Also Negotiates", # RFC 2295 - 507 => "Insufficient Storage", # RFC 4918 - 509 => "Bandwidth Limit Exceeded", - 510 => "Not Extended", # RFC 2774 - 511 => "Network Authentication Required", # RFC 6585 - 520 => "CloudFlare Server Error: Unknown", - 521 => "CloudFlare Server Error: Connection Refused", - 522 => "CloudFlare Server Error: Connection Timeout", - 523 => "CloudFlare Server Error: Origin Server Unreachable", - 524 => "CloudFlare Server Error: Connection Timeout", - 525 => "CloudFlare Server Error: Connection Failed", - 526 => "CloudFlare Server Error: Invalid SSL Ceritificate", - 527 => "CloudFlare Server Error: Railgun Error", - 530 => "Site Frozen" -) - -@enum(Method, - DELETE=0, - GET=1, - HEAD=2, - POST=3, - PUT=4, - # pathological - CONNECT=5, - OPTIONS=6, - TRACE=7, - # WebDAV - COPY=8, - LOCK=9, - MKCOL=10, - MOVE=11, - PROPFIND=12, - PROPPATCH=13, - SEARCH=14, - UNLOCK=15, - BIND=16, - REBIND=17, - UNBIND=18, - ACL=19, - # subversion - REPORT=20, - MKACTIVITY=21, - CHECKOUT=22, - MERGE=23, - # upnp - MSEARCH=24, - NOTIFY=25, - SUBSCRIBE=26, - UNSUBSCRIBE=27, - # RFC-5789 - PATCH=28, - PURGE=29, - # CalDAV - MKCALENDAR=30, - # RFC-2068, section 19.6.1.2 - LINK=31, - UNLINK=32, -) - -const MethodMap = Dict( - "DELETE" => DELETE, - "GET" => GET, - "HEAD" => HEAD, - "POST" => POST, - "PUT" => PUT, - "CONNECT" => CONNECT, - "OPTIONS" => OPTIONS, - "TRACE" => TRACE, - "COPY" => COPY, - "LOCK" => LOCK, - "MKCOL" => MKCOL, - "MOVE" => MOVE, - "PROPFIND" => PROPFIND, - "PROPPATCH" => PROPPATCH, - "SEARCH" => SEARCH, - "UNLOCK" => UNLOCK, - "BIND" => BIND, - "REBIND" => REBIND, - "UNBIND" => UNBIND, - "ACL" => ACL, - "REPORT" => REPORT, - "MKACTIVITY" => MKACTIVITY, - "CHECKOUT" => CHECKOUT, - "MERGE" => MERGE, - "MSEARCH" => MSEARCH, - "NOTIFY" => NOTIFY, - "SUBSCRIBE" => SUBSCRIBE, - "UNSUBSCRIBE" => UNSUBSCRIBE, - "PATCH" => PATCH, - "PURGE" => PURGE, - "MKCALENDAR" => MKCALENDAR, - "LINK" => LINK, - "UNLINK" => UNLINK, -) -Base.convert(::Type{Method}, s::String) = MethodMap[s] - -# parsing codes -@enum(ParsingErrorCode, - # No error - HPE_OK, - # Callback-related errors - HPE_CB_message_begin, - HPE_CB_url, - HPE_CB_header_field, - HPE_CB_header_value, - HPE_CB_headers_complete, - HPE_CB_body, - HPE_CB_message_complete, - HPE_CB_status, - HPE_CB_chunk_header, - HPE_CB_chunk_complete, - # Parsing-related errors - HPE_INVALID_EOF_STATE, - HPE_HEADER_OVERFLOW, - HPE_CLOSED_CONNECTION, - HPE_INVALID_VERSION, - HPE_INVALID_STATUS, - HPE_INVALID_METHOD, - HPE_INVALID_URL, - HPE_INVALID_HOST, - HPE_INVALID_PORT, - HPE_INVALID_PATH, - HPE_INVALID_QUERY_STRING, - HPE_INVALID_FRAGMENT, - HPE_LF_EXPECTED, - HPE_INVALID_HEADER_TOKEN, - HPE_INVALID_CONTENT_LENGTH, - HPE_UNEXPECTED_CONTENT_LENGTH, - HPE_INVALID_CHUNK_SIZE, - HPE_INVALID_CONSTANT, - HPE_INVALID_INTERNAL_STATE, - HPE_STRICT, - HPE_PAUSED, - HPE_URI_OVERFLOW, - HPE_BODY_OVERFLOW, - HPE_UNKNOWN, -) - -const ParsingErrorCodeMap = Dict( - HPE_OK => "success", - HPE_CB_message_begin => "the on_message_begin callback failed", - HPE_CB_url => "the on_url callback failed", - HPE_CB_header_field => "the on_header_field callback failed", - HPE_CB_header_value => "the on_header_value callback failed", - HPE_CB_headers_complete => "the on_headers_complete callback failed", - HPE_CB_body => "the on_body callback failed", - HPE_CB_message_complete => "the on_message_complete callback failed", - HPE_CB_status => "the on_status callback failed", - HPE_CB_chunk_header => "the on_chunk_header callback failed", - HPE_CB_chunk_complete => "the on_chunk_complete callback failed", - HPE_INVALID_EOF_STATE => "stream ended at an unexpected time", - HPE_HEADER_OVERFLOW => "too many header bytes seen; overflow detected", - HPE_CLOSED_CONNECTION => "data received after completed connection: close message", - HPE_INVALID_VERSION => "invalid HTTP version", - HPE_INVALID_STATUS => "invalid HTTP status code", - HPE_INVALID_METHOD => "invalid HTTP method", - HPE_INVALID_URL => "invalid URL", - HPE_INVALID_HOST => "invalid host", - HPE_INVALID_PORT => "invalid port", - HPE_INVALID_PATH => "invalid path", - HPE_INVALID_QUERY_STRING => "invalid query string", - HPE_INVALID_FRAGMENT => "invalid fragment", - HPE_LF_EXPECTED => "LF character expected", - HPE_INVALID_HEADER_TOKEN => "invalid character in header", - HPE_INVALID_CONTENT_LENGTH => "invalid character in content-length header", - HPE_UNEXPECTED_CONTENT_LENGTH => "unexpected content-length header", - HPE_INVALID_CHUNK_SIZE => "invalid character in chunk size header", - HPE_INVALID_CONSTANT => "invalid constant string", - HPE_INVALID_INTERNAL_STATE => "encountered unexpected internal state", - HPE_STRICT => "strict mode assertion failed", - HPE_PAUSED => "parser is paused", - HPE_URI_OVERFLOW => "uri exceeded configured maximum uri size", - HPE_BODY_OVERFLOW => "body exceeded configured maximum body size", - HPE_UNKNOWN => "an unknown error occurred", -) - # parsing state codes @enum(ParsingStateCode ,es_dead=1 - ,es_start_req_or_res=2 - ,es_res_or_resp_H=3 - ,es_start_res=4 - ,es_res_H=5 - ,es_res_HT=6 - ,es_res_HTT=7 - ,es_res_HTTP=8 - ,es_res_first_http_major=9 - ,es_res_http_major=10 - ,es_res_first_http_minor=11 - ,es_res_http_minor=12 - ,es_res_first_status_code=13 - ,es_res_status_code=14 - ,es_res_status_start=15 - ,es_res_status=16 - ,es_res_line_almost_done=17 - ,es_start_req=18 - ,es_req_method=19 - ,es_req_spaces_before_url=20 - ,es_req_schema=21 + ,es_start_req_or_res + ,es_res_or_resp_H + ,es_res_first_http_major + ,es_res_http_major + ,es_res_first_http_minor + ,es_res_http_minor + ,es_res_first_status_code + ,es_res_status_code + ,es_res_status_start + ,es_res_status + ,es_res_line_almost_done + ,es_start_req + ,es_req_method + ,es_req_spaces_before_target + ,es_req_target_start + ,es_req_target_wildcard + ,es_req_schema ,es_req_schema_slash ,es_req_schema_slash_slash ,es_req_server_start @@ -275,6 +38,7 @@ const ParsingErrorCodeMap = Dict( ,es_req_first_http_minor ,es_req_http_minor ,es_req_line_almost_done + ,es_trailer_start ,es_header_field_start ,es_header_field ,es_header_value_discard_ws @@ -284,12 +48,13 @@ const ParsingErrorCodeMap = Dict( ,es_header_value ,es_header_value_lws ,es_header_almost_done + ,es_headers_almost_done + ,es_headers_done + ,es_body_start ,es_chunk_size_start ,es_chunk_size ,es_chunk_parameters ,es_chunk_size_almost_done - ,es_headers_almost_done - ,es_headers_done ,es_chunk_data ,es_chunk_data_almost_done ,es_chunk_data_done @@ -301,163 +66,9 @@ for i in instances(ParsingStateCode) @eval const $(Symbol(string(i)[2:end])) = UInt8($i) end -# header states -const h_general = 0x00 -const h_C = 0x01 -const h_CO = 0x02 -const h_CON = 0x03 - -const h_matching_connection = 0x04 -const h_matching_proxy_connection = 0x05 -const h_matching_content_length = 0x06 -const h_matching_transfer_encoding = 0x07 -const h_matching_upgrade = 0x08 -const h_matching_setcookie = 0x09 - -const h_connection = 0x0a -const h_content_length = 0x0b -const h_transfer_encoding = 0x0c -const h_upgrade = 0x0d -const h_setcookie = 0x0e - -const h_matching_transfer_encoding_chunked = 0x0f -const h_matching_connection_token_start = 0x10 -const h_matching_connection_keep_alive = 0x11 -const h_matching_connection_close = 0x12 -const h_matching_connection_upgrade = 0x13 -const h_matching_connection_token = 0x14 - -const h_transfer_encoding_chunked = 0x15 -const h_connection_keep_alive = 0x16 -const h_connection_close = 0x17 -const h_connection_upgrade = 0x18 const CR = '\r' const bCR = UInt8('\r') const LF = '\n' const bLF = UInt8('\n') - -const ULLONG_MAX = typemax(UInt64) - -const PROXY_CONNECTION = "proxy-connection" -const CONNECTION = "connection" -const CONTENT_LENGTH = "content-length" -const TRANSFER_ENCODING = "transfer-encoding" -const UPGRADE = "upgrade" -const SETCOOKIE = "set-cookie" -const CHUNKED = "chunked" -const KEEP_ALIVE = "keep-alive" -const CLOSE = "close" - -#= Tokens as defined by rfc 2616. Also lowercases them. - # token = 1* - # separators = "(" | ")" | "<" | ">" | "@" - # | "," | ";" | ":" | "\" | <"> - # | "/" | "[" | "]" | "?" | "=" - # | "{" | "}" | SP | HT - =# -const tokens = Char[ -#= 0 nul 1 soh 2 stx 3 etx 4 eot 5 enq 6 ack 7 bel =# - 0, 0, 0, 0, 0, 0, 0, 0, -#= 8 bs 9 ht 10 nl 11 vt 12 np 13 cr 14 so 15 si =# - 0, 0, 0, 0, 0, 0, 0, 0, -#= 16 dle 17 dc1 18 dc2 19 dc3 20 dc4 21 nak 22 syn 23 etb =# - 0, 0, 0, 0, 0, 0, 0, 0, -#= 24 can 25 em 26 sub 27 esc 28 fs 29 gs 30 rs 31 us =# - 0, 0, 0, 0, 0, 0, 0, 0, -#= 32 sp 33 ! 34 " 35 # 36 $ 37 % 38 & 39 ' =# - 0, '!', 0, '#', '$', '%', '&', '\'', -#= 40 ( 41 ) 42 * 43 + 44 , 45 - 46 . 47 / =# - 0, 0, '*', '+', 0, '-', '.', 0, -#= 48 0 49 1 50 2 51 3 52 4 53 5 54 6 55 7 =# - '0', '1', '2', '3', '4', '5', '6', '7', -#= 56 8 57 9 58 : 59 ; 60 < 61 = 62 > 63 ? =# - '8', '9', 0, 0, 0, 0, 0, 0, -#= 64 @ 65 A 66 B 67 C 68 D 69 E 70 F 71 G =# - 0, 'a', 'b', 'c', 'd', 'e', 'f', 'g', -#= 72 H 73 I 74 J 75 K 76 L 77 M 78 N 79 O =# - 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', -#= 80 P 81 Q 82 R 83 S 84 T 85 U 86 V 87 W =# - 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', -#= 88 X 89 Y 90 Z 91 [ 92 \ 93 ] 94 ^ 95 _ =# - 'x', 'y', 'z', 0, 0, 0, '^', '_', -#= 96 ` 97 a 98 b 99 c 100 d 101 e 102 f 103 g =# - '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', -#= 104 h 105 i 106 j 107 k 108 l 109 m 110 n 111 o =# - 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', -#= 112 p 113 q 114 r 115 s 116 t 117 u 118 v 119 w =# - 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', -#= 120 x 121 y 122 z 123 { 124 | 125 } 126 ~ 127 del =# - 'x', 'y', 'z', 0, '|', 0, '~', 0 ] - -const unhex = Int8[ - -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 - ,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 - ,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 - , 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,-1,-1,-1,-1,-1,-1 - ,-1,10,11,12,13,14,15,-1,-1,-1,-1,-1,-1,-1,-1,-1 - ,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 - ,-1,10,11,12,13,14,15,-1,-1,-1,-1,-1,-1,-1,-1,-1 - ,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 -] - -# flags -const F_CHUNKED = UInt8(1 << 0) -const F_CONNECTION_KEEP_ALIVE = UInt8(1 << 1) -const F_CONNECTION_CLOSE = UInt8(1 << 2) -const F_CONNECTION_UPGRADE = UInt8(1 << 3) -const F_TRAILING = UInt8(1 << 4) -const F_UPGRADE = UInt8(1 << 5) -const F_SKIPBODY = UInt8(1 << 6) -const F_CONTENTLENGTH = UInt8(1 << 7) - -# url parsing -const normal_url_char = Bool[ -#= 0 nul 1 soh 2 stx 3 etx 4 eot 5 enq 6 ack 7 bel =# - false, false, false, false, false, false, false, false, -#= 8 bs 9 ht 10 nl 11 vt 12 np 13 cr 14 so 15 si =# - false, true, false, false, true, false, false, false, -#= 16 dle 17 dc1 18 dc2 19 dc3 20 dc4 21 nak 22 syn 23 etb =# - false, false, false, false, false, false, false, false, -#= 24 can 25 em 26 sub 27 esc 28 fs 29 gs 30 rs 31 us =# - false, false, false, false, false, false, false, false, -#= 32 sp 33 ! 34 " 35 # 36 $ 37 % 38 & 39 ' =# - false, true, true, false, true, true, true, true, -#= 40 ( 41 ) 42 * 43 + 44 , 45 - 46 . 47 / =# - true, true, true, true, true, true, true, true, -#= 48 0 49 1 50 2 51 3 52 4 53 5 54 6 55 7 =# - true, true, true, true, true, true, true, true, -#= 56 8 57 9 58 : 59 ; 60 < 61 = 62 > 63 ? =# - true, true, true, true, true, true, true, false, -#= 64 @ 65 A 66 B 67 C 68 D 69 E 70 F 71 G =# - true, true, true, true, true, true, true, true, -#= 72 H 73 I 74 J 75 K 76 L 77 M 78 N 79 O =# - true, true, true, true, true, true, true, true, -#= 80 P 81 Q 82 R 83 S 84 T 85 U 86 V 87 W =# - true, true, true, true, true, true, true, true, -#= 88 X 89 Y 90 Z 91 [ 92 \ 93 ] 94 ^ 95 _ =# - true, true, true, true, true, true, true, true, -#= 96 ` 97 a 98 b 99 c 100 d 101 e 102 f 103 g =# - true, true, true, true, true, true, true, true, -#= 104 h 105 i 106 j 107 k 108 l 109 m 110 n 111 o =# - true, true, true, true, true, true, true, true, -#= 112 p 113 q 114 r 115 s 116 t 117 u 118 v 119 w =# - true, true, true, true, true, true, true, true, -#= 120 x 121 y 122 z 123 { 124, 125 } 126 ~ 127 del =# - true, true, true, true, true, true, true, false, -] - -@enum(http_host_state, - s_http_host_dead = 1, - s_http_userinfo_start =2, - s_http_userinfo = 3, - s_http_host_start = 4, - s_http_host_v6_start = 5, - s_http_host = 6, - s_http_host_v6, - s_http_host_v6_end, - s_http_host_v6_zone_start, - s_http_host_v6_zone, - s_http_host_port_start, - s_http_host_port, -) +const CRLF = "\r\n" diff --git a/src/cookies.jl b/src/cookies.jl index 9d1a65835..baa970b5a 100644 --- a/src/cookies.jl +++ b/src/cookies.jl @@ -30,25 +30,13 @@ module Cookies -if VERSION < v"0.7.0-DEV.2575" - const Dates = Base.Dates -else - import Dates -end - -if VERSION >= v"0.7.0-DEV.2915" - using Unicode -end - -if !isdefined(Base, :pairs) - pairs(x) = x -end - - export Cookie +import ..Dates + import Base.== -import HTTP.isurlchar +import ..URIs.isurlchar +using ..pairs """ Cookie() diff --git a/src/debug.jl b/src/debug.jl new file mode 100644 index 000000000..568d327c6 --- /dev/null +++ b/src/debug.jl @@ -0,0 +1,112 @@ +taskid(t=current_task()) = hex(hash(t) & 0xffff, 4) + +macro debug(n::Int, s) + DEBUG_LEVEL >= n ? :(println("DEBUG: ", taskid(), " ", $(esc(s)))) : + :() +end + +macro debugshow(n::Int, s) + DEBUG_LEVEL >= n ? :(println("DEBUG: ", taskid(), " ", + $(sprint(Base.show_unquoted, s)), " = ", + sprint(io->show(io, "text/plain", + begin value=$(esc(s)) end)))) : + :() + +end + +macro debugshort(n::Int, s) + DEBUG_LEVEL >= n ? :(println("DEBUG: ", taskid(), " ", + sprint(showcompact, $(esc(s))))) : + :() +end + +printlncompact(x) = println(sprint(showcompact, x)) + + +@noinline function precondition_error(msg, frame) + msg = string(sprint(StackTraces.show_spec_linfo, + StackTraces.lookup(frame)[2]), + " requires ", msg) + return ArgumentError(msg) +end + + +""" + @require precondition [message] +Throw `ArgumentError` if `precondition` is false. +""" +macro require(condition, msg = string(condition)) + esc(:(if ! $condition throw(precondition_error($msg, backtrace()[1])) end)) +end + + +@noinline function postcondition_error(msg, frame, ls="", l="", rs="", r="") + msg = string(sprint(StackTraces.show_spec_linfo, + StackTraces.lookup(frame)[2]), + " failed to ensure ", msg) + if ls != "" + msg = string(msg, "\n", ls, " = ", sprint(show, l), + "\n", rs, " = ", sprint(show, r)) + end + return AssertionError(msg) +end + + +# Copied from stdlib/Test/src/Test.jl:get_test_result() +iscondition(ex) = isa(ex, Expr) && + ex.head == :call && + length(ex.args) == 3 && + first(string(ex.args[1])) != '.' && + (!isa(ex.args[2], Expr) || ex.args[2].head != :...) && + (!isa(ex.args[3], Expr) || ex.args[3].head != :...) && + (ex.args[1] === :(==) || + Base.operator_precedence(ex.args[1]) == + Base.operator_precedence(:(==))) + + +""" + @ensure postcondition [message] +Throw `ArgumentError` if `postcondition` is false. +""" +macro ensure(condition, msg = string(condition)) + + if DEBUG_LEVEL < 0 + return :() + end + + if iscondition(condition) + l,r = condition.args[2], condition.args[3] + ls, rs = string(l), string(r) + return esc(quote + if ! $condition + throw(postcondition_error($msg, backtrace()[1], + $ls, $l, $rs, $r)) + end + end) + end + + esc(:(if ! $condition throw(postcondition_error($msg, backtrace()[1])) end)) +end + + +# FIXME +# Should this have a branch-prediction hint? (same for @assert?) +# http://llvm.org/docs/BranchWeightMetadata.html#built-in-expect-instructions + +#= +macro src() + @static if VERSION >= v"0.7-" && length(:(@test).args) == 2 + esc(quote + (__module__, + __source__.file == nothing ? "?" : String(__source__.file), + __source__.line) + end) + else + esc(quote + (current_module(), + (p = Base.source_path(); p == nothing ? "REPL" : p), + Int(unsafe_load(cglobal(:jl_lineno, Cint)))) + end) + end +end +=# diff --git a/src/multipart.jl b/src/multipart.jl index 1a88f2894..7487f56d0 100644 --- a/src/multipart.jl +++ b/src/multipart.jl @@ -17,6 +17,7 @@ end Form(f::Form) = f Base.eof(f::Form) = f.index > length(f.data) Base.isopen(f::Form) = false +Base.close(f::Form) = nothing Base.length(f::Form) = sum(x->isa(x, IOStream) ? filesize(x) - position(x) : nb_available(x), f.data) function Base.position(f::Form) index = f.index @@ -58,7 +59,7 @@ function Form(d::Dict) io = IOBuffer() len = length(d) for (i, (k, v)) in enumerate(d) - write(io, (i == 1 ? "" : "$CRLF") * "--" * boundary * "$CRLF") + write(io, (i == 1 ? "" : "\r\n") * "--" * boundary * "\r\n") write(io, "Content-Disposition: form-data; name=\"$k\"") if isa(v, IO) writemultipartheader(io, v) @@ -67,10 +68,10 @@ function Form(d::Dict) push!(data, v) io = IOBuffer() else - write(io, "$CRLF$CRLF") - write(io, escape(v)) + write(io, "\r\n\r\n") + write(io, escapeuri(v)) end - i == len && write(io, "$CRLF--" * boundary * "--" * "$CRLF") + i == len && write(io, "\r\n--" * boundary * "--" * "\r\n") end seekstart(io) push!(data, io) @@ -78,12 +79,12 @@ function Form(d::Dict) end function writemultipartheader(io::IOBuffer, i::IOStream) - write(io, "; filename=\"$(i.name[7:end-1])\"$CRLF") - write(io, "Content-Type: $(HTTP.sniff(i))$CRLF$CRLF") + write(io, "; filename=\"$(i.name[7:end-1])\"\r\n") + write(io, "Content-Type: $(HTTP.sniff(i))\r\n\r\n") return end function writemultipartheader(io::IOBuffer, i::IO) - write(io, "$CRLF$CRLF") + write(io, "\r\n\r\n") return end @@ -113,9 +114,9 @@ Base.mark(m::Multipart{T}) where {T} = mark(m.data) Base.reset(m::Multipart{T}) where {T} = reset(m.data) function writemultipartheader(io::IOBuffer, i::Multipart) - write(io, "; filename=\"$(i.filename)\"$CRLF") + write(io, "; filename=\"$(i.filename)\"\r\n") contenttype = i.contenttype == "" ? HTTP.sniff(i.data) : i.contenttype - write(io, "Content-Type: $(contenttype)$CRLF") - write(io, i.contenttransferencoding == "" ? "$CRLF" : "Content-Transfer-Encoding: $(i.contenttransferencoding)$CRLF$CRLF") + write(io, "Content-Type: $(contenttype)\r\n") + write(io, i.contenttransferencoding == "" ? "\r\n" : "Content-Transfer-Encoding: $(i.contenttransferencoding)\r\n\r\n") return end diff --git a/src/parser.jl b/src/parser.jl deleted file mode 100644 index f5cf01fd8..000000000 --- a/src/parser.jl +++ /dev/null @@ -1,1361 +0,0 @@ -# Based on src/http/ngx_http_parse.c from NGINX copyright Igor Sysoev -# -# Additional changes are licensed under the same terms as NGINX and -# copyright Joyent, Inc. and other Node contributors. All rights reserved. -# -# 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. -# - -mutable struct Parser - state::UInt8 - header_state::UInt8 - index::UInt8 - flags::UInt8 - nread::UInt32 - content_length::UInt64 - fieldbuffer::Vector{UInt8} - valuebuffer::Vector{UInt8} -end - -Parser() = Parser(s_start_req_or_res, 0x00, 0, 0, 0, 0, UInt8[], UInt8[]) - -function reset!(p::Parser) - p.state = start_state - p.header_state = 0x00 - p.index = 0x00 - p.flags = 0x00 - p.nread = 0x00000000 - p.content_length = 0x0000000000000000 - empty!(p.fieldbuffer) - empty!(p.valuebuffer) - return -end - -macro nread(n) - return esc(quote - parser.nread += UInt32($n) - @errorif(parser.nread > maxheader, HPE_HEADER_OVERFLOW) - end) -end - -onmessagebegin(r) = @debug(PARSING_DEBUG, "onmessagebegin") -# should we just make a copy of the byte vector for URI here? -function onurl(r, bytes, i, j) - @debug(PARSING_DEBUG, "onurl") - @debug(PARSING_DEBUG, i - j + 1) - @debug(PARSING_DEBUG, "'$(String(bytes[i:j]))'") - @debug(PARSING_DEBUG, r.method) - uri = URIs.http_parser_parse_url(bytes, i, j - i + 1, r.method == CONNECT) - @debug(PARSING_DEBUG, uri) - setfield!(r, :uri, uri) - return -end -onstatus(r) = @debug(PARSING_DEBUG, "onstatus") -function onheaderfield(p::Parser, bytes, i, j) - @debug(PARSING_DEBUG, "onheaderfield") - append!(p.fieldbuffer, view(bytes, i:j)) - return -end -function onheadervalue(p::Parser, bytes, i, j) - @debug(PARSING_DEBUG, "onheadervalue") - append!(p.valuebuffer, view(bytes, i:j)) - return -end -function onheadervalue(p, r, bytes, i, j, issetcookie, host, KEY, canonicalizeheaders) - @debug(PARSING_DEBUG, "onheadervalue2") - append!(p.valuebuffer, view(bytes, i:j)) - if canonicalizeheaders - key = canonicalize!(unsafe_string(pointer(p.fieldbuffer), length(p.fieldbuffer))) - else - key = unsafe_string(pointer(p.fieldbuffer), length(p.fieldbuffer)) - end - val = unsafe_string(pointer(p.valuebuffer), length(p.valuebuffer)) - if key == "" - # the header value was parsed in two parts, - # KEY[] holds the most recently parsed header field, - # and we already stored the first part of the header value in r.headers - # get the first part and concatenate it with the second part we have now - key = KEY[] - setindex!(r.headers, string(r.headers[key], val), key) - else - KEY[] = key - val2 = get!(r.headers, key, val) - val2 !== val && setindex!(r.headers, string(val2, ", ", val), key) - end - issetcookie && push!(r.cookies, Cookies.readsetcookie(host, val)) - empty!(p.fieldbuffer) - empty!(p.valuebuffer) - return -end -onheaderscomplete(r) = @debug(PARSING_DEBUG, "onheaderscomplete") -function onbody(r, maintask, bytes, i, j) - @debug(PARSING_DEBUG, "onbody") - @debug(PARSING_DEBUG, String(r.body)) - @debug(PARSING_DEBUG, String(bytes[i:j])) - len = j - i + 1 - #TODO: avoid copying the bytes here? can we somehow write the bytes to a FIFOBuffer more efficiently? - nb = write(r.body, bytes, i, j) - if nb < len # didn't write all available bytes - if current_task() == maintask - # main request function hasn't returned yet, so not safe to wait - r.body.max += len - nb - write(r.body, bytes, i + nb, j) - else - while nb < len - nb += write(r.body, bytes, i + nb, j) - end - end - end - @debug(PARSING_DEBUG, String(r.body)) - return -end -onmessagecomplete(r::Request) = @debug(PARSING_DEBUG, "onmessagecomplete") -onmessagecomplete(r::Response) = (@debug(PARSING_DEBUG, "onmessagecomplete"); close(r.body)) - -""" - HTTP.parse([HTTP.Request, HTTP.Response], str; kwargs...) - -Parse a `HTTP.Request` or `HTTP.Response` from a string. `str` must contain at least one -full request or response (but may include more than one). Supported keyword arguments include: - - * `extra`: a `Ref{String}` that will be used to store any extra bytes beyond a full request or response - * `lenient`: whether the request/response parsing should allow additional characters - * `maxuri`: the maximum allowed size of a uri in a request - * `maxheader`: the maximum allowed size of headers - * `maxbody`: the maximum allowed size of a request or response body -""" -function parse(T::Type{<:Union{Request, Response}}, str; - extra::Ref{String}=Ref{String}(), lenient::Bool=true, - maxuri::Int64=DEFAULT_MAX_URI, maxheader::Int64=DEFAULT_MAX_HEADER, - maxbody::Int64=DEFAULT_MAX_BODY, - maintask::Task=current_task(), - canonicalizeheaders::Bool=true) - r = T(body=FIFOBuffer()) - reset!(DEFAULT_PARSER) - err, headerscomplete, messagecomplete, upgrade = parse!(r, DEFAULT_PARSER, Vector{UInt8}(str); - lenient=lenient, maxuri=maxuri, maxheader=maxheader, maxbody=maxbody, maintask=maintask, canonicalizeheaders=canonicalizeheaders) - err != HPE_OK && throw(ParsingError("error parsing $T: $(ParsingErrorCodeMap[err])")) - extra[] = upgrade - return r -end - -const start_state = s_start_req_or_res -const DEFAULT_MAX_HEADER = Int64(80 * 1024) -const DEFAULT_MAX_URI = Int64(8000) -const DEFAULT_MAX_BODY = Int64(2)^32 # 4Gib -const DEFAULT_PARSER = Parser() - -function parse!(r::Union{Request, Response}, parser, bytes, len=length(bytes); - lenient::Bool=true, host::String="", method::Method=GET, - maxuri::Int64=DEFAULT_MAX_URI, maxheader::Int64=DEFAULT_MAX_HEADER, - maxbody::Int64=DEFAULT_MAX_BODY, maintask::Task=current_task(), - canonicalizeheaders::Bool=true)::Tuple{ParsingErrorCode, Bool, Bool, String} - return parse!(r, parser, bytes, len, lenient, host, method, maxuri, maxheader, maxbody, maintask, canonicalizeheaders) -end -function parse!(r, parser, bytes, len, lenient, host, method, maxuri, maxheader, maxbody, maintask, canonicalizeheaders) - strict = !lenient - p_state = parser.state - status_mark = url_mark = header_field_mark = header_field_end_mark = header_value_mark = body_mark = 0 - errno = HPE_OK - upgrade = issetcookie = headersdone = false - KEY = Ref{String}() - @debug(PARSING_DEBUG, len) - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if len == 0 - if p_state == s_body_identity_eof - parser.state = p_state - onmessagecomplete(r) - @debug(PARSING_DEBUG, "this 6") - return HPE_OK, true, true, "" - elseif @anyeq(p_state, s_dead, s_start_req_or_res, s_start_res, s_start_req) - return HPE_OK, false, false, "" - else - return HPE_INVALID_EOF_STATE, false, false, "" - end - end - - if p_state == s_header_field - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - header_field_mark = header_field_end_mark = 1 - end - if p_state == s_header_value - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - header_value_mark = 1 - end - if @anyeq(p_state, s_req_path, s_req_schema, s_req_schema_slash, s_req_schema_slash_slash, - s_req_server_start, s_req_server, s_req_server_with_at, - s_req_query_string_start, s_req_query_string, s_req_fragment) - url_mark = 1 - elseif p_state == s_res_status - status_mark = 1 - end - p = 1 - while p <= len - @inbounds ch = Char(bytes[p]) - @debug(PARSING_DEBUG, "top of main for-loop") - @debug(PARSING_DEBUG, Base.escape_string(string(ch))) - if p_state <= s_headers_done - @nread(1) - end - - @label reexecute - - if p_state == s_dead - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - #= this state is used after a 'Connection: close' message - # the parser will error out if it reads another message - =# - (ch == CR || ch == LF) && @goto breakout - @err HPE_CLOSED_CONNECTION - - elseif p_state == s_start_req_or_res - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - - (ch == CR || ch == LF) && @goto breakout - parser.flags = 0 - parser.content_length = ULLONG_MAX - - if ch == 'H' - p_state = s_res_or_resp_H - parser.state = p_state - onmessagebegin(r) - else - p_state = s_start_req - @goto reexecute - end - - elseif p_state == s_res_or_resp_H - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if ch == 'T' - p_state = s_res_HT - else - @errorif(ch != 'E', HPE_INVALID_CONSTANT) - r.method = HEAD - parser.index = 3 - p_state = s_req_method - end - - elseif p_state == s_start_res - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - parser.flags = 0 - parser.content_length = ULLONG_MAX - if ch == 'H' - p_state = s_res_H - elseif ch == CR || ch == LF - else - @err HPE_INVALID_CONSTANT - end - parser.state = p_state - onmessagebegin(r) - - elseif p_state == s_res_H - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @strictcheck(ch != 'T') - p_state = s_res_HT - - elseif p_state == s_res_HT - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @strictcheck(ch != 'T') - p_state = s_res_HTT - - elseif p_state == s_res_HTT - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @strictcheck(ch != 'P') - p_state = s_res_HTTP - - elseif p_state == s_res_HTTP - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @strictcheck(ch != '/') - p_state = s_res_first_http_major - - elseif p_state == s_res_first_http_major - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @errorif(!isnum(ch), HPE_INVALID_VERSION) - r.major = Int16(ch - '0') - p_state = s_res_http_major - - #= major HTTP version or dot =# - elseif p_state == s_res_http_major - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if ch == '.' - p_state = s_res_first_http_minor - @goto breakout - end - @errorif(!isnum(ch), HPE_INVALID_VERSION) - r.major *= Int16(10) - r.major += Int16(ch - '0') - @errorif(r.major > 999, HPE_INVALID_VERSION) - - #= first digit of minor HTTP version =# - elseif p_state == s_res_first_http_minor - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @errorif(!isnum(ch), HPE_INVALID_VERSION) - r.minor = Int16(ch - '0') - p_state = s_res_http_minor - - #= minor HTTP version or end of request line =# - elseif p_state == s_res_http_minor - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if ch == ' ' - p_state = s_res_first_status_code - @goto breakout - end - @errorif(!isnum(ch), HPE_INVALID_VERSION) - r.minor *= Int16(10) - r.minor += Int16(ch - '0') - @errorif(r.minor > 999, HPE_INVALID_VERSION) - - elseif p_state == s_res_first_status_code - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if !isnum(ch) - ch == ' ' && @goto breakout - @err(HPE_INVALID_STATUS) - end - r.status = Int32(ch - '0') - p_state = s_res_status_code - - elseif p_state == s_res_status_code - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if !isnum(ch) - if ch == ' ' - p_state = s_res_status_start - elseif ch == CR - p_state = s_res_line_almost_done - elseif ch == LF - p_state = s_header_field_start - else - @err(HPE_INVALID_STATUS) - end - else - r.status *= Int32(10) - r.status += Int32(ch - '0') - @errorif(r.status > 999, HPE_INVALID_STATUS) - end - - elseif p_state == s_res_status_start - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if ch == CR - p_state = s_res_line_almost_done - elseif ch == LF - p_state = s_header_field_start - else - status_mark = p - p_state = s_res_status - parser.index = 1 - end - - elseif p_state == s_res_status - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if ch == CR - p_state = s_res_line_almost_done - parser.state = p_state - onstatus(r) - status_mark = 0 - elseif ch == LF - p_state = s_header_field_start - parser.state = p_state - onstatus(r) - status_mark = 0 - end - - elseif p_state == s_res_line_almost_done - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @strictcheck(ch != LF) - p_state = s_header_field_start - - elseif p_state == s_start_req - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - (ch == CR || ch == LF) && @goto breakout - parser.flags = 0 - parser.content_length = ULLONG_MAX - @errorif(!isalpha(ch), HPE_INVALID_METHOD) - - r.method = Method(0) - parser.index = 2 - - if ch == 'A' - r.method = ACL - elseif ch == 'B' - r.method = BIND - elseif ch == 'C' - r.method = CONNECT - elseif ch == 'D' - r.method = DELETE - elseif ch == 'G' - r.method = GET - elseif ch == 'H' - r.method = HEAD - elseif ch == 'L' - r.method = LOCK - elseif ch == 'M' - r.method = MKCOL - elseif ch == 'N' - r.method = NOTIFY - elseif ch == 'O' - r.method = OPTIONS - elseif ch == 'P' - r.method = POST - elseif ch == 'R' - r.method = REPORT - elseif ch == 'S' - r.method = SUBSCRIBE - elseif ch == 'T' - r.method = TRACE - elseif ch == 'U' - r.method = UNLOCK - else - @err(HPE_INVALID_METHOD) - end - p_state = s_req_method - parser.state = p_state - onmessagebegin(r) - - elseif p_state == s_req_method - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - matcher = string(r.method) - @debug(PARSING_DEBUG, matcher) - @debug(PARSING_DEBUG, parser.index) - @debug(PARSING_DEBUG, Base.escape_string(string(ch))) - if ch == ' ' && parser.index == length(matcher) + 1 - p_state = s_req_spaces_before_url - elseif parser.index > length(matcher) - @err(HPE_INVALID_METHOD) - elseif ch == matcher[parser.index] - @debug(PARSING_DEBUG, "nada") - elseif isalpha(ch) - ci = @shifted(r.method, Int(parser.index) - 1, ch) - if ci == @shifted(POST, 1, 'U') - r.method = PUT - elseif ci == @shifted(POST, 1, 'A') - r.method = PATCH - elseif ci == @shifted(CONNECT, 1, 'H') - r.method = CHECKOUT - elseif ci == @shifted(CONNECT, 2, 'P') - r.method = COPY - elseif ci == @shifted(MKCOL, 1, 'O') - r.method = MOVE - elseif ci == @shifted(MKCOL, 1, 'E') - r.method = MERGE - elseif ci == @shifted(MKCOL, 2, 'A') - r.method = MKACTIVITY - elseif ci == @shifted(MKCOL, 3, 'A') - r.method = MKCALENDAR - elseif ci == @shifted(SUBSCRIBE, 1, 'E') - r.method = SEARCH - elseif ci == @shifted(REPORT, 2, 'B') - r.method = REBIND - elseif ci == @shifted(POST, 1, 'R') - r.method = PROPFIND - elseif ci == @shifted(PROPFIND, 4, 'P') - r.method = PROPPATCH - elseif ci == @shifted(PUT, 2, 'R') - r.method = PURGE - elseif ci == @shifted(LOCK, 1, 'I') - r.method = LINK - elseif ci == @shifted(UNLOCK, 2, 'S') - r.method = UNSUBSCRIBE - elseif ci == @shifted(UNLOCK, 2, 'B') - r.method = UNBIND - elseif ci == @shifted(UNLOCK, 3, 'I') - r.method = UNLINK - else - @err(HPE_INVALID_METHOD) - end - elseif ch == '-' && parser.index == 2 && r.method == MKCOL - @debug(PARSING_DEBUG, "matched MSEARCH") - r.method = MSEARCH - parser.index -= 1 - else - @err(HPE_INVALID_METHOD) - end - parser.index += 1 - @debug(PARSING_DEBUG, parser.index) - - elseif p_state == s_req_spaces_before_url - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - ch == ' ' && @goto breakout - url_mark = p - if r.method == CONNECT - p_state = s_req_server_start - end - p_state = URIs.parseurlchar(p_state, ch, strict) - @errorif(p_state == s_dead, HPE_INVALID_URL) - - elseif @anyeq(p_state, s_req_schema, s_req_schema_slash, s_req_schema_slash_slash, s_req_server_start) - @errorif(ch in (' ', CR, LF), HPE_INVALID_URL) - p_state = URIs.parseurlchar(p_state, ch, strict) - @errorif(p_state == s_dead, HPE_INVALID_URL) - - elseif @anyeq(p_state, s_req_server, s_req_server_with_at, s_req_path, s_req_query_string_start, - s_req_query_string, s_req_fragment_start, s_req_fragment) - if ch == ' ' - p_state = s_req_http_start - parser.state = p_state - p - url_mark > maxuri && @err(HPE_URI_OVERFLOW) - onurl(r, bytes, url_mark, p-1) - url_mark = 0 - elseif ch in (CR, LF) - r.major = Int16(0) - r.minor = Int16(9) - p_state = ifelse(ch == CR, s_req_line_almost_done, s_header_field_start) - parser.state = p_state - p - url_mark > maxuri && @err(HPE_URI_OVERFLOW) - onurl(r, bytes, url_mark, p-1) - url_mark = 0 - else - p_state = URIs.parseurlchar(p_state, ch, strict) - @errorif(p_state == s_dead, HPE_INVALID_URL) - end - - elseif p_state == s_req_http_start - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if ch == 'H' - p_state = s_req_http_H - elseif ch == ' ' - else - @err(HPE_INVALID_CONSTANT) - end - - elseif p_state == s_req_http_H - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @strictcheck(ch != 'T') - p_state = s_req_http_HT - - elseif p_state == s_req_http_HT - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @strictcheck(ch != 'T') - p_state = s_req_http_HTT - - elseif p_state == s_req_http_HTT - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @strictcheck(ch != 'P') - p_state = s_req_http_HTTP - - elseif p_state == s_req_http_HTTP - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @strictcheck(ch != '/') - p_state = s_req_first_http_major - - #= first digit of major HTTP version =# - elseif p_state == s_req_first_http_major - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @errorif(ch < '1' || ch > '9', HPE_INVALID_VERSION) - r.major = Int16(ch - '0') - p_state = s_req_http_major - - #= major HTTP version or dot =# - elseif p_state == s_req_http_major - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if ch == '.' - p_state = s_req_first_http_minor - elseif !isnum(ch) - @err(HPE_INVALID_VERSION) - else - r.major *= Int16(10) - r.major += Int16(ch - '0') - @errorif(r.major > 999, HPE_INVALID_VERSION) - end - - #= first digit of minor HTTP version =# - elseif p_state == s_req_first_http_minor - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @errorif(!isnum(ch), HPE_INVALID_VERSION) - r.minor = Int16(ch - '0') - p_state = s_req_http_minor - - #= minor HTTP version or end of request line =# - elseif p_state == s_req_http_minor - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if ch == CR - p_state = s_req_line_almost_done - elseif ch == LF - p_state = s_header_field_start - else - #= XXX allow spaces after digit? =# - @errorif(!isnum(ch), HPE_INVALID_VERSION) - r.minor *= Int16(10) - r.minor += Int16(ch - '0') - @errorif(r.minor > 999, HPE_INVALID_VERSION) - end - - #= end of request line =# - elseif p_state == s_req_line_almost_done - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @errorif(ch != LF, HPE_LF_EXPECTED) - p_state = s_header_field_start - - elseif p_state == s_header_field_start - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if ch == CR - p_state = s_headers_almost_done - elseif ch == LF - #= they might be just sending \n instead of \r\n so this would be - * the second \n to denote the end of headers=# - p_state = s_headers_almost_done - @goto reexecute - else - c = (!strict && ch == ' ') ? ' ' : tokens[Int(ch)+1] - @errorif(c == Char(0), HPE_INVALID_HEADER_TOKEN) - header_field_mark = header_field_end_mark = p - parser.index = 1 - issetcookie = false - p_state = s_header_field - - if c == 'c' - parser.header_state = h_C - elseif c == 'p' - parser.header_state = h_matching_proxy_connection - elseif c == 't' - parser.header_state = h_matching_transfer_encoding - elseif c == 'u' - parser.header_state = h_matching_upgrade - elseif c == 's' - parser.header_state = h_matching_setcookie - else - parser.header_state = h_general - end - end - - elseif p_state == s_header_field - @debug(PARSING_DEBUG, "parsing header_field") - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - start = p - while p <= len - ch = Char(bytes[p]) - @debug(PARSING_DEBUG, Base.escape_string(string(ch))) - c = (!strict && ch == ' ') ? ' ' : tokens[Int(ch)+1] - c == Char(0) && break - h = parser.header_state - if h == h_general - @debug(PARSING_DEBUG, parser.header_state) - - elseif h == h_C - @debug(PARSING_DEBUG, parser.header_state) - parser.index += 1 - parser.header_state = c == 'o' ? h_CO : h_general - elseif h == h_CO - @debug(PARSING_DEBUG, parser.header_state) - parser.index += 1 - parser.header_state = c == 'n' ? h_CON : h_general - elseif h == h_CON - @debug(PARSING_DEBUG, parser.header_state) - parser.index += 1 - if c == 'n' - parser.header_state = h_matching_connection - elseif c == 't' - parser.header_state = h_matching_content_length - else - parser.header_state = h_general - end - #= connection =# - elseif h == h_matching_connection - @debug(PARSING_DEBUG, parser.header_state) - parser.index += 1 - if parser.index > length(CONNECTION) || c != CONNECTION[parser.index] - parser.header_state = h_general - elseif parser.index == length(CONNECTION) - parser.header_state = h_connection - end - #= proxy-connection =# - elseif h == h_matching_proxy_connection - @debug(PARSING_DEBUG, parser.header_state) - parser.index += 1 - if parser.index > length(PROXY_CONNECTION) || c != PROXY_CONNECTION[parser.index] - parser.header_state = h_general - elseif parser.index == length(PROXY_CONNECTION) - parser.header_state = h_connection - end - #= content-length =# - elseif h == h_matching_content_length - @debug(PARSING_DEBUG, parser.header_state) - parser.index += 1 - if parser.index > length(CONTENT_LENGTH) || c != CONTENT_LENGTH[parser.index] - parser.header_state = h_general - elseif parser.index == length(CONTENT_LENGTH) - parser.header_state = h_content_length - end - #= transfer-encoding =# - elseif h == h_matching_transfer_encoding - @debug(PARSING_DEBUG, parser.header_state) - parser.index += 1 - if parser.index > length(TRANSFER_ENCODING) || c != TRANSFER_ENCODING[parser.index] - parser.header_state = h_general - elseif parser.index == length(TRANSFER_ENCODING) - parser.header_state = h_transfer_encoding - end - #= upgrade =# - elseif h == h_matching_upgrade - @debug(PARSING_DEBUG, parser.header_state) - parser.index += 1 - if parser.index > length(UPGRADE) || c != UPGRADE[parser.index] - parser.header_state = h_general - elseif parser.index == length(UPGRADE) - parser.header_state = h_upgrade - end - #= set-cookie =# - elseif h == h_matching_setcookie - @debug(PARSING_DEBUG, parser.header_state) - parser.index += 1 - if parser.index > length(SETCOOKIE) || c != SETCOOKIE[parser.index] - parser.header_state = h_general - elseif parser.index == length(SETCOOKIE) - parser.header_state = h_general - issetcookie = true - end - elseif h in (h_connection, h_content_length, h_transfer_encoding, h_upgrade) - if ch != ' ' - parser.header_state = h_general - end - else - error("Unknown header_state") - end - p += 1 - end - - @nread(p - start) - - if p >= len - p -= 1 - @goto breakout - end - if ch == ':' - p_state = s_header_value_discard_ws - parser.state = p_state - header_field_end_mark = p - onheaderfield(parser, bytes, header_field_mark, p - 1) - header_field_mark = 0 - else - @err(HPE_INVALID_HEADER_TOKEN) - end - - elseif p_state == s_header_value_discard_ws - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - (ch == ' ' || ch == '\t') && @goto breakout - if ch == CR - p_state = s_header_value_discard_ws_almost_done - @goto breakout - end - if ch == LF - p_state = s_header_value_discard_lws - @goto breakout - end - @goto s_header_value_start_label - #= FALLTHROUGH =# - elseif p_state == s_header_value_start - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @label s_header_value_start_label - header_value_mark = p - p_state = s_header_value - parser.index = 1 - c = lower(ch) - - if parser.header_state == h_upgrade - parser.flags |= F_UPGRADE - parser.header_state = h_general - elseif parser.header_state == h_transfer_encoding - #= looking for 'Transfer-Encoding: chunked' =# - parser.header_state = ifelse(c == 'c', h_matching_transfer_encoding_chunked, h_general) - - elseif parser.header_state == h_content_length - @errorif(!isnum(ch), HPE_INVALID_CONTENT_LENGTH) - @errorif((parser.flags & F_CONTENTLENGTH > 0) != 0, HPE_UNEXPECTED_CONTENT_LENGTH) - parser.flags |= F_CONTENTLENGTH - parser.content_length = UInt64(ch - '0') - - elseif parser.header_state == h_connection - #= looking for 'Connection: keep-alive' =# - if c == 'k' - parser.header_state = h_matching_connection_keep_alive - #= looking for 'Connection: close' =# - elseif c == 'c' - parser.header_state = h_matching_connection_close - elseif c == 'u' - parser.header_state = h_matching_connection_upgrade - else - parser.header_state = h_matching_connection_token - end - #= Multi-value `Connection` header =# - elseif parser.header_state == h_matching_connection_token_start - else - parser.header_state = h_general - end - - elseif p_state == s_header_value - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - start = p - h = parser.header_state - while p <= len - @inbounds ch = Char(bytes[p]) - @debug(PARSING_DEBUG, Base.escape_string(string('\'', ch, '\''))) - @debug(PARSING_DEBUG, lenient) - @debug(PARSING_DEBUG, isheaderchar(ch)) - if ch == CR - p_state = s_header_almost_done - parser.header_state = h - parser.state = p_state - @debug(PARSING_DEBUG, "onheadervalue 1") - onheadervalue(parser, r, bytes, header_value_mark, p - 1, issetcookie, host, KEY, canonicalizeheaders) - header_value_mark = 0 - break - elseif ch == LF - p_state = s_header_almost_done - @nread(p - start) - parser.header_state = h - parser.state = p_state - @debug(PARSING_DEBUG, "onheadervalue 2") - onheadervalue(parser, r, bytes, header_value_mark, p - 1, issetcookie, host, KEY, canonicalizeheaders) - header_value_mark = 0 - @goto reexecute - elseif !lenient && !isheaderchar(ch) - @err(HPE_INVALID_HEADER_TOKEN) - end - - c = lower(ch) - - if h == h_general - @debug(PARSING_DEBUG, parser.header_state) - limit = len - p - limit = min(limit, maxheader) - ptr = pointer(bytes, p) - @debug(PARSING_DEBUG, Base.escape_string(string('\'', Char(bytes[p]), '\''))) - p_cr = ccall(:memchr, Ptr{Cvoid}, (Ptr{Cvoid}, Cint, Csize_t), ptr, CR, limit) - p_lf = ccall(:memchr, Ptr{Cvoid}, (Ptr{Cvoid}, Cint, Csize_t), ptr, LF, limit) - @debug(PARSING_DEBUG, limit) - @debug(PARSING_DEBUG, Int(p_cr)) - @debug(PARSING_DEBUG, Int(p_lf)) - if p_cr != C_NULL - if p_lf != C_NULL && p_cr >= p_lf - @debug(PARSING_DEBUG, Base.escape_string(string('\'', Char(bytes[p + Int(p_lf - ptr + 1)]), '\''))) - p += Int(p_lf - ptr) - else - @debug(PARSING_DEBUG, Base.escape_string(string('\'', Char(bytes[p + Int(p_cr - ptr + 1)]), '\''))) - p += Int(p_cr - ptr) - end - elseif p_lf != C_NULL - @debug(PARSING_DEBUG, Base.escape_string(string('\'', Char(bytes[p + Int(p_lf - ptr + 1)]), '\''))) - p += Int(p_lf - ptr) - else - @debug(PARSING_DEBUG, Base.escape_string(string('\'', Char(bytes[len]), '\''))) - p = len + 1 - end - p -= 1 - - elseif h == h_connection || h == h_transfer_encoding - error("Shouldn't get here.") - elseif h == h_content_length - t = UInt64(0) - if ch == ' ' - else - if !isnum(ch) - parser.header_state = h - @err(HPE_INVALID_CONTENT_LENGTH) - end - t = parser.content_length - t *= UInt64(10) - t += UInt64(ch - '0') - - #= Overflow? Test against a conservative limit for simplicity. =# - @debug(PARSING_DEBUG, "this content_length 1") - @debug(PARSING_DEBUG, Int(parser.content_length)) - if div(ULLONG_MAX - 10, 10) < t - parser.header_state = h - @err(HPE_INVALID_CONTENT_LENGTH) - elseif t > maxbody - @err(HPE_BODY_OVERFLOW) - end - parser.content_length = t - end - - #= Transfer-Encoding: chunked =# - elseif h == h_matching_transfer_encoding_chunked - parser.index += 1 - if parser.index > length(CHUNKED) || c != CHUNKED[parser.index] - h = h_general - elseif parser.index == length(CHUNKED) - h = h_transfer_encoding_chunked - end - - elseif h == h_matching_connection_token_start - #= looking for 'Connection: keep-alive' =# - if c == 'k' - h = h_matching_connection_keep_alive - #= looking for 'Connection: close' =# - elseif c == 'c' - h = h_matching_connection_close - elseif c == 'u' - h = h_matching_connection_upgrade - elseif tokens[Int(c)+1] > '\0' - h = h_matching_connection_token - elseif c == ' ' || c == '\t' - #= Skip lws =# - else - h = h_general - end - #= looking for 'Connection: keep-alive' =# - elseif h == h_matching_connection_keep_alive - parser.index += 1 - if parser.index > length(KEEP_ALIVE) || c != KEEP_ALIVE[parser.index] - h = h_matching_connection_token - elseif parser.index == length(KEEP_ALIVE) - h = h_connection_keep_alive - end - - #= looking for 'Connection: close' =# - elseif h == h_matching_connection_close - parser.index += 1 - if parser.index > length(CLOSE) || c != CLOSE[parser.index] - h = h_matching_connection_token - elseif parser.index == length(CLOSE) - h = h_connection_close - end - - #= looking for 'Connection: upgrade' =# - elseif h == h_matching_connection_upgrade - parser.index += 1 - if parser.index > length(UPGRADE) || c != UPGRADE[parser.index] - h = h_matching_connection_token - elseif parser.index == length(UPGRADE) - h = h_connection_upgrade - end - - elseif h == h_matching_connection_token - if ch == ',' - h = h_matching_connection_token_start - parser.index = 1 - end - - elseif h == h_transfer_encoding_chunked - if ch != ' ' - h = h_general - end - - elseif h in (h_connection_keep_alive, h_connection_close, h_connection_upgrade) - if ch == ',' - if h == h_connection_keep_alive - parser.flags |= F_CONNECTION_KEEP_ALIVE - elseif h == h_connection_close - parser.flags |= F_CONNECTION_CLOSE - elseif h == h_connection_upgrade - parser.flags |= F_CONNECTION_UPGRADE - end - h = h_matching_connection_token_start - parser.index = 1 - elseif ch != ' ' - h = h_matching_connection_token - end - - else - p_state = s_header_value - h = h_general - end - p += 1 - end - parser.header_state = h - - @nread(p - start) - - if p == len - p -= 1 - end - - elseif p_state == s_header_almost_done - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @errorif(ch != LF, HPE_LF_EXPECTED) - p_state = s_header_value_lws - - elseif p_state == s_header_value_lws - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if ch == ' ' || ch == '\t' - p_state = s_header_value_start - @goto reexecute - end - #= finished the header =# - if parser.header_state == h_connection_keep_alive - parser.flags |= F_CONNECTION_KEEP_ALIVE - elseif parser.header_state == h_connection_close - parser.flags |= F_CONNECTION_CLOSE - elseif parser.header_state == h_transfer_encoding_chunked - parser.flags |= F_CHUNKED - elseif parser.header_state == h_connection_upgrade - parser.flags |= F_CONNECTION_UPGRADE - end - - p_state = s_header_field_start - @goto reexecute - - elseif p_state == s_header_value_discard_ws_almost_done - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @strictcheck(ch != LF) - p_state = s_header_value_discard_lws - - elseif p_state == s_header_value_discard_lws - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - if ch == ' ' || ch == '\t' - p_state = s_header_value_discard_ws - else - if parser.header_state == h_connection_keep_alive - parser.flags |= F_CONNECTION_KEEP_ALIVE - elseif parser.header_state == h_connection_close - parser.flags |= F_CONNECTION_CLOSE - elseif parser.header_state == h_connection_upgrade - parser.flags |= F_CONNECTION_UPGRADE - elseif parser.header_state == h_transfer_encoding_chunked - parser.flags |= F_CHUNKED - end - - #= header value was empty =# - header_value_mark = p - p_state = s_header_field_start - parser.state = p_state - @debug(PARSING_DEBUG, "onheadervalue 3") - onheadervalue(parser, r, bytes, header_value_mark, p - 1, issetcookie, host, KEY, canonicalizeheaders) - header_value_mark = 0 - @goto reexecute - end - - elseif p_state == s_headers_almost_done - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @strictcheck(ch != LF) - if (parser.flags & F_TRAILING) > 0 - #= End of a chunked request =# - p_state = s_message_done - @goto reexecute - end - - #= Cannot use chunked encoding and a content-length header together - per the HTTP specification. =# - @errorif((parser.flags & F_CHUNKED) > 0 && (parser.flags & F_CONTENTLENGTH) > 0, HPE_UNEXPECTED_CONTENT_LENGTH) - - p_state = s_headers_done - - #= Set this here so that on_headers_complete() callbacks can see it =# - @debug(PARSING_DEBUG, "checking for upgrade...") - if (parser.flags & F_UPGRADE > 0) && (parser.flags & F_CONNECTION_UPGRADE > 0) - upgrade = typeof(r) == Request || r.status == 101 - else - upgrade = typeof(r) == Request && r.method == CONNECT - end - @debug(PARSING_DEBUG, upgrade) - #= Here we call the headers_complete callback. This is somewhat - * different than other callbacks because if the user returns 1, we - * will interpret that as saying that this message has no body. This - * is needed for the annoying case of recieving a response to a HEAD - * request. - * - * We'd like to use CALLBACK_NOTIFY_NOADVANCE() here but we cannot, so - * we have to simulate it by handling a change in errno below. - =# - onheaderscomplete(r) - headersdone = true - if method == HEAD - parser.flags |= F_SKIPBODY - elseif method == CONNECT - upgrade = true - end - @goto reexecute - - elseif p_state == s_headers_done - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - @strictcheck(ch != LF) - - parser.nread = UInt32(0) - - hasBody = parser.flags & F_CHUNKED > 0 || - (parser.content_length > 0 && parser.content_length != ULLONG_MAX) - if upgrade && ((typeof(r) == Request && r.method == CONNECT) || - (parser.flags & F_SKIPBODY) > 0 || !hasBody) - #= Exit, the rest of the message is in a different protocol. =# - p_state = ifelse(http_should_keep_alive(parser, r), start_state, s_dead) - parser.state = p_state - onmessagecomplete(r) - @debug(PARSING_DEBUG, "this 1") - return errno, true, true, String(bytes[p+1:end]) - end - - if parser.flags & F_SKIPBODY > 0 - p_state = ifelse(http_should_keep_alive(parser, r), start_state, s_dead) - parser.state = p_state - onmessagecomplete(r) - @debug(PARSING_DEBUG, "this 2") - return errno, true, true, "" - elseif parser.flags & F_CHUNKED > 0 - #= chunked encoding - ignore Content-Length header =# - p_state = s_chunk_size_start - else - if parser.content_length == 0 - #= Content-Length header given but zero: Content-Length: 0\r\n =# - p_state = ifelse(http_should_keep_alive(parser, r), start_state, s_dead) - parser.state = p_state - onmessagecomplete(r) - @debug(PARSING_DEBUG, "this 3") - return errno, true, true, "" - elseif parser.content_length != ULLONG_MAX - #= Content-Length header given and non-zero =# - p_state = s_body_identity - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - else - if !http_message_needs_eof(parser, r) - #= Assume content-length 0 - read the next =# - p_state = ifelse(http_should_keep_alive(parser, r), start_state, s_dead) - parser.state = p_state - onmessagecomplete(r) - @debug(PARSING_DEBUG, "this 4") - return errno, true, true, String(bytes[p+1:end]) - else - #= Read body until EOF =# - p_state = s_body_identity_eof - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - end - end - end - - elseif p_state == s_body_identity - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - to_read = UInt64(min(parser.content_length, len - p + 1)) - to_read > maxbody && @err(HPE_BODY_OVERFLOW) - assert(parser.content_length != 0 && parser.content_length != ULLONG_MAX) - - #= The difference between advancing content_length and p is because - * the latter will automaticaly advance on the next loop iteration. - * Further, if content_length ends up at 0, we want to see the last - * byte again for our message complete callback. - =# - body_mark = p - parser.content_length -= to_read - p += Int(to_read) - 1 - - if parser.content_length == 0 - p_state = s_message_done - - #= Mimic CALLBACK_DATA_NOADVANCE() but with one extra byte. - * - * The alternative to doing this is to wait for the next byte to - * trigger the data callback, just as in every other case. The - * problem with this is that this makes it difficult for the test - * harness to distinguish between complete-on-EOF and - * complete-on-length. It's not clear that this distinction is - * important for applications, but let's keep it for now. - =# - @debug(PARSING_DEBUG, "this onbody 1") - onbody(r, maintask, bytes, body_mark, p) - body_mark = 0 - @goto reexecute - end - - #= read until EOF =# - elseif p_state == s_body_identity_eof - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - body_mark = p - p = len - - elseif p_state == s_message_done - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - # p_state = ifelse(http_should_keep_alive(parser, r), start_state, s_dead) - parser.state = p_state - onmessagecomplete(r) - @debug(PARSING_DEBUG, "this 5") - if upgrade - #= Exit, the rest of the message is in a different protocol. =# - parser.state = p_state - return errno, true, true, String(bytes[p+1:end]) - end - - elseif p_state == s_chunk_size_start - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - assert(parser.nread == 1) - assert(parser.flags & F_CHUNKED > 0) - - unhex_val = unhex[Int(ch)+1] - @errorif(unhex_val == -1, HPE_INVALID_CHUNK_SIZE) - - parser.content_length = unhex_val - p_state = s_chunk_size - - elseif p_state == s_chunk_size - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - assert(parser.flags & F_CHUNKED > 0) - if ch == CR - p_state = s_chunk_size_almost_done - else - unhex_val = unhex[Int(ch)+1] - @debug(PARSING_DEBUG, unhex_val) - if unhex_val == -1 - if ch == ';' || ch == ' ' - p_state = s_chunk_parameters - @goto breakout - end - @err(HPE_INVALID_CHUNK_SIZE) - end - t = parser.content_length - t *= UInt64(16) - t += UInt64(unhex_val) - - #= Overflow? Test against a conservative limit for simplicity. =# - @debug(PARSING_DEBUG, "this content_length 2") - @debug(PARSING_DEBUG, Int(parser.content_length)) - if div(ULLONG_MAX - 16, 16) < t - @err(HPE_INVALID_CONTENT_LENGTH) - end - parser.content_length = t - end - - elseif p_state == s_chunk_parameters - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - assert(parser.flags & F_CHUNKED > 0) - #= just ignore this shit. TODO check for overflow =# - if ch == CR - p_state = s_chunk_size_almost_done - end - - elseif p_state == s_chunk_size_almost_done - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - assert(parser.flags & F_CHUNKED > 0) - @strictcheck(ch != LF) - - parser.nread = 0 - - if parser.content_length == 0 - parser.flags |= F_TRAILING - p_state = s_header_field_start - else - p_state = s_chunk_data - end - - elseif p_state == s_chunk_data - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - to_read = UInt64(min(parser.content_length, len - p + 1)) - - assert(parser.flags & F_CHUNKED > 0) - assert(parser.content_length != 0 && parser.content_length != ULLONG_MAX) - - #= See the explanation in s_body_identity for why the content - * length and data pointers are managed this way. - =# - body_mark = p - parser.content_length -= to_read - p += Int(to_read) - 1 - - if parser.content_length == 0 - p_state = s_chunk_data_almost_done - end - - elseif p_state == s_chunk_data_almost_done - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - assert(parser.flags & F_CHUNKED > 0) - assert(parser.content_length == 0) - @strictcheck(ch != CR) - p_state = s_chunk_data_done - @debug(PARSING_DEBUG, "this onbody 2") - body_mark > 0 && onbody(r, maintask, bytes, body_mark, p - 1) - body_mark = 0 - - elseif p_state == s_chunk_data_done - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - assert(parser.flags & F_CHUNKED > 0) - @strictcheck(ch != LF) - parser.nread = 0 - p_state = s_chunk_size_start - - else - error("unhandled state") - end - @label breakout - p += 1 - end - - #= Run callbacks for any marks that we have leftover after we ran our of - * bytes. There should be at most one of these set, so it's OK to invoke - * them in series (unset marks will not result in callbacks). - * - * We use the NOADVANCE() variety of callbacks here because 'p' has already - * overflowed 'data' and this allows us to correct for the off-by-one that - * we'd otherwise have (since CALLBACK_DATA() is meant to be run with a 'p' - * value that's in-bounds). - =# - - assert(((header_field_mark > 0 ? 1 : 0) + - (header_value_mark > 0 ? 1 : 0) + - (url_mark > 0 ? 1 : 0) + - (body_mark > 0 ? 1 : 0) + - (status_mark > 0 ? 1 : 0)) <= 1) - - header_field_mark > 0 && onheaderfield(parser, bytes, header_field_mark, min(len, p)) - @debug(PARSING_DEBUG, "onheadervalue 4") - @debug(PARSING_DEBUG, len) - @debug(PARSING_DEBUG, p) - header_value_mark > 0 && onheadervalue(parser, bytes, header_value_mark, min(len, p)) - url_mark > 0 && (min(len, p) - url_mark > maxuri) && @err(HPE_URI_OVERFLOW) - url_mark > 0 && onurl(r, bytes, url_mark, min(len, p)) - @debug(PARSING_DEBUG, "this onbody 3") - body_mark > 0 && onbody(r, maintask, bytes, body_mark, min(len, p - 1)) - status_mark > 0 && onstatus(r) - - parser.state = p_state - @debug(PARSING_DEBUG, "exiting maybe unfinished...") - @debug(PARSING_DEBUG, ParsingStateCode(p_state)) - b = p_state == start_state || p_state == s_dead - he = b | (headersdone || p_state >= s_headers_done) - m = b | (p_state >= s_message_done) - return errno, he, m, String(bytes[p:end]) - - @label error - if errno == HPE_OK - errno = HPE_UNKNOWN - end - - parser.state = s_start_req_or_res - parser.header_state = 0x00 - @debug(PARSING_DEBUG, "exiting due to error...") - @debug(PARSING_DEBUG, errno) - return errno, false, false, "" -end - -#= Does the parser need to see an EOF to find the end of the message? =# -http_message_needs_eof(parser, r::Request) = false -function http_message_needs_eof(parser, r::Response) - #= See RFC 2616 section 4.4 =# - if (div(r.status, 100) == 1 || #= 1xx e.g. Continue =# - r.status == 204 || #= No Content =# - r.status == 304 || #= Not Modified =# - parser.flags & F_SKIPBODY > 0) #= response to a HEAD request =# - return false - end - - if (parser.flags & F_CHUNKED > 0) || parser.content_length != ULLONG_MAX - return false - end - - return true -end - -function http_should_keep_alive(parser, r) - if r.major > 0 && r.minor > 0 - #= HTTP/1.1 =# - if parser.flags & F_CONNECTION_CLOSE > 0 - return false - end - else - #= HTTP/1.0 or earlier =# - if !(parser.flags & F_CONNECTION_KEEP_ALIVE > 0) - return false - end - end - - return !http_message_needs_eof(parser, r) -end diff --git a/src/parseutils.jl b/src/parseutils.jl new file mode 100644 index 000000000..360d169c6 --- /dev/null +++ b/src/parseutils.jl @@ -0,0 +1,35 @@ +# parsing utils +macro anyeq(var, vals...) + ret = e = Expr(:||) + for (i, v) in enumerate(vals) + x = :($var == $v) + push!(e.args, x) + i >= length(vals) - 1 && continue + ne = Expr(:||) + push!(e.args, ne) + e = ne + end + return esc(ret) +end + +@inline lower(c) = Char(UInt32(c) | 0x20) +@inline ismark(c) = @anyeq(c, '-', '_', '.', '!', '~', '*', '\'', '(', ')') +@inline isalpha(c) = 'a' <= lower(c) <= 'z' +@inline isnum(c) = '0' <= c <= '9' +@inline isalphanum(c) = isalpha(c) || isnum(c) +@inline isuserinfochar(c) = isalphanum(c) || ismark(c) || @anyeq(c, '%', ';', ':', '&', '=', '+', '$', ',') +@inline ishex(c) = isnum(c) || ('a' <= lower(c) <= 'f') +@inline ishostchar(c) = isalphanum(c) || @anyeq(c, '.', '-', '_', '~') +@inline isheaderchar(c) = c == CR || c == LF || c == Char(9) || (c > Char(31) && c != Char(127)) + +""" + escapelines(string) + +Escape `string` and insert '\n' after escaped newline characters. +""" + +function escapelines(s) + s = Base.escape_string(s) + s = replace(s, "\\n", "\\n\n ") + return string(" ", strip(s)) +end diff --git a/src/precompile.jl b/src/precompile.jl index 4caf645e6..eb87a25d4 100644 --- a/src/precompile.jl +++ b/src/precompile.jl @@ -1,28 +1,35 @@ function _precompile_() ccall(:jl_generating_output, Cint, ()) == 1 || return nothing + + println("Precompiling...") +# @assert precompile(HTTP.request, (String, String,)) +# @assert precompile(HTTP.request, (String, URI, Headers, Vector{UInt8},)) +#= @assert precompile(HTTP.URIs.parseurlchar, (UInt8, Char, Bool,)) @assert precompile(HTTP.status, (HTTP.Response,)) @assert precompile(HTTP.Cookies.pathmatch, (HTTP.Cookies.Cookie, String,)) @assert precompile(HTTP.onheaderfield, (HTTP.Parser, Array{UInt8, 1}, Int64, Int64,)) @assert precompile(HTTP.isjson, (Array{UInt8, 1}, UInt64, Int64,)) - @assert precompile(HTTP.onheadervalue, (HTTP.Parser, HTTP.Response, Array{UInt8, 1}, Int64, Int64, Bool, String, Base.RefValue{String}, Bool)) + @assert precompile(HTTP.onheadervalue, (HTTP.Parser, HTTP.Response, Array{UInt8, 1}, Int64, Int64, Bool, String,)) @assert precompile(HTTP.isjson, (Array{UInt8, 1}, Int64, Int64,)) - @assert precompile(HTTP.onurl, (HTTP.Response, Array{UInt8, 1}, Int64, Int64,)) + @assert precompile(HTTP.onurlbytes, (HTTP.Parser, Array{UInt8, 1}, Int64, Int64,)) + @assert precompile(HTTP.onurl, (HTTP.Parser,)) @assert precompile(HTTP.Response, (Int64, String,)) @assert precompile(HTTP.URIs.getindex, (Array{UInt8, 1}, HTTP.URIs.Offset,)) @assert precompile(HTTP.iscompressed, (Array{UInt8, 1},)) @assert precompile(HTTP.Cookies.readcookies, (Base.Dict{String, String}, String,)) - @assert precompile(HTTP.canonicalize!, (String,)) + @assert precompile(HTTP.tocameldash!, (String,)) + @assert precompile(HTTP.canonicalizeheaders, (Dict{String,String},)) @assert precompile(HTTP.URIs.http_parse_host_char, (HTTP.URIs.http_host_state, Char,)) @assert precompile(HTTP.Form, (Base.Dict{String, Any},)) @assert precompile(HTTP.Cookies.hasdotsuffix, (String, String,)) @assert precompile(HTTP.onheadervalue, (HTTP.Parser, Array{UInt8, 1}, Int64, Int64,)) @assert precompile(HTTP.Cookies.parsecookievalue, (String, Bool,)) - @assert precompile(HTTP.processresponse!, (HTTP.Client, HTTP.Connection{Base.TCPSocket}, HTTP.Response, String, HTTP.Method, Task, Bool, Float64, Bool, Bool)) + @assert precompile(HTTP.processresponse!, (HTTP.Client, HTTP.Connection{Base.TCPSocket}, HTTP.Response, String, HTTP.Method, Task, Bool, Float64, Bool)) @assert precompile(HTTP.Request, (HTTP.Method, HTTP.URIs.URI, Base.Dict{String, String}, HTTP.FIFOBuffers.FIFOBuffer,)) @assert precompile(HTTP.Cookies.isCookieDomainName, (String,)) @assert precompile(HTTP.getbytes, (Base.TCPSocket, Float64)) - @assert precompile(HTTP.FIFOBuffers.write, (HTTP.FIFOBuffers.FIFOBuffer, Array{UInt8, 1}, Int64, Int64,)) +# @assert precompile(HTTP.FIFOBuffers.write, (HTTP.FIFOBuffers.FIFOBuffer, Array{UInt8, 1}, Int64, Int64,)) @assert precompile(HTTP.URIs.port, (HTTP.URIs.URI,)) @assert precompile(HTTP.read, (HTTP.Form,)) @assert precompile(HTTP.get, (HTTP.Nitrogen.ServerOptions, Symbol, Int64,)) @@ -30,9 +37,9 @@ function _precompile_() @assert precompile(HTTP.Response, ()) @assert precompile(HTTP.Cookies.string, (String, Array{HTTP.Cookies.Cookie, 1}, Bool,)) @assert precompile(HTTP.FIFOBuffers.read, (HTTP.FIFOBuffers.FIFOBuffer, Int64,)) - @assert precompile(HTTP.processresponse!, (HTTP.Client, HTTP.Connection{MbedTLS.SSLContext}, HTTP.Response, String, HTTP.Method, Task, Bool, Float64, Bool, Bool)) + @assert precompile(HTTP.processresponse!, (HTTP.Client, HTTP.Connection{MbedTLS.SSLContext}, HTTP.Response, String, HTTP.Method, Task, Bool, Float64, Bool)) @assert precompile(HTTP.Form, (Base.Dict{String, String},)) - @assert precompile(HTTP.FIFOBuffers.String, (HTTP.FIFOBuffers.FIFOBuffer,)) + #@assert precompile(HTTP.FIFOBuffers.String, (HTTP.FIFOBuffers.FIFOBuffer,)) @assert precompile(HTTP.update!, (HTTP.RequestOptions, HTTP.RequestOptions,)) @assert precompile(HTTP.restofstring, (String, Int64, Int64,)) @assert precompile(HTTP.ismatch, (Type{HTTP.MP4Sig}, Array{UInt8, 1}, Int64,)) @@ -55,13 +62,13 @@ function _precompile_() @assert precompile(HTTP.sniff, (String,)) @assert precompile(HTTP.restofstring, (Array{UInt8, 1}, UInt64, Int64,)) @assert precompile(HTTP.stalebytes!, (Base.TCPSocket,)) - @assert precompile(HTTP.FIFOBuffers.write, (HTTP.FIFOBuffers.FIFOBuffer, UInt8,)) +# @assert precompile(HTTP.FIFOBuffers.write, (HTTP.FIFOBuffers.FIFOBuffer, UInt8,)) @assert precompile(HTTP.eof, (HTTP.Form,)) @assert precompile(HTTP.Cookies.readsetcookie, (String, String,)) @assert precompile(HTTP.Response, (Int64, HTTP.Request,)) @assert precompile(HTTP.URIs.http_parser_parse_url, (Array{UInt8, 1}, Int64, Int64, Bool,)) @assert precompile(HTTP.Response, (String,)) - @assert precompile(HTTP.FIFOBuffers.read, (HTTP.FIFOBuffers.FIFOBuffer, Type{Tuple{UInt8, Bool}},)) +# @assert precompile(HTTP.FIFOBuffers.read, (HTTP.FIFOBuffers.FIFOBuffer, Type{Tuple{UInt8, Bool}},)) @assert precompile(HTTP.Response, (Int64, Base.Dict{String, String}, String,)) @assert precompile(HTTP.ismatch, (HTTP.Masked, Array{UInt8, 1}, Int64,)) @assert precompile(HTTP.Cookies.sanitizeCookieValue, (String,)) @@ -78,17 +85,17 @@ function _precompile_() @assert precompile(HTTP.restofstring, (Array{UInt8, 1}, Int64, Int64,)) @assert precompile(HTTP.dead!, (HTTP.Connection{Base.TCPSocket},)) @assert precompile(HTTP.addcookies!, (HTTP.Client, String, HTTP.Request, Bool,)) - @assert precompile(HTTP.FIFOBuffers.length, (HTTP.FIFOBuffers.FIFOBuffer,)) +# @assert precompile(HTTP.FIFOBuffers.length, (HTTP.FIFOBuffers.FIFOBuffer,)) @assert precompile(HTTP.Cookies.domainandtype, (String, String,)) @assert precompile(HTTP.sniff, (Array{UInt8, 1},)) @assert precompile(HTTP.mark, (HTTP.Multipart{Base.IOStream},)) @assert precompile(HTTP.seek, (HTTP.Form, Int64,)) - @assert precompile(HTTP.onheadervalue, (HTTP.Parser, HTTP.Request, Array{UInt8, 1}, Int64, Int64, Bool, String, Base.RefValue{String}, Bool)) + @assert precompile(HTTP.onheadervalue, (HTTP.Parser, HTTP.Request, Array{UInt8, 1}, Int64, Int64, Bool, String)) @assert precompile(HTTP.FIFOBuffers.write, (HTTP.FIFOBuffers.FIFOBuffer, String,)) @assert precompile(HTTP.URIs.escape, (String, String,)) @assert precompile(HTTP.dead!, (HTTP.Connection{MbedTLS.SSLContext},)) @assert precompile(HTTP.URIs.isvalid, (HTTP.URIs.URI,)) - @assert precompile(HTTP.FIFOBuffers.read, (HTTP.FIFOBuffers.FIFOBuffer, Type{UInt8},)) +# @assert precompile(HTTP.FIFOBuffers.read, (HTTP.FIFOBuffers.FIFOBuffer, Type{UInt8},)) @assert precompile(HTTP.getconnections, (Type{HTTP.http}, HTTP.Client, String,)) @assert precompile(HTTP.Cookies.validCookieDomain, (String,)) @assert precompile(HTTP.headers, (HTTP.Response,)) @@ -98,14 +105,14 @@ function _precompile_() @assert precompile(HTTP.body, (HTTP.Response,)) @assert precompile(HTTP.http_should_keep_alive, (HTTP.Parser, HTTP.Request,)) @assert precompile(HTTP.length, (HTTP.Form,)) - @assert precompile(HTTP.FIFOBuffers.position, (HTTP.FIFOBuffers.FIFOBuffer,)) +# @assert precompile(HTTP.FIFOBuffers.position, (HTTP.FIFOBuffers.FIFOBuffer,)) @assert precompile(HTTP.URIs.escape, (String,)) @assert precompile(HTTP.busy!, (HTTP.Connection{Base.TCPSocket},)) @assert precompile(HTTP.connect, (HTTP.Client, HTTP.http, String, String, HTTP.RequestOptions, Bool,)) @assert precompile(HTTP.string, (HTTP.Request,)) @assert precompile(HTTP.parse!, (HTTP.Request, HTTP.Parser, Array{UInt8, 1},)) - @assert precompile(HTTP.parse!, (HTTP.Request, HTTP.Parser, Array{UInt8, 1}, Int64, Bool, String, HTTP.Method, Int64, Int64, Int64, Task, Bool)) - @assert precompile(HTTP.parse!, (HTTP.Response, HTTP.Parser, Array{UInt8, 1}, Int64, Bool, String, HTTP.Method, Int64, Int64, Int64, Task, Bool)) + @assert precompile(HTTP.parse!, (HTTP.Request, HTTP.Parser, Array{UInt8, 1}, Int64, String, HTTP.Method, Task)) + @assert precompile(HTTP.parse!, (HTTP.Response, HTTP.Parser, Array{UInt8, 1}, Int64, String, HTTP.Method, Task)) @assert precompile(HTTP.onbody, (HTTP.Request, Task, Array{UInt8, 1}, Int64, Int64,)) @assert precompile(HTTP.take!, (HTTP.Response,)) @assert precompile(HTTP.Response, (String,)) @@ -120,45 +127,45 @@ function _precompile_() @assert precompile(HTTP.haskey, (Type{HTTP.http}, HTTP.Client, String,)) @assert precompile(HTTP.parse!, (HTTP.Response, HTTP.Parser, Array{UInt8, 1},)) @assert precompile(HTTP.reset, (HTTP.Multipart{Base.IOStream},)) - @assert precompile(HTTP.FIFOBuffers.seek, (HTTP.FIFOBuffers.FIFOBuffer, Tuple{Int64, Int64, Int64},)) - @assert precompile(HTTP.FIFOBuffers.wait, (HTTP.FIFOBuffers.FIFOBuffer,)) +# @assert precompile(HTTP.FIFOBuffers.seek, (HTTP.FIFOBuffers.FIFOBuffer, Tuple{Int64, Int64, Int64},)) +# @assert precompile(HTTP.FIFOBuffers.wait, (HTTP.FIFOBuffers.FIFOBuffer,)) @assert precompile(HTTP.initTLS!, (Type{HTTP.https}, String, HTTP.RequestOptions, Base.TCPSocket,)) @assert precompile(HTTP.stalebytes!, (MbedTLS.SSLContext,)) @assert precompile(HTTP.sniff, (Base.IOStream,)) @assert precompile(HTTP.request, (HTTP.Client, HTTP.Request, HTTP.RequestOptions, Bool, Array{HTTP.Response, 1}, Int, Bool,)) @assert precompile(HTTP.read, (HTTP.Multipart{Base.IOStream}, Int64,)) @assert precompile(HTTP.isjson, (Array{UInt8, 1},)) - @assert precompile(HTTP.FIFOBuffers.close, (HTTP.FIFOBuffers.FIFOBuffer,)) +# @assert precompile(HTTP.FIFOBuffers.close, (HTTP.FIFOBuffers.FIFOBuffer,)) @assert precompile(HTTP.headers, (HTTP.Request,)) @assert precompile(HTTP.busy!, (HTTP.Connection{MbedTLS.SSLContext},)) @assert precompile(HTTP.seek, (HTTP.Form, Tuple{Int64, Int64, Int64},)) - @assert precompile(HTTP.FIFOBuffers.eof, (HTTP.FIFOBuffers.FIFOBuffer,)) +# @assert precompile(HTTP.FIFOBuffers.eof, (HTTP.FIFOBuffers.FIFOBuffer,)) @assert precompile(HTTP.contenttype, (HTTP.Masked,)) @assert precompile(HTTP.get, (HTTP.RequestOptions, Symbol, MbedTLS.SSLConfig,)) @assert precompile(HTTP.contenttype, (HTTP.Exact,)) - @assert precompile(HTTP.FIFOBuffers.FIFOBuffer, (HTTP.FIFOBuffers.FIFOBuffer,)) +# @assert precompile(HTTP.FIFOBuffers.FIFOBuffer, (HTTP.FIFOBuffers.FIFOBuffer,)) @assert precompile(HTTP.haskey, (Type{HTTP.https}, HTTP.Client, String,)) - @assert precompile(HTTP.FIFOBuffers.readavailable, (HTTP.FIFOBuffers.FIFOBuffer,)) +# @assert precompile(HTTP.FIFOBuffers.readavailable, (HTTP.FIFOBuffers.FIFOBuffer,)) @assert precompile(HTTP.string, (HTTP.Response, HTTP.Nitrogen.ServerOptions,)) @assert precompile(HTTP.string, (HTTP.Response, HTTP.RequestOptions,)) @assert precompile(HTTP.string, (HTTP.Request, HTTP.RequestOptions,)) @assert precompile(HTTP.Request, (String,)) @assert precompile(HTTP.ismatch, (Type{HTTP.JSONSig}, Array{UInt8, 1}, Int64,)) @assert precompile(HTTP.hasmessagebody, (HTTP.Request,)) - @assert precompile(HTTP.FIFOBuffers.write, (HTTP.FIFOBuffers.FIFOBuffer, Array{UInt8, 1},)) +# @assert precompile(HTTP.FIFOBuffers.write, (HTTP.FIFOBuffers.FIFOBuffer, Array{UInt8, 1},)) @assert precompile(HTTP.readavailable, (HTTP.Form,)) @assert precompile(HTTP.get, (String,)) @assert precompile(HTTP.URL, (String,)) @assert precompile(HTTP.request, (HTTP.Client, HTTP.Method, HTTP.URI,)) @assert precompile(HTTP.RequestOptions, ()) - @assert precompile(HTTP.Request, (HTTP.Method, HTTP.URI, Dict{String, String}, HTTP.FIFOBuffer)) +# @assert precompile(HTTP.Request, (HTTP.Method, HTTP.URI, Dict{String, String}, HTTP.FIFOBuffer)) @assert precompile(HTTP.request, (HTTP.Request,)) @assert precompile(HTTP.request, (HTTP.Client, HTTP.Request)) @assert precompile(HTTP.request, (HTTP.Client, HTTP.Request, HTTP.RequestOptions, Bool, Vector{HTTP.Response}, Int, Bool)) @static if VERSION < v"0.7-DEV" @assert precompile(HTTP.Client, (Base.AbstractIOBuffer{Array{UInt8, 1}}, HTTP.RequestOptions,)) @assert precompile(HTTP.URIs.printuri, (Base.AbstractIOBuffer{Array{UInt8, 1}}, String, String, String, String, String, String, String,)) - @assert precompile(HTTP.FIFOBuffers.FIFOBuffer, (Base.AbstractIOBuffer{Array{UInt8, 1}},)) +# @assert precompile(HTTP.FIFOBuffers.FIFOBuffer, (Base.AbstractIOBuffer{Array{UInt8, 1}},)) @assert precompile(HTTP.startline, (Base.AbstractIOBuffer{Array{UInt8, 1}}, HTTP.Response,)) @assert precompile(HTTP.writemultipartheader, (Base.AbstractIOBuffer{Array{UInt8, 1}}, HTTP.Multipart{Base.IOStream},)) @assert precompile(HTTP.print, (Base.AbstractIOBuffer{Array{UInt8, 1}}, HTTP.Method,)) @@ -178,7 +185,7 @@ function _precompile_() else @assert precompile(HTTP.Client, (Base.GenericIOBuffer{Array{UInt8, 1}}, HTTP.RequestOptions,)) @assert precompile(HTTP.URIs.printuri, (Base.GenericIOBuffer{Array{UInt8, 1}}, String, String, String, String, String, String, String,)) - @assert precompile(HTTP.FIFOBuffers.FIFOBuffer, (Base.GenericIOBuffer{Array{UInt8, 1}},)) +# @assert precompile(HTTP.FIFOBuffers.FIFOBuffer, (Base.GenericIOBuffer{Array{UInt8, 1}},)) @assert precompile(HTTP.startline, (Base.GenericIOBuffer{Array{UInt8, 1}}, HTTP.Response,)) @assert precompile(HTTP.writemultipartheader, (Base.GenericIOBuffer{Array{UInt8, 1}}, HTTP.Multipart{Base.IOStream},)) @assert precompile(HTTP.print, (Base.GenericIOBuffer{Array{UInt8, 1}}, HTTP.Method,)) @@ -196,5 +203,6 @@ function _precompile_() @assert precompile(HTTP.body, (Base.GenericIOBuffer{Array{UInt8, 1}}, HTTP.Request, HTTP.RequestOptions,)) @assert precompile(HTTP.startline, (Base.GenericIOBuffer{Array{UInt8, 1}}, HTTP.Request,)) end +=# end -_precompile_() \ No newline at end of file +_precompile_() diff --git a/src/server.jl b/src/server.jl deleted file mode 100644 index 6ae0b8458..000000000 --- a/src/server.jl +++ /dev/null @@ -1,358 +0,0 @@ -module Nitrogen - -if !isdefined(Base, :Nothing) - const Nothing = Void - const Cvoid = Void -end - -if VERSION < v"0.7.0-DEV.2575" - const Dates = Base.Dates -else - import Dates -end -@static if !isdefined(Base, :Distributed) - using Distributed -end - -using ..HTTP, ..Handlers - -export Server, ServerOptions, serve -#TODO: - # add in "events" handling - # dealing w/ cookies - # reverse proxy? - # auto-compression - # health report of server - # http authentication subrequests? - # ip access control lists? - # JWT authentication? - # live activity monitoring - # live reconfigure? - # memory/performance profiling for thousands of concurrent requests? - # fault tolerance? - # handle IPv6? - # flv & mp4 streaming? - # URL rewriting? - # bandwidth throttling - # IP address-based geolocation - # user-tracking - # WebDAV - # FastCGI - # default handler: - # handle all common http requests/etc. - # just map straight to filesystem - # special case OPTIONS method like go? - # buffer re-use for server/client wire-reading - # easter egg (response 418) -mutable struct ServerOptions - tlsconfig::HTTP.TLS.SSLConfig - readtimeout::Float64 - ratelimit::Rational{Int} - maxuri::Int64 - maxheader::Int64 - maxbody::Int64 - support100continue::Bool - chunksize::Union{Nothing, Int} - logbody::Bool -end - -ServerOptions(; tlsconfig::HTTP.TLS.SSLConfig=HTTP.TLS.SSLConfig(true), - readtimeout::Float64=180.0, - ratelimit::Rational{Int64}=Int64(5)//Int64(1), - maxuri::Int64=HTTP.DEFAULT_MAX_URI, - maxheader::Int64=HTTP.DEFAULT_MAX_HEADER, - maxbody::Int64=HTTP.DEFAULT_MAX_BODY, - support100continue::Bool=true, - chunksize::Union{Nothing, Int}=nothing, - logbody::Bool=true) = - ServerOptions(tlsconfig, readtimeout, ratelimit, maxbody, maxuri, maxheader, support100continue, chunksize, logbody) - -""" - Server(handler, logger::IO=STDOUT; kwargs...) - -An http/https server. Supports listening on a `host` and `port` via the `HTTP.serve(server, host, port)` function. -`handler` is a function of the form `f(::Request, ::Response) -> HTTP.Response`, i.e. it takes both a `Request` and pre-built `Response` -objects as inputs and returns the, potentially modified, `Response`. `logger` indicates where logging output should be directed. -When `HTTP.serve` is called, it aims to "never die", catching and recovering from all internal errors. To forcefully stop, one can obviously -kill the julia process, interrupt (ctrl/cmd+c) if main task, or send the kill signal over a server in channel like: -`put!(server.in, HTTP.KILL)`. - -Supported keyword arguments include: - * `cert`: if https, the cert file to use, as passed to `HTTP.TLS.SSLConfig(cert, key)` - * `key`: if https, the key file to use, as passed to `HTTP.TLS.SSLConfig(cert, key)` - * `tlsconfig`: pass in an already-constructed `HTTP.TLS.SSLConfig` instance - * `readtimeout`: how long a client connection will be left open without receiving any bytes - * `ratelimit`: a `Rational{Int}` of the form `5//1` indicating how many `messages//second` should be allowed per client IP address; requests exceeding the rate limit will be dropped - * `maxuri`: the maximum size in bytes that a request uri can be; default 8000 - * `maxheader`: the maximum size in bytes that request headers can be; default 8kb - * `maxbody`: the maximum size in bytes that a request body can be; default 4gb - * `support100continue`: a `Bool` indicating whether `Expect: 100-continue` headers should be supported for delayed request body sending; default = `true` - * `logbody`: whether the Response body should be logged when `verbose=true` logging is enabled; default = `true` -""" -mutable struct Server{T <: HTTP.Scheme, H <: HTTP.Handler} - handler::H - logger::IO - in::Channel{Any} - out::Channel{Any} - options::ServerOptions - - Server{T, H}(handler::H, logger::IO=STDOUT, ch=Channel(1), ch2=Channel(1), options=ServerOptions()) where {T, H} = new{T, H}(handler, logger, ch, ch2, options) -end - -backtrace() = sprint(Base.show_backtrace, catch_backtrace()) - -function process!(server::Server{T, H}, parser, request, i, tcp, rl, starttime, verbose) where {T, H} - handler, logger, options = server.handler, server.logger, server.options - startedprocessingrequest = error = shouldclose = alreadysent100continue = false - rate = Float64(server.options.ratelimit.num) - rl.allowance += 1.0 # because it was just decremented right before we got here - HTTP.@log "processing on connection i=$i..." - try - tsk = @async begin - request.body.task = current_task() - while isopen(tcp) - update!(rl, server.options.ratelimit) - if rl.allowance > rate - HTTP.@log "throttling on connection i=$i" - rl.allowance = rate - end - if rl.allowance < 1.0 - HTTP.@log "sleeping on connection i=$i due to rate limiting" - sleep(1.0) - else - rl.allowance -= 1.0 - HTTP.@log "reading request bytes with readtimeout=$(options.readtimeout)" - # EH: - buffer = try - readavailable(tcp) - catch e - UInt8[] - end - length(buffer) > 0 || break - starttime[] = time() # reset the timeout while still receiving bytes - errno, headerscomplete, messagecomplete, upgrade = HTTP.parse!(request, parser, buffer) - startedprocessingrequest = true - if errno != HTTP.HPE_OK - # error in parsing the http request - HTTP.@log "error parsing request on connection i=$i: $(HTTP.ParsingErrorCodeMap[errno])" - if errno == HTTP.HPE_INVALID_VERSION - response = HTTP.Response(505) - elseif errno == HTTP.HPE_HEADER_OVERFLOW - response = HTTP.Response(431) - elseif errno == HTTP.HPE_URI_OVERFLOW - response = HTTP.Response(414) - elseif errno == HTTP.HPE_BODY_OVERFLOW - response = HTTP.Response(413) - elseif errno == HTTP.HPE_INVALID_METHOD - response = HTTP.Response(405) - else - response = HTTP.Response(400) - end - error = true - elseif headerscomplete && Base.get(HTTP.headers(request), "Expect", "") == "100-continue" && !alreadysent100continue - if options.support100continue - HTTP.@log "sending 100 Continue response to get request body" - # EH: - try - write(tcp, HTTP.Response(100), options) - catch e - HTTP.@log e - error = true - end - parser.state = HTTP.s_body_identity - alreadysent100continue = true - continue - else - response = HTTP.Response(417) - error = true - end - elseif length(upgrade) > 0 - HTTP.@log "received upgrade request on connection i=$i" - response = HTTP.Response(501, "upgrade requests are not currently supported") - error = true - elseif messagecomplete - HTTP.@log "received request on connection i=$i" - verbose && (println(logger, "HTTP.Request:\n"); println(logger, string(request))) - try - response = Handlers.handle(handler, request, HTTP.Response()) - catch e - response = HTTP.Response(500) - error = true - showerror(logger, e) - println(logger, backtrace()) - end - if HTTP.http_should_keep_alive(parser, request) && !error - get!(HTTP.headers(response), "Connection", "keep-alive") - HTTP.reset!(parser) - request = HTTP.Request() - else - get!(HTTP.headers(response), "Connection", "close") - shouldclose = true - end - if !error - HTTP.@log "responding with response on connection i=$i" - respstr = string(response, options) - verbose && (println(logger, "HTTP.Response:\n"); println(logger, respstr)) - try - write(tcp, respstr) - catch e - HTTP.@log e - error = true - end - end - (error || shouldclose) && break - startedprocessingrequest = alreadysent100continue = false - end - end - end - end - timeout = options.readtimeout - while !istaskdone(tsk) && (time() - starttime[] < timeout) - sleep(0.001) - end - if !istaskdone(tsk) - HTTP.@log "connection i=$i timed out waiting for request bytes" - startedprocessingrequest && write(tcp, HTTP.Response(408), options) - end - finally - close(tcp) - end - HTTP.@log "finished processing on connection i=$i" - return nothing -end - -initTLS!(::Type{HTTP.http}, tcp, tlsconfig) = return tcp -function initTLS!(::Type{HTTP.https}, tcp, tlsconfig) - try - tls = HTTP.TLS.SSLContext() - HTTP.TLS.setup!(tls, tlsconfig) - HTTP.TLS.associate!(tls, tcp) - HTTP.TLS.handshake!(tls) - return tls - catch e - close(tcp) - error("Error establishing SSL connection: $e") - end -end - -mutable struct RateLimit - allowance::Float64 - lastcheck::Dates.DateTime -end - -function update!(rl::RateLimit, ratelimit) - current = Dates.now() - timepassed = float(Dates.value(current - rl.lastcheck)) / 1000.0 - rl.lastcheck = current - rl.allowance += timepassed * ratelimit - return nothing -end - -@enum Signals KILL - -function serve(server::Server{T, H}, host, port, verbose) where {T, H} - logger = server.logger - HTTP.@log "starting server to listen on: $(host):$(port)" - tcpserver = listen(host, port) - ratelimits = Dict{IPAddr, RateLimit}() - rate = Float64(server.options.ratelimit.num) - i = 0 - @async begin - while true - val = take!(server.in) - val == KILL && close(tcpserver) - end - end - while true - p = HTTP.Parser() - request = HTTP.Request() - try - # accept blocks until a new connection is detected - tcp = accept(tcpserver) - ip = getsockname(tcp)[1] - rl = get!(ratelimits, ip, RateLimit(rate, Dates.now())) - update!(rl, server.options.ratelimit) - if rl.allowance > rate - HTTP.@log "throttling $ip" - rl.allowance = rate - end - if rl.allowance < 1.0 - HTTP.@log "discarding connection from $ip due to rate limiting" - close(tcp) - else - rl.allowance -= 1.0 - HTTP.@log "new tcp connection accepted, reading request..." - let server=server, p=p, request=request, i=i, tcp=tcp, rl=rl - @async process!(server, p, request, i, initTLS!(T, tcp, server.options.tlsconfig::HTTP.TLS.SSLConfig), rl, Ref{Float64}(time()), verbose) - end - i += 1 - end - catch e - if typeof(e) <: InterruptException - HTTP.@log "interrupt detected, shutting down..." - interrupt() - break - else - if !isopen(tcpserver) - HTTP.@log "server TCPServer is closed, shutting down..." - # Server was closed while waiting to accept client. Exit gracefully. - interrupt() - break - end - HTTP.@log "error encountered: $e" - HTTP.@log "resuming serving..." - end - end - end - close(tcpserver) - return -end - -Server(h::Function, l::IO=STDOUT; cert::String="", key::String="", args...) = Server(HTTP.HandlerFunction(h), l; cert=cert, key=key, args...) -function Server(handler::H=HTTP.HandlerFunction((req, rep) -> HTTP.Response("Hello World!")), - logger::IO=STDOUT; - cert::String="", - key::String="", - args...) where {H <: HTTP.Handler} - if cert != "" && key != "" - server = Server{HTTP.https, H}(handler, logger, Channel(1), Channel(1), ServerOptions(; tlsconfig=HTTP.TLS.SSLConfig(cert, key), args...)) - else - server = Server{HTTP.http, H}(handler, logger, Channel(1), Channel(1), ServerOptions(; args...)) - end - return server -end - -""" - HTTP.serve([server,] host::IPAddr, port::Int; verbose::Bool=true, kwargs...) - -Start a server listening on the provided `host` and `port`. `verbose` indicates whether server activity should be logged. -Optional keyword arguments allow construction of `Server` on the fly if the `server` argument isn't provided directly. -See `?HTTP.Server` for more details on server construction and supported keyword arguments. -By default, `HTTP.serve` aims to "never die", catching and recovering from all internal errors. Two methods for stopping -`HTTP.serve` include interrupting (ctrl/cmd+c) if blocking on the main task, or sending the kill signal via the server's in channel -(`put!(server.in, HTTP.KILL)`). -""" -function serve end - -serve(server::Server, host=IPv4(127,0,0,1), port=8081; verbose::Bool=true) = serve(server, host, port, verbose) -function serve(host::IPAddr, port::Int, - handler=(req, rep) -> HTTP.Response("Hello World!"), - logger::I=STDOUT; - cert::String="", - key::String="", - verbose::Bool=true, - args...) where {I} - server = Server(handler, logger; cert=cert, key=key, args...) - return serve(server, host, port, verbose) -end -serve(; host::IPAddr=IPv4(127,0,0,1), - port::Int=8081, - handler=(req, rep) -> HTTP.Response("Hello World!"), - logger::IO=STDOUT, - cert::String="", - key::String="", - verbose::Bool=true, - args...) = - serve(host, port, handler, logger; cert=cert, key=key, verbose=verbose, args...) - -end # module diff --git a/src/sniff.jl b/src/sniff.jl index c997b5655..7afe165f8 100644 --- a/src/sniff.jl +++ b/src/sniff.jl @@ -40,7 +40,7 @@ end sniff(str::String) = sniff(Vector{UInt8}(str)[1:min(length(Vector{UInt8}(str)), MAXSNIFFLENGTH)]) sniff(f::FIFOBuffer) = sniff(String(f)) -function sniff(data::Vector{UInt8}) +function sniff(data::AbstractVector{UInt8}) firstnonws = 1 while firstnonws < length(data) && data[firstnonws] in WHITESPACE firstnonws += 1 @@ -58,7 +58,7 @@ struct Exact end contenttype(e::Exact) = e.contenttype -function ismatch(e::Exact, data::Vector{UInt8}, firstnonws) +function ismatch(e::Exact, data::AbstractVector{UInt8}, firstnonws) length(data) < length(e.sig) && return false for i = 1:length(e.sig) e.sig[i] == data[i] || return false @@ -76,7 +76,7 @@ Masked(mask::Vector{UInt8}, pat::Vector{UInt8}, contenttype::String) = Masked(ma contenttype(m::Masked) = m.contenttype -function ismatch(m::Masked, data::Vector{UInt8}, firstnonws) +function ismatch(m::Masked, data::AbstractVector{UInt8}, firstnonws) # pattern matching algorithm section 6 # https://mimesniff.spec.whatwg.org/#pattern-matching-algorithm sk = (m.skipws ? firstnonws : 1) - 1 @@ -95,7 +95,7 @@ end contenttype(h::HTMLSig) = "text/html; charset=utf-8" -function ismatch(h::HTMLSig, data::Vector{UInt8}, firstnonws) +function ismatch(h::HTMLSig, data::AbstractVector{UInt8}, firstnonws) length(data) < length(h.html)+1 && return false for (i, b) in enumerate(h.html) db = data[i+firstnonws-1] @@ -122,7 +122,7 @@ const mp4 = Vector{UInt8}("mp4") # Byte swap int bigend(b) = UInt32(b[4]) | UInt32(b[3])<<8 | UInt32(b[2])<<16 | UInt32(b[1])<<24 -function ismatch(::Type{MP4Sig}, data::Vector{UInt8}, firstnonws) +function ismatch(::Type{MP4Sig}, data::AbstractVector{UInt8}, firstnonws) # https://mimesniff.spec.whatwg.org/#signature-for-mp4 # c.f. section 6.2.1 length(data) < 12 && return false @@ -139,7 +139,7 @@ end struct TextSig end contenttype(::Type{TextSig}) = "text/plain; charset=utf-8" -function ismatch(::Type{TextSig}, data::Vector{UInt8}, firstnonws) +function ismatch(::Type{TextSig}, data::AbstractVector{UInt8}, firstnonws) # c.f. section 5, step 4. for i = firstnonws:min(length(data),MAXSNIFFLENGTH) b = data[i] @@ -153,7 +153,7 @@ end struct JSONSig end contenttype(::Type{JSONSig}) = "application/json; charset=utf-8" -ismatch(::Type{JSONSig}, data::Vector{UInt8}, firstnonws) = isjson(data)[1] +ismatch(::Type{JSONSig}, data::AbstractVector{UInt8}, firstnonws) = isjson(data)[1] const DISPLAYABLE_TYPES = ["text/html; charset=utf-8", "text/plain; charset=utf-8", diff --git a/src/types.jl b/src/types.jl index 8a41fd81c..e69de29bb 100644 --- a/src/types.jl +++ b/src/types.jl @@ -1,394 +0,0 @@ -abstract type Scheme end - -struct http <: Scheme end -struct https <: Scheme end -# struct ws <: Scheme end -# struct wss <: Scheme end - -sockettype(::Type{http}) = TCPSocket -sockettype(::Type{https}) = TLS.SSLContext -schemetype(::Type{TCPSocket}) = http -schemetype(::Type{TLS.SSLContext}) = https - -const Headers = Dict{String, String} - -const Option{T} = Union{T, Nothing} -not(::Nothing) = true -not(x) = false -function get(value::T, name::Symbol, default::R)::R where {T, R} - val = getfield(value, name)::Option{R} - return not(val) ? default : val -end - -""" - RequestOptions(; chunksize=, connecttimeout=, readtimeout=, tlsconfig=, maxredirects=, allowredirects=) - -A type to represent various http request options. Lives as a separate type so that options can be set -at the `HTTP.Client` level to be applied to every request sent. Options include: - - * `chunksize::Int`: if a request body is larger than `chunksize`, the "chunked-transfer" http mechanism will be used and chunks will be sent no larger than `chunksize`; default = `nothing` - * `connecttimeout::Float64`: sets a timeout on how long to wait when trying to connect to a remote host; default = Inf. Note that while setting a timeout will affect the actual program control flow, there are current lower-level limitations that mean underlying resources may not actually be freed until their own timeouts occur (i.e. libuv sockets only timeout after 75 seconds, with no option to configure) - * `readtimeout::Float64`: sets a timeout on how long to wait when receiving a response from a remote host; default = Int - * `tlsconfig::TLS.SSLConfig`: a valid `TLS.SSLConfig` which will be used to initialize every https connection; default = `nothing` - * `maxredirects::Int`: the maximum number of redirects that will automatically be followed for an http request; default = 5 - * `allowredirects::Bool`: whether redirects should be allowed to be followed at all; default = `true` - * `forwardheaders::Bool`: whether user-provided headers should be forwarded on redirects; default = `false` - * `retries::Int`: # of times a request will be tried before throwing an error; default = 3 - * `managecookies::Bool`: whether the request client should automatically store and add cookies from/to requests (following appropriate host-specific & expiration rules); default = `true` - * `statusraise::Bool`: whether an `HTTP.StatusError` should be raised on a non-2XX response status code; default = `true` - * `insecure::Bool`: whether an "https" connection should allow insecure connections (no TLS verification); default = `false` - * `canonicalizeheaders::Bool`: whether header field names should be canonicalized in responses, e.g. `content-type` is canonicalized to `Content-Type`; default = `true` - * `logbody::Bool`: whether the request body should be logged when `verbose=true` is passed; default = `true` -""" -mutable struct RequestOptions - chunksize::Option{Int} - gzip::Option{Bool} - connecttimeout::Option{Float64} - readtimeout::Option{Float64} - tlsconfig::Option{TLS.SSLConfig} - maxredirects::Option{Int} - allowredirects::Option{Bool} - forwardheaders::Option{Bool} - retries::Option{Int} - managecookies::Option{Bool} - statusraise::Option{Bool} - insecure::Option{Bool} - canonicalizeheaders::Option{Bool} - logbody::Option{Bool} - RequestOptions(ch::Option{Int}, gzip::Option{Bool}, ct::Option{Float64}, rt::Option{Float64}, tls::Option{TLS.SSLConfig}, mr::Option{Int}, ar::Option{Bool}, fh::Option{Bool}, tr::Option{Int}, mc::Option{Bool}, sr::Option{Bool}, i::Option{Bool}, h::Option{Bool}, lb::Option{Bool}) = - new(ch, gzip, ct, rt, tls, mr, ar, fh, tr, mc, sr, i, h, lb) -end - -const RequestOptionsFieldTypes = Dict(:chunksize => Int, - :gzip => Bool, - :connecttimeout => Float64, - :readtimeout => Float64, - :tlsconfig => TLS.SSLConfig, - :maxredirects => Int, - :allowredirects => Bool, - :forwardheaders => Bool, - :retries => Int, - :managecookies => Bool, - :statusraise => Bool, - :insecure => Bool, - :canonicalizeheaders => Bool, - :logbody => Bool) - -function RequestOptions(options::RequestOptions; kwargs...) - for (k, v) in pairs(kwargs) - setfield!(options, k, convert(RequestOptionsFieldTypes[k], v)) - end - return options -end - -RequestOptions(chunk=nothing, gzip=nothing, ct=nothing, rt=nothing, tls=nothing, mr=nothing, ar=nothing, fh=nothing, tr=nothing, mc=nothing, sr=nothing, i=nothing, h=nothing, lb=nothing; kwargs...) = - RequestOptions(RequestOptions(chunk, gzip, ct, rt, tls, mr, ar, fh, tr, mc, sr, i, h, lb); kwargs...) - -function update!(opts1::RequestOptions, opts2::RequestOptions) - for i = 1:nfields(RequestOptions) - f = fieldname(RequestOptions, i) - not(getfield(opts1, f)) && setfield!(opts1, f, getfield(opts2, f)) - end - return opts1 -end - -# Request -""" - Request() - Request(method, uri, headers, body; options=RequestOptions()) - Request(; method=HTTP.GET, uri=HTTP.URI(""), major=1, minor=1, headers=HTTP.Headers(), body="") - -A type representing an http request. `method` can be provided as a string or `HTTP.GET` type enum. -`uri` can be provided as an actual `HTTP.URI` or string. `headers` should be provided as a `Dict`. -`body` may be provided as string, byte vector, IO, or `HTTP.FIFOBuffer`. -`options` should be a `RequestOptions` type, see `?HTTP.RequestOptions` for details. - -Accessor methods include: - * `HTTP.method`: method for a request - * `HTTP.major`: major http version for a request - * `HTTP.minor`: minor http version for a request - * `HTTP.uri`: uri for a request - * `HTTP.headers`: headers for a request - * `HTTP.body`: body for a request as a `HTTP.FIFOBuffer` - -Two convenience methods are provided for accessing a request body: - * `take!(r)`: consume the request body, returning it as a `Vector{UInt8}` - * `String(r)`: consume the request body, returning it as a `String` -""" -mutable struct Request - method::HTTP.Method - major::Int16 - minor::Int16 - uri::URI - headers::Headers # includes cookies - body::Union{FIFOBuffer, Form} -end - -# accessors -method(r::Request) = r.method -major(r::Request) = r.major -minor(r::Request) = r.minor -uri(r::Request) = r.uri -headers(r::Request) = r.headers -body(r::Request) = r.body - -defaultheaders(::Type{Request}) = Headers( - "User-Agent" => "HTTP.jl/0.0.0", - "Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,application/json; charset=utf-8" -) -makeheaders(d::Dict) = Headers((string(k), string(v)) for (k, v) in d) - -function Request(m::HTTP.Method, uri::URI, userheaders::Dict, b; - options::RequestOptions=RequestOptions(), - verbose::Bool=false, - logger::Option{IO}=STDOUT) - if m != CONNECT - headers = defaultheaders(Request) - headers["Host"] = host(uri) - else - headers = Headers() - end - if !isempty(userinfo(uri)) && !haskey(headers, "Authorization") - headers["Authorization"] = "Basic $(base64encode(userinfo(uri)))" - @log "adding basic authentication header" - end - if isa(b, Dict) || isa(b, Form) - # form data - body = Form(b) - headers["Content-Type"] = "multipart/form-data; boundary=$(body.boundary)" - else - body = FIFOBuffer(b) - end - if iscompressed(body) && length(body) > get(options, :chunksize, 0) - options.chunksize = length(body) + 1 - end - if !haskey(headers, "Content-Type") && length(body) > 0 && !isa(body, Form) - sn = sniff(body) - headers["Content-Type"] = sn - @log "setting Content-Type header to: $sn" - end - return Request(m, Int16(1), Int16(1), uri, merge!(headers, makeheaders(userheaders)), body) -end - -Request(method, uri, h=Headers(), body=""; options::RequestOptions=RequestOptions(), logger::Option{IO}=STDOUT, verbose::Bool=false) = - Request(convert(HTTP.Method, method), - isa(uri, String) ? URI(uri; isconnect=(method == "CONNECT" || method == CONNECT)) : uri, - h, body; options=options, logger=logger, verbose=verbose) - -Request(; method::Method=GET, major::Integer=Int16(1), minor::Integer=Int16(1), uri=URI(""), headers=Headers(), body=FIFOBuffer("")) = - Request(method, major, minor, uri, headers, body) - -==(a::Request,b::Request) = (a.method == b.method) && - (a.major == b.major) && - (a.minor == b.minor) && - (a.uri == b.uri) && - (a.headers == b.headers) && - (a.body == b.body) - -Base.showcompact(io::IO, r::Request) = print(io, "Request(\"", resource(r.uri), "\", ", - length(r.headers), " headers, ", - length(r.body), " bytes in body)") - -""" - Response(status::Integer) - Response(status::Integer, body::String) - Response(status::Integer, headers, body) - Response(; status=200, cookies=HTTP.Cookie[], headers=HTTP.Headers(), body="") - -A type representing an http response. `status` represents the http status code for the response. -`headers` should be provided as a `Dict`. `body` can be provided as a string, byte vector, IO, or `HTTP.FIFOBuffer`. - -Accessor methods include: - * `HTTP.status`: status for a response - * `HTTP.statustext`: statustext for a response - * `HTTP.major`: major http version for a response - * `HTTP.minor`: minor http version for a response - * `HTTP.cookies`: cookies for a response, returned as a `Vector{HTTP.Cookie}` - * `HTTP.headers`: headers for a response - * `HTTP.request`: the `HTTP.Request` that resulted in this response - * `HTTP.history`: history for a response if redirects were followed from an original request - * `HTTP.body`: body for a response as a `HTTP.FIFOBuffer` - -Two convenience methods are provided for accessing a response body: - * `take!(r)`: consume the response body, returning it as a `Vector{UInt8}` - * `String(r)`: consume the response body, returning it as a `String` -""" -mutable struct Response - status::Int32 - major::Int16 - minor::Int16 - cookies::Vector{Cookie} - headers::Headers - body::FIFOBuffer - request::Union{Nothing,Request} - history::Vector{Response} -end - -# accessors -status(r::Response) = r.status -major(r::Response) = r.major -minor(r::Response) = r.minor -cookies(r::Response) = r.cookies -headers(r::Response) = r.headers -request(r::Response) = r.request -history(r::Response) = r.history -statustext(r::Response) = Base.get(STATUS_CODES, r.status, "Unknown Code") -body(r::Union{Request, Response}) = r.body -Base.take!(r::Union{Request, Response}) = readavailable(body(r)) -function Base.String(r::Union{Request, Response}) - if contains(Base.get(headers(r), "Content-Type", ""), "ISO-8859-1") - return iso8859_1_to_utf8(String(body(r))) - else - return String(body(r)) - end -end - -Response(; status::Int=200, - cookies::Vector{Cookie}=Cookie[], - headers::Headers=Headers(), - body::FIFOBuffer=FIFOBuffer(""), - request::Union{Nothing,Request}=nothing, - history::Vector{Response}=Response[]) = - Response(status, Int16(1), Int16(1), cookies, headers, body, request, history) - -Response(n::Integer, r::Request) = Response(; body=FIFOBuffer(n), request=r) -Response(s::Integer) = Response(; status=s) -Response(s::Integer, msg) = Response(; status=s, body=FIFOBuffer(msg)) -Response(b::Union{Vector{UInt8}, String}) = Response(; headers=defaultheaders(Response), body=FIFOBuffer(b)) -Response(s::Integer, h::Headers, body) = Response(; status=s, headers=h, body=FIFOBuffer(body)) - -defaultheaders(::Type{Response}) = Headers( - "Server" => "Julia/$VERSION", - "Content-Type" => "text/html; charset=utf-8", - "Content-Language" => "en", - "Date" => Dates.format(Dates.now(Dates.UTC), Dates.RFC1123Format) -) - -==(a::Response,b::Response) = (a.status == b.status) && - (a.major == b.major) && - (a.minor == b.minor) && - (a.headers == b.headers) && - (a.cookies == b.cookies) && - (a.body == b.body) - -function Base.showcompact(io::IO, r::Response) - print(io, "Response(", r.status, " ", Base.get(STATUS_CODES, r.status, "Unknown Code"), ", ", - length(r.headers)," headers, ", - length(r.body)," bytes in body)") -end - -## Request & Response writing -# start lines -function startline(io::IO, r::Request) - res = resource(uri(r); isconnect=r.method == CONNECT) - res = ifelse(res == "", "/", res) - write(io, "$(r.method) $res HTTP/$(r.major).$(r.minor)$CRLF") -end - -function startline(io::IO, r::Response) - write(io, "HTTP/$(r.major).$(r.minor) $(r.status) $(statustext(r))$CRLF") -end - -# headers -function headers(io::IO, r::Union{Request, Response}) - for (k, v) in headers(r) - write(io, "$k: $v$CRLF") - end - # write(io, CRLF); we let the body write this in case of chunked transfer -end - -# body -# https://tools.ietf.org/html/rfc7230#section-3.3 -function hasmessagebody(r::Response) - if 100 <= status(r) < 200 || status(r) == 204 || status(r) == 304 - return false - elseif request(r) !== nothing - req = request(r) - method(req) in (HEAD, CONNECT) && return false - end - return true -end -hasmessagebody(r::Request) = length(r.body) > 0 && !(r.method in (GET, HEAD, CONNECT)) - -function body(io::IO, r::Union{Request, Response}, opts) - if !hasmessagebody(r) - write(io, "$CRLF") - return - end - chksz = get(opts, :chunksize, 0) - pos = position(r.body) - @sync begin - @async begin - chunked = false - bytes = UInt8[] - while !eof(r.body) - bytes = chksz == 0 ? read(r.body) : read(r.body, chksz) - eof(r.body) && !chunked && break - if !chunked - write(io, "Transfer-Encoding: chunked$CRLF$CRLF") - end - chunked = true - chunk = length(bytes) - chunk == 0 && break - write(io, "$(hex(chunk))$CRLF") - write(io, bytes, CRLF) - end - if chunked - write(io, "$(hex(0))$CRLF$CRLF") - else - write(io, "Content-Length: $(dec(length(bytes)))$CRLF$CRLF") - write(io, bytes) - end - end - end - seek(r.body, pos) - return -end - -Base.write(io::IO, r::Union{Request, Response}, opts) = write(io, string(r)) -function Base.string(r::Union{Request, Response}, opts=RequestOptions()) - i = IOBuffer() - startline(i, r) - headers(i, r) - lb = opts.logbody - if lb === nothing || lb - body(i, r, opts) - else - println(i, "\n[request body logging disabled]\n") - end - return String(take!(i)) -end - -function Base.show(io::IO, r::Union{Request,Response}; opts=RequestOptions()) - println(io, typeof(r), ":") - println(io, "\"\"\"") - startline(io, r) - headers(io, r) - buf = IOBuffer() - if isopen(r.body) - println(io, "\n[open HTTP.FIFOBuffer with $(length(r.body)) bytes to read]") - else - body(buf, r, opts) - b = take!(buf) - if length(b) > 2 - contenttype = sniff(b) - if contenttype in DISPLAYABLE_TYPES - if length(b) > 750 - println(io, "\n[$(typeof(r)) body of $(length(b)) bytes]") - println(io, String(b)[1:750]) - println(io, "⋮") - else - print(io, String(b)) - end - else - contenttype = Base.get(r.headers, "Content-Type", contenttype) - encoding = Base.get(r.headers, "Content-Encoding", "") - encodingtxt = encoding == "" ? "" : " with '$encoding' encoding" - println(io, "\n[$(length(b)) bytes of '$contenttype' data$encodingtxt]") - end - else - print(io, String(b)) - end - end - print(io, "\"\"\"") -end diff --git a/src/uri.jl b/src/uri.jl deleted file mode 100644 index 74a096027..000000000 --- a/src/uri.jl +++ /dev/null @@ -1,249 +0,0 @@ -module URIs - -if VERSION >= v"0.7.0-DEV.2915" - using Unicode -end - -import Base.== - -include("urlparser.jl") - -export URI, URL, - hasscheme, scheme, - hashostname, hostname, - haspath, path, - hasquery, query, - hasfragment, fragment, - hasuserinfo, userinfo, - hasport, port, - resource, host, - escape, unescape, - splitpath, queryparams - -""" - HTTP.URL(host; userinfo="", path="", query="", fragment="", isconnect=false) - HTTP.URI(; scheme="", hostname="", port="", ...) - HTTP.URI(str; isconnect=false) - parse(HTTP.URI, str::String; isconnect=false) - -A type representing a valid uri. Can be constructed from distinct parts using the various -supported keyword arguments. With a raw, already-encoded uri string, use `parse(HTTP.URI, str)` -to parse the `HTTP.URI` directly. The `HTTP.URI` constructors will automatically escape any provided -`query` arguments, typically provided as `"key"=>"value"::Pair` or `Dict("key"=>"value")`. -Note that multiple values for a single query key can provided like `Dict("key"=>["value1", "value2"])`. - -For efficiency, the internal representation is stored as a set of offsets and lengths to the various uri components. -To access and return these components as strings, use the various accessor methods: - * `HTTP.scheme`: returns the scheme (if any) associated with the uri - * `HTTP.userinfo`: returns the userinfo (if any) associated with the uri - * `HTTP.hostname`: returns the hostname only of the uri - * `HTTP.port`: returns the port of the uri; will return "80" or "443" by default if the scheme is "http" or "https", respectively - * `HTTP.host`: returns the "hostname:port" combination; if the port is not provided or is the default port for the uri scheme, it will be omitted - * `HTTP.path`: returns the path for a uri - * `HTTP.query`: returns the query for a uri - * `HTTP.fragment`: returns the fragment for a uri - * `HTTP.resource`: returns the path-query-fragment combination -""" -struct URI - data::Vector{UInt8} - offsets::NTuple{7, Offset} -end - -function URI(;hostname::AbstractString="", path::AbstractString="", - scheme::AbstractString="", userinfo::AbstractString="", - port::Union{Integer,AbstractString}="", query="", - fragment::AbstractString="", isconnect::Bool=false) - hostname != "" && scheme == "" && !isconnect && (scheme = "http") - io = IOBuffer() - printuri(io, scheme, userinfo, hostname, string(port), path, escape(query), fragment) - return Base.parse(URI, String(take!(io)); isconnect=isconnect) -end - -# we assume `str` is at least hostname & port -# if all others keywords are empty, assume CONNECT -# can include path, userinfo, query, & fragment -function URL(str::AbstractString; userinfo::AbstractString="", path::AbstractString="", - query="", fragment::AbstractString="", - isconnect::Bool=false) - if str != "" - if startswith(str, "http") || startswith(str, "https") - str = string(str, path, ifelse(query == "", "", "?" * escape(query)), - ifelse(fragment == "", "", "#$fragment")) - else - if startswith(str, "/") || str == "*" - # relative uri like "/" or "*", leave it alone - elseif path == "" && userinfo == "" && query == "" && fragment == "" && ':' in str - isconnect = true - else - str = string("http://", userinfo == "" ? "" : "$userinfo@", - str, path, ifelse(query == "", "", "?" * escape(query)), - ifelse(fragment == "", "", "#$fragment")) - end - end - end - return Base.parse(URI, str; isconnect=isconnect) -end -URI(str::AbstractString; isconnect::Bool=false) = Base.parse(URI, str; isconnect=isconnect) -Base.parse(::Type{URI}, str::AbstractString; isconnect::Bool=false) = http_parser_parse_url(Vector{UInt8}(str), 1, sizeof(str), isconnect) - -==(a::URI,b::URI) = scheme(a) == scheme(b) && - hostname(a) == hostname(b) && - path(a) == path(b) && - query(a) == query(b) && - fragment(a) == fragment(b) && - userinfo(a) == userinfo(b) && - ((!hasport(a) || !hasport(b)) || (port(a) == port(b))) - -# accessors -for uf in instances(http_parser_url_fields) - uf == UF_MAX && break - nm = lowercase(string(uf)[4:end]) - has = Symbol(string("has", nm)) - @eval $has(uri::URI) = uri.offsets[Int($uf)].len > 0 - uf == UF_PORT && continue - @eval $(Symbol(nm))(uri::URI) = String(uri.data[uri.offsets[Int($uf)]]) -end - -# special def for port -function port(uri::URI) - if hasport(uri) - return String(uri.data[uri.offsets[Int(UF_PORT)]]) - else - sch = scheme(uri) - return sch == "http" ? "80" : sch == "https" ? "443" : "" - end -end - -resource(uri::URI; isconnect::Bool=false) = isconnect ? host(uri) : path(uri) * (isempty(query(uri)) ? "" : "?$(query(uri))") * (isempty(fragment(uri)) ? "" : "#$(fragment(uri))") -function host(uri::URI) - h = hostname(uri) - sch = scheme(uri) - p = String(uri.data[uri.offsets[Int(UF_PORT)]]) - if isempty(p) || (sch == "http" && p == "80") || (sch == "https" && p == "443") - return h - else - return string(h, p) - end -end - -Base.show(io::IO, uri::URI) = print(io, "HTTP.URI(\"", uri, "\")") - -Base.print(io::IO, u::URI) = printuri(io, scheme(u), userinfo(u), hostname(u), port(u), path(u), query(u), fragment(u)) -function printuri(io::IO, sch::String, userinfo::String, hostname::String, port::String, path::String, query::String, fragment::String) - if sch in uses_authority - print(io, sch, "://") - !isempty(userinfo) && print(io, userinfo, "@") - print(io, ':' in hostname ? "[$hostname]" : hostname) - print(io, ((sch == "http" && port == "80") || - (sch == "https" && port == "443") || isempty(port)) ? "" : ":$port") - elseif path != "" && path != "*" && sch != "" - print(io, sch, ":") - elseif hostname != "" && port != "" # CONNECT - print(io, hostname, ":", port) - end - if (isempty(hostname) || hostname[end] != '/') && - (isempty(path) || path[1] != '/') && - (!isempty(fragment) || !isempty(path)) - path = (!isempty(sch) && sch == "http" || sch == "https") ? string("/", path) : path - end - print(io, path, isempty(query) ? "" : "?$query", isempty(fragment) ? "" : "#$fragment") -end - -queryparams(uri::URI) = queryparams(query(uri)) -function queryparams(q::AbstractString) - Dict(unescape(k) => unescape(v) - for (k,v) in ([split(e, "=")..., ""][1:2] - for e in split(q, "&", keep=false))) -end - -# Validate known URI formats -const uses_authority = ["hdfs", "ftp", "http", "gopher", "nntp", "telnet", "imap", "wais", "file", "mms", "https", "shttp", "snews", "prospero", "rtsp", "rtspu", "rsync", "svn", "svn+ssh", "sftp" ,"nfs", "git", "git+ssh", "ldap", "s3"] -const uses_params = ["ftp", "hdl", "prospero", "http", "imap", "https", "shttp", "rtsp", "rtspu", "sip", "sips", "mms", "sftp", "tel"] -const non_hierarchical = ["gopher", "hdl", "mailto", "news", "telnet", "wais", "imap", "snews", "sip", "sips"] -const uses_query = ["http", "wais", "imap", "https", "shttp", "mms", "gopher", "rtsp", "rtspu", "sip", "sips", "ldap"] -const uses_fragment = ["hdfs", "ftp", "hdl", "http", "gopher", "news", "nntp", "wais", "https", "shttp", "snews", "file", "prospero"] - -"checks if a `HTTP.URI` is valid" -function Base.isvalid(uri::URI) - sch = scheme(uri) - isempty(sch) && throw(ArgumentError("can not validate relative URI")) - if ((sch in non_hierarchical) && (search(path(uri), '/') > 1)) || # path hierarchy not allowed - (!(sch in uses_query) && !isempty(query(uri))) || # query component not allowed - (!(sch in uses_fragment) && !isempty(fragment(uri))) || # fragment identifier component not allowed - (!(sch in uses_authority) && (!isempty(hostname(uri)) || ("" != port(uri)) || !isempty(userinfo(uri)))) # authority component not allowed - return false - end - return true -end - -# RFC3986 Unreserved Characters (and '~' Unsafe per RFC1738). -@inline issafe(c::Char) = c == '-' || - c == '.' || - c == '_' || - (isascii(c) && isalnum(c)) - -utf8_chars(str::AbstractString) = (Char(c) for c in Vector{UInt8}(str)) - -"percent-encode a string, dict, or pair for a uri" -function escape end - -escape(c::Char) = string('%', uppercase(hex(c,2))) -escape(str::AbstractString, safe::Function=issafe) = - join(safe(c) ? c : escape(c) for c in utf8_chars(str)) - -escape(bytes::Vector{UInt8}) = bytes -escape(v::Number) = escape(string(v)) -escape(v::Symbol) = escape(string(v)) -@static if VERSION < v"0.7.0-DEV.3017" -escape(v::Nullable) = Base.isnull(v) ? "" : escape(get(v)) -end - -escape(key, value) = string(escape(key), "=", escape(value)) -escape(key, values::Vector) = escape(key => v for v in values) -escape(query) = join((escape(k, v) for (k,v) in query), "&") - -"unescape a percent-encoded uri/url" -function unescape(str) - contains(str, "%") || return str - out = IOBuffer() - i = 1 - while !done(str, i) - c, i = next(str, i) - if c == '%' - c1, i = next(str, i) - c, i = next(str, i) - write(out, Base.parse(UInt8, string(c1, c), 16)) - else - write(out, c) - end - end - return String(take!(out)) -end - -""" -Splits the path into components -See: http://tools.ietf.org/html/rfc3986#section-3.3 -""" -function splitpath end - -splitpath(uri::URI) = splitpath(path(uri)) -function splitpath(p::String) - elems = String[] - len = length(p) - len > 1 || return elems - start_ind = i = ifelse(p[1] == '/', 2, 1) - while true - c = p[i] - if c == '/' - push!(elems, p[start_ind:i-1]) - start_ind = i + 1 - elseif i == len - push!(elems, p[start_ind:i]) - end - i += 1 - (i > len || c in ('?', '#')) && break - end - return elems -end - -end # module diff --git a/src/urlparser.jl b/src/urlparser.jl index 76c3fb98c..046862f05 100644 --- a/src/urlparser.jl +++ b/src/urlparser.jl @@ -1,55 +1,35 @@ include("consts.jl") -include("utils.jl") +include("parseutils.jl") struct URLParsingError <: Exception msg::String end -Base.show(io::IO, p::URLParsingError) = println("HTTP.URLParsingError: ", p.msg) +Base.show(io::IO, p::URLParsingError) = println(io, "HTTP.URLParsingError: ", p.msg) -struct Offset - off::UInt16 - len::UInt16 -end -Offset() = Offset(0, 0) -Base.getindex(A::Vector{UInt8}, o::Offset) = A[o.off:(o.off + o.len - 1)] -Base.isempty(o::Offset) = o.off == 0x0000 && o.len == 0x0000 -==(a::Offset, b::Offset) = a.off == b.off && a.len == b.len -const EMPTYOFFSET = Offset() - -@enum(http_parser_url_fields, - UF_SCHEME = 1 - , UF_HOSTNAME = 2 - , UF_PORT = 3 - , UF_PATH = 4 - , UF_QUERY = 5 - , UF_FRAGMENT = 6 - , UF_USERINFO = 7 - , UF_MAX = 8 +@enum(http_host_state, + s_http_host_dead, + s_http_userinfo_start, + s_http_userinfo, + s_http_host_start, + s_http_host_v6_start, + s_http_host, + s_http_host_v6, + s_http_host_v6_end, + s_http_host_v6_zone_start, + s_http_host_v6_zone, + s_http_host_port_start, + s_http_host_port, ) -const UF_SCHEME_MASK = 0x01 -const UF_HOSTNAME_MASK = 0x02 -const UF_PORT_MASK = 0x04 -const UF_PATH_MASK = 0x08 -const UF_QUERY_MASK = 0x10 -const UF_FRAGMENT_MASK = 0x20 -const UF_USERINFO_MASK = 0x40 - -@inline function Base.getindex(A::Vector{T}, i::http_parser_url_fields) where {T} - @inbounds v = A[Int(i)] - return v -end -@inline function Base.setindex!(A::Vector{T}, v::T, i::http_parser_url_fields) where {T} - @inbounds v = setindex!(A, v, Int(i)) - return v -end + +const blank_userinfo = SubString("blank_userinfo", 1, 0) # url parsing function parseurlchar(s, ch::Char, strict::Bool) @anyeq(ch, ' ', '\r', '\n') && return s_dead strict && (ch == '\t' || ch == '\f') && return s_dead - if s == s_req_spaces_before_url - (ch == '/' || ch == '*') && return s_req_path + if s == s_req_spaces_before_target || s == s_req_target_start + (ch == '/') && return s_req_path isalpha(ch) && return s_req_schema elseif s == s_req_schema isalphanum(ch) && return s @@ -87,7 +67,7 @@ function parseurlchar(s, ch::Char, strict::Bool) (ch == '?' || ch == '#') && return s end #= We should never fall out of the switch above unless there's an error =# - return s_dead; + return s_dead end function http_parse_host_char(s::http_host_state, ch) @@ -120,134 +100,175 @@ function http_parse_host_char(s::http_host_state, ch) return s_http_host_dead end -function http_parse_host(buf, host::Offset, foundat) - portoff = portlen = uioff = uilen = UInt16(0) - off = len = UInt16(0) +function http_parse_host(host::SubString, foundat=false) + + host1 = port1 = userinfo1 = 1 + host2 = port2 = userinfo2 = 0 s = ifelse(foundat, s_http_userinfo_start, s_http_host_start) - for i = host.off:(host.off + host.len - 0x0001) - p = Char(buf[i]) + for i in eachindex(host) + @inbounds p = host[i] + new_s = http_parse_host_char(s, p) - new_s == s_http_host_dead && throw(URLParsingError("encountered invalid host character: \n$(String(buf))\n$(lpad("", i-1, "-"))^")) + if new_s == s_http_host_dead + throw(URLParsingError("encountered invalid host character: \n" * + "$host\n$(lpad("", i-1, "-"))^")) + end if new_s == s_http_host if s != s_http_host - off = i + host1 = i end - len += 0x0001 + host2 = i elseif new_s == s_http_host_v6 if s != s_http_host_v6 - off = i + host1 = i end - len += 0x0001 + host2 = i - elseif new_s == s_http_host_v6_zone_start || new_s == s_http_host_v6_zone - len += 0x0001 + elseif new_s == s_http_host_v6_zone_start || + new_s == s_http_host_v6_zone + host2 = i elseif new_s == s_http_host_port if s != s_http_host_port - portoff = i - portlen = 0x0000 + port1 = i end - portlen += 0x0001 + port2 = i elseif new_s == s_http_userinfo if s != s_http_userinfo - uioff = i - uilen = 0x0000 + userinfo1 = i end - uilen += 0x0001 + userinfo2 = i end s = new_s end - if @anyeq(s, s_http_host_start, s_http_host_v6_start, s_http_host_v6, s_http_host_v6_zone_start, - s_http_host_v6_zone, s_http_host_port_start, s_http_userinfo, s_http_userinfo_start) + if @anyeq(s, s_http_host_start, s_http_host_v6_start, s_http_host_v6, + s_http_host_v6_zone_start, s_http_host_v6_zone, + s_http_host_port_start, s_http_userinfo, s_http_userinfo_start) throw(URLParsingError("ended in unexpected parsing state: $s")) end - # (host, port, userinfo) - return Offset(off, len), Offset(portoff, portlen), Offset(uioff, uilen) + + return SubString(host, host1, host2), + SubString(host, port1, port2), + SubString(host, userinfo1, userinfo2) end -function http_parser_parse_url(buf, startind=1, buflen=length(buf), isconnect::Bool=false) - s = ifelse(isconnect, s_req_server_start, s_req_spaces_before_url) - old_uf = UF_MAX - off = len = 0 + +http_parser_parse_url(url::AbstractString) = http_parser_parse_url(String(url)) + +function http_parser_parse_url(url::String) + + s = s_req_spaces_before_target + + old_uf = -1 + off1 = off2 = 0 foundat = false - offsets = Offset[Offset(), Offset(), Offset(), Offset(), Offset(), Offset(), Offset()] + + empty = SubString(url, 1, 0) + scheme = userinfo = host = port = path = query = fragment = empty + mask = 0x00 - for i = startind:(startind + buflen - 1) - @inbounds p = Char(buf[i]) + end_i = endof(url) + for i in eachindex(url) + @inbounds p = url[i] olds = s s = parseurlchar(s, p, false) if s == s_dead - throw(URLParsingError("encountered invalid url character for parsing state = $(ParsingStateCode(olds)): \n$(String(buf))\n$(lpad("", i-1, "-"))^")) - elseif @anyeq(s, s_req_schema_slash, s_req_schema_slash_slash, s_req_server_start, s_req_query_string_start, s_req_fragment_start) + throw(URLParsingError( + "encountered invalid url character for parsing state = " * + "$(ParsingStateCode(olds)):\n$url)\n$(lpad("", i-1, "-"))^")) + elseif @anyeq(s, s_req_schema_slash, + s_req_schema_slash_slash, + s_req_server_start, + s_req_query_string_start, + s_req_fragment_start) continue - elseif s == s_req_schema - uf = UF_SCHEME - mask |= UF_SCHEME_MASK elseif s == s_req_server_with_at foundat = true - uf = UF_HOSTNAME - mask |= UF_HOSTNAME_MASK - elseif s == s_req_server - uf = UF_HOSTNAME - mask |= UF_HOSTNAME_MASK - elseif s == s_req_path - uf = UF_PATH - mask |= UF_PATH_MASK - elseif s == s_req_query_string - uf = UF_QUERY - mask |= UF_QUERY_MASK - elseif s == s_req_fragment - uf = UF_FRAGMENT - mask |= UF_FRAGMENT_MASK + uf = s_req_server + elseif @anyeq(s, s_req_schema, + s_req_server_with_at, + s_req_server, + s_req_path, + s_req_query_string, + s_req_fragment) + uf = s else - throw(URLParsingError("ended in unexpected parsing state: $s")) + throw(URLParsingError("ended in unexpected parsing state: $s\n$url")) end if uf == old_uf - len += 1 - continue + off2 = i + if i != end_i + continue + end end - if old_uf != UF_MAX - offsets[old_uf] = Offset(off, len) + + + @label save_part + if old_uf != -1 + part = SubString(url, off1, off2) + old_uf == s_req_schema && (scheme = part) + old_uf == s_req_server && (host = part) + old_uf == s_req_path && (path = part) + old_uf == s_req_query_string && (query = part) + old_uf == s_req_fragment && (fragment = part) + end + + off1 = i + off2 = i + if i == end_i && uf != old_uf + old_uf = uf + @goto save_part end - off = i - len = 1 old_uf = uf end - if old_uf != UF_MAX - offsets[old_uf] = Offset(off, len) - end - check = ~(UF_HOSTNAME_MASK | UF_PATH_MASK) - if (mask & UF_SCHEME_MASK > 0) && (mask | check == check) - throw(URLParsingError("URI must include host or path with scheme")) + if !isempty(scheme) && isempty(host) && isempty(path) + throw(URLParsingError("URI must include host or path with scheme\n$url")) end - if mask & UF_HOSTNAME_MASK > 0 - host, port, userinfo = http_parse_host(buf, offsets[UF_HOSTNAME], foundat) - if !isempty(host) - offsets[UF_HOSTNAME] = host - mask |= UF_HOSTNAME_MASK + if !isempty(host) + host, port, userinfo = http_parse_host(host, foundat) + if foundat && isempty(userinfo) + userinfo = blank_userinfo end - if !isempty(port) - offsets[UF_PORT] = port - mask |= UF_PORT_MASK - end - if !isempty(userinfo) - offsets[UF_USERINFO] = userinfo - mask |= UF_USERINFO_MASK - end - end - # CONNECT requests can only contain "hostname:port" - if isconnect - chk = UF_HOSTNAME_MASK | UF_PORT_MASK - ((mask | chk) > chk) && throw(URLParsingError("connect requests must contain and can only contain both hostname and port")) end - return URI(buf, (offsets[UF_SCHEME], - offsets[UF_HOSTNAME], - offsets[UF_PORT], - offsets[UF_PATH], - offsets[UF_QUERY], - offsets[UF_FRAGMENT], - offsets[UF_USERINFO])) -end \ No newline at end of file + return URI(url, scheme, userinfo, host, port, path, query, fragment) +end + +const normal_url_char = Bool[ +#= 0 nul 1 soh 2 stx 3 etx 4 eot 5 enq 6 ack 7 bel =# + false, false, false, false, false, false, false, false, +#= 8 bs 9 ht 10 nl 11 vt 12 np 13 cr 14 so 15 si =# + false, true, false, false, true, false, false, false, +#= 16 dle 17 dc1 18 dc2 19 dc3 20 dc4 21 nak 22 syn 23 etb =# + false, false, false, false, false, false, false, false, +#= 24 can 25 em 26 sub 27 esc 28 fs 29 gs 30 rs 31 us =# + false, false, false, false, false, false, false, false, +#= 32 sp 33 ! 34 " 35 # 36 $ 37 % 38 & 39 ' =# + false, true, true, false, true, true, true, true, +#= 40 ( 41 ) 42 * 43 + 44 , 45 - 46 . 47 / =# + true, true, true, true, true, true, true, true, +#= 48 0 49 1 50 2 51 3 52 4 53 5 54 6 55 7 =# + true, true, true, true, true, true, true, true, +#= 56 8 57 9 58 : 59 ; 60 < 61 = 62 > 63 ? =# + true, true, true, true, true, true, true, false, +#= 64 @ 65 A 66 B 67 C 68 D 69 E 70 F 71 G =# + true, true, true, true, true, true, true, true, +#= 72 H 73 I 74 J 75 K 76 L 77 M 78 N 79 O =# + true, true, true, true, true, true, true, true, +#= 80 P 81 Q 82 R 83 S 84 T 85 U 86 V 87 W =# + true, true, true, true, true, true, true, true, +#= 88 X 89 Y 90 Z 91 [ 92 \ 93 ] 94 ^ 95 _ =# + true, true, true, true, true, true, true, true, +#= 96 ` 97 a 98 b 99 c 100 d 101 e 102 f 103 g =# + true, true, true, true, true, true, true, true, +#= 104 h 105 i 106 j 107 k 108 l 109 m 110 n 111 o =# + true, true, true, true, true, true, true, true, +#= 112 p 113 q 114 r 115 s 116 t 117 u 118 v 119 w =# + true, true, true, true, true, true, true, true, +#= 120 x 121 y 122 z 123 { 124, 125 } 126 ~ 127 del =# + true, true, true, true, true, true, true, false, +] + +@inline isurlchar(c) = c > '\u80' ? true : normal_url_char[Int(c) + 1] diff --git a/src/utils.jl b/src/utils.jl index 27a35037d..93418ed08 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -1,72 +1,3 @@ -""" -escapeHTML(i::String) - -Returns a string with special HTML characters escaped: &, <, >, ", ' -""" -function escapeHTML(i::String) - # Refer to http://stackoverflow.com/a/7382028/3822752 for spec. links - o = replace(i, "&", "&") - o = replace(o, "\"", """) - o = replace(o, "'", "'") - o = replace(o, "<", "<") - o = replace(o, ">", ">") - return o -end - -macro retry(expr) - :(@retry 2 $(esc(expr))) -end - -macro retry(N, expr) - :(@retryif Any $N $(esc(expr))) -end - -macro retryif(cond, expr) - :(@retryif $(esc(cond)) 2 $(esc(expr))) -end - -macro retryif(cond, N, expr) - quote - local __r__ - for i = 1:$N - try - __r__ = $(esc(expr)) - break - catch e - typeof(e) <: $(esc(cond)) || rethrow(e) - i == $N && rethrow(e) - sleep(0.1) - end - end - __r__ - end -end - -""" -@timeout secs expr then pollint - -Start executing `expr`; if it doesn't finish executing in `secs` seconds, -then execute `then`. `pollint` controls the amount of time to wait in between -checking if `expr` has finished executing (short for polling interval). -""" -macro timeout(t, expr, then, pollint=0.01) - return quote - if $(esc(t)) == Inf - $(esc(expr)) - else - tm = Float64($(esc(t))) - start = time() - tsk = @async $(esc(expr)) - yield() - while !istaskdone(tsk) && (time() - start < tm) - sleep($pollint) - end - istaskdone(tsk) || $(esc(then)) - wait(tsk) - end - end -end - macro src() @static if VERSION >= v"0.7-" && length(:(@test).args) == 2 esc(quote @@ -83,123 +14,18 @@ macro src() end end -""" - @debug DEBUG expr - @debug DEBUG "message" - -A macro to aid when needing to turn on extremely verbose output for debugging. -Set `const DEBUG = true` in HTTP.jl and re-compile the package to see -debug-level output from the package. When `DEBUG = false`, all `@debug` statements -compile to `nothing`. -""" -macro debug(should, expr) - m, f, l = @src() - if typeof(expr) == String - e = esc(:(println("[DEBUG - ", $m, '.', $f, ":", $(rpad(l, 5, ' ')), "]: ", $(escape_string(expr))))) - else - e = esc(:(println("[DEBUG - ", $m, '.', $f, ":", $(rpad(l, 5, ' ')), "]: ", $(sprint(Base.show_unquoted, expr)), " = ", escape_string(string($expr))))) - end - return quote - @static if $should - $e - end - end -end macro log(stmt) # "[HTTP]: Connecting to remote host..." return esc(:(verbose && (write(logger, "[HTTP - $(rpad(Dates.now(), 23, ' '))]: $($stmt)\n"); flush(logger)))) end -# parsing utils -macro anyeq(var, vals...) - ret = e = Expr(:||) - for (i, v) in enumerate(vals) - x = :($var == $v) - push!(e.args, x) - i >= length(vals) - 1 && continue - ne = Expr(:||) - push!(e.args, ne) - e = ne - end - return esc(ret) -end - -@inline islower(b::UInt8) = UInt8('a') <= b <= UInt8('z') -@inline isupper(b::UInt8) = UInt8('A') <= b <= UInt8('Z') -@inline lower(c::UInt8) = c | 0x20 -@inline lower(c) = Char(UInt32(c) | 0x20) -@inline isurlchar(c) = c > '\u80' ? true : normal_url_char[Int(c) + 1] -@inline ismark(c) = @anyeq(c, '-', '_', '.', '!', '~', '*', '\'', '(', ')') -@inline isalpha(c) = 'a' <= lower(c) <= 'z' -@inline isnum(c) = '0' <= c <= '9' -@inline isalphanum(c) = isalpha(c) || isnum(c) -@inline isuserinfochar(c) = isalphanum(c) || ismark(c) || @anyeq(c, '%', ';', ':', '&', '=', '+', '$', ',') -@inline ishex(c) = isnum(c) || ('a' <= lower(c) <= 'f') -@inline ishostchar(c) = isalphanum(c) || @anyeq(c, '.', '-', '_', '~') -@inline isheaderchar(c) = c == CR || c == LF || c == Char(9) || (c > Char(31) && c != Char(127)) - -macro shifted(meth, i, char) - return esc(:(Int($meth) << Int(16) | Int($i) << Int(8) | Int($char))) -end - -macro errorif(cond, err) - return esc(quote - $cond && @err($err) - end) -end - -macro err(e) - return esc(quote - errno = $e - @goto error - end) -end - -macro strictcheck(cond) - return esc(:(strict && @errorif($cond, HPE_STRICT))) -end - -# ensure the first character and subsequent characters that follow a '-' are uppercase -function canonicalize!(s::String) - toUpper = UInt8('A') - UInt8('a') - bytes = Vector{UInt8}(s) - upper = true - for i = 1:length(bytes) - @inbounds b = bytes[i] - if upper - islower(b) && (bytes[i] = b + toUpper) - else - isupper(b) && (bytes[i] = lower(b)) - end - upper = b == UInt8('-') - end - return s -end - -iso8859_1_to_utf8(str::String) = iso8859_1_to_utf8(Vector{UInt8}(str)) -function iso8859_1_to_utf8(bytes::Vector{UInt8}) - io = IOBuffer() - for b in bytes - if b < 0x80 - write(io, b) - else - write(io, 0xc0 | b >> 6) - write(io, 0x80 | b & 0x3f) - end - end - return String(take!(io)) -end - -macro lock(l, expr) +macro catcherr(etype, expr) esc(quote - lock($l) try $expr - catch - rethrow() - finally - unlock($l) + catch e + isa(e, $etype) ? e : rethrow(e) end end) end diff --git a/test/REQUIRE b/test/REQUIRE new file mode 100644 index 000000000..c4c018438 --- /dev/null +++ b/test/REQUIRE @@ -0,0 +1,3 @@ +JSON +XMLDict +MicroLogging diff --git a/test/WebSockets.jl b/test/WebSockets.jl new file mode 100644 index 000000000..de3aae5f3 --- /dev/null +++ b/test/WebSockets.jl @@ -0,0 +1,24 @@ +using HTTP +using HTTP.Test +using HTTP.IOExtras + +for s in ["ws", "wss"] + + HTTP.WebSockets.open("$s://echo.websocket.org") do io + write(io, Vector{UInt8}("Foo")) + @test !eof(io) + @test String(readavailable(io)) == "Foo" + + write(io, Vector{UInt8}("Hello")) + write(io, " There") + write(io, " World", "!") + closewrite(io) + + buf = IOBuffer() + write(buf, io) + @test String(take!(buf)) == "Hello There World!" + + close(io) + end + +end diff --git a/test/async.jl b/test/async.jl new file mode 100644 index 000000000..85ebaf4a8 --- /dev/null +++ b/test/async.jl @@ -0,0 +1,343 @@ +using HTTP +using HTTP.Test +using HTTP.Base64 +using JSON +using MbedTLS: digest, MD_MD5, MD_SHA256 + +using HTTP.IOExtras +using HTTP.request + +println("async tests") + +stop_pool_dump = false + +@async HTTP.listen() do http + startwrite(http) + write(http, """ + + HTTP.jl Connection Pool + + + +
+    """)
+    write(http, "
")
+    buf = IOBuffer()
+    HTTP.ConnectionPool.showpoolhtml(buf)
+    write(http, take!(buf))
+    write(http, "
") +end + +@async begin + sleep(5) + try + run(`open http://localhost:8081`) + catch e + while !stop_pool_dump + HTTP.ConnectionPool.showpool(STDOUT) + sleep(1) + end + end +end + +# Tiny S3 interface... +s3region = "ap-southeast-2" +s3url = "https://s3.$s3region.amazonaws.com" +#s3(method, path, body=UInt8[]; kw...) = +# request(method, "$s3url/$path", [], body; aws_authorization=true, kw...) +#s3get(path; kw...) = s3("GET", path; kw...) +#s3put(path, data; kw...) = s3("PUT", path, data; kw...) + +#= +function create_bucket(bucket) + s3put(bucket, """ + + $s3region + """, + statusexception=false) +end + +create_bucket("http.jl.test") +=# + +function dump_async_exception(e, st) + buf = IOBuffer() + write(buf, "==========\n@async exception:\n==========\n") + show(buf, "text/plain", e) + show(buf, "text/plain", st) + write(buf, "==========\n\n") + print(String(take!(buf))) +end + +if haskey(ENV, "AWS_ACCESS_KEY_ID") || + (VERSION > v"0.7.0-DEV.2338" && haskey(ENV, "AWS_DEFAULT_PROFILE")) +@testset "async s3 dup$dup, count$count, sz$sz, pipw$pipe, $http, $mode" for + count in [10, 100, 1000], + dup in [0, 7], + http in ["http", "https"], + sz in [100, 10000], + mode in [:request, :open], + pipe in [0, 32] + +if (dup == 0 || pipe == 0) && count > 100 + continue +end + +global s3url +s3url = "$http://s3.$s3region.amazonaws.com" +println("running async s3 dup$dup, count$count, sz$sz, pipe$pipe, $http, $mode") + +put_data_sums = Dict() +ch = 100 +conf = [:reuse_limit => 90, + :verbose => 0, + :pipeline_limit => pipe, + :connection_limit => dup + 1, + :readtimeout => 120] + +@sync for i = 1:count + data = rand(UInt8(65):UInt8(75), sz) + md5 = bytes2hex(digest(MD_MD5, data)) + put_data_sums[i] = md5 + @async try + url = "$s3url/http.jl.test/file$i" + r = nothing + if mode == :open + r = HTTP.open("PUT", url, ["Content-Length" => sz]; + body_sha256=digest(MD_SHA256, data), + body_md5=digest(MD_MD5, data), + aws_authorization=true, + conf...) do http + for n = 1:ch:sz + write(http, data[n:n+(ch-1)]) + sleep(rand(1:10)/1000) + end + end + end + if mode == :request + r = HTTP.request("PUT", url, [], data; + aws_authorization=true, conf...) + end + #println("S3 put file$i") + @assert strip(HTTP.header(r, "ETag"), '"') == md5 + catch e + dump_async_exception(e, catch_stacktrace()) + rethrow(e) + end +end + + +get_data_sums = Dict() +@sync begin + for i = 1:count + @async try + url = "$s3url/http.jl.test/file$i" + buf = BufferStream() + r = nothing + if mode == :open + r = HTTP.open("GET", url, ["Content-Length" => 0]; + aws_authorization=true, + conf...) do http + buf = BufferStream() # in case of retry! + while !eof(http) + write(buf, readavailable(http)) + sleep(rand(1:10)/1000) + end + close(buf) + end + end + if mode == :request + r = HTTP.request("GET", url; response_stream=buf, + aws_authorization=true, conf...) + end + #println("S3 get file$i") + bytes = read(buf) + md5 = bytes2hex(digest(MD_MD5, bytes)) + get_data_sums[i] = (md5, strip(HTTP.header(r, "ETag"), '"')) + catch e + dump_async_exception(e, catch_stacktrace()) + rethrow(e) + end + end +end + +for i = 1:count + a, b = get_data_sums[i] + @test a == b + @test a == put_data_sums[i] +end + +if haskey(ENV, "HTTP_JL_TEST_QUICK_ASYNC") + break +end + +end # testset +end # if haskey(ENV, "AWS_ACCESS_KEY_ID") + +configs = [ + [:verbose => 0], + [:verbose => 0, :reuse_limit => 200], + [:verbose => 0, :reuse_limit => 50] +] + + +@testset "async $count, $num, $config, $http" for count in 1:1, + num in [100, 1000, 2000], + config in configs, + http in ["http", "https"] + +println("running async $count, 1:$num, $config, $http A") + + result = [] + @sync begin + for i = 1:min(num,100) + @async try + r = HTTP.request("GET", + "$http://httpbin.org/headers", ["i" => i]; config...) + r = JSON.parse(String(r.body)) + push!(result, r["headers"]["I"] => string(i)) + catch e + dump_async_exception(e, catch_stacktrace()) + rethrow(e) + end + end + end + for (a,b) in result + @test a == b + end + + HTTP.ConnectionPool.showpool(STDOUT) + HTTP.ConnectionPool.closeall() + + result = [] + +println("running async $count, 1:$num, $config, $http B") + + @sync begin + for i = 1:min(num,100) + @async try + r = HTTP.request("GET", + "$http://httpbin.org/stream/$i"; config...) + r = String(r.body) + r = split(strip(r), "\n") + push!(result, length(r) => i) + catch e + dump_async_exception(e, catch_stacktrace()) + rethrow(e) + end + end + end + + for (a,b) in result + @test a == b + end + + HTTP.ConnectionPool.showpool(STDOUT) + HTTP.ConnectionPool.closeall() + + result = [] + +#= + asyncmap(i->begin + n = i % 20 + 1 + str = "" + r = HTTP.open("GET", "$http://httpbin.org/stream/$n"; + retries=5, config...) do s + str = String(read(s)) + end + l = split(strip(str), "\n") + #println("GOT $i $n") + + push!(result, length(l) => n) + + end, 1:num, ntasks=20) + + for (a,b) in result + @test a == b + end + + result = [] +=# + +println("running async $count, 1:$num, $config, $http C") + + @sync begin + for i = 1:num + n = i % 20 + 1 + @async try + r = nothing + str = nothing + url = "$http://httpbin.org/stream/$n" + if rand(Bool) + if rand(Bool) + for attempt in 1:4 + try + #println("GET $i $n BufferStream $attempt") + s = BufferStream() + r = HTTP.request( + "GET", url; response_stream=s, config...) + @assert r.status == 200 + str = String(read(s)) + break + catch e + if attempt == 10 || + !HTTP.RetryRequest.isrecoverable(e) + rethrow(e) + end + buf = IOBuffer() + println(buf, "$i retry $e $attempt...") + write(STDOUT, take!(buf)) + sleep(0.1) + end + end + else + #println("GET $i $n Plain") + r = HTTP.request("GET", url; config...) + @assert r.status == 200 + str = String(r.body) + end + else + #println("GET $i $n open()") + r = HTTP.open("GET", url; config...) do http + str = String(read(http)) + end + @assert r.status == 200 + end + + l = split(strip(str), "\n") + #println("GOT $i $n $(length(l))") + if length(l) != n + @show r + @show str + end + push!(result, length(l) => n) + catch e + push!(result, e => n) + dump_async_exception(e, catch_stacktrace()) + rethrow(e) + end + end + end + + for (a,b) in result + @test a == b + end + + HTTP.ConnectionPool.showpool(STDOUT) + HTTP.ConnectionPool.closeall() + + + if haskey(ENV, "HTTP_JL_TEST_QUICK_ASYNC") + break + end + +end # testset + +stop_pool_dump=true + +HTTP.ConnectionPool.showpool(STDOUT) + +println("async tests done") diff --git a/test/body.jl b/test/body.jl new file mode 100644 index 000000000..04fff864e --- /dev/null +++ b/test/body.jl @@ -0,0 +1,53 @@ +using HTTP.Messages + +@testset "HTTP.Bodies" begin + + @test String(take!(Body("Hello!"))) == "Hello!" + @test String(take!(Body(IOBuffer("Hello!")))) == "Hello!" + @test String(take!(Body(Vector{UInt8}("Hello!")))) == "Hello!" + @test String(take!(Body())) == "" + + io = BufferStream() + @async begin + write(io, "Hello") + sleep(0.1) + write(io, "!") + sleep(0.1) + close(io) + end + @test String(take!(Body(io))) == "5\r\nHello\r\n1\r\n!\r\n0\r\n\r\n" + + b = Body() + write(b, "Hello") + write(b, "!") + @test String(take!(b)) == "Hello!" + + io = BufferStream() + b = Body(io) + write(b, "Hello") + write(b, "!") + @test String(readavailable(io)) == "Hello!" + + #display(b); println() + + buf = IOBuffer() + show(buf, b) + @test String(take!(buf)) == "Hello!\n⋮\nWaiting for BufferStream...\n" + + write(b, "\nWorld!") + close(io) + + #display(b); println() + buf = IOBuffer() + show(buf, b) + @test String(take!(buf)) == "Hello!\nWorld!\n" + + tmp = HTTP.Messages.Bodies.body_show_max + HTTP.Messages.Bodies.set_show_max(12) + b = Body("Hello World!xxx") + #display(b); println() + buf = IOBuffer() + show(buf, b) + @test String(take!(buf)) == "Hello World!\n⋮\n15-byte body\n" + HTTP.Messages.Bodies.set_show_max(tmp) +end diff --git a/test/client.jl b/test/client.jl index 5f7fbad60..e5bdfd567 100644 --- a/test/client.jl +++ b/test/client.jl @@ -1,52 +1,46 @@ +using HTTP +using HTTP.Test + @testset "HTTP.Client" begin -@testset "HTTP.Connection" begin - conn = HTTP.Connection(IOBuffer()) - @test conn.state == HTTP.Busy - HTTP.idle!(conn) - @test conn.state == HTTP.Idle - HTTP.busy!(conn) - @test conn.state == HTTP.Busy - HTTP.dead!(conn) - @test conn.state == HTTP.Dead - HTTP.idle!(conn) - @test conn.state == HTTP.Dead - HTTP.busy!(conn) - @test conn.state == HTTP.Dead -end +using JSON + +status(r) = r.status for sch in ("http", "https") println("running $sch client tests...") println("simple GET, HEAD, POST, DELETE, etc.") - @test HTTP.status(HTTP.get("$sch://httpbin.org/ip")) == 200 - @test HTTP.status(HTTP.head("$sch://httpbin.org/ip")) == 200 - @test HTTP.status(HTTP.options("$sch://httpbin.org/ip")) == 200 - @test HTTP.status(HTTP.post("$sch://httpbin.org/ip"; statusraise=false)) == 405 - @test HTTP.status(HTTP.post("$sch://httpbin.org/post")) == 200 - @test HTTP.status(HTTP.put("$sch://httpbin.org/put")) == 200 - @test HTTP.status(HTTP.delete("$sch://httpbin.org/delete")) == 200 - @test HTTP.status(HTTP.patch("$sch://httpbin.org/patch")) == 200 + @test status(HTTP.get("$sch://httpbin.org/ip")) == 200 + @test status(HTTP.head("$sch://httpbin.org/ip")) == 200 + @test status(HTTP.options("$sch://httpbin.org/ip")) == 200 + @test status(HTTP.post("$sch://httpbin.org/ip"; status_exception=false)) == 405 + @test status(HTTP.post("$sch://httpbin.org/post")) == 200 + @test status(HTTP.put("$sch://httpbin.org/put")) == 200 + @test status(HTTP.delete("$sch://httpbin.org/delete")) == 200 + @test status(HTTP.patch("$sch://httpbin.org/patch")) == 200 # Testing within tasks, see https://github.com/JuliaWeb/HTTP.jl/issues/18 println("async client request") - @test HTTP.status(wait(@schedule HTTP.get("$sch://httpbin.org/ip"))) == 200 + @test status(wait(@schedule HTTP.get("$sch://httpbin.org/ip"))) == 200 - @test HTTP.status(HTTP.get("$sch://httpbin.org/encoding/utf8")) == 200 + @test status(HTTP.get("$sch://httpbin.org/encoding/utf8")) == 200 println("pass query to uri") - r = HTTP.get("$sch://httpbin.org/response-headers"; query=Dict("hey"=>"dude")) - h = HTTP.headers(r) + r = HTTP.get(merge(HTTP.URI("$sch://httpbin.org/response-headers"); query=Dict("hey"=>"dude"))) + h = Dict(r.headers) @test (haskey(h, "Hey") ? h["Hey"] == "dude" : h["hey"] == "dude") println("cookie requests") - r = HTTP.get("$sch://httpbin.org/cookies") - body = String(take!(r)) - @test (body == "{\n \"cookies\": {}\n}\n" || body == "{\n \"cookies\": {\n \"hey\": \"\"\n }\n}\n" || body == "{\n \"cookies\": {\n \"hey\": \"sailor\"\n }\n}\n") - r = HTTP.get("$sch://httpbin.org/cookies/set?hey=sailor") - @test HTTP.status(r) == 200 - body = String(take!(r)) - @test (body == "{\n \"cookies\": {\n \"hey\": \"sailor\"\n }\n}\n" || body == "{\n \"cookies\": {\n \"hey\": \"\"\n }\n}\n") + empty!(HTTP.CookieRequest.default_cookiejar) + empty!(HTTP.DEFAULT_CLIENT.cookies) + r = HTTP.get("$sch://httpbin.org/cookies", cookies=true) + body = String(r.body) + @test body == "{\n \"cookies\": {}\n}\n" + r = HTTP.get("$sch://httpbin.org/cookies/set?hey=sailor&foo=bar", cookies=true) + @test status(r) == 200 + body = String(r.body) + @test body == "{\n \"cookies\": {\n \"foo\": \"bar\", \n \"hey\": \"sailor\"\n }\n}\n" # r = HTTP.get("$sch://httpbin.org/cookies/delete?hey") # @test String(take!(r)) == "{\n \"cookies\": {\n \"hey\": \"\"\n }\n}\n" @@ -54,79 +48,83 @@ for sch in ("http", "https") # stream println("client streaming tests") r = HTTP.post("$sch://httpbin.org/post"; body="hey") - @test HTTP.status(r) == 200 + @test status(r) == 200 # stream, but body is too small to actually stream r = HTTP.post("$sch://httpbin.org/post"; body="hey", stream=true) - @test HTTP.status(r) == 200 + @test status(r) == 200 r = HTTP.get("$sch://httpbin.org/stream/100") - @test HTTP.status(r) == 200 - totallen = length(HTTP.body(r)) # number of bytes to expect - bytes = take!(r) + @test status(r) == 200 + bytes = r.body + a = [JSON.parse(l) for l in split(chomp(String(bytes)), "\n")] + totallen = length(bytes) # number of bytes to expect begin - r = HTTP.get("$sch://httpbin.org/stream/100"; stream=true) - @test HTTP.status(r) == 200 - len = length(HTTP.body(r)) - HTTP.@timeout 15.0 begin - while !eof(HTTP.body(r)) - b = take!(r) - end - end throw(error("timed out")) + io = BufferStream() + r = HTTP.get("$sch://httpbin.org/stream/100"; response_stream=io) + @test status(r) == 200 + + b = [JSON.parse(l) for l in eachline(io)] + @test a == b end # body posting: Vector{UInt8}, String, IOStream, IOBuffer, FIFOBuffer println("client body posting of various types") - @test HTTP.status(HTTP.post("$sch://httpbin.org/post"; body="hey")) == 200 - @test HTTP.status(HTTP.post("$sch://httpbin.org/post"; body=UInt8['h','e','y'])) == 200 + @test status(HTTP.post("$sch://httpbin.org/post"; body="hey")) == 200 + @test status(HTTP.post("$sch://httpbin.org/post"; body=UInt8['h','e','y'])) == 200 io = IOBuffer("hey"); seekstart(io) - @test HTTP.status(HTTP.post("$sch://httpbin.org/post"; body=io)) == 200 + @test status(HTTP.post("$sch://httpbin.org/post"; body=io)) == 200 tmp = tempname() open(f->write(f, "hey"), tmp, "w") io = open(tmp) - @test HTTP.status(HTTP.post("$sch://httpbin.org/post"; body=io)) == 200 + @test status(HTTP.post("$sch://httpbin.org/post"; body=io, enablechunked=false)) == 200 close(io); rm(tmp) f = HTTP.FIFOBuffer("hey") - @test HTTP.status(HTTP.post("$sch://httpbin.org/post"; body=f)) == 200 + @test status(HTTP.post("$sch://httpbin.org/post"; body=f, enablechunked=false)) == 200 # chunksize + # + # FIXME + # Currently httpbin.org responds with 411 status and “Length Required” + # message to any POST/PUT requests that are sent using chunked encoding + # See https://github.com/kennethreitz/httpbin/issues/340#issuecomment-330176449 println("client transfer-encoding chunked") - @test_broken HTTP.status(HTTP.post("$sch://httpbin.org/post"; body="hey", chunksize=2)) == 200 - @test_broken HTTP.status(HTTP.post("$sch://httpbin.org/post"; body=UInt8['h','e','y'], chunksize=2)) == 200 + @test status(HTTP.post("$sch://httpbin.org/post"; body="hey", #=chunksize=2=#)) == 200 + @test status(HTTP.post("$sch://httpbin.org/post"; body=UInt8['h','e','y'], #=chunksize=2=#)) == 200 io = IOBuffer("hey"); seekstart(io) - @test_broken HTTP.status(HTTP.post("$sch://httpbin.org/post"; body=io, chunksize=2)) == 200 + @test status(HTTP.post("$sch://httpbin.org/post"; body=io, #=chunksize=2=#)) == 200 tmp = tempname() open(f->write(f, "hey"), tmp, "w") io = open(tmp) - @test_broken HTTP.status(HTTP.post("$sch://httpbin.org/post"; body=io, chunksize=2)) == 200 + @test_broken status(HTTP.post("$sch://httpbin.org/post"; body=io, #=chunksize=2=#)) == 200 close(io); rm(tmp) f = HTTP.FIFOBuffer("hey") - @test_broken HTTP.status(HTTP.post("$sch://httpbin.org/post"; body=f, chunksize=2)) == 200 + @test_broken status(HTTP.post("$sch://httpbin.org/post"; body=f, #=chunksize=2=#)) == 200 # multipart println("client multipart body") r = HTTP.post("$sch://httpbin.org/post"; body=Dict("hey"=>"there")) - @test HTTP.status(r) == 200 - @test startswith(String(take!(r)), "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {}, \n \"form\": {\n \"hey\": \"there\"\n }") + @test status(r) == 200 + @test startswith(String(r.body), "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {}, \n \"form\": {\n \"hey\": \"there\"\n }") - r = HTTP.post("$sch://httpbin.org/post"; body=Dict("hey"=>"there"), chunksize=1000) - @test HTTP.status(r) == 200 - @test startswith(String(take!(r)), "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {}, \n \"form\": {\n \"hey\": \"there\"\n }") + r = HTTP.post("$sch://httpbin.org/post"; body=Dict("hey"=>"there")) + @test status(r) == 200 + @test startswith(String(r.body), "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {}, \n \"form\": {\n \"hey\": \"there\"\n }") tmp = tempname() open(f->write(f, "hey"), tmp, "w") io = open(tmp) r = HTTP.post("$sch://httpbin.org/post"; body=Dict("hey"=>"there", "iostream"=>io)) close(io); rm(tmp) - @test HTTP.status(r) == 200 - str = String(take!(r)) + @test status(r) == 200 + str = String(r.body) @test startswith(str, "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {\n \"iostream\": \"hey\"\n }, \n \"form\": {\n \"hey\": \"there\"\n }") tmp = tempname() open(f->write(f, "hey"), tmp, "w") io = open(tmp) - r = HTTP.post("$sch://httpbin.org/post"; body=Dict("hey"=>"there", "iostream"=>io), chunksize=1000) + r = HTTP.post("$sch://httpbin.org/post"; body=Dict("hey"=>"there", "iostream"=>io)) close(io); rm(tmp) - @test HTTP.status(r) == 200 - @test startswith(String(take!(r)), "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {\n \"iostream\": \"hey\"\n }, \n \"form\": {\n \"hey\": \"there\"\n }") + @test status(r) == 200 + @test startswith(String(r.body), "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {\n \"iostream\": \"hey\"\n }, \n \"form\": {\n \"hey\": \"there\"\n }") tmp = tempname() open(f->write(f, "hey"), tmp, "w") @@ -134,99 +132,107 @@ for sch in ("http", "https") m = HTTP.Multipart("mycoolfile.txt", io) r = HTTP.post("$sch://httpbin.org/post"; body=Dict("hey"=>"there", "multi"=>m)) close(io); rm(tmp) - @test HTTP.status(r) == 200 - @test startswith(String(take!(r)), "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {\n \"multi\": \"hey\"\n }, \n \"form\": {\n \"hey\": \"there\"\n }") + @test status(r) == 200 + @test startswith(String(r.body), "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {\n \"multi\": \"hey\"\n }, \n \"form\": {\n \"hey\": \"there\"\n }") tmp = tempname() open(f->write(f, "hey"), tmp, "w") io = open(tmp) m = HTTP.Multipart("mycoolfile", io, "application/octet-stream") - r = HTTP.post("$sch://httpbin.org/post"; body=Dict("hey"=>"there", "multi"=>m), chunksize=1000) + r = HTTP.post("$sch://httpbin.org/post"; body=Dict("hey"=>"there", "multi"=>m), #=chunksize=1000=#) close(io); rm(tmp) - @test HTTP.status(r) == 200 - @test startswith(String(take!(r)), "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {\n \"multi\": \"hey\"\n }, \n \"form\": {\n \"hey\": \"there\"\n }") + @test status(r) == 200 + @test startswith(String(r.body), "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {\n \"multi\": \"hey\"\n }, \n \"form\": {\n \"hey\": \"there\"\n }") # asynchronous println("asynchronous client request body") begin f = HTTP.FIFOBuffer() write(f, "hey") - t = @async HTTP.post("$sch://httpbin.org/post"; body=f) + t = @async HTTP.post("$sch://httpbin.org/post"; body=f, enablechunked=false) wait(f) # wait for the async call to write it's first data write(f, " there ") # as we write to f, it triggers another chunk to be sent in our async request write(f, "sailor") close(f) # setting eof on f causes the async request to send a final chunk and return the response - @test HTTP.status(wait(t)) == 200 + @test status(wait(t)) == 200 end # redirects println("client redirect following") r = HTTP.get("$sch://httpbin.org/redirect/1") - @test HTTP.status(r) == 200 - @test length(HTTP.history(r)) == 1 - @test_throws HTTP.RedirectError HTTP.get("$sch://httpbin.org/redirect/6") - @test HTTP.status(HTTP.get("$sch://httpbin.org/relative-redirect/1")) == 200 - @test HTTP.status(HTTP.get("$sch://httpbin.org/absolute-redirect/1")) == 200 - @test HTTP.status(HTTP.get("$sch://httpbin.org/redirect-to?url=http%3A%2F%2Fexample.com")) == 200 - - @test HTTP.status(HTTP.post("$sch://httpbin.org/post"; body="√")) == 200 + @test status(r) == 200 + #@test length(HTTP.history(r)) == 1 + @test status(HTTP.get("$sch://httpbin.org/redirect/6")) == 302 + @test status(HTTP.get("$sch://httpbin.org/relative-redirect/1")) == 200 + @test status(HTTP.get("$sch://httpbin.org/absolute-redirect/1")) == 200 + @test status(HTTP.get("$sch://httpbin.org/redirect-to?url=http%3A%2F%2Fexample.com")) == 200 + + @test status(HTTP.post("$sch://httpbin.org/post"; body="√")) == 200 println("client basic auth") - @test HTTP.status(HTTP.get("$sch://user:pwd@httpbin.org/basic-auth/user/pwd")) == 200 - @test HTTP.status(HTTP.get("$sch://user:pwd@httpbin.org/hidden-basic-auth/user/pwd")) == 200 + @test status(HTTP.get("$sch://user:pwd@httpbin.org/basic-auth/user/pwd"; basic_authorization=true)) == 200 + @test status(HTTP.get("$sch://user:pwd@httpbin.org/hidden-basic-auth/user/pwd"; basic_authorization=true)) == 200 # custom client & other high-level entries println("high-level client request methods") + cli = HTTP.Client() +#= buf = IOBuffer() cli = HTTP.Client(buf) HTTP.get(cli, "$sch://httpbin.org/ip") seekstart(buf) @test length(String(take!(buf))) > 0 +=# - r = HTTP.request("$sch://httpbin.org/ip") - @test HTTP.status(r) == 200 + r = HTTP.request("GET", "$sch://httpbin.org/ip") + @test status(r) == 200 uri = HTTP.URI("$sch://httpbin.org/ip") - r = HTTP.request(uri) - @test HTTP.status(r) == 200 + r = HTTP.request("GET", uri) + @test status(r) == 200 r = HTTP.get(uri) - @test HTTP.status(r) == 200 + @test status(r) == 200 r = HTTP.get(cli, uri) - @test HTTP.status(r) == 200 + @test status(r) == 200 - r = HTTP.request(HTTP.GET, "$sch://httpbin.org/ip") - @test HTTP.status(r) == 200 + r = HTTP.request("GET", "$sch://httpbin.org/ip") + @test status(r) == 200 uri = HTTP.URI("$sch://httpbin.org/ip") r = HTTP.request("GET", uri) - @test HTTP.status(r) == 200 + @test status(r) == 200 +#= FIXME req = HTTP.Request(HTTP.GET, uri, HTTP.Headers(), HTTP.FIFOBuffer()) r = HTTP.request(req) - @test HTTP.status(r) == 200 + @test status(r) == 200 @test HTTP.request(r) !== nothing @test length(take!(r)) > 0 +=# +#= for c in HTTP.DEFAULT_CLIENT.httppool["httpbin.org"] HTTP.dead!(c) end +=# r = HTTP.get(cli, "$sch://httpbin.org/ip") - @test isempty(HTTP.cookies(r)) - @test isempty(HTTP.history(r)) +# @test isempty(HTTP.cookies(r)) +# @test isempty(HTTP.history(r)) r = HTTP.get("$sch://httpbin.org/image/png") - @test HTTP.status(r) == 200 + @test status(r) == 200 # ensure we can use AbstractString for requests r = HTTP.get(SubString("http://httpbin.org/ip",1)) # canonicalizeheaders - @test HTTP.status(HTTP.get("$sch://httpbin.org/ip"; canonicalizeheaders=false)) == 200 + @test status(HTTP.get("$sch://httpbin.org/ip"; canonicalizeheaders=false)) == 200 # r = HTTP.connect("http://47.89.41.164:80") # gzip body = "hey" # body = UInt8[0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x00,0x03,0xcb,0x48,0xad,0x04,0x00,0xf0,0x15,0xd6,0x88,0x03,0x00,0x00,0x00] # r = HTTP.post("$sch://httpbin.org/post"; body=body, chunksize=1) + end end # @testset "HTTP.Client" diff --git a/test/cookies.jl b/test/cookies.jl index d5738e974..3a0dfa04b 100644 --- a/test/cookies.jl +++ b/test/cookies.jl @@ -49,25 +49,25 @@ end @testset "readsetcookies" begin cookietests = [ - (HTTP.Headers("Set-Cookie"=> "Cookie-1=v\$1"), [HTTP.Cookie("Cookie-1", "v\$1")]), - (HTTP.Headers("Set-Cookie"=> "NID=99=YsDT5i3E-CXax-; expires=Wed, 23-Nov-2011 01:05:03 GMT; path=/; domain=.google.ch; HttpOnly"), + (Dict(["Set-Cookie"=> "Cookie-1=v\$1"]), [HTTP.Cookie("Cookie-1", "v\$1")]), + (Dict(["Set-Cookie"=> "NID=99=YsDT5i3E-CXax-; expires=Wed, 23-Nov-2011 01:05:03 GMT; path=/; domain=.google.ch; HttpOnly"]), [HTTP.Cookie("NID", "99=YsDT5i3E-CXax-"; path="/", domain="google.ch", httponly=true, expires=Dates.DateTime(2011, 11, 23, 1, 5, 3, 0))]), - (HTTP.Headers("Set-Cookie"=> ".ASPXAUTH=7E3AA; expires=Wed, 07-Mar-2012 14:25:06 GMT; path=/; HttpOnly"), + (Dict(["Set-Cookie"=> ".ASPXAUTH=7E3AA; expires=Wed, 07-Mar-2012 14:25:06 GMT; path=/; HttpOnly"]), [HTTP.Cookie(".ASPXAUTH", "7E3AA"; path="/", expires=Dates.DateTime(2012, 3, 7, 14, 25, 6, 0), httponly=true)]), - (HTTP.Headers("Set-Cookie"=> "ASP.NET_SessionId=foo; path=/; HttpOnly"), + (Dict(["Set-Cookie"=> "ASP.NET_SessionId=foo; path=/; HttpOnly"]), [HTTP.Cookie("ASP.NET_SessionId", "foo"; path="/", httponly=true)]), - (HTTP.Headers("Set-Cookie"=> "special-1=a z"), [HTTP.Cookie("special-1", "a z")]), - (HTTP.Headers("Set-Cookie"=> "special-2=\" z\""), [HTTP.Cookie("special-2", " z")]), - (HTTP.Headers("Set-Cookie"=> "special-3=\"a \""), [HTTP.Cookie("special-3", "a ")]), - (HTTP.Headers("Set-Cookie"=> "special-4=\" \""), [HTTP.Cookie("special-4", " ")]), - (HTTP.Headers("Set-Cookie"=> "special-5=a,z"), [HTTP.Cookie("special-5", "a,z")]), - (HTTP.Headers("Set-Cookie"=> "special-6=\",z\""), [HTTP.Cookie("special-6", ",z")]), - (HTTP.Headers("Set-Cookie"=> "special-7=a,"), [HTTP.Cookie("special-7", "a,")]), - (HTTP.Headers("Set-Cookie"=> "special-8=\",\""), [HTTP.Cookie("special-8", ",")]), + (Dict(["Set-Cookie"=> "special-1=a z"]), [HTTP.Cookie("special-1", "a z")]), + (Dict(["Set-Cookie"=> "special-2=\" z\""]), [HTTP.Cookie("special-2", " z")]), + (Dict(["Set-Cookie"=> "special-3=\"a \""]), [HTTP.Cookie("special-3", "a ")]), + (Dict(["Set-Cookie"=> "special-4=\" \""]), [HTTP.Cookie("special-4", " ")]), + (Dict(["Set-Cookie"=> "special-5=a,z"]), [HTTP.Cookie("special-5", "a,z")]), + (Dict(["Set-Cookie"=> "special-6=\",z\""]), [HTTP.Cookie("special-6", ",z")]), + (Dict(["Set-Cookie"=> "special-7=a,"]), [HTTP.Cookie("special-7", "a,")]), + (Dict(["Set-Cookie"=> "special-8=\",\""]), [HTTP.Cookie("special-8", ",")]), ] for (h, c) in cookietests - @test HTTP.Cookies.readsetcookies("", [h["Set-Cookie"]]) == c + @test HTTP.Cookies.readsetcookies("", [Dict(h)["Set-Cookie"]]) == c end end diff --git a/test/handlers.jl b/test/handlers.jl index 952825a9c..6d107ad26 100644 --- a/test/handlers.jl +++ b/test/handlers.jl @@ -1,3 +1,13 @@ +using HTTP +using HTTP.Test + +import Base.== + +==(a::HTTP.Response,b::HTTP.Response) = (a.status == b.status) && + (a.version == b.version) && + (a.headers == b.headers) && + (a.body == b.body) + @testset "HTTP.Handler" begin f = HTTP.HandlerFunction((req, resp) -> HTTP.Response(200)) @@ -10,7 +20,7 @@ r = HTTP.Router() HTTP.register!(r, "/path/to/greatness", f) @test length(methods(r.func)) == 2 req = HTTP.Request() -req.uri = HTTP.URI("/path/to/greatness") +req.target = "/path/to/greatness" @test HTTP.handle(r, req, HTTP.Response()) == HTTP.Response(200) p = "/next/path/to/greatness" @@ -18,24 +28,24 @@ f2 = HTTP.HandlerFunction((req, resp) -> HTTP.Response(201)) HTTP.register!(r, p, f2) @test length(methods(r.func)) == 3 req = HTTP.Request() -req.uri = HTTP.URI("/next/path/to/greatness") +req.target = "/next/path/to/greatness" @test HTTP.handle(r, req, HTTP.Response()) == HTTP.Response(201) r = HTTP.Router() HTTP.register!(r, "GET", "/sget", f) HTTP.register!(r, "POST", "/spost", f) -HTTP.register!(r, HTTP.POST, "/tpost", f) -req = HTTP.Request(HTTP.GET, "/sget") +HTTP.register!(r, "POST", "/tpost", f) +req = HTTP.Request("GET", "/sget") @test HTTP.handle(r, req, HTTP.Response()) == HTTP.Response(200) -req = HTTP.Request(HTTP.POST, "/sget") +req = HTTP.Request("POST", "/sget") @test HTTP.handle(r, req, HTTP.Response()) == HTTP.Response(404) -req = HTTP.Request(HTTP.GET, "/spost") +req = HTTP.Request("GET", "/spost") @test HTTP.handle(r, req, HTTP.Response()) == HTTP.Response(404) -req = HTTP.Request(HTTP.POST, "/spost") +req = HTTP.Request("POST", "/spost") @test HTTP.handle(r, req, HTTP.Response()) == HTTP.Response(200) -req = HTTP.Request(HTTP.GET, "/tpost") +req = HTTP.Request("GET", "/tpost") @test HTTP.handle(r, req, HTTP.Response()) == HTTP.Response(404) -req = HTTP.Request(HTTP.POST, "/tpost") +req = HTTP.Request("POST", "/tpost") @test HTTP.handle(r, req, HTTP.Response()) == HTTP.Response(200) r = HTTP.Router() @@ -47,16 +57,16 @@ f4 = HTTP.HandlerFunction((req, resp) -> HTTP.Response(203)) HTTP.register!(r, "/test/*/ghotra/seven", f4) req = HTTP.Request() -req.uri = HTTP.URI("/test") +req.target = "/test" @test HTTP.handle(r, req, HTTP.Response()) == HTTP.Response(200) -req.uri = HTTP.URI("/test/sarv") +req.target = "/test/sarv" @test HTTP.handle(r, req, HTTP.Response()) == HTTP.Response(201) -req.uri = HTTP.URI("/test/sarv/ghotra") +req.target = "/test/sarv/ghotra" @test HTTP.handle(r, req, HTTP.Response()) == HTTP.Response(202) -req.uri = HTTP.URI("/test/sar/ghotra/seven") +req.target = "/test/sar/ghotra/seven" @test HTTP.handle(r, req, HTTP.Response()) == HTTP.Response(203) end diff --git a/test/loopback.jl b/test/loopback.jl new file mode 100644 index 000000000..371cce865 --- /dev/null +++ b/test/loopback.jl @@ -0,0 +1,432 @@ +using HTTP +using HTTP.Test +using HTTP.IOExtras +using HTTP.Parsers +using HTTP.Messages +using HTTP.MessageRequest.bodylength +using HTTP.Parsers.escapelines + + +mutable struct FunctionIO <: IO + f::Function + buf::IOBuffer + done::Bool +end + +FunctionIO(f::Function) = FunctionIO(f, IOBuffer(), false) +call(fio::FunctionIO) = !fio.done && + (fio.buf = IOBuffer(fio.f()) ; fio.done = true) +Base.eof(fio::FunctionIO) = (call(fio); eof(fio.buf)) +Base.nb_available(fio::FunctionIO) = (call(fio); nb_available(fio.buf)) +Base.readavailable(fio::FunctionIO) = (call(fio); readavailable(fio.buf)) +Base.read(fio::FunctionIO, a...) = (call(fio); read(fio.buf, a...)) + + +mutable struct Loopback <: IO + got_headers::Bool + buf::IOBuffer + io::BufferStream +end +Loopback() = Loopback(false, IOBuffer(), BufferStream()) + +function reset(lb::Loopback) + truncate(lb.buf, 0) + lb.got_headers = false +end + +Base.eof(lb::Loopback) = eof(lb.io) +Base.nb_available(lb::Loopback) = nb_available(lb.io) +Base.readavailable(lb::Loopback) = readavailable(lb.io) +Base.close(lb::Loopback) = (close(lb.io); close(lb.buf)) +Base.isopen(lb::Loopback) = isopen(lb.io) + +HTTP.ConnectionPool.tcpstatus(c::HTTP.ConnectionPool.Connection{Loopback}) = "🤖 " + + +server_events = [] + +function on_headers(f, lb) + if lb.got_headers + return + end + buf = copy(lb.buf) + seek(buf, 0) + req = Request() + try + readheaders(buf, Parser(), req) + lb.got_headers = true + catch e + if !(e isa EOFError || e isa HTTP.ParsingError) + rethrow(e) + end + end + if lb.got_headers + f(req) + end +end + +function on_body(f, lb) + s = String(take!(copy(lb.buf))) +# println("Request: \"\"\"") +# println(escapelines(s)) +# println("\"\"\"") + req = nothing + try + req = parse(HTTP.Request, s) + catch e + if !(e isa EOFError || e isa HTTP.ParsingError) + rethrow(e) + end + end + if req != nothing + reset(lb) + @schedule try + f(req) + catch e + println("⚠️ on_body exception: $(sprint(showerror, e))\n$(catch_stacktrace())") + end + end +end + + +function Base.unsafe_write(lb::Loopback, p::Ptr{UInt8}, n::UInt) + + global server_events + + if !isopen(lb.buf) + throw(ArgumentError("stream is closed or unusable")) + end + + n = unsafe_write(lb.buf, p, n) + + on_headers(lb) do req + + println("📡 $(sprint(showcompact, req))") + push!(server_events, "Request: $(sprint(showcompact, req))") + + if req.target == "/abort" + reset(lb) + response = HTTP.Response(403, ["Connection" => "close", + "Content-Length" => 0]; request=req) + push!(server_events, "Response: $(sprint(showcompact, response))") + write(lb.io, response) + end + end + + on_body(lb) do req + + l = length(req.body) + response = HTTP.Response(200, ["Content-Length" => l], + body = req.body; request=req) + if req.target == "/echo" + push!(server_events, "Response: $(sprint(showcompact, response))") + write(lb.io, response) + elseif (m = match(r"^/delay([0-9]*)$", req.target)) != nothing + t = parse(Int, first(m.captures)) + sleep(t/10) + push!(server_events, "Response: $(sprint(showcompact, response))") + write(lb.io, response) + else + response = HTTP.Response(403, + ["Connection" => "close", + "Content-Length" => 0]; request=req) + push!(server_events, "Response: $(sprint(showcompact, response))") + write(lb.io, response) + end + end + + return n +end + +HTTP.IOExtras.tcpsocket(::Loopback) = TCPSocket() + +function HTTP.ConnectionPool.getconnection(::Type{Loopback}, + host::AbstractString, + port::AbstractString; + kw...)::Loopback + return Loopback() +end + +config = [ + :socket_type => Loopback, + :retry => false, + :connection_limit => 1 +] + +lbreq(req, headers, body; method="GET", kw...) = + HTTP.request(method, "http://test/$req", headers, body; config..., kw...) + +lbopen(f, req, headers) = + HTTP.open(f, "PUT", "http://test/$req", headers; config...) + +@testset "loopback" begin + + global server_events + + r = lbreq("echo", [], ["Hello", IOBuffer(" "), "World!"]); + @test String(r.body) == "Hello World!" + + io = FunctionIO(()->"Hello World!") + @test String(read(io)) == "Hello World!" + + r = lbreq("echo", [], FunctionIO(()->"Hello World!")) + @test String(r.body) == "Hello World!" + + r = lbreq("echo", [], ["Hello", " ", "World!"]); + @test String(r.body) == "Hello World!" + + r = lbreq("echo", [], [Vector{UInt8}("Hello"), + Vector{UInt8}(" "), + Vector{UInt8}("World!")]); + @test String(r.body) == "Hello World!" + + r = lbreq("delay10", [], [Vector{UInt8}("Hello"), + Vector{UInt8}(" "), + Vector{UInt8}("World!")]); + @test String(r.body) == "Hello World!" + + HTTP.ConnectionPool.showpool(STDOUT) + + body = nothing + body_sent = false + r = lbopen("delay10", []) do http + @sync begin + @async begin + write(http, "Hello World!") + closewrite(http) + body_sent = true + end + startread(http) + body = read(http) + closeread(http) + end + end + @test String(body) == "Hello World!" + + + + # "If [the response] indicates the server does not wish to receive the + # message body and is closing the connection, the client SHOULD + # immediately cease transmitting the body and close the connection." + # https://tools.ietf.org/html/rfc7230#section-6.5 + + body = nothing + body_aborted = false + body_sent = false + @test_throws HTTP.StatusError begin + r = lbopen("abort", []) do http + @sync begin + @async try + sleep(0.1) + write(http, "Hello World!") + closewrite(http) + body_sent = true + catch e + if e isa ArgumentError && + e.msg == "stream is closed or unusable" + body_aborted = true + else + rethrow(e) + end + end + startread(http) + body = read(http) + closeread(http) + end + end + end + @test body_aborted == true + @test body_sent == false + + r = lbreq("echo", [], [ + FunctionIO(()->(sleep(0.1); "Hello")), + FunctionIO(()->(sleep(0.1); " World!"))]) + @test String(r.body) == "Hello World!" + + hello_sent = false + world_sent = false + @test_throws HTTP.StatusError begin + r = lbreq("abort", [], [ + FunctionIO(()->(hello_sent = true; sleep(0.1); "Hello")), + FunctionIO(()->(world_sent = true; " World!"))]) + end + @test hello_sent + @test !world_sent + + HTTP.ConnectionPool.showpool(STDOUT) + + function async_test(m=["GET","GET","GET","GET","GET"];kw...) + r1 = nothing + r2 = nothing + r3 = nothing + r4 = nothing + r5 = nothing + t1 = time() + @sync begin + @async r1 = lbreq("delay1", [], FunctionIO(()->(sleep(0.00); "Hello World! 1")); + method=m[1], kw...) + @async r2 = lbreq("delay2", [], + FunctionIO(()->(sleep(0.01); "Hello World! 2")); + method=m[2], kw...) + @async r3 = lbreq("delay3", [], + FunctionIO(()->(sleep(0.02); "Hello World! 3")); + method=m[3], kw...) + @async r4 = lbreq("delay4", [], + FunctionIO(()->(sleep(0.03); "Hello World! 4")); + method=m[4], kw...) + @async r5 = lbreq("delay5", [], + FunctionIO(()->(sleep(0.04); "Hello World! 5")); + method=m[5], kw...) + end + t2 = time() + + @test String(r1.body) == "Hello World! 1" + @test String(r2.body) == "Hello World! 2" + @test String(r3.body) == "Hello World! 3" + @test String(r4.body) == "Hello World! 4" + @test String(r5.body) == "Hello World! 5" + + return t2 - t1 + end + + + server_events = [] + t = async_test(;pipeline_limit=0) + @show t +# @test 2.1 < t < 2.3 + @test server_events == [ + "Request: GET /delay1 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay1 HTTP/1.1)", + "Request: GET /delay2 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay2 HTTP/1.1)", + "Request: GET /delay3 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay3 HTTP/1.1)", + "Request: GET /delay4 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay4 HTTP/1.1)", + "Request: GET /delay5 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay5 HTTP/1.1)"] + + server_events = [] + t = async_test(;pipeline_limit=1) + @show t +# @test 0.9 < t < 1.1 + @test server_events == [ + "Request: GET /delay1 HTTP/1.1", + "Request: GET /delay2 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay1 HTTP/1.1)", + "Request: GET /delay3 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay2 HTTP/1.1)", + "Request: GET /delay4 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay3 HTTP/1.1)", + "Request: GET /delay5 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay4 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay5 HTTP/1.1)"] + + server_events = [] + t = async_test(;pipeline_limit=2) + @show t +# @test 0.6 < t < 1 + @test server_events == [ + "Request: GET /delay1 HTTP/1.1", + "Request: GET /delay2 HTTP/1.1", + "Request: GET /delay3 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay1 HTTP/1.1)", + "Request: GET /delay4 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay2 HTTP/1.1)", + "Request: GET /delay5 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay3 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay4 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay5 HTTP/1.1)"] + + server_events = [] + t = async_test(;pipeline_limit=3) + @show t +# @test 0.5 < t < 0.8 + @test server_events == [ + "Request: GET /delay1 HTTP/1.1", + "Request: GET /delay2 HTTP/1.1", + "Request: GET /delay3 HTTP/1.1", + "Request: GET /delay4 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay1 HTTP/1.1)", + "Request: GET /delay5 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay2 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay3 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay4 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay5 HTTP/1.1)"] || + server_events == [ + "Request: GET /delay1 HTTP/1.1", + "Request: GET /delay2 HTTP/1.1", + "Request: GET /delay3 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay1 HTTP/1.1)", + "Request: GET /delay4 HTTP/1.1", + "Request: GET /delay5 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay2 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay3 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay4 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay5 HTTP/1.1)"] + # https://github.com/JuliaWeb/HTTP.jl/pull/135#issuecomment-357376222 + + server_events = [] + t = async_test() + @show t +# @test 0.5 < t < 0.8 + @test server_events == [ + "Request: GET /delay1 HTTP/1.1", + "Request: GET /delay2 HTTP/1.1", + "Request: GET /delay3 HTTP/1.1", + "Request: GET /delay4 HTTP/1.1", + "Request: GET /delay5 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay1 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay2 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay3 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay4 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay5 HTTP/1.1)"] || + server_events == [ + "Request: GET /delay1 HTTP/1.1", + "Request: GET /delay2 HTTP/1.1", + "Request: GET /delay3 HTTP/1.1", + "Request: GET /delay4 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay1 HTTP/1.1)", + "Request: GET /delay5 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay2 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay3 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay4 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay5 HTTP/1.1)"] + # https://github.com/JuliaWeb/HTTP.jl/pull/135#issuecomment-357376222 + + + # "A user agent SHOULD NOT pipeline requests after a + # non-idempotent method, until the final response + # status code for that method has been received" + # https://tools.ietf.org/html/rfc7230#section-6.3.2 + + server_events = [] + t = async_test(["POST","GET","GET","GET","GET"]) + @test server_events == [ + "Request: POST /delay1 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (POST /delay1 HTTP/1.1)", + "Request: GET /delay2 HTTP/1.1", + "Request: GET /delay3 HTTP/1.1", + "Request: GET /delay4 HTTP/1.1", + "Request: GET /delay5 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay2 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay3 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay4 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay5 HTTP/1.1)"] + + server_events = [] + t = async_test(["GET","GET","POST", "GET","GET"]) + @test server_events == [ + "Request: GET /delay1 HTTP/1.1", + "Request: GET /delay2 HTTP/1.1", + "Request: POST /delay3 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay1 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay2 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (POST /delay3 HTTP/1.1)", + "Request: GET /delay4 HTTP/1.1", + "Request: GET /delay5 HTTP/1.1", + "Response: HTTP/1.1 200 OK <= (GET /delay4 HTTP/1.1)", + "Response: HTTP/1.1 200 OK <= (GET /delay5 HTTP/1.1)"] + + HTTP.ConnectionPool.closeall() +end diff --git a/test/messages.jl b/test/messages.jl new file mode 100644 index 000000000..07902c5d3 --- /dev/null +++ b/test/messages.jl @@ -0,0 +1,172 @@ +module MessagesTest + +using ..Test + +using HTTP.Messages +import HTTP.Messages.appendheader +import HTTP.URI +import HTTP.request + +using HTTP.StatusError + +using HTTP.MessageRequest.bodylength +using HTTP.MessageRequest.bodybytes +using HTTP.MessageRequest.unknown_length + +using JSON + +@testset "HTTP.Messages" begin + + @test bodylength(7) == unknown_length + @test bodylength(UInt8[1,2,3]) == 3 + @test bodylength(view(UInt8[1,2,3], 1:2)) == 2 + @test bodylength("Hello") == 5 + @test bodylength(SubString("World!",1,5)) == 5 + @test bodylength(["Hello", " ", "World!"]) == 12 + @test bodylength(["Hello", " ", SubString("World!",1,5)]) == 11 + @test bodylength([SubString("Hello", 1,5), " ", SubString("World!",1,5)]) == 11 + @test bodylength([UInt8[1,2,3], UInt8[4,5,6]]) == 6 + @test bodylength([UInt8[1,2,3], view(UInt8[4,5,6],1:2)]) == 5 + @test bodylength([view(UInt8[1,2,3],1:2), view(UInt8[4,5,6],1:2)]) == 4 + @test bodylength(IOBuffer("foo")) == 3 + @test bodylength([IOBuffer("foo"), IOBuffer("bar")]) == 6 + + @test bodybytes(7) == UInt8[] + @test bodybytes(UInt8[1,2,3]) == UInt8[1,2,3] + @test bodybytes(view(UInt8[1,2,3], 1:2)) == UInt8[1,2] + @test bodybytes("Hello") == Vector{UInt8}("Hello") + @test bodybytes(SubString("World!",1,5)) == Vector{UInt8}("World") + @test bodybytes(["Hello", " ", "World!"]) == UInt8[] + @test bodybytes([UInt8[1,2,3], UInt8[4,5,6]]) == UInt8[] + + + req = Request("GET", "/foo", ["Foo" => "Bar"]) + res = Response(200, ["Content-Length" => "5"]; body="Hello", request=req) + + @test req.method == "GET" + @test res.request.method == "GET" + + #display(req); println() + #display(res); println() + + @test String(req) == "GET /foo HTTP/1.1\r\nFoo: Bar\r\n\r\n" + @test String(res) == "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello" + + @test header(req, "Foo") == "Bar" + @test header(res, "Content-Length") == "5" + setheader(req, "X" => "Y") + @test header(req, "X") == "Y" + + appendheader(req, "" => "Z") + @test header(req, "X") == "YZ" + + appendheader(req, "X" => "more") + @test header(req, "X") == "YZ, more" + + appendheader(req, "Set-Cookie" => "A") + appendheader(req, "Set-Cookie" => "B") + @test filter(x->first(x) == "Set-Cookie", req.headers) == + ["Set-Cookie" => "A", "Set-Cookie" => "B"] + + @test Messages.httpversion(req) == "HTTP/1.1" + @test Messages.httpversion(res) == "HTTP/1.1" + + raw = String(req) + #@show raw + req = Request(raw) + #display(req); println() + @test String(req) == raw + + req = Request(raw * "xxx") + @test String(req) == raw + + raw = String(res) + #@show raw + res = Response(raw) + #display(res); println() + @test String(res) == raw + + res = Response(raw * "xxx") + @test String(res) == raw + + for sch in ["http", "https"] + for m in ["GET", "HEAD", "OPTIONS"] + @test request(m, "$sch://httpbin.org/ip").status == 200 + end + try + request("POST", "$sch://httpbin.org/ip") + @test false + catch e + @test isa(e, StatusError) + @test e.status == 405 + end + end + +#= + @sync begin + io = BufferStream() + @async begin + for i = 1:100 + sleep(0.1) + write(io, "Hello!") + end + close(io) + end + yield() + r = request("POST", "http://httpbin.org/post", [], io) + @test r.status == 200 + end +=# + + for sch in ["http", "https"] + for m in ["POST", "PUT", "DELETE", "PATCH"] + + uri = "$sch://httpbin.org/$(lowercase(m))" + r = request(m, uri) + @test r.status == 200 + body = r.body + + io = BufferStream() + r = request(m, uri, response_stream=io) + @test r.status == 200 + @test read(io) == body + end + end + + for sch in ["http", "https"] + for m in ["POST", "PUT", "DELETE", "PATCH"] + + uri = "$sch://httpbin.org/$(lowercase(m))" + io = BufferStream() + r = request(m, uri, response_stream=io) + @test r.status == 200 + end + + r = request("POST", "$sch://httpbin.org/post", + ["Expect" => "100-continue"], "Hello") + @test r.status == 200 + r = JSON.parse(String(r.body)) + @test r["data"] == "Hello" + end + + mktempdir() do d + cd(d) do + + n = 50 + io = open("result_file", "w") + r = request("GET", "http://httpbin.org/stream/$n", + response_stream=io) + @show filesize("result_file") + i = 0 + for l in readlines("result_file") + x = JSON.parse(l) + @test i == x["id"] + i += 1 + end + @test i == n + end + end + +end + +end # module MessagesTest diff --git a/test/parser.jl b/test/parser.jl index 0c1142473..cf7645d4b 100644 --- a/test/parser.jl +++ b/test/parser.jl @@ -1,7 +1,88 @@ +using HTTP +using HTTP.Test + +module ParserTest + +using ..Test + +import ..HTTP +import ..HTTP.pairs + +using HTTP.Messages +using HTTP.Parsers + +const DEFAULT_PARSER = Parser() + +import Base.== + +const Headers = Vector{Pair{String,String}} + +==(a::Request,b::Request) = (a.method == b.method) && + (a.version == b.version) && + (a.headers == b.headers) && + (a.body == b.body) + + +function HTTP.IOExtras.unread!(io::BufferStream, bytes) + if length(bytes) == 0 + return + end + if nb_available(io) > 0 + buf = readavailable(io) + write(io, bytes) + write(io, buf) + else + write(io, bytes) + end + return +end + +function Base.length(io::IOBuffer) + mark(io) + seek(io, 0) + n = nb_available(io) + reset(io) + return n +end + +parse!(parser::Parser, message::Messages.Message, body, bytes)::Int = + parse!(parser, message, body, Vector{UInt8}(bytes)) + +function parse!(parser::Parser, message::Messages.Message, body, bytes::Vector{UInt8})::Int + + l = length(bytes) + count = 0 + while count < l + if !headerscomplete(parser) + excess = parseheaders(parser, bytes) do h + appendheader(message, h) + end + readstartline!(parser.message, message) + else + if ischunked(message) + fragment, excess = parsebody(parser, bytes) + write(body, fragment) + else + n = min(length(bytes), bodylength(message) - length(body)) + write(body, view(bytes, 1:n)) + count += n + break + end + end + count += length(bytes) - length(excess) + bytes = excess + if ischunked(message) && messagecomplete(parser) + break + end + end + return count +end + + mutable struct Message name::String raw::String - method::HTTP.Method + method::String status_code::Int response_status::String request_path::String @@ -14,13 +95,13 @@ mutable struct Message userinfo::String port::String num_headers::Int - headers::Dict{String,String} + headers::Headers should_keep_alive::Bool upgrade::String http_major::Int http_minor::Int - Message(name::String) = new(name, "", HTTP.GET, 200, "", "", "", "", "", "", 0, "", "", "", 0, HTTP.Headers(), true, "", 1, 1) + Message(name::String) = new(name, "", "GET", 200, "", "", "", "", "", "", 0, "", "", "", 0, Headers(), true, "", 1, 1) end function Message(; name::String="", kwargs...) @@ -35,28 +116,30 @@ function Message(; name::String="", kwargs...) return m end + + #= * R E Q U E S T S * =# const requests = Message[ Message(name= "curl get" ,raw= "GET /test HTTP/1.1\r\n" * "User-Agent: curl/7.18.0 (i486-pc-linux-gnu) libcurl/7.18.0 OpenSSL/0.9.8g zlib/1.2.3.3 libidn/1.1\r\n" * - "Host: 0.0.0.0=5000\r\n" * + "Host:0.0.0.0=5000\r\n" * # missing space after colon "Accept: */*\r\n" * "\r\n" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/test" ,request_url= "/test" ,num_headers= 3 -,headers=Dict{String,String}( +,headers=[ "User-Agent"=> "curl/7.18.0 (i486-pc-linux-gnu) libcurl/7.18.0 OpenSSL/0.9.8g zlib/1.2.3.3 libidn/1.1" , "Host"=> "0.0.0.0=5000" , "Accept"=> "*/*" - ) + ] ,body= "" ), Message(name= "firefox get" ,raw= "GET /favicon.ico HTTP/1.1\r\n" * @@ -72,13 +155,13 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/favicon.ico" ,request_url= "/favicon.ico" ,num_headers= 8 -,headers=Dict{String,String}( +,headers=[ "Host"=> "0.0.0.0=5000" , "User-Agent"=> "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9) Gecko/2008061015 Firefox/3.0" , "Accept"=> "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" @@ -87,7 +170,7 @@ Message(name= "curl get" , "Accept-Charset"=> "ISO-8859-1,utf-8;q=0.7,*;q=0.7" , "Keep-Alive"=> "300" , "Connection"=> "keep-alive" -) +] ,body= "" ), Message(name= "abcdefgh" ,raw= "GET /abcdefgh HTTP/1.1\r\n" * @@ -96,15 +179,15 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/abcdefgh" ,request_url= "/abcdefgh" ,num_headers= 1 -,headers=Dict{String,String}( +,headers=[ "Aaaaaaaaaaaaa"=> "++++++++++" -) +] ,body= "" ), Message(name= "fragment in url" ,raw= "GET /forums/1/topics/2375?page=1#posts-17408 HTTP/1.1\r\n" * @@ -112,7 +195,7 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "page=1" ,fragment= "posts-17408" ,request_path= "/forums/1/topics/2375" @@ -126,7 +209,7 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/get_no_headers_no_body/world" @@ -140,15 +223,15 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/get_one_header_no_body" ,request_url= "/get_one_header_no_body" ,num_headers= 1 -,headers=Dict{String,String}( +,headers=[ "Accept" => "*/*" -) +] ,body= "" ), Message(name= "get funky content length body hello" ,raw= "GET /get_funky_content_length_body_hello HTTP/1.0\r\n" * @@ -158,15 +241,15 @@ Message(name= "curl get" ,should_keep_alive= false ,http_major= 1 ,http_minor= 0 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/get_funky_content_length_body_hello" ,request_url= "/get_funky_content_length_body_hello" ,num_headers= 1 -,headers=Dict{String,String}( +,headers=[ "Content-Length" => "5" -) +] ,body= "HELLO" ), Message(name= "post identity body world" ,raw= "POST /post_identity_body_world?q=search#hey HTTP/1.1\r\n" * @@ -178,17 +261,17 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.POST +,method= "POST" ,query_string= "q=search" ,fragment= "hey" ,request_path= "/post_identity_body_world" ,request_url= "/post_identity_body_world?q=search#hey" ,num_headers= 3 -,headers=Dict{String,String}( +,headers=[ "Accept"=> "*/*" , "Transfer-Encoding"=> "identity" , "Content-Length"=> "5" -) +] ,body= "World" ), Message(name= "post - chunked body: all your base are belong to us" ,raw= "POST /post_chunked_all_your_base HTTP/1.1\r\n" * @@ -200,15 +283,15 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.POST +,method= "POST" ,query_string= "" ,fragment= "" ,request_path= "/post_chunked_all_your_base" ,request_url= "/post_chunked_all_your_base" ,num_headers= 1 -,headers=Dict{String,String}( +,headers=[ "Transfer-Encoding" => "chunked" -) +] ,body= "all your base are belong to us" ), Message(name= "two chunks ; triple zero ending" ,raw= "POST /two_chunks_mult_zero_end HTTP/1.1\r\n" * @@ -221,15 +304,15 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.POST +,method= "POST" ,query_string= "" ,fragment= "" ,request_path= "/two_chunks_mult_zero_end" ,request_url= "/two_chunks_mult_zero_end" ,num_headers= 1 -,headers=Dict{String,String}( +,headers=[ "Transfer-Encoding"=> "chunked" -) +] ,body= "hello world" ), Message(name= "chunked with trailing headers. blech." ,raw= "POST /chunked_w_trailing_headers HTTP/1.1\r\n" * @@ -244,17 +327,17 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.POST +,method= "POST" ,query_string= "" ,fragment= "" ,request_path= "/chunked_w_trailing_headers" ,request_url= "/chunked_w_trailing_headers" ,num_headers= 3 -,headers=Dict{String,String}( +,headers=[ "Transfer-Encoding"=> "chunked" , "Vary"=> "*" , "Content-Type"=> "text/plain" -) +] ,body= "hello world" ), Message(name= "with excessss after the length" ,raw= "POST /chunked_w_excessss_after_length HTTP/1.1\r\n" * @@ -267,28 +350,28 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.POST +,method= "POST" ,query_string= "" ,fragment= "" ,request_path= "/chunked_w_excessss_after_length" ,request_url= "/chunked_w_excessss_after_length" ,num_headers= 1 -,headers=Dict{String,String}( +,headers=[ "Transfer-Encoding"=> "chunked" -) +] ,body= "hello world" ), Message(name= "with quotes" ,raw= "GET /with_\"stupid\"_quotes?foo=\"bar\" HTTP/1.1\r\n\r\n" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "foo=\"bar\"" ,fragment= "" ,request_path= "/with_\"stupid\"_quotes" ,request_url= "/with_\"stupid\"_quotes?foo=\"bar\"" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body= "" ), Message(name = "apachebench get" ,raw= "GET /test HTTP/1.0\r\n" * @@ -298,42 +381,42 @@ Message(name= "curl get" ,should_keep_alive= false ,http_major= 1 ,http_minor= 0 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/test" ,request_url= "/test" ,num_headers= 3 -,headers=Dict{String,String}( "Host"=> "0.0.0.0:5000" +,headers=[ "Host"=> "0.0.0.0:5000" , "User-Agent"=> "ApacheBench/2.3" , "Accept"=> "*/*" - ) + ] ,body= "" ), Message(name = "query url with question mark" ,raw= "GET /test.cgi?foo=bar?baz HTTP/1.1\r\n\r\n" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "foo=bar?baz" ,fragment= "" ,request_path= "/test.cgi" ,request_url= "/test.cgi?foo=bar?baz" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body= "" ), Message(name = "newline prefix get" ,raw= "\r\nGET /test HTTP/1.1\r\n\r\n" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/test" ,request_url= "/test" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body= "" ), Message(name = "upgrade request" ,raw= "GET /demo HTTP/1.1\r\n" * @@ -349,21 +432,21 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/demo" ,request_url= "/demo" ,num_headers= 7 ,upgrade="Hot diggity dogg" -,headers=Dict{String,String}( "Host"=> "example.com" +,headers=[ "Host"=> "example.com" , "Connection"=> "Upgrade" , "Sec-Websocket-Key2"=> "12998 5 Y3 1 .P00" , "Sec-Websocket-Protocol"=> "sample" , "Upgrade"=> "WebSocket" , "Sec-Websocket-Key1"=> "4 @1 46546xW%0l 1 5" , "Origin"=> "http://example.com" - ) + ] ,body= "" ), Message(name = "connect request" ,raw= "CONNECT 0-home0.netscape.com:443 HTTP/1.0\r\n" * @@ -375,7 +458,7 @@ Message(name= "curl get" ,should_keep_alive= false ,http_major= 1 ,http_minor= 0 -,method= HTTP.CONNECT +,method= "CONNECT" ,query_string= "" ,fragment= "" ,request_path= "" @@ -384,9 +467,9 @@ Message(name= "curl get" ,request_url= "0-home0.netscape.com:443" ,num_headers= 2 ,upgrade="some data\r\nand yet even more data" -,headers=Dict{String,String}( "User-Agent"=> "Mozilla/1.1N" +,headers=[ "User-Agent"=> "Mozilla/1.1N" , "Proxy-Authorization"=> "basic aGVsbG86d29ybGQ=" - ) + ] ,body= "" ), Message(name= "report request" ,raw= "REPORT /test HTTP/1.1\r\n" * @@ -394,13 +477,13 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.REPORT +,method= "REPORT" ,query_string= "" ,fragment= "" ,request_path= "/test" ,request_url= "/test" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body= "" ), Message(name= "request with no http version" ,raw= "GET /\r\n" * @@ -408,13 +491,13 @@ Message(name= "curl get" ,should_keep_alive= false ,http_major= 0 ,http_minor= 9 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/" ,request_url= "/" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body= "" ), Message(name= "m-search request" ,raw= "M-SEARCH * HTTP/1.1\r\n" * @@ -425,16 +508,16 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.MSEARCH +,method= "M-SEARCH" ,query_string= "" ,fragment= "" ,request_path= "*" ,request_url= "*" ,num_headers= 3 -,headers=Dict{String,String}( "Host"=> "239.255.255.250:1900" +,headers=[ "Host"=> "239.255.255.250:1900" , "Man"=> "\"ssdp:discover\"" , "St"=> "\"ssdp:all\"" - ) + ] ,body= "" ), Message(name= "host terminated by a query string" ,raw= "GET http://hypnotoad.org?hail=all HTTP/1.1\r\n" * @@ -442,14 +525,14 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "hail=all" ,fragment= "" ,request_path= "" ,request_url= "http://hypnotoad.org?hail=all" ,host= "hypnotoad.org" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body= "" ), Message(name= "host:port terminated by a query string" ,raw= "GET http://hypnotoad.org:1234?hail=all HTTP/1.1\r\n" * @@ -457,7 +540,7 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "hail=all" ,fragment= "" ,request_path= "" @@ -465,7 +548,7 @@ Message(name= "curl get" ,host= "hypnotoad.org" ,port= "1234" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body= "" ), Message(name= "host:port terminated by a space" ,raw= "GET http://hypnotoad.org:1234 HTTP/1.1\r\n" * @@ -473,7 +556,7 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "" @@ -481,7 +564,7 @@ Message(name= "curl get" ,host= "hypnotoad.org" ,port= "1234" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body= "" ), Message(name = "PATCH request" ,raw= "PATCH /file.txt HTTP/1.1\r\n" * @@ -494,17 +577,17 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.PATCH +,method= "PATCH" ,query_string= "" ,fragment= "" ,request_path= "/file.txt" ,request_url= "/file.txt" ,num_headers= 4 -,headers=Dict{String,String}( "Host"=> "www.example.com" +,headers=[ "Host"=> "www.example.com" , "Content-Type"=> "application/example" , "If-Match"=> "\"e0023aa4e\"" , "Content-Length"=> "10" - ) + ] ,body= "cccccccccc" ), Message(name = "connect caps request" ,raw= "CONNECT HOME0.NETSCAPE.COM:443 HTTP/1.0\r\n" * @@ -514,7 +597,7 @@ Message(name= "curl get" ,should_keep_alive= false ,http_major= 1 ,http_minor= 0 -,method= HTTP.CONNECT +,method= "CONNECT" ,query_string= "" ,fragment= "" ,request_path= "" @@ -523,9 +606,9 @@ Message(name= "curl get" ,port="443" ,num_headers= 2 ,upgrade="" -,headers=Dict{String,String}( "User-Agent"=> "Mozilla/1.1N" +,headers=[ "User-Agent"=> "Mozilla/1.1N" , "Proxy-Authorization"=> "basic aGVsbG86d29ybGQ=" - ) + ] ,body= "" ), Message(name= "utf-8 path request" ,raw= "GET /δ¶/δt/pope?q=1#narf HTTP/1.1\r\n" * @@ -534,13 +617,13 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "q=1" ,fragment= "narf" ,request_path= "/δ¶/δt/pope" ,request_url= "/δ¶/δt/pope?q=1#narf" ,num_headers= 1 -,headers=Dict{String,String}("Host" => "github.com") +,headers=["Host" => "github.com"] ,body= "" ), Message(name = "hostname underscore" ,raw= "CONNECT home_0.netscape.com:443 HTTP/1.0\r\n" * @@ -550,7 +633,7 @@ Message(name= "curl get" ,should_keep_alive= false ,http_major= 1 ,http_minor= 0 -,method= HTTP.CONNECT +,method= "CONNECT" ,query_string= "" ,fragment= "" ,request_path= "" @@ -559,9 +642,9 @@ Message(name= "curl get" ,port="443" ,num_headers= 2 ,upgrade="" -,headers=Dict{String,String}( "User-Agent"=> "Mozilla/1.1N" +,headers=[ "User-Agent"=> "Mozilla/1.1N" , "Proxy-Authorization"=> "basic aGVsbG86d29ybGQ=" - ) + ] ,body= "" ), Message(name = "eat CRLF between requests, no \"Connection: close\" header" ,raw= "POST / HTTP/1.1\r\n" * @@ -573,17 +656,17 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.POST +,method= "POST" ,query_string= "" ,fragment= "" ,request_path= "/" ,request_url= "/" ,num_headers= 3 ,upgrade= "" -,headers=Dict{String,String}( "Host"=> "www.example.com" +,headers=[ "Host"=> "www.example.com" , "Content-Type"=> "application/x-www-form-urlencoded" , "Content-Length"=> "4" - ) + ] ,body= "q=42" ), Message(name = "eat CRLF between requests even if \"Connection: close\" is set" ,raw= "POST / HTTP/1.1\r\n" * @@ -596,18 +679,18 @@ Message(name= "curl get" ,should_keep_alive= false ,http_major= 1 ,http_minor= 1 -,method= HTTP.POST +,method= "POST" ,query_string= "" ,fragment= "" ,request_path= "/" ,request_url= "/" ,num_headers= 4 ,upgrade= "" -,headers=Dict{String,String}( "Host"=> "www.example.com" +,headers=[ "Host"=> "www.example.com" , "Content-Type"=> "application/x-www-form-urlencoded" , "Content-Length"=> "4" , "Connection"=> "close" - ) + ] ,body= "q=42" ), Message(name = "PURGE request" ,raw= "PURGE /file.txt HTTP/1.1\r\n" * @@ -616,13 +699,13 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.PURGE +,method= "PURGE" ,query_string= "" ,fragment= "" ,request_path= "/file.txt" ,request_url= "/file.txt" ,num_headers= 1 -,headers=Dict{String,String}( "Host"=> "www.example.com" ) +,headers=[ "Host"=> "www.example.com" ] ,body= "" ), Message(name = "SEARCH request" ,raw= "SEARCH / HTTP/1.1\r\n" * @@ -631,13 +714,13 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.SEARCH +,method= "SEARCH" ,query_string= "" ,fragment= "" ,request_path= "/" ,request_url= "/" ,num_headers= 1 -,headers=Dict{String,String}( "Host"=> "www.example.com") +,headers=[ "Host"=> "www.example.com"] ,body= "" ), Message(name= "host:port and basic_auth" ,raw= "GET http://a%12:b!&*\$@hypnotoad.org:1234/toto HTTP/1.1\r\n" * @@ -645,7 +728,7 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,fragment= "" ,request_path= "/toto" ,request_url= "http://a%12:b!&*\$@hypnotoad.org:1234/toto" @@ -653,7 +736,7 @@ Message(name= "curl get" ,userinfo= "a%12:b!&*\$" ,port= "1234" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body= "" ), Message(name = "upgrade post request" ,raw= "POST /demo HTTP/1.1\r\n" * @@ -667,16 +750,16 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.POST +,method= "POST" ,request_path= "/demo" ,request_url= "/demo" ,num_headers= 4 ,upgrade="Hot diggity dogg" -,headers=Dict{String,String}( "Host"=> "example.com" +,headers=[ "Host"=> "example.com" , "Connection"=> "Upgrade" , "Upgrade"=> "HTTP/2.0" , "Content-Length"=> "15" - ) + ] ,body= "sweet post body" ), Message(name = "connect with body request" ,raw= "CONNECT foo.bar.com:443 HTTP/1.0\r\n" * @@ -688,17 +771,17 @@ Message(name= "curl get" ,should_keep_alive= false ,http_major= 1 ,http_minor= 0 -,method= HTTP.CONNECT +,method= "CONNECT" ,request_url= "foo.bar.com:443" ,host="foo.bar.com" ,port="443" ,num_headers= 3 -,upgrade="blarfcicle" -,headers=Dict{String,String}( "User-Agent"=> "Mozilla/1.1N" +,upgrade="" +,headers=[ "User-Agent"=> "Mozilla/1.1N" , "Proxy-Authorization"=> "basic aGVsbG86d29ybGQ=" , "Content-Length"=> "10" - ) -,body= "" + ] +,body= "blarfcicle" ), Message(name = "link request" ,raw= "LINK /images/my_dog.jpg HTTP/1.1\r\n" * "Host: example.com\r\n" * @@ -708,15 +791,15 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.LINK +,method= "LINK" ,request_path= "/images/my_dog.jpg" ,request_url= "/images/my_dog.jpg" ,query_string= "" ,fragment= "" ,num_headers= 2 -,headers=Dict{String,String}( "Host"=> "example.com" +,headers=[ "Host"=> "example.com" , "Link"=> "; rel=\"tag\", ; rel=\"tag\"" - ) + ] ,body= "" ), Message(name = "link request" ,raw= "UNLINK /images/my_dog.jpg HTTP/1.1\r\n" * @@ -726,15 +809,15 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.UNLINK +,method= "UNLINK" ,request_path= "/images/my_dog.jpg" ,request_url= "/images/my_dog.jpg" ,query_string= "" ,fragment= "" ,num_headers= 2 -,headers=Dict{String,String}( "Host"=> "example.com" +,headers=[ "Host"=> "example.com" , "Link"=> "; rel=\"tag\"" - ) + ] ,body= "" ), Message(name = "multiple connection header values with folding" ,raw= "GET /demo HTTP/1.1\r\n" * @@ -751,21 +834,21 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/demo" ,request_url= "/demo" ,num_headers= 7 ,upgrade="Hot diggity dogg" -,headers=Dict{String,String}( "Host"=> "example.com" +,headers=[ "Host"=> "example.com" , "Connection"=> "Something, Upgrade, ,Keep-Alive" , "Sec-Websocket-Key2"=> "12998 5 Y3 1 .P00" , "Sec-Websocket-Protocol"=> "sample" , "Upgrade"=> "WebSocket" , "Sec-Websocket-Key1"=> "4 @1 46546xW%0l 1 5" , "Origin"=> "http://example.com" - ) + ] ,body= "" ), Message(name= "line folding in header value" ,raw= "GET / HTTP/1.1\r\n" * @@ -786,18 +869,18 @@ Message(name= "curl get" ,should_keep_alive= false ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/" ,request_url= "/" ,num_headers= 5 -,headers=Dict{String,String}( "Line1"=> "abc\tdef ghi\t\tjkl mno \t \tqrs" +,headers=[ "Line1"=> "abc\tdef ghi\t\tjkl mno \t \tqrs" , "Line2"=> "line2\t" , "Line3"=> "line3" , "Line4"=> "" , "Connection"=> "close" - ) + ] ,body= "" ), Message(name = "multiple connection header values with folding and lws" ,raw= "GET /demo HTTP/1.1\r\n" * @@ -808,16 +891,16 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/demo" ,request_url= "/demo" ,num_headers= 2 ,upgrade="Hot diggity dogg" -,headers=Dict{String,String}( "Connection"=> "keep-alive, upgrade" +,headers=[ "Connection"=> "keep-alive, upgrade" , "Upgrade"=> "WebSocket" - ) + ] ,body= "" ), Message(name = "multiple connection header values with folding and lws" ,raw= "GET /demo HTTP/1.1\r\n" * @@ -828,16 +911,16 @@ Message(name= "curl get" ,should_keep_alive= true ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/demo" ,request_url= "/demo" ,num_headers= 2 ,upgrade="Hot diggity dogg" -,headers=Dict{String,String}( "Connection"=> "keep-alive, upgrade" +,headers=[ "Connection"=> "keep-alive, upgrade" , "Upgrade"=> "WebSocket" - ) + ] ,body= "" ), Message(name= "line folding in header value" ,raw= "GET / HTTP/1.1\n" * @@ -858,18 +941,18 @@ Message(name= "curl get" ,should_keep_alive= false ,http_major= 1 ,http_minor= 1 -,method= HTTP.GET +,method= "GET" ,query_string= "" ,fragment= "" ,request_path= "/" ,request_url= "/" ,num_headers= 5 -,headers=Dict{String,String}( "Line1"=> "abc\tdef ghi\t\tjkl mno \t \tqrs" +,headers=[ "Line1"=> "abc\tdef ghi\t\tjkl mno \t \tqrs" , "Line2"=> "line2\t" , "Line3"=> "line3" , "Line4"=> "" , "Connection"=> "close" - ) + ] ,body= "" ) ] @@ -899,7 +982,7 @@ const responses = Message[ ,status_code= 301 ,response_status= "Moved Permanently" ,num_headers= 8 -,headers=Dict{String,String}( +,headers=[ "Location"=> "http://www.google.com/" , "Content-Type"=> "text/html; charset=UTF-8" , "Date"=> "Sun, 26 Apr 2009 11:11:49 GMT" @@ -908,7 +991,7 @@ const responses = Message[ , "Cache-Control"=> "public, max-age=2592000" , "Server"=> "gws" , "Content-Length"=> "219 " -) +] ,body= "\n" * "301 Moved\n" * "

301 Moved

\n" * @@ -938,13 +1021,13 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 5 -,headers=Dict{String,String}( +,headers=[ "Date"=> "Tue, 04 Aug 2009 07:59:32 GMT" , "Server"=> "Apache" , "X-Powered-By"=> "Servlet/2.5 JSP/2.1" , "Content-Type"=> "text/xml; charset=utf-8" , "Connection"=> "close" -) +] ,body= "\n" * "\n" * " \n" * @@ -962,7 +1045,7 @@ const responses = Message[ ,status_code= 404 ,response_status= "Not Found" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body_size= 0 ,body= "" ), Message(name= "301 no response phrase" @@ -973,7 +1056,7 @@ const responses = Message[ ,status_code= 301 ,response_status= "Moved Permanently" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body= "" ), Message(name="200 trailing space on chunked body" ,raw= "HTTP/1.1 200 OK\r\n" * @@ -994,10 +1077,10 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 2 -,headers=Dict{String,String}( +,headers=[ "Content-Type"=> "text/plain" , "Transfer-Encoding"=> "chunked" -) +] ,body_size = 37+28 ,body = "This is the data in the first chunk\r\n" * @@ -1014,10 +1097,10 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 2 -,headers=Dict{String,String}( +,headers=[ "Content-Type"=> "text/html; charset=utf-8" , "Connection"=> "close" -) +] ,body= "these headers are from http://news.ycombinator.com/" ), Message(name="proxy connection" ,raw= "HTTP/1.1 200 OK\r\n" * @@ -1033,12 +1116,12 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 4 -,headers=Dict{String,String}( +,headers=[ "Content-Type"=> "text/html; charset=UTF-8" , "Content-Length"=> "11" , "Proxy-Connection"=> "close" , "Date"=> "Thu, 31 Dec 2009 20:55:48 +0000" -) +] ,body= "hello world" ), Message(name="underscore header key" ,raw= "HTTP/1.1 200 OK\r\n" * @@ -1052,12 +1135,12 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 4 -,headers=Dict{String,String}( +,headers=[ "Server"=> "DCLK-AdSvr" , "Content-Type"=> "text/xml" , "Content-Length"=> "0" , "Dclk_imp"=> "v7;x;114750856;0-0;0;17820020;0/0;21603567/21621457/1;;~okv=;dcmt=text/xml;;~cs=o" -) +] ,body= "" ), Message(name= "bonjourmadame.fr" ,raw= "HTTP/1.0 301 Moved Permanently\r\n" * @@ -1077,7 +1160,7 @@ const responses = Message[ ,status_code= 301 ,response_status= "Moved Permanently" ,num_headers= 9 -,headers=Dict{String,String}( +,headers=[ "Date"=> "Thu, 03 Jun 2010 09:56:32 GMT" , "Server"=> "Apache/2.2.3 (Red Hat)" , "Cache-Control"=> "public" @@ -1087,7 +1170,7 @@ const responses = Message[ , "Content-Length"=> "0" , "Content-Type"=> "text/html; charset=UTF-8" , "Connection"=> "keep-alive" -) +] ,body= "" ), Message(name= "field underscore" ,raw= "HTTP/1.1 200 OK\r\n" * @@ -1110,7 +1193,7 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 11 -,headers=Dict{String,String}( +,headers=[ "Date"=> "Tue, 28 Sep 2010 01:14:13 GMT" , "Server"=> "Apache" , "Cache-Control"=> "no-cache, must-revalidate" @@ -1122,7 +1205,7 @@ const responses = Message[ , "Transfer-Encoding"=> "chunked" , "Content-Type"=> "text/html" , "Connection"=> "close" -) +] ,body= "" ), Message(name= "non-ASCII in status line" ,raw= "HTTP/1.1 500 Oriëntatieprobleem\r\n" * @@ -1136,11 +1219,11 @@ const responses = Message[ ,status_code= 500 ,response_status= "Internal Server Error" ,num_headers= 3 -,headers=Dict{String,String}( +,headers=[ "Date"=> "Fri, 5 Nov 2010 23:07:12 GMT+2" , "Content-Length"=> "0" , "Connection"=> "close" -) +] ,body= "" ), Message(name= "http version 0.9" ,raw= "HTTP/0.9 200 OK\r\n" * @@ -1151,7 +1234,7 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body= "" ), Message(name= "neither content-length nor transfer-encoding response" ,raw= "HTTP/1.1 200 OK\r\n" * @@ -1164,9 +1247,9 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 1 -,headers=Dict{String,String}( +,headers=[ "Content-Type"=> "text/plain" -) +] ,body= "hello world" ), Message(name= "HTTP/1.0 with keep-alive and EOF-terminated 200 status" ,raw= "HTTP/1.0 200 OK\r\n" * @@ -1178,9 +1261,9 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 1 -,headers=Dict{String,String}( +,headers=[ "Connection"=> "keep-alive" -) +] ,body_size= 0 ,body= "" ), Message(name= "HTTP/1.0 with keep-alive and a 204 status" @@ -1193,9 +1276,9 @@ const responses = Message[ ,status_code= 204 ,response_status= "No Content" ,num_headers= 1 -,headers=Dict{String,String}( +,headers=[ "Connection"=> "keep-alive" -) +] ,body_size= 0 ,body= "" ), Message(name= "HTTP/1.1 with an EOF-terminated 200 status" @@ -1207,7 +1290,7 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body_size= 0 ,body= "" ), Message(name= "HTTP/1.1 with a 204 status" @@ -1219,7 +1302,7 @@ const responses = Message[ ,status_code= 204 ,response_status= "No Content" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body_size= 0 ,body= "" ), Message(name= "HTTP/1.1 with a 204 status and keep-alive disabled" @@ -1232,9 +1315,9 @@ const responses = Message[ ,status_code= 204 ,response_status= "No Content" ,num_headers= 1 -,headers=Dict{String,String}( +,headers=[ "Connection"=> "close" -) +] ,body_size= 0 ,body= "" ), Message(name= "HTTP/1.1 with chunked endocing and a 200 response" @@ -1249,9 +1332,9 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 1 -,headers=Dict{String,String}( +,headers=[ "Transfer-Encoding"=> "chunked" -) +] ,body_size= 0 ,body= "" ), Message(name= "field space" @@ -1271,7 +1354,7 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 7 -,headers=Dict{String,String}( +,headers=[ "Server"=> "Microsoft-IIS/6.0" , "X-Powered-By"=> "ASP.NET" , "En-Us content-Type"=> "text/xml" @@ -1279,7 +1362,7 @@ const responses = Message[ , "Content-Length"=> "16" , "Date"=> "Fri, 23 Jul 2010 18:45:38 GMT" , "Connection"=> "keep-alive" -) +] ,body= "hello" ), Message(name= "amazon.com" ,raw= "HTTP/1.1 301 MovedPermanently\r\n" * @@ -1303,7 +1386,7 @@ const responses = Message[ ,status_code= 301 ,response_status= "Moved Permanently" ,num_headers= 9 -,headers=Dict{String,String}( "Date"=> "Wed, 15 May 2013 17:06:33 GMT" +,headers=[ "Date"=> "Wed, 15 May 2013 17:06:33 GMT" , "Server"=> "Server" , "X-Amz-Id-1"=> "0GPHKXSJQ826RK7GZEB2" , "P3p"=> "policyref=\"http://www.amazon.com/w3c/p3p.xml\",CP=\"CAO DSP LAW CUR ADM IVAo IVDo CONo OTPo OUR DELi PUBi OTRi BUS PHY ONL UNI PUR FIN COM NAV INT DEM CNT STA HEA PRE LOC GOV OTC \"" @@ -1312,7 +1395,7 @@ const responses = Message[ , "Vary"=> "Accept-Encoding,User-Agent" , "Content-Type"=> "text/html; charset=ISO-8859-1" , "Transfer-Encoding"=> "chunked" - ) + ] ,body= "\n" ), Message(name= "empty reason phrase after space" ,raw= "HTTP/1.1 200 \r\n" * @@ -1323,7 +1406,7 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 0 -,headers=Dict{String,String}() +,headers=Headers() ,body= "" ), Message(name= "Content-Length-X" ,raw= "HTTP/1.1 200 OK\r\n" * @@ -1340,9 +1423,9 @@ const responses = Message[ ,status_code= 200 ,response_status= "OK" ,num_headers= 2 -,headers=Dict{String,String}( "Content-Length-X"=> "0" +,headers=[ "Content-Length-X"=> "0" , "Transfer-Encoding"=> "chunked" - ) + ] ,body= "OK" ) ] @@ -1350,25 +1433,61 @@ const responses = Message[ @testset "HTTP.parse" begin @testset "HTTP.parse(HTTP.Request, str)" begin - for req in requests - println("TEST - parser.jl - Request: $(req.name)") - upgrade = Ref{String}() - r = HTTP.parse(HTTP.Request, req.raw; extra=upgrade) - @test HTTP.major(r) == req.http_major - @test HTTP.minor(r) == req.http_minor - @test HTTP.method(r) == req.method - @test HTTP.query(HTTP.uri(r)) == req.query_string - @test HTTP.fragment(HTTP.uri(r)) == req.fragment - @test HTTP.path(HTTP.uri(r)) == req.request_path - @test HTTP.hostname(HTTP.uri(r)) == req.host - @test HTTP.userinfo(HTTP.uri(r)) == req.userinfo - @test HTTP.port(HTTP.uri(r)) in (req.port, "80", "443") - @test string(HTTP.uri(r)) == req.request_url - @test length(HTTP.headers(r)) == req.num_headers - @test HTTP.headers(r) == req.headers - @test String(readavailable(HTTP.body(r))) == req.body - @test HTTP.http_should_keep_alive(HTTP.DEFAULT_PARSER, r) == req.should_keep_alive - @test upgrade[] == req.upgrade + for req in requests, t in [-1, 0, 1, 2, 3, 4, 11, 13, 17, 19, 23, 29, 31, 32] + + println("TEST - parser.jl - Request $t: $(req.name)") + upgrade = Ref{SubArray{UInt8, 1}}() + r = Request() + p = Parser() + b = IOBuffer() + bytes = Vector{UInt8}(req.raw) + sz = t + if t > 0 + for i in 1:sz:length(bytes) + parse!(p, r, b, view(bytes, i:min(i+sz-1, length(bytes)))) + end + r.body = take!(b) + elseif t < 0 + i = rand(2:length(bytes)) + parse!(p, r, b, bytes[1:i-1]) + parse!(p, r, b, bytes[i:end]) + r.body = take!(b) + else + r = Request(req.raw) + #r = HTTP.parse(HTTP.Request, req.raw; extraref=upgrade) + end + if r.method == "CONNECT" + host, port, userinfo = HTTP.URIs.http_parse_host(SubString(r.target)) + @test host == req.host + @test port == req.port + else + if r.target == "*" + @test r.target == req.request_path + else + target = parse(HTTP.URI, r.target) + @test target.query == req.query_string + @test target.fragment == req.fragment + @test target.path == req.request_path + @test target.host == req.host + @test target.userinfo == req.userinfo + @test target.port in (req.port, "80", "443") + @test string(target) == req.request_url + end + end + @test r.version.major == req.http_major + @test r.version.minor == req.http_minor + @test r.method == string(req.method) + @test length(r.headers) == req.num_headers + @test Dict(HTTP.CanonicalizeRequest.canonicalizeheaders(r.headers)) == Dict(req.headers) + @test String(r.body) == req.body +# FIXME @test HTTP.http_should_keep_alive(HTTP.DEFAULT_PARSER) == req.should_keep_alive + + if isassigned(upgrade) + @show String(collect(upgrade[])) + end +# FIXME @test t != 0 || +# req.upgrade == "" && !isassigned(upgrade) || +# String(collect(upgrade[])) == req.upgrade end reqstr = "GET http://www.techcrunch.com/ HTTP/1.1\r\n" * @@ -1380,41 +1499,40 @@ const responses = Message[ "Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7\r\n" * "Keep-Alive: 300\r\n" * "Content-Length: 7\r\n" * - "Proxy-Connection: keep-alive\r\n\r\n" + "Proxy-Connection: keep-alive\r\n\r\n1234567" - req = HTTP.Request() - req.uri = HTTP.URI("http://www.techcrunch.com/") - req.headers = HTTP.Headers("Content-Length"=>"7","Host"=>"www.techcrunch.com","Accept"=>"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","Accept-Charset"=>"ISO-8859-1,utf-8;q=0.7,*;q=0.7","Proxy-Connection"=>"keep-alive","Accept-Language"=>"en-us,en;q=0.5","Keep-Alive"=>"300","User-Agent"=>"Fake","Accept-Encoding"=>"gzip,deflate") + req = Request("GET", "http://www.techcrunch.com/") + req.headers = ["Host"=>"www.techcrunch.com","User-Agent"=>"Fake","Accept"=>"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","Accept-Language"=>"en-us,en;q=0.5","Accept-Encoding"=>"gzip,deflate","Accept-Charset"=>"ISO-8859-1,utf-8;q=0.7,*;q=0.7","Keep-Alive"=>"300","Content-Length"=>"7","Proxy-Connection"=>"keep-alive"] + req.body = Vector{UInt8}("1234567") - @test HTTP.parse(HTTP.Request, reqstr) == req + @test Request(reqstr).headers == req.headers + @test Request(reqstr) == req reqstr = "GET / HTTP/1.1\r\n" * "Host: foo.com\r\n\r\n" - req = HTTP.Request() - req.uri = HTTP.URI("/") - req.headers = HTTP.Headers("Host"=>"foo.com") + req = Request("GET", "/") + req.headers = ["Host"=>"foo.com"] - @test HTTP.parse(HTTP.Request, reqstr) == req + @test Request(reqstr) == req reqstr = "GET //user@host/is/actually/a/path/ HTTP/1.1\r\n" * "Host: test\r\n\r\n" - req = HTTP.Request() - req.uri = HTTP.URI("//user@host/is/actually/a/path/") - req.headers = HTTP.Headers("Host"=>"test") + req = Request("GET", "//user@host/is/actually/a/path/") + req.headers = ["Host"=>"test"] - @test HTTP.parse(HTTP.Request, reqstr) == req + @test Request(reqstr) == req reqstr = "GET ../../../../etc/passwd HTTP/1.1\r\n" * "Host: test\r\n\r\n" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, reqstr) + @test_throws ParsingError Request(reqstr) reqstr = "GET HTTP/1.1\r\n" * "Host: test\r\n\r\n" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, reqstr) + @test_throws ParsingError Request(reqstr) reqstr = "POST / HTTP/1.1\r\n" * "Host: foo.com\r\n" * @@ -1425,14 +1543,15 @@ const responses = Message[ "Trailer-Key: Trailer-Value\r\n" * "\r\n" - req = HTTP.Request() + req = Request() req.method = "POST" - req.uri = HTTP.URI("/") - req.headers = HTTP.Headers("Transfer-Encoding"=>"chunked", "Host"=>"foo.com", "Trailer-Key"=>"Trailer-Value") - req.body = HTTP.FIFOBuffer("foobar") + req.target = "/" + req.headers = ["Host"=>"foo.com", "Transfer-Encoding"=>"chunked", "Trailer-Key"=>"Trailer-Value"] + req.body = Vector{UInt8}("foobar") - @test HTTP.parse(HTTP.Request, reqstr) == req + @test Request(reqstr) == req +#= FIXME reqstr = "POST / HTTP/1.1\r\n" * "Host: foo.com\r\n" * "Transfer-Encoding: chunked\r\n" * @@ -1442,66 +1561,66 @@ const responses = Message[ "0\r\n" * "\r\n" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, reqstr) + @test_throws ParsingError Request(reqstr) +=# reqstr = "CONNECT www.google.com:443 HTTP/1.1\r\n\r\n" - req = HTTP.Request() + req = Request() req.method = "CONNECT" - req.uri = HTTP.URI("www.google.com:443"; isconnect=true) + req.target = "www.google.com:443" - @test HTTP.parse(HTTP.Request, reqstr) == req + @test Request(reqstr) == req reqstr = "CONNECT 127.0.0.1:6060 HTTP/1.1\r\n\r\n" - req = HTTP.Request() + req = Request() req.method = "CONNECT" - req.uri = HTTP.URI("127.0.0.1:6060"; isconnect=true) + req.target = "127.0.0.1:6060" - @test HTTP.parse(HTTP.Request, reqstr) == req + @test Request(reqstr) == req # reqstr = "CONNECT /_goRPC_ HTTP/1.1\r\n\r\n" # # req = HTTP.Request() # req.method = "CONNECT" - # req.uri = HTTP.URI("/_goRPC_"; isconnect=true) + # req.target = HTTP.URI("/_goRPC_"; isconnect=true) # @test HTTP.parse(HTTP.Request, reqstr) == req reqstr = "NOTIFY * HTTP/1.1\r\nServer: foo\r\n\r\n" - req = HTTP.Request() + req = Request() req.method = "NOTIFY" - req.uri = HTTP.URI("*") - req.headers = HTTP.Headers("Server"=>"foo") + req.target = "*" + req.headers = ["Server"=>"foo"] - @test HTTP.parse(HTTP.Request, reqstr) == req + @test Request(reqstr) == req reqstr = "OPTIONS * HTTP/1.1\r\nServer: foo\r\n\r\n" - req = HTTP.Request() + req = Request() req.method = "OPTIONS" - req.uri = HTTP.URI("*") - req.headers = HTTP.Headers("Server"=>"foo") + req.target = "*" + req.headers = ["Server"=>"foo"] - @test HTTP.parse(HTTP.Request, reqstr) == req + @test Request(reqstr) == req reqstr = "GET / HTTP/1.1\r\nHost: issue8261.com\r\nConnection: close\r\n\r\n" - req = HTTP.Request() - req.uri = HTTP.URI("/") - req.headers = HTTP.Headers("Host"=>"issue8261.com", "Connection"=>"close") + req = Request("GET", "/") + req.headers = ["Host"=>"issue8261.com", "Connection"=>"close"] - @test HTTP.parse(HTTP.Request, reqstr) == req + @test Request(reqstr) == req reqstr = "HEAD / HTTP/1.1\r\nHost: issue8261.com\r\nConnection: close\r\nContent-Length: 0\r\n\r\n" - req = HTTP.Request() + req = Request() req.method = "HEAD" - req.uri = HTTP.URI("/") - req.headers = HTTP.Headers("Host"=>"issue8261.com", "Connection"=>"close", "Content-Length"=>"0") + req.target = "/" + req.headers = ["Host"=>"issue8261.com", "Connection"=>"close", "Content-Length"=>"0"] - @test HTTP.parse(HTTP.Request, reqstr) == req + @test Request(reqstr) == req reqstr = "POST /cgi-bin/process.cgi HTTP/1.1\r\n" * "User-Agent: Mozilla/4.0 (compatible; MSIE5.01; Windows NT)\r\n" * @@ -1513,210 +1632,248 @@ const responses = Message[ "Connection: Keep-Alive\r\n\r\n" * "first=Zara&last=Ali\r\n\r\n" - req = HTTP.Request() + req = Request() req.method = "POST" - req.uri = HTTP.URI("/cgi-bin/process.cgi") - req.headers = HTTP.Headers("Host"=>"www.tutorialspoint.com", - "Connection"=>"Keep-Alive", - "Content-Length"=>"19", - "User-Agent"=>"Mozilla/4.0 (compatible; MSIE5.01; Windows NT)", - "Content-Type"=>"text/xml; charset=utf-8", - "Accept-Language"=>"en-us", - "Accept-Encoding"=>"gzip, deflate") - req.body = HTTP.FIFOBuffer("first=Zara&last=Ali") - - @test HTTP.parse(HTTP.Request, reqstr) == req + req.target = "/cgi-bin/process.cgi" + req.headers = ["User-Agent"=>"Mozilla/4.0 (compatible; MSIE5.01; Windows NT)", + "Host"=>"www.tutorialspoint.com", + "Content-Type"=>"text/xml; charset=utf-8", + "Content-Length"=>"19", + "Accept-Language"=>"en-us", + "Accept-Encoding"=>"gzip, deflate", + "Connection"=>"Keep-Alive"] + req.body = Vector{UInt8}("first=Zara&last=Ali") + + @test Request(reqstr) == req end - @testset "HTTP.parse(HTTP.Response, str)" begin - for resp in responses - println("TEST - parser.jl - Response: $(resp.name)") - r = HTTP.parse(HTTP.Response, resp.raw) - @test HTTP.major(r) == resp.http_major - @test HTTP.minor(r) == resp.http_minor - @test HTTP.status(r) == resp.status_code - @test HTTP.statustext(r) == resp.response_status - @test length(HTTP.headers(r)) == resp.num_headers - @test HTTP.headers(r) == resp.headers - @test String(readavailable(HTTP.body(r))) == resp.body - @test HTTP.http_should_keep_alive(HTTP.DEFAULT_PARSER, r) == resp.should_keep_alive + @testset "Response(str)" begin + for resp in responses, t in [0, 1, 2, 3, 4, 11, 13, 17, 19, 23, 29, 31, 32] + println("TEST - parser.jl - Response $t: $(resp.name)") + try + if t > 0 + r = Request().response + p = Parser() + b = IOBuffer() + bytes = Vector{UInt8}(resp.raw) + sz = t + for i in 1:sz:length(bytes) + parse!(p, r, b, view(bytes, i:min(i+sz-1, length(bytes)))) + end + r.body = take!(b) + else + r = Response(resp.raw) + end + @test r.version.major == resp.http_major + @test r.version.minor == resp.http_minor + @test r.status == resp.status_code + @test HTTP.Messages.statustext(r) == resp.response_status + @test length(r.headers) == resp.num_headers + @test Dict(HTTP.CanonicalizeRequest.canonicalizeheaders(r.headers)) == Dict(resp.headers) + @test String(r.body) == resp.body +# FIXME @test HTTP.http_should_keep_alive(HTTP.DEFAULT_PARSER) == resp.should_keep_alive + catch e + if HTTP.Parsers.strict && isa(e, ParsingError) + println("HTTP.strict is enabled. ParsingError ignored.") + else + rethrow() + end + end end end @testset "HTTP.parse errors" begin - reqstr = "GET / HTTP/1.1\r\n" * "Foo: F\01ailure" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, reqstr; lenient=false) - r = HTTP.parse(HTTP.Request, reqstr; lenient=true) - - @test HTTP.method(r) == HTTP.GET - @test HTTP.uri(r) == HTTP.URI("/") - @test length(HTTP.headers(r)) == 0 - - reqstr = "GET / HTTP/1.1\r\n" * "Foo: B\02ar" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, reqstr; lenient=false) - r = HTTP.parse(HTTP.Request, reqstr; lenient=true) - - @test HTTP.method(r) == HTTP.GET - @test HTTP.uri(r) == HTTP.URI("/") - @test length(HTTP.headers(r)) == 0 - - respstr = "HTTP/1.1 200 OK\r\n" * "Foo: F\01ailure" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr; lenient=false) - r = HTTP.parse(HTTP.Response, respstr; lenient=true) - @test HTTP.status(r) == 200 - @test length(HTTP.headers(r)) == 0 - - respstr = "HTTP/1.1 200 OK\r\n" * "Foo: B\02ar" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr; lenient=false) - r = HTTP.parse(HTTP.Response, respstr; lenient=true) - @test HTTP.status(r) == 200 - @test length(HTTP.headers(r)) == 0 + reqstr = "GET / HTTP/1.1\r\n" * "Foo: F\01ailure\r\n\r\n" + HTTP.Parsers.strict && @test_throws ParsingError Request(reqstr) + if !HTTP.Parsers.strict + r = HTTP.parse(HTTP.Messages.Request, reqstr) + @test r.method == "GET" + @test r.target == "/" + @test length(r.headers) == 1 + end + + reqstr = "GET / HTTP/1.1\r\n" * "Foo: B\02ar\r\n\r\n" + HTTP.Parsers.strict && @test_throws ParsingError Request(reqstr) + if !HTTP.Parsers.strict + r = parse(HTTP.Messages.Request, reqstr) + @test r.method == "GET" + @test r.target == "/" + @test length(r.headers) == 1 + end + + respstr = "HTTP/1.1 200 OK\r\n" * "Foo: F\01ailure\r\n\r\n" + HTTP.Parsers.strict && @test_throws ParsingError Response(respstr) + if !HTTP.Parsers.strict + r = parse(HTTP.Messages.Response, respstr) + @test r.status == 200 + @test length(r.headers) == 1 + end + + respstr = "HTTP/1.1 200 OK\r\n" * "Foo: B\02ar\r\n\r\n" + HTTP.Parsers.strict && @test_throws ParsingError Response(respstr) + if !HTTP.Parsers.strict + r = parse(HTTP.Messages.Response, respstr) + @test r.status == 200 + @test length(r.headers) == 1 + end reqstr = "GET / HTTP/1.1\r\n" * "Fo@: Failure" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, reqstr; lenient=false) - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, reqstr; lenient=true) + HTTP.Parsers.strict && @test_throws ParsingError Request(reqstr) + !HTTP.Parsers.strict && (@test_throws ParsingError Request(reqstr)) reqstr = "GET / HTTP/1.1\r\n" * "Foo\01\test: Bar" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, reqstr; lenient=false) - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, reqstr; lenient=true) + HTTP.Parsers.strict && @test_throws ParsingError Request(reqstr) + !HTTP.Parsers.strict && (@test_throws ParsingError Request(reqstr)) respstr = "HTTP/1.1 200 OK\r\n" * "Fo@: Failure" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr; lenient=false) - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr; lenient=true) + HTTP.Parsers.strict && @test_throws ParsingError Response(respstr) + !HTTP.Parsers.strict && (@test_throws ParsingError Response(respstr)) respstr = "HTTP/1.1 200 OK\r\n" * "Foo\01\test: Bar" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr; lenient=false) - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr; lenient=true) + HTTP.Parsers.strict && @test_throws ParsingError Response(respstr) + !HTTP.Parsers.strict && (@test_throws ParsingError Response(respstr)) reqstr = "GET / HTTP/1.1\r\n" * "Content-Length: 0\r\nContent-Length: 1\r\n\r\n" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, reqstr; lenient=false) + HTTP.Parsers.strict && @test_throws ParsingError Request(reqstr) respstr = "HTTP/1.1 200 OK\r\n" * "Content-Length: 0\r\nContent-Length: 1\r\n\r\n" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr; lenient=false) + HTTP.Parsers.strict && @test_throws ParsingError Response(respstr) reqstr = "GET / HTTP/1.1\r\n" * "Transfer-Encoding: chunked\r\nContent-Length: 1\r\n\r\n" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, reqstr; lenient=false) + HTTP.Parsers.strict && @test_throws ParsingError Request(reqstr) respstr = "HTTP/1.1 200 OK\r\n" * "Transfer-Encoding: chunked\r\nContent-Length: 1\r\n\r\n" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr; lenient=false) + HTTP.Parsers.strict && @test_throws ParsingError Response(respstr) reqstr = "GET / HTTP/1.1\r\n" * "Foo: 1\rBar: 1\r\n\r\n" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, reqstr; lenient=false) + HTTP.Parsers.strict && @test_throws ParsingError Request(reqstr) respstr = "HTTP/1.1 200 OK\r\n" * "Foo: 1\rBar: 1\r\n\r\n" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr; lenient=false) + HTTP.Parsers.strict && @test_throws ParsingError Response(respstr) + - for r in ((HTTP.Request, "GET / HTTP/1.1\r\n"), (HTTP.Response, "HTTP/1.0 200 OK\r\n")) - HTTP.reset!(HTTP.DEFAULT_PARSER) + for r in ((Request, "GET / HTTP/1.1\r\n"), (Response, "HTTP/1.0 200 OK\r\n")) R = r[1]() - e, h, m, ex = HTTP.parse!(R, HTTP.DEFAULT_PARSER, Vector{UInt8}(r[2])) - @test e == HTTP.HPE_OK - @test !h - @test !m - @test ex == "" - buf = "header-key: header-value\r\n" - for i = 1:10000 - e, h, m, ex = HTTP.parse!(R, HTTP.DEFAULT_PARSER, Vector{UInt8}(r[2])) - e == HTTP.HPE_HEADER_OVERFLOW && break - end - @test e == HTTP.HPE_HEADER_OVERFLOW + b = IOBuffer() + p = Parser() + n = parse!(Parser(), R, b, r[2]) + @test !headerscomplete(p) + @test !messagecomplete(p) + @test n == length(Vector{UInt8}(r[2])) end buf = "GET / HTTP/1.1\r\nheader: value\nhdr: value\r\n" - r = HTTP.parse(HTTP.Request, buf) - @test HTTP.DEFAULT_PARSER.nread == length(buf) + @test_throws EOFError r = Request(buf) respstr = "HTTP/1.1 200 OK\r\n" * "Content-Length: " * "1844674407370955160" * "\r\n\r\n" - r = HTTP.parse(HTTP.Response, respstr; maxbody=1844674407370955160) - @test HTTP.status(r) == 200 - @test HTTP.headers(r) == Dict("Content-Length"=>"1844674407370955160") - + r = Response() + b = IOBuffer() + p = Parser() + parse!(p, r, b, respstr) + @test r.status == 200 + @test r.headers == ["Content-Length"=>"1844674407370955160"] + +#= respstr = "HTTP/1.1 200 OK\r\n" * "Content-Length: " * "18446744073709551615" * "\r\n\r\n" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr) + e = try Response(respstr) catch e e end + @test isa(e, ParsingError) && e.code == Parsers.HPE_INVALID_CONTENT_LENGTH + respstr = "HTTP/1.1 200 OK\r\n" * "Content-Length: " * "18446744073709551616" * "\r\n\r\n" - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr) + e = try Response(respstr) catch e e end + @test isa(e, ParsingError) && e.code == Parsers.HPE_INVALID_CONTENT_LENGTH +=# respstr = "HTTP/1.1 200 OK\r\n" * "Transfer-Encoding: chunked\r\n\r\n" * "FFFFFFFFFFFFFFE" * "\r\n..." - r = HTTP.parse(HTTP.Response, respstr) - @test HTTP.status(r) == 200 - @test HTTP.headers(r) == Dict("Transfer-Encoding"=>"chunked") + r = Response() + p = Parser() + parse!(p, r, b, respstr) + @test r.status == 200 + @test r.headers == ["Transfer-Encoding"=>"chunked"] respstr = "HTTP/1.1 200 OK\r\n" * "Transfer-Encoding: chunked\r\n\r\n" * "FFFFFFFFFFFFFFF" * "\r\n..." - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr) + e = try Response(respstr) catch e e end + @test isa(e, ParsingError) && e.code == :HPE_INVALID_CONTENT_LENGTH respstr = "HTTP/1.1 200 OK\r\n" * "Transfer-Encoding: chunked\r\n\r\n" * "10000000000000000" * "\r\n..." - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Response, respstr) + e = try Response(respstr) catch e e end + @test isa(e, ParsingError) && e.code == :HPE_INVALID_CONTENT_LENGTH - p = HTTP.Parser() for len in (1000, 100000) - HTTP.reset!(p) + b = IOBuffer() + HTTP.Parsers.reset!(p) reqstr = "POST / HTTP/1.0\r\nConnection: Keep-Alive\r\nContent-Length: $len\r\n\r\n" - r = HTTP.Request() - e, h, m, ex = HTTP.parse!(r, p, Vector{UInt8}(reqstr)) - @test e == HTTP.HPE_OK - @test h - @test !m + r = Request() + p = Parser() + parse!(p, r, b, reqstr) + @test headerscomplete(p) + @test !messagecomplete(p) for i = 1:len-1 - e, h, m, ex = HTTP.parse!(r, p, Vector{UInt8}("a")) - @test e == HTTP.HPE_OK - @test h - @test !m + parse!(p, r, b, "a") + @test headerscomplete(p) + @test !messagecomplete(p) end - e, h, m, ex = HTTP.parse!(r, p, Vector{UInt8}("a")) - @test e == HTTP.HPE_OK - @test h - @test m + parse!(p, r, b, "a") + @test headerscomplete(p) +# @test messagecomplete(p) + @test length(take!(b)) == len end for len in (1000, 100000) - HTTP.reset!(p) + b = IOBuffer() + HTTP.Parsers.reset!(p) respstr = "HTTP/1.0 200 OK\r\nConnection: Keep-Alive\r\nContent-Length: $len\r\n\r\n" - r = HTTP.Response() - e, h, m, ex = HTTP.parse!(r, p, Vector{UInt8}(respstr)) - @test e == HTTP.HPE_OK - @test h - @test !m + r = Request().response + p = Parser() + parse!(p, r, b, respstr) + @test headerscomplete(p) + @test !messagecomplete(p) for i = 1:len-1 - e, h, m, ex = HTTP.parse!(r, p, Vector{UInt8}("a")) - @test e == HTTP.HPE_OK - @test h - @test !m + parse!(p, r, b, "a") + @test headerscomplete(p) + @test !messagecomplete(p) end - e, h, m, ex = HTTP.parse!(r, p, Vector{UInt8}("a")) - @test e == HTTP.HPE_OK - @test h - @test m + parse!(p, r, b, "a") + @test headerscomplete(p) +# @test messagecomplete(p) + @test length(take!(b)) == len end + b = IOBuffer() reqstr = requests[1].raw * requests[2].raw - HTTP.reset!(p) - r = HTTP.Request() - e, h, m, ex = HTTP.parse!(r, p, Vector{UInt8}(reqstr)) - @test e == HTTP.HPE_OK - @test h - @test m - HTTP.reset!(p) - e, h, m, ex = HTTP.parse!(r, p, Vector{UInt8}(ex)) - @test e == HTTP.HPE_OK - @test h - @test m - - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, "GET / HTP/1.1\r\n\r\n") - - r = HTTP.parse(HTTP.Request, "GET / HTTP/1.1\r\n" * "Test: Düsseldorf\r\n") - @test HTTP.headers(r) == Dict("Test" => "Düsseldorf") - - r = HTTP.parse(HTTP.Request, "GET / HTTP/1.1\r\n" * "Content-Type: text/plain\r\n" * "Content-Length: 6\r\n\r\n" * "fooba") - @test String(readavailable(r.body)) == "fooba" - - for m in instances(HTTP.Method) - m == HTTP.CONNECT && continue - me = m == HTTP.MSEARCH ? "M-SEARCH" : "$m" - r = HTTP.parse(HTTP.Request, "$me / HTTP/1.1\r\n\r\n") - @test HTTP.method(r) == m + r = Request() + p = Parser() + n = parse!(p, r, b, reqstr) + @test headerscomplete(p) + #@test messagecomplete(p) + @test String(take!(b)) == requests[1].body + b = IOBuffer() + ex = Vector{UInt8}(reqstr)[n+1:end] + HTTP.Parsers.reset!(p) + parse!(p, r, b, ex) + @test headerscomplete(p) + #@test messagecomplete(p) + @test String(take!(b)) == requests[2].body + + @test_throws ParsingError Request("GET / HTP/1.1\r\n\r\n") + + r = Request("GET / HTTP/1.1\r\n" * "Test: Düsseldorf\r\n\r\n") + @test r.headers == ["Test" => "Düsseldorf"] + + r = Request().response + p = Parser() + b = IOBuffer() + parse!(p, r, b, "GET / HTTP/1.1\r\n" * "Content-Type: text/plain\r\n" * "Content-Length: 6\r\n\r\n" * "fooba") + @test String(take!(b)) == "fooba" + + for m in ["GET", "PUT", "M-SEARCH", "FOOMETHOD"] + r = Request("$m / HTTP/1.1\r\n\r\n") + @test r.method == string(m) end - for m in ("ASDF","C******","COLA","GEM","GETA","M****","MKCOLA","PROPPATCHA","PUN","PX","SA","hello world") - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, "$m / HTTP/1.1\r\n\r\n") + for m in ("HTTP/1.1", "hello world") + @test_throws ParsingError Request("$m / HTTP/1.1\r\n\r\n") + end + for m in ("ASDF","C******","COLA","GEM","GETA","M****","MKCOLA","PROPPATCHA","PUN","PX","SA") + @test Request("$m / HTTP/1.1\r\n\r\n").method == m end - @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, "GET / HTTP/1.1\r\n" * "name\r\n" * " : value\r\n\r\n") + @test_throws ParsingError Request("GET / HTTP/1.1\r\n" * "name\r\n" * " : value\r\n\r\n") reqstr = "GET / HTTP/1.1\r\n" * "X-SSL-FoooBarr: -----BEGIN CERTIFICATE-----\r\n" * @@ -1753,12 +1910,16 @@ const responses = Message[ "\t-----END CERTIFICATE-----\r\n" * "\r\n" - r = HTTP.parse(HTTP.Request, reqstr) - @test HTTP.method(r) == HTTP.GET + r = Request(reqstr) + @test r.method == "GET" + + @test "GET / HTTP/1.1X-SSL-FoooBarr: $(header(r, "X-SSL-FoooBarr"))" == replace(reqstr, "\r\n", "") # @test_throws HTTP.ParsingError HTTP.parse(HTTP.Request, "GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection\r\033\065\325eep-Alive\r\nAccept-Encoding: gzip\r\n\r\n") - r = HTTP.parse(HTTP.Request, "GET /bad_get_no_headers_no_body/world HTTP/1.1\r\nAccept: */*\r\n\r\nHELLO") - @test String(readavailable(HTTP.body(r))) == "" + r = Request("GET /bad_get_no_headers_no_body/world HTTP/1.1\r\nAccept: */*\r\n\r\nHELLO") + @test String(r.body) == "" end end # @testset HTTP.parse + +end # module ParserTest diff --git a/test/runtests.jl b/test/runtests.jl index 62fd69612..8c2ca95d7 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,19 +1,6 @@ using HTTP -@static if VERSION < v"0.7.0-DEV.2005" - using Base.Test -else - using Test -end - -if VERSION < v"0.7.0-DEV.2575" - const Dates = Base.Dates -else - import Dates -end -if !isdefined(Base, :pairs) - pairs(x) = x -end - +using HTTP.Dates +using HTTP.Test @testset "HTTP" begin include("utils.jl"); @@ -22,8 +9,14 @@ end include("uri.jl"); include("cookies.jl"); include("parser.jl"); - include("types.jl"); - include("handlers.jl") + + include("loopback.jl"); + include("WebSockets.jl"); + include("messages.jl"); include("client.jl"); + + include("handlers.jl") include("server.jl") + + include("async.jl"); end; diff --git a/test/server.jl b/test/server.jl index 491a00836..8fb013f5a 100644 --- a/test/server.jl +++ b/test/server.jl @@ -1,72 +1,122 @@ -@testset "HTTP.serve" begin +using HTTP +using HTTP.Test + +port = rand(8000:8999) + +function testget(url) + mktempdir() do d + cd(d) do + cmd = `"curl -v -s $url > tmpout 2>&1"` + cmd = `bash -c $cmd` + #println(cmd) + run(cmd) + return String(read(joinpath(d, "tmpout"))) + end + end +end + +@testset "HTTP.Servers.serve" begin # test kill switch -server = HTTP.Server() -tsk = @async HTTP.serve(server) +server = HTTP.Servers.Server() +tsk = @async HTTP.Servers.serve(server, "localhost", port) sleep(1.0) -put!(server.in, HTTP.Nitrogen.KILL) -sleep(0.1) +put!(server.in, HTTP.Servers.KILL) +sleep(2) @test istaskdone(tsk) + # test http vs. https + # echo response serverlog = HTTP.FIFOBuffer() -server = HTTP.Server((req, rep) -> HTTP.Response(String(req)), serverlog) -tsk = @async HTTP.serve(server) +server = HTTP.Servers.Server((req, rep) -> begin + rep.body = req.body + return rep +end, serverlog) + +server.options.ratelimit=0 +tsk = @async HTTP.Servers.serve(server, "localhost", port) sleep(1.0) -r = HTTP.get("http://127.0.0.1:8081/"; readtimeout=30) -@test HTTP.status(r) == 200 -@test String(take!(r)) == "" -print(String(read(serverlog))) +r = testget("http://127.0.0.1:$port/") +@test ismatch(r"HTTP/1.1 200 OK", r) + +rv = [] +n = 3 +@sync for i = 1:n + @async begin + r = testget(repeat("http://127.0.0.1:$port/$i ", n)) + #println(r) + push!(rv, r) + end + sleep(0.01) +end +for i = 1:n + @test length(filter(l->ismatch(r"HTTP/1.1 200 OK", l), + split(rv[i], "\n"))) == n +end + +r = HTTP.get("http://127.0.0.1:$port/"; readtimeout=30) +@test r.status == 200 +@test String(r.body) == "" + + +# large headers +sleep(2.0) +tcp = connect(ip"127.0.0.1", port) +write(tcp, "GET / HTTP/1.1\r\n$(repeat("Foo: Bar\r\n", 10000))\r\n") +@test ismatch(r"HTTP/1.1 413 Request Entity Too Large", String(read(tcp))) # invalid HTTP sleep(2.0) -tcp = connect(ip"127.0.0.1", 8081) +tcp = connect(ip"127.0.0.1", port) write(tcp, "GET / HTP/1.1\r\n\r\n") +!HTTP.Parsers.strict && +@test ismatch(r"HTTP/1.1 505 HTTP Version Not Supported", String(read(tcp))) sleep(2.0) -log = String(read(serverlog)) -print(log) -@test contains(log, "invalid HTTP version") -# bad method +# no URL sleep(2.0) -tcp = connect(ip"127.0.0.1", 8081) -write(tcp, "BADMETHOD / HTTP/1.1\r\n\r\n") +tcp = connect(ip"127.0.0.1", port) +write(tcp, "SOMEMETHOD HTTP/1.1\r\nContent-Length: 0\r\n\r\n") +r = String(read(tcp)) +!HTTP.Parsers.strict && @test ismatch(r"HTTP/1.1 400 Bad Request", r) +!HTTP.Parsers.strict && @test ismatch(r"invalid URL", r) sleep(2.0) -log = String(read(serverlog)) - -print(log) -@test contains(log, "invalid HTTP method") # Expect: 100-continue sleep(2.0) -tcp = connect(ip"127.0.0.1", 8081) +tcp = connect(ip"127.0.0.1", port) write(tcp, "POST / HTTP/1.1\r\nContent-Length: 15\r\nExpect: 100-continue\r\n\r\n") sleep(2.0) -log = String(read(serverlog)) +log = String(readavailable(serverlog)) -@test contains(log, "sending 100 Continue response to get request body") +#@test contains(log, "sending 100 Continue response to get request body") client = String(readavailable(tcp)) @test client == "HTTP/1.1 100 Continue\r\n\r\n" + write(tcp, "Body of Request") sleep(2.0) -log = String(read(serverlog)) +#log = String(readavailable(serverlog)) client = String(readavailable(tcp)) -print(client) +#println("log:") +#println(log) +#println() +println("client:") +println(client) @test contains(client, "HTTP/1.1 200 OK\r\n") -@test contains(client, "Connection: keep-alive\r\n") -@test contains(client, "Content-Length: 15\r\n") -@test contains(client, "\r\n\r\nBody of Request") +@test contains(client, "Transfer-Encoding: chunked\r\n") +@test contains(client, "Body of Request") -put!(server.in, HTTP.Nitrogen.KILL) +put!(server.in, HTTP.Servers.KILL) # serverlog = HTTP.FIFOBuffer() # server = HTTP.Server((req, rep) -> begin @@ -87,7 +137,7 @@ put!(server.in, HTTP.Nitrogen.KILL) # "Connection" => "keep-alive"), io) # end, serverlog) -# tsk = @async HTTP.serve(server, IPv4(0,0,0,0), 8082) +# tsk = @async HTTP.Servers.serve(server, IPv4(0,0,0,0), 8082) # sleep(5.0) # r = HTTP.get("http://localhost:8082/"; readtimeout=30, verbose=true) # log = String(read(serverlog)) @@ -103,14 +153,15 @@ put!(server.in, HTTP.Nitrogen.KILL) # handler throw error # keep-alive vs. close: issue #81 -tsk = @async HTTP.serve(HTTP.Server((req, res) -> Response("Hello\n"), STDOUT), ip"127.0.0.1", 8083) +port += 1 +tsk = @async HTTP.Servers.serve(HTTP.Servers.Server((req, res) -> (res.body = "Hello\n"; res), STDOUT), ip"127.0.0.1", port) sleep(2.0) -r = HTTP.request(HTTP.Request(major=1, minor=0, uri=HTTP.URI("http://127.0.0.1:8083/"), headers=Dict("Host"=>"127.0.0.1:8083"))) -@test HTTP.status(r) == 200 -@test HTTP.headers(r)["Connection"] == "close" +r = HTTP.request("GET", "http://127.0.0.1:$port/", ["Host"=>"127.0.0.1:$port"]; http_version=v"1.0") +@test r.status == 200 +#@test HTTP.header(r, "Connection") == "close" # body too big # other bad requests -end # @testset \ No newline at end of file +end # @testset diff --git a/test/types.jl b/test/types.jl index e3e34a765..7ccb383d0 100644 --- a/test/types.jl +++ b/test/types.jl @@ -23,4 +23,4 @@ showcompact(io, HTTP.Response(200)) showcompact(io, HTTP.Request()) @test String(take!(io)) == "Request(\"\", 0 headers, 0 bytes in body)" -end \ No newline at end of file +end diff --git a/test/uri.jl b/test/uri.jl index c0d97a462..883a740c7 100644 --- a/test/uri.jl +++ b/test/uri.jl @@ -1,22 +1,45 @@ +using HTTP +using HTTP.Test + mutable struct URLTest name::String url::String isconnect::Bool - offsets::NTuple{7, HTTP.URIs.Offset} + expecteduri::HTTP.URI shouldthrow::Bool end -URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) = URLTest(nm, url, isconnect, ntuple(x->HTTP.URIs.Offset(), 7), shouldthrow) +struct Offset + off::UInt16 + len::UInt16 +end + +function offsetss(uri, offset) + if offset == Offset(0,0) + return SubString(uri, 1, 0) + else + return SubString(uri, offset.off, offset.off + offset.len-1) + end +end + +function URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) + URLTest(nm, url, isconnect, HTTP.URI(""), shouldthrow) +end + +function URLTest(nm::String, url::String, isconnect::Bool, offsets::NTuple{7, Offset}, shouldthrow::Bool) + uri = HTTP.URI(url, (offsetss(url, o) for o in offsets)...) + URLTest(nm, url, isconnect, uri, shouldthrow) +end @testset "HTTP.URI" begin # constructor @test string(HTTP.URI("")) == "" - @test HTTP.URI(hostname="google.com") == HTTP.URI("http://google.com") - @test HTTP.URI(hostname="google.com", path="/") == HTTP.URI("http://google.com/") - @test HTTP.URI(hostname="google.com", userinfo="user") == HTTP.URI("http://user@google.com") - @test HTTP.URI(hostname="google.com", path="user") == HTTP.URI("http://google.com/user") - @test HTTP.URI(hostname="google.com", query=Dict("key"=>"value")) == HTTP.URI("http://google.com?key=value") - @test HTTP.URI(hostname="google.com", fragment="user") == HTTP.URI("http://google.com/#user") + @test HTTP.URI(scheme="http", host="google.com") == HTTP.URI("http://google.com") + @test HTTP.URI(scheme="http", host="google.com", path="/") == HTTP.URI("http://google.com/") + @test HTTP.URI(scheme="http", host="google.com", userinfo="user") == HTTP.URI("http://user@google.com") + @test HTTP.URI(scheme="http", host="google.com", path="/user") == HTTP.URI("http://google.com/user") + @test HTTP.URI(scheme="http", host="google.com", query=Dict("key"=>"value")) == HTTP.URI("http://google.com?key=value") + @test HTTP.URI(scheme="http", host="google.com", path="/", fragment="user") == HTTP.URI("http://google.com/#user") urls = [("hdfs://user:password@hdfshost:9000/root/folder/file.csv#frag", ["root", "folder", "file.csv"]), ("https://user:password@httphost:9000/path1/path2;paramstring?q=a&p=r#frag", ["path1", "path2;paramstring"]), @@ -38,27 +61,26 @@ URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) = URLTest(n u = parse(HTTP.URI, url) @test string(u) == url @test isvalid(u) - @test HTTP.splitpath(u) == splpath + @test HTTP.URIs.splitpath(u.path) == splpath end - @test parse(HTTP.URI, "hdfs://user:password@hdfshost:9000/root/folder/file.csv") == HTTP.URI(hostname="hdfshost", path="/root/folder/file.csv", scheme="hdfs", port=9000, userinfo="user:password") - @test parse(HTTP.URI, "http://google.com:80/some/path") == HTTP.URI(hostname="google.com", path="/some/path") + @test parse(HTTP.URI, "hdfs://user:password@hdfshost:9000/root/folder/file.csv") == HTTP.URI(host="hdfshost", path="/root/folder/file.csv", scheme="hdfs", port=9000, userinfo="user:password") + @test parse(HTTP.URI, "http://google.com:80/some/path") == HTTP.URI(scheme="http", host="google.com", path="/some/path") - @test isempty(HTTP.URIs.Offset()) - @test HTTP.lower(UInt8('A')) == UInt8('a') - @test HTTP.escape(Char(1)) == "%01" + @test HTTP.Strings.lower(UInt8('A')) == UInt8('a') + @test HTTP.escapeuri(Char(1)) == "%01" - @test HTTP.escape(Dict("key1"=>"value1", "key2"=>["value2", "value3"])) == "key2=value2&key2=value3&key1=value1" + @test HTTP.escapeuri(Dict("key1"=>"value1", "key2"=>["value2", "value3"])) == "key2=value2&key2=value3&key1=value1" - @test HTTP.escape("abcdef αβ 1234-=~!@#\$()_+{}|[]a;") == "abcdef%20%CE%B1%CE%B2%201234-%3D%7E%21%40%23%24%28%29_%2B%7B%7D%7C%5B%5Da%3B" - @test HTTP.unescape(HTTP.escape("abcdef 1234-=~!@#\$()_+{}|[]a;")) == "abcdef 1234-=~!@#\$()_+{}|[]a;" - @test HTTP.unescape(HTTP.escape("👽")) == "👽" + @test HTTP.escapeuri("abcdef αβ 1234-=~!@#\$()_+{}|[]a;") == "abcdef%20%CE%B1%CE%B2%201234-%3D%7E%21%40%23%24%28%29_%2B%7B%7D%7C%5B%5Da%3B" + @test HTTP.unescapeuri(HTTP.escapeuri("abcdef 1234-=~!@#\$()_+{}|[]a;")) == "abcdef 1234-=~!@#\$()_+{}|[]a;" + @test HTTP.unescapeuri(HTTP.escapeuri("👽")) == "👽" - @test HTTP.escape([("foo", "bar"), (1, 2)]) == "foo=bar&1=2" - @test HTTP.escape(Dict(["foo" => "bar", 1 => 2])) in ("1=2&foo=bar", "foo=bar&1=2") - @test HTTP.escape(["foo" => "bar", 1 => 2]) == "foo=bar&1=2" + @test HTTP.escapeuri([("foo", "bar"), (1, 2)]) == "foo=bar&1=2" + @test HTTP.escapeuri(Dict(["foo" => "bar", 1 => 2])) in ("1=2&foo=bar", "foo=bar&1=2") + @test HTTP.escapeuri(["foo" => "bar", 1 => 2]) == "foo=bar&1=2" - @test "user:password" == HTTP.userinfo(parse(HTTP.URI, "https://user:password@httphost:9000/path1/path2;paramstring?q=a&p=r#frag")) + @test "user:password" == parse(HTTP.URI, "https://user:password@httphost:9000/path1/path2;paramstring?q=a&p=r#frag").userinfo @test HTTP.queryparams(HTTP.URI("https://httphost/path1/path2;paramstring?q=a&p=r#frag")) == Dict("q"=>"a","p"=>"r") @test HTTP.queryparams(HTTP.URI("https://foo.net/?q=a&malformed")) == Dict("q"=>"a","malformed"=>"") @@ -67,7 +89,7 @@ URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) = URLTest(n @test false == isvalid(parse(HTTP.URI, "file:///path/to/file/with?should=work#fine")) @test true == isvalid( parse(HTTP.URI, "file:///path/to/file/with%3fshould%3dwork%23fine")) - @test parse(HTTP.URI, "s3://bucket/key") == HTTP.URI(hostname="bucket", path="/key", scheme="s3") + @test parse(HTTP.URI, "s3://bucket/key") == HTTP.URI(host="bucket", path="/key", scheme="s3") @test sprint(show, parse(HTTP.URI, "http://google.com")) == "HTTP.URI(\"http://google.com\")" @@ -80,7 +102,7 @@ URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) = URLTest(n @test_throws HTTP.URIs.URLParsingError parse(HTTP.URI, "ht!tp://google.com") # Issue #27 - @test HTTP.escape("t est\n") == "t%20est%0A" + @test HTTP.escapeuri("t est\n") == "t%20est%0A" @testset "HTTP.parse(HTTP.URI, str)" begin @@ -88,157 +110,157 @@ URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) = URLTest(n URLTest("proxy request" ,"http://hostname/" ,false - ,(HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(8, 8) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(16, 1) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(1, 4) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(8, 8) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(16, 1) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("proxy request with port" ,"http://hostname:444/" ,false - ,(HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(8, 8) # UF_HOST - ,HTTP.URIs.Offset(17, 3) # UF_PORT - ,HTTP.URIs.Offset(20, 1) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(1, 4) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(8, 8) # UF_HOST + ,Offset(17, 3) # UF_PORT + ,Offset(20, 1) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("CONNECT request" ,"hostname:443" ,true - ,(HTTP.URIs.Offset(0, 0) # UF_SCHEMA - ,HTTP.URIs.Offset(1, 8) # UF_HOST - ,HTTP.URIs.Offset(10, 3) # UF_PORT - ,HTTP.URIs.Offset(0, 0) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(0, 0) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(1, 8) # UF_HOST + ,Offset(10, 3) # UF_PORT + ,Offset(0, 0) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("proxy ipv6 request" ,"http://[1:2::3:4]/" ,false - ,(HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(9, 8) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(18, 1) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(1, 4) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(9, 8) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(18, 1) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("proxy ipv6 request with port" ,"http://[1:2::3:4]:67/" ,false - ,(HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(9, 8) # UF_HOST - ,HTTP.URIs.Offset(19, 2) # UF_PORT - ,HTTP.URIs.Offset(21, 1) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(1, 4) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(9, 8) # UF_HOST + ,Offset(19, 2) # UF_PORT + ,Offset(21, 1) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("CONNECT ipv6 address" ,"[1:2::3:4]:443" ,true - ,(HTTP.URIs.Offset(0, 0) # UF_SCHEMA - ,HTTP.URIs.Offset(2, 8) # UF_HOST - ,HTTP.URIs.Offset(12, 3) # UF_PORT - ,HTTP.URIs.Offset(0, 0) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(0, 0) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(2, 8) # UF_HOST + ,Offset(12, 3) # UF_PORT + ,Offset(0, 0) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("ipv4 in ipv6 address" ,"http://[2001:0000:0000:0000:0000:0000:1.9.1.1]/" ,false - ,(HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(9,37) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(47, 1) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(1, 4) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(9,37) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(47, 1) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("extra ? in query string" ,"http://a.tbcdn.cn/p/fp/2010c/??fp-header-min.css,fp-base-min.css,fp-channel-min.css,fp-product-min.css,fp-mall-min.css,fp-category-min.css,fp-sub-min.css,fp-gdp4p-min.css,fp-css3-min.css,fp-misc-min.css?t=20101022.css" ,false - ,(HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(8,10) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(18,12) # UF_PATH - ,HTTP.URIs.Offset(31,187) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(1, 4) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(8,10) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(18,12) # UF_PATH + ,Offset(31,187) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("space URL encoded" ,"/toto.html?toto=a%20b" ,false - ,(HTTP.URIs.Offset(0, 0) # UF_SCHEMA - ,HTTP.URIs.Offset(0, 0) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(1,10) # UF_PATH - ,HTTP.URIs.Offset(12,10) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(0, 0) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(0, 0) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(1,10) # UF_PATH + ,Offset(12,10) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("URL fragment" ,"/toto.html#titi" ,false - ,(HTTP.URIs.Offset(0, 0) # UF_SCHEMA - ,HTTP.URIs.Offset(0, 0) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(1,10) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(12, 4) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(0, 0) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(0, 0) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(1,10) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(12, 4) # UF_FRAGMENT ) ,false ), URLTest("complex URL fragment" ,"http://www.webmasterworld.com/r.cgi?f=21&d=8405&url=http://www.example.com/index.html?foo=bar&hello=world#midpage" ,false - ,(HTTP.URIs.Offset( 1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset( 8, 22) # UF_HOST - ,HTTP.URIs.Offset( 0, 0) # UF_PORT - ,HTTP.URIs.Offset( 30, 6) # UF_PATH - ,HTTP.URIs.Offset( 37, 69) # UF_QUERY - ,HTTP.URIs.Offset(107, 7) # UF_FRAGMENT - ,HTTP.URIs.Offset( 0, 0) # UF_USERINFO + ,(Offset( 1, 4) # UF_SCHEMA + ,Offset( 0, 0) # UF_USERINFO + ,Offset( 8, 22) # UF_HOST + ,Offset( 0, 0) # UF_PORT + ,Offset( 30, 6) # UF_PATH + ,Offset( 37, 69) # UF_QUERY + ,Offset(107, 7) # UF_FRAGMENT ) ,false ), URLTest("complex URL from node js url parser doc" ,"http://host.com:8080/p/a/t/h?query=string#hash" ,false - ,( HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(8, 8) # UF_HOST - ,HTTP.URIs.Offset(17, 4) # UF_PORT - ,HTTP.URIs.Offset(21, 8) # UF_PATH - ,HTTP.URIs.Offset(30,12) # UF_QUERY - ,HTTP.URIs.Offset(43, 4) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,( Offset(1, 4) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(8, 8) # UF_HOST + ,Offset(17, 4) # UF_PORT + ,Offset(21, 8) # UF_PATH + ,Offset(30,12) # UF_QUERY + ,Offset(43, 4) # UF_FRAGMENT ) ,false ), URLTest("complex URL with basic auth from node js url parser doc" ,"http://a:b@host.com:8080/p/a/t/h?query=string#hash" ,false - ,( HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(12, 8) # UF_HOST - ,HTTP.URIs.Offset(21, 4) # UF_PORT - ,HTTP.URIs.Offset(25, 8) # UF_PATH - ,HTTP.URIs.Offset(34,12) # UF_QUERY - ,HTTP.URIs.Offset(47, 4) # UF_FRAGMENT - ,HTTP.URIs.Offset(8, 3) # UF_USERINFO + ,( Offset(1, 4) # UF_SCHEMA + ,Offset(8, 3) # UF_USERINFO + ,Offset(12, 8) # UF_HOST + ,Offset(21, 4) # UF_PORT + ,Offset(25, 8) # UF_PATH + ,Offset(34,12) # UF_QUERY + ,Offset(47, 4) # UF_FRAGMENT ) ,false ), URLTest("double @" @@ -276,13 +298,13 @@ URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) = URLTest(n ), URLTest("proxy basic auth with space url encoded" ,"http://a%20:b@host.com/" ,false - ,(HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(15, 8) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(23, 1) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(8, 6) # UF_USERINFO + ,(Offset(1, 4) # UF_SCHEMA + ,Offset(8, 6) # UF_USERINFO + ,Offset(15, 8) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(23, 1) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("carriage return in URL" @@ -296,13 +318,13 @@ URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) = URLTest(n ), URLTest("proxy basic auth with double :" ,"http://a::b@host.com/" ,false - ,(HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(13, 8) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(21, 1) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(8, 4) # UF_USERINFO + ,(Offset(1, 4) # UF_SCHEMA + ,Offset(8, 4) # UF_USERINFO + ,Offset(13, 8) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(21, 1) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("line feed in URL" @@ -312,13 +334,13 @@ URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) = URLTest(n ), URLTest("proxy empty basic auth" ,"http://@hostname/fo" ,false - ,(HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(9, 8) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(17, 3) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(1, 4) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(9, 8) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(17, 3) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("proxy line feed in hostname" @@ -336,13 +358,13 @@ URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) = URLTest(n ), URLTest("proxy basic auth with unreservedchars" ,"http://a!;-_!=+\$@host.com/" ,false - ,(HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(18, 8) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(26, 1) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(8, 9) # UF_USERINFO + ,(Offset(1, 4) # UF_SCHEMA + ,Offset(8, 9) # UF_USERINFO + ,Offset(18, 8) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(26, 1) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("proxy only empty basic auth" @@ -360,25 +382,25 @@ URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) = URLTest(n ), URLTest("ipv6 address with Zone ID" ,"http://[fe80::a%25eth0]/" ,false - ,(HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(9,14) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(24, 1) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(1, 4) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(9,14) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(24, 1) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("ipv6 address with Zone ID, but '%' is not percent-encoded" ,"http://[fe80::a%eth0]/" ,false - ,(HTTP.URIs.Offset(1, 4) # UF_SCHEMA - ,HTTP.URIs.Offset(9,12) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(22, 1) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(1, 4) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(9,12) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(22, 1) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("ipv6 address ending with '%'" @@ -396,25 +418,25 @@ URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) = URLTest(n ), URLTest("tab in URL" ,"/foo\tbar/" ,false - ,(HTTP.URIs.Offset(0, 0) # UF_SCHEMA - ,HTTP.URIs.Offset(0, 0) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(1, 9) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(0, 0) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(0, 0) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(1, 9) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ), URLTest("form feed in URL" ,"/foo\fbar/" ,false - ,(HTTP.URIs.Offset(0, 0) # UF_SCHEMA - ,HTTP.URIs.Offset(0, 0) # UF_HOST - ,HTTP.URIs.Offset(0, 0) # UF_PORT - ,HTTP.URIs.Offset(1, 9) # UF_PATH - ,HTTP.URIs.Offset(0, 0) # UF_QUERY - ,HTTP.URIs.Offset(0, 0) # UF_FRAGMENT - ,HTTP.URIs.Offset(0, 0) # UF_USERINFO + ,(Offset(0, 0) # UF_SCHEMA + ,Offset(0, 0) # UF_USERINFO + ,Offset(0, 0) # UF_HOST + ,Offset(0, 0) # UF_PORT + ,Offset(1, 9) # UF_PATH + ,Offset(0, 0) # UF_QUERY + ,Offset(0, 0) # UF_FRAGMENT ) ,false ) @@ -422,11 +444,19 @@ URLTest(nm::String, url::String, isconnect::Bool, shouldthrow::Bool) = URLTest(n for u in urltests println("TEST - uri.jl: $(u.name)") - if u.shouldthrow - @test_throws HTTP.URIs.URLParsingError parse(HTTP.URI, u.url; isconnect=u.isconnect) + if u.isconnect + if u.shouldthrow + @test_throws HTTP.URIs.URLParsingError HTTP.URIs.http_parse_host(SubString(u.url)) + else + host, port, userinfo = HTTP.URIs.http_parse_host(SubString(u.url)) + @test host == u.expecteduri.host + @test port == u.expecteduri.port + end + elseif u.shouldthrow + @test_throws HTTP.URIs.URLParsingError parse(HTTP.URI, u.url) else - url = parse(HTTP.URI, u.url; isconnect=u.isconnect) - @test u.offsets == url.offsets + url = parse(HTTP.URI, u.url) + @test u.expecteduri == url end end end diff --git a/test/utils.jl b/test/utils.jl index 25d56ebfc..a064263b1 100644 --- a/test/utils.jl +++ b/test/utils.jl @@ -1,48 +1,51 @@ @testset "utils.jl" begin -@test HTTP.escapeHTML("&\"'<>") == "&"'<>" +import HTTP.Parsers +import HTTP.URIs -@test HTTP.isurlchar('\u81') -@test !HTTP.isurlchar('\0') +@test HTTP.Strings.escapehtml("&\"'<>") == "&"'<>" + +@test URIs.isurlchar('\u81') +@test !URIs.isurlchar('\0') for c = '\0':'\x7f' if c in ('.', '-', '_', '~') - @test HTTP.ishostchar(c) - @test HTTP.ismark(c) - @test HTTP.isuserinfochar(c) + @test Parsers.ishostchar(c) + @test Parsers.ismark(c) + @test Parsers.isuserinfochar(c) elseif c in ('-', '_', '.', '!', '~', '*', '\'', '(', ')') - @test HTTP.ismark(c) - @test HTTP.isuserinfochar(c) + @test Parsers.ismark(c) + @test Parsers.isuserinfochar(c) else - @test !HTTP.ismark(c) + @test !Parsers.ismark(c) end end -@test HTTP.isalphanum('a') -@test HTTP.isalphanum('1') -@test !HTTP.isalphanum(']') - -@test HTTP.ishex('a') -@test HTTP.ishex('1') -@test !HTTP.ishex(']') - -@test HTTP.canonicalize!("accept") == "Accept" -@test HTTP.canonicalize!("Accept") == "Accept" -@test HTTP.canonicalize!("eXcept-this") == "Except-This" -@test HTTP.canonicalize!("exCept-This") == "Except-This" -@test HTTP.canonicalize!("not-valid") == "Not-Valid" -@test HTTP.canonicalize!("♇") == "♇" -@test HTTP.canonicalize!("bλ-a") == "Bλ-A" -@test HTTP.canonicalize!("not fixable") == "Not fixable" -@test HTTP.canonicalize!("aaaaaaaaaaaaa") == "Aaaaaaaaaaaaa" -@test HTTP.canonicalize!("conTENT-Length") == "Content-Length" -@test HTTP.canonicalize!("Sec-WebSocket-Key2") == "Sec-Websocket-Key2" -@test HTTP.canonicalize!("User-agent") == "User-Agent" -@test HTTP.canonicalize!("Proxy-authorization") == "Proxy-Authorization" -@test HTTP.canonicalize!("HOST") == "Host" -@test HTTP.canonicalize!("ST") == "St" -@test HTTP.canonicalize!("X-\$PrototypeBI-Version") == "X-\$prototypebi-Version" -@test HTTP.canonicalize!("DCLK_imp") == "Dclk_imp" +@test Parsers.isalphanum('a') +@test Parsers.isalphanum('1') +@test !Parsers.isalphanum(']') + +@test Parsers.ishex('a') +@test Parsers.ishex('1') +@test !Parsers.ishex(']') + +@test HTTP.Strings.tocameldash!("accept") == "Accept" +@test HTTP.Strings.tocameldash!("Accept") == "Accept" +@test HTTP.Strings.tocameldash!("eXcept-this") == "Except-This" +@test HTTP.Strings.tocameldash!("exCept-This") == "Except-This" +@test HTTP.Strings.tocameldash!("not-valid") == "Not-Valid" +@test HTTP.Strings.tocameldash!("♇") == "♇" +@test HTTP.Strings.tocameldash!("bλ-a") == "Bλ-A" +@test HTTP.Strings.tocameldash!("not fixable") == "Not fixable" +@test HTTP.Strings.tocameldash!("aaaaaaaaaaaaa") == "Aaaaaaaaaaaaa" +@test HTTP.Strings.tocameldash!("conTENT-Length") == "Content-Length" +@test HTTP.Strings.tocameldash!("Sec-WebSocket-Key2") == "Sec-Websocket-Key2" +@test HTTP.Strings.tocameldash!("User-agent") == "User-Agent" +@test HTTP.Strings.tocameldash!("Proxy-authorization") == "Proxy-Authorization" +@test HTTP.Strings.tocameldash!("HOST") == "Host" +@test HTTP.Strings.tocameldash!("ST") == "St" +@test HTTP.Strings.tocameldash!("X-\$PrototypeBI-Version") == "X-\$prototypebi-Version" +@test HTTP.Strings.tocameldash!("DCLK_imp") == "Dclk_imp" for (bytes, utf8) in ( @@ -54,10 +57,10 @@ for (bytes, utf8) in ( # (UInt8[0x6e, 0x6f, 0xeb, 0x6c, 0x20, 0xa4], "noël €"), (UInt8[0xc4, 0xc6, 0xe4], "ÄÆä"), ) - @test HTTP.iso8859_1_to_utf8(bytes) == utf8 + @test HTTP.Strings.iso8859_1_to_utf8(bytes) == utf8 end # using StringEncodings # println(encode("ÄÆä", "ISO-8859-15")) -end \ No newline at end of file +end