Skip to content

V16: XHR requests do not report the underlying problem details object #19160

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 28, 2025
Merged
Show file tree
Hide file tree
Changes from 6 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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { extractUmbNotificationColor } from './extractUmbNotificationColor.function.js';
import { isUmbNotifications, UMB_NOTIFICATION_HEADER } from './isUmbNotifications.function.js';
import { isProblemDetailsLike } from './apiTypeValidators.function.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth';
import type { UmbNotificationColor } from '@umbraco-cms/backoffice/notification';
Expand Down Expand Up @@ -78,16 +79,16 @@ export class UmbApiInterceptorController extends UmbControllerBase {
const origResponse = response.clone();
const error = await origResponse.json();

// If there is no JSON in the error, we just return the response
if (!error) return response;

// If the error is not an UmbError, we check if it is a problem details object
// Check if the error is a problem details object
if (!('type' in error) || !('title' in error) || !('status' in error)) {
if (!isProblemDetailsLike(error)) {
// If not, we just return the response
return response;
}

let headline = error.title ?? error.name ?? 'Server Error';
let headline = error.title ?? 'Server Error';
let message = 'A fatal server error occurred. If this continues, please reach out to your administrator.';

// Special handling for ObjectCacheAppCache corruption errors, which we are investigating
Expand All @@ -100,7 +101,7 @@ export class UmbApiInterceptorController extends UmbControllerBase {
'The Umbraco object cache is corrupt, but your action may still have been executed. Please restart the server to reset the cache. This is a work in progress.';
}

this.#peekError(headline, message, error.errors ?? error.detail);
this.#peekError(headline, message, error.errors);
} catch (e) {
// Ignore JSON parse error
console.error('[Interceptor] Caught a 500 Error, but failed parsing error body (expected JSON)', e);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isProblemDetailsLike } from './apiTypeValidators.function.js';
import { UmbResourceController } from './resource.controller.js';
import type { UmbApiResponse, UmbTryExecuteOptions } from './types.js';
import { UmbApiError, UmbCancelError } from './umb-error.js';
Expand Down Expand Up @@ -44,11 +45,17 @@ export class UmbTryExecuteController<T> extends UmbResourceController<T> {
let message = 'An error occurred while trying to execute the request.';
let details: Record<string, string[]> | undefined = undefined;

if (UmbApiError.isUmbApiError(error)) {
// UmbApiError, show notification
headline = error.problemDetails.title ?? error.name;
message = error.problemDetails.detail ?? error.message;
details = error.problemDetails.errors ?? undefined;
// Check if we can extract problem details from the error
const problemDetails = UmbApiError.isUmbApiError(error)
? error.problemDetails
: isProblemDetailsLike(error)
? error
: undefined;

if (problemDetails) {
// UmbProblemDetails, show notification
message = problemDetails.title;
details = problemDetails.errors ?? undefined;
} else {
// Unknown error, show notification
headline = '';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { UmbTryExecuteController } from './try-execute.controller.js';
import { UmbCancelablePromise } from './cancelable-promise.js';
import { UmbApiError } from './umb-error.js';
import { isProblemDetailsLike } from './apiTypeValidators.function.js';
import type { UmbApiResponse, XhrRequestOptions } from './types.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { umbHttpClient } from '@umbraco-cms/backoffice/http-client';
Expand Down Expand Up @@ -82,12 +83,7 @@ function createXhrRequest<T>(options: XhrRequestOptions): UmbCancelablePromise<T
resolve(JSON.parse(xhr.responseText));
}
} else {
const error = new UmbApiError(xhr.statusText, xhr.status, xhr, {
title: xhr.statusText,
type: 'ApiError',
status: xhr.status,
});
reject(error);
reject(createErrorResponse(xhr));
}
} catch {
// This most likely happens when the response is not JSON
Expand All @@ -102,12 +98,7 @@ function createXhrRequest<T>(options: XhrRequestOptions): UmbCancelablePromise<T
};

xhr.onerror = () => {
const error = new UmbApiError(xhr.statusText, xhr.status, xhr, {
title: xhr.statusText,
type: 'ApiError',
status: xhr.status,
});
reject(error);
reject(createErrorResponse(xhr));
};

if (!onCancel.isCancelled) {
Expand All @@ -124,3 +115,33 @@ function createXhrRequest<T>(options: XhrRequestOptions): UmbCancelablePromise<T
});
});
}

