"
12 |
13 | // Return a 200 OK response with the body and a HTML content type.
14 | wisp.html_response(body, 200)
15 | }
16 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | test-action:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - uses: erlef/setup-beam@v1
15 | with:
16 | otp-version: "28"
17 | gleam-version: "1.12.0-rc1"
18 | rebar3-version: "3"
19 |
20 | - name: Test Wisp
21 | run: gleam test
22 |
23 | - name: Test examples
24 | run: gleam test
25 | working-directory: examples
26 |
--------------------------------------------------------------------------------
/examples/src/logging/README.md:
--------------------------------------------------------------------------------
1 | # Wisp Example: Logging
2 |
3 | ```sh
4 | gleam run -m logging/app # Run the server
5 | ```
6 |
7 | This example shows how to log messages using the BEAM logger.
8 |
9 | This example is based off of the ["routing" example][routing], so read that
10 | one first. The additions are detailed here and commented in the code.
11 |
12 | [routing]: [examples/src/hello_world](./../routing/)
13 |
14 | ### `app/router` module
15 |
16 | The `handle_request` function now logs messages depending on the request.
17 |
18 | ### Other files
19 |
20 | No changes have been made to the other files.
21 |
--------------------------------------------------------------------------------
/examples/src/serving_static_assets/app/web.gleam:
--------------------------------------------------------------------------------
1 | import wisp
2 |
3 | pub type Context {
4 | Context(static_directory: String)
5 | }
6 |
7 | pub fn middleware(
8 | req: wisp.Request,
9 | ctx: Context,
10 | handle_request: fn(wisp.Request) -> wisp.Response,
11 | ) -> wisp.Response {
12 | let req = wisp.method_override(req)
13 | use <- wisp.log_request(req)
14 | use <- wisp.rescue_crashes
15 | use req <- wisp.handle_head(req)
16 | use req <- wisp.csrf_known_header_protection(req)
17 | use <- wisp.serve_static(req, under: "/static", from: ctx.static_directory)
18 |
19 | handle_request(req)
20 | }
21 |
--------------------------------------------------------------------------------
/examples/src/serving_static_assets/app/router.gleam:
--------------------------------------------------------------------------------
1 | import serving_static_assets/app/web.{type Context}
2 | import wisp.{type Request, type Response}
3 |
4 | const html = "
5 |
6 |
7 |
8 | Wisp Example
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | "
17 |
18 | pub fn handle_request(req: Request, ctx: Context) -> Response {
19 | use _req <- web.middleware(req, ctx)
20 | wisp.html_response(html, 200)
21 | }
22 |
--------------------------------------------------------------------------------
/examples/utilities/tiny_database/test/tiny_database_test.gleam:
--------------------------------------------------------------------------------
1 | import gleam/dict
2 | import gleeunit
3 | import tiny_database
4 |
5 | pub fn main() {
6 | gleeunit.main()
7 | }
8 |
9 | pub fn insert_read_test() {
10 | let connection = tiny_database.connect("tmp/data")
11 |
12 | let data = dict.from_list([#("name", "Alice"), #("profession", "Programmer")])
13 |
14 | let assert Ok(Nil) = tiny_database.truncate(connection)
15 | let assert Ok([]) = tiny_database.list(connection)
16 | let assert Ok(id) = tiny_database.insert(connection, data)
17 |
18 | let assert Ok(read) = tiny_database.read(connection, id)
19 | assert read == data
20 |
21 | let assert Ok([single]) = tiny_database.list(connection)
22 | assert single == id
23 | }
24 |
--------------------------------------------------------------------------------
/examples/src/using_a_database/app/router.gleam:
--------------------------------------------------------------------------------
1 | import using_a_database/app/web.{type Context}
2 | import using_a_database/app/web/people
3 | import wisp.{type Request, type Response}
4 |
5 | pub fn handle_request(req: Request, ctx: Context) -> Response {
6 | use req <- web.middleware(req)
7 |
8 | // A new `app/web/people` module now contains the handlers and other functions
9 | // relating to the People feature of the application.
10 | //
11 | // The router module now only deals with routing, and dispatches to the
12 | // feature modules for handling requests.
13 | //
14 | case wisp.path_segments(req) {
15 | ["people"] -> people.all(req, ctx)
16 | ["people", id] -> people.one(req, ctx, id)
17 | _ -> wisp.not_found()
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/src/working_with_files/README.md:
--------------------------------------------------------------------------------
1 | # Wisp Example: Working with files
2 |
3 | ```sh
4 | gleam run -m working_with_files/app # Run the server
5 | ```
6 |
7 | This example shows how to accept file uploads and allow users to download files.
8 |
9 | This example is based off of the ["working with form data" example][form_data],
10 | so read that first. The additions are detailed here and commented in the code.
11 |
12 | [form_data]: [examples/src/working_with_form_data](./../working_with_form_data/)
13 |
14 | ### `app/router` module
15 |
16 | The `handle_request` function has been updated to upload and download files.
17 |
18 | ### Unit tests [examples/test/working_with_files/](../../test/working_with_files/)
19 |
20 | Tests have been added that upload and download files to verify the behaviour.
21 |
22 | ### Other files
23 |
24 | No changes have been made to the other files.
25 |
--------------------------------------------------------------------------------
/examples/src/using_a_database/app/web.gleam:
--------------------------------------------------------------------------------
1 | import tiny_database
2 | import wisp
3 |
4 | // A new Context type, which holds any additional data that the request handlers
5 | // need in addition to the request.
6 | //
7 | // Here it is holding a database connection, but it could hold anything else
8 | // such as API keys, IO performing functions (so they can be swapped out in
9 | // tests for mock implementations), configuration, and so on.
10 | //
11 | pub type Context {
12 | Context(db: tiny_database.Connection)
13 | }
14 |
15 | pub fn middleware(
16 | req: wisp.Request,
17 | handle_request: fn(wisp.Request) -> wisp.Response,
18 | ) -> wisp.Response {
19 | let req = wisp.method_override(req)
20 | use <- wisp.log_request(req)
21 | use <- wisp.rescue_crashes
22 | use req <- wisp.handle_head(req)
23 | use req <- wisp.csrf_known_header_protection(req)
24 |
25 | handle_request(req)
26 | }
27 |
--------------------------------------------------------------------------------
/examples/src/hello_world/app.gleam:
--------------------------------------------------------------------------------
1 | import gleam/erlang/process
2 | import hello_world/app/router
3 | import mist
4 | import wisp
5 | import wisp/wisp_mist
6 |
7 | pub fn main() {
8 | // This sets the logger to print INFO level logs, and other sensible defaults
9 | // for a web application.
10 | wisp.configure_logger()
11 |
12 | // Here we generate a secret key, but in a real application you would want to
13 | // load this from somewhere so that it is not regenerated on every restart.
14 | let secret_key_base = wisp.random_string(64)
15 |
16 | // Start the Mist web server.
17 | let assert Ok(_) =
18 | wisp_mist.handler(router.handle_request, secret_key_base)
19 | |> mist.new
20 | |> mist.port(8000)
21 | |> mist.start
22 |
23 | // The web server runs in new Erlang process, so put this one to sleep while
24 | // it works concurrently.
25 | process.sleep_forever()
26 | }
27 |
--------------------------------------------------------------------------------
/gleam.toml:
--------------------------------------------------------------------------------
1 | name = "wisp"
2 | version = "2.1.1"
3 | gleam = ">= 1.11.0"
4 | description = "A practical web framework for Gleam"
5 | licences = ["Apache-2.0"]
6 |
7 | repository = { type = "github", user = "gleam-wisp", repo = "wisp" }
8 | links = [{ title = "Sponsor", href = "https://github.com/sponsors/lpil" }]
9 |
10 | [dependencies]
11 | exception = ">= 2.0.0 and < 3.0.0"
12 | gleam_crypto = ">= 1.0.0 and < 2.0.0"
13 | gleam_erlang = ">= 1.0.0 and < 2.0.0"
14 | gleam_http = ">= 3.5.0 and < 5.0.0"
15 | gleam_json = ">= 3.0.0 and < 4.0.0"
16 | gleam_stdlib = ">= 0.50.0 and < 2.0.0"
17 | mist = ">= 2.0.0 and < 6.0.0"
18 | simplifile = ">= 2.0.0 and < 3.0.0"
19 | marceau = ">= 1.1.0 and < 2.0.0"
20 | logging = ">= 1.2.0 and < 2.0.0"
21 | directories = ">= 1.0.0 and < 2.0.0"
22 | houdini = ">= 1.0.0 and < 2.0.0"
23 | filepath = ">= 1.1.2 and < 2.0.0"
24 |
25 | [dev-dependencies]
26 | gleeunit = ">= 1.0.0 and < 2.0.0"
27 |
--------------------------------------------------------------------------------
/examples/src/routing/README.md:
--------------------------------------------------------------------------------
1 | # Wisp Example: Routing
2 |
3 | ```sh
4 | gleam run -m routing/app # Run the server
5 | ```
6 |
7 | This example shows how to route requests to different handlers based on the
8 | request path and method.
9 |
10 | This example is based off of the ["Hello, World!" example][hello], so read that
11 | one first. The additions are detailed here and commented in the code.
12 |
13 | [hello]: [examples/src/hello_world](./../hello_world/)
14 |
15 | ### `app/router` module
16 |
17 | The `handle_request` function now pattern matches on the request and calls other
18 | request handler functions depending on where the request should go.
19 |
20 | ### Unit tests [examples/test/routing/](../../test/routing/)
21 |
22 | Tests have been added for each of the routes. The `wisp/testing` module is used
23 | to create different requests to test the application with.
24 |
25 | ### Other files
26 |
27 | No changes have been made to the other files.
28 |
--------------------------------------------------------------------------------
/examples/src/logging/app/router.gleam:
--------------------------------------------------------------------------------
1 | import logging/app/web
2 | import wisp.{type Request, type Response}
3 |
4 | // Wisp has functions for logging messages using the BEAM logger.
5 | //
6 | // Messages can be logged at different levels. From most important to least
7 | // important they are:
8 | // - emergency
9 | // - alert
10 | // - critical
11 | // - error
12 | // - warning
13 | // - notice
14 | // - info
15 | // - debug
16 | //
17 | pub fn handle_request(req: Request) -> Response {
18 | use req <- web.middleware(req)
19 |
20 | case wisp.path_segments(req) {
21 | [] -> {
22 | wisp.log_debug("The home page")
23 | wisp.ok()
24 | }
25 |
26 | ["about"] -> {
27 | wisp.log_info("They're reading about us")
28 | wisp.ok()
29 | }
30 |
31 | ["secret"] -> {
32 | wisp.log_error("The secret page was found!")
33 | wisp.ok()
34 | }
35 |
36 | _ -> {
37 | wisp.log_warning("User requested a route that does not exist")
38 | wisp.not_found()
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/examples/src/hello_world/README.md:
--------------------------------------------------------------------------------
1 | # Wisp Example: Hello, world!
2 |
3 | ```sh
4 | gleam run -m hello_world/app # Run the server
5 | ```
6 |
7 | This example shows a minimal Wisp application, it does nothing but respond with
8 | a greeting to any request.
9 |
10 | The project has this structure:
11 |
12 | ```
13 | ├─ app
14 | │ ├─ router.gleam
15 | │ └─ web.gleam
16 | └─ app.gleam
17 | ```
18 |
19 | In your project `app` would be replaced by the name of your application.
20 |
21 | ### `app` module
22 |
23 | The entrypoint to the application. It performs initialisation and starts the
24 | web server.
25 |
26 | ### `app/web` module
27 |
28 | This module contains the application's middleware stack and any custom types,
29 | middleware, and other functions that are used by the request handlers.
30 |
31 | ### `app/router` module
32 |
33 | This module contains the application's request handlers. Or "handler" in this
34 | case, as there's only one!
35 |
36 | ### Unit tests [examples/test/hello_world/](../../test/hello_world/)
37 |
38 | The tests for the application.
39 |
--------------------------------------------------------------------------------
/examples/src/working_with_form_data/README.md:
--------------------------------------------------------------------------------
1 | # Wisp Example: Working with form data
2 |
3 | ```sh
4 | gleam run -m working_with_form_data/app # Run the server
5 | ```
6 |
7 | This example shows how to read urlencoded and multipart formdata from a request
8 |
9 | This example is based off of the ["Hello, World!" example][hello], and uses
10 | concepts from the [routing example][routing] so read those first. The additions
11 | are detailed here and commented in the code.
12 |
13 | [hello]: [examples/src/hello_world](./../hello_world/)
14 | [routing]: [examples/src/hello_world](./../routing/)
15 |
16 | ### `app/router` module
17 |
18 | The `handle_request` function has been updated to read the form data from the
19 | request body and make use of values from it.
20 |
21 | ### Unit tests [examples/test/working_with_form_data/](../../test/working_with_form_data/)
22 |
23 | Tests have been added that send requests with form data bodies and check that
24 | the expected response is returned.
25 |
26 | ### Other files
27 |
28 | No changes have been made to the other files.
29 |
--------------------------------------------------------------------------------
/examples/src/using_a_database/app.gleam:
--------------------------------------------------------------------------------
1 | import gleam/erlang/process
2 | import mist
3 | import tiny_database
4 | import using_a_database/app/router
5 | import using_a_database/app/web
6 | import wisp
7 | import wisp/wisp_mist
8 |
9 | pub const data_directory = "tmp/data"
10 |
11 | pub fn main() {
12 | wisp.configure_logger()
13 | let secret_key_base = wisp.random_string(64)
14 |
15 | // A database creation is created here, when the program starts.
16 | // This connection is used by all requests.
17 | use db <- tiny_database.with_connection(data_directory)
18 |
19 | // A context is constructed to hold the database connection.
20 | let context = web.Context(db: db)
21 |
22 | // The handle_request function is partially applied with the context to make
23 | // the request handler function that only takes a request.
24 | let handler = router.handle_request(_, context)
25 |
26 | let assert Ok(_) =
27 | handler
28 | |> wisp_mist.handler(secret_key_base)
29 | |> mist.new
30 | |> mist.port(8000)
31 | |> mist.start
32 |
33 | process.sleep_forever()
34 | }
35 |
--------------------------------------------------------------------------------
/examples/test/working_with_other_formats/app_test.gleam:
--------------------------------------------------------------------------------
1 | import gleam/http
2 | import wisp/simulate
3 | import working_with_other_formats/app/router
4 |
5 | pub fn get_test() {
6 | let response = router.handle_request(simulate.browser_request(http.Get, "/"))
7 |
8 | assert response.status == 405
9 | }
10 |
11 | pub fn post_wrong_content_type_test() {
12 | let response = router.handle_request(simulate.browser_request(http.Post, "/"))
13 |
14 | assert response.status == 415
15 |
16 | assert response.headers
17 | == [#("accept", "text/csv"), #("content-type", "text/plain")]
18 | }
19 |
20 | pub fn post_successful_test() {
21 | let csv = "name,is-cool\nJoe,true\nJosé,true\n"
22 |
23 | let response =
24 | simulate.browser_request(http.Post, "/")
25 | |> simulate.string_body(csv)
26 | |> simulate.header("content-type", "text/csv")
27 | |> router.handle_request()
28 |
29 | assert response.status == 200
30 |
31 | assert response.headers == [#("content-type", "text/csv")]
32 |
33 | assert simulate.read_body(response)
34 | == "headers,row-count\n\"name,is-cool\",2\n"
35 | }
36 |
--------------------------------------------------------------------------------
/examples/src/working_with_json/README.md:
--------------------------------------------------------------------------------
1 | # Wisp Example: Working with JSON
2 |
3 | ```sh
4 | gleam run -m working_with_json/app # Run the server
5 | ```
6 |
7 | This example shows how to read JSON from a request and return JSON in the
8 | response.
9 |
10 | This example is based off of the ["Hello, World!" example][hello], and uses
11 | concepts from the [routing example][routing] so read those first. The additions
12 | are detailed here and commented in the code.
13 |
14 | [hello]: [examples/src/hello_world](./../hello_world/)
15 | [routing]: [examples/src/hello_world](./../routing/)
16 |
17 | ### [`gleam.toml`](../../gleam.toml) file
18 |
19 | The `gleam_json` JSON package has been added as a dependency.
20 |
21 | ### `app/router` module
22 |
23 | The `handle_request` function has been updated to read JSON from the
24 | request body, decode it using the Gleam standard library, and return JSON
25 | back to the client.
26 |
27 | ### Unit tests [examples/test/working_with_json/](../../test/working_with_json/)
28 |
29 | Tests have been added that send requests with JSON bodies and check that the
30 | expected response is returned.
31 |
32 | ### Other files
33 |
34 | No changes have been made to the other files.
35 |
--------------------------------------------------------------------------------
/examples/src/serving_static_assets/app.gleam:
--------------------------------------------------------------------------------
1 | import gleam/erlang/process
2 | import mist
3 | import serving_static_assets/app/router
4 | import serving_static_assets/app/web.{Context}
5 | import wisp
6 | import wisp/wisp_mist
7 |
8 | pub fn main() {
9 | wisp.configure_logger()
10 | let secret_key_base = wisp.random_string(64)
11 |
12 | // A context is constructed holding the static directory path.
13 | let ctx = Context(static_directory: static_directory())
14 |
15 | // The handle_request function is partially applied with the context to make
16 | // the request handler function that only takes a request.
17 | let handler = router.handle_request(_, ctx)
18 |
19 | let assert Ok(_) =
20 | wisp_mist.handler(handler, secret_key_base)
21 | |> mist.new
22 | |> mist.port(8000)
23 | |> mist.start
24 |
25 | process.sleep_forever()
26 | }
27 |
28 | pub fn static_directory() -> String {
29 | // The priv directory is where we store non-Gleam and non-Erlang files,
30 | // including static assets to be served.
31 | // This function returns an absolute path and works both in development and in
32 | // production after compilation.
33 | let assert Ok(priv_directory) = wisp.priv_directory("examples")
34 | priv_directory <> "/static"
35 | }
36 |
--------------------------------------------------------------------------------
/examples/src/hello_world/app/web.gleam:
--------------------------------------------------------------------------------
1 | import wisp
2 |
3 | /// The middleware stack that the request handler uses. The stack is itself a
4 | /// middleware function!
5 | ///
6 | /// Middleware wrap each other, so the request travels through the stack from
7 | /// top to bottom until it reaches the request handler, at which point the
8 | /// response travels back up through the stack.
9 | ///
10 | /// The middleware used here are the ones that are suitable for use in your
11 | /// typical web application.
12 | ///
13 | pub fn middleware(
14 | req: wisp.Request,
15 | handle_request: fn(wisp.Request) -> wisp.Response,
16 | ) -> wisp.Response {
17 | // Permit browsers to simulate methods other than GET and POST using the
18 | // `_method` query parameter.
19 | let req = wisp.method_override(req)
20 |
21 | // Log information about the request and response.
22 | use <- wisp.log_request(req)
23 |
24 | // Return a default 500 response if the request handler crashes.
25 | use <- wisp.rescue_crashes
26 |
27 | // Rewrite HEAD requests to GET requests and return an empty body.
28 | use req <- wisp.handle_head(req)
29 |
30 | // Known-header based CSRF protection for non-HEAD/GET requests
31 | use req <- wisp.csrf_known_header_protection(req)
32 |
33 | // Handle the request!
34 | handle_request(req)
35 | }
36 |
--------------------------------------------------------------------------------
/examples/src/working_with_other_formats/README.md:
--------------------------------------------------------------------------------
1 | # Wisp Example: Working with other formats
2 |
3 | ```sh
4 | gleam run -m working_with_other_formats/app # Run the server
5 | ```
6 | This example shows how to read and return formats that do not have special
7 | support in Wisp. In this case we'll use CSV, but the same techniques can be used
8 | for any format.
9 |
10 | This example is based off of the ["Hello, World!" example][hello], and uses
11 | concepts from the [routing example][routing] so read those first. The additions
12 | are detailed here and commented in the code.
13 |
14 | [hello]: [examples/src/hello_world](./../hello_world/)
15 | [routing]: [examples/src/hello_world](./../routing/)
16 |
17 | ### [`gleam.toml`](../../gleam.toml) file
18 |
19 | The `gsv` CSV package has been added as a dependency.
20 |
21 | ### `app/router` module
22 |
23 | The `handle_request` function has been updated to read a string from the
24 | request body, decode it using the `gsv` library, and return some CSV data
25 | back to the client.
26 |
27 | ### Unit tests [examples/test/working_with_other_formats/](../../test/working_with_other_formats/)
28 |
29 | Tests have been added that send requests with CSV bodies and check that the
30 | expected response is returned.
31 |
32 | ### Other files
33 |
34 | No changes have been made to the other files.
35 |
--------------------------------------------------------------------------------
/examples/src/working_with_cookies/README.md:
--------------------------------------------------------------------------------
1 | # Wisp Example: Working with cookies
2 |
3 | ```sh
4 | gleam run -m working_with_form_data/app # Run the server
5 | ```
6 |
7 | This example shows how to read and write cookies, and how to sign cookies so
8 | they cannot be tampered with.
9 |
10 | This example is based off of the ["working with form data" example][form_data] so read that one
11 | first. The additions are detailed here and commented in the code.
12 |
13 | Signing of cookies uses the `secret_key_base` value. If this value changes then
14 | the application will not be able to verify previously signed cookies, and if
15 | someone gains access to the secret key they will be able to forge cookies. This
16 | example application generates a random string in `app.gleam`, but in a real
17 | application you will need to read this secret value from somewhere secure.
18 |
19 | [form_data]: [examples/src/working_with_form_data](./../working_with_form_data/)
20 |
21 | ### `app/router` module
22 |
23 | The `handle_request` function has been updated to read and write cookies.
24 |
25 | ### Unit tests [examples/test/working_with_cookies/](../../test/working_with_cookies/)
26 |
27 | Tests have been added to test that cookies are handled correctly, and to create signed cookies for test requests.
28 |
29 | ### Other files
30 |
31 | No changes have been made to the other files.
32 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | # Wisp Examples
2 |
3 | For each example, you have its respective [module name](https://tour.gleam.run/basics/modules/).
4 | You can find the source associated to the example under `./src/$MODULE_NAME`,
5 | and its tests under `./test/$MODULE_NAME`.
6 |
7 | To run an example, you can run the following:
8 |
9 | ```sh
10 | # replace $MODULE_NAME with the name of the module associated to each example
11 | gleam run -m $MODULE_NAME/app
12 | ```
13 |
14 | To run the tests, do the following:
15 |
16 | ```sh
17 | gleam test
18 | ```
19 |
20 | If you would like to use these tests in your project, make sure to change the
21 | `app` keyword to the name of your project.
22 |
23 | ## Examples
24 |
25 | Here is a list of all the examples and their associated module name (formatted
26 | "`$MODULE_NAME` - Example title"):
27 |
28 | - [`hello_world` - Hello, World!](./src/hello_world)
29 | - [`routing` - Routing](./src/routing)
30 | - [`working_with_form_data` - Working with form data](./src/working_with_form_data)
31 | - [`working_with_json` - Working with JSON](./src/working_with_json)
32 | - [`working_with_other_formats` - Working with other formats](./src/working_with_other_formats)
33 | - [`using_a_database` - Using a database](./src/using_a_database)
34 | - [`serving_static_assets` - Serving static assets](./src/serving_static_assets)
35 | - [`logging` - Logging](./src/logging)
36 | - [`working_with_cookies` - Working with cookies](./src/working_with_cookies)
37 | - [`working_with_files` - Working with files](./src/working_with_files)
38 |
--------------------------------------------------------------------------------
/examples/test/serving_static_assets/app_test.gleam:
--------------------------------------------------------------------------------
1 | import gleam/http
2 | import gleam/list
3 | import serving_static_assets/app
4 | import serving_static_assets/app/router
5 | import serving_static_assets/app/web.{type Context, Context}
6 | import wisp/simulate
7 |
8 | fn with_context(testcase: fn(Context) -> t) -> t {
9 | // Create the context to use in tests
10 | let context = Context(static_directory: app.static_directory())
11 |
12 | // Run the test with the context
13 | testcase(context)
14 | }
15 |
16 | pub fn get_home_page_test() {
17 | use ctx <- with_context
18 | let request = simulate.browser_request(http.Get, "/")
19 | let response = router.handle_request(request, ctx)
20 |
21 | assert response.status == 200
22 |
23 | assert response.headers == [#("content-type", "text/html; charset=utf-8")]
24 | }
25 |
26 | pub fn get_stylesheet_test() {
27 | use ctx <- with_context
28 | let request = simulate.browser_request(http.Get, "/static/styles.css")
29 | let response = router.handle_request(request, ctx)
30 |
31 | assert response.status == 200
32 |
33 | assert list.key_find(response.headers, "content-type")
34 | == Ok("text/css; charset=utf-8")
35 | }
36 |
37 | pub fn get_javascript_test() {
38 | use ctx <- with_context
39 | let request = simulate.browser_request(http.Get, "/static/main.js")
40 | let response = router.handle_request(request, ctx)
41 |
42 | assert response.status == 200
43 |
44 | assert list.key_find(response.headers, "content-type")
45 | == Ok("text/javascript; charset=utf-8")
46 | }
47 |
--------------------------------------------------------------------------------
/examples/src/serving_static_assets/README.md:
--------------------------------------------------------------------------------
1 | # Wisp Example: Serving static assets
2 |
3 | ```sh
4 | gleam run -m serving_static_assets/app # Run the server
5 | ```
6 |
7 | This example shows how to serve static assets. In this case we'll serve
8 | a CSS file for page styling and a JavaScript file for updating the content
9 | of the HTML page, but the same techniques can also be used for other file types.
10 |
11 | This example is based off of the ["Hello, World!" example][hello], so read that
12 | one first. The additions are detailed here and commented in the code.
13 |
14 | [hello]: [examples/src/hello_world](./../hello_world/)
15 |
16 | ### [`priv/static`](../../priv/static/) directory
17 |
18 | This directory contains the static assets that will be served by the application.
19 |
20 | ### `app/web` module
21 |
22 | A `Context` type has been defined to hold the path to the directory containing
23 | the static assets.
24 |
25 | The `serve_static` middleware has been added to the middleware stack to serve
26 | the static assets.
27 |
28 | ### `app` module
29 |
30 | The `main` function now starts by determining the path to the static assets
31 | directory and constructs a `Context` record to pass to the handler function.
32 |
33 | ### `app/router` module
34 |
35 | The `handle_request` function now returns a page of HTML.
36 |
37 | ### Unit tests [examples/test/serving_static_assets/](../../test/serving_static_assets/)
38 |
39 | Tests have been added to ensure that the static assets are served correctly.
40 |
41 | ### Other files
42 |
43 | No changes have been made to the other files.
44 |
--------------------------------------------------------------------------------
/examples/test/working_with_json/app_test.gleam:
--------------------------------------------------------------------------------
1 | import gleam/http
2 | import gleam/json
3 | import wisp/simulate
4 | import working_with_json/app/router
5 |
6 | pub fn get_test() {
7 | let response = router.handle_request(simulate.browser_request(http.Get, "/"))
8 |
9 | assert response.status == 405
10 | }
11 |
12 | pub fn submit_wrong_content_type_test() {
13 | let response = router.handle_request(simulate.browser_request(http.Post, "/"))
14 |
15 | assert response.status == 415
16 |
17 | assert response.headers
18 | == [#("accept", "application/json"), #("content-type", "text/plain")]
19 | }
20 |
21 | pub fn submit_missing_parameters_test() {
22 | let json = json.object([#("name", json.string("Joe"))])
23 |
24 | // The `METHOD_json` functions are used to create a request with a JSON body,
25 | // with the appropriate `content-type` header.
26 | let response =
27 | simulate.browser_request(http.Post, "/")
28 | |> simulate.json_body(json)
29 | |> router.handle_request()
30 |
31 | assert response.status == 422
32 | }
33 |
34 | pub fn submit_successful_test() {
35 | let json =
36 | json.object([#("name", json.string("Joe")), #("is-cool", json.bool(True))])
37 | let response =
38 | simulate.browser_request(http.Post, "/")
39 | |> simulate.json_body(json)
40 | |> router.handle_request()
41 |
42 | assert response.status == 201
43 |
44 | assert response.headers
45 | == [#("content-type", "application/json; charset=utf-8")]
46 |
47 | assert simulate.read_body(response)
48 | == "{\"name\":\"Joe\",\"is-cool\":true,\"saved\":true}"
49 | }
50 |
--------------------------------------------------------------------------------
/examples/test/working_with_cookies/app_test.gleam:
--------------------------------------------------------------------------------
1 | import gleam/crypto
2 | import gleam/http
3 | import gleam/list
4 | import gleam/string
5 | import wisp
6 | import wisp/simulate
7 | import working_with_cookies/app/router
8 |
9 | pub fn home_not_logged_in_test() {
10 | let response = router.handle_request(simulate.browser_request(http.Get, "/"))
11 |
12 | assert response.status == 303
13 |
14 | assert response.headers
15 | == [#("location", "/session"), #("content-type", "text/plain")]
16 | }
17 |
18 | pub fn home_logged_in_test() {
19 | let response =
20 | simulate.browser_request(http.Get, "/")
21 | |> simulate.cookie("id", "Tim", wisp.Signed)
22 | |> router.handle_request
23 |
24 | assert response.status == 200
25 |
26 | assert response
27 | |> simulate.read_body
28 | |> string.contains("Hello, Tim!")
29 | == True
30 | }
31 |
32 | pub fn new_session_test() {
33 | let response =
34 | router.handle_request(simulate.browser_request(http.Get, "/session"))
35 |
36 | assert response.status == 200
37 |
38 | assert response
39 | |> simulate.read_body
40 | |> string.contains("Log in")
41 | == True
42 | }
43 |
44 | pub fn create_session_test() {
45 | let request =
46 | simulate.browser_request(http.Post, "/session")
47 | |> simulate.form_body([#("name", "Tim")])
48 | let response = router.handle_request(request)
49 |
50 | assert response.status == 303
51 |
52 | let assert Ok(cookie) = list.key_find(response.headers, "set-cookie")
53 |
54 | let signed = wisp.sign_message(request, <<"Tim":utf8>>, crypto.Sha512)
55 | assert string.starts_with(cookie, "id=" <> signed) == True
56 | }
57 |
--------------------------------------------------------------------------------
/examples/test/working_with_form_data/app_test.gleam:
--------------------------------------------------------------------------------
1 | import gleam/http
2 | import gleam/string
3 | import wisp/simulate
4 | import working_with_form_data/app/router
5 |
6 | pub fn view_form_test() {
7 | let response = router.handle_request(simulate.browser_request(http.Get, "/"))
8 |
9 | assert response.status == 200
10 |
11 | assert response.headers == [#("content-type", "text/html; charset=utf-8")]
12 |
13 | assert response
14 | |> simulate.read_body
15 | |> string.contains("",
27 | ]
28 | |> string.concat
29 | |> wisp.html_response(200)
30 | }
31 | Error(_) -> {
32 | wisp.redirect("/session")
33 | }
34 | }
35 | }
36 |
37 | pub fn session(req: Request) -> Response {
38 | case req.method {
39 | Get -> new_session()
40 | Post -> create_session(req)
41 | Delete -> destroy_session(req)
42 | _ -> wisp.method_not_allowed([Get, Post, Delete])
43 | }
44 | }
45 |
46 | pub fn new_session() -> Response {
47 | "
48 |
54 | "
55 | |> wisp.html_response(200)
56 | }
57 |
58 | pub fn destroy_session(req: Request) -> Response {
59 | let resp = wisp.redirect("/session")
60 | case wisp.get_cookie(req, cookie_name, wisp.Signed) {
61 | Ok(value) -> wisp.set_cookie(resp, req, cookie_name, value, wisp.Signed, 0)
62 | Error(_) -> resp
63 | }
64 | }
65 |
66 | pub fn create_session(req: Request) -> Response {
67 | use formdata <- wisp.require_form(req)
68 |
69 | case list.key_find(formdata.values, "name") {
70 | Ok(name) -> {
71 | wisp.redirect("/")
72 | |> wisp.set_cookie(req, cookie_name, name, wisp.Signed, 60 * 60 * 24)
73 | }
74 | Error(_) -> {
75 | wisp.redirect("/session")
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/examples/utilities/tiny_database/manifest.toml:
--------------------------------------------------------------------------------
1 | # This file was generated by Gleam
2 | # You typically do not need to edit this file
3 |
4 | packages = [
5 | { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
6 | { name = "gleam_crypto", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "917BC8B87DBD584830E3B389CBCAB140FFE7CB27866D27C6D0FB87A9ECF35602" },
7 | { name = "gleam_erlang", version = "0.34.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "0C38F2A128BAA0CEF17C3000BD2097EB80634E239CE31A86400C4416A5D0FDCC" },
8 | { name = "gleam_json", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "6E5AEA1884D92A2E9755825F16812EF8B10D8207676BE01E1F1A23F08BF94E0F" },
9 | { name = "gleam_stdlib", version = "0.60.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "621D600BB134BC239CB2537630899817B1A42E60A1D46C5E9F3FAE39F88C800B" },
10 | { name = "gleeunit", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "A7DD6C07B7DA49A6E28796058AA89E651D233B357D5607006D70619CD89DAAAB" },
11 | { name = "simplifile", version = "2.2.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "C88E0EE2D509F6D86EB55161D631657675AA7684DAB83822F7E59EB93D9A60E3" },
12 | { name = "youid", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_stdlib"], otp_app = "youid", source = "hex", outer_checksum = "DFC3718B6BFAD7FED4303C0DDEC3275D501466CF100E556936284F72B1723968" },
13 | ]
14 |
15 | [requirements]
16 | gleam_json = { version = ">= 3.0.0 and < 4.0.0" }
17 | gleam_stdlib = { version = "~> 0.30" }
18 | gleeunit = { version = "~> 1.0" }
19 | simplifile = { version = "~> 2.0" }
20 | youid = { version = ">= 1.1.0 and < 2.0.0" }
21 |
--------------------------------------------------------------------------------
/examples/src/working_with_other_formats/app/router.gleam:
--------------------------------------------------------------------------------
1 | import gleam/http.{Post}
2 | import gleam/int
3 | import gleam/list
4 | import gleam/result
5 | import gleam/string
6 | import gsv
7 | import wisp.{type Request, type Response}
8 | import working_with_other_formats/app/web
9 |
10 | pub fn handle_request(req: Request) -> Response {
11 | use req <- web.middleware(req)
12 | use <- wisp.require_method(req, Post)
13 |
14 | // We want to accept only CSV content, so we use this middleware to check the
15 | // correct content type header is set, and return an error response if not.
16 | use <- wisp.require_content_type(req, "text/csv")
17 |
18 | // This middleware reads the body of the request and returns it as a string,
19 | // erroring if the body is not valid UTF-8, or if the body is too large.
20 | //
21 | // If you want to get a bit-string and don't need specifically UTF-8 encoded
22 | // data then the `wisp.require_bit_string_body` middleware can be used
23 | // instead.
24 | use body <- wisp.require_string_body(req)
25 |
26 | // Now that we have the body we can parse and process it.
27 | // In this case we expect it to be a CSV file with a header row, but in your
28 | // application it could be XML, protobuf, or anything else.
29 | let result = {
30 | // The GSV library is used to parse the CSV.
31 | use rows <- result.try(gsv.to_lists(body, ",") |> result.replace_error(Nil))
32 |
33 | // Get the first row, which is the header row.
34 | use headers <- result.try(list.first(rows))
35 |
36 | // Define the table we want to send back to the client.
37 | let table = [
38 | ["headers", "row-count"],
39 | [string.join(headers, ","), int.to_string(list.length(rows) - 1)],
40 | ]
41 |
42 | // Convert the table to CSV.
43 | let csv = gsv.from_lists(table, ",", gsv.Unix)
44 |
45 | Ok(csv)
46 | }
47 |
48 | // An appropriate response is returned depending on whether the CSV could be
49 | // successfully handled or not.
50 | case result {
51 | Ok(csv) -> {
52 | wisp.ok()
53 | |> wisp.set_header("content-type", "text/csv")
54 | |> wisp.string_body(csv)
55 | }
56 |
57 | Error(_error) -> {
58 | wisp.unprocessable_content()
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/examples/src/routing/app/router.gleam:
--------------------------------------------------------------------------------
1 | import gleam/http.{Get, Post}
2 | import routing/app/web
3 | import wisp.{type Request, type Response}
4 |
5 | pub fn handle_request(req: Request) -> Response {
6 | use req <- web.middleware(req)
7 |
8 | // Wisp doesn't have a special router abstraction, instead we recommend using
9 | // regular old pattern matching. This is faster than a router, is type safe,
10 | // and means you don't have to learn or be limited by a special DSL.
11 | //
12 | case wisp.path_segments(req) {
13 | // This matches `/`.
14 | [] -> home_page(req)
15 |
16 | // This matches `/comments`.
17 | ["comments"] -> comments(req)
18 |
19 | // This matches `/comments/:id`.
20 | // The `id` segment is bound to a variable and passed to the handler.
21 | ["comments", id] -> show_comment(req, id)
22 |
23 | // This matches all other paths.
24 | _ -> wisp.not_found()
25 | }
26 | }
27 |
28 | fn home_page(req: Request) -> Response {
29 | // The home page can only be accessed via GET requests, so this middleware is
30 | // used to return a 405: Method Not Allowed response for all other methods.
31 | use <- wisp.require_method(req, Get)
32 |
33 | wisp.ok()
34 | |> wisp.html_body("Hello, Joe!")
35 | }
36 |
37 | fn comments(req: Request) -> Response {
38 | // This handler for `/comments` can respond to both GET and POST requests,
39 | // so we pattern match on the method here.
40 | case req.method {
41 | Get -> list_comments()
42 | Post -> create_comment(req)
43 | _ -> wisp.method_not_allowed([Get, Post])
44 | }
45 | }
46 |
47 | fn list_comments() -> Response {
48 | // In a later example we'll show how to read from a database.
49 | wisp.ok()
50 | |> wisp.html_body("Comments!")
51 | }
52 |
53 | fn create_comment(_req: Request) -> Response {
54 | // In a later example we'll show how to parse data from the request body.
55 | wisp.created()
56 | |> wisp.html_body("Created")
57 | }
58 |
59 | fn show_comment(req: Request, id: String) -> Response {
60 | use <- wisp.require_method(req, Get)
61 |
62 | // The `id` path parameter has been passed to this function, so we could use
63 | // it to look up a comment in a database.
64 | // For now we'll just include in the response body.
65 | wisp.ok()
66 | |> wisp.html_body("Comment with id " <> id)
67 | }
68 |
--------------------------------------------------------------------------------
/src/wisp/internal.gleam:
--------------------------------------------------------------------------------
1 | import directories
2 | import filepath
3 | import gleam/bit_array
4 | import gleam/crypto
5 | import gleam/int
6 | import gleam/string
7 |
8 | // HELPERS
9 |
10 | //
11 | // Requests
12 | //
13 |
14 | /// The connection to the client for a HTTP request.
15 | ///
16 | /// The body of the request can be read from this connection using functions
17 | /// such as `require_multipart_body`.
18 | ///
19 | pub type Connection {
20 | Connection(
21 | reader: Reader,
22 | max_body_size: Int,
23 | max_files_size: Int,
24 | read_chunk_size: Int,
25 | secret_key_base: String,
26 | temporary_directory: String,
27 | )
28 | }
29 |
30 | pub fn make_connection(
31 | body_reader: Reader,
32 | secret_key_base: String,
33 | ) -> Connection {
34 | // Fallback to current working directory when no valid tmp directory exists
35 | let prefix = case directories.tmp_dir() {
36 | Ok(tmp_dir) -> tmp_dir <> "/gleam-wisp/"
37 | Error(_) -> "./tmp/"
38 | }
39 | let temporary_directory = filepath.join(prefix, random_slug())
40 | Connection(
41 | reader: body_reader,
42 | max_body_size: 8_000_000,
43 | max_files_size: 32_000_000,
44 | read_chunk_size: 1_000_000,
45 | temporary_directory: temporary_directory,
46 | secret_key_base: secret_key_base,
47 | )
48 | }
49 |
50 | pub type Reader =
51 | fn(Int) -> Result(Read, Nil)
52 |
53 | pub type Read {
54 | Chunk(BitArray, next: Reader)
55 | ReadingFinished
56 | }
57 |
58 | //
59 | // Middleware Helpers
60 | //
61 |
62 | pub fn remove_preceeding_slashes(string: String) -> String {
63 | case string {
64 | "/" <> rest -> remove_preceeding_slashes(rest)
65 | _ -> string
66 | }
67 | }
68 |
69 | //
70 | // Cryptography
71 | //
72 |
73 | /// Generate a random string of the given length.
74 | ///
75 | pub fn random_string(length: Int) -> String {
76 | crypto.strong_random_bytes(length)
77 | |> bit_array.base64_url_encode(False)
78 | |> string.slice(0, length)
79 | }
80 |
81 | pub fn random_slug() -> String {
82 | random_string(16)
83 | }
84 |
85 | /// Generates etag using file size + file mtime as seconds
86 | ///
87 | /// Exmaple etag value: `2C-67A4D2F1`
88 | pub fn generate_etag(file_size: Int, mtime_seconds: Int) -> String {
89 | int.to_base16(file_size) <> "-" <> int.to_base16(mtime_seconds)
90 | }
91 |
--------------------------------------------------------------------------------
/examples/utilities/tiny_database/src/tiny_database.gleam:
--------------------------------------------------------------------------------
1 | import gleam/dict.{type Dict}
2 | import gleam/dynamic/decode
3 | import gleam/json
4 | import gleam/list
5 | import gleam/result
6 | import simplifile
7 | import youid/uuid
8 |
9 | pub opaque type Connection {
10 | Connection(root: String)
11 | }
12 |
13 | pub fn connect(root: String) -> Connection {
14 | let assert Ok(_) = simplifile.create_directory_all(root)
15 | Connection(root)
16 | }
17 |
18 | pub fn disconnect(_connection: Connection) -> Nil {
19 | // Here we do nothing, but a real database would close the connection or do
20 | // some other teardown.
21 | Nil
22 | }
23 |
24 | pub fn with_connection(root: String, f: fn(Connection) -> t) -> t {
25 | let connection = connect(root)
26 | let result = f(connection)
27 | let _ = disconnect(connection)
28 | result
29 | }
30 |
31 | pub fn truncate(connection: Connection) -> Result(Nil, Nil) {
32 | let assert Ok(_) = simplifile.delete(connection.root)
33 | Ok(Nil)
34 | }
35 |
36 | pub fn list(connection: Connection) -> Result(List(String), Nil) {
37 | let assert Ok(_) = simplifile.create_directory_all(connection.root)
38 | simplifile.read_directory(connection.root)
39 | |> result.replace_error(Nil)
40 | }
41 |
42 | pub fn insert(
43 | connection: Connection,
44 | values: Dict(String, String),
45 | ) -> Result(String, Nil) {
46 | let assert Ok(_) = simplifile.create_directory_all(connection.root)
47 | let id = uuid.v4_string()
48 | let values =
49 | values
50 | |> dict.to_list
51 | |> list.map(fn(pair) { #(pair.0, json.string(pair.1)) })
52 | let json = json.to_string(json.object(values))
53 | use _ <- result.try(
54 | simplifile.write(file_path(connection, id), json)
55 | |> result.replace_error(Nil),
56 | )
57 | Ok(id)
58 | }
59 |
60 | pub fn read(
61 | connection: Connection,
62 | id: String,
63 | ) -> Result(Dict(String, String), Nil) {
64 | use data <- result.try(
65 | simplifile.read(file_path(connection, id))
66 | |> result.replace_error(Nil),
67 | )
68 |
69 | let decoder = decode.dict(decode.string, decode.string)
70 |
71 | use data <- result.try(
72 | json.parse(data, decoder)
73 | |> result.replace_error(Nil),
74 | )
75 |
76 | Ok(data)
77 | }
78 |
79 | fn file_path(connection: Connection, id: String) -> String {
80 | connection.root <> "/" <> id
81 | }
82 |
--------------------------------------------------------------------------------
/examples/src/working_with_form_data/app/router.gleam:
--------------------------------------------------------------------------------
1 | import gleam/http.{Get, Post}
2 | import gleam/list
3 | import gleam/result
4 | import wisp.{type Request, type Response}
5 | import working_with_form_data/app/web
6 |
7 | pub fn handle_request(req: Request) -> Response {
8 | use req <- web.middleware(req)
9 |
10 | // For GET requests, show the form,
11 | // for POST requests we use the data from the form
12 | case req.method {
13 | Get -> show_form()
14 | Post -> handle_form_submission(req)
15 | _ -> wisp.method_not_allowed(allowed: [Get, Post])
16 | }
17 | }
18 |
19 | pub fn show_form() -> Response {
20 | // In a larger application a template library or HTML form library might
21 | // be used here instead of a string literal.
22 | let html =
23 | ""
32 | wisp.ok()
33 | |> wisp.html_body(html)
34 | }
35 |
36 | pub fn handle_form_submission(req: Request) -> Response {
37 | // This middleware parses a `wisp.FormData` from the request body.
38 | // It returns an error response if the body is not valid form data, or
39 | // if the content-type is not `application/x-www-form-urlencoded` or
40 | // `multipart/form-data`, or if the body is too large.
41 | use formdata <- wisp.require_form(req)
42 |
43 | // The list and result module are used here to extract the values from the
44 | // form data.
45 | // Alternatively you could also pattern match on the list of values (they are
46 | // sorted into alphabetical order), or use a HTML form library.
47 | let result = {
48 | use title <- result.try(list.key_find(formdata.values, "title"))
49 | use name <- result.try(list.key_find(formdata.values, "name"))
50 | let greeting =
51 | "Hi, " <> wisp.escape_html(title) <> " " <> wisp.escape_html(name) <> "!"
52 | Ok(greeting)
53 | }
54 |
55 | // An appropriate response is returned depending on whether the form data
56 | // could be successfully handled or not.
57 | case result {
58 | Ok(content) -> {
59 | wisp.ok()
60 | |> wisp.html_body(content)
61 | }
62 | Error(_) -> {
63 | wisp.bad_request("Invalid form")
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | [](https://hex.pm/packages/wisp)
4 | [](https://hexdocs.pm/wisp/)
5 |
6 | Wisp is a practical Gleam web framework for rapid development and easy maintenance.
7 | We worry about the hassle of web development, and you focus on writing your
8 | application.
9 |
10 | It is based around two concepts: handlers and middleware.
11 |
12 | # Handlers
13 |
14 | A handler is a function that takes a HTTP request and returns a HTTP
15 | response. A handler may also take other arguments, such as a "context" type
16 | defined in your application which may hold other state such as a database
17 | connection or user session.
18 |
19 | ```gleam
20 | import wisp.{type Request, type Response}
21 |
22 | pub type Context {
23 | Context(secret: String)
24 | }
25 |
26 | pub fn handle_request(request: Request, context: Context) -> Response {
27 | wisp.ok()
28 | }
29 | ```
30 |
31 | # Middleware
32 |
33 | A middleware is a function that takes a response returning function as its
34 | last argument, and itself returns a response. As with handlers both
35 | middleware and the functions they take as an argument may take other
36 | arguments.
37 |
38 | Middleware can be applied in a handler with Gleam's `use` syntax. Here the
39 | `log_request` middleware is used to log a message for each HTTP request
40 | handled, and the `serve_static` middleware is used to serve static files
41 | such as images and CSS.
42 |
43 | ```gleam
44 | import wisp.{type Request, type Response}
45 |
46 | pub fn handle_request(request: Request) -> Response {
47 | use <- wisp.log_request(request)
48 | use <- wisp.serve_static(request, under: "/static", from: "/public")
49 | wisp.ok()
50 | }
51 | ```
52 |
53 | # Learning Wisp
54 |
55 | The [Wisp examples](https://github.com/gleam-wisp/wisp/tree/main/examples)
56 | are a good place to start. They cover various scenarios and include comments and
57 | tests.
58 |
59 | API documentation is available on [HexDocs](https://hexdocs.pm/wisp/).
60 |
61 | # Wisp applications
62 |
63 | These open source Wisp applications may be useful examples.
64 |
65 | - [https://packages.gleam.run/](https://github.com/gleam-lang/packages): A HTML
66 | serving application that uses an SQLite + LiteFS database, deployed to Fly.io.
67 |
--------------------------------------------------------------------------------
/docs/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --gap: 12px;
3 | --gap-l: calc(var(--gap) * 2);
4 | --gap-xl: calc(var(--gap) * 4);
5 | --page-width: 850px;
6 | }
7 |
8 | * {
9 | box-sizing: border-box;
10 | }
11 |
12 | body,
13 | html {
14 | margin: 0;
15 | font-family: sans-serif;
16 | font-size: 17px;
17 | line-height: 1.25;
18 | }
19 |
20 | a {
21 | text-decoration-style: dotted;
22 | }
23 |
24 | a:hover {
25 | text-decoration-thickness: 2px;
26 | }
27 |
28 | .home-hero {
29 | margin: 0;
30 | padding: 0;
31 | min-height: 70vh;
32 |
33 | display: flex;
34 | align-items: center;
35 | justify-content: center;
36 | flex-direction: column;
37 |
38 | background-color: hsla(90, 60%, 64%, 1);
39 | background-image: radial-gradient(
40 | at 65% 68%,
41 | hsla(87, 56%, 61%, 1) 0px,
42 | transparent 50%
43 | ),
44 | radial-gradient(at 99% 98%, hsla(66, 44%, 75%, 1) 0px, transparent 50%),
45 | radial-gradient(at 39% 18%, hsla(53, 67%, 66%, 1) 0px, transparent 50%),
46 | radial-gradient(at 0% 1%, hsla(88, 76%, 80%, 1) 0px, transparent 50%),
47 | radial-gradient(at 15% 73%, hsla(152, 89%, 71%, 1) 0px, transparent 50%),
48 | radial-gradient(at 50% 99%, hsla(97, 48%, 79%, 1) 0px, transparent 50%),
49 | radial-gradient(at 98% 0%, hsla(121, 69%, 81%, 1) 0px, transparent 50%);
50 | }
51 |
52 | .home-hero img {
53 | width: 360px;
54 | }
55 |
56 | .home-hero nav {
57 | position: absolute;
58 | top: 0;
59 | text-align: right;
60 | width: 100%;
61 | padding: var(--gap-l);
62 | }
63 |
64 | .home-hero nav a {
65 | color: black;
66 | opacity: 0.8;
67 | margin: 0 var(--gap);
68 | }
69 |
70 | .content-width {
71 | max-width: 100%;
72 | width: var(--page-width);
73 | margin: 0 auto;
74 | padding: var(--gap-l);
75 | }
76 |
77 | .features {
78 | display: flex;
79 | flex-wrap: wrap;
80 | gap: var(--gap-l);
81 | }
82 |
83 | .features li {
84 | list-style: none;
85 | width: calc((var(--page-width) - var(--gap-l) * 3) * 0.5);
86 | max-width: 100%;
87 | }
88 |
89 | pre {
90 | font-size: 16px;
91 | padding: var(--gap-l);
92 | background-color: #f5f5f5;
93 | box-shadow: 7px 7px #c0c0c0;
94 | margin: var(--gap-l) 0;
95 | overflow-x: auto;
96 | }
97 |
98 | footer {
99 | display: flex;
100 | flex-direction: column;
101 | align-items: center;
102 | padding: var(--gap-xl);
103 | }
104 |
--------------------------------------------------------------------------------
/docs/images/gradient.svg:
--------------------------------------------------------------------------------
1 |
46 |
--------------------------------------------------------------------------------
/src/wisp/wisp_mist.gleam:
--------------------------------------------------------------------------------
1 | import exception
2 | import gleam/bytes_tree
3 | import gleam/http/request.{type Request as HttpRequest}
4 | import gleam/http/response.{type Response as HttpResponse}
5 | import gleam/option
6 | import gleam/result
7 | import gleam/string
8 | import mist
9 | import wisp
10 | import wisp/internal
11 |
12 | //
13 | // Running the server
14 | //
15 |
16 | /// Convert a Wisp request handler into a function that can be run with the Mist
17 | /// web server.
18 | ///
19 | /// # Examples
20 | ///
21 | /// ```gleam
22 | /// pub fn main() {
23 | /// let secret_key_base = "..."
24 | /// let assert Ok(_) =
25 | /// handle_request
26 | /// |> wisp_mist.handler(secret_key_base)
27 | /// |> mist.new
28 | /// |> mist.port(8000)
29 | /// |> mist.start_http
30 | /// process.sleep_forever()
31 | /// }
32 | /// ```
33 | ///
34 | /// The secret key base is used for signing and encryption. To be able to
35 | /// verify and decrypt messages you will need to use the same key each time
36 | /// your program is run. Keep this value secret! Malicious people with this
37 | /// value will likely be able to hack your application.
38 | ///
39 | pub fn handler(
40 | handler: fn(wisp.Request) -> wisp.Response,
41 | secret_key_base: String,
42 | ) -> fn(HttpRequest(mist.Connection)) -> HttpResponse(mist.ResponseData) {
43 | fn(request: HttpRequest(_)) {
44 | let connection =
45 | internal.make_connection(mist_body_reader(request), secret_key_base)
46 | let request = request.set_body(request, connection)
47 |
48 | use <- exception.defer(fn() {
49 | let assert Ok(_) = wisp.delete_temporary_files(request)
50 | })
51 |
52 | let response =
53 | request
54 | |> handler
55 | |> mist_response
56 |
57 | response
58 | }
59 | }
60 |
61 | fn mist_body_reader(request: HttpRequest(mist.Connection)) -> internal.Reader {
62 | case mist.stream(request) {
63 | Error(_) -> fn(_) { Ok(internal.ReadingFinished) }
64 | Ok(stream) -> fn(size) { wrap_mist_chunk(stream(size)) }
65 | }
66 | }
67 |
68 | fn wrap_mist_chunk(
69 | chunk: Result(mist.Chunk, mist.ReadError),
70 | ) -> Result(internal.Read, Nil) {
71 | chunk
72 | |> result.replace_error(Nil)
73 | |> result.map(fn(chunk) {
74 | case chunk {
75 | mist.Done -> internal.ReadingFinished
76 | mist.Chunk(data, consume) ->
77 | internal.Chunk(data, fn(size) { wrap_mist_chunk(consume(size)) })
78 | }
79 | })
80 | }
81 |
82 | fn mist_response(response: wisp.Response) -> HttpResponse(mist.ResponseData) {
83 | let body = case response.body {
84 | wisp.Text(text) -> mist.Bytes(bytes_tree.from_string(text))
85 | wisp.Bytes(bytes) -> mist.Bytes(bytes)
86 | wisp.File(path:, offset:, limit:) -> mist_send_file(path, offset, limit)
87 | }
88 | response
89 | |> response.set_body(body)
90 | }
91 |
92 | fn mist_send_file(
93 | path: String,
94 | offset: Int,
95 | limit: option.Option(Int),
96 | ) -> mist.ResponseData {
97 | case mist.send_file(path, offset:, limit:) {
98 | Ok(body) -> body
99 | Error(error) -> {
100 | wisp.log_error(string.inspect(error))
101 | // TODO: return 500
102 | mist.Bytes(bytes_tree.new())
103 | }
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/examples/test/using_a_database/app_test.gleam:
--------------------------------------------------------------------------------
1 | import gleam/http
2 | import gleam/json
3 | import tiny_database
4 | import using_a_database/app
5 | import using_a_database/app/router
6 | import using_a_database/app/web.{type Context, Context}
7 | import using_a_database/app/web/people.{Person}
8 | import wisp/simulate
9 |
10 | fn with_context(testcase: fn(Context) -> t) -> t {
11 | // Create a new database connection for this test
12 | use db <- tiny_database.with_connection(app.data_directory)
13 |
14 | // Truncate the database so there is no prexisting data from previous tests
15 | let assert Ok(_) = tiny_database.truncate(db)
16 | let context = Context(db: db)
17 |
18 | // Run the test with the context
19 | testcase(context)
20 | }
21 |
22 | pub fn get_unknown_test() {
23 | use ctx <- with_context
24 | let request = simulate.browser_request(http.Get, "/")
25 | let response = router.handle_request(request, ctx)
26 |
27 | assert response.status == 404
28 | }
29 |
30 | pub fn list_people_test() {
31 | use ctx <- with_context
32 |
33 | let response =
34 | router.handle_request(simulate.browser_request(http.Get, "/people"), ctx)
35 | assert response.status == 200
36 | assert response.headers
37 | == [#("content-type", "application/json; charset=utf-8")]
38 |
39 | assert // Initially there are no people in the database
40 | simulate.read_body(response) == "{\"people\":[]}"
41 |
42 | // Create a new person
43 | let assert Ok(id) = people.save_to_database(ctx.db, Person("Jane", "Red"))
44 |
45 | // The id of the new person is listed by the API
46 | let response =
47 | router.handle_request(simulate.browser_request(http.Get, "/people"), ctx)
48 | assert simulate.read_body(response)
49 | == "{\"people\":[{\"id\":\"" <> id <> "\"}]}"
50 | }
51 |
52 | pub fn create_person_test() {
53 | use ctx <- with_context
54 | let json =
55 | json.object([
56 | #("name", json.string("Lucy")),
57 | #("favourite-colour", json.string("Pink")),
58 | ])
59 | let request =
60 | simulate.request(http.Post, "/people")
61 | |> simulate.json_body(json)
62 | let response = router.handle_request(request, ctx)
63 |
64 | assert response.status == 201
65 |
66 | // The request created a new person in the database
67 | let assert Ok([id]) = tiny_database.list(ctx.db)
68 |
69 | assert simulate.read_body(response) == "{\"id\":\"" <> id <> "\"}"
70 | }
71 |
72 | pub fn create_person_missing_parameters_test() {
73 | use ctx <- with_context
74 | let json = json.object([#("name", json.string("Lucy"))])
75 | let request =
76 | simulate.request(http.Post, "/people")
77 | |> simulate.json_body(json)
78 | let response = router.handle_request(request, ctx)
79 |
80 | assert response.status == 422
81 |
82 | // Nothing was created in the database
83 | let assert Ok([]) = tiny_database.list(ctx.db)
84 | }
85 |
86 | pub fn read_person_test() {
87 | use ctx <- with_context
88 | let assert Ok(id) = people.save_to_database(ctx.db, Person("Jane", "Red"))
89 | let request = simulate.browser_request(http.Get, "/people/" <> id)
90 | let response = router.handle_request(request, ctx)
91 |
92 | assert response.status == 200
93 |
94 | assert simulate.read_body(response)
95 | == "{\"id\":\""
96 | <> id
97 | <> "\",\"name\":\"Jane\",\"favourite-colour\":\"Red\"}"
98 | }
99 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Wisp - A practical web framework for Gleam
8 |
9 |
10 |
11 |
12 |
13 |
18 |
19 |
20 | A practical web framework for Gleam
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Perfectly productive
29 |
30 |
31 | Wisp is simple, type safe, and entirely free from confusing magic. Make
32 | development as stress-free as possible whether you're starting a new
33 | prototype or maintaining a large system.
34 |
35 |
36 |
37 |
38 |
39 | Flipping fast
40 |
41 |
42 | Thanks to the Mist HTTP server and the mighty multithreaded BEAM
43 | runtime Wisp applications are fast, even at the 99th percentile during a
44 | big burst of traffic. In benchmarks Wisp can outperform Go, NodeJS, and
45 | Elixir Phoenix + Cowboy.
46 |
47 |
48 |
49 |
50 |
51 | Totally testable
52 |
53 |
54 | If your application matters then you're going to want to test it. A Wisp
55 | web application is as easy to test as any regular Gleam function, and an
56 | assortment of useful test helpers are provided to keep your tests
57 | concise.
58 |
59 |
60 |
61 |
62 |
63 | Really reliable
64 |
65 |
66 | Scrambling to fix problems in production is stressful, so Wisp uses
67 | Gleam's type safety and the BEAM's fault tolerance help prevent those
68 | panicked late night phone calls from your boss.
69 |
70 |
71 |
72 |
73 |
74 |
OK, but what does Wisp actually give you?
75 |
76 |
Composable middleware, with lots of useful ones built-in.
77 |
Type safe routing with good old fashioned pattern matching.
78 |
Parsing of JSON, urlencoded, and multipart bodies.
79 |
Tamper-proof signed cookies, suitable for authentication.
80 |
Body size limiting and file upload streaming to disc, to prevent
81 | memory exhaustion attacks.
82 |
Serving of CSS, JavaScript, or whatever other static assets you want.
83 |
Logging, both ad-hoc logging and request logging, using a middleware.
84 |
Regular Gleam programming, so you can use any Gleam package you want
85 | without trouble.
86 |
87 |
88 | And a recommended project structure, so you can focus on solving the
89 | problems you want to solve, rather than reinventing the wheel.
90 |
91 |
92 |
93 |
94 |
That sounds good! What does it look like?
95 |
96 | Here's a JSON API request handler that saves an item in a database.
97 |
25 |
26 |
32 | "
33 |
34 | fn show_home(req: Request) -> Response {
35 | use <- wisp.require_method(req, Get)
36 | html
37 | |> wisp.html_response(200)
38 | }
39 |
40 | fn handle_download_file_from_memory(req: Request) -> Response {
41 | use <- wisp.require_method(req, Get)
42 |
43 | // In this case we have the file contents in memory as a string.
44 | // This is good if we have just made the file, but if the file already exists
45 | // on the disc then the approach in the next function is more efficient.
46 | let file_contents = bytes_tree.from_string("Hello, Joe!")
47 |
48 | wisp.ok()
49 | |> wisp.set_header("content-type", "text/plain")
50 | // The content-disposition header is set by this function to ensure this is
51 | // treated as a file download. If the file was uploaded by the user then you
52 | // want to ensure that this header is set as otherwise the browser may try to
53 | // display the file, which could enable cross-site scripting attacks.
54 | |> wisp.file_download_from_memory(
55 | named: "hello.txt",
56 | containing: file_contents,
57 | )
58 | }
59 |
60 | fn handle_download_file_from_disc(req: Request) -> Response {
61 | use <- wisp.require_method(req, Get)
62 |
63 | // In this case the file exists on the disc.
64 | // Here we're using the project gleam.toml, but in a real application you'd
65 | // probably have an absolute path to wherever it is you keep your files.
66 | let file_path = "./gleam.toml"
67 |
68 | wisp.ok()
69 | |> wisp.set_header("content-type", "text/markdown")
70 | |> wisp.file_download(named: "hello.md", from: file_path)
71 | }
72 |
73 | fn handle_file_upload(req: Request) -> Response {
74 | use <- wisp.require_method(req, Post)
75 | use formdata <- wisp.require_form(req)
76 |
77 | // The list and result module are used here to extract the values from the
78 | // form data.
79 | // Alternatively you could also pattern match on the list of values (they are
80 | // sorted into alphabetical order), or use a HTML form library.
81 | let result = {
82 | // Note the name of the input is used to find the value.
83 | use file <- result.try(list.key_find(formdata.files, "uploaded-file"))
84 |
85 | // The file has been streamed to a temporary file on the disc, so there's no
86 | // risk of large files causing memory issues.
87 | // The `.path` field contains the path to this file, which you may choose to
88 | // move or read using a library like `simplifile`. When the request is done the
89 | // temporary file is deleted.
90 | wisp.log_info("File uploaded to " <> file.path)
91 |
92 | // File uploads may include a file name. Some clients such as curl may not
93 | // have one, so this field may be empty.
94 | // You should never trust this field. Just because it has a particular file
95 | // extension does not mean it is a file of that type, and it may contain
96 | // invalid characters. Always validate the file type and do not use this
97 | // name as the new path for the file.
98 | wisp.log_info("The file name is reportedly " <> file.file_name)
99 |
100 | // Once the response has been sent the uploaded file will be deleted. If
101 | // you want to retain the file then move it to a new location.
102 |
103 | Ok(file.file_name)
104 | }
105 |
106 | // An appropriate response is returned depending on whether the form data
107 | // could be successfully handled or not.
108 | case result {
109 | Ok(name) -> {
110 | { "
Thank you for your file `" <> name <> "`
" <> html }
111 | |> wisp.html_response(200)
112 | }
113 | Error(_) -> {
114 | wisp.bad_request("File missing")
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/examples/src/using_a_database/app/web/people.gleam:
--------------------------------------------------------------------------------
1 | import gleam/dict
2 | import gleam/dynamic/decode
3 | import gleam/http.{Get, Post}
4 | import gleam/json
5 | import gleam/result.{try}
6 | import tiny_database
7 | import using_a_database/app/web.{type Context}
8 | import wisp.{type Request, type Response}
9 |
10 | // This request handler is used for requests to `/people`.
11 | //
12 | pub fn all(req: Request, ctx: Context) -> Response {
13 | // Dispatch to the appropriate handler based on the HTTP method.
14 | case req.method {
15 | Get -> list_people(ctx)
16 | Post -> create_person(req, ctx)
17 | _ -> wisp.method_not_allowed([Get, Post])
18 | }
19 | }
20 |
21 | // This request handler is used for requests to `/people/:id`.
22 | //
23 | pub fn one(req: Request, ctx: Context, id: String) -> Response {
24 | // Dispatch to the appropriate handler based on the HTTP method.
25 | case req.method {
26 | Get -> read_person(ctx, id)
27 | _ -> wisp.method_not_allowed([Get])
28 | }
29 | }
30 |
31 | pub type Person {
32 | Person(name: String, favourite_colour: String)
33 | }
34 |
35 | // This handler returns a list of all the people in the database, in JSON
36 | // format.
37 | //
38 | pub fn list_people(ctx: Context) -> Response {
39 | let result = {
40 | // Get all the ids from the database.
41 | use ids <- try(tiny_database.list(ctx.db))
42 |
43 | // Convert the ids into a JSON array of objects.
44 | Ok(
45 | json.to_string(
46 | json.object([
47 | #(
48 | "people",
49 | json.array(ids, fn(id) { json.object([#("id", json.string(id))]) }),
50 | ),
51 | ]),
52 | ),
53 | )
54 | }
55 |
56 | case result {
57 | // When everything goes well we return a 200 response with the JSON.
58 | Ok(json) -> wisp.json_response(json, 200)
59 |
60 | // In a later example we will see how to return specific errors to the user
61 | // depending on what went wrong. For now we will just return a 500 error.
62 | Error(Nil) -> wisp.internal_server_error()
63 | }
64 | }
65 |
66 | pub fn create_person(req: Request, ctx: Context) -> Response {
67 | // Read the JSON from the request body.
68 | use json <- wisp.require_json(req)
69 |
70 | let result = {
71 | // Decode the JSON into a Person record.
72 | // In a real application we wouldn't throw away the errors.
73 | use person <- try(
74 | decode.run(json, person_decoder())
75 | |> result.replace_error(Nil),
76 | )
77 |
78 | // Save the person to the database.
79 | use id <- try(save_to_database(ctx.db, person))
80 |
81 | // Construct a JSON payload with the id of the newly created person.
82 | Ok(json.to_string(json.object([#("id", json.string(id))])))
83 | }
84 |
85 | // Return an appropriate response depending on whether everything went well or
86 | // if there was an error.
87 | case result {
88 | Ok(json) -> wisp.json_response(json, 201)
89 | Error(Nil) -> wisp.unprocessable_content()
90 | }
91 | }
92 |
93 | pub fn read_person(ctx: Context, id: String) -> Response {
94 | let result = {
95 | // Read the person with the given id from the database.
96 | use person <- try(read_from_database(ctx.db, id))
97 |
98 | // Construct a JSON payload with the person's details.
99 | Ok(
100 | json.to_string(
101 | json.object([
102 | #("id", json.string(id)),
103 | #("name", json.string(person.name)),
104 | #("favourite-colour", json.string(person.favourite_colour)),
105 | ]),
106 | ),
107 | )
108 | }
109 |
110 | // Return an appropriate response.
111 | case result {
112 | Ok(json) -> wisp.json_response(json, 200)
113 | Error(Nil) -> wisp.not_found()
114 | }
115 | }
116 |
117 | fn person_decoder() -> decode.Decoder(Person) {
118 | use name <- decode.field("name", decode.string)
119 | use favourite_colour <- decode.field("favourite-colour", decode.string)
120 | decode.success(Person(name:, favourite_colour:))
121 | }
122 |
123 | /// Save a person to the database and return the id of the newly created record.
124 | pub fn save_to_database(
125 | db: tiny_database.Connection,
126 | person: Person,
127 | ) -> Result(String, Nil) {
128 | // In a real application you might use a database client with some SQL here.
129 | // Instead we create a simple dict and save that.
130 | let data =
131 | dict.from_list([
132 | #("name", person.name),
133 | #("favourite-colour", person.favourite_colour),
134 | ])
135 | tiny_database.insert(db, data)
136 | }
137 |
138 | pub fn read_from_database(
139 | db: tiny_database.Connection,
140 | id: String,
141 | ) -> Result(Person, Nil) {
142 | // In a real application you might use a database client with some SQL here.
143 | use data <- try(tiny_database.read(db, id))
144 | use name <- try(dict.get(data, "name"))
145 | use favourite_colour <- try(dict.get(data, "favourite-colour"))
146 | Ok(Person(name, favourite_colour))
147 | }
148 |
--------------------------------------------------------------------------------
/manifest.toml:
--------------------------------------------------------------------------------
1 | # This file was generated by Gleam
2 | # You typically do not need to edit this file
3 |
4 | packages = [
5 | { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" },
6 | { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" },
7 | { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
8 | { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
9 | { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" },
10 | { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
11 | { name = "gleam_http", version = "4.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "FFE29C3832698AC3EF6202922EC534EE19540152D01A7C2D22CB97482E4AF211" },
12 | { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
13 | { name = "gleam_otp", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7987CBEBC8060B88F14575DEF546253F3116EBE2A5DA6FD82F38243FCE97C54B" },
14 | { name = "gleam_stdlib", version = "0.64.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "EA2E13FC4E65750643E078487D5EF360BEBCA5EBBBA12042FB589C19F53E35C0" },
15 | { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
16 | { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" },
17 | { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" },
18 | { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" },
19 | { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
20 | { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" },
21 | { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" },
22 | { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },
23 | { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" },
24 | { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },
25 | { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
26 | { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" },
27 | ]
28 |
29 | [requirements]
30 | directories = { version = ">= 1.0.0 and < 2.0.0" }
31 | exception = { version = ">= 2.0.0 and < 3.0.0" }
32 | filepath = { version = ">= 1.1.2 and < 2.0.0" }
33 | gleam_crypto = { version = ">= 1.0.0 and < 2.0.0" }
34 | gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" }
35 | gleam_http = { version = ">= 3.5.0 and < 5.0.0" }
36 | gleam_json = { version = ">= 3.0.0 and < 4.0.0" }
37 | gleam_stdlib = { version = ">= 0.50.0 and < 2.0.0" }
38 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
39 | houdini = { version = ">= 1.0.0 and < 2.0.0" }
40 | logging = { version = ">= 1.2.0 and < 2.0.0" }
41 | marceau = { version = ">= 1.1.0 and < 2.0.0" }
42 | mist = { version = ">= 2.0.0 and < 6.0.0" }
43 | simplifile = { version = ">= 2.0.0 and < 3.0.0" }
44 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## Unreleased
4 |
5 | - The `content_security_policy_protection` middleware has been added.
6 |
7 | ## v2.1.1 - 2025-12-11
8 |
9 | - Fixed a bug where `serve_static` wouldn't handle special characters in paths
10 | correctly.
11 |
12 | ## v2.1.0 - 2025-10-01
13 |
14 | - The `multipart_body` function and the `FileUpload` type have been added to
15 | the `simulate` module.
16 |
17 | ## v2.0.1 - 2025-09-27
18 |
19 | - Fixed warnings with latest stdlib.
20 |
21 | ## v2.0.0 - 2025-09-04
22 |
23 | - The `unprocessable_entity` function has renamed to `unprocessable_content`.
24 | - The `entity_too_large` function has renamed to `content_too_large`.
25 |
26 | ## v2.0.0-rc1 - 2025-07-24
27 |
28 | - `set_cookie` will no longer set the `Secure` cookie attributes for HTTP
29 | requests that do not have the `x-forwarded-proto` header. This means that
30 | browsers like Safari, that do not consider `localhost` etc to be a secure
31 | context, will send Wisp-set cookies during local development.
32 | - The `parse_range_header` function and `Range` type have been added.
33 | - The `serve_static` middleware now respects the `range` header.
34 | - The `wisp/simulate` module replaces the `wisp/testing` module.
35 | - The `create_canned_connection` function has been removed from the public API.
36 | - The `read_body_to_bitstring` function has renamed to `read_body_bits`.
37 | - The `csrf_known_header_protection` middleware has been added.
38 | - The `Text` body type and associated functions now take a `String` rather than
39 | a `StringTree`.
40 | - The `Empty` response body type has been removed. Functions that previously
41 | returned `Empty` now return a suitable text response for the status code.
42 | - The `moved_permanently` function has been renamed to `permanent_redirect`.
43 | - The `bad_request` function now takes a string as an argument.
44 |
45 | ## v1.8.0 - 2025-06-20
46 |
47 | - Updated for `gleam_erlang` v1.
48 | - Fixed a bug where the `etag` header may not always be set for static assets.
49 |
50 | ## v1.7.0 - 2025-05-13
51 |
52 | - Updated for latest `gleam_stdlib`.
53 |
54 | ## v1.6.0 - 2025-03-21
55 |
56 | - Updated `serve_static` to generate etags for static assets.
57 |
58 | ## v1.5.3 - 2025-02-06
59 |
60 | - Relaxed the `gleam_http` requirement to permit v4.
61 |
62 | ## v1.5.2 - 2025-02-03
63 |
64 | - Updated for `gleam_erlang` v0.34.0.
65 | - The function `wisp.get_cookie` gains function labels for its arguments.
66 |
67 | ## v1.5.1 - 2025-01-02
68 |
69 | - Fixed a bug where Wisp would fail to compile.
70 |
71 | ## v1.5.0 - 2024-12-28
72 |
73 | - `handle_head` no longer sets the body to `Empty`. This is so the webserver can
74 | get the content-length of the body that would have been set, which may be
75 | useful to clients.
76 |
77 | ## v1.4.0 - 2024-12-19
78 |
79 | - Updated for `mist` v5.0.0.
80 |
81 | ## v1.3.0 - 2024-11-21
82 |
83 | - Updated for `gleam_stdlib` v0.43.0.
84 |
85 | ## v1.2.0 - 2024-10-09
86 |
87 | - The requirement for `gleam_json` has been relaxed to < 3.0.0.
88 | - The requirement for `mist` has been relaxed to < 4.0.0.
89 | - The Gleam version requirement has been corrected to `>= 1.1.0` from the
90 | previously inaccurate `">= 0.32.0`.
91 |
92 | ## v1.1.0 - 2024-08-23
93 |
94 | - Rather than using `/tmp`, the platform-specific temporary directory is
95 | detected used.
96 |
97 | ## v1.0.0 - 2024-08-21
98 |
99 | - The Mist web server related functions have been moved to the `wisp_mist`
100 | module.
101 | - The `wisp` module gains the `set_logger_level` function and `LogLevel` type.
102 |
103 | ## v0.16.0 - 2024-07-13
104 |
105 | - HTML and JSON body functions now include `charset=utf-8` in the content-type
106 | header.
107 | - The `require_content_type` function now handles additional attributes
108 | correctly.
109 |
110 | ## v0.15.0 - 2024-05-12
111 |
112 | - The `mist` version constraint has been increased to >= 1.2.0.
113 | - The `simplifile` version constraint has been increased to >= 2.0.0.
114 | - The `escape_html` function in the `wisp` module has been optimised.
115 |
116 | ## v0.14.0 - 2024-03-28
117 |
118 | - The `mist` version constraint has been relaxed to permit 0.x or 1.x versions.
119 |
120 | ## v0.13.0 - 2024-03-23
121 |
122 | - The `wisp` module gains the `file_download_from_memory` and `file_download`
123 | functions.
124 |
125 | ## v0.12.0 - 2024-02-17
126 |
127 | - The output format used by the logger has been improved.
128 | - Erlang SASL and supervisor logs are no longer emitted.
129 |
130 | ## v0.11.0 - 2024-02-03
131 |
132 | - Updated for simplifile v1.4 and replaced the deprecated `simplifile.is_file`
133 | function with `simplifile.verify_is_file`.
134 |
135 | ## v0.10.0 - 2024-01-17
136 |
137 | - Relaxed version constraints for `gleam_stdlib` and `gleam_json` to permit 0.x
138 | or 1.x versions.
139 |
140 | ## v0.9.0 - 2024-01-15
141 |
142 | - Updated for Gleam v0.33.0.
143 | - Updated for simplifile v1.0.
144 |
145 | ## v0.8.0 - 2023-11-13
146 |
147 | - Updated for simplifile v0.3.
148 |
149 | ## v0.7.0 - 2023-11-05
150 |
151 | - Updated for Gleam v0.32. All references to "bit string" have been changed to
152 | "bit array" to match.
153 | - The `wisp` module gains the `get_query` function.
154 |
155 | ## v0.6.0 - 2023-10-19
156 |
157 | - The `wisp.require_form` now handles `application/x-www-form-urlencoded`
158 | content types with a charset.
159 |
160 | ## v0.5.0 - 2023-09-13
161 |
162 | - The `wisp` module gains the `set_cookie`, `get_cookie`, `json_response` and
163 | `priv_directory` functions.
164 | - The `wisp` module gains the `Security` type.
165 |
166 | ## v0.4.0 - 2023-08-24
167 |
168 | - The `wisp` module gains the `set_header`, `string_builder_body`,
169 | `string_body`, `json_body`, `unprocessable_entity`, `require_json` and
170 | `require_content_type` functions.
171 | - The `wisp/testing` module gains the `post_json`, `put_json`, `patch_json`,
172 | `delete_json`, and `set_header` functions.
173 | - The request construction functions in the `wisp/testing` module now support
174 | query strings. e.g. `get("/users?limit=10", [])`.
175 |
176 | ## v0.3.0 - 2023-08-21
177 |
178 | - The `mist_service` function has been renamed to `mist_handler`.
179 | - The `method_not_allowed` function gains the `allowed` label for its argument.
180 | - The `wisp` module gains the `html_escape` function.
181 | - The `wisp/testing` module gains the `post_form`, `put_form`, `patch_form`, and
182 | `delete_form` functions.
183 |
184 | ## v0.2.0 - 2023-08-12
185 |
186 | - Initial release
187 |
--------------------------------------------------------------------------------
/examples/manifest.toml:
--------------------------------------------------------------------------------
1 | # This file was generated by Gleam
2 | # You typically do not need to edit this file
3 |
4 | packages = [
5 | { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" },
6 | { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" },
7 | { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
8 | { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
9 | { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" },
10 | { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
11 | { name = "gleam_http", version = "4.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "FFE29C3832698AC3EF6202922EC534EE19540152D01A7C2D22CB97482E4AF211" },
12 | { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
13 | { name = "gleam_otp", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7987CBEBC8060B88F14575DEF546253F3116EBE2A5DA6FD82F38243FCE97C54B" },
14 | { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" },
15 | { name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" },
16 | { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
17 | { name = "glearray", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "5E272F7CB278CC05A929C58DEB58F5D5AC6DB5B879A681E71138658D0061C38A" },
18 | { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" },
19 | { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" },
20 | { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" },
21 | { name = "gsv", version = "4.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glearray"], otp_app = "gsv", source = "hex", outer_checksum = "FFF2F19075E9512BC1208865D0E3546DE4E1A210648272751BDE26E901BA898D" },
22 | { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
23 | { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" },
24 | { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" },
25 | { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },
26 | { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" },
27 | { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },
28 | { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
29 | { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" },
30 | { name = "tiny_database", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "simplifile", "youid"], source = "local", path = "utilities/tiny_database" },
31 | { name = "wisp", version = "2.0.1", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], source = "local", path = ".." },
32 | { name = "youid", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_stdlib", "gleam_time"], otp_app = "youid", source = "hex", outer_checksum = "580E909FD704DB16416D5CB080618EDC2DA0F1BE4D21B490C0683335E3FFC5AF" },
33 | ]
34 |
35 | [requirements]
36 | gleam_crypto = { version = ">= 1.5.0 and < 2.0.0" }
37 | gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" }
38 | gleam_http = { version = ">= 4.0.0 and < 5.0.0" }
39 | gleam_json = { version = ">= 3.0.1 and < 4.0.0" }
40 | gleam_stdlib = { version = ">= 0.60.0 and < 1.0.0" }
41 | gleeunit = { version = "~> 1.0" }
42 | gsv = { version = ">= 4.0.0 and < 5.0.0" }
43 | mist = { version = ">= 5.0.0 and < 6.0.0" }
44 | tiny_database = { path = "utilities/tiny_database" }
45 | wisp = { path = ".." }
46 |
--------------------------------------------------------------------------------
/docs/images/wordmark.svg:
--------------------------------------------------------------------------------
1 |
2 |
88 |
--------------------------------------------------------------------------------
/test/wisp/simulate_test.gleam:
--------------------------------------------------------------------------------
1 | import gleam/http
2 | import gleam/http/response
3 | import gleam/json
4 | import gleam/list
5 | import gleam/option.{None}
6 | import gleam/string
7 | import simplifile
8 | import wisp
9 | import wisp/simulate
10 |
11 | pub fn request_test() {
12 | let request = simulate.request(http.Patch, "/wibble/woo")
13 | assert request.method == http.Patch
14 | assert request.headers == [#("host", "wisp.example.com")]
15 | assert request.scheme == http.Https
16 | assert request.host == "wisp.example.com"
17 | assert request.port == None
18 | assert request.path == "/wibble/woo"
19 | assert request.query == None
20 | assert wisp.read_body_bits(request) == Ok(<<>>)
21 | }
22 |
23 | pub fn browser_request_test() {
24 | let request = simulate.browser_request(http.Put, "/wibble/woo")
25 | assert request.method == http.Put
26 | assert request.headers
27 | == [
28 | #("origin", "https://wisp.example.com"),
29 | #("host", "wisp.example.com"),
30 | ]
31 | assert request.scheme == http.Https
32 | assert request.host == "wisp.example.com"
33 | assert request.port == None
34 | assert request.path == "/wibble/woo"
35 | assert request.query == None
36 | assert wisp.read_body_bits(request) == Ok(<<>>)
37 | }
38 |
39 | pub fn text_body_test() {
40 | let request =
41 | simulate.request(http.Patch, "/wibble/woo")
42 | |> simulate.string_body("Hello, Joe!")
43 | assert request.headers
44 | == [
45 | #("host", "wisp.example.com"),
46 | #("content-type", "text/plain"),
47 | ]
48 | assert wisp.read_body_bits(request) == Ok(<<"Hello, Joe!">>)
49 | }
50 |
51 | pub fn binary_body_test() {
52 | let request =
53 | simulate.request(http.Patch, "/wibble/woo")
54 | |> simulate.bit_array_body(<<123>>)
55 | assert request.headers
56 | == [
57 | #("host", "wisp.example.com"),
58 | #("content-type", "application/octet-stream"),
59 | ]
60 | assert wisp.read_body_bits(request) == Ok(<<123>>)
61 | }
62 |
63 | pub fn form_body_test() {
64 | let request =
65 | simulate.request(http.Patch, "/wibble/woo")
66 | |> simulate.form_body([#("a", "1"), #("b", "2")])
67 | assert request.headers
68 | == [
69 | #("host", "wisp.example.com"),
70 | #("content-type", "application/x-www-form-urlencoded"),
71 | ]
72 | assert wisp.read_body_bits(request) == Ok(<<"a=1&b=2">>)
73 | }
74 |
75 | pub fn json_body_test() {
76 | let request =
77 | simulate.request(http.Patch, "/wibble/woo")
78 | |> simulate.json_body(
79 | json.object([
80 | #("a", json.int(1)),
81 | #("b", json.int(2)),
82 | ]),
83 | )
84 | assert request.headers
85 | == [
86 | #("host", "wisp.example.com"),
87 | #("content-type", "application/json"),
88 | ]
89 | assert wisp.read_body_bits(request) == Ok(<<"{\"a\":1,\"b\":2}">>)
90 | }
91 |
92 | pub fn read_text_body_file_test() {
93 | assert wisp.ok()
94 | |> response.set_body(wisp.File("test/fixture.txt", 0, None))
95 | |> simulate.read_body
96 | == "Hello, Joe! 👨👩👧👦\n"
97 | }
98 |
99 | pub fn read_text_body_text_test() {
100 | assert wisp.ok()
101 | |> response.set_body(wisp.Text("Hello, Joe!"))
102 | |> simulate.read_body
103 | == "Hello, Joe!"
104 | }
105 |
106 | pub fn read_binary_body_file_test() {
107 | assert wisp.ok()
108 | |> response.set_body(wisp.File("test/fixture.txt", 0, None))
109 | |> simulate.read_body_bits
110 | == <<"Hello, Joe! 👨👩👧👦\n":utf8>>
111 | }
112 |
113 | pub fn read_binary_body_text_test() {
114 | assert wisp.ok()
115 | |> response.set_body(wisp.Text("Hello, Joe!"))
116 | |> simulate.read_body_bits
117 | == <<"Hello, Joe!":utf8>>
118 | }
119 |
120 | pub fn request_query_string_test() {
121 | let request = simulate.request(http.Patch, "/wibble/woo?one=two&three=four")
122 |
123 | assert request.host == "wisp.example.com"
124 | assert request.path == "/wibble/woo"
125 | assert request.query == option.Some("one=two&three=four")
126 | }
127 |
128 | pub fn header_test() {
129 | let request = simulate.request(http.Get, "/")
130 |
131 | assert request.headers == [#("host", "wisp.example.com")]
132 |
133 | // Set new headers
134 | let request =
135 | request
136 | |> simulate.header("content-type", "application/json")
137 | |> simulate.header("accept", "application/json")
138 | assert request.headers
139 | == [
140 | #("host", "wisp.example.com"),
141 | #("content-type", "application/json"),
142 | #("accept", "application/json"),
143 | ]
144 |
145 | // Replace the header
146 | let request = simulate.header(request, "content-type", "text/plain")
147 | assert request.headers
148 | == [
149 | #("host", "wisp.example.com"),
150 | #("content-type", "text/plain"),
151 | #("accept", "application/json"),
152 | ]
153 | }
154 |
155 | pub fn cookie_plain_text_test() {
156 | let req =
157 | simulate.browser_request(http.Get, "/")
158 | |> simulate.cookie("abc", "1234", wisp.PlainText)
159 | |> simulate.cookie("def", "5678", wisp.PlainText)
160 | assert req.headers
161 | == [
162 | #("cookie", "abc=MTIzNA; def=NTY3OA"),
163 | #("origin", "https://wisp.example.com"),
164 | #("host", "wisp.example.com"),
165 | ]
166 | }
167 |
168 | pub fn cookie_signed_test() {
169 | let req =
170 | simulate.browser_request(http.Get, "/")
171 | |> simulate.cookie("abc", "1234", wisp.Signed)
172 | |> simulate.cookie("def", "5678", wisp.Signed)
173 | assert req.headers
174 | == [
175 | #(
176 | "cookie",
177 | "abc=SFM1MTI.MTIzNA.QWGuB_lZLssnh71rC6R5_WOr8MDr8dxE3C_2JvLRAAC4ad4SnmQk0Fl_6_RrtmzdH2O3WaNPExkJsuwBixtWIA; def=SFM1MTI.NTY3OA.R3HRe5woa1qwxvjRUC5ggQVd3hTqGCXIk_4ybU35SXPtGvLrFpHBXWGIjyG5QeuEk9j3jnWIL3ct18olJiSCMw",
178 | ),
179 | #("origin", "https://wisp.example.com"),
180 | #("host", "wisp.example.com"),
181 | ]
182 | }
183 |
184 | pub fn session_test() {
185 | // Initial cookies
186 | let request =
187 | simulate.browser_request(http.Get, "/")
188 | |> simulate.cookie("zero", "0", wisp.PlainText)
189 | |> simulate.cookie("one", "1", wisp.PlainText)
190 | |> simulate.cookie("two", "2", wisp.PlainText)
191 | assert list.key_find(request.headers, "cookie")
192 | == Ok("zero=MA; one=MQ; two=Mg")
193 |
194 | // A response from the server that changes the cookies.
195 | // - one: changed value
196 | // - two: expired
197 | // - three: newly added
198 | let response =
199 | wisp.ok()
200 | |> wisp.set_cookie(request, "one", "11", wisp.PlainText, 100)
201 | |> wisp.set_cookie(request, "two", "2", wisp.PlainText, 0)
202 | |> wisp.set_cookie(request, "three", "3", wisp.PlainText, 100)
203 |
204 | // Continue the session
205 | let request =
206 | simulate.browser_request(http.Get, "/")
207 | |> simulate.session(request, response)
208 |
209 | assert list.key_find(request.headers, "cookie")
210 | == Ok("zero=MA; one=MTE; three=Mw")
211 | }
212 |
213 | pub fn multipart_body_test() {
214 | let file1 = simulate.FileUpload("test.txt", "text/plain", <<"Hello, world!">>)
215 | let file2 =
216 | simulate.FileUpload("data.bin", "application/octet-stream", <<
217 | 1,
218 | 2,
219 | 3,
220 | 4,
221 | >>)
222 |
223 | let request =
224 | simulate.request(http.Post, "/upload")
225 | |> simulate.multipart_body(
226 | [#("name", "test"), #("description", "A test file")],
227 | [#("file1", file1), #("file2", file2)],
228 | )
229 |
230 | let assert Ok(content_type) = list.key_find(request.headers, "content-type")
231 | assert string.starts_with(content_type, "multipart/form-data; boundary=")
232 |
233 | {
234 | use formdata <- wisp.require_form(request)
235 | let assert [#("file1", file1), #("file2", file2)] = formdata.files
236 | assert "test.txt" == file1.file_name
237 | assert simplifile.read(file1.path) == Ok("Hello, world!")
238 | assert "data.bin" == file2.file_name
239 | assert simplifile.read_bits(file2.path) == Ok(<<1, 2, 3, 4>>)
240 | wisp.ok()
241 | }
242 | }
243 |
244 | pub fn multipart_generation_validation_test() {
245 | let file = simulate.FileUpload("test.txt", "text/plain", <<"Hello, world!">>)
246 | let request =
247 | simulate.browser_request(http.Post, "/upload")
248 | |> simulate.multipart_body([#("name", "test")], [#("uploaded-file", file)])
249 |
250 | let assert Ok("multipart/form-data; boundary=" <> boundary) =
251 | list.key_find(request.headers, "content-type")
252 |
253 | let assert Ok(body) = wisp.read_body_bits(request)
254 | let expected_body =
255 | "--"
256 | <> boundary
257 | <> "\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\ntest\r\n--"
258 | <> boundary
259 | <> "\r\nContent-Disposition: form-data; name=\"uploaded-file\"; filename=\"test.txt\"\r\nContent-Type: text/plain\r\n\r\nHello, world!\r\n--"
260 | <> boundary
261 | <> "--\r\n"
262 | assert body == <>
263 | }
264 |
--------------------------------------------------------------------------------
/LICENCE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | Copyright 2023, Louis Pilfold .
179 |
180 | Licensed under the Apache License, Version 2.0 (the "License");
181 | you may not use this file except in compliance with the License.
182 | You may obtain a copy of the License at
183 |
184 | http://www.apache.org/licenses/LICENSE-2.0
185 |
186 | Unless required by applicable law or agreed to in writing, software
187 | distributed under the License is distributed on an "AS IS" BASIS,
188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189 | See the License for the specific language governing permissions and
190 | limitations under the License.
191 |
192 |
--------------------------------------------------------------------------------
/src/wisp/simulate.gleam:
--------------------------------------------------------------------------------
1 | import gleam/bit_array
2 | import gleam/bytes_tree
3 | import gleam/crypto
4 | import gleam/http
5 | import gleam/http/request
6 | import gleam/json.{type Json}
7 | import gleam/list
8 | import gleam/option.{None, Some}
9 | import gleam/result
10 | import gleam/string
11 | import gleam/uri
12 | import simplifile
13 | import wisp.{type Request, type Response, Bytes, File, Text}
14 |
15 | /// Create a test request that can be used to test your request handler
16 | /// functions.
17 | ///
18 | /// If you are testing handlers that are intended to be accessed from a browser
19 | /// (such as those that use cookies) consider using `browser_request` instead.
20 | ///
21 | pub fn request(method: http.Method, path: String) -> Request {
22 | let #(path, query) = case string.split_once(path, "?") {
23 | Ok(#(path, query)) -> #(path, Some(query))
24 | _ -> #(path, None)
25 | }
26 | let connection = wisp.create_canned_connection(<<>>, default_secret_key_base)
27 | request.Request(
28 | method: method,
29 | headers: default_headers,
30 | body: connection,
31 | scheme: http.Https,
32 | host: default_host,
33 | port: None,
34 | path: path,
35 | query: query,
36 | )
37 | }
38 |
39 | /// Create a test request with browser-set headers that can be used to test
40 | /// your request handler functions.
41 | ///
42 | /// The `origin` header is set when using this function.
43 | ///
44 | pub fn browser_request(method: http.Method, path: String) -> Request {
45 | request.Request(..request(method, path), headers: default_browser_headers)
46 | }
47 |
48 | /// Continue a browser session from a previous request and response, adopting
49 | /// the request cookies, and updating the cookies as specified by the response.
50 | ///
51 | pub fn session(
52 | next_request: Request,
53 | previous_request: Request,
54 | previous_response: Response,
55 | ) -> Request {
56 | let request = case list.key_find(previous_request.headers, "cookie") {
57 | Ok(cookies) -> header(next_request, "cookie", cookies)
58 | Error(_) -> next_request
59 | }
60 |
61 | let set_cookies =
62 | // Get the newly set cookies
63 | list.key_filter(previous_response.headers, "set-cookie")
64 | // Parse them to get the name, value, and attributes
65 | |> list.map(fn(cookie) {
66 | case string.split_once(cookie, ";") {
67 | Ok(#(cookie, attributes)) -> {
68 | let attributes =
69 | string.split(attributes, ";") |> list.map(string.trim)
70 | #(cookie, attributes)
71 | }
72 | Error(Nil) -> #(cookie, [])
73 | }
74 | })
75 | |> list.filter_map(fn(cookie) {
76 | string.split_once(cookie.0, "=")
77 | |> result.map(fn(split) { #(split.0, split.1, cookie.1) })
78 | })
79 |
80 | // Set or remove the cookies as needed on the request
81 | list.fold(set_cookies, request, fn(request, cookie) {
82 | case list.contains(cookie.2, "Max-Age=0") {
83 | True -> request.remove_cookie(request, cookie.0)
84 | False -> request.set_cookie(request, cookie.0, cookie.1)
85 | }
86 | })
87 | }
88 |
89 | /// Add a text body to the request.
90 | ///
91 | /// The `content-type` header is set to `text/plain`. You may want to override
92 | /// this with `request.set_header`.
93 | ///
94 | pub fn string_body(request: Request, text: String) -> Request {
95 | let body =
96 | text
97 | |> bit_array.from_string
98 | |> wisp.create_canned_connection(default_secret_key_base)
99 | request
100 | |> request.set_body(body)
101 | |> request.set_header("content-type", "text/plain")
102 | }
103 |
104 | /// Add a binary body to the request.
105 | ///
106 | /// The `content-type` header is set to `application/octet-stream`. You may
107 | /// want to override/ this with `request.set_header`.
108 | ///
109 | pub fn bit_array_body(request: Request, data: BitArray) -> Request {
110 | let body = wisp.create_canned_connection(data, default_secret_key_base)
111 | request
112 | |> request.set_body(body)
113 | |> request.set_header("content-type", "application/octet-stream")
114 | }
115 |
116 | /// Add HTML body to the request.
117 | ///
118 | /// The `content-type` header is set to `text/html; charset=utf-8`.
119 | ///
120 | pub fn html_body(request: Request, html: String) -> Request {
121 | let body =
122 | html
123 | |> bit_array.from_string
124 | |> wisp.create_canned_connection(default_secret_key_base)
125 | request
126 | |> request.set_body(body)
127 | |> request.set_header("content-type", "text/html; charset=utf-8")
128 | }
129 |
130 | /// Add a form data body to the request.
131 | ///
132 | /// The `content-type` header is set to `application/x-www-form-urlencoded`.
133 | ///
134 | pub fn form_body(request: Request, data: List(#(String, String))) -> Request {
135 | let body =
136 | uri.query_to_string(data)
137 | |> bit_array.from_string
138 | |> wisp.create_canned_connection(default_secret_key_base)
139 | request
140 | |> request.set_body(body)
141 | |> request.set_header("content-type", "application/x-www-form-urlencoded")
142 | }
143 |
144 | /// Add a JSON body to the request.
145 | ///
146 | /// The `content-type` header is set to `application/json`.
147 | ///
148 | pub fn json_body(request: Request, data: Json) -> Request {
149 | let body =
150 | json.to_string(data)
151 | |> bit_array.from_string
152 | |> wisp.create_canned_connection(default_secret_key_base)
153 | request
154 | |> request.set_body(body)
155 | |> request.set_header("content-type", "application/json")
156 | }
157 |
158 | /// Represents a file to be uploaded in a multipart form.
159 | ///
160 | pub type FileUpload {
161 | FileUpload(file_name: String, content_type: String, content: BitArray)
162 | }
163 |
164 | /// Add a multipart/form-data body to the request for testing file uploads
165 | /// and form submissions.
166 | ///
167 | /// The `content-type` header is set to `multipart/form-data` with an
168 | /// appropriate boundary.
169 | ///
170 | /// # Examples
171 | ///
172 | /// ```gleam
173 | /// let file = UploadedFile(
174 | /// file_name: "test.txt",
175 | /// content_type: "text/plain",
176 | /// content: <<"Hello, world!":utf8>>
177 | /// )
178 | ///
179 | /// simulate.request(http.Post, "/upload")
180 | /// |> simulate.multipart_body([#("user", "joe")], [#("file", file)])
181 | /// ```
182 | ///
183 | pub fn multipart_body(
184 | request: Request,
185 | values values: List(#(String, String)),
186 | files files: List(#(String, FileUpload)),
187 | ) -> Request {
188 | let boundary = crypto.strong_random_bytes(16) |> bit_array.base16_encode
189 | let body_data = build_multipart_body(values, files, boundary)
190 | let body = wisp.create_canned_connection(body_data, default_secret_key_base)
191 |
192 | request
193 | |> request.set_body(body)
194 | |> request.set_header(
195 | "content-type",
196 | "multipart/form-data; boundary=" <> boundary,
197 | )
198 | }
199 |
200 | fn build_multipart_body(
201 | form_values: List(#(String, String)),
202 | files: List(#(String, FileUpload)),
203 | boundary: String,
204 | ) -> BitArray {
205 | // Append form parts
206 | let body =
207 | list.fold(form_values, <<>>, fn(acc, field) {
208 | let #(name, value) = field
209 | // Append this part to accumulator
210 | <<
211 | acc:bits,
212 | "--":utf8,
213 | boundary:utf8,
214 | "\r\n":utf8,
215 | "Content-Disposition: form-data; name=\"":utf8,
216 | name:utf8,
217 | "\"\r\n":utf8,
218 | "\r\n":utf8,
219 | value:utf8,
220 | "\r\n":utf8,
221 | >>
222 | })
223 | |> list.fold(files, _, fn(acc, file) {
224 | // Append this file part to accumulator
225 | <<
226 | acc:bits,
227 | "--":utf8,
228 | boundary:utf8,
229 | "\r\n":utf8,
230 | "Content-Disposition: form-data; name=\"":utf8,
231 | file.0:utf8,
232 | "\"; filename=\"":utf8,
233 | { file.1 }.file_name:utf8,
234 | "\"\r\n":utf8,
235 | "Content-Type: ":utf8,
236 | { file.1 }.content_type:utf8,
237 | "\r\n":utf8,
238 | "\r\n":utf8,
239 | { file.1 }.content:bits,
240 | "\r\n":utf8,
241 | >>
242 | })
243 |
244 | // Append final boundary
245 | <>
246 | }
247 |
248 | /// Read a text body from a response.
249 | ///
250 | /// # Panics
251 | ///
252 | /// This function will panic if the response body is a file and the file cannot
253 | /// be read, or if it does not contain valid UTF-8.
254 | ///
255 | pub fn read_body(response: Response) -> String {
256 | case response.body {
257 | Text(tree) -> tree
258 | Bytes(bytes) -> {
259 | let data = bytes_tree.to_bit_array(bytes)
260 | let assert Ok(string) = bit_array.to_string(data)
261 | as "the response body was non-UTF8 binary data"
262 | string
263 | }
264 | File(path:, offset: 0, limit: None) -> {
265 | let assert Ok(data) = simplifile.read_bits(path)
266 | as "the body was a file, but the file could not be read"
267 | let assert Ok(contents) = bit_array.to_string(data)
268 | as "the body file was not valid UTF-8"
269 | contents
270 | }
271 | File(path:, offset:, limit:) -> {
272 | let assert Ok(data) = simplifile.read_bits(path)
273 | as "the body was a file, but the file could not be read"
274 | let byte_length =
275 | limit |> option.unwrap(bit_array.byte_size(data) - offset)
276 | let assert Ok(slice) = bit_array.slice(data, offset, byte_length)
277 | as "the body was a file, but the limit and offset were invalid"
278 | let assert Ok(string) = bit_array.to_string(slice)
279 | as "the body file range was not valid UTF-8"
280 | string
281 | }
282 | }
283 | }
284 |
285 | /// Read a binary data body from a response.
286 | ///
287 | /// # Panics
288 | ///
289 | /// This function will panic if the response body is a file and the file cannot
290 | /// be read.
291 | ///
292 | pub fn read_body_bits(response: Response) -> BitArray {
293 | case response.body {
294 | Bytes(tree) -> bytes_tree.to_bit_array(tree)
295 | Text(tree) -> <>
296 | File(path:, offset: 0, limit: None) -> {
297 | let assert Ok(contents) = simplifile.read_bits(path)
298 | as "the response body was a file, but the file could not be read"
299 | contents
300 | }
301 | File(path:, offset:, limit:) -> {
302 | let assert Ok(contents) = simplifile.read_bits(path)
303 | as "the body was a file, but the file could not be read"
304 | let limit = limit |> option.unwrap(bit_array.byte_size(contents))
305 | let assert Ok(sliced) = contents |> bit_array.slice(offset, limit)
306 | as "the body was a file, but the limit and offset were invalid"
307 | sliced
308 | }
309 | }
310 | }
311 |
312 | /// Set a header on a request.
313 | ///
314 | pub fn header(request: Request, name: String, value: String) -> Request {
315 | request.set_header(request, name, value)
316 | }
317 |
318 | /// Set a cookie on the request.
319 | ///
320 | pub fn cookie(
321 | request: Request,
322 | name: String,
323 | value: String,
324 | security: wisp.Security,
325 | ) -> Request {
326 | let value = case security {
327 | wisp.PlainText -> bit_array.base64_encode(<>, False)
328 | wisp.Signed -> wisp.sign_message(request, <>, crypto.Sha512)
329 | }
330 | request.set_cookie(request, name, value)
331 | }
332 |
333 | /// The default secret key base used for test requests.
334 | /// This should never be used outside of tests.
335 | ///
336 | pub const default_secret_key_base: String = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
337 |
338 | /// The default host for test requests.
339 | ///
340 | pub const default_host: String = "wisp.example.com"
341 |
342 | /// The default headers for non-browser requests.
343 | ///
344 | pub const default_headers: List(#(String, String)) = [#("host", default_host)]
345 |
346 | /// The default headers for browser requests.
347 | ///
348 | pub const default_browser_headers: List(#(String, String)) = [
349 | #("origin", "https://" <> default_host),
350 | #("host", default_host),
351 | ]
352 |
--------------------------------------------------------------------------------
/test/wisp/csrf_test.gleam:
--------------------------------------------------------------------------------
1 | import gleam/http
2 | import gleam/http/request
3 | import gleam/list
4 | import gleam/result
5 | import helper
6 | import wisp
7 | import wisp/simulate
8 |
9 | const expected_cookie_value = "123"
10 |
11 | fn cookies_handler_with_csrf_protection(
12 | request: wisp.Request,
13 | callback: fn() -> t,
14 | ) -> wisp.Response {
15 | use request <- wisp.csrf_known_header_protection(request)
16 | let cookie = wisp.get_cookie(request, "data", wisp.PlainText)
17 | callback()
18 | wisp.ok()
19 | |> wisp.string_body(cookie |> result.unwrap(""))
20 | }
21 |
22 | fn delete_header(request: wisp.Request, name: String) -> wisp.Request {
23 | request.Request(
24 | ..request,
25 | headers: list.filter(request.headers, fn(header) { header.0 != name }),
26 | )
27 | }
28 |
29 | /// Here we are testing that the test helper functions set the appropriate
30 | /// headers for tests to pass when the CSRF protection middlware is in place.
31 | fn send_cookie_request_with_test_helper(method: http.Method) -> wisp.Response {
32 | let request =
33 | simulate.browser_request(http.Get, "/")
34 | |> simulate.header("cookie", "data=MTIz")
35 | |> request.set_method(method)
36 | use <- helper.disable_logger
37 | cookies_handler_with_csrf_protection(request, fn() { Nil })
38 | }
39 |
40 | pub fn test_helper_method_get_test() {
41 | assert send_cookie_request_with_test_helper(http.Get)
42 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
43 | }
44 |
45 | pub fn test_helper_method_head_test() {
46 | assert send_cookie_request_with_test_helper(http.Head)
47 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
48 | }
49 |
50 | pub fn test_helper_method_post_test() {
51 | assert send_cookie_request_with_test_helper(http.Post)
52 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
53 | }
54 |
55 | pub fn test_helper_method_put_test() {
56 | assert send_cookie_request_with_test_helper(http.Put)
57 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
58 | }
59 |
60 | pub fn test_helper_method_delete_test() {
61 | assert send_cookie_request_with_test_helper(http.Delete)
62 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
63 | }
64 |
65 | pub fn test_helper_method_connect_test() {
66 | assert send_cookie_request_with_test_helper(http.Connect)
67 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
68 | }
69 |
70 | pub fn test_helper_method_options_test() {
71 | assert send_cookie_request_with_test_helper(http.Options)
72 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
73 | }
74 |
75 | pub fn test_helper_method_trace_test() {
76 | assert send_cookie_request_with_test_helper(http.Trace)
77 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
78 | }
79 |
80 | pub fn test_helper_method_patch_test() {
81 | assert send_cookie_request_with_test_helper(http.Patch)
82 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
83 | }
84 |
85 | /// Here we are explictly setting the origin and host headers, rather than rely
86 | /// on the test helper. This is to be extra sure the protection is implemented
87 | /// correctly, being as clear as possible.
88 | fn send_cookie_request_with_explicit_matched_origin_header(
89 | method: http.Method,
90 | ) -> wisp.Response {
91 | let request =
92 | simulate.request(http.Get, "/")
93 | |> simulate.header("cookie", "data=MTIz")
94 | |> request.set_method(method)
95 | |> request.set_header("host", "example.com")
96 | |> request.set_header("origin", "https://example.com")
97 | |> delete_header("referer")
98 | use <- helper.disable_logger
99 | cookies_handler_with_csrf_protection(request, fn() { Nil })
100 | }
101 |
102 | pub fn explicitly_matching_origin_method_get_test() {
103 | assert send_cookie_request_with_explicit_matched_origin_header(http.Get)
104 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
105 | }
106 |
107 | pub fn explicitly_matching_origin_method_head_test() {
108 | assert send_cookie_request_with_explicit_matched_origin_header(http.Head)
109 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
110 | }
111 |
112 | pub fn explicitly_matching_origin_method_post_test() {
113 | assert send_cookie_request_with_explicit_matched_origin_header(http.Post)
114 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
115 | }
116 |
117 | pub fn explicitly_matching_origin_method_put_test() {
118 | assert send_cookie_request_with_explicit_matched_origin_header(http.Put)
119 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
120 | }
121 |
122 | pub fn explicitly_matching_origin_method_delete_test() {
123 | assert send_cookie_request_with_explicit_matched_origin_header(http.Delete)
124 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
125 | }
126 |
127 | pub fn explicitly_matching_origin_method_connect_test() {
128 | assert send_cookie_request_with_explicit_matched_origin_header(http.Connect)
129 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
130 | }
131 |
132 | pub fn explicitly_matching_origin_method_options_test() {
133 | assert send_cookie_request_with_explicit_matched_origin_header(http.Options)
134 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
135 | }
136 |
137 | pub fn explicitly_matching_origin_method_trace_test() {
138 | assert send_cookie_request_with_explicit_matched_origin_header(http.Trace)
139 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
140 | }
141 |
142 | pub fn explicitly_matching_origin_method_patch_test() {
143 | assert send_cookie_request_with_explicit_matched_origin_header(http.Patch)
144 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
145 | }
146 |
147 | /// Here we are explictly setting the referer and host headers, rather than rely
148 | /// on the test helper. This is to be extra sure the protection is implemented
149 | /// correctly, being as clear as possible.
150 | fn send_cookie_request_with_explicit_matched_referer_header(
151 | method: http.Method,
152 | ) -> wisp.Response {
153 | let request =
154 | simulate.request(http.Get, "/")
155 | |> simulate.header("cookie", "data=MTIz")
156 | |> request.set_method(method)
157 | |> request.set_header("host", "example.com")
158 | |> delete_header("origin")
159 | |> request.set_header("referer", "https://example.com")
160 | use <- helper.disable_logger
161 | cookies_handler_with_csrf_protection(request, fn() { Nil })
162 | }
163 |
164 | pub fn explicitly_matching_referer_method_get_test() {
165 | assert send_cookie_request_with_explicit_matched_referer_header(http.Get)
166 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
167 | }
168 |
169 | pub fn explicitly_matching_referer_method_head_test() {
170 | assert send_cookie_request_with_explicit_matched_referer_header(http.Head)
171 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
172 | }
173 |
174 | pub fn explicitly_matching_referer_method_post_test() {
175 | assert send_cookie_request_with_explicit_matched_referer_header(http.Post)
176 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
177 | }
178 |
179 | pub fn explicitly_matching_referer_method_put_test() {
180 | assert send_cookie_request_with_explicit_matched_referer_header(http.Put)
181 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
182 | }
183 |
184 | pub fn explicitly_matching_referer_method_delete_test() {
185 | assert send_cookie_request_with_explicit_matched_referer_header(http.Delete)
186 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
187 | }
188 |
189 | pub fn explicitly_matching_referer_method_connect_test() {
190 | assert send_cookie_request_with_explicit_matched_referer_header(http.Connect)
191 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
192 | }
193 |
194 | pub fn explicitly_matching_referer_method_options_test() {
195 | assert send_cookie_request_with_explicit_matched_referer_header(http.Options)
196 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
197 | }
198 |
199 | pub fn explicitly_matching_referer_method_trace_test() {
200 | assert send_cookie_request_with_explicit_matched_referer_header(http.Trace)
201 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
202 | }
203 |
204 | pub fn explicitly_matching_referer_method_patch_test() {
205 | assert send_cookie_request_with_explicit_matched_referer_header(http.Patch)
206 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
207 | }
208 |
209 | fn send_cookie_request_with_mismatched_origin_header(
210 | method: http.Method,
211 | should_pass: Bool,
212 | ) -> wisp.Response {
213 | let request =
214 | simulate.request(http.Get, "/")
215 | |> simulate.header("cookie", "data=MTIz")
216 | |> request.set_method(method)
217 | |> request.set_header("host", "one.example.com")
218 | |> request.set_header("origin", "https://two.example.com")
219 | |> delete_header("referer")
220 | use <- helper.disable_logger
221 | cookies_handler_with_csrf_protection(request, fn() {
222 | case should_pass {
223 | True -> Nil
224 | False -> {
225 | panic as { http.method_to_string(method) <> " should not run" }
226 | }
227 | }
228 | })
229 | }
230 |
231 | pub fn mismatched_origin_headers_method_get_test() {
232 | assert send_cookie_request_with_mismatched_origin_header(http.Get, True)
233 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
234 | }
235 |
236 | pub fn mismatched_origin_headers_method_head_test() {
237 | assert send_cookie_request_with_mismatched_origin_header(http.Head, True)
238 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
239 | }
240 |
241 | pub fn mismatched_origin_headers_method_post_test() {
242 | assert send_cookie_request_with_mismatched_origin_header(http.Post, False)
243 | == wisp.bad_request("Invalid origin")
244 | }
245 |
246 | pub fn mismatched_origin_headers_method_put_test() {
247 | assert send_cookie_request_with_mismatched_origin_header(http.Put, False)
248 | == wisp.bad_request("Invalid origin")
249 | }
250 |
251 | pub fn mismatched_origin_headers_method_delete_test() {
252 | assert send_cookie_request_with_mismatched_origin_header(http.Delete, False)
253 | == wisp.bad_request("Invalid origin")
254 | }
255 |
256 | pub fn mismatched_origin_headers_method_connect_test() {
257 | assert send_cookie_request_with_mismatched_origin_header(http.Connect, False)
258 | == wisp.bad_request("Invalid origin")
259 | }
260 |
261 | pub fn mismatched_origin_headers_method_options_test() {
262 | assert send_cookie_request_with_mismatched_origin_header(http.Options, False)
263 | == wisp.bad_request("Invalid origin")
264 | }
265 |
266 | pub fn mismatched_origin_headers_method_trace_test() {
267 | assert send_cookie_request_with_mismatched_origin_header(http.Trace, False)
268 | == wisp.bad_request("Invalid origin")
269 | }
270 |
271 | pub fn mismatched_origin_headers_method_patch_test() {
272 | assert send_cookie_request_with_mismatched_origin_header(http.Patch, False)
273 | == wisp.bad_request("Invalid origin")
274 | }
275 |
276 | fn send_cookie_request_with_mismatched_referer_header(
277 | method: http.Method,
278 | should_pass: Bool,
279 | ) -> wisp.Response {
280 | let request =
281 | simulate.request(http.Get, "/")
282 | |> simulate.header("cookie", "data=MTIz")
283 | |> request.set_method(method)
284 | |> request.set_header("host", "one.example.com")
285 | |> delete_header("origin")
286 | |> request.set_header("referer", "https://two.example.com")
287 | use <- helper.disable_logger
288 | cookies_handler_with_csrf_protection(request, fn() {
289 | case should_pass {
290 | True -> Nil
291 | False -> {
292 | panic as { http.method_to_string(method) <> " should not run" }
293 | }
294 | }
295 | })
296 | }
297 |
298 | pub fn mismatched_referer_headers_method_get_test() {
299 | assert send_cookie_request_with_mismatched_referer_header(http.Get, True)
300 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
301 | }
302 |
303 | pub fn mismatched_referer_headers_method_head_test() {
304 | assert send_cookie_request_with_mismatched_referer_header(http.Head, True)
305 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
306 | }
307 |
308 | pub fn mismatched_referer_headers_method_post_test() {
309 | assert send_cookie_request_with_mismatched_referer_header(http.Post, False)
310 | == wisp.bad_request("Invalid origin")
311 | }
312 |
313 | pub fn mismatched_referer_headers_method_put_test() {
314 | assert send_cookie_request_with_mismatched_referer_header(http.Put, False)
315 | == wisp.bad_request("Invalid origin")
316 | }
317 |
318 | pub fn mismatched_referer_headers_method_delete_test() {
319 | assert send_cookie_request_with_mismatched_referer_header(http.Delete, False)
320 | == wisp.bad_request("Invalid origin")
321 | }
322 |
323 | pub fn mismatched_referer_headers_method_connect_test() {
324 | assert send_cookie_request_with_mismatched_referer_header(http.Connect, False)
325 | == wisp.bad_request("Invalid origin")
326 | }
327 |
328 | pub fn mismatched_referer_headers_method_options_test() {
329 | assert send_cookie_request_with_mismatched_referer_header(http.Options, False)
330 | == wisp.bad_request("Invalid origin")
331 | }
332 |
333 | pub fn mismatched_referer_headers_method_trace_test() {
334 | assert send_cookie_request_with_mismatched_referer_header(http.Trace, False)
335 | == wisp.bad_request("Invalid origin")
336 | }
337 |
338 | pub fn mismatched_referer_headers_method_patch_test() {
339 | assert send_cookie_request_with_mismatched_referer_header(http.Patch, False)
340 | == wisp.bad_request("Invalid origin")
341 | }
342 |
343 | /// This shouldn't be possible with HTTP1 or HTTP2, but let's test it in case
344 | /// something goes wrong or we end up with HTTP3.
345 | fn send_cookie_request_with_no_host_header(
346 | method: http.Method,
347 | should_pass: Bool,
348 | ) -> wisp.Response {
349 | let request =
350 | simulate.request(http.Get, "/")
351 | |> simulate.header("cookie", "data=MTIz")
352 | |> request.set_method(method)
353 | |> delete_header("host")
354 | |> request.set_header("origin", "https://two.example.com")
355 | |> delete_header("referer")
356 | use <- helper.disable_logger
357 | cookies_handler_with_csrf_protection(request, fn() {
358 | case should_pass {
359 | True -> Nil
360 | False -> {
361 | panic as { http.method_to_string(method) <> " should not run" }
362 | }
363 | }
364 | })
365 | }
366 |
367 | pub fn missing_host_header_method_get_test() {
368 | assert send_cookie_request_with_no_host_header(http.Get, True)
369 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
370 | }
371 |
372 | pub fn missing_host_header_method_head_test() {
373 | assert send_cookie_request_with_no_host_header(http.Head, True)
374 | == wisp.ok() |> wisp.string_body(expected_cookie_value)
375 | }
376 |
377 | pub fn missing_host_header_method_post_test() {
378 | assert send_cookie_request_with_no_host_header(http.Post, False)
379 | == wisp.bad_request("Invalid host")
380 | }
381 |
382 | pub fn missing_host_header_method_put_test() {
383 | assert send_cookie_request_with_no_host_header(http.Put, False)
384 | == wisp.bad_request("Invalid host")
385 | }
386 |
387 | pub fn missing_host_header_method_delete_test() {
388 | assert send_cookie_request_with_no_host_header(http.Delete, False)
389 | == wisp.bad_request("Invalid host")
390 | }
391 |
392 | pub fn missing_host_header_method_connect_test() {
393 | assert send_cookie_request_with_no_host_header(http.Connect, False)
394 | == wisp.bad_request("Invalid host")
395 | }
396 |
397 | pub fn missing_host_header_method_options_test() {
398 | assert send_cookie_request_with_no_host_header(http.Options, False)
399 | == wisp.bad_request("Invalid host")
400 | }
401 |
402 | pub fn missing_host_header_method_trace_test() {
403 | assert send_cookie_request_with_no_host_header(http.Trace, False)
404 | == wisp.bad_request("Invalid host")
405 | }
406 |
407 | pub fn missing_host_header_method_patch_test() {
408 | assert send_cookie_request_with_no_host_header(http.Patch, False)
409 | == wisp.bad_request("Invalid host")
410 | }
411 |
--------------------------------------------------------------------------------
/test/wisp_test.gleam:
--------------------------------------------------------------------------------
1 | import exception
2 | import gleam/bit_array
3 | import gleam/bytes_tree
4 | import gleam/crypto
5 | import gleam/dynamic.{type Dynamic}
6 | import gleam/http
7 | import gleam/http/request
8 | import gleam/http/response.{Response}
9 | import gleam/int
10 | import gleam/list
11 | import gleam/option
12 | import gleam/result
13 | import gleam/set
14 | import gleam/string
15 | import gleam/string_tree
16 | import gleeunit
17 | import helper
18 | import simplifile
19 | import wisp
20 | import wisp/internal
21 | import wisp/simulate
22 |
23 | pub fn main() {
24 | wisp.configure_logger()
25 | gleeunit.main()
26 | }
27 |
28 | fn form_handler(
29 | request: wisp.Request,
30 | callback: fn(wisp.FormData) -> anything,
31 | ) -> wisp.Response {
32 | use form <- wisp.require_form(request)
33 | callback(form)
34 | wisp.ok()
35 | }
36 |
37 | fn json_handler(
38 | request: wisp.Request,
39 | callback: fn(Dynamic) -> anything,
40 | ) -> wisp.Response {
41 | use json <- wisp.require_json(request)
42 | callback(json)
43 | wisp.ok()
44 | }
45 |
46 | fn static_file_handler(request: wisp.Request) -> wisp.Response {
47 | use <- wisp.serve_static(request, under: "/", from: "./test")
48 | wisp.ok()
49 | }
50 |
51 | pub fn ok_test() {
52 | assert wisp.ok()
53 | == Response(200, [#("content-type", "text/plain")], wisp.Text("OK"))
54 | }
55 |
56 | pub fn created_test() {
57 | assert wisp.created()
58 | == Response(201, [#("content-type", "text/plain")], wisp.Text("Created"))
59 | }
60 |
61 | pub fn accepted_test() {
62 | assert wisp.accepted()
63 | == Response(202, [#("content-type", "text/plain")], wisp.Text("Accepted"))
64 | }
65 |
66 | pub fn no_content_test() {
67 | assert wisp.no_content() == Response(204, [], wisp.Text(""))
68 | }
69 |
70 | pub fn redirect_test() {
71 | assert wisp.redirect(to: "https://example.com/wibble")
72 | == Response(
73 | 303,
74 | [
75 | #("location", "https://example.com/wibble"),
76 | #("content-type", "text/plain"),
77 | ],
78 | wisp.Text("You are being redirected: https://example.com/wibble"),
79 | )
80 | }
81 |
82 | pub fn moved_permanently_test() {
83 | assert wisp.permanent_redirect(to: "https://example.com/wobble")
84 | == Response(
85 | 308,
86 | [
87 | #("location", "https://example.com/wobble"),
88 | #("content-type", "text/plain"),
89 | ],
90 | wisp.Text("You are being redirected: https://example.com/wobble"),
91 | )
92 | }
93 |
94 | pub fn internal_server_error_test() {
95 | assert wisp.internal_server_error()
96 | == Response(
97 | 500,
98 | [#("content-type", "text/plain")],
99 | wisp.Text("Internal server error"),
100 | )
101 | }
102 |
103 | pub fn content_too_large_test() {
104 | assert wisp.content_too_large()
105 | == Response(
106 | 413,
107 | [#("content-type", "text/plain")],
108 | wisp.Text("Content too large"),
109 | )
110 | }
111 |
112 | pub fn bad_request_test() {
113 | assert wisp.bad_request("")
114 | == Response(
115 | 400,
116 | [#("content-type", "text/plain")],
117 | wisp.Text("Bad request"),
118 | )
119 | }
120 |
121 | pub fn bad_request_with_message_test() {
122 | assert wisp.bad_request("On fire")
123 | == Response(
124 | 400,
125 | [#("content-type", "text/plain")],
126 | wisp.Text("Bad request: On fire"),
127 | )
128 | }
129 |
130 | pub fn not_found_test() {
131 | assert wisp.not_found()
132 | == Response(404, [#("content-type", "text/plain")], wisp.Text("Not found"))
133 | }
134 |
135 | pub fn method_not_allowed_test() {
136 | assert wisp.method_not_allowed([http.Get, http.Patch, http.Delete])
137 | == Response(
138 | 405,
139 | [#("allow", "DELETE, GET, PATCH")],
140 | wisp.Text("Method not allowed"),
141 | )
142 | }
143 |
144 | pub fn unsupported_media_type_test() {
145 | assert wisp.unsupported_media_type(accept: ["application/json", "text/plain"])
146 | == Response(
147 | 415,
148 | [
149 | #("accept", "application/json, text/plain"),
150 | #("content-type", "text/plain"),
151 | ],
152 | wisp.Text("Unsupported media type"),
153 | )
154 | }
155 |
156 | pub fn unprocessable_content_test() {
157 | assert wisp.unprocessable_content()
158 | == Response(
159 | 422,
160 | [#("content-type", "text/plain")],
161 | wisp.Text("Unprocessable content"),
162 | )
163 | }
164 |
165 | pub fn json_response_test() {
166 | let body = "{\"one\":1,\"two\":2}"
167 | let response = wisp.json_response(body, 201)
168 | assert response.status == 201
169 | assert response.headers
170 | == [#("content-type", "application/json; charset=utf-8")]
171 | assert simulate.read_body(response) == "{\"one\":1,\"two\":2}"
172 | }
173 |
174 | pub fn html_response_test() {
175 | let body = "Hello, world!"
176 | let response = wisp.html_response(body, 200)
177 | assert response.status == 200
178 | assert response.headers == [#("content-type", "text/html; charset=utf-8")]
179 | assert simulate.read_body(response) == "Hello, world!"
180 | }
181 |
182 | pub fn html_body_test() {
183 | let body = "Hello, world!"
184 | let response =
185 | wisp.method_not_allowed([http.Get])
186 | |> wisp.html_body(body)
187 | assert response.status == 405
188 | assert response.headers
189 | == [
190 | #("allow", "GET"),
191 | #("content-type", "text/html; charset=utf-8"),
192 | ]
193 | assert simulate.read_body(response) == "Hello, world!"
194 | }
195 |
196 | pub fn random_string_test() {
197 | let count = 10_000
198 | let new = fn(_) {
199 | let random = wisp.random_string(64)
200 | assert string.length(random) == 64
201 | random
202 | }
203 |
204 | assert list.repeat(Nil, count)
205 | |> list.map(new)
206 | |> set.from_list
207 | |> set.size
208 | == count
209 | }
210 |
211 | pub fn set_get_secret_key_base_test() {
212 | let request = simulate.request(http.Get, "/")
213 | let valid = wisp.random_string(64)
214 | let too_short = wisp.random_string(63)
215 |
216 | assert wisp.get_secret_key_base(request) == simulate.default_secret_key_base
217 |
218 | assert request
219 | |> wisp.set_secret_key_base(valid)
220 | |> wisp.get_secret_key_base
221 | == valid
222 |
223 | // Panics if the key is too short
224 | let assert Error(_) =
225 | exception.rescue(fn() { wisp.set_secret_key_base(request, too_short) })
226 | }
227 |
228 | pub fn set_get_max_body_size_test() {
229 | let request = simulate.request(http.Get, "/")
230 |
231 | assert wisp.get_max_body_size(request) == 8_000_000
232 |
233 | assert request
234 | |> wisp.set_max_body_size(10)
235 | |> wisp.get_max_body_size
236 | == 10
237 | }
238 |
239 | pub fn set_get_max_files_size_test() {
240 | let request = simulate.request(http.Get, "/")
241 |
242 | assert wisp.get_max_files_size(request) == 32_000_000
243 |
244 | assert request
245 | |> wisp.set_max_files_size(10)
246 | |> wisp.get_max_files_size
247 | == 10
248 | }
249 |
250 | pub fn set_get_read_chunk_size_test() {
251 | let request = simulate.request(http.Get, "/")
252 |
253 | assert wisp.get_read_chunk_size(request) == 1_000_000
254 |
255 | assert request
256 | |> wisp.set_read_chunk_size(10)
257 | |> wisp.get_read_chunk_size
258 | == 10
259 | }
260 |
261 | pub fn path_segments_test() {
262 | assert request.new()
263 | |> request.set_path("/one/two/three")
264 | |> wisp.path_segments
265 | == ["one", "two", "three"]
266 | }
267 |
268 | pub fn method_override_test() {
269 | // These methods can be overridden to
270 | use method <- list.each([http.Put, http.Delete, http.Patch])
271 |
272 | let request =
273 | request.new()
274 | |> request.set_method(method)
275 | |> request.set_query([#("_method", http.method_to_string(method))])
276 | assert wisp.method_override(request) == request.set_method(request, method)
277 | }
278 |
279 | pub fn method_override_unacceptable_unoriginal_method_test() {
280 | // These methods are not allowed to be overridden
281 | use method <- list.each([
282 | http.Head,
283 | http.Put,
284 | http.Delete,
285 | http.Trace,
286 | http.Connect,
287 | http.Options,
288 | http.Patch,
289 | http.Other("MYSTERY"),
290 | ])
291 |
292 | let request =
293 | request.new()
294 | |> request.set_method(method)
295 | |> request.set_query([#("_method", "DELETE")])
296 | assert wisp.method_override(request) == request
297 | }
298 |
299 | pub fn method_override_unacceptable_target_method_test() {
300 | // These methods are not allowed to be overridden to
301 | use method <- list.each([
302 | http.Get,
303 | http.Head,
304 | http.Trace,
305 | http.Connect,
306 | http.Options,
307 | http.Other("MYSTERY"),
308 | ])
309 |
310 | let request =
311 | request.new()
312 | |> request.set_method(http.Post)
313 | |> request.set_query([#("_method", http.method_to_string(method))])
314 | assert wisp.method_override(request) == request
315 | }
316 |
317 | pub fn require_method_test() {
318 | let response = {
319 | let request = request.new()
320 | use <- wisp.require_method(request, http.Get)
321 | wisp.ok()
322 | }
323 |
324 | assert response == wisp.ok()
325 | }
326 |
327 | pub fn require_method_invalid_test() {
328 | let response = {
329 | let request = request.set_method(request.new(), http.Post)
330 | use <- wisp.require_method(request, http.Get)
331 | panic as "should be unreachable"
332 | }
333 | assert response == wisp.method_not_allowed([http.Get])
334 | }
335 |
336 | pub fn require_string_body_test() {
337 | let response = {
338 | let request =
339 | simulate.request(http.Post, "/")
340 | |> simulate.string_body("Hello, Joe!")
341 | use body <- wisp.require_string_body(request)
342 | assert body == "Hello, Joe!"
343 | wisp.accepted()
344 | }
345 | assert response == wisp.accepted()
346 | }
347 |
348 | pub fn require_string_body_invalid_test() {
349 | let response = {
350 | let request =
351 | simulate.request(http.Post, "/")
352 | |> simulate.bit_array_body(<<254>>)
353 | use _ <- wisp.require_string_body(request)
354 | panic as "should be unreachable"
355 | }
356 | assert response == wisp.bad_request("Invalid UTF-8")
357 | }
358 |
359 | pub fn rescue_crashes_error_test() {
360 | use <- helper.disable_logger()
361 |
362 | let response = {
363 | use <- wisp.rescue_crashes
364 | panic as "we need to crash to test the middleware"
365 | }
366 | assert response == wisp.internal_server_error()
367 | }
368 |
369 | pub fn rescue_crashes_ok_test() {
370 | let response = {
371 | use <- wisp.rescue_crashes
372 | wisp.ok()
373 | }
374 | assert response == wisp.ok()
375 | }
376 |
377 | pub fn serve_static_test() {
378 | let handler = fn(request) {
379 | use <- wisp.serve_static(request, under: "/stuff", from: "./")
380 | wisp.ok()
381 | }
382 |
383 | // Get a text file
384 | let response =
385 | simulate.request(http.Get, "/stuff/test/fixture.txt")
386 | |> handler
387 | let assert Ok(file_info) = simplifile.file_info("test/fixture.txt")
388 | let etag = internal.generate_etag(file_info.size, file_info.mtime_seconds)
389 |
390 | assert response.status == 200
391 | assert response.headers
392 | == [
393 | #("content-type", "text/plain; charset=utf-8"),
394 | #("etag", etag),
395 | ]
396 | assert response.body
397 | == wisp.File("./test/fixture.txt", offset: 0, limit: option.None)
398 |
399 | // Get a json file
400 | let response =
401 | simulate.request(http.Get, "/stuff/test/fixture.json")
402 | |> handler
403 | let assert Ok(file_info) = simplifile.file_info("test/fixture.json")
404 | let etag = internal.generate_etag(file_info.size, file_info.mtime_seconds)
405 |
406 | assert response.status == 200
407 | assert response.headers
408 | == [
409 | #("content-type", "application/json; charset=utf-8"),
410 | #("etag", etag),
411 | ]
412 | assert response.body
413 | == wisp.File("./test/fixture.json", offset: 0, limit: option.None)
414 |
415 | // Get some other file
416 | let response =
417 | simulate.request(http.Get, "/stuff/test/fixture.dat")
418 | |> handler
419 | let assert Ok(file_info) = simplifile.file_info("test/fixture.dat")
420 | let etag = internal.generate_etag(file_info.size, file_info.mtime_seconds)
421 |
422 | assert response.status == 200
423 | assert response.headers
424 | == [
425 | #("content-type", "application/octet-stream"),
426 | #("etag", etag),
427 | ]
428 | assert response.body
429 | == wisp.File("./test/fixture.dat", offset: 0, limit: option.None)
430 |
431 | // Get something not handled by the static file server
432 | let response =
433 | simulate.request(http.Get, "/stuff/this-does-not-exist")
434 | |> handler
435 | assert response.status == 200
436 | assert response.headers == [#("content-type", "text/plain")]
437 | assert response.body == wisp.Text("OK")
438 | }
439 |
440 | pub fn serve_static_directory_request_test() {
441 | let handler = fn(request) {
442 | use <- wisp.serve_static(request, under: "/stuff", from: "./")
443 | wisp.ok()
444 | }
445 |
446 | assert // confirm test directory is a directory
447 | result.unwrap(simplifile.is_directory("./test"), False)
448 |
449 | // Get a directory
450 | let response =
451 | simulate.request(http.Get, "/stuff/test")
452 | |> handler
453 | assert response.status == 200
454 | assert response.headers == [#("content-type", "text/plain")]
455 | assert response.body == wisp.Text("OK")
456 | }
457 |
458 | pub fn serve_static_under_has_no_trailing_slash_test() {
459 | let request =
460 | simulate.request(http.Get, "/")
461 | |> request.set_path("/stuff/test/fixture.txt")
462 | let response = {
463 | use <- wisp.serve_static(request, under: "stuff", from: "./")
464 | wisp.ok()
465 | }
466 | let assert Ok(file_info) = simplifile.file_info("test/fixture.txt")
467 | let etag = internal.generate_etag(file_info.size, file_info.mtime_seconds)
468 |
469 | assert response.status == 200
470 | assert response.headers
471 | == [
472 | #("content-type", "text/plain; charset=utf-8"),
473 | #("etag", etag),
474 | ]
475 | assert response.body
476 | == wisp.File("./test/fixture.txt", offset: 0, limit: option.None)
477 | }
478 |
479 | pub fn serve_static_from_has_no_trailing_slash_test() {
480 | let request =
481 | simulate.request(http.Get, "/")
482 | |> request.set_path("/stuff/test/fixture.txt")
483 | let response = {
484 | use <- wisp.serve_static(request, under: "stuff", from: ".")
485 | wisp.ok()
486 | }
487 | let assert Ok(file_info) = simplifile.file_info("test/fixture.txt")
488 | let etag = internal.generate_etag(file_info.size, file_info.mtime_seconds)
489 |
490 | assert response.status == 200
491 | assert response.headers
492 | == [
493 | #("content-type", "text/plain; charset=utf-8"),
494 | #("etag", etag),
495 | ]
496 | assert response.body
497 | == wisp.File("./test/fixture.txt", offset: 0, limit: option.None)
498 | }
499 |
500 | pub fn serve_static_not_found_test() {
501 | let request =
502 | simulate.request(http.Get, "/")
503 | |> request.set_path("/stuff/credit_card_details.txt")
504 | assert {
505 | use <- wisp.serve_static(request, under: "/stuff", from: "./")
506 | wisp.ok()
507 | }
508 | == wisp.ok()
509 | }
510 |
511 | pub fn serve_static_go_up_test() {
512 | let request =
513 | simulate.request(http.Get, "/")
514 | |> request.set_path("/../test/fixture.txt")
515 | assert {
516 | use <- wisp.serve_static(request, under: "/stuff", from: "./src/")
517 | wisp.ok()
518 | }
519 | == wisp.ok()
520 | }
521 |
522 | pub fn serve_static_etags_returns_304_test() {
523 | let handler = fn(request) {
524 | use <- wisp.serve_static(request, under: "/stuff", from: "./")
525 | wisp.ok()
526 | }
527 |
528 | // Get a text file without any headers
529 | let response =
530 | simulate.request(http.Get, "/stuff/test/fixture.txt")
531 | |> handler
532 | let assert Ok(file_info) = simplifile.file_info("test/fixture.txt")
533 | let etag = internal.generate_etag(file_info.size, file_info.mtime_seconds)
534 |
535 | assert response.status == 200
536 | assert response.headers
537 | == [
538 | #("content-type", "text/plain; charset=utf-8"),
539 | #("etag", etag),
540 | ]
541 | assert response.body
542 | == wisp.File("./test/fixture.txt", offset: 0, limit: option.None)
543 |
544 | // Get a text file with outdated if-none-match header
545 | let response =
546 | simulate.request(http.Get, "/stuff/test/fixture.txt")
547 | |> simulate.header("if-none-match", "invalid-etag")
548 | |> handler
549 |
550 | assert response.status == 200
551 | assert response.headers
552 | == [
553 | #("content-type", "text/plain; charset=utf-8"),
554 | #("etag", etag),
555 | ]
556 | assert response.body
557 | == wisp.File("./test/fixture.txt", offset: 0, limit: option.None)
558 |
559 | // Get a text file with current etag in if-none-match header
560 | let response =
561 | simulate.request(http.Get, "/stuff/test/fixture.txt")
562 | |> simulate.header("if-none-match", etag)
563 | |> handler
564 |
565 | assert response.status == 304
566 | assert response.headers == [#("etag", etag)]
567 | assert response.body == wisp.Text("")
568 | }
569 |
570 | pub fn serve_static_range_start_test() {
571 | let response =
572 | simulate.request(http.Get, "/fixture.txt")
573 | |> simulate.header("range", "bytes=2-")
574 | |> static_file_handler
575 |
576 | assert response.status == 206
577 | assert response.headers
578 | |> list.key_set("etag", "")
579 | == [
580 | #("content-type", "text/plain; charset=utf-8"),
581 | #("etag", ""),
582 | #("content-length", "36"),
583 | #("accept-ranges", "bytes"),
584 | #("content-range", "bytes 2-37/38"),
585 | ]
586 | assert simulate.read_body(response) == "llo, Joe! 👨👩👧👦\n"
587 | }
588 |
589 | pub fn serve_static_range_start_limit_test() {
590 | let response =
591 | simulate.request(http.Get, "/fixture.txt")
592 | |> simulate.header("range", "bytes=2-15")
593 | |> static_file_handler
594 |
595 | assert response.status == 206
596 | assert response.headers
597 | |> list.key_set("etag", "")
598 | == [
599 | #("content-type", "text/plain; charset=utf-8"),
600 | #("etag", ""),
601 | #("content-length", "14"),
602 | #("accept-ranges", "bytes"),
603 | #("content-range", "bytes 2-15/38"),
604 | ]
605 | assert simulate.read_body(response) == "llo, Joe! 👨"
606 | }
607 |
608 | pub fn serve_static_range_negative_test() {
609 | let response =
610 | simulate.request(http.Get, "/fixture.txt")
611 | |> simulate.header("range", "bytes=-26")
612 | |> static_file_handler
613 |
614 | assert response.status == 206
615 | assert response.headers
616 | |> list.key_set("etag", "")
617 | == [
618 | #("content-type", "text/plain; charset=utf-8"),
619 | #("etag", ""),
620 | #("content-length", "26"),
621 | #("accept-ranges", "bytes"),
622 | #("content-range", "bytes 12-37/38"),
623 | ]
624 | assert simulate.read_body(response) == "👨👩👧👦\n"
625 | }
626 |
627 | pub fn serve_static_range_limit_larger_than_content_test() {
628 | let response =
629 | simulate.request(http.Get, "/fixture.txt")
630 | |> simulate.header("range", "bytes=2-100")
631 | |> static_file_handler
632 | assert response.status == 416
633 | }
634 |
635 | pub fn serve_static_range_header_invalid_test() {
636 | // The range values are is backwards
637 | let response =
638 | simulate.request(http.Get, "/fixture.txt")
639 | |> simulate.header("range", "bytes=6-4")
640 | |> static_file_handler
641 |
642 | assert response.status == 416
643 | }
644 |
645 | pub fn serve_static_with_uri_encoding_test() {
646 | let response =
647 | simulate.request(http.Get, "/fixture+123%26%3F.txt")
648 | |> static_file_handler
649 |
650 | assert response.status == 200
651 | }
652 |
653 | pub fn temporary_file_test() {
654 | // Create tmp files for a first request
655 | let request1 = simulate.request(http.Get, "/")
656 | let assert Ok(request1_file1) = wisp.new_temporary_file(request1)
657 | let assert Ok(request1_file2) = wisp.new_temporary_file(request1)
658 |
659 | assert // The files exist
660 | request1_file1 != request1_file2
661 | let assert Ok(_) = simplifile.read(request1_file1)
662 | let assert Ok(_) = simplifile.read(request1_file2)
663 |
664 | // Create tmp files for a second request
665 | let request2 = simulate.request(http.Get, "/")
666 | let assert Ok(request2_file1) = wisp.new_temporary_file(request2)
667 | let assert Ok(request2_file2) = wisp.new_temporary_file(request2)
668 |
669 | assert // The files exist
670 | request2_file1 != request1_file2
671 | let assert Ok(_) = simplifile.read(request2_file1)
672 | let assert Ok(_) = simplifile.read(request2_file2)
673 |
674 | // Delete the files for the first request
675 | let assert Ok(_) = wisp.delete_temporary_files(request1)
676 |
677 | // They no longer exist
678 | let assert Error(simplifile.Enoent) = simplifile.read(request1_file1)
679 | let assert Error(simplifile.Enoent) = simplifile.read(request1_file2)
680 |
681 | // The files for the second request still exist
682 | let assert Ok(_) = simplifile.read(request2_file1)
683 | let assert Ok(_) = simplifile.read(request2_file2)
684 |
685 | // Delete the files for the first request
686 | let assert Ok(_) = wisp.delete_temporary_files(request2)
687 |
688 | // They no longer exist
689 | let assert Error(simplifile.Enoent) = simplifile.read(request2_file1)
690 | let assert Error(simplifile.Enoent) = simplifile.read(request2_file2)
691 | }
692 |
693 | pub fn require_content_type_test() {
694 | let response = {
695 | let request =
696 | simulate.request(http.Get, "/")
697 | |> simulate.header("content-type", "text/plain")
698 | use <- wisp.require_content_type(request, "text/plain")
699 | wisp.ok()
700 | }
701 | assert response == wisp.ok()
702 | }
703 |
704 | pub fn require_content_type_charset_test() {
705 | let response = {
706 | let request =
707 | simulate.request(http.Get, "/")
708 | |> simulate.header("content-type", "text/plain; charset=utf-8")
709 | use <- wisp.require_content_type(request, "text/plain")
710 | wisp.ok()
711 | }
712 | assert response == wisp.ok()
713 | }
714 |
715 | pub fn require_content_type_missing_test() {
716 | let response = {
717 | let request = simulate.request(http.Get, "/")
718 | use <- wisp.require_content_type(request, "text/plain")
719 | wisp.ok()
720 | }
721 | assert response == wisp.unsupported_media_type(["text/plain"])
722 | }
723 |
724 | pub fn require_content_type_invalid_test() {
725 | let response = {
726 | let request =
727 | simulate.request(http.Get, "/")
728 | |> simulate.header("content-type", "text/plain")
729 | use <- wisp.require_content_type(request, "text/html")
730 | panic as "should be unreachable"
731 | }
732 | assert response == wisp.unsupported_media_type(["text/html"])
733 | }
734 |
735 | pub fn json_test() {
736 | assert simulate.request(http.Post, "/")
737 | |> simulate.string_body("{\"one\":1,\"two\":2}")
738 | |> request.set_header("content-type", "application/json")
739 | |> json_handler(fn(json) {
740 | assert json
741 | == dynamic.properties([
742 | #(dynamic.string("one"), dynamic.int(1)),
743 | #(dynamic.string("two"), dynamic.int(2)),
744 | ])
745 | })
746 | == wisp.ok()
747 | }
748 |
749 | pub fn json_wrong_content_type_test() {
750 | assert simulate.request(http.Post, "/")
751 | |> simulate.string_body("{\"one\":1,\"two\":2}")
752 | |> request.set_header("content-type", "text/plain")
753 | |> json_handler(fn(_) { panic as "should be unreachable" })
754 | == wisp.unsupported_media_type(["application/json"])
755 | }
756 |
757 | pub fn json_no_content_type_test() {
758 | assert json_handler(
759 | simulate.request(http.Post, "/")
760 | |> simulate.string_body("{\"one\":1,\"two\":2}"),
761 | fn(_) { panic as "should be unreachable" },
762 | )
763 | == wisp.unsupported_media_type(["application/json"])
764 | }
765 |
766 | pub fn json_too_big_test() {
767 | assert simulate.request(http.Post, "/")
768 | |> simulate.string_body("{\"one\":1,\"two\":2}")
769 | |> wisp.set_max_body_size(1)
770 | |> request.set_header("content-type", "application/json")
771 | |> json_handler(fn(_) { panic as "should be unreachable" })
772 | == Response(
773 | 413,
774 | [#("content-type", "text/plain")],
775 | wisp.Text("Content too large"),
776 | )
777 | }
778 |
779 | pub fn json_syntax_error_test() {
780 | assert simulate.request(http.Post, "/")
781 | |> simulate.string_body("{\"one\":")
782 | |> request.set_header("content-type", "application/json")
783 | |> json_handler(fn(_) { panic as "should be unreachable" })
784 | == Response(
785 | 400,
786 | [#("content-type", "text/plain")],
787 | wisp.Text("Bad request: Invalid JSON"),
788 | )
789 | }
790 |
791 | pub fn urlencoded_form_test() {
792 | assert simulate.request(http.Post, "/")
793 | |> simulate.string_body("one=1&two=2")
794 | |> request.set_header("content-type", "application/x-www-form-urlencoded")
795 | |> form_handler(fn(form) {
796 | assert form == wisp.FormData([#("one", "1"), #("two", "2")], [])
797 | })
798 | == wisp.ok()
799 | }
800 |
801 | pub fn urlencoded_form_with_charset_test() {
802 | assert simulate.request(http.Post, "/")
803 | |> simulate.string_body("one=1&two=2")
804 | |> request.set_header(
805 | "content-type",
806 | "application/x-www-form-urlencoded; charset=UTF-8",
807 | )
808 | |> form_handler(fn(form) {
809 | assert form == wisp.FormData([#("one", "1"), #("two", "2")], [])
810 | })
811 | == wisp.ok()
812 | }
813 |
814 | pub fn urlencoded_too_big_form_test() {
815 | assert simulate.request(http.Post, "/")
816 | |> simulate.string_body("12")
817 | |> request.set_header("content-type", "application/x-www-form-urlencoded")
818 | |> wisp.set_max_body_size(1)
819 | |> form_handler(fn(_) { panic as "should be unreachable" })
820 | == Response(
821 | 413,
822 | [#("content-type", "text/plain")],
823 | wisp.Text("Content too large"),
824 | )
825 | }
826 |
827 | pub fn multipart_form_test() {
828 | let data =
829 | "--theboundary\r
830 | Content-Disposition: form-data; name=\"one\"\r
831 | \r
832 | 1\r
833 | --theboundary\r
834 | Content-Disposition: form-data; name=\"two\"\r
835 | \r
836 | 2\r
837 | --theboundary--\r
838 | "
839 | assert simulate.request(http.Post, "/")
840 | |> simulate.string_body(data)
841 | |> request.set_header(
842 | "content-type",
843 | "multipart/form-data; boundary=theboundary",
844 | )
845 | |> form_handler(fn(form) {
846 | assert form == wisp.FormData([#("one", "1"), #("two", "2")], [])
847 | })
848 | == wisp.ok()
849 | }
850 |
851 | pub fn multipart_form_too_big_test() {
852 | let data =
853 | "--theboundary\r
854 | Content-Disposition: form-data; name=\"one\"\r
855 | \r
856 | 1\r
857 | --theboundary--\r
858 | "
859 | assert simulate.request(http.Post, "/")
860 | |> wisp.set_max_body_size(1)
861 | |> simulate.string_body(data)
862 | |> request.set_header(
863 | "content-type",
864 | "multipart/form-data; boundary=theboundary",
865 | )
866 | |> wisp.set_max_body_size(1)
867 | |> form_handler(fn(_) { panic as "should be unreachable" })
868 | == Response(
869 | 413,
870 | [#("content-type", "text/plain")],
871 | wisp.Text("Content too large"),
872 | )
873 | }
874 |
875 | pub fn multipart_form_no_boundary_test() {
876 | let data =
877 | "--theboundary\r
878 | Content-Disposition: form-data; name=\"one\"\r
879 | \r
880 | 1\r
881 | --theboundary--\r
882 | "
883 | assert simulate.request(http.Post, "/")
884 | |> simulate.string_body(data)
885 | |> request.set_header("content-type", "multipart/form-data")
886 | |> form_handler(fn(_) { panic as "should be unreachable" })
887 | == Response(
888 | 400,
889 | [#("content-type", "text/plain")],
890 | wisp.Text("Bad request: Invalid form encoding"),
891 | )
892 | }
893 |
894 | pub fn multipart_form_invalid_format_test() {
895 | let data = "--theboundary\r\n--theboundary--\r\n"
896 | assert simulate.request(http.Post, "/")
897 | |> simulate.string_body(data)
898 | |> request.set_header(
899 | "content-type",
900 | "multipart/form-data; boundary=theboundary",
901 | )
902 | |> form_handler(fn(_) { panic as "should be unreachable" })
903 | == Response(
904 | 400,
905 | [#("content-type", "text/plain")],
906 | wisp.Text("Bad request: Unexpected end of request body"),
907 | )
908 | }
909 |
910 | pub fn form_unknown_content_type_test() {
911 | let data = "one=1&two=2"
912 | assert simulate.request(http.Post, "/")
913 | |> simulate.string_body(data)
914 | |> request.set_header("content-type", "text/form")
915 | |> form_handler(fn(_) { panic as "should be unreachable" })
916 | == Response(
917 | 415,
918 | [
919 | #("accept", "application/x-www-form-urlencoded, multipart/form-data"),
920 | #("content-type", "text/plain"),
921 | ],
922 | wisp.Text("Unsupported media type"),
923 | )
924 | }
925 |
926 | pub fn multipart_form_with_files_test() {
927 | let data =
928 | "--theboundary\r
929 | Content-Disposition: form-data; name=\"one\"\r
930 | \r
931 | 1\r
932 | --theboundary\r
933 | Content-Disposition: form-data; name=\"two\"; filename=\"file.txt\"\r
934 | \r
935 | file contents\r
936 | --theboundary--\r
937 | "
938 | assert simulate.request(http.Post, "/")
939 | |> simulate.string_body(data)
940 | |> request.set_header(
941 | "content-type",
942 | "multipart/form-data; boundary=theboundary",
943 | )
944 | |> form_handler(fn(form) {
945 | let assert [#("one", "1")] = form.values
946 | let assert [#("two", wisp.UploadedFile("file.txt", path))] = form.files
947 | let assert Ok("file contents") = simplifile.read(path)
948 | })
949 | == wisp.ok()
950 | }
951 |
952 | pub fn multipart_form_files_too_big_test() {
953 | let testcase = fn(limit, callback) {
954 | let data =
955 | "--theboundary\r
956 | Content-Disposition: form-data; name=\"two\"; filename=\"file.txt\"\r
957 | \r
958 | 12\r
959 | --theboundary\r
960 | Content-Disposition: form-data; name=\"two\"\r
961 | \r
962 | this one isn't a file. If it was it would use the entire quota.\r
963 | --theboundary\r
964 | Content-Disposition: form-data; name=\"two\"; filename=\"another.txt\"\r
965 | \r
966 | 34\r
967 | --theboundary--\r
968 | "
969 | simulate.request(http.Post, "/")
970 | |> simulate.string_body(data)
971 | |> wisp.set_max_files_size(limit)
972 | |> request.set_header(
973 | "content-type",
974 | "multipart/form-data; boundary=theboundary",
975 | )
976 | |> form_handler(callback)
977 | }
978 |
979 | assert testcase(1, fn(_) { panic as "should be unreachable for limit of 1" })
980 | == Response(
981 | 413,
982 | [#("content-type", "text/plain")],
983 | wisp.Text("Content too large"),
984 | )
985 |
986 | assert testcase(2, fn(_) { panic as "should be unreachable for limit of 2" })
987 | == Response(
988 | 413,
989 | [#("content-type", "text/plain")],
990 | wisp.Text("Content too large"),
991 | )
992 |
993 | assert testcase(3, fn(_) { panic as "should be unreachable for limit of 3" })
994 | == Response(
995 | 413,
996 | [#("content-type", "text/plain")],
997 | wisp.Text("Content too large"),
998 | )
999 |
1000 | assert testcase(4, fn(_) { Nil })
1001 | == Response(200, [#("content-type", "text/plain")], wisp.Text("OK"))
1002 | }
1003 |
1004 | pub fn handle_head_test() {
1005 | let handler = fn(request, header) {
1006 | use request <- wisp.handle_head(request)
1007 | use <- wisp.require_method(request, http.Get)
1008 |
1009 | assert list.key_find(request.headers, "x-original-method") == header
1010 |
1011 | "Hello!"
1012 | |> wisp.html_response(201)
1013 | }
1014 |
1015 | assert simulate.request(http.Get, "/")
1016 | |> request.set_method(http.Get)
1017 | |> handler(Error(Nil))
1018 | == Response(
1019 | 201,
1020 | [#("content-type", "text/html; charset=utf-8")],
1021 | wisp.Text("Hello!"),
1022 | )
1023 |
1024 | assert simulate.request(http.Get, "/")
1025 | |> request.set_method(http.Head)
1026 | |> handler(Ok("HEAD"))
1027 | == Response(
1028 | 201,
1029 | [#("content-type", "text/html; charset=utf-8")],
1030 | wisp.Text("Hello!"),
1031 | )
1032 |
1033 | assert simulate.request(http.Get, "/")
1034 | |> request.set_method(http.Post)
1035 | |> handler(Error(Nil))
1036 | == Response(405, [#("allow", "GET")], wisp.Text("Method not allowed"))
1037 | }
1038 |
1039 | pub fn multipart_form_fields_are_sorted_test() {
1040 | let data =
1041 | "--theboundary\r
1042 | Content-Disposition: form-data; name=\"xx\"\r
1043 | \r
1044 | XX\r
1045 | --theboundary\r
1046 | Content-Disposition: form-data; name=\"zz\"\r
1047 | \r
1048 | ZZ\r
1049 | --theboundary\r
1050 | Content-Disposition: form-data; name=\"yy\"\r
1051 | \r
1052 | YY\r
1053 | --theboundary\r
1054 | Content-Disposition: form-data; name=\"cc\"; filename=\"file.txt\"\r
1055 | \r
1056 | CC\r
1057 | --theboundary\r
1058 | Content-Disposition: form-data; name=\"aa\"; filename=\"file.txt\"\r
1059 | \r
1060 | AA\r
1061 | --theboundary\r
1062 | Content-Disposition: form-data; name=\"bb\"; filename=\"file.txt\"\r
1063 | \r
1064 | BB\r
1065 | --theboundary--\r
1066 | "
1067 | assert simulate.request(http.Post, "/")
1068 | |> simulate.string_body(data)
1069 | |> request.set_header(
1070 | "content-type",
1071 | "multipart/form-data; boundary=theboundary",
1072 | )
1073 | |> form_handler(fn(form) {
1074 | // Fields are sorted by name.
1075 | let assert [#("xx", "XX"), #("yy", "YY"), #("zz", "ZZ")] = form.values
1076 | let assert [
1077 | #("aa", wisp.UploadedFile("file.txt", path_a)),
1078 | #("bb", wisp.UploadedFile("file.txt", path_b)),
1079 | #("cc", wisp.UploadedFile("file.txt", path_c)),
1080 | ] = form.files
1081 | let assert Ok("AA") = simplifile.read(path_a)
1082 | let assert Ok("BB") = simplifile.read(path_b)
1083 | let assert Ok("CC") = simplifile.read(path_c)
1084 | })
1085 | == wisp.ok()
1086 | }
1087 |
1088 | pub fn urlencoded_form_fields_are_sorted_test() {
1089 | let data = "xx=XX&zz=ZZ&yy=YY&cc=CC&aa=AA&bb=BB"
1090 | assert simulate.request(http.Post, "/")
1091 | |> simulate.string_body(data)
1092 | |> request.set_header("content-type", "application/x-www-form-urlencoded")
1093 | |> form_handler(fn(form) {
1094 | // Fields are sorted by name.
1095 | let assert [
1096 | #("aa", "AA"),
1097 | #("bb", "BB"),
1098 | #("cc", "CC"),
1099 | #("xx", "XX"),
1100 | #("yy", "YY"),
1101 | #("zz", "ZZ"),
1102 | ] = form.values
1103 | })
1104 | == wisp.ok()
1105 | }
1106 |
1107 | pub fn message_signing_test() {
1108 | let request = simulate.request(http.Get, "/")
1109 | let request1 = wisp.set_secret_key_base(request, wisp.random_string(64))
1110 | let request2 = wisp.set_secret_key_base(request, wisp.random_string(64))
1111 |
1112 | let signed1 = wisp.sign_message(request1, <<"a":utf8>>, crypto.Sha512)
1113 | let signed2 = wisp.sign_message(request2, <<"b":utf8>>, crypto.Sha512)
1114 |
1115 | let assert Ok(<<"a":utf8>>) = wisp.verify_signed_message(request1, signed1)
1116 | let assert Ok(<<"b":utf8>>) = wisp.verify_signed_message(request2, signed2)
1117 |
1118 | let assert Error(Nil) = wisp.verify_signed_message(request1, signed2)
1119 | let assert Error(Nil) = wisp.verify_signed_message(request2, signed1)
1120 | }
1121 |
1122 | pub fn create_canned_connection_test() {
1123 | let secret = wisp.random_string(64)
1124 | let connection = wisp.create_canned_connection(<<"Hello!":utf8>>, secret)
1125 | let request = request.set_body(request.new(), connection)
1126 |
1127 | assert wisp.get_secret_key_base(request) == secret
1128 |
1129 | assert wisp.read_body_bits(request) == Ok(<<"Hello!":utf8>>)
1130 | }
1131 |
1132 | pub fn escape_html_test() {
1133 | assert wisp.escape_html("")
1134 | == "<script>alert('&');</script>"
1135 | }
1136 |
1137 | pub fn set_header_test() {
1138 | assert wisp.ok()
1139 | |> wisp.set_header("accept", "application/json")
1140 | |> wisp.set_header("accept", "text/plain")
1141 | |> wisp.set_header("content-type", "text/html")
1142 | == Response(
1143 | 200,
1144 | [#("content-type", "text/html"), #("accept", "text/plain")],
1145 | wisp.Text("OK"),
1146 | )
1147 | }
1148 |
1149 | pub fn string_body_test() {
1150 | assert wisp.string_body(wisp.ok(), "Hello, world!")
1151 | == Response(
1152 | 200,
1153 | [#("content-type", "text/plain")],
1154 | wisp.Text("Hello, world!"),
1155 | )
1156 | }
1157 |
1158 | pub fn string_tree_body_test() {
1159 | assert wisp.string_tree_body(
1160 | wisp.ok(),
1161 | string_tree.from_string("Hello, world!"),
1162 | )
1163 | == Response(
1164 | 200,
1165 | [#("content-type", "text/plain")],
1166 | wisp.Bytes(bytes_tree.from_string("Hello, world!")),
1167 | )
1168 | }
1169 |
1170 | pub fn json_body_test() {
1171 | assert wisp.json_body(wisp.ok(), "{\"one\":1,\"two\":2}")
1172 | == Response(
1173 | 200,
1174 | [#("content-type", "application/json; charset=utf-8")],
1175 | wisp.Text("{\"one\":1,\"two\":2}"),
1176 | )
1177 | }
1178 |
1179 | pub fn priv_directory_test() {
1180 | let assert Error(Nil) = wisp.priv_directory("unknown_application")
1181 |
1182 | let assert Ok(dir) = wisp.priv_directory("wisp")
1183 | let assert True = string.ends_with(dir, "/wisp/priv")
1184 |
1185 | let assert Ok(dir) = wisp.priv_directory("gleam_erlang")
1186 | let assert True = string.ends_with(dir, "/gleam_erlang/priv")
1187 |
1188 | let assert Ok(dir) = wisp.priv_directory("gleam_stdlib")
1189 | let assert True = string.ends_with(dir, "/gleam_stdlib/priv")
1190 | }
1191 |
1192 | pub fn set_cookie_plain_test() {
1193 | let req = simulate.request(http.Get, "/")
1194 | let response =
1195 | wisp.ok()
1196 | |> wisp.set_cookie(req, "id", "123", wisp.PlainText, 60 * 60 * 24 * 365)
1197 | |> wisp.set_cookie(req, "flash", "hi-there", wisp.PlainText, 60)
1198 |
1199 | assert response.headers
1200 | == [
1201 | #(
1202 | "set-cookie",
1203 | "flash=aGktdGhlcmU; Max-Age=60; Path=/; Secure; HttpOnly; SameSite=Lax",
1204 | ),
1205 | #(
1206 | "set-cookie",
1207 | "id=MTIz; Max-Age=31536000; Path=/; Secure; HttpOnly; SameSite=Lax",
1208 | ),
1209 | #("content-type", "text/plain"),
1210 | ]
1211 | }
1212 |
1213 | pub fn set_cookie_signed_test() {
1214 | let req = simulate.request(http.Get, "/")
1215 | let response =
1216 | wisp.ok()
1217 | |> wisp.set_cookie(req, "id", "123", wisp.Signed, 60 * 60 * 24 * 365)
1218 | |> wisp.set_cookie(req, "flash", "hi-there", wisp.Signed, 60)
1219 |
1220 | assert response.headers
1221 | == [
1222 | #(
1223 | "set-cookie",
1224 | "flash=SFM1MTI.aGktdGhlcmU.uWUWvrAleKQ2jsWcU97HzGgPqtLjjUgl4oe40-RPJ5qRRcE_soXPacgmaHTLxK3xZbOJ5DOTIRMI0szD4Re7wA; Max-Age=60; Path=/; Secure; HttpOnly; SameSite=Lax",
1225 | ),
1226 | #(
1227 | "set-cookie",
1228 | "id=SFM1MTI.MTIz.LT5VxVwopQ7VhZ3OzF6Pgy3sfIIQaiUH5anHXNRt6o3taBMfCNBQskZ-EIkodchsPGSu_AJrAHjMfYPV7D5ogg; Max-Age=31536000; Path=/; Secure; HttpOnly; SameSite=Lax",
1229 | ),
1230 | #("content-type", "text/plain"),
1231 | ]
1232 | }
1233 |
1234 | /// If the scheme is HTTP and the `x-forwarded-proto` header is not set then
1235 | /// the `Secure` attribute is not set.
1236 | pub fn set_cookie_http_localhost_test() {
1237 | let req = simulate.request(http.Get, "/")
1238 | let req = request.Request(..req, scheme: http.Http, host: "localhost")
1239 | let response =
1240 | wisp.ok()
1241 | |> wisp.set_cookie(req, "id", "123", wisp.PlainText, 60)
1242 | assert response.headers
1243 | == [
1244 | #("set-cookie", "id=MTIz; Max-Age=60; Path=/; HttpOnly; SameSite=Lax"),
1245 | #("content-type", "text/plain"),
1246 | ]
1247 | }
1248 |
1249 | /// If the scheme is HTTP and the `x-forwarded-proto` header is not set then
1250 | /// the `Secure` attribute is not set.
1251 | pub fn set_cookie_http_localhost_ip4_test() {
1252 | let req = simulate.request(http.Get, "/")
1253 | let req = request.Request(..req, scheme: http.Http, host: "127.0.0.1")
1254 | let response =
1255 | wisp.ok()
1256 | |> wisp.set_cookie(req, "id", "123", wisp.PlainText, 60)
1257 | assert response.headers
1258 | == [
1259 | #("set-cookie", "id=MTIz; Max-Age=60; Path=/; HttpOnly; SameSite=Lax"),
1260 | #("content-type", "text/plain"),
1261 | ]
1262 | }
1263 |
1264 | /// If the scheme is HTTP and the `x-forwarded-proto` header is not set then
1265 | /// the `Secure` attribute is not set.
1266 | pub fn set_cookie_http_localhost_ip6_test() {
1267 | let req = simulate.request(http.Get, "/")
1268 | let req = request.Request(..req, scheme: http.Http, host: "[::1]")
1269 | let response =
1270 | wisp.ok()
1271 | |> wisp.set_cookie(req, "id", "123", wisp.PlainText, 60)
1272 | assert response.headers
1273 | == [
1274 | #("set-cookie", "id=MTIz; Max-Age=60; Path=/; HttpOnly; SameSite=Lax"),
1275 | #("content-type", "text/plain"),
1276 | ]
1277 | }
1278 |
1279 | /// If the scheme is HTTP but the `x-forwarded-proto` header is set then the
1280 | /// `Secure` attribute is set, regardless of what the header value is.
1281 | pub fn set_cookie_http_forwarded_test() {
1282 | let req = simulate.request(http.Get, "/")
1283 | let req =
1284 | request.Request(..req, scheme: http.Http)
1285 | |> request.set_header("x-forwarded-proto", "http")
1286 | let response =
1287 | wisp.ok()
1288 | |> wisp.set_cookie(req, "id", "123", wisp.PlainText, 60)
1289 |
1290 | assert response.headers
1291 | == [
1292 | #(
1293 | "set-cookie",
1294 | "id=MTIz; Max-Age=60; Path=/; Secure; HttpOnly; SameSite=Lax",
1295 | ),
1296 | #("content-type", "text/plain"),
1297 | ]
1298 | }
1299 |
1300 | pub fn get_cookie_test() {
1301 | let cookies =
1302 | string.concat([
1303 | // Plain text
1304 | "plain=MTIz",
1305 | ";",
1306 | // Signed
1307 | "signed=SFM1MTI.aGktdGhlcmU.uWUWvrAleKQ2jsWcU97HzGgPqtLjjUgl4oe40-RPJ5qRRcE_soXPacgmaHTLxK3xZbOJ5DOTIRMI0szD4Re7wA",
1308 | ";",
1309 | // Signed but tampered with
1310 | "signed-and-tampered-with=SFM1MTI.aGktdGhlcmU.uWUWvrAleKQ2jsWcU97HzGgPqtLjjUgl4oe40-RPJ5qRRcE_soXPacgmaHTLxK3xZbOJ5DOTIRMI0szD4Re7wAA",
1311 | ])
1312 | let request =
1313 | simulate.request(http.Get, "/")
1314 | |> simulate.header("cookie", cookies)
1315 |
1316 | assert wisp.get_cookie(request, "plain", wisp.PlainText) == Ok("123")
1317 | assert wisp.get_cookie(request, "plain", wisp.Signed) == Error(Nil)
1318 |
1319 | assert wisp.get_cookie(request, "signed", wisp.PlainText) == Error(Nil)
1320 | assert wisp.get_cookie(request, "signed", wisp.Signed) == Ok("hi-there")
1321 |
1322 | assert wisp.get_cookie(request, "signed-and-tampered-with", wisp.PlainText)
1323 | == Error(Nil)
1324 | assert wisp.get_cookie(request, "signed-and-tampered-with", wisp.Signed)
1325 | == Error(Nil)
1326 |
1327 | assert wisp.get_cookie(request, "unknown", wisp.PlainText) == Error(Nil)
1328 | assert wisp.get_cookie(request, "unknown", wisp.Signed) == Error(Nil)
1329 | }
1330 |
1331 | // Let's roundtrip signing and verification a bunch of times to have confidence
1332 | // it works, and that we detect any regressions.
1333 | pub fn cookie_sign_roundtrip_test() {
1334 | use _ <- list.each(list.repeat(1, 10_000))
1335 | let message =
1336 | <>
1337 | |> bit_array.base64_encode(True)
1338 | let req = simulate.request(http.Get, "/")
1339 | let signed = wisp.sign_message(req, <>, crypto.Sha512)
1340 | let req =
1341 | simulate.request(http.Get, "/")
1342 | |> simulate.header("cookie", "message=" <> signed)
1343 | let assert Ok(out) = wisp.get_cookie(req, "message", wisp.Signed)
1344 | assert out == message
1345 | }
1346 |
1347 | pub fn get_query_test() {
1348 | assert simulate.request(http.Get, "/wibble?wobble=1&wubble=2&wobble=3&wabble")
1349 | |> wisp.get_query
1350 | == [
1351 | #("wobble", "1"),
1352 | #("wubble", "2"),
1353 | #("wobble", "3"),
1354 | #("wabble", ""),
1355 | ]
1356 | }
1357 |
1358 | pub fn get_query_no_query_test() {
1359 | assert simulate.request(http.Get, "/wibble")
1360 | |> wisp.get_query
1361 | == []
1362 | }
1363 |
1364 | pub fn content_security_policy_protection_test() {
1365 | let handler = fn() {
1366 | use csp_nonce <- wisp.content_security_policy_protection()
1367 | wisp.html_response(csp_nonce, 200)
1368 | }
1369 |
1370 | let response = handler()
1371 | let nonce = simulate.read_body(response)
1372 |
1373 | // Each time the CSP protection middleware is run it generates a new nonce
1374 | assert nonce != simulate.read_body(handler())
1375 | assert nonce != simulate.read_body(handler())
1376 | assert nonce != simulate.read_body(handler())
1377 |
1378 | // The CSP header is set
1379 | assert response.get_header(response, "content-security-policy")
1380 | == Ok(
1381 | "script-src 'nonce-"
1382 | <> nonce
1383 | <> "' 'strict-dynamic'; object-src 'none'; base-uri 'none'",
1384 | )
1385 | }
1386 |
--------------------------------------------------------------------------------