Current section
Files
Jump to
Current section
Files
src/dove.gleam
import gleam/int
import gleam/bool
import gleam/list
import gleam/option
import gleam/string
import gleam/result
import gleam/dynamic
import gleam/bit_array
import gleam/json
import gleam/erlang
import gleam/erlang/process
import gleam/http
import gleam/http/request.{type Request} as _
import gleam/http/response as gleam_http_response
import dove/error
import dove/request
import dove/response
import dove/internal/tcp
import dove/internal/request.{encode} as _
import dove/internal/response.{decode} as _
import mug
pub type RequestOption(a) {
JSONDecoder(fn(dynamic.Dynamic) -> Result(a, List(dynamic.DecodeError)))
}
pub opaque type Connection(a) {
Connection(
host: String,
socket: mug.Socket,
buffer: BitArray,
requests: List(
#(
erlang.Reference,
option.Option(
fn(dynamic.Dynamic) -> Result(a, List(dynamic.DecodeError)),
),
),
),
responses: List(
#(
erlang.Reference,
Result(
gleam_http_response.Response(option.Option(response.Body(a))),
error.Error,
),
),
),
default_timeout: Int,
)
}
pub fn connect(host: String, port: Int, timeout: Int) {
use socket <- result.then(tcp.connect(host, port, timeout))
Ok(Connection(
host <> ":" <> int.to_string(port),
socket,
<<>>,
[],
[],
timeout,
))
}
pub fn request(
conn: Connection(a),
request: Request(option.Option(request.Body)),
options: List(RequestOption(a)),
) {
use <- bool.guard(
request.scheme == http.Https,
Error(error.HttpsNotSupportedYet),
)
let method = case request.method {
http.Other(method) -> method
_ -> {
let assert Ok(method) = list.key_find(method_mapping, request.method)
method
}
}
let #(body, headers) = case request.body {
option.Some(request.JSON(body)) -> #(
body
|> bit_array.from_string
|> option.Some,
list.append(
request.headers,
[
#("content-type", "application/json; charset=utf-8"),
#(
"content-length",
body
|> string.length
|> int.to_string,
),
],
),
)
option.Some(request.PlainText(body)) -> #(
body
|> bit_array.from_string
|> option.Some,
list.append(
request.headers,
[
#("content-type", "text/plain; charset=utf-8"),
#(
"content-length",
body
|> string.length
|> int.to_string,
),
],
),
)
option.Some(request.OctetStream(body)) -> #(
option.Some(body),
list.append(
request.headers,
[
#("content-type", "application/octet-stream"),
#(
"content-length",
body
|> bit_array.byte_size
|> int.to_string,
),
],
),
)
option.None -> #(option.None, request.headers)
}
let decoder = get_decoder(options)
use request <- result.then(encode(
conn.host,
method,
request.path,
request.query,
headers,
body,
))
use _ <- result.then(
tcp.send(conn.socket, request)
|> result.replace_error(error.UnableToSendRequest),
)
let ref = erlang.make_reference()
Ok(#(
Connection(
conn.host,
conn.socket,
conn.buffer,
list.append(conn.requests, [#(ref, decoder)]),
conn.responses,
conn.default_timeout,
),
ref,
))
}
pub fn receive(conn: Connection(a), timeout) {
let selector = tcp.new_selector()
receive_internal(conn, selector, timeout)
}
fn receive_internal(conn: Connection(a), selector, timeout) {
case conn.requests {
[#(ref, decoder), ..rest] -> {
let data = case bit_array.byte_size(conn.buffer) {
0 -> receive_packet(conn.socket, selector, <<>>, now(), timeout)
_ -> decode(conn.buffer)
}
let conn =
Connection(
conn.host,
conn.socket,
case data {
Ok(#(_, rest)) -> rest
_ -> <<>>
},
rest,
list.append(
conn.responses,
[
#(
ref,
data
|> result.map(fn(response) {
case response.0 {
#(status, headers, option.Some(body)) ->
case decoder {
option.Some(decoder) ->
case json.decode_bits(body, decoder) {
Ok(value) ->
Ok(gleam_http_response.Response(
status,
headers,
option.Some(response.JSONDecoded(value)),
))
Error(decode_error) ->
Ok(gleam_http_response.Response(
status,
headers,
option.Some(response.InvalidOrUnexpectedJSON(
body,
decode_error,
)),
))
}
option.None ->
case bit_array.to_string(body) {
Ok(body) ->
case list.key_find(headers, "content-type") {
Ok(mime) ->
case
string.contains(mime, "application/json")
{
True ->
Ok(gleam_http_response.Response(
status,
headers,
option.Some(response.HeadersSayJSON(
body,
)),
))
False ->
Ok(gleam_http_response.Response(
status,
headers,
option.Some(response.PlainText(body)),
))
}
Error(Nil) ->
Ok(gleam_http_response.Response(
status,
headers,
option.Some(response.PlainText(body)),
))
}
Error(Nil) ->
Ok(gleam_http_response.Response(
status,
headers,
option.Some(response.OctetStream(body)),
))
}
}
#(status, headers, option.None) ->
Ok(gleam_http_response.Response(
status,
headers,
option.None,
))
}
})
|> result.flatten,
),
],
),
conn.default_timeout,
)
case rest {
[] -> conn
_ -> receive_internal(conn, selector, timeout)
}
}
[] -> conn
}
}
pub fn get_response(conn: Connection(a), ref) {
case list.key_pop(conn.responses, ref) {
Ok(#(response, other_responses)) -> #(
Connection(..conn, responses: other_responses),
response,
)
Error(Nil) -> #(conn, Error(error.TCPError(mug.Timeout)))
}
}
pub fn shutdown(conn: Connection(a)) {
let conn = receive(conn, conn.default_timeout)
mug.shutdown(conn.socket)
|> result.map(fn(_) { conn.responses })
|> result.map_error(fn(tcp_error) { error.TCPError(tcp_error) })
}
const method_mapping = [
#(http.Get, "GET"),
#(http.Put, "PUT"),
#(http.Post, "POST"),
#(http.Head, "HEAD"),
#(http.Patch, "PATCH"),
#(http.Trace, "TRACE"),
#(http.Delete, "DELETE"),
#(http.Connect, "CONNECT"),
#(http.Options, "OPTIONS"),
]
fn get_decoder(options: List(RequestOption(a))) {
case
list.find_map(
options,
fn(opt) {
case opt {
JSONDecoder(decoder) -> Ok(decoder)
_ -> Error(Nil)
}
},
)
{
Ok(decoder) -> option.Some(decoder)
Error(Nil) -> option.None
}
}
fn receive_packet(
socket: mug.Socket,
selector: process.Selector(Result(BitArray, mug.Error)),
storage: BitArray,
start_time: Int,
timeout: Int,
) {
case decode(storage) {
Ok(value) -> Ok(value)
Error(error.MoreNeeded) -> {
case now() - start_time >= timeout * 1_000_000 {
True -> Error(error.TCPError(mug.Timeout))
False ->
case tcp.receive(socket, selector, timeout) {
Error(tcp_error) -> Error(error.TCPError(tcp_error))
Ok(packet) -> {
receive_packet(
socket,
selector,
bit_array.append(storage, packet),
start_time,
timeout,
)
}
}
}
}
Error(error) -> Error(error)
}
}
@external(erlang, "erlang", "monotonic_time")
fn now() -> Int