Skip to content

Commit 59ba69f

Browse files
fmontesclaude
andauthored
fix(graphql): structured error handling for page query (NOT_FOUND/PERMISSION_DENIED/BAD_REQUEST) (#35044)
## Summary This PR introduces **structured error handling** for the GraphQL page query across the dotCMS SDKs and example apps. The core idea: errors should carry a stable, machine-readable code that consumers can branch on — not free-form strings that break the moment a backend message is reworded. ## The core: error messages Before this PR, the SDK detected error types by string-matching on GraphQL error messages (`"not found"`, `"permission"`, etc.). That is brittle — any backend rewording silently degrades every error into a generic 404 — and it doesn't survive i18n. The fix is end-to-end: 1. **Backend always emits `extensions.classification`.** `PermissionDeniedGraphQLException` now overrides `getErrorType()` so GraphQL serializes the classification reliably. `PageDataFetcher` logs the original `HTMLPageAssetNotFoundException` before rethrowing, so stack traces are preserved. 2. **A stable contract on the wire.** Every page-query error response now carries an `extensions.code` value: `NOT_FOUND`, `PERMISSION_DENIED`, `BAD_REQUEST`, etc. 3. **The client reads the code, not the message.** `page-api.ts` was rewritten around four explicit branches (logged unstructured errors → `BAD_REQUEST` when `data` is null → structured codes when `data.page` is null → generic 404 fallback). Partial content failures (page loads but a sub-query fails) no longer swallow the errors — they surface via `response.errors[]`. 4. **Error objects are useful.** `DotErrorPage` exposes `status` and `code`, the `PERMISSION_DENIED` message includes actionable guidance about dotCMS permissions, and a new `logLevel: 'verbose'` config logs status, code, URL, and variables for debugging without polluting production logs. | Scenario | Before | After | |---|---|---| | Page does not exist | `null`, always 404 | `NOT_FOUND` / 404 | | No permission | String-match miss → 404 | `PERMISSION_DENIED` / 403 | | Malformed GraphQL query | JS `TypeError` | `BAD_REQUEST` / 400 | | Partial content failure | Silently swallowed | `errors[]` on a successful page response | --- ## Backend (Java) - `PageDataFetcher` logs the original `HTMLPageAssetNotFoundException` before rethrowing as `ResourceNotFoundException`. - `PermissionDeniedGraphQLException` implements `getErrorType()` returning `DataFetchingException` so `extensions.classification` is always serialized. ## SDK — `@dotcms/types` (v1.2.0) - `DotGraphQLApiResponse.data` typed as `{ page: DotCMSGraphQLPage | null } | null` — models all four real response shapes. - `DotCMSGraphQLError.extensions` and `.classification` made optional; `path?: string[]` added. - `DotCMSPageResponse` gains `errors?: DotCMSGraphQLError[]`; the singular `error` field is kept as a `@deprecated` alias (removal: August 2026). - `DotErrorPage` constructor `status` / `code` params have safe defaults (`500`, `'UNKNOWN'`) — non-breaking. ## SDK — `@dotcms/client` (v1.2.0) - Error detection switched from message string-matching to `extensions.code`. - Four-branch error handling in `page-api.ts` (see core summary above). - `normalizedUrl` extracted once and reused across all error messages, logs, and `DotErrorPage` instances. - New `logLevel: 'default' | 'verbose'` config on `DotCMSClientConfig`; verbose mode also gates the "no page query" performance warning. - Improved `PERMISSION_DENIED` message with actionable guidance. - Test coverage expanded for `page-api`, `content-api`, `utils`, and verbose logging. --- ## Examples ### `examples/nextjs` - `getDotCMSPage` returns `{ error }` on failure — consumers check `pageContent?.error` instead of try/catch. - New `ErrorPage` component handles 403 (Access Denied) and defaults unknown statuses to 500. - New shared `ErrorLayout` component used by both `ErrorPage` and `not-found.js`; styling matched to the 404 design. - Return-home link uses semantic `<Link>` directly. - `dotCMSClient` enables `logLevel: 'verbose'` automatically in development. ### `examples/astro` - `Error.astro` updated to render structured status/code from `DotErrorPage`. - `getPage.ts` returns structured errors instead of throwing. - `[...slug].astro` branches on the structured error to render the right status. - `dotCMSClient` enables verbose logging in development. ### `examples/angular` - New standalone `ErrorComponent` rendering status, code, and message. - `page.ts` consumes the structured error from the SDK and forwards it to the template. - `app.config.ts` enables verbose logging in development. ### `examples/angular-ssr` - Same `ErrorComponent` + `page.ts` updates as the Angular example, wired through `server.ts` so SSR returns the correct HTTP status. ## ⚠️ Headless API contract change (release notes) The GraphQL page query response shape changes for missing pages: - **Before:** `{ data: { page: null } }` with no `errors` array. - **After:** `{ data: { page: null }, errors: [{ extensions: { code: 'NOT_FOUND', status: 404 } }] }`. Headless consumers that detected 404 via `response.data?.page === null` should migrate to `errors[0]?.extensions?.code === 'NOT_FOUND'`. The SDK already handles this; only direct GraphQL consumers are affected. The deprecated singular `DotCMSPageResponse.error` field is preserved as an alias for backward compatibility and will be removed in **August 2026**. ## Test plan - [ ] `nx test sdk-client --testPathPattern=page-api` - [ ] Request a missing page — `error.code === 'NOT_FOUND'`, `error.status === 404` - [ ] Request a restricted page — `error.code === 'PERMISSION_DENIED'`, `error.status === 403` - [ ] Send a malformed GraphQL query — `error.code === 'BAD_REQUEST'`, `error.status === 400` - [ ] Request a valid page — no errors, page data returned - [ ] Request a valid page with a failing content query — page loads, `response.errors` populated - [ ] Smoke each example app (nextjs, astro, angular, angular-ssr) on a missing page and a restricted page 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 32daf63 commit 59ba69f

34 files changed

Lines changed: 1507 additions & 196 deletions

File tree

core-web/libs/sdk/angular/src/lib/providers/dotcms-client/dotcms-client.provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export function provideDotCMSClient(options: DotCMSAngularProviderConfig): Envir
7575
dotcmsUrl: options.dotcmsUrl,
7676
authToken: options.authToken,
7777
siteId: options.siteId,
78+
logLevel: options.logLevel,
7879
httpClient: httpClient
7980
});
8081

