From 2f0303b9f176858417b5a0e5a376b10761c3df40 Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:18:26 +0100 Subject: [PATCH 01/22] chore(tsconfig): update compiler options and paths --- tsconfig.build.json | 3 ++- tsconfig.eslint.json | 2 +- tsconfig.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tsconfig.build.json b/tsconfig.build.json index 3157725..dbdb3a2 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,7 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "stripInternal": true }, "include": ["src"], "exclude": ["tests", "**/__tests__/**", "**/cli/**"] diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 9fac91c..b363eb6 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -3,6 +3,6 @@ "compilerOptions": { "noEmit": true }, - "include": ["examples", "src", "tests", "scripts", "*.config.ts", "*.config.mjs", ".eslintrc.js"], + "include": ["src", "tests"], "exclude": ["node_modules"] } diff --git a/tsconfig.json b/tsconfig.json index ea9c800..d376c2e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src"], "exclude": ["node_modules", "dist"], "compilerOptions": { + "sourceMap": true, "esModuleInterop": true, "skipLibCheck": true, "target": "ES2022", @@ -14,7 +15,6 @@ "noUncheckedIndexedAccess": true, "declaration": true, "module": "preserve", - "noEmit": true, "lib": ["ESNext", "DOM"] } } From 29a2c0b40898900d33377b80255bcb4d77b91f9f Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:18:40 +0100 Subject: [PATCH 02/22] docs(README): update SDK usage for JavaScript/TypeScript projects --- README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3fcc301..897f9bc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

sdk-js

-

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

+

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


