├── test ├── fixture.dat ├── fixture.json ├── fixture 123&?.txt ├── fixture.txt ├── helper.gleam ├── wisp │ ├── simulate_test.gleam │ └── csrf_test.gleam └── wisp_test.gleam ├── docs ├── images │ ├── cover.png │ ├── gradient.svg │ └── wordmark.svg ├── style.css └── index.html ├── .gitignore ├── examples ├── utilities │ └── tiny_database │ │ ├── .gitignore │ │ ├── README.md │ │ ├── gleam.toml │ │ ├── test │ │ └── tiny_database_test.gleam │ │ ├── manifest.toml │ │ └── src │ │ └── tiny_database.gleam ├── .gitignore ├── test │ ├── examples_test.gleam │ ├── logging │ │ └── app_test.gleam │ ├── hello_world │ │ └── app_test.gleam │ ├── working_with_other_formats │ │ └── app_test.gleam │ ├── serving_static_assets │ │ └── app_test.gleam │ ├── working_with_json │ │ └── app_test.gleam │ ├── working_with_cookies │ │ └── app_test.gleam │ ├── working_with_form_data │ │ └── app_test.gleam │ ├── routing │ │ └── app_test.gleam │ ├── working_with_files │ │ └── app_test.gleam │ └── using_a_database │ │ └── app_test.gleam ├── priv │ └── static │ │ ├── main.js │ │ └── styles.css ├── src │ ├── logging │ │ ├── app │ │ │ ├── web.gleam │ │ │ └── router.gleam │ │ ├── app.gleam │ │ └── README.md │ ├── routing │ │ ├── app │ │ │ ├── web.gleam │ │ │ └── router.gleam │ │ ├── app.gleam │ │ └── README.md │ ├── working_with_files │ │ ├── app │ │ │ ├── web.gleam │ │ │ └── router.gleam │ │ ├── app.gleam │ │ └── README.md │ ├── working_with_cookies │ │ ├── app │ │ │ ├── web.gleam │ │ │ └── router.gleam │ │ ├── app.gleam │ │ └── README.md │ ├── working_with_form_data │ │ ├── app │ │ │ ├── web.gleam │ │ │ └── router.gleam │ │ ├── app.gleam │ │ └── README.md │ ├── working_with_json │ │ ├── app │ │ │ ├── web.gleam │ │ │ └── router.gleam │ │ ├── app.gleam │ │ └── README.md │ ├── working_with_other_formats │ │ ├── app │ │ │ ├── web.gleam │ │ │ └── router.gleam │ │ ├── app.gleam │ │ └── README.md │ ├── hello_world │ │ ├── app │ │ │ ├── router.gleam │ │ │ └── web.gleam │ │ ├── app.gleam │ │ └── README.md │ ├── serving_static_assets │ │ ├── app │ │ │ ├── web.gleam │ │ │ └── router.gleam │ │ ├── app.gleam │ │ └── README.md │ └── using_a_database │ │ ├── app │ │ ├── router.gleam │ │ ├── web.gleam │ │ └── web │ │ │ └── people.gleam │ │ ├── app.gleam │ │ └── README.md ├── gleam.toml ├── README.md └── manifest.toml ├── src ├── wisp_ffi.erl └── wisp │ ├── internal.gleam │ ├── wisp_mist.gleam │ └── simulate.gleam ├── bin └── examples-update-packages.sh ├── .github └── workflows │ └── ci.yml ├── gleam.toml ├── README.md ├── manifest.toml ├── CHANGELOG.md └── LICENCE /test/fixture.dat: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixture.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/fixture 123&?.txt: -------------------------------------------------------------------------------- 1 | test file 2 | -------------------------------------------------------------------------------- /test/fixture.txt: -------------------------------------------------------------------------------- 1 | Hello, Joe! 👨‍👩‍👧‍👦 2 | -------------------------------------------------------------------------------- /docs/images/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleam-wisp/wisp/main/docs/images/cover.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | build 4 | erl_crash.dump 5 | tmp 6 | examples/*/manifest.toml 7 | -------------------------------------------------------------------------------- /examples/utilities/tiny_database/.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | build 4 | erl_crash.dump 5 | tmp 6 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | build 4 | erl_crash.dump 5 | tmp 6 | examples/*/manifest.toml 7 | -------------------------------------------------------------------------------- /examples/test/examples_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | 3 | pub fn main() { 4 | gleeunit.main() 5 | } 6 | -------------------------------------------------------------------------------- /examples/priv/static/main.js: -------------------------------------------------------------------------------- 1 | function update() { 2 | document.body.innerText = new Date().toLocaleTimeString(); 3 | } 4 | 5 | setInterval(update, 1000); 6 | update(); 7 | -------------------------------------------------------------------------------- /src/wisp_ffi.erl: -------------------------------------------------------------------------------- 1 | -module(wisp_ffi). 2 | 3 | -export([atom_from_dynamic/1]). 4 | 5 | atom_from_dynamic(Atom) when is_atom(Atom) -> {ok, Atom}; 6 | atom_from_dynamic(_) -> {error, nil}. 7 | -------------------------------------------------------------------------------- /examples/utilities/tiny_database/README.md: -------------------------------------------------------------------------------- 1 | # Tiny Database 2 | 3 | An example database library that the Wisp examples may use. 4 | 5 | A real application likely uses a proper database such as PostgreSQL, MySQL, 6 | MariaDB, or SQLite. 7 | -------------------------------------------------------------------------------- /examples/priv/static/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | border: 50px solid #ffaff3; 6 | height: 100vh; 7 | box-sizing: border-box; 8 | font-size: 40px; 9 | } 10 | -------------------------------------------------------------------------------- /test/helper.gleam: -------------------------------------------------------------------------------- 1 | import exception 2 | import wisp 3 | 4 | pub fn disable_logger(f: fn() -> t) -> t { 5 | wisp.set_logger_level(wisp.CriticalLevel) 6 | use <- exception.defer(fn() { wisp.set_logger_level(wisp.InfoLevel) }) 7 | f() 8 | } 9 | -------------------------------------------------------------------------------- /examples/test/logging/app_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/http 2 | import logging/app/router 3 | import wisp/simulate 4 | 5 | pub fn get_home_page_test() { 6 | let request = simulate.browser_request(http.Get, "/") 7 | let response = router.handle_request(request) 8 | 9 | assert response.status == 200 10 | } 11 | -------------------------------------------------------------------------------- /bin/examples-update-packages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | for project in examples/*; do 6 | if [ ! -f "$project/gleam.toml" ]; then 7 | continue 8 | fi 9 | 10 | echo "Updating dependencies for $project" 11 | cd "$project" 12 | gleam update 13 | gleam test || true 14 | cd ../.. 15 | done 16 | -------------------------------------------------------------------------------- /examples/utilities/tiny_database/gleam.toml: -------------------------------------------------------------------------------- 1 | name = "tiny_database" 2 | version = "1.0.0" 3 | description = "A silly little database to be used in the examples" 4 | licences = ["Apache-2.0"] 5 | gleam = ">= 0.32.0" 6 | 7 | [dependencies] 8 | gleam_stdlib = "~> 0.30" 9 | simplifile = "~> 2.0" 10 | gleam_json = ">= 3.0.0 and < 4.0.0" 11 | youid = ">= 1.1.0 and < 2.0.0" 12 | 13 | [dev-dependencies] 14 | gleeunit = "~> 1.0" 15 | -------------------------------------------------------------------------------- /examples/src/logging/app/web.gleam: -------------------------------------------------------------------------------- 1 | import wisp 2 | 3 | pub fn middleware( 4 | req: wisp.Request, 5 | handle_request: fn(wisp.Request) -> wisp.Response, 6 | ) -> wisp.Response { 7 | let req = wisp.method_override(req) 8 | use <- wisp.log_request(req) 9 | use <- wisp.rescue_crashes 10 | use req <- wisp.handle_head(req) 11 | use req <- wisp.csrf_known_header_protection(req) 12 | 13 | handle_request(req) 14 | } 15 | -------------------------------------------------------------------------------- /examples/src/routing/app/web.gleam: -------------------------------------------------------------------------------- 1 | import wisp 2 | 3 | pub fn middleware( 4 | req: wisp.Request, 5 | handle_request: fn(wisp.Request) -> wisp.Response, 6 | ) -> wisp.Response { 7 | let req = wisp.method_override(req) 8 | use <- wisp.log_request(req) 9 | use <- wisp.rescue_crashes 10 | use req <- wisp.handle_head(req) 11 | use req <- wisp.csrf_known_header_protection(req) 12 | 13 | handle_request(req) 14 | } 15 | -------------------------------------------------------------------------------- /examples/src/working_with_files/app/web.gleam: -------------------------------------------------------------------------------- 1 | import wisp 2 | 3 | pub fn middleware( 4 | req: wisp.Request, 5 | handle_request: fn(wisp.Request) -> wisp.Response, 6 | ) -> wisp.Response { 7 | let req = wisp.method_override(req) 8 | use <- wisp.log_request(req) 9 | use <- wisp.rescue_crashes 10 | use req <- wisp.handle_head(req) 11 | use req <- wisp.csrf_known_header_protection(req) 12 | handle_request(req) 13 | } 14 | -------------------------------------------------------------------------------- /examples/src/working_with_cookies/app/web.gleam: -------------------------------------------------------------------------------- 1 | import wisp 2 | 3 | pub fn middleware( 4 | req: wisp.Request, 5 | handle_request: fn(wisp.Request) -> wisp.Response, 6 | ) -> wisp.Response { 7 | let req = wisp.method_override(req) 8 | use <- wisp.log_request(req) 9 | use <- wisp.rescue_crashes 10 | use req <- wisp.handle_head(req) 11 | use req <- wisp.csrf_known_header_protection(req) 12 | handle_request(req) 13 | } 14 | -------------------------------------------------------------------------------- /examples/src/working_with_form_data/app/web.gleam: -------------------------------------------------------------------------------- 1 | import wisp 2 | 3 | pub fn middleware( 4 | req: wisp.Request, 5 | handle_request: fn(wisp.Request) -> wisp.Response, 6 | ) -> wisp.Response { 7 | let req = wisp.method_override(req) 8 | use <- wisp.log_request(req) 9 | use <- wisp.rescue_crashes 10 | use req <- wisp.handle_head(req) 11 | use req <- wisp.csrf_known_header_protection(req) 12 | handle_request(req) 13 | } 14 | -------------------------------------------------------------------------------- /examples/src/working_with_json/app/web.gleam: -------------------------------------------------------------------------------- 1 | import wisp 2 | 3 | pub fn middleware( 4 | req: wisp.Request, 5 | handle_request: fn(wisp.Request) -> wisp.Response, 6 | ) -> wisp.Response { 7 | let req = wisp.method_override(req) 8 | use <- wisp.log_request(req) 9 | use <- wisp.rescue_crashes 10 | use req <- wisp.handle_head(req) 11 | use req <- wisp.csrf_known_header_protection(req) 12 | 13 | handle_request(req) 14 | } 15 | -------------------------------------------------------------------------------- /examples/src/working_with_other_formats/app/web.gleam: -------------------------------------------------------------------------------- 1 | import wisp 2 | 3 | pub fn middleware( 4 | req: wisp.Request, 5 | handle_request: fn(wisp.Request) -> wisp.Response, 6 | ) -> wisp.Response { 7 | let req = wisp.method_override(req) 8 | use <- wisp.log_request(req) 9 | use <- wisp.rescue_crashes 10 | use req <- wisp.handle_head(req) 11 | use req <- wisp.csrf_known_header_protection(req) 12 | 13 | handle_request(req) 14 | } 15 | -------------------------------------------------------------------------------- /examples/test/hello_world/app_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/http 2 | import hello_world/app/router 3 | import wisp/simulate 4 | 5 | pub fn hello_world_test() { 6 | let response = router.handle_request(simulate.browser_request(http.Get, "/")) 7 | 8 | assert response.status == 200 9 | 10 | assert response.headers == [#("content-type", "text/html; charset=utf-8")] 11 | 12 | assert simulate.read_body(response) == "