core-web/libs/sdk/client/README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -699,24 +699,29 @@ createDotCMSClient(config: DotCMSClientConfig): DotCMSClient
699699

700700
#### Parameters
701701

702-
| Option | Type | Required | Description |
703-
| ---------------- | ----------------- | -------- | ------------------------------------------------------------- |
704-
| `dotcmsUrl` | string || Your dotCMS instance URL |
705-
| `authToken` | string || Authentication token |
706-
| `siteId` | string || Site identifier (falls back to default site if not specified) |
707-
| `requestOptions` | DotRequestOptions || Additional request options |
708-
| `httpClient` | DotHttpClient || Custom HTTP client implementation |
702+
| Option | Type | Required | Description |
703+
| ---------------- | -------------------------- | -------- | ------------------------------------------------------------- |
704+
| `dotcmsUrl` | string || Your dotCMS instance URL |
705+
| `authToken` | string || Authentication token |
706+
| `siteId` | string || Site identifier (falls back to default site if not specified) |
707+
| `requestOptions` | DotRequestOptions || Additional request options |
708+
| `httpClient` | DotHttpClient || Custom HTTP client implementation |
709+
| `logLevel` | `'default'` \| `'verbose'` || Controls log verbosity. `'verbose'` adds status, code, and variables to error logs. Defaults to `'default'` |
709710

710711
#### Example
711712
```typescript
712713
const client = createDotCMSClient({
713714
dotcmsUrl: 'https://your-dotcms-instance.com',
714715
authToken: 'your-auth-token',
715716
siteId: 'your-site-id',
716-
httpClient: customHttpClient // Optional: provide custom HTTP client
717+
httpClient: customHttpClient, // Optional: provide custom HTTP client
718+
logLevel: 'verbose' // Optional: enable detailed error logs
717719
});
718720
```
719721

722+
> [!TIP]
723+
> Enable `logLevel: 'verbose'` during development to see HTTP status codes, error codes, and request variables in error logs. In verbose mode, error logs also include a hint to access the full GraphQL query via `error.graphql.query`. Keep it at `'default'` (or omit it) in production to avoid noisy logs.
724+
720725
### HTTP Client Configuration
721726

