diff --git a/src/auth/providers/users-permissions.ts b/src/auth/providers/users-permissions.ts index 3eb8b90..d1c27c0 100644 --- a/src/auth/providers/users-permissions.ts +++ b/src/auth/providers/users-permissions.ts @@ -1,4 +1,4 @@ -import { StrapiSDKError, StrapiSDKValidationError } from '../../errors'; +import { StrapiSDKValidationError } from '../../errors'; import { HttpClient } from '../../http'; import { AbstractAuthProvider } from './abstract'; @@ -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; @@ -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; } } diff --git a/src/errors/http.ts b/src/errors/http.ts new file mode 100644 index 0000000..0d2e859 --- /dev/null +++ b/src/errors/http.ts @@ -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'; +} diff --git a/src/errors/index.ts b/src/errors/index.ts index ecd846c..01db27f 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,2 +1,3 @@ export * from './sdk'; export * from './url'; +export * from './http'; diff --git a/src/http/client.ts b/src/http/client.ts index 515da8b..970e9f3 100644 --- a/src/http/client.ts +++ b/src/http/client.ts @@ -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; /** @@ -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'); @@ -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; } /** @@ -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); + } } diff --git a/src/http/constants.ts b/src/http/constants.ts new file mode 100644 index 0000000..79b77bd --- /dev/null +++ b/src/http/constants.ts @@ -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, +} diff --git a/src/http/index.ts b/src/http/index.ts index 4f1cce4..217d867 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -1 +1,2 @@ export * from './client'; +export * from './constants'; diff --git a/tests/unit/auth/providers/users-permissions.test.ts b/tests/unit/auth/providers/users-permissions.test.ts index 71fe807..4eeb49a 100644 --- a/tests/unit/auth/providers/users-permissions.test.ts +++ b/tests/unit/auth/providers/users-permissions.test.ts @@ -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 = { @@ -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); } } @@ -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); }); }); diff --git a/tests/unit/errors/http-errors.test.ts b/tests/unit/errors/http-errors.test.ts new file mode 100644 index 0000000..8a2c71d --- /dev/null +++ b/tests/unit/errors/http-errors.test.ts @@ -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); + }); +}); diff --git a/tests/unit/errors/sdk.test.ts b/tests/unit/errors/sdk-errors.test.ts similarity index 100% rename from tests/unit/errors/sdk.test.ts rename to tests/unit/errors/sdk-errors.test.ts diff --git a/tests/unit/errors/url.test.ts b/tests/unit/errors/url-errors.test.ts similarity index 100% rename from tests/unit/errors/url.test.ts rename to tests/unit/errors/url-errors.test.ts diff --git a/tests/unit/http/client.test.ts b/tests/unit/http/client.test.ts index ecf7293..833e73b 100644 --- a/tests/unit/http/client.test.ts +++ b/tests/unit/http/client.test.ts @@ -1,4 +1,13 @@ -import { HttpClient } from '../../../src/http'; +import { + HTTPAuthorizationError, + HTTPBadRequestError, + HTTPError, + HTTPForbiddenError, + HTTPInternalServerError, + HTTPNotFoundError, + HTTPTimeoutError, +} from '../../../src/errors'; +import { HttpClient, StatusCode } from '../../../src/http'; import { MockAuthManager, MockAuthProvider, MockURLValidator } from '../mocks'; describe('HttpClient', () => { @@ -145,8 +154,6 @@ describe('HttpClient', () => { // Assert expect(mockAuthManager.isAuthenticated).toBe(true); - expect(fetchSpy).toHaveBeenCalledWith(expect.any(Request), undefined); - expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual(payload); }); @@ -161,13 +168,29 @@ describe('HttpClient', () => { const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); - // Act - const response = await httpClient.fetch('/'); + // Act & Assert + await expect(httpClient.fetch('/')).rejects.toThrow(HTTPAuthorizationError); - // Assert expect(handleUnauthorizedErrorSpy).toHaveBeenCalled(); expect(mockAuthManager.isAuthenticated).toBe(false); - expect(fetchSpy).toHaveBeenCalledWith(expect.any(Request), undefined); - expect(response.status).toBe(401); + }); + + describe('Error Mapping', () => { + it.each([ + ['Bad Request', StatusCode.BAD_REQUEST, HTTPBadRequestError], + ['Unauthorized', StatusCode.UNAUTHORIZED, HTTPAuthorizationError], + ['Forbidden', StatusCode.FORBIDDEN, HTTPForbiddenError], + ['Not Found', StatusCode.NOT_FOUND, HTTPNotFoundError], + ['Timeout', StatusCode.TIMEOUT, HTTPTimeoutError], + ['Internal Server', StatusCode.INTERNAL_SERVER_ERROR, HTTPInternalServerError], + ['Unknown', 504, HTTPError], + ])('should throw on %s error', async (_name, status, error) => { + // Arrange + fetchSpy.mockImplementationOnce(() => Promise.resolve(new Response('', { status }))); + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + + // Act & Assert + await expect(httpClient.fetch('/foo')).rejects.toThrow(error); + }); }); }); diff --git a/tests/unit/mocks/index.ts b/tests/unit/mocks/index.ts index 1d525ff..6242bf0 100644 --- a/tests/unit/mocks/index.ts +++ b/tests/unit/mocks/index.ts @@ -4,3 +4,5 @@ export { MockAuthManager } from './auth-manager.mock'; export { MockHttpClient } from './http-client.mock'; export { MockURLValidator } from './url-validator.mock'; export { MockStrapiSDKValidator } from './sdk-validator.mock'; +export { mockRequest } from './request.mock'; +export { mockResponse } from './response.mock'; diff --git a/tests/unit/mocks/request.mock.ts b/tests/unit/mocks/request.mock.ts new file mode 100644 index 0000000..f13e4aa --- /dev/null +++ b/tests/unit/mocks/request.mock.ts @@ -0,0 +1,23 @@ +export const mockRequest = (method: string, url: string): Request => ({ + method, + url, + headers: new Headers(), + redirect: 'follow', + clone: jest.fn(), + body: null, + bodyUsed: false, + cache: 'default', + credentials: 'same-origin', + integrity: '', + keepalive: false, + mode: 'same-origin', + referrer: '', + referrerPolicy: 'no-referrer', + destination: '', + signal: AbortSignal.any([]), + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)), + blob: jest.fn().mockResolvedValue(new Blob()), + formData: jest.fn().mockResolvedValue(new FormData()), + text: jest.fn().mockResolvedValue(''), + json: jest.fn().mockResolvedValue({}), +}); diff --git a/tests/unit/mocks/response.mock.ts b/tests/unit/mocks/response.mock.ts new file mode 100644 index 0000000..0edf191 --- /dev/null +++ b/tests/unit/mocks/response.mock.ts @@ -0,0 +1,17 @@ +export const mockResponse = (status: number, statusText: string): Response => ({ + status, + statusText, + headers: new Headers(), + ok: status >= 200 && status < 300, + redirected: false, + type: 'basic', + url: 'https://example.com', + clone: jest.fn(), + body: null, + bodyUsed: false, + text: jest.fn().mockResolvedValue(''), + json: jest.fn().mockResolvedValue({}), + blob: jest.fn().mockResolvedValue(new Blob()), + formData: jest.fn().mockResolvedValue(new FormData()), + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)), +});