Skip to content

Commit 3c69915

Browse files
committed
feat: handle problem details responses.
1 parent 33c3d9d commit 3c69915

File tree

4 files changed

+151
-13
lines changed

4 files changed

+151
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# 6.0.2
22
- Fix error in c-select when keypress event has no key.
3+
- Accept problem details error messages in API Callers.
34

45
# 6.0.1
56
- `createAspNetCoreHmrPlugin` now displays NPM package mismatches with the standard vite overlay instead of a custom overlay.

src/IntelliTect.Coalesce/Helpers/QueryableExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public static IQueryable<T> WherePrimaryKeyIs<T>(this IQueryable<T> query, objec
4646
?? throw new ArgumentException("Queried type is not a class");
4747

4848
var pkProp = classViewModel.PrimaryKey
49-
?? throw new ArgumentException("Unable to determine primary key of the queried type");
49+
?? throw new ArgumentException($"Unable to determine primary key of {classViewModel.FullyQualifiedName}");
5050

5151
return query.WhereExpression(it => Expression.Equal(it.Prop(pkProp), id.AsQueryParam(pkProp.Type)));
5252
}

src/coalesce-vue/src/api-client.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -400,12 +400,25 @@ export function getMessageForError(error: unknown): string {
400400
| AxiosResponse<ListResult<any> | ItemResult<any>>
401401
| undefined;
402402

403-
if (
404-
result &&
405-
typeof result.data === "object" &&
406-
"message" in result.data
407-
) {
408-
return result.data.message || "Unknown Error";
403+
if (result && typeof result.data === "object") {
404+
// Check for standard Coalesce ApiResult object
405+
if ("message" in result.data) {
406+
return result.data.message || "Unknown Error";
407+
}
408+
409+
// Check for RFC 7807 Problem Details (application/problem+json)
410+
if (
411+
result.headers?.["content-type"]?.includes("application/problem+json")
412+
) {
413+
const problemDetails = result.data as any;
414+
// Prefer detail over title, as detail is typically more specific
415+
if (problemDetails.detail) {
416+
return problemDetails.detail;
417+
}
418+
if (problemDetails.title) {
419+
return problemDetails.title;
420+
}
421+
}
409422
}
410423

411424
// Axios normally returns a message like "Request failed with status code 403".

src/coalesce-vue/test/api-client.spec.ts

Lines changed: 130 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type ListResultPromise,
1414
mapParamsToDto,
1515
mapQueryToParams,
16+
getMessageForError,
1617
} from "../src/api-client";
1718
import { getInternalInstance } from "../src/util";
1819
import { delay, mountData, mockEndpoint } from "./test-utils";
@@ -73,6 +74,125 @@ describe("error handling", () => {
7374
});
7475
});
7576