722727
The SDK now supports custom HTTP client implementations for advanced use cases. By default, it uses the built-in `FetchHttpClient` based on the native Fetch API.

core-web/libs/sdk/client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@dotcms/client",
3-
"version": "1.1.1",
3+
"version": "1.2.0",
44
"description": "Official JavaScript library for interacting with DotCMS REST APIs.",
55
"repository": {
66
"type": "git",
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/// <reference types="jest" />
2+
3+
import {
4+
DotCMSClientConfig,
5+
DotErrorContent,
6+
DotHttpError,
7+
DotRequestOptions
8+
} from '@dotcms/types';
9+
10+
import { CollectionBuilder } from './builders/collection/collection';
11+
import { RawQueryBuilder } from './builders/raw-query/raw-query.builder';
12+
import { Content } from './content-api';
13+
14+
import { FetchHttpClient } from '../adapters/fetch-http-client';
15+
16+
jest.mock('../adapters/fetch-http-client');
17+
18+
describe('Content', () => {
19+
const mockRequest = jest.fn();
20+
const MockedFetchHttpClient = FetchHttpClient as jest.MockedClass<typeof FetchHttpClient>;
21+
22+
const config: DotCMSClientConfig = {
23+
dotcmsUrl: 'http://localhost:8080',
24+
authToken: 'test-token',
25+
siteId: 'test-site'
26+
};
27+
28+
const requestOptions: DotRequestOptions = {
29+
cache: 'no-cache'
30+
};
31+
32+
const mockResponseData = {
33+
entity: {
34+
jsonObjectView: { contentlets: [{ title: 'Post 1' }, { title: 'Post 2' }] },
35+
resultsSize: 2
36+
}
37+
};
38+
39+
beforeEach(() => {
40+
mockRequest.mockReset();
41+
MockedFetchHttpClient.mockImplementation(
42+
() => ({ request: mockRequest }) as Partial<FetchHttpClient> as FetchHttpClient
43+
);
44+
mockRequest.mockResolvedValue(mockResponseData);
45+
});
46+
47+
describe('getCollection()', () => {
48+
it('returns a CollectionBuilder', () => {
49+
const content = new Content(config, requestOptions, new FetchHttpClient());
50+
expect(content.getCollection('Blog')).toBeInstanceOf(CollectionBuilder);
51+
});
52+
53+
it('executes an HTTP request when awaited', async () => {
54+
const content = new Content(config, requestOptions, new FetchHttpClient());
55+
await content.getCollection('Blog');
56+
expect(mockRequest).toHaveBeenCalledTimes(1);
57+
});
58+
59+
it('sends the content type in the query body', async () => {
60+
const content = new Content(config, requestOptions, new FetchHttpClient());
61+
await content.getCollection('Blog');
62+
const body = JSON.parse(mockRequest.mock.calls[0][1].body);
63+
expect(body.query).toContain('+contentType:Blog');
64+
});
65+
66+
it('returns mapped contentlets from the response', async () => {
67+
const content = new Content(config, requestOptions, new FetchHttpClient());
68+
const result = await content.getCollection('Blog');
69+
expect(result.contentlets).toEqual([{ title: 'Post 1' }, { title: 'Post 2' }]);
70+
});
71+
72+
it('returns total from resultsSize', async () => {
73+
const content = new Content(config, requestOptions, new FetchHttpClient());
74+
const result = await content.getCollection('Blog');
75+
expect(result.total).toBe(2);
76+
});
77+
78+
it('applies limit() and page() on the builder', async () => {
79+
const content = new Content(config, requestOptions, new FetchHttpClient());
80+
await content.getCollection('Blog').limit(5).page(3);
81+
const body = JSON.parse(mockRequest.mock.calls[0][1].body);
82+
expect(body.limit).toBe(5);
83+
expect(body.offset).toBe(10);
84+
});
85+
86+
it('applies sortBy() on the builder', async () => {
87+
const content = new Content(config, requestOptions, new FetchHttpClient());
88+
await content.getCollection('Blog').sortBy([{ field: 'title', order: 'asc' }]);
89+
const body = JSON.parse(mockRequest.mock.calls[0][1].body);
90+
expect(body.sort).toBe('title asc');
91+
});
92+
93+
it('throws DotErrorContent when the HTTP request fails', async () => {
94+
const httpError = new DotHttpError({
95+
status: 500,
96+
statusText: 'Internal Server Error',
97+
message: 'Server error',
98+
data: {}
99+
});
100+
mockRequest.mockRejectedValue(httpError);
101+
const content = new Content(config, requestOptions, new FetchHttpClient());
102+
await expect(content.getCollection('Blog')).rejects.toBeInstanceOf(DotErrorContent);
103+
});
104+
105+
it('DotErrorContent carries the original query on 404', async () => {
106+
const httpError = new DotHttpError({
107+
status: 404,
108+
statusText: 'Not Found',
109+
message: 'Content not found',
110+
data: {}
111+
});
112+
mockRequest.mockRejectedValue(httpError);
113+
const content = new Content(config, requestOptions, new FetchHttpClient());
114+
115+
try {
116+
await content.getCollection('Blog');
117+
} catch (e: unknown) {
118+
expect(e).toBeInstanceOf(DotErrorContent);
119+
if (e instanceof DotErrorContent) {
120+
expect(e.query).toContain('+contentType:Blog');
121+
}
122+
}
123+
});
124+
125+
it('passes siteId from config into the query', async () => {
126+
const content = new Content(config, requestOptions, new FetchHttpClient());
127+
await content.getCollection('Blog');
128+
const body = JSON.parse(mockRequest.mock.calls[0][1].body);
129+
expect(body.query).toContain(`+conhost:${config.siteId}`);
130+
});
131+
132+
it('each getCollection() call produces an independent builder', async () => {
133+
const content = new Content(config, requestOptions, new FetchHttpClient());
134+
const b1 = content.getCollection('Blog').limit(5);
135+
const b2 = content.getCollection('News').limit(20);
136+
await b1;
137+
await b2;
138+
const body1 = JSON.parse(mockRequest.mock.calls[0][1].body);
139+
const body2 = JSON.parse(mockRequest.mock.calls[1][1].body);
140+
expect(body1.query).toContain('+contentType:Blog');
141+
expect(body1.limit).toBe(5);
142+
expect(body2.query).toContain('+contentType:News');
143+
expect(body2.limit).toBe(20);
144+
});
145+
});
146+
147+
describe('query()', () => {
148+
it('returns a RawQueryBuilder', () => {
149+
const content = new Content(config, requestOptions, new FetchHttpClient());
150+
expect(content.query('+contentType:Blog')).toBeInstanceOf(RawQueryBuilder);
151+
});
152+
153+
it('executes an HTTP request when awaited', async () => {
154+
const content = new Content(config, requestOptions, new FetchHttpClient());
155+
await content.query('+contentType:Blog');
156+
expect(mockRequest).toHaveBeenCalledTimes(1);
157+
});
158+
159+
it('sends the raw query as-is without adding implicit constraints', async () => {
160+
const content = new Content(config, requestOptions, new FetchHttpClient());
161+
await content.query('+contentType:Blog +languageId:1');
162+
const body = JSON.parse(mockRequest.mock.calls[0][1].body);
163+
expect(body.query).toBe('+contentType:Blog +languageId:1');
164+
});
165+
166+
it('returns mapped contentlets from the response', async () => {
167+
const content = new Content(config, requestOptions, new FetchHttpClient());
168+
const result = await content.query('+contentType:Blog');
169+
expect(result.contentlets).toEqual([{ title: 'Post 1' }, { title: 'Post 2' }]);
170+
});
171+
172+
it('throws DotErrorContent when the HTTP request fails', async () => {
173+
const httpError = new DotHttpError({
174+
status: 403,
175+
statusText: 'Forbidden',
176+
message: 'Access denied',
177+
data: {}
178+
});
179+
mockRequest.mockRejectedValue(httpError);
180+
const content = new Content(config, requestOptions, new FetchHttpClient());
181+
await expect(content.query('+contentType:Blog')).rejects.toBeInstanceOf(
182+
DotErrorContent
183+
);
184+
});
185+
186+
it('does NOT inject siteId into a raw query', async () => {
187+
const content = new Content(config, requestOptions, new FetchHttpClient());
188+
await content.query('+contentType:Blog');
189+
const body = JSON.parse(mockRequest.mock.calls[0][1].body);
190+
expect(body.query).not.toContain('conhost');
191+
});
192+
});
193+
});

0 commit comments

Comments
 (0)