diff --git a/examples/0-hello-world/src/app.gleam b/examples/0-hello-world/src/app.gleam index 2511536..51907f9 100644 --- a/examples/0-hello-world/src/app.gleam +++ b/examples/0-hello-world/src/app.gleam @@ -16,7 +16,7 @@ pub fn middleware( service: fn(wisp.Request) -> wisp.Response, ) -> wisp.Response { let req = wisp.method_override(req) - use <- wisp.log_requests(req) + use <- wisp.log_request(req) use <- wisp.rescue_crashes service(req) diff --git a/src/wisp.gleam b/src/wisp.gleam index eaf6b66..b2681cf 100644 --- a/src/wisp.gleam +++ b/src/wisp.gleam @@ -23,17 +23,30 @@ import mist // Running the server // -// TODO: test -// TODO: document +/// Convert a Wisp request handler into a function that can be run with the Mist +/// web server. +/// +/// # Examples +/// +/// ```gleam +/// pub fn main() { +/// let assert Ok(_) = +/// wisp.mist_service(handle_request) +/// |> mist.new +/// |> mist.port(8000) +/// |> mist.start_http +/// process.sleep_forever() +/// } +/// ``` pub fn mist_service( - service: fn(Request) -> Response, + handler: fn(Request) -> Response, ) -> fn(HttpRequest(mist.Connection)) -> HttpResponse(mist.ResponseData) { fn(request: HttpRequest(_)) { let connection = make_connection(mist_body_reader(request)) let request = request.set_body(request, connection) let response = request - |> service + |> handler |> mist_response // TODO: use some FFI to ensure this always happens, even if there is a crash @@ -90,43 +103,114 @@ fn mist_send_file(path: String) -> mist.ResponseData { // Responses // -pub type ResponseBody { - Empty - // TODO: remove content type - File(path: String) +/// The body of a HTTP response, to be sent to the client. +/// +pub type Body { + /// A body of unicode text. + /// + /// The body is represented using a `StringBuilder`. If you have a `String` + /// you can use the `string_builder.from_string` function to convert it. + /// Text(StringBuilder) + /// A body of the contents of a file. + /// + /// This will be sent efficiently using the `send_file` function of the + /// underlying HTTP server. The file will not be read into memory so it is + /// safe to send large files this way. + /// + File(path: String) + /// An empty body. This may be returned by the `require_*` middleware + /// functions in the event of a failure, invalid request, or other situation + /// in which the request cannot be processed. + /// + /// Your application may wish to use a middleware to provide default responses + /// in place of any with an empty body. + /// + Empty } -/// An alias for a HTTP response containing a `ResponseBody`. +/// An alias for a HTTP response containing a `Body`. pub type Response = - HttpResponse(ResponseBody) + HttpResponse(Body) -// TODO: document +/// Create an empty response with the given status code. +/// +/// # Examples +/// +/// ```gleam +/// response(200) +/// // -> Response(200, [], Empty) +/// ``` +/// pub fn response(status: Int) -> Response { HttpResponse(status, [], Empty) } -// TODO: test -// TODO: document -pub fn set_body(response: Response, body: ResponseBody) -> Response { +/// Set the body of a response. +/// +/// # Examples +/// +/// ```gleam +/// response(200) +/// |> set_body(File("/tmp/myfile.txt")) +/// // -> Response(200, [], File("/tmp/myfile.txt")) +/// ``` +/// +pub fn set_body(response: Response, body: Body) -> Response { response |> response.set_body(body) } -// TODO: test -// TODO: document +/// Create a HTML response. +/// +/// The body is expected to be valid HTML, though this is not validated. +/// The `content-type` header will be set to `text/html`. +/// +/// # Examples +/// +/// ```gleam +/// let body = string_builder.from_string("

Hello, Joe!

") +/// html_response(body, 200) +/// // -> Response(200, [#("content-type", "text/html")], Text(body)) +/// ``` +/// pub fn html_response(html: StringBuilder, status: Int) -> Response { HttpResponse(status, [#("content-type", "text/html")], Text(html)) } -// TODO: document +/// Set the body of a response to a given HTML document, and set the +/// `content-type` header to `text/html`. +/// +/// The body is expected to be valid HTML, though this is not validated. +/// +/// # Examples +/// +/// ```gleam +/// let body = string_builder.from_string("

Hello, Joe!

") +/// response(201) +/// |> html_body(body) +/// // -> Response(201, [#("content-type", "text/html")], Text(body)) +/// ``` +/// pub fn html_body(response: Response, html: StringBuilder) -> Response { response |> response.set_body(Text(html)) |> response.set_header("content-type", "text/html") } -// TODO: document +/// Create an empty response with status code 405: Method Not Allowed. Use this +/// when a request does not have an appropriate method to be handled. +/// +/// The `allow` header will be set to a comma separated list of the permitted +/// methods. +/// +/// # Examples +/// +/// ```gleam +/// method_not_allowed([Get, Post]) +/// // -> Response(405, [#("allow", "GET, POST")], Empty) +/// ``` +/// pub fn method_not_allowed(permitted: List(Method)) -> Response { let allowed = permitted @@ -137,42 +221,106 @@ pub fn method_not_allowed(permitted: List(Method)) -> Response { HttpResponse(405, [#("allow", allowed)], Empty) } -// TODO: document +/// Create an empty response with status code 200: OK. +/// +/// # Examples +/// +/// ```gleam +/// ok() +/// // -> Response(200, [], Empty) +/// ``` +/// pub fn ok() -> Response { HttpResponse(200, [], Empty) } -// TODO: document +/// Create an empty response with status code 201: Created. +/// +/// # Examples +/// +/// ```gleam +/// created() +/// // -> Response(201, [], Empty) +/// ``` +/// pub fn created() -> Response { HttpResponse(201, [], Empty) } -// TODO: document +/// Create an empty response with status code 202: Accepted. +/// +/// # Examples +/// +/// ```gleam +/// created() +/// // -> Response(202, [], Empty) +/// ``` +/// pub fn accepted() -> Response { HttpResponse(202, [], Empty) } -// TODO: document +/// Create an empty response with status code 204: No content. +/// +/// # Examples +/// +/// ```gleam +/// no_content() +/// // -> Response(204, [], Empty) +/// ``` +/// pub fn no_content() -> Response { HttpResponse(204, [], Empty) } -// TODO: document +/// Create an empty response with status code 404: No content. +/// +/// # Examples +/// +/// ```gleam +/// not_found() +/// // -> Response(404, [], Empty) +/// ``` +/// pub fn not_found() -> Response { HttpResponse(404, [], Empty) } -// TODO: document +/// Create an empty response with status code 400: Bad request. +/// +/// # Examples +/// +/// ```gleam +/// bad_request() +/// // -> Response(400, [], Empty) +/// ``` +/// pub fn bad_request() -> Response { HttpResponse(400, [], Empty) } -// TODO: document +/// Create an empty response with status code 413: Entity too large. +/// +/// # Examples +/// +/// ```gleam +/// entity_too_large() +/// // -> Response(413, [], Empty) +/// ``` +/// pub fn entity_too_large() -> Response { HttpResponse(413, [], Empty) } -// TODO: document +/// Create an empty response with status code 500: Internal server error. +/// +/// # Examples +/// +/// ```gleam +/// internal_server_error() +/// // -> Response(500, [], Empty) +/// ``` +/// pub fn internal_server_error() -> Response { HttpResponse(500, [], Empty) } @@ -181,6 +329,11 @@ pub fn internal_server_error() -> Response { // Requests // +/// The connection to the client for a HTTP request. +/// +/// The body of the request can be read from this connection using functions +/// such as `require_multipart_body`. +/// pub opaque type Connection { Connection( reader: Reader, @@ -245,43 +398,87 @@ type Read { ReadingFinished } -// TODO: document +/// Set the maximum permitted size of a request body of the request in bytes. +/// +/// If a body is larger than this size attempting to read the body will result +/// in a response with status code 413: Entity too large will be returned to the +/// client. +/// +/// This limit only applies for headers and bodies that get read into memory. +/// Part of a multipart body that contain files and so are streamed to disc +/// instead use the `max_files_size` limit. +/// pub fn set_max_body_size(request: Request, size: Int) -> Request { Connection(..request.body, max_body_size: size) |> request.set_body(request, _) } -// TODO: document +/// Get the maximum permitted size of a request body of the request in bytes. +/// pub fn get_max_body_size(request: Request) -> Int { request.body.max_body_size } -// TODO: document +/// Set the maximum permitted size of all files uploaded by a request, in bytes. +/// +/// If a request contains fails which are larger in total than this size +/// then attempting to read the body will result in a response with status code +/// 413: Entity too large will be returned to the client. +/// +/// This limit only applies for files in a multipart body that get streamed to +/// disc. For headers and other content that gets read into memory use the +/// `max_files_size` limit. +/// pub fn set_max_files_size(request: Request, size: Int) -> Request { Connection(..request.body, max_files_size: size) |> request.set_body(request, _) } -// TODO: document +/// Get the maximum permitted total size of a files uploaded by a request in +/// bytes. +/// pub fn get_max_files_size(request: Request) -> Int { request.body.max_files_size } -// TODO: document +/// The the size limit for each chunk of the request body when read from the +/// client. +/// +/// This value is passed to the underlying web server when reading the body and +/// the exact size of chunks read depends on the server implementation. It most +/// likely will read chunks smaller than this size if not yet enough data has +/// been received from the client. +/// pub fn set_read_chunk_size(request: Request, size: Int) -> Request { Connection(..request.body, read_chunk_size: size) |> request.set_body(request, _) } -// TODO: document +/// Get the size limit for each chunk of the request body when read from the +/// client. +/// pub fn get_read_chunk_size(request: Request) -> Int { request.body.read_chunk_size } +/// A convenient alias for a HTTP request with a Wisp connection as the body. +/// pub type Request = HttpRequest(Connection) -// TODO: document +/// This middleware function ensures that the request has a specific HTTP +/// method, returning an empty response with status code 405: Method not allowed +/// if the method is not correct. +/// +/// # Examples +/// +/// ```gleam +/// fn handle_request(request: Request) -> Response { +/// use <- wisp.require_method(request, http.Patch) +/// // ... +/// } +/// ``` +/// pub fn require_method( request: HttpRequest(t), method: Method, @@ -321,6 +518,14 @@ pub const path_segments = request.path_segments /// /// /// +/// # Examples +/// +/// ```gleam +/// fn handle_request(request: Request) -> Response { +/// let request = wisp.method_override(request) +/// // The method has now been overridden if appropriate +/// } +/// pub fn method_override(request: HttpRequest(a)) -> HttpRequest(a) { use <- bool.guard(when: request.method != http.Post, return: request) { @@ -342,6 +547,23 @@ pub fn method_override(request: HttpRequest(a)) -> HttpRequest(a) { // TODO: note you probably want a `require_` function // TODO: note it'll hang if you call it twice // TODO: note it respects the max body size +/// A middleware function which reads the entire body of the request as a string. +/// +/// If the body is larger than the `max_body_size` limit then an empty response +/// with status code 413: Entity too large will be returned to the client. +/// +/// If the body is found not to be valid UTF-8 then an empty response with +/// status code 400: Bad request will be returned to the client. +/// +/// # Examples +/// +/// ```gleam +/// fn handle_request(request: Request) -> Response { +/// use body <- wisp.require_string_body(request) +/// // ... +/// } +/// ``` +/// pub fn require_string_body( request: Request, next: fn(String) -> Response, @@ -601,7 +823,24 @@ fn sort_keys(pairs: List(#(String, t))) -> List(#(String, t)) { list.sort(pairs, fn(a, b) { string.compare(a.0, b.0) }) } -// TODO: document +// TODO: determine is this a good API. Perhaps the response should be +// parameterised? +/// A middleware function which returns an empty response with the status code +/// 400: Bad request if the result is an error. +/// +/// This function is similar to the `try` function of the `gleam/result` module, +/// except returning a HTTP response rather than the error when the result is +/// not OK. +/// +/// # Example +/// +/// ```gleam +/// fn handle_request(request: Request) -> Response { +/// use value <- wisp.request(result_returning_function()) +/// // ... +/// } +/// ``` +/// pub fn require( result: Result(value, error), next: fn(value) -> Response, @@ -612,15 +851,27 @@ pub fn require( } } +/// Data parsed from form sent in a request's body. +/// pub type FormData { FormData( + /// String values of the form's fields. values: List(#(String, String)), + /// Uploaded files. files: List(#(String, UploadedFile)), ) } pub type UploadedFile { - UploadedFile(file_name: String, path: String) + UploadedFile( + /// The name that was given to the file in the form. + /// This is user input and should not be trusted. + file_name: String, + /// The location of the file on the server. + /// This is a temporary file and will be deleted when the request has + /// finished being handled. + path: String, + ) } // @@ -741,9 +992,20 @@ fn extension_to_mime_type(extension: String) -> String { // Middleware // -// TODO: document -pub fn rescue_crashes(service: fn() -> Response) -> Response { - case erlang.rescue(service) { +/// A middleware function that rescues crashes and returns an empty response +/// with status code 500: Internal server error. +/// +/// # Examples +/// +/// ```gleam +/// fn handle_request(req: Request) -> Response { +/// use <- wisp.rescue_crashes +/// // ... +/// } +/// ``` +/// +pub fn rescue_crashes(handler: fn() -> Response) -> Response { + case erlang.rescue(handler) { Ok(response) -> response Error(error) -> { // TODO: log the error @@ -753,11 +1015,23 @@ pub fn rescue_crashes(service: fn() -> Response) -> Response { } } -// TODO: test -// TODO: document -// TODO: real implementation that uses the logger -pub fn log_requests(req: Request, service: fn() -> Response) -> Response { - let response = service() +// TODO: test, somehow. +/// A middleware function that logs details about the request and response. +/// +/// The format used logged by this middleware may change in future versions of +/// Wisp. +/// +/// # Examples +/// +/// ```gleam +/// fn handle_request(req: Request) -> Response { +/// use <- wisp.log_request(req) +/// // ... +/// } +/// ``` +/// +pub fn log_request(req: Request, handler: fn() -> Response) -> Response { + let response = handler() [ int.to_string(response.status), " ", @@ -766,6 +1040,7 @@ pub fn log_requests(req: Request, service: fn() -> Response) -> Response { req.path, ] |> string.concat + // TODO: use the logger |> io.println response } @@ -786,12 +1061,40 @@ fn join_path(a: String, b: String) -> String { } } -// TODO: document +/// A middleware function that serves files from a directory, along with a +/// suitable `content-type` header for known file extensions. +/// +/// Files are sent using the `File` response body type, so they will be sent +/// directly to the client from the disc, without being read into memory. +/// +/// The `under` parameter is the request path prefix that must match for the +/// file to be served. +/// +/// | `under` | `from` | `request.path` | `file` | +/// |-----------|---------|--------------------|-------------------------| +/// | `/static` | `/data` | `/static/file.txt` | `/data/file.txt` | +/// | `` | `/data` | `/static/file.txt` | `/data/static/file.txt` | +/// | `/static` | `` | `/static/file.txt` | `file.txt` | +/// +/// This middleware will discard any `..` path segments in the request path to +/// prevent the client from accessing files outside of the directory. It is +/// advised not to serve a directory that contains your source code, application +/// configuration, database, or other private files. +/// +/// # Examples +/// +/// ```gleam +/// fn handle_request(req: Request) -> Response { +/// use <- wisp.log_request(req) +/// // ... +/// } +/// ``` +/// pub fn serve_static( req: Request, under prefix: String, from directory: String, - next service: fn() -> Response, + next handler: fn() -> Response, ) -> Response { let path = remove_preceeding_slashes(req.path) let prefix = remove_preceeding_slashes(prefix) @@ -812,14 +1115,14 @@ pub fn serve_static( // TODO: better check for file existence. case file_info(path) { - Error(_) -> service() + Error(_) -> handler() Ok(_) -> response.new(200) |> response.set_header("content-type", mime_type) |> response.set_body(File(path)) } } - _, _ -> service() + _, _ -> handler() } } @@ -835,10 +1138,13 @@ fn make_directory(path: String) -> Result(Nil, simplifile.FileError) { } } -// TODO: test -// TODO: document -// TODO: document that you need to call `remove_temporary_files` when you're -// done, unless you're using `mist_service` which will do it for you. +/// Create a new temporary directory for the given request. +/// +/// If you are using the `mist_service` function or another compliant web server +/// adapter then this file will be deleted for you when the request is complete. +/// Otherwise you will need to call the `delete_temporary_files` function +/// yourself. +/// pub fn new_temporary_file( request: Request, ) -> Result(String, simplifile.FileError) { @@ -854,8 +1160,12 @@ pub fn new_temporary_file( @external(erlang, "file", "del_dir_r") fn del_dir(path: String) -> Dynamic -// TODO: test -// TODO: document +/// Delete any temporary files created for the given request. +/// +/// If you are using the `mist_service` function or another compliant web server +/// adapter then this file will be deleted for you when the request is complete. +/// Otherwise you will need to call this function yourself. +/// pub fn delete_temporary_files( request: Request, ) -> Result(Nil, simplifile.FileError) { @@ -874,10 +1184,8 @@ fn file_info(path: String) -> Result(Dynamic, Dynamic) // Cryptography // -pub const strong_random_bytes = crypto.strong_random_bytes - fn random_slug() -> String { - strong_random_bytes(16) + crypto.strong_random_bytes(16) |> base.url_encode64(False) } @@ -904,7 +1212,7 @@ pub fn test_request(body: BitString) -> Request { // TODO: test // TODO: document -pub fn body_to_string_builder(body: ResponseBody) -> StringBuilder { +pub fn body_to_string_builder(body: Body) -> StringBuilder { case body { Empty -> string_builder.new() Text(text) -> text @@ -917,7 +1225,7 @@ pub fn body_to_string_builder(body: ResponseBody) -> StringBuilder { // TODO: test // TODO: document -pub fn body_to_bit_builder(body: ResponseBody) -> BitBuilder { +pub fn body_to_bit_builder(body: Body) -> BitBuilder { case body { Empty -> bit_builder.new() Text(text) -> bit_builder.from_string_builder(text)