Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
2.6.0
=====

* (improvement) Bump zod to `4.x`
* (feature) Add proper return values for `fetchApi()`.


2.5.2
=====

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"test": "pnpm run build && cd dist && pnpm version '2.0.0-dummy' --no-git-tag-version && npm pack --dry-run && publint"
},
"dependencies": {
"zod": "^3.25.57"
"zod": "^4.1.11"
},
"optionalDependencies": {
"next": "^15.3",
Expand Down
2 changes: 0 additions & 2 deletions pnpm-workspace.yaml

This file was deleted.

129 changes: 108 additions & 21 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,104 @@
import {z} from "zod/v4-mini";
import {z} from "zod/mini";

const errorSchema = z.object({
ok: z.literal(false),
error: z.optional(z.string()),
errorMessage: z.optional(z.string()),
data: z.unknown(),
});

type Logger = Readonly<{
debug: (...args: unknown[]) => unknown;
error: (...args: unknown[]) => unknown;
debug: (message: string, context?: Record<string, unknown>) => void;
error: (message: string, context?: Record<string, unknown>) => void;
}>

type ApiFetchSettings = Readonly<{
logger?: Logger;
}>;

class ApiError extends Error
{
readonly #errorCode: string;
readonly #data: unknown;
readonly #errorMessage: string|null;


constructor (
errorCode: string,
data: unknown = undefined,
errorMessage: string|null|undefined = undefined,
)
{
super(`The API request failed due to an error: ${errorCode}`);
this.#errorCode = errorCode;
this.#data = data;
this.#errorMessage = errorMessage ?? null;
}

/**
*
*/
get errorCode () : string
{
return this.#errorCode;
}

/**
* The data of the response
*/
get data () : unknown
{
return this.#data;
}

/**
* A user-facing error message
*/
get errorMessage () : string|null
{
return this.#errorMessage;
}
}

class RequestError extends Error
{
private response: Response;

/**
*
*/
constructor (response: Response)
{
super();
this.response = response;
}

/**
*
*/
get is404 ()
{
return 404 === this.response.status;
}
}

/**
*
*/
export function isApiError (value: unknown) : value is ApiError
{
return value instanceof ApiError;
}

/**
*
*/
export function isRequestError (value: unknown) : value is RequestError
{
return value instanceof RequestError;
}



/**
* API helper to fetch data from an API
Expand All @@ -25,20 +109,16 @@ export async function fetchApi <
url: string | URL,
dataSchema?: DataSchema,
requestSettings: RequestInit = {},
isDebug: boolean = false,
settings: ApiFetchSettings = {},
) : Promise<z.infer<typeof dataSchema>>
) : Promise<z.infer<DataSchema>>
{
let response: Response;
const logger = settings.logger ?? console;
const logger = settings.logger;

// region Send Request
try
{
if (isDebug)
{
logger.debug("Fetching from API", {url: url.toString()});
}
logger?.debug("Fetching from API", {url: url.toString()});

response = await fetch(
url,
Expand All @@ -54,7 +134,7 @@ export async function fetchApi <
}
catch (error)
{
logger.error(
logger?.error(
"API request failed due to error",
{
err: error,
Expand All @@ -80,7 +160,7 @@ export async function fetchApi <
}
catch (error)
{
logger.error(
logger?.error(
"API response is no JSON",
{
contentType: response.headers.get("content-type"),
Expand All @@ -103,34 +183,41 @@ export async function fetchApi <
{
if (!response.ok)
{
logger.error("Got success response, but API response is no success");
logger?.error("Got success response, but API response is no success");
}

// @ts-expect-error .data errors out in zod right now. So we add this and the cast in the meantime
return successResponse.data.data as z.infer<typeof dataSchema>;
}
else
{
logger.debug("No success cause", successResponse.error);
}

const failureResponse = errorSchema.safeParse(responseData);

if (failureResponse.success)
{
if (response.ok)
{
logger.error("Got error response, but API response is success");
logger?.error("Got error response, but API response is success");
}

throw failureResponse.data;
throw new ApiError(
failureResponse.data.error,
failureResponse.data.data,
failureResponse.data.errorMessage,
);
}
else
{
logger.debug("No failure cause", failureResponse.error);
logger?.debug("No failure cause", {
error: failureResponse.error,
});
}

if (404 === response.status)
{
throw new RequestError(response);
}

logger.error(
logger?.error(
"Invalid API response",
{
responseData,
Expand Down