Skip to content

Commit

Permalink
Merge pull request #9 from strapi/feat/http-error-handling
Browse files Browse the repository at this point in the history
  • Loading branch information
Convly authored Dec 11, 2024
2 parents cac33fe + 88c756b commit b86cbf9
Show file tree
Hide file tree
Showing 14 changed files with 314 additions and 51 deletions.
50 changes: 20 additions & 30 deletions src/auth/providers/users-permissions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { StrapiSDKError, StrapiSDKValidationError } from '../../errors';
import { StrapiSDKValidationError } from '../../errors';
import { HttpClient } from '../../http';

import { AbstractAuthProvider } from './abstract';
Expand Down Expand Up @@ -28,11 +28,11 @@ export type UsersPermissionsAuthPayload = Pick<
'identifier' | 'password'
>;

/**
* @experimental
* Authentication through users and permissions is experimental for the MVP of
* the Strapi SDK.
*/
/**
* @experimental
* Authentication through users and permissions is experimental for the MVP of
* the Strapi SDK.
*/
export class UsersPermissionsAuthProvider extends AbstractAuthProvider<UsersPermissionsAuthProviderOptions> {
public static readonly identifier = USERS_PERMISSIONS_AUTH_STRATEGY_IDENTIFIER;

Expand Down Expand Up @@ -88,29 +88,19 @@ export class UsersPermissionsAuthProvider extends AbstractAuthProvider<UsersPerm
}

async authenticate(httpClient: HttpClient): Promise<void> {
try {
const { baseURL } = httpClient;
const localAuthURL = `${baseURL}/auth/local`;

const request = new Request(localAuthURL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.credentials),
});

// Make sure to use the HttpClient's "_fetch" method to not perform authentication in an infinite loop.
const response = await httpClient._fetch(request);

if (!response.ok) {
// TODO: use dedicated exceptions
throw new Error(response.statusText);
}

const data = await response.json();
this._token = data.jwt;
} catch (error) {
// TODO: use dedicated exceptions
throw new StrapiSDKError(error, 'Failed to authenticate with Strapi server.');
}
const { baseURL } = httpClient;
const localAuthURL = `${baseURL}/auth/local`;

const request = new Request(localAuthURL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.credentials),
});

// Make sure to use the HttpClient's "_fetch" method to not perform authentication in an infinite loop.
const response = await httpClient._fetch(request);
const data = await response.json();

this._token = data.jwt;
}
}
41 changes: 41 additions & 0 deletions src/errors/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export class HTTPError extends Error {
public name = 'HTTPError';
public response: Response;
public request: Request;

constructor(response: Response, request: Request) {
const code: string = response.status?.toString() ?? '';
const title = response.statusText ?? '';
const status = `${code} ${title}`.trim();
const reason = status ? `status code ${status}` : 'an unknown error';

super(`Request failed with ${reason}: ${request.method} ${request.url}`);

this.response = response;
this.request = request;
}
}

export class HTTPAuthorizationError extends HTTPError {
public name = 'HTTPAuthorizationError';
}

export class HTTPNotFoundError extends HTTPError {
public name = 'HTTPNotFoundError';
}

export class HTTPBadRequestError extends HTTPError {
public name = 'HTTPBadRequestError';
}

export class HTTPInternalServerError extends HTTPError {
public name = 'HTTPInternalServerError';
}

export class HTTPForbiddenError extends HTTPError {
public name = 'HTTPForbiddenError';
}

export class HTTPTimeoutError extends HTTPError {
public name = 'HTTPTimeoutError';
}
1 change: 1 addition & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './sdk';
export * from './url';
export * from './http';
82 changes: 74 additions & 8 deletions src/http/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { AuthManager } from '../auth';
import {
HTTPAuthorizationError,
HTTPBadRequestError,
HTTPError,
HTTPForbiddenError,
HTTPInternalServerError,
HTTPNotFoundError,
HTTPTimeoutError,
} from '../errors';
import { URLValidator } from '../validators';

import { StatusCode } from './constants';

export type Fetch = typeof globalThis.fetch;

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

this.attachHeaders(request);

const response = await this._fetch(request);
try {
return await this._fetch(request);
} catch (e) {
this.handleFetchError(e);

if (response.status === 401) {
this._authManager.handleUnauthorizedError();
throw e;
}
}

return response;
/**
* Handles HTTP fetch error logic.
*
* It deals with unauthorized responses by delegating the handling of the error to the authentication manager.
*
* @param error - The original HTTP request object that encountered an error. Used for error handling.
*
* @see {@link AuthManager#handleUnauthorizedError} for handling unauthorized responses.
*/
private handleFetchError(error: unknown) {
if (error instanceof HTTPAuthorizationError) {
this._authManager.handleUnauthorizedError();
}
}

/**
* Executes an HTTP fetch request using the Fetch API.
*
* @param url - The target URL for the HTTP request which can be a string URL or a `Request` object.
* @param input - The target of the HTTP request which can be a string URL or a `Request` object.
* @param [init] - An optional `RequestInit` object that contains any custom settings that you want to apply to the request.
*
* @returns A promise that resolves to the `Response` object representing the complete HTTP response.
*
* @throws {HTTPError} if the request fails
*
* @additionalInfo
* - This method doesn't perform any authentication or header customization.
* It directly passes the parameters to the global `fetch` function.
* - To include authentication, consider using the `fetch` method from the `HttpClient` class, which handles headers and authentication.
*/
async _fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
return globalThis.fetch(url, init);
async _fetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
const request = new Request(input, init);
const response = await globalThis.fetch(request);

if (!response.ok) {
throw this.mapResponseToHTTPError(response, request);
}

return response;
}

