" }
55 |
56 | []
57 | module CloudLayerApi =
58 |
59 |
60 | let isOnline connection =
61 | connection |> fetch Head "/oapi" |> map (fun res -> res.IsSuccessStatusCode)
62 |
63 |
64 | let accountStatus connection =
65 | connection |> fetch Get "/v1/getStatus" |> bind tryParseResponse
66 |
67 | let fetchImageWith (options : ImageOptions) connection =
68 | let uri =
69 | match options.Source with
70 | | Url _ -> "/v1/url/image"
71 | | Html _ -> "/v1/html/image"
72 |
73 | connection |> fetch (Post options) uri |> bind tryReadStream
74 |
75 | let fetchImage source connection =
76 | connection |> fetchImageWith { ImageOptions.Defaults with Source = source }
77 |
78 | let fetchPdfWith (options : PdfOptions) connection =
79 | let uri =
80 | match options.Source with
81 | | Url _ -> "/v1/url/pdf"
82 | | Html _ -> "/v1/html/pdf"
83 |
84 | connection |> fetch (Post options) uri |> bind tryReadStream
85 |
86 |
87 | let fetchPdf source connection =
88 | connection |> fetchPdfWith { PdfOptions.Defaults with Source = source }
89 |
90 | let saveToFile (path: string) result =
91 | result
92 | |> Result.mapAsync(fun (stream : Stream, resp) ->
93 | async {
94 | let! token = Async.CancellationToken
95 | use file = File.OpenWrite(path)
96 | do! stream.CopyToAsync(file, 1024, token) |> Async.AwaitTask
97 | return resp
98 | })
99 |
100 | let toByteArray result =
101 | result
102 | |> Result.mapAsync(fun (stream : Stream, _) ->
103 | async {
104 | let! token = Async.CancellationToken
105 | use memstream = new MemoryStream()
106 | do! stream.CopyToAsync(memstream, 1024, token) |> Async.AwaitTask
107 | return memstream.ToArray()
108 | })
109 |
--------------------------------------------------------------------------------
/tests/ApiSpecs.fs:
--------------------------------------------------------------------------------
1 | module CloudLayer.Testing.ApiSpecs
2 |
3 | open System
4 | open NUnit.Framework
5 | open CloudLayerIo
6 | open System.Security.Cryptography
7 | open System.IO
8 |
9 | let conn = Connection.Defaults
10 | let badconn = { conn with ApiKey = "unauthorized-key" }
11 |
12 | let isOk = function | Ok _ -> true | Error _ -> false
13 | let run async' = Async.RunSynchronously(async', 5000000)
14 |
15 | []
16 | let ``API key exists`` () =
17 | Assert.That(not (String.IsNullOrWhiteSpace conn.ApiKey))
18 |
19 | []
20 | let ``Service is online`` () =
21 | Assert.That(conn |> CloudLayerApi.isOnline |> run)
22 |
23 | []
24 | let ``Account is active`` () =
25 | Assert.That(conn |> CloudLayerApi.accountStatus |> run |> isOk)
26 |
27 | []
28 | let ``Has valid rate-limits`` () =
29 | let result = conn |> CloudLayerApi.accountStatus |> run
30 | match result with
31 | | Ok limit ->
32 | Assert.That(limit.Limit > 0)
33 | Assert.That(limit.Remaining > 0)
34 | Assert.That(limit.ResetsAt > DateTimeOffset.UnixEpoch)
35 | | Error err ->
36 | Assert.Fail(string err)
37 |
38 | []
39 | let ``Fails on bad API key`` () =
40 | let response = badconn |> CloudLayerApi.accountStatus |> run
41 | let isInvalid = response = Error FailureReason.InvalidApiKey
42 | Assert.That(isInvalid)
43 |
44 | let referenceUrl = Url "http://acid2.acidtests.org/reference.html"
45 | let referenceHtml = Html "
Hello World!
"
46 |
47 | let fileContents fileName =
48 | File.ReadAllBytes(fileName) |> Ok
49 |
50 | let isSame actual reference =
51 | match actual, reference with
52 | | Ok a, Ok b ->
53 | let headerLength = 10
54 |
55 | // same header
56 | let headerMatches =
57 | (a |> Array.take headerLength) = (b |> Array.take headerLength)
58 | let (la,lb) = Array.length a, Array.length b
59 |
60 | // file sizes match to within 1%
61 | let similarSize = (abs la - lb) <= (lb / 100)
62 |
63 | headerMatches && similarSize
64 | | _ -> false
65 |
66 | []
67 | let ``Captures an image from a url`` () =
68 | let res =
69 | conn
70 | |> CloudLayerApi.fetchImage referenceUrl
71 | |> CloudLayerApi.toByteArray
72 | |> Async.RunSynchronously
73 |
74 | let imgRef = fileContents "url-reference.jpg"
75 |
76 | Assert.That(isSame res imgRef)
77 |
78 | []
79 | let ``Captures an image from html`` () =
80 | let res =
81 | conn
82 | |> CloudLayerApi.fetchImage referenceHtml
83 | |> CloudLayerApi.toByteArray
84 | |> Async.RunSynchronously
85 |
86 | let imgRef = fileContents "html-reference.jpg"
87 |
88 | Assert.That(isSame res imgRef)
89 |
90 | []
91 | let ``Captures a pdf from a url`` () =
92 | let res =
93 | conn
94 | |> CloudLayerApi.fetchPdf referenceUrl
95 | |> CloudLayerApi.toByteArray
96 | |> Async.RunSynchronously
97 |
98 | let pdfRef = fileContents "url-reference.pdf"
99 |
100 | Assert.That(isSame res pdfRef)
101 |
102 | []
103 | let ``Captures a pdf from html`` () =
104 | let res =
105 | conn
106 | |> CloudLayerApi.fetchPdf referenceHtml
107 | |> CloudLayerApi.toByteArray
108 | |> Async.RunSynchronously
109 |
110 | let pdfRef = fileContents "html-reference.pdf"
111 |
112 | Assert.That(isSame res pdfRef)
113 |
114 | []
115 | let ``Saves a file to disk`` () =
116 | let filename = "eagle-island.jpg"
117 | let result =
118 | conn
119 | |> CloudLayerApi.fetchImageWith
120 | { ImageOptions.Defaults with
121 | Source = Url "https://www.openstreetmap.org#map=13/-6.1918/71.2976"
122 | Timeout = TimeSpan.FromSeconds 60.
123 | Inline = false }
124 | |> CloudLayerApi.saveToFile filename
125 | |> Async.RunSynchronously
126 |
127 | Assert.That(isOk result)
128 | Assert.That(File.Exists filename)
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CloudLayer for F#
2 | This is the [CloudLayer](https://cloudlayer.io) API for easy access to our REST based API services using F#.
3 |
4 | To read about how to get started using CloudLayer, see [documentation here](https://cloudlayer.io/docs/getstarted).
5 |
6 | # Installation
7 |
8 | You can reference it directly from Nuget or Paket.
9 |
10 | ```powershell
11 | PS> Install-Package CloudLayer.FSharp
12 | ```
13 |
14 | The assembly targets `NetStandard 2.0`.
15 |
16 | # Usage
17 |
18 | To begin, create an API key from the [dashboard](https://cloudlayer.io/dashboard/account/api).
19 |
20 |
21 |
22 | ## Basics
23 |
24 | All API calls take in a `Connection`:
25 |
26 | ```fsharp
27 | let connection =
28 | { Connection.Defaults with ApiKey = "ca-644907a519df4f84b0db24b822b37c5e" }
29 | ```
30 |
31 | If you are using this from an Asp.Net Core app, you can specify a `IHttpClientFactory` to be used (this is usually available through Dependency Injection),
32 |
33 | ```fsharp
34 | let connection' = { connection with ClientFactory = factory }
35 | ```
36 |
37 | `IHttpClientFactory` avoids socket exhaustion problems and maintains a pool of `HttpClient` instances for reuse. See [this article](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests) for more.
38 |
39 | API calls have, as the last argument a `Connection`, and they take the shape:
40 |
41 | ```fsharp
42 | connection |> CloudLayerApi.apiCall : Async>
43 | ```
44 |
45 | All API calls return the `Result` type, and they follow the [railway-oriented approach](https://fsharpforfunandprofit.com/posts/recipe-part2/).
46 |
47 | ## Account Status
48 |
49 | You can check the status of your account with
50 |
51 | ```fsharp
52 | let status =
53 | connection |> CloudLayerApi.accountStatus |> Async.RunSynchronously
54 | ```
55 |
56 | The results can be pattern matched.
57 |
58 | ```fsharp
59 | match status with
60 | | Ok status ->
61 | $"{status.Remaining} of {status.Limit} remaining. " +
62 | $"Limit resets at {status.ResetsAt.LocalDateTime}"
63 | | Error err ->
64 | match err with
65 | | FailureReason.InvalidApiKey -> "Check your api key"
66 | | FailureReason.InsufficientCredit -> "Buy more credit pls"
67 | | FailureReason.SubscriptionInactive -> "Please activate your account"
68 | | FailureReason.Unauthorized -> "Please check your credentials or proxy"
69 | | other -> $"There was an error: {other}"
70 | |> printfn "%s"
71 | ```
72 |
73 | ## Creating Images
74 |
75 | CloudLayer can create images of public URLs:
76 |
77 | ```fsharp
78 | let image =
79 | connection |> CloudLayerApi.fetchImage (Url "https://google.com")
80 | ```
81 |
82 | and raw html:
83 |
84 | ```fsharp
85 | let image =
86 | connection |> CloudLayerApi.fetchImage (Html "
Hello World!
")
87 | ```
88 |
89 | and returns either a `System.IO.Stream` or a `FailureReason`.
90 |
91 | ```fsharp
92 | match image with
93 | | Ok (stream, status) ->
94 | //do something with stream
95 | | Error err ->
96 | failwithf "Something went wrong: %A" err
97 | ```
98 |
99 | You can save the result to a file with `saveToFile`, or read directly to memory with `toByteArray`.
100 |
101 | ```fsharp
102 | connection
103 | |> CloudLayerApi.fetchImage (Url "https://google.com")
104 | |> CloudLayerApi.saveToFile "google.jpg"
105 | |> Async.RunSynchronously
106 | ```
107 |
108 | 
109 |
110 | To use more configuration options, use `fetchImageWith`. Options are specified by the `ImageOptions` record.
111 |
112 | ```fsharp
113 |
114 | connection
115 | |> CloudLayerApi.fetchImageWith
116 | { ImageOptions.Defaults with
117 | Source = Url "https://www.openstreetmap.org#map=13/-6.1918/71.2976"
118 | Timeout = TimeSpan.FromSeconds 60.
119 | Inline = false }
120 | |> CloudLayerApi.saveToFile "eagle-island.jpg"
121 | |> Async.RunSynchronously
122 | ```
123 |
124 | ## Creating PDFs
125 |
126 | Creating PDFs is similar to the API for creating images.
127 |
128 | ```fsharp
129 | connection |> CloudLayerApi.fetchPdf (Url "https://en.wikipedia.org/wiki/Marine_snow")
130 | connection |> CloudLayerApi.fetchPdf (Html "
Hello from PDF!
")
131 | ```
132 |
133 | For more options, use `fetchPdfWith`. Options are specified by the `PdfOptions` record.
134 |
135 | ```fsharp
136 | connection
137 | |> CloudLayerApi.fetchPdfWith
138 | { PdfOptions.Defaults with
139 | Source = (Url "https://en.wikipedia.org/wiki/Marine_snow")
140 | PrintBackground = false
141 | Format = "A4" }
142 | |> CloudLayerApi.saveToFile "snow.pdf"
143 | |> Async.RunSynchronously
144 |
145 | ```
146 |
147 |
148 |
149 | ### Note
150 |
151 | This library is specifically for F#, if you are using C# you should [use our C# library](github.com/cloudlayerio/cloudlayerio-csharp). We did this because we wanted to give F# developers first class support instead of wrapping a C# library.
--------------------------------------------------------------------------------
/src/Http.fs:
--------------------------------------------------------------------------------
1 | namespace CloudLayerIo
2 |
3 | open System
4 | open System.Net
5 | open System.Net.Http
6 | open System.Net.Http.Json
7 | open System.Text
8 | open System.Text.Json
9 | open System.Text.Json.Serialization
10 |
11 | type ApiStatus =
12 | { Limit: int
13 | Remaining: int
14 | ResetsAt: DateTimeOffset }
15 |
16 | []
17 | type FailureReason =
18 | | InvalidApiKey
19 | | InsufficientCredit
20 | | SubscriptionInactive
21 | | InvalidRequest
22 | | Unauthorized
23 | | ServerError
24 | | ErrorCode of statusCode: HttpStatusCode * content: string
25 | | Faulted of Exception
26 |
27 | type CloudLayerResponse =
28 | { mutable reason: string
29 | mutable allowed: bool }
30 |
31 | type Source =
32 | | Url of string
33 | | Html of string
34 |
35 | module Service =
36 |
37 | let [] Uriv1 =
38 | #if DEV
39 | "https://dev-api.cloudlayer.io/oapi"
40 | #else
41 | "https://api.cloudlayer.io"
42 | #endif
43 |
44 | let [] ClientName = "cloudlayerio"
45 | let [] UserAgent = "cloudlayerio-fsharp"
46 | let [] ContentType = "application/json"
47 |
48 | let BaseUri = Uri Uriv1
49 |
50 | let internal ClientFactory = {
51 | new IHttpClientFactory with
52 | member _.CreateClient(_name) = new HttpClient()
53 | }
54 |
55 | type Connection =
56 | { ClientFactory: IHttpClientFactory
57 | ApiKey: string }
58 | static member Defaults =
59 | { ClientFactory = Service.ClientFactory
60 | ApiKey = Environment.GetEnvironmentVariable("CLOUDLAYER_API_KEY") }
61 |
62 | module internal Async =
63 |
64 | let bind fn asyncInstance =
65 | async.Bind(asyncInstance, fn)
66 |
67 | let map fn asyncInstance = async {
68 | let! value = asyncInstance
69 | return fn value
70 | }
71 |
72 | module internal Result =
73 | let mapAsync mapping asyncResult =
74 | asyncResult
75 | |> Async.bind(function
76 | | Ok value ->
77 | mapping value |> Async.map Ok
78 | | Error err ->
79 | async.Return(Error err)
80 | )
81 |
82 | module internal Http =
83 |
84 | open Service
85 | open Async
86 |
87 | type 'a Verb =
88 | | Head
89 | | Get
90 | | Post of data: 'a
91 |
92 |
93 | let parseRateLimit (response: HttpResponseMessage) =
94 | let getValue key =
95 | response.Headers.GetValues key
96 | |> Seq.map Int64.TryParse
97 | |> Seq.tryHead
98 | |> Option.bind(function true, v -> Some v | _ -> None)
99 | |> Option.defaultValue 0L
100 |
101 | {
102 | Limit = getValue "X-RateLimit-Limit" |> int
103 | Remaining = getValue "X-RateLimit-Remaining" |> int
104 | ResetsAt = getValue "X-RateLimit-Reset" |> DateTimeOffset.FromUnixTimeSeconds
105 | }
106 |
107 | let parseResponse (resp: HttpResponseMessage) = async {
108 | let! token = Async.CancellationToken
109 | let! response =
110 | if resp.Content.Headers.ContentType.MediaType = ContentType then
111 | resp.Content.ReadFromJsonAsync(cancellationToken = token)
112 | |> Async.AwaitTask
113 | |> map Some
114 | else
115 | async.Return None
116 |
117 | return
118 | match resp.StatusCode with
119 | | HttpStatusCode.OK
120 | | HttpStatusCode.Created ->
121 | Ok (parseRateLimit resp)
122 | | HttpStatusCode.Unauthorized ->
123 | match response with
124 | | Some res ->
125 | match res.reason with
126 | | "Invalid API key." -> FailureReason.InvalidApiKey
127 | | "Insufficient credit." -> FailureReason.InsufficientCredit
128 | | "Subscription inactive." -> FailureReason.SubscriptionInactive
129 | | _ -> FailureReason.Unauthorized
130 | | None -> FailureReason.Unauthorized
131 | |> Error
132 | | HttpStatusCode.BadRequest ->
133 | Error FailureReason.InvalidRequest
134 | | HttpStatusCode.InternalServerError ->
135 | Error FailureReason.ServerError
136 | | other ->
137 | let content = resp.Content.ReadAsStringAsync()
138 | do content.Wait(token)
139 | Error (FailureReason.ErrorCode (other, content.Result))
140 | }
141 |
142 | let tryParseResponse response = async {
143 | try
144 | return! parseResponse response
145 | with ex ->
146 | return Error (FailureReason.Faulted ex)
147 | }
148 |
149 | let tryReadStream response =
150 | response
151 | |> tryParseResponse
152 | |> Result.mapAsync(fun status ->
153 | response.Content.ReadAsStreamAsync()
154 | |> Async.AwaitTask
155 | |> map (fun stream -> stream, status)
156 | )
157 |
158 | let notSupported () = raise (NotSupportedException())
159 |
160 | type OptionValueConverter<'T>() =
161 | inherit JsonConverter<'T option>()
162 |
163 | override _.Write (writer, value: 'T option, options: JsonSerializerOptions) =
164 | match value with
165 | | Some value -> JsonSerializer.Serialize(writer, value, options)
166 | | None -> writer.WriteNullValue ()
167 |
168 | override _.Read (_r, _t, _o) =
169 | notSupported ()
170 |
171 | let serializerOptions =
172 | let opts = JsonSerializerOptions(PropertyNamingPolicy = JsonNamingPolicy.CamelCase)
173 |
174 | opts.Converters.Add <| {
175 | new Serialization.JsonConverter() with
176 | override _.Write(writer, timespan, _options) =
177 | writer.WriteNumberValue(int timespan.TotalMilliseconds)
178 | override _.Read(_r, _t, _opts) =
179 | notSupported ()
180 | }
181 |
182 | opts.Converters.Add <| {
183 | new Serialization.JsonConverter() with
184 | override _.Write(writer, source, _options) =
185 | match source with
186 | | Url url ->
187 | writer.WriteStringValue("url")
188 | writer.WriteString("url", url)
189 | | Html html ->
190 | writer.WriteStringValue("html")
191 | writer.WriteString("html", html |> Encoding.UTF8.GetBytes |> Convert.ToBase64String)
192 |
193 | override _.Read(r, t, opts) =
194 | notSupported ()
195 | }
196 |
197 | opts.Converters.Add <| { new Serialization.JsonConverterFactory() with
198 | override _.CanConvert(t: Type) : bool =
199 | t.IsGenericType &&
200 | t.GetGenericTypeDefinition() = typedefof