Use HTTP conditional requests, and fix some handling of HTTP error cases for BackendTask error paths (return the error data instead of handling them as build errors).

This commit is contained in:
Dillon Kearns 2023-01-06 10:03:19 -08:00
parent fef2cbf6a4
commit 89491a9515
9 changed files with 1611 additions and 131 deletions

View File

@ -1,5 +1,5 @@
it(`glob tests`, () => {
cy.visit("/tests");
cy.contains("All tests passed");
cy.document().should("not.include.text", "Expected");
cy.get(".test-pass").should("exist");
cy.get(".test-fail").should("not.exist");
});

View File

@ -0,0 +1,5 @@
it(`BackendTask tests`, () => {
cy.visit("/http-tests");
cy.get(".test-pass").should("exist");
cy.get(".test-fail").should("not.exist");
});

View File

@ -16,6 +16,7 @@ import Route exposing (Route)
import Server.Request as Request
import Server.Response as Response exposing (Response)
import Test.Glob
import Test.HttpRequests
import Test.Runner.Html
import Time
import Xml.Decode
@ -43,6 +44,15 @@ routes getStaticRoutes htmlToString =
)
|> ApiRoute.literal "tests"
|> ApiRoute.serverRender
, ApiRoute.succeed
(Request.succeed
(Test.HttpRequests.all
|> BackendTask.map viewHtmlResults
|> BackendTask.map html
)
)
|> ApiRoute.literal "http-tests"
|> ApiRoute.serverRender
, requestPrinter
, xmlDecoder
, multipleContentTypes

View File

@ -0,0 +1,80 @@
module Test.HttpRequests exposing (all)
import BackendTask exposing (BackendTask)
import BackendTask.Http
import Exception exposing (Catchable)
import Expect
import Json.Decode as Decode
import Test exposing (Test)
all : BackendTask error Test
all =
[ BackendTask.Http.get "http://httpstat.us/500" (Decode.succeed ())
|> test "http 500 error"
(\result ->
case result of
Err error ->
case error of
BackendTask.Http.BadStatus metadata string ->
metadata.statusCode
|> Expect.equal 500
_ ->
Expect.fail ("Expected BadStatus, got :" ++ Debug.toString error)
Ok () ->
Expect.fail "Expected HTTP error, got Ok"
)
, BackendTask.Http.get "http://httpstat.us/404" (Decode.succeed ())
|> test "http 404 error"
(\result ->
case result of
Err error ->
case error of
BackendTask.Http.BadStatus metadata string ->
metadata.statusCode
|> Expect.equal 404
_ ->
Expect.fail ("Expected BadStatus, got: " ++ Debug.toString error)
Ok () ->
Expect.fail "Expected HTTP error, got Ok"
)
, BackendTask.Http.get "https://api.github.com/repos/dillonkearns/elm-pages" (Decode.field "stargazers_count" Decode.int)
|> test "200 JSON"
(\result ->
case result of
Err error ->
Expect.fail ("Expected BadStatus, got: " ++ Debug.toString error)
Ok count ->
Expect.pass
)
, BackendTask.Http.get "https://api.github.com/repos/dillonkearns/elm-pages" (Decode.field "this-field-doesn't-exist" Decode.int)
|> test "JSON decoding error"
(\result ->
case result of
Err (BackendTask.Http.BadBody (Just (Decode.Failure failureString _)) _) ->
failureString
|> Expect.equal "Expecting an OBJECT with a field named `this-field-doesn't-exist`"
_ ->
Expect.fail ("Expected BadStatus, got: " ++ Debug.toString result)
)
]
|> BackendTask.combine
|> BackendTask.map (Test.describe "glob tests")
test : String -> (Result error data -> Expect.Expectation) -> BackendTask (Catchable error) data -> BackendTask noError Test
test name assert task =
task
|> BackendTask.toResult
|> BackendTask.map
(\result ->
Test.test name <|
\() ->
assert result
)

View File

