Files
authentik/web/src/common/errors/network.ts
Teffen Ellis 1bc2daae98 web: Form validation regressions, consistency fixes (#15894)
* web: Clean up naming of inputs.

* web: Flesh out label components, slots.

* web: Expand clickable area of labels.

* web: Fix issue where launch URL is required.

* web: Surface string errors verbatim.

* web: Fix issues surrounding client-side error reporting, form validation,
server-side error reporting.

* web: Clarify property visibility.

* web: Fix issue where provider errors are not surfaced.

* web: Fix issue where wizard steps do not consistently use form validation.

* web: Fix issue where render root is not preferred.

* web: Fix import path.

* web: Fix selectors.

* keep labels aligned

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web: Fix field nesting.

* web: Fix issue where required secret text inputs fail validation when editing existing entities.

- We need to make the component have a better understanding of this concept.

* web: Fix slot alignment on legacy elements.

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-08-12 14:34:08 +00:00

236 lines
7.1 KiB
TypeScript

import {
GenericError,
GenericErrorFromJSON,
ResponseError,
ValidationError,
ValidationErrorFromJSON,
} from "@goauthentik/api";
//#region HTTP
/**
* Common HTTP status names used in the API and their corresponding codes.
*/
export const HTTPStatusCode = {
BadRequest: 400,
Forbidden: 403,
InternalServiceError: 500,
} as const satisfies Record<string, number>;
export type HTTPStatusCode = (typeof HTTPStatusCode)[keyof typeof HTTPStatusCode];
export type HTTPErrorJSONTransformer<T = unknown> = (json: T) => APIError;
export const HTTPStatusCodeTransformer: Record<number, HTTPErrorJSONTransformer> = {
[HTTPStatusCode.BadRequest]: ValidationErrorFromJSON,
[HTTPStatusCode.Forbidden]: GenericErrorFromJSON,
} as const;
//#endregion
//#region Type Predicates
/**
* Type predicate to check if a response contains a JSON body.
*
* This is useful to guard against parsing errors when attempting to read the response body.
*/
export function isJSONResponse(response: Response): boolean {
return Boolean(response.headers.get("content-type")?.includes("application/json"));
}
/**
* An error originating from an aborted request.
*
* @see {@linkcode isAbortError} to check if an error originates from an aborted request.
*/
export interface AbortErrorLike extends DOMException {
name: "AbortError";
}
/**
* Type predicate to check if an error originates from an aborted request.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort | MDN}
*/
export function isAbortError(error: unknown): error is AbortErrorLike {
return error instanceof DOMException && error.name === "AbortError";
}
/**
* Type predicate to check if an error originates from an aborted request.
*
* @see {@linkcode isAbortError} for the underlying implementation.
*/
export function isCausedByAbortError(error: unknown): error is AbortErrorLike {
return (
error instanceof Error &&
// ---
(isAbortError(error) || isAbortError(error.cause))
);
}
//#endregion
//#region API
/**
* An API response error, typically derived from a {@linkcode Response} body.
*
* @see {@linkcode parseAPIResponseError}
*/
export type APIError = ValidationError | GenericError;
/**
* Given an error-like object, attempts to normalize it into a {@linkcode GenericError}
* suitable for display to the user.
*/
export function createSyntheticGenericError(detail?: string): GenericError {
const syntheticGenericError: GenericError = {
detail: detail || ResponseErrorMessages[HTTPStatusCode.InternalServiceError].reason,
};
return syntheticGenericError;
}
/**
* An error that contains a native response object.
*
* @see {@linkcode isResponseErrorLike} to determine if an error contains a response object.
*/
export type APIErrorWithResponse = Pick<ResponseError, "response" | "message">;
/**
* Type guard to check if an error contains a HTTP {@linkcode Response} object.
*
* @see {@linkcode parseAPIResponseError} to parse the response body into a {@linkcode APIError}.
*/
export function isResponseErrorLike(errorLike: unknown): errorLike is APIErrorWithResponse {
if (!errorLike || typeof errorLike !== "object") return false;
return "response" in errorLike && errorLike.response instanceof Response;
}
/**
* A descriptor to provide a human readable error message for a given HTTP status code.
*
* @see {@linkcode ResponseErrorMessages} for a list of fallback error messages.
*/
interface ResponseErrorDescriptor {
headline: string;
reason: string;
}
/**
* Fallback error messages for HTTP status codes used when a more specific error message is not available in the response.
*/
export const ResponseErrorMessages: Record<number, ResponseErrorDescriptor> = {
[HTTPStatusCode.BadRequest]: {
headline: "Bad request",
reason: "The server did not understand the request",
},
[HTTPStatusCode.InternalServiceError]: {
headline: "Internal server error",
reason: "An unexpected error occurred",
},
} as const;
/**
* Composes a human readable error message from a {@linkcode ResponseErrorDescriptor}.
*
* Note that this is kept separate from localization to lower the complexity of the error handling code.
*/
export function composeResponseErrorDescriptor(descriptor: ResponseErrorDescriptor): string {
return `${descriptor.headline}: ${descriptor.reason}`;
}
export const ErrorFieldFallbackKeys = [
// ---
"detail", // OpenAPI
"non_field_errors", // ValidationError.non_field_errors
"message", // Error.prototype.message
"string", // OpenAPI
] as const;
export type FallbackError = Record<(typeof ErrorFieldFallbackKeys)[number], string | undefined>;
/**
* Attempts to pluck a human readable error message from a {@linkcode ValidationError}.
*/
export function pluckErrorDetail(validationError: ValidationError, fallback?: string): string;
/**
* Attempts to pluck a human readable error message from a {@linkcode GenericError}.
*/
export function pluckErrorDetail(genericError: GenericError, fallback?: string): string;
/**
* Attempts to pluck a human readable error message from an `Error` object.
*/
export function pluckErrorDetail(error: Error, fallback?: string): string;
/**
* Attempts to pluck a human readable error message from an error-like object.
*
* Prioritizes the `detail` key, then the `message` key.
*
*/
export function pluckErrorDetail(errorLike: unknown, fallback?: string): string;
export function pluckErrorDetail(errorLike: unknown, fallback?: string): string {
if (typeof errorLike === "string" && errorLike) {
return errorLike;
}
fallback ||= composeResponseErrorDescriptor(
ResponseErrorMessages[HTTPStatusCode.InternalServiceError],
);
if (!errorLike || typeof errorLike !== "object") {
return fallback;
}
for (const fieldKey of ErrorFieldFallbackKeys) {
if (!(fieldKey in errorLike)) continue;
const value = (errorLike as FallbackError)[fieldKey];
if (typeof value === "string" && value) {
return value;
}
}
return fallback;
}
/**
* Given API error, parses the response body and transforms it into a {@linkcode APIError}.
*/
export async function parseAPIResponseError<T extends APIError = APIError>(
error: unknown,
): Promise<T> {
if (!isResponseErrorLike(error)) {
const message = error instanceof Error ? error.message : String(error);
return createSyntheticGenericError(message) as T;
}
const { response, message } = error;
if (!isJSONResponse(response)) {
return createSyntheticGenericError(message || response.statusText) as T;
}
return response
.json()
.then((body) => {
const transformer = HTTPStatusCodeTransformer[response.status];
const transformedBody = transformer ? transformer(body) : body;
return transformedBody as unknown as T;
})
.catch((transformerError: unknown) => {
console.error("Failed to parse response error body", transformerError);
return createSyntheticGenericError(message || response.statusText) as T;
});
}
//#endregion