Skip to content

Commit b86cbf9

Browse files
authored
Merge pull request #9 from strapi/feat/http-error-handling
2 parents cac33fe + 88c756b commit b86cbf9

File tree

14 files changed

+314
-51
lines changed

14 files changed

+314
-51
lines changed

src/auth/providers/users-permissions.ts

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { StrapiSDKError, StrapiSDKValidationError } from '../../errors';
1+
import { StrapiSDKValidationError } from '../../errors';
22
import { HttpClient } from '../../http';
33

44
import { AbstractAuthProvider } from './abstract';
@@ -28,11 +28,11 @@ export type UsersPermissionsAuthPayload = Pick<
2828
'identifier' | 'password'
2929
>;
3030

31-
/**
32-
* @experimental
33-
* Authentication through users and permissions is experimental for the MVP of
34-
* the Strapi SDK.
35-
*/
31+
/**
32+
* @experimental
33+
* Authentication through users and permissions is experimental for the MVP of
34+
* the Strapi SDK.
35+
*/
3636
export class UsersPermissionsAuthProvider extends AbstractAuthProvider<UsersPermissionsAuthProviderOptions> {
3737
public static readonly identifier = USERS_PERMISSIONS_AUTH_STRATEGY_IDENTIFIER;
3838

@@ -88,29 +88,19 @@ export class UsersPermissionsAuthProvider extends AbstractAuthProvider<UsersPerm
8888
}
8989

9090
async authenticate(httpClient: HttpClient): Promise<void> {
91-
try {
92-
const { baseURL } = httpClient;
93-
const localAuthURL = `${baseURL}/auth/local`;
94-
95-
const request = new Request(localAuthURL, {
96-
method: 'POST',
97-
headers: { 'Content-Type': 'application/json' },
98-
body: JSON.stringify(this.credentials),
99-
});
100-
101-
// Make sure to use the HttpClient's "_fetch" method to not perform authentication in an infinite loop.
102-
const response = await httpClient._fetch(request);
103-
104-
if (!response.ok) {
105-
// TODO: use dedicated exceptions
106-
throw new Error(response.statusText);
107-
}
108-
109-
const data = await response.json();
110-
this._token = data.jwt;
111-
} catch (error) {
112-
// TODO: use dedicated exceptions
113-
throw new StrapiSDKError(error, 'Failed to authenticate with Strapi server.');
114-
}
91+
const { baseURL } = httpClient;
92+
const localAuthURL = `${baseURL}/auth/local`;
93+
94+
const request = new Request(localAuthURL, {
95+
method: 'POST',
96+
headers: { 'Content-Type': 'application/json' },
97+
body: JSON.stringify(this.credentials),
98+
});
99+
100+
// Make sure to use the HttpClient's "_fetch" method to not perform authentication in an infinite loop.
101+
const response = await httpClient._fetch(request);
102+
const data = await response.json();
103+
104+
this._token = data.jwt;
115105
}
116106
}

src/errors/http.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export class HTTPError extends Error {
2+
public name = 'HTTPError';
3+
public response: Response;
4+
public request: Request;
5+
6+
constructor(response: Response, request: Request) {
7+
const code: string = response.status?.toString() ?? '';
8+
const title = response.statusText ?? '';
9+
const status = `${code} ${title}`.trim();
10+
const reason = status ? `status code ${status}` : 'an unknown error';
11+
12+
super(`Request failed with ${reason}: ${request.method} ${request.url}`);
13+
14+
this.response = response;
15+
this.request = request;
16+
}
17+
}
18+
19+
export class HTTPAuthorizationError extends HTTPError {
20+
public name = 'HTTPAuthorizationError';
21+
}
22+
23+
export class HTTPNotFoundError extends HTTPError {
24+
public name = 'HTTPNotFoundError';
25+
}
26+
27+
export class HTTPBadRequestError extends HTTPError {
28+
public name = 'HTTPBadRequestError';
29+
}
30+
31+
export class HTTPInternalServerError extends HTTPError {
32+
public name = 'HTTPInternalServerError';
33+
}
34+
35+
export class HTTPForbiddenError extends HTTPError {
36+
public name = 'HTTPForbiddenError';
37+
}
38+
39+
export class HTTPTimeoutError extends HTTPError {
40+
public name = 'HTTPTimeoutError';
41+
}

src/errors/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './sdk';
22
export * from './url';
3+
export * from './http';

src/http/client.ts

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
import { AuthManager } from '../auth';
2+
import {
3+
HTTPAuthorizationError,
4+
HTTPBadRequestError,
5+
HTTPError,
6+
HTTPForbiddenError,
7+
HTTPInternalServerError,
8+
HTTPNotFoundError,
9+
HTTPTimeoutError,
10+
} from '../errors';
211
import { URLValidator } from '../validators';
312

