source_api_apiResponse.bs
import "pkg:/source/utils/misc.bs"
' Shape checks for parsed response bodies: the one place a caller learns whether a body holds
' what its endpoint promised, before it iterates or indexes it.
namespace apiResponse
' `req.expect` value for an endpoint that answers with a bare array (ApiClient.listReq).
const EXPECT_LIST = "list"
' `res.error` value when a 200's body is not the shape its request expected.
const ERROR_UNEXPECTED_SHAPE = "unexpectedShape"
' `res.error` value when the pool completed a queued read without sending it, because
' nobody was waiting for the answer any more (apiPool.settleIfCallerGone).
const ERROR_ABANDONED = "abandoned"
' Returns the list a list endpoint answered with, or invalid when the body holds none.
'
' BrightScript iterates an AA's KEYS, so a body that is an object instead of an array does not
' fail softly: `for each item in json` hands back key strings, and the first `item.Field` (or a
' `json[0]`) is a runtime error that kills the app.
'
' Also accepts the `{ Items: [...] }` query-result wrapper. Stock Jellyfin does not send it for
' bare-array endpoints, but a server has sent exactly that for /Items/Latest in the field, and
' its `Items` is the same list, so reading it shows the user their content where rejecting it
' would leave the row empty on every retry. The same property makes this the reader for a
' query-result endpoint too, when a caller wants its list and nothing else.
'
' invalid means "no list", which callers must treat as NO ANSWER, not as an authoritative
' empty list. An empty array is returned as-is and is the only way to say "empty".
'
' @param json - a parsed response body (`res.json`), any type
' @return the roArray, or invalid
function listFrom(json as dynamic) as dynamic
if type(json) = "roArray" then return json
if type(json) = "roAssociativeArray" and type(json.Items) = "roArray" then return json.Items
return invalid
end function
' Whether a request declared that its endpoint answers with a bare array (ApiClient.listReq).
'
' @param req - a request AA, or invalid
' @return true for a list request
function expectsList(req as dynamic) as boolean
if type(req) <> "roAssociativeArray" or not isValid(req.expect) then return false
return req.expect = apiResponse.EXPECT_LIST
end function
' Holds a pool response to the shape its request declared. ApiTask runs every response through
' this, so a caller of a list request never sees a body it cannot iterate.
'
' For a request built with `expect: "list"`, an ok response leaves with `json` set to the list
' (unwrapped by listFrom), or, when the body holds none, with `ok = false`, `json = invalid`
' and `error = "unexpectedShape"`. `statusCode` stays the server's, so a log line still says
' what came back. Reporting it as not-ok is deliberate: every caller already treats `not ok` as
' "no answer", which is what an unreadable body is. Any other response is returned untouched.
'
' @param req - the request AA the response answers
' @param res - the pool response AA { requestId, ok, statusCode, json, text }; modified in place
' @return res
function enforceShape(req as object, res as object) as object
if not res.ok or not apiResponse.expectsList(req) then return res
list = apiResponse.listFrom(res.json)
if isValid(list)
res.json = list
else
res.ok = false
res.json = invalid
res.error = apiResponse.ERROR_UNEXPECTED_SHAPE
end if
return res
end function
' True when a request got no answer at all: the caller's wait ran out, or roku-requests gave
' up waiting. Distinct from a refusal (an HTTP error) or a network failure, because the
' server may still be working on the request.
'
' @param res - a pool response AA, or invalid
function noAnswer(res as dynamic) as boolean
if type(res) <> "roAssociativeArray" then return true
if isValid(res.ok) and res.ok then return false
if isValid(res.error) then return false
return not isValid(res.statusCode)
end function
' Why a request that should have answered with JSON did not, in the few words a warning log
' line needs, or "" when the reply is usable. A caller that shows a failure state keeps the
' cause for its log and tells its screen only that the load failed.
'
' The shapes are the pool's: `invalid` from fetchRes when the caller's wait ran out; no
' statusCode when roku-requests gave up waiting; a negative curl code for a connection that
' failed; statusCode 0 when ApiTask could not make the request at all.
'
' @param res - a pool response AA, or invalid
' @return "" for an ok reply with a parsed body, otherwise the cause
function jsonFailure(res as dynamic) as string
if apiResponse.noAnswer(res)
if type(res) <> "roAssociativeArray" then return "no answer in time"
return "timed out"
end if
if isValid(res.error)
if res.error = apiResponse.ERROR_UNEXPECTED_SHAPE then return "unexpected reply shape"
if res.error = apiResponse.ERROR_ABANDONED then return "abandoned"
end if
if isValid(res.ok) and res.ok
if isValid(res.json) then return ""
return "unreadable reply"
end if
status = res.statusCode
if not isValid(status) then return "timed out"
if status < 0 then return "network error " + status.toStr()
if status = 0 then return "not sent"
return "HTTP " + status.toStr()
end function
end namespace