//// //// //// #### IP Address //// - [ip_address_to_string](#ip_address_to_string) //// #### Information //// - [get_client_info](#get_client_info) //// - [get_server_info](#get_server_info) //// #### Builder //// - [new](#new) //// - [bind](#bind) //// - [bind_all](#bind_all) //// - [with_port](#with_port) //// - [with_random_port](#with_random_port) //// - [with_ipv6](#with_ipv6) //// - [with_tls](#with_tls) //// - [with_name](#with_name) //// - [on_start](#on_start) //// - [on_crash](#on_crash) //// #### Server //// - [start](#start) //// - [supervised](#supervised) //// #### Request //// - [read_body](#read_body) //// #### Response //// - [text](#text) //// - [bytes](#bytes) //// - [bits](#bits) //// - [string_tree](#string_tree) //// - [empty](#empty) //// - [json](#json) //// #### Websocket //// - [upgrade_websocket](#upgrade_websocket) //// - [send_binary_frame](#send_binary_frame) //// - [send_text_frame](#send_text_frame) //// - [continue](#continue) //// - [stop](#stop) //// - [stop_abnormal](#stop_abnormal) //// #### Experimental //// - [use_expression](#use_expression) import gleam/bit_array import gleam/bytes_tree.{type BytesTree} import gleam/erlang/process import gleam/http import gleam/http/request.{type Request} import gleam/http/response.{type Response} import gleam/int import gleam/io import gleam/option.{type Option, None, Some} import gleam/otp/actor import gleam/otp/static_supervisor as supervisor import gleam/otp/supervision import gleam/result import gleam/string import gleam/string_tree import glisten import glisten/socket/options as glisten_options import glisten/transport import gramps/websocket as ws import ewe/internal/file as file_ import ewe/internal/handler as handler_ import ewe/internal/http as http_ import ewe/internal/info as info_ import ewe/internal/websocket as websocket_ // CONNECTION ------------------------------------------------------------------ /// Represents a connection between a client and a server, stored inside a /// `Request`. Can be converted to a `BitArray` using `ewe.read_body`. /// pub type Connection = http_.Connection // IP ADDRESS ------------------------------------------------------------------ /// Represents an IP address. Appears when accessing client's information /// (`ewe.client_stats`) or `on_start` handler (`ewe.on_start`). /// pub type IpAddress { IpV4(Int, Int, Int, Int) IpV6(Int, Int, Int, Int, Int, Int, Int, Int) } /// Converts an `IpAddress` to a string for later printing. /// pub fn ip_address_to_string(address: IpAddress) -> String { ewe_to_glisten_ip(address) |> glisten.ip_address_to_string() } fn glisten_to_ewe_ip(ip: glisten.IpAddress) -> IpAddress { case ip { glisten.IpV4(n1, n2, n3, n4) -> IpV4(n1, n2, n3, n4) glisten.IpV6(n1, n2, n3, n4, n5, n6, n7, n8) -> IpV6(n1, n2, n3, n4, n5, n6, n7, n8) } } fn glisten_options_to_ewe_ip(ip: glisten_options.IpAddress) -> IpAddress { case ip { glisten_options.IpV4(n1, n2, n3, n4) -> IpV4(n1, n2, n3, n4) glisten_options.IpV6(n1, n2, n3, n4, n5, n6, n7, n8) -> IpV6(n1, n2, n3, n4, n5, n6, n7, n8) } } fn ewe_to_glisten_ip(ip: IpAddress) -> glisten.IpAddress { case ip { IpV4(n1, n2, n3, n4) -> glisten.IpV4(n1, n2, n3, n4) IpV6(n1, n2, n3, n4, n5, n6, n7, n8) -> glisten.IpV6(n1, n2, n3, n4, n5, n6, n7, n8) } } // INFO ------------------------------------------------------------------------ /// Represents started server's information. Can be retrieved using /// `ewe.get_server_info`. /// pub type ServerInfo { ServerInfo(scheme: http.Scheme, ip_address: IpAddress, port: Int) } /// Performs an attempt to get the client's IP address and port. /// pub fn get_client_info(connection: Connection) -> Result(#(IpAddress, Int), Nil) { transport.peername(connection.transport, connection.socket) |> result.map(fn(tuple) { let #(ip, port) = tuple #(glisten_options_to_ewe_ip(ip), port) }) } /// Retrieves server's information. Requires the same name as the one used in /// `ewe.with_name` and server to be started. Otherwise, will crash the program. /// pub fn get_server_info( name: process.Name(info_.Message(ServerInfo)), ) -> Result(ServerInfo, Nil) { info_.get(process.named_subject(name)) } // RESPONSE -------------------------------------------------------------------- /// Represents a response body. To set the response body, use the following /// functions: /// /// - `ewe.text` /// - `ewe.bytes` /// - `ewe.bits` /// - `ewe.string_tree` /// - `ewe.empty` /// - `ewe.json` /// pub opaque type ResponseBody { TextData(String) BytesData(BytesTree) BitsData(BitArray) StringTreeData(string_tree.StringTree) WebsocketConnection(process.Selector(process.Down)) Empty } fn transform_response_body( resp: Response(ResponseBody), ) -> Response(http_.ResponseBody) { response.set_body(resp, case resp.body { TextData(text) -> http_.TextData(text) BytesData(bytes) -> http_.BytesData(bytes) BitsData(bits) -> http_.BitsData(bits) StringTreeData(string_tree) -> http_.StringTreeData(string_tree) WebsocketConnection(selector) -> http_.WebsocketConnection(selector) Empty -> http_.Empty }) } /// Sets response body from string, sets `content-type` to /// `text/plain; charset=utf-8` and `content-length` headers. /// pub fn text(response: Response(a), text: String) -> Response(ResponseBody) { response.set_body(response, TextData(text)) |> response.set_header("content-type", "text/plain; charset=utf-8") |> response.set_header( "content-length", int.to_string(string.byte_size(text)), ) } /// Sets response body from bytes, sets `content-length` header. Doesn't set /// `content-type` header. /// pub fn bytes(response: Response(a), bytes: BytesTree) -> Response(ResponseBody) { response.set_body(response, BytesData(bytes)) |> response.set_header( "content-length", int.to_string(bytes_tree.byte_size(bytes)), ) } /// Sets response body from bits, sets `content-length` header. Doesn't set /// `content-type` header. /// pub fn bits(response: Response(a), bits: BitArray) -> Response(ResponseBody) { response.set_body(response, BitsData(bits)) |> response.set_header( "content-length", int.to_string(bit_array.byte_size(bits)), ) } /// Sets response body from string tree, sets `content-length` header. Doesn't /// set `content-type` header. /// pub fn string_tree( response: Response(a), string_tree: string_tree.StringTree, ) -> Response(ResponseBody) { response.set_body(response, StringTreeData(string_tree)) |> response.set_header( "content-length", int.to_string(string_tree.byte_size(string_tree)), ) } /// Sets response body to empty, sets `content-length` header to `0`. /// pub fn empty(response: Response(a)) -> Response(ResponseBody) { response.set_body(response, Empty) |> response.set_header("content-length", "0") } /// Sets response body from string tree (use `gleam_json` package and encode /// using `json.to_string_tree`), sets `content-type` to `application/json; /// charset=utf-8` and `content-length` headers. /// pub fn json( response: Response(a), json: string_tree.StringTree, ) -> Response(ResponseBody) { string_tree(response, json) |> response.set_header("content-type", "application/json; charset=utf-8") } // BUILDER --------------------------------------------------------------------- /// Ewe's server builder. Contains all server's configuration. Can be adjusted /// with the following functions: /// - `ewe.bind` /// - `ewe.bind_all` /// - `ewe.with_read_body` /// - `ewe.with_port` /// - `ewe.with_random_port` /// - `ewe.with_ipv6` /// - `ewe.with_tls` /// - `ewe.with_name` /// - `ewe.on_start` /// - `ewe.on_crash` /// pub opaque type Builder(body) { Builder( handler: fn(Request(body)) -> Response(ResponseBody), port: Int, interface: String, ipv6: Bool, tls: Option(#(String, String)), on_start: fn(ServerInfo) -> Nil, on_crash: Response(ResponseBody), info_worker_name: process.Name(info_.Message(ServerInfo)), ) } /// Creates new server builder with handler provided. /// /// Default configuration: /// - port: `8080` /// - interface: `127.0.0.1` /// - No ipv6 support /// - No TLS support /// - Default process name for server information retrieval /// - on_start: prints `Listening on ://:` /// - on_crash: empty 500 response /// pub fn new( handler: fn(Request(body)) -> Response(ResponseBody), ) -> Builder(body) { Builder( handler:, port: 8080, interface: "127.0.0.1", ipv6: False, tls: None, on_start: fn(server) { let address = case server.ip_address { IpV6(..) -> "[" <> ip_address_to_string(server.ip_address) <> "]" IpV4(..) -> ip_address_to_string(server.ip_address) } let url = http.scheme_to_string(server.scheme) <> "://" <> address <> ":" <> int.to_string(server.port) io.println("Listening on " <> url) }, on_crash: response.new(500) |> response.set_body(Empty), info_worker_name: process.new_name("ewe_server_info"), ) } /// Binds server to a specific interface. Crashes program if interface is invalid. /// pub fn bind(builder: Builder(body), interface: String) -> Builder(body) { Builder(..builder, interface:) } /// Binds server to all interfaces. /// pub fn bind_all(builder: Builder(body)) -> Builder(body) { Builder(..builder, interface: "0.0.0.0") } /// Sets listening port for server. /// pub fn with_port(builder: Builder(body), port: Int) -> Builder(body) { Builder(..builder, port:) } /// Sets listening port for server to a random port. Useful for testing. /// pub fn with_random_port(builder: Builder(body)) -> Builder(body) { Builder(..builder, port: 0) } /// Enables IPv6 support. /// pub fn with_ipv6(builder: Builder(body)) -> Builder(body) { Builder(..builder, ipv6: True) } /// Enables TLS support, requires certificate and key file. /// pub fn with_tls( builder: Builder(body), certificate: String, keyfile: String, ) -> Builder(body) { let cert = case file_.open(certificate) { Ok(_) -> certificate Error(_) -> panic as "Failed to find cert file" } let key = case file_.open(keyfile) { Ok(_) -> keyfile Error(_) -> panic as "Failed to find key file" } Builder(..builder, tls: Some(#(cert, key))) } /// Sets a custom process name for server information retrieval, allowing to /// use `ewe.get_server_info` after server starts. /// pub fn with_name( builder: Builder(body), name: process.Name(info_.Message(ServerInfo)), ) -> Builder(body) { Builder(..builder, info_worker_name: name) } /// Sets a custom handler that will be called after server starts. /// pub fn on_start( builder: Builder(body), on_start: fn(ServerInfo) -> Nil, ) -> Builder(body) { Builder(..builder, on_start:) } /// Sets a custom response that will be sent when server crashes. /// pub fn on_crash( builder: Builder(body), on_crash: Response(ResponseBody), ) -> Builder(body) { Builder(..builder, on_crash:) } // SERVER ---------------------------------------------------------------------- /// Starts the server. /// pub fn start( builder: Builder(Connection), ) -> Result(actor.Started(supervisor.Supervisor), actor.StartError) { let name = process.new_name("ewe_glisten") let handler = fn(req) { transform_response_body(builder.handler(req)) } let on_crash = transform_response_body(builder.on_crash) let worker_name = builder.info_worker_name let subject = process.named_subject(worker_name) let info_worker = info_.start_worker(worker_name) let glisten_supervisor = glisten.new( fn(conn) { #(http_.transform_connection(conn), None) }, handler_.loop(handler, on_crash), ) |> glisten.bind(builder.interface) |> fn(glisten_builder) { case builder.ipv6 { True -> glisten.with_ipv6(glisten_builder) False -> glisten_builder } } |> fn(glisten_builder) { case builder.tls { Some(#(cert, key)) -> glisten.with_tls(glisten_builder, cert, key) None -> glisten_builder } } // https://github.com/rawhat/glisten/blob/master/src/glisten.gleam#L359 |> glisten.start_with_listener_name(builder.port, name) |> result.map(fn(started) { let scheme = case builder.tls { Some(#(_, _)) -> http.Https None -> http.Http } let server_info = glisten.get_server_info(name, 10_000) let ip_address = glisten_to_ewe_ip(server_info.ip_address) let server = ServerInfo( scheme: scheme, ip_address: ip_address, port: server_info.port, ) info_.set(subject, server) builder.on_start(server) started }) let glisten_child = supervision.supervisor(fn() { glisten_supervisor }) supervisor.new(supervisor.OneForAll) |> supervisor.add(glisten_child) |> supervisor.add(info_worker) |> supervisor.start() } /// Creates a supervisor that can be appended to a supervision tree. /// pub fn supervised( builder: Builder(Connection), ) -> supervision.ChildSpecification(supervisor.Supervisor) { supervision.supervisor(fn() { start(builder) }) } // REQUEST --------------------------------------------------------------------- /// Possible errors that can occur when reading a body. /// pub type BodyError { BodyTooLarge InvalidBody } /// Reads body from a request. If request body is malformed, `InvalidBody` /// error is returned. On success, returns a request with body converted to /// `BitArray`. /// - When `transfer-encoding` header set as `chunked`, `BodyTooLarge` error is returned if /// accumulated body is larger than `size_limit`. /// - Ensures that `content-length` is in `size_limit` scope. /// pub fn read_body( req: Request(Connection), size_limit size_limit: Int, ) -> Result(Request(BitArray), BodyError) { case http_.read_body(req, size_limit) { Ok(req) -> Ok(req) Error(http_.BodyTooLarge) -> Error(BodyTooLarge) Error(_) -> Error(InvalidBody) } } // WEBSOCKET ------------------------------------------------------------------- pub type WebsocketConnection = websocket_.WebsocketConnection type ExitReason { Normal Abnormal(reason: String) } /// Represents instruction on how WebSocket connection should proceed. /// /// - continue processing the WebSocket connection. /// - stop the WebSocket connection. /// - stop the WebSocket connection with abnormal reason. /// pub opaque type Next(user_state) { Continue(user_state) Stop(ExitReason) } /// Instructs WebSocket connection to continue processing. /// pub fn continue(user_state: user_state) -> Next(user_state) { Continue(user_state) } /// Instructs WebSocket connection to stop. /// pub fn stop() -> Next(user_state) { Stop(Normal) } /// Instructs WebSocket connection to stop with abnormal reason. /// pub fn stop_abnormal(reason: String) -> Next(user_state) { Stop(Abnormal(reason)) } fn to_internal_next(next: Next(user_state)) -> websocket_.Next(user_state) { case next { Continue(user_state) -> websocket_.Continue(user_state) Stop(Normal) -> websocket_.Stop(websocket_.Normal) Stop(Abnormal(reason)) -> websocket_.Stop(websocket_.Abnormal(reason)) } } /// Represents a WebSocket message received from the client. pub type WebsocketMessage { Text(String) Binary(BitArray) } fn transform_websocket_message(frame: ws.Frame) -> Result(WebsocketMessage, Nil) { case frame { ws.Data(ws.TextFrame(text)) -> bit_array.to_string(text) |> result.map(Text) ws.Data(ws.BinaryFrame(binary)) -> Ok(Binary(binary)) _ -> Error(Nil) } } /// Upgrade request to a WebSocket connection. If the initial request is not /// valid for WebSocket upgrade, 400 response is sent. Handler must return /// instruction on how WebSocket connection should proceed. /// pub fn upgrade_websocket( req: Request(Connection), on_init on_init: fn(WebsocketConnection) -> user_state, handler handler: fn(WebsocketConnection, user_state, WebsocketMessage) -> Next(user_state), ) -> Response(ResponseBody) { let handler = fn(conn, state, msg) { transform_websocket_message(msg) |> result.map(handler(conn, state, _)) |> result.unwrap(continue(state)) |> to_internal_next() } let transport = req.body.transport let socket = req.body.socket let resp = { use _ <- result.try( http_.upgrade_websocket(req, transport, socket) |> result.replace_error(response.new(400) |> response.set_body(Empty)), ) use selector <- result.try( websocket_.start(transport, socket, on_init, handler) |> result.replace_error(response.new(500) |> response.set_body(Empty)), ) response.new(500) |> response.set_body(WebsocketConnection(selector)) |> Ok } result.unwrap_both(resp) } /// Sends a binary frame to the websocket client. /// pub fn send_binary_frame( conn: WebsocketConnection, bits: BitArray, ) -> Result(Nil, glisten.SocketReason) { websocket_.send_binary_frame(conn.transport, conn.socket, bits) } /// Sends a text frame to the websocket client. /// pub fn send_text_frame( conn: WebsocketConnection, text: String, ) -> Result(Nil, glisten.SocketReason) { websocket_.send_text_frame(conn.transport, conn.socket, text) } // EXPERIMENTAL ---------------------------------------------------------------- /// Experimental function that simplifies error handling in handlers when /// working with `Result` type. /// /// ## Example /// /// ```gleam /// pub fn handle_echo( /// req: Request(ewe.Connection), /// ) -> Response(bytes_tree.BytesTree) { /// let content_type = /// request.get_header(req, "content-type") /// |> result.unwrap("text/plain") /// /// // Start the use_expression block /// use <- ewe.use_expression() /// /// // Now you can use result.try with use expressions /// // If any step fails, the error response is automatically returned /// use req <- result.try( /// ewe.read_body(req, 1024) /// |> result.replace_error( /// response.new(400) /// |> ewe.json(error_json("Invalid request body")), /// ), /// ) /// /// response.new(200) /// |> ewe.bits(req.body) /// |> response.set_header("content-type", content_type) /// |> Ok ///} /// ``` /// pub fn use_expression( handler: fn() -> Result(Response(ResponseBody), Response(ResponseBody)), ) -> Response(ResponseBody) { result.unwrap_both(handler()) }