77+
describe("getMessageForError", () => {
78+
test("returns string error as-is", () => {
79+
expect(getMessageForError("Custom error message")).toBe(
80+
"Custom error message",
81+
);
82+
});
83+
84+
test("returns standard Error message", () => {
85+
expect(getMessageForError(new Error("Error occurred"))).toBe(
86+
"Error occurred",
87+
);
88+
});
89+
90+
test("returns message from standard Coalesce API response", () => {
91+
const axiosError = new AxiosError("Request failed");
92+
axiosError.response = {
93+
data: { wasSuccessful: false, message: "Validation failed" },
94+
status: 400,
95+
statusText: "Bad Request",
96+
headers: {},
97+
config: {} as any,
98+
};
99+
100+
expect(getMessageForError(axiosError)).toBe("Validation failed");
101+
});
102+
103+
test("returns 'Unknown Error' when Coalesce response has empty message", () => {
104+
const axiosError = new AxiosError("Request failed");
105+
axiosError.response = {
106+
data: { wasSuccessful: false, message: "" },
107+
status: 400,
108+
statusText: "Bad Request",
109+
headers: {},
110+
config: {} as any,
111+
};
112+
113+
expect(getMessageForError(axiosError)).toBe("Unknown Error");
114+
});
115+
116+
test("returns detail from application/problem+json response", () => {
117+
const axiosError = new AxiosError("Request failed");
118+
axiosError.response = {
119+
data: {
120+
type: "https://example.com/probs/out-of-credit",
121+
title: "You do not have enough credit.",
122+
detail: "Your current balance is 30, but that costs 50.",
123+
status: 403,
124+
},
125+
status: 403,
126+
statusText: "Forbidden",
127+
headers: { "content-type": "application/problem+json; charset=utf-8" },
128+
config: {} as any,
129+
};
130+
131+
expect(getMessageForError(axiosError)).toBe(
132+
"Your current balance is 30, but that costs 50.",
133+
);
134+
});
135+
136+
test("returns title from application/problem+json when detail is missing", () => {
137+
const axiosError = new AxiosError("Request failed");
138+
axiosError.response = {
139+
data: {
140+
type: "https://example.com/probs/forbidden",
141+
title: "Access denied",
142+
status: 403,
143+
},
144+
status: 403,
145+
statusText: "Forbidden",
146+
headers: { "content-type": "application/problem+json" },
147+
config: {} as any,
148+
};
149+
150+
expect(getMessageForError(axiosError)).toBe("Access denied");
151+
});
152+
153+
test("returns axios message with status text appended", () => {
154+
const axiosError = new AxiosError("Request failed with status code 403");
155+
axiosError.response = {
156+
data: {},
157+
status: 403,
158+
statusText: "Forbidden",
159+
headers: {},
160+
config: {} as any,
161+
};
162+
163+
expect(getMessageForError(axiosError)).toBe(
164+
"Request failed with status code 403 (Forbidden)",
165+
);
166+
});
167+
168+
test("does not append status text if already in message", () => {
169+
const axiosError = new AxiosError("Request failed: Forbidden");
170+
axiosError.response = {
171+
data: {},
172+
status: 403,
173+
statusText: "Forbidden",
174+
headers: {},
175+
config: {} as any,
176+
};
177+
178+
expect(getMessageForError(axiosError)).toBe("Request failed: Forbidden");
179+
});
180+
181+
test("returns generic message for axios error without response", () => {
182+
const axiosError = new AxiosError("Network Error");
183+
// No response set
184+
expect(getMessageForError(axiosError)).toBe("Network Error");
185+
});
186+
187+
test("returns 'An unknown error occurred' for null", () => {
188+
expect(getMessageForError(null)).toBe("An unknown error occurred");
189+
});
190+
191+
test("returns 'An unknown error occurred' for undefined", () => {
192+
expect(getMessageForError(undefined)).toBe("An unknown error occurred");
193+
});
194+
});
195+
76196
describe("$useSimultaneousRequestCaching", () => {
77197
test("uses proper cache key for standard method", async () => {
78198
const mock = (AxiosClient.defaults.adapter = vitest
@@ -1551,15 +1671,19 @@ describe("ModelApiClient", () => {
15511671
});
15521672

15531673
test("refResponse is ignored for file-returning methods", async () => {
1554-
const mock = (AxiosClient.defaults.adapter = vitest.fn().mockResolvedValue(<AxiosResponse<any>>{
1555-
data: new Blob(),
1556-
status: 200,
1557-
}));
1674+
const mock = (AxiosClient.defaults.adapter = vitest
1675+
.fn()
1676+
.mockResolvedValue(<AxiosResponse<any>>{
1677+
data: new Blob(),
1678+
status: 200,
1679+
}));
15581680

15591681
const client = new ComplexModelApiClient();
1560-
1682+
15611683
// Create a caller with refResponse enabled for a file-returning method
1562-
const caller = client.$makeCaller("item", (c) => c.downloadAttachment(1)).useRefResponse();
1684+
const caller = client
1685+
.$makeCaller("item", (c) => c.downloadAttachment(1))
1686+
.useRefResponse();
15631687

15641688
await caller();
15651689

0 commit comments

Comments
 (0)