12 | The goal of this project is to showcase the features offered by the{' '}
13 | @strapi/client SDK in the context of a
14 | React application, including collection type managers, files management, and advanced
15 | query options.
16 |
17 |
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/demo/react-vite/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface File {
2 | id: number;
3 | name: string;
4 | alternativeText?: string;
5 | caption?: string;
6 | width: number;
7 | height: number;
8 | formats?: Record;
9 | hash: string;
10 | ext: string;
11 | mime: string;
12 | size: number;
13 | url: string;
14 | previewUrl?: string | null;
15 | provider: string;
16 | createdAt: string;
17 | updatedAt: string;
18 | }
19 |
20 | export interface FileFormat {
21 | ext: string;
22 | hash: string;
23 | height: number;
24 | width: number;
25 | mime: string;
26 | name: string;
27 | path: string | null;
28 | size: number;
29 | sizeInBytes: number;
30 | url: string;
31 | }
32 |
33 | export interface Category {
34 | id: number;
35 | name: string;
36 | slug: string;
37 | image: File | null;
38 | createdAt: string;
39 | updatedAt: string;
40 | }
41 |
42 | export type QueryParam = 'populate' | 'fields' | 'sort' | 'filters';
43 |
--------------------------------------------------------------------------------
/demo/react-vite/src/utils/constants.ts:
--------------------------------------------------------------------------------
1 | import type { QueryParam } from '@/types.ts';
2 |
3 | export const BASE_URL = 'http://localhost:1337';
4 |
5 | export const BASE_API_PATH = '/api';
6 |
7 | export const DEFAULT_COLLECTION_QUERIES = {
8 | populate: ['image'],
9 | sort: 'createdAt:desc',
10 | fields: ['name'],
11 | filters: { name: { $contains: 'e' } },
12 | } satisfies Record;
13 |
--------------------------------------------------------------------------------
/demo/react-vite/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/demo/react-vite/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true,
24 |
25 | "paths": {
26 | "@/*": ["./src/*"]
27 | }
28 | },
29 | "include": ["src"]
30 | }
31 |
--------------------------------------------------------------------------------
/demo/react-vite/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
4 | }
5 |
--------------------------------------------------------------------------------
/demo/react-vite/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true,
22 |
23 | "paths": {
24 | "@/*": ["./src/*"]
25 | }
26 | },
27 | "include": ["vite.config.ts"]
28 | }
29 |
--------------------------------------------------------------------------------
/demo/react-vite/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import tailwindcss from '@tailwindcss/vite';
3 |
4 | import path from 'node:path';
5 | import { defineConfig, loadEnv } from 'vite';
6 |
7 | // https://vite.dev/config/
8 | export default defineConfig(({ mode }) => {
9 | const env = loadEnv(mode, process.cwd(), '');
10 |
11 | return {
12 | server: { port: 5174 },
13 | plugins: [react(), tailwindcss()],
14 | resolve: {
15 | alias: {
16 | '@': path.resolve(__dirname, './src'),
17 | },
18 | },
19 | // Note: vite expects its env variables to be defined using VITE_XXX. To circumvent this, we define global
20 | // variable to mimic what is done in other demos as tokens are saved in the .env file with predefined names.
21 | // This is a hack and shouldn't be used in real applications
22 | define: {
23 | 'process.env.FULL_ACCESS_TOKEN': JSON.stringify(env.FULL_ACCESS_TOKEN),
24 | 'process.env.READ_ONLY_TOKEN': JSON.stringify(env.READ_ONLY_TOKEN),
25 | },
26 | };
27 | });
28 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import pluginEslintImport from 'eslint-plugin-import';
2 | import pluginTypescriptEslint from '@typescript-eslint/eslint-plugin';
3 | import tsParser from '@typescript-eslint/parser';
4 |
5 | export default [
6 | {
7 | // the "ignores" patterns have to be defined as the only key of a config object
8 | // see: https://eslint.org/docs/latest/use/configure/ignore#ignoring-directories
9 | ignores: [
10 | // Configuration
11 | '.coverage',
12 | '.vscode',
13 | '.idea',
14 | // Common directories
15 | '**/dist',
16 | '**/node_modules',
17 | // Demo-related directories
18 | 'demo/**/.next',
19 | ],
20 | },
21 | {
22 | languageOptions: {
23 | parser: tsParser,
24 | parserOptions: {
25 | project: ['./tsconfig.eslint.json'],
26 | },
27 | },
28 | files: ['{src,tests}/**/*.{js,ts,yml,yaml}'],
29 | plugins: {
30 | '@typescript-eslint': pluginTypescriptEslint,
31 | import: pluginEslintImport,
32 | },
33 | rules: {
34 | // Use the TypeScript port of 'no-unused-vars' to prevent false positives on abstract methods parameters
35 | // while keeping consistency with TS native behavior of ignoring parameters starting with '_'.
36 | // https://typescript-eslint.io/rules/no-unused-vars/
37 | 'no-unused-vars': 'off',
38 | '@typescript-eslint/no-unused-vars': [
39 | 'error',
40 | {
41 | args: 'all',
42 | argsIgnorePattern: '^_',
43 | caughtErrors: 'all',
44 | caughtErrorsIgnorePattern: '^_',
45 | destructuredArrayIgnorePattern: '^_',
46 | varsIgnorePattern: '^_',
47 | ignoreRestSiblings: true,
48 | },
49 | ],
50 |
51 | // eslint-plugin-import
52 | 'import/no-default-export': 'error',
53 | 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
54 | 'import/first': ['error'],
55 | 'import/exports-last': ['error'],
56 | 'import/order': [
57 | 'error',
58 | {
59 | groups: [
60 | 'builtin',
61 | 'external',
62 | 'internal',
63 | 'parent',
64 | 'sibling',
65 | 'index',
66 | 'object',
67 | 'type',
68 | ],
69 | 'newlines-between': 'always',
70 | alphabetize: { order: 'asc', caseInsensitive: true },
71 | },
72 | ],
73 | },
74 | },
75 | ];
76 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/
2 | module.exports = {
3 | rootDir: '.',
4 | testEnvironment: 'node',
5 | testMatch: ['/tests/unit/**/*.test.ts'],
6 | transform: {
7 | '^.+.tsx?$': ['ts-jest', {}],
8 | },
9 | coverageDirectory: '/.coverage/',
10 | coveragePathIgnorePatterns: ['/node_modules/', '/tests/'],
11 | coverageThreshold: {
12 | global: {
13 | branches: 95,
14 | functions: 95,
15 | lines: 95,
16 | statements: 95,
17 | },
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@strapi/client",
3 | "version": "1.4.0",
4 | "description": "The official client library to easily interface with Strapi from your JavaScript/TypeScript project",
5 | "keywords": [
6 | "strapi",
7 | "sdk",
8 | "library",
9 | "client",
10 | "js",
11 | "ts"
12 | ],
13 | "repository": {
14 | "type": "git",
15 | "url": "https://github.com/strapi/client.git"
16 | },
17 | "license": "SEE LICENSE IN LICENSE",
18 | "author": {
19 | "name": "Strapi Solutions SAS",
20 | "email": "hi@strapi.io",
21 | "url": "https://strapi.io"
22 | },
23 | "maintainers": [
24 | {
25 | "name": "Strapi Solutions SAS",
26 | "email": "hi@strapi.io",
27 | "url": "https://strapi.io"
28 | }
29 | ],
30 | "browser": "./dist/bundle.browser.iife.min.js",
31 | "exports": {
32 | ".": {
33 | "types": "./dist/exports.d.ts",
34 | "browser": {
35 | "import": "./dist/bundle.browser.mjs",
36 | "require": "./dist/bundle.browser.cjs"
37 | },
38 | "node": {
39 | "require": "./dist/bundle.cjs",
40 | "import": "./dist/bundle.mjs"
41 | }
42 | },
43 | "./package.json": "./package.json"
44 | },
45 | "files": [
46 | "./package.json",
47 | "dist/"
48 | ],
49 | "scripts": {
50 | "build": "rollup --config rollup.config.mjs --failAfterWarnings",
51 | "build:clean": "pnpm run clean && pnpm run build",
52 | "clean": "pnpm exec rimraf ./dist",
53 | "demo": "pnpm exec scripts/demo.sh",
54 | "lint": "eslint .",
55 | "lint:fix": "eslint . --fix",
56 | "lint:fix:dry": "eslint . --fix-dry-run",
57 | "prepare": "husky",
58 | "prettier:check": "prettier --check .",
59 | "prettier:write": "prettier --write .",
60 | "test": "jest --verbose",
61 | "test:cov": "jest --verbose --coverage",
62 | "ts:check": "tsc -p tsconfig.build.json --noEmit",
63 | "watch": "pnpm run build --watch",
64 | "prepack": "pnpm exec ./scripts/pre-pack.sh"
65 | },
66 | "devDependencies": {
67 | "@commitlint/cli": "19.6.1",
68 | "@commitlint/config-conventional": "19.6.0",
69 | "@commitlint/types": "19.5.0",
70 | "@rollup/plugin-commonjs": "28.0.2",
71 | "@rollup/plugin-node-resolve": "16.0.0",
72 | "@rollup/plugin-replace": "6.0.2",
73 | "@rollup/plugin-terser": "0.4.4",
74 | "@rollup/plugin-typescript": "12.1.2",
75 | "@strapi/eslint-config": "0.2.1",
76 | "@types/debug": "4.1.12",
77 | "@types/jest": "29.5.14",
78 | "@types/qs": "6.9.18",
79 | "@typescript-eslint/eslint-plugin": "8.21.0",
80 | "@typescript-eslint/parser": "8.21.0",
81 | "eslint": "8.57.1",
82 | "eslint-plugin-import": "2.31.0",
83 | "husky": "9.1.7",
84 | "jest": "29.7.0",
85 | "lint-staged": "15.4.1",
86 | "prettier": "3.4.2",
87 | "rimraf": "6.0.1",
88 | "rollup": "4.31.0",
89 | "ts-jest": "29.2.5",
90 | "typescript": "5.7.3"
91 | },
92 | "packageManager": "pnpm@9.1.0",
93 | "engines": {
94 | "node": ">=20.18.2 <=22.x.x"
95 | },
96 | "dependencies": {
97 | "debug": "4.4.0",
98 | "qs": "6.14.0"
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/scripts/pre-pack.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Run the Prettier check
4 | pnpm prettier:check &&
5 |
6 | # Run linting
7 | pnpm lint &&
8 |
9 | # Run TypeScript type check
10 | pnpm ts:check &&
11 |
12 | # Run tests with coverage
13 | pnpm test:cov &&
14 |
15 | # Run the production build
16 | NODE_ENV=production pnpm build:clean
17 |
--------------------------------------------------------------------------------
/src/auth/factory/factory.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 |
3 | import { StrapiError } from '../../errors';
4 |
5 | import type { AuthProviderCreator, AuthProviderMap, CreateAuthProviderParams } from './types';
6 | import type { AuthProvider } from '../providers';
7 |
8 | const debug = createDebug('strapi:auth:factory');
9 |
10 | /**
11 | * A factory class responsible for creating and managing authentication providers.
12 | *
13 | * It facilitates the registration and creation of different authentication
14 | * strategies which implement the AuthProvider interface.
15 | *
16 | * @template T_Providers Defines a map for authentication strategy names to their corresponding creator functions.
17 | */
18 | export class AuthProviderFactory {
19 | private readonly _registry = new Map, AuthProviderCreator>();
20 |
21 | /**
22 | * Creates an instance of an authentication provider based on the specified strategy.
23 | *
24 | * @param authStrategy The authentication strategy name to be used for creating the provider.
25 | * @param options Configuration options required to initialize the authentication provider.
26 | *
27 | * @returns An instance of an AuthProvider initialized with the given options.
28 | *
29 | * @throws {StrapiError} Throws an error if the specified strategy is not registered in the factory.
30 | *
31 | * @example
32 | * ```typescript
33 | * const factory = new AuthProviderFactory();
34 | *
35 | * factory.register(
36 | * 'api-token',
37 | * (options: ApiTokenAuthProviderOptions) => new ApiTokenAuthProvider(options)
38 | * );
39 | *
40 | * const provider = factory.create('api-token', { jwt: 'token' });
41 | * ```
42 | */
43 | create | string>(
44 | authStrategy: T_Strategy,
45 | options: CreateAuthProviderParams
46 | ): AuthProvider {
47 | const creator = this._registry.get(authStrategy);
48 |
49 | if (!creator) {
50 | debug('the %o auth strategy is not registered, skipping', authStrategy);
51 | throw new StrapiError(`Auth strategy "${authStrategy}" is not supported.`);
52 | }
53 |
54 | const instance = creator(options);
55 |
56 | debug('successfully instantiated a new %o provider', authStrategy);
57 |
58 | return instance;
59 | }
60 |
61 | /**
62 | * Registers a new authentication strategy with the factory.
63 | *
64 | * @param strategy The name of the authentication strategy to register.
65 | * @param creator A function that creates an instance of an authentication provider for the specified strategy.
66 | *
67 | * @returns The instance of AuthProviderFactory, for chaining purpose.
68 | *
69 | * @example
70 | * ```typescript
71 | * const factory = new AuthProviderFactory();
72 | *
73 | * factory
74 | * .register(
75 | * 'api-token',
76 | * (options: ApiTokenAuthProviderOptions) => new ApiTokenAuthProvider(options)
77 | * )
78 | * .register(
79 | * 'users-permissions',
80 | * (options: UsersPermissionsAuthProviderOptions) => new UsersPermissionsAuthProvider(options)
81 | * );
82 | * ```
83 | */
84 | register(
85 | strategy: T_Strategy,
86 | creator: T_Creator
87 | ) {
88 | this._registry.set(strategy, creator);
89 |
90 | debug('registered a new auth strategy: %o', strategy);
91 |
92 | return this as AuthProviderFactory;
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/auth/factory/index.ts:
--------------------------------------------------------------------------------
1 | export * from './factory';
2 |
--------------------------------------------------------------------------------
/src/auth/factory/types.ts:
--------------------------------------------------------------------------------
1 | import type { AuthProvider } from '../providers';
2 |
3 | export type AuthProviderCreator = (options: T) => AuthProvider;
4 |
5 | export type AuthProviderMap = { [key: string]: AuthProviderCreator };
6 |
7 | export type CreateAuthProviderParams<
8 | T_Providers extends AuthProviderMap,
9 | T_Strategy extends StringKeysOf,
10 | > = T_Providers[T_Strategy] extends AuthProviderCreator ? T_Options : unknown;
11 |
--------------------------------------------------------------------------------
/src/auth/index.ts:
--------------------------------------------------------------------------------
1 | export * from './providers';
2 | export * from './factory';
3 |
4 | export * from './manager';
5 |
--------------------------------------------------------------------------------
/src/auth/providers/abstract.ts:
--------------------------------------------------------------------------------
1 | import { HttpClient } from '../../http';
2 |
3 | import type { AuthProvider } from './types';
4 |
5 | /**
6 | * An abstract class that provides a foundational structure for implementing different authentication providers.
7 | *
8 | * It is designed to be extended by specific authentication strategies such as
9 | * API token or users-permissions based authentication.
10 | *
11 | * This class implements the {@link AuthProvider} interface, ensuring consistency across
12 | * authentication strategies in handling authentication processes and headers.
13 | *
14 | * @template T - The type of options that the specific authentication provider requires.
15 | *
16 | * @example
17 | * // Example of extending the AbstractAuthProvider
18 | * class MyAuthProvider extends AbstractAuthProvider {
19 | * constructor(options: MyOptions) {
20 | * super(options);
21 | * }
22 | *
23 | * authenticate(): Promise {
24 | * // Implementation for authentication
25 | * }
26 | *
27 | * get headers() {
28 | * return {
29 | * Authorization: `Bearer ${this._options.token}`,
30 | * };
31 | * }
32 | * }
33 | *
34 | * @abstract
35 | */
36 | export abstract class AbstractAuthProvider implements AuthProvider {
37 | protected readonly _options: T;
38 |
39 | protected constructor(options: T) {
40 | this._options = options;
41 |
42 | // Validation
43 | this.preflightValidation();
44 | }
45 |
46 | /**
47 | * Conducts necessary preflight validation checks for the authentication provider.
48 | *
49 | * This method validates the options passed during the instantiation of the provider.
50 | *
51 | * It is called within the constructor to ensure that all required options adhere
52 | * to the expected format or values before proceeding with operational methods.
53 | *
54 | * @throws {StrapiValidationError} If the validation fails due to invalid or missing options.
55 | */
56 | protected abstract preflightValidation(): void;
57 |
58 | public abstract get name(): string;
59 |
60 | public abstract get headers(): Record;
61 |
62 | public abstract authenticate(httpClient: HttpClient): Promise;
63 | }
64 |
--------------------------------------------------------------------------------
/src/auth/providers/api-token.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 |
3 | import { StrapiValidationError } from '../../errors';
4 |
5 | import { AbstractAuthProvider } from './abstract';
6 |
7 | const debug = createDebug('strapi:auth:provider:api-token');
8 |
9 | const API_TOKEN_AUTH_STRATEGY_IDENTIFIER = 'api-token';
10 |
11 | /**
12 | * Configuration options for API token authentication.
13 | */
14 | export interface ApiTokenAuthProviderOptions {
15 | /**
16 | * This is the Strapi API token used for authenticating requests.
17 | *
18 | * It should be a non-empty string
19 | */
20 | token: string;
21 | }
22 |
23 | export class ApiTokenAuthProvider extends AbstractAuthProvider {
24 | public static readonly identifier = API_TOKEN_AUTH_STRATEGY_IDENTIFIER;
25 |
26 | constructor(options: ApiTokenAuthProviderOptions) {
27 | super(options);
28 | }
29 |
30 | public get name() {
31 | return ApiTokenAuthProvider.identifier;
32 | }
33 |
34 | private get _token(): string {
35 | return this._options.token;
36 | }
37 |
38 | preflightValidation(): void {
39 | debug('validating provider configuration');
40 |
41 | if ((typeof this._token as unknown) !== 'string' || this._token.trim().length === 0) {
42 | debug('invalid api token provided: %o (%o)', this._token, typeof this._token);
43 |
44 | throw new StrapiValidationError(
45 | `A valid API token is required when using the api-token auth strategy. Got "${this._token}"`
46 | );
47 | }
48 |
49 | debug('provider configuration validated successfully');
50 | }
51 |
52 | authenticate(): Promise {
53 | debug('no authentication step is required for the %o auth strategy, skipping', this.name);
54 | return Promise.resolve(); // does nothing
55 | }
56 |
57 | get headers() {
58 | return {
59 | Authorization: `Bearer ${this._token}`,
60 | };
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/auth/providers/index.ts:
--------------------------------------------------------------------------------
1 | export type * from './types';
2 |
3 | export * from './abstract';
4 | export * from './api-token';
5 | export * from './users-permissions';
6 |
--------------------------------------------------------------------------------
/src/auth/providers/types.ts:
--------------------------------------------------------------------------------
1 | import type { HttpClient } from '../../http';
2 |
3 | /**
4 | * Provides an interface for implementing various authentication providers, allowing integration
5 | * of different authentication schemes in a consistent manner.
6 | *
7 | * It enables setting custom headers required by authentication schemes.
8 | *
9 | * @example
10 | * const authProvider = new MyAuthProvider('my-secret-token');
11 | *
12 | * authProvider.authenticate();
13 | *
14 | * console.log(authProvider.headers); // Retrieves auth headers to attach them to requests
15 | */
16 | export interface AuthProvider {
17 | /**
18 | * The identifying name of the authentication provider.
19 | *
20 | * This can be used for differentiating between multiple auth strategies.
21 | */
22 | get name(): string;
23 |
24 | /**
25 | * Object containing the headers that should be included in requests authenticated by this provider.
26 | *
27 | * Typically, these headers include tokens or keys required by the auth scheme.
28 | */
29 | get headers(): Record;
30 |
31 | /**
32 | * Authenticates by executing any required authentication steps such as
33 | * fetching tokens or setting the necessary state for future requests.
34 | *
35 | * @param httpClient - The {@link HttpClient} instance used to perform the necessary HTTP requests to authenticate
36 | *
37 | * @returns A promise that resolves when the authentication process has completed.
38 | *
39 | * @throws {Error} If an error occurs that prevents successful authentication.
40 | */
41 | authenticate(httpClient: HttpClient): Promise;
42 | }
43 |
--------------------------------------------------------------------------------
/src/auth/providers/users-permissions.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 |
3 | import { StrapiValidationError } from '../../errors';
4 | import { HttpClient } from '../../http';
5 |
6 | import { AbstractAuthProvider } from './abstract';
7 |
8 | const debug = createDebug('strapi:auth:provider:users-permissions');
9 |
10 | const USERS_PERMISSIONS_AUTH_STRATEGY_IDENTIFIER = 'users-permissions';
11 | const LOCAL_AUTH_ENDPOINT = '/auth/local';
12 |
13 | /**
14 | * Configuration options for Users & Permissions authentication.
15 | */
16 | export interface UsersPermissionsAuthProviderOptions {
17 | /**
18 | * The unique user identifier used for authentication.
19 | */
20 | identifier: string;
21 |
22 | /**
23 | * The secret passphrase associated with the user's identifier
24 | */
25 | password: string;
26 | }
27 |
28 | /**
29 | * Payload required for the Users & Permissions authentication process.
30 | */
31 | export type UsersPermissionsAuthPayload = Pick<
32 | UsersPermissionsAuthProviderOptions,
33 | 'identifier' | 'password'
34 | >;
35 |
36 | /**
37 | * @experimental
38 | * Authentication through users and permissions is experimental for the MVP of
39 | * the Strapi client.
40 | */
41 | export class UsersPermissionsAuthProvider extends AbstractAuthProvider {
42 | public static readonly identifier = USERS_PERMISSIONS_AUTH_STRATEGY_IDENTIFIER;
43 |
44 | private _token: string | null = null;
45 |
46 | constructor(options: UsersPermissionsAuthProviderOptions) {
47 | super(options);
48 | }
49 |
50 | public get name() {
51 | return UsersPermissionsAuthProvider.identifier;
52 | }
53 |
54 | private get _credentials(): UsersPermissionsAuthPayload {
55 | return {
56 | identifier: this._options.identifier,
57 | password: this._options.password,
58 | };
59 | }
60 |
61 | preflightValidation() {
62 | debug('validating provider configuration');
63 |
64 | if (
65 | this._options === undefined ||
66 | this._options === null ||
67 | typeof this._options !== 'object'
68 | ) {
69 | debug('invalid options provided: %s (%s)', this._options, typeof this._options);
70 |
71 | throw new StrapiValidationError(
72 | 'Missing valid options for initializing the Users & Permissions auth provider.'
73 | );
74 | }
75 |
76 | const { identifier, password } = this._options;
77 |
78 | if ((typeof identifier as unknown) !== 'string') {
79 | debug('invalid identifier provided: %s (%s)', identifier, typeof identifier);
80 |
81 | throw new StrapiValidationError(
82 | `The "identifier" option must be a string, but got "${typeof identifier}"`
83 | );
84 | }
85 |
86 | if ((typeof password as unknown) !== 'string') {
87 | debug('invalid password provided: %s (%s)', password, typeof password);
88 |
89 | throw new StrapiValidationError(
90 | `The "password" option must be a string, but got "${typeof password}"`
91 | );
92 | }
93 |
94 | debug('provider configuration validated successfully');
95 | }
96 |
97 | get headers(): Record {
98 | if (this._token === null) {
99 | return {};
100 | }
101 |
102 | return { Authorization: `Bearer ${this._token}` };
103 | }
104 |
105 | async authenticate(httpClient: HttpClient): Promise {
106 | const { identifier, password } = this._credentials;
107 |
108 | debug(
109 | 'trying to authenticate with %o as %o at %o ',
110 | this.name,
111 | identifier,
112 | LOCAL_AUTH_ENDPOINT
113 | );
114 |
115 | const response = await httpClient.post(
116 | LOCAL_AUTH_ENDPOINT,
117 | JSON.stringify({ identifier, password }),
118 | {
119 | headers: { 'Content-Type': 'application/json' },
120 | }
121 | );
122 |
123 | if (!response.ok) {
124 | throw new Error(response.statusText);
125 | }
126 |
127 | const data = await response.json();
128 |
129 | const obfuscatedToken = data.jwt.slice(0, 5) + '...' + data.jwt.slice(-5);
130 | debug('authentication successful for %o (%o)', identifier, obfuscatedToken);
131 |
132 | this._token = data.jwt;
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/src/content-types/abstract.ts:
--------------------------------------------------------------------------------
1 | import { HttpClient } from '../http';
2 |
3 | /**
4 | * Options to configure a content-type manager.
5 | */
6 | export interface ContentTypeManagerOptions {
7 | /**
8 | * The name of the resource this manager handles.
9 | */
10 | resource: string;
11 |
12 | /**
13 | * Optional path override for the resource.
14 | *
15 | * If not provided, the resource name is used to construct the path.
16 | */
17 | path?: string;
18 | }
19 |
20 | /**
21 | * Abstract base class for managing content types.
22 | */
23 | export abstract class AbstractContentTypeManager {
24 | /**
25 | * Configuration options for the content-type manager.
26 | */
27 | protected readonly _options: ContentTypeManagerOptions;
28 |
29 | /**
30 | * HTTP client instance for communicating with the Strapi app.
31 | */
32 | protected readonly _httpClient: HttpClient;
33 |
34 | protected constructor(options: ContentTypeManagerOptions, httpClient: HttpClient) {
35 | this._options = options;
36 | this._httpClient = httpClient;
37 | }
38 |
39 | /**
40 | * Gets the resource name for this manager.
41 | */
42 | protected get _resource() {
43 | return this._options.resource;
44 | }
45 |
46 | /**
47 | * Gets the configured path for this manager.
48 | *
49 | * Returns `undefined` if no path is explicitly set in the options.
50 | */
51 | protected get _path() {
52 | return this._options.path;
53 | }
54 |
55 | /**
56 | * Gets the root path for the resource.
57 | *
58 | * If a custom path is configured, it returns that value.
59 | *
60 | * Otherwise, it defaults to `/`.
61 | */
62 | protected get _rootPath() {
63 | return this._path ?? `/${this._resource}`;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/content-types/collection/index.ts:
--------------------------------------------------------------------------------
1 | export { CollectionTypeManager } from './manager';
2 |
--------------------------------------------------------------------------------
/src/content-types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './single';
2 | export * from './collection';
3 |
--------------------------------------------------------------------------------
/src/content-types/single/index.ts:
--------------------------------------------------------------------------------
1 | export { SingleTypeManager } from './manager';
2 |
--------------------------------------------------------------------------------
/src/errors/http.ts:
--------------------------------------------------------------------------------
1 | export class HTTPError extends Error {
2 | public name = 'HTTPError';
3 | public response: Response;
4 | public request: Request;
5 |
6 | constructor(response: Response, request: Request) {
7 | const code: string = response.status?.toString() ?? '';
8 | const title = response.statusText ?? '';
9 | const status = `${code} ${title}`.trim();
10 | const reason = status ? `status code ${status}` : 'an unknown error';
11 |
12 | super(`Request failed with ${reason}: ${request.method} ${request.url}`);
13 |
14 | this.response = response;
15 | this.request = request;
16 | }
17 | }
18 |
19 | export class HTTPAuthorizationError extends HTTPError {
20 | public name = 'HTTPAuthorizationError';
21 | }
22 |
23 | export class HTTPNotFoundError extends HTTPError {
24 | public name = 'HTTPNotFoundError';
25 | }
26 |
27 | export class HTTPBadRequestError extends HTTPError {
28 | public name = 'HTTPBadRequestError';
29 | }
30 |
31 | export class HTTPInternalServerError extends HTTPError {
32 | public name = 'HTTPInternalServerError';
33 | }
34 |
35 | export class HTTPForbiddenError extends HTTPError {
36 | public name = 'HTTPForbiddenError';
37 | }
38 |
39 | export class HTTPTimeoutError extends HTTPError {
40 | public name = 'HTTPTimeoutError';
41 | }
42 |
--------------------------------------------------------------------------------
/src/errors/index.ts:
--------------------------------------------------------------------------------
1 | export * from './strapi';
2 | export * from './url';
3 | export * from './http';
4 |
--------------------------------------------------------------------------------
/src/errors/strapi.ts:
--------------------------------------------------------------------------------
1 | export class StrapiError extends Error {
2 | constructor(
3 | cause: unknown = undefined,
4 | message: string = 'An error occurred in the Strapi client. Please check the logs for more information.'
5 | ) {
6 | super(message);
7 |
8 | this.cause = cause;
9 | }
10 | }
11 |
12 | export class StrapiValidationError extends StrapiError {
13 | constructor(
14 | cause: unknown = undefined,
15 | message: string = 'Some of the provided values are not valid.'
16 | ) {
17 | super(cause, message);
18 | }
19 | }
20 |
21 | export class StrapiInitializationError extends StrapiError {
22 | constructor(
23 | cause: unknown = undefined,
24 | message: string = 'Could not initialize the Strapi Client'
25 | ) {
26 | super(cause, message);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/errors/url.ts:
--------------------------------------------------------------------------------
1 | export class URLValidationError extends Error {}
2 |
3 | export class URLParsingError extends URLValidationError {
4 | constructor(url: unknown) {
5 | super(`Could not parse invalid URL: "${url}"`);
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/exports.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file is used to export all the public symbols for the Strapi Client API.
3 | */
4 |
5 | import type { StrapiClient, StrapiClientConfig } from './client';
6 |
7 | // ############################
8 | // # Main Client API #
9 | // ############################
10 |
11 | export { strapi, type Config } from './index';
12 |
13 | // ############################
14 | // # Error Classes #
15 | // ############################
16 |
17 | export * from './errors';
18 |
19 | // ############################
20 | // # Public Utility Types #
21 | // ############################
22 |
23 | export type { StrapiClientConfig, StrapiClient } from './client';
24 | export type { CollectionTypeManager, SingleTypeManager } from './content-types';
25 | export type { FilesManager, FileQueryParams, FileResponse, FileListResponse } from './files';
26 | export type * as API from './types/content-api';
27 |
28 | // ############################
29 | // # Deprecated symbols #
30 | // # (backward compatibility) #
31 | // ############################
32 |
33 | /**
34 | * @deprecated This type will be removed in v2, consider using {@link StrapiClientConfig} as a replacement
35 | */
36 | export type StrapiConfig = StrapiClientConfig;
37 |
38 | /**
39 | * @deprecated This type will be removed in v2, consider using {@link StrapiClient} as a replacement
40 | */
41 | export type Strapi = StrapiClient;
42 |
--------------------------------------------------------------------------------
/src/files/constants.ts:
--------------------------------------------------------------------------------
1 | export const FILE_API_PREFIX = '/upload';
2 |
--------------------------------------------------------------------------------
/src/files/errors.ts:
--------------------------------------------------------------------------------
1 | import { HTTPError, HTTPNotFoundError, HTTPForbiddenError } from '../errors';
2 |
3 | /**
4 | * Base error class for file-related errors.
5 | * Extends the generic HTTPError class to provide more context for file operations.
6 | */
7 | export class FileError extends HTTPError {
8 | public name = 'FileError';
9 | }
10 |
11 | /**
12 | * Error thrown when a file is not found.
13 | */
14 | export class FileNotFoundError extends HTTPNotFoundError {
15 | public name = 'FileNotFoundError';
16 | public fileId: number;
17 |
18 | constructor(fileId: number, originalError: HTTPNotFoundError) {
19 | super(originalError.response, originalError.request);
20 | this.fileId = fileId;
21 | this.message = `File with ID ${fileId} not found. The requested file may have been deleted or never existed.`;
22 | }
23 | }
24 |
25 | /**
26 | * Error thrown when a file operation encounters a permission issue.
27 | */
28 | export class FileForbiddenError extends HTTPForbiddenError {
29 | public name = 'FileForbiddenError';
30 | public fileId?: number;
31 |
32 | /**
33 | * Creates a new FileForbiddenError instance.
34 | *
35 | * @param originalError - The original HTTP forbidden error.
36 | * @param fileId - Optional file ID to include in the error message.
37 | */
38 | constructor(originalError: HTTPForbiddenError, fileId?: number) {
39 | super(originalError.response, originalError.request);
40 | this.fileId = fileId;
41 | this.message =
42 | fileId !== undefined
43 | ? `Access to file with ID ${fileId} is forbidden. You may not have sufficient permissions.`
44 | : `Access to files is forbidden. You may not have sufficient permissions.`;
45 | }
46 | }
47 |
48 | /**
49 | * Factory for creating error mappers for file operations.
50 | */
51 | export class FileErrorMapper {
52 | /**
53 | * Creates an error mapper function for a specific file ID.
54 | *
55 | * @param fileId - The file ID to include in error messages, optional.
56 | * @returns A function that maps HTTP errors to domain-specific file errors.
57 | */
58 | static createMapper(fileId?: number) {
59 | return (error: Error): Error => {
60 | if (error instanceof HTTPNotFoundError) {
61 | return fileId !== undefined ? new FileNotFoundError(fileId, error) : error;
62 | }
63 |
64 | if (error instanceof HTTPForbiddenError) {
65 | return new FileForbiddenError(error, fileId);
66 | }
67 |
68 | return error;
69 | };
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/files/index.ts:
--------------------------------------------------------------------------------
1 | export * from './errors';
2 | export * from './types';
3 | export * from './manager';
4 |
--------------------------------------------------------------------------------
/src/files/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Parameters for querying files in the Strapi upload plugin.
3 | *
4 | * Note: The upload plugin uses a different API structure than the content API
5 | * and doesn't support the standard pagination parameters.
6 | */
7 | export interface FileQueryParams {
8 | /**
9 | * Filters to apply to the query.
10 | * Example: { name: { $contains: 'example' } }
11 | */
12 | filters?: Record;
13 |
14 | /**
15 | * Sorting criteria for the query.
16 | * Example: ['name:asc'] or 'name:asc'
17 | */
18 | sort?: string | string[];
19 | }
20 |
21 | /**
22 | * Interface defining the fields that can be updated for a file.
23 | */
24 | export interface FileUpdateData {
25 | name?: string;
26 | alternativeText?: string;
27 | caption?: string;
28 | }
29 |
30 | /**
31 | * Response structure for a single file from the Strapi upload plugin.
32 | * This interface represents the actual response structure from the API.
33 | */
34 | export interface FileResponse {
35 | id: number;
36 | documentId: string;
37 | name: string;
38 | alternativeText?: string;
39 | caption?: string;
40 | width: number;
41 | height: number;
42 | hash: string;
43 | ext: string;
44 | mime: string;
45 | url: string;
46 | size: number;
47 | provider: string;
48 | previewUrl: string | null;
49 | provider_metadata?: any;
50 | createdAt: string;
51 | updatedAt: string;
52 | formats?: any;
53 | folderPath?: string;
54 | }
55 |
56 | /**
57 | * Response structure for a list of files.
58 | *
59 | * Note: Unlike the core content API, the upload plugin returns a flat array of files
60 | * without the data/meta structure used in the core content API responses.
61 | */
62 | export type FileListResponse = Array;
63 |
--------------------------------------------------------------------------------
/src/formatters/index.ts:
--------------------------------------------------------------------------------
1 | export * from './path';
2 |
--------------------------------------------------------------------------------
/src/formatters/path.ts:
--------------------------------------------------------------------------------
1 | const DEFAULT_CONFIG = {
2 | trailingSlashes: false,
3 | leadingSlashes: false,
4 | } satisfies FormatterConfig;
5 |
6 | type SlashConfig = 'single' | true | false;
7 |
8 | export interface FormatterConfig {
9 | trailingSlashes?: SlashConfig;
10 | leadingSlashes?: SlashConfig;
11 | }
12 |
13 | export class PathFormatter {
14 | public static format(path: string, config: FormatterConfig = DEFAULT_CONFIG): string {
15 | // Trailing Slashes
16 | path = PathFormatter.formatTrailingSlashes(path, config.trailingSlashes);
17 |
18 | // Leading Slashes
19 | path = PathFormatter.formatLeadingSlashes(path, config.leadingSlashes);
20 |
21 | return path;
22 | }
23 |
24 | public static formatTrailingSlashes(
25 | path: string,
26 | config: SlashConfig = DEFAULT_CONFIG.trailingSlashes
27 | ): string {
28 | // Single means making sure there is exactly one trailing slash
29 | if (config === 'single') {
30 | return PathFormatter.ensureSingleTrailingSlash(path);
31 | }
32 |
33 | // False means removing all trailing slashes
34 | else if (!config) {
35 | return PathFormatter.removeTrailingSlashes(path);
36 | }
37 |
38 | // False or anything else
39 | else {
40 | return path;
41 | }
42 | }
43 |
44 | public static removeTrailingSlashes(path: string) {
45 | return path.replace(/\/+$/, '');
46 | }
47 |
48 | public static ensureSingleTrailingSlash(path: string) {
49 | return `${this.removeTrailingSlashes(path)}/`;
50 | }
51 |
52 | public static formatLeadingSlashes(
53 | path: string,
54 | config: SlashConfig = DEFAULT_CONFIG.leadingSlashes
55 | ): string {
56 | // Single means making sure there is exactly one leading slash
57 | if (config === 'single') {
58 | return PathFormatter.ensureSingleLeadingSlash(path);
59 | }
60 |
61 | // False means removing all leading slashes
62 | else if (!config) {
63 | return PathFormatter.removeLeadingSlashes(path);
64 | }
65 |
66 | // False or anything else
67 | else {
68 | return path;
69 | }
70 | }
71 |
72 | public static removeLeadingSlashes(path: string) {
73 | return path.replace(/^\/+/, '');
74 | }
75 |
76 | public static ensureSingleLeadingSlash(path: string) {
77 | return `/${this.removeLeadingSlashes(path)}`;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Extracts `string` keys from the properties of a given {@link T}.
3 | *
4 | * This utility type is beneficial when dealing with mapped types or interfaces where it is necessary to
5 | * operate only on properties whose keys are specifically of the `string` type.
6 | *
7 | * @template T The target object type from which string keys are extracted.
8 | *
9 | * @example
10 | * // Define an interface with different types of keys.
11 | interface Example {
12 | name: string;
13 | age: number;
14 | [key: symbol]: boolean;
15 | }
16 |
17 | // Use `StringKeysOf` to extract only the string keys.
18 | type StringKeys = StringKeysOf; // Results in "name" | "age"
19 | *
20 | * @remark This type is particularly useful in scenarios involving conditional types and mapped type utilities.
21 | */
22 | type StringKeysOf = keyof T & string;
23 |
--------------------------------------------------------------------------------
/src/http/constants.ts:
--------------------------------------------------------------------------------
1 | export enum StatusCode {
2 | /**
3 | * Standard response for successful HTTP requests.
4 | *
5 | * The actual response depends on the request method used.
6 | *
7 | * - In a GET request, the response contains an entity corresponding to the requested resource.
8 | * - In a POST request, the response contains an entity describing or containing the result of the action
9 | */
10 | OK = 200,
11 |
12 | /**
13 | * The request has been fulfilled, resulting in the creation of a new resource
14 | */
15 | CREATED = 201,
16 |
17 | /**
18 | * The server successfully processed the request and is not returning any content
19 | */
20 | NO_CONTENT = 204,
21 |
22 | /**
23 | * The server can't or won't process the request due to a client error.
24 | *
25 | * Possible reasons are:
26 | * - malformed request syntax
27 | * - size too large
28 | * - invalid request message framing
29 | * - deceptive request routing
30 | */
31 | BAD_REQUEST = 400,
32 |
33 | /**
34 | * Similar to 403 Forbidden, but specifically for use when authentication
35 | * is required and has failed or has not yet been provided.
36 | *
37 | * The '401' status semantically means "unauthenticated", the user doesn't
38 | * have valid authentication credentials for the target resource.
39 | */
40 | UNAUTHORIZED = 401,
41 |
42 | /**
43 | * The request contained valid data and was understood by the server, but the server is refusing action.
44 | *
45 | * This may be due to the user not having the necessary permissions for a resource or needing an account of some sort,
46 | * or attempting a prohibited action
47 | *
48 | * The request shouldn't be repeated.
49 | */
50 | FORBIDDEN = 403,
51 |
52 | /**
53 | * The requested resource couldn't be found but may be available in the future.
54 | *
55 | * Subsequent requests by the client are permissible.
56 | */
57 | NOT_FOUND = 404,
58 |
59 | /**
60 | * The server timed out waiting for the request.
61 | *
62 | * According to HTTP specifications:
63 | *
64 | * "The client didn't produce a request within the time that the server was prepared to wait.
65 | * The client MAY repeat the request without modifications at any later time."
66 | */
67 | TIMEOUT = 408,
68 |
69 | /**
70 | * A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.
71 | */
72 | INTERNAL_SERVER_ERROR = 500,
73 | }
74 |
75 | /**
76 | * Default timeout value in milliseconds for HTTP requests.
77 | *
78 | * It is set to 10.000 ms (10 seconds) and can be used as a baseline for setting request timeouts.
79 | */
80 | export const DEFAULT_HTTP_TIMEOUT_MS = 10000;
81 |
--------------------------------------------------------------------------------
/src/http/index.ts:
--------------------------------------------------------------------------------
1 | export * from './client';
2 | export * from './constants';
3 | export type * from './types';
4 |
--------------------------------------------------------------------------------
/src/http/types.ts:
--------------------------------------------------------------------------------
1 | import { HttpInterceptorManager } from './interceptor-manager';
2 |
3 | import type { Interceptor } from './interceptor-manager';
4 |
5 | export interface HttpClientConfig {
6 | baseURL: string;
7 | timeout?: number;
8 | headers?: Record;
9 | }
10 |
11 | // Payloads
12 |
13 | export type RequestInterceptorPayload = { request: Request };
14 | export type ResponseInterceptorPayload = { response: Response; request: Request };
15 |
16 | // Interceptors
17 |
18 | export type RequestInterceptor = Interceptor;
19 | export type ResponseInterceptor = Interceptor;
20 |
21 | // Interceptor Managers
22 |
23 | export type RequestInterceptorManager = HttpInterceptorManager;
24 | export type ResponseInterceptorManager = HttpInterceptorManager;
25 |
26 | // MISC
27 |
28 | export interface InterceptorManagerMap {
29 | request: RequestInterceptorManager;
30 | response: ResponseInterceptorManager;
31 | }
32 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { ApiTokenAuthProvider } from './auth';
2 | import { StrapiClient } from './client';
3 |
4 | import type { StrapiClientConfig } from './client';
5 |
6 | export interface Config {
7 | /**
8 | * The base URL of the Strapi content API.
9 | *
10 | * This specifies where the client should send requests.
11 | *
12 | * The URL must include the protocol (`http` or `https`) and serve
13 | * as the root path for all later API operations.
14 | *
15 | * @example
16 | * 'https://api.example.com'
17 | *
18 | * @remarks
19 | * Failing to provide a valid HTTP or HTTPS URL results in a
20 | * `StrapiInitializationError`.
21 | */
22 | baseURL: string;
23 |
24 | /**
25 | * API token to authenticate requests (optional).
26 | *
27 | * When provided, this token is included in the `Authorization` header
28 | * of every request to the Strapi API.
29 | *
30 | * @remarks
31 | * - A valid token must be a non-empty string.
32 | *
33 | * - If the token is invalid or improperly formatted, the client
34 | * throws a `StrapiValidationError` during initialization.
35 | *
36 | * - If excluded, the client operates without authentication.
37 | */
38 | auth?: string;
39 |
40 | /**
41 | * Custom headers to include with every request (optional).
42 | *
43 | * These headers will be added to all requests made by the client,
44 | * but can be overridden by headers specific to individual requests.
45 | *
46 | * @example
47 | * {
48 | * 'X-Custom-Header': 'value',
49 | * 'Accept-Language': 'en-US'
50 | * }
51 | */
52 | headers?: Record;
53 | }
54 |
55 | /**
56 | * Creates a new instance of the Strapi Client with a specified configuration.
57 | *
58 | * The Strapi Client functions as a client library to interface with the Strapi content API.
59 | *
60 | * It facilitates reliable and secure interactions with Strapi's APIs by handling URL validation,
61 | * request dispatch, and response parsing for content management.
62 | *
63 | * @param config - The configuration for initializing the client. This should include the base URL
64 | * of the Strapi content API that the client communicates with. The baseURL
65 | * must be formatted with one of the supported protocols: `http` or `https`.
66 | * Additionally, optional authentication details can be specified within the config.
67 | *
68 | * @returns An instance of the Strapi Client configured with the specified baseURL and auth settings.
69 | *
70 | * @example
71 | * ```typescript
72 | * // Basic configuration using API token auth
73 | * const config = {
74 | * baseURL: 'https://api.example.com',
75 | * auth: 'your_token_here',
76 | * };
77 | *
78 | * // Create the client instance
79 | * const client = strapi(config);
80 | *
81 | * // Using the client to fetch content from a custom endpoint
82 | * const response = await client.fetch('/content-endpoint');
83 | * const data = await response.json();
84 | *
85 | * console.log(data);
86 | * ```
87 | *
88 | * @throws {StrapiInitializationError} If the provided baseURL doesn't conform to a valid HTTP or HTTPS URL,
89 | * or if the auth configuration is invalid.
90 | */
91 | export const strapi = (config: Config) => {
92 | const { baseURL, auth, headers } = config;
93 |
94 | const clientConfig: StrapiClientConfig = { baseURL, headers };
95 |
96 | // In this factory, while there is only one auth strategy available, users can't manually set the strategy options.
97 | // Since the client constructor needs to define a proper strategy,
98 | // it is handled here if the auth property is provided
99 | if (auth !== undefined) {
100 | clientConfig.auth = {
101 | strategy: ApiTokenAuthProvider.identifier,
102 | options: { token: auth },
103 | };
104 | }
105 |
106 | return new StrapiClient(clientConfig);
107 | };
108 |
--------------------------------------------------------------------------------
/src/interceptors/auth.ts:
--------------------------------------------------------------------------------
1 | import { AuthManager } from '../auth';
2 | import { HTTPAuthorizationError } from '../errors';
3 | import { HttpClient, StatusCode } from '../http';
4 |
5 | import type { RequestInterceptor, ResponseInterceptor } from '../http';
6 | import type { Interceptor } from '../http/interceptor-manager';
7 |
8 | /**
9 | * A utility class providing a set of HTTP interceptors for managing authentication lifecycle operations.
10 | *
11 | * It includes methods to:
12 | * - Ensure pre-authentication before making any HTTP requests.
13 | * - Automatically authenticate outgoing HTTP requests by injecting authentication headers.
14 | * - Handle unauthorized HTTP responses and notify the authentication manager.
15 | *
16 | * This class is primarily used in combination with an {@link AuthManager} and an {@link HttpClient} to enable seamless
17 | * integration of authentication capability within HTTP workflows.
18 | */
19 | export class AuthInterceptors {
20 | /**
21 | * Ensures the user is pre-authenticated before an HTTP request is sent.
22 | *
23 | * This interceptor checks if the authentication manager has a configured strategy but the
24 | * user is not yet authenticated. If so, it triggers the authentication process.
25 | *
26 | * @param authManager - The `AuthManager` instance that handles the authentication process.
27 | * @param httpClient - The `HttpClient` instance used during the authentication process.
28 | *
29 | * @returns A request interceptor that ensures pre-authentication.
30 | *
31 | * @example
32 | * ```typescript
33 | * httpClient.interceptors.request.use(
34 | * AuthInterceptors.ensurePreAuthentication(authManager, httpClient)
35 | * );
36 | * ```
37 | *
38 | * @throws - An error if the authentication process fails.
39 | *
40 | * @note The provided http client should **NOT** have auth interceptors attached to it as it might lead to infinite authentication loops
41 | */
42 | public static ensurePreAuthentication(
43 | authManager: AuthManager,
44 | httpClient: HttpClient
45 | ): RequestInterceptor {
46 | return async ({ request }) => {
47 | const { strategy, isAuthenticated } = authManager;
48 |
49 | if (strategy && !isAuthenticated) {
50 | await authManager.authenticate(httpClient);
51 | }
52 |
53 | return { request };
54 | };
55 | }
56 |
57 | /**
58 | * Authenticates outgoing HTTP requests by injecting authentication-specific headers.
59 | *
60 | * This interceptor updates HTTP requests with the necessary authentication information,
61 | * such as tokens, sourced from the current authentication provider.
62 | *
63 | * @param authManager - The `AuthManager` instance that manages request authentication.
64 | *
65 | * @returns A request interceptor that injects authentication headers into outgoing requests.
66 | *
67 | * @example
68 | * ```typescript
69 | * httpClient.interceptors.request.use(
70 | * AuthInterceptors.authenticateRequests(authManager)
71 | * );
72 | * ```
73 | *
74 | * @throws - An error if the headers in the HTTP request are invalid or unavailable.
75 | */
76 | public static authenticateRequests(authManager: AuthManager): RequestInterceptor {
77 | return ({ request }) => {
78 | authManager.authenticateRequest(request);
79 |
80 | return { request };
81 | };
82 | }
83 |
84 | /**
85 | * Notifies the authentication manager upon receiving an unauthorized HTTP response.
86 | *
87 | * This interceptor detects `401 Unauthorized` errors in HTTP responses, indicating
88 | * that the current authentication session has become invalid and resets the
89 | * authentication state.
90 | *
91 | * @param authManager - The `AuthManager` instance that manages the authentication state and errors.
92 | *
93 | * @returns A response interceptor that handles unauthorized responses.
94 | *
95 | * @example
96 | * ```typescript
97 | * httpClient.interceptors.response.use(
98 | * AuthInterceptors.notifyOnUnauthorizedResponse(authManager)
99 | * );
100 | * ```
101 | */
102 | public static notifyOnUnauthorizedResponse(
103 | authManager: AuthManager
104 | ): [fulfillment: ResponseInterceptor, rejection: Interceptor] {
105 | const notify = () => authManager.handleUnauthorizedError();
106 |
107 | // Intercepts successful unauthorized requests and notifies the auth manager
108 | const fulfillment: ResponseInterceptor = ({ request, response }) => {
109 | const isUnauthorized = !response.ok && response.status === StatusCode.UNAUTHORIZED;
110 |
111 | if (isUnauthorized) {
112 | notify();
113 | }
114 |
115 | return { request, response };
116 | };
117 |
118 | // Intercepts HTTPAuthorizationError errors and notifies the auth manager
119 | const rejection: Interceptor = (payload) => {
120 | if (payload instanceof HTTPAuthorizationError) {
121 | notify();
122 | }
123 |
124 | return payload;
125 | };
126 |
127 | return [fulfillment, rejection];
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/src/interceptors/http.ts:
--------------------------------------------------------------------------------
1 | import { HttpClient } from '../http';
2 |
3 | import type { RequestInterceptor, ResponseInterceptor } from '../http';
4 |
5 | /**
6 | * A utility class to manage HTTP interceptors for requests and responses.
7 | *
8 | * This class provides static factory methods to create and register interceptors
9 | * for HTTP clients, ensuring consistent capability like adding default headers
10 | * to requests and transforming HTTP response errors into standardized exceptions.
11 | *
12 | * It is primarily used in conjunction with the {@link HttpClient} class to handle
13 | * pre- and post-processing of HTTP requests and responses.
14 | */
15 | export class HttpInterceptors {
16 | /**
17 | * Automatically sets default headers for all HTTP requests.
18 | *
19 | * This interceptor is typically used to attach headers such as `Content-Type`, or other app-specific metadata.
20 | *
21 | * @returns A request interceptor that modifies the request to include the default headers.
22 | *
23 | * @example
24 | * ```typescript
25 | * httpClient.interceptors.request.use(HttpInterceptors.setDefaultHeaders());
26 | *```
27 | */
28 | public static setDefaultHeaders(): RequestInterceptor {
29 | const DEFAULT_HEADERS = new Map([['Content-Type', 'application/json']]);
30 |
31 | return ({ request }) => {
32 | for (const [key, value] of DEFAULT_HEADERS.entries()) {
33 | const hasHeader = request.headers.has(key);
34 |
35 | if (!hasHeader) {
36 | request.headers.set(key, value);
37 | }
38 | }
39 |
40 | return { request };
41 | };
42 | }
43 |
44 | /**
45 | * Handle HTTP response errors and transform them into
46 | * more specific and meaningful exceptions (subclasses of `HTTPError`)
47 | *
48 | * This interceptor looks at HTTP responses and checks whether it was successful.
49 | *
50 | * If the response indicates failure (non-OK status), it maps the response status to a
51 | * specific `HTTPError` subclass.
52 | *
53 | * @returns A response interceptor that transforms errors into custom exceptions.
54 | *
55 | * @example
56 | * ```typescript
57 | * // Register error transformation in an HTTP client
58 | * const httpClient = new HttpClient(config);
59 | * httpClient.interceptors.response.use(HttpInterceptors.transformErrors());
60 | *```
61 | *
62 | * @errors
63 | * Throws a specific subclass of `HTTPError` if the response is not successful.
64 | * - `HTTPBadRequestError` (400)
65 | * - `HTTPAuthorizationError` (401)
66 | * - `HTTPForbiddenError` (403)
67 | * - `HTTPNotFoundError` (404)
68 | * - `HTTPTimeoutError` (408)
69 | * - `HTTPInternalServerError` (500)
70 | * - 'HTTPError' (default)
71 | *
72 | * @see {@link HttpClient.mapResponseToHTTPError}
73 | */
74 | public static transformErrors(): ResponseInterceptor {
75 | return ({ request, response }) => {
76 | if (response.ok) {
77 | return { request, response };
78 | }
79 |
80 | throw HttpClient.mapResponseToHTTPError(response, request);
81 | };
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/interceptors/index.ts:
--------------------------------------------------------------------------------
1 | export * from './auth';
2 | export * from './http';
3 |
--------------------------------------------------------------------------------
/src/types/content-api.ts:
--------------------------------------------------------------------------------
1 | export interface Document {
2 | documentId: string;
3 | createdAt: string;
4 | updatedAt: string;
5 | [key: string]: any;
6 | }
7 |
8 | export interface Pagination {
9 | page: number;
10 | pageSize: number;
11 | pageCount: number;
12 | total: number;
13 | }
14 |
15 | export interface ResponseMeta {
16 | pagination?: Pagination;
17 | }
18 |
19 | export interface DocumentResponse {
20 | data: T;
21 | meta: ResponseMeta;
22 | }
23 |
24 | export interface DocumentResponseCollection {
25 | data: T[];
26 | meta: ResponseMeta;
27 | }
28 |
29 | /**
30 | * Defines the structure of query parameters supported for Strapi content API requests.
31 | *
32 | * It provides options for filtering, sorting, pagination, localization, and population of related fields.
33 | *
34 | * @example
35 | * Fetching articles with filters, pagination, and specific fields.
36 | * ```typescript
37 | * const queryParams: baseQueryParams = {
38 | * filters: { category: 'tech' }, // Filters articles by a 'tech' category
39 | * fields: ['title', 'author'], // Fetches only 'title' and 'author' fields
40 | * pagination: { page: 1, pageSize: 10 }, // Retrieves the first page with 10 items
41 | * sort: ['publishedAt:desc'], // Sorts by 'publishedAt' field in descending order
42 | * populate: { comments: true }, // Populates the 'comments' field with related data
43 | * locale: 'en', // Fetches content available in English
44 | * status: 'published' // Retrieves only published documents
45 | * };
46 | *
47 | * const articles = await collectionManager.find(queryParams);
48 | * ```
49 | *
50 | * @example
51 | * Appending query parameters to a URL
52 | * ```typescript
53 | * const url = 'https://api.example.com/articles';
54 | *
55 | * const params: BaseQueryParams = {
56 | * filters: { isFeatured: true }, // Filters featured articles
57 | * sort: 'title', // Sorts articles alphabetically by title
58 | * };
59 | *
60 | * const fullUrl = URLHelper.appendQueryParams(url, params);
61 | * // fullUrl: 'https://api.example.com/articles?filters[isFeatured]=true&sort=title'
62 | * ```
63 | *
64 | * @see {@link URLHelper.appendQueryParams} for how this type is serialized into query strings for API endpoints.
65 | */
66 | export interface BaseQueryParams {
67 | /**
68 | * Specifies related fields or relations to return.
69 | *
70 | * Can be:
71 | * - a `string` to specify relation paths as `populate=name`
72 | * - an `array` of strings to specify multiple relation paths
73 | * - an `object` to enable deeper population configurations
74 | */
75 | populate?: string | string[] | Record;
76 |
77 | /**
78 | * Specifies the fields of documents to include in the response.
79 | *
80 | * @note `documentId` and `id` fields are always present in a document, regardless of manually selected fields.
81 | */
82 | fields?: string[];
83 |
84 | /**
85 | * Applies filters to the records based on field values.
86 | */
87 | filters?: Record;
88 |
89 | /**
90 | * Specifies the locale of the content to fetch.
91 | *
92 | * Relevant only for content types with i18n localization enabled.
93 | *
94 | * @example
95 | * // Retrieving content in German:
96 | * locale: 'de'
97 | */
98 | locale?: string;
99 |
100 | /**
101 | * Specifies the publication status of the content to fetch.
102 | * - `'draft'`: fetches only draft records.
103 | * - `'published'`: fetches only published records.
104 | */
105 | status?: 'draft' | 'published';
106 |
107 | /**
108 | * Sort the results based on a specific order.
109 | *
110 | * Can be
111 | * - a single string like `'field:asc'`
112 | * - an array of strings for multiple sorting criteria.
113 | */
114 | sort?: string | string[];
115 |
116 | /**
117 | * Configures pagination options for requests.
118 | *
119 | * Supports two combinations:
120 | * - `page` and `pageSize`: specify the pagination page and its size.
121 | * - `start` and `limit`: specify the starting index and the number of records to fetch.
122 | *
123 | * If `withCount` is set to true, include the total count of matched records in the response.
124 | */
125 | pagination?: {
126 | page?: number;
127 | pageSize?: number;
128 | withCount?: boolean;
129 | start?: number;
130 | limit?: number;
131 | };
132 | }
133 |
--------------------------------------------------------------------------------
/src/utilities/index.ts:
--------------------------------------------------------------------------------
1 | export { URLHelper } from './url-helper';
2 | export { RequestHelper } from './request-helper';
3 |
--------------------------------------------------------------------------------
/src/utilities/request-helper.ts:
--------------------------------------------------------------------------------
1 | import { URLHelper } from './url-helper';
2 |
3 | export class RequestHelper {
4 | /**
5 | * Formats a Fetch request into a concise, human-readable string.
6 | *
7 | * Extracts the HTTP method and URL from the given `Request` object
8 | * and formats them into a readable format for logging or debugging purposes.
9 | *
10 | * @param input - The HTTP request to format.
11 | * This parameter must be a valid `Request` object that includes
12 | * the method and URL fields.
13 | *
14 | * @returns A formatted string representing the HTTP request in the form ` - `.
15 | * The URL included in the formatted output contains only the origin and path,
16 | * excluding any query parameters or fragments.
17 | *
18 | * @example
19 | * // Example usage of the `formatRequest` method:
20 | * const request = new Request('https://example.com/api/items?filter=active', { method: 'POST' });
21 | *
22 | * const formattedRequest = RequestHelper.format(request);
23 | * // Output: "POST - https://example.com/api/items"
24 | *
25 | * @see {@link URLHelper.toReadablePath}
26 | */
27 | public static format(input: Request): string {
28 | if (!(input instanceof Request)) {
29 | throw new TypeError(`Invalid input, expected a Request instance but found ${typeof input}`);
30 | }
31 |
32 | return `${input.method} - ${URLHelper.toReadablePath(input.url)}`;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/validators/client.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 |
3 | import { StrapiValidationError, URLValidationError } from '../errors';
4 |
5 | import { URLValidator } from './url';
6 |
7 | import type { StrapiClientConfig } from '../client';
8 |
9 | const debug = createDebug('strapi:validators:config');
10 |
11 | /**
12 | * Provides the ability to validate the configuration used for initializing the Strapi client.
13 | *
14 | * This includes URL validation to ensure compatibility with Strapi's API endpoints.
15 | */
16 | export class StrapiConfigValidator {
17 | private readonly _urlValidator: URLValidator;
18 |
19 | constructor(
20 | // Dependencies
21 | urlValidator: URLValidator = new URLValidator()
22 | ) {
23 | this._urlValidator = urlValidator;
24 | }
25 |
26 | /**
27 | * Validates the provided client configuration, ensuring that all values are
28 | * suitable for the client operations..
29 | *
30 | * @param config - The configuration object for the Strapi client. Must include a `baseURL` property indicating the API's endpoint.
31 | *
32 | * @throws {StrapiValidationError} If the configuration is invalid, or if the baseURL is invalid.
33 | */
34 | validateConfig(config: StrapiClientConfig) {
35 | debug('validating client config');
36 |
37 | if (
38 | config === undefined ||
39 | config === null ||
40 | Array.isArray(config) ||
41 | typeof config !== 'object'
42 | ) {
43 | debug(`provided client configuration is not a valid object: %o (%s)`, config, typeof config);
44 |
45 | throw new StrapiValidationError(
46 | new TypeError('The provided configuration is not a valid object.')
47 | );
48 | }
49 |
50 | this.validateBaseURL(config.baseURL);
51 | this.validateHeaders(config.headers);
52 |
53 | debug('validated client config successfully');
54 | }
55 |
56 | /**
57 | * Validates the base URL, ensuring it follows acceptable protocols and structure for reliable API interaction.
58 | *
59 | * @param url - The base URL string to validate.
60 | *
61 | * @throws {StrapiValidationError} If the URL is invalid or if it fails through the URLValidator checks.
62 | */
63 | private validateBaseURL(url: unknown) {
64 | try {
65 | debug('validating base url');
66 | this._urlValidator.validate(url);
67 | } catch (e) {
68 | if (e instanceof URLValidationError) {
69 | debug('failed to validate client config, invalid base url %o', url);
70 | throw new StrapiValidationError(e);
71 | }
72 |
73 | throw e;
74 | }
75 | }
76 |
77 | /**
78 | * Validates the headers object to ensure it's a plain object with string key-value pairs.
79 | *
80 | * @param headers - The headers object to validate.
81 | *
82 | * @throws {StrapiValidationError} If the headers are invalid.
83 | */
84 | private validateHeaders(headers: unknown) {
85 | debug('validating headers');
86 |
87 | if (headers === undefined) {
88 | return;
89 | }
90 |
91 | if (headers === null || typeof headers !== 'object' || Array.isArray(headers)) {
92 | debug(`invalid headers type: %o (%s)`, headers, typeof headers);
93 | throw new StrapiValidationError(new TypeError('Headers must be a valid object.'));
94 | }
95 |
96 | for (const [key, value] of Object.entries(headers)) {
97 | if (typeof value !== 'string') {
98 | debug(`invalid header value for key %s: %o (%s)`, key, value, typeof value);
99 | throw new StrapiValidationError(new TypeError('Header values must be strings.'));
100 | }
101 | }
102 |
103 | debug('headers validated successfully');
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src/validators/index.ts:
--------------------------------------------------------------------------------
1 | export { StrapiConfigValidator } from './client';
2 | export { URLValidator } from './url';
3 |
--------------------------------------------------------------------------------
/src/validators/url.ts:
--------------------------------------------------------------------------------
1 | import createDebug from 'debug';
2 |
3 | import { URLParsingError } from '../errors';
4 |
5 | const debug = createDebug('strapi:validators:url');
6 |
7 | /**
8 | * Class representing a URLValidator.
9 | *
10 | * It provides the ability to validate URLs based on a predefined list of allowed protocols.
11 | */
12 | export class URLValidator {
13 | /**
14 | * Validates the provided URL.
15 | *
16 | * This method checks that the provided URL is a string and can be parsed.
17 | *
18 | * @param url - The URL to be validated. This parameter must be a valid string representation of a URL.
19 | *
20 | * @throws {URLParsingError} Thrown if the URL is not a string or can't be parsed.
21 | *
22 | * @example
23 | * // Example of validating a URL successfully
24 | * const validator = new URLValidator();
25 | *
26 | * const url = 'http://example.com';
27 | *
28 | * validator.validate(url); // Does not throw an error
29 | *
30 | * @example
31 | * // Example of a failing validation
32 | * const validator = new URLValidator();
33 | *
34 | * const url = 123;
35 | *
36 | * try {
37 | * validator.validate(url);
38 | * } catch (error) {
39 | * console.error(error); // URLParsingError
40 | * }
41 | */
42 | validate(url: unknown) {
43 | if (typeof url !== 'string') {
44 | debug('url must be a string, received: %o (%s)', url, typeof url);
45 | throw new URLParsingError(url);
46 | }
47 |
48 | const canParse = this.canParse(url);
49 |
50 | if (!canParse) {
51 | debug('url could not be parsed: %o (%s)', url, typeof url);
52 | throw new URLParsingError(url);
53 | }
54 |
55 | debug('validated url successfully: %o', url);
56 | }
57 |
58 | /**
59 | * Checks if the URL string can be parsed.
60 | *
61 | * @param url - The URL string to be checked.
62 | *
63 | * @returns A boolean indicating whether the URL can be parsed.
64 | */
65 | private canParse(url: string): boolean {
66 | return URL.canParse(url);
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/tests/fixtures/files.ts:
--------------------------------------------------------------------------------
1 | import { FileResponse } from '../../src/files/types';
2 |
3 | // Mock file data for testing
4 | export const mockFile: FileResponse = {
5 | id: 1,
6 | documentId: 'doc123',
7 | name: 'test-file.jpg',
8 | alternativeText: 'Test image',
9 | caption: 'A test image caption',
10 | width: 800,
11 | height: 600,
12 | hash: 'hash_12345',
13 | ext: '.jpg',
14 | mime: 'image/jpeg',
15 | url: 'https://example.com/uploads/test-file.jpg',
16 | size: 12345,
17 | provider: 'local',
18 | previewUrl: null,
19 | createdAt: '2023-01-01T00:00:00.000Z',
20 | updatedAt: '2023-01-01T00:00:00.000Z',
21 | };
22 |
23 | // Mock file list for testing
24 | export const mockFiles = [
25 | mockFile,
26 | {
27 | ...mockFile,
28 | id: 2,
29 | name: 'another-file.pdf',
30 | ext: '.pdf',
31 | mime: 'application/pdf',
32 | url: 'https://example.com/uploads/another-file.pdf',
33 | width: 0,
34 | height: 0,
35 | },
36 | ];
37 |
--------------------------------------------------------------------------------
/tests/fixtures/http-error-associations.ts:
--------------------------------------------------------------------------------
1 | import {
2 | HTTPAuthorizationError,
3 | HTTPBadRequestError,
4 | HTTPError,
5 | HTTPForbiddenError,
6 | HTTPInternalServerError,
7 | HTTPNotFoundError,
8 | HTTPTimeoutError,
9 | } from '../../src/errors';
10 | import { StatusCode } from '../../src/http';
11 |
12 | export const HTTP_ERROR_ASSOCIATIONS = [
13 | [{ status: StatusCode.BAD_REQUEST, statusText: 'Bad Request' }, HTTPBadRequestError],
14 | [{ status: StatusCode.UNAUTHORIZED, statusText: 'Unauthorized' }, HTTPAuthorizationError],
15 | [{ status: StatusCode.FORBIDDEN, statusText: 'Forbidden' }, HTTPForbiddenError],
16 | [{ status: StatusCode.NOT_FOUND, statusText: 'Not Found' }, HTTPNotFoundError],
17 | [{ status: StatusCode.TIMEOUT, statusText: 'Timeout' }, HTTPTimeoutError],
18 | [
19 | { status: StatusCode.INTERNAL_SERVER_ERROR, statusText: 'Internal Server Error' },
20 | HTTPInternalServerError,
21 | ],
22 | [{ status: 599, statusText: 'Unknown Error' }, HTTPError],
23 | ] as const;
24 |
--------------------------------------------------------------------------------
/tests/fixtures/invalid-urls.json:
--------------------------------------------------------------------------------
1 | {
2 | "impossibleToParse": [
3 | ["", "empty string"],
4 | ["foobar", "regular string"],
5 | ["example.com", "missing protocol"],
6 | [123, "number"],
7 | [null, "null"],
8 | [true, "boolean"],
9 | [{}, "empty object"],
10 | [[], "empty array"]
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/tests/unit/auth/factory.test.ts:
--------------------------------------------------------------------------------
1 | import { AuthProviderFactory } from '../../../src/auth';
2 | import { StrapiError } from '../../../src/errors';
3 | import { MockAuthProvider } from '../mocks';
4 |
5 | describe('AuthProviderFactory', () => {
6 | let factory: AuthProviderFactory;
7 |
8 | beforeEach(() => {
9 | factory = new AuthProviderFactory();
10 | });
11 |
12 | it('should throw an error if an unregistered strategy is provided', () => {
13 | // Arrange
14 | const invalidStrategyName = '';
15 |
16 | // Act & Assert
17 | expect(() => {
18 | factory.create(invalidStrategyName, {});
19 | }).toThrow(StrapiError);
20 | });
21 |
22 | it('should create a valid instance for registered providers', () => {
23 | // Arrange
24 | const mockCreator = jest.fn(() => new MockAuthProvider());
25 |
26 | // Act
27 | factory.register(MockAuthProvider.identifier, mockCreator);
28 |
29 | const provider = factory.create(MockAuthProvider.identifier, undefined);
30 |
31 | // Assert
32 | expect(provider).toBeDefined();
33 | expect(provider).toBeInstanceOf(MockAuthProvider);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/tests/unit/auth/manager.test.ts:
--------------------------------------------------------------------------------
1 | import { ApiTokenAuthProvider, AuthManager, UsersPermissionsAuthProvider } from '../../../src/auth';
2 | import { MockAuthProvider, MockAuthProviderFactory, MockHttpClient } from '../mocks';
3 |
4 | describe('AuthManager', () => {
5 | const mockHttpClient = new MockHttpClient({ baseURL: 'https://example.com' });
6 |
7 | describe('Default Registered Strategies', () => {
8 | it.each([
9 | [ApiTokenAuthProvider.identifier, ApiTokenAuthProvider, { token: '' }],
10 | [
11 | UsersPermissionsAuthProvider.identifier,
12 | UsersPermissionsAuthProvider,
13 | { identifier: '', password: '' },
14 | ],
15 | ])(
16 | 'should have a strategy registered by default: "%s"',
17 | (providerName, providerClass, options) => {
18 | // Arrange
19 | const mockAuthProviderFactory = new MockAuthProviderFactory();
20 | const authManager = new AuthManager(mockAuthProviderFactory);
21 |
22 | // Act
23 | const instance = mockAuthProviderFactory.create(providerName, options);
24 |
25 | // Assert
26 | expect(authManager).toBeInstanceOf(AuthManager);
27 | expect(instance).toBeInstanceOf(providerClass);
28 | }
29 | );
30 | });
31 |
32 | it('should have no strategy selected after initialization', () => {
33 | // Arrange
34 | const authManager = new AuthManager(new MockAuthProviderFactory());
35 |
36 | // Assert
37 | expect(authManager.strategy).toBeUndefined();
38 | });
39 |
40 | it('should set strategy correctly', () => {
41 | // Arrange
42 | const authManager = new AuthManager(new MockAuthProviderFactory());
43 | const strategy = MockAuthProvider.identifier;
44 |
45 | // Act
46 | authManager.setStrategy(strategy, {});
47 |
48 | // Assert
49 | expect(authManager.strategy).toBe(MockAuthProvider.identifier);
50 | });
51 |
52 | it('should not be authenticated when strategy is not set', async () => {
53 | // Arrange
54 | const authManager = new AuthManager(new MockAuthProviderFactory());
55 |
56 | // Act
57 | await authManager.authenticate(mockHttpClient);
58 |
59 | // Assert
60 | expect(authManager.isAuthenticated).toBe(false);
61 | });
62 |
63 | it('should authenticate correctly when strategy is set', async () => {
64 | // Arrange
65 | const authManager = new AuthManager(new MockAuthProviderFactory());
66 | authManager.setStrategy(MockAuthProvider.identifier, {});
67 |
68 | // Act
69 | await authManager.authenticate(mockHttpClient);
70 |
71 | // Assert
72 | expect(authManager.isAuthenticated).toBe(true);
73 | });
74 |
75 | it('should handle unauthorized error properly', async () => {
76 | // Arrange
77 | const authManager = new AuthManager(new MockAuthProviderFactory());
78 | authManager.setStrategy(MockAuthProvider.identifier, {});
79 |
80 | // Act
81 | await authManager.authenticate(mockHttpClient);
82 | authManager.handleUnauthorizedError();
83 |
84 | // Assert
85 | expect(authManager.isAuthenticated).toBe(false);
86 | });
87 |
88 | it('should not do anything if authenticate is called without setting strategy', async () => {
89 | // Arrange
90 | const authManager = new AuthManager(new MockAuthProviderFactory());
91 |
92 | // Assert
93 | await expect(authManager.authenticate(mockHttpClient)).resolves.toBeUndefined();
94 | expect(authManager.isAuthenticated).toBe(false);
95 | });
96 |
97 | it('should remove authentication if authenticate throws an error', async () => {
98 | // Arrange
99 | const authManager = new AuthManager(new MockAuthProviderFactory());
100 | authManager.setStrategy(MockAuthProvider.identifier, {});
101 |
102 | jest.spyOn(MockAuthProvider.prototype, 'authenticate').mockImplementationOnce(() => {
103 | throw new Error();
104 | });
105 |
106 | // Act
107 | await authManager.authenticate(mockHttpClient);
108 |
109 | // Assert
110 | expect(authManager.isAuthenticated).toBe(false);
111 | });
112 |
113 | describe('Authenticate Request', () => {
114 | it.each([
115 | ['an Headers instance', new Headers()],
116 | ['a string record', {}],
117 | ['an array', []],
118 | ])('should authenticate request correctly with headers initialized as %s', (_, headers) => {
119 | // Arrange
120 | const authManager = new AuthManager(new MockAuthProviderFactory());
121 | const mockRequest = new Request('https://example.com', { headers });
122 |
123 | authManager.setStrategy(MockAuthProvider.identifier, {});
124 |
125 | // Act
126 | authManager.authenticateRequest(mockRequest);
127 |
128 | // Assert
129 | expect(mockRequest.headers.get('Authorization')).toBe('Bearer ');
130 | });
131 |
132 | it('should throw an error if the request headers are not a valid Headers instance', async () => {
133 | // Arrange
134 | const expectedError = new Error(
135 | 'Invalid request headers, headers must be an instance of Headers but found "string"'
136 | );
137 | const authManager = new AuthManager(new MockAuthProviderFactory());
138 |
139 | authManager.setStrategy(MockAuthProvider.identifier, {});
140 |
141 | // @ts-expect-error the "headers" value is purposefully invalid to make the request's authentication fail
142 | const action = () => authManager.authenticateRequest({ headers: '' });
143 |
144 | // Act & Assert
145 | expect(action).toThrow(expectedError);
146 | });
147 | });
148 | });
149 |
--------------------------------------------------------------------------------
/tests/unit/auth/providers/api-token.test.ts:
--------------------------------------------------------------------------------
1 | import { ApiTokenAuthProvider, ApiTokenAuthProviderOptions } from '../../../../src/auth';
2 | import { StrapiValidationError } from '../../../../src/errors';
3 |
4 | describe('ApiTokenAuthProvider', () => {
5 | describe('Name', () => {
6 | it('should return the static provider name from the instance', () => {
7 | // Arrange
8 | const token = 'abc-xyz';
9 | const provider = new ApiTokenAuthProvider({ token });
10 |
11 | // Act
12 | const name = provider.name;
13 |
14 | // Assert
15 | expect(name).toBe(ApiTokenAuthProvider.identifier);
16 | });
17 | });
18 |
19 | describe('Preflight Validation', () => {
20 | let spy: jest.SpyInstance;
21 |
22 | beforeEach(() => {
23 | spy = jest.spyOn(ApiTokenAuthProvider.prototype, 'preflightValidation');
24 | });
25 |
26 | afterEach(() => {
27 | spy.mockRestore();
28 | });
29 |
30 | it('should throw error if token is invalid in preflightValidation', () => {
31 | // Arrange
32 | const token = ' ';
33 |
34 | // Act & Assert
35 | expect(() => new ApiTokenAuthProvider({ token })).toThrow(StrapiValidationError);
36 | expect(spy).toHaveBeenCalledTimes(1);
37 | });
38 |
39 | it('should not throw error if token is valid in preflightValidation', () => {
40 | // Arrange
41 | const token = 'abc-xyz';
42 |
43 | // Act & Assert
44 | expect(() => new ApiTokenAuthProvider({ token })).not.toThrow();
45 | expect(spy).toHaveBeenCalledTimes(1);
46 | });
47 |
48 | it('should throw error when token is null in preflightValidation', () => {
49 | // Arrange
50 | const options = { token: null } as unknown as ApiTokenAuthProviderOptions;
51 |
52 | // Act & Assert
53 | expect(() => new ApiTokenAuthProvider(options)).toThrow(StrapiValidationError);
54 | expect(spy).toHaveBeenCalledTimes(1);
55 | });
56 | });
57 |
58 | describe('Authenticate', () => {
59 | it('should do nothing when authenticate is called', async () => {
60 | // Arrange
61 | const token = 'abc-xyz';
62 | const provider = new ApiTokenAuthProvider({ token });
63 |
64 | // Act & Assert
65 | await expect(provider.authenticate()).resolves.not.toThrow();
66 | });
67 | });
68 |
69 | describe('Headers', () => {
70 | it('should return correct headers with valid token', () => {
71 | // Arrange
72 | const token = 'abc-xyz';
73 | const provider = new ApiTokenAuthProvider({ token });
74 |
75 | // Act
76 | const headers = provider.headers;
77 |
78 | // Assert
79 | expect(headers).toEqual({ Authorization: `Bearer ${token}` });
80 | });
81 |
82 | it('should maintain immutability of headers', () => {
83 | // Arrange
84 | const token = 'abc-xyz';
85 | const provider = new ApiTokenAuthProvider({ token });
86 |
87 | // Act
88 | provider.headers.Authorization = 'Modified_1';
89 |
90 | // Assert
91 | expect(provider.headers.Authorization).toEqual(`Bearer ${token}`);
92 | });
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/tests/unit/content-types/collection/single-manager.test.ts:
--------------------------------------------------------------------------------
1 | import { SingleTypeManager } from '../../../../src/content-types';
2 | import { MockHttpClient } from '../../mocks';
3 |
4 | describe('SingleTypeManager CRUD Methods', () => {
5 | const mockHttpClientFactory = (url: string) => new MockHttpClient({ baseURL: url });
6 | const config = { baseURL: 'http://localhost:1337/api' };
7 | const mockHttpClient = mockHttpClientFactory(config.baseURL);
8 |
9 | beforeEach(() => {
10 | jest
11 | .spyOn(MockHttpClient.prototype, 'request')
12 | .mockImplementation(() =>
13 | Promise.resolve(
14 | new Response(JSON.stringify({ data: { id: 1 }, meta: {} }), { status: 200 })
15 | )
16 | );
17 | });
18 |
19 | afterEach(() => {
20 | jest.restoreAllMocks();
21 | });
22 |
23 | describe('Simple Config', () => {
24 | let singleTypeManager: SingleTypeManager;
25 |
26 | beforeEach(() => {
27 | singleTypeManager = new SingleTypeManager({ resource: 'homepage' }, mockHttpClient);
28 | });
29 |
30 | it('should properly set resource and path properties', () => {
31 | expect(singleTypeManager).toHaveProperty('_resource', 'homepage');
32 | expect(singleTypeManager).toHaveProperty('_path', undefined);
33 | expect(singleTypeManager).toHaveProperty('_rootPath', '/homepage');
34 | });
35 |
36 | it('should return an object with CRUD methods for a single type', () => {
37 | expect(singleTypeManager).toHaveProperty('find', expect.any(Function));
38 | expect(singleTypeManager).toHaveProperty('update', expect.any(Function));
39 | expect(singleTypeManager).toHaveProperty('delete', expect.any(Function));
40 | });
41 |
42 | it('should fetch a single document with complex query params in find method', async () => {
43 | // Arrange
44 | const expected =
45 | '/homepage?locale=en&populate=sections&fields%5B0%5D=title&fields%5B1%5D=content';
46 |
47 | // Act
48 | await singleTypeManager.find({
49 | locale: 'en',
50 | populate: 'sections',
51 | fields: ['title', 'content'],
52 | });
53 |
54 | // Assert
55 | expect(mockHttpClient.request).toHaveBeenCalledWith(expected, { method: 'GET' });
56 | });
57 |
58 | it('should update an existing document with update method', async () => {
59 | // Arrange
60 | const payload = { title: 'Updated Title' };
61 |
62 | // Act
63 | await singleTypeManager.update(payload, { locale: 'en' });
64 |
65 | // Assert
66 | expect(mockHttpClient.request).toHaveBeenCalledWith('/homepage?locale=en', {
67 | method: 'PUT',
68 | body: JSON.stringify({ data: payload }),
69 | headers: {
70 | 'Content-Type': 'application/json',
71 | },
72 | });
73 | });
74 |
75 | it('should delete a document with delete method', async () => {
76 | // Act
77 | await singleTypeManager.delete({ locale: 'en' });
78 |
79 | // Assert
80 | expect(mockHttpClient.request).toHaveBeenCalledWith('/homepage?locale=en', {
81 | method: 'DELETE',
82 | });
83 | });
84 | });
85 |
86 | describe('Custom Path Config', () => {
87 | let singleTypeManager: SingleTypeManager;
88 |
89 | beforeEach(() => {
90 | singleTypeManager = new SingleTypeManager(
91 | {
92 | resource: 'homepage',
93 | path: '/custom-homepage',
94 | },
95 | mockHttpClient
96 | );
97 | });
98 |
99 | it('should properly set resource and custom path properties', () => {
100 | expect(singleTypeManager).toHaveProperty('_resource', 'homepage');
101 | expect(singleTypeManager).toHaveProperty('_path', '/custom-homepage');
102 | expect(singleTypeManager).toHaveProperty('_rootPath', '/custom-homepage');
103 | });
104 |
105 | it('should use custom path when fetching document', async () => {
106 | // Arrange
107 | const expected = '/custom-homepage?locale=en';
108 |
109 | // Act
110 | await singleTypeManager.find({ locale: 'en' });
111 |
112 | // Assert
113 | expect(mockHttpClient.request).toHaveBeenCalledWith(expected, { method: 'GET' });
114 | });
115 |
116 | it('should use custom path when updating document', async () => {
117 | // Arrange
118 | const payload = { title: 'Updated Title' };
119 |
120 | // Act
121 | await singleTypeManager.update(payload, { locale: 'en' });
122 |
123 | // Assert
124 | expect(mockHttpClient.request).toHaveBeenCalledWith('/custom-homepage?locale=en', {
125 | method: 'PUT',
126 | body: JSON.stringify({ data: payload }),
127 | headers: {
128 | 'Content-Type': 'application/json',
129 | },
130 | });
131 | });
132 |
133 | it('should use custom path when deleting document', async () => {
134 | // Act
135 | await singleTypeManager.delete({ locale: 'en' });
136 |
137 | // Assert
138 | expect(mockHttpClient.request).toHaveBeenCalledWith('/custom-homepage?locale=en', {
139 | method: 'DELETE',
140 | });
141 | });
142 | });
143 | });
144 |
--------------------------------------------------------------------------------
/tests/unit/errors/client-errors.test.ts:
--------------------------------------------------------------------------------
1 | import { StrapiError, StrapiInitializationError, StrapiValidationError } from '../../../src/errors';
2 |
3 | describe('Strapi Errors', () => {
4 | describe('StrapiError', () => {
5 | it('should have a default message', () => {
6 | // Act
7 | const error = new StrapiError();
8 |
9 | // Assert
10 | expect(error.message).toBe(
11 | 'An error occurred in the Strapi client. Please check the logs for more information.'
12 | );
13 | expect(error.cause).toBeUndefined();
14 | });
15 |
16 | it('should allow a custom message', () => {
17 | // Arrange
18 | const customMessage = 'Custom error message.';
19 |
20 | // Act
21 | const error = new StrapiError(undefined, customMessage);
22 |
23 | // Assert
24 | expect(error.message).toBe(customMessage);
25 | });
26 |
27 | it('should set the cause if provided', () => {
28 | // Arrange
29 | const cause = new Error('Root cause');
30 |
31 | // Act
32 | const error = new StrapiError(cause);
33 |
34 | // Assert
35 | expect(error.cause).toBe(cause);
36 | });
37 | });
38 |
39 | describe('StrapiValidationError', () => {
40 | it('should have a default message', () => {
41 | // Act
42 | const error = new StrapiValidationError();
43 |
44 | // Assert
45 | expect(error.message).toBe('Some of the provided values are not valid.');
46 | expect(error.cause).toBeUndefined();
47 | });
48 |
49 | it('should allow a custom message', () => {
50 | // Arrange
51 | const customMessage = 'Validation error occurred.';
52 |
53 | // Act
54 | const error = new StrapiValidationError(undefined, customMessage);
55 |
56 | // Assert
57 | expect(error.message).toBe(customMessage);
58 | });
59 |
60 | it('should set the cause if provided', () => {
61 | // Arrange
62 | const cause = new Error('Validation root cause');
63 |
64 | // Act
65 | const error = new StrapiValidationError(cause);
66 |
67 | // Assert
68 | expect(error.cause).toBe(cause);
69 | });
70 | });
71 |
72 | describe('StrapiInitializationError', () => {
73 | it('should have a default message', () => {
74 | // Act
75 | const error = new StrapiInitializationError();
76 |
77 | // Assert
78 | expect(error.message).toBe('Could not initialize the Strapi Client');
79 | expect(error.cause).toBeUndefined();
80 | });
81 |
82 | it('should allow a custom message', () => {
83 | // Arrange
84 | const customMessage = 'Initialization error occurred.';
85 |
86 | // Act
87 | const error = new StrapiInitializationError(undefined, customMessage);
88 |
89 | // Assert
90 | expect(error.message).toBe(customMessage);
91 | });
92 |
93 | it('should set the cause if provided', () => {
94 | // Arrange
95 | const cause = new Error('Initialization root cause');
96 |
97 | // Act
98 | const error = new StrapiInitializationError(cause);
99 |
100 | // Assert
101 | expect(error.cause).toBe(cause);
102 | });
103 | });
104 | });
105 |
--------------------------------------------------------------------------------
/tests/unit/errors/http-errors.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | HTTPAuthorizationError,
3 | HTTPBadRequestError,
4 | HTTPError,
5 | HTTPForbiddenError,
6 | HTTPInternalServerError,
7 | HTTPNotFoundError,
8 | HTTPTimeoutError,
9 | } from '../../../src/errors';
10 | import { StatusCode } from '../../../src/http';
11 | import { mockRequest, mockResponse } from '../mocks';
12 |
13 | describe('HTTP Errors', () => {
14 | describe('HTTPError', () => {
15 | it('should correctly instantiate with a status code and status text', () => {
16 | // Arrange
17 | const response = mockResponse(504, 'Gateway Timeout');
18 | const request = mockRequest('GET', 'https://example.com/resource');
19 |
20 | // Act
21 | const error = new HTTPError(response, request);
22 |
23 | // Assert
24 | expect(error.name).toBe('HTTPError');
25 | expect(error.message).toBe(
26 | 'Request failed with status code 504 Gateway Timeout: GET https://example.com/resource'
27 | );
28 | expect(error.response).toBe(response);
29 | expect(error.request).toBe(request);
30 | });
31 |
32 | it('should handle status code without status text', () => {
33 | // Arrange
34 | const response = mockResponse(500, undefined as unknown as string);
35 | const request = mockRequest('POST', 'https://example.com/update');
36 |
37 | // Act
38 | const error = new HTTPError(response, request);
39 |
40 | // Assert
41 | expect(error.message).toBe(
42 | 'Request failed with status code 500: POST https://example.com/update'
43 | );
44 | });
45 |
46 | it('should handle requests with no status code', () => {
47 | // Arrange
48 | const response = mockResponse(undefined as any, '');
49 | const request = mockRequest('GET', 'https://example.com/unknown');
50 |
51 | // Act
52 | const error = new HTTPError(response, request);
53 |
54 | // Assert
55 | expect(error.message).toBe(
56 | 'Request failed with an unknown error: GET https://example.com/unknown'
57 | );
58 | });
59 | });
60 |
61 | it.each([
62 | [HTTPBadRequestError.name, HTTPBadRequestError, StatusCode.BAD_REQUEST],
63 | [HTTPAuthorizationError.name, HTTPAuthorizationError, StatusCode.UNAUTHORIZED],
64 | [HTTPForbiddenError.name, HTTPForbiddenError, StatusCode.FORBIDDEN],
65 | [HTTPNotFoundError.name, HTTPNotFoundError, StatusCode.NOT_FOUND],
66 | [HTTPTimeoutError.name, HTTPTimeoutError, StatusCode.TIMEOUT],
67 | [HTTPInternalServerError.name, HTTPInternalServerError, StatusCode.INTERNAL_SERVER_ERROR],
68 | ])('%s', (name, errorClass, status) => {
69 | // Arrange
70 | const response = mockResponse(status, name);
71 | const request = mockRequest('GET', 'https://example.com');
72 |
73 | // Act
74 | const error = new errorClass(response, request);
75 |
76 | // Assert
77 | expect(error).toBeInstanceOf(HTTPError);
78 | expect(error.name).toBe(name);
79 | expect(error.message).toBe(
80 | `Request failed with status code ${status} ${name}: GET https://example.com`
81 | );
82 | expect(error.response).toBe(response);
83 | expect(error.request).toBe(request);
84 | });
85 | });
86 |
--------------------------------------------------------------------------------
/tests/unit/errors/url-errors.test.ts:
--------------------------------------------------------------------------------
1 | import { URLValidationError, URLParsingError } from '../../../src/errors';
2 |
3 | describe('URL Errors', () => {
4 | describe('URLParsingError', () => {
5 | it('should construct with a correct error message', () => {
6 | // Arrange
7 | const url = 'invalid_url';
8 |
9 | // Act
10 | const error = new URLParsingError(url);
11 |
12 | // Assert
13 | expect(error).toBeInstanceOf(URLParsingError);
14 | expect(error).toBeInstanceOf(URLValidationError);
15 | expect(error).toBeInstanceOf(Error);
16 | expect(error.message).toBe(`Could not parse invalid URL: "${url}"`);
17 | });
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/tests/unit/formatters/path.test.ts:
--------------------------------------------------------------------------------
1 | import { PathFormatter } from '../../../src/formatters';
2 |
3 | import type { FormatterConfig } from '../../../src/formatters';
4 |
5 | describe('PathFormatter', () => {
6 | describe('format', () => {
7 | it('should format both leading and trailing slashes according to the config', () => {
8 | // Arrange
9 | const config = { leadingSlashes: 'single', trailingSlashes: false } satisfies FormatterConfig;
10 |
11 | // Act
12 | const result = PathFormatter.format('///path///', config);
13 |
14 | // Assert
15 | expect(result).toBe('/path');
16 | });
17 |
18 | it('should use default config when no config is provided', () => {
19 | // Act
20 | const result = PathFormatter.format('///path///');
21 |
22 | // Assert
23 | expect(result).toEqual('path');
24 | });
25 | });
26 |
27 | describe('formatTrailingSlashes', () => {
28 | it('should remove all trailing slashes when config is false', () => {
29 | // Act
30 | const result = PathFormatter.formatTrailingSlashes('path////', false);
31 |
32 | // Assert
33 | expect(result).toBe('path');
34 | });
35 |
36 | it('should ensure a single trailing slash when config is "single"', () => {
37 | // Act
38 | const result = PathFormatter.formatTrailingSlashes('path////', 'single');
39 |
40 | // Assert
41 | expect(result).toBe('path/');
42 | });
43 |
44 | it('should leave the path unchanged when config is true', () => {
45 | // Act
46 | const result = PathFormatter.formatTrailingSlashes('path////', true);
47 |
48 | // Assert
49 | expect(result).toBe('path////');
50 | });
51 |
52 | it('should respect the config default when no config is passed', () => {
53 | // Act
54 | const result = PathFormatter.formatTrailingSlashes('path///');
55 |
56 | // Assert
57 | expect(result).toBe('path');
58 | });
59 | });
60 |
61 | describe('formatLeadingSlashes', () => {
62 | it('should remove all leading slashes when config is false', () => {
63 | // Act
64 | const result = PathFormatter.formatLeadingSlashes('////path', false);
65 |
66 | // Assert
67 | expect(result).toBe('path');
68 | });
69 |
70 | it('should ensure a single leading slash when config is "single"', () => {
71 | // Act
72 | const result = PathFormatter.formatLeadingSlashes('////path', 'single');
73 |
74 | // Assert
75 | expect(result).toBe('/path');
76 | });
77 |
78 | it('should leave the path unchanged when config is true', () => {
79 | // Act
80 | const result = PathFormatter.formatLeadingSlashes('////path', true);
81 |
82 | // Assert
83 | expect(result).toBe('////path');
84 | });
85 |
86 | it('should respect the config default when no config is passed', () => {
87 | // Act
88 | const result = PathFormatter.formatLeadingSlashes('path');
89 |
90 | // Assert
91 | expect(result).toBe('path');
92 | });
93 | });
94 |
95 | describe('removeTrailingSlashes', () => {
96 | it('should remove all trailing slashes from the path', () => {
97 | // Act
98 | const result = PathFormatter.removeTrailingSlashes('path///');
99 |
100 | // Assert
101 | expect(result).toBe('path');
102 | });
103 |
104 | it('should return the path unchanged if there are no trailing slashes', () => {
105 | // Act
106 | const result = PathFormatter.removeTrailingSlashes('path');
107 |
108 | // Assert
109 | expect(result).toBe('path');
110 | });
111 | });
112 |
113 | describe('ensureSingleTrailingSlash', () => {
114 | it('should add a single trailing slash when there are none', () => {
115 | // Act
116 | const result = PathFormatter.ensureSingleTrailingSlash('path');
117 |
118 | // Assert
119 | expect(result).toBe('path/');
120 | });
121 |
122 | it('should reduce multiple trailing slashes to a single one', () => {
123 | // Act
124 | const result = PathFormatter.ensureSingleTrailingSlash('path///');
125 |
126 | // Assert
127 | expect(result).toBe('path/');
128 | });
129 | });
130 |
131 | describe('removeLeadingSlashes', () => {
132 | it('should remove all leading slashes from the path', () => {
133 | // Act
134 | const result = PathFormatter.removeLeadingSlashes('///path');
135 |
136 | // Assert
137 | expect(result).toBe('path');
138 | });
139 |
140 | it('should return the path unchanged if there are no leading slashes', () => {
141 | // Act
142 | const result = PathFormatter.removeLeadingSlashes('path');
143 |
144 | // Assert
145 | expect(result).toBe('path');
146 | });
147 | });
148 |
149 | describe('ensureSingleLeadingSlash', () => {
150 | it('should add a single leading slash when there are none', () => {
151 | // Act
152 | const result = PathFormatter.ensureSingleLeadingSlash('path');
153 |
154 | // Assert
155 | expect(result).toBe('/path');
156 | });
157 |
158 | it('should reduce multiple leading slashes to a single one', () => {
159 | // Act
160 | const result = PathFormatter.ensureSingleLeadingSlash('///path');
161 |
162 | // Assert
163 | expect(result).toBe('/path');
164 | });
165 | });
166 | });
167 |
--------------------------------------------------------------------------------
/tests/unit/http/interceptor-manager.test.ts:
--------------------------------------------------------------------------------
1 | import { HttpInterceptorManager } from '../../../src/http/interceptor-manager';
2 |
3 | describe('HttpInterceptorManager', () => {
4 | afterEach(() => {
5 | jest.restoreAllMocks();
6 | });
7 |
8 | describe('use()', () => {
9 | it('should add a fulfilled interceptor to the handler chain', async () => {
10 | // Arrange
11 | const manager = new HttpInterceptorManager();
12 |
13 | const mockFulfilled = jest.fn((value) => value + 1);
14 |
15 | // Act
16 | manager.use(mockFulfilled);
17 |
18 | const result = await manager.execute(1);
19 |
20 | // Assert
21 | expect(mockFulfilled).toHaveBeenCalledWith(1);
22 | expect(result).toBe(2);
23 | });
24 |
25 | it('should add both fulfilled and rejected interceptors to the handler chain', async () => {
26 | // Arrange
27 | const manager = new HttpInterceptorManager();
28 |
29 | const mockFulfilled = jest.fn((value) => value + 1);
30 | const mockRejected = jest.fn((error) => error);
31 |
32 | // Act
33 | manager.use(mockFulfilled, mockRejected);
34 |
35 | const result = await manager.execute(1);
36 |
37 | // Assert
38 | expect(mockFulfilled).toHaveBeenCalledWith(1);
39 | expect(result).toBe(2);
40 | });
41 | });
42 |
43 | describe('execute()', () => {
44 | it('should process value through all fulfilled interceptors sequentially', async () => {
45 | // Arrange
46 | const manager = new HttpInterceptorManager();
47 |
48 | manager.use((value) => value + 1);
49 | manager.use((value) => value * 2);
50 |
51 | // Act
52 | const result = await manager.execute(1);
53 |
54 | // Assert
55 | expect(result).toBe(4);
56 | });
57 |
58 | it('should handle errors with the corresponding rejected interceptor', async () => {
59 | // Arrange
60 | const manager = new HttpInterceptorManager();
61 |
62 | const mockRejected = jest.fn(() => 0);
63 |
64 | // Act
65 | manager.use((value) => {
66 | if (value === 1) {
67 | throw new Error('Test error');
68 | }
69 |
70 | return value;
71 | }, mockRejected);
72 |
73 | const result = await manager.execute(1);
74 |
75 | // Assert
76 | expect(mockRejected).toHaveBeenCalled();
77 | expect(result).toBe(0);
78 | });
79 |
80 | it('should propagate an unhandled error', async () => {
81 | // Arrange
82 | const manager = new HttpInterceptorManager();
83 |
84 | // Act
85 | manager.use((value) => {
86 | if (value === 1) {
87 | throw new Error('Test error');
88 | }
89 |
90 | return value;
91 | });
92 |
93 | // Assert
94 | await expect(manager.execute(1)).rejects.toThrow('Test error');
95 | });
96 | });
97 |
98 | describe('clone()', () => {
99 | it('should create a deep clone with the same interceptors', async () => {
100 | // Arrange
101 | const manager = new HttpInterceptorManager();
102 |
103 | manager.use((value) => value + 1);
104 |
105 | // Act
106 | const clone = manager.clone();
107 |
108 | const originalResult = await manager.execute(1);
109 | const cloneResult = await clone.execute(1);
110 |
111 | // Assert
112 | expect(originalResult).toBe(2);
113 | expect(cloneResult).toBe(2);
114 | });
115 |
116 | it('should not affect the original handler chain when a clone is modified', async () => {
117 | // Arrange
118 | const manager = new HttpInterceptorManager();
119 |
120 | manager.use((value) => value + 1);
121 |
122 | const clone = manager.clone();
123 |
124 | // Act
125 | clone.use((value) => value * 2);
126 |
127 | const originalResult = await manager.execute(1);
128 | const cloneResult = await clone.execute(1);
129 |
130 | // Assert
131 | expect(originalResult).toBe(2);
132 | expect(cloneResult).toBe(4);
133 | });
134 | });
135 |
136 | describe('reject()', () => {
137 | it('should process error through all rejected interceptors sequentially', async () => {
138 | // Arrange
139 | const manager = new HttpInterceptorManager();
140 |
141 | const mockRejected1 = jest.fn((error) => `${error}-intercepted1`);
142 | const mockRejected2 = jest.fn((error) => `${error}-intercepted2`);
143 |
144 | // Act
145 | manager.use(undefined, mockRejected1);
146 | manager.use(undefined, mockRejected2);
147 |
148 | const result = await manager.reject('Test error');
149 |
150 | // Assert
151 | expect(mockRejected1).toHaveBeenCalledWith('Test error');
152 | expect(mockRejected2).toHaveBeenCalledWith('Test error-intercepted1');
153 |
154 | expect(result).toBe('Test error-intercepted1-intercepted2');
155 | });
156 | });
157 | });
158 |
--------------------------------------------------------------------------------
/tests/unit/index.test.ts:
--------------------------------------------------------------------------------
1 | import { strapi } from '../../src';
2 | import { ApiTokenAuthProvider } from '../../src/auth';
3 | import { StrapiClient } from '../../src/client';
4 | import { StrapiInitializationError } from '../../src/errors';
5 |
6 | import type { Config } from '../../src';
7 |
8 | describe('strapi', () => {
9 | it('should create an client instance with valid http configuration', () => {
10 | // Arrange
11 | const config = { baseURL: 'https://api.example.com' } satisfies Config;
12 |
13 | // Act
14 | const client = strapi(config);
15 |
16 | // Assert
17 | expect(client).toBeInstanceOf(StrapiClient);
18 | expect(client).toHaveProperty('baseURL', config.baseURL);
19 | });
20 |
21 | it('should create an client instance with valid auth configuration', () => {
22 | // Arrange
23 | const token = '';
24 | const config = { baseURL: 'https://api.example.com', auth: token } satisfies Config;
25 |
26 | // Act
27 | const client = strapi(config);
28 |
29 | // Assert
30 | expect(client).toBeInstanceOf(StrapiClient);
31 | expect(client).toHaveProperty('auth', {
32 | strategy: ApiTokenAuthProvider.identifier, // default auth strategy
33 | options: { token },
34 | });
35 | });
36 |
37 | it('should throw an error for an invalid baseURL', () => {
38 | // Arrange
39 | const config = { baseURL: 'invalid-url' } satisfies Config;
40 |
41 | // Act & Assert
42 | expect(() => strapi(config)).toThrow(StrapiInitializationError);
43 | });
44 |
45 | it('should throw an error if auth configuration is invalid', () => {
46 | // Arrange
47 | const config = {
48 | baseURL: 'https://api.example.com',
49 | auth: '', // Invalid API token
50 | } satisfies Config;
51 |
52 | // Act & Assert
53 | expect(() => strapi(config)).toThrow(StrapiInitializationError);
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/tests/unit/interceptors/auth.test.ts:
--------------------------------------------------------------------------------
1 | import { HTTPAuthorizationError } from '../../../src/errors';
2 | import { AuthInterceptors } from '../../../src/interceptors';
3 | import { MockAuthManager, MockAuthProvider, MockHttpClient, MockURLValidator } from '../mocks';
4 |
5 | describe('AuthInterceptors', () => {
6 | let mockAuthManager: MockAuthManager;
7 | let mockHttpClient: MockHttpClient;
8 | let mockURLValidator: MockURLValidator;
9 |
10 | beforeEach(() => {
11 | mockURLValidator = new MockURLValidator();
12 | mockAuthManager = new MockAuthManager();
13 | mockHttpClient = new MockHttpClient({ baseURL: 'https://example.com' }, mockURLValidator);
14 |
15 | jest.spyOn(mockAuthManager, 'authenticate');
16 | jest.spyOn(mockAuthManager, 'handleUnauthorizedError');
17 | jest.spyOn(mockAuthManager, 'authenticateRequest');
18 | });
19 |
20 | afterEach(() => {
21 | jest.restoreAllMocks();
22 | });
23 |
24 | describe('ensurePreAuthentication', () => {
25 | it('should trigger authentication if strategy is defined and user is not authenticated', async () => {
26 | // Arrange
27 | mockAuthManager.setStrategy(MockAuthProvider.identifier, {});
28 |
29 | const interceptor = AuthInterceptors.ensurePreAuthentication(mockAuthManager, mockHttpClient);
30 | const request = new Request('https://example.com');
31 |
32 | // Act
33 | await interceptor({ request });
34 |
35 | // Assert
36 | expect(mockAuthManager.authenticate).toHaveBeenCalledWith(mockHttpClient);
37 | expect(mockAuthManager.isAuthenticated).toBe(true);
38 | });
39 |
40 | it('should not trigger authentication if user is already authenticated', async () => {
41 | // Arrange
42 | mockAuthManager['_isAuthenticated'] = true;
43 | mockAuthManager.setStrategy(MockAuthProvider.identifier, {});
44 |
45 | const interceptor = AuthInterceptors.ensurePreAuthentication(mockAuthManager, mockHttpClient);
46 | const request = new Request('https://example.com');
47 |
48 | // Act
49 | await interceptor({ request });
50 |
51 | // Assert
52 | expect(mockAuthManager.authenticate).not.toHaveBeenCalled();
53 | });
54 |
55 | it('should not trigger authentication if no strategy is defined', async () => {
56 | // Arrange
57 | const interceptor = AuthInterceptors.ensurePreAuthentication(mockAuthManager, mockHttpClient);
58 | const request = new Request('https://example.com');
59 |
60 | // Act
61 | await interceptor({ request });
62 |
63 | // Assert
64 | expect(mockAuthManager.authenticate).not.toHaveBeenCalled();
65 | });
66 | });
67 |
68 | describe('authenticateRequests', () => {
69 | it('should call authenticateRequest with request', () => {
70 | // Arrange
71 | mockAuthManager.setStrategy(MockAuthProvider.identifier, {});
72 |
73 | const interceptor = AuthInterceptors.authenticateRequests(mockAuthManager);
74 | const request = new Request('https://example.com');
75 |
76 | jest.spyOn(request.headers, 'set');
77 |
78 | // Act
79 | interceptor({ request });
80 |
81 | // Assert
82 | expect(mockAuthManager.authenticateRequest).toHaveBeenCalledWith(request);
83 | expect(request.headers.set).toHaveBeenCalledWith('Authorization', 'Bearer ');
84 | });
85 | });
86 |
87 | describe('notifyOnUnauthorizedResponse', () => {
88 | it('should call handleUnauthorizedError on 401 response status', () => {
89 | // Arrange
90 | const [fulfillment] = AuthInterceptors.notifyOnUnauthorizedResponse(mockAuthManager);
91 | const request = new Request('https://example.com');
92 | const response = new Response(null, { status: 401 });
93 |
94 | // Act
95 | fulfillment({ request, response });
96 |
97 | // Assert
98 | expect(mockAuthManager.handleUnauthorizedError).toHaveBeenCalled();
99 | });
100 |
101 | it('should not call handleUnauthorizedError for non-401 response status', () => {
102 | // Arrange
103 | const [fulfillment] = AuthInterceptors.notifyOnUnauthorizedResponse(mockAuthManager);
104 | const request = new Request('https://example.com');
105 | const response = new Response(null, { status: 200 });
106 |
107 | // Act
108 | fulfillment({ request, response });
109 |
110 | // Assert
111 | expect(mockAuthManager.handleUnauthorizedError).not.toHaveBeenCalled();
112 | });
113 |
114 | it('should call handleUnauthorizedError on HTTPAuthorizationError rejection', () => {
115 | // Arrange
116 | const [, rejection] = AuthInterceptors.notifyOnUnauthorizedResponse(mockAuthManager);
117 |
118 | const request = new Request('https://example.com');
119 | const response = new Response(null, { status: 401 });
120 |
121 | const error = new HTTPAuthorizationError(response, request);
122 |
123 | // Act
124 | rejection(error);
125 |
126 | // Assert
127 | expect(mockAuthManager.handleUnauthorizedError).toHaveBeenCalled();
128 | });
129 |
130 | it('should not call handleUnauthorizedError for non HTTPAuthorizationError rejection', () => {
131 | // Arrange
132 | const [, rejection] = AuthInterceptors.notifyOnUnauthorizedResponse(mockAuthManager);
133 | const error = new Error('Other Error');
134 |
135 | // Act
136 | rejection(error);
137 |
138 | // Assert
139 | expect(mockAuthManager.handleUnauthorizedError).not.toHaveBeenCalled();
140 | });
141 | });
142 | });
143 |
--------------------------------------------------------------------------------
/tests/unit/interceptors/http.test.ts:
--------------------------------------------------------------------------------
1 | import { HttpInterceptors } from '../../../src/interceptors';
2 | import { HTTP_ERROR_ASSOCIATIONS } from '../../fixtures/http-error-associations';
3 |
4 | describe('HTTP Interceptors', () => {
5 | describe('setDefaultHeaders', () => {
6 | it('should add the headers to the given request', () => {
7 | // Arrange
8 | const request = new Request('https://example.com');
9 |
10 | const interceptor = HttpInterceptors.setDefaultHeaders();
11 |
12 | // Act
13 | interceptor({ request });
14 |
15 | // Assert
16 | expect(request.headers.get('Content-Type')).toBe('application/json');
17 | });
18 |
19 | it('should not override the headers if a value is already set', async () => {
20 | // Arrange
21 | const request = new Request('https://example.com', {
22 | headers: { 'Content-Type': 'text/plain' },
23 | });
24 |
25 | const interceptor = HttpInterceptors.setDefaultHeaders();
26 |
27 | // Act
28 | await interceptor({ request });
29 |
30 | // Assert
31 | expect(request.headers.get('Content-Type')).toBe('text/plain');
32 | });
33 |
34 | it('should perform case insensitive checks on headers', async () => {
35 | // Arrange
36 | const request = new Request('https://example.com', {
37 | headers: { 'content-type': 'text/plain' },
38 | });
39 |
40 | const interceptor = HttpInterceptors.setDefaultHeaders();
41 |
42 | // Act
43 | await interceptor({ request });
44 |
45 | // Assert
46 | expect(request.headers.get('Content-Type')).toBe('text/plain');
47 | });
48 | });
49 |
50 | describe('transformErrors', () => {
51 | const interceptor = HttpInterceptors.transformErrors();
52 |
53 | it('should do nothing if the response is not an error', async () => {
54 | // Arrange
55 | const request = new Request('https://example.com');
56 | const response = new Response(null, { status: 200 });
57 |
58 | // Act
59 | const res = await interceptor({ request, response });
60 |
61 | // Assert
62 | expect(res.request).toBe(request);
63 | expect(res.response).toBe(response);
64 | });
65 |
66 | it.each(HTTP_ERROR_ASSOCIATIONS)(
67 | 'should throw the correct error for %j',
68 | async (statuses, errorClass) => {
69 | // Arrange
70 | const request = new Request('https://example.com');
71 | const response = new Response(null, statuses);
72 |
73 | const action = async () => await interceptor({ request, response });
74 |
75 | // Act & Assert
76 | await expect(action).rejects.toThrow(errorClass);
77 | }
78 | );
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/tests/unit/mocks/auth-manager.mock.ts:
--------------------------------------------------------------------------------
1 | import { AuthManager, AuthProviderFactory } from '../../../src/auth';
2 |
3 | import { MockAuthProviderFactory } from './auth-provider-factory.mock';
4 |
5 | export class MockAuthManager extends AuthManager {
6 | constructor(authProviderFactory: AuthProviderFactory = new MockAuthProviderFactory()) {
7 | super(authProviderFactory);
8 | }
9 |
10 | registerDefaultProviders() {
11 | // does nothing
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/tests/unit/mocks/auth-provider-factory.mock.ts:
--------------------------------------------------------------------------------
1 | import { AuthProviderFactory } from '../../../src/auth';
2 |
3 | import { MockAuthProvider } from './auth-provider.mock';
4 |
5 | export class MockAuthProviderFactory extends AuthProviderFactory {
6 | constructor() {
7 | super();
8 |
9 | this.register(MockAuthProvider.identifier, () => new MockAuthProvider());
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/tests/unit/mocks/auth-provider.mock.ts:
--------------------------------------------------------------------------------
1 | import { AbstractAuthProvider } from '../../../src/auth';
2 |
3 | export class MockAuthProvider extends AbstractAuthProvider<{ jwt: string }> {
4 | public static readonly identifier = 'mock-jwt';
5 |
6 | constructor() {
7 | super({ jwt: '' });
8 | }
9 |
10 | authenticate(): Promise {
11 | return Promise.resolve();
12 | }
13 |
14 | get headers(): Record {
15 | return { Authorization: 'Bearer ' };
16 | }
17 |
18 | get name(): string {
19 | return MockAuthProvider.identifier;
20 | }
21 |
22 | protected preflightValidation(): void {
23 | // does nothing
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/tests/unit/mocks/flaky-url-validator.mock.ts:
--------------------------------------------------------------------------------
1 | import { URLValidator } from '../../../src/validators';
2 |
3 | /**
4 | * Class representing a FlakyURLValidator which extends URLValidator.
5 | *
6 | * This validator is designed to throw an error unexpectedly upon validation and should only be used in test suites.
7 | */
8 | export class MockFlakyURLValidator extends URLValidator {
9 | validate() {
10 | throw new Error('Unexpected error');
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/tests/unit/mocks/http-client.mock.ts:
--------------------------------------------------------------------------------
1 | import { HttpClient } from '../../../src/http';
2 |
3 | import { MockURLValidator } from './url-validator.mock';
4 |
5 | import type { HttpClientConfig } from '../../../src/http';
6 | import type { URLValidator } from '../../../src/validators';
7 |
8 | export class MockHttpClient extends HttpClient {
9 | constructor(config: HttpClientConfig, urlValidator: URLValidator = new MockURLValidator()) {
10 | super(config, urlValidator);
11 | }
12 |
13 | fetch(_input: RequestInfo | URL, _init?: RequestInit): Promise {
14 | return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/tests/unit/mocks/index.ts:
--------------------------------------------------------------------------------
1 | export { MockAuthProvider } from './auth-provider.mock';
2 | export { MockAuthProviderFactory } from './auth-provider-factory.mock';
3 | export { MockAuthManager } from './auth-manager.mock';
4 | export { MockHttpClient } from './http-client.mock';
5 | export { MockURLValidator } from './url-validator.mock';
6 | export { MockFlakyURLValidator } from './flaky-url-validator.mock';
7 | export { MockStrapiConfigValidator } from './strapi-config-validator.mock';
8 | export { mockRequest } from './request.mock';
9 | export { mockResponse } from './response.mock';
10 |
--------------------------------------------------------------------------------
/tests/unit/mocks/request.mock.ts:
--------------------------------------------------------------------------------
1 | export const mockRequest = (method: string, url: string): Request => ({
2 | method,
3 | url,
4 | headers: new Headers(),
5 | redirect: 'follow',
6 | clone: jest.fn(),
7 | body: null,
8 | bodyUsed: false,
9 | cache: 'default',
10 | credentials: 'same-origin',
11 | integrity: '',
12 | keepalive: false,
13 | mode: 'same-origin',
14 | referrer: '',
15 | referrerPolicy: 'no-referrer',
16 | destination: '',
17 | signal: AbortSignal.any([]),
18 | arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)),
19 | blob: jest.fn().mockResolvedValue(new Blob()),
20 | bytes: jest.fn().mockResolvedValue(new Uint8Array()),
21 | formData: jest.fn().mockResolvedValue(new FormData()),
22 | text: jest.fn().mockResolvedValue(''),
23 | json: jest.fn().mockResolvedValue({}),
24 | });
25 |
--------------------------------------------------------------------------------
/tests/unit/mocks/response.mock.ts:
--------------------------------------------------------------------------------
1 | export const mockResponse = (status: number, statusText: string): Response => ({
2 | status,
3 | statusText,
4 | headers: new Headers(),
5 | ok: status >= 200 && status < 300,
6 | redirected: false,
7 | type: 'basic',
8 | url: 'https://example.com',
9 | clone: jest.fn(),
10 | body: null,
11 | bodyUsed: false,
12 | text: jest.fn().mockResolvedValue(''),
13 | json: jest.fn().mockResolvedValue({}),
14 | blob: jest.fn().mockResolvedValue(new Blob()),
15 | bytes: jest.fn().mockResolvedValue(new Uint8Array()),
16 | formData: jest.fn().mockResolvedValue(new FormData()),
17 | arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)),
18 | });
19 |
--------------------------------------------------------------------------------
/tests/unit/mocks/strapi-config-validator.mock.ts:
--------------------------------------------------------------------------------
1 | import { StrapiConfigValidator } from '../../../src/validators';
2 |
3 | export class MockStrapiConfigValidator extends StrapiConfigValidator {}
4 |
--------------------------------------------------------------------------------
/tests/unit/mocks/url-validator.mock.ts:
--------------------------------------------------------------------------------
1 | import { URLValidator } from '../../../src/validators';
2 |
3 | export class MockURLValidator extends URLValidator {}
4 |
--------------------------------------------------------------------------------
/tests/unit/utilities/request-helper.test.ts:
--------------------------------------------------------------------------------
1 | import { RequestHelper } from '../../../src/utilities';
2 |
3 | describe('RequestHelper', () => {
4 | describe('format', () => {
5 | it.each(['POST', 'GET', 'HEAD', 'PUT', 'DELETE'])(
6 | 'should format valid requests and display the correct method: %s',
7 | (method) => {
8 | // Arrange
9 | const url = new URL('https://example.com/articles/1?param1=a¶m2=b¶m3=c');
10 | const request = new Request(url, { method });
11 |
12 | // Act
13 | const out = RequestHelper.format(request);
14 |
15 | // Assert
16 | expect(out).toBe(`${method} - https://example.com/articles/1`);
17 | }
18 | );
19 |
20 | it.each(['str', 42, undefined, null, {}, []])(
21 | 'should throw a type error for invalid inputs: %s',
22 | (input) => {
23 | // Arrange
24 | const action = () => RequestHelper.format(input as unknown as Request);
25 |
26 | // Act & Assert
27 | expect(action).toThrow(
28 | new TypeError(`Invalid input, expected a Request instance but found ${typeof input}`)
29 | );
30 | }
31 | );
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/tests/unit/validators/url.test.ts:
--------------------------------------------------------------------------------
1 | import { URLParsingError } from '../../../src/errors';
2 | import { URLValidator } from '../../../src/validators';
3 | import invalidURLs from '../../fixtures/invalid-urls.json';
4 |
5 | describe('URLValidator', () => {
6 | let urlValidator: URLValidator;
7 |
8 | beforeEach(() => {
9 | urlValidator = new URLValidator();
10 | });
11 |
12 | describe('Protocol Validation', () => {
13 | it('should validate a valid HTTP URL', () => {
14 | // Arrange
15 | const url = 'http://example.com';
16 |
17 | // Act & Assert
18 | expect(() => urlValidator.validate(url)).not.toThrow();
19 | });
20 |
21 | it('should validate a valid HTTPS URL', () => {
22 | // Arrange
23 | const url = 'https://example.com';
24 |
25 | // Act & Assert
26 | expect(() => urlValidator.validate(url)).not.toThrow();
27 | });
28 | });
29 |
30 | describe('Parsing Validation', () => {
31 | it('should not throw when given a valid URL', () => {
32 | // Arrange
33 | const url = 'https://example.com';
34 |
35 | // Act & Assert
36 | expect(() => urlValidator.validate(url)).not.toThrow();
37 | });
38 |
39 | it.each(invalidURLs.impossibleToParse)(
40 | 'should throw an error for a non-string input: %s (%s)',
41 | (url) => {
42 | // Act & Assert
43 | expect(() => urlValidator.validate(url)).toThrow(new URLParsingError(url));
44 | }
45 | );
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": "./src",
5 | "outDir": "./dist",
6 | "stripInternal": true
7 | },
8 | "include": ["src"],
9 | "exclude": ["tests", "**/__tests__/**", "**/cli/**"]
10 | }
11 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "noEmit": true
5 | },
6 | "include": ["src", "tests"],
7 | "exclude": ["node_modules"]
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json.schemastore.org/tsconfig",
3 | "include": ["src"],
4 | "exclude": ["node_modules", "dist"],
5 | "compilerOptions": {
6 | "sourceMap": true,
7 | "esModuleInterop": true,
8 | "skipLibCheck": true,
9 | "target": "ESNext",
10 | "allowJs": false,
11 | "resolveJsonModule": true,
12 | "moduleDetection": "force",
13 | "isolatedModules": true,
14 | "strict": true,
15 | "noUncheckedIndexedAccess": true,
16 | "declaration": true,
17 | "module": "preserve",
18 | "lib": ["ESNext", "DOM"]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------