Phenomenal Photo by Tobias Keller on Unsplash
I've been looking to use fp-ts to communicate with a webdriver, called fp-ts-webdriver
. I love the API because it allows the reduction of side effects and creates testable code.
Here is a non-exhaustive list of challenges across the way, hoping the problem solving process may aid you in your challenges.
Why
There are so many webdriver frameworks out in the abyss. Why bother writing another? Why not just wrap an existing client side API in fp-ts?
I already tried it. It sucks:
- Imperative code is hard to follow (now that I've indulged in functional programming).
- The
async/await
syntax is terrible to work with.then().then().then()
- Because promises aren't lazy, they cannot be easily composed.
Challenges
Understanding the Webdriver
The webdriver protocol is a specification written by W3C, the officially standards body of the web. The specification indicates a client-server relationship, just like when we send a message to a server and the server sends back information, like a webpage. We are the client (or local end) and the server is the remote end.
The client sends a request (a webdriver command) to the remote web driver, as advised in the specification. The remote end processes the request, performs the actions you have asked, then sends back an error or a success response, as advised in the specification.
As a web developer, you're likely to have seen the client-server relationship in action many times prior.
Choosing how to compose the API
Asynchonous Composition
In fp-ts and functional programming, we use monads to compose actions.
In our case, we want to:
- Compose the sending of requests to the remote webdriver (server).
- Send a request.
- Receive the response.
- If a successful response (200), send the next request
- If it's an unsuccessful response (
<200 && >=300
), return an error (without throwing of course) and do not send another request issued by the user. - ALWAYS cleanup any resources (eg. An open session)
I knew I was going to use the fetch
API to send a request, which returns a promise that may return an error. The monad of choice is TaskEither
. A Task
is a lazy promise and an Either
is monad with 2 mutually present values, meaning it's either one value or the other.
Here's an article I wrote to prime you for Either.
Dependency Injection
We have a few values we don't quite know their exact values as of yet. I want to expose these as options for the user, so they can choose the endpoint
(url) and change the RequestInit
so they could use a proxy or do any other HTTP configuration that I don't know of yet.
In fp-ts, use the Reader
monad for composing dependency injection. All it is a function that always takes the same argument <R, A>(dependency: R) => A
. To inject more than one, use a tuple or an object. We'll use an object so we can name our properties.
Our monad is now ReaderTaskEither
, where the Reader
is wrapped around TaskEither
.
This feels good! Can we go further?
Session Composition
YES! We can go further.
I noticed that the session was always an argument in the functions I needed. Not in all of it, but with 80% of the functions. This is a dependency. You know what we do with dependencies? We apply (inject) them!
Instead of merging our dependencies, let's keep them separate. Refactored, we have now created a new monad, never before seen by fp-ts: ReaderReaderTaskEither
.
It sounds like we're going over the top, and it does seem like we've gone bananas...
But you wanted to control a browser right? That's a lot of responsibility. We need the power of functional programming!
With great responsibility comes great power.
Runtime type safety
We know the shape that the response should be in Success<A> = {value: A}
, where A
could be anything. When we know the shape of A at compile time, how do we ensure that every response we use is the right type at runtime? Imagine getting response 500
instead of 200
, in which the responses body is not type Success<A>
. Ouch.
If you know the fp-ts ecosystem well, you'll know we're using our boy io-ts
to ensure that the typescript you see in your project will be GUARANTEED at runtime. We'll be using the Decode
module.
This uses the Either
monad ,as discussed earlier. This lifts easily into *TaskEither
via Kleisli Composition, which we'll use internally.
Boilerplate
This is where we start to apply all the previous content in this post.
After a while, we start to see a pattern emerge. We're copying and pasting a lot of code. Some copy and paste is expected, as we're writing a uniform API. However, you'll know what to refactor to after doing it 6 TIMES.
If you get one take from this, it is that you must repeatedly write code to truly understand how and why you're able to refactor it.
I created a make
function, which takes a decoder
, method
, endomorphicURL
and maybe a body
. Endomorphism is <A>(a: A) => A
; The input type is the same as the output type. We use a string for this property.
Reading a Specification
Now specifications are supposed to clear and normative. Let's try decipher one together.
The remote end steps are:
- Let data be the result of getting a property named cookie from the parameters argument.
- If data is not a JSON Object with all the required (non-optional) JSON keys listed in the table for cookie conversion, return error with error code invalid argument.
The server (remote) expects to see at least the following the body of the response: { cookie: { name: "", value: "", path?: "", ... rest } }
.
I won't list the rest, but you can see them in the table for cookie conversion
It's a lot of jargon, but after a while it's like a second language.
Testing a webdriver
This hit me for a six at the start.
In jest, we need to call beforeAll()
to spin up a webdriver (chromedriver in our case) and afterAll()
to spind down the webdriver.
On my local machine, it opens up an actual browser and I can see it do the tests. What if we don't want that when it's running on CI? Welcome to headless.
I had to squeeze together a bunch of resources to find this fix, so here's the code:
const capabilities: Capabilities = {
alwaysMatch: {
"goog:chromeOptions": { args: ["--headless"] },
},
}
Now all the setup is done, I can just write a test for a feature.
If it works, it's ready to ship!
Publishing
I've published a few libraries, but I knew I needed to automate this one.
I conjured up GitHub Actions alongside semantic-release
. The only thing I have to do is use git
correctly, run git pull --rebase
before running git push
in order to get. You should be doing this anyway, depending on your workflow.
I get an iterative change log, a published NPM package and a release on GitHub without even having to think about publishing. Phenomenal!
Top comments (0)