use "collections"
use "net"
use "format"
use "buffered"
primitive ChunkedTransfer
primitive StreamTransfer
primitive OneshotTransfer
type TransferMode is (ChunkedTransfer | StreamTransfer | OneshotTransfer)
class trn Payload
"""
This class represent a single HTTP message, which can be either a
`request` or a `response`.
### Transfer Modes
HTTP provides two ways to encode the transmission of a message 'body',
of any size. This package supports both of them:
2. **StreamTransfer**. This is used for payload bodies where the exact
length is known in advance, including most transfers of files. It is
selected by calling `Payload.set_length` with an integer bytecount.
Appication buffer sizes determine how much data is fed to the TCP
connection at once, but the total amount must match this size.
3. **ChunkedTransfer**. This is used when the payload length can not be
known in advance, but can be large. It is selected by calling
`Payload.set_length` with a parameter of `None`. On the TCP link this mode
can be detected because there is no `Content-Length` header at all, being
replaced by the `Transfer-Encoding: chunked` header. In addition, the
message body is separated into chunks, each with its own bytecount. As with
`StreamTransfer` mode, transmission can be spread out over time with the
difference that it is the original data source that determines the chunk
size.
If `Payload.set_length` is never called at all, a variation on
`StreamTransfer` called `OneshotTransfer` is used. In this case, all of
the message body is placed into the message at once, using
`Payload.add_chunk` calls. The size will be determined when the message is
submitted for transmission. Care must be taken not to consume too much
memory, especially on a server where there can be multiple messages in
transit at once.
The type of transfer being used by an incoming message can be determined
from its `transfer_mode` field, which will be one of the
[TransferMode](/http/http-TransferMode) types.
### Sequence
For example, to send a message of possibly large size:
1. Create the message with a call to `Payload.request` or `Payload.response`.
2. Set the `session` field of the message.
2. Call `Payload.set_length` to indicate the length of the body.
3. Add any additional headers that may be required, such as `Content-type`.
4. Submit the message for transmission by calling the either the
`HTTPSession.apply` method (in servers) or the `HTTPCLient.apply` method
in clients.
5. Wait for the `send_body` notification.
6. Make any number of calls to `Payload.send_chunk`.
7. Call `Payload.finish`.
To send a message of small, reasonable size (say, under 20KB), this
simplified method can be used instead:
1. Create the message with a call to `Payload.request` or `Payload.response`.
2. Set the `session` field of the message.
3. Add any additional headers that may be required, such as `Content-type`.
4. Call `add_chunk` one or more times to add body data.
4. Submit the message for transmission by calling the either the
[HTTPSession](/http/http-HTTPSession)`.apply` method (in servers) or the
[HTTPClient](/http/http-HTTPClient)`.apply` method in clients.
"""
var proto: String = "HTTP/1.1"
"""The HTTP protocol string"""
var status: U16
"""
Internal representation of the response [Status](http-Status).
Will be `0` for HTTP requests.
"""
var method: String
"""
The HTTP Method.
`GET`, `POST`, `DELETE`, `OPTIONS`, ...
For HTTP responses this will be the status string,
for a `200` status this will be `200 OK`, for `404`, `404 Not Found` etc..
"""
var url: URL
"""
The HTTP request [URL](http-URL).
It will be used for the HTTP path and the `Host` header.
The `user` and `password` fields are ignored.
For HTTP responses this will be an empty [URL](http-URL).
"""
var _body_length: USize = 0
var transfer_mode: TransferMode = OneshotTransfer
"""
Determines the transfer mode of this message.
In case of outgoing requests or responses,
use `set_length` to control the transfer mode.
In case of incoming requests, this field determines
how the request is transferred.
"""
var session: (HTTPSession | None) = None
embed _headers: Map[String, String] = _headers.create()
embed _body: Array[ByteSeq val] = _body.create()
let _response: Bool
var username: String = ""
"""
The username extracted from an `Authentication` header of an HTTP request
received via [HTTPServer](https://ponylang.github.io/http_server/http_server-Server/).
This is not used and not sent using [HTTPClient](http-HTTPClient),
use `update` to set an `Authentication` header instead.
"""
var password: String = ""
"""
The password extracted from an `Authentication` header of an HTTP request
received via [HTTPServer](https://ponylang.github.io/http_server/http_server-Server/).
This is not used and not sent using [HTTPClient](http-HTTPClient),
use `update` to set an `Authentication` header instead.
"""
new iso request(method': String = "GET", url': URL = URL) =>
"""
Create an HTTP `request` message.
"""
status = 0
method = method'
url = url'
_response = false
new iso response(status': Status = StatusOK) =>
"""
Create an HTTP `response` message.
"""
status = status'()
method = status'.string()
url = URL
_response = true
new iso _empty(response': Bool = true) =>
"""
Create an empty HTTP payload.
"""
status = 0
method = ""
url = URL
_response = response'
fun apply(key: String): String ? =>
"""
Get a header.
"""
_headers(key.lower())?
fun is_safe(): Bool =>
"""
A request method is "safe" if it does not modify state in the resource.
These methods can be guaranteed not to have any body data.
Return true for a safe request method, false otherwise.
"""
match method
| "GET"
| "HEAD"
| "OPTIONS" =>
true
else
false
end
fun body(): this->Array[ByteSeq] ? =>
"""
Get the body in `OneshotTransfer` mode.
In the other modes it raises an error.
"""
match transfer_mode
| OneshotTransfer => _body
else error
end
fun ref set_length(bytecount: (USize | None)) =>
"""
Set the body length when known in advance. This determines the
transfer mode that will be used. A parameter of 'None' will use
Chunked Transfer Encoding. A numeric value will use Streamed
transfer. Not calling this function at all will
use Oneshot transfer.
"""
match bytecount
| None =>
transfer_mode = ChunkedTransfer
_headers("Transfer-Encoding") = "chunked"
| let n: USize =>
try not _headers.contains("Content-Length") then
_headers("Content-Length") = n.string()
end
_body_length = n
transfer_mode = StreamTransfer
end
fun ref update(key: String, value: String): Payload ref^ =>
"""
Set any header. If we've already received the header, append the value as a
comma separated list, as per RFC 2616 section 4.2.
"""
_headers.upsert(key.lower(),
value,
{(current, provided) => current + "," + provided})
this
fun headers(): this->Map[String, String] =>
"""
Get all the headers.
"""
_headers
fun body_size(): (USize | None) =>
"""
Get the total intended size of the body.
`ServerConnection` accumulates actual size transferred for logging.
"""
match transfer_mode
| ChunkedTransfer => None
else _body_length
end
fun ref add_chunk(data: ByteSeq val): Payload ref^ =>
"""
This is how application code adds data to the body in
`OneshotTransfer` mode. For large bodies, call `set_length`
and use `send_chunk` instead.
"""
_body.push(data)
_body_length = _body_length + data.size()
this
fun box send_chunk(data: ByteSeq val) =>
"""
This is how application code sends body data in `StreamTransfer` and
`ChunkedTransfer` modes. For smaller body lengths, `add_chunk` in
`Oneshot` mode can be used instead.
"""
match session
| let s: HTTPSession =>
match transfer_mode
| ChunkedTransfer =>
// Wrap some body data in the Chunked Transfer Encoding format,
// which is the length in hex, the data, and a CRLF. It is
// important to never send a chunk of length zero, as that is
// how the end of the body is signalled.
s.write(Format.int[USize](data.size(), FormatHexBare))
s.write("\r\n")
s.write(data)
s.write("\r\n")
| StreamTransfer =>
// In stream mode just send the data. Its length should have
// already been accounted for by `set_length`.
s.write(data)
end
end
fun val finish() =>
"""
Mark the end of body transmission. This does not do anything,
and is unnecessary, in Oneshot mode.
"""
match session
| let s: HTTPSession =>
match transfer_mode
| ChunkedTransfer =>
s.write("0\r\n\r\n")
s.finish()
| StreamTransfer =>
s.finish()
end
end
fun val respond(response': Payload) =>
"""
Start sending a response from the server to the client.
"""
try
(session as HTTPSession)(consume response')
end
fun val _client_fail() =>
"""
Start sending an error response.
"""
None
/* Not sure if we need this. Nobody calls it. But something like:
try
(session as HTTPSession)(
Payload.response(StatusInternalServerError))
end
*/
fun val _write(keepalive: Bool = true, wr: Writer ref) =>
"""
Writes the payload to a Writer. Requests and Responses differ
only in the first line of text - everything after that is the same format.
"""
if _response then
_write_response(keepalive, wr)
else
_write_request(keepalive, wr)
end
_write_common(wr)
fun val _write_request(keepalive: Bool, wr: Writer ref) =>
"""
Writes the 'request' parts of an HTTP message.
"""
wr
.> write(method)
.> write(" ")
.> write(url.path)
if url.query.size() > 0 then
wr
.> write("?")
.> write(url.query)
end
if url.fragment.size() > 0 then
wr
.> write("#")
.> write(url.fragment)
end
wr
.> write(" ")
.> write(proto)
.> write("\r\n")
if not keepalive then
wr.write("Connection: close\r\n")
end
if url.port == url.default_port() then
wr
.> write("Host: ")
.> write(url.host)
.> write("\r\n")
else
wr
.> write("Host: ")
.> write(url.host)
.> write(":")
.> write(url.port.string())
.> write("\r\n")
end
fun val _write_common(wr: Writer ref) =>
"""
Writes the parts of an HTTP message common to both requests and
responses.
"""
_write_headers(wr)
// In oneshot mode we send the entire stored body.
if transfer_mode is OneshotTransfer then
for piece in _body.values() do
wr.write(piece)
end
end
fun val _write_response(keepalive: Bool, wr: Writer ref) =>
"""
Write the response-specific parts of an HTTP message. This is the
status line, consisting of the protocol name, the status value,
and a string representation of the status (carried in the `method`
field). Since writing it out is an actor behavior call, we go to
the trouble of packaging it into a single string before sending.
"""
wr
.> write(proto)
.> write(" ")
.> write(status.string())
.> write(" ")
.> write(method)
.> write("\r\n")
if keepalive then
wr.write("Connection: keep-alive\r\n")
end
fun _write_headers(wr: Writer ref) =>
"""
Write all of the HTTP headers to the Writer.
"""
var saw_length: Bool = false
for (k, v) in _headers.pairs() do
if (k != "Host") then
if k == "Content-Length" then saw_length = true end
wr
.> write(k)
.> write(": ")
.> write(v)
.> write("\r\n")
end
end
if (not saw_length) and (transfer_mode is OneshotTransfer) then
wr
.> write("Content-Length: ")
.> write(_body_length.string())
.> write("\r\n")
end
// Blank line before the body.
wr.write("\r\n")
fun box has_body(): Bool =>
"""
Determines whether a message has a body portion.
"""
if _response then
// Errors never have bodies.
if
(status == 204) // no content
or (status == 304) // not modified
or ((status > 0) and (status < 200))
or (status > 400)
then
false
else
true
end
else
match transfer_mode
| ChunkedTransfer => true
else (_body_length > 0)
end
end