// Query parameters, and the query string they become.
//
// Both queries and procedures carry them -- a procedure's input is its body,
// but its `parameters` still go in the URL, which is a thing that surprises
// people writing an XRPC client for the first time.
//
// Arrays serialize as REPEATED KEYS (`?uris=a&uris=b`), not as a comma-joined
// value and not as `uris[]`. And arrays are homogeneous by construction here,
// because the Lexicon `params` grammar only admits an array of one primitive
// type -- so a mixed list is not something a caller should be able to build.
///|
pub(all) enum ParamValue {
Str(String)
Int(Int64)
Bool(Bool)
Strs(Array[String])
Ints(Array[Int64])
Bools(Array[Bool])
} derive(Eq, Debug)
///|
/// An ordered list rather than a map, because two entries may share a key --
/// that is how arrays are spelled -- and because a stable order makes a request
/// comparable in a test.
pub(all) struct Params {
entries : Array[(String, ParamValue)]
} derive(Eq, Debug)
///|
pub fn Params::new() -> Params {
{ entries: [] }
}
///|
pub fn Params::of(entries : Array[(String, ParamValue)]) -> Params {
{ entries, }
}
///|
pub fn Params::length(self : Self) -> Int {
self.entries.length()
}
///|
pub fn Params::is_empty(self : Self) -> Bool {
self.entries.length() == 0
}
///|
pub fn Params::put(self : Self, key : String, value : ParamValue) -> Unit {
self.entries.push((key, value))
}
///|
/// The whole reason this type exists rather than a `Map`: an absent optional
/// argument must vanish, not become an empty string. Every generated call is a
/// column of these.
pub fn Params::put_opt(self : Self, key : String, value : ParamValue?) -> Unit {
if value is Some(v) {
self.entries.push((key, v))
}
}
///|
pub fn Params::put_string(self : Self, key : String, value : String?) -> Unit {
self.put_opt(key, value.map(v => ParamValue::Str(v)))
}
///|
pub fn Params::put_int(self : Self, key : String, value : Int64?) -> Unit {
self.put_opt(key, value.map(v => ParamValue::Int(v)))
}
///|
pub fn Params::put_bool(self : Self, key : String, value : Bool?) -> Unit {
self.put_opt(key, value.map(v => ParamValue::Bool(v)))
}
///|
pub fn Params::put_strings(
self : Self,
key : String,
value : Array[String]?,
) -> Unit {
self.put_opt(key, value.map(v => ParamValue::Strs(v)))
}
///|
/// The query string, without a leading `?`, or `""` when there is nothing to
/// send. An empty array contributes nothing at all, which matches the reference
/// implementation -- `?tags=` would mean a tag that is the empty string.
pub fn Params::encode(self : Self) -> String {
let out = StringBuilder::new()
let mut first = true
fn append(key : String, value : String) -> Unit {
if !first {
out.write_char('&')
}
first = false
out.write_string(percent_encode(key))
out.write_char('=')
out.write_string(percent_encode(value))
}
for entry in self.entries {
let (key, value) = entry
match value {
Str(v) => append(key, v)
Int(v) => append(key, v.to_string())
Bool(v) => append(key, bool_to_wire(v))
Strs(items) =>
for item in items {
append(key, item)
}
Ints(items) =>
for item in items {
append(key, item.to_string())
}
Bools(items) =>
for item in items {
append(key, bool_to_wire(item))
}
}
}
out.to_string()
}
///|
/// `true` / `false`, never `1` / `0`. Slack accepts both and this protocol does
/// not, so unlike `@slack/api` there is no style to choose.
fn bool_to_wire(value : Bool) -> String {
if value {
"true"
} else {
"false"
}
}
///|
/// RFC 3986 percent-encoding of everything outside the unreserved set.
///
/// Encoding goes through the UTF-8 bytes, not the string's UTF-16 code units.
/// Getting that wrong is invisible until someone searches for a word with an
/// accent in it, and then the query silently matches nothing.
pub fn percent_encode(text : String) -> String {
let bytes = @utf8.encode(text)
let out = StringBuilder::new(size_hint=bytes.length())
for i = 0; i < bytes.length(); i = i + 1 {
let byte = bytes[i].to_int()
if is_unreserved(byte) {
out.write_char(hex_char(byte))
} else {
out.write_char('%')
out.write_char(hex_digit(byte >> 4))
out.write_char(hex_digit(byte & 0x0F))
}
}
out.to_string()
}
///|
/// `A-Za-z0-9-._~`, RFC 3986's unreserved set. Note `~` is in it: encoding it
/// is legal but needless, and leaving it alone matches what servers echo back.
fn is_unreserved(byte : Int) -> Bool {
(byte >= 'a'.to_int() && byte <= 'z'.to_int()) ||
(byte >= 'A'.to_int() && byte <= 'Z'.to_int()) ||
(byte >= '0'.to_int() && byte <= '9'.to_int()) ||
byte == '-'.to_int() ||
byte == '.'.to_int() ||
byte == '_'.to_int() ||
byte == '~'.to_int()
}
///|
/// Safe only for the unreserved bytes, which are all ASCII.
fn hex_char(byte : Int) -> Char {
byte.unsafe_to_char()
}
///|
fn hex_digit(value : Int) -> Char {
if value < 10 {
('0'.to_int() + value).unsafe_to_char()
} else {
('A'.to_int() + value - 10).unsafe_to_char()
}
}