Hello, Joe!

" 13 | } 14 | -------------------------------------------------------------------------------- /examples/src/logging/app.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/process 2 | import logging/app/router 3 | import mist 4 | import wisp 5 | import wisp/wisp_mist 6 | 7 | pub fn main() { 8 | wisp.configure_logger() 9 | let secret_key_base = wisp.random_string(64) 10 | 11 | let assert Ok(_) = 12 | wisp_mist.handler(router.handle_request, secret_key_base) 13 | |> mist.new 14 | |> mist.port(8000) 15 | |> mist.start 16 | 17 | process.sleep_forever() 18 | } 19 | -------------------------------------------------------------------------------- /examples/src/routing/app.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/process 2 | import mist 3 | import routing/app/router 4 | import wisp 5 | import wisp/wisp_mist 6 | 7 | pub fn main() { 8 | wisp.configure_logger() 9 | let secret_key_base = wisp.random_string(64) 10 | 11 | let assert Ok(_) = 12 | wisp_mist.handler(router.handle_request, secret_key_base) 13 | |> mist.new 14 | |> mist.port(8000) 15 | |> mist.start 16 | 17 | process.sleep_forever() 18 | } 19 | -------------------------------------------------------------------------------- /examples/src/working_with_files/app.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/process 2 | import mist 3 | import wisp 4 | import wisp/wisp_mist 5 | import working_with_files/app/router 6 | 7 | pub fn main() { 8 | wisp.configure_logger() 9 | let secret_key_base = wisp.random_string(64) 10 | 11 | let assert Ok(_) = 12 | wisp_mist.handler(router.handle_request, secret_key_base) 13 | |> mist.new 14 | |> mist.port(8000) 15 | |> mist.start 16 | 17 | process.sleep_forever() 18 | } 19 | -------------------------------------------------------------------------------- /examples/src/working_with_json/app.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/process 2 | import mist 3 | import wisp 4 | import wisp/wisp_mist 5 | import working_with_json/app/router 6 | 7 | pub fn main() { 8 | wisp.configure_logger() 9 | let secret_key_base = wisp.random_string(64) 10 | 11 | let assert Ok(_) = 12 | wisp_mist.handler(router.handle_request, secret_key_base) 13 | |> mist.new 14 | |> mist.port(8000) 15 | |> mist.start 16 | 17 | process.sleep_forever() 18 | } 19 | -------------------------------------------------------------------------------- /examples/src/working_with_cookies/app.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/process 2 | import mist 3 | import wisp 4 | import wisp/wisp_mist 5 | import working_with_cookies/app/router 6 | 7 | pub fn main() { 8 | wisp.configure_logger() 9 | let secret_key_base = wisp.random_string(64) 10 | 11 | let assert Ok(_) = 12 | wisp_mist.handler(router.handle_request, secret_key_base) 13 | |> mist.new 14 | |> mist.port(8000) 15 | |> mist.start 16 | 17 | process.sleep_forever() 18 | } 19 | -------------------------------------------------------------------------------- /examples/src/working_with_form_data/app.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/process 2 | import mist 3 | import wisp 4 | import wisp/wisp_mist 5 | import working_with_form_data/app/router 6 | 7 | pub fn main() { 8 | wisp.configure_logger() 9 | let secret_key_base = wisp.random_string(64) 10 | 11 | let assert Ok(_) = 12 | wisp_mist.handler(router.handle_request, secret_key_base) 13 | |> mist.new 14 | |> mist.port(8000) 15 | |> mist.start 16 | 17 | process.sleep_forever() 18 | } 19 | -------------------------------------------------------------------------------- /examples/src/working_with_other_formats/app.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/process 2 | import mist 3 | import wisp 4 | import wisp/wisp_mist 5 | import working_with_other_formats/app/router 6 | 7 | pub fn main() { 8 | wisp.configure_logger() 9 | let secret_key_base = wisp.random_string(64) 10 | 11 | let assert Ok(_) = 12 | wisp_mist.handler(router.handle_request, secret_key_base) 13 | |> mist.new 14 | |> mist.port(8000) 15 | |> mist.start 16 | 17 | process.sleep_forever() 18 | } 19 | -------------------------------------------------------------------------------- /examples/gleam.toml: -------------------------------------------------------------------------------- 1 | name = "examples" 2 | version = "1.0.0" 3 | description = "A collection of Wisp examples" 4 | 5 | [dependencies] 6 | wisp = { path = ".." } 7 | mist = ">= 5.0.0 and < 6.0.0" 8 | gleam_stdlib = ">= 0.60.0 and < 1.0.0" 9 | gleam_erlang = ">= 1.0.0 and < 2.0.0" 10 | gleam_http = ">= 4.0.0 and < 5.0.0" 11 | gleam_json = ">= 3.0.1 and < 4.0.0" 12 | gleam_crypto = ">= 1.5.0 and < 2.0.0" 13 | gsv = ">= 4.0.0 and < 5.0.0" 14 | tiny_database = { path = "utilities/tiny_database" } 15 | 16 | [dev-dependencies] 17 | gleeunit = "~> 1.0" 18 | -------------------------------------------------------------------------------- /examples/src/hello_world/app/router.gleam: -------------------------------------------------------------------------------- 1 | import hello_world/app/web 2 | import wisp.{type Request, type Response} 3 | 4 | /// The HTTP request handler- your application! 5 | /// 6 | pub fn handle_request(req: Request) -> Response { 7 | // Apply the middleware stack for this request/response. 8 | use _req <- web.middleware(req) 9 | 10 | // Later we'll use templates, but for now a string will do. 11 | let body = "

