Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: better http error handling #9

Merged
merged 7 commits into from
Dec 11, 2024
Merged
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) {
innerdvations marked this conversation as resolved.
Show resolved Hide resolved
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> {
innerdvations marked this conversation as resolved.
Show resolved Hide resolved
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 {
innerdvations marked this conversation as resolved.
Show resolved Hide resolved
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
Loading