/**
* Create an error response from an XMLHttpRequest.
* This function is used to create a consistent error response format for failed requests.
* It extracts the status, statusText, and responseText from the XMLHttpRequest object and creates an UmbApiError object.
* It tries to parse the responseText as JSON and, if successful, adds it to the UmbApiError object as UmbProblemDetails.
* @param {XMLHttpRequest} xhr The XMLHttpRequest object
* @returns {UmbApiError} An UmbApiError object containing the error details.
* @internal
*/
function createErrorResponse(xhr: XMLHttpRequest): UmbApiError {
const error = new UmbApiError(xhr.statusText, xhr.status, xhr, {
title: xhr.statusText,
type: 'ApiError',
status: xhr.status,
});
try {
const errorBody = xhr.responseText;
if (errorBody) {
const parsedError = JSON.parse(errorBody);
if (parsedError && isProblemDetailsLike(parsedError)) {
error.problemDetails = parsedError;
}
}
} catch {
// Ignore JSON parsing errors
}

return error;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
import { formatBytes } from '@umbraco-cms/backoffice/utils';
import { UmbCancelError } from '@umbraco-cms/backoffice/resources';
import { UmbApiError, UmbCancelError } from '@umbraco-cms/backoffice/resources';

export class UmbTemporaryFileManager<
UploadableItem extends UmbTemporaryFileModel = UmbTemporaryFileModel,
Expand Down Expand Up @@ -91,25 +91,28 @@
return filesCompleted;
}

async #notifyOnFileSizeLimitExceeded(maxFileSize: number, item: UploadableItem) {
const notification = await this.getContext(UMB_NOTIFICATION_CONTEXT);
notification?.peek('warning', {
data: {
headline: 'Upload',
message: `
${this.#localization.term('media_invalidFileSize')}: ${item.file.name} (${formatBytes(item.file.size)}).

${this.#localization.term('media_maxFileSize')} ${maxFileSize > 0 ? formatBytes(maxFileSize) : 'N/A'}.
`,
},
});
}

async #validateItem(item: UploadableItem): Promise<boolean> {
const config = await this.getConfiguration();
let maxFileSize = await this.observe(config.part('maxFileSize')).asPromise();
if (maxFileSize) {
// Convert from kilobytes to bytes
maxFileSize *= 1024;
if (item.file.size > maxFileSize) {
const notification = await this.getContext(UMB_NOTIFICATION_CONTEXT);
if (!notification) throw new Error('Notification context is missing');
notification.peek('warning', {
data: {
headline: 'Upload',
message: `
${this.#localization.term('media_invalidFileSize')}: ${item.file.name} (${formatBytes(item.file.size)}).

${this.#localization.term('media_maxFileSize')} ${formatBytes(maxFileSize)}.
`,
},
});
await this.#notifyOnFileSizeLimitExceeded(maxFileSize, item);

Check notice on line 115 in src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file-manager.class.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (release/16.0)

✅ Getting better: Complex Method

UmbTemporaryFileManager.validateItem decreases in cyclomatic complexity from 13 to 11, threshold = 9. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
return false;
}
}
Expand Down Expand Up @@ -161,11 +164,26 @@
},
item.abortController?.signal ?? item.abortSignal,
);

let status = TemporaryFileStatus.SUCCESS;

