From 80673c8376de2aa7df8cf19d56e9962a8a4708ad Mon Sep 17 00:00:00 2001 From: Douglas Wade Date: Sun, 9 Jul 2023 15:35:33 -0700 Subject: [PATCH] feat(#23): Pass in a custom fetching function rather than a URL --- eleventy-fetch.js | 10 +++- src/AssetCache.js | 11 +++- src/RemoteAssetCache.js | 23 ++++++-- test/AssetCacheTest.js | 19 +++++++ test/RemoteAssetCacheTest.js | 103 ++++++++++++++++++++++++++++++++--- 5 files changed, 150 insertions(+), 16 deletions(-) diff --git a/eleventy-fetch.js b/eleventy-fetch.js index f8d5da8..8cc425f 100644 --- a/eleventy-fetch.js +++ b/eleventy-fetch.js @@ -34,11 +34,19 @@ function isFullUrl(url) { } } +function isAwaitable(maybeAwaitable) { + return (typeof maybeAwaitable === "object" && typeof maybeAwaitable.then === "function") || (maybeAwaitable.constructor.name === "AsyncFunction"); +} + async function save(source, options) { - if(!isFullUrl(source)) { + if(!(isFullUrl(source) || isAwaitable(source))) { return Promise.reject(new Error("Caching an already local asset is not yet supported.")); } + if (isAwaitable(source) && !options.formatUrlForDisplay) { + return Promise.reject(new Error("formatUrlForDisplay must be implemented, as a Promise has been provided.")); + } + let asset = new RemoteAssetCache(source, options.directory, options); return asset.fetch(options); } diff --git a/src/AssetCache.js b/src/AssetCache.js index 362f973..1fceb5c 100644 --- a/src/AssetCache.js +++ b/src/AssetCache.js @@ -7,8 +7,14 @@ const { createHash } = require("crypto"); const debug = require("debug")("EleventyCacheAssets"); class AssetCache { - constructor(uniqueKey, cacheDirectory, options = {}) { - this.uniqueKey = uniqueKey; + constructor(url, cacheDirectory, options = {}) { + let uniqueKey; + if ((typeof url === "object" && typeof url.then === "function") || (typeof url === "function" && url.constructor.name === "AsyncFunction")) { + uniqueKey = options.formatUrlForDisplay(); + } else { + uniqueKey = url; + } + this.hash = AssetCache.getHash(uniqueKey, options.hashLength); this.cacheDirectory = cacheDirectory || ".cache"; this.defaultDuration = "1d"; @@ -24,6 +30,7 @@ class AssetCache { } // Defult hashLength also set in global options, duplicated here for tests + // Default hashLength also set in global options, duplicated here for tests static getHash(url, hashLength = 30) { let hash = createHash("sha256"); hash.update(url); diff --git a/src/RemoteAssetCache.js b/src/RemoteAssetCache.js index 4eea3e7..f41a493 100644 --- a/src/RemoteAssetCache.js +++ b/src/RemoteAssetCache.js @@ -64,13 +64,24 @@ class RemoteAssetCache extends AssetCache { this.log( `${isDryRun? "Fetching" : "Cache miss for"} ${this.displayUrl}`); let fetchOptions = optionsOverride.fetchOptions || this.options.fetchOptions || {}; - let response = await fetch(this.url, fetchOptions); - if(!response.ok) { - throw new Error(`Bad response for ${this.displayUrl} (${response.status}): ${response.statusText}`, { cause: response }); - } - let type = optionsOverride.type || this.options.type; - let body = await this.getResponseValue(response, type); + + let body; + if(typeof this.url === "object" && typeof this.url.then === "function") { + body = await this.url; + } else if (typeof this.url === "function" && this.url.constructor.name === "AsyncFunction") { + body = await this.url(); + } else { + let response = await fetch(this.url, fetchOptions); + if (!response.ok) { + throw new Error( + `Bad response for ${this.displayUrl} (${response.status}): ${response.statusText}`, + { cause: response } + ); + } + + body = await this.getResponseValue(response, type); + } if(!isDryRun) { await super.save(body, type); } diff --git a/test/AssetCacheTest.js b/test/AssetCacheTest.js index c640db8..e7c5ead 100644 --- a/test/AssetCacheTest.js +++ b/test/AssetCacheTest.js @@ -59,4 +59,23 @@ test("Cache path should handle slashes without creating directories, issue #14", let cachePath = normalizePath(cache.cachePath); t.is(cachePath, "/tmp/.cache/eleventy-fetch-135797dbf5ab1187e5003c49162602"); +}); + +test("Uses formatUrlForDisplay when caching a promise", async t => { + let promise = Promise.resolve(); + let displayUrl = 'mock-display-url' + let asset = new AssetCache(promise, ".customcache", { + formatUrlForDisplay() { + return displayUrl; + } + }); + let cachePath = normalizePath(asset.cachePath); + let jsonCachePath = normalizePath(asset.getCachedContentsPath("json")); + + await asset.save({name: "Sophia Smith" }, "json"); + + t.truthy(fs.existsSync(jsonCachePath)); + + fs.unlinkSync(cachePath); + fs.unlinkSync(jsonCachePath); }); \ No newline at end of file diff --git a/test/RemoteAssetCacheTest.js b/test/RemoteAssetCacheTest.js index 99b77b9..19b8bf3 100644 --- a/test/RemoteAssetCacheTest.js +++ b/test/RemoteAssetCacheTest.js @@ -4,6 +4,12 @@ const { Util } = require("../"); const AssetCache = require("../src/AssetCache"); const RemoteAssetCache = require("../src/RemoteAssetCache"); +// Cause is an optional enhancement for Node 16.19+ +let [major, minor] = process.version.split("."); +major = parseInt(major.startsWith("v") ? major.slice(1) : major, 10); +minor = parseInt(minor, 10); +const isCauseSupported = major > 16 || major === 16 && minor > 19; + test("getDurationMs", t => { let cache = new RemoteAssetCache("lksdjflkjsdf"); t.is(cache.getDurationMs("1s"), 1000); @@ -112,13 +118,96 @@ test("Error with `cause`", async t => { } catch(e) { t.is(e.message, `Bad response for https://example.com/207115/photos/243-0-1.jpg (404): Not Found`) - // Cause is an optional enhancement for Node 16.19+ - let [major, minor] = process.version.split("."); - major = parseInt(major.startsWith("v") ? major.slice(1) : major, 10); - minor = parseInt(minor, 10); - - if(major > 16 || major === 16 && minor > 19) { + if(isCauseSupported) { t.truthy(e.cause); } } -}); \ No newline at end of file +}); + +test("supports promises that resolve", async (t) => { + let expected = { mockKey: "mockValue" }; + let promise = Promise.resolve(expected); + let asset = new RemoteAssetCache(promise, undefined, { + type: "json", + formatUrlForDisplay() { + return "resolve-promise"; + }, + }); + + let actual = await asset.fetch(); + + t.deepEqual(actual, expected); + }); + + test("supports promises that reject", async (t) => { + let expected = "mock error message"; + let cause = new Error("mock cause"); + let promise; + if (isCauseSupported) { + promise = Promise.reject(new Error(expected, { cause })); + } else { + promise = Promise.reject(new Error(expected)); + } + let asset = new RemoteAssetCache(promise, undefined, { + formatUrlForDisplay() { + return "reject-promise"; + }, + }); + + try { + await asset.fetch(); + } catch (e) { + t.is(e.message, expected); + + if (isCauseSupported) { + t.is(e.cause, cause); + } + } + }); + + test("supports async functions that return data", async (t) => { + let expected = { mockKey: "mockValue" }; + let asyncFunction = async () => { + return Promise.resolve(expected); + }; + let asset = new RemoteAssetCache(asyncFunction, undefined, { + type: "json", + formatUrlForDisplay() { + return "async-return"; + }, + }); + + let actual = await asset.fetch(); + + t.deepEqual(actual, expected); + }); + + test("supports async functions that throw", async (t) => { + let expected = "mock error message"; + let cause = new Error("mock cause"); + let asyncFunction; + if (isCauseSupported) { + asyncFunction = async () => { + throw new Error(expected, { cause }); + }; + } else { + asyncFunction = async () => { + throw new Error(expected); + }; + } + let asset = new RemoteAssetCache(asyncFunction, undefined, { + formatUrlForDisplay() { + return "async-throws"; + }, + }); + + try { + await asset.fetch(); + } catch (e) { + t.is(e.message, expected); + + if (isCauseSupported) { + t.is(e.cause, cause); + } + } + }); \ No newline at end of file