Skip to content

Commit

Permalink
wrap-fetch (#41)
Browse files Browse the repository at this point in the history
factor out fetch error handling
  • Loading branch information
wydengyre authored Jan 29, 2024
1 parent e829c81 commit d1d11f5
Show file tree
Hide file tree
Showing 13 changed files with 294 additions and 217 deletions.
6 changes: 0 additions & 6 deletions lib/error.ts

This file was deleted.

59 changes: 56 additions & 3 deletions lib/feed-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { json } from "itty-router";
import { feedHandler } from "./feed-handler.js";
import { FetchWithErr, NotOk, OkResponse } from "./fetch.js";
import * as logger from "./logger.js";
import { fetchMock, raiBaseUrl } from "./test/fetch-mock.js";
import genresJson from "./test/generi.json";
import feedJson from "./test/lastoriaingiallo.json";
import expectedJson from "./test/lastoriaingiallo.parsed.json" with {
type: "json",
};
Expand All @@ -16,14 +19,64 @@ test("feed-handler", async (t) => {
});

const baseUrl = new URL("https://test.dev/");
const raiBaseUrl = new URL("https://rai.dev/");
const mediaBaseUrl = new URL("https://media.dev/");

const fetchMock: FetchWithErr = async (input, init) => {
const requestUrlStr = input.toString();
const url = new URL(requestUrlStr);
const { protocol, hostname, pathname, search } = url;

if (!(protocol === raiBaseUrl.protocol && hostname === raiBaseUrl.hostname)) {
throw new Error(`unexpected request to ${requestUrlStr}`);
}

if (pathname === "/generi.json") {
return json(genresJson) as OkResponse;
}

if (pathname === "/programmi/lastoriaingiallo.json") {
return json(feedJson) as OkResponse;
}
if (pathname === "/programmi/500.json") {
throw new NotOk(url, 500, "server error");
}
if (pathname === "/programmi/corrupt.json") {
return json({ foo: "bar" }) as OkResponse;
}

const relinkerRel = "/relinker/relinkerServlet.htm";
const relinkerSearchStart = "?cont=";
if (
init?.method === "HEAD" &&
pathname === relinkerRel &&
search.startsWith(relinkerSearchStart)
) {
const urlStart = requestUrlStr.replace(
new URL(`${relinkerRel}${relinkerSearchStart}`, raiBaseUrl).toString(),
mediaBaseUrl.toString(),
);
const url = `${urlStart}.mp3`;
return {
url: url,
headers: new Headers({
"Content-Type": "audio/mpeg",
"Content-Length": "123456789",
}),
} as OkResponse;
}

throw new NotOk(url, 404, "not found");
};

const conf = {
baseUrl,
raiBaseUrl,
poolSize: 1,
fetch: fetchMock,
fetchWithErr: fetchMock,
logger: logger.disabled,
};

async function rssFeedSuccess() {
const req = new Request("https://test.dev/programmi/lastoriaingiallo.xml");
const resp = await feedHandler(conf, req);
Expand All @@ -42,7 +95,7 @@ async function rssFeedFail404() {
const text = await resp.text();
assert.strictEqual(
text,
"<error><code>404</code><message>Not Found</message></error>",
"<error><code>404</code><message>not found</message></error>",
);
}

Expand Down
16 changes: 8 additions & 8 deletions lib/feed-handler.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { createResponse } from "itty-router";
import { NotFoundError } from "./error.js";
import { convertFeed } from "./feed.js";
import { FetchWithErr, NotOk } from "./fetch.js";
import { Logger } from "./logger.js";

type Config = {
baseUrl: URL;
raiBaseUrl: URL;
poolSize: number;
fetch: typeof fetch;
fetchWithErr: FetchWithErr;
logger: Logger;
};
export async function feedHandler(
Expand All @@ -22,14 +22,14 @@ export async function feedHandler(
try {
feedXml = await convertFeed(conf, jsonPath);
} catch (e) {
const headers = new Headers({ "Content-Type": "application/xml" });
conf.logger.error("error converting feed", jsonPath, e);
const contentType = "application/xml";
const headers = new Headers({ "Content-Type": contentType });
let status = 500;
let body = "<error><code>500</code><message>server error</message></error>";
if (e instanceof NotFoundError) {
status = 404;
body = "<error><code>404</code><message>Not Found</message></error>";
} else {
conf.logger.error("error converting feed", jsonPath, e);
if (e instanceof NotOk && e.status === 404) {
status = e.status;
body = "<error><code>404</code><message>not found</message></error>";
}
return new Response(body, { status, headers });
}
Expand Down
38 changes: 18 additions & 20 deletions lib/feed.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { error, json } from "itty-router";
import { NotFoundError } from "./error.js";
import { json } from "itty-router";
import { ConvertConf, convertFeed } from "./feed.js";
import { FetchWithErr, NotOk, OkResponse } from "./fetch.js";
import feedJson from "./test/lastoriaingiallo.json" with { type: "json" };
import expectedJson from "./test/lastoriaingiallo.parsed.json" with {
type: "json",
Expand All @@ -18,12 +18,11 @@ test("feed", async (t) => {
const baseUrl = new URL("https://test.dev/");
const raiBaseUrl = new URL("https://rai.dev/");
const poolSize = 5; // arbitrary
const feedFetchFn: typeof fetch = async (input) => {
assert.strictEqual(input.toString(), "https://rai.dev/programmi/foo.json");
return json(feedJson);
};
const mediaFetchFn: typeof fetch = async (input) =>
({

const feedFetchFn: FetchWithErr = () =>
Promise.resolve(json(feedJson) as OkResponse);
const mediaFetchFn: FetchWithErr = (input) =>
Promise.resolve({
url: input
.toString()
.replace(/.+cont=(.*)/, (_, cont) => `https://media.dev/${cont}.mp3`),
Expand All @@ -32,33 +31,32 @@ const mediaFetchFn: typeof fetch = async (input) =>
"content-type": "audio/mpeg",
"content-length": "123456789",
}),
}) as Response;
} as OkResponse);

async function convertFeedSuccess() {
const fetchFn: typeof fetch = async (input) => {
const fetchWithErr: FetchWithErr = async (input) => {
return input.toString().endsWith("foo.json")
? feedFetchFn(input)
: mediaFetchFn(input);
};
const conf: ConvertConf = { raiBaseUrl, baseUrl, poolSize, fetch: fetchFn };
const conf: ConvertConf = { raiBaseUrl, baseUrl, poolSize, fetchWithErr };
const feed = await convertFeed(conf, "programmi/foo.json");
const parsed = parseFeed(feed);
assert.deepStrictEqual(parsed, expectedJson);
}

async function convertFeed404() {
const fetchFn = async () => error(404, "Not found");
const conf: ConvertConf = { raiBaseUrl, baseUrl, poolSize, fetch: fetchFn };
const expectedErr = new NotFoundError(
new URL("https://rai.dev/programmi/foo.json"),
"fetching feed",
);
await assert.rejects(convertFeed(conf, "programmi/foo.json"), expectedErr);
const url = new URL("https://rai.dev/programmi/foo.json");
const notFound = new NotOk(url, 404, "Not Found");
const fetchWithErr = () => Promise.reject(notFound);
const conf: ConvertConf = { raiBaseUrl, baseUrl, poolSize, fetchWithErr };
await assert.rejects(convertFeed(conf, "programmi/foo.json"), notFound);
}

async function convertFeedNonCompliantJson() {
const fetchFn = async () => json({ foo: "bar" });
const conf: ConvertConf = { raiBaseUrl, baseUrl, poolSize, fetch: fetchFn };
const fetchWithErr = () =>
Promise.resolve(json({ foo: "bar" }) as OkResponse);
const conf: ConvertConf = { raiBaseUrl, baseUrl, poolSize, fetchWithErr };
const expectedErr = /^Error: failed to parse feed JSON/;
await assert.rejects(convertFeed(conf, "programmi/foo.json"), expectedErr);
}
47 changes: 14 additions & 33 deletions lib/feed.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { PromisePool } from "@supercharge/promise-pool";
import { z } from "zod";
import { Podcast } from "../build/podcast/index.js";
import { NotFoundError } from "./error.js";
import { fetchInfo } from "./media.js";
import { FetchWithErr } from "./fetch.js";
import * as media from "./media.js";

const cardSchema = z.object({
episode_title: z.string(),
Expand Down Expand Up @@ -32,58 +32,39 @@ export type ConvertConf = {
raiBaseUrl: URL;
baseUrl: URL;
poolSize: number;
fetch: typeof fetch;
fetchWithErr: FetchWithErr;
};
export async function convertFeed(
c: ConvertConf,
relUrl: string,
): Promise<string> {
const fetchInfo = media.mkFetchInfo(c.fetchWithErr);
const convertor = new Convertor({
raiBaseUrl: c.raiBaseUrl,
poolSize: c.poolSize,
fetch: c.fetch,
fetchInfo,
});
const feedJson = await fetchFeed(c, relUrl);
return convertor.convert(feedJson);
}

type FetcherConf = {
raiBaseUrl: URL;
fetch: typeof fetch;
};

async function fetchFeed(
{ raiBaseUrl, fetch }: FetcherConf,
relUrl: string,
): Promise<unknown> {
const url = new URL(relUrl, raiBaseUrl);
const res = await fetch(url);
if (!res.ok) {
if (res.status === 404) {
throw new NotFoundError(url, "fetching feed");
}
throw new Error(
`Failed to fetch ${url}: ${res.status} ${res.statusText}`.trim(),
);
}
return res.json();
const url = new URL(relUrl, c.raiBaseUrl);
const resp = await c.fetchWithErr(url);
const json = await resp.json();
return convertor.convert(json);
}

type ConvertorConf = {
raiBaseUrl: URL;
poolSize: number;
fetch: typeof fetch;
fetchInfo: media.FetchInfo;
};

class Convertor {
readonly #raiBaseUrl: URL;
readonly #poolSize: number;
readonly #fetch: typeof fetch;
readonly #fetchInfo: media.FetchInfo;

constructor({ raiBaseUrl, poolSize, fetch }: ConvertorConf) {
constructor({ raiBaseUrl, poolSize, fetchInfo }: ConvertorConf) {
this.#raiBaseUrl = raiBaseUrl;
this.#poolSize = poolSize;
this.#fetch = fetch;
this.#fetchInfo = fetchInfo;
}

// TODO: feedUrl, siteUrl
Expand Down Expand Up @@ -125,7 +106,7 @@ class Convertor {
async convertCard(card: Card) {
const imageUrl = new URL(card.image, this.#raiBaseUrl).toString();
const date = new Date(card.track_info.date);
const mediaInfo = await fetchInfo(this.#fetch, card.downloadable_audio.url);
const mediaInfo = await this.#fetchInfo(card.downloadable_audio.url);
const url = mediaInfo.url.toString();
return {
title: card.episode_title,
Expand Down
48 changes: 48 additions & 0 deletions lib/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export { FetchWithErr, NotOk, OkResponse, mkFetchWithErr };

type FetchWithErr = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<OkResponse>;

type OkResponse = Response & { ok: true };

/**
* Returns a fetch function that errors on a non-ok response
* @param fetchFn - The underlying fetch function to use
* @returns A fetch function that throws an error for non-ok responses
*/
const mkFetchWithErr =
(fetchFn: typeof fetch): FetchWithErr =>
async (input, init) => {
const res = await fetchFn(input, init);
if (!res.ok) {
// maybe we should use the url from the response?
const url = new URL(input.toString());
throw new NotOk(url, res.status, res.statusText);
}
// this assertion really shouldn't be necessary...
return res as OkResponse;
};

/**
* Error class for non-ok responses
*/
class NotOk extends Error {
readonly url: URL;
readonly status: number;
readonly statusText: string;

/**
* @param url - The URL that resulted in a non-ok response
* @param status - The status code of the non-ok response
* @param statusText - The status text of the non-ok response
*/
constructor(url: URL, status: number, statusText: string) {
super(`Not ok: ${status} ${statusText} (${url})`);
this.name = "NotOkError";
this.url = url;
this.status = status;
this.statusText = statusText;
}
}
Loading

0 comments on commit d1d11f5

Please sign in to comment.