13+
import { StatusCode } from './constants';
14+
415
export type Fetch = typeof globalThis.fetch;
516

617
/**
@@ -52,7 +63,6 @@ export class HttpClient {
5263
* @returns The HttpClient instance for chaining.
5364
*
5465
* @throws {URLParsingError} If the URL cannot be parsed.
55-
* @throws {URLProtocolValidationError} If the URL uses an unsupported protocol.
5666
*
5767
* @example
5868
* const client = new HttpClient('http://example.com');
@@ -114,30 +124,54 @@ export class HttpClient {
114124

115125
this.attachHeaders(request);
116126

117-
const response = await this._fetch(request);
127+
try {
128+
return await this._fetch(request);
129+
} catch (e) {
130+
this.handleFetchError(e);
118131

119-
if (response.status === 401) {
120-
this._authManager.handleUnauthorizedError();
132+
throw e;
121133
}
134+
}
122135

123-
return response;
136+
/**
137+
* Handles HTTP fetch error logic.
138+
*
139+
* It deals with unauthorized responses by delegating the handling of the error to the authentication manager.
140+
*
141+
* @param error - The original HTTP request object that encountered an error. Used for error handling.
142+
*
143+
* @see {@link AuthManager#handleUnauthorizedError} for handling unauthorized responses.
144+
*/
145+
private handleFetchError(error: unknown) {
146+
if (error instanceof HTTPAuthorizationError) {
147+
this._authManager.handleUnauthorizedError();
148+
}
124149
}
125150

126151
/**
127152
* Executes an HTTP fetch request using the Fetch API.
128153
*
129-
* @param url - The target URL for the HTTP request which can be a string URL or a `Request` object.
154+
* @param input - The target of the HTTP request which can be a string URL or a `Request` object.
130155
* @param [init] - An optional `RequestInit` object that contains any custom settings that you want to apply to the request.
131156
*
132157
* @returns A promise that resolves to the `Response` object representing the complete HTTP response.
133158
*
159+
* @throws {HTTPError} if the request fails
160+
*
134161
* @additionalInfo
135162
* - This method doesn't perform any authentication or header customization.
136163
* It directly passes the parameters to the global `fetch` function.
137164
* - To include authentication, consider using the `fetch` method from the `HttpClient` class, which handles headers and authentication.
138165
*/
139-
async _fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
140-
return globalThis.fetch(url, init);
166+
async _fetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
167+
const request = new Request(input, init);
168+
const response = await globalThis.fetch(request);
169+
170+
if (!response.ok) {
171+
throw this.mapResponseToHTTPError(response, request);
172+
}
173+
174+
return response;
141175
}
142176

143177
/**
@@ -159,4 +193,36 @@ export class HttpClient {
159193
// Set auth headers if available, potentially overwrite manually set auth headers
160194
this._authManager.authenticateRequest(request);
161195
}
196+
197+
/**
198+
* Maps an HTTP response's status code to a specific HTTP error class.
199+
*
200+
* @param response - The HTTP response object obtained from a failed HTTP request,
201+
* which contains the status code and reason for failure.
202+
* @param request - The original HTTP request object that resulted in the error response.
203+
*
204+
* @returns A specific subclass instance of HTTPError based on the response status code.
205+
*
206+
* @throws {HTTPError} or any of its subclass.
207+
*
208+
* @see {@link StatusCode} for all possible HTTP status codes and their meanings.
209+
*/
210+
private mapResponseToHTTPError(response: Response, request: Request): HTTPError {
211+
switch (response.status) {
212+
case StatusCode.BAD_REQUEST:
213+
return new HTTPBadRequestError(response, request);
214+
case StatusCode.UNAUTHORIZED:
215+
return new HTTPAuthorizationError(response, request);
216+
case StatusCode.FORBIDDEN:
217+
return new HTTPForbiddenError(response, request);
218+
case StatusCode.NOT_FOUND:
219+
return new HTTPNotFoundError(response, request);
220+
case StatusCode.TIMEOUT:
221+
return new HTTPTimeoutError(response, request);
222+
case StatusCode.INTERNAL_SERVER_ERROR:
223+
return new HTTPInternalServerError(response, request);
224+
}
225+
226+
return new HTTPError(response, request);
227+
}
162228
}

src/http/constants.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export enum StatusCode {
2+
OK = 200,
3+
CREATED = 201,
4+
NO_CONTENT = 204,
5+
BAD_REQUEST = 400,
6+
UNAUTHORIZED = 401,
7+
FORBIDDEN = 403,
8+
NOT_FOUND = 404,
9+
TIMEOUT = 408,
10+
INTERNAL_SERVER_ERROR = 500,
11+
}

src/http/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './client';
2+
export * from './constants';

