Skip to content

Commit 3752284

Browse files
committed
refactor: centralize API failure decoding
1 parent 4f80100 commit 3752284

6 files changed

Lines changed: 98 additions & 91 deletions

File tree

‎src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.ts‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1+
import { apiFailure } from "../api/api-failure.js";
2+
13
export function reportApiErrorMessage(error: unknown, status: number): string {
2-
return reportErrorMessage(typeof error === "object" && error !== null
3-
? { ...error, status }
4-
: { status });
4+
return reportErrorMessage(apiFailure(error, status));
55
}
66

77
export function reportErrorMessage(error: unknown): string {
8-
const problem = typeof error === "object" && error !== null
9-
? error as { code?: unknown; status?: unknown }
10-
: undefined;
11-
const code = typeof problem?.code === "string" ? problem.code : undefined;
12-
const status = problem?.status === undefined ? undefined : Number(problem.status);
8+
const { code, status } = apiFailure(error);
139

1410
switch (code) {
1511
case "invalid_credentials":
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vitest";
2+
import { apiFailure } from "./api-failure.js";
3+
4+
describe("apiFailure", () => {
5+
it("normalizes supported problem fields", () => {
6+
expect(apiFailure({ code: "invalid_query", detail: "Unsupported dimension", status: "400" })).toEqual({
7+
code: "invalid_query",
8+
detail: "Unsupported dimension",
9+
status: 400,
10+
});
11+
});
12+
13+
it("prefers the HTTP response status", () => {
14+
expect(apiFailure({ status: 400 }, 404).status).toBe(404);
15+
});
16+
17+
it("ignores malformed problem fields", () => {
18+
expect(apiFailure({ code: 42, detail: "", status: "unknown" })).toEqual({
19+
code: undefined,
20+
detail: undefined,
21+
status: undefined,
22+
});
23+
});
24+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export interface ApiFailure {
2+
code?: string;
3+
detail?: string;
4+
status?: number;
5+
}
6+
7+
export function apiFailure(error: unknown, responseStatus?: number): ApiFailure {
8+
const problem = typeof error === "object" && error !== null ? error as Record<string, unknown> : undefined;
9+
return {
10+
code: stringProperty(problem, "code"),
11+
detail: stringProperty(problem, "detail"),
12+
status: responseStatus ?? numberProperty(problem, "status"),
13+
};
14+
}
15+
16+
function stringProperty(value: Record<string, unknown> | undefined, property: string): string | undefined {
17+
const candidate = value?.[property];
18+
return typeof candidate === "string" && candidate.trim() ? candidate : undefined;
19+
}
20+
21+
function numberProperty(value: Record<string, unknown> | undefined, property: string): number | undefined {
22+
const candidate = Number(value?.[property]);
23+
return Number.isFinite(candidate) ? candidate : undefined;
24+
}

‎src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { createSettingsUpdate, validateConnection, validateEditableSettings } fr
2121
import { announceAnalyticsAvailability } from "../section/analytics-availability.js";
2222
import { MOCK_SCENARIOS, type MockScenarioDefinition } from "./mock-scenarios.js";
2323
import { identifierField, providerDescriptor, providerLogo } from "./provider-identity.js";
24-
import { settingsActionError, settingsLoadError, type SettingsLoadError } from "./settings-error.js";
24+
import { settingsError, type SettingsError } from "./settings-error.js";
2525

2626
type NewConnection =
2727
| { kind: "provider"; descriptor: AnalyticsProviderDescriptor; hasAccessToken: boolean }
@@ -36,7 +36,7 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
3636
@state() private _showValidation = false;
3737
@state() private _testingKey?: string;
3838
@state() private _status?: { type: "success" | "error"; message: string };
39-
@state() private _loadError?: SettingsLoadError;
39+
@state() private _loadError?: SettingsError;
4040
@state() private _connectionStatuses: Record<string, ConnectionActionStatus> = {};
4141
@state() private _showProviderPicker = false;
4242

@@ -50,7 +50,7 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
5050
try {
5151
const { data, error, response } = await WebAnalyticsService.settings();
5252
if (error || !data) {
53-
this._loadError = settingsLoadError(error, response?.status);
53+
this._loadError = settingsError("load", error, response?.status);
5454
return;
5555
}
5656
this._settings = data;
@@ -59,7 +59,7 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
5959
this._status = undefined;
6060
this._loadError = undefined;
6161
} catch (error) {
62-
this._loadError = settingsLoadError(error);
62+
this._loadError = settingsError("load", error);
6363
} finally {
6464
this._loading = false;
6565
}
@@ -162,13 +162,13 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
162162
this._connectionStatuses = {
163163
...this._connectionStatuses,
164164
[key]: error || !data
165-
? { type: "error", message: settingsActionError("test", error, response?.status) }
165+
? { type: "error", message: settingsError("test", error, response?.status).message }
166166
: { type: data.success ? "success" : "error", message: data.message },
167167
};
168168
} catch (error) {
169169
this._connectionStatuses = {
170170
...this._connectionStatuses,
171-
[key]: { type: "error", message: settingsActionError("test", error) },
171+
[key]: { type: "error", message: settingsError("test", error).message },
172172
};
173173
} finally {
174174
this._testingKey = undefined;
@@ -197,7 +197,7 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
197197
try {
198198
const { data, error, response } = await WebAnalyticsService.saveSettings({ body });
199199
if (error || !data) {
200-
this._status = { type: "error", message: settingsActionError("save", error, response?.status) };
200+
this._status = { type: "error", message: settingsError("save", error, response?.status).message };
201201
return false;
202202
}
203203
this._settings = data;
@@ -207,7 +207,7 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
207207
if (successMessage) this._status = { type: "success", message: successMessage };
208208
return true;
209209
} catch (error) {
210-
this._status = { type: "error", message: settingsActionError("save", error) };
210+
this._status = { type: "error", message: settingsError("save", error).message };
211211
return false;
212212
} finally {
213213
this._saving = false;
Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
11
import { describe, expect, it } from "vitest";
2-
import { settingsActionError, settingsLoadError } from "./settings-error.js";
2+
import { settingsError } from "./settings-error.js";
33

4-
describe("settingsLoadError", () => {
4+
describe("settingsError", () => {
55
it.each([
66
[401, "Sign in again", "session has expired"],
77
[403, "Administrator access required", "Only administrators"],
88
[404, "Package files are out of sync", "server and package files use the same version"],
99
[429, "Too many requests", "Wait a moment"],
1010
[503, "Settings service unavailable", "Umbraco logs"],
1111
])("describes HTTP %s with a useful recovery", (status, headline, message) => {
12-
expect(settingsLoadError({}, status)).toEqual(expect.objectContaining({ headline }));
13-
expect(settingsLoadError({}, status).message).toContain(message);
12+
expect(settingsError("load", {}, status)).toEqual(expect.objectContaining({ headline }));
13+
expect(settingsError("load", {}, status).message).toContain(message);
1414
});
1515

1616
it("uses a network recovery message when no response exists", () => {
17-
expect(settingsLoadError(new TypeError("Failed to fetch"))).toEqual({
17+
expect(settingsError("load", new TypeError("Failed to fetch"))).toEqual({
1818
headline: "Could not reach the settings service",
1919
message: "Check your connection and that Umbraco is running, then retry loading Web Analytics settings.",
2020
});
2121
});
2222

2323
it("uses a status carried by an SDK error when no response is available", () => {
24-
expect(settingsLoadError({ status: 403 }).headline).toBe("Administrator access required");
24+
expect(settingsError("load", { status: 403 }).headline).toBe("Administrator access required");
2525
});
2626
});
2727

28-
describe("settingsActionError", () => {
28+
describe("settings actions", () => {
2929
it.each([
3030
["save", 401, "session has expired"],
3131
["save", 404, "server and package files use the same version"],
@@ -34,16 +34,16 @@ describe("settingsActionError", () => {
3434
["test", 404, "connection test API was not found"],
3535
["test", 503, "Umbraco logs"],
3636
] as const)("describes %s HTTP %s failures", (action, status, message) => {
37-
expect(settingsActionError(action, {}, status)).toContain(message);
37+
expect(settingsError(action, {}, status).message).toContain(message);
3838
});
3939

4040
it("preserves a server validation detail when saving", () => {
41-
expect(settingsActionError("save", { detail: "Cache duration must use hh:mm:ss." }, 400))
41+
expect(settingsError("save", { detail: "Cache duration must use hh:mm:ss." }, 400).message)
4242
.toBe("Cache duration must use hh:mm:ss.");
4343
});
4444

4545
it("describes rejected requests as connection failures", () => {
46-
expect(settingsActionError("save", new TypeError("Failed to fetch"))).toContain("could not reach Umbraco");
47-
expect(settingsActionError("test", new TypeError("Failed to fetch"))).toContain("could not reach Umbraco");
46+
expect(settingsError("save", new TypeError("Failed to fetch")).message).toContain("could not reach Umbraco");
47+
expect(settingsError("test", new TypeError("Failed to fetch")).message).toContain("could not reach Umbraco");
4848
});
4949
});
Lines changed: 27 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,44 @@
1-
export interface SettingsLoadError {
1+
import { apiFailure } from "../api/api-failure.js";
2+
3+
export interface SettingsError {
24
headline: string;
35
message: string;
46
}
57

6-
export type SettingsAction = "save" | "test";
8+
export type SettingsOperation = "load" | "save" | "test";
79

8-
export function settingsLoadError(error: unknown, responseStatus?: number): SettingsLoadError {
9-
const status = requestStatus(error, responseStatus);
10+
export function settingsError(operation: SettingsOperation, error: unknown, responseStatus?: number): SettingsError {
11+
const failure = apiFailure(error, responseStatus);
12+
const retryAction = operation === "load" ? "retry loading Web Analytics settings" : operation === "save" ? "save your preserved changes" : "retry the connection test";
13+
const subject = operation === "test" ? "The connection test" : "Web Analytics settings";
1014

11-
switch (status) {
12-
case 401:
13-
return {
14-
headline: "Sign in again",
15-
message: "Your backoffice session has expired. Sign in again, then retry loading Web Analytics settings.",
16-
};
17-
case 403:
18-
return {
19-
headline: "Administrator access required",
20-
message: "Only administrators can manage Web Analytics settings. Ask an administrator to open this page.",
21-
};
22-
case 404:
23-
return {
24-
headline: "Package files are out of sync",
25-
message: "The settings API was not found. Restart Umbraco after updating Web Analytics, then refresh the backoffice so the server and package files use the same version.",
26-
};
27-
case 429:
28-
return {
29-
headline: "Too many requests",
30-
message: "Web Analytics settings cannot be loaded right now. Wait a moment, then retry.",
31-
};
32-
default:
33-
if (status !== undefined && status >= 500) {
34-
return {
35-
headline: "Settings service unavailable",
36-
message: "Umbraco could not load Web Analytics settings. Retry, and check the Umbraco logs if the problem continues.",
37-
};
38-
}
39-
return {
40-
headline: "Could not reach the settings service",
41-
message: "Check your connection and that Umbraco is running, then retry loading Web Analytics settings.",
42-
};
43-
}
44-
}
45-
46-
export function settingsActionError(action: SettingsAction, error: unknown, responseStatus?: number): string {
47-
const status = requestStatus(error, responseStatus);
48-
const subject = action === "save" ? "Web Analytics settings" : "The connection test";
49-
50-
switch (status) {
15+
switch (failure.status) {
5116
case 400:
52-
return action === "save"
53-
? problemDetail(error) ?? "Settings were not saved. Review the connection fields and mapping values."
54-
: "The connection test request was invalid. Check the saved connection configuration.";
17+
return operation === "save"
18+
? { headline: "Review the settings", message: failure.detail ?? "Settings were not saved. Review the connection fields and mapping values." }
19+
: operation === "test"
20+
? { headline: "Invalid connection test", message: "The connection test request was invalid. Check the saved connection configuration." }
21+
: unavailable("Settings request rejected", "Umbraco rejected the settings request. Refresh the backoffice, then retry.");
5522
case 401:
56-
return `Your backoffice session has expired. Sign in again, then ${action === "save" ? "save your preserved changes" : "retry the connection test"}.`;
23+
return unavailable("Sign in again", `Your backoffice session has expired. Sign in again, then ${retryAction}.`);
5724
case 403:
58-
return `Administrator access is required to ${action === "save" ? "save Web Analytics settings" : "test analytics connections"}.`;
25+
return unavailable("Administrator access required", operation === "load"
26+
? "Only administrators can manage Web Analytics settings. Ask an administrator to open this page."
27+
: `Administrator access is required to ${operation === "save" ? "save Web Analytics settings" : "test analytics connections"}.`);
5928
case 404:
60-
return `${subject} API was not found. Restart Umbraco after updating Web Analytics, then refresh the backoffice so the server and package files use the same version.`;
29+
return unavailable("Package files are out of sync", `${operation === "load" ? "The settings" : subject} API was not found. Restart Umbraco after updating Web Analytics, then refresh the backoffice so the server and package files use the same version.`);
6130
case 429:
62-
return `${subject} could not be completed because there were too many requests. Wait a moment, then retry.`;
31+
return unavailable("Too many requests", `${subject} ${operation === "load" ? "cannot be loaded" : "could not be completed"} right now. Wait a moment, then retry.`);
6332
default:
64-
if (status !== undefined && status >= 500) {
65-
return `${subject} could not be completed. Retry, and check the Umbraco logs if the problem continues.`;
33+
if (failure.status !== undefined && failure.status >= 500) {
34+
return unavailable("Settings service unavailable", `${subject} could not be ${operation === "load" ? "loaded" : "completed"}. Retry, and check the Umbraco logs if the problem continues.`);
6635
}
67-
return `${subject} could not reach Umbraco. Check your connection and that Umbraco is running, then retry.`;
36+
return operation === "load"
37+
? unavailable("Could not reach the settings service", "Check your connection and that Umbraco is running, then retry loading Web Analytics settings.")
38+
: unavailable("Could not reach the settings service", `${subject} could not reach Umbraco. Check your connection and that Umbraco is running, then retry.`);
6839
}
6940
}
7041

71-
function requestStatus(error: unknown, responseStatus?: number): number | undefined {
72-
const errorStatus = typeof error === "object" && error !== null && "status" in error
73-
? Number(error.status)
74-
: undefined;
75-
return responseStatus || errorStatus;
76-
}
77-
78-
function problemDetail(error: unknown): string | undefined {
79-
if (typeof error !== "object" || error === null || !("detail" in error)) return undefined;
80-
return typeof error.detail === "string" && error.detail.trim() ? error.detail : undefined;
42+
function unavailable(headline: string, message: string): SettingsError {
43+
return { headline, message };
8144
}

0 commit comments

Comments
 (0)