@ -1,5 +1,4 @@
const path = require("path");
const fetch = require("node-fetch");
const objectHash = require("object-hash");
const kleur = require("kleur");
@ -44,6 +43,10 @@ function fullPath(portsHash, request, hasFsAccess) {
* @returns {Promise<Response>}
*/
function lookupOrPerform(portsFile, mode, rawRequest, hasFsAccess, useCache) {
const fetch = require("make-fetch-happen").defaults({
cachePath: "./.elm-pages/http-cache",
cache: mode === "build" ? "no-cache" : "default",
});
const { fs } = require("./request-cache-fs.js")(hasFsAccess);
return new Promise(async (resolve, reject) => {
const request = toRequest(rawRequest);
@ -151,57 +154,53 @@ function lookupOrPerform(portsFile, mode, rawRequest, hasFsAccess, useCache) {
console.timeEnd(`fetch ${request.url}`);
const expectString = request.headers["elm-pages-internal"];
if (response.ok || expectString === "ExpectResponse") {
let body;
let bodyKind;
if (expectString === "ExpectJson") {
body = await response.json();
let body;
let bodyKind;
if (expectString === "ExpectJson") {
try {
body = await response.buffer();
body = JSON.parse(body.toString("utf-8"));
bodyKind = "json";
} else if (
expectString === "ExpectBytes" ||
expectString === "ExpectBytesResponse"
) {
bodyKind = "bytes";
const arrayBuffer = await response.arrayBuffer();
body = Buffer.from(arrayBuffer).toString("base64");
} else if (expectString === "ExpectWhatever") {
bodyKind = "whatever";
body = null;
} else if (
expectString === "ExpectResponse" ||
expectString === "ExpectString"
) {
} catch (error) {
body = body.toString("utf8");
bodyKind = "string";
body = await response.text();
} else {
throw `Unexpected expectString ${expectString}`;
}
await fs.promises.writeFile(
responsePath,
JSON.stringify({
headers: Object.fromEntries(response.headers.entries()),
statusCode: response.status,
body: body,
bodyKind,
url: response.url,
statusText: response.statusText,
})
);
resolve({ kind: "cache-response-path", value: responsePath });
} else if (
expectString === "ExpectBytes" ||
expectString === "ExpectBytesResponse"
) {
body = await response.buffer();
try {
body = body.toString("base64");
bodyKind = "bytes";
} catch (e) {
body = body.toString("utf8");
bodyKind = "string";
}
} else if (expectString === "ExpectWhatever") {
bodyKind = "whatever";
body = null;
} else if (
expectString === "ExpectResponse" ||
expectString === "ExpectString"
) {
bodyKind = "string";
body = await response.text();
} else {
console.log("@@@ request-cache1 bad HTTP response");
reject({
title: "BackendTask.Http Error",
message: `${kleur
.yellow()
.underline(request.url)} Bad HTTP response ${response.status} ${
response.statusText
}
`,
});
throw `Unexpected expectString ${expectString}`;
}
resolve({
kind: "response-json",
value: {
headers: Object.fromEntries(response.headers.entries()),
statusCode: response.status,
body,
bodyKind,
url: response.url,
statusText: response.statusText,
},
});
} catch (error) {
console.trace("@@@ request-cache2 HTTP error", error);
reject({

1351
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -39,6 +39,7 @@
"gray-matter": "^4.0.3",
"jsesc": "^3.0.2",
"kleur": "^4.1.5",
"make-fetch-happen": "^11.0.2",
"memfs": "^3.4.7",
"micromatch": "^4.0.5",
"node-fetch": "^2.6.7",
@ -51,6 +52,7 @@
"devDependencies": {
"@types/cross-spawn": "^6.0.2",
"@types/fs-extra": "^9.0.13",
"@types/make-fetch-happen": "^10.0.1",
"@types/micromatch": "^4.0.2",
"@types/node": "^18.11.9",
"@types/serve-static": "^1.15.0",
@ -77,4 +79,4 @@
"bin": {
"elm-pages": "generator/src/cli.js"
}
}
}

View File

@ -6,6 +6,7 @@ module BackendTask exposing
, andMap
, map2, map3, map4, map5, map6, map7, map8, map9
, catch, throw, mapError, onError
, toResult
)
{-| In an `elm-pages` app, each Route Module can define a value `data` which is a `BackendTask` that will be resolved **before** `init` is called. That means it is also available
@ -541,3 +542,12 @@ throw : BackendTask (Catchable error) data -> BackendTask Throwable data
throw backendTask =
backendTask
|> onError (Exception.throw >> fail)
{-| -}
toResult : BackendTask (Catchable error) data -> BackendTask noError (Result error data)
toResult backendTask =
backendTask
|> catch
|> andThen (Ok >> succeed)
|> onError (Err >> succeed)

View File

@ -67,6 +67,7 @@ and describe your use case!
-}
import BackendTask exposing (BackendTask)
import Base64
import Bytes exposing (Bytes)
import Bytes.Decode
import Dict exposing (Dict)
@ -327,81 +328,139 @@ requestRaw request__ expect =
Nothing ->
--Err (Pages.StaticHttpRequest.UserCalledStaticHttpFail ("INTERNAL ERROR - expected request" ++ request_.url))
Err (BadBody ("INTERNAL ERROR - expected request" ++ request_.url))
Err (BadBody Nothing ("INTERNAL ERROR - expected request" ++ request_.url))
)
|> Result.andThen
(\(RequestsAndPending.Response maybeResponse body) ->
case ( expect, body, maybeResponse ) of
( ExpectJson decoder, RequestsAndPending.JsonBody json, _ ) ->
json
|> Json.Decode.decodeValue decoder
|> Result.mapError
(\error ->
error
|> Json.Decode.errorToString
|> BadBody
)
let
maybeBadResponse : Maybe Error
maybeBadResponse =
case maybeResponse of
Just response ->
if not (response.statusCode >= 200 && response.statusCode < 300) then
case body of
RequestsAndPending.StringBody s ->
BadStatus
{ url = response.url
, statusCode = response.statusCode
, statusText = response.statusText
, headers = response.headers
}
s
|> Just
( ExpectString mapStringFn, RequestsAndPending.StringBody string, _ ) ->
string
|> mapStringFn
|> Ok
RequestsAndPending.BytesBody bytes ->
BadStatus
{ url = response.url
, statusCode = response.statusCode
, statusText = response.statusText
, headers = response.headers
}
(Base64.fromBytes bytes |> Maybe.withDefault "")
|> Just
( ExpectResponse mapResponse, RequestsAndPending.StringBody asStringBody, Just rawResponse ) ->
let
asMetadata : Metadata
asMetadata =
{ url = rawResponse.url
, statusCode = rawResponse.statusCode
, statusText = rawResponse.statusText
, headers = rawResponse.headers
}
RequestsAndPending.JsonBody value ->
BadStatus
{ url = response.url
, statusCode = response.statusCode
, statusText = response.statusText
, headers = response.headers
}
(Encode.encode 0 value)
|> Just
rawResponseToResponse : Response String
rawResponseToResponse =
if 200 <= rawResponse.statusCode && rawResponse.statusCode < 300 then
GoodStatus_ asMetadata asStringBody
RequestsAndPending.WhateverBody ->
BadStatus
{ url = response.url
, statusCode = response.statusCode
, statusText = response.statusText
, headers = response.headers
}
""
|> Just
else
BadStatus_ asMetadata asStringBody
in
rawResponseToResponse
|> mapResponse
|> Ok
Nothing
( ExpectBytesResponse mapResponse, RequestsAndPending.BytesBody rawBytesBody, Just rawResponse ) ->
let
asMetadata : Metadata
asMetadata =
{ url = rawResponse.url
, statusCode = rawResponse.statusCode
, statusText = rawResponse.statusText
, headers = rawResponse.headers
}
Nothing ->
Nothing
in
case maybeBadResponse of
Just badResponse ->
Err badResponse
rawResponseToResponse : Response Bytes
rawResponseToResponse =
if 200 <= rawResponse.statusCode && rawResponse.statusCode < 300 then
GoodStatus_ asMetadata rawBytesBody
Nothing ->
case ( expect, body, maybeResponse ) of
( ExpectJson decoder, RequestsAndPending.JsonBody json, _ ) ->
json
|> Json.Decode.decodeValue decoder
|> Result.mapError
(\error ->
error
|> Json.Decode.errorToString
|> BadBody (Just error)
)
else
BadStatus_ asMetadata rawBytesBody
in
rawResponseToResponse
|> mapResponse
|> Ok
( ExpectString mapStringFn, RequestsAndPending.StringBody string, _ ) ->
string
|> mapStringFn
|> Ok
( ExpectBytes bytesDecoder, RequestsAndPending.BytesBody rawBytes, _ ) ->
rawBytes
|> Bytes.Decode.decode bytesDecoder
|> Result.fromMaybe
(BadBody "Bytes decoding failed.")
( ExpectResponse mapResponse, RequestsAndPending.StringBody asStringBody, Just rawResponse ) ->
let
asMetadata : Metadata
asMetadata =
{ url = rawResponse.url
, statusCode = rawResponse.statusCode
, statusText = rawResponse.statusText
, headers = rawResponse.headers
}
( ExpectWhatever whateverValue, RequestsAndPending.WhateverBody, _ ) ->
Ok whateverValue
rawResponseToResponse : Response String
rawResponseToResponse =
if 200 <= rawResponse.statusCode && rawResponse.statusCode < 300 then
GoodStatus_ asMetadata asStringBody
_ ->
Err (BadBody "Unexpected combination, internal error")
else
BadStatus_ asMetadata asStringBody
in
rawResponseToResponse
|> mapResponse
|> Ok
( ExpectBytesResponse mapResponse, RequestsAndPending.BytesBody rawBytesBody, Just rawResponse ) ->
let
asMetadata : Metadata
asMetadata =
{ url = rawResponse.url
, statusCode = rawResponse.statusCode
, statusText = rawResponse.statusText
, headers = rawResponse.headers
}
rawResponseToResponse : Response Bytes
rawResponseToResponse =
if 200 <= rawResponse.statusCode && rawResponse.statusCode < 300 then
GoodStatus_ asMetadata rawBytesBody
else
BadStatus_ asMetadata rawBytesBody
in
rawResponseToResponse
|> mapResponse
|> Ok
( ExpectBytes bytesDecoder, RequestsAndPending.BytesBody rawBytes, _ ) ->
rawBytes
|> Bytes.Decode.decode bytesDecoder
|> Result.fromMaybe
(BadBody Nothing "Bytes decoding failed.")
( ExpectWhatever whateverValue, RequestsAndPending.WhateverBody, _ ) ->
Ok whateverValue
_ ->
Err (BadBody Nothing "Unexpected combination, internal error")
)
|> BackendTask.fromResult
|> BackendTask.mapError
@ -432,7 +491,7 @@ errorToString error =
[ TerminalText.text ("BadStatus: " ++ string)
]
BadBody string ->
BadBody _ string ->
[ TerminalText.text ("BadBody: " ++ string)
]
)
@ -464,4 +523,4 @@ type Error
| Timeout
| NetworkError
| BadStatus Metadata String
| BadBody String
| BadBody (Maybe Json.Decode.Error) String