Skip to content

Commit

Permalink
Add optional response type validation to fetch utils
Browse files Browse the repository at this point in the history
  • Loading branch information
jsloat committed Nov 1, 2024
1 parent 49a6ce9 commit ed1f524
Show file tree
Hide file tree
Showing 8 changed files with 62 additions and 42 deletions.
8 changes: 4 additions & 4 deletions src/fetch/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,19 @@ export const DEFAULT_DEBUG_OPTS: DebugOpts = {
includeVerbose: false,
};

type FetchDebugOpts = {
type FetchDebugOpts<R> = {
message: any;
/** Is the message being logged verbose? */
isVerbose?: boolean;
parsedOpts: ParsedFetchOpts;
parsedOpts: ParsedFetchOpts<R>;
};
export const fetchDebug = ({
export const fetchDebug = <R>({
message,
isVerbose = false,
parsedOpts: {
debug: { enabled, logToPersistedLog, includeVerbose },
},
}: FetchDebugOpts) => {
}: FetchDebugOpts<R>) => {
if (!enabled) return;
if (isVerbose && !includeVerbose) return;
return logToPersistedLog ? PersistedLog.log(message) : tidyLog(message);
Expand Down
38 changes: 27 additions & 11 deletions src/fetch/fetchBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import { DEFAULT_DEBUG_OPTS, fetchDebug } from './debug';
import { FetchOpts, ParsedFetchOpts } from './types';
import { maybeCensorHeaders } from './utils';

const getBody = ({ body, contentType }: ParsedFetchOpts) => {
const getBody = <R>({ body, contentType }: ParsedFetchOpts<R>) => {
if (!body) return null;
if (isString(body)) return body;
const isBodyUrlEncoded = contentType === 'application/x-www-form-urlencoded';
return isBodyUrlEncoded ? getUrlEncodedParams(body) : JSON.stringify(body);
};

const getRequestAttributes = (opts: ParsedFetchOpts) => ({
const getRequestAttributes = <R>(opts: ParsedFetchOpts<R>) => ({
body: getBody(opts),
headers: {
'Content-Type': opts.contentType,
Expand All @@ -21,7 +21,7 @@ const getRequestAttributes = (opts: ParsedFetchOpts) => ({
});

/** Gets an object which describes the ongoing request for debug logging. */
const getRequestLogMessage = (opts: ParsedFetchOpts) => {
const getRequestLogMessage = <R>(opts: ParsedFetchOpts<R>) => {
const { body, headers } = getRequestAttributes(opts);
return {
...opts,
Expand All @@ -30,7 +30,7 @@ const getRequestLogMessage = (opts: ParsedFetchOpts) => {
};
};

const getRequest = (opts: ParsedFetchOpts) => {
const getRequest = <R>(opts: ParsedFetchOpts<R>) => {
const { url, method } = opts;
const { body, headers } = getRequestAttributes(opts);
const req = new Request(url);
Expand All @@ -40,10 +40,10 @@ const getRequest = (opts: ParsedFetchOpts) => {
return req;
};

const parseFetchOpts = ({
const parseFetchOpts = <R>({
debug = DEFAULT_DEBUG_OPTS,
...restOpts
}: FetchOpts): ParsedFetchOpts => ({ debug, ...restOpts });
}: FetchOpts<R>): ParsedFetchOpts<R> => ({ debug, ...restOpts });

const isStatusCodeError = (code: unknown) => {
const str = String(code);
Expand Down Expand Up @@ -76,19 +76,20 @@ const getResponseError = (response: unknown): string | null => {
}
};

export default async <Returns = unknown>(opts: FetchOpts) => {
export default async <Returns = unknown>(opts: FetchOpts<Returns>) => {
const parsedOpts = parseFetchOpts(opts);
const log = (message: any, isVerbose = false) =>
fetchDebug({ message: String(message), parsedOpts, isVerbose });
await log(`Initiating request to "${opts.url}"...`);
const { url, fetchFnKey, responseValidator } = parsedOpts;

await log(`Initiating request to "${url}"...`);
log(getRequestLogMessage(parsedOpts));
const requestObj = getRequest(parsedOpts);

// Intentionally not trying or catching here -- this is the responsibility of
// the calling function.
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const response = (await requestObj[opts.fetchFnKey]()) as unknown;
const response = (await requestObj[fetchFnKey]()) as unknown;
await log({ response }, true);

const { statusCode } = requestObj.response;
const isErrorCode = isStatusCodeError(statusCode);
if (isErrorCode) {
Expand All @@ -99,5 +100,20 @@ export default async <Returns = unknown>(opts: FetchOpts) => {
log({ error });
throw error;
}

// Check that response type is as expected
if (responseValidator) {
try {
const { isValid, errorMessage } = responseValidator(response as Returns);
if (!isValid) throw new Error(errorMessage ?? 'validation failed');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'can not parse error';
throw new Error(
`Error during responseValidator execution: ${errorMessage}`
);
}
}

return response as Returns;
};
2 changes: 1 addition & 1 deletion src/fetch/multipart/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type MultipartRequest = Pick<Request, 'url' | 'method'> & {
};

export type MultipartOpts = Omit<
FetchImplementationOpts,
FetchImplementationOpts<string>,
'body' | 'contentType'
> & {
requests: MultipartRequest[];
Expand Down
14 changes: 9 additions & 5 deletions src/fetch/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,25 @@ export type DebugOpts = {
includeVerbose?: boolean;
};

export type FetchOpts = Pick<Request, 'url' | 'headers' | 'method'> & {
export type FetchOpts<R> = Pick<Request, 'url' | 'headers' | 'method'> & {
contentType: string;
fetchFnKey: FunctionKeys<Request>;
body?: string | AnyObj;
debug?: DebugOpts;
responseValidator?: (response: R) => {
isValid: boolean;
errorMessage?: string;
};
};

export type ParsedFetchOpts = MakeSomeReqd<FetchOpts, 'debug'>;
export type ParsedFetchOpts<R> = MakeSomeReqd<FetchOpts<R>, 'debug'>;

/**
* When implemented, the fetch fn and method are known, and content type
* has a default value
*/
export type FetchImplementationOpts = Omit<
FetchOpts,
export type FetchImplementationOpts<R> = Omit<
FetchOpts<R>,
'fetchFnKey' | 'method' | 'contentType'
> &
Partial<Pick<FetchOpts, 'contentType'>>;
Partial<Pick<FetchOpts<R>, 'contentType'>>;
4 changes: 2 additions & 2 deletions src/fetch/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { AnyObj } from '../types/utilTypes';
import { ParsedFetchOpts } from './types';

// Don't log auth details in persisted log
export const maybeCensorHeaders = (
export const maybeCensorHeaders = <R>(
headers: AnyObj,
{ debug: { logToPersistedLog } }: ParsedFetchOpts
{ debug: { logToPersistedLog } }: ParsedFetchOpts<R>
) => {
if (!logToPersistedLog || !headers.Authorization) return headers;
return { ...headers, Authorization: 'Censored for security' };
Expand Down
12 changes: 6 additions & 6 deletions src/fetch/verbs/get.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import fetchBase from '../fetchBase';
import { FetchImplementationOpts, FetchOpts } from '../types';

const getArgs = (
contentType: FetchOpts['contentType'],
fetchFnKey: FetchOpts['fetchFnKey'],
opts: FetchImplementationOpts
): FetchOpts => ({
const getArgs = <R>(
contentType: FetchOpts<R>['contentType'],
fetchFnKey: FetchOpts<R>['fetchFnKey'],
opts: FetchImplementationOpts<R>
): FetchOpts<R> => ({
method: 'GET',
contentType,
fetchFnKey,
...opts,
});

export const getJson = <R>(opts: FetchImplementationOpts) =>
export const getJson = <R>(opts: FetchImplementationOpts<R>) =>
fetchBase<R>(getArgs('application/json', 'loadJSON', opts));

// export const getString = <R>(opts: FetchImplementationOpts) =>
Expand Down
14 changes: 7 additions & 7 deletions src/fetch/verbs/post.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import fetchBase from '../fetchBase';
import { FetchImplementationOpts, FetchOpts } from '../types';

const getArgs = (
contentType: FetchOpts['contentType'],
fetchFnKey: FetchOpts['fetchFnKey'],
opts: FetchImplementationOpts
): FetchOpts => ({
const getArgs = <R>(
contentType: FetchOpts<R>['contentType'],
fetchFnKey: FetchOpts<R>['fetchFnKey'],
opts: FetchImplementationOpts<R>
): FetchOpts<R> => ({
method: 'POST',
contentType,
fetchFnKey,
...opts,
});

export const postJson = <R = void>(opts: FetchImplementationOpts) =>
export const postJson = <R = void>(opts: FetchImplementationOpts<R>) =>
fetchBase<R>(getArgs('application/json', 'loadJSON', opts));

export const postString = (opts: FetchImplementationOpts) =>
export const postString = (opts: FetchImplementationOpts<string>) =>
fetchBase<string>(getArgs('application/json', 'loadString', opts));
12 changes: 6 additions & 6 deletions src/fetch/verbs/put.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import fetchBase from '../fetchBase';
import { FetchImplementationOpts, FetchOpts } from '../types';

const getArgs = (
contentType: FetchOpts['contentType'],
fetchFnKey: FetchOpts['fetchFnKey'],
opts: FetchImplementationOpts
): FetchOpts => ({ method: 'PUT', contentType, fetchFnKey, ...opts });
const getArgs = <R>(
contentType: FetchOpts<R>['contentType'],
fetchFnKey: FetchOpts<R>['fetchFnKey'],
opts: FetchImplementationOpts<R>
): FetchOpts<R> => ({ method: 'PUT', contentType, fetchFnKey, ...opts });

export const putJson = <R = void>(opts: FetchImplementationOpts) =>
export const putJson = <R = void>(opts: FetchImplementationOpts<R>) =>
fetchBase<R>(getArgs('application/json', 'loadJSON', opts));

// export const putString = (opts: FetchImplementationOpts) =>
Expand Down

0 comments on commit ed1f524

Please sign in to comment.