/**
Expand All @@ -159,4 +193,36 @@ export class HttpClient {
// Set auth headers if available, potentially overwrite manually set auth headers
this._authManager.authenticateRequest(request);
}

/**
* Maps an HTTP response's status code to a specific HTTP error class.
*
* @param response - The HTTP response object obtained from a failed HTTP request,
* which contains the status code and reason for failure.
* @param request - The original HTTP request object that resulted in the error response.
*
* @returns A specific subclass instance of HTTPError based on the response status code.
*
* @throws {HTTPError} or any of its subclass.
*
* @see {@link StatusCode} for all possible HTTP status codes and their meanings.
*/
private mapResponseToHTTPError(response: Response, request: Request): HTTPError {
switch (response.status) {
case StatusCode.BAD_REQUEST:
return new HTTPBadRequestError(response, request);
case StatusCode.UNAUTHORIZED:
return new HTTPAuthorizationError(response, request);
case StatusCode.FORBIDDEN:
return new HTTPForbiddenError(response, request);
case StatusCode.NOT_FOUND:
return new HTTPNotFoundError(response, request);
case StatusCode.TIMEOUT:
return new HTTPTimeoutError(response, request);
case StatusCode.INTERNAL_SERVER_ERROR:
return new HTTPInternalServerError(response, request);
}

return new HTTPError(response, request);
}
}
11 changes: 11 additions & 0 deletions src/http/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export enum StatusCode {
OK = 200,
CREATED = 201,
NO_CONTENT = 204,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
FORBIDDEN = 403,
NOT_FOUND = 404,
TIMEOUT = 408,
INTERNAL_SERVER_ERROR = 500,
}
1 change: 1 addition & 0 deletions src/http/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './client';
export * from './constants';
13 changes: 8 additions & 5 deletions tests/unit/auth/providers/users-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import {
UsersPermissionsAuthProvider,
UsersPermissionsAuthProviderOptions,
} from '../../../../src/auth';
import { StrapiSDKError, StrapiSDKValidationError } from '../../../../src/errors';
import { HTTPBadRequestError, StrapiSDKValidationError } from '../../../../src/errors';
import { HttpClient } from '../../../../src/http';
import { MockHttpClient } from '../../mocks';
import { MockHttpClient, mockRequest, mockResponse } from '../../mocks';

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

class FaultyFakeHttpClient extends HttpClient {
async _fetch() {
return new Response('Bad request', { status: 400 });
async _fetch(): Promise<Response> {
const response = mockResponse(400, 'Bad Request');
const request = mockRequest('GET', 'https://example.com');

throw new HTTPBadRequestError(response, request);
}
}

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

// Act & Assert
await expect(provider.authenticate(faultyHttpClient)).rejects.toThrow(StrapiSDKError);
await expect(provider.authenticate(faultyHttpClient)).rejects.toThrow(HTTPBadRequestError);
});
});

Expand Down
85 changes: 85 additions & 0 deletions tests/unit/errors/http-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
HTTPAuthorizationError,
HTTPBadRequestError,
HTTPError,
HTTPForbiddenError,
HTTPInternalServerError,
HTTPNotFoundError,
HTTPTimeoutError,
} from '../../../src/errors';
import { StatusCode } from '../../../src/http';
import { mockRequest, mockResponse } from '../mocks';

describe('HTTP Errors', () => {
describe('HTTPError', () => {
it('should correctly instantiate with a status code and status text', () => {
// Arrange
const response = mockResponse(504, 'Gateway Timeout');
const request = mockRequest('GET', 'https://example.com/resource');

// Act
const error = new HTTPError(response, request);

// Assert
expect(error.name).toBe('HTTPError');
expect(error.message).toBe(
'Request failed with status code 504 Gateway Timeout: GET https://example.com/resource'
);
expect(error.response).toBe(response);
expect(error.request).toBe(request);
});

it('should handle status code without status text', () => {
// Arrange
const response = mockResponse(500, '');
const request = mockRequest('POST', 'https://example.com/update');

// Act
const error = new HTTPError(response, request);

// Assert
expect(error.message).toBe(
'Request failed with status code 500: POST https://example.com/update'
);
});

it('should handle requests with no status code', () => {
// Arrange
const response = mockResponse(undefined as any, '');
const request = mockRequest('GET', 'https://example.com/unknown');

// Act
const error = new HTTPError(response, request);

// Assert
expect(error.message).toBe(
'Request failed with an unknown error: GET https://example.com/unknown'
);
});
});

it.each([
[HTTPBadRequestError.name, HTTPBadRequestError, StatusCode.BAD_REQUEST],
[HTTPAuthorizationError.name, HTTPAuthorizationError, StatusCode.UNAUTHORIZED],
[HTTPForbiddenError.name, HTTPForbiddenError, StatusCode.FORBIDDEN],
[HTTPNotFoundError.name, HTTPNotFoundError, StatusCode.NOT_FOUND],
[HTTPTimeoutError.name, HTTPTimeoutError, StatusCode.TIMEOUT],
[HTTPInternalServerError.name, HTTPInternalServerError, StatusCode.INTERNAL_SERVER_ERROR],
])('%s', (name, errorClass, status) => {
// Arrange
const response = mockResponse(status, name);
const request = mockRequest('GET', 'https://example.com');

// Act
const error = new errorClass(response, request);

// Assert
expect(error).toBeInstanceOf(HTTPError);
expect(error.name).toBe(name);
expect(error.message).toBe(
`Request failed with status code ${status} ${name}: GET https://example.com`
);
expect(error.response).toBe(response);
expect(error.request).toBe(request);
});
});
File renamed without changes.
File renamed without changes.
Loading

0 comments on commit b86cbf9

Please sign in to comment.