diff --git a/.commitlintrc.ts b/.commitlintrc.ts index b446373..4dab21b 100644 --- a/.commitlintrc.ts +++ b/.commitlintrc.ts @@ -1,6 +1,7 @@ -import type { UserConfig } from '@commitlint/types'; import { RuleConfigSeverity } from '@commitlint/types'; +import type { UserConfig } from '@commitlint/types'; + const config: UserConfig = { extends: ['@commitlint/config-conventional'], rules: { diff --git a/.github/workflows/issues_handleLabel.yml b/.github/workflows/issues_handleLabel.yml index a56f3ed..a4af438 100644 --- a/.github/workflows/issues_handleLabel.yml +++ b/.github/workflows/issues_handleLabel.yml @@ -2,7 +2,7 @@ name: Issue Labeled on: issues: - types: [ labeled ] + types: [labeled] permissions: issues: write diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e4844ad..b3db83c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,11 +62,11 @@ jobs: unit_back: name: 'unit_back (node: ${{ matrix.node }})' - needs: [ cache-and-install, build ] + needs: [cache-and-install, build] runs-on: ubuntu-latest strategy: matrix: - node: [ 20, 22 ] + node: [20, 22] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index bb6300a..c894534 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ $RECYCLE.BIN/ *.jar *.rar *.tar +*.tgz *.zip *.com *.class diff --git a/README.md b/README.md index 3fcc301..897f9bc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

sdk-js

-

An SDK you can use to easily interface with Strapi from your javascript project

+

An SDK you can use to easily interface with Strapi from your JavaScript and TypeScript projects


@@ -21,13 +21,14 @@ sdk-js is an SDK you can use to easily interface with Strapi from your JavaScrip ## Getting Started With Strapi -If you're brand new to Strapi development, we recommend you follow the [Strapi Quick Start Guide](https://docs.strapi.io/dev-docs/quick-start) +If you're brand new to Strapi development, it is recommended to follow the [Strapi Quick Start Guide](https://docs.strapi.io/dev-docs/quick-start) sdk-js is compatible with Strapi v5+ and interfaces with Strapi's REST API. You can read the API docs [here](https://docs.strapi.io/dev-docs/api/rest) ## SDK Purpose -sdk-js is the recommended and easiest way to interface with Strapi from your javascript project. It allows you to easily create, read, update, and delete Strapi content through strongly typed methods. +sdk-js is the recommended and easiest way to interface with Strapi from your JavaScript and TypeScript projects. +It allows you to easily create, read, update, and delete Strapi content through strongly typed methods. @@ -42,11 +43,11 @@ In its simplest form, "@strapi/sdk-js" works by being connected to the URL of yo ### Importing the SDK ```js -import { createSDK } from '@strapi/sdk-js'; // ES Modules -// const { createSDK } = require("@strapi/sdk-js"); CommonJS +import { createStrapiSDK } from '@strapi/sdk-js'; // ES Modules +// const { createStrapiSDK } = require("@strapi/sdk-js"); CommonJS -const strapiSDK = createSDK({ - url: 'http://localhost:1337', +const strapiSDK = createStrapiSDK({ + baseURL: 'http://localhost:1337', }); ``` @@ -55,8 +56,8 @@ const strapiSDK = createSDK({ ```html ``` @@ -89,12 +90,12 @@ As opposed to importing the SDK from a CDN or NPM, the generated asset can then Alternatively, you can use the SDK from a CDN or NPM, but provide the SDK with a Strapi schema. ```js -import { createSDK } from '@strapi/sdk-js'; +import { createStrapiSDK } from '@strapi/sdk-js'; // TODO clarify where this comes from and how to generate it import strapiAppSchema from '../path/to/strapi-app-schema.json'; -const strapiSDK = createSDK({ - url: 'http://localhost:1337', +const strapiSDK = createStrapiSDK({ + baseURL: 'http://localhost:1337', schema: strapiAppSchema, }); ``` diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 41832a9..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,24 +0,0 @@ -module.exports = { - languageOptions: { - parser: require('@typescript-eslint/parser'), - }, - files: ['src/*.{js,ts,jsx,tsx,yml,yaml}'], - plugins: { - import: require('eslint-plugin-import'), - }, - rules: { - // eslint-plugin-import - 'import/no-default-export': 'error', - 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - 'import/first': ['error'], - 'import/exports-last': ['error'], - 'import/order': [ - 'error', - { - groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'], - 'newlines-between': 'always', - alphabetize: { order: 'asc', caseInsensitive: true }, - }, - ], - }, -}; diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..5ccf2a8 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,49 @@ +import pluginEslintImport from 'eslint-plugin-import'; +import pluginTypescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; + +export default { + languageOptions: { + parser: tsParser, + parserOptions: { + project: ['./tsconfig.eslint.json'], + }, + }, + files: ['{src,tests}/**/*.{js,ts,jsx,tsx,yml,yaml}'], + plugins: { + '@typescript-eslint': pluginTypescriptEslint, + import: pluginEslintImport, + }, + rules: { + // Use the TypeScript port of 'no-unused-vars' to prevent false positives on abstract methods parameters + // while keeping consistency with TS native behavior of ignoring parameters starting with '_'. + // https://typescript-eslint.io/rules/no-unused-vars/ + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + + // eslint-plugin-import + 'import/no-default-export': 'error', + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], + 'import/first': ['error'], + 'import/exports-last': ['error'], + 'import/order': [ + 'error', + { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'], + 'newlines-between': 'always', + alphabetize: { order: 'asc', caseInsensitive: true }, + }, + ], + }, +}; diff --git a/jest.config.js b/jest.config.js index 07b1559..9f9c785 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,9 +1,19 @@ /** @type {import('ts-jest').JestConfigWithTsJest} **/ module.exports = { + rootDir: '.', testEnvironment: 'node', testMatch: ['/tests/unit/**/*.test.ts'], transform: { '^.+.tsx?$': ['ts-jest', {}], }, coverageDirectory: '/.coverage/', + coveragePathIgnorePatterns: ['/node_modules/', '/tests/'], + coverageThreshold: { + global: { + branches: 95, + functions: 95, + lines: 95, + statements: 95, + }, + }, }; diff --git a/package.json b/package.json index 5fdb244..2460977 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,10 @@ }, "./package.json": "./package.json" }, - "files": [], + "files": [ + "./package.json", + "dist/" + ], "scripts": { "build": "rollup --config rollup.config.mjs --failAfterWarnings", "build:clean": "rollup --config rollup.config.mjs --failAfterWarnings", @@ -45,7 +48,8 @@ "test": "jest --verbose", "test:cov": "jest --verbose --coverage", "ts:check": "tsc -p tsconfig.build.json --noEmit", - "watch": "rollup --config rollup.config.mjs --watch --failAfterWarnings" + "watch": "rollup --config rollup.config.mjs --watch --failAfterWarnings", + "prepack": "pnpm exec ./scripts/pre-pack.sh" }, "devDependencies": { "@commitlint/cli": "19.6.0", diff --git a/scripts/pre-pack.sh b/scripts/pre-pack.sh new file mode 100755 index 0000000..e30e3f0 --- /dev/null +++ b/scripts/pre-pack.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +# Run the Prettier check +pnpm prettier:check && + +# Run linting +pnpm lint && + +# Run TypeScript type check +pnpm ts:check && + +# Run tests with coverage +pnpm test:cov && + +# Run the clean build +pnpm build:clean diff --git a/src/auth/factory/factory.ts b/src/auth/factory/factory.ts new file mode 100644 index 0000000..99139fb --- /dev/null +++ b/src/auth/factory/factory.ts @@ -0,0 +1,83 @@ +import { StrapiSDKError } from '../../errors'; + +import type { AuthProviderCreator, AuthProviderMap, CreateAuthProviderParams } from './types'; +import type { AuthProvider } from '../providers'; + +/** + * A factory class responsible for creating and managing authentication providers. + * + * It facilitates the registration and creation of different authentication + * strategies which implement the AuthProvider interface. + * + * @template T_Providers Defines a map for authentication strategy names to their corresponding creator functions. + */ +export class AuthProviderFactory { + private readonly _registry = new Map, AuthProviderCreator>(); + + /** + * Creates an instance of an authentication provider based on the specified strategy. + * + * @param authStrategy The authentication strategy name to be used for creating the provider. + * @param options Configuration options required to initialize the authentication provider. + * + * @returns An instance of an AuthProvider initialized with the given options. + * + * @throws {StrapiSDKError} Throws an error if the specified strategy is not registered in the factory. + * + * @example + * ```typescript + * const factory = new AuthProviderFactory(); + * + * factory.register( + * 'api-token', + * (options: ApiTokenAuthProviderOptions) => new ApiTokenAuthProvider(options) + * ); + * + * const provider = factory.create('api-token', { jwt: 'token' }); + * ``` + */ + create | string>( + authStrategy: T_Strategy, + options: CreateAuthProviderParams + ): AuthProvider { + const creator = this._registry.get(authStrategy); + + if (!creator) { + throw new StrapiSDKError(`Auth strategy "${authStrategy}" is not supported.`); + } + + return creator(options); + } + + /** + * Registers a new authentication strategy with the factory. + * + * @param strategy The name of the authentication strategy to register. + * @param creator A function that creates an instance of an authentication provider for the specified strategy. + * + * @returns The instance of AuthProviderFactory, for chaining purpose. + * + * @example + * ```typescript + * const factory = new AuthProviderFactory(); + * + * factory + * .register( + * 'api-token', + * (options: ApiTokenAuthProviderOptions) => new ApiTokenAuthProvider(options) + * ) + * .register( + * 'users-permissions', + * (options: UsersPermissionsAuthProviderOptions) => new UsersPermissionsAuthProvider(options) + * ); + * ``` + */ + register( + strategy: T_Strategy, + creator: T_Creator + ) { + this._registry.set(strategy, creator); + + return this as AuthProviderFactory; + } +} diff --git a/src/auth/factory/index.ts b/src/auth/factory/index.ts new file mode 100644 index 0000000..d847d7a --- /dev/null +++ b/src/auth/factory/index.ts @@ -0,0 +1 @@ +export * from './factory'; diff --git a/src/auth/factory/types.ts b/src/auth/factory/types.ts new file mode 100644 index 0000000..bc641c2 --- /dev/null +++ b/src/auth/factory/types.ts @@ -0,0 +1,10 @@ +import type { AuthProvider } from '../providers'; + +export type AuthProviderCreator = (options: T) => AuthProvider; + +export type AuthProviderMap = { [key: string]: AuthProviderCreator }; + +export type CreateAuthProviderParams< + T_Providers extends AuthProviderMap, + T_Strategy extends StringKeysOf, +> = T_Providers[T_Strategy] extends AuthProviderCreator ? T_Options : unknown; diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..3ce71de --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,4 @@ +export * from './providers'; +export * from './factory'; + +export * from './manager'; diff --git a/src/auth/manager.ts b/src/auth/manager.ts new file mode 100644 index 0000000..ad8ba92 --- /dev/null +++ b/src/auth/manager.ts @@ -0,0 +1,159 @@ +import { HttpClient } from '../http'; + +import { AuthProviderFactory } from './factory'; +import { ApiTokenAuthProvider, UsersPermissionsAuthProvider } from './providers'; + +import type { + ApiTokenAuthProviderOptions, + AuthProvider, + UsersPermissionsAuthProviderOptions, +} from './providers'; + +/** + * Manages authentication by using different authentication providers and strategies. + * + * Responsible for the registration and management of multiple authentication strategies. + * + * It allows for setting the current strategy, authenticating requests, and tracking authentication status. + */ +export class AuthManager { + protected readonly _authProviderFactory: AuthProviderFactory; + + protected _authProvider?: AuthProvider; + protected _isAuthenticated: boolean = false; + + constructor( + // Dependencies + authProviderFactory: AuthProviderFactory = new AuthProviderFactory() + ) { + // Initialization + this._authProviderFactory = authProviderFactory; + + // Setup + this.registerDefaultProviders(); + } + + /** + * Retrieves the strategy name of the currently active authentication provider. + * + * @returns The name of the current authentication strategy, or undefined if no provider is set. + */ + get strategy(): string | undefined { + return this._authProvider?.name; + } + + /** + * Checks if the last authentication was successful and if the current provider can authenticate HTTP requests. + * + * @returns A boolean indicating whether the user is currently authenticated. + */ + get isAuthenticated() { + return this._isAuthenticated; + } + + /** + * Resets the authentication status to unauthenticated when an unauthorized error is encountered. + * + * @example + * ```typescript + * authManager.handleUnauthorizedError(); + * + * console.log(authManager.isAuthenticated); // false + * ``` + */ + handleUnauthorizedError() { + this._isAuthenticated = false; + } + + /** + * Sets the current authentication strategy with configuration options. + * + * @param strategy - The name of the authentication strategy to be set. + * @param options - Configuration options required to initialize the strategy. + * + * @example + * ```typescript + * authManager.setStrategy('api-token', { jwt: 'my-token' }); + * ``` + */ + setStrategy(strategy: string, options: unknown) { + this._authProvider = this._authProviderFactory.create(strategy, options); + } + + /** + * Performs authentication by using the current authentication provider. + * + * @param http - The HttpClient instance that can be used for the authentication process. + * + * @returns A promise that resolves when the authentication process is complete. + * + * @example + * ```typescript + * await authManager.authenticate(httpClient); + * + * console.log(authManager.isAuthenticated); // true or false depending on success + * ``` + */ + async authenticate(http: HttpClient) { + if (this._authProvider === undefined) { + this._isAuthenticated = false; + + return; + } + + try { + await this._authProvider.authenticate(http); + + this._isAuthenticated = true; + } catch { + this._isAuthenticated = false; + } + } + + /** + * Adds authentication headers to an HTTP request using the current authentication provider. + * + * @param request - The HTTP request to which authentication headers are added. + * + * @example + * ```typescript + * const request = new Request('https://api.example.com/data'); + * + * authManager.authenticateRequest(request); + * + * console.log(request.headers.get('Authorization')) // 'Bearer ' + * ``` + */ + authenticateRequest(request: Request) { + if (this._authProvider) { + const { headers } = this._authProvider; + + for (const [key, value] of Object.entries(headers)) { + request.headers.set(key, value); + } + } + } + + /** + * Registers the SDK default authentication providers in the factory so that they can be later selected. + * + * The default authentication providers are: + * - API Token ({@link ApiTokenAuthProvider}) + * - Users Permissions ({@link UsersPermissionsAuthProvider}) + * + * @note This method is called internally during initialization to set up the available strategies. + */ + protected registerDefaultProviders() { + this._authProviderFactory + // API Token + .register( + ApiTokenAuthProvider.identifier, + (options: ApiTokenAuthProviderOptions) => new ApiTokenAuthProvider(options) + ) + // Users and Permissions + .register( + UsersPermissionsAuthProvider.identifier, + (options: UsersPermissionsAuthProviderOptions) => new UsersPermissionsAuthProvider(options) + ); + } +} diff --git a/src/auth/providers/abstract.ts b/src/auth/providers/abstract.ts new file mode 100644 index 0000000..9387e7a --- /dev/null +++ b/src/auth/providers/abstract.ts @@ -0,0 +1,63 @@ +import { HttpClient } from '../../http'; + +import type { AuthProvider } from './types'; + +/** + * An abstract class that provides a foundational structure for implementing different authentication providers. + * + * It is designed to be extended by specific authentication strategies such as + * API token or users-permissions based authentication. + * + * This class implements the {@link AuthProvider} interface, ensuring consistency across + * authentication strategies in handling authentication processes and headers. + * + * @template T - The type of options that the specific authentication provider requires. + * + * @example + * // Example of extending the AbstractAuthProvider + * class MyAuthProvider extends AbstractAuthProvider { + * constructor(options: MyOptions) { + * super(options); + * } + * + * authenticate(): Promise { + * // Implementation for authentication + * } + * + * get headers() { + * return { + * Authorization: `Bearer ${this._options.token}`, + * }; + * } + * } + * + * @abstract + */ +export abstract class AbstractAuthProvider implements AuthProvider { + protected readonly _options: T; + + protected constructor(options: T) { + this._options = options; + + // Validation + this.preflightValidation(); + } + + /** + * Conducts necessary preflight validation checks for the authentication provider. + * + * This method validates the options passed during the instantiation of the provider. + * + * It is called within the constructor to ensure that all required options adhere + * to the expected format or values before proceeding with operational methods. + * + * @throws {StrapiSDKValidationError} If the validation fails due to invalid or missing options. + */ + protected abstract preflightValidation(): void; + + public abstract get name(): string; + + public abstract get headers(): Record; + + public abstract authenticate(httpClient: HttpClient): Promise; +} diff --git a/src/auth/providers/api-token.ts b/src/auth/providers/api-token.ts new file mode 100644 index 0000000..fc0476e --- /dev/null +++ b/src/auth/providers/api-token.ts @@ -0,0 +1,51 @@ +import { StrapiSDKValidationError } from '../../errors'; + +import { AbstractAuthProvider } from './abstract'; + +const API_TOKEN_AUTH_STRATEGY_IDENTIFIER = 'api-token'; + +/** + * Configuration options for API token authentication. + */ +export interface ApiTokenAuthProviderOptions { + /** + * This is the Strapi API token used for authenticating requests. + * + * It should be a non-empty string + */ + token: string; +} + +export class ApiTokenAuthProvider extends AbstractAuthProvider { + public static readonly identifier = API_TOKEN_AUTH_STRATEGY_IDENTIFIER; + + constructor(options: ApiTokenAuthProviderOptions) { + super(options); + } + + public get name() { + return ApiTokenAuthProvider.identifier; + } + + private get token(): string { + return this._options.token; + } + + preflightValidation(): void { + if ((typeof this.token as unknown) !== 'string' || this.token.trim().length === 0) { + throw new StrapiSDKValidationError( + `A valid API token is required when using the api-token auth strategy. Got "${this.token}"` + ); + } + } + + authenticate(): Promise { + return Promise.resolve(); // does nothing + } + + get headers() { + return { + Authorization: `Bearer ${this.token}`, + }; + } +} diff --git a/src/auth/providers/index.ts b/src/auth/providers/index.ts new file mode 100644 index 0000000..67cbe85 --- /dev/null +++ b/src/auth/providers/index.ts @@ -0,0 +1,5 @@ +export type * from './types'; + +export * from './abstract'; +export * from './api-token'; +export * from './users-permissions'; diff --git a/src/auth/providers/types.ts b/src/auth/providers/types.ts new file mode 100644 index 0000000..0f78e52 --- /dev/null +++ b/src/auth/providers/types.ts @@ -0,0 +1,42 @@ +import type { HttpClient } from '../../http'; + +/** + * Provides an interface for implementing various authentication providers, allowing integration + * of different authentication schemes in a consistent manner. + * + * It enables setting custom headers required by authentication schemes. + * + * @example + * const authProvider = new MyAuthProvider('my-secret-token'); + * + * authProvider.authenticate(); + * + * console.log(authProvider.headers); // Retrieves auth headers to attach them to requests + */ +export interface AuthProvider { + /** + * The identifying name of the authentication provider. + * + * This can be used for differentiating between multiple auth strategies. + */ + get name(): string; + + /** + * Object containing the headers that should be included in requests authenticated by this provider. + * + * Typically, these headers include tokens or keys required by the auth scheme. + */ + get headers(): Record; + + /** + * Authenticates by executing any required authentication steps such as + * fetching tokens or setting the necessary state for future requests. + * + * @param httpClient - The {@link HttpClient} instance used to perform the necessary HTTP requests to authenticate + * + * @returns A promise that resolves when the authentication process has completed. + * + * @throws {Error} If an error occurs that prevents successful authentication. + */ + authenticate(httpClient: HttpClient): Promise; +} diff --git a/src/auth/providers/users-permissions.ts b/src/auth/providers/users-permissions.ts new file mode 100644 index 0000000..3eb8b90 --- /dev/null +++ b/src/auth/providers/users-permissions.ts @@ -0,0 +1,116 @@ +import { StrapiSDKError, StrapiSDKValidationError } from '../../errors'; +import { HttpClient } from '../../http'; + +import { AbstractAuthProvider } from './abstract'; + +const USERS_PERMISSIONS_AUTH_STRATEGY_IDENTIFIER = 'users-permissions'; + +/** + * Configuration options for Users & Permissions authentication. + */ +export interface UsersPermissionsAuthProviderOptions { + /** + * The unique user identifier used for authentication. + */ + identifier: string; + + /** + * The secret passphrase associated with the user's identifier + */ + password: string; +} + +/** + * Payload required for the Users & Permissions authentication process. + */ +export type UsersPermissionsAuthPayload = Pick< + UsersPermissionsAuthProviderOptions, + 'identifier' | 'password' +>; + + /** + * @experimental + * Authentication through users and permissions is experimental for the MVP of + * the Strapi SDK. + */ +export class UsersPermissionsAuthProvider extends AbstractAuthProvider { + public static readonly identifier = USERS_PERMISSIONS_AUTH_STRATEGY_IDENTIFIER; + + private _token: string | null = null; + + constructor(options: UsersPermissionsAuthProviderOptions) { + super(options); + } + + public get name() { + return UsersPermissionsAuthProvider.identifier; + } + + private get credentials(): UsersPermissionsAuthPayload { + return { + identifier: this._options.identifier, + password: this._options.password, + }; + } + + preflightValidation() { + if ( + this._options === undefined || + this._options === null || + typeof this._options !== 'object' + ) { + throw new StrapiSDKValidationError( + 'Missing valid options for initializing the Users & Permissions auth provider.' + ); + } + + const { identifier, password } = this._options; + + if ((typeof identifier as unknown) !== 'string') { + throw new StrapiSDKValidationError( + `The "identifier" option must be a string, but got "${typeof identifier}"` + ); + } + + if ((typeof password as unknown) !== 'string') { + throw new StrapiSDKValidationError( + `The "password" option must be a string, but got "${typeof password}"` + ); + } + } + + get headers(): Record { + if (this._token === null) { + return {}; + } + + return { Authorization: `Bearer ${this._token}` }; + } + + async authenticate(httpClient: HttpClient): Promise { + 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.'); + } + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 0000000..ecd846c --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,2 @@ +export * from './sdk'; +export * from './url'; diff --git a/src/errors/sdk.ts b/src/errors/sdk.ts new file mode 100644 index 0000000..a2107ea --- /dev/null +++ b/src/errors/sdk.ts @@ -0,0 +1,25 @@ +export class StrapiSDKError extends Error { + constructor( + cause: unknown = undefined, + message: string = 'An error occurred in the Strapi SDK. Please check the logs for more information.' + ) { + super(message); + + this.cause = cause; + } +} + +export class StrapiSDKValidationError extends StrapiSDKError { + constructor( + cause: unknown = undefined, + message: string = 'Some of the provided values are not valid.' + ) { + super(cause, message); + } +} + +export class StrapiSDKInitializationError extends StrapiSDKError { + constructor(cause: unknown = undefined, message: string = 'Could not initialize the Strapi SDK') { + super(cause, message); + } +} diff --git a/src/errors/url.ts b/src/errors/url.ts new file mode 100644 index 0000000..71d390c --- /dev/null +++ b/src/errors/url.ts @@ -0,0 +1,7 @@ +export class URLValidationError extends Error {} + +export class URLParsingError extends URLValidationError { + constructor(url: unknown) { + super(`Could not parse invalid URL: "${url}"`); + } +} diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..2e3415d --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,22 @@ +/** + * Extracts `string` keys from the properties of a given {@link T}. + * + * This utility type is beneficial when dealing with mapped types or interfaces where it is necessary to + * operate only on properties whose keys are specifically of the `string` type. + * + * @template T The target object type from which string keys are extracted. + * + * @example + * // Define an interface with different types of keys. + interface Example { + name: string; + age: number; + [key: symbol]: boolean; + } + + // Use `StringKeysOf` to extract only the string keys. + type StringKeys = StringKeysOf; // Results in "name" | "age" + * + * @remark This type is particularly useful in scenarios involving conditional types and mapped type utilities. + */ +type StringKeysOf = keyof T & string; diff --git a/src/http/client.ts b/src/http/client.ts new file mode 100644 index 0000000..515da8b --- /dev/null +++ b/src/http/client.ts @@ -0,0 +1,162 @@ +import { AuthManager } from '../auth'; +import { URLValidator } from '../validators'; + +export type Fetch = typeof globalThis.fetch; + +/** + * Strapi SDK's HTTP Client + * + * Provides methods for configuring the base URL, authentication strategies, + * and for performing HTTP requests with automatic header management and URL validation. + */ +export class HttpClient { + // Properties + private _baseURL: string; + + // Dependencies + private readonly _authManager: AuthManager; + private readonly _urlValidator: URLValidator; + + constructor( + // Properties + baseURL: string, + + // Dependencies + authManager = new AuthManager(), + urlValidator: URLValidator = new URLValidator() + ) { + // Initialization + this._baseURL = baseURL; + + this._authManager = authManager; + this._urlValidator = urlValidator; + + // Validation + this._urlValidator.validate(this._baseURL); + } + + /** + * Gets the currently set base URL. + * + * @returns The base URL used for HTTP requests. + */ + get baseURL(): string { + return this._baseURL; + } + + /** + * Sets a new base URL for the HTTP client and validates it. + * + * @param url - The new base URL to set. + * + * @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'); + * + * client.setBaseURL('http://newexample.com'); + */ + setBaseURL(url: string): this { + this._urlValidator.validate(url); + + this._baseURL = url; + + return this; + } + + /** + * Sets the authentication strategy for the HTTP client. + * + * Configures how the client handles authentication based on the specified strategy and options. + * + * @param strategy - The authentication strategy to use. + * @param options - Additional options required for the authentication strategy. + * + * @throws {StrapiSDKError} If the given strategy is not supported + * + * @returns The HttpClient instance for chaining. + * + * @example + * client.setAuthStrategy('api-token', { token: 'abc123' }); + */ + setAuthStrategy(strategy: string, options: unknown): this { + this._authManager.setStrategy(strategy, options); + + return this; + } + + /** + * Performs an HTTP fetch request to the specified URL. + * + * Attaches the necessary headers, authenticates if required, and handles unauthorized errors. + * + * @param url - The URL to which the request is made, appended to the base URL. + * @param [init] - Optional object containing any custom settings to apply to the fetch request. + * + * @returns A promise that resolves to the HTTP response. + * + * @throws {Error} If the authentication can't be completed, or if the server can't be reached + * + * @example + * client.fetch('/data') + * .then(response => response.json()) + * .then(data => console.log(data)); + */ + async fetch(url: string, init?: RequestInit): Promise { + if (!this._authManager.isAuthenticated) { + await this._authManager.authenticate(this); + } + + const request = new Request(`${this._baseURL}${url}`, init); + + this.attachHeaders(request); + + const response = await this._fetch(request); + + if (response.status === 401) { + this._authManager.handleUnauthorizedError(); + } + + return response; + } + + /** + * 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 [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. + * + * @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 { + return globalThis.fetch(url, init); + } + + /** + * Attaches default and authentication headers to an HTTP request. + * + * This method ensures that a default 'Content-Type' header is set for the request if it is not already specified. + * + * It also delegates to the AuthManager to append any necessary authentication headers, + * potentially overwriting existing ones to ensure correct authorization. + * + * @param request - The HTTP request object to which headers are added. + */ + private attachHeaders(request: Request) { + // Set the default content-type header if it hasn't been defined already + if (!request.headers.has('Content-Type')) { + request.headers.set('Content-Type', 'application/json'); + } + + // Set auth headers if available, potentially overwrite manually set auth headers + this._authManager.authenticateRequest(request); + } +} diff --git a/src/http/index.ts b/src/http/index.ts new file mode 100644 index 0000000..4f1cce4 --- /dev/null +++ b/src/http/index.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/src/index.ts b/src/index.ts index b8c0015..00522e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,54 @@ -export function greetings() { - return 'Hello World!'; -} +import { StrapiSDK } from './sdk'; +import { StrapiSDKValidator } from './validators'; + +import type { StrapiSDKConfig } from './sdk'; + +/** + * Creates a new instance of the Strapi SDK with a specified configuration. + * + * The Strapi SDK functions as a client library to interface with the Strapi content API. + * + * It facilitates reliable and secure interactions with Strapi's APIs by handling URL validation, + * request dispatch, and response parsing for content management. + * + * @param config - The configuration for initializing the SDK. This should include the base URL + * of the Strapi content API that the SDK communicates with. The baseURL + * must be formatted with one of the supported protocols: `http` or `https`. + * Additionally, optional authentication details can be specified within the config. + * + * @returns An instance of the Strapi SDK configured with the specified baseURL and auth settings. + * + * @example + * ```typescript + * // Basic configuration using API token auth + * const sdkConfig = { + * baseURL: 'https://api.example.com', + * auth: { + * strategy: 'api-token', + * options: { token: 'your_token_here' } + * } + * }; + * + * // Create the SDK instance + * const strapiSDK = createStrapiSDK(sdkConfig); + * + * // Using the SDK to fetch content from a custom endpoint + * const response = await strapiSDK.fetch('/content-endpoint'); + * const data = await response.json(); + * + * console.log(data); + * ``` + * + * @throws {StrapiSDKInitializationError} If the provided baseURL does not conform to a valid HTTP or HTTPS URL, + * or if the auth configuration is invalid. + */ +export const createStrapiSDK = (config: StrapiSDKConfig) => { + const sdkValidator = new StrapiSDKValidator(); + + return new StrapiSDK( + // Properties + config, + // Dependencies + sdkValidator + ); +}; diff --git a/src/sdk.ts b/src/sdk.ts new file mode 100644 index 0000000..fba800c --- /dev/null +++ b/src/sdk.ts @@ -0,0 +1,179 @@ +import { StrapiSDKInitializationError } from './errors'; +import { HttpClient } from './http'; +import { StrapiSDKValidator } from './validators'; + +export interface StrapiSDKConfig { + baseURL: string; + auth?: AuthConfig; +} + +export interface AuthConfig { + strategy: string; + options: T; +} + +/** + * Class representing the Strapi SDK to interface with a Strapi backend. + * + * This class integrates setting up configuration, validation, and handling + * HTTP requests with authentication. + * + * It serves as the main interface through which users interact with + * their Strapi installation programmatically. + * + * @template T_Config - Configuration type inferred from the user-provided SDK configuration + */ +export class StrapiSDK { + /** @internal */ + private readonly _config: T_Config; + + /** @internal */ + private readonly _validator: StrapiSDKValidator; + + /** @internal */ + private readonly _httpClient: HttpClient; + + /** @internal */ + constructor( + // Properties + config: T_Config, + + // Dependencies + validator: StrapiSDKValidator = new StrapiSDKValidator(), + httpClientFactory?: (url: string) => HttpClient + ) { + // Properties + this._config = config; + this._validator = validator; + + // Validation + this.preflightValidation(); + + // The HTTP client depends on the preflightValidation for the baseURL validity. + // It could be instantiated before but would throw an invalid URL error + // instead of the SDK itself throwing an initialization exception. + this._httpClient = httpClientFactory?.(config.baseURL) ?? new HttpClient(config.baseURL); + + this.init(); + } + + /** + * Performs preliminary validation of the SDK configuration. + * + * This method ensures that the provided configuration for the SDK is valid by using the + * internal SDK validator. It is invoked during the initialization process to confirm that + * all necessary parts are correctly configured before effectively using the SDK. + * + * @throws {StrapiSDKInitializationError} If the configuration validation fails, indicating an issue with the SDK initialization process. + * + * @example + * // Creating a new instance of StrapiSDK which triggers preflightValidation + * const config = { + * baseURL: 'https://example.com', + * auth: { + * strategy: 'jwt', + * options: { token: 'your-token-here' } + * } + * }; + * const sdk = new StrapiSDK(config); + * + * // The preflightValidation is automatically called within the constructor + * // to ensure the provided config is valid prior to any further setup. + * + * @note This method is private and only called internally during SDK initialization. + * + * @internal + */ + private preflightValidation() { + try { + this._validator.validateConfig(this._config); + } catch (e) { + throw new StrapiSDKInitializationError(e); + } + } + + /** + * Initializes the configuration settings for the SDK. + * + * Sets up the necessary parts required for the SDK's operation, + * including setting up an authentication strategy if provided. + * + * @throws {StrapiSDKValidationError} From the _httpClient if the baseURL is invalid. + * + * @note + * - This method is private and internally invoked only during SDK initialization. + * - Although this method technically -can- throw a validation error, the baseURL + * should already have been validated during the SDK preflight validation. + * + * @internal + */ + private init() { + if (this.auth) { + const { strategy, options } = this.auth; + + this._httpClient.setAuthStrategy(strategy, options); + } + } + + /** + * Retrieves the authentication configuration for the Strapi SDK. + * + * @note This is a private property used internally within the SDK for configuring authentication in the HTTP layer. + * + * @internal + */ + private get auth() { + return this._config.auth; + } + + /** + * Retrieves the base URL of the Strapi SDK instance. + * + * This getter returns the `baseURL` property stored within the SDK's configuration object. + * + * The base URL is used as the starting point for all HTTP requests initiated through the SDK. + * + * @returns The current base URL configured in the SDK. + * This URL typically represents the root endpoint of the Strapi service the SDK interfaces with. + * + * @example + * const config = { baseURL: 'http://localhost:1337' }; + * const sdk = new StrapiSDK(config); + * + * console.log(sdk.baseURL); // Output: http://localhost:1337 + */ + public get baseURL(): string { + return this._config.baseURL; + } + + /** + * Executes an HTTP fetch request to a specified endpoint using the SDK HTTP client. + * + * This method ensures authentication is handled before issuing requests and sets the necessary headers. + * + * @param url - The endpoint to fetch from, appended to the base URL of the SDK. + * @param [init] - Optional initialization options for the request, such as headers or method type. + * + * @example + * ```typescript + * // Create the SDK instance + * const sdk = createStrapiSDK({ baseURL: 'http://localhost:1337' ); + * + * // Perform a custom fetch query + * const response = await sdk.fetch('/categories'); + * + * // Parse the categories into a readable JSON object + * const categories = await response.json(); + * + * // Log the categories + * console.log(categories); + * ``` + * + * @note + * - The method automatically handles authentication by checking if the user is authenticated and attempts to authenticate if not. + * - The base URL is prepended to the provided endpoint path. + */ + fetch(url: string, init?: RequestInit) { + return this._httpClient.fetch(url, init); + } +} diff --git a/src/validators/index.ts b/src/validators/index.ts new file mode 100644 index 0000000..acbffb3 --- /dev/null +++ b/src/validators/index.ts @@ -0,0 +1,2 @@ +export { StrapiSDKValidator } from './sdk'; +export { URLValidator } from './url'; diff --git a/src/validators/sdk.ts b/src/validators/sdk.ts new file mode 100644 index 0000000..db20148 --- /dev/null +++ b/src/validators/sdk.ts @@ -0,0 +1,63 @@ +import { StrapiSDKValidationError, URLValidationError } from '../errors'; + +import { URLValidator } from './url'; + +import type { StrapiSDKConfig } from '../sdk'; + +/** + * Provides the ability to validate the configuration used for initializing the Strapi SDK. + * + * This includes URL validation to ensure compatibility with Strapi's API endpoints. + */ +export class StrapiSDKValidator { + private readonly _urlValidator: URLValidator; + + constructor( + // Dependencies + urlValidator: URLValidator = new URLValidator() + ) { + this._urlValidator = urlValidator; + } + + /** + * Validates the provided SDK configuration, ensuring that all values are + * suitable for the SDK operations.. + * + * @param config - The configuration object for the Strapi SDK. Must include a `baseURL` property indicating the API's endpoint. + * + * @throws {StrapiSDKValidationError} If the configuration is invalid, or if the baseURL is invalid. + */ + validateConfig(config: StrapiSDKConfig) { + if ( + config === undefined || + config === null || + Array.isArray(config) || + typeof config !== 'object' + ) { + throw new StrapiSDKValidationError( + new TypeError('The provided configuration is not a valid object.') + ); + } + + this.validateBaseURL(config.baseURL); + } + + /** + * Validates the base URL, ensuring it follows acceptable protocols and structure for reliable API interaction. + * + * @param url - The base URL string to validate. + * + * @throws {StrapiSDKValidationError} If the URL is invalid or if it fails through the URLValidator checks. + */ + private validateBaseURL(url: unknown) { + try { + this._urlValidator.validate(url); + } catch (e) { + if (e instanceof URLValidationError) { + throw new StrapiSDKValidationError(e); + } + + throw e; + } + } +} diff --git a/src/validators/url.ts b/src/validators/url.ts new file mode 100644 index 0000000..81b7f5b --- /dev/null +++ b/src/validators/url.ts @@ -0,0 +1,60 @@ +import { URLParsingError } from '../errors'; + +/** + * Class representing a URLValidator. + * + * It provides the ability to validate URLs based on a predefined list of allowed protocols. + */ +export class URLValidator { + /** + * Validates the provided URL. + * + * This method checks that the provided URL is a string and can be parsed. + * + * @param url - The URL to be validated. This parameter must be a valid string representation of a URL. + * + * @throws {URLParsingError} Thrown if the URL is not a string or can't be parsed. + * + * @example + * // Example of validating a URL successfully + * const validator = new URLValidator(); + * + * const url = 'http://example.com'; + * + * validator.validate(url); // Does not throw an error + * + * @example + * // Example of a failing validation + * const validator = new URLValidator(); + * + * const url = 123; + * + * try { + * validator.validate(url); + * } catch (error) { + * console.error(error); // URLParsingError + * } + */ + validate(url: unknown) { + if (typeof url !== 'string') { + throw new URLParsingError(url); + } + + const canParse = this.canParse(url); + + if (!canParse) { + throw new URLParsingError(url); + } + } + + /** + * Checks if the URL string can be parsed. + * + * @param url - The URL string to be checked. + * + * @returns A boolean indicating whether the URL can be parsed. + */ + private canParse(url: string): boolean { + return URL.canParse(url); + } +} diff --git a/tests/fixtures/invalid-urls.json b/tests/fixtures/invalid-urls.json new file mode 100644 index 0000000..d8ce6d5 --- /dev/null +++ b/tests/fixtures/invalid-urls.json @@ -0,0 +1,12 @@ +{ + "impossibleToParse": [ + ["", "empty string"], + ["foobar", "regular string"], + ["example.com", "missing protocol"], + [123, "number"], + [null, "null"], + [true, "boolean"], + [{}, "empty object"], + [[], "empty array"] + ] +} diff --git a/tests/unit/auth/factory.test.ts b/tests/unit/auth/factory.test.ts new file mode 100644 index 0000000..c7f92eb --- /dev/null +++ b/tests/unit/auth/factory.test.ts @@ -0,0 +1,35 @@ +import { AuthProviderFactory } from '../../../src/auth'; +import { StrapiSDKError } from '../../../src/errors'; +import { MockAuthProvider } from '../mocks'; + +describe('AuthProviderFactory', () => { + let factory: AuthProviderFactory; + + beforeEach(() => { + factory = new AuthProviderFactory(); + }); + + it('should throw an error if an unregistered strategy is provided', () => { + // Arrange + const invalidStrategyName = ''; + + // Act & Assert + expect(() => { + factory.create(invalidStrategyName, {}); + }).toThrow(StrapiSDKError); + }); + + it('should create a valid instance for registered providers', () => { + // Arrange + const mockCreator = jest.fn(() => new MockAuthProvider()); + + // Act + factory.register(MockAuthProvider.identifier, mockCreator); + + const provider = factory.create(MockAuthProvider.identifier, undefined); + + // Assert + expect(provider).toBeDefined(); + expect(provider).toBeInstanceOf(MockAuthProvider); + }); +}); diff --git a/tests/unit/auth/manager.test.ts b/tests/unit/auth/manager.test.ts new file mode 100644 index 0000000..4ed00ca --- /dev/null +++ b/tests/unit/auth/manager.test.ts @@ -0,0 +1,126 @@ +import { ApiTokenAuthProvider, AuthManager, UsersPermissionsAuthProvider } from '../../../src/auth'; +import { MockAuthProvider, MockAuthProviderFactory, MockHttpClient } from '../mocks'; + +describe('AuthManager', () => { + const mockHttpClient = new MockHttpClient('https://example.com'); + + describe('Default Registered Strategies', () => { + it.each([ + [ApiTokenAuthProvider.identifier, ApiTokenAuthProvider, { token: '' }], + [ + UsersPermissionsAuthProvider.identifier, + UsersPermissionsAuthProvider, + { identifier: '', password: '' }, + ], + ])( + 'should have a strategy registered by default: "%s"', + (providerName, providerClass, options) => { + // Arrange + const mockAuthProviderFactory = new MockAuthProviderFactory(); + const authManager = new AuthManager(mockAuthProviderFactory); + + // Act + const instance = mockAuthProviderFactory.create(providerName, options); + + // Assert + expect(authManager).toBeInstanceOf(AuthManager); + expect(instance).toBeInstanceOf(providerClass); + } + ); + }); + + it('should have no strategy selected after initialization', () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + + // Assert + expect(authManager.strategy).toBeUndefined(); + }); + + it('should set strategy correctly', () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + const strategy = MockAuthProvider.identifier; + + // Act + authManager.setStrategy(strategy, {}); + + // Assert + expect(authManager.strategy).toBe(MockAuthProvider.identifier); + }); + + it('should not be authenticated when strategy is not set', async () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + + // Act + await authManager.authenticate(mockHttpClient); + + // Assert + expect(authManager.isAuthenticated).toBe(false); + }); + + it('should authenticate correctly when strategy is set', async () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + authManager.setStrategy(MockAuthProvider.identifier, {}); + + // Act + await authManager.authenticate(mockHttpClient); + + // Assert + expect(authManager.isAuthenticated).toBe(true); + }); + + it('should handle unauthorized error properly', async () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + authManager.setStrategy(MockAuthProvider.identifier, {}); + + // Act + await authManager.authenticate(mockHttpClient); + authManager.handleUnauthorizedError(); + + // Assert + expect(authManager.isAuthenticated).toBe(false); + }); + + it('should not do anything if authenticate is called without setting strategy', async () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + + // Assert + await expect(authManager.authenticate(mockHttpClient)).resolves.toBeUndefined(); + expect(authManager.isAuthenticated).toBe(false); + }); + + it('should remove authentication if authenticate throws an error', async () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + authManager.setStrategy(MockAuthProvider.identifier, {}); + + jest.spyOn(MockAuthProvider.prototype, 'authenticate').mockImplementationOnce(() => { + throw new Error(); + }); + + // Act + await authManager.authenticate(mockHttpClient); + + // Assert + expect(authManager.isAuthenticated).toBe(false); + }); + + it('should authenticate request correctly', () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + const mockRequest = new Request('https://example.com', { headers: new Headers() }); + + authManager.setStrategy(MockAuthProvider.identifier, {}); + + // Act + authManager.authenticateRequest(mockRequest); + + // Assert + expect(mockRequest.headers.get('Authorization')).toBe('Bearer '); + }); +}); diff --git a/tests/unit/auth/providers/api-token.test.ts b/tests/unit/auth/providers/api-token.test.ts new file mode 100644 index 0000000..0e3a520 --- /dev/null +++ b/tests/unit/auth/providers/api-token.test.ts @@ -0,0 +1,94 @@ +import { ApiTokenAuthProvider, ApiTokenAuthProviderOptions } from '../../../../src/auth'; +import { StrapiSDKValidationError } from '../../../../src/errors'; + +describe('ApiTokenAuthProvider', () => { + describe('Name', () => { + it('should return the static provider name from the instance', () => { + // Arrange + const token = 'abc-xyz'; + const provider = new ApiTokenAuthProvider({ token }); + + // Act + const name = provider.name; + + // Assert + expect(name).toBe(ApiTokenAuthProvider.identifier); + }); + }); + + describe('Preflight Validation', () => { + let spy: jest.SpyInstance; + + beforeEach(() => { + spy = jest.spyOn(ApiTokenAuthProvider.prototype, 'preflightValidation'); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + it('should throw error if token is invalid in preflightValidation', () => { + // Arrange + const token = ' '; + + // Act & Assert + expect(() => new ApiTokenAuthProvider({ token })).toThrow(StrapiSDKValidationError); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should not throw error if token is valid in preflightValidation', () => { + // Arrange + const token = 'abc-xyz'; + + // Act & Assert + expect(() => new ApiTokenAuthProvider({ token })).not.toThrow(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should throw error when token is null in preflightValidation', () => { + // Arrange + const options = { token: null } as unknown as ApiTokenAuthProviderOptions; + + // Act & Assert + expect(() => new ApiTokenAuthProvider(options)).toThrow(StrapiSDKValidationError); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Authenticate', () => { + it('should do nothing when authenticate is called', async () => { + // Arrange + const token = 'abc-xyz'; + const provider = new ApiTokenAuthProvider({ token }); + + // Act & Assert + await expect(provider.authenticate()).resolves.not.toThrow(); + }); + }); + + describe('Headers', () => { + it('should return correct headers with valid token', () => { + // Arrange + const token = 'abc-xyz'; + const provider = new ApiTokenAuthProvider({ token }); + + // Act + const headers = provider.headers; + + // Assert + expect(headers).toEqual({ Authorization: `Bearer ${token}` }); + }); + + it('should maintain immutability of headers', () => { + // Arrange + const token = 'abc-xyz'; + const provider = new ApiTokenAuthProvider({ token }); + + // Act + provider.headers.Authorization = 'Modified_1'; + + // Assert + expect(provider.headers.Authorization).toEqual(`Bearer ${token}`); + }); + }); +}); diff --git a/tests/unit/auth/providers/users-permissions.test.ts b/tests/unit/auth/providers/users-permissions.test.ts new file mode 100644 index 0000000..71fe807 --- /dev/null +++ b/tests/unit/auth/providers/users-permissions.test.ts @@ -0,0 +1,144 @@ +import { + UsersPermissionsAuthProvider, + UsersPermissionsAuthProviderOptions, +} from '../../../../src/auth'; +import { StrapiSDKError, StrapiSDKValidationError } from '../../../../src/errors'; +import { HttpClient } from '../../../../src/http'; +import { MockHttpClient } from '../../mocks'; + +const FAKE_TOKEN = ''; +const FAKE_VALID_CONFIG: UsersPermissionsAuthProviderOptions = { + identifier: 'user@example.com', + password: 'securePassword123', +}; + +class ValidFakeHttpClient extends MockHttpClient { + async _fetch() { + return new Response(JSON.stringify({ jwt: FAKE_TOKEN }), { status: 200 }); + } +} + +class FaultyFakeHttpClient extends HttpClient { + async _fetch() { + return new Response('Bad request', { status: 400 }); + } +} + +describe('UsersPermissionsAuthProvider', () => { + const fakeHttpClient = new ValidFakeHttpClient('https://example.com'); + const faultyHttpClient = new FaultyFakeHttpClient('https://example.com'); + + describe('Name', () => { + it('should return the static provider name from the instance', () => { + // Arrange + const identifier = 'user@example.com'; + const password = 'securePassword123'; + const provider = new UsersPermissionsAuthProvider({ identifier, password }); + + // Act + const name = provider.name; + + // Assert + expect(name).toBe(UsersPermissionsAuthProvider.identifier); + }); + }); + + describe('Preflight Validation', () => { + let spy: jest.SpyInstance; + + beforeEach(() => { + spy = jest.spyOn(UsersPermissionsAuthProvider.prototype, 'preflightValidation'); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + it.each([ + { identifier: null, password: 'securePassword123' }, + { identifier: true, password: 'securePassword123' }, + { identifier: 42, password: 'securePassword123' }, + { identifier: 'user@example.com', password: null }, + { identifier: 'user@example.com', password: true }, + { identifier: 'user@example.com', password: 42 }, + undefined, + null, + 'not_an_object', + 42, + ])('should throw error if credentials are invalid in preflightValidation: %s', (options) => { + // Act & Assert + expect( + () => + new UsersPermissionsAuthProvider( + options as unknown as UsersPermissionsAuthProviderOptions + ) + ).toThrow(StrapiSDKValidationError); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should not throw error if identifier and password are valid in preflightValidation', () => { + // Arrange + const options = { identifier: 'user@example.com', password: 'securePassword123' }; + + // Act & Assert + expect(() => new UsersPermissionsAuthProvider(options)).not.toThrow(); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Authenticate', () => { + it('should authenticate successfully without throwing', async () => { + // Arrange + const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); + + // Act & Assert + await expect(provider.authenticate(fakeHttpClient)).resolves.not.toThrow(); + }); + + it('should set the headers correctly with the received token', async () => { + // Arrange + const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); + + // Act + await provider.authenticate(fakeHttpClient); + + // Assert + expect(provider.headers).toEqual({ Authorization: `Bearer ${FAKE_TOKEN}` }); + }); + + it('should throw if it fails to authenticate', async () => { + // Arrange + const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); + + // Act & Assert + await expect(provider.authenticate(faultyHttpClient)).rejects.toThrow(StrapiSDKError); + }); + }); + + describe('Headers', () => { + it('should maintain immutability of headers', async () => { + // Arrange + const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); + + // Act + await provider.authenticate(fakeHttpClient); + + // It shouldn't be possible to modify the headers manually + provider.headers.Authorization = 'Modified_1'; + + // Assert + expect(provider.headers.Authorization).toEqual(`Bearer ${FAKE_TOKEN}`); + }); + + it('should return an empty object if the provider has not perform authentication yet', () => { + // Arrange + const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); + + // Act + const { headers } = provider; + + // Assert + expect(headers).toEqual({}); + }); + }); +}); diff --git a/tests/unit/errors/sdk.test.ts b/tests/unit/errors/sdk.test.ts new file mode 100644 index 0000000..c322f15 --- /dev/null +++ b/tests/unit/errors/sdk.test.ts @@ -0,0 +1,108 @@ +import { + StrapiSDKError, + StrapiSDKInitializationError, + StrapiSDKValidationError, +} from '../../../src/errors'; + +describe('SDK Errors', () => { + describe('StrapiSDKError', () => { + it('should have a default message', () => { + // Act + const error = new StrapiSDKError(); + + // Assert + expect(error.message).toBe( + 'An error occurred in the Strapi SDK. Please check the logs for more information.' + ); + expect(error.cause).toBeUndefined(); + }); + + it('should allow a custom message', () => { + // Arrange + const customMessage = 'Custom error message.'; + + // Act + const error = new StrapiSDKError(undefined, customMessage); + + // Assert + expect(error.message).toBe(customMessage); + }); + + it('should set the cause if provided', () => { + // Arrange + const cause = new Error('Root cause'); + + // Act + const error = new StrapiSDKError(cause); + + // Assert + expect(error.cause).toBe(cause); + }); + }); + + describe('StrapiSDKValidationError', () => { + it('should have a default message', () => { + // Act + const error = new StrapiSDKValidationError(); + + // Assert + expect(error.message).toBe('Some of the provided values are not valid.'); + expect(error.cause).toBeUndefined(); + }); + + it('should allow a custom message', () => { + // Arrange + const customMessage = 'Validation error occurred.'; + + // Act + const error = new StrapiSDKValidationError(undefined, customMessage); + + // Assert + expect(error.message).toBe(customMessage); + }); + + it('should set the cause if provided', () => { + // Arrange + const cause = new Error('Validation root cause'); + + // Act + const error = new StrapiSDKValidationError(cause); + + // Assert + expect(error.cause).toBe(cause); + }); + }); + + describe('StrapiSDKInitializationError', () => { + it('should have a default message', () => { + // Act + const error = new StrapiSDKInitializationError(); + + // Assert + expect(error.message).toBe('Could not initialize the Strapi SDK'); + expect(error.cause).toBeUndefined(); + }); + + it('should allow a custom message', () => { + // Arrange + const customMessage = 'Initialization error occurred.'; + + // Act + const error = new StrapiSDKInitializationError(undefined, customMessage); + + // Assert + expect(error.message).toBe(customMessage); + }); + + it('should set the cause if provided', () => { + // Arrange + const cause = new Error('Initialization root cause'); + + // Act + const error = new StrapiSDKInitializationError(cause); + + // Assert + expect(error.cause).toBe(cause); + }); + }); +}); diff --git a/tests/unit/errors/url.test.ts b/tests/unit/errors/url.test.ts new file mode 100644 index 0000000..faa7714 --- /dev/null +++ b/tests/unit/errors/url.test.ts @@ -0,0 +1,19 @@ +import { URLValidationError, URLParsingError } from '../../../src/errors'; + +describe('URL Errors', () => { + describe('URLParsingError', () => { + it('should construct with a correct error message', () => { + // Arrange + const url = 'invalid_url'; + + // Act + const error = new URLParsingError(url); + + // Assert + expect(error).toBeInstanceOf(URLParsingError); + expect(error).toBeInstanceOf(URLValidationError); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe(`Could not parse invalid URL: "${url}"`); + }); + }); +}); diff --git a/tests/unit/http/client.test.ts b/tests/unit/http/client.test.ts new file mode 100644 index 0000000..ecf7293 --- /dev/null +++ b/tests/unit/http/client.test.ts @@ -0,0 +1,173 @@ +import { HttpClient } from '../../../src/http'; +import { MockAuthManager, MockAuthProvider, MockURLValidator } from '../mocks'; + +describe('HttpClient', () => { + let mockAuthManager: MockAuthManager; + let mockURLValidator: MockURLValidator; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + mockAuthManager = new MockAuthManager(); + mockURLValidator = new MockURLValidator(); + + fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + }) + ) + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should validate baseURL in constructor', () => { + // Arrange & Act + const spy = jest.spyOn(mockURLValidator, 'validate'); + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + + // Assert + expect(httpClient).toBeInstanceOf(HttpClient); + expect(spy).toHaveBeenCalledWith('https://example.com'); + }); + + it('setBaseURL should validate and update baseURL', () => { + // Arrange + const baseURL = 'https://example.com'; + const newBaseURL = 'https://newurl.com'; + + const spy = jest.spyOn(mockURLValidator, 'validate'); + + const httpClient = new HttpClient(baseURL, mockAuthManager, mockURLValidator); + + // Act + httpClient.setBaseURL(newBaseURL); + + // Assert + expect(spy).toHaveBeenCalledWith(newBaseURL); + expect(httpClient.baseURL).toBe(newBaseURL); + }); + + it('setAuthStrategy should configure the authentication strategy', () => { + // Arrange + const strategy = MockAuthProvider.identifier; + const strategyOptions = {}; + + const spy = jest.spyOn(mockAuthManager, 'setStrategy'); + + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + + // Act + httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + + // Assert + expect(spy).toHaveBeenCalledWith(strategy, strategyOptions); + expect(mockAuthManager.strategy).toBe(strategy); + }); + + it('should try to authenticate before making a request if not already authenticated', async () => { + // Arrange + const authenticateSpy = jest.spyOn(mockAuthManager, 'authenticate'); + + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + + // Act + await httpClient.fetch('/'); + + // Assert + expect(authenticateSpy).toHaveBeenCalled(); + }); + + it('fetch should add auth headers to the http request if authenticated', async () => { + // Arrange + const authenticateRequestSpy = jest.spyOn(mockAuthManager, 'authenticateRequest'); + + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + + // Act + await httpClient.fetch('/'); + + const authorizationHeader = authenticateRequestSpy.mock.lastCall + ?.at(0) + ?.headers.get('Authorization'); + + // Assert + expect(authenticateRequestSpy).toHaveBeenCalled(); + expect(authorizationHeader).toBe('Bearer '); + }); + + it('fetch should add an application/json Content-Type header to each request', async () => { + // Arrange + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + + // Act + await httpClient.fetch('/'); + + const contentTypeHeader = fetchSpy.mock.lastCall?.at(0)?.headers.get('Content-Type'); + + // Assert + expect(contentTypeHeader).toBe('application/json'); + }); + + it('fetch should not add auth headers to the http request if not authenticated', async () => { + // Arrange + const authenticateRequestSpy = jest.spyOn(mockAuthManager, 'authenticateRequest'); + + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + + // Act + await httpClient.fetch('/'); + + const authorizationHeader = fetchSpy.mock.lastCall?.at(0)?.headers.get('Authorization'); + + // Assert + expect(authenticateRequestSpy).toHaveBeenCalled(); + expect(authorizationHeader).toBeNull(); + }); + + it('fetch should forward valid responses', async () => { + // Arrange + const payload = { ok: true }; + + fetchSpy.mockImplementationOnce(() => { + return Promise.resolve(new Response(JSON.stringify(payload), { status: 200 })); + }); + + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + + // Act + const response = await httpClient.fetch('/'); + + // 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); + }); + + it('fetch should handle 401 unauthorized responses', async () => { + // Arrange + const handleUnauthorizedErrorSpy = jest.spyOn(mockAuthManager, 'handleUnauthorizedError'); + + fetchSpy.mockImplementationOnce(() => { + return Promise.resolve(new Response('Unauthorized', { status: 401 })); + }); + + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + + // Act + const response = await httpClient.fetch('/'); + + // Assert + expect(handleUnauthorizedErrorSpy).toHaveBeenCalled(); + expect(mockAuthManager.isAuthenticated).toBe(false); + expect(fetchSpy).toHaveBeenCalledWith(expect.any(Request), undefined); + expect(response.status).toBe(401); + }); +}); diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts deleted file mode 100644 index 643e37c..0000000 --- a/tests/unit/index.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { greetings } from '../../src'; - -describe('greetings function', () => { - it('should return "Hello World!"', () => { - expect(greetings()).toBe('Hello World!'); - }); -}); diff --git a/tests/unit/mocks/auth-manager.mock.ts b/tests/unit/mocks/auth-manager.mock.ts new file mode 100644 index 0000000..1104383 --- /dev/null +++ b/tests/unit/mocks/auth-manager.mock.ts @@ -0,0 +1,13 @@ +import { AuthManager, AuthProviderFactory } from '../../../src/auth'; + +import { MockAuthProviderFactory } from './auth-provider-factory.mock'; + +export class MockAuthManager extends AuthManager { + constructor(authProviderFactory: AuthProviderFactory = new MockAuthProviderFactory()) { + super(authProviderFactory); + } + + registerDefaultProviders() { + // does nothing + } +} diff --git a/tests/unit/mocks/auth-provider-factory.mock.ts b/tests/unit/mocks/auth-provider-factory.mock.ts new file mode 100644 index 0000000..6b19c6e --- /dev/null +++ b/tests/unit/mocks/auth-provider-factory.mock.ts @@ -0,0 +1,11 @@ +import { AuthProviderFactory } from '../../../src/auth'; + +import { MockAuthProvider } from './auth-provider.mock'; + +export class MockAuthProviderFactory extends AuthProviderFactory { + constructor() { + super(); + + this.register(MockAuthProvider.identifier, () => new MockAuthProvider()); + } +} diff --git a/tests/unit/mocks/auth-provider.mock.ts b/tests/unit/mocks/auth-provider.mock.ts new file mode 100644 index 0000000..7f91a46 --- /dev/null +++ b/tests/unit/mocks/auth-provider.mock.ts @@ -0,0 +1,25 @@ +import { AbstractAuthProvider } from '../../../src/auth'; + +export class MockAuthProvider extends AbstractAuthProvider<{ jwt: string }> { + public static readonly identifier = 'mock-jwt'; + + constructor() { + super({ jwt: '' }); + } + + authenticate(): Promise { + return Promise.resolve(); + } + + get headers(): Record { + return { Authorization: 'Bearer ' }; + } + + get name(): string { + return MockAuthProvider.identifier; + } + + protected preflightValidation(): void { + // does nothing + } +} diff --git a/tests/unit/mocks/http-client.mock.ts b/tests/unit/mocks/http-client.mock.ts new file mode 100644 index 0000000..ac640c5 --- /dev/null +++ b/tests/unit/mocks/http-client.mock.ts @@ -0,0 +1,26 @@ +import { HttpClient } from '../../../src/http'; + +import { MockAuthManager } from './auth-manager.mock'; +import { MockAuthProviderFactory } from './auth-provider-factory.mock'; +import { MockURLValidator } from './url-validator.mock'; + +import type { AuthManager } from '../../../src/auth'; +import type { URLValidator } from '../../../src/validators'; + +export class MockHttpClient extends HttpClient { + constructor( + baseURL: string, + authManager: AuthManager = new MockAuthManager(new MockAuthProviderFactory()), + urlValidator: URLValidator = new MockURLValidator() + ) { + super(baseURL, authManager, urlValidator); + } + + fetch() { + return this._fetch(); + } + + _fetch() { + return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })); + } +} diff --git a/tests/unit/mocks/index.ts b/tests/unit/mocks/index.ts new file mode 100644 index 0000000..1d525ff --- /dev/null +++ b/tests/unit/mocks/index.ts @@ -0,0 +1,6 @@ +export { MockAuthProvider } from './auth-provider.mock'; +export { MockAuthProviderFactory } from './auth-provider-factory.mock'; +export { MockAuthManager } from './auth-manager.mock'; +export { MockHttpClient } from './http-client.mock'; +export { MockURLValidator } from './url-validator.mock'; +export { MockStrapiSDKValidator } from './sdk-validator.mock'; diff --git a/tests/unit/mocks/sdk-validator.mock.ts b/tests/unit/mocks/sdk-validator.mock.ts new file mode 100644 index 0000000..a354fb1 --- /dev/null +++ b/tests/unit/mocks/sdk-validator.mock.ts @@ -0,0 +1,3 @@ +import { StrapiSDKValidator } from '../../../src/validators'; + +export class MockStrapiSDKValidator extends StrapiSDKValidator {} diff --git a/tests/unit/mocks/url-validator.mock.ts b/tests/unit/mocks/url-validator.mock.ts new file mode 100644 index 0000000..0130b1a --- /dev/null +++ b/tests/unit/mocks/url-validator.mock.ts @@ -0,0 +1,3 @@ +import { URLValidator } from '../../../src/validators'; + +export class MockURLValidator extends URLValidator {} diff --git a/tests/unit/sdk.test.ts b/tests/unit/sdk.test.ts new file mode 100644 index 0000000..d14b61d --- /dev/null +++ b/tests/unit/sdk.test.ts @@ -0,0 +1,129 @@ +import { StrapiSDKInitializationError } from '../../src/errors'; +import { StrapiSDK } from '../../src/sdk'; +import { StrapiSDKValidator, URLValidator } from '../../src/validators'; + +import { MockAuthProvider, MockHttpClient, MockStrapiSDKValidator } from './mocks'; + +import type { StrapiSDKConfig } from '../../src/sdk'; + +/** + * Class representing a FlakyURLValidator which extends URLValidator. + * + * This validator is designed to throw an error unexpectedly upon validation and should only be used in test suites. + */ +class FlakyURLValidator extends URLValidator { + validate() { + throw new Error('Unexpected error'); + } +} + +describe('StrapiSDK', () => { + const mockHttpClientFactory = (url: string) => new MockHttpClient(url); + + describe('Initialization', () => { + it('should initialize with valid config', () => { + // Arrange + const config = { + baseURL: 'http://localhost:1337', + auth: { strategy: MockAuthProvider.identifier, options: {} }, + }; + + const mockValidator = new MockStrapiSDKValidator(); + + const sdkValidatorSpy = jest.spyOn(mockValidator, 'validateConfig'); + const httpSetAuthStrategySpy = jest.spyOn(MockHttpClient.prototype, 'setAuthStrategy'); + + // Act + const sdk = new StrapiSDK(config, mockValidator, mockHttpClientFactory); + + // Assert + + // instance + expect(sdk).toBeInstanceOf(StrapiSDK); + // internal Validation + expect(sdkValidatorSpy).toHaveBeenCalledWith(config); + // internal setup + expect(httpSetAuthStrategySpy).toHaveBeenCalledWith(MockAuthProvider.identifier, {}); + }); + + it('should throw an error on invalid baseURL', () => { + // Arrange + const config = { baseURL: 'invalid-url' }; + + const mockValidator = new MockStrapiSDKValidator(); + + const validateConfigSpy = jest.spyOn(mockValidator, 'validateConfig'); + + // Act & Assert + expect(() => new StrapiSDK(config, mockValidator)).toThrow(StrapiSDKInitializationError); + expect(validateConfigSpy).toHaveBeenCalledWith(config); + }); + + it('should fail to create and SDK instance if there is an unexpected error', () => { + // Arrange + let sdk!: StrapiSDK; + + const baseURL = 'http://example.com'; + const config: StrapiSDKConfig = { baseURL }; + const expectedError = new StrapiSDKInitializationError(new Error('Unexpected error')); + + const validateSpy = jest.spyOn(FlakyURLValidator.prototype, 'validate'); + + // Act + const createSDK = () => { + sdk = new StrapiSDK(config, new StrapiSDKValidator(new FlakyURLValidator())); + }; + + // Assert + expect(createSDK).toThrow(expectedError); + + expect(sdk).toBeUndefined(); + + expect(validateSpy).toHaveBeenCalledTimes(1); + expect(validateSpy).toHaveBeenCalledWith(baseURL); + }); + + it('should initialize correctly with the default validator', () => { + // Arrange + const sdk = new StrapiSDK({ baseURL: 'http://localhost:1337' }); + + // Act & Assert + expect(sdk).toBeInstanceOf(StrapiSDK); + }); + }); + + // todo implement validation capabilities for providers (e.g. checks if the provided auth strategy exists before trying to create a provider instance) + it.todo('should throw an error on invalid auth configuration'); + + it('should fetch data correctly with fetch method', async () => { + // Arrange + const config = { baseURL: 'http://localhost:1337' }; + + const fetchSpy = jest.spyOn(MockHttpClient.prototype, 'fetch'); + + const mockValidator = new MockStrapiSDKValidator(); + const sdk = new StrapiSDK(config, mockValidator, mockHttpClientFactory); + + // Act + const response = await sdk.fetch('/data'); + + // Assert + expect(fetchSpy).toHaveBeenCalledWith('/data', undefined); + await expect(response.json()).resolves.toEqual({ ok: true }); + }); + + it('should retrieve baseURL correctly from config', () => { + // Arrange + const config = { baseURL: 'http://localhost:1337' }; + + const mockValidator = new MockStrapiSDKValidator(); + + const sdk = new StrapiSDK(config, mockValidator, mockHttpClientFactory); + + // Act + const { baseURL } = sdk; + + // Assert + expect(baseURL).toBe(config.baseURL); + }); +}); diff --git a/tests/unit/validators/sdk.test.ts b/tests/unit/validators/sdk.test.ts new file mode 100644 index 0000000..a8d7468 --- /dev/null +++ b/tests/unit/validators/sdk.test.ts @@ -0,0 +1,65 @@ +import { StrapiSDKValidationError, URLValidationError } from '../../../src/errors'; +import { StrapiSDKValidator, URLValidator } from '../../../src/validators'; + +import type { StrapiSDKConfig } from '../../../src/sdk'; + +describe('Strapi SDKValidator', () => { + let urlValidatorMock: jest.Mocked; + + beforeEach(() => { + urlValidatorMock = new URLValidator() as jest.Mocked; + urlValidatorMock.validate = jest.fn(); + }); + + describe('validateConfig', () => { + it.each([undefined, null, 2, []])( + 'should throw an error if config is not a valid object (%s)', + (config: unknown) => { + // Arrange + const validator = new StrapiSDKValidator(urlValidatorMock); + const expected = new StrapiSDKValidationError( + new TypeError('The provided configuration is not a valid object.') + ); + + // Act & Assert + expect(() => validator.validateConfig(config as StrapiSDKConfig)).toThrow(expected); + } + ); + + it('should not throw an error if config is a valid object', () => { + // Arrange + const config = { baseURL: 'https://example.com' }; + const validator = new StrapiSDKValidator(urlValidatorMock); + + // Act & Assert + expect(() => validator.validateConfig(config)).not.toThrow(); + }); + }); + + describe('validateBaseURL', () => { + it('should call validateBaseURL method with the baseURL', () => { + // Arrange + const validator = new StrapiSDKValidator(urlValidatorMock); + const config: StrapiSDKConfig = { baseURL: 'http://valid.url' }; + + // Act + validator.validateConfig(config); + + // Assert + expect(urlValidatorMock.validate).toHaveBeenCalledWith('http://valid.url'); + }); + + it('should throw StrapiSDKValidationError on URLValidationError', () => { + // Arrange + const validator = new StrapiSDKValidator(urlValidatorMock); + const baseURL = 'invalid-url'; + + urlValidatorMock.validate.mockImplementationOnce(() => { + throw new URLValidationError('invalid url'); + }); + + // Act & Assert + expect(() => validator.validateConfig({ baseURL })).toThrow(StrapiSDKValidationError); + }); + }); +}); diff --git a/tests/unit/validators/url.test.ts b/tests/unit/validators/url.test.ts new file mode 100644 index 0000000..80a753d --- /dev/null +++ b/tests/unit/validators/url.test.ts @@ -0,0 +1,47 @@ +import { URLParsingError } from '../../../src/errors'; +import { URLValidator } from '../../../src/validators'; +import invalidURLs from '../../fixtures/invalid-urls.json'; + +describe('URLValidator', () => { + let urlValidator: URLValidator; + + beforeEach(() => { + urlValidator = new URLValidator(); + }); + + describe('Protocol Validation', () => { + it('should validate a valid HTTP URL', () => { + // Arrange + const url = 'http://example.com'; + + // Act & Assert + expect(() => urlValidator.validate(url)).not.toThrow(); + }); + + it('should validate a valid HTTPS URL', () => { + // Arrange + const url = 'https://example.com'; + + // Act & Assert + expect(() => urlValidator.validate(url)).not.toThrow(); + }); + }); + + describe('Parsing Validation', () => { + it('should not throw when given a valid URL', () => { + // Arrange + const url = 'https://example.com'; + + // Act & Assert + expect(() => urlValidator.validate(url)).not.toThrow(); + }); + + it.each(invalidURLs.impossibleToParse)( + 'should throw an error for a non-string input: %s (%s)', + (url) => { + // Act & Assert + expect(() => urlValidator.validate(url)).toThrow(new URLParsingError(url)); + } + ); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 3157725..dbdb3a2 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,7 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "stripInternal": true }, "include": ["src"], "exclude": ["tests", "**/__tests__/**", "**/cli/**"] diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 9fac91c..b363eb6 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -3,6 +3,6 @@ "compilerOptions": { "noEmit": true }, - "include": ["examples", "src", "tests", "scripts", "*.config.ts", "*.config.mjs", ".eslintrc.js"], + "include": ["src", "tests"], "exclude": ["node_modules"] } diff --git a/tsconfig.json b/tsconfig.json index ea9c800..d376c2e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src"], "exclude": ["node_modules", "dist"], "compilerOptions": { + "sourceMap": true, "esModuleInterop": true, "skipLibCheck": true, "target": "ES2022", @@ -14,7 +15,6 @@ "noUncheckedIndexedAccess": true, "declaration": true, "module": "preserve", - "noEmit": true, "lib": ["ESNext", "DOM"] } }