@@ -21,13 +21,14 @@ sdk-js is an SDK you can use to easily interface with Strapi from your JavaScrip ## Getting Started With Strapi -If you're brand new to Strapi development, we recommend you follow the [Strapi Quick Start Guide](https://docs.strapi.io/dev-docs/quick-start) +If you're brand new to Strapi development, it is recommended to follow the [Strapi Quick Start Guide](https://docs.strapi.io/dev-docs/quick-start) sdk-js is compatible with Strapi v5+ and interfaces with Strapi's REST API. You can read the API docs [here](https://docs.strapi.io/dev-docs/api/rest) ## SDK Purpose -sdk-js is the recommended and easiest way to interface with Strapi from your javascript project. It allows you to easily create, read, update, and delete Strapi content through strongly typed methods. +sdk-js is the recommended and easiest way to interface with Strapi from your JavaScript and TypeScript projects. +It allows you to easily create, read, update, and delete Strapi content through strongly typed methods. @@ -42,11 +43,11 @@ In its simplest form, "@strapi/sdk-js" works by being connected to the URL of yo ### Importing the SDK ```js -import { createSDK } from '@strapi/sdk-js'; // ES Modules -// const { createSDK } = require("@strapi/sdk-js"); CommonJS +import { createStrapiSDK } from '@strapi/sdk-js'; // ES Modules +// const { createStrapiSDK } = require("@strapi/sdk-js"); CommonJS -const strapiSDK = createSDK({ - url: 'http://localhost:1337', +const strapiSDK = createStrapiSDK({ + baseURL: 'http://localhost:1337', }); ``` @@ -55,8 +56,8 @@ const strapiSDK = createSDK({ ```html ``` @@ -89,12 +90,12 @@ As opposed to importing the SDK from a CDN or NPM, the generated asset can then Alternatively, you can use the SDK from a CDN or NPM, but provide the SDK with a Strapi schema. ```js -import { createSDK } from '@strapi/sdk-js'; +import { createStrapiSDK } from '@strapi/sdk-js'; // TODO clarify where this comes from and how to generate it import strapiAppSchema from '../path/to/strapi-app-schema.json'; -const strapiSDK = createSDK({ - url: 'http://localhost:1337', +const strapiSDK = createStrapiSDK({ + baseURL: 'http://localhost:1337', schema: strapiAppSchema, }); ``` From 9bf822b21842882826248f6fd57146fb5b85b5f0 Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:19:06 +0100 Subject: [PATCH 03/22] test(jest): configure coverage thresholds and ignore patterns --- jest.config.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/jest.config.js b/jest.config.js index 07b1559..9f9c785 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,9 +1,19 @@ /** @type {import('ts-jest').JestConfigWithTsJest} **/ module.exports = { + rootDir: '.', testEnvironment: 'node', testMatch: ['/tests/unit/**/*.test.ts'], transform: { '^.+.tsx?$': ['ts-jest', {}], }, coverageDirectory: '/.coverage/', + coveragePathIgnorePatterns: ['/node_modules/', '/tests/'], + coverageThreshold: { + global: { + branches: 95, + functions: 95, + lines: 95, + statements: 95, + }, + }, }; From ade62704ce5aad8cca42670bad38609f84287beb Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:19:29 +0100 Subject: [PATCH 04/22] chore(commitlint): reorder import statements --- .commitlintrc.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.commitlintrc.ts b/.commitlintrc.ts index b446373..4dab21b 100644 --- a/.commitlintrc.ts +++ b/.commitlintrc.ts @@ -1,6 +1,7 @@ -import type { UserConfig } from '@commitlint/types'; import { RuleConfigSeverity } from '@commitlint/types'; +import type { UserConfig } from '@commitlint/types'; + const config: UserConfig = { extends: ['@commitlint/config-conventional'], rules: { From 90a1fdb11ec20775da6f7f361c54c816354e48bb Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:21:01 +0100 Subject: [PATCH 05/22] chore(git): update ignore files for compressed and config dirs --- .gitignore | 1 + .prettierignore | 1 + 2 files changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index bb6300a..c894534 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ $RECYCLE.BIN/ *.jar *.rar *.tar +*.tgz *.zip *.com *.class diff --git a/.prettierignore b/.prettierignore index e493dd6..03a9d0f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ bin dist +.github From d74ef5ee4422d6a0cd2da0232a06ac27e44af940 Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:21:14 +0100 Subject: [PATCH 06/22] chore(eslint): migrate config to mjs module format --- eslint.config.js | 24 ----------------------- eslint.config.mjs | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 24 deletions(-) delete mode 100644 eslint.config.js create mode 100644 eslint.config.mjs diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 41832a9..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,24 +0,0 @@ -module.exports = { - languageOptions: { - parser: require('@typescript-eslint/parser'), - }, - files: ['src/*.{js,ts,jsx,tsx,yml,yaml}'], - plugins: { - import: require('eslint-plugin-import'), - }, - rules: { - // eslint-plugin-import - 'import/no-default-export': 'error', - 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - 'import/first': ['error'], - 'import/exports-last': ['error'], - 'import/order': [ - 'error', - { - groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'], - 'newlines-between': 'always', - alphabetize: { order: 'asc', caseInsensitive: true }, - }, - ], - }, -}; diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..5ccf2a8 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,49 @@ +import pluginEslintImport from 'eslint-plugin-import'; +import pluginTypescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; + +export default { + languageOptions: { + parser: tsParser, + parserOptions: { + project: ['./tsconfig.eslint.json'], + }, + }, + files: ['{src,tests}/**/*.{js,ts,jsx,tsx,yml,yaml}'], + plugins: { + '@typescript-eslint': pluginTypescriptEslint, + import: pluginEslintImport, + }, + rules: { + // Use the TypeScript port of 'no-unused-vars' to prevent false positives on abstract methods parameters + // while keeping consistency with TS native behavior of ignoring parameters starting with '_'. + // https://typescript-eslint.io/rules/no-unused-vars/ + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + + // eslint-plugin-import + 'import/no-default-export': 'error', + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], + 'import/first': ['error'], + 'import/exports-last': ['error'], + 'import/order': [ + 'error', + { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'], + 'newlines-between': 'always', + alphabetize: { order: 'asc', caseInsensitive: true }, + }, + ], + }, +}; From 691960675d1de045b88e62a0a8d9e8fa916a15bf Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:22:00 +0100 Subject: [PATCH 07/22] chore(scripts): add pre-pack script for checks and build --- package.json | 8 ++++++-- scripts/pre-pack.sh | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100755 scripts/pre-pack.sh diff --git a/package.json b/package.json index 5fdb244..2460977 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,10 @@ }, "./package.json": "./package.json" }, - "files": [], + "files": [ + "./package.json", + "dist/" + ], "scripts": { "build": "rollup --config rollup.config.mjs --failAfterWarnings", "build:clean": "rollup --config rollup.config.mjs --failAfterWarnings", @@ -45,7 +48,8 @@ "test": "jest --verbose", "test:cov": "jest --verbose --coverage", "ts:check": "tsc -p tsconfig.build.json --noEmit", - "watch": "rollup --config rollup.config.mjs --watch --failAfterWarnings" + "watch": "rollup --config rollup.config.mjs --watch --failAfterWarnings", + "prepack": "pnpm exec ./scripts/pre-pack.sh" }, "devDependencies": { "@commitlint/cli": "19.6.0", diff --git a/scripts/pre-pack.sh b/scripts/pre-pack.sh new file mode 100755 index 0000000..e30e3f0 --- /dev/null +++ b/scripts/pre-pack.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +# Run the Prettier check +pnpm prettier:check && + +# Run linting +pnpm lint && + +# Run TypeScript type check +pnpm ts:check && + +# Run tests with coverage +pnpm test:cov && + +# Run the clean build +pnpm build:clean From 0ab754c412e542b52c1d7906fbd7f4ae0c80cb7f Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:23:02 +0100 Subject: [PATCH 08/22] feat(auth): add base types and abstract class for providers --- src/auth/providers/abstract.ts | 63 ++++++++++++++++++++++++++++++++++ src/auth/providers/index.ts | 5 +++ src/auth/providers/types.ts | 42 +++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 src/auth/providers/abstract.ts create mode 100644 src/auth/providers/index.ts create mode 100644 src/auth/providers/types.ts diff --git a/src/auth/providers/abstract.ts b/src/auth/providers/abstract.ts new file mode 100644 index 0000000..9387e7a --- /dev/null +++ b/src/auth/providers/abstract.ts @@ -0,0 +1,63 @@ +import { HttpClient } from '../../http'; + +import type { AuthProvider } from './types'; + +/** + * An abstract class that provides a foundational structure for implementing different authentication providers. + * + * It is designed to be extended by specific authentication strategies such as + * API token or users-permissions based authentication. + * + * This class implements the {@link AuthProvider} interface, ensuring consistency across + * authentication strategies in handling authentication processes and headers. + * + * @template T - The type of options that the specific authentication provider requires. + * + * @example + * // Example of extending the AbstractAuthProvider + * class MyAuthProvider extends AbstractAuthProvider { + * constructor(options: MyOptions) { + * super(options); + * } + * + * authenticate(): Promise { + * // Implementation for authentication + * } + * + * get headers() { + * return { + * Authorization: `Bearer ${this._options.token}`, + * }; + * } + * } + * + * @abstract + */ +export abstract class AbstractAuthProvider implements AuthProvider { + protected readonly _options: T; + + protected constructor(options: T) { + this._options = options; + + // Validation + this.preflightValidation(); + } + + /** + * Conducts necessary preflight validation checks for the authentication provider. + * + * This method validates the options passed during the instantiation of the provider. + * + * It is called within the constructor to ensure that all required options adhere + * to the expected format or values before proceeding with operational methods. + * + * @throws {StrapiSDKValidationError} If the validation fails due to invalid or missing options. + */ + protected abstract preflightValidation(): void; + + public abstract get name(): string; + + public abstract get headers(): Record; + + public abstract authenticate(httpClient: HttpClient): Promise; +} diff --git a/src/auth/providers/index.ts b/src/auth/providers/index.ts new file mode 100644 index 0000000..67cbe85 --- /dev/null +++ b/src/auth/providers/index.ts @@ -0,0 +1,5 @@ +export type * from './types'; + +export * from './abstract'; +export * from './api-token'; +export * from './users-permissions'; diff --git a/src/auth/providers/types.ts b/src/auth/providers/types.ts new file mode 100644 index 0000000..0f78e52 --- /dev/null +++ b/src/auth/providers/types.ts @@ -0,0 +1,42 @@ +import type { HttpClient } from '../../http'; + +/** + * Provides an interface for implementing various authentication providers, allowing integration + * of different authentication schemes in a consistent manner. + * + * It enables setting custom headers required by authentication schemes. + * + * @example + * const authProvider = new MyAuthProvider('my-secret-token'); + * + * authProvider.authenticate(); + * + * console.log(authProvider.headers); // Retrieves auth headers to attach them to requests + */ +export interface AuthProvider { + /** + * The identifying name of the authentication provider. + * + * This can be used for differentiating between multiple auth strategies. + */ + get name(): string; + + /** + * Object containing the headers that should be included in requests authenticated by this provider. + * + * Typically, these headers include tokens or keys required by the auth scheme. + */ + get headers(): Record; + + /** + * Authenticates by executing any required authentication steps such as + * fetching tokens or setting the necessary state for future requests. + * + * @param httpClient - The {@link HttpClient} instance used to perform the necessary HTTP requests to authenticate + * + * @returns A promise that resolves when the authentication process has completed. + * + * @throws {Error} If an error occurs that prevents successful authentication. + */ + authenticate(httpClient: HttpClient): Promise; +} From 17073b49b3847324ff262a0a1a60586f569dca62 Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:23:20 +0100 Subject: [PATCH 09/22] feat(auth): add new API-token and users-permissions providers --- src/auth/providers/api-token.ts | 51 +++++++++++ src/auth/providers/users-permissions.ts | 111 ++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/auth/providers/api-token.ts create mode 100644 src/auth/providers/users-permissions.ts diff --git a/src/auth/providers/api-token.ts b/src/auth/providers/api-token.ts new file mode 100644 index 0000000..fc0476e --- /dev/null +++ b/src/auth/providers/api-token.ts @@ -0,0 +1,51 @@ +import { StrapiSDKValidationError } from '../../errors'; + +import { AbstractAuthProvider } from './abstract'; + +const API_TOKEN_AUTH_STRATEGY_IDENTIFIER = 'api-token'; + +/** + * Configuration options for API token authentication. + */ +export interface ApiTokenAuthProviderOptions { + /** + * This is the Strapi API token used for authenticating requests. + * + * It should be a non-empty string + */ + token: string; +} + +export class ApiTokenAuthProvider extends AbstractAuthProvider { + public static readonly identifier = API_TOKEN_AUTH_STRATEGY_IDENTIFIER; + + constructor(options: ApiTokenAuthProviderOptions) { + super(options); + } + + public get name() { + return ApiTokenAuthProvider.identifier; + } + + private get token(): string { + return this._options.token; + } + + preflightValidation(): void { + if ((typeof this.token as unknown) !== 'string' || this.token.trim().length === 0) { + throw new StrapiSDKValidationError( + `A valid API token is required when using the api-token auth strategy. Got "${this.token}"` + ); + } + } + + authenticate(): Promise { + return Promise.resolve(); // does nothing + } + + get headers() { + return { + Authorization: `Bearer ${this.token}`, + }; + } +} diff --git a/src/auth/providers/users-permissions.ts b/src/auth/providers/users-permissions.ts new file mode 100644 index 0000000..2bc8bd9 --- /dev/null +++ b/src/auth/providers/users-permissions.ts @@ -0,0 +1,111 @@ +import { StrapiSDKError, StrapiSDKValidationError } from '../../errors'; +import { HttpClient } from '../../http'; + +import { AbstractAuthProvider } from './abstract'; + +const USERS_PERMISSIONS_AUTH_STRATEGY_IDENTIFIER = 'users-permissions'; + +/** + * Configuration options for Users & Permissions authentication. + */ +export interface UsersPermissionsAuthProviderOptions { + /** + * The unique user identifier used for authentication. + */ + identifier: string; + + /** + * The secret passphrase associated with the user's identifier + */ + password: string; +} + +/** + * Payload required for the Users & Permissions authentication process. + */ +export type UsersPermissionsAuthPayload = Pick< + UsersPermissionsAuthProviderOptions, + 'identifier' | 'password' +>; + +export class UsersPermissionsAuthProvider extends AbstractAuthProvider { + public static readonly identifier = USERS_PERMISSIONS_AUTH_STRATEGY_IDENTIFIER; + + private _token: string | null = null; + + constructor(options: UsersPermissionsAuthProviderOptions) { + super(options); + } + + public get name() { + return UsersPermissionsAuthProvider.identifier; + } + + private get credentials(): UsersPermissionsAuthPayload { + return { + identifier: this._options.identifier, + password: this._options.password, + }; + } + + preflightValidation() { + if ( + this._options === undefined || + this._options === null || + typeof this._options !== 'object' + ) { + throw new StrapiSDKValidationError( + 'Missing valid options for initializing the Users & Permissions auth provider.' + ); + } + + const { identifier, password } = this._options; + + if ((typeof identifier as unknown) !== 'string') { + throw new StrapiSDKValidationError( + `The "identifier" option must be a string, but got "${typeof identifier}"` + ); + } + + if ((typeof password as unknown) !== 'string') { + throw new StrapiSDKValidationError( + `The "password" option must be a string, but got "${typeof password}"` + ); + } + } + + get headers(): Record { + if (this._token === null) { + return {}; + } + + return { Authorization: `Bearer ${this._token}` }; + } + + async authenticate(httpClient: HttpClient): Promise { + try { + const { baseURL } = httpClient; + const localAuthURL = `${baseURL}/api/auth/local`; + + const request = new Request(localAuthURL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.credentials), + }); + + // Make sure to use the HttpClient's "_fetch" method to not perform authentication in an infinite loop. + const response = await httpClient._fetch(request); + + if (!response.ok) { + // TODO: use dedicated exceptions + throw new Error(response.statusText); + } + + const data = await response.json(); + this._token = data.jwt; + } catch (error) { + // TODO: use dedicated exceptions + throw new StrapiSDKError(error, 'Failed to authenticate with Strapi server.'); + } + } +} From fd37281b3e56c8f1bdfaca7356394d4a29a18bee Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:23:34 +0100 Subject: [PATCH 10/22] feat(auth): add authentication provider factory with type definitions --- src/auth/factory/factory.ts | 83 +++++++++++++++++++++++++++++++++++++ src/auth/factory/index.ts | 1 + src/auth/factory/types.ts | 10 +++++ 3 files changed, 94 insertions(+) create mode 100644 src/auth/factory/factory.ts create mode 100644 src/auth/factory/index.ts create mode 100644 src/auth/factory/types.ts diff --git a/src/auth/factory/factory.ts b/src/auth/factory/factory.ts new file mode 100644 index 0000000..99139fb --- /dev/null +++ b/src/auth/factory/factory.ts @@ -0,0 +1,83 @@ +import { StrapiSDKError } from '../../errors'; + +import type { AuthProviderCreator, AuthProviderMap, CreateAuthProviderParams } from './types'; +import type { AuthProvider } from '../providers'; + +/** + * A factory class responsible for creating and managing authentication providers. + * + * It facilitates the registration and creation of different authentication + * strategies which implement the AuthProvider interface. + * + * @template T_Providers Defines a map for authentication strategy names to their corresponding creator functions. + */ +export class AuthProviderFactory { + private readonly _registry = new Map, AuthProviderCreator>(); + + /** + * Creates an instance of an authentication provider based on the specified strategy. + * + * @param authStrategy The authentication strategy name to be used for creating the provider. + * @param options Configuration options required to initialize the authentication provider. + * + * @returns An instance of an AuthProvider initialized with the given options. + * + * @throws {StrapiSDKError} Throws an error if the specified strategy is not registered in the factory. + * + * @example + * ```typescript + * const factory = new AuthProviderFactory(); + * + * factory.register( + * 'api-token', + * (options: ApiTokenAuthProviderOptions) => new ApiTokenAuthProvider(options) + * ); + * + * const provider = factory.create('api-token', { jwt: 'token' }); + * ``` + */ + create | string>( + authStrategy: T_Strategy, + options: CreateAuthProviderParams + ): AuthProvider { + const creator = this._registry.get(authStrategy); + + if (!creator) { + throw new StrapiSDKError(`Auth strategy "${authStrategy}" is not supported.`); + } + + return creator(options); + } + + /** + * Registers a new authentication strategy with the factory. + * + * @param strategy The name of the authentication strategy to register. + * @param creator A function that creates an instance of an authentication provider for the specified strategy. + * + * @returns The instance of AuthProviderFactory, for chaining purpose. + * + * @example + * ```typescript + * const factory = new AuthProviderFactory(); + * + * factory + * .register( + * 'api-token', + * (options: ApiTokenAuthProviderOptions) => new ApiTokenAuthProvider(options) + * ) + * .register( + * 'users-permissions', + * (options: UsersPermissionsAuthProviderOptions) => new UsersPermissionsAuthProvider(options) + * ); + * ``` + */ + register( + strategy: T_Strategy, + creator: T_Creator + ) { + this._registry.set(strategy, creator); + + return this as AuthProviderFactory; + } +} diff --git a/src/auth/factory/index.ts b/src/auth/factory/index.ts new file mode 100644 index 0000000..d847d7a --- /dev/null +++ b/src/auth/factory/index.ts @@ -0,0 +1 @@ +export * from './factory'; diff --git a/src/auth/factory/types.ts b/src/auth/factory/types.ts new file mode 100644 index 0000000..bc641c2 --- /dev/null +++ b/src/auth/factory/types.ts @@ -0,0 +1,10 @@ +import type { AuthProvider } from '../providers'; + +export type AuthProviderCreator = (options: T) => AuthProvider; + +export type AuthProviderMap = { [key: string]: AuthProviderCreator }; + +export type CreateAuthProviderParams< + T_Providers extends AuthProviderMap, + T_Strategy extends StringKeysOf, +> = T_Providers[T_Strategy] extends AuthProviderCreator ? T_Options : unknown; From 8cbe76ffdcc00b1ad6a9889d4851bc03cfd312e0 Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:23:45 +0100 Subject: [PATCH 11/22] feat(auth): add authentication manager with strategy support --- src/auth/index.ts | 4 ++ src/auth/manager.ts | 159 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/auth/index.ts create mode 100644 src/auth/manager.ts diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..3ce71de --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,4 @@ +export * from './providers'; +export * from './factory'; + +export * from './manager'; diff --git a/src/auth/manager.ts b/src/auth/manager.ts new file mode 100644 index 0000000..ad8ba92 --- /dev/null +++ b/src/auth/manager.ts @@ -0,0 +1,159 @@ +import { HttpClient } from '../http'; + +import { AuthProviderFactory } from './factory'; +import { ApiTokenAuthProvider, UsersPermissionsAuthProvider } from './providers'; + +import type { + ApiTokenAuthProviderOptions, + AuthProvider, + UsersPermissionsAuthProviderOptions, +} from './providers'; + +/** + * Manages authentication by using different authentication providers and strategies. + * + * Responsible for the registration and management of multiple authentication strategies. + * + * It allows for setting the current strategy, authenticating requests, and tracking authentication status. + */ +export class AuthManager { + protected readonly _authProviderFactory: AuthProviderFactory; + + protected _authProvider?: AuthProvider; + protected _isAuthenticated: boolean = false; + + constructor( + // Dependencies + authProviderFactory: AuthProviderFactory = new AuthProviderFactory() + ) { + // Initialization + this._authProviderFactory = authProviderFactory; + + // Setup + this.registerDefaultProviders(); + } + + /** + * Retrieves the strategy name of the currently active authentication provider. + * + * @returns The name of the current authentication strategy, or undefined if no provider is set. + */ + get strategy(): string | undefined { + return this._authProvider?.name; + } + + /** + * Checks if the last authentication was successful and if the current provider can authenticate HTTP requests. + * + * @returns A boolean indicating whether the user is currently authenticated. + */ + get isAuthenticated() { + return this._isAuthenticated; + } + + /** + * Resets the authentication status to unauthenticated when an unauthorized error is encountered. + * + * @example + * ```typescript + * authManager.handleUnauthorizedError(); + * + * console.log(authManager.isAuthenticated); // false + * ``` + */ + handleUnauthorizedError() { + this._isAuthenticated = false; + } + + /** + * Sets the current authentication strategy with configuration options. + * + * @param strategy - The name of the authentication strategy to be set. + * @param options - Configuration options required to initialize the strategy. + * + * @example + * ```typescript + * authManager.setStrategy('api-token', { jwt: 'my-token' }); + * ``` + */ + setStrategy(strategy: string, options: unknown) { + this._authProvider = this._authProviderFactory.create(strategy, options); + } + + /** + * Performs authentication by using the current authentication provider. + * + * @param http - The HttpClient instance that can be used for the authentication process. + * + * @returns A promise that resolves when the authentication process is complete. + * + * @example + * ```typescript + * await authManager.authenticate(httpClient); + * + * console.log(authManager.isAuthenticated); // true or false depending on success + * ``` + */ + async authenticate(http: HttpClient) { + if (this._authProvider === undefined) { + this._isAuthenticated = false; + + return; + } + + try { + await this._authProvider.authenticate(http); + + this._isAuthenticated = true; + } catch { + this._isAuthenticated = false; + } + } + + /** + * Adds authentication headers to an HTTP request using the current authentication provider. + * + * @param request - The HTTP request to which authentication headers are added. + * + * @example + * ```typescript + * const request = new Request('https://api.example.com/data'); + * + * authManager.authenticateRequest(request); + * + * console.log(request.headers.get('Authorization')) // 'Bearer ' + * ``` + */ + authenticateRequest(request: Request) { + if (this._authProvider) { + const { headers } = this._authProvider; + + for (const [key, value] of Object.entries(headers)) { + request.headers.set(key, value); + } + } + } + + /** + * Registers the SDK default authentication providers in the factory so that they can be later selected. + * + * The default authentication providers are: + * - API Token ({@link ApiTokenAuthProvider}) + * - Users Permissions ({@link UsersPermissionsAuthProvider}) + * + * @note This method is called internally during initialization to set up the available strategies. + */ + protected registerDefaultProviders() { + this._authProviderFactory + // API Token + .register( + ApiTokenAuthProvider.identifier, + (options: ApiTokenAuthProviderOptions) => new ApiTokenAuthProvider(options) + ) + // Users and Permissions + .register( + UsersPermissionsAuthProvider.identifier, + (options: UsersPermissionsAuthProviderOptions) => new UsersPermissionsAuthProvider(options) + ); + } +} From 7d7a1853fad7e0a5f0843fb44c2390df1cdc6f0a Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:23:57 +0100 Subject: [PATCH 12/22] feat(validators): add URL and SDK validation components --- src/errors/index.ts | 2 + src/errors/sdk.ts | 25 ++++++ src/errors/url.ts | 17 +++++ src/validators/index.ts | 2 + src/validators/sdk.ts | 63 ++++++++++++++++ src/validators/url.ts | 163 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 272 insertions(+) create mode 100644 src/errors/index.ts create mode 100644 src/errors/sdk.ts create mode 100644 src/errors/url.ts create mode 100644 src/validators/index.ts create mode 100644 src/validators/sdk.ts create mode 100644 src/validators/url.ts diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 0000000..ecd846c --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,2 @@ +export * from './sdk'; +export * from './url'; diff --git a/src/errors/sdk.ts b/src/errors/sdk.ts new file mode 100644 index 0000000..a2107ea --- /dev/null +++ b/src/errors/sdk.ts @@ -0,0 +1,25 @@ +export class StrapiSDKError extends Error { + constructor( + cause: unknown = undefined, + message: string = 'An error occurred in the Strapi SDK. Please check the logs for more information.' + ) { + super(message); + + this.cause = cause; + } +} + +export class StrapiSDKValidationError extends StrapiSDKError { + constructor( + cause: unknown = undefined, + message: string = 'Some of the provided values are not valid.' + ) { + super(cause, message); + } +} + +export class StrapiSDKInitializationError extends StrapiSDKError { + constructor(cause: unknown = undefined, message: string = 'Could not initialize the Strapi SDK') { + super(cause, message); + } +} diff --git a/src/errors/url.ts b/src/errors/url.ts new file mode 100644 index 0000000..8b086a3 --- /dev/null +++ b/src/errors/url.ts @@ -0,0 +1,17 @@ +export class URLValidationError extends Error {} + +export class URLParsingError extends URLValidationError { + constructor(url: string) { + super(`Could not parse invalid URL: "${url}"`); + } +} + +export class URLProtocolValidationError extends URLValidationError { + constructor(url: URL | string, allowedProtocols: string[]) { + const formattedProtocols = allowedProtocols.map((protocol) => `"${protocol}"`).join(', '); + + super( + `Only ${formattedProtocols} protocols are supported, but got "${new URL(url).protocol}" instead.` + ); + } +} diff --git a/src/validators/index.ts b/src/validators/index.ts new file mode 100644 index 0000000..acbffb3 --- /dev/null +++ b/src/validators/index.ts @@ -0,0 +1,2 @@ +export { StrapiSDKValidator } from './sdk'; +export { URLValidator } from './url'; diff --git a/src/validators/sdk.ts b/src/validators/sdk.ts new file mode 100644 index 0000000..db20148 --- /dev/null +++ b/src/validators/sdk.ts @@ -0,0 +1,63 @@ +import { StrapiSDKValidationError, URLValidationError } from '../errors'; + +import { URLValidator } from './url'; + +import type { StrapiSDKConfig } from '../sdk'; + +/** + * Provides the ability to validate the configuration used for initializing the Strapi SDK. + * + * This includes URL validation to ensure compatibility with Strapi's API endpoints. + */ +export class StrapiSDKValidator { + private readonly _urlValidator: URLValidator; + + constructor( + // Dependencies + urlValidator: URLValidator = new URLValidator() + ) { + this._urlValidator = urlValidator; + } + + /** + * Validates the provided SDK configuration, ensuring that all values are + * suitable for the SDK operations.. + * + * @param config - The configuration object for the Strapi SDK. Must include a `baseURL` property indicating the API's endpoint. + * + * @throws {StrapiSDKValidationError} If the configuration is invalid, or if the baseURL is invalid. + */ + validateConfig(config: StrapiSDKConfig) { + if ( + config === undefined || + config === null || + Array.isArray(config) || + typeof config !== 'object' + ) { + throw new StrapiSDKValidationError( + new TypeError('The provided configuration is not a valid object.') + ); + } + + this.validateBaseURL(config.baseURL); + } + + /** + * Validates the base URL, ensuring it follows acceptable protocols and structure for reliable API interaction. + * + * @param url - The base URL string to validate. + * + * @throws {StrapiSDKValidationError} If the URL is invalid or if it fails through the URLValidator checks. + */ + private validateBaseURL(url: unknown) { + try { + this._urlValidator.validate(url); + } catch (e) { + if (e instanceof URLValidationError) { + throw new StrapiSDKValidationError(e); + } + + throw e; + } + } +} diff --git a/src/validators/url.ts b/src/validators/url.ts new file mode 100644 index 0000000..7f3f444 --- /dev/null +++ b/src/validators/url.ts @@ -0,0 +1,163 @@ +import { URLParsingError, URLProtocolValidationError } from '../errors'; + +/** + * Configuration settings for the URL validator. + */ +export interface URLValidatorConfig { + /** An array of URL protocols that are considered valid */ + allowedProtocols: URLProtocol[]; +} + +/** + * Represents a set of predefined URL protocols. + * + * This type encompasses a list of commonly used URL schemes, including: + * - 'http:': Hypertext Transfer Protocol + * - 'https:': Secure Hypertext Transfer Protocol + * - 'ftp:': File Transfer Protocol + * - 'ftps:': Secure File Transfer Protocol + * - 'ws:': WebSocket Protocol + * - 'wss:': Secure WebSocket Protocol + * - 'sftp:': Secure File Transfer Protocol via SSH + * - 'mailto:': Email Addressing + * - 'file:': Local File Access + * - 'data:': In-line Data + * - 'git:': Git Version Control System + * - 'ssh:': Secure Shell Protocol + * - 'telnet:': Telnet Protocol + * - 'ldap:': Lightweight Directory Access Protocol + * - 'ldaps:': Secure LDAP + * - 'scp:': Secure Copy Protocol + * - 'gopher:': Gopher Protocol + * - 'irc:': Internet Relay Chat + */ +export type URLProtocol = + | 'http:' + | 'https:' + | 'ftp:' + | 'ftps:' + | 'ws:' + | 'wss:' + | 'sftp:' + | 'mailto:' + | 'file:' + | 'data:' + | 'git:' + | 'ssh:' + | 'telnet:' + | 'ldap:' + | 'ldaps:' + | 'scp:' + | 'gopher:' + | 'irc:'; + +/** + * Class representing a URLValidator. + * + * It provides the ability to validate URLs based on a predefined list of allowed protocols. + */ +export class URLValidator { + private static readonly DEFAULT_CONFIG: URLValidatorConfig = { + allowedProtocols: ['http:', 'https:'], + }; + + public readonly config: URLValidatorConfig; + + constructor(config: URLValidatorConfig = URLValidator.DEFAULT_CONFIG) { + this.config = config; + } + + /** + * Validates the provided URL. + * + * This method checks that the provided URL is a string, can be parsed, and uses an allowed protocol. + * + * @param url - The URL to be validated. This parameter must be a valid string representation of a URL. + * + * @throws {URLParsingError} Thrown if the URL is not a string or can't be parsed. + * @throws {URLProtocolValidationError} Thrown if the URL uses an unsupported protocol according to the validator's configuration. + * + * @example + * // Example of validating a URL successfully + * const validator = new URLValidator(); + * + * const url = 'http://example.com'; + * + * validator.validate(url); // Does not throw an error + * + * @example + * // Example of a failing validation due to unsupported protocol + * const validator = new URLValidator(); + * + * const url = 'ftp://example.com'; + * + * try { + * validator.validate(url); + * } catch (error) { + * console.error(error); // URLProtocolValidationError + * } + */ + validate(url: unknown) { + this.preValidation(url); + + const parsedURL = new URL(url); + + this.validateProtocol(parsedURL); + } + + /** + * Ensures the URL is a string and can be parsed. + * + * @param url - The URL to be validated. + * + * @throws {URLParsingError} Thrown if the URL is not a string or can't be parsed. + */ + private preValidation(url: unknown): asserts url is string { + if (typeof url !== 'string') { + throw new URLParsingError(`The provided URL is not a string. Got "${typeof url}"`); + } + + const canParse = this.canParse(url); + + if (!canParse) { + throw new URLParsingError(url); + } + } + + /** + * Validates that the URL uses a protocol allowed by the configuration. + * + * @param url - A URL object representing the parsed URL. + * + * @throws {URLProtocolValidationError} Thrown if the URL's protocol is not in the allowed list. + */ + private validateProtocol(url: URL) { + const hasValidProtocol = this.hasValidProtocol(url); + + if (!hasValidProtocol) { + throw new URLProtocolValidationError(url, this.config.allowedProtocols); + } + } + + /** + * Checks if the URL string can be parsed. + * + * @param url - The URL string to be checked. + * + * @returns A boolean indicating whether the URL can be parsed. + */ + private canParse(url: string): boolean { + return URL.canParse(url); + } + + /** + * Checks if the protocol of the URL is valid based on the allowed protocols. + * + * @param url - A URL object. + * + * @returns A boolean indicating whether the protocol is in the allowed list. + */ + private hasValidProtocol(url: URL): boolean { + return this.config.allowedProtocols.includes(url.protocol as URLProtocol); + } +} From 711f3ba987c7396372103e93948f606a75f8fe40 Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:24:09 +0100 Subject: [PATCH 13/22] feat(http): add initial HttpClient implementation with auth and url validation --- src/global.d.ts | 22 ++++++ src/http/client.ts | 162 +++++++++++++++++++++++++++++++++++++++++++++ src/http/index.ts | 1 + 3 files changed, 185 insertions(+) create mode 100644 src/global.d.ts create mode 100644 src/http/client.ts create mode 100644 src/http/index.ts diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..2e3415d --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,22 @@ +/** + * Extracts `string` keys from the properties of a given {@link T}. + * + * This utility type is beneficial when dealing with mapped types or interfaces where it is necessary to + * operate only on properties whose keys are specifically of the `string` type. + * + * @template T The target object type from which string keys are extracted. + * + * @example + * // Define an interface with different types of keys. + interface Example { + name: string; + age: number; + [key: symbol]: boolean; + } + + // Use `StringKeysOf` to extract only the string keys. + type StringKeys = StringKeysOf; // Results in "name" | "age" + * + * @remark This type is particularly useful in scenarios involving conditional types and mapped type utilities. + */ +type StringKeysOf = keyof T & string; diff --git a/src/http/client.ts b/src/http/client.ts new file mode 100644 index 0000000..515da8b --- /dev/null +++ b/src/http/client.ts @@ -0,0 +1,162 @@ +import { AuthManager } from '../auth'; +import { URLValidator } from '../validators'; + +export type Fetch = typeof globalThis.fetch; + +/** + * Strapi SDK's HTTP Client + * + * Provides methods for configuring the base URL, authentication strategies, + * and for performing HTTP requests with automatic header management and URL validation. + */ +export class HttpClient { + // Properties + private _baseURL: string; + + // Dependencies + private readonly _authManager: AuthManager; + private readonly _urlValidator: URLValidator; + + constructor( + // Properties + baseURL: string, + + // Dependencies + authManager = new AuthManager(), + urlValidator: URLValidator = new URLValidator() + ) { + // Initialization + this._baseURL = baseURL; + + this._authManager = authManager; + this._urlValidator = urlValidator; + + // Validation + this._urlValidator.validate(this._baseURL); + } + + /** + * Gets the currently set base URL. + * + * @returns The base URL used for HTTP requests. + */ + get baseURL(): string { + return this._baseURL; + } + + /** + * Sets a new base URL for the HTTP client and validates it. + * + * @param url - The new base URL to set. + * + * @returns The HttpClient instance for chaining. + * + * @throws {URLParsingError} If the URL cannot be parsed. + * @throws {URLProtocolValidationError} If the URL uses an unsupported protocol. + * + * @example + * const client = new HttpClient('http://example.com'); + * + * client.setBaseURL('http://newexample.com'); + */ + setBaseURL(url: string): this { + this._urlValidator.validate(url); + + this._baseURL = url; + + return this; + } + + /** + * Sets the authentication strategy for the HTTP client. + * + * Configures how the client handles authentication based on the specified strategy and options. + * + * @param strategy - The authentication strategy to use. + * @param options - Additional options required for the authentication strategy. + * + * @throws {StrapiSDKError} If the given strategy is not supported + * + * @returns The HttpClient instance for chaining. + * + * @example + * client.setAuthStrategy('api-token', { token: 'abc123' }); + */ + setAuthStrategy(strategy: string, options: unknown): this { + this._authManager.setStrategy(strategy, options); + + return this; + } + + /** + * Performs an HTTP fetch request to the specified URL. + * + * Attaches the necessary headers, authenticates if required, and handles unauthorized errors. + * + * @param url - The URL to which the request is made, appended to the base URL. + * @param [init] - Optional object containing any custom settings to apply to the fetch request. + * + * @returns A promise that resolves to the HTTP response. + * + * @throws {Error} If the authentication can't be completed, or if the server can't be reached + * + * @example + * client.fetch('/data') + * .then(response => response.json()) + * .then(data => console.log(data)); + */ + async fetch(url: string, init?: RequestInit): Promise { + if (!this._authManager.isAuthenticated) { + await this._authManager.authenticate(this); + } + + const request = new Request(`${this._baseURL}${url}`, init); + + this.attachHeaders(request); + + const response = await this._fetch(request); + + if (response.status === 401) { + this._authManager.handleUnauthorizedError(); + } + + return response; + } + + /** + * Executes an HTTP fetch request using the Fetch API. + * + * @param url - The target URL for the HTTP request which can be a string URL or a `Request` object. + * @param [init] - An optional `RequestInit` object that contains any custom settings that you want to apply to the request. + * + * @returns A promise that resolves to the `Response` object representing the complete HTTP response. + * + * @additionalInfo + * - This method doesn't perform any authentication or header customization. + * It directly passes the parameters to the global `fetch` function. + * - To include authentication, consider using the `fetch` method from the `HttpClient` class, which handles headers and authentication. + */ + async _fetch(url: RequestInfo, init?: RequestInit): Promise { + return globalThis.fetch(url, init); + } + + /** + * Attaches default and authentication headers to an HTTP request. + * + * This method ensures that a default 'Content-Type' header is set for the request if it is not already specified. + * + * It also delegates to the AuthManager to append any necessary authentication headers, + * potentially overwriting existing ones to ensure correct authorization. + * + * @param request - The HTTP request object to which headers are added. + */ + private attachHeaders(request: Request) { + // Set the default content-type header if it hasn't been defined already + if (!request.headers.has('Content-Type')) { + request.headers.set('Content-Type', 'application/json'); + } + + // Set auth headers if available, potentially overwrite manually set auth headers + this._authManager.authenticateRequest(request); + } +} diff --git a/src/http/index.ts b/src/http/index.ts new file mode 100644 index 0000000..4f1cce4 --- /dev/null +++ b/src/http/index.ts @@ -0,0 +1 @@ +export * from './client'; From 0dcb4f21c7107a1cecbeed74258d1d9dba5a5964 Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:24:48 +0100 Subject: [PATCH 14/22] feat(sdk): implement Strapi SDK --- src/index.ts | 59 ++++++++++++++++- src/sdk.ts | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 src/sdk.ts diff --git a/src/index.ts b/src/index.ts index b8c0015..fee8656 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,56 @@ -export function greetings() { - return 'Hello World!'; -} +import { StrapiSDK } from './sdk'; +import { StrapiSDKValidator, URLValidator } from './validators'; + +import type { StrapiSDKConfig } from './sdk'; + +/** + * Creates a new instance of the Strapi SDK with a specified configuration. + * + * The Strapi SDK functions as a client library to interface with the Strapi content API. + * + * It facilitates reliable and secure interactions with Strapi's APIs by handling URL validation, + * request dispatch, and response parsing for content management. + * + * @param config - The configuration for initializing the SDK. This should include the base URL + * of the Strapi backend instance that the SDK communicates with. The baseURL + * must be formatted with one of the supported protocols: `http` or `https`. + * Additionally, optional authentication details can be specified within the config. + * + * @returns An instance of the Strapi SDK configured with the specified baseURL and auth settings. + * + * @example + * ```typescript + * // Basic configuration using API token auth + * const sdkConfig = { + * baseURL: 'https://api.example.com', + * auth: { + * strategy: 'api-token', + * options: { token: 'your_token_here' } + * } + * }; + * + * // Create the SDK instance + * const strapiSDK = createStrapiSDK(sdkConfig); + * + * // Using the SDK to fetch content from a custom endpoint + * const response = await strapiSDK.fetch('/content-endpoint'); + * const data = await response.json(); + * + * console.log(data); + * ``` + * + * @throws {StrapiSDKInitializationError} If the provided baseURL does not conform to a valid HTTP or HTTPS URL, + * or if the auth configuration is invalid. + */ +export const createStrapiSDK = (config: StrapiSDKConfig) => { + // TODO allow setting custom url validation rules from the configuration + const urlValidator = new URLValidator({ allowedProtocols: ['http:', 'https:'] }); + const sdkValidator = new StrapiSDKValidator(urlValidator); + + return new StrapiSDK( + // Properties + config, + // Dependencies + sdkValidator + ); +}; diff --git a/src/sdk.ts b/src/sdk.ts new file mode 100644 index 0000000..44e0d8c --- /dev/null +++ b/src/sdk.ts @@ -0,0 +1,179 @@ +import { StrapiSDKInitializationError } from './errors'; +import { HttpClient } from './http'; +import { StrapiSDKValidator } from './validators'; + +export interface StrapiSDKConfig { + baseURL: string; + auth?: AuthConfig; +} + +export interface AuthConfig { + strategy: string; + options: T; +} + +/** + * Class representing the Strapi SDK to interface with a Strapi backend. + * + * This class integrates setting up configuration, validation, and handling + * HTTP requests with authentication. + * + * It serves as the main interface through which users interact with + * their Strapi installation programmatically. + * + * @template T_Config - Configuration type inferred from the user-provided SDK configuration + */ +export class StrapiSDK { + /** @internal */ + private readonly _config: T_Config; + + /** @internal */ + private readonly _validator: StrapiSDKValidator; + + /** @internal */ + private readonly _httpClient: HttpClient; + + /** @internal */ + constructor( + // Properties + config: T_Config, + + // Dependencies + validator: StrapiSDKValidator = new StrapiSDKValidator(), + httpClientFactory?: (url: string) => HttpClient + ) { + // Properties + this._config = config; + this._validator = validator; + + // Validation + this.preflightValidation(); + + // The HTTP client depends on the preflightValidation for the baseURL validity. + // It could be instantiated before but would throw an invalid URL error + // instead of the SDK itself throwing an initialization exception. + this._httpClient = httpClientFactory?.(config.baseURL) ?? new HttpClient(config.baseURL); + + this.init(); + } + + /** + * Performs preliminary validation of the SDK configuration. + * + * This method ensures that the provided configuration for the SDK is valid by using the + * internal SDK validator. It is invoked during the initialization process to confirm that + * all necessary parts are correctly configured before effectively using the SDK. + * + * @throws {StrapiSDKInitializationError} If the configuration validation fails, indicating an issue with the SDK initialization process. + * + * @example + * // Creating a new instance of StrapiSDK which triggers preflightValidation + * const config = { + * baseURL: 'https://example.com', + * auth: { + * strategy: 'jwt', + * options: { token: 'your-token-here' } + * } + * }; + * const sdk = new StrapiSDK(config); + * + * // The preflightValidation is automatically called within the constructor + * // to ensure the provided config is valid prior to any further setup. + * + * @note This method is private and only called internally during SDK initialization. + * + * @internal + */ + private preflightValidation() { + try { + this._validator.validateConfig(this._config); + } catch (e) { + throw new StrapiSDKInitializationError(e); + } + } + + /** + * Initializes the configuration settings for the SDK. + * + * Sets up the necessary parts required for the SDK's operation, + * including setting up an authentication strategy if provided. + * + * @throws {StrapiSDKValidationError} From the _httpClient if the baseURL is invalid. + * + * @note + * - This method is private and internally invoked only during SDK initialization. + * - Although this method technically -can- throw a validation error, the baseURL + * should already have been validated during the SDK preflight validation. + * + * @internal + */ + private init() { + if (this.auth) { + const { strategy, options } = this.auth; + + this._httpClient.setAuthStrategy(strategy, options); + } + } + + /** + * Retrieves the authentication configuration for the Strapi SDK. + * + * @note This is a private property used internally within the SDK for configuring authentication in the HTTP layer. + * + * @internal + */ + private get auth() { + return this._config.auth; + } + + /** + * Retrieves the base URL of the Strapi SDK instance. + * + * This getter returns the `baseURL` property stored within the SDK's configuration object. + * + * The base URL is used as the starting point for all HTTP requests initiated through the SDK. + * + * @returns The current base URL configured in the SDK. + * This URL typically represents the root endpoint of the Strapi service the SDK interfaces with. + * + * @example + * const config = { baseURL: 'http://localhost:1337' }; + * const sdk = new StrapiSDK(config); + * + * console.log(sdk.baseURL); // Output: http://localhost:1337 + */ + public get baseURL(): string { + return this._config.baseURL; + } + + /** + * Executes an HTTP fetch request to a specified endpoint using the SDK HTTP client. + * + * This method ensures authentication is handled before issuing requests and sets the necessary headers. + * + * @param url - The endpoint to fetch from, appended to the base URL of the SDK. + * @param [init] - Optional initialization options for the request, such as headers or method type. + * + * @example + * ```typescript + * // Create the SDK instance + * const sdk = createStrapiSDK({ baseURL: 'http://localhost:1337' ); + * + * // Perform a custom fetch query + * const response = await sdk.fetch('/api/categories'); + * + * // Parse the categories into a readable JSON object + * const categories = await response.json(); + * + * // Log the categories + * console.log(categories); + * ``` + * + * @note + * - The method automatically handles authentication by checking if the user is authenticated and attempts to authenticate if not. + * - The base URL is prepended to the provided endpoint path. + */ + fetch(url: string, init?: RequestInit) { + return this._httpClient.fetch(url, init); + } +} From 3cd4bb682dce08067363a37a4be8f3834b061dc6 Mon Sep 17 00:00:00 2001 From: Convly Date: Fri, 29 Nov 2024 23:25:21 +0100 Subject: [PATCH 15/22] test: add unit tests for full coverage --- tests/fixtures/invalid-urls.json | 14 ++ tests/unit/auth/factory.test.ts | 35 ++++ tests/unit/auth/manager.test.ts | 126 +++++++++++++ tests/unit/auth/providers/api-token.test.ts | 94 ++++++++++ .../auth/providers/users-permissions.test.ts | 144 +++++++++++++++ tests/unit/errors/sdk.test.ts | 108 +++++++++++ tests/unit/errors/url.test.ts | 71 +++++++ tests/unit/http/client.test.ts | 173 ++++++++++++++++++ tests/unit/index.test.ts | 7 - tests/unit/mocks/auth-manager.mock.ts | 13 ++ .../unit/mocks/auth-provider-factory.mock.ts | 11 ++ tests/unit/mocks/auth-provider.mock.ts | 25 +++ tests/unit/mocks/http-client.mock.ts | 26 +++ tests/unit/mocks/index.ts | 6 + tests/unit/mocks/sdk-validator.mock.ts | 3 + tests/unit/mocks/url-validator.mock.ts | 3 + tests/unit/sdk.test.ts | 91 +++++++++ tests/unit/sdk/initialization.test.ts | 154 ++++++++++++++++ tests/unit/validators/sdk.test.ts | 65 +++++++ tests/unit/validators/url.test.ts | 72 ++++++++ 20 files changed, 1234 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/invalid-urls.json create mode 100644 tests/unit/auth/factory.test.ts create mode 100644 tests/unit/auth/manager.test.ts create mode 100644 tests/unit/auth/providers/api-token.test.ts create mode 100644 tests/unit/auth/providers/users-permissions.test.ts create mode 100644 tests/unit/errors/sdk.test.ts create mode 100644 tests/unit/errors/url.test.ts create mode 100644 tests/unit/http/client.test.ts delete mode 100644 tests/unit/index.test.ts create mode 100644 tests/unit/mocks/auth-manager.mock.ts create mode 100644 tests/unit/mocks/auth-provider-factory.mock.ts create mode 100644 tests/unit/mocks/auth-provider.mock.ts create mode 100644 tests/unit/mocks/http-client.mock.ts create mode 100644 tests/unit/mocks/index.ts create mode 100644 tests/unit/mocks/sdk-validator.mock.ts create mode 100644 tests/unit/mocks/url-validator.mock.ts create mode 100644 tests/unit/sdk.test.ts create mode 100644 tests/unit/sdk/initialization.test.ts create mode 100644 tests/unit/validators/sdk.test.ts create mode 100644 tests/unit/validators/url.test.ts diff --git a/tests/fixtures/invalid-urls.json b/tests/fixtures/invalid-urls.json new file mode 100644 index 0000000..c2e890b --- /dev/null +++ b/tests/fixtures/invalid-urls.json @@ -0,0 +1,14 @@ +{ + "impossibleToParse": [ + ["", "empty string"], + ["foobar", "regular string"], + ["example.com", "missing protocol"] + ], + "unsupportedProtocols": [ + ["ftp://example.com", "ftp"], + ["ftps://example.com", "ftps"], + ["ws://example.com", "websocket"], + ["wss://example.com", "secure websocket"], + ["file://example.com", "file"] + ] +} diff --git a/tests/unit/auth/factory.test.ts b/tests/unit/auth/factory.test.ts new file mode 100644 index 0000000..c7f92eb --- /dev/null +++ b/tests/unit/auth/factory.test.ts @@ -0,0 +1,35 @@ +import { AuthProviderFactory } from '../../../src/auth'; +import { StrapiSDKError } from '../../../src/errors'; +import { MockAuthProvider } from '../mocks'; + +describe('AuthProviderFactory', () => { + let factory: AuthProviderFactory; + + beforeEach(() => { + factory = new AuthProviderFactory(); + }); + + it('should throw an error if an unregistered strategy is provided', () => { + // Arrange + const invalidStrategyName = ''; + + // Act & Assert + expect(() => { + factory.create(invalidStrategyName, {}); + }).toThrow(StrapiSDKError); + }); + + it('should create a valid instance for registered providers', () => { + // Arrange + const mockCreator = jest.fn(() => new MockAuthProvider()); + + // Act + factory.register(MockAuthProvider.identifier, mockCreator); + + const provider = factory.create(MockAuthProvider.identifier, undefined); + + // Assert + expect(provider).toBeDefined(); + expect(provider).toBeInstanceOf(MockAuthProvider); + }); +}); diff --git a/tests/unit/auth/manager.test.ts b/tests/unit/auth/manager.test.ts new file mode 100644 index 0000000..cdb2371 --- /dev/null +++ b/tests/unit/auth/manager.test.ts @@ -0,0 +1,126 @@ +import { ApiTokenAuthProvider, AuthManager, UsersPermissionsAuthProvider } from '../../../src/auth'; +import { MockAuthProvider, MockAuthProviderFactory, MockHttpClient } from '../mocks'; + +describe('AuthManager', () => { + const mockHttpClient = new MockHttpClient('https://example.com'); + + describe('Default Registered Strategies', () => { + it.each([ + [ApiTokenAuthProvider.identifier, ApiTokenAuthProvider, { token: '' }], + [ + UsersPermissionsAuthProvider.identifier, + UsersPermissionsAuthProvider, + { identifier: '', password: '' }, + ], + ])( + 'should have a strategy registered by default: "%s"', + (providerName, providerClass, options) => { + // Arrange + const mockAuthProviderFactory = new MockAuthProviderFactory(); + const authManager = new AuthManager(mockAuthProviderFactory); + + // Act + const instance = mockAuthProviderFactory.create(providerName, options); + + // Assert + expect(authManager).toBeInstanceOf(AuthManager); + expect(instance).toBeInstanceOf(providerClass); + } + ); + }); + + it('should have no strategy selected after initialization', () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + + // Assert + expect(authManager.strategy).toBeUndefined(); + }); + + it('should set strategy correctly', () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + const strategy = MockAuthProvider.identifier; + + // Act + authManager.setStrategy(strategy, {}); + + // Assert + expect(authManager.strategy).toBe(MockAuthProvider.identifier); + }); + + it('should not authenticated when strategy is not set', async () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + + // Act + await authManager.authenticate(mockHttpClient); + + // Assert + expect(authManager.isAuthenticated).toBe(false); + }); + + it('should authenticate correctly when strategy is set', async () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + authManager.setStrategy(MockAuthProvider.identifier, {}); + + // Act + await authManager.authenticate(mockHttpClient); + + // Assert + expect(authManager.isAuthenticated).toBe(true); + }); + + it('should handle unauthorized error properly', async () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + authManager.setStrategy(MockAuthProvider.identifier, {}); + + // Act + await authManager.authenticate(mockHttpClient); + authManager.handleUnauthorizedError(); + + // Assert + expect(authManager.isAuthenticated).toBe(false); + }); + + it('should not do anything if authenticate is called without setting strategy', async () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + + // Assert + await expect(authManager.authenticate(mockHttpClient)).resolves.toBeUndefined(); + expect(authManager.isAuthenticated).toBe(false); + }); + + it('should remove authentication if authenticate throws an error', async () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + authManager.setStrategy(MockAuthProvider.identifier, {}); + + jest.spyOn(MockAuthProvider.prototype, 'authenticate').mockImplementationOnce(() => { + throw new Error(); + }); + + // Act + await authManager.authenticate(mockHttpClient); + + // Assert + expect(authManager.isAuthenticated).toBe(false); + }); + + it('should authenticate request correctly', () => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + const mockRequest = new Request('https://example.com', { headers: new Headers() }); + + authManager.setStrategy(MockAuthProvider.identifier, {}); + + // Act + authManager.authenticateRequest(mockRequest); + + // Assert + expect(mockRequest.headers.get('Authorization')).toBe('Bearer '); + }); +}); diff --git a/tests/unit/auth/providers/api-token.test.ts b/tests/unit/auth/providers/api-token.test.ts new file mode 100644 index 0000000..3f466a6 --- /dev/null +++ b/tests/unit/auth/providers/api-token.test.ts @@ -0,0 +1,94 @@ +import { ApiTokenAuthProvider, ApiTokenAuthProviderOptions } from '../../../../src/auth'; +import { StrapiSDKValidationError } from '../../../../src/errors'; + +describe('ApiTokenAuthProvider', () => { + describe('Name', () => { + it('should return the static provider name from the instance', () => { + // Arrange + const token = 'abc-xyz'; + const provider = new ApiTokenAuthProvider({ token }); + + // Act + const name = provider.name; + + // Assert + expect(name).toBe(ApiTokenAuthProvider.identifier); + }); + }); + + describe('Preflight Validation', () => { + let spy: jest.SpyInstance; + + beforeEach(() => { + spy = jest.spyOn(ApiTokenAuthProvider.prototype, 'preflightValidation'); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + it('should throw error if token is invalid in preflightValidation', () => { + // Arrange + const token = ' '; + + // Act & Assert + expect(() => new ApiTokenAuthProvider({ token })).toThrow(StrapiSDKValidationError); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should not throw error if token is valid in preflightValidation', () => { + // Arrange + const token = 'abc-xyz'; + + // Act & Assert + expect(() => new ApiTokenAuthProvider({ token })).not.toThrow(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should throw error when token is null in preflightValidation', () => { + // Arrange + const options = { token: null } as unknown as ApiTokenAuthProviderOptions; + + // Act & Assert + expect(() => new ApiTokenAuthProvider(options)).toThrow(StrapiSDKValidationError); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Authenticate', () => { + it('should doe nothing when authenticate is called', async () => { + // Arrange + const token = 'abc-xyz'; + const provider = new ApiTokenAuthProvider({ token }); + + // Act & Assert + await expect(provider.authenticate()).resolves.not.toThrow(); + }); + }); + + describe('Headers', () => { + it('should return correct headers with valid token', () => { + // Arrange + const token = 'abc-xyz'; + const provider = new ApiTokenAuthProvider({ token }); + + // Act + const headers = provider.headers; + + // Assert + expect(headers).toEqual({ Authorization: `Bearer ${token}` }); + }); + + it('should maintain immutability of headers', () => { + // Arrange + const token = 'abc-xyz'; + const provider = new ApiTokenAuthProvider({ token }); + + // Act + provider.headers.Authorization = 'Modified_1'; + + // Assert + expect(provider.headers.Authorization).toEqual(`Bearer ${token}`); + }); + }); +}); diff --git a/tests/unit/auth/providers/users-permissions.test.ts b/tests/unit/auth/providers/users-permissions.test.ts new file mode 100644 index 0000000..71fe807 --- /dev/null +++ b/tests/unit/auth/providers/users-permissions.test.ts @@ -0,0 +1,144 @@ +import { + UsersPermissionsAuthProvider, + UsersPermissionsAuthProviderOptions, +} from '../../../../src/auth'; +import { StrapiSDKError, StrapiSDKValidationError } from '../../../../src/errors'; +import { HttpClient } from '../../../../src/http'; +import { MockHttpClient } from '../../mocks'; + +const FAKE_TOKEN = ''; +const FAKE_VALID_CONFIG: UsersPermissionsAuthProviderOptions = { + identifier: 'user@example.com', + password: 'securePassword123', +}; + +class ValidFakeHttpClient extends MockHttpClient { + async _fetch() { + return new Response(JSON.stringify({ jwt: FAKE_TOKEN }), { status: 200 }); + } +} + +class FaultyFakeHttpClient extends HttpClient { + async _fetch() { + return new Response('Bad request', { status: 400 }); + } +} + +describe('UsersPermissionsAuthProvider', () => { + const fakeHttpClient = new ValidFakeHttpClient('https://example.com'); + const faultyHttpClient = new FaultyFakeHttpClient('https://example.com'); + + describe('Name', () => { + it('should return the static provider name from the instance', () => { + // Arrange + const identifier = 'user@example.com'; + const password = 'securePassword123'; + const provider = new UsersPermissionsAuthProvider({ identifier, password }); + + // Act + const name = provider.name; + + // Assert + expect(name).toBe(UsersPermissionsAuthProvider.identifier); + }); + }); + + describe('Preflight Validation', () => { + let spy: jest.SpyInstance; + + beforeEach(() => { + spy = jest.spyOn(UsersPermissionsAuthProvider.prototype, 'preflightValidation'); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + it.each([ + { identifier: null, password: 'securePassword123' }, + { identifier: true, password: 'securePassword123' }, + { identifier: 42, password: 'securePassword123' }, + { identifier: 'user@example.com', password: null }, + { identifier: 'user@example.com', password: true }, + { identifier: 'user@example.com', password: 42 }, + undefined, + null, + 'not_an_object', + 42, + ])('should throw error if credentials are invalid in preflightValidation: %s', (options) => { + // Act & Assert + expect( + () => + new UsersPermissionsAuthProvider( + options as unknown as UsersPermissionsAuthProviderOptions + ) + ).toThrow(StrapiSDKValidationError); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should not throw error if identifier and password are valid in preflightValidation', () => { + // Arrange + const options = { identifier: 'user@example.com', password: 'securePassword123' }; + + // Act & Assert + expect(() => new UsersPermissionsAuthProvider(options)).not.toThrow(); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Authenticate', () => { + it('should authenticate successfully without throwing', async () => { + // Arrange + const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); + + // Act & Assert + await expect(provider.authenticate(fakeHttpClient)).resolves.not.toThrow(); + }); + + it('should set the headers correctly with the received token', async () => { + // Arrange + const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); + + // Act + await provider.authenticate(fakeHttpClient); + + // Assert + expect(provider.headers).toEqual({ Authorization: `Bearer ${FAKE_TOKEN}` }); + }); + + it('should throw if it fails to authenticate', async () => { + // Arrange + const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); + + // Act & Assert + await expect(provider.authenticate(faultyHttpClient)).rejects.toThrow(StrapiSDKError); + }); + }); + + describe('Headers', () => { + it('should maintain immutability of headers', async () => { + // Arrange + const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); + + // Act + await provider.authenticate(fakeHttpClient); + + // It shouldn't be possible to modify the headers manually + provider.headers.Authorization = 'Modified_1'; + + // Assert + expect(provider.headers.Authorization).toEqual(`Bearer ${FAKE_TOKEN}`); + }); + + it('should return an empty object if the provider has not perform authentication yet', () => { + // Arrange + const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); + + // Act + const { headers } = provider; + + // Assert + expect(headers).toEqual({}); + }); + }); +}); diff --git a/tests/unit/errors/sdk.test.ts b/tests/unit/errors/sdk.test.ts new file mode 100644 index 0000000..c322f15 --- /dev/null +++ b/tests/unit/errors/sdk.test.ts @@ -0,0 +1,108 @@ +import { + StrapiSDKError, + StrapiSDKInitializationError, + StrapiSDKValidationError, +} from '../../../src/errors'; + +describe('SDK Errors', () => { + describe('StrapiSDKError', () => { + it('should have a default message', () => { + // Act + const error = new StrapiSDKError(); + + // Assert + expect(error.message).toBe( + 'An error occurred in the Strapi SDK. Please check the logs for more information.' + ); + expect(error.cause).toBeUndefined(); + }); + + it('should allow a custom message', () => { + // Arrange + const customMessage = 'Custom error message.'; + + // Act + const error = new StrapiSDKError(undefined, customMessage); + + // Assert + expect(error.message).toBe(customMessage); + }); + + it('should set the cause if provided', () => { + // Arrange + const cause = new Error('Root cause'); + + // Act + const error = new StrapiSDKError(cause); + + // Assert + expect(error.cause).toBe(cause); + }); + }); + + describe('StrapiSDKValidationError', () => { + it('should have a default message', () => { + // Act + const error = new StrapiSDKValidationError(); + + // Assert + expect(error.message).toBe('Some of the provided values are not valid.'); + expect(error.cause).toBeUndefined(); + }); + + it('should allow a custom message', () => { + // Arrange + const customMessage = 'Validation error occurred.'; + + // Act + const error = new StrapiSDKValidationError(undefined, customMessage); + + // Assert + expect(error.message).toBe(customMessage); + }); + + it('should set the cause if provided', () => { + // Arrange + const cause = new Error('Validation root cause'); + + // Act + const error = new StrapiSDKValidationError(cause); + + // Assert + expect(error.cause).toBe(cause); + }); + }); + + describe('StrapiSDKInitializationError', () => { + it('should have a default message', () => { + // Act + const error = new StrapiSDKInitializationError(); + + // Assert + expect(error.message).toBe('Could not initialize the Strapi SDK'); + expect(error.cause).toBeUndefined(); + }); + + it('should allow a custom message', () => { + // Arrange + const customMessage = 'Initialization error occurred.'; + + // Act + const error = new StrapiSDKInitializationError(undefined, customMessage); + + // Assert + expect(error.message).toBe(customMessage); + }); + + it('should set the cause if provided', () => { + // Arrange + const cause = new Error('Initialization root cause'); + + // Act + const error = new StrapiSDKInitializationError(cause); + + // Assert + expect(error.cause).toBe(cause); + }); + }); +}); diff --git a/tests/unit/errors/url.test.ts b/tests/unit/errors/url.test.ts new file mode 100644 index 0000000..28e777a --- /dev/null +++ b/tests/unit/errors/url.test.ts @@ -0,0 +1,71 @@ +import { + URLValidationError, + URLParsingError, + URLProtocolValidationError, +} from '../../../src/errors'; + +describe('URL Errors', () => { + describe('URLParsingError', () => { + it('should construct with a correct error message', () => { + // Arrange + const url = 'invalid_url'; + + // Act + const error = new URLParsingError(url); + + // Assert + expect(error).toBeInstanceOf(URLParsingError); + expect(error).toBeInstanceOf(URLValidationError); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe(`Could not parse invalid URL: "${url}"`); + }); + }); + + describe('URLProtocolValidationError', () => { + it('should construct with a correct error message for a URL object', () => { + // Arrange + const url = new URL('ftp://example.com'); + const allowedProtocols = ['http:', 'https:']; + + // Act + const error = new URLProtocolValidationError(url, allowedProtocols); + + // Assert + expect(error).toBeInstanceOf(URLProtocolValidationError); + expect(error).toBeInstanceOf(URLValidationError); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe( + `Only "http:", "https:" protocols are supported, but got "${url.protocol}" instead.` + ); + }); + + it('should construct with a correct error message for a string URL', () => { + // Arrange + const url = 'ftp://example.com'; + const allowedProtocols = ['http:', 'https:']; + + // Act + const error = new URLProtocolValidationError(url, allowedProtocols); + + // Assert + expect(error).toBeInstanceOf(URLProtocolValidationError); + expect(error).toBeInstanceOf(URLValidationError); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe( + `Only "http:", "https:" protocols are supported, but got "ftp:" instead.` + ); + }); + + it('should handle empty allowedProtocols array', () => { + // Arrange + const url = 'ftp://example.com'; + const allowedProtocols: string[] = []; + + // Act + const error = new URLProtocolValidationError(url, allowedProtocols); + + // Assert + expect(error.message).toBe(`Only protocols are supported, but got "ftp:" instead.`); + }); + }); +}); diff --git a/tests/unit/http/client.test.ts b/tests/unit/http/client.test.ts new file mode 100644 index 0000000..ecf7293 --- /dev/null +++ b/tests/unit/http/client.test.ts @@ -0,0 +1,173 @@ +import { HttpClient } from '../../../src/http'; +import { MockAuthManager, MockAuthProvider, MockURLValidator } from '../mocks'; + +describe('HttpClient', () => { + let mockAuthManager: MockAuthManager; + let mockURLValidator: MockURLValidator; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + mockAuthManager = new MockAuthManager(); + mockURLValidator = new MockURLValidator(); + + fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + }) + ) + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should validate baseURL in constructor', () => { + // Arrange & Act + const spy = jest.spyOn(mockURLValidator, 'validate'); + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + + // Assert + expect(httpClient).toBeInstanceOf(HttpClient); + expect(spy).toHaveBeenCalledWith('https://example.com'); + }); + + it('setBaseURL should validate and update baseURL', () => { + // Arrange + const baseURL = 'https://example.com'; + const newBaseURL = 'https://newurl.com'; + + const spy = jest.spyOn(mockURLValidator, 'validate'); + + const httpClient = new HttpClient(baseURL, mockAuthManager, mockURLValidator); + + // Act + httpClient.setBaseURL(newBaseURL); + + // Assert + expect(spy).toHaveBeenCalledWith(newBaseURL); + expect(httpClient.baseURL).toBe(newBaseURL); + }); + + it('setAuthStrategy should configure the authentication strategy', () => { + // Arrange + const strategy = MockAuthProvider.identifier; + const strategyOptions = {}; + + const spy = jest.spyOn(mockAuthManager, 'setStrategy'); + + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + + // Act + httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + + // Assert + expect(spy).toHaveBeenCalledWith(strategy, strategyOptions); + expect(mockAuthManager.strategy).toBe(strategy); + }); + + it('should try to authenticate before making a request if not already authenticated', async () => { + // Arrange + const authenticateSpy = jest.spyOn(mockAuthManager, 'authenticate'); + + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + + // Act + await httpClient.fetch('/'); + + // Assert + expect(authenticateSpy).toHaveBeenCalled(); + }); + + it('fetch should add auth headers to the http request if authenticated', async () => { + // Arrange + const authenticateRequestSpy = jest.spyOn(mockAuthManager, 'authenticateRequest'); + + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + + // Act + await httpClient.fetch('/'); + + const authorizationHeader = authenticateRequestSpy.mock.lastCall + ?.at(0) + ?.headers.get('Authorization'); + + // Assert + expect(authenticateRequestSpy).toHaveBeenCalled(); + expect(authorizationHeader).toBe('Bearer '); + }); + + it('fetch should add an application/json Content-Type header to each request', async () => { + // Arrange + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + + // Act + await httpClient.fetch('/'); + + const contentTypeHeader = fetchSpy.mock.lastCall?.at(0)?.headers.get('Content-Type'); + + // Assert + expect(contentTypeHeader).toBe('application/json'); + }); + + it('fetch should not add auth headers to the http request if not authenticated', async () => { + // Arrange + const authenticateRequestSpy = jest.spyOn(mockAuthManager, 'authenticateRequest'); + + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + + // Act + await httpClient.fetch('/'); + + const authorizationHeader = fetchSpy.mock.lastCall?.at(0)?.headers.get('Authorization'); + + // Assert + expect(authenticateRequestSpy).toHaveBeenCalled(); + expect(authorizationHeader).toBeNull(); + }); + + it('fetch should forward valid responses', async () => { + // Arrange + const payload = { ok: true }; + + fetchSpy.mockImplementationOnce(() => { + return Promise.resolve(new Response(JSON.stringify(payload), { status: 200 })); + }); + + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + + // Act + const response = await httpClient.fetch('/'); + + // Assert + expect(mockAuthManager.isAuthenticated).toBe(true); + expect(fetchSpy).toHaveBeenCalledWith(expect.any(Request), undefined); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual(payload); + }); + + it('fetch should handle 401 unauthorized responses', async () => { + // Arrange + const handleUnauthorizedErrorSpy = jest.spyOn(mockAuthManager, 'handleUnauthorizedError'); + + fetchSpy.mockImplementationOnce(() => { + return Promise.resolve(new Response('Unauthorized', { status: 401 })); + }); + + const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + + // Act + const response = await httpClient.fetch('/'); + + // Assert + expect(handleUnauthorizedErrorSpy).toHaveBeenCalled(); + expect(mockAuthManager.isAuthenticated).toBe(false); + expect(fetchSpy).toHaveBeenCalledWith(expect.any(Request), undefined); + expect(response.status).toBe(401); + }); +}); diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts deleted file mode 100644 index 643e37c..0000000 --- a/tests/unit/index.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { greetings } from '../../src'; - -describe('greetings function', () => { - it('should return "Hello World!"', () => { - expect(greetings()).toBe('Hello World!'); - }); -}); diff --git a/tests/unit/mocks/auth-manager.mock.ts b/tests/unit/mocks/auth-manager.mock.ts new file mode 100644 index 0000000..1104383 --- /dev/null +++ b/tests/unit/mocks/auth-manager.mock.ts @@ -0,0 +1,13 @@ +import { AuthManager, AuthProviderFactory } from '../../../src/auth'; + +import { MockAuthProviderFactory } from './auth-provider-factory.mock'; + +export class MockAuthManager extends AuthManager { + constructor(authProviderFactory: AuthProviderFactory = new MockAuthProviderFactory()) { + super(authProviderFactory); + } + + registerDefaultProviders() { + // does nothing + } +} diff --git a/tests/unit/mocks/auth-provider-factory.mock.ts b/tests/unit/mocks/auth-provider-factory.mock.ts new file mode 100644 index 0000000..6b19c6e --- /dev/null +++ b/tests/unit/mocks/auth-provider-factory.mock.ts @@ -0,0 +1,11 @@ +import { AuthProviderFactory } from '../../../src/auth'; + +import { MockAuthProvider } from './auth-provider.mock'; + +export class MockAuthProviderFactory extends AuthProviderFactory { + constructor() { + super(); + + this.register(MockAuthProvider.identifier, () => new MockAuthProvider()); + } +} diff --git a/tests/unit/mocks/auth-provider.mock.ts b/tests/unit/mocks/auth-provider.mock.ts new file mode 100644 index 0000000..7f91a46 --- /dev/null +++ b/tests/unit/mocks/auth-provider.mock.ts @@ -0,0 +1,25 @@ +import { AbstractAuthProvider } from '../../../src/auth'; + +export class MockAuthProvider extends AbstractAuthProvider<{ jwt: string }> { + public static readonly identifier = 'mock-jwt'; + + constructor() { + super({ jwt: '' }); + } + + authenticate(): Promise { + return Promise.resolve(); + } + + get headers(): Record { + return { Authorization: 'Bearer ' }; + } + + get name(): string { + return MockAuthProvider.identifier; + } + + protected preflightValidation(): void { + // does nothing + } +} diff --git a/tests/unit/mocks/http-client.mock.ts b/tests/unit/mocks/http-client.mock.ts new file mode 100644 index 0000000..ac640c5 --- /dev/null +++ b/tests/unit/mocks/http-client.mock.ts @@ -0,0 +1,26 @@ +import { HttpClient } from '../../../src/http'; + +import { MockAuthManager } from './auth-manager.mock'; +import { MockAuthProviderFactory } from './auth-provider-factory.mock'; +import { MockURLValidator } from './url-validator.mock'; + +import type { AuthManager } from '../../../src/auth'; +import type { URLValidator } from '../../../src/validators'; + +export class MockHttpClient extends HttpClient { + constructor( + baseURL: string, + authManager: AuthManager = new MockAuthManager(new MockAuthProviderFactory()), + urlValidator: URLValidator = new MockURLValidator() + ) { + super(baseURL, authManager, urlValidator); + } + + fetch() { + return this._fetch(); + } + + _fetch() { + return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })); + } +} diff --git a/tests/unit/mocks/index.ts b/tests/unit/mocks/index.ts new file mode 100644 index 0000000..1d525ff --- /dev/null +++ b/tests/unit/mocks/index.ts @@ -0,0 +1,6 @@ +export { MockAuthProvider } from './auth-provider.mock'; +export { MockAuthProviderFactory } from './auth-provider-factory.mock'; +export { MockAuthManager } from './auth-manager.mock'; +export { MockHttpClient } from './http-client.mock'; +export { MockURLValidator } from './url-validator.mock'; +export { MockStrapiSDKValidator } from './sdk-validator.mock'; diff --git a/tests/unit/mocks/sdk-validator.mock.ts b/tests/unit/mocks/sdk-validator.mock.ts new file mode 100644 index 0000000..a354fb1 --- /dev/null +++ b/tests/unit/mocks/sdk-validator.mock.ts @@ -0,0 +1,3 @@ +import { StrapiSDKValidator } from '../../../src/validators'; + +export class MockStrapiSDKValidator extends StrapiSDKValidator {} diff --git a/tests/unit/mocks/url-validator.mock.ts b/tests/unit/mocks/url-validator.mock.ts new file mode 100644 index 0000000..0130b1a --- /dev/null +++ b/tests/unit/mocks/url-validator.mock.ts @@ -0,0 +1,3 @@ +import { URLValidator } from '../../../src/validators'; + +export class MockURLValidator extends URLValidator {} diff --git a/tests/unit/sdk.test.ts b/tests/unit/sdk.test.ts new file mode 100644 index 0000000..0cbc6b7 --- /dev/null +++ b/tests/unit/sdk.test.ts @@ -0,0 +1,91 @@ +import { StrapiSDKInitializationError } from '../../src/errors'; +import { StrapiSDK } from '../../src/sdk'; + +import { MockAuthProvider, MockHttpClient, MockStrapiSDKValidator } from './mocks'; + +describe('StrapiSDK', () => { + const mockHttpClientFactory = (url: string) => new MockHttpClient(url); + + describe('Initialization', () => { + it('should initialize with valid config', () => { + // Arrange + const config = { + baseURL: 'http://localhost:1337', + auth: { strategy: MockAuthProvider.identifier, options: {} }, + }; + + const mockValidator = new MockStrapiSDKValidator(); + + const sdkValidatorSpy = jest.spyOn(mockValidator, 'validateConfig'); + const httpSetAuthStrategySpy = jest.spyOn(MockHttpClient.prototype, 'setAuthStrategy'); + + // Act + const sdk = new StrapiSDK(config, mockValidator, mockHttpClientFactory); + + // Assert + + // instance + expect(sdk).toBeInstanceOf(StrapiSDK); + // internal Validation + expect(sdkValidatorSpy).toHaveBeenCalledWith(config); + // internal setup + expect(httpSetAuthStrategySpy).toHaveBeenCalledWith(MockAuthProvider.identifier, {}); + }); + + it('should throw an error on invalid baseURL', () => { + // Arrange + const config = { baseURL: 'invalid-url' }; + + const mockValidator = new MockStrapiSDKValidator(); + + const validateConfigSpy = jest.spyOn(mockValidator, 'validateConfig'); + + // Act & Assert + expect(() => new StrapiSDK(config, mockValidator)).toThrow(StrapiSDKInitializationError); + expect(validateConfigSpy).toHaveBeenCalledWith(config); + }); + + it('should initialize correctly with the default validator', () => { + // Arrange + const sdk = new StrapiSDK({ baseURL: 'http://localhost:1337' }); + + // Act & Assert + expect(sdk).toBeInstanceOf(StrapiSDK); + }); + }); + + // todo implement validation capabilities for providers (e.g. checks if the provided auth strategy exists before trying to create a provider instance) + it.todo('should throw an error on invalid auth configuration'); + + it('should fetch data correctly with fetch method', async () => { + // Arrange + const config = { baseURL: 'http://localhost:1337' }; + + const fetchSpy = jest.spyOn(MockHttpClient.prototype, 'fetch'); + + const mockValidator = new MockStrapiSDKValidator(); + const sdk = new StrapiSDK(config, mockValidator, mockHttpClientFactory); + + // Act + const response = await sdk.fetch('/api/data'); + + // Assert + expect(fetchSpy).toHaveBeenCalledWith('/api/data', undefined); + await expect(response.json()).resolves.toEqual({ ok: true }); + }); + + it('should retrieve baseURL correctly from config', () => { + // Arrange + const config = { baseURL: 'http://localhost:1337' }; + + const mockValidator = new MockStrapiSDKValidator(); + + const sdk = new StrapiSDK(config, mockValidator, mockHttpClientFactory); + + // Act + const { baseURL } = sdk; + + // Assert + expect(baseURL).toBe(config.baseURL); + }); +}); diff --git a/tests/unit/sdk/initialization.test.ts b/tests/unit/sdk/initialization.test.ts new file mode 100644 index 0000000..8727820 --- /dev/null +++ b/tests/unit/sdk/initialization.test.ts @@ -0,0 +1,154 @@ +import { + StrapiSDKInitializationError, + StrapiSDKValidationError, + URLParsingError, + URLProtocolValidationError, +} from '../../../src/errors'; +import { StrapiSDK } from '../../../src/sdk'; +import { StrapiSDKValidator, URLValidator } from '../../../src/validators'; +import invalidURLs from '../../fixtures/invalid-urls.json'; + +import type { StrapiSDKConfig } from '../../../src/sdk'; +import type { URLProtocol } from '../../../src/validators/url'; + +/** + * Class representing a FlakyURLValidator which extends URLValidator. + * + * This validator is designed to throw an error unexpectedly upon validation and should only be used in test suites. + */ +class FlakyURLValidator extends URLValidator { + validate() { + throw new Error('Unexpected error'); + } +} + +describe('SDK Initialization', () => { + let validateSpy: jest.SpyInstance; + + beforeEach(() => { + validateSpy = jest.spyOn(StrapiSDKValidator.prototype, 'validateConfig'); + }); + + afterEach(() => { + validateSpy.mockRestore(); + }); + + it('should initialize correctly when given a valid base url', () => { + // Arrange + let sdk!: StrapiSDK; + + const baseURL = 'http://localhost:1337'; + const config: StrapiSDKConfig = { baseURL }; + + // Act + const createSDK = () => { + sdk = new StrapiSDK(config, new StrapiSDKValidator()); + }; + + // Assert + expect(createSDK).not.toThrow(); + + expect(sdk).toBeDefined(); + + expect(sdk.baseURL).toBe(baseURL); + + expect(validateSpy).toHaveBeenCalledTimes(1); + expect(validateSpy).toHaveBeenCalledWith(config); + }); + + describe('Unknown errors', () => { + let urlValidatorSpy: jest.SpyInstance; + + beforeEach(() => { + urlValidatorSpy = jest.spyOn(FlakyURLValidator.prototype, 'validate'); + }); + + afterEach(() => { + urlValidatorSpy.mockRestore(); + }); + + it('should fail to create and SDK instance if there is an unexpected error', () => { + // Arrange + let sdk!: StrapiSDK; + + const baseURL = 'http://example.com'; + const config: StrapiSDKConfig = { baseURL }; + const expectedError = new StrapiSDKInitializationError(new Error('Unexpected error')); + + // Act + const createSDK = () => { + sdk = new StrapiSDK(config, new StrapiSDKValidator(new FlakyURLValidator())); + }; + + // Assert + expect(createSDK).toThrow(expectedError); + + expect(sdk).toBeUndefined(); + + expect(validateSpy).toHaveBeenCalledTimes(1); + expect(validateSpy).toHaveBeenCalledWith(config); + + expect(urlValidatorSpy).toHaveBeenCalledTimes(1); + expect(urlValidatorSpy).toHaveBeenCalledWith(baseURL); + }); + }); + + describe('baseURL validation', () => { + describe('Parsing Error', () => { + it.each(invalidURLs.impossibleToParse)( + 'should fail to initialize when given an impossible to parse url: "%s" (%s)', + (baseURL) => { + // Arrange + let sdk!: StrapiSDK; + + const config: StrapiSDKConfig = { baseURL }; + const expectedError = new StrapiSDKInitializationError( + new StrapiSDKValidationError(new URLParsingError(baseURL)) + ); + + // Act + const createSDK = () => { + sdk = new StrapiSDK(config, new StrapiSDKValidator()); + }; + + // Assert + expect(createSDK).toThrow(expectedError); + + expect(sdk).toBeUndefined(); + + expect(validateSpy).toHaveBeenCalledTimes(1); + expect(validateSpy).toHaveBeenCalledWith(config); + } + ); + }); + + describe('Unsupported Protocol Error', () => { + it.each(invalidURLs.unsupportedProtocols)( + 'should fail to initialize when given a baseURL with an unsupported protocol: "%s" (%s)', + (baseURL) => { + // Arrange + let sdk!: StrapiSDK; + + const config: StrapiSDKConfig = { baseURL }; + const allowedProtocols = ['http:', 'https:'] satisfies URLProtocol[]; + const expectedError = new StrapiSDKInitializationError( + new StrapiSDKValidationError(new URLProtocolValidationError(baseURL, allowedProtocols)) + ); + + // Act + const createSDK = () => { + sdk = new StrapiSDK(config, new StrapiSDKValidator()); + }; + + // Assert + expect(createSDK).toThrow(expectedError); + + expect(sdk).toBeUndefined(); + + expect(validateSpy).toHaveBeenCalledTimes(1); + expect(validateSpy).toHaveBeenCalledWith(config); + } + ); + }); + }); +}); diff --git a/tests/unit/validators/sdk.test.ts b/tests/unit/validators/sdk.test.ts new file mode 100644 index 0000000..a8d7468 --- /dev/null +++ b/tests/unit/validators/sdk.test.ts @@ -0,0 +1,65 @@ +import { StrapiSDKValidationError, URLValidationError } from '../../../src/errors'; +import { StrapiSDKValidator, URLValidator } from '../../../src/validators'; + +import type { StrapiSDKConfig } from '../../../src/sdk'; + +describe('Strapi SDKValidator', () => { + let urlValidatorMock: jest.Mocked; + + beforeEach(() => { + urlValidatorMock = new URLValidator() as jest.Mocked; + urlValidatorMock.validate = jest.fn(); + }); + + describe('validateConfig', () => { + it.each([undefined, null, 2, []])( + 'should throw an error if config is not a valid object (%s)', + (config: unknown) => { + // Arrange + const validator = new StrapiSDKValidator(urlValidatorMock); + const expected = new StrapiSDKValidationError( + new TypeError('The provided configuration is not a valid object.') + ); + + // Act & Assert + expect(() => validator.validateConfig(config as StrapiSDKConfig)).toThrow(expected); + } + ); + + it('should not throw an error if config is a valid object', () => { + // Arrange + const config = { baseURL: 'https://example.com' }; + const validator = new StrapiSDKValidator(urlValidatorMock); + + // Act & Assert + expect(() => validator.validateConfig(config)).not.toThrow(); + }); + }); + + describe('validateBaseURL', () => { + it('should call validateBaseURL method with the baseURL', () => { + // Arrange + const validator = new StrapiSDKValidator(urlValidatorMock); + const config: StrapiSDKConfig = { baseURL: 'http://valid.url' }; + + // Act + validator.validateConfig(config); + + // Assert + expect(urlValidatorMock.validate).toHaveBeenCalledWith('http://valid.url'); + }); + + it('should throw StrapiSDKValidationError on URLValidationError', () => { + // Arrange + const validator = new StrapiSDKValidator(urlValidatorMock); + const baseURL = 'invalid-url'; + + urlValidatorMock.validate.mockImplementationOnce(() => { + throw new URLValidationError('invalid url'); + }); + + // Act & Assert + expect(() => validator.validateConfig({ baseURL })).toThrow(StrapiSDKValidationError); + }); + }); +}); diff --git a/tests/unit/validators/url.test.ts b/tests/unit/validators/url.test.ts new file mode 100644 index 0000000..51e0070 --- /dev/null +++ b/tests/unit/validators/url.test.ts @@ -0,0 +1,72 @@ +import { URLParsingError, URLProtocolValidationError } from '../../../src/errors'; +import { URLValidator } from '../../../src/validators'; + +import type { URLProtocol } from '../../../src/validators/url'; + +const ALLOWED_PROTOCOLS: URLProtocol[] = ['http:', 'https:']; + +describe('URLValidator', () => { + let urlValidator: URLValidator; + + beforeEach(() => { + urlValidator = new URLValidator(); + }); + + describe('Protocol Validation', () => { + it('should validate a valid HTTP URL', () => { + // Arrange + const url = 'http://example.com'; + + // Act & Assert + expect(() => urlValidator.validate(url)).not.toThrow(); + }); + + it('should validate a valid HTTPS URL', () => { + // Arrange + const url = 'https://example.com'; + + // Act & Assert + expect(() => urlValidator.validate(url)).not.toThrow(); + }); + + it('should throw an error for a URL with an invalid protocol', () => { + // Arrange + const url = 'ftp://example.com'; + + // Act & Assert + expect(() => urlValidator.validate(url)).toThrow( + new URLProtocolValidationError(url, ALLOWED_PROTOCOLS) + ); + }); + + it('should allow custom configuration to validate different protocols', () => { + // Arrange + const url = 'ftp://example.com'; + const allowedProtocols: URLProtocol[] = ['file:', 'ftp:']; + const customURLValidator = new URLValidator({ allowedProtocols }); + + // Act & Assert + expect(() => customURLValidator.validate(url)).not.toThrow(); + }); + }); + + describe('Parsing Validation', () => { + it('should not throw when given a valid URL', () => { + // Arrange + const url = 'https://example.com'; + + // Act & Assert + expect(() => urlValidator.validate(url)).not.toThrow(); + }); + + it.each([123, null, undefined, true, {}, []])( + 'should throw an error for a non-string input: %s', + (url: unknown) => { + // Act & Assert + expect(() => urlValidator.validate(url)).toThrow( + new URLParsingError(`The provided URL is not a string. Got "${typeof url}"`) + ); + } + ); + }); +}); From e611b87fc7a6fa4245de8b97bb88e84fa900f7da Mon Sep 17 00:00:00 2001 From: Convly Date: Mon, 2 Dec 2024 13:34:08 +0100 Subject: [PATCH 16/22] chore(workflows): fix style --- .github/workflows/issues_handleLabel.yml | 2 +- .github/workflows/tests.yml | 4 ++-- .prettierignore | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/issues_handleLabel.yml b/.github/workflows/issues_handleLabel.yml index a56f3ed..a4af438 100644 --- a/.github/workflows/issues_handleLabel.yml +++ b/.github/workflows/issues_handleLabel.yml @@ -2,7 +2,7 @@ name: Issue Labeled on: issues: - types: [ labeled ] + types: [labeled] permissions: issues: write diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e4844ad..b3db83c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,11 +62,11 @@ jobs: unit_back: name: 'unit_back (node: ${{ matrix.node }})' - needs: [ cache-and-install, build ] + needs: [cache-and-install, build] runs-on: ubuntu-latest strategy: matrix: - node: [ 20, 22 ] + node: [20, 22] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.prettierignore b/.prettierignore index 03a9d0f..e493dd6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,2 @@ bin dist -.github From 82f37b6c88ea153169b3619275ba70e42814b42c Mon Sep 17 00:00:00 2001 From: Convly Date: Mon, 2 Dec 2024 13:46:56 +0100 Subject: [PATCH 17/22] test(sdk): remove initialization tests and refactor url validation tests --- src/errors/url.ts | 2 +- src/validators/url.ts | 2 +- tests/fixtures/invalid-urls.json | 7 +- tests/unit/sdk.test.ts | 38 +++++++ tests/unit/sdk/initialization.test.ts | 154 -------------------------- tests/unit/validators/url.test.ts | 29 +++-- 6 files changed, 60 insertions(+), 172 deletions(-) delete mode 100644 tests/unit/sdk/initialization.test.ts diff --git a/src/errors/url.ts b/src/errors/url.ts index 8b086a3..d8441e3 100644 --- a/src/errors/url.ts +++ b/src/errors/url.ts @@ -1,7 +1,7 @@ export class URLValidationError extends Error {} export class URLParsingError extends URLValidationError { - constructor(url: string) { + constructor(url: unknown) { super(`Could not parse invalid URL: "${url}"`); } } diff --git a/src/validators/url.ts b/src/validators/url.ts index 7f3f444..b3237ee 100644 --- a/src/validators/url.ts +++ b/src/validators/url.ts @@ -114,7 +114,7 @@ export class URLValidator { */ private preValidation(url: unknown): asserts url is string { if (typeof url !== 'string') { - throw new URLParsingError(`The provided URL is not a string. Got "${typeof url}"`); + throw new URLParsingError(url); } const canParse = this.canParse(url); diff --git a/tests/fixtures/invalid-urls.json b/tests/fixtures/invalid-urls.json index c2e890b..853b319 100644 --- a/tests/fixtures/invalid-urls.json +++ b/tests/fixtures/invalid-urls.json @@ -2,7 +2,12 @@ "impossibleToParse": [ ["", "empty string"], ["foobar", "regular string"], - ["example.com", "missing protocol"] + ["example.com", "missing protocol"], + [123, "number"], + [null, "null"], + [true, "boolean"], + [{}, "empty object"], + [[], "empty array"] ], "unsupportedProtocols": [ ["ftp://example.com", "ftp"], diff --git a/tests/unit/sdk.test.ts b/tests/unit/sdk.test.ts index 0cbc6b7..c95ef30 100644 --- a/tests/unit/sdk.test.ts +++ b/tests/unit/sdk.test.ts @@ -1,8 +1,22 @@ import { StrapiSDKInitializationError } from '../../src/errors'; import { StrapiSDK } from '../../src/sdk'; +import { StrapiSDKValidator, URLValidator } from '../../src/validators'; import { MockAuthProvider, MockHttpClient, MockStrapiSDKValidator } from './mocks'; +import type { StrapiSDKConfig } from '../../src/sdk'; + +/** + * Class representing a FlakyURLValidator which extends URLValidator. + * + * This validator is designed to throw an error unexpectedly upon validation and should only be used in test suites. + */ +class FlakyURLValidator extends URLValidator { + validate() { + throw new Error('Unexpected error'); + } +} + describe('StrapiSDK', () => { const mockHttpClientFactory = (url: string) => new MockHttpClient(url); @@ -45,6 +59,30 @@ describe('StrapiSDK', () => { expect(validateConfigSpy).toHaveBeenCalledWith(config); }); + it('should fail to create and SDK instance if there is an unexpected error', () => { + // Arrange + let sdk!: StrapiSDK; + + const baseURL = 'http://example.com'; + const config: StrapiSDKConfig = { baseURL }; + const expectedError = new StrapiSDKInitializationError(new Error('Unexpected error')); + + const validateSpy = jest.spyOn(FlakyURLValidator.prototype, 'validate'); + + // Act + const createSDK = () => { + sdk = new StrapiSDK(config, new StrapiSDKValidator(new FlakyURLValidator())); + }; + + // Assert + expect(createSDK).toThrow(expectedError); + + expect(sdk).toBeUndefined(); + + expect(validateSpy).toHaveBeenCalledTimes(1); + expect(validateSpy).toHaveBeenCalledWith(baseURL); + }); + it('should initialize correctly with the default validator', () => { // Arrange const sdk = new StrapiSDK({ baseURL: 'http://localhost:1337' }); diff --git a/tests/unit/sdk/initialization.test.ts b/tests/unit/sdk/initialization.test.ts deleted file mode 100644 index 8727820..0000000 --- a/tests/unit/sdk/initialization.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { - StrapiSDKInitializationError, - StrapiSDKValidationError, - URLParsingError, - URLProtocolValidationError, -} from '../../../src/errors'; -import { StrapiSDK } from '../../../src/sdk'; -import { StrapiSDKValidator, URLValidator } from '../../../src/validators'; -import invalidURLs from '../../fixtures/invalid-urls.json'; - -import type { StrapiSDKConfig } from '../../../src/sdk'; -import type { URLProtocol } from '../../../src/validators/url'; - -/** - * Class representing a FlakyURLValidator which extends URLValidator. - * - * This validator is designed to throw an error unexpectedly upon validation and should only be used in test suites. - */ -class FlakyURLValidator extends URLValidator { - validate() { - throw new Error('Unexpected error'); - } -} - -describe('SDK Initialization', () => { - let validateSpy: jest.SpyInstance; - - beforeEach(() => { - validateSpy = jest.spyOn(StrapiSDKValidator.prototype, 'validateConfig'); - }); - - afterEach(() => { - validateSpy.mockRestore(); - }); - - it('should initialize correctly when given a valid base url', () => { - // Arrange - let sdk!: StrapiSDK; - - const baseURL = 'http://localhost:1337'; - const config: StrapiSDKConfig = { baseURL }; - - // Act - const createSDK = () => { - sdk = new StrapiSDK(config, new StrapiSDKValidator()); - }; - - // Assert - expect(createSDK).not.toThrow(); - - expect(sdk).toBeDefined(); - - expect(sdk.baseURL).toBe(baseURL); - - expect(validateSpy).toHaveBeenCalledTimes(1); - expect(validateSpy).toHaveBeenCalledWith(config); - }); - - describe('Unknown errors', () => { - let urlValidatorSpy: jest.SpyInstance; - - beforeEach(() => { - urlValidatorSpy = jest.spyOn(FlakyURLValidator.prototype, 'validate'); - }); - - afterEach(() => { - urlValidatorSpy.mockRestore(); - }); - - it('should fail to create and SDK instance if there is an unexpected error', () => { - // Arrange - let sdk!: StrapiSDK; - - const baseURL = 'http://example.com'; - const config: StrapiSDKConfig = { baseURL }; - const expectedError = new StrapiSDKInitializationError(new Error('Unexpected error')); - - // Act - const createSDK = () => { - sdk = new StrapiSDK(config, new StrapiSDKValidator(new FlakyURLValidator())); - }; - - // Assert - expect(createSDK).toThrow(expectedError); - - expect(sdk).toBeUndefined(); - - expect(validateSpy).toHaveBeenCalledTimes(1); - expect(validateSpy).toHaveBeenCalledWith(config); - - expect(urlValidatorSpy).toHaveBeenCalledTimes(1); - expect(urlValidatorSpy).toHaveBeenCalledWith(baseURL); - }); - }); - - describe('baseURL validation', () => { - describe('Parsing Error', () => { - it.each(invalidURLs.impossibleToParse)( - 'should fail to initialize when given an impossible to parse url: "%s" (%s)', - (baseURL) => { - // Arrange - let sdk!: StrapiSDK; - - const config: StrapiSDKConfig = { baseURL }; - const expectedError = new StrapiSDKInitializationError( - new StrapiSDKValidationError(new URLParsingError(baseURL)) - ); - - // Act - const createSDK = () => { - sdk = new StrapiSDK(config, new StrapiSDKValidator()); - }; - - // Assert - expect(createSDK).toThrow(expectedError); - - expect(sdk).toBeUndefined(); - - expect(validateSpy).toHaveBeenCalledTimes(1); - expect(validateSpy).toHaveBeenCalledWith(config); - } - ); - }); - - describe('Unsupported Protocol Error', () => { - it.each(invalidURLs.unsupportedProtocols)( - 'should fail to initialize when given a baseURL with an unsupported protocol: "%s" (%s)', - (baseURL) => { - // Arrange - let sdk!: StrapiSDK; - - const config: StrapiSDKConfig = { baseURL }; - const allowedProtocols = ['http:', 'https:'] satisfies URLProtocol[]; - const expectedError = new StrapiSDKInitializationError( - new StrapiSDKValidationError(new URLProtocolValidationError(baseURL, allowedProtocols)) - ); - - // Act - const createSDK = () => { - sdk = new StrapiSDK(config, new StrapiSDKValidator()); - }; - - // Assert - expect(createSDK).toThrow(expectedError); - - expect(sdk).toBeUndefined(); - - expect(validateSpy).toHaveBeenCalledTimes(1); - expect(validateSpy).toHaveBeenCalledWith(config); - } - ); - }); - }); -}); diff --git a/tests/unit/validators/url.test.ts b/tests/unit/validators/url.test.ts index 51e0070..a062521 100644 --- a/tests/unit/validators/url.test.ts +++ b/tests/unit/validators/url.test.ts @@ -1,5 +1,6 @@ import { URLParsingError, URLProtocolValidationError } from '../../../src/errors'; import { URLValidator } from '../../../src/validators'; +import invalidURLs from '../../fixtures/invalid-urls.json'; import type { URLProtocol } from '../../../src/validators/url'; @@ -29,15 +30,15 @@ describe('URLValidator', () => { expect(() => urlValidator.validate(url)).not.toThrow(); }); - it('should throw an error for a URL with an invalid protocol', () => { - // Arrange - const url = 'ftp://example.com'; - - // Act & Assert - expect(() => urlValidator.validate(url)).toThrow( - new URLProtocolValidationError(url, ALLOWED_PROTOCOLS) - ); - }); + it.each(invalidURLs.unsupportedProtocols)( + 'should throw an error for a URL with an invalid protocol: %s', + (url) => { + // Act & Assert + expect(() => urlValidator.validate(url)).toThrow( + new URLProtocolValidationError(url, ALLOWED_PROTOCOLS) + ); + } + ); it('should allow custom configuration to validate different protocols', () => { // Arrange @@ -59,13 +60,11 @@ describe('URLValidator', () => { expect(() => urlValidator.validate(url)).not.toThrow(); }); - it.each([123, null, undefined, true, {}, []])( - 'should throw an error for a non-string input: %s', - (url: unknown) => { + it.each(invalidURLs.impossibleToParse)( + 'should throw an error for a non-string input: %s (%s)', + (url) => { // Act & Assert - expect(() => urlValidator.validate(url)).toThrow( - new URLParsingError(`The provided URL is not a string. Got "${typeof url}"`) - ); + expect(() => urlValidator.validate(url)).toThrow(new URLParsingError(url)); } ); }); From 9f0ffce4cad8d03cd0d874759ff013ef6f002eb2 Mon Sep 17 00:00:00 2001 From: Convly Date: Mon, 2 Dec 2024 13:58:48 +0100 Subject: [PATCH 18/22] chore(url): remove protocol validation logic --- src/errors/url.ts | 10 --- src/index.ts | 6 +- src/validators/url.ts | 113 ++---------------------------- tests/fixtures/invalid-urls.json | 7 -- tests/unit/errors/url.test.ts | 54 +------------- tests/unit/validators/url.test.ts | 26 +------ 6 files changed, 9 insertions(+), 207 deletions(-) diff --git a/src/errors/url.ts b/src/errors/url.ts index d8441e3..71d390c 100644 --- a/src/errors/url.ts +++ b/src/errors/url.ts @@ -5,13 +5,3 @@ export class URLParsingError extends URLValidationError { super(`Could not parse invalid URL: "${url}"`); } } - -export class URLProtocolValidationError extends URLValidationError { - constructor(url: URL | string, allowedProtocols: string[]) { - const formattedProtocols = allowedProtocols.map((protocol) => `"${protocol}"`).join(', '); - - super( - `Only ${formattedProtocols} protocols are supported, but got "${new URL(url).protocol}" instead.` - ); - } -} diff --git a/src/index.ts b/src/index.ts index fee8656..9efc6b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { StrapiSDK } from './sdk'; -import { StrapiSDKValidator, URLValidator } from './validators'; +import { StrapiSDKValidator } from './validators'; import type { StrapiSDKConfig } from './sdk'; @@ -43,9 +43,7 @@ import type { StrapiSDKConfig } from './sdk'; * or if the auth configuration is invalid. */ export const createStrapiSDK = (config: StrapiSDKConfig) => { - // TODO allow setting custom url validation rules from the configuration - const urlValidator = new URLValidator({ allowedProtocols: ['http:', 'https:'] }); - const sdkValidator = new StrapiSDKValidator(urlValidator); + const sdkValidator = new StrapiSDKValidator(); return new StrapiSDK( // Properties diff --git a/src/validators/url.ts b/src/validators/url.ts index b3237ee..81b7f5b 100644 --- a/src/validators/url.ts +++ b/src/validators/url.ts @@ -1,55 +1,4 @@ -import { URLParsingError, URLProtocolValidationError } from '../errors'; - -/** - * Configuration settings for the URL validator. - */ -export interface URLValidatorConfig { - /** An array of URL protocols that are considered valid */ - allowedProtocols: URLProtocol[]; -} - -/** - * Represents a set of predefined URL protocols. - * - * This type encompasses a list of commonly used URL schemes, including: - * - 'http:': Hypertext Transfer Protocol - * - 'https:': Secure Hypertext Transfer Protocol - * - 'ftp:': File Transfer Protocol - * - 'ftps:': Secure File Transfer Protocol - * - 'ws:': WebSocket Protocol - * - 'wss:': Secure WebSocket Protocol - * - 'sftp:': Secure File Transfer Protocol via SSH - * - 'mailto:': Email Addressing - * - 'file:': Local File Access - * - 'data:': In-line Data - * - 'git:': Git Version Control System - * - 'ssh:': Secure Shell Protocol - * - 'telnet:': Telnet Protocol - * - 'ldap:': Lightweight Directory Access Protocol - * - 'ldaps:': Secure LDAP - * - 'scp:': Secure Copy Protocol - * - 'gopher:': Gopher Protocol - * - 'irc:': Internet Relay Chat - */ -export type URLProtocol = - | 'http:' - | 'https:' - | 'ftp:' - | 'ftps:' - | 'ws:' - | 'wss:' - | 'sftp:' - | 'mailto:' - | 'file:' - | 'data:' - | 'git:' - | 'ssh:' - | 'telnet:' - | 'ldap:' - | 'ldaps:' - | 'scp:' - | 'gopher:' - | 'irc:'; +import { URLParsingError } from '../errors'; /** * Class representing a URLValidator. @@ -57,25 +6,14 @@ export type URLProtocol = * It provides the ability to validate URLs based on a predefined list of allowed protocols. */ export class URLValidator { - private static readonly DEFAULT_CONFIG: URLValidatorConfig = { - allowedProtocols: ['http:', 'https:'], - }; - - public readonly config: URLValidatorConfig; - - constructor(config: URLValidatorConfig = URLValidator.DEFAULT_CONFIG) { - this.config = config; - } - /** * Validates the provided URL. * - * This method checks that the provided URL is a string, can be parsed, and uses an allowed protocol. + * This method checks that the provided URL is a string and can be parsed. * * @param url - The URL to be validated. This parameter must be a valid string representation of a URL. * * @throws {URLParsingError} Thrown if the URL is not a string or can't be parsed. - * @throws {URLProtocolValidationError} Thrown if the URL uses an unsupported protocol according to the validator's configuration. * * @example * // Example of validating a URL successfully @@ -86,33 +24,18 @@ export class URLValidator { * validator.validate(url); // Does not throw an error * * @example - * // Example of a failing validation due to unsupported protocol + * // Example of a failing validation * const validator = new URLValidator(); * - * const url = 'ftp://example.com'; + * const url = 123; * * try { * validator.validate(url); * } catch (error) { - * console.error(error); // URLProtocolValidationError + * console.error(error); // URLParsingError * } */ validate(url: unknown) { - this.preValidation(url); - - const parsedURL = new URL(url); - - this.validateProtocol(parsedURL); - } - - /** - * Ensures the URL is a string and can be parsed. - * - * @param url - The URL to be validated. - * - * @throws {URLParsingError} Thrown if the URL is not a string or can't be parsed. - */ - private preValidation(url: unknown): asserts url is string { if (typeof url !== 'string') { throw new URLParsingError(url); } @@ -124,21 +47,6 @@ export class URLValidator { } } - /** - * Validates that the URL uses a protocol allowed by the configuration. - * - * @param url - A URL object representing the parsed URL. - * - * @throws {URLProtocolValidationError} Thrown if the URL's protocol is not in the allowed list. - */ - private validateProtocol(url: URL) { - const hasValidProtocol = this.hasValidProtocol(url); - - if (!hasValidProtocol) { - throw new URLProtocolValidationError(url, this.config.allowedProtocols); - } - } - /** * Checks if the URL string can be parsed. * @@ -149,15 +57,4 @@ export class URLValidator { private canParse(url: string): boolean { return URL.canParse(url); } - - /** - * Checks if the protocol of the URL is valid based on the allowed protocols. - * - * @param url - A URL object. - * - * @returns A boolean indicating whether the protocol is in the allowed list. - */ - private hasValidProtocol(url: URL): boolean { - return this.config.allowedProtocols.includes(url.protocol as URLProtocol); - } } diff --git a/tests/fixtures/invalid-urls.json b/tests/fixtures/invalid-urls.json index 853b319..d8ce6d5 100644 --- a/tests/fixtures/invalid-urls.json +++ b/tests/fixtures/invalid-urls.json @@ -8,12 +8,5 @@ [true, "boolean"], [{}, "empty object"], [[], "empty array"] - ], - "unsupportedProtocols": [ - ["ftp://example.com", "ftp"], - ["ftps://example.com", "ftps"], - ["ws://example.com", "websocket"], - ["wss://example.com", "secure websocket"], - ["file://example.com", "file"] ] } diff --git a/tests/unit/errors/url.test.ts b/tests/unit/errors/url.test.ts index 28e777a..faa7714 100644 --- a/tests/unit/errors/url.test.ts +++ b/tests/unit/errors/url.test.ts @@ -1,8 +1,4 @@ -import { - URLValidationError, - URLParsingError, - URLProtocolValidationError, -} from '../../../src/errors'; +import { URLValidationError, URLParsingError } from '../../../src/errors'; describe('URL Errors', () => { describe('URLParsingError', () => { @@ -20,52 +16,4 @@ describe('URL Errors', () => { expect(error.message).toBe(`Could not parse invalid URL: "${url}"`); }); }); - - describe('URLProtocolValidationError', () => { - it('should construct with a correct error message for a URL object', () => { - // Arrange - const url = new URL('ftp://example.com'); - const allowedProtocols = ['http:', 'https:']; - - // Act - const error = new URLProtocolValidationError(url, allowedProtocols); - - // Assert - expect(error).toBeInstanceOf(URLProtocolValidationError); - expect(error).toBeInstanceOf(URLValidationError); - expect(error).toBeInstanceOf(Error); - expect(error.message).toBe( - `Only "http:", "https:" protocols are supported, but got "${url.protocol}" instead.` - ); - }); - - it('should construct with a correct error message for a string URL', () => { - // Arrange - const url = 'ftp://example.com'; - const allowedProtocols = ['http:', 'https:']; - - // Act - const error = new URLProtocolValidationError(url, allowedProtocols); - - // Assert - expect(error).toBeInstanceOf(URLProtocolValidationError); - expect(error).toBeInstanceOf(URLValidationError); - expect(error).toBeInstanceOf(Error); - expect(error.message).toBe( - `Only "http:", "https:" protocols are supported, but got "ftp:" instead.` - ); - }); - - it('should handle empty allowedProtocols array', () => { - // Arrange - const url = 'ftp://example.com'; - const allowedProtocols: string[] = []; - - // Act - const error = new URLProtocolValidationError(url, allowedProtocols); - - // Assert - expect(error.message).toBe(`Only protocols are supported, but got "ftp:" instead.`); - }); - }); }); diff --git a/tests/unit/validators/url.test.ts b/tests/unit/validators/url.test.ts index a062521..80a753d 100644 --- a/tests/unit/validators/url.test.ts +++ b/tests/unit/validators/url.test.ts @@ -1,11 +1,7 @@ -import { URLParsingError, URLProtocolValidationError } from '../../../src/errors'; +import { URLParsingError } from '../../../src/errors'; import { URLValidator } from '../../../src/validators'; import invalidURLs from '../../fixtures/invalid-urls.json'; -import type { URLProtocol } from '../../../src/validators/url'; - -const ALLOWED_PROTOCOLS: URLProtocol[] = ['http:', 'https:']; - describe('URLValidator', () => { let urlValidator: URLValidator; @@ -29,26 +25,6 @@ describe('URLValidator', () => { // Act & Assert expect(() => urlValidator.validate(url)).not.toThrow(); }); - - it.each(invalidURLs.unsupportedProtocols)( - 'should throw an error for a URL with an invalid protocol: %s', - (url) => { - // Act & Assert - expect(() => urlValidator.validate(url)).toThrow( - new URLProtocolValidationError(url, ALLOWED_PROTOCOLS) - ); - } - ); - - it('should allow custom configuration to validate different protocols', () => { - // Arrange - const url = 'ftp://example.com'; - const allowedProtocols: URLProtocol[] = ['file:', 'ftp:']; - const customURLValidator = new URLValidator({ allowedProtocols }); - - // Act & Assert - expect(() => customURLValidator.validate(url)).not.toThrow(); - }); }); describe('Parsing Validation', () => { From f20fd143bd13c383ac96f11cac01402df4aeb8c8 Mon Sep 17 00:00:00 2001 From: Jamie Howard Date: Wed, 4 Dec 2024 17:01:53 +0000 Subject: [PATCH 19/22] fix: pr review typos, always prepend api to baseurl and make package a module --- jest.config.js => jest.config.cjs | 0 package.json | 1 + src/auth/providers/users-permissions.ts | 2 +- src/http/client.ts | 2 +- tests/unit/auth/manager.test.ts | 2 +- tests/unit/auth/providers/api-token.test.ts | 2 +- tests/unit/http/client.test.ts | 2 +- tests/unit/sdk.test.ts | 4 ++-- 8 files changed, 8 insertions(+), 7 deletions(-) rename jest.config.js => jest.config.cjs (100%) diff --git a/jest.config.js b/jest.config.cjs similarity index 100% rename from jest.config.js rename to jest.config.cjs diff --git a/package.json b/package.json index 2460977..607c280 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "sdk", "js" ], + "type": "module", "repository": { "type": "git", "url": "https://github.com/strapi/sdk-js.git" diff --git a/src/auth/providers/users-permissions.ts b/src/auth/providers/users-permissions.ts index 2bc8bd9..cb00558 100644 --- a/src/auth/providers/users-permissions.ts +++ b/src/auth/providers/users-permissions.ts @@ -85,7 +85,7 @@ export class UsersPermissionsAuthProvider extends AbstractAuthProvider { try { const { baseURL } = httpClient; - const localAuthURL = `${baseURL}/api/auth/local`; + const localAuthURL = `${baseURL}/auth/local`; const request = new Request(localAuthURL, { method: 'POST', diff --git a/src/http/client.ts b/src/http/client.ts index 515da8b..c7668e2 100644 --- a/src/http/client.ts +++ b/src/http/client.ts @@ -62,7 +62,7 @@ export class HttpClient { setBaseURL(url: string): this { this._urlValidator.validate(url); - this._baseURL = url; + this._baseURL = `${url}/api`; return this; } diff --git a/tests/unit/auth/manager.test.ts b/tests/unit/auth/manager.test.ts index cdb2371..4ed00ca 100644 --- a/tests/unit/auth/manager.test.ts +++ b/tests/unit/auth/manager.test.ts @@ -49,7 +49,7 @@ describe('AuthManager', () => { expect(authManager.strategy).toBe(MockAuthProvider.identifier); }); - it('should not authenticated when strategy is not set', async () => { + it('should not be authenticated when strategy is not set', async () => { // Arrange const authManager = new AuthManager(new MockAuthProviderFactory()); diff --git a/tests/unit/auth/providers/api-token.test.ts b/tests/unit/auth/providers/api-token.test.ts index 3f466a6..0e3a520 100644 --- a/tests/unit/auth/providers/api-token.test.ts +++ b/tests/unit/auth/providers/api-token.test.ts @@ -56,7 +56,7 @@ describe('ApiTokenAuthProvider', () => { }); describe('Authenticate', () => { - it('should doe nothing when authenticate is called', async () => { + it('should do nothing when authenticate is called', async () => { // Arrange const token = 'abc-xyz'; const provider = new ApiTokenAuthProvider({ token }); diff --git a/tests/unit/http/client.test.ts b/tests/unit/http/client.test.ts index ecf7293..d70c261 100644 --- a/tests/unit/http/client.test.ts +++ b/tests/unit/http/client.test.ts @@ -47,7 +47,7 @@ describe('HttpClient', () => { // Assert expect(spy).toHaveBeenCalledWith(newBaseURL); - expect(httpClient.baseURL).toBe(newBaseURL); + expect(httpClient.baseURL).toBe(`${newBaseURL}/api`); }); it('setAuthStrategy should configure the authentication strategy', () => { diff --git a/tests/unit/sdk.test.ts b/tests/unit/sdk.test.ts index c95ef30..d14b61d 100644 --- a/tests/unit/sdk.test.ts +++ b/tests/unit/sdk.test.ts @@ -105,10 +105,10 @@ describe('StrapiSDK', () => { const sdk = new StrapiSDK(config, mockValidator, mockHttpClientFactory); // Act - const response = await sdk.fetch('/api/data'); + const response = await sdk.fetch('/data'); // Assert - expect(fetchSpy).toHaveBeenCalledWith('/api/data', undefined); + expect(fetchSpy).toHaveBeenCalledWith('/data', undefined); await expect(response.json()).resolves.toEqual({ ok: true }); }); From 935d16f056559a7792efac0c9640b9b2b9de4bc4 Mon Sep 17 00:00:00 2001 From: Jamie Howard Date: Thu, 5 Dec 2024 12:23:09 +0000 Subject: [PATCH 20/22] fix: expect content api url as baseurl --- src/http/client.ts | 2 +- src/index.ts | 2 +- src/sdk.ts | 2 +- tests/unit/http/client.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/http/client.ts b/src/http/client.ts index c7668e2..515da8b 100644 --- a/src/http/client.ts +++ b/src/http/client.ts @@ -62,7 +62,7 @@ export class HttpClient { setBaseURL(url: string): this { this._urlValidator.validate(url); - this._baseURL = `${url}/api`; + this._baseURL = url; return this; } diff --git a/src/index.ts b/src/index.ts index 9efc6b0..00522e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import type { StrapiSDKConfig } from './sdk'; * request dispatch, and response parsing for content management. * * @param config - The configuration for initializing the SDK. This should include the base URL - * of the Strapi backend instance that the SDK communicates with. The baseURL + * of the Strapi content API that the SDK communicates with. The baseURL * must be formatted with one of the supported protocols: `http` or `https`. * Additionally, optional authentication details can be specified within the config. * diff --git a/src/sdk.ts b/src/sdk.ts index 44e0d8c..fba800c 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -160,7 +160,7 @@ export class StrapiSDK * const sdk = createStrapiSDK({ baseURL: 'http://localhost:1337' ); * * // Perform a custom fetch query - * const response = await sdk.fetch('/api/categories'); + * const response = await sdk.fetch('/categories'); * * // Parse the categories into a readable JSON object * const categories = await response.json(); diff --git a/tests/unit/http/client.test.ts b/tests/unit/http/client.test.ts index d70c261..ecf7293 100644 --- a/tests/unit/http/client.test.ts +++ b/tests/unit/http/client.test.ts @@ -47,7 +47,7 @@ describe('HttpClient', () => { // Assert expect(spy).toHaveBeenCalledWith(newBaseURL); - expect(httpClient.baseURL).toBe(`${newBaseURL}/api`); + expect(httpClient.baseURL).toBe(newBaseURL); }); it('setAuthStrategy should configure the authentication strategy', () => { From 711e9ea1adca08421f50e0405191e8ce8fe19326 Mon Sep 17 00:00:00 2001 From: Jamie Howard Date: Thu, 5 Dec 2024 16:04:43 +0000 Subject: [PATCH 21/22] chore: revert type module --- jest.config.cjs => jest.config.js | 0 package.json | 1 - 2 files changed, 1 deletion(-) rename jest.config.cjs => jest.config.js (100%) diff --git a/jest.config.cjs b/jest.config.js similarity index 100% rename from jest.config.cjs rename to jest.config.js diff --git a/package.json b/package.json index 607c280..2460977 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "sdk", "js" ], - "type": "module", "repository": { "type": "git", "url": "https://github.com/strapi/sdk-js.git" From ad8fb9447312722b789616f0da01b57482679d1c Mon Sep 17 00:00:00 2001 From: Jamie Howard Date: Thu, 5 Dec 2024 16:09:05 +0000 Subject: [PATCH 22/22] chore: mark users and permissions auth as experimental --- src/auth/providers/users-permissions.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/auth/providers/users-permissions.ts b/src/auth/providers/users-permissions.ts index cb00558..3eb8b90 100644 --- a/src/auth/providers/users-permissions.ts +++ b/src/auth/providers/users-permissions.ts @@ -28,6 +28,11 @@ export type UsersPermissionsAuthPayload = Pick< 'identifier' | 'password' >; + /** + * @experimental + * Authentication through users and permissions is experimental for the MVP of + * the Strapi SDK. + */ export class UsersPermissionsAuthProvider extends AbstractAuthProvider { public static readonly identifier = USERS_PERMISSIONS_AUTH_STRATEGY_IDENTIFIER;