// The wrapper, the two result shapes, and the extraction step every method
// shares.
//
// Extraction is per method and never generic, and the corpus is why. `channel`
// is an object on `conversations.info` and a bare STRING on
// `chat.postMessage`; `user` is an object on `users.info` and a string on
// `auth.test`; `members` is a list of users on `users.list` and a list of ids
// on `conversations.members`. Only the method knows which, so there is no
// `ApiResponse::user()` here and there should not be one -- every method below
// names its own key.
///|
/// A `Client`, and the extraction step that turns its answers into @model
/// values.
///
/// Holds the client rather than replacing it: `Api::client` is always there,
/// and mixing the two levels in one function is the expected way to use this.
pub struct Api {
client : @client.Client
}
///|
/// Wrap an existing client.
///
/// The usual entry point, because a `Client` is where the transport, the token
/// and the base URL are already configured.
pub fn Api::of(client : @client.Client) -> Api {
{ client, }
}
///|
/// Build the client and wrap it in one step, with `Client::new`'s arguments.
pub fn Api::new(
transport : &@api.Transport,
token : String,
base_url? : String,
bool_style? : @api.BoolStyle,
extra_headers? : Map[String, String],
team_id? : String,
) -> Api {
{
client: @client.Client::new(
transport,
token,
base_url?,
bool_style?,
extra_headers?,
team_id?,
),
}
}
///|
/// The low-level client, for everything this package does not wrap.
///
/// Not an escape hatch so much as the other half of the API: `Client::call`
/// reaches every one of Slack's 326 methods, and `ApiResponse.raw` reaches
/// every field of every response.
pub fn Api::client(self : Api) -> @client.Client {
self.client
}
///|
/// One page of a cursor-paginated call.
///
/// Carries the whole response as well as the items, because the interesting
/// fields of a page are not all items -- `has_more` on
/// `conversations.history`, the warnings on a partial `users.list`.
pub(all) struct Page[T] {
items : Array[T]
/// The cursor for the next page, or `None` on the last one.
///
/// Slack signals the end with an EMPTY `next_cursor` rather than by omitting
/// it, which is the single easiest way to write an infinite loop over the
/// last page. `@client.Paginator` already knows that; this normalises the
/// empty string to `None` so a hand-rolled loop cannot get it wrong either.
cursor : String?
response : @api.ApiResponse
}
///|
/// Whether another page exists.
pub fn[T] Page::has_more(self : Page[T]) -> Bool {
self.cursor is Some(_)
}
///|
/// What `chat.postMessage` and `chat.update` answer.
///
/// Not a `Channel`: the `channel` here is an id, which is the clearest case in
/// the whole API for why extraction cannot be generic.
pub(all) struct Posted {
channel : String
ts : String
/// The message as stored, which is not always the message as sent -- Slack
/// resolves links and mentions on the way in.
message : @model.Message?
} derive(Eq, @debug.Debug)
///|
/// Pull a modelled payload out of a response, or say which method and key
/// disagreed with what this version models.
fn[T] entity(
response : @api.ApiResponse,
api_method : String,
key : String,
parse : (Json) -> T?,
) -> T raise TypedError {
guard response.get(key) is Some(value) else {
raise ResponseShapeError(api_method~, key~)
}
guard parse(value) is Some(parsed) else {
raise ResponseShapeError(api_method~, key~)
}
parsed
}
///|
/// The same, for a key holding a list.
///
/// An ABSENT key is an error and an empty array is not: a filtered
/// `conversations.list` whose last page matched nothing is a normal answer, a
/// `conversations.list` with no `channels` at all is not one this version
/// understands. One bad element fails the whole call rather than being skipped
/// -- silently returning four of five channels is the kind of bug that is
/// found in production, months later.
fn[T] entities(
response : @api.ApiResponse,
api_method : String,
key : String,
parse : (Json) -> T?,
) -> Array[T] raise TypedError {
guard response.get(key) is Some(Array(items)) else {
raise ResponseShapeError(api_method~, key~)
}
let out = []
for item in items {
guard parse(item) is Some(parsed) else {
raise ResponseShapeError(api_method~, key~)
}
out.push(parsed)
}
out
}
///|
/// The same again, as a page.
fn[T] page(
response : @api.ApiResponse,
api_method : String,
key : String,
parse : (Json) -> T?,
) -> Page[T] raise TypedError {
let items = entities(response, api_method, key, parse)
let cursor = match response.response_metadata.next_cursor {
Some("") | None => None
Some(c) => Some(c)
}
{ items, cursor, response }
}
///|
/// Map a parser over what a `_all` helper collected.
///
/// `Client`'s `_all` methods answer `Array[Json]` because they walk pages and
/// concatenate the items; the key was already applied there, so all that is
/// left is the parse.
fn[T] all(
items : Array[Json],
api_method : String,
key : String,
parse : (Json) -> T?,
) -> Array[T] raise TypedError {
let out = []
for item in items {
guard parse(item) is Some(parsed) else {
raise ResponseShapeError(api_method~, key~)
}
out.push(parsed)
}
out
}
///|
/// An id, for the keys that hold one rather than an object.
fn id_of(json : Json) -> String? {
match json {
String(s) => Some(s)
_ => None
}
}