Query strings
The query string is the part of a URL after the ?, as in
/users?search=ada&page=2. It plays no part in routing: the router matches
on the path alone, so /users and /users?page=2 reach the same handler.
Reading the values is the handler's job.
The howdy/query module does that reading for you. Each function looks up
one key, hands your handler a typed value, and answers 400 on your behalf
when the input is missing or has the wrong shape.
Reading values
The functions are designed for use. Each takes the context and a key, and
the rest of the handler runs only if the value is good:
import gleam/json
import howdy/controller.{type Context}
import howdy/query
pub fn user_controller() {
controller.new("users")
|> controller.get("/", list_users)
}
fn list_users(ctx: Context) {
use search <- query.string_or(ctx, "search", default: "")
use page <- query.int_or(ctx, "page", default: 1)
use active <- query.bool_or(ctx, "active", default: True)
controller.json(
ctx,
json.object([
#("search", json.string(search)),
#("page", json.int(page)),
#("active", json.bool(active)),
]),
)
}
A request for /users?search=ada&page=2 runs the handler with search set
to "ada", page to 2, and active to its default of True.
Required, optional and default
There are three types, string, int and bool, and each comes in three
forms. They differ only in what happens when the key is absent:
-
query.string,intandboolare required. An absent key answers400. -
query.optional_string,optional_intandoptional_boolgive you anOption:Some(value)when the key is present,Nonewhen it is not. -
query.string_or,int_orandbool_ortake adefault:and use it when the key is absent.
Reach for the optional form when "not given" means something different from any default, such as a filter that should only apply when asked for:
import gleam/option.{None, Some}
fn list_orders(ctx: Context) {
use status <- query.optional_string(ctx, "status")
let orders = case status {
Some(status) -> orders_with_status(status)
None -> all_orders()
}
controller.json(ctx, orders_to_json(orders))
}
When the input is wrong
A bad value stops the handler and answers 400 with a JSON body that says
what was wrong. With use page <- query.int(ctx, "page"):
-
/usersanswers{"error":"missing query parameter page"}. -
/users?page=helloanswers{"error":"query parameter page must be an integer"}. -
/users?page=1&page=2answers{"error":"query parameter page must occur only once"}.
Optional and default forms are only forgiving about absence. A default never
hides a bad value: query.int_or(ctx, "page", default: 1) still answers
400 for ?page=hello, rather than quietly using 1.
A few details of how values are read:
-
Booleans accept exactly
trueandfalse.?active=1and?active=yesanswer400. -
Empty strings are kept.
?search=is present, with the value"", sostring_ordoes not fall back to its default. -
Values are percent-decoded for you, so
?search=ada%20lovelacegives"ada lovelace". A query string with invalid encoding answers400from every function.
Repeated keys
The functions above expect a key to appear at most once. When a key is meant
to repeat, as in /users?tag=admin&tag=staff, use query.strings. It gives
you every value, in the order they were sent:
fn list_users(ctx: Context) {
use tags <- query.strings(ctx, "tag")
// `tags` is ["admin", "staff"] for the request above.
controller.json(ctx, users_to_json(users_with_tags(tags)))
}
An absent key gives an empty list, so strings never answers 400 for a
missing value, only for invalid encoding.
Adding constraints
howdy/query checks the type of a value, not its range. To require a
minimum page number or cap a page size, pass the value through
howdy/validate:
import howdy/validate
fn list_users(ctx: Context) {
use page <- query.int_or(ctx, "page", default: 1)
use page <- validate.check(ctx, {
use page <- validate.field("page", page, [validate.min(1)])
validate.ok(page)
})
controller.json(ctx, users_to_json(users_on_page(page)))
}
A request for /users?page=0 now answers 422, with the failing field and
its message in the body:
{"error":"validation failed","fields":[{"field":"page","message":"must be at least 1"}]}
Testing query strings
howdy/testing keeps a query string written in the path, so the simplest
test is to ask for the URL you mean:
import howdy/testing
pub fn page_must_be_an_integer_test() {
let res = testing.get("/users?page=hello") |> testing.send(app())
assert res.status == 400
}
When a value needs encoding, testing.query sets the query string from
pairs and encodes them for you:
pub fn search_test() {
let res =
testing.get("/users")
|> testing.query([#("search", "ada lovelace")])
|> testing.send(app())
assert res.status == 200
}