tests/unit/auth/providers/users-permissions.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import {
22
UsersPermissionsAuthProvider,
33
UsersPermissionsAuthProviderOptions,
44
} from '../../../../src/auth';
5-
import { StrapiSDKError, StrapiSDKValidationError } from '../../../../src/errors';
5+
import { HTTPBadRequestError, StrapiSDKValidationError } from '../../../../src/errors';
66
import { HttpClient } from '../../../../src/http';
7-
import { MockHttpClient } from '../../mocks';
7+
import { MockHttpClient, mockRequest, mockResponse } from '../../mocks';
88

99
const FAKE_TOKEN = '<token>';
1010
const FAKE_VALID_CONFIG: UsersPermissionsAuthProviderOptions = {
@@ -19,8 +19,11 @@ class ValidFakeHttpClient extends MockHttpClient {
1919
}
2020

2121
class FaultyFakeHttpClient extends HttpClient {
22-
async _fetch() {
23-
return new Response('Bad request', { status: 400 });
22+
async _fetch(): Promise<Response> {
23+
const response = mockResponse(400, 'Bad Request');
24+
const request = mockRequest('GET', 'https://example.com');
25+
26+
throw new HTTPBadRequestError(response, request);
2427
}
2528
}
2629

@@ -111,7 +114,7 @@ describe('UsersPermissionsAuthProvider', () => {
111114
const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG);
112115

113116
// Act & Assert
114-
await expect(provider.authenticate(faultyHttpClient)).rejects.toThrow(StrapiSDKError);
117+
await expect(provider.authenticate(faultyHttpClient)).rejects.toThrow(HTTPBadRequestError);
115118
});
116119
});
117120

tests/unit/errors/http-errors.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {
2+
HTTPAuthorizationError,
3+
HTTPBadRequestError,
4+
HTTPError,
5+
HTTPForbiddenError,
6+
HTTPInternalServerError,
7+
HTTPNotFoundError,
8+
HTTPTimeoutError,
9+
} from '../../../src/errors';
10+
import { StatusCode } from '../../../src/http';
11+
import { mockRequest, mockResponse } from '../mocks';
12+
13+
describe('HTTP Errors', () => {
14+
describe('HTTPError', () => {
15+
it('should correctly instantiate with a status code and status text', () => {
16+
// Arrange
17+
const response = mockResponse(504, 'Gateway Timeout');
18+
const request = mockRequest('GET', 'https://example.com/resource');
19+
20+
// Act
21+
const error = new HTTPError(response, request);
22+
23+
// Assert
24+
expect(error.name).toBe('HTTPError');
25+
expect(error.message).toBe(
26+
'Request failed with status code 504 Gateway Timeout: GET https://example.com/resource'
27+
);
28+
expect(error.response).toBe(response);
29+
expect(error.request).toBe(request);
30+
});
31+
32+
it('should handle status code without status text', () => {
33+
// Arrange
34+
const response = mockResponse(500, '');
35+
const request = mockRequest('POST', 'https://example.com/update');
36+
37+
// Act
38+
const error = new HTTPError(response, request);
39+
40+
// Assert
41+
expect(error.message).toBe(
42+
'Request failed with status code 500: POST https://example.com/update'
43+
);
44+
});
45+
46+
it('should handle requests with no status code', () => {
47+
// Arrange
48+
const response = mockResponse(undefined as any, '');
49+
const request = mockRequest('GET', 'https://example.com/unknown');
50+
51+
// Act
52+
const error = new HTTPError(response, request);
53+
54+
// Assert
55+
expect(error.message).toBe(
56+
'Request failed with an unknown error: GET https://example.com/unknown'
57+
);
58+
});
59+
});
60+
61+
it.each([
62+
[HTTPBadRequestError.name, HTTPBadRequestError, StatusCode.BAD_REQUEST],
63+
[HTTPAuthorizationError.name, HTTPAuthorizationError, StatusCode.UNAUTHORIZED],
64+
[HTTPForbiddenError.name, HTTPForbiddenError, StatusCode.FORBIDDEN],
65+
[HTTPNotFoundError.name, HTTPNotFoundError, StatusCode.NOT_FOUND],
66+
[HTTPTimeoutError.name, HTTPTimeoutError, StatusCode.TIMEOUT],
67+
[HTTPInternalServerError.name, HTTPInternalServerError, StatusCode.INTERNAL_SERVER_ERROR],
68+
])('%s', (name, errorClass, status) => {
69+
// Arrange
70+
const response = mockResponse(status, name);
71+
const request = mockRequest('GET', 'https://example.com');
72+
73+
// Act
74+
const error = new errorClass(response, request);
75+
76+
// Assert
77+
expect(error).toBeInstanceOf(HTTPError);
78+
expect(error.name).toBe(name);
79+
expect(error.message).toBe(
80+
`Request failed with status code ${status} ${name}: GET https://example.com`
81+
);
82+
expect(error.response).toBe(response);
83+
expect(error.request).toBe(request);
84+
});
85+
});
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)