Istvan
Istvan

Reputation: 8562

Is there a way in F# to chain computation?

I would like to create a chain of expressions and any of them can fail when the computation should just stop.

With Unix pipes it is usually like this:

bash-3.2$ echo && { echo 'a ok'; echo; }  && { echo 'b ok'; echo; }
a ok

b ok

When something fails the pipeline stops:

echo && { echo 'a ok'; false; }  && { echo 'b ok'; echo; }

a ok

I can handle Optionals but my problem is that I might want to do multiple things in each branch:

let someExternalOperation = callToAnAPI()
match someExternalOperation with
| None -> LogAndStop()
| Some x -> LogAndContinue()

Then I would like to keep going with other API calls and only stop if there is an error.

Is there something like that in F#?

Update1:

What I am trying to do is calling out to external APIs. Each call can fail. Would be nice to try to retry but not required.

Upvotes: 1

Views: 173

Answers (1)

Aaron M. Eshbach
Aaron M. Eshbach

Reputation: 6510

You can use the F# Async and Result types together to represent the results of each API Call. You can then use the bind functions for those types to build a workflow in which you only continue processing when the previous calls were successful. In order to make that easier, you can wrap the Async<Result<_,_>> you would be working with for each api call in its own type and build a module around binding those results to orchestrate a chained computation. Here's a quick example of what that would look like:

First, we would lay out the type ApiCallResult to wrap Async and Result, and we would define ApiCallError to represent HTTP error responses or exceptions:

open System
open System.Net
open System.Net.Http

type ApiCallError =
| HttpError of (int * string)
| UnexpectedError of exn

type ApiCallResult<'a> = Async<Result<'a, ApiCallError>>

Next, we would create a module to work with ApiCallResult instances, allowing us to do things like bind, map, and return so that we can process the results of a computation and feed them into the next one.

