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"]
}
}