diff --git a/.changeset/stupid-bears-report.md b/.changeset/stupid-bears-report.md new file mode 100644 index 000000000..f30a828a7 --- /dev/null +++ b/.changeset/stupid-bears-report.md @@ -0,0 +1,22 @@ +--- +"@commercetools/ts-client": major +--- + +Introduce the new typescript client package with the following eatures and middleware +- [x] add client and middleware composer +- [x] add http-middleware +- [x] add error-middleware +- [x] add logger-middleware +- [x] add auth-middleware + - [x] add with-client-credentials-flow + - [x] add with-anonymous-session-flow + - [x] add with-password-flow + - [x] add with-refresh-token-flow + - [x] add with-existing-token-flow + - [x] add token cache +- [x] add retry-middleware +- [x] add correlation-id-middleware +- [x] add queue-middleware +- [x] add user-agent-middleware +- [x] add concurrent-modification-middleware +- [x] add axios and node-fetch support diff --git a/.gitignore b/.gitignore index 26cd039fb..e8afdd492 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ training-tmp/ loadtest/ .npmignore api.http -vue \ No newline at end of file +vue +poc diff --git a/packages/sdk-client-v3/LICENSE b/packages/sdk-client-v3/LICENSE new file mode 100644 index 000000000..45abef374 --- /dev/null +++ b/packages/sdk-client-v3/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 commercetools + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sdk-client-v3/README.md b/packages/sdk-client-v3/README.md new file mode 100644 index 000000000..a4a3bed82 --- /dev/null +++ b/packages/sdk-client-v3/README.md @@ -0,0 +1,126 @@ +# Commercetools Composable Commerce (Improved) TypeScript SDK client (beta) + +This is the new and improved Typescript SDK client. + +## Usage examples + +```bash +npm install --save @commercetools/ts-client +npm install --save @commercetools/platform-sdk + +or + +yarn add @commercetools/ts-client +yarn add @commercetools/platform-sdk +``` + +```ts +import { + type Next, + type HttpMiddlewareOptions, + type AuthMiddlewareBaseOptions, + type ClientRequest, + type MiddlewareRequest, + type MiddlewareResponse, + type Client, + ClientBuilder, +} from '@commercetools/ts-client' +import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk' +import fetch from 'node-fetch' + +const projectKey = 'mc-project-key' +const authMiddlewareOptions = { + host: 'https://auth.europe-west1.gcp.commercetools.com', + projectKey, + credentials: { + clientId: 'mc-client-id', + clientSecret: 'mc-client-secrets', + }, + oauthUri: '/oauth/token', // - optional: custom oauthUri + scopes: [`manage_project:${projectKey}`], + httpClient: fetch, +} + +const httpMiddlewareOptions = { + host: 'https://api.europe-west1.gcp.commercetools.com', + httpClient: fetch, +} + +const retryOptions = { + maxRetries: 3, + retryDelay: 200, + backoff: true, + retryCodes: [200], +} + +// custom middleware +function middleware(options) { + return (next: Next) => + async (request: MiddlewareRequest): Promise => { + const { response, ...rest } = request + + // other actions can also be carried out here e.g logging, + // error handling, injecting custom headers to http requests etc. + console.log({ response, rest }) + return next({ ...request }) + } +} + +const client: Client = new ClientBuilder() + .withPasswordFlow(authMiddlewareOptions) + .withLoggerMiddleware({ + includeOriginalRequest: false, + includeResponseHeaders: false, + }) + .withCorrelationIdMiddleware({ + generate: () => 'fake-correlation-id' + Math.floor(Math.random() + 2), + }) + .withHttpMiddleware(httpMiddlewareOptions) + .withRetryMiddleware(retryOptions) + .withMiddleware(middleware({})) // <<<------------------- add the custom middleware here + .withErrorMiddleware({}) + .build() + +const apiRoot = createApiBuilderFromCtpClient(client) + +// calling the Composable Commerce `api` functions +// get project details +apiRoot + .withProjectKey({ projectKey }) + .get() + .execute() + .then((x) => { + /*...*/ + }) + +// create a productType +apiRoot + .withProjectKey({ projectKey }) + .productTypes() + .post({ + body: { name: 'product-type-name', description: 'some description' }, + }) + .execute() + .then((x) => { + /*...*/ + }) + +// create a product +apiRoot + .withProjectKey({ projectKey }) + .products() + .post({ + body: { + name: { en: 'our-great-product-name' }, + productType: { + typeId: 'product-type', + id: 'some-product-type-id', + }, + slug: { en: 'some-slug' }, + }, + }) + .execute() + .then((x) => { + /*...*/ + }) +``` diff --git a/packages/sdk-client-v3/package.json b/packages/sdk-client-v3/package.json new file mode 100644 index 000000000..839cac813 --- /dev/null +++ b/packages/sdk-client-v3/package.json @@ -0,0 +1,61 @@ +{ + "name": "@commercetools/ts-client", + "version": "0.0.0-beta.11", + "engines": { + "node": ">=14" + }, + "description": "commercetools Composable Commerce TypeScript SDK client.", + "keywords": [ + "commercetools", + "composable commerce", + "sdk", + "typescript", + "client", + "middleware", + "http", + "oauth", + "auth" + ], + "homepage": "https://github.com/commercetools/commercetools-sdk-typescript", + "license": "MIT", + "directories": { + "lib": "lib", + "test": "test" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/commercetools/commercetools-sdk-typescript.git" + }, + "bugs": { + "url": "https://github.com/commercetools/commercetools-sdk-typescript/issues" + }, + "dependencies": { + "abort-controller": "3.0.0", + "buffer": "^6.0.3", + "node-fetch": "^2.6.1", + "querystring": "^0.2.1" + }, + "files": ["dist", "CHANGELOG.md"], + "author": "Chukwuemeka Ajima ", + "main": "dist/commercetools-ts-client.cjs.js", + "module": "dist/commercetools-ts-client.esm.js", + "browser": { + "./dist/commercetools-ts-client.cjs.js": "./dist/commercetools-ts-client.browser.cjs.js", + "./dist/commercetools-ts-client.esm.js": "./dist/commercetools-ts-client.browser.esm.js" + }, + "devDependencies": { + "common-tags": "1.8.2", + "dotenv": "16.0.3", + "jest": "29.5.0", + "nock": "12.0.3", + "organize-imports-cli": "0.10.0" + }, + "scripts": { + "organize_imports": "find src -type f -name '*.ts' | xargs organize-imports-cli", + "postbuild": "yarn organize_imports", + "post_process_generate": "yarn organize_imports" + } +} diff --git a/packages/sdk-client-v3/src/client/builder.ts b/packages/sdk-client-v3/src/client/builder.ts new file mode 100644 index 000000000..fefd69c50 --- /dev/null +++ b/packages/sdk-client-v3/src/client/builder.ts @@ -0,0 +1,256 @@ +import fetch from 'node-fetch' +import { default as createClient } from './client' +import * as middleware from '../middleware' +import { constants } from '../utils' + +import { + Client, + AuthMiddlewareOptions, + CorrelationIdMiddlewareOptions, + Credentials, + ExistingTokenMiddlewareOptions, + HttpMiddlewareOptions, + HttpUserAgentOptions, + Middleware, + Nullable, + PasswordAuthMiddlewareOptions, + QueueMiddlewareOptions, + RefreshAuthMiddlewareOptions, + LoggerMiddlewareOptions, + ErrorMiddlewareOptions, +} from '../types/types' + +const { + createAuthMiddlewareForPasswordFlow, + createAuthMiddlewareForAnonymousSessionFlow, + createAuthMiddlewareForClientCredentialsFlow, + createAuthMiddlewareForRefreshTokenFlow, + createAuthMiddlewareForExistingTokenFlow, + + createCorrelationIdMiddleware, + createHttpMiddleware, + createLoggerMiddleware, + createQueueMiddleware, + createUserAgentMiddleware, + createConcurrentModificationMiddleware, + + createErrorMiddleware, +} = middleware + +export default class ClientBuilder { + private projectKey: string | undefined + + private authMiddleware: Nullable + private httpMiddleware: Nullable + private userAgentMiddleware: Nullable + private correlationIdMiddleware: Nullable + private loggerMiddleware: Nullable + private queueMiddleware: Nullable + private concurrentMiddleware: Nullable + private errorMiddleware: Nullable + + private middlewares: Array = [] + + constructor() { + this.userAgentMiddleware = createUserAgentMiddleware({}) + } + + public withProjectKey(key: string): ClientBuilder { + this.projectKey = key + return this + } + + public defaultClient( + baseUri: string, + credentials: Credentials, + oauthUri?: string, + projectKey?: string, + scopes?: Array, + httpClient?: Function + ): ClientBuilder { + return this.withClientCredentialsFlow({ + host: oauthUri, + projectKey: projectKey || this.projectKey, + credentials, + scopes, + }).withHttpMiddleware({ + host: baseUri, + httpClient: httpClient || fetch, + }) + } + + private withAuthMiddleware(authMiddleware: Middleware): ClientBuilder { + this.authMiddleware = authMiddleware + return this + } + + public withMiddleware(middleware: Middleware): ClientBuilder { + this.middlewares.push(middleware) + return this + } + + public withClientCredentialsFlow( + options: AuthMiddlewareOptions + ): ClientBuilder { + return this.withAuthMiddleware( + createAuthMiddlewareForClientCredentialsFlow({ + host: options.host || constants.CTP_AUTH_URL, + projectKey: options.projectKey || this.projectKey, + credentials: { + clientId: options.credentials.clientId || null, + clientSecret: options.credentials.clientSecret || null, + }, + oauthUri: options.oauthUri || null, + scopes: options.scopes, + httpClient: options.httpClient || fetch, + ...options, + }) + ) + } + + public withPasswordFlow( + options: PasswordAuthMiddlewareOptions + ): ClientBuilder { + return this.withAuthMiddleware( + createAuthMiddlewareForPasswordFlow({ + host: options.host || constants.CTP_AUTH_URL, + projectKey: options.projectKey || this.projectKey, + credentials: { + clientId: options.credentials.clientId || null, + clientSecret: options.credentials.clientSecret || null, + user: { + username: options.credentials.user.username || null, + password: options.credentials.user.password || null, + }, + }, + httpClient: options.httpClient || fetch, + ...options, + }) + ) + } + + public withAnonymousSessionFlow( + options: AuthMiddlewareOptions + ): ClientBuilder { + return this.withAuthMiddleware( + createAuthMiddlewareForAnonymousSessionFlow({ + host: options.host || constants.CTP_AUTH_URL, + projectKey: this.projectKey || options.projectKey, + credentials: { + clientId: options.credentials.clientId || null, + clientSecret: options.credentials.clientSecret || null, + anonymousId: options.credentials.anonymousId || null, + }, + httpClient: options.httpClient || fetch, + ...options, + }) + ) + } + + public withRefreshTokenFlow( + options: RefreshAuthMiddlewareOptions + ): ClientBuilder { + return this.withAuthMiddleware( + createAuthMiddlewareForRefreshTokenFlow({ + host: options.host || constants.CTP_AUTH_URL, + projectKey: this.projectKey || options.projectKey, + credentials: { + clientId: options.credentials.clientId || null, + clientSecret: options.credentials.clientSecret || null, + }, + httpClient: options.httpClient || fetch, + refreshToken: options.refreshToken || null, + ...options, + }) + ) + } + + public withExistingTokenFlow( + authorization: string, + options?: ExistingTokenMiddlewareOptions + ): ClientBuilder { + return this.withAuthMiddleware( + createAuthMiddlewareForExistingTokenFlow(authorization, { + force: options.force || true, + ...options, + }) + ) + } + + public withHttpMiddleware(options: HttpMiddlewareOptions): ClientBuilder { + this.httpMiddleware = createHttpMiddleware({ + host: options.host || constants.CTP_API_URL, + httpClient: options.httpClient || fetch, + ...options, + }) + + return this + } + + public withUserAgentMiddleware( + options?: HttpUserAgentOptions + ): ClientBuilder { + this.userAgentMiddleware = createUserAgentMiddleware(options) + + return this + } + + public withQueueMiddleware(options?: QueueMiddlewareOptions): ClientBuilder { + this.queueMiddleware = createQueueMiddleware({ + concurrency: options.concurrency || constants.CONCURRENCT_REQUEST, + ...options, + }) + + return this + } + + public withLoggerMiddleware(options?: LoggerMiddlewareOptions) { + this.loggerMiddleware = createLoggerMiddleware(options) + + return this + } + + public withCorrelationIdMiddleware( + options?: CorrelationIdMiddlewareOptions + ): ClientBuilder { + this.correlationIdMiddleware = createCorrelationIdMiddleware({ + generate: options?.generate, + ...options, + }) + + return this + } + + public withConcurrentModificationMiddleware(): ClientBuilder { + this.concurrentMiddleware = createConcurrentModificationMiddleware() + + return this + } + + public withErrorMiddleware(options?: ErrorMiddlewareOptions): ClientBuilder { + this.errorMiddleware = createErrorMiddleware(options) + + return this + } + + build(): Client { + const middlewares = this.middlewares.slice() + + /** + * - use default retry policy if not explicity added + * - add retry middleware to be used by concurrent modification + * middleware if not explicitly added as part of the middleware + */ + if (this.correlationIdMiddleware) + middlewares.push(this.correlationIdMiddleware) + if (this.userAgentMiddleware) middlewares.push(this.userAgentMiddleware) + if (this.authMiddleware) middlewares.push(this.authMiddleware) + if (this.queueMiddleware) middlewares.push(this.queueMiddleware) + if (this.loggerMiddleware) middlewares.push(this.loggerMiddleware) + if (this.errorMiddleware) middlewares.push(this.errorMiddleware) + if (this.concurrentMiddleware) middlewares.push(this.concurrentMiddleware) + if (this.httpMiddleware) middlewares.push(this.httpMiddleware) + + return createClient({ middlewares }) + } +} diff --git a/packages/sdk-client-v3/src/client/client.ts b/packages/sdk-client-v3/src/client/client.ts new file mode 100644 index 000000000..5dbb11ef5 --- /dev/null +++ b/packages/sdk-client-v3/src/client/client.ts @@ -0,0 +1,183 @@ +import qs from 'querystring' +import { + Next, + Client, + Dispatch, + ClientResult, + ClientRequest, + ClientOptions, + MiddlewareResponse, + Middleware, + ProcessFn, + ProcessOptions, +} from '../types/types' +import { validate, maskAuthData, validateClient } from '../../src/utils' + +function compose({ + middlewares, +}: { + middlewares: Array +}): Middleware { + if (middlewares.length === 1) return middlewares[0] + + const _middlewares = middlewares.slice() + return _middlewares.reduce( + (ac: Dispatch, cv: Dispatch) => + (...args: Array): Next => + ac(cv.apply(null, args)) + ) +} + +// process batch requests +let _options: ClientOptions +export function process( + request: ClientRequest, + fn: ProcessFn, + processOpt?: ProcessOptions +): Promise> { + validate('process', request, { allowedMethods: ['GET'] }) + + if (typeof fn !== 'function') + throw new Error( + 'The "process" function accepts a "Function" as a second argument that returns a Promise. See https://commercetools.github.io/nodejs/sdk/api/sdkClient.html#processrequest-processfn-options' + ) + + // Set default process options + const opt: ProcessOptions = { + total: Number.POSITIVE_INFINITY, + accumulate: true, + ...processOpt, + } + + return new Promise((resolve: Function, reject: Function) => { + let _path: string, + _queryString = '' + if (request && request.uri) { + const [path, queryString] = request.uri.split('?') + _path = path + _queryString = queryString + } + + const requestQuery = { ...qs.parse(_queryString) } + const query = { + // defaults + limit: 20, + // merge given query params + ...requestQuery, + } + + let itemsToGet = opt.total + let hasFirstPageBeenProcessed = false + const processPage = async (lastId?: string, acc: Array = []) => { + // Use the lesser value between limit and itemsToGet in query + const limit = query.limit < itemsToGet ? query.limit : itemsToGet + const originalQueryString = qs.stringify({ ...query, limit }) + + const enhancedQuery = { + sort: 'id asc', + withTotal: false, + ...(lastId ? { where: `id > "${lastId}"` } : {}), + } + const enhancedQueryString = qs.stringify(enhancedQuery) + const enhancedRequest = { + ...request, + uri: `${_path}?${enhancedQueryString}&${originalQueryString}`, + } + + try { + const payload: ClientResult = await createClient(_options).execute( + enhancedRequest + ) + + const { results, count: resultsLength } = payload?.body || {} + + if (!resultsLength && hasFirstPageBeenProcessed) { + return resolve(acc || []) + } + + const result = await Promise.resolve(fn(payload)) + let accumulated: Array> + hasFirstPageBeenProcessed = true + + if (opt.accumulate) accumulated = acc.concat(result || []) + + itemsToGet -= resultsLength + // If there are no more items to get, it means the total number + // of items in the original request have been fetched so we + // resolve the promise. + // Also, if we get less results in a page then the limit set it + // means that there are no more pages and that we can finally + // resolve the promise. + if (resultsLength < query.limit || !itemsToGet) { + return resolve(accumulated || []) + } + + const last = results[resultsLength - 1] + const newLastId = last && last.id + + processPage(newLastId, accumulated) + } catch (error) { + reject(error) + } + } + + // Start iterating through pages + processPage() + }) +} + +export default function createClient(middlewares: ClientOptions): Client { + _options = middlewares + validateClient(middlewares) + + const resolver = { + async resolve(rs: ClientRequest): Promise { + const { + response, + includeOriginalRequest, + maskSensitiveHeaderData, + ...request + } = rs + const { retryCount, ...rest } = response + + const res = { + body: null, + error: null, + reject: rs.reject, + resolve: rs.resolve, + ...rest, + ...(includeOriginalRequest + ? { + originalRequest: maskSensitiveHeaderData + ? maskAuthData(request) + : request, + } + : {}), + ...(response?.retryCount ? { retryCount: response.retryCount } : {}), + } as MiddlewareResponse + + if (res.error) { + res.reject(res.error) + return res + } + + res.resolve(res) + return res + }, + } + + const dispatch = compose(middlewares)(resolver.resolve as any) + return { + process, + execute(request: ClientRequest): Promise { + validate('exec', request) + return new Promise((resolve, reject) => { + return dispatch({ + reject, + resolve, + ...request, + }) + }) + }, + } +} diff --git a/packages/sdk-client-v3/src/client/index.ts b/packages/sdk-client-v3/src/client/index.ts new file mode 100644 index 000000000..766ccad4a --- /dev/null +++ b/packages/sdk-client-v3/src/client/index.ts @@ -0,0 +1,3 @@ +export { default as createClient } from './client' +export { default as ClientBuilder } from './builder' +export { process as Process } from './client' diff --git a/packages/sdk-client-v3/src/index.ts b/packages/sdk-client-v3/src/index.ts new file mode 100644 index 000000000..865d3f50f --- /dev/null +++ b/packages/sdk-client-v3/src/index.ts @@ -0,0 +1,14 @@ +export { ClientBuilder, createClient, Process } from './client' + +// export { default as getErrorByCode } from './sdk-client/errors' +export { createAuthMiddlewareForAnonymousSessionFlow } from './middleware' +export { createAuthMiddlewareForClientCredentialsFlow } from './middleware' +export { createAuthMiddlewareForExistingTokenFlow } from './middleware' +export { createAuthMiddlewareForPasswordFlow } from './middleware' +export { createAuthMiddlewareForRefreshTokenFlow } from './middleware' +export { createCorrelationIdMiddleware } from './middleware' +export { createHttpMiddleware } from './middleware' +export { createLoggerMiddleware } from './middleware' +export { createQueueMiddleware } from './middleware' +export { createUserAgentMiddleware } from './middleware' +export * from './types/types.d' diff --git a/packages/sdk-client-v3/src/middleware/auth-middleware/anonymous-session-flow.ts b/packages/sdk-client-v3/src/middleware/auth-middleware/anonymous-session-flow.ts new file mode 100644 index 000000000..66eb625fe --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/auth-middleware/anonymous-session-flow.ts @@ -0,0 +1,64 @@ +import fetch from 'node-fetch' +import { + Next, + Task, + Middleware, + MiddlewareRequest, + MiddlewareResponse, + RequestState, + AuthMiddlewareOptions, + RequestStateStore, + TokenCache, + TokenStore, +} from '../../types/types' +import { buildRequestForAnonymousSessionFlow } from './auth-request-builder' +import { executeRequest } from './auth-request-executor' +import { store, buildTokenCacheKey } from '../../utils' + +export default function createAuthMiddlewareForAnonymousSessionFlow( + options: AuthMiddlewareOptions +): Middleware { + const pendingTasks: Array = [] + const requestState = store(false) + const tokenCache = + options.tokenCache || + store({ + token: '', + expirationTime: -1, + }) + + const tokenCacheKey = buildTokenCacheKey(options) + + return (next: Next) => { + return async (request: MiddlewareRequest): Promise => { + // if here is a token in the header, then move on to the next middleware + if ( + request.headers && + (request.headers.Authorization || request.headers.authorization) + ) { + // move on + return next(request) + } + + // prepare request options + const requestOptions = { + request, + requestState, + tokenCache, + pendingTasks, + tokenCacheKey, + httpClient: options.httpClient || fetch, + ...buildRequestForAnonymousSessionFlow(options), + userOption: options, + next, + } + + // make request to coco + const requestWithAuth = await executeRequest(requestOptions) + + if (requestWithAuth) { + return next(requestWithAuth) + } + } + } +} diff --git a/packages/sdk-client-v3/src/middleware/auth-middleware/auth-request-builder.ts b/packages/sdk-client-v3/src/middleware/auth-middleware/auth-request-builder.ts new file mode 100644 index 000000000..8f8967224 --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/auth-middleware/auth-request-builder.ts @@ -0,0 +1,162 @@ +import { + IBuiltRequestParams, + AuthMiddlewareOptions, + RefreshAuthMiddlewareOptions, + PasswordAuthMiddlewareOptions, +} from '../../types/types' +import { Buffer } from 'buffer/' + +/** + * + * @param {AuthMiddlewareOptions} options + * @returns { IBuiltRequestParams } * + */ +export function buildRequestForClientCredentialsFlow( + options: AuthMiddlewareOptions +): IBuiltRequestParams { + // Validate options + if (!options) throw new Error('Missing required options') + if (!options.host) throw new Error('Missing required option (host)') + if (!options.projectKey) + throw new Error('Missing required option (projectKey)') + if (!options.credentials) + throw new Error('Missing required option (credentials)') + + const { clientId, clientSecret } = options.credentials || {} + if (!(clientId && clientSecret)) + throw new Error('Missing required credentials (clientId, clientSecret)') + + const scope = options.scopes ? options.scopes.join(' ') : undefined + const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString( + 'base64' + ) + // This is mostly useful for internal testing purposes to be able to check + // other oauth endpoints. + const oauthUri = options.oauthUri || '/oauth/token' + const url = options.host.replace(/\/$/, '') + oauthUri + const body = `grant_type=client_credentials${scope ? `&scope=${scope}` : ''}` + + return { + url, + body, + basicAuth, + } +} + +/** + * + * @param {AuthMiddlewareOptions} options + * @returns {IBuiltRequestParams} * + */ +export function buildRequestForAnonymousSessionFlow( + options: AuthMiddlewareOptions +): IBuiltRequestParams { + if (!options) throw new Error('Missing required options') + if (!options.projectKey) + throw new Error('Missing required option (projectKey)') + + const projectKey = options.projectKey + options.oauthUri = options.oauthUri || `/oauth/${projectKey}/anonymous/token` + const result = buildRequestForClientCredentialsFlow(options) + + if (options.credentials.anonymousId) + result.body += `&anonymous_id=${options.credentials.anonymousId}` + + return { + ...result, + } +} + +/** + * + * @param {RefreshAuthMiddlewareOptions} options + * @returns {IBuiltRequestParams} + */ +export function buildRequestForRefreshTokenFlow( + options: RefreshAuthMiddlewareOptions +): IBuiltRequestParams { + if (!options) throw new Error('Missing required options') + + if (!options.host) throw new Error('Missing required option (host)') + + if (!options.projectKey) + throw new Error('Missing required option (projectKey)') + + if (!options.credentials) + throw new Error('Missing required option (credentials)') + + if (!options.refreshToken) + throw new Error('Missing required option (refreshToken)') + + const { clientId, clientSecret } = options.credentials + + if (!(clientId && clientSecret)) + throw new Error('Missing required credentials (clientId, clientSecret)') + + const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString( + 'base64' + ) + // This is mostly useful for internal testing purposes to be able to check + // other oauth endpoints. + const oauthUri = options.oauthUri || '/oauth/token' + const url = options.host.replace(/\/$/, '') + oauthUri + const body = `grant_type=refresh_token&refresh_token=${encodeURIComponent( + options.refreshToken + )}` + + return { basicAuth, url, body } +} + +/** + * @param {PasswordAuthMiddlewareOptions} options + * @returns {IBuiltRequestParams} + */ +export function buildRequestForPasswordFlow( + options: PasswordAuthMiddlewareOptions +): IBuiltRequestParams { + if (!options) throw new Error('Missing required options') + + if (!options.host) throw new Error('Missing required option (host)') + + if (!options.projectKey) + throw new Error('Missing required option (projectKey)') + + if (!options.credentials) + throw new Error('Missing required option (credentials)') + + const { clientId, clientSecret, user } = options.credentials + const projectKey = options.projectKey + if (!(clientId && clientSecret && user)) + throw new Error( + 'Missing required credentials (clientId, clientSecret, user)' + ) + + const { username, password } = user + if (!(username && password)) + throw new Error('Missing required user credentials (username, password)') + + const scope = (options.scopes || []).join(' ') + const scopeStr = scope ? `&scope=${scope}` : '' + + const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString( + 'base64' + ) + + /** + * This is mostly useful for internal testing purposes to be able to check + * other oauth endpoints. + */ + const oauthUri = options.oauthUri || `/oauth/${projectKey}/customers/token` + const url = options.host.replace(/\/$/, '') + oauthUri + + // encode username and password as requested by the system + const body = `grant_type=password&username=${encodeURIComponent( + username + )}&password=${encodeURIComponent(password)}${scopeStr}` + + return { + basicAuth, + url, + body, + } +} diff --git a/packages/sdk-client-v3/src/middleware/auth-middleware/auth-request-executor.ts b/packages/sdk-client-v3/src/middleware/auth-middleware/auth-request-executor.ts new file mode 100644 index 000000000..a6759f621 --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/auth-middleware/auth-request-executor.ts @@ -0,0 +1,181 @@ +import { + Task, + ClientRequest, + TokenInfo, + executeRequestOptions, + IBuiltRequestParams, + MiddlewareResponse, +} from '../../types/types' +import { buildRequestForRefreshTokenFlow } from './auth-request-builder' +import { Buffer } from 'buffer/' +import { calculateExpirationTime, mergeAuthHeader, executor } from '../../utils' + +export async function executeRequest( + options: executeRequestOptions +): Promise { + const { + request, + httpClient, + tokenCache, + tokenCacheKey, + requestState, + userOption, + next, + } = options + + let url = options.url + let body = options.body + let basicAuth = options.basicAuth + + // get the pending object from option + let pendingTasks: Array = options.pendingTasks + + if (!httpClient || typeof httpClient !== 'function') + throw new Error( + 'an `httpClient` is not available, please pass in a `fetch` or `axios` instance as an option or have them globally available.' + ) + + /** + * If there is a token in the tokenCache, and it's not + * expired, append the token in the `Authorization` header. + */ + const tokenCacheObject = tokenCache.get(tokenCacheKey) + if ( + tokenCacheObject && + tokenCacheObject.token && + Date.now() < tokenCacheObject.expirationTime + ) { + const requestWithAuth = mergeAuthHeader(tokenCacheObject.token, request) + + return { + ...requestWithAuth, + } + } + + /** + * Keep pending tasks until a token is fetched + * Save next function as well, to call it once the token has been fetched, which prevents + * unexpected behaviour in a context in which the next function uses global vars + * or Promises to capture the token to hand it to other libraries, e.g. Apollo + */ + pendingTasks.push({ request, next }) + + // if a token is currently being fetched, then wait + if (requestState.get()) return + + // signal that a token is being fetched + requestState.set(true) + + /** + * use refreshToken flow if there is refresh-token + * and there's either no token or the token is expired + */ + if ( + tokenCacheObject && + tokenCacheObject.refreshToken && + (!tokenCacheObject.token || + (tokenCacheObject.token && Date.now() > tokenCacheObject.expirationTime)) + ) { + if (!userOption) throw new Error('Missing required options.') + const opt: IBuiltRequestParams = { + ...buildRequestForRefreshTokenFlow({ + ...userOption, + refreshToken: tokenCacheObject.refreshToken, + }), + } + + // reassign values + url = opt.url + body = opt.body + basicAuth = opt.basicAuth + } + + // request a new token + let response + try { + response = await executor({ + url, + method: 'POST', + headers: { + Authorization: `Basic ${basicAuth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Conent-Length': Buffer.byteLength(body).toString(), + }, + httpClient, + body, + }) + + if (response.statusCode >= 200 && response.statusCode < 300) { + const { + access_token: token, + expires_in: expiresIn, + refresh_token: refreshToken, + }: TokenInfo = response?.data + + // calculate token expiration time + const expirationTime = calculateExpirationTime(expiresIn) + + // cache new generated token, refreshToken and expiration time + tokenCache.set({ token, expirationTime, refreshToken }) + + // signal that a token fetch is complete + requestState.set(false) + + /** + * Freeze and copy pending queue, reset + * original one for accepting new pending tasks + */ + const requestQueue = pendingTasks.slice() + + // reset pendingTask queue + pendingTasks = [] + + if (requestQueue.length === 1) { + return mergeAuthHeader(token, requestQueue.pop().request) + } + + // execute all pending tasks if any + for (let i = 0; i < requestQueue.length; i++) { + const task: Task = requestQueue[i] + const requestWithAuth = mergeAuthHeader(token, task.request) + + // execute task + task.next(requestWithAuth) + } + + return + } + + const error = new Error( + response.data.message + ? response.data.message + : JSON.stringify(response.data) + ) + /** + * reject the error immediately + * and free up the middleware chain + */ + request.reject({ + ...request, + headers: { ...request.headers }, + response: { + statusCode: response.statusCode || response.data.statusCode, + error: { error, body: response }, + }, + }) + } catch (error) { + return { + ...request, + headers: { ...request.headers }, + response: { + body: null, + statusCode: error.statusCode || 0, + error: { + ...response, + error, + body: response, + }, + }, + } + } +} diff --git a/packages/sdk-client-v3/src/middleware/auth-middleware/client-credentials-flow.ts b/packages/sdk-client-v3/src/middleware/auth-middleware/client-credentials-flow.ts new file mode 100644 index 000000000..133b19fbb --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/auth-middleware/client-credentials-flow.ts @@ -0,0 +1,62 @@ +import fetch from 'node-fetch' +import { + Next, + Task, + Middleware, + MiddlewareRequest, + AuthMiddlewareOptions, + TokenStore, + MiddlewareResponse, + TokenCache, + RequestState, + RequestStateStore, +} from '../../types/types' +import { executeRequest } from './auth-request-executor' +import { buildRequestForClientCredentialsFlow } from './auth-request-builder' +import { store, buildTokenCacheKey } from '../../utils' + +export default function createAuthMiddlewareForClientCredentialsFlow( + options: AuthMiddlewareOptions +): Middleware { + const requestState = store(false) + const pendingTasks: Array = [] + const tokenCache = + options.tokenCache || + store({ + token: '', + expirationTime: -1, + }) + + const tokenCacheKey = buildTokenCacheKey(options) + return (next: Next) => { + return async (request: MiddlewareRequest): Promise => { + // if here is a token in the header, then move on to the next middleware + if ( + request.headers && + (request.headers.Authorization || request.headers.authorization) + ) { + // move on + return next(request) + } + + // prepare request options + const requestOptions = { + request, + requestState, + tokenCache, + pendingTasks, + tokenCacheKey, + httpClient: options.httpClient || fetch, + ...buildRequestForClientCredentialsFlow(options), + next, + } + + // make request to coco + const requestWithAuth = await executeRequest(requestOptions) + if (requestWithAuth) { + // make the request and inject the token into the header + return next(requestWithAuth) + } + } + } +} diff --git a/packages/sdk-client-v3/src/middleware/auth-middleware/existing-token-flow.ts b/packages/sdk-client-v3/src/middleware/auth-middleware/existing-token-flow.ts new file mode 100644 index 000000000..0124a17d3 --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/auth-middleware/existing-token-flow.ts @@ -0,0 +1,45 @@ +import { + Next, + Middleware, + MiddlewareRequest, + MiddlewareResponse, + ExistingTokenMiddlewareOptions, +} from '../../types/types' + +export default function createAuthMiddlewareForExistingTokenFlow( + authorization: string, + options?: ExistingTokenMiddlewareOptions +): Middleware { + return (next: Next) => { + return async (request: MiddlewareRequest): Promise => { + if (typeof authorization !== 'string') + throw new Error('authorization must be a string') + + const isForce = options?.force === undefined ? true : options.force + + /** + * The request will not be modified if: + * 1. no argument is passed + * 2. force is false and authorization header exists + */ + if ( + !authorization || + (request.headers && + (request.headers.Authorization || request.headers.authorization) && + isForce === false) + ) { + return next(request) + } + + const requestWithAuth = { + ...request, + headers: { + ...request.headers, + Authorization: authorization, + }, + } + + return next(requestWithAuth) + } + } +} diff --git a/packages/sdk-client-v3/src/middleware/auth-middleware/index.ts b/packages/sdk-client-v3/src/middleware/auth-middleware/index.ts new file mode 100644 index 000000000..8f8a3475e --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/auth-middleware/index.ts @@ -0,0 +1,13 @@ +import createAuthMiddlewareForPasswordFlow from './password-flow' +import createAuthMiddlewareForAnonymousSessionFlow from './anonymous-session-flow' +import createAuthMiddlewareForClientCredentialsFlow from './client-credentials-flow' +import createAuthMiddlewareForRefreshTokenFlow from './refresh-token-flow' +import createAuthMiddlewareForExistingTokenFlow from './existing-token-flow' + +export { + createAuthMiddlewareForPasswordFlow, + createAuthMiddlewareForAnonymousSessionFlow, + createAuthMiddlewareForClientCredentialsFlow, + createAuthMiddlewareForRefreshTokenFlow, + createAuthMiddlewareForExistingTokenFlow +} diff --git a/packages/sdk-client-v3/src/middleware/auth-middleware/password-flow.ts b/packages/sdk-client-v3/src/middleware/auth-middleware/password-flow.ts new file mode 100644 index 000000000..7eaa69e04 --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/auth-middleware/password-flow.ts @@ -0,0 +1,59 @@ +import fetch from 'node-fetch' +import { + Next, + Task, + Middleware, + MiddlewareRequest, + MiddlewareResponse, + PasswordAuthMiddlewareOptions, + RequestState, + RequestStateStore, +} from '../../types/types' +import { executeRequest } from './auth-request-executor' +import { buildRequestForPasswordFlow } from './auth-request-builder' +import { store, buildTokenCacheKey } from '../../utils' + +export default function createAuthMiddlewareForPasswordFlow( + options: PasswordAuthMiddlewareOptions +): Middleware { + const tokenCache = + options.tokenCache || + store({ + token: '', + expirationTime: -1, + }) + + const pendingTasks: Array = [] + const requestState = store(false) + + const tokenCacheKey = buildTokenCacheKey(options) + + return (next: Next) => { + return async (request: MiddlewareRequest): Promise => { + if ( + request.headers && + (request.headers.Authorization || request.headers.authorization) + ) { + return next(request) + } + + const requestOptions = { + request, + requestState, + tokenCache, + pendingTasks, + tokenCacheKey, + httpClient: options.httpClient || fetch, + ...buildRequestForPasswordFlow(options), + userOption: options, + next, + } + + // make request to coco + const requestWithAuth = await executeRequest(requestOptions) + if (requestWithAuth) { + return next(requestWithAuth) + } + } + } +} diff --git a/packages/sdk-client-v3/src/middleware/auth-middleware/refresh-token-flow.ts b/packages/sdk-client-v3/src/middleware/auth-middleware/refresh-token-flow.ts new file mode 100644 index 000000000..20d97b7b4 --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/auth-middleware/refresh-token-flow.ts @@ -0,0 +1,55 @@ +import { + Next, + Task, + Middleware, + MiddlewareRequest, + MiddlewareResponse, + RefreshAuthMiddlewareOptions, + RequestState, + RequestStateStore, +} from '../../types/types' +import { executeRequest } from './auth-request-executor' +import { buildRequestForRefreshTokenFlow } from './auth-request-builder' +import { store } from '../../utils' + +export default function createAuthMiddlewareForRefreshTokenFlow( + options: RefreshAuthMiddlewareOptions +): Middleware { + const tokenCache = + options.tokenCache || + store({ + token: '', + tokenCacheKey: null, + }) + + const pendingTasks: Array = [] + const requestState = store(false) + + return (next: Next) => { + return async (request: MiddlewareRequest): Promise => { + if ( + request.headers && + (request.headers.Authorization || request.headers.authorization) + ) { + return next(request) + } + + // prepare request options + const requestOptions = { + request, + requestState, + tokenCache, + pendingTasks, + httpClient: options.httpClient || fetch, + ...buildRequestForRefreshTokenFlow(options), + next, + } + + // make request to coco + const requestWithAuth = await executeRequest(requestOptions) + if (requestWithAuth) { + return next(requestWithAuth) + } + } + } +} diff --git a/packages/sdk-client-v3/src/middleware/create-concurrent-modification-middleware.ts b/packages/sdk-client-v3/src/middleware/create-concurrent-modification-middleware.ts new file mode 100644 index 000000000..cdc9ff16f --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/create-concurrent-modification-middleware.ts @@ -0,0 +1,34 @@ +import { + Next, + Middleware, + MiddlewareRequest, + MiddlewareResponse, +} from '../types/types' + +export default function createConcurrentModificationMiddleware(): Middleware { + return (next: Next) => { + return async (request: MiddlewareRequest): Promise => { + const response = await next(request) + if (response.statusCode == 409) { + /** + * extract the currentVersion + * from the error body and update + * request with the currentVersion + */ + const version = response.error?.body?.errors?.[0]?.currentVersion + + // update the resource version here + if (version) { + request.body = + typeof request.body == 'string' + ? { ...JSON.parse(request.body), version } + : { ...request.body, version } + + return next(request) + } + } + + return response + } + } +} diff --git a/packages/sdk-client-v3/src/middleware/create-correlation-id-middleware.ts b/packages/sdk-client-v3/src/middleware/create-correlation-id-middleware.ts new file mode 100644 index 000000000..d8575f150 --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/create-correlation-id-middleware.ts @@ -0,0 +1,26 @@ +import { + Middleware, + MiddlewareRequest, + Next, + CorrelationIdMiddlewareOptions, +} from '../types/types' +import { generate } from '../utils' + +export default function createCorrelationIdMiddleware( + options?: CorrelationIdMiddlewareOptions +): Middleware { + return (next: Next) => (request: MiddlewareRequest) => { + const nextRequest = { + ...request, + headers: { + ...request.headers, + 'X-Correlation-ID': + options.generate && typeof options.generate == 'function' + ? options.generate() + : generate(), + }, + } + + return next(nextRequest) + } +} diff --git a/packages/sdk-client-v3/src/middleware/create-error-middleware.ts b/packages/sdk-client-v3/src/middleware/create-error-middleware.ts new file mode 100644 index 000000000..c58f5ece6 --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/create-error-middleware.ts @@ -0,0 +1,32 @@ +import { + Next, + Middleware, + HttpErrorType, + MiddlewareRequest, + MiddlewareResponse, + ErrorMiddlewareOptions, +} from '../types/types' +import { getHeaders } from '../utils' + +export default function createErrorMiddleware( + options?: ErrorMiddlewareOptions +): Middleware { + return (next: Next): Next => + async (request: MiddlewareRequest): Promise => { + const response = await next(request) + if (response.error) { + const { error } = response + return { + ...response, + statusCode: error.statusCode || 0, + headers: error.headers || getHeaders({}), + error: { + ...error, + body: error.data || error, + } as HttpErrorType, + } + } + + return response + } +} diff --git a/packages/sdk-client-v3/src/middleware/create-http-middleware.ts b/packages/sdk-client-v3/src/middleware/create-http-middleware.ts new file mode 100644 index 000000000..515b9ac51 --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/create-http-middleware.ts @@ -0,0 +1,239 @@ +import { + Next, + Middleware, + MiddlewareRequest, + MiddlewareResponse, + HttpMiddlewareOptions, + JsonObject, + QueryParam, + HttpOptions, + HttpErrorType, + HttpClientConfig, + HttpClientOptions, + ClientResult, + TResponse, +} from '../types/types' +import { + validateHttpOptions, + isBuffer, + getHeaders, + executor, + constants, + createError, + NetworkError, + maskAuthData, +} from '../utils' +import { Buffer } from 'buffer/' +import AbortController from 'abort-controller' + +async function executeRequest({ + url, + httpClient, + clientOptions, +}: HttpOptions): Promise { + let timer: ReturnType + + const { + timeout, + request, + abortController, + maskSensitiveHeaderData, + includeRequestInErrorResponse, + } = clientOptions + + try { + if (timeout) + timer = setTimeout(() => { + abortController.abort() + }, timeout) + + const response: TResponse = await executor({ + url, + ...clientOptions, + httpClient, + method: clientOptions.method, + ...(clientOptions.body ? { body: clientOptions.body } : {}), + } as HttpClientConfig) + + if (response.statusCode >= 200 && response.statusCode < 300) { + if (clientOptions.method == 'HEAD') { + return { + body: null, + statusCode: response.statusCode, + retryCount: response.retryCount, + headers: getHeaders(response.headers), + } + } + + return { + body: response.data, + statusCode: response.statusCode, + retryCount: response.retryCount, + headers: getHeaders(response.headers), + } + } + + const error: HttpErrorType = createError({ + message: response?.data?.message || response?.message, + statusCode: response.statusCode || response?.data?.statusCode, + headers: getHeaders(response.headers), + method: clientOptions.method, + body: response.data, + retryCount: response.retryCount, + ...(includeRequestInErrorResponse + ? { + originalRequest: maskSensitiveHeaderData + ? maskAuthData(request) + : request, + } + : { uri: request.uri }), + }) + + /** + * handle non-ok (error) response + * build error body + */ + return { + body: response.data, + code: response.statusCode, + statusCode: response.statusCode, + headers: getHeaders(response.headers), + error, + } + } catch (e) { + // We know that this is a network error + const headers = getHeaders(e.response?.headers) + const statusCode = e.response?.status || e.response?.data0 || 0 + const message = e.response?.data?.message + + const error: HttpErrorType = createError({ + statusCode, + code: statusCode, + status: statusCode, + message: message || e.message, + headers, + body: e.response?.data || e, + error: e.response?.data, + ...(includeRequestInErrorResponse + ? { + originalRequest: maskSensitiveHeaderData + ? maskAuthData(request) + : request, + } + : { uri: request.uri }), + }) + + return { + body: error, + error, + } + } finally { + clearTimeout(timer) + } +} + +export default function createHttpMiddleware( + options: HttpMiddlewareOptions +): Middleware { + // validate response + validateHttpOptions(options) + + const { + host, + credentialsMode, + httpClient, + timeout, + enableRetry, + retryConfig, + getAbortController, + includeOriginalRequest, + includeRequestInErrorResponse, + maskSensitiveHeaderData, + httpClientOptions, + } = options + + return (next: Next) => { + return async (request: MiddlewareRequest): Promise => { + let abortController: AbortController + + if (timeout || getAbortController) + abortController = + (getAbortController ? getAbortController() : null) || + new AbortController() + + const url = host.replace(/\/$/, '') + request.uri + const requestHeader: JsonObject = { ...request.headers } + + // validate header + if ( + !( + Object.prototype.hasOwnProperty.call(requestHeader, 'Content-Type') || + Object.prototype.hasOwnProperty.call(requestHeader, 'content-type') + ) + ) { + requestHeader['Content-Type'] = 'application/json' + } + + // Unset the content-type header if explicitly asked to (passing `null` as value). + if (requestHeader['Content-Type'] === null) { + delete requestHeader['Content-Type'] + } + + // Ensure body is a string if content type is application/{json|graphql} + const body: string | Buffer = + (constants.HEADERS_CONTENT_TYPES.indexOf( + requestHeader['Content-Type'] as string + ) > -1 && + typeof request.body === 'string') || + isBuffer(request.body) + ? request.body + : JSON.stringify(request.body || undefined) + + if (body && (typeof body === 'string' || isBuffer(body))) { + requestHeader['Content-Length'] = Buffer.byteLength( + body as string + ).toString() + } + + const clientOptions: HttpClientOptions = { + enableRetry, + retryConfig, + request: request, + method: request.method, + headers: requestHeader, + includeRequestInErrorResponse, + maskSensitiveHeaderData, + ...httpClientOptions, + } + + if (credentialsMode) { + clientOptions.credentialsMode = credentialsMode + } + + if (abortController) { + clientOptions.signal = abortController.signal + } + + if (timeout) { + clientOptions.timeout = timeout + clientOptions.abortController = abortController + } + + if (body) { + clientOptions.body = body + } + + // get result from executed request + const response = await executeRequest({ url, clientOptions, httpClient }) + + const responseWithRequest = { + ...request, + includeOriginalRequest, + maskSensitiveHeaderData, + response, + } + + return next(responseWithRequest) + } + } +} diff --git a/packages/sdk-client-v3/src/middleware/create-logger-middleware.ts b/packages/sdk-client-v3/src/middleware/create-logger-middleware.ts new file mode 100644 index 000000000..635a2de63 --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/create-logger-middleware.ts @@ -0,0 +1,51 @@ +import { + Next, + Middleware, + MiddlewareRequest, + MiddlewareResponse, + LoggerMiddlewareOptions, +} from '../types/types' +import { maskAuthData } from '../utils' + +// error, info, success +export default function createLoggerMiddleware( + options?: LoggerMiddlewareOptions +): Middleware { + return (next: Next) => { + return async (request: MiddlewareRequest): Promise => { + let response = await next(request) + const originalResponse = Object.assign({}, response) + + const { + loggerFn = console.log, + // logLevel = 'ERROR', + maskSensitiveHeaderData = true, + includeOriginalRequest = true, + includeResponseHeaders = true, + // includeRequestInErrorResponse + } = options || {} + + if (includeOriginalRequest && maskSensitiveHeaderData) { + maskAuthData(response.request) + } + + if (!includeOriginalRequest) { + const { request, ...rest } = response + response = rest + } + + if (!includeResponseHeaders) { + const { headers, ...rest } = response + response = rest + } + + if (loggerFn && typeof loggerFn == 'function') { + loggerFn(response) + // return originalResponse + } + + // console.log({ Response: response }) + return originalResponse + } + } +} diff --git a/packages/sdk-client-v3/src/middleware/create-queue-middleware.ts b/packages/sdk-client-v3/src/middleware/create-queue-middleware.ts new file mode 100644 index 000000000..584c71fb7 --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/create-queue-middleware.ts @@ -0,0 +1,53 @@ +import { + Middleware, + MiddlewareRequest, + MiddlewareResponse, + Next, + Task, + QueueMiddlewareOptions, +} from '../types/types' + +export default function createQueueMiddleware({ + concurrency = 20, +}: QueueMiddlewareOptions): Middleware { + let runningCount = 0 + const queue: Array = [] + + const dequeue = (next: Next): Promise => { + runningCount-- + if (queue.length && runningCount <= concurrency) { + const nextTask = queue.shift() + runningCount++ + + return next(nextTask.request) + } + } + + const enqueue = ({ request }: { request: MiddlewareRequest }) => + queue.push({ request }) + + return (next: Next) => (request: MiddlewareRequest) => { + // wrap and override resolve and reject functions + const patchedRequest = { + ...request, + resolve(data: any) { + request.resolve(data) + dequeue(next) + }, + reject(error: any) { + request.reject(error) + dequeue(next) + }, + } + + // enqueue requests + enqueue({ request: patchedRequest }) + + if (runningCount < concurrency) { + runningCount++ + const nextTask = queue.shift() + + return next(nextTask.request) + } + } +} diff --git a/packages/sdk-client-v3/src/middleware/create-user-agent-middleware.ts b/packages/sdk-client-v3/src/middleware/create-user-agent-middleware.ts new file mode 100644 index 000000000..5bd53d6bf --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/create-user-agent-middleware.ts @@ -0,0 +1,31 @@ +import { + Next, + HttpUserAgentOptions, + Middleware, + MiddlewareRequest, + MiddlewareResponse, +} from '../types/types' +import packageJson from '../../package.json' +import { default as createUserAgent } from '../utils/userAgent' + +export default function createUserAgentMiddleware( + options?: HttpUserAgentOptions +): Middleware { + return (next: Next): Next => + async (request: MiddlewareRequest): Promise => { + const userAgent = createUserAgent({ + ...options, + name: `commercetools-sdk-javascript-v3/${packageJson.version}`, + }) + + const requestWithUserAgent = { + ...request, + headers: { + ...request.headers, + 'User-Agent': userAgent, + }, + } + + return next(requestWithUserAgent) + } +} diff --git a/packages/sdk-client-v3/src/middleware/index.ts b/packages/sdk-client-v3/src/middleware/index.ts new file mode 100644 index 000000000..9e130ffb9 --- /dev/null +++ b/packages/sdk-client-v3/src/middleware/index.ts @@ -0,0 +1,15 @@ +export { default as createCorrelationIdMiddleware } from './create-correlation-id-middleware' + +export { default as createHttpMiddleware } from './create-http-middleware' +export { default as createQueueMiddleware } from './create-queue-middleware' +export { default as createLoggerMiddleware } from './create-logger-middleware' +export { default as createUserAgentMiddleware } from './create-user-agent-middleware' +export { default as createConcurrentModificationMiddleware } from './create-concurrent-modification-middleware' +export { default as createErrorMiddleware } from './create-error-middleware' +export { + createAuthMiddlewareForPasswordFlow, + createAuthMiddlewareForClientCredentialsFlow, + createAuthMiddlewareForAnonymousSessionFlow, + createAuthMiddlewareForExistingTokenFlow, + createAuthMiddlewareForRefreshTokenFlow +} from './auth-middleware'; diff --git a/packages/sdk-client-v3/src/types/types.d.ts b/packages/sdk-client-v3/src/types/types.d.ts new file mode 100644 index 000000000..5fb0d12a4 --- /dev/null +++ b/packages/sdk-client-v3/src/types/types.d.ts @@ -0,0 +1,329 @@ +import { Buffer } from 'buffer/' +import AbortController from 'abort-controller' + +export type Nullable = T | null +export type Keys = string | number | symbol +export type JsonObject = { [key in Keys]: T } +export type MiddlewareRequest = ClientRequest +export type Optional = { [k in Keys]: any } + +export type Middleware = (next: Next) => (request: MiddlewareRequest) => Promise + +export type MiddlewareResponse = { + resolve: Function; + reject: Function; + body: T; + error?: HttpErrorType; + statusCode: number; + headers?: Record + request?: MiddlewareRequest; +} + +export type HttpErrorType = { + name?: string + message: string + code?: number + status?: number + method: MethodType + statusCode: number + originalRequest?: ClientRequest + body: JsonObject + retryCount?: number + headers?: Record + [key: string]: any +} + +export interface ClientRequest { + baseUri?: string + uri?: string + headers?: Record + method: MethodType + uriTemplate?: string + pathVariables?: VariableMap + queryParams?: VariableMap + body?: string | Buffer + response?: ClientResponse + resolve?: Function; + reject?: Function; + [key: string]: any +} + +export type Next = (request: MiddlewareRequest) => Promise + +export type MethodType = + | 'GET' + | 'HEAD' + | 'POST' + | 'PUT' + | 'PATCH' + | 'DELETE' + | 'CONNECT' + | 'OPTIONS' + | 'TRACE' + +export type QueryParam = + | string + | string[] + | number + | number[] + | boolean + | boolean[] + | undefined + +export type VariableMap = { + [key: string]: QueryParam +} + +export type ClientResponse = { + body: T + code?: number + statusCode?: number + headers?: Record + error?: HttpErrorType + retryCount?: number +} + +export type ClientResult = ClientResponse +export type ClientOptions = { middlewares: Array } + +export type Credentials = { + clientId: string + clientSecret: string + anonymousId?: string +} + +export type AuthMiddlewareOptions = { + host: string + projectKey: string + credentials: Credentials + scopes?: Array + // For internal usage only + oauthUri?: string + httpClient?: Function + tokenCache?: TokenCache +} + +export type TokenCacheOptions = { + clientId: string + projectKey: string + host: string +} + +export type TokenStore = { + token: string + expirationTime?: number + refreshToken?: string + tokenCacheKey?: TokenCacheOptions +} + +export type TokenCache = { + get: (tokenCacheOptions?: TokenCacheOptions) => TokenStore + set: (cache: TokenStore, tokenCacheOptions?: TokenCacheOptions) => void +} + +export type IBuiltRequestParams = { + basicAuth: string + url: string + body: string + data?: string +} + +export type RefreshAuthMiddlewareOptions = { + host: string + projectKey: string + credentials: { + clientId: string + clientSecret: string + } + refreshToken: string + tokenCache?: TokenCache, + // For internal usage only + oauthUri?: string + httpClient?: Function +} + +export type RequestStateStore = { + get: () => RequestState + set: (requestState: RequestState) => void +} + +/* Request */ +type requestBaseOptions = { + url: string + body: string + basicAuth: string + request: MiddlewareRequest + tokenCache: TokenCache, + requestState: RequestStateStore, + pendingTasks: Array, + tokenCacheKey?: TokenCacheOptions, +} + +export type executeRequestOptions = requestBaseOptions & { + next: Next + httpClient: Function + userOption?: AuthMiddlewareOptions | PasswordAuthMiddlewareOptions +} + +export type AuthMiddlewareBaseOptions = requestBaseOptions & { + request: MiddlewareRequest + httpClient?: Function +} + +export type RequestState = boolean + +export type Task = { + request: MiddlewareRequest + next?: Next +} + +export type UserAuthOptions = { + username: string + password: string +} + +export type PasswordAuthMiddlewareOptions = { + host: string + projectKey: string + credentials: { + clientId: string + clientSecret: string + user: UserAuthOptions + } + scopes?: Array + tokenCache?: TokenCache, + // For internal usage only + oauthUri?: string + httpClient?: Function +} + +export type TokenInfo = { + refresh_token: string + access_token: string + expires_at: number + expires_in: number // since this will always be used to calculate the expiration time, it shouldn't be made optional + scope?: string + token_type?: string +} + +export type Dispatch = (next: Next) => (request: MiddlewareRequest) => Promise + +export type CredentialsMode = 'omit' | 'same-origin' | 'include' + +export type HttpMiddlewareOptions = { + host: string + credentialsMode?: CredentialsMode + includeResponseHeaders?: boolean + includeOriginalRequest?: boolean + includeRequestInErrorResponse?: boolean + maskSensitiveHeaderData?: boolean + timeout?: number + enableRetry?: boolean + retryConfig?: RetryOptions + httpClient: Function + getAbortController?: () => AbortController + httpClientOptions?: object +} + +export type RetryOptions = RetryMiddlewareOptions + +export type HttpOptions = { + url: string + clientOptions: HttpClientOptions + httpClient: Function +} + +export type LogLevel = 'INFO' | 'ERROR' + +export type LoggerMiddlewareOptions = { + logLevel?: LogLevel + maskSensitiveHeaderData?: boolean + includeOriginalRequest?: boolean + includeResponseHeaders?: boolean + includeRequestInErrorResponse?: boolean + loggerFn?: (options: MiddlewareResponse) => void +} + +export type RetryMiddlewareOptions = { + backoff?: boolean + maxRetries?: number + retryDelay?: number + maxDelay?: typeof Infinity + retryCodes?: Array +} + +export type CorrelationIdMiddlewareOptions = { + generate?: () => string +} + +/* HTTP User Agent */ +export type HttpUserAgentOptions = { + name?: string + version?: string + libraryName?: string + libraryVersion?: string + contactUrl?: string + contactEmail?: string + customAgent?: string +} + +export type QueueMiddlewareOptions = { + concurrency?: number +} + +export type ExistingTokenMiddlewareOptions = { + force: boolean +} + +export type IClientOptions = { + method: MethodType; + headers: Record + credentialsMode?: CredentialsMode; + body?: string | Buffer + timeout?: number + abortController?: AbortController + includeOriginalRequest?: boolean + enableRetry?: boolean + retryConfig?: RetryOptions + maskSensitiveHeaderData?: boolean +} + +export type HttpClientOptions = IClientOptions & Optional + +export type HttpClientConfig = IClientOptions & { + url: string + httpClient: Function +} + +type TResponse = { + data: Record + message?: string + statusCode: number + retryCount: number + headers: Record +} + +export type Client = { + execute(request: ClientRequest): Promise + process: ( + request: ClientRequest, + fn: ProcessFn, + processOpt?: ProcessOptions + ) => Promise +} + +export type ProcessFn = (result: ClientResult) => Promise +export type ProcessOptions = { accumulate?: boolean; total?: number } +export type ErrorMiddlewareOptions = {} +export type SuccessResult = { + body: { + results: Record>; + count: number; + }; + statusCode: number; + headers?: JsonObject; +} + +export type IResponse = Response & { statusCode?: number; data?: object } + +export type executeRequest = (request: ClientRequest) => Promise diff --git a/packages/sdk-client-v3/src/utils/constants.ts b/packages/sdk-client-v3/src/utils/constants.ts new file mode 100644 index 000000000..7a336ca7c --- /dev/null +++ b/packages/sdk-client-v3/src/utils/constants.ts @@ -0,0 +1,23 @@ +export const HEADERS_CONTENT_TYPES = ['application/json', 'application/graphql'] +export const CONCURRENCT_REQUEST = 20 +export const CTP_API_URL = 'https://api.europe-west1.gcp.commercetools.com' +export const CTP_AUTH_URL = 'https://auth.europe-west1.gcp.commercetools.com' +export const DEFAULT_HEADERS = [ + 'content-type', + 'access-control-allow-origin', + 'access-control-allow-headers', + 'access-control-allow-methods', + 'access-control-expose-headers', + 'access-control-max-ag', + 'x-correlation-id', + 'server-timing', + 'date', + 'server', + 'transfer-encoding', + 'access-control-max-age', + 'content-encoding', + 'x-envoy-upstream-service-time', + 'via', + 'alt-svc', + 'connection', +] diff --git a/packages/sdk-client-v3/src/utils/createError.ts b/packages/sdk-client-v3/src/utils/createError.ts new file mode 100644 index 000000000..65d62dfdf --- /dev/null +++ b/packages/sdk-client-v3/src/utils/createError.ts @@ -0,0 +1,25 @@ +import { HttpErrorType } from '../types/types' +import getErrorByCode, { HttpError } from './errors' + +type ErrorType = ErrorArgs & Partial + +type ErrorArgs = { + statusCode: number + message: string +} + +function createError({ + statusCode, + message, + ...rest +}: ErrorType): HttpErrorType { + let errorMessage = message || 'Unexpected non-JSON error response' + if (statusCode === 404) + errorMessage = `URI not found: ${rest.originalRequest?.uri || rest.uri}` + + const ResponseError = getErrorByCode(statusCode) + if (ResponseError) return new ResponseError(errorMessage, rest) + return new HttpError(statusCode, errorMessage, rest) +} + +export default createError diff --git a/packages/sdk-client-v3/src/utils/errors.ts b/packages/sdk-client-v3/src/utils/errors.ts new file mode 100644 index 000000000..b41c83769 --- /dev/null +++ b/packages/sdk-client-v3/src/utils/errors.ts @@ -0,0 +1,70 @@ +function DefineError(statusCode: number, message: string, meta: object = {}) { + // eslint-disable-next-line no-multi-assign + this.status = this.statusCode = this.code = statusCode + this.message = message + Object.assign(this, meta) + + this.name = this.constructor.name + // eslint-disable-next-line no-proto + this.constructor.prototype.__proto__ = Error.prototype + if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor) +} + +export function NetworkError(...args: Array) { + DefineError.call(this, 0, ...args) +} + +export function HttpError(...args: Array) { + DefineError.call(this, ...args) +} + +export function BadRequest(...args: Array) { + DefineError.call(this, 400, ...args) +} + +export function Unauthorized(...args: Array) { + DefineError.call(this, 401, ...args) +} + +export function Forbidden(...args: Array) { + DefineError.call(this, 403, ...args) +} + +export function NotFound(...args: Array) { + DefineError.call(this, 404, ...args) +} + +export function ConcurrentModification(...args: Array) { + DefineError.call(this, 409, ...args) +} + +export function InternalServerError(...args: Array) { + DefineError.call(this, 500, ...args) +} + +export function ServiceUnavailable(...args: Array) { + DefineError.call(this, 503, ...args) +} + +export default function getErrorByCode(code: number) { + switch (code) { + case 0: + return NetworkError + case 400: + return BadRequest + case 401: + return Unauthorized + case 403: + return Forbidden + case 404: + return NotFound + case 409: + return ConcurrentModification + case 500: + return InternalServerError + case 503: + return ServiceUnavailable + default: + return undefined + } +} diff --git a/packages/sdk-client-v3/src/utils/executor.ts b/packages/sdk-client-v3/src/utils/executor.ts new file mode 100644 index 000000000..777def476 --- /dev/null +++ b/packages/sdk-client-v3/src/utils/executor.ts @@ -0,0 +1,128 @@ +import { TResponse, IResponse, HttpClientConfig } from '../types/types' +import { sleep, validateRetryCodes, calculateRetryDelay } from '../utils' + +function predicate(retryCodes: Array, response: any) { + return !( + // retryCodes.includes(response?.error?.message) || + [503, ...retryCodes].includes(response?.status || response?.statusCode) + ) +} + +async function executeHttpClientRequest( + fetcher: Function, + config?: any +): Promise { + async function sendRequest() { + const response = await fetcher({ + ...config, + headers: { + ...config.headers, + }, + }) + + // validations and error handlings can also be done here + return response + } + + // Attempt to send the request. + return sendRequest().catch((error) => Promise.reject(error)) +} + +export default async function executor(request: HttpClientConfig) { + const { url, httpClient, ...rest } = request + + const data: TResponse = await executeHttpClientRequest( + async (options: HttpClientConfig): Promise => { + const { enableRetry, retryConfig } = rest + const { + retryCodes = [], + maxDelay = Infinity, + maxRetries = 3, + backoff = true, + retryDelay = 200, + } = retryConfig || {} + + let result: string, + data: any, + retryCount: number = 0 + + // validate the `retryCodes` option + validateRetryCodes(retryCodes) + + async function execute() { + return httpClient(url, { + ...rest, + ...options, + headers: { + ...rest.headers, + ...options.headers, + + // axios header encoding + 'Accept-Encoding': 'application/json', + }, + + // for axios + ...(rest.body ? { data: rest.body } : {}), + withCredentials: options.credentialsMode === 'include', + }) + } + + async function executeWithRetry(): Promise { + // first attempt + let _response = await execute() + + if (predicate(retryCodes, _response)) return _response + + // retry attempts + while (enableRetry && retryCount < maxRetries) { + retryCount++ + _response = await execute() + + if (predicate(retryCodes, _response)) return _response + + // delay next execution + const timer = calculateRetryDelay({ + retryCount, + retryDelay, + maxRetries, + backoff, + maxDelay, + }) + + await sleep(timer) + } + + return _response + } + + const response: IResponse = await executeWithRetry() + try { + // try to parse the `fetch` response as text + if (response.text && typeof response.text == 'function') { + result = await response.text() + data = JSON.parse(result) + } else { + // axios response + data = response.data || response + } + } catch (err) { + data = result + } + + return { + data, + retryCount, + statusCode: response.status || response.statusCode || data.statusCode, + headers: response.headers, + } + }, + /** + * get this object from the + * middleware options or from + * http client config + */ + {} + ) + + return data +} diff --git a/packages/sdk-client-v3/src/utils/generateID.ts b/packages/sdk-client-v3/src/utils/generateID.ts new file mode 100644 index 000000000..9cd52a5eb --- /dev/null +++ b/packages/sdk-client-v3/src/utils/generateID.ts @@ -0,0 +1,9 @@ +import crytpo from 'crypto' + +// TODO: Polyfill crypto for browsers +export default function generateID() { + return crytpo + .randomBytes(32) + .toString('base64') + .replace(/[\/\-=+]/gi, '') +} diff --git a/packages/sdk-client-v3/src/utils/headers.ts b/packages/sdk-client-v3/src/utils/headers.ts new file mode 100644 index 000000000..5aff93f5d --- /dev/null +++ b/packages/sdk-client-v3/src/utils/headers.ts @@ -0,0 +1,34 @@ +import { JsonObject } from '../types/types' +import { DEFAULT_HEADERS } from './constants' + +function parse(headers: JsonObject) { + return DEFAULT_HEADERS.reduce((result: object, key: string): object => { + let val = headers[key] + ? headers[key] + : typeof headers.get == 'function' + ? headers.get(key) + : null + + if (val) result[key] = val + + return result + }, {}) +} + +export default function getHeaders( + headers: JsonObject +): JsonObject { + if (!headers) return null + + // node-fetch + if (headers.raw && typeof headers.raw == 'function') return headers.raw() + + // Tmp fix for Firefox until it supports iterables + if (!headers.forEach) return parse(headers) + + // whatwg-fetch + const map: JsonObject = {} + return headers.forEach( + (value: any, name: string | number) => (map[name] = value) + ) +} diff --git a/packages/sdk-client-v3/src/utils/index.ts b/packages/sdk-client-v3/src/utils/index.ts new file mode 100644 index 000000000..2b613306f --- /dev/null +++ b/packages/sdk-client-v3/src/utils/index.ts @@ -0,0 +1,24 @@ +// export { default as logger } from './logger' +export { default as getHeaders } from './headers' +export { default as isBuffer } from './isBuffer' +export { default as calculateRetryDelay } from './retryDelay' +export { default as generate } from './generateID' +export { default as userAgent } from './userAgent' +export { default as maskAuthData } from './maskAuthData' +export { default as calculateExpirationTime } from './tokenExpirationTime' +export { default as buildTokenCacheKey } from './tokenCacheKey' +export { default as store } from './tokenStore' +export { default as mergeAuthHeader } from './mergeAuthHeader' +export { default as executor } from './executor' +export * as constants from './constants' +export { default as sleep } from './sleep' +export { default as METHODS } from './methods' +export { default as createError } from './createError' +export { NetworkError } from './errors' +export { + validateRetryCodes, + validateHttpOptions, + // validateUserAgentOptions, + validateClient, + validate +} from './validate' diff --git a/packages/sdk-client-v3/src/utils/isBuffer.ts b/packages/sdk-client-v3/src/utils/isBuffer.ts new file mode 100644 index 000000000..55a98109a --- /dev/null +++ b/packages/sdk-client-v3/src/utils/isBuffer.ts @@ -0,0 +1,8 @@ +export default function isBuffer(obj: any): boolean { + return ( + obj != null && + obj.constructor != null && + typeof obj.constructor.isBuffer === 'function' && + obj.constructor.isBuffer(obj) + ) +} diff --git a/packages/sdk-client-v3/src/utils/logger.ts b/packages/sdk-client-v3/src/utils/logger.ts new file mode 100644 index 000000000..963ea4d27 --- /dev/null +++ b/packages/sdk-client-v3/src/utils/logger.ts @@ -0,0 +1,10 @@ +import { ClientResponse } from '../types/types' + +const { log } = console +export default function logger(res: ClientResponse): void { + log( + ':::::::::::::::::::::: Start of log ::::::::::::::::::::::::::\n\r', + res, + '\n:::::::::::::::::::::: End of log ::::::::::::::::::::::::::::\n' + ) +} diff --git a/packages/sdk-client-v3/src/utils/maskAuthData.ts b/packages/sdk-client-v3/src/utils/maskAuthData.ts new file mode 100644 index 000000000..a29d670c1 --- /dev/null +++ b/packages/sdk-client-v3/src/utils/maskAuthData.ts @@ -0,0 +1,16 @@ +import { MiddlewareRequest } from '../types/types' + +export default function maskAuthData(request: MiddlewareRequest) { + const _request = Object.assign({}, request) + if (_request?.headers) { + if (_request.headers.Authorization) { + _request.headers['Authorization'] = 'Bearer ********' + } + + if (_request.headers.authorization) { + _request.headers['authorization'] = 'Bearer ********' + } + } + + return _request +} diff --git a/packages/sdk-client-v3/src/utils/mergeAuthHeader.ts b/packages/sdk-client-v3/src/utils/mergeAuthHeader.ts new file mode 100644 index 000000000..04deef1b6 --- /dev/null +++ b/packages/sdk-client-v3/src/utils/mergeAuthHeader.ts @@ -0,0 +1,14 @@ +import { MiddlewareRequest } from '../types/types' + +export default function mergeAuthHeader( + token: string, + req: MiddlewareRequest +): MiddlewareRequest { + return { + ...req, + headers: { + ...req.headers, + Authorization: `Bearer ${token}`, + }, + } +} diff --git a/packages/sdk-client-v3/src/utils/methods.ts b/packages/sdk-client-v3/src/utils/methods.ts new file mode 100644 index 000000000..2d0dfd72a --- /dev/null +++ b/packages/sdk-client-v3/src/utils/methods.ts @@ -0,0 +1,36 @@ +export default [ + 'ACL', + 'BIND', + 'CHECKOUT', + 'CONNECT', + 'COPY', + 'DELETE', + 'GET', + 'HEAD', + 'LINK', + 'LOCK', + 'M-SEARCH', + 'MERGE', + 'MKACTIVITY', + 'MKCALENDAR', + 'MKCOL', + 'MOVE', + 'NOTIFY', + 'OPTIONS', + 'PATCH', + 'POST', + 'PROPFIND', + 'PROPPATCH', + 'PURGE', + 'PUT', + 'REBIND', + 'REPORT', + 'SEARCH', + 'SOURCE', + 'SUBSCRIBE', + 'TRACE', + 'UNBIND', + 'UNLINK', + 'UNLOCK', + 'UNSUBSCRIBE', +] diff --git a/packages/sdk-client-v3/src/utils/retryDelay.ts b/packages/sdk-client-v3/src/utils/retryDelay.ts new file mode 100644 index 000000000..16ed969d9 --- /dev/null +++ b/packages/sdk-client-v3/src/utils/retryDelay.ts @@ -0,0 +1,28 @@ +export type TRetryPolicy = { + retryCount: number + retryDelay: number + maxRetries: number + backoff: boolean + maxDelay: number +} + +export default function calculateRetryDelay({ + retryCount, + retryDelay, + // maxRetries, + backoff, + maxDelay, +}: TRetryPolicy): number { + if (backoff) { + return retryCount !== 0 // do not increase if it's the first retry + ? Math.min( + Math.round((Math.random() + 1) * retryDelay * 2 ** retryCount), + maxDelay + ) + : retryDelay + } + + return retryDelay +} + +Math.min(Math.round((Math.random() + 1) * 200 * 2 ** 10), Infinity) diff --git a/packages/sdk-client-v3/src/utils/sleep.ts b/packages/sdk-client-v3/src/utils/sleep.ts new file mode 100644 index 000000000..4799a572e --- /dev/null +++ b/packages/sdk-client-v3/src/utils/sleep.ts @@ -0,0 +1,5 @@ +export default function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} diff --git a/packages/sdk-client-v3/src/utils/tokenCacheKey.ts b/packages/sdk-client-v3/src/utils/tokenCacheKey.ts new file mode 100644 index 000000000..35979866a --- /dev/null +++ b/packages/sdk-client-v3/src/utils/tokenCacheKey.ts @@ -0,0 +1,14 @@ +import { AuthMiddlewareOptions, TokenCacheOptions } from '../types/types' + +export default function buildTokenCacheKey( + options: AuthMiddlewareOptions +): TokenCacheOptions { + if (!options?.credentials?.clientId || !options.projectKey || !options.host) + throw new Error('Missing required options.') + + return { + clientId: options.credentials.clientId, + host: options.host, + projectKey: options.projectKey, + } +} diff --git a/packages/sdk-client-v3/src/utils/tokenExpirationTime.ts b/packages/sdk-client-v3/src/utils/tokenExpirationTime.ts new file mode 100644 index 000000000..6ebef9cc5 --- /dev/null +++ b/packages/sdk-client-v3/src/utils/tokenExpirationTime.ts @@ -0,0 +1,8 @@ +export default function calculateExpirationTime(expiresIn: number): number { + return ( + Date.now() + + // Add a gap of 5 minutes before expiration time. + expiresIn * 1000 - + 5 * 60 * 1000 + ) +} diff --git a/packages/sdk-client-v3/src/utils/tokenStore.ts b/packages/sdk-client-v3/src/utils/tokenStore.ts new file mode 100644 index 000000000..9b7e57f60 --- /dev/null +++ b/packages/sdk-client-v3/src/utils/tokenStore.ts @@ -0,0 +1,11 @@ +import { TokenCacheOptions } from '../types/types' + +export default function store(initVal: T): V { + let value: T = initVal + return { + get: (TokenCacheOption?: S) => value, + set: (val: T, TokenCacheOption?: S) => { + value = val + }, + } as V +} diff --git a/packages/sdk-client-v3/src/utils/userAgent.ts b/packages/sdk-client-v3/src/utils/userAgent.ts new file mode 100644 index 000000000..51842baf5 --- /dev/null +++ b/packages/sdk-client-v3/src/utils/userAgent.ts @@ -0,0 +1,65 @@ +// import { validateUserAgentOptions } from '../utils' +import { HttpUserAgentOptions } from '../types/types' + +/* + This is the easiest way, for this use case, to detect if we're running in + Node.js or in a browser environment. In other cases, this won't be even a + problem as Rollup will provide the correct polyfill in the bundle. + The main advantage by doing it this way is that it allows to easily test + the code running in both environments, by overriding `global.window` in + the specific test. +*/ +const isBrowser = (): boolean => + typeof window !== 'undefined' && + window.document && + window.document.nodeType === 9 + +function getSystemInfo(): string { + if (isBrowser()) return window.navigator.userAgent + + const nodeVersion: string = process?.version.slice(1) || 'unknow' // unknow environment like React Native etc + const platformInfo = `(${process.platform}; ${process.arch})` + + // return `node.js/${nodeVersion}` + return `node.js/${nodeVersion} ${platformInfo}` +} + +export default function createUserAgent(options: HttpUserAgentOptions) { + let libraryInfo: string | null = null + let contactInfo: string | null = null + + // validateUserAgentOptions(options) + if (!options) { + throw new Error('Missing required option `name`') + } + + // Main info + const baseInfo = options.version + ? `${options.name}/${options.version}` + : options.name + + // Library info + if (options.libraryName && !options.libraryVersion) { + libraryInfo = options.libraryName + } else if (options.libraryName && options.libraryVersion) { + libraryInfo = `${options.libraryName}/${options.libraryVersion}` + } + + // Contact info + if (options.contactUrl && !options.contactEmail) { + contactInfo = `(+${options.contactUrl})` + } else if (!options.contactUrl && options.contactEmail) { + contactInfo = `(+${options.contactEmail})` + } else if (options.contactUrl && options.contactEmail) { + contactInfo = `(+${options.contactUrl}; +${options.contactEmail})` + } + + // System info + const systemInfo = getSystemInfo() + + // customName + const customAgent = options.customAgent || '' + return [baseInfo, systemInfo, libraryInfo, contactInfo, customAgent] + .filter(Boolean) + .join(' ') +} diff --git a/packages/sdk-client-v3/src/utils/validate.ts b/packages/sdk-client-v3/src/utils/validate.ts new file mode 100644 index 000000000..baa811f65 --- /dev/null +++ b/packages/sdk-client-v3/src/utils/validate.ts @@ -0,0 +1,85 @@ +import { METHODS } from '../../src/utils' +import { + ClientRequest, + HttpMiddlewareOptions, + HttpUserAgentOptions, + Middleware, + MethodType, +} from '../types/types' + +/** + * validate some essential http options + * @param options + */ +export function validateHttpOptions(options: HttpMiddlewareOptions) { + if (!options.host) + throw new Error( + 'Request `host` or `url` is missing or invalid, please pass in a valid host e.g `host: http://a-valid-host-url`' + ) + + if (!options.httpClient && typeof options.httpClient !== 'function') + throw new Error( + 'An `httpClient` is not available, please pass in a `fetch` or `axios` instance as an option or have them globally available.' + ) + + if (options.timeout && !options.getAbortController) + throw new Error( + '`AbortController` is not available. Please pass in `getAbortController` as an option or have AbortController globally available when using timeout.' + ) +} + +/** + * + * @param retryCodes + * @example + * const retryCodes = [500, 504, "ETIMEDOUT"] + */ +export function validateRetryCodes(retryCodes: Array) { + if (!Array.isArray(retryCodes)) { + throw new Error( + '`retryCodes` option must be an array of retry status (error) codes and/or messages.' + ) + } +} + +/** + * @param options + */ +export function validateClient(options: { middlewares: Array }) { + if (!options) throw new Error('Missing required options') + + if (options.middlewares && !Array.isArray(options.middlewares)) + throw new Error('Middlewares should be an array') + + if ( + !options.middlewares || + !Array.isArray(options.middlewares) || + !options.middlewares.length + ) { + throw new Error('You need to provide at least one middleware') + } +} + +/** + * @param options + */ +export function validate( + funcName: string, + request: ClientRequest, + options: { allowedMethods: Array } = { allowedMethods: METHODS } +): void { + if (!request) + throw new Error( + `The "${funcName}" function requires a "Request" object as an argument. See https://commercetools.github.io/nodejs/sdk/Glossary.html#clientrequest` + ) + + if (typeof request.uri !== 'string') + throw new Error( + `The "${funcName}" Request object requires a valid uri. See https://commercetools.github.io/nodejs/sdk/Glossary.html#clientrequest` + ) + + if (!options.allowedMethods.includes(request.method)) + throw new Error( + `The "${funcName}" Request object requires a valid method. See https://commercetools.github.io/nodejs/sdk/Glossary.html#clientrequest` + ) +} diff --git a/packages/sdk-client-v3/tests/auth/anonymous-flow.test.ts b/packages/sdk-client-v3/tests/auth/anonymous-flow.test.ts new file mode 100644 index 000000000..eb730075d --- /dev/null +++ b/packages/sdk-client-v3/tests/auth/anonymous-flow.test.ts @@ -0,0 +1,326 @@ +import { createAuthMiddlewareForAnonymousSessionFlow } from '../../src/middleware' +import { buildRequestForAnonymousSessionFlow } from '../../src/middleware/auth-middleware/auth-request-builder' + +function createTestRequest(options) { + return { + url: '', + method: 'GET', + body: null, + headers: {}, + ...options, + } +} + +function createTestResponse(options) { + return { + ...options, + } +} + +function createTestMiddlewareOptions(options) { + return { + host: 'https://auth.europe-west1.gcp.commercetools.com', + projectKey: 'foo', + credentials: { + clientId: '123', + clientSecret: 'secret', + anonymousId: 'secretme', + }, + ...options, + } +} + +describe('Anonymous Session Flow', () => { + describe('Anonymous session flow', () => { + test('should throw if `options` are not provided', () => { + const middlewareOptions = null + + expect(() => + buildRequestForAnonymousSessionFlow(middlewareOptions) + ).toThrow('Missing required options') + }) + + test('should throw if `projectKey` is not provided', () => { + const middlewareOptions = createTestMiddlewareOptions({ + projectKey: null, + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + }) + + expect(() => + buildRequestForAnonymousSessionFlow(middlewareOptions) + ).toThrow('Missing required option (projectKey') + }) + }) + + test('should fetch anonymous token and inject token in request headers', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxx-xx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + }) + + createAuthMiddlewareForAnonymousSessionFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + resolve(null) + })) + + test('should throw error if required `projectKey` option is not provided.', () => + new Promise(async (resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxx-xx') + // expect() + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + projectKey: null, // <--------------- null + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + }) + + expect(() => + createAuthMiddlewareForAnonymousSessionFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).toThrow('Missing required options.') // 'Missing required options.' + + resolve(null) + })) + + test('should throw error if required `host` option is not provided.', () => + new Promise(async (resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + // expect() + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + host: null, // <--------------- null + projectKey: null, + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + }) + + expect(() => + createAuthMiddlewareForAnonymousSessionFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).toThrow('Missing required options.') // 'Missing required options.' + + resolve(null) + })) + + test('should throw error if required `clientId` and `clientSecret` options are not provided.', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + // expect() + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + projectKey: 'demo-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: null, + clientSecret: null, + }, + }) + + expect(() => + createAuthMiddlewareForAnonymousSessionFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).toThrow('Missing required options.') // 'Missing required options.' + + resolve(null) + })) + + test('should not throw if `anonymousId` options is not provided.', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxx-xx') + // expect() + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: '123', + clientSecret: 'secret', + anonymousId: null, // <---------------- null + }, + }) + + createAuthMiddlewareForAnonymousSessionFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + + expect(middlewareOptions.httpClient).toHaveBeenCalled() + expect(middlewareOptions.httpClient).toHaveBeenCalledTimes(1) + resolve(null) + })) + + test('should not call the auth server if Authorization is already present in the headers', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxxx-xxx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { + access_token: 'x-Xxxx', // <------------ should not use this acess_token from mock response + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + }) + + createAuthMiddlewareForAnonymousSessionFlow(middlewareOptions)(next)( + createTestRequest({ headers: { Authorization: 'Bearer xxxx-xxx' } }) + ) + })) + + test('should fetch and store token in tokenCache object', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const store = (value) => { + let val = value + return { + get: () => val, + set: (newValue) => { + val = newValue + }, + } + } + + const tokenCache = store({}) + + const next = (req): any => { + expect(typeof tokenCache.get).toBe('function') + expect(typeof tokenCache.get()).toBe('object') + expect(tokenCache.get()).toHaveProperty('token') + expect(tokenCache.get()).toEqual( + expect.objectContaining({ + token: 'x-Xxxx', + // expirationTime: 8544429619082, // computed based on current time + refreshToken: undefined, + }) + ) + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer x-Xxxx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { + access_token: 'x-Xxxx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + tokenCache, + }) + + createAuthMiddlewareForAnonymousSessionFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + })) +}) diff --git a/packages/sdk-client-v3/tests/auth/auth-request-executor.test.ts b/packages/sdk-client-v3/tests/auth/auth-request-executor.test.ts new file mode 100644 index 000000000..47c3b9de0 --- /dev/null +++ b/packages/sdk-client-v3/tests/auth/auth-request-executor.test.ts @@ -0,0 +1,232 @@ +import { executeRequest } from '../../src/middleware/auth-middleware/auth-request-executor' + +function createTestExecutorOptions(options) { + return { + url: 'test-url-endpoint', + body: 'this is a body', + baseAuth: {}, + request: { resolve: jest.fn(), reject: jest.fn() }, + pendingTasks: [], + httpClient: jest.fn(), + tokenCache: {}, + tokenCacheKey: {}, + requestState: { get: jest.fn(), set: jest.fn() }, + userOption: { + host: 'test-host', + projectKey: 'test-project-key', + credentials: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, + }, + next: jest.fn(), + ...options, + } +} + +describe('Auth request executor', () => { + describe('Auth request executor - resolved response', () => { + test('should throw if `httpClient` is not provided', () => { + const options = createTestExecutorOptions({ httpClient: null }) + expect(executeRequest(options as any)).rejects.toEqual(expect.any(Error)) + }) + + test('should return early if token exists in `tokenCacheObject`', async () => { + const options = createTestExecutorOptions({ + tokenCache: { + set: jest.fn(), + get: jest.fn(() => ({ + token: 'test-cached-token', + expirationTime: Date.now() + 999, + })), + }, + httpClient: jest.fn(() => ({ + statusCode: 200, + data: { statusCode: 200, access_token: 'test-access-token' }, + })), + }) + + const response = await executeRequest(options) + + expect(typeof response).toEqual('object') + expect(response).toHaveProperty('headers') + expect(typeof response.headers).toEqual('object') + expect(response.headers['Authorization']).toEqual( + 'Bearer test-cached-token' + ) + }) + + test('should return if a token is already being fetched', async () => { + const options = createTestExecutorOptions({ + requestState: { get: jest.fn(() => true), set: jest.fn() }, + tokenCache: { + set: jest.fn(), + get: jest.fn(() => ({ + token: 'test-cached-token', + expirationTime: Date.now() - 999, + })), + }, + httpClient: jest.fn(() => ({ + statusCode: 200, + data: { statusCode: 200, access_token: 'test-access-token' }, + })), + }) + + expect(await executeRequest(options)).toEqual(undefined) + }) + + test('should throw if userOptions are not provided for token refresh', () => { + const options = createTestExecutorOptions({ + userOption: null, + tokenCache: { + set: jest.fn(), + get: jest.fn(() => ({ + token: 'test-cached-token', + expirationTime: Date.now() - 999, + refreshToken: 'refresh-cache-token', + })), + }, + httpClient: jest.fn(() => ({ + statusCode: 200, + data: { statusCode: 200, access_token: 'test-access-token' }, + })), + }) + + expect(executeRequest(options)).rejects.toEqual(expect.any(Error)) + }) + + test('should refresh token if token is expired and there is a refreshToken present in `tokenCacheObject`', async () => { + const task = { + request: { headers: { 'Content-Type': 'application/json' } }, + next: jest.fn(), + } + + const options = createTestExecutorOptions({ + url: 'test-demo-uri', + pendingTasks: [task, task], + tokenCache: { + set: jest.fn(), + get: jest.fn(() => ({ + token: 'test-cached-token', + expirationTime: Date.now() - 999, + refreshToken: 'refresh-cache-token', + })), + }, + httpClient: jest.fn(() => ({ + statusCode: 200, + data: { statusCode: 200, access_token: 'test-access-token' }, + })), + }) + + expect(await executeRequest(options)).toEqual(undefined) + }) + + test('should execute all pending tasks with a valid token', async () => { + const task = { + request: { + url: 'test-uri', + method: 'GET', + body: {}, + headers: { + 'Content-Type': 'application/json', + }, + }, + next: jest.fn(), + } + + const options = createTestExecutorOptions({ + url: 'test-demo-uri', + pendingTasks: [task, task], + tokenCache: { + set: jest.fn(), + get: jest.fn(() => ({ + token: 'test-cached-token', + expirationTime: Date.now() - 999, + refreshToken: 'refresh-cache-token', + })), + }, + httpClient: jest.fn(() => ({ + statusCode: 200, + data: { statusCode: 200, access_token: 'test-access-token' }, + })), + }) + + expect(await executeRequest(options)).toEqual(undefined) + }) + }) + + describe('Auth request executor - rejected response', () => { + test('should reject if `statusCode` is within acceptable range', async () => { + const task = { + request: { + url: 'test-uri', + method: 'GET', + body: {}, + headers: { + 'Content-Type': 'application/json', + }, + }, + next: jest.fn(), + } + + const options = createTestExecutorOptions({ + url: 'test-demo-uri', + pendingTasks: [task], + tokenCache: { + set: jest.fn(), + get: jest.fn(() => ({ + token: 'test-cached-token', + expirationTime: Date.now() - 999, + refreshToken: 'refresh-cache-token', + })), + }, + httpClient: jest.fn(() => ({ + statusCode: 400, + message: 'error fetching token', + })), + }) + + expect(await executeRequest(options)).toEqual(undefined) + }) + + test('should throw on network error', async () => { + const task = { + request: { + url: 'test-uri', + method: 'GET', + body: {}, + headers: { + 'Content-Type': 'application/json', + }, + }, + next: jest.fn(), + } + + const options = createTestExecutorOptions({ + url: 'test-demo-uri', + pendingTasks: [task], + tokenCache: { + set: jest.fn(), + get: jest.fn(() => ({ + token: 'test-cached-token', + expirationTime: Date.now() - 999, + refreshToken: 'refresh-cache-token', + })), + }, + httpClient: jest.fn(() => { + throw Error('an error occurred.') + }), + }) + + const errorResponse = await executeRequest(options) + expect(errorResponse).toHaveProperty('reject') + expect(errorResponse).toHaveProperty('resolve') + expect(errorResponse.response.body).toEqual(null) + expect(errorResponse.response.error).toBeTruthy() + expect(errorResponse.response.statusCode).toEqual(0) + expect(errorResponse.response.error.error.message).toMatch( + 'an error occurred.' + ) + }) + }) +}) diff --git a/packages/sdk-client-v3/tests/auth/client-credentials-flow.test.ts b/packages/sdk-client-v3/tests/auth/client-credentials-flow.test.ts new file mode 100644 index 000000000..ce17d9e78 --- /dev/null +++ b/packages/sdk-client-v3/tests/auth/client-credentials-flow.test.ts @@ -0,0 +1,433 @@ +import { createAuthMiddlewareForClientCredentialsFlow } from '../../src/middleware' +import { buildRequestForClientCredentialsFlow } from '../../src/middleware/auth-middleware/auth-request-builder' + +function createTestRequest(options) { + return { + url: '', + method: 'GET', + body: null, + headers: {}, + ...options, + } +} + +function createTestResponse(options) { + return { + ...options, + } +} + +function createTestMiddlewareOptions(options) { + return { + host: 'https://auth.europe-west1.gcp.commercetools.com', + projectKey: 'foo', + credentials: { + clientId: '123', + clientSecret: 'secret', + }, + ...options, + } +} + +describe('Client Credentials Flow', () => { + describe('Client credentials flow auth request builder', () => { + test('should throw if `options` are not provided.', () => { + new Promise((resolve, reject) => { + const middlewareOptions = null + expect(() => + buildRequestForClientCredentialsFlow(middlewareOptions) + ).toThrow('Missing required options') + resolve(null) + }) + }) + + test('should throw if `host` is not provided.', () => { + new Promise((resolve, reject) => { + const middlewareOptions = createTestMiddlewareOptions({ + host: null, + projectKey: 'demo-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, + }) + expect(() => + buildRequestForClientCredentialsFlow(middlewareOptions) + ).toThrow('Missing required option (host)') + resolve(null) + }) + }) + + test('should throw if `projectKey` is not provided.', () => { + new Promise((resolve, reject) => { + const middlewareOptions = createTestMiddlewareOptions({ + host: 'test-host', + projectKey: null, + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, + }) + expect(() => + buildRequestForClientCredentialsFlow(middlewareOptions) + ).toThrow('Missing required option (projectKey)') + resolve(null) + }) + }) + + test('should throw if `credentials` is not provided.', () => { + new Promise((resolve, reject) => { + const middlewareOptions = createTestMiddlewareOptions({ + host: 'test-host', + projectKey: 'test-project-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: null, // <------------- null + }) + expect(() => + buildRequestForClientCredentialsFlow(middlewareOptions) + ).toThrow('Missing required option (credentials)') + resolve(null) + }) + }) + + test('should throw if `clientId or clientSecret` are not provided.', () => { + new Promise((resolve, reject) => { + const middlewareOptions = createTestMiddlewareOptions({ + host: 'test-host', + projectKey: 'test-project-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: null, + clientSecret: null, + }, + }) + expect(() => + buildRequestForClientCredentialsFlow(middlewareOptions) + ).toThrow('Missing required credentials (clientId, clientSecret)') + resolve(null) + }) + }) + }) + + test('should throw error if required `host` option is not provided.', () => + new Promise(async (resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + // expect() + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + host: null, // <--------------- null + projectKey: 'demo-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + }) + + expect(() => + createAuthMiddlewareForClientCredentialsFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).toThrow('Missing required options.') // 'Missing required options.' + + resolve(null) + })) + + test('should throw error if required `credentials` option is not provided.', () => + new Promise(async (resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + // expect() + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: null, + }) + + expect(() => + createAuthMiddlewareForClientCredentialsFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).toThrow('Missing required options.') // 'Missing required options.' + + resolve(null) + })) + + test('should throw error if required `clientId` and `clientSecret` options are not provided.', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + // expect() + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: null, + clientSecret: null, + }, + }) + + expect(() => + createAuthMiddlewareForClientCredentialsFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).toThrow('Missing required options.') // 'Missing required options.' + + resolve(null) + })) + + test('should throw error if required `httpClient` is not a function', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + host: 'test-host-url', + projectKey: 'test-key', + httpClient: {} as any, + credentials: { + clientId: 'test-id', + clientSecret: 'test-secret', + }, + }) + + // expect(async () => + // await createAuthMiddlewareForClientCredentialsFlow(middlewareOptions)(next)( + // createTestRequest({}) + // ) + // ).toThrow('an `httpClient` is not available, please pass in a `fetch` or `axios` instance as an option or have them globally available.') // 'Missing required options.' + expect( + createAuthMiddlewareForClientCredentialsFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).rejects.toEqual(expect.any(Error)) + + resolve(null) + })) + + test('should fetch token using client credentials and inject token in request headers', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxx-xx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { access_token: 'xxx-xx', expires_in: 6873735270 }, + statusCode: 200, + headers: {}, + })), + }) + + createAuthMiddlewareForClientCredentialsFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + })) + + test('should throw error if required options were not provided.', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxx-xx') + // expect() + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + projectKey: null, + credentials: null, + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + }) + + expect(() => + createAuthMiddlewareForClientCredentialsFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).toThrow('Missing required options.') // 'Missing required options.' + resolve(null) + })) + + test('should not call the auth server if Authorization is already present in the headers', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxxx-xxx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { + access_token: 'x-Xxxx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + }) + + createAuthMiddlewareForClientCredentialsFlow(middlewareOptions)(next)( + createTestRequest({ headers: { Authorization: 'Bearer xxxx-xxx' } }) + ) + })) + + test('should fetch and store token in tokenCache object', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const store = (value) => { + let val = value + return { + get: () => val, + set: (newValue) => { + val = newValue + }, + } + } + + const tokenCache = store({}) + + const next = (req): any => { + expect(typeof tokenCache.get).toBe('function') + expect(typeof tokenCache.get()).toBe('object') + expect(tokenCache.get()).toHaveProperty('token') + expect(tokenCache.get()).toEqual( + expect.objectContaining({ + token: 'x-Xxxx', + // expirationTime: 8544429619082, // computed based on current time + refreshToken: undefined, + }) + ) + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer x-Xxxx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { + access_token: 'x-Xxxx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + tokenCache, + }) + + createAuthMiddlewareForClientCredentialsFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + })) +}) diff --git a/packages/sdk-client-v3/tests/auth/existing-token-flow.test.ts b/packages/sdk-client-v3/tests/auth/existing-token-flow.test.ts new file mode 100644 index 000000000..57708aa64 --- /dev/null +++ b/packages/sdk-client-v3/tests/auth/existing-token-flow.test.ts @@ -0,0 +1,169 @@ +import { createAuthMiddlewareForExistingTokenFlow } from '../../src/middleware' + +function createTestRequest(options) { + return { + url: 'http://auth-url', + host: 'http://auth-url', + method: 'GET', + body: null, + headers: {}, + ...options, + } +} + +function createTestResponse(options) { + return { + ...options, + } +} + +function createTestMiddlewareOptions(options) { + return { + authorization: 12345, + ...options, + } +} + +describe('Existing Token Flow', () => { + test('should throw an error if the provided authorization argument is not a string.', () => { + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxx-xx') + // expect() + + resolve(null) + } + + const authorization = 123456 as any + + expect( + createAuthMiddlewareForExistingTokenFlow(authorization)(next)( + createTestRequest({}) + ) + ).rejects.toEqual(expect.any(Error)) + resolve(null) + }) + }) + + test('should call the next middleware if token exists in header and `authorization` option is not provided.', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer token') + + resolve(null) + } + + const request = createTestRequest({}) + createAuthMiddlewareForExistingTokenFlow('', { force: false })(next)( + createTestRequest({ headers: { Authorization: 'Bearer token' } }) + ) + })) + + test('should use the provided auth token.', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe( + 'Bearer xzXCwBY2I1MD5QB3J7oJ3jOBBDXDEZpr8==' + ) + + resolve(null) + } + + const request = createTestRequest({}) + createAuthMiddlewareForExistingTokenFlow( + 'Bearer xzXCwBY2I1MD5QB3J7oJ3jOBBDXDEZpr8==', + { force: true } + )(next)(createTestRequest({})) + })) + + test('should overide existing authorization', () => + new Promise((resolve, rejects) => { + const next = (req): any => { + expect(req.headers).toEqual( + expect.objectContaining({ + Authorization: 'Bearer xzXCwBY2I1MD5QB3J7oJ3jOBBDXDEZpr8==', + }) + ) + expect(req.headers.Authorization).toEqual( + 'Bearer xzXCwBY2I1MD5QB3J7oJ3jOBBDXDEZpr8==' + ) + resolve(null) + } + + const request = createTestRequest({ + headers: { Authorization: `Bearer original-access-token` }, + }) + createAuthMiddlewareForExistingTokenFlow( + 'Bearer xzXCwBY2I1MD5QB3J7oJ3jOBBDXDEZpr8==', + { force: true } + )(next)(request) + })) + + describe('`force option`', () => { + test('should not overide authorization if force is false', () => { + new Promise((resolve, rejects) => { + const next = (req): any => { + expect(req.headers).toEqual( + expect.objectContaining({ + Authorization: 'Bearer original-access-token', + }) + ) + expect(req.headers.Authorization).toEqual( + 'Bearer original-access-token' + ) + resolve(null) + } + + const request = createTestRequest({ + headers: { Authorization: `Bearer original-access-token` }, + }) + createAuthMiddlewareForExistingTokenFlow( + 'Bearer xzXCwBY2I1MD5QB3J7oJ3jOBBDXDEZpr8==', + { force: false } + )(next)(request) + }) + }) + + test('should overide existing authorization if `force` is true', () => { + new Promise((resolve, rejects) => { + const next = (req): any => { + expect(req.headers).toEqual( + expect.objectContaining({ + Authorization: 'Bearer xzXCwBY2I1MD5QB3J7oJ3jOBBDXDEZpr8==', + }) + ) + expect(req.headers.Authorization).toEqual( + 'Bearer xzXCwBY2I1MD5QB3J7oJ3jOBBDXDEZpr8==' + ) + resolve(null) + } + + const request = createTestRequest({ + headers: { Authorization: `Bearer original-access-token` }, + }) + createAuthMiddlewareForExistingTokenFlow( + 'Bearer xzXCwBY2I1MD5QB3J7oJ3jOBBDXDEZpr8==', + { force: true } + )(next)(request) + }) + }) + }) +}) diff --git a/packages/sdk-client-v3/tests/auth/password-flow.test.ts b/packages/sdk-client-v3/tests/auth/password-flow.test.ts new file mode 100644 index 000000000..7ff0a5bec --- /dev/null +++ b/packages/sdk-client-v3/tests/auth/password-flow.test.ts @@ -0,0 +1,337 @@ +import { createAuthMiddlewareForPasswordFlow } from '../../src/middleware' +import { buildRequestForPasswordFlow } from '../../src/middleware/auth-middleware/auth-request-builder' + +function createTestRequest(options) { + return { + url: '', + method: 'GET', + body: null, + headers: {}, + ...options, + } +} + +function createTestResponse(options) { + return { + ...options, + } +} + +function createTestMiddlewareOptions(options) { + return { + host: 'https://auth.europe-west1.gcp.commercetools.com', + projectKey: 'foo', + credentials: { + clientId: '123', + clientSecret: 'secret', + user: { + username: 'jane-doe', + password: 'jane-doe00', + }, + }, + ...options, + } +} + +describe('Password Flow', () => { + describe('Password flow auth request builder', () => { + test('should throw if `options` are not provided.', () => { + new Promise((resolve, reject) => { + const middlewareOptions = null + expect(() => buildRequestForPasswordFlow(middlewareOptions)).toThrow( + 'Missing required options' + ) // 'Missing required options.' + resolve(null) + }) + }) + + test('should throw if `host` is not provided.', () => { + new Promise((resolve, reject) => { + const middlewareOptions = createTestMiddlewareOptions({ + host: null, + projectKey: 'demo-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + user: { + username: 'test-username', + password: 'test-password', + }, + }, + }) + + expect(() => buildRequestForPasswordFlow(middlewareOptions)).toThrow( + 'Missing required option (host)' + ) // 'Missing required options.' + resolve(null) + }) + }) + + test('should throw if `projectKey` is not provided.', () => { + new Promise((resolve, reject) => { + const middlewareOptions = createTestMiddlewareOptions({ + host: 'test-host', + projectKey: null, + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + user: { + username: 'test-username', + password: 'test-password', + }, + }, + }) + + expect(() => buildRequestForPasswordFlow(middlewareOptions)).toThrow( + 'Missing required option (projectKey)' + ) // 'Missing required options.' + resolve(null) + }) + }) + + test('should throw if `credentials` is not provided.', () => { + new Promise((resolve, reject) => { + const middlewareOptions = createTestMiddlewareOptions({ + host: 'test-host', + projectKey: 'test-projectKey', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: null, + }) + + expect(() => buildRequestForPasswordFlow(middlewareOptions)).toThrow( + 'Missing required option (credentials)' + ) // 'Missing required options.' + resolve(null) + }) + }) + }) + + test('should throw if `user credentials` is not provided.', () => { + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + // expect(req.headers.Authorization).toBe('Bearer xxx-xx') + // expect() + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + projectKey: 'demo-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: null, + }) + + expect(() => + createAuthMiddlewareForPasswordFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).toThrow('Missing required options.') // 'Missing required options.' + resolve(null) + }) + }) + + test('should throw error if required `user` option is not provided.', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxx-xx') + // expect() + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + projectKey: 'demo-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: '123', + clientSecret: 'secret', + user: null, + }, + }) + + expect( + createAuthMiddlewareForPasswordFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).rejects.toEqual(expect.any(Error)) + resolve(null) + })) + + test('should throw error if required `username` and `password` options are not provided.', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxx-xx') + // expect() + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + projectKey: 'demo-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: '123', + clientSecret: 'secret', + user: { + username: '', + password: '', + }, + }, + }) + + expect( + createAuthMiddlewareForPasswordFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).rejects.toEqual(expect.any(Error)) + resolve(null) + })) + + test('should not call the auth server if Authorization is already present in the headers', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxxx-xxx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { + access_token: 'x-Xxxx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + }) + + createAuthMiddlewareForPasswordFlow(middlewareOptions)(next)( + createTestRequest({ headers: { Authorization: 'Bearer xxxx-xxx' } }) + ) + expect(middlewareOptions.httpClient).toHaveBeenCalledTimes(0) + + resolve(null) + })) + + test('should fetch and store token in tokenCache object', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const store = (value) => { + let val = value + return { + get: () => val, + set: (newValue) => { + val = newValue + }, + } + } + + const tokenCache = store({}) + + const next = (req): any => { + expect(typeof tokenCache.get).toBe('function') + expect(typeof tokenCache.get()).toBe('object') + expect(tokenCache.get()).toHaveProperty('token') + expect(tokenCache.get()).toEqual( + expect.objectContaining({ + token: 'x-Xxxx', + // expirationTime: 8544429619082, // computed based on current time + refreshToken: undefined, + }) + ) + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer x-Xxxx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { + access_token: 'x-Xxxx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + tokenCache, + }) + + createAuthMiddlewareForPasswordFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + })) +}) diff --git a/packages/sdk-client-v3/tests/auth/refresh-token.test.ts b/packages/sdk-client-v3/tests/auth/refresh-token.test.ts new file mode 100644 index 000000000..09896b499 --- /dev/null +++ b/packages/sdk-client-v3/tests/auth/refresh-token.test.ts @@ -0,0 +1,323 @@ +import { createAuthMiddlewareForRefreshTokenFlow } from '../../src/middleware' +import { buildRequestForRefreshTokenFlow } from '../../src/middleware/auth-middleware/auth-request-builder' + +function createTestRequest(options) { + return { + url: '', + method: 'GET', + body: null, + headers: {}, + ...options, + } +} + +function createTestResponse(options) { + return { + ...options, + } +} + +function createTestMiddlewareOptions(options) { + return { + host: 'https://auth.europe-west1.gcp.commercetools.com', + projectKey: 'foo', + credentials: { + clientId: '123', + clientSecret: 'secret', + }, + refreshToken: 'xMhrTyoUxzyisERv==', + ...options, + } +} + +describe('Refresh Token Flow', () => { + describe('Refresh token flow auth request builder', () => { + test('should throw if `options` are not provided.', () => { + new Promise((resolve, reject) => { + const middlewareOptions = null + expect(() => + buildRequestForRefreshTokenFlow(middlewareOptions) + ).toThrow('Missing required options') + resolve(null) + }) + }) + }) + + test('should throw if `credentials` is not provided.', () => { + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + host: 'http://demo-auth-url', + projectKey: 'demo-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: null, // <------------- null + }) + + expect( + createAuthMiddlewareForRefreshTokenFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).rejects.toEqual(expect.any(Error)) + resolve(null) + }) + }) + + test('should throw error if required `projectKey` option is not provided.', () => + new Promise(async (resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxx-xx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + host: 'http://demo-auth-url', + projectKey: null, // <--------------- null + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: '123', + clientSecret: 'secret', + }, + }) + + expect( + createAuthMiddlewareForRefreshTokenFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).rejects.toEqual(expect.any(Error)) + resolve(null) + })) + + test('should throw error if required `refreshToken` option is not provided.', () => + new Promise(async (resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxx-xx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + projectKey: 'demo-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: '123', + clientSecret: 'secret', + }, + refreshToken: null, + }) + + expect( + createAuthMiddlewareForRefreshTokenFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).rejects.toEqual(expect.any(Error)) + resolve(null) + })) + + test('should throw error if required `host` option is not provided.', () => + new Promise(async (resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxx-xx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + host: null, // <------------- null + projectKey: 'demo-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: '123', + clientSecret: 'secret', + }, + refreshToken: null, + }) + + expect( + createAuthMiddlewareForRefreshTokenFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).rejects.toEqual(expect.any(Error)) + resolve(null) + })) + + test('should throw error if required `clientId` and `clientSecret` options are not provided.', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxx-xx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + projectKey: 'demo-key', + httpClient: jest.fn(() => ({ + data: { + access_token: 'xxx-xx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + credentials: { + clientId: null, + clientSecret: null, + }, + }) + + expect( + createAuthMiddlewareForRefreshTokenFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + ).rejects.toEqual(expect.any(Error)) + resolve(null) + })) + + test('should call the next function if Authorization is already present in the headers', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const next = (req): any => { + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer xxxx-xxx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { + access_token: 'x-Xxxx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + }) + + createAuthMiddlewareForRefreshTokenFlow(middlewareOptions)(next)( + createTestRequest({ headers: { Authorization: 'Bearer xxxx-xxx' } }) + ) + expect(next).toHaveBeenCalled() + expect(middlewareOptions.httpClient).toHaveBeenCalledTimes(0) + + resolve(null) + })) + + test('should fetch and store token in tokenCache object', () => + new Promise((resolve, reject) => { + const response = createTestResponse({ + resolve, + reject, + }) + + const store = (value) => { + let val = value + return { + get: () => val, + set: (newValue) => { + val = newValue + }, + } + } + + const tokenCache = store({}) + + const next = (req): any => { + expect(typeof tokenCache.get).toBe('function') + expect(typeof tokenCache.get()).toBe('object') + expect(tokenCache.get()).toHaveProperty('token') + expect(tokenCache.get()).toEqual( + expect.objectContaining({ + token: 'x-Xxxx', + }) + ) + expect(typeof req.headers).toBe('object') + expect(req.headers.Authorization).toBe('Bearer x-Xxxx') + + resolve(null) + } + + const middlewareOptions = createTestMiddlewareOptions({ + httpClient: jest.fn(() => ({ + data: { + access_token: 'x-Xxxx', + expires_in: 6873735270, + }, + statusCode: 200, + headers: {}, + })), + tokenCache, + }) + + createAuthMiddlewareForRefreshTokenFlow(middlewareOptions)(next)( + createTestRequest({}) + ) + expect(middlewareOptions.httpClient).toHaveBeenCalledTimes(1) + resolve(null) + })) +}) diff --git a/packages/sdk-client-v3/tests/builder.test/client-builder.test.ts b/packages/sdk-client-v3/tests/builder.test/client-builder.test.ts new file mode 100644 index 000000000..851253b95 --- /dev/null +++ b/packages/sdk-client-v3/tests/builder.test/client-builder.test.ts @@ -0,0 +1,219 @@ +import { ClientBuilder } from '../../src' +require('dotenv').config() + +export const projectKey = 'demo' +const fetch = require('node-fetch') + +describe('Client Builder', () => { + const authMiddlewareOptions = { + host: 'https://auth.europe-west1.gcp.commercetools.com', + projectKey: process.env.PROJECT_KEY || projectKey, + credentials: { + clientId: process.env.CTP_CLIENT_ID || '', + clientSecret: process.env.CTP_CLIENT_SECRET || '', + }, + oauthUri: process.env.OAUTH_URL || '', + scopes: ['manage_project:demo-1'], + httpClient: fetch, + } + + const httpMiddlewareOptions = { + host: 'https://api.europe-west1.gcp.commercetools.com', + httpClient: fetch, + } + + describe('general', () => { + test('should set the projectKey', () => { + const client = new ClientBuilder() as any + expect(client.projectKey).toEqual(undefined) + const clientWithKeyProp = client.withProjectKey(projectKey) + + expect(clientWithKeyProp.projectKey).toEqual('demo') + }) + + test('should set authorization middleware', () => { + const client = new ClientBuilder() as any + expect(client.authMiddleware).toEqual(undefined) + const clientWithKeyProp = client.withClientCredentialsFlow( + authMiddlewareOptions + ) + + expect(clientWithKeyProp.authMiddleware).toBeTruthy() + }) + + test('should set the http middleware', () => { + const client = new ClientBuilder() as any + expect(client.httpMiddleware).toEqual(undefined) + const clientWithKeyProp = client.withHttpMiddleware(httpMiddlewareOptions) + + expect(clientWithKeyProp.httpMiddleware).toBeTruthy() + }) + + test('should build the client when build method is called', () => { + const client = new ClientBuilder() + .withHttpMiddleware(httpMiddlewareOptions) + .withClientCredentialsFlow(authMiddlewareOptions) + .build() as any + + expect(client).toHaveProperty('execute') + expect(client).toHaveProperty('process') + + expect(typeof client.execute).toEqual('function') + expect(typeof client.process).toEqual('function') + }) + }) + + describe('middlewares', () => { + test('should create should create default client', () => { + const client = new ClientBuilder() as any + expect(client.authMiddleware).toEqual(undefined) + const defaultClient = client.defaultClient( + httpMiddlewareOptions.host, + authMiddlewareOptions.credentials, + authMiddlewareOptions.host, + authMiddlewareOptions.projectKey + ) + expect(defaultClient.httpMiddleware).toBeTruthy() + expect(defaultClient.authMiddleware).toBeTruthy() + }) + }) + + test('should create a client using a middleware', () => { + const middleware = { + request: () => {}, + response: () => {}, + } + const client = new ClientBuilder() as any + expect(client.middlewares).toHaveLength(0) + const clientWithMiddleware = client.withMiddleware(middleware) + expect(clientWithMiddleware.middlewares).toHaveLength(1) + }) + + test('should create a client using password flow middleware', () => { + const client = new ClientBuilder() as any + expect(client.authMiddleware).toBeFalsy() + + const clientWithUserCredentials = client.withPasswordFlow({ + ...authMiddlewareOptions, + credentials: { + ...authMiddlewareOptions.credentials, + user: { username: 'user', password: 'password' }, + }, + }) + expect(clientWithUserCredentials.authMiddleware).toBeTruthy() + }) + + test('should create a client with anonymousId', () => { + const client = new ClientBuilder() as any + expect(client.authMiddleware).toBeFalsy() + + const clientWithAnonymousID = client.withAnonymousSessionFlow({ + ...authMiddlewareOptions, + credentials: { + ...authMiddlewareOptions.credentials, + anonymousId: 'super-anonymous-id', + }, + }) + + expect(clientWithAnonymousID.authMiddleware).toBeTruthy() + }) + + test('should create a client with refreshToken', () => { + const client = new ClientBuilder() as any + expect(client.authMiddleware).toBeFalsy() + + const clientWithRefreshToken = client.withRefreshTokenFlow({ + ...authMiddlewareOptions, + refreshToken: 'refresh-token', + }) + expect(clientWithRefreshToken.authMiddleware).toBeTruthy() + }) + + test('should create a client with existingToken', () => { + const client = new ClientBuilder() as any + expect(client.authMiddleware).toBeFalsy() + + const clientWithExistingToken = client.withExistingTokenFlow('token', { + force: true, + }) + expect(clientWithExistingToken.authMiddleware).toBeTruthy() + }) + + test('should create client with userAgentMiddleware', () => { + const client = new ClientBuilder() as any + expect(client.userAgentMiddleware).toBeTruthy() + + const clientWithUserAgentMiddleware = client.withUserAgentMiddleware() + expect(clientWithUserAgentMiddleware.userAgentMiddleware).toBeTruthy() + }) + + test('should create client with queue middleware', () => { + const client = new ClientBuilder() as any + expect(client.queueMiddleware).toBeFalsy() + + const clientWithQueueMiddleware = client.withQueueMiddleware({ + concurrency: 20, + }) + expect(clientWithQueueMiddleware.queueMiddleware).toBeTruthy() + }) + + test('should create client with correlation id middleware', () => { + const client = new ClientBuilder() as any + expect(client.correlationIdMiddleware).toBeFalsy() + + const clientWithCorrelationIDMiddleware = + client.withCorrelationIdMiddleware({ generate: 'generated-uuid-string' }) + expect( + clientWithCorrelationIDMiddleware.correlationIdMiddleware + ).toBeTruthy() + }) + + test('should create client with error middleware', () => { + const client = new ClientBuilder() as any + expect(client.errorMiddleware).toBeFalsy() + + const clientWithErrorMiddleware = client.withErrorMiddleware({}) + expect(clientWithErrorMiddleware.errorMiddleware).toBeTruthy() + }) + + test('should create client with concurrent modification middleware', () => { + const client = new ClientBuilder() as any + expect(client.concurrentMiddleware).toBeFalsy() + + const clientWithConcurrentModificationMiddleware = + client.withConcurrentModificationMiddleware() + expect( + clientWithConcurrentModificationMiddleware.concurrentMiddleware + ).toBeTruthy() + }) + + test('should create client with logger middleware', () => { + const client = new ClientBuilder() as any + expect(client.loggerMiddleware).toBeFalsy() + + const clientWithLoggerMiddleware = client.withLoggerMiddleware({ + loggerFn: jest.fn(), + }) + expect(clientWithLoggerMiddleware.withLoggerMiddleware).toBeTruthy() + }) + + describe('builder method', () => { + test('build client', () => { + const client = new ClientBuilder() + .withCorrelationIdMiddleware({ generate: jest.fn() }) + .withUserAgentMiddleware({ name: 'test-user-agent' }) + .withClientCredentialsFlow(authMiddlewareOptions) + .withQueueMiddleware({ concurrency: 20 }) + .withLoggerMiddleware({ loggerFn: jest.fn() }) + .withErrorMiddleware({}) + .withConcurrentModificationMiddleware() + .withHttpMiddleware(httpMiddlewareOptions) + .build() + + expect(client).toBeTruthy() + expect(typeof client).toEqual('object') + expect(typeof client.execute).toEqual('function') + expect(typeof client.process).toEqual('function') + }) + }) +}) diff --git a/packages/sdk-client-v3/tests/client.test/client.test.ts b/packages/sdk-client-v3/tests/client.test/client.test.ts new file mode 100644 index 000000000..c263596d9 --- /dev/null +++ b/packages/sdk-client-v3/tests/client.test/client.test.ts @@ -0,0 +1,927 @@ +import qs from 'querystring' +import { + Next, + ClientRequest, + createClient, + MiddlewareRequest, + MiddlewareResponse, + HttpErrorType, + Process, + Client, + MethodType, + ClientBuilder, +} from '../../src' +import fetch from 'node-fetch' +import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk' + +const createPayloadResult = (tot: number, startingId = 0) => ({ + count: tot, + results: Array.from(Array(tot), (_, index) => ({ + id: String(index + 1 + startingId), + })), +}) + +describe('validate options', () => { + test('middlewares is required', () => { + expect(() => createClient(null)).toThrow('Missing required options') + }) + + test('middlewares must be an array', () => { + expect(() => createClient({ middlewares: {} as any })).toThrow( + 'Middlewares should be an array' + ) + }) + + test('middlewares must not be an empty array', () => { + expect(() => createClient({ middlewares: [] })).toThrow( + 'You need to provide at least one middleware' + ) + }) +}) + +describe('api', () => { + const middlewares = [ + (next: Next) => + (req: ClientRequest): Promise => + next({ ...req, response: { body: {} } }), + ] + const client = createClient({ middlewares }) + const request: ClientRequest = { + uri: '/foo', + method: 'POST', + } + + test('expose "execute" function', () => { + expect(typeof client.execute).toBe('function') + }) + + test('execute should return a promise', () => { + const promise = client.execute(request) + expect(promise.then).toBeDefined() + }) +}) + +describe('execute function', () => { + const request: MiddlewareRequest = { + uri: '/test/products', + method: 'GET', + body: null, + headers: {}, + // reject: Promise.reject + } + + test('should throw if request is missing', () => { + const middlewares = [ + (next: Next) => + (req: ClientRequest): Promise => + next(req), + ] + + const client = createClient({ middlewares }) + expect(() => client.execute(null)).toThrow( + /The "exec" function requires a "Request" object/ + ) + }) + + test('should throw if request uri is invalid', () => { + const middlewares = [ + (next: Next) => + (req: ClientRequest): Promise => + next(req), + ] + const client = createClient({ middlewares }) + const badRequest: MiddlewareRequest = { + ...request, + uri: 24 as any, + } + expect(() => client.execute(badRequest)).toThrow( + /The "exec" Request object requires a valid uri/ + ) + }) + + test('should throw if request method is invalid', () => { + const middlewares = [ + (next: Next) => + (req: ClientRequest): Promise => + next({ ...req, response: { body: {} } }), + ] + const client = createClient({ middlewares }) + const badRequest: MiddlewareRequest = { + ...request, + method: 'INVALID_METHOD' as any, + } + expect(() => client.execute(badRequest)).toThrow( + /The "exec" Request object requires a valid method./ + ) + }) + + test('execute and resolve a simple request', () => { + const client = createClient({ + middlewares: [ + (next: Next) => + async (req: ClientRequest): Promise => { + const headers = { + Authorization: 'Bearer 123', + } + return next({ ...req, headers }) + }, + (next: Next) => + async (req: ClientRequest): Promise => { + expect(req.headers).toEqual({ Authorization: 'Bearer 123' }) + return next({ ...req, response: { body: null } }) + }, + ], + }) + + return client.execute(request).then((response) => { + expect(response).toHaveProperty('reject') + expect(response).toHaveProperty('resolve') + expect(response).toEqual( + expect.objectContaining({ + body: null, + error: null, + }) + ) + }) + }) + + test('execute and resolve a simple request with `originalRequest`', () => { + const client = createClient({ + middlewares: [ + (next: Next) => + async (req: ClientRequest): Promise => { + const headers = { + Authorization: 'Bearer 123', + } + return next({ ...req, headers, response: { body: null } }) + }, + (next: Next) => + async (req: ClientRequest): Promise => { + return next({ + ...req, + includeOriginalRequest: true, + response: { body: null }, + }) + }, + ], + }) + + return client.execute(request).then((response) => { + expect(response).toEqual( + expect.objectContaining({ + body: null, + error: null, + originalRequest: expect.objectContaining({ + uri: '/test/products', + method: 'GET', + body: null, + headers: { Authorization: 'Bearer 123' }, + }), + }) + ) + expect(response).toHaveProperty('reject') + expect(response).toHaveProperty('resolve') + // @ts-ignore + expect(response.originalRequest).toHaveProperty('reject') + // @ts-ignore + expect(response.originalRequest).toHaveProperty('resolve') + }) + }) + + test('execute and reject a request', () => { + const client = createClient({ + middlewares: [ + (next: Next) => + async (req: ClientRequest): Promise => { + const error = new Error('Invalid password') + return next({ + ...req, + error, + statusCode: 400, + response: { body: {} }, + }) + }, + ], + }) + + client + .execute(request) + // .then(() => null) + .catch((error) => { + expect(error.message).toEqual('Invalid password') + return Promise.resolve() + }) + }) + + describe('ensure correct functions are used to resolve the promise', () => { + test('resolve', () => { + const customResolveSpy = jest.fn() + const client = createClient({ + middlewares: [ + (next) => (req: ClientRequest) => { + const requestWithCustomResolver = { + resolve() { + customResolveSpy() + req.resolve(null) + }, + } + return next({ + ...req, + ...requestWithCustomResolver, + response: { body: null }, + }) + }, + ], + }) + + return client.execute(request).then(() => { + expect(customResolveSpy).toHaveBeenCalled() + }) + }) + + test('reject', () => { + const customRejectSpy = jest.fn() + const client = createClient({ + middlewares: [ + (next: Next) => + (req: ClientRequest): Promise => { + const requestWithCustomResolver = { + reject() { + customRejectSpy() + req.reject(null) + }, + } + + const error = { + method: 'GET' as MethodType, + statusCode: 400, + message: 'Oops', + error: new Error('Oops'), + body: null, + } + + const resObject = { + ...req, + ...requestWithCustomResolver, + response: { body: null, error }, + } + return next(resObject) + }, + ], + }) + + return client.execute(request).catch((e) => { + expect(customRejectSpy).toHaveBeenCalled() + }) + }) + }) +}) + +describe('process', () => { + const request: MiddlewareRequest = { + uri: '/test/products', + method: 'GET', + body: null, + headers: {}, + } + + describe('validate arguments', () => { + const middlewares = [ + (next: Next) => + (req: MiddlewareRequest): Promise => + next({ ...req, response: { body: null } }), + ] + const client = createClient({ middlewares }) + + test('should throw if second argument missing', () => { + // @ts-ignore - disable type error + expect(() => client.process(request)).toThrow( + /The "process" function accepts a "Function"/ + ) + }) + + test('should throw if second argument is not a function', () => { + // @ts-ignore - disable type error + expect(() => client.process(request, 'foo')).toThrow( + /The "process" function accepts a "Function"/ + ) + }) + + test('should throw if request method is not `GET`', () => { + expect(() => + // @ts-ignore - disable type error + client.process({ uri: 'foo', method: 'POST' }, () => {}) + ).toThrow(/The "process" Request object requires a valid method/) + }) + }) + + test('process and resolve paginating 3 times', () => { + let reqCount = 0 + const reqStubs = { + 0: { + body: createPayloadResult(20), + query: { + sort: 'id asc', + withTotal: 'false', + limit: '20', + }, + }, + 1: { + body: createPayloadResult(20), + query: { + sort: 'id asc', + withTotal: 'false', + where: 'id > "20"', + limit: '20', + }, + }, + 2: { + body: createPayloadResult(10), + query: { + sort: 'id asc', + withTotal: 'false', + where: 'id > "20"', + limit: '20', + }, + }, + } + + const client = createClient({ + middlewares: [ + (next) => async (req) => { + const body = reqStubs[reqCount].body + expect(qs.parse(req.uri.split('?')[1])).toEqual( + reqStubs[reqCount].query + ) + + reqCount += 1 + return next({ + ...req, + response: { + body, + statusCode: 200, + }, + }) + }, + ], + }) + + return client + .process(request, () => Promise.resolve('OK')) + .then((response) => { + expect(response).toEqual(['OK', 'OK', 'OK']) + }) + }) + + test('return only the required number of items', () => { + let reqCount = 0 + const reqStubs = { + 0: { + body: createPayloadResult(20), + query: { + sort: 'id asc', + withTotal: 'false', + limit: '20', + }, + }, + 1: { + body: createPayloadResult(20), + query: { + sort: 'id asc', + withTotal: 'false', + where: 'id > "20"', + limit: '20', + }, + }, + 2: { + body: createPayloadResult(6), + query: { + sort: 'id asc', + withTotal: 'false', + where: 'id > "20"', + limit: '6', + }, + }, + } + + const client = createClient({ + middlewares: [ + (next) => async (req) => { + const body = reqStubs[reqCount].body + expect(qs.parse(req.uri.split('?')[1])).toEqual( + reqStubs[reqCount].query + ) + + reqCount += 1 + return next({ + ...req, + response: { + body, + statusCode: 200, + }, + }) + }, + ], + }) + + return client + .process(request, () => Promise.resolve('OK'), { total: 46 }) + .then((response) => { + expect(response).toEqual(['OK', 'OK', 'OK']) + }) + }) + + test('process and resolve pagination by preserving original query', () => { + let reqCount = 0 + const reqStubs = { + 0: { + body: createPayloadResult(5), + query: { + sort: ['id asc', 'createdAt desc'], + withTotal: 'false', + where: 'name (en = "Foo")', + limit: '5', + }, + }, + 1: { + body: createPayloadResult(2), + query: { + sort: ['id asc', 'createdAt desc'], + withTotal: 'false', + where: ['id > "5"', 'name (en = "Foo")'], + limit: '5', + }, + }, + } + + const client = createClient({ + middlewares: [ + (next) => async (req) => { + const body = reqStubs[reqCount].body + expect(qs.parse(req.uri.split('?')[1])).toEqual( + reqStubs[reqCount].query + ) + + reqCount += 1 + return next({ + ...req, + response: { + body, + statusCode: 200, + }, + }) + }, + ], + }) + + return client.process( + { + ...request, + uri: `${request.uri}?${qs.stringify({ + sort: 'createdAt desc', + where: 'name (en = "Foo")', + limit: 5, + })}`, + }, + () => Promise.resolve('OK') + ) + }) + + test('process and reject a request', () => { + const client = createClient({ + middlewares: [ + (next: Next) => + async (req: MiddlewareRequest): Promise => { + const httpError = new Error('Invalid password') + const error = { + name: 'error', + message: httpError.message, + code: 400, + status: 400, + statusCode: 400, + originalRequest: req, + } as HttpErrorType + + return next({ + ...req, + response: { body: null, error, statusCode: 400 }, + }) + }, + ], + }) + + return client + .process(request, () => Promise.resolve('OK')) + .then(() => + Promise.reject( + new Error( + 'This function should never be called, the response was rejected' + ) + ) + ) + .catch((error) => { + expect(error.message).toEqual('Invalid password') + return Promise.resolve() + }) + }) + + test('process and reject on rejection from user', () => { + const client = createClient({ + middlewares: [ + (next) => async (req) => { + return next({ ...req, response: { body: null, statusCode: 200 } }) + }, + ], + }) + + return client + .process(request, () => Promise.reject(new Error('Rejection from user'))) + .then(() => + Promise.reject( + new Error( + 'This function should never be called, the response was rejected' + ) + ) + ) + .catch((error) => { + expect(error).toEqual(new Error('Rejection from user')) + }) + }) +}) + +describe('process - exposed', () => { + const request = { + uri: '/test/products', + method: 'GET', + body: null, + headers: {}, + } as any + + describe('validate arguments', () => { + const middlewares = [ + (next: Next) => async (req: MiddlewareRequest) => + next({ ...req, response: { body: null } }), + ] + + createClient({ middlewares }) as Client + test('should throw if second argument missing', () => { + expect(() => Process(request, null, {})).toThrow( + /The "process" function accepts a "Function"/ + ) + }) + + test('should throw if second argument is not a function', () => { + expect(() => Process(request, undefined, {})).toThrow( + /The "process" function accepts a "Function"/ + ) + }) + + test('should throw if request method is not `GET`', () => { + expect(() => + Process( + { uri: 'foo', method: 'POST' }, + (res) => Promise.resolve(res), + {} + ) + ).toThrow(/The "process" Request object requires a valid method/) + }) + }) + + test('process and resolve paginating 3 times', () => { + let reqCount = 0 + const reqStubs = { + 0: { + body: createPayloadResult(20), + query: { + sort: 'id asc', + withTotal: 'false', + limit: '20', + }, + }, + 1: { + body: createPayloadResult(20), + query: { + sort: 'id asc', + withTotal: 'false', + where: 'id > "20"', + limit: '20', + }, + }, + 2: { + body: createPayloadResult(10), + query: { + sort: 'id asc', + withTotal: 'false', + where: 'id > "20"', + limit: '20', + }, + }, + } + + createClient({ + middlewares: [ + (next) => async (req) => { + const body = reqStubs[reqCount].body + expect(qs.parse(req.uri.split('?')[1])).toEqual( + reqStubs[reqCount].query + ) + + reqCount += 1 + return next({ ...req, response: { body, statusCode: 200 } }) + }, + ], + }) + + return Process(request, () => Promise.resolve('OK'), {}).then( + (response) => { + expect(response).toEqual(['OK', 'OK', 'OK']) + } + ) + }) + + test('return only the required number of items', () => { + let reqCount = 0 + const reqStubs = { + 0: { + body: createPayloadResult(20), + query: { + sort: 'id asc', + withTotal: 'false', + limit: '20', + }, + }, + 1: { + body: createPayloadResult(20), + query: { + sort: 'id asc', + withTotal: 'false', + where: 'id > "20"', + limit: '20', + }, + }, + 2: { + body: createPayloadResult(6), + query: { + sort: 'id asc', + withTotal: 'false', + where: 'id > "20"', + limit: '6', + }, + }, + } + + createClient({ + middlewares: [ + (next: Next) => + async (req: MiddlewareRequest): Promise => { + const body = reqStubs[reqCount].body + expect(qs.parse(req.uri.split('?')[1])).toEqual( + reqStubs[reqCount].query + ) + + reqCount += 1 + return next({ ...req, response: { body, statusCode: 200 } }) + }, + ], + }) + + return Process(request, () => Promise.resolve('OK'), { total: 46 }).then( + (response) => { + expect(response).toEqual(['OK', 'OK', 'OK']) + } + ) + }) + + test('process and resolve pagination by preserving original query', () => { + let reqCount = 0 + const reqStubs = { + 0: { + body: createPayloadResult(5), + query: { + sort: ['id asc', 'createdAt desc'], + withTotal: 'false', + where: 'name (en = "Foo")', + limit: '5', + }, + }, + 1: { + body: createPayloadResult(2), + query: { + sort: ['id asc', 'createdAt desc'], + withTotal: 'false', + where: ['id > "5"', 'name (en = "Foo")'], + limit: '5', + }, + }, + } + + createClient({ + middlewares: [ + (next) => async (req) => { + const body = reqStubs[reqCount].body + expect(qs.parse(req.uri.split('?')[1])).toEqual( + reqStubs[reqCount].query + ) + + reqCount += 1 + return next({ ...req, response: { body, statusCode: 200 } }) + }, + ], + }) + + return Process( + { + ...request, + uri: `${request.uri}?${qs.stringify({ + sort: 'createdAt desc', + where: 'name (en = "Foo")', + limit: 5, + })}`, + }, + () => Promise.resolve('OK'), + {} + ) + }) + + test('process should not call fn when last page is empty', async () => { + let reqCount = 0 + const reqStubs = { + 0: { + body: createPayloadResult(5), + query: { + sort: ['id asc', 'createdAt desc'], + withTotal: 'false', + where: 'name (en = "Foo")', + limit: '5', + }, + }, + 1: { + body: createPayloadResult(5, 5), + query: { + sort: ['id asc', 'createdAt desc'], + withTotal: 'false', + where: ['id > "5"', 'name (en = "Foo")'], + limit: '5', + }, + }, + 2: { + body: createPayloadResult(0), + query: { + sort: ['id asc', 'createdAt desc'], + withTotal: 'false', + where: ['id > "10"', 'name (en = "Foo")'], + limit: '5', + }, + }, + } + + createClient({ + middlewares: [ + (next) => + async (req: MiddlewareRequest): Promise => { + const body = reqStubs[reqCount].body + expect(qs.parse(req.uri.split('?')[1])).toEqual( + reqStubs[reqCount].query + ) + + reqCount += 1 + return next({ ...req, response: { body, statusCode: 200 } }) + }, + ], + }) + + let fnCall = 0 + const processRes = await Process( + { + ...request, + uri: `${request.uri}?${qs.stringify({ + sort: 'createdAt desc', + where: 'name (en = "Foo")', + limit: 5, + })}`, + }, + (res) => { + expect(res.body.results).toEqual(reqStubs[fnCall].body.results) + expect(fnCall).toBeLessThan(2) // should not call fn if the last page is empty + + fnCall += 1 + return Promise.resolve(`OK${fnCall}`) + }, + { + accumulate: true, + } + ) + + expect(processRes).toEqual(['OK1', 'OK2']) // results from fn calls + expect(fnCall).toBe(2) // fn was called two times + }) + + test('process and reject a request', () => { + createClient({ + middlewares: [ + (next) => async (req) => { + const httpError = new Error('Invalid password') + const error = { + name: 'error', + message: httpError.message, + code: 400, + status: 400, + statusCode: 400, + originalRequest: req, + } as HttpErrorType + + return next({ + ...req, + response: { body: null, error, statusCode: 400 }, + }) + }, + ], + }) as any + + return Process(request, () => Promise.resolve('OK'), {}) + .then(() => + Promise.reject( + new Error( + 'This function should never be called, the response was rejected' + ) + ) + ) + .catch((error) => { + expect(error.message).toEqual('Invalid password') + return Promise.resolve() + }) + }) + + test('process and reject on rejection from user', () => { + createClient({ + middlewares: [ + (next) => + async (req: MiddlewareRequest): Promise => { + return next({ ...req, statusCode: 200, response: { body: {} } }) + }, + ], + }) + + return Process( + request, + () => Promise.reject(new Error('Rejection from user')), + {} + ) + .then(() => + Promise.reject( + new Error( + 'This function should never be called, the response was rejected' + ) + ) + ) + .catch((error) => { + expect(error).toEqual(new Error('Rejection from user')) + }) + }) + + test('should process and project details', async () => { + const projectKey = process.env.CTP_PROJECT_KEY + const authMiddlewareOptions = { + host: 'https://auth.europe-west1.gcp.commercetools.com', + projectKey, + credentials: { + clientId: process.env.CTP_CLIENT_ID || '', + clientSecret: process.env.CTP_CLIENT_SECRET || '', + }, + oauthUri: process.env.adminAuthUrl || '', + scopes: [`manage_project:${projectKey}`], + httpClient: fetch, + } + + const httpMiddlewareOptions = { + host: 'https://api.europe-west1.gcp.commercetools.com', + httpClient: fetch, + } + + const apiRoot = createApiBuilderFromCtpClient( + new ClientBuilder() + .withProjectKey(projectKey) + .withClientCredentialsFlow(authMiddlewareOptions) + .withHttpMiddleware(httpMiddlewareOptions) + .build() + ) + + // @ts-ignore + const request = apiRoot.withProjectKey({ projectKey }).get().request + const fn = (data: any) => data + + Process(request, fn, {}) + /** + * response is an array of processed results + */ + .then((response) => { + // @ts-ignore + expect(response[0].body.key).toEqual(process.env.CTP_PROJECT_KEY) + expect(response[0].error).toBe(null) + expect(response[0].statusCode).toEqual(200) + expect(response[0].countries).toEqual(expect.any(Array)) + expect(response[0].currencies).toEqual(expect.any(Array)) + }) + .catch(fn) + }) +}) diff --git a/packages/sdk-client-v3/tests/concurrent-modification.test/concurrent-modification-middleware.test.ts b/packages/sdk-client-v3/tests/concurrent-modification.test/concurrent-modification-middleware.test.ts new file mode 100644 index 000000000..5bd2e6a91 --- /dev/null +++ b/packages/sdk-client-v3/tests/concurrent-modification.test/concurrent-modification-middleware.test.ts @@ -0,0 +1,70 @@ +import { createConcurrentModificationMiddleware } from '../../src/middleware' + +function createTestRequest(options) { + return { + uri: '', + method: 'GET', + body: null, + headers: {}, + ...options, + } +} + +function createTestResponse(options) { + return { + ...options, + } +} + +describe('Concurrent Modification Middleware.', () => { + test('should not modify a request with a non `409` status or error code.', async () => { + const request = createTestRequest({ body: { version: 4 } }) + const response = createTestResponse({ statusCode: 200 }) + + expect(request.body.version).toEqual(4) + const next = jest.fn((req) => { + expect(req.body.version).toEqual(4) + return response + }) + + const res = await createConcurrentModificationMiddleware()(next)(request) + expect(next).toHaveBeenCalled() + expect(next).toHaveBeenCalledTimes(1) + expect(next).toHaveBeenCalledWith(request) + + expect(res).toEqual(response) + }) + + test('should modify a request with a `409` status or error code.', async () => { + const request = createTestRequest({ body: { version: 4 } }) + const response = createTestResponse({ + statusCode: 409, + error: { body: { errors: [{ currentVersion: 5 }] } }, + }) + + // before the call version is 4 + expect(request.body.version).toEqual(4) + + // after the call that returned a 409 + const next = jest.fn((req) => { + // expect(req.body.version).toEqual(4) // <<-------------------- first call + // expect(req.body.version).toEqual(5) // <<-------------------- second call + + return response + }) + + await createConcurrentModificationMiddleware()(next)(request) + expect(next).toHaveBeenCalled() + expect(next).toHaveBeenCalledTimes(2) + expect(next).toHaveBeenCalledWith(request) + + // <--------- original request + expect(next).toHaveBeenNthCalledWith(1, request) + + // <------- second call use modified request body + expect(next).toHaveBeenNthCalledWith(2, { + ...request, + body: { ...request.body, version: 5 }, + }) + }) +}) diff --git a/packages/sdk-client-v3/tests/correlation-id.test/correlation-id-middleware.test.ts b/packages/sdk-client-v3/tests/correlation-id.test/correlation-id-middleware.test.ts new file mode 100644 index 000000000..23909d8a7 --- /dev/null +++ b/packages/sdk-client-v3/tests/correlation-id.test/correlation-id-middleware.test.ts @@ -0,0 +1,59 @@ +import { createCorrelationIdMiddleware } from '../../src/middleware' + +function createTestRequest(options) { + return { + uri: '', + method: 'GET', + body: null, + headers: {}, + ...options, + } +} + +const request = createTestRequest({ + headers: { + Authorization: '123', + }, +}) + +describe('Correlation id middleware', () => { + const correlationId = 'abc-def-123' + + test('should generate and inject `X-Correlation-ID` in header even if `generate` function is not provided', () => { + const next = (req): any => { + expect(req.headers['X-Correlation-ID']).toBeDefined() + expect(typeof req.headers['X-Correlation-ID']).toEqual('string') + } + + createCorrelationIdMiddleware({})(next)(request) + }) + + test('should call `generate()` function and inject id into the header object', () => { + const next = (req): any => { + expect(req.headers['X-Correlation-ID']).toBe(correlationId) + } + + const middlewareOptions = { + generate: jest.fn(() => correlationId), + } + + createCorrelationIdMiddleware(middlewareOptions)(next)(request) + expect(middlewareOptions.generate).toHaveBeenCalled() + expect(middlewareOptions.generate).toHaveBeenCalledTimes(1) + }) + + test('retains existing headers', () => { + const next = (req): any => { + expect(req.headers.Authorization).toBe('123') + expect(req.headers['X-Correlation-ID']).toBe(correlationId) + } + + const middlewareOptions = { + generate: jest.fn(() => correlationId), + } + + createCorrelationIdMiddleware(middlewareOptions)(next)(request) + expect(middlewareOptions.generate).toHaveBeenCalled() + expect(middlewareOptions.generate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/sdk-client-v3/tests/error.test/error-middleware.test.ts b/packages/sdk-client-v3/tests/error.test/error-middleware.test.ts new file mode 100644 index 000000000..8ee1909ba --- /dev/null +++ b/packages/sdk-client-v3/tests/error.test/error-middleware.test.ts @@ -0,0 +1,72 @@ +import { createErrorMiddleware } from '../../src/middleware' + +function createTestRequest(options) { + return { + uri: '', + method: 'GET', + body: null, + headers: {}, + ...options, + } +} + +function createTestResponse(options) { + return { + ...options, + } +} + +describe('Error Middleware.', () => { + test('should properly structure an error response.', async () => { + const request = createTestRequest({}) + const response = createTestResponse({ + body: null, + error: { + statusCode: 400, + message: 'unknown user input.', + headers: { + 'Content-Type': 'application/json', + }, + }, + }) + + const next = jest.fn(() => response) + const res = await createErrorMiddleware()(next)(request) + + expect(res).toEqual( + expect.objectContaining({ + ...response, + statusCode: response.error.statusCode, + headers: response.error.headers, + body: null, + error: { + ...response.error, + body: response.error, + }, + }) + ) + }) + + test('should move over non error responses and call the `next` middleware.', async () => { + const request = createTestRequest({}) + const response = createTestResponse({ + body: { + statusCode: 200, + message: 'success.', + headers: { + 'Content-Type': 'application/json', + }, + }, + error: null, + }) + + const next = jest.fn(() => response) + const res = await createErrorMiddleware()(next)(request) + + expect(next).toHaveBeenCalled() + expect(next).toHaveBeenCalledTimes(1) + expect(next).toHaveBeenCalledWith(request) + + expect(res).toEqual(response) + }) +}) diff --git a/packages/sdk-client-v3/tests/http.test/http-middleware.test.ts b/packages/sdk-client-v3/tests/http.test/http-middleware.test.ts new file mode 100644 index 000000000..8cc06d085 --- /dev/null +++ b/packages/sdk-client-v3/tests/http.test/http-middleware.test.ts @@ -0,0 +1,556 @@ +import { HttpMiddlewareOptions, MiddlewareRequest } from '../../src' +import { Buffer } from 'buffer/' +import { createHttpMiddleware } from '../../src/middleware' + +function createTestRequest(options) { + return { + uri: '', + method: 'GET', + body: null, + headers: {}, + ...options, + } +} + +function createTestResponse(options) { + return { + ...options, + } +} + +class FormDataMockClass { + append + constructor() { + this.append = jest.fn() + } +} + +describe('Http Middleware.', () => { + test('should throw if host is not provided.', () => { + const response = createTestResponse({}) + const httpMiddlewareOptions = { + host: null, + httpClient: jest.fn(), + } + + const next = () => response + expect(() => + createHttpMiddleware(httpMiddlewareOptions)(next)(createTestRequest({})) + ).toThrow() + }) + + test('should throw if host is not provided.', () => { + const response = createTestResponse({}) + const httpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: null, + } + + const next = () => response + expect(() => + createHttpMiddleware(httpMiddlewareOptions)(next)(createTestRequest({})) + ).toThrow() + }) + + test('should throw if timeout is provided and `AbortController` is not.', () => { + const response = createTestResponse({}) + const httpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(), + timeout: 2000, + getAbortController: null, + } + + const next = () => response + expect(() => + createHttpMiddleware(httpMiddlewareOptions)(next)(createTestRequest({})) + ).toThrow( + /`AbortController` is not available. Please pass in `getAbortController` as an option or have AbortController globally available when using timeout./ + ) + }) + + test('should throw if the set timeout value elapses', async () => { + const response = createTestResponse({ statusCode: 0 }) + const httpMiddlewareOptions: HttpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + timeout: 10, + getAbortController: jest.fn(), + credentialsMode: 'include', + } + + const next = (req: MiddlewareRequest) => { + expect(httpMiddlewareOptions.getAbortController).toHaveBeenCalled() + expect(req.response.error).toBeTruthy() + expect(req.response.code).toEqual(0) + expect(req.response.error.status).toEqual(0) + expect(req.response.statusCode).toEqual(0) + expect(req.response.error.name).toEqual('NetworkError') + expect(req.response.error.message).toEqual( + 'Unexpected non-JSON error response' + ) + + return response + } + + await createHttpMiddleware(httpMiddlewareOptions)(next)( + createTestRequest({}) + ) + }) + + test('should execute a GET request and return a json body.', async () => { + const response = createTestResponse({ + body: {}, + statusCode: 200, + headers: { + 'server-time': '05:07', + }, + }) + + const httpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + } + + const next = (req: MiddlewareRequest) => { + expect(typeof req.response).toEqual('object') + expect(req.response.statusCode).toEqual(200) + return response + } + + createHttpMiddleware(httpMiddlewareOptions)(next)(createTestRequest({})) + }) + + test('execute a GET request which does not return a json response', () => { + const response = createTestResponse({ + data: 'this is a string response data', + statusCode: 200, + headers: { + 'server-time': '05:07', + }, + }) + + const httpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + } + + const next = (req: MiddlewareRequest) => { + expect(typeof req.response).toEqual('object') + expect(typeof req.response.body).toEqual('string') + expect(req.response.statusCode).toEqual(200) + return response + } + + createHttpMiddleware(httpMiddlewareOptions)(next)(createTestRequest({})) + }) + + test('execute a GET request with a timeout option [success].', () => { + const response = createTestResponse({ + data: {}, + statusCode: 200, + headers: { + 'server-time': '05:07', + }, + }) + + const httpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + timeout: 1000, + getAbortController: jest.fn(), + } + + const next = (req: MiddlewareRequest) => { + expect(typeof req.response).toEqual('object') + expect(typeof req.response.body).toEqual('object') + expect(req.response.statusCode).toEqual(200) + return response + } + + createHttpMiddleware(httpMiddlewareOptions)(next)(createTestRequest({})) + }) + + test('execute a GET request with a timeout option [failure].', () => { + const response = createTestResponse({ + data: {}, + statusCode: 200, + headers: { + 'server-time': '05:07', + }, + }) + + const httpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + timeout: 10, + getAbortController: jest.fn(), + } + + const next = (req: MiddlewareRequest) => response + + createHttpMiddleware(httpMiddlewareOptions)(next)(createTestRequest({})) + expect(httpMiddlewareOptions.getAbortController).toHaveBeenCalled() + expect(httpMiddlewareOptions.getAbortController).toHaveBeenCalledTimes(1) + }) + + test('should accept HEAD request and return without response body', () => { + const response = createTestResponse({ + data: {}, + statusCode: 200, + headers: { + 'server-time': '05:07', + }, + }) + + const httpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + getAbortController: jest.fn(), + } + + const next = (req: MiddlewareRequest) => { + expect(req.response.body).toBeFalsy() + expect(req.method).toEqual('HEAD') + return response + } + + createHttpMiddleware(httpMiddlewareOptions as any)(next)( + createTestRequest({ method: 'HEAD' }) + ) + }) + + test('should accept a Buffer body', () => { + const response = createTestResponse({ + data: {}, + statusCode: 200, + headers: { + 'server-time': '05:07', + }, + }) + + const request = createTestRequest({ + method: 'POST', + body: Buffer.from('buffer body'), + }) + + const httpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + getAbortController: jest.fn(), + } + + const next = (req: MiddlewareRequest) => { + expect(req.response.body).toBeDefined() + expect(req.method).toEqual('POST') + expect(Buffer.isBuffer(req.body)).toEqual(true) + expect(req.body.toString()).toEqual('buffer body') + return response + } + + createHttpMiddleware(httpMiddlewareOptions as any)(next)(request) + }) + + test('should accept a `FormData` body', () => { + const formData = new FormDataMockClass() + formData.append('file', 'file content', 'file123') + + const response = createTestResponse({ + data: {}, + statusCode: 200, + headers: { + 'server-time': '05:07', + }, + }) + + const request = createTestRequest({ + uri: '/import/file-upload', + method: 'POST', + body: formData, + headers: { + 'Content-Type': null, + }, + }) + + const httpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + getAbortController: jest.fn(), + } + + const next = (req: MiddlewareRequest) => { + expect(req.headers['Content-Type']).toEqual(null) + expect(req.response.body).toBeDefined() + expect(req.body).toHaveProperty('append') + expect(req.method).toEqual('POST') + return response + } + + createHttpMiddleware(httpMiddlewareOptions as any)(next)(request) + }) + + test('should mask sensitive header contents', () => { + const response = createTestResponse({ + data: {}, + statusCode: 504, + headers: { + 'server-time': '05:07', + }, + }) + + const request = createTestRequest({ + uri: '/default-header/content-type', + method: 'POST', + body: { id: 'test-id' }, + headers: { + 'Content-Type': 'image/jpeg', + authorization: 'Bearer xbghRywRe===', + }, + }) + + const httpMiddlewareOptions: HttpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + maskSensitiveHeaderData: true, + includeOriginalRequest: true, + includeRequestInErrorResponse: true, + } + + const next = (req: MiddlewareRequest) => { + expect(req.response.error.originalRequest).toBeTruthy() + expect(req.response.error.originalRequest.headers.authorization).toEqual( + 'Bearer ********' + ) + return response + } + + createHttpMiddleware(httpMiddlewareOptions as any)(next)(request) + }) + + test('should not default other header content-type to application/json', () => { + const response = createTestResponse({ + data: {}, + statusCode: 200, + headers: { + 'server-time': '05:07', + }, + }) + + const request = createTestRequest({ + uri: '/default-header/content-type', + method: 'POST', + body: { id: 'test-id' }, + headers: { + 'Content-Type': 'image/jpeg', + }, + }) + + const httpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + getAbortController: jest.fn(), + } + + const next = (req: MiddlewareRequest) => { + expect(req.headers['Content-Type']).toEqual('image/jpeg') + expect(req.headers).toEqual({ 'Content-Type': 'image/jpeg' }) + return response + } + + createHttpMiddleware(httpMiddlewareOptions as any)(next)(request) + }) + + test('should throw if `httpClient` is not a function', async () => { + const response = createTestResponse({ + data: {}, + statusCode: 200, + headers: { + 'server-time': '05:07', + }, + }) + + const request = createTestRequest({ + uri: '/api-custom-url', + method: 'POST', + body: { id: 'test-id' }, + headers: { + 'Content-Type': 'image/jpeg', + }, + }) + + const httpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: {} as any, + } + + const next = (req: MiddlewareRequest) => { + expect(req.response.error).toBeTruthy() + expect(req.response.error.statusCode).toEqual(0) + expect(req.response.error.message).toEqual('httpClient is not a function') + + return response + } + + await createHttpMiddleware(httpMiddlewareOptions as any)(next)(request) + }) + + test('should return a parse a text encoded response as json', async () => { + const _response = { + data: {}, + statusCode: 200, + } + + const response = createTestResponse({ + // text: jest.fn(() => _response) + text: () => _response, + }) + + const request = createTestRequest({ + uri: '/error-url/retry', + method: 'POST', + body: { id: 'test-id' }, + headers: { + 'Content-Type': 'image/jpeg', + }, + }) + + const httpMiddlewareOptions: HttpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + enableRetry: false, + } + + const next = (req: MiddlewareRequest) => { + expect(typeof req.response).toEqual('object') + expect(req.response.body).toEqual(_response) + + return response + } + + await createHttpMiddleware(httpMiddlewareOptions)(next)(request) + }) + + describe('::retry test', () => { + test('should throw if `retryCode` is not an array', async () => { + const response = createTestResponse({ + data: {}, + statusCode: 503, + }) + + const request = createTestRequest({ + uri: '/error-url/retry', + method: 'POST', + body: { id: 'test-id' }, + headers: { + 'Content-Type': 'image/jpeg', + }, + }) + + const httpMiddlewareOptions: HttpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + enableRetry: true, + retryConfig: { + maxRetries: 2, + backoff: true, + retryDelay: 200, + retryCodes: {} as any, + }, + } + + const next = (req: MiddlewareRequest) => { + expect(req.response.error.statusCode).toEqual(0) + expect(req.response.error.message).toEqual( + '`retryCodes` option must be an array of retry status (error) codes and/or messages.' + ) + expect(req.response.error.name).toEqual('NetworkError') + + return response + } + + await createHttpMiddleware(httpMiddlewareOptions)(next)(request) + }) + + test('should retry request based on response status code', async () => { + const response = createTestResponse({ + data: {}, + statusCode: 504, + }) + + const request = createTestRequest({ + uri: '/error-url/retry', + method: 'POST', + body: { id: 'test-id' }, + headers: { + 'Content-Type': 'image/jpeg', + }, + }) + + const httpMiddlewareOptions: HttpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + enableRetry: true, + retryConfig: { + maxRetries: 3, + backoff: false, + retryDelay: 200, + retryCodes: [504], + }, + } + + const next = (req: MiddlewareRequest) => { + expect(req.response.error.statusCode).toEqual(504) + expect(req.response.error.message).toBeTruthy() + expect(req.response.error.name).toEqual('HttpError') + expect(req.response.error.retryCount).toEqual( + httpMiddlewareOptions.retryConfig.maxRetries + ) // 3 + + return response + } + + await createHttpMiddleware(httpMiddlewareOptions)(next)(request) + }) + + test('should retry request with exponential backoff', async () => { + const response = createTestResponse({ + data: {}, + statusCode: 503, + }) + + const request = createTestRequest({ + uri: '/error-url/retry', + method: 'POST', + body: { id: 'test-id' }, + headers: { + 'Content-Type': 'image/jpeg', + }, + }) + + const httpMiddlewareOptions: HttpMiddlewareOptions = { + host: 'http://api-host.com', + httpClient: jest.fn(() => response), + enableRetry: true, + retryConfig: { + maxRetries: 2, + backoff: true, + retryDelay: 200, + retryCodes: [503], + }, + } + + const next = (req: MiddlewareRequest) => { + expect(req.response.error.statusCode).toEqual(503) + expect(req.response.error.message).toBeTruthy() + expect(req.response.error.name).toEqual('ServiceUnavailable') + expect(req.response.error.retryCount).toEqual( + httpMiddlewareOptions.retryConfig.maxRetries + ) // 2 + + return response + } + + await createHttpMiddleware(httpMiddlewareOptions)(next)(request) + }) + }) +}) diff --git a/packages/sdk-client-v3/tests/logger.test/fixtures.ts b/packages/sdk-client-v3/tests/logger.test/fixtures.ts new file mode 100644 index 000000000..0fa316263 --- /dev/null +++ b/packages/sdk-client-v3/tests/logger.test/fixtures.ts @@ -0,0 +1,15 @@ +export function createTestResponse(options) { + return { + body: {}, + statusCode: 200, + request: { + url: 'http://demo-url/1235', + headers: { + Authorization: 'token-12345', + }, + }, + headers: {}, + error: null, + ...options, + } +} diff --git a/packages/sdk-client-v3/tests/logger.test/logger-middleware.test.ts b/packages/sdk-client-v3/tests/logger.test/logger-middleware.test.ts new file mode 100644 index 000000000..6039103aa --- /dev/null +++ b/packages/sdk-client-v3/tests/logger.test/logger-middleware.test.ts @@ -0,0 +1,120 @@ +import { createTestResponse } from './fixtures' +import { createLoggerMiddleware } from '../../src/middleware' + +function createTestRequest(options) { + return { + uri: '', + method: 'GET', + body: null, + headers: {}, + ...options, + } +} + +const response = createTestResponse({}) + +describe('Logger Middleware', () => { + beforeEach(() => { + console.log = jest.fn() + }) + + test('should include log response with default options.', async () => { + const request = createTestRequest({}) + const next = (req): any => response + const loggerMiddlewareOptions = { loggerFn: jest.fn() } + + await createLoggerMiddleware(loggerMiddlewareOptions)(next)(request) + expect(loggerMiddlewareOptions.loggerFn).toHaveBeenCalled() + expect(loggerMiddlewareOptions.loggerFn).toHaveBeenCalledWith(response) + }) + + test('should not include original request in the response object.', async () => { + const request = createTestRequest({}) + const next = (req): any => response + const loggerMiddlewareOptions = { + includeOriginalRequest: false, + loggerFn: jest.fn(), + } + + const { request: req, ...rest } = response + await createLoggerMiddleware(loggerMiddlewareOptions)(next)(request) + expect(loggerMiddlewareOptions.loggerFn).toHaveBeenCalled() + expect(loggerMiddlewareOptions.loggerFn).toHaveBeenCalledWith(rest) + }) + + test('should not include `response headers` in the response object.', async () => { + const request = createTestRequest({}) + const next = (req): any => response + const loggerMiddlewareOptions = { + includeResponseHeaders: false, + loggerFn: jest.fn(), + } + + const { headers, ...rest } = response + await createLoggerMiddleware(loggerMiddlewareOptions)(next)(request) + expect(loggerMiddlewareOptions.loggerFn).toHaveBeenCalled() + expect(loggerMiddlewareOptions.loggerFn).toHaveBeenCalledTimes(1) + expect(loggerMiddlewareOptions.loggerFn).toHaveBeenCalledWith(rest) + }) + + test('should include original request and mask sensitive headers', async () => { + const request = createTestRequest({}) + + const next = (req): any => response + const loggerMiddlewareOptions = { + loggerFn: jest.fn(), + maskSensitiveHeaderData: true, + includeOriginalRequest: true, + includeResponseHeaders: true, + } + + await createLoggerMiddleware(loggerMiddlewareOptions)(next)(request) + expect(loggerMiddlewareOptions.loggerFn).toHaveBeenCalled() + expect(loggerMiddlewareOptions.loggerFn).toHaveBeenCalledWith({ + ...response, + request: { + ...response.request, + headers: { + ...response.request.headers, + Authorization: 'Bearer ********', // header `Authorization has been masked. + }, + }, + }) + }) + + test('should return unaltered response [original] response.', async () => { + const request = createTestRequest({}) + const next = (req): any => response + const loggerMiddlewareOptions = { + loggerFn: jest.fn(), + maskSensitiveHeaderData: true, + includeOriginalRequest: false, + includeResponseHeaders: false, + } + + const { request: req, headers, ...rest } = response + + const originalResponse = await createLoggerMiddleware( + loggerMiddlewareOptions + )(next)(request) + expect(loggerMiddlewareOptions.loggerFn).toHaveBeenCalled() + expect(loggerMiddlewareOptions.loggerFn).toHaveBeenCalledTimes(1) + expect(loggerMiddlewareOptions.loggerFn).toHaveBeenCalledWith(rest) + expect(response).toEqual(originalResponse) + expect(originalResponse.request).toBeTruthy() + expect(originalResponse.headers).toBeTruthy() + expect(originalResponse).toHaveProperty('request') + expect(originalResponse).toHaveProperty('headers') + }) + + test('should call `console.log` if a loggerFn was not provided.', async () => { + const next = (req): any => response + const request = createTestRequest({}) + const loggerMiddlewareOptions = {} + await createLoggerMiddleware(loggerMiddlewareOptions)(next)(request) + + expect(console.log).toHaveBeenCalled() + expect(console.log).toHaveBeenCalledTimes(1) + expect(console.log).toHaveBeenCalledWith(response) + }) +}) diff --git a/packages/sdk-client-v3/tests/queue.test/queue-middleware.test.ts b/packages/sdk-client-v3/tests/queue.test/queue-middleware.test.ts new file mode 100644 index 000000000..2676b7a1a --- /dev/null +++ b/packages/sdk-client-v3/tests/queue.test/queue-middleware.test.ts @@ -0,0 +1,117 @@ +import { createQueueMiddleware } from '../../src/middleware' + +function createTestRequest(options) { + return { + uri: '', + method: 'GET', + body: null, + headers: {}, + ...options, + } +} + +function createTestResponse(options) { + return { + ...options, + } +} + +function createTestMiddlewareOptions(options) { + return { + ...options, + } +} + +describe('Queue', () => { + test('correctly enqueue / resolve tasks based on concurrency', () => + new Promise((resolve) => { + const resolveSpy = jest.fn() + const rejectSpy = jest.fn() + + const request = createTestRequest({ + uri: '/foo/bar', + ...createTestMiddlewareOptions({ + resolve: resolveSpy, + reject: rejectSpy, + }), + }) + + const middlewareOptions = createTestMiddlewareOptions({ concurrency: 2 }) + const queueMiddleware = createQueueMiddleware(middlewareOptions) + let count = 0 + const responseArgs = [] + const nextCount = (req): any => { + count += 1 + responseArgs.push(req) + } + + // Trigger multiple concurrent dispatches (with max concurrency 2) + queueMiddleware(nextCount)(request) + queueMiddleware(nextCount)(request) + // First 2 tasks should be dispatched straight away + expect(count).toBe(2) + // Dispatch new tasks, they won't be executed though + queueMiddleware(nextCount)(request) + queueMiddleware(nextCount)(request) + // Until running tasks are resolved, no more task should run + expect(count).toBe(2) + // Resolve the first task. We expect a new task to be dispatched since + // there is a free slot + responseArgs[0].resolve() + expect(count).toBe(3) + // Reject the second task. We expect a new task to be dispatched since + // there is a free slot + responseArgs[1].reject() + expect(count).toBe(4) + // Trigger the remaining tasks + responseArgs[2].resolve() + responseArgs[3].reject() + expect(resolveSpy).toHaveBeenCalledTimes(2) + expect(rejectSpy).toHaveBeenCalledTimes(2) + // All good, end the test + resolve(null) + })) + + test('dispatch incoming tasks with default concurrency', () => + new Promise((resolve) => { + const request = createTestRequest({ + uri: '/foo/bar', + }) + const response = createTestResponse(null) + const middlewareOptions = createTestMiddlewareOptions(null) + const queueMiddleware = createQueueMiddleware(middlewareOptions) + const nextSpy = jest.fn() + + // Trigger multiple concurrent dispatches (default 20) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + // 5 + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + // 10 + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + // 15 + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + queueMiddleware(nextSpy)(request) + // 20 + queueMiddleware(nextSpy)(request) + + expect(nextSpy).toHaveBeenCalledTimes(20) + + // All good, end the test + resolve(null) + })) +}) diff --git a/packages/sdk-client-v3/tests/user-agent.test/user-agent-middleware.test.ts b/packages/sdk-client-v3/tests/user-agent.test/user-agent-middleware.test.ts new file mode 100644 index 000000000..1565ed0a0 --- /dev/null +++ b/packages/sdk-client-v3/tests/user-agent.test/user-agent-middleware.test.ts @@ -0,0 +1,100 @@ +import { + MiddlewareRequest, + MiddlewareResponse, + JsonObject, +} from '../../src/types/types' +import { createUserAgentMiddleware } from '../../src/middleware' + +describe('UserAgent', () => { + const option = { name: 'agentName', customAgent: 'customAgent' } + const userAgentMiddleware = createUserAgentMiddleware(option) + const request: MiddlewareRequest = { + method: 'GET', + uri: '/foo', + headers: { + Authorization: '123', + }, + resolve: jest.fn(), + reject: jest.fn(), + } + + test('has the same given header', () => { + const next = (req): any => { + const headers: JsonObject = req.headers + expect(headers.Authorization).toBe('123') + } + + userAgentMiddleware(next)(request) + }) + + test('has sdk info', () => { + const next = (req: MiddlewareRequest): any => { + const headers: JsonObject = req.headers + expect(headers.Authorization).toBe('123') + expect(headers['User-Agent']).toMatch('commercetools-sdk-javascript') + } + + userAgentMiddleware(next)(request) + }) + + test('has browser info', () => { + // because we use jsdom + const next = (req: MiddlewareRequest): any => { + const headers: JsonObject = req.headers + expect(headers.Authorization).toBe('123') + expect(headers['User-Agent']).toMatch('commercetools-sdk-javascript') + expect(headers['User-Agent']).toMatch('node.js') + } + + userAgentMiddleware(next)(request) + }) + + test('has browser version', () => { + // because we use jsdom + const next = (req: MiddlewareRequest): any => { + const headers: JsonObject = req.headers + expect(headers.Authorization).toBe('123') + expect(headers['User-Agent']).toMatch('commercetools-sdk-javascript') + expect(headers['User-Agent']).toMatch(process.version.slice(1)) + } + + userAgentMiddleware(next)(request) + }) + + test('has a customAgent', () => { + const next = (req: MiddlewareRequest): any => { + const headers: JsonObject = req.headers + expect(headers.Authorization).toBe('123') + expect(headers['User-Agent']).toMatch('commercetools-sdk-javascript') + expect(headers['User-Agent']).toMatch('customAgent') + } + + userAgentMiddleware(next)(request) + }) + + test('should not override the name', () => { + const next = (req: MiddlewareRequest): any => { + const headers: JsonObject = req.headers + expect(headers.Authorization).toBe('123') + expect(headers['User-Agent']).toMatch('commercetools-sdk-javascript') + expect(headers['User-Agent']).toMatch('commercetools-sdk-javascript-v3/') + } + + userAgentMiddleware(next)(request) + }) + + test('do not change existing request header object', () => { + const next = (req: MiddlewareRequest): any => { + const headers: JsonObject = req.headers + expect(headers.Authorization).toBe('123') + expect(headers['User-Agent']).toMatch('commercetools-sdk-javascript') + expect(req.headers).toEqual( + expect.objectContaining({ + ...request.headers, + }) + ) + } + + userAgentMiddleware(next)(request) + }) +}) diff --git a/packages/sdk-client-v3/tests/user-agent.test/user-agent-util.test.ts b/packages/sdk-client-v3/tests/user-agent.test/user-agent-util.test.ts new file mode 100644 index 000000000..42e2fdadf --- /dev/null +++ b/packages/sdk-client-v3/tests/user-agent.test/user-agent-util.test.ts @@ -0,0 +1,169 @@ +import { userAgent } from '../../src/utils' + +const userAgentBrowser = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36' + +describe('for browser', () => { + const originalWindow = global.window + global.window = { + // @ts-ignore + document: { + nodeType: 9, + }, + // @ts-ignore + navigator: { + userAgent: userAgentBrowser, + }, + } + const userAgentObect = userAgent({ + name: 'commercetools-sdk-javascript', + version: '1.0.0', + libraryName: 'my-awesome-library', + libraryVersion: '1.0.0', + contactUrl: 'https://commercetools.com', + contactEmail: 'helpdesk@commercetools.com', + }) + // Reset original `global.window` + global.window = originalWindow + + test('has sdk info', () => { + expect(userAgentObect).toMatch('commercetools-sdk-javascript') + }) + + test('has browser info', () => { + // because we use jsdom + expect(userAgentObect).toMatch(userAgentBrowser) + }) + + test('has library info', () => { + expect(userAgentObect).toMatch('my-awesome-library/1.0.0') + }) + + test('has library url', () => { + expect(userAgentObect).toMatch('https://commercetools.com') + }) + + test('has contact info', () => { + expect(userAgentObect).toMatch('helpdesk@commercetools.com') + }) +}) + +describe('for node', () => { + const userAgentObject = userAgent({ + name: 'commercetools-sdk-javascript', + version: '1.0.0', + libraryName: 'my-awesome-library', + libraryVersion: '1.0.0', + contactUrl: 'https://commercetools.com', + contactEmail: 'helpdesk@commercetools.com', + }) + + test('has sdk info', () => { + expect(userAgentObject).toMatch('commercetools-sdk-javascript') + }) + + test('has node info', () => { + expect(userAgentObject).toMatch(`node.js/`) + }) + + test('has library info', () => { + expect(userAgentObject).toMatch('my-awesome-library/1.0.0') + }) + + test('has library info', () => { + const _userAgentObject = userAgent({ + version: '1.0.0', + name: 'commercetools-sdk-javascript', + libraryName: 'my-awesome-library', + }) + expect(_userAgentObject).toMatch('my-awesome-library') + }) + + test('has library url', () => { + expect(userAgentObject).toMatch('https://commercetools.com') + }) + + test('has contact info', () => { + expect(userAgentObject).toMatch('helpdesk@commercetools.com') + }) +}) + +describe('validation', () => { + test('should throws if options is undefined', () => { + expect(() => userAgent(null)).toThrow('Missing required option `name`') + }) + + test('should throws if options is empty', () => { + expect(() => userAgent(null)).toThrow('Missing required option `name`') + }) + + test('should not throw if options is missing name', () => { + expect(() => userAgent({})).not.toThrow() + }) +}) + +describe('optional information', () => { + const { version } = require('../../package.json') + const platformInfo = `(${process.platform}; ${process.arch})` + + test('create user agent with the correct SDK', () => { + const userAgentObect = userAgent({ + name: `commercetools-sdk-javascript-v2/${version}`, + }) + expect(userAgentObect).toBe( + `commercetools-sdk-javascript-v2/${version} node.js/${process.version.slice( + 1 + )} ${platformInfo}` + ) + }) + + test('create user agent with library name and version', () => { + const userAgentObect = userAgent({ + name: `commercetools-sdk-javascript-v2/${version}`, + libraryName: 'my-awesome-library', + libraryVersion: '1.0.0', + }) + expect(userAgentObect).toBe( + `commercetools-sdk-javascript-v2/${version} node.js/${process.version.slice( + 1 + )} ${platformInfo} my-awesome-library/1.0.0` + ) + }) + + test('create user agent with contact url', () => { + const userAgentObect = userAgent({ + name: `commercetools-sdk-javascript-v2/${version}`, + contactUrl: 'https://commercetools.com', + }) + expect(userAgentObect).toBe( + `commercetools-sdk-javascript-v2/${version} node.js/${process.version.slice( + 1 + )} ${platformInfo} (+https://commercetools.com)` + ) + }) + + test('create user agent with contact email', () => { + const userAgentObect = userAgent({ + name: `commercetools-sdk-javascript-v2/${version}`, + contactEmail: 'helpdesk@commercetools.com', + }) + expect(userAgentObect).toBe( + `commercetools-sdk-javascript-v2/${version} node.js/${process.version.slice( + 1 + )} ${platformInfo} (+helpdesk@commercetools.com)` + ) + }) + + test('create user agent with full contact info', () => { + const userAgentObect = userAgent({ + name: `commercetools-sdk-javascript-v2/${version}`, + contactUrl: 'https://commercetools.com', + contactEmail: 'helpdesk@commercetools.com', + }) + expect(userAgentObect).toBe( + `commercetools-sdk-javascript-v2/${version} node.js/${process.version.slice( + 1 + )} ${platformInfo} (+https://commercetools.com; +helpdesk@commercetools.com)` + ) + }) +}) diff --git a/packages/sdk-client-v3/tests/utils.test/createError.test.ts b/packages/sdk-client-v3/tests/utils.test/createError.test.ts new file mode 100644 index 000000000..0b33b1833 --- /dev/null +++ b/packages/sdk-client-v3/tests/utils.test/createError.test.ts @@ -0,0 +1,156 @@ +import { HttpErrorType, MethodType } from '../../src' +import { createError } from '../../src/utils' + +type ErrorType = ErrorArgs & Partial +type ErrorArgs = { + statusCode: number + message: string + originalRequest?: { + uri: string + method: MethodType + } +} + +const errorObject = (errorArgs = {}): ErrorType => ({ + statusCode: 404, + message: 'resource not found.', + originalRequest: { + uri: '/error-path-uri', + method: 'GET', + }, + ...errorArgs, +}) + +describe('createError', () => { + test('a 404 error', () => { + const _error = errorObject() + const errorResponse = createError(_error) + expect(errorResponse.code).toEqual(404) + expect(errorResponse.status).toEqual(404) + expect(errorResponse.statusCode).toEqual(404) + expect(errorResponse.name).toEqual('NotFound') + expect(errorResponse instanceof Error).toEqual(true) + expect(errorResponse.message).toEqual('URI not found: /error-path-uri') + }) + + test('a 400 error', () => { + const _error = errorObject({ statusCode: 400, message: 'Bad request.' }) + const errorResponse = createError(_error) + expect(errorResponse.code).toEqual(400) + expect(errorResponse.status).toEqual(400) + expect(errorResponse.statusCode).toEqual(400) + expect(errorResponse.name).toEqual('BadRequest') + expect(errorResponse.message).toEqual('Bad request.') + expect(errorResponse instanceof Error).toEqual(true) + }) + + test('a network error', () => { + const _error = errorObject({ statusCode: 0, message: 'Network error.' }) + const errorResponse = createError(_error) + + expect(errorResponse.code).toEqual(0) + expect(errorResponse.status).toEqual(0) + expect(errorResponse.statusCode).toEqual(0) + expect(errorResponse.name).toEqual('NetworkError') + expect(errorResponse.message).toEqual('Network error.') + expect(errorResponse instanceof Error).toEqual(true) + }) + + test('a 401 error', () => { + const _error = errorObject({ + statusCode: 401, + message: 'Unauthorized client request.', + }) + + const errorResponse = createError(_error) + + expect(errorResponse.code).toEqual(401) + expect(errorResponse.status).toEqual(401) + expect(errorResponse.statusCode).toEqual(401) + expect(errorResponse.name).toEqual('Unauthorized') + expect(errorResponse.message).toEqual('Unauthorized client request.') + expect(errorResponse instanceof Error).toEqual(true) + }) + + test('a 403 error', () => { + const _error = errorObject({ + statusCode: 403, + message: 'Forbidden client request.', + }) + + const errorResponse = createError(_error) + + expect(errorResponse.code).toEqual(403) + expect(errorResponse.status).toEqual(403) + expect(errorResponse.statusCode).toEqual(403) + expect(errorResponse.name).toEqual('Forbidden') + expect(errorResponse.message).toEqual('Forbidden client request.') + expect(errorResponse instanceof Error).toEqual(true) + }) + + test('a 409 error', () => { + const _error = errorObject({ + statusCode: 409, + message: 'Concurrent modification error.', + }) + + const errorResponse = createError(_error) + + expect(errorResponse.code).toEqual(409) + expect(errorResponse.status).toEqual(409) + expect(errorResponse.statusCode).toEqual(409) + expect(errorResponse.name).toEqual('ConcurrentModification') + expect(errorResponse.message).toEqual('Concurrent modification error.') + expect(errorResponse instanceof Error).toEqual(true) + }) + + test('a 500 error', () => { + const _error = errorObject({ + statusCode: 500, + message: 'Internal server error.', + }) + + const errorResponse = createError(_error) + + expect(errorResponse.code).toEqual(500) + expect(errorResponse.status).toEqual(500) + expect(errorResponse.statusCode).toEqual(500) + expect(errorResponse.name).toEqual('InternalServerError') + expect(errorResponse.message).toEqual('Internal server error.') + expect(errorResponse instanceof Error).toEqual(true) + }) + + test('a 503 error', () => { + const _error = errorObject({ + statusCode: 503, + message: 'Service unavailable, try again later.', + }) + + const errorResponse = createError(_error) + + expect(errorResponse.code).toEqual(503) + expect(errorResponse.status).toEqual(503) + expect(errorResponse.statusCode).toEqual(503) + expect(errorResponse.name).toEqual('ServiceUnavailable') + expect(errorResponse.message).toEqual( + 'Service unavailable, try again later.' + ) + expect(errorResponse instanceof Error).toEqual(true) + }) + + test('a 504 (generic) error', () => { + const _error = errorObject({ + statusCode: 504, + message: 'Gateway timeout.', + }) + + const errorResponse = createError(_error) + + expect(errorResponse.code).toEqual(504) + expect(errorResponse.status).toEqual(504) + expect(errorResponse.statusCode).toEqual(504) + expect(errorResponse.name).toEqual('HttpError') + expect(errorResponse.message).toEqual('Gateway timeout.') + expect(errorResponse instanceof Error).toEqual(true) + }) +}) diff --git a/packages/sdk-client-v3/tests/utils.test/headers.test.ts b/packages/sdk-client-v3/tests/utils.test/headers.test.ts new file mode 100644 index 000000000..9f660d8f6 --- /dev/null +++ b/packages/sdk-client-v3/tests/utils.test/headers.test.ts @@ -0,0 +1,36 @@ +import { getHeaders } from '../../src/utils' + +describe('header parser', () => { + test('should return `null` if header is not provided', () => { + expect(getHeaders(null)).toEqual(null) + }) + + test('should parse raw header', () => { + const headers = { + raw: jest.fn(() => ({ + 'Content-Type': 'application/json', + })), + } + + expect(getHeaders(headers)).toEqual({ 'Content-Type': 'application/json' }) + }) + + test('should parse header with header parser function', () => { + const headers = { + 'content-type': 'application/json', + } + + expect(getHeaders(headers)).toEqual({ 'content-type': 'application/json' }) + }) + + test('should parse headers without header parser functions', () => { + const map = {} + const headers = { + forEach: jest.fn(() => ({ 'Content-Type': 'application/json' })), + } + + expect(getHeaders(headers)).toEqual({ 'Content-Type': 'application/json' }) + expect(headers.forEach).toHaveBeenCalled() + expect(headers.forEach).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/sdk-client-v3/yarn.lock b/packages/sdk-client-v3/yarn.lock new file mode 100644 index 000000000..c780099c2 --- /dev/null +++ b/packages/sdk-client-v3/yarn.lock @@ -0,0 +1,150 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@commercetools/platform-sdk@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@commercetools/platform-sdk/-/platform-sdk-3.0.2.tgz#bab9acd435444090071f4e05a4777b4d15419bd4" + integrity sha512-dfFyyp9ee++hDYWL15crwEfIyBjnZJg/QfRggU0Zp93zCSlM84otVzf36Il18aAfP54TEWIYtonYy7u5lg5DfQ== + dependencies: + "@commercetools/sdk-client-v2" "^1.4.1" + "@commercetools/sdk-middleware-auth" "^6.0.4" + "@commercetools/sdk-middleware-http" "^6.0.4" + "@commercetools/sdk-middleware-logger" "^2.1.1" + querystring "^0.2.1" + +"@commercetools/sdk-client-v2@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@commercetools/sdk-client-v2/-/sdk-client-v2-1.4.1.tgz#af7f4b58142220b9b475e38ac4e398214728428f" + integrity sha512-wdJZ4jHhEh5CofW7rfgBjmnvJWl43dh82JhcWk69E3XlbankPvvqAW0BW+c5QPS0kQXw+YeVusaeL3BFHII5YA== + dependencies: + buffer "^6.0.3" + node-fetch "^2.6.1" + querystring "^0.2.1" + +"@commercetools/sdk-middleware-auth@^6.0.4": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@commercetools/sdk-middleware-auth/-/sdk-middleware-auth-6.2.1.tgz#bac3324bbedda004fc848167abe9b90abe0d6e85" + integrity sha512-JNVRVf7zssECg0i/amAG0gnFmx4Kj7rB0J9MfRlvN/54qyA6tKJOJaA5j9hYy60qKSW/NCGbVMcVlBnPJLhREQ== + dependencies: + node-fetch "^2.6.7" + +"@commercetools/sdk-middleware-http@^6.0.4": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@commercetools/sdk-middleware-http/-/sdk-middleware-http-6.2.0.tgz#8e51107bea9d3a7003ee653e1ed9e97d4d1171b8" + integrity sha512-3E1nV+awhP0eeFuyChxgbaPF5CWWH0PvGZO9FtNl/mirlYjGbXAHO4Ql5tG4/G+CywlXI9XVA9wKSwxG0kgwgA== + +"@commercetools/sdk-middleware-logger@^2.1.1": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@commercetools/sdk-middleware-logger/-/sdk-middleware-logger-2.1.2.tgz#6b8ce77699bf5d8e5ec4f243a2c9d691094ef0c7" + integrity sha512-uGhhAUnZzLWO3ozzz6VDMUAKr5Y9ctVyqfExXVC7t37IgppLsKrrvLl6VMoU2uXjIGSNA0VN0Kxgj93rBBejTQ== + +"@types/node@^18.7.18": + version "18.7.18" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.18.tgz#633184f55c322e4fb08612307c274ee6d5ed3154" + integrity sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.1.3.tgz#8274250dada2edf53814ed7db644b9c2866c1e35" + integrity sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA== + dependencies: + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +follow-redirects@^1.15.0: + version "1.15.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" + integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +node-fetch@2.6.7, node-fetch@^2.6.1, node-fetch@^2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +querystring@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.1.tgz#40d77615bb09d16902a85c3e38aa8b5ed761c2dd" + integrity sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg== + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" diff --git a/yarn.lock b/yarn.lock index 7aabcca4f..e43b4fc9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,19 +10,19 @@ "@jridgewell/gen-mapping" "^0.1.0" "@jridgewell/trace-mapping" "^0.3.9" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.5.5": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" - integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.21.4", "@babel/code-frame@^7.5.5": + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.21.4.tgz#d0fa9e4413aca81f2b23b9442797bda1826edb39" + integrity sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g== dependencies: "@babel/highlight" "^7.18.6" -"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.20.1", "@babel/compat-data@^7.20.5": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.21.0.tgz#c241dc454e5b5917e40d37e525e2f4530c399298" - integrity sha512-gMuZsmsgxk/ENC3O/fRw5QY8A9/uxQbbCEypnLIiYYc/qVJtEV7ouxC3EllIIwNzMqAQee5tanFabWsUOutS7g== +"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.20.1", "@babel/compat-data@^7.20.5", "@babel/compat-data@^7.21.4": + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.21.4.tgz#457ffe647c480dff59c2be092fc3acf71195c87f" + integrity sha512-/DYyDpeCfaVinT40FPGdkkb+lYSKvsVuMjDAG7jPOWWiM1ibOaB9CXJAlc4d1QpP/U2q2P9jbrSlClKSErd55g== -"@babel/core@7.21.3", "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.7.7": +"@babel/core@7.21.3": version "7.21.3" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.21.3.tgz#cf1c877284a469da5d1ce1d1e53665253fae712e" integrity sha512-qIJONzoa/qiHghnm0l1n4i/6IIziDpzqc36FBs4pzMhDUraHqponwJLiAKm1hGLP3OSB/TVNz6rMwVGpwxxySw== @@ -43,12 +43,33 @@ json5 "^2.2.2" semver "^6.3.0" -"@babel/generator@^7.21.3", "@babel/generator@^7.7.2": - version "7.21.3" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.3.tgz#232359d0874b392df04045d72ce2fd9bb5045fce" - integrity sha512-QS3iR1GYC/YGUnW7IdggFeN5c1poPUurnGttOV/bZgPGV+izC/D8HnD6DLwod0fsatNyVn1G3EVWMYIF0nHbeA== +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.7.7": + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz#c6dc73242507b8e2a27fd13a9c1814f9fa34a659" + integrity sha512-qt/YV149Jman/6AfmlxJ04LMIu8bMoyl3RB91yTFrxQmgbrSvQMy7cI8Q62FHx1t8wJ8B5fu0UDoLwHAhUo1QA== dependencies: - "@babel/types" "^7.21.3" + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.21.4" + "@babel/generator" "^7.21.4" + "@babel/helper-compilation-targets" "^7.21.4" + "@babel/helper-module-transforms" "^7.21.2" + "@babel/helpers" "^7.21.0" + "@babel/parser" "^7.21.4" + "@babel/template" "^7.20.7" + "@babel/traverse" "^7.21.4" + "@babel/types" "^7.21.4" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.2" + semver "^6.3.0" + +"@babel/generator@^7.21.3", "@babel/generator@^7.21.4", "@babel/generator@^7.7.2": + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.4.tgz#64a94b7448989f421f919d5239ef553b37bb26bc" + integrity sha512-NieM3pVIYW2SwGzKoqfPrQsf4xGs9M9AIG3ThppsSRmO+m7eQhmI6amajKMUeIO37wFfsvnvcxQFx6x6iqxDnA== + dependencies: + "@babel/types" "^7.21.4" "@jridgewell/gen-mapping" "^0.3.2" "@jridgewell/trace-mapping" "^0.3.17" jsesc "^2.5.1" @@ -68,21 +89,21 @@ "@babel/helper-explode-assignable-expression" "^7.18.6" "@babel/types" "^7.18.9" -"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.20.0", "@babel/helper-compilation-targets@^7.20.7": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.7.tgz#a6cd33e93629f5eb473b021aac05df62c4cd09bb" - integrity sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ== +"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.20.0", "@babel/helper-compilation-targets@^7.20.7", "@babel/helper-compilation-targets@^7.21.4": + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.4.tgz#770cd1ce0889097ceacb99418ee6934ef0572656" + integrity sha512-Fa0tTuOXZ1iL8IeDFUWCzjZcn+sJGd9RZdH9esYVjEejGmzf+FFYQpMi/kZUk2kPy/q1H3/GPw7np8qar/stfg== dependencies: - "@babel/compat-data" "^7.20.5" - "@babel/helper-validator-option" "^7.18.6" + "@babel/compat-data" "^7.21.4" + "@babel/helper-validator-option" "^7.21.0" browserslist "^4.21.3" lru-cache "^5.1.1" semver "^6.3.0" "@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.21.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.21.0.tgz#64f49ecb0020532f19b1d014b03bccaa1ab85fb9" - integrity sha512-Q8wNiMIdwsv5la5SPxNYzzkPnjgC0Sy0i7jLkVOCdllu/xcVNkr3TeZzbHBJrj+XXRqzX5uCyCoV9eu6xUG7KQ== + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.21.4.tgz#3a017163dc3c2ba7deb9a7950849a9586ea24c18" + integrity sha512-46QrX2CQlaFRF4TkwfTt6nJD7IHq8539cCL7SDpqWSDeJKY1xylKKY5F/33mJhLZ3mFvKv2gGrVS6NkyF6qs+Q== dependencies: "@babel/helper-annotate-as-pure" "^7.18.6" "@babel/helper-environment-visitor" "^7.18.9" @@ -94,9 +115,9 @@ "@babel/helper-split-export-declaration" "^7.18.6" "@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.20.5": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.21.0.tgz#53ff78472e5ce10a52664272a239787107603ebb" - integrity sha512-N+LaFW/auRSWdx7SHD/HiARwXQju1vXTW4fKr4u5SgBUTm51OKEjKgj+cs00ggW3kEvNqwErnlwuq7Y3xBe4eg== + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.21.4.tgz#40411a8ab134258ad2cf3a3d987ec6aa0723cee5" + integrity sha512-M00OuhU+0GyZ5iBBN9czjugzWrEq2vDpf/zCYHxxf93ul/Q5rv+a5h+/+0WnI1AebHNVtl5bFV0qsJoH23DbfA== dependencies: "@babel/helper-annotate-as-pure" "^7.18.6" regexpu-core "^5.3.1" @@ -148,11 +169,11 @@ "@babel/types" "^7.21.0" "@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e" - integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA== + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.21.4.tgz#ac88b2f76093637489e718a90cec6cf8a9b029af" + integrity sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg== dependencies: - "@babel/types" "^7.18.6" + "@babel/types" "^7.21.4" "@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.20.11", "@babel/helper-module-transforms@^7.21.2": version "7.21.2" @@ -266,10 +287,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.3": - version "7.21.3" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.3.tgz#1d285d67a19162ff9daa358d4cb41d50c06220b3" - integrity sha512-lobG0d7aOfQRXh8AyklEAgZGvA4FShxo6xQbUrrT/cNBPUdIDojlokwJsQyCC/eKia7ifqM0yP+2DRZ4WKw2RQ== +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.3", "@babel/parser@^7.21.4": + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.4.tgz#94003fdfc520bbe2875d4ae557b43ddb6d880f17" + integrity sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw== "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" @@ -480,11 +501,11 @@ "@babel/helper-plugin-utils" "^7.8.0" "@babel/plugin-syntax-jsx@^7.7.2": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz#a8feef63b010150abd97f1649ec296e849943ca0" - integrity sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q== + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.21.4.tgz#f264ed7bf40ffc9ec239edabc17a50c4f5b6fea2" + integrity sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ== dependencies: - "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-plugin-utils" "^7.20.2" "@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3": version "7.10.4" @@ -543,11 +564,11 @@ "@babel/helper-plugin-utils" "^7.14.5" "@babel/plugin-syntax-typescript@^7.20.0", "@babel/plugin-syntax-typescript@^7.7.2": - version "7.20.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.20.0.tgz#4e9a0cfc769c85689b77a2e642d24e9f697fc8c7" - integrity sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ== + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.21.4.tgz#2751948e9b7c6d771a8efa59340c15d4a2891ff8" + integrity sha512-xz0D39NvhQn4t4RNsHmDnnsaQizIlUkdtYvLs8La1BlfjQ6JEwxkJGeqJMW2tAXx+q6H+WFuUTXNdYVpEya0YA== dependencies: - "@babel/helper-plugin-utils" "^7.19.0" + "@babel/helper-plugin-utils" "^7.20.2" "@babel/plugin-transform-arrow-functions@^7.18.6": version "7.20.7" @@ -932,26 +953,26 @@ "@babel/parser" "^7.20.7" "@babel/types" "^7.20.7" -"@babel/traverse@^7.20.5", "@babel/traverse@^7.20.7", "@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.21.3", "@babel/traverse@^7.7.2": - version "7.21.3" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.3.tgz#4747c5e7903d224be71f90788b06798331896f67" - integrity sha512-XLyopNeaTancVitYZe2MlUEvgKb6YVVPXzofHgqHijCImG33b/uTurMS488ht/Hbsb2XK3U2BnSTxKVNGV3nGQ== +"@babel/traverse@^7.20.5", "@babel/traverse@^7.20.7", "@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.21.3", "@babel/traverse@^7.21.4", "@babel/traverse@^7.7.2": + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.4.tgz#a836aca7b116634e97a6ed99976236b3282c9d36" + integrity sha512-eyKrRHKdyZxqDm+fV1iqL9UAHMoIg0nDaGqfIOd8rKH17m5snv7Gn4qgjBoFfLz9APvjFU/ICT00NVCv1Epp8Q== dependencies: - "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.21.3" + "@babel/code-frame" "^7.21.4" + "@babel/generator" "^7.21.4" "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-function-name" "^7.21.0" "@babel/helper-hoist-variables" "^7.18.6" "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.21.3" - "@babel/types" "^7.21.3" + "@babel/parser" "^7.21.4" + "@babel/types" "^7.21.4" debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.20.5", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.2", "@babel/types@^7.21.3", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": - version "7.21.3" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.3.tgz#4865a5357ce40f64e3400b0f3b737dc6d4f64d05" - integrity sha512-sBGdETxC+/M4o/zKC0sl6sjWv62WFR/uzxrJ6uYyMLZOUlPnwzw0tKgVHOXxaAd5l2g8pEDM5RZ495GPQI77kg== +"@babel/types@^7.0.0", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.20.5", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.2", "@babel/types@^7.21.3", "@babel/types@^7.21.4", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": + version "7.21.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.4.tgz#2d5d6bb7908699b3b416409ffd3b5daa25b030d4" + integrity sha512-rU2oY501qDxE8Pyo7i/Orqma4ziCOrby0/9mvbDUGEfvZjb279Nk9k19e2fiCxHbRRpY2ZyrgW1eq22mvmOIzA== dependencies: "@babel/helper-string-parser" "^7.19.4" "@babel/helper-validator-identifier" "^7.19.1" @@ -1305,14 +1326,14 @@ conventional-commits-parser "^3.2.2" "@commitlint/read@^17.4.4": - version "17.4.4" - resolved "https://registry.yarnpkg.com/@commitlint/read/-/read-17.4.4.tgz#de6ec00aad827764153009aa54517e3df2154555" - integrity sha512-B2TvUMJKK+Svzs6eji23WXsRJ8PAD+orI44lVuVNsm5zmI7O8RSGJMvdEZEikiA4Vohfb+HevaPoWZ7PiFZ3zA== + version "17.5.1" + resolved "https://registry.yarnpkg.com/@commitlint/read/-/read-17.5.1.tgz#fec903b766e2c41e3cefa80630040fcaba4f786c" + integrity sha512-7IhfvEvB//p9aYW09YVclHbdf1u7g7QhxeYW9ZHSO8Huzp8Rz7m05aCO1mFG7G8M+7yfFnXB5xOmG18brqQIBg== dependencies: "@commitlint/top-level" "^17.4.0" "@commitlint/types" "^17.4.4" fs-extra "^11.0.0" - git-raw-commits "^2.0.0" + git-raw-commits "^2.0.11" minimist "^1.2.6" "@commitlint/resolve-extends@^17.4.4": @@ -1940,9 +1961,9 @@ "@types/estree" "*" "@types/eslint@*": - version "8.21.3" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.21.3.tgz#5794b3911f0f19e34e3a272c49cbdf48d6f543f2" - integrity sha512-fa7GkppZVEByMWGbTtE5MbmXWJTVbrjjaS8K6uQj+XtuuUv1fsuPAxhygfqLmsb/Ufb3CV8deFCpiMfAgi00Sw== + version "8.37.0" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.37.0.tgz#29cebc6c2a3ac7fea7113207bf5a828fdf4d7ef1" + integrity sha512-Piet7dG2JBuDIfohBngQ3rCt7MgO9xCO4xIMKxBThCq5PNRB91IjlJ10eJVwfoNtvTErmxLzwBZ7rHZtbOMmFQ== dependencies: "@types/estree" "*" "@types/json-schema" "*" @@ -2027,9 +2048,9 @@ integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== "@types/node@*", "@types/node@^18.0.0": - version "18.15.10" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.10.tgz#4ee2171c3306a185d1208dad5f44dae3dee4cfe3" - integrity sha512-9avDaQJczATcXgfmMAW3MIWArOO7A+m90vuCFLr8AotWf8igO/mRoYukrk2cqZVtv38tHs33retzHEilM7FpeQ== + version "18.15.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f" + integrity sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q== "@types/node@^12.7.1": version "12.20.55" @@ -2084,9 +2105,9 @@ integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== "@types/yargs@^17.0.8": - version "17.0.23" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.23.tgz#a7db3a2062c95ca1a5e0d5d5ddb6521cbc649e35" - integrity sha512-yuogunc04OnzGQCrfHx+Kk883Q4X0aSwmYZhKjI21m+SVYzjIbrWl8dOOwSv5hf2Um2pdCOXWo9isteZTNXUZQ== + version "17.0.24" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" + integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== dependencies: "@types/yargs-parser" "*" @@ -2778,9 +2799,9 @@ camelcase@^6.2.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-lite@^1.0.30001449: - version "1.0.30001470" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001470.tgz#09c8e87c711f75ff5d39804db2613dd593feeb10" - integrity sha512-065uNwY6QtHCBOExzbV6m236DDhYCCtPmQUCoQtwkVqzud8v5QPidoMr6CoMkC2nfp6nksjttqWQRRh75LqUmA== + version "1.0.30001473" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001473.tgz#3859898b3cab65fc8905bb923df36ad35058153c" + integrity sha512-ewDad7+D2vlyy+E4UJuVfiBsU69IL+8oVmTuZnH5Q6CIUbxNfI50uVpRHbUPDD6SUaN2o0Lh4DhTrvLG/Tn1yg== chalk@^2.0.0, chalk@^2.1.0, chalk@^2.4.2: version "2.4.2" @@ -3358,9 +3379,9 @@ editorconfig@^0.15.3: sigmund "^1.0.1" electron-to-chromium@^1.4.284: - version "1.4.340" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.340.tgz#3a6d7414c1fc2dbf84b6f7af3ec24270606c85b8" - integrity sha512-zx8hqumOqltKsv/MF50yvdAlPF9S/4PXbyfzJS6ZGhbddGkRegdwImmfSVqCkEziYzrIGZ/TlrzBND4FysfkDg== + version "1.4.348" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.348.tgz#f49379dc212d79f39112dd026f53e371279e433d" + integrity sha512-gM7TdwuG3amns/1rlgxMbeeyNoBFPa+4Uu0c7FeROWh4qWmvSOnvcslKmWy51ggLKZ2n/F/4i2HJ+PVNxH9uCQ== elliptic@^6.5.3: version "6.5.4" @@ -3835,7 +3856,7 @@ get-symbol-description@^1.0.0: call-bind "^1.0.2" get-intrinsic "^1.1.1" -git-raw-commits@^2.0.0: +git-raw-commits@^2.0.11: version "2.0.11" resolved "https://registry.yarnpkg.com/git-raw-commits/-/git-raw-commits-2.0.11.tgz#bc3576638071d18655e1cc60d7f524920008d723" integrity sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A== @@ -6926,9 +6947,9 @@ type-fest@^2.14.0: integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== type-fest@^3.0.0: - version "3.7.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.7.1.tgz#94f1bac89863e507c3635d96010012040aba9215" - integrity sha512-8LZNdvuztgxCF4eYpEmPYUPS0lbbByM2qHcp2oMxHZhWLIQB9QE36EeQ1PKwsUIDZXEP8HCBEmkBbT1//kLU4Q== + version "3.7.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.7.2.tgz#08f83ee3229b63077e95c9035034d32905969457" + integrity sha512-f9BHrLjRJ4MYkfOsnC/53PNDzZJcVo14MqLp2+hXE39p5bgwqohxR5hDZztwxlbxmIVuvC2EFAKrAkokq23PLA== typed-array-length@^1.0.4: version "1.0.4" @@ -6945,9 +6966,9 @@ typescript@4.9.5: integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== "typescript@^4.6.4 || ^5.0.0": - version "5.0.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.2.tgz#891e1a90c5189d8506af64b9ef929fca99ba1ee5" - integrity sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw== + version "5.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.3.tgz#fe976f0c826a88d0a382007681cbb2da44afdedf" + integrity sha512-xv8mOEDnigb/tN9PSMTwSEqAnUvkoXMQlicOb0IUVDBSQCgBSaAAROUZYy2IcUy5qU6XajK5jjjO7TMWqBTKZA== unbox-primitive@^1.0.2: version "1.0.2" @@ -7159,9 +7180,9 @@ webpack-sources@^3.2.3: integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== webpack@^5.53.0: - version "5.76.3" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.3.tgz#dffdc72c8950e5b032fddad9c4452e7787d2f489" - integrity sha512-18Qv7uGPU8b2vqGeEEObnfICyw2g39CHlDEK4I7NK13LOur1d0HGmGNKGT58Eluwddpn3oEejwvBPoP4M7/KSA== + version "5.77.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.77.0.tgz#dea3ad16d7ea6b84aa55fa42f4eac9f30e7eb9b4" + integrity sha512-sbGNjBr5Ya5ss91yzjeJTLKyfiwo5C628AFjEa6WSXcZa4E+F57om3Cc8xLb1Jh0b243AWuSYRf3dn7HVeFQ9Q== dependencies: "@types/eslint-scope" "^3.7.3" "@types/estree" "^0.0.51"