if (error) {
status = TemporaryFileStatus.ERROR;
if (UmbCancelError.isUmbCancelError(error)) {
if (error instanceof UmbCancelError) {
// Ignore the error if the upload was cancelled
status = TemporaryFileStatus.CANCELLED;
} else if (error instanceof UmbApiError && error.problemDetails.title.includes('Request body too large')) {
// Special handling for when the request body is too large
const maxFileSizeGuestimate = parseInt(/\d+/.exec(error.problemDetails.title)?.[0] ?? '0', 10);
this.#notifyOnFileSizeLimitExceeded(maxFileSizeGuestimate, item);
} else {
const notification = await this.getContext(UMB_NOTIFICATION_CONTEXT);
notification?.peek('danger', {
data: {
headline: this.#localization.term('errors_receivedErrorFromServer'),
message: error.message,
},
});

Check warning on line 186 in src/Umbraco.Web.UI.Client/src/packages/core/temporary-file/temporary-file-manager.class.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (release/16.0)

❌ New issue: Complex Method

UmbTemporaryFileManager.handleUpload has a cyclomatic complexity of 12, threshold = 9. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export class UmbTemporaryFileServerDataSource {
url: '/umbraco/management/api/v1/temporary-file',
method: 'POST',
responseHeader: 'Umb-Generated-Resource',
disableNotifications: true,
body,
onProgress,
abortSignal,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { css, html, customElement, query, state, when } from '@umbraco-cms/backo
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
import { UmbId } from '@umbraco-cms/backoffice/id';
import type { UmbTreeElement, UmbTreeSelectionConfiguration } from '@umbraco-cms/backoffice/tree';
import { UmbTemporaryFileRepository } from '@umbraco-cms/backoffice/temporary-file';
import type { UmbTreeSelectionConfiguration } from '@umbraco-cms/backoffice/tree';
import { TemporaryFileStatus, UmbTemporaryFileManager } from '@umbraco-cms/backoffice/temporary-file';
import type { UUIButtonState } from '@umbraco-cms/backoffice/external/uui';

interface UmbDictionaryItemPreview {
id: string;
Expand All @@ -31,16 +32,20 @@ export class UmbImportDictionaryModalLayout extends UmbModalBaseElement<
@state()
private _temporaryFileId = '';

@state()
private _importButtonState?: UUIButtonState;

@state()
private _importAllowed = false;

@query('#form')
private _form!: HTMLFormElement;

@query('umb-tree')
private _treeElement?: UmbTreeElement;

#fileReader;
#fileContent: Array<UmbDictionaryItemPreview> = [];
#dictionaryImportRepository = new UmbDictionaryImportRepository(this);
#temporaryFileRepository = new UmbTemporaryFileRepository(this);
#temporaryFileManager = new UmbTemporaryFileManager(this);
#abortController?: AbortController;

constructor() {
super();
Expand Down Expand Up @@ -99,14 +104,28 @@ export class UmbImportDictionaryModalLayout extends UmbModalBaseElement<

async #onUpload(e: Event) {
e.preventDefault();

this.#onClear();

const formData = new FormData(this._form);
const file = formData.get('file') as File;
if (!file) throw new Error('File is missing');

this.#fileReader.readAsText(file);
this._temporaryFileId = UmbId.new();
const temporaryUnique = UmbId.new();

this.#abortController = new AbortController();

const { status } = await this.#temporaryFileManager.uploadOne({
file,
temporaryUnique,
abortController: this.#abortController,
});

this.#temporaryFileRepository.upload(this._temporaryFileId, file);
if (status === TemporaryFileStatus.SUCCESS) {
this._importAllowed = true;
this._temporaryFileId = temporaryUnique;
}
}

async #onFileInput() {
Expand All @@ -117,6 +136,9 @@ export class UmbImportDictionaryModalLayout extends UmbModalBaseElement<

#onClear() {
this._temporaryFileId = '';
this._importAllowed = false;
this.#abortController?.abort();
this.#abortController = undefined;
}

override render() {
Expand Down Expand Up @@ -163,6 +185,8 @@ export class UmbImportDictionaryModalLayout extends UmbModalBaseElement<
${this.localize.term('general_back')}
</uui-button>
<uui-button
.state=${this._importButtonState}
?disabled=${!this._importAllowed}
type="button"
label=${this.localize.term('general_import')}
look="primary"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import { UmbUserRepositoryBase } from '../user-repository-base.js';
import { UmbUserAvatarServerDataSource } from './user-avatar.server.data-source.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbId } from '@umbraco-cms/backoffice/id';
import { UmbTemporaryFileRepository } from '@umbraco-cms/backoffice/temporary-file';
import { TemporaryFileStatus, UmbTemporaryFileManager } from '@umbraco-cms/backoffice/temporary-file';

export class UmbUserAvatarRepository extends UmbUserRepositoryBase {
#temporaryFileRepository: UmbTemporaryFileRepository;
#avatarSource: UmbUserAvatarServerDataSource;

constructor(host: UmbControllerHost) {
super(host);

this.#avatarSource = new UmbUserAvatarServerDataSource(host);
this.#temporaryFileRepository = new UmbTemporaryFileRepository(host);
}
#temporaryFileManager = new UmbTemporaryFileManager(this);
#avatarSource = new UmbUserAvatarServerDataSource(this);
#abortController = new AbortController();

/**
* Uploads an avatar for the user with the given id
Expand All @@ -27,11 +20,19 @@ export class UmbUserAvatarRepository extends UmbUserRepositoryBase {
await this.init;

// upload temp file
const fileId = UmbId.new();
await this.#temporaryFileRepository.upload(fileId, file);
const temporaryUnique = UmbId.new();
const { status } = await this.#temporaryFileManager.uploadOne({
file,
temporaryUnique,
abortController: this.#abortController,
});

if (status === TemporaryFileStatus.ERROR) {
return { error: new Error('Avatar upload failed') };
}

// assign temp file to avatar
const { error } = await this.#avatarSource.createAvatar(userUnique, fileId);
const { error } = await this.#avatarSource.createAvatar(userUnique, temporaryUnique);

if (!error) {
// TODO: update store + current user
Expand Down
Loading
Loading