module ApiCall =
    let ``return`` x : ApiCallResult<_> =
        async { return Ok x }

    let private zero () : ApiCallResult<_> = 
        ``return`` []

    let bind<'a, 'b> (f: 'a -> ApiCallResult<'b>) (x: ApiCallResult<'a>) : ApiCallResult<'b> =
        async {
            let! result = x
            match result with
            | Ok value -> 
                return! f value
            | Error error ->
                return Error error
        }

    let map f x = x |> bind (f >> ``return``)

    let combine<'a> (acc: ApiCallResult<'a list>) (cur: ApiCallResult<'a>) =
        acc |> bind (fun values -> cur |> map (fun value -> value :: values))

    let join results =
        results |> Seq.fold (combine) (zero ())

Then, you would have a module to simply do your API calls, however that works in your real scenario. Here's one that just handles GETs with query parameters, but you could make this more sophisticated:

module Api =
    let call (baseUrl: Uri) (queryString: string) : ApiCallResult<string> =
        async {
            try
                use client = new HttpClient()
                let url = 
                    let builder = UriBuilder(baseUrl)
                    builder.Query <- queryString
                    builder.Uri
                printfn "Calling API: %O" url
                let! response = client.GetAsync(url) |> Async.AwaitTask
                let! content = response.Content.ReadAsStringAsync() |> Async.AwaitTask
                if response.IsSuccessStatusCode then
                    let! content = response.Content.ReadAsStringAsync() |> Async.AwaitTask
                    return Ok content
                else
                    return Error <| HttpError (response.StatusCode |> int, content)
            with ex ->
                return Error <| UnexpectedError ex
        }

    let getQueryParam name value =
        value |> WebUtility.UrlEncode |> sprintf "%s=%s" name

Finally, you would have your actual business workflow logic, where you call multiple APIs and feed the results of one into another. In the below example, anywhere you see callMathApi, it is making a call to an external REST API that may fail, and by using the ApiCall module to bind the results of the API call, it only proceeds to the next API call if the previous call was successful. You can declare an operator like >>= to eliminate some of the noise in the code when binding computations together:

module MathWorkflow =
    let private (>>=) x f = ApiCall.bind f x

    let private apiUrl = Uri "http://api.mathjs.org/v4/" // REST API for mathematical expressions

    let private callMathApi expression =
        expression |> Api.getQueryParam "expr" |> Api.call apiUrl

    let average values =        
        values 
        |> List.map (sprintf "%d") 
        |> String.concat "+" 
        |> callMathApi
        >>= fun sum -> 
                sprintf "%s/%d" sum values.Length 
                |> callMathApi

    let averageOfSquares values =
        values 
        |> List.map (fun value -> sprintf "%d*%d" value value)
        |> List.map callMathApi
        |> ApiCall.join
        |> ApiCall.map (List.map int)
        >>= average

This example uses the Mathjs.org API to compute the average of a list of integers (making one API call to compute the sum, then another to divide by the number of elements), and also allows you to compute the average of the squares of a list of values, by calling the API asynchronously for each element in the list to square it, then joining the results together and computing the average. You can use these functions as follows (I added a printfn to the actual API call so it logs the HTTP requests):

Calling average:

MathWorkflow.average [1;2;3;4;5] |> Async.RunSynchronously

Outputs:

Calling API: http://api.mathjs.org/v4/?expr=1%2B2%2B3%2B4%2B5
Calling API: http://api.mathjs.org/v4/?expr=15%2F5
[<Struct>]
val it : Result<string,ApiCallError> = Ok "3"

Calling averageOfSquares:

MathWorkflow.averageOfSquares [2;4;6;8;10] |> Async.RunSynchronously

Outputs:

Calling API: http://api.mathjs.org/v4/?expr=2*2
Calling API: http://api.mathjs.org/v4/?expr=4*4
Calling API: http://api.mathjs.org/v4/?expr=6*6
Calling API: http://api.mathjs.org/v4/?expr=8*8
Calling API: http://api.mathjs.org/v4/?expr=10*10
Calling API: http://api.mathjs.org/v4/?expr=100%2B64%2B36%2B16%2B4
Calling API: http://api.mathjs.org/v4/?expr=220%2F5
[<Struct>]
val it : Result<string,ApiCallError> = Ok "44"

Ultimately, you may want to implement a custom Computation Builder to allow you to use a computation expression with the let! syntax, instead of explicitly writing the calls to ApiCall.bind everywhere. This is fairly simple, since you already do all the real work in the ApiCall module, and you just need to make a class with the appropriate Bind/Return members:


type ApiCallBuilder () =
    member __.Bind (x, f) = ApiCall.bind f x
    member __.Return x = ApiCall.``return`` x
    member __.ReturnFrom x = x
    member __.Zero () = ApiCall.``return`` ()

let apiCall = ApiCallBuilder()

With the ApiCallBuilder, you could rewrite the functions in the MathWorkflow module like this, making them a little easier to read and compose:

    let average values =        
        apiCall {
            let! sum =
                values 
                |> List.map (sprintf "%d") 
                |> String.concat "+" 
                |> callMathApi

            return! 
                sprintf "%s/%d" sum values.Length
                |> callMathApi
        }        

    let averageOfSquares values =
        apiCall {
            let! squares = 
                values 
                |> List.map (fun value -> sprintf "%d*%d" value value)
                |> List.map callMathApi
                |> ApiCall.join

            return! squares |> List.map int |> average
        }

These work as you described in the question, where each API call is made independently and the results feed into the next call, but if one call fails the computation is stopped and the error is returned. For example, if you change the URL used in the example calls here to the v3 API ("http://api.mathjs.org/v3/") without changing anything else, you get the following:

Calling API: http://api.mathjs.org/v3/?expr=2*2
[<Struct>]
val it : Result<string,ApiCallError> =
  Error
    (HttpError
       (404,
        "<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /v3/</pre>
</body>
</html>
"))

Upvotes: 3

Related Questions