Hello, Joe!

" 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("
") 16 | == True 17 | } 18 | 19 | pub fn submit_wrong_content_type_test() { 20 | let response = router.handle_request(simulate.browser_request(http.Post, "/")) 21 | 22 | assert response.status == 415 23 | 24 | assert response.headers 25 | == [ 26 | #("accept", "application/x-www-form-urlencoded, multipart/form-data"), 27 | #("content-type", "text/plain"), 28 | ] 29 | } 30 | 31 | pub fn submit_missing_parameters_test() { 32 | // The `METHOD_form` functions are used to create a request with a 33 | // `x-www-form-urlencoded` body, with the appropriate `content-type` header. 34 | let response = 35 | simulate.browser_request(http.Post, "/") 36 | |> simulate.form_body([]) 37 | |> router.handle_request() 38 | 39 | assert response.status == 400 40 | } 41 | 42 | pub fn submit_successful_test() { 43 | let response = 44 | simulate.browser_request(http.Post, "/") 45 | |> simulate.form_body([#("title", "Captain"), #("name", "Caveman")]) 46 | |> router.handle_request() 47 | 48 | assert response.status == 200 49 | 50 | assert response.headers == [#("content-type", "text/html; charset=utf-8")] 51 | 52 | assert simulate.read_body(response) == "Hi, Captain Caveman!" 53 | } 54 | -------------------------------------------------------------------------------- /examples/src/using_a_database/README.md: -------------------------------------------------------------------------------- 1 | # Wisp Example: Using a database 2 | 3 | ```sh 4 | gleam run -m using_a_database/app # Run the server 5 | ``` 6 | 7 | This example shows how to use a database, using a `Context` type to hold the 8 | database connection. 9 | 10 | This example is based off of the ["working with JSON" example][json], so read 11 | that first. The additions are detailed here and commented in the code. 12 | 13 | [json]: [examples/src/working_with_json](./../working_with_json/) 14 | 15 | ### [`gleam.toml`](../../gleam.toml) file 16 | 17 | The `tiny_database` package has been added as a dependency. In a real project 18 | you would like use a proper database such as Postgres or SQLite. 19 | 20 | ### `app/web` module 21 | 22 | A new `Context` type has been created to hold the database connection. 23 | 24 | ### `app` module 25 | 26 | The `main` function now starts by creating a database connection and passing it 27 | to the handler function in a `Context` record. 28 | 29 | ### `app/router` module 30 | 31 | The `handle_request` function has been updated to route requests to functions in 32 | the new `app/web/people` module. 33 | 34 | ### `app/web/people` module 35 | 36 | This module has been created to hold all the functions for working with the 37 | "people" feature, including their request handlers. 38 | 39 | ### Unit tests [examples/test/using_a_database/](../../test/using_a_database/) 40 | 41 | The `with_context` function has been added to create a `Context` record with a 42 | database connection, and to setup the database. 43 | 44 | The tests have been updated to verify that the application saves and retrieves 45 | the data correctly. 46 | 47 | ### Other files 48 | 49 | No changes have been made to the other files. 50 | -------------------------------------------------------------------------------- /examples/test/routing/app_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/http 2 | import routing/app/router 3 | import wisp/simulate 4 | 5 | pub fn get_home_page_test() { 6 | let request = simulate.browser_request(http.Get, "/") 7 | let response = router.handle_request(request) 8 | 9 | assert response.status == 200 10 | 11 | assert response.headers == [#("content-type", "text/html; charset=utf-8")] 12 | 13 | assert simulate.read_body(response) == "Hello, Joe!" 14 | } 15 | 16 | pub fn post_home_page_test() { 17 | let request = 18 | simulate.browser_request(http.Post, "/") 19 | |> simulate.string_body("a body") 20 | let response = router.handle_request(request) 21 | assert response.status == 405 22 | } 23 | 24 | pub fn page_not_found_test() { 25 | let request = simulate.browser_request(http.Get, "/nothing-here") 26 | let response = router.handle_request(request) 27 | assert response.status == 404 28 | } 29 | 30 | pub fn get_comments_test() { 31 | let request = simulate.browser_request(http.Get, "/comments") 32 | let response = router.handle_request(request) 33 | assert response.status == 200 34 | } 35 | 36 | pub fn post_comments_test() { 37 | let request = simulate.browser_request(http.Post, "/comments") 38 | let response = router.handle_request(request) 39 | assert response.status == 201 40 | } 41 | 42 | pub fn delete_comments_test() { 43 | let request = simulate.browser_request(http.Delete, "/comments") 44 | let response = router.handle_request(request) 45 | assert response.status == 405 46 | } 47 | 48 | pub fn get_comment_test() { 49 | let request = simulate.browser_request(http.Get, "/comments/123") 50 | let response = router.handle_request(request) 51 | assert response.status == 200 52 | assert simulate.read_body(response) == "Comment with id 123" 53 | } 54 | 55 | pub fn delete_comment_test() { 56 | let request = simulate.browser_request(http.Delete, "/comments/123") 57 | let response = router.handle_request(request) 58 | assert response.status == 405 59 | } 60 | -------------------------------------------------------------------------------- /examples/test/working_with_files/app_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/http 2 | import gleam/string 3 | import wisp/simulate 4 | import working_with_files/app/router 5 | 6 | pub fn home_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(" simulate.read_body 33 | |> string.starts_with("name = \"examples\"") 34 | == True 35 | } 36 | 37 | pub fn file_from_memory_test() { 38 | let response = 39 | router.handle_request(simulate.browser_request( 40 | http.Get, 41 | "/file-from-memory", 42 | )) 43 | 44 | assert response.status == 200 45 | 46 | assert response.headers 47 | == [ 48 | #("content-type", "text/plain"), 49 | #("content-disposition", "attachment; filename=\"hello.txt\""), 50 | ] 51 | 52 | assert simulate.read_body(response) == "Hello, Joe!" 53 | } 54 | 55 | pub fn upload_file_test() { 56 | let file = simulate.FileUpload("test.txt", "text/plain", <<"Hello, Joe!">>) 57 | let request = 58 | simulate.browser_request(http.Post, "/upload-file") 59 | |> simulate.multipart_body([], [ 60 | #("uploaded-file", file), 61 | ]) 62 | 63 | let response = router.handle_request(request) 64 | 65 | assert response.status == 200 66 | assert response.headers == [#("content-type", "text/html; charset=utf-8")] 67 | assert simulate.read_body(response) 68 | |> string.contains("Thank you for your file `test.txt`") 69 | } 70 | -------------------------------------------------------------------------------- /examples/src/working_with_json/app/router.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic/decode 2 | import gleam/http.{Post} 3 | import gleam/json 4 | import gleam/result 5 | import wisp.{type Request, type Response} 6 | import working_with_json/app/web 7 | 8 | // This type is going to be parsed and decoded from the request body. 9 | pub type Person { 10 | Person(name: String, is_cool: Bool) 11 | } 12 | 13 | // To decode the type we need a dynamic decoder. 14 | // See the standard library documentation for more information on decoding 15 | // dynamic values [1]. 16 | // 17 | // [1]: https://hexdocs.pm/gleam_stdlib/gleam/dynamic.html 18 | fn person_decoder() -> decode.Decoder(Person) { 19 | use name <- decode.field("name", decode.string) 20 | use is_cool <- decode.field("is-cool", decode.bool) 21 | decode.success(Person(name:, is_cool:)) 22 | } 23 | 24 | pub fn handle_request(req: Request) -> Response { 25 | use req <- web.middleware(req) 26 | use <- wisp.require_method(req, Post) 27 | 28 | // This middleware parses a `Dynamic` value from the request body. 29 | // It returns an error response if the body is not valid JSON, or 30 | // if the content-type is not `application/json`, or if the body 31 | // is too large. 32 | use json <- wisp.require_json(req) 33 | 34 | let result = { 35 | // The JSON data can be decoded into a `Person` value. 36 | use person <- result.try(decode.run(json, person_decoder())) 37 | 38 | // And then a JSON response can be created from the person. 39 | let object = 40 | json.object([ 41 | #("name", json.string(person.name)), 42 | #("is-cool", json.bool(person.is_cool)), 43 | #("saved", json.bool(True)), 44 | ]) 45 | Ok(json.to_string(object)) 46 | } 47 | 48 | // An appropriate response is returned depending on whether the JSON could be 49 | // successfully handled or not. 50 | case result { 51 | Ok(json) -> wisp.json_response(json, 201) 52 | 53 | // In a real application we would probably want to return some JSON error 54 | // object, but for this example we'll just return an empty response. 55 | Error(_) -> wisp.unprocessable_content() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /examples/src/working_with_cookies/app/router.gleam: -------------------------------------------------------------------------------- 1 | import gleam/http.{Delete, Get, Post} 2 | import gleam/list 3 | import gleam/string 4 | import wisp.{type Request, type Response} 5 | import working_with_cookies/app/web 6 | 7 | const cookie_name = "id" 8 | 9 | pub fn handle_request(req: Request) -> Response { 10 | use req <- web.middleware(req) 11 | 12 | case wisp.path_segments(req) { 13 | [] -> home(req) 14 | ["session"] -> session(req) 15 | _ -> wisp.not_found() 16 | } 17 | } 18 | 19 | pub fn home(req: Request) -> Response { 20 | case wisp.get_cookie(req, cookie_name, wisp.Signed) { 21 | Ok(name) -> { 22 | [ 23 | "

Hello, " <> wisp.escape_html(name) <> "!

", 24 | "", 25 | " ", 26 | "
", 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 |
49 | 52 | 53 |
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 | "
24 | 27 | 30 | 31 |
" 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 | ![Wisp](https://github.com/lpil/wisp/blob/main/docs/images/cover.png?raw=true) 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/wisp)](https://hex.pm/packages/wisp) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 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 | Wisp 19 |

20 | A practical web framework for Gleam 21 |

22 |
23 | 24 | 72 | 73 |
74 |

OK, but what does Wisp actually give you?

75 | 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 |

98 |
import my_app/people
 99 | import my_app/web.{Context}
100 | import gleam/result.{try}
101 | import wisp.{Request, Response}
102 | 
103 | pub fn handle_request(req: Request, ctx: Context) -> Response {
104 |   use json <- wisp.require_json(req)
105 | 
106 |   let result = {
107 |     use params <- try(people.parse_params(json))
108 |     use person <- try(people.save(params, ctx.db))
109 |     Ok(people.to_json(person))
110 |   }
111 | 
112 |   case result {
113 |     Ok(body) -> wisp.json_response(body, 201)
114 |     Error(_) -> wisp.bad_request()
115 |   }
116 | }
117 | 
118 | 119 |

120 | Want to learn more? Check out 121 | the Wisp guides. 122 |

123 |
124 | 125 | 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /examples/src/working_with_files/app/router.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bytes_tree 2 | import gleam/http.{Get, Post} 3 | import gleam/list 4 | import gleam/result 5 | import wisp.{type Request, type Response} 6 | import working_with_files/app/web 7 | 8 | pub fn handle_request(req: Request) -> Response { 9 | use req <- web.middleware(req) 10 | 11 | case wisp.path_segments(req) { 12 | [] -> show_home(req) 13 | ["file-from-disc"] -> handle_download_file_from_disc(req) 14 | ["file-from-memory"] -> handle_download_file_from_memory(req) 15 | ["upload-file"] -> handle_file_upload(req) 16 | _ -> wisp.not_found() 17 | } 18 | } 19 | 20 | // Notice how `enctype="multipart/form-data"` is used in the file-upload form. 21 | // This ensure that the file is encoded appropriately for the server to read. 22 | const html = " 23 |

Download file from memory

24 |

Download file from disc

25 | 26 |
27 | 30 | 31 |
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 | 15 | 33 | 37 | 41 | 42 | 44 | 52 | 56 | 61 | 64 | 67 | 71 | 74 | 79 | 85 | 86 | 87 | 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 | --------------------------------------------------------------------------------