As a library implementer, sans-io targeting Erlang and JavaScript with different concurrency models without duplicating code is very elegant, and you're so in the weeds of http you can say "Just don't make the request for me. Just let me make the request. It's one extra line of code, it's fine.". As a library user, though, these details might be too low level: I don't want to hear about IO and function coloring and snapping lego together; I want a single function to prepare, send, and parse my http request. Layering common http clients over sans-io abstracts ugliness away, and if it doesn't help sans-io is still an option.
For example, for the spacetraders API, a wrapper over sansio+httpc or a wrapper over sansio+fetch may be ideal for users who don't want to worry about http stuff. Meanwhile sans-io is still available for users who need it.
Layered APIs are probably described many places, but I like how API Design by Carson Gross motivates easy high level APIs on top of flexible low level ones, for instance http client wrappers over sans-io.
When you develop a sans-io library, you have to test it against an http client, so you can provide your first layered client by pulling out and formalizing that code you've already written. Even so, providing a layered client is a tradeoff between maintainence burden and friendly API.
In my project, Gleam fhir, it's not that I think library users are too dumb to understand http if they try, but it does save a little attention to abstract http away, and it's good to save 100% focus for complex FHIR data. So for example, instead of 3 lines to plug into httpc to read a patient, a wrapper client can read a patient in 1 line, without worrying about sansio or httpc stuff around http.
import fhir/r4_httpc
import fhir/r4_sansio
import gleam/httpc
pub fn main() {
let fc = r4_httpc.fhirclient_new("https://r4.smarthealthit.org/")
//prepare req (sansio), send (httpc), parse resp (sansio)
let pat_req =
r4_sansio.patient_read_req("87a339d0-8cae-418e-89c7-8651e6aab3c6", fc)
let assert Ok(pat_resp) = httpc.send(pat_req)
let assert Ok(pat1) = r4_sansio.patient_resp(pat_resp)
//read patient (httpc)
let assert Ok(pat2) =
r4_httpc.patient_read("87a339d0-8cae-418e-89c7-8651e6aab3c6", fc)
echo pat1 == pat2
}
Normally httpc and fetch would probably be good choices, but I want to do a Lustre browser app, so I'm doing rsvp. But I or someone else could always go add fetch or whatever, each layered client is its own little modular module.
I am not an expert on Lustre or rsvp, but it seems different enough that we can't have the same pattern as httpc of just doing everything in one function. Instead of sending and receiving the request all at once, rsvp gives you an Effect to give to Lustre, which at some later point comes back with a Msg.
httpc: prepare request -> send -> parse response
rsvp: prepare request (and handler) -> give Effect to lustre
.
-> lustre sends request (and uses handler on response)
.
-> lustre returns Msg
It's nice to define request and handler all in one place, they're different little mechanisms but all part of the same concern. So a wrapper that puts those together for us at least does something, although it can't totally abstract away receiving a Msg. Also, the API to get type safe search params is a bit verbose. So you might look at this and say eh, is that supposed to be the nice version?
UserTypedName(name) ->
case name {
"" -> #(Model(..model, show: EmptyMsg), effect.none())
name -> {
let search: Effect(Msg) =
r4_rsvp.patient_search(
search_for: r4_sansio.SpPatient(
..r4_sansio.sp_patient_new(),
name: Some(name),
),
with_client: model.client,
response_msg: ServerReturnedPatients,
)
let model = Model(..model, show: LoadingMsg)
#(model, search)
}
}
ServerReturnedPatients(Ok(pats)) -> #(
Model(..model, show: Pats(pats)),
effect.none(),
)
Well, let's see how it looks without the wrapper:
type ErrSansioOrRsvp {
ErrRsvp(rsvp.Error)
ErrSansio(r4_sansio.Err)
}
UserTypedName(name) ->
case name {
"" -> #(Model(..model, show: EmptyMsg), effect.none())
name -> {
let search_req =
r4_sansio.patient_search_req(
r4_sansio.SpPatient(
..r4_sansio.sp_patient_new(),
name: Some(name),
),
model.client,
)
let handle_read = fn(resp_res: Result(Response(String), rsvp.Error)) {
ServerReturnedPatients(case resp_res {
Error(err) -> Error(ErrRsvp(err))
Ok(resp_res) -> {
case r4_sansio.any_resp(resp_res, r4.bundle_decoder()) {
Ok(bundle) ->
Ok(
{ bundle |> r4_sansio.bundle_to_groupedresources }.patient,
)
Error(err) -> Error(ErrSansio(err))
}
}
})
}
let handler = rsvp.expect_any_response(handle_read)
let search = rsvp.send(search_req, handler)
let model = Model(..model, show: LoadingMsg)
#(model, search)
}
}
ServerReturnedPatients(Ok(pats)) -> #(
Model(..model, show: Pats(pats)),
effect.none(),
)
Imo, much worse without the wrapper to combine rsvp and r4_sansio for us. Remember you have the answer sitting in front of you; imagine writing it from scratch, especially if new to Gleam or Lustre. I'd bet if you give r4_sansio and rsvp to random programmers it would take them longer to get working than if you gave them the layered r4_rsvp. Could someone very familiar with translating sansio to rsvp write this fast or clean it up? Probably, but it's not trivial for everyone.
On the bright side, Gleam is nice in that once we line up the types it might even work on the first try. And after all this work we get a cool demo where you can type a name and it will go off to https://r4.smarthealthit.org/ and search for that name, then list the returned patients or error. (see network in your browser tools if interested)
[Source] P.S. it has a bug which might make a good case against this sort of layering, but I will leave that as an exercise to the reader.