diff --git a/elm.json b/elm.json index e2e4744..e1d819e 100644 --- a/elm.json +++ b/elm.json @@ -14,7 +14,9 @@ ], "elm-version": "0.19.0 <= v < 0.20.0", "dependencies": { + "danfishgold/base64-bytes": "1.1.0 <= v < 2.0.0", "elm/browser": "1.0.2 <= v < 2.0.0", + "elm/bytes": "1.0.8 <= v < 2.0.0", "elm/core": "1.0.5 <= v < 2.0.0", "elm/json": "1.1.3 <= v < 2.0.0", "elm/random": "1.0.0 <= v < 2.0.0", diff --git a/integration/elm.json b/integration/elm.json index d25cac1..b42ff43 100644 --- a/integration/elm.json +++ b/integration/elm.json @@ -7,7 +7,9 @@ "elm-version": "0.19.1", "dependencies": { "direct": { + "danfishgold/base64-bytes": "1.1.0", "elm/browser": "1.0.2", + "elm/bytes": "1.0.8", "elm/core": "1.0.5", "elm/html": "1.0.0", "elm/json": "1.1.3", diff --git a/integration/scripts/server.mjs b/integration/scripts/server.mjs index e63117a..8a90a4a 100644 --- a/integration/scripts/server.mjs +++ b/integration/scripts/server.mjs @@ -6,6 +6,7 @@ const PORT = process.env.PORT || 4999; app.use(morgan("tiny")); app.use(express.json()); +app.use(express.raw({ type: "application/octet-stream" })); app.get("/wait-then-respond/:time", (req, res) => { setTimeout(() => { @@ -14,6 +15,7 @@ app.get("/wait-then-respond/:time", (req, res) => { }); app.post("/echo", (req, res) => { + res.setHeaders(new Headers(req.headers)) res.send(req.body); }); diff --git a/integration/src/Main.elm b/integration/src/Main.elm index b78f7a3..13225d2 100644 --- a/integration/src/Main.elm +++ b/integration/src/Main.elm @@ -1,10 +1,14 @@ port module Main exposing (main) +import Bytes +import Bytes.Decode +import Bytes.Encode import ConcurrentTask as Task exposing (ConcurrentTask, UnexpectedError(..)) import ConcurrentTask.Http as Http import ConcurrentTask.Process import ConcurrentTask.Random import ConcurrentTask.Time +import Dict import Integration.Runner as Runner exposing (RunnerProgram) import Integration.Spec as Spec exposing (Spec) import Json.Decode as Decode exposing (Decoder) @@ -39,6 +43,8 @@ specs = , complexResponseSpec , missingFunctionSpec , httpJsonBodySpec + , httpHeadersSpec + , httpBytesSpec , httpMalformedSpec , httpStringSpec , httpTimeoutSpec @@ -225,6 +231,38 @@ httpJsonBodySpec = ) +httpBytesSpec : Spec +httpBytesSpec = + let + body : Bytes.Encode.Encoder + body = + Bytes.Encode.sequence + [ Bytes.Encode.unsignedInt32 Bytes.BE 41 + , Bytes.Encode.unsignedInt32 Bytes.BE 1 + ] + + response : Bytes.Decode.Decoder Int + response = + Bytes.Decode.map2 (+) + (Bytes.Decode.unsignedInt32 Bytes.BE) + (Bytes.Decode.unsignedInt32 Bytes.BE) + in + Spec.describe + "http bytes" + "sends http bytes body in a request and decodes them in response" + (Http.post + { url = echoBody + , headers = [] + , timeout = Nothing + , expect = Http.expectBytes response + , body = Http.bytesBody "application/octet-stream" (Bytes.Encode.encode body) + } + ) + (Spec.assertSuccess + (Spec.shouldEqual 42) + ) + + httpMalformedSpec : Spec httpMalformedSpec = Spec.describe @@ -242,6 +280,34 @@ httpMalformedSpec = ) +httpHeadersSpec : Spec +httpHeadersSpec = + Spec.describe + "http headers" + "should send and receive http headers" + (Http.post + { url = echoBody + , headers = [ Http.header "foo" "bar" ] + , expect = Http.withMetadata always Http.expectWhatever + , timeout = Nothing + , body = Http.emptyBody + } + ) + (Spec.assertSuccess + (\meta -> + case Dict.get "foo" meta.headers of + Just "bar" -> + Spec.pass + + Just x -> + Spec.failWith "Got a header but not the expected value" x + + Nothing -> + Spec.failWith "Did not contain expected header" meta + ) + ) + + httpStringSpec : Spec httpStringSpec = Spec.describe diff --git a/runner/http/fetch.ts b/runner/http/fetch.ts index 5c88390..48663bb 100644 --- a/runner/http/fetch.ts +++ b/runner/http/fetch.ts @@ -35,6 +35,20 @@ export function http(request: HttpRequest): Promise { body: x || null, })); } + case "BYTES": { + return res + .blob() + .then((blob) => blob.text()) + .then((x) => { + return { + url: res.url, + headers: headers, + statusCode: res.status, + statusText: res.statusText, + body: x || null, + }; + }); + } case "WHATEVER": { return { url: res.url, diff --git a/runner/http/index.ts b/runner/http/index.ts index 472a385..da41549 100644 --- a/runner/http/index.ts +++ b/runner/http/index.ts @@ -10,7 +10,7 @@ export interface HttpRequest { } export type HttpResponse = ResponseSuccess | ResponseError; -export type Expect = "STRING" | "JSON" | "WHATEVER"; +export type Expect = "STRING" | "JSON" | "BYTES" | "WHATEVER"; export interface ResponseSuccess { body: string | null; diff --git a/src/ConcurrentTask/Http.elm b/src/ConcurrentTask/Http.elm index 34e04ca..64d4203 100644 --- a/src/ConcurrentTask/Http.elm +++ b/src/ConcurrentTask/Http.elm @@ -1,7 +1,7 @@ module ConcurrentTask.Http exposing ( request, get, post - , Body, emptyBody, stringBody, jsonBody - , Expect, expectJson, expectString, expectWhatever + , Body, emptyBody, stringBody, jsonBody, bytesBody + , Expect, expectJson, expectString, expectBytes, expectWhatever, withMetadata , Header, header , Error(..), Metadata ) @@ -43,12 +43,12 @@ You could create entirely your own from scratch - maybe you want an http package # Body -@docs Body, emptyBody, stringBody, jsonBody +@docs Body, emptyBody, stringBody, jsonBody, bytesBody # Expect -@docs Expect, expectJson, expectString, expectWhatever +@docs Expect, expectJson, expectString, expectBytes, expectWhatever, withMetadata # Headers @@ -62,6 +62,9 @@ You could create entirely your own from scratch - maybe you want an http package -} +import Base64 +import Bytes exposing (Bytes) +import Bytes.Decode import ConcurrentTask exposing (ConcurrentTask) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) @@ -77,6 +80,7 @@ import Json.Encode as Encode type Body = EmptyBody | StringBody String String + | BytesBody String Bytes {-| Describe what you expect to be returned in an http response body. @@ -84,7 +88,9 @@ type Body type Expect a = ExpectJson (Decoder a) | ExpectString (Decoder a) + | ExpectBytes (Bytes.Decode.Decoder a) | ExpectWhatever (Decoder a) + | ExpectMetadata (Metadata -> Expect a) {-| An Http header for configuring a request. @@ -186,6 +192,31 @@ jsonBody value = stringBody "application/json" (Encode.encode 0 value) +{-| Put some `Bytes` in the body of your `Request`. This allows you to use +[`elm/bytes`](https://package.elm-lang.org/packages/elm/bytes/latest/) to have full control over the binary +representation of the data you are sending. For example, you could create an +`archive.zip` file and send it along like this: + + import Bytes exposing (Bytes) + + zipBody : Bytes -> Body + zipBody bytes = + bytesBody "application/zip" bytes + +The first argument is a [MIME type](https://en.wikipedia.org/wiki/Media_type) +of the body. In other scenarios you may want to use MIME types like `image/png` +or `image/jpeg` instead. + +**NOTE**: Because `Bytes` can't be sent out of a port (internally `ConcurrentTask` sends all its arguments out of a port), they are serialised to a base64 encoded `String`. + +Unfortunately some of the performance benefits of `Bytes` are lost at this point. + +-} +bytesBody : String -> Bytes -> Body +bytesBody = + BytesBody + + -- Expect @@ -204,6 +235,13 @@ expectString = ExpectString Decode.string +{-| Expect the response body to be `Bytes`, decode it using the supplied decoder. +-} +expectBytes : Bytes.Decode.Decoder a -> Expect a +expectBytes = + ExpectBytes + + {-| Discard the response body. -} expectWhatever : Expect () @@ -211,6 +249,27 @@ expectWhatever = ExpectWhatever (Decode.succeed ()) +{-| Include Http metadata in a successful response. +-} +withMetadata : (Metadata -> a -> b) -> Expect a -> Expect b +withMetadata toMeta expect = + case expect of + ExpectJson decoder -> + ExpectMetadata (\meta -> ExpectJson (Decode.map (toMeta meta) decoder)) + + ExpectString decoder -> + ExpectMetadata (\meta -> ExpectString (Decode.map (toMeta meta) decoder)) + + ExpectBytes decoder -> + ExpectMetadata (\meta -> ExpectBytes (Bytes.Decode.map (toMeta meta) decoder)) + + ExpectWhatever decoder -> + ExpectMetadata (\meta -> ExpectWhatever (Decode.map (toMeta meta) decoder)) + + ExpectMetadata f -> + ExpectMetadata (\meta -> withMetadata toMeta (f meta)) + + -- Send Request @@ -313,21 +372,32 @@ decodeExpect expect = |> Decode.andThen (\meta -> if meta.statusCode >= 200 && meta.statusCode < 300 then - case expect of - ExpectJson decoder -> - Decode.field "body" (decodeJsonBody decoder meta) - - ExpectString decoder -> - Decode.field "body" (Decode.map Ok decoder) - - ExpectWhatever decoder -> - Decode.field "body" (Decode.map Ok decoder) + decodeSuccess meta expect else withBodyValue (\body -> Err (BadStatus meta body)) ) +decodeSuccess : Metadata -> Expect a -> Decoder (Result Error a) +decodeSuccess meta expect = + case expect of + ExpectJson decoder -> + Decode.field "body" (decodeJsonBody decoder meta) + + ExpectString decoder -> + Decode.field "body" (Decode.map Ok decoder) + + ExpectBytes decoder -> + Decode.field "body" (decodeBytesBody decoder meta) + + ExpectWhatever decoder -> + Decode.field "body" (Decode.map Ok decoder) + + ExpectMetadata toMeta -> + decodeSuccess meta (toMeta meta) + + decodeMetadata : Decoder Metadata decodeMetadata = Decode.map4 Metadata @@ -351,6 +421,25 @@ decodeJsonBody decoder meta = ) +decodeBytesBody : Bytes.Decode.Decoder a -> Metadata -> Decoder (Result Error a) +decodeBytesBody decoder meta = + Decode.string + |> Decode.andThen + (\res -> + case Base64.toBytes res of + Just bytes -> + case Bytes.Decode.decode decoder bytes of + Just a -> + Decode.succeed (Ok a) + + Nothing -> + Decode.succeed (Err (BadBody meta (Encode.string res) (Decode.Failure "Could not decode Bytes" Encode.null))) + + Nothing -> + Decode.succeed (Err (BadBody meta (Encode.string res) (Decode.Failure "Invalid Bytes body" Encode.null))) + ) + + withBodyValue : (Decode.Value -> a) -> Decoder a withBodyValue decode = Decode.map decode (Decode.field "body" Decode.value) @@ -386,9 +475,26 @@ encodeExpect expect = ExpectJson _ -> Encode.string "JSON" + ExpectBytes _ -> + Encode.string "BYTES" + ExpectWhatever _ -> Encode.string "WHATEVER" + ExpectMetadata toExpect -> + let + -- It's safe to use fake metadata here to get at the expect kind `String`, as `withMetadata` never changes the underlying kind of expect. + -- But it's important not to expose the `ExpectMetadata` constructor as this would make it unsafe. + fake : Metadata + fake = + { url = "" + , statusCode = 123 + , statusText = "" + , headers = Dict.empty + } + in + encodeExpect (toExpect fake) + encodeHeaders : Body -> List Header -> Encode.Value encodeHeaders body headers = @@ -406,6 +512,9 @@ addContentTypeForBody body headers = StringBody contentType _ -> header "Content-Type" contentType :: headers + BytesBody contentType _ -> + header "Content-Type" contentType :: headers + encodeHeader : Header -> Encode.Value encodeHeader ( name, value ) = @@ -423,3 +532,8 @@ encodeBody body = EmptyBody -> Encode.null + + BytesBody _ bytes -> + Base64.fromBytes bytes + |> Maybe.withDefault "" + |> Encode.string