From a93397933be07b83175e45449747e6ce4eee1708 Mon Sep 17 00:00:00 2001 From: Kamil Date: Fri, 1 Mar 2024 23:02:10 +0100 Subject: [PATCH 1/6] Rewrite module from scratch - TypeScript as a base - Deno as a base - Removed all dependencies --- .eslintrc.js | 18 ---- .gitignore | 6 +- .gitlab-ci.yml | 52 ++++++++-- LICENSE | 2 +- Magento2Api.ts | 85 +++++++++++++++ OAuth.ts | 177 ++++++++++++++++++++++++++++++++ README.md | 150 ++++++++++++--------------- deno.json | 37 +++++++ hash/createHmacHasher.ts | 28 +++++ hash/hmacSha1.ts | 3 + hash/hmacSha256.ts | 3 + package.json | 44 -------- scripts/build_npm.ts | 42 ++++++++ src/index.d.ts | 41 -------- src/index.js | 130 ----------------------- src/request/addStoreCode.js | 9 -- src/response/extractData.js | 3 - src/response/handleError.js | 5 - test.config.js | 13 --- test/OAuth_test.ts | 125 ++++++++++++++++++++++ test/adminEndpoints.js | 25 ----- test/e2e/loadConfig.ts | 20 ++++ test/e2e/localhostAdmin_test.ts | 39 +++++++ test/e2e/localhostGuest_test.ts | 23 +++++ test/guestEndpoints.js | 11 -- test/hash/hmacSha256_test.ts | 9 ++ test/index.js | 147 -------------------------- test/request/addStoreCode.js | 21 ---- test/response/extractData.js | 16 --- test/response/handleError.js | 14 --- test/util/flatten_test.ts | 68 ++++++++++++ util/flatten.ts | 43 ++++++++ 32 files changed, 816 insertions(+), 593 deletions(-) delete mode 100644 .eslintrc.js create mode 100644 Magento2Api.ts create mode 100644 OAuth.ts create mode 100644 deno.json create mode 100644 hash/createHmacHasher.ts create mode 100644 hash/hmacSha1.ts create mode 100644 hash/hmacSha256.ts delete mode 100644 package.json create mode 100644 scripts/build_npm.ts delete mode 100644 src/index.d.ts delete mode 100644 src/index.js delete mode 100644 src/request/addStoreCode.js delete mode 100644 src/response/extractData.js delete mode 100644 src/response/handleError.js delete mode 100644 test.config.js create mode 100644 test/OAuth_test.ts delete mode 100644 test/adminEndpoints.js create mode 100644 test/e2e/loadConfig.ts create mode 100644 test/e2e/localhostAdmin_test.ts create mode 100644 test/e2e/localhostGuest_test.ts delete mode 100644 test/guestEndpoints.js create mode 100644 test/hash/hmacSha256_test.ts delete mode 100644 test/index.js delete mode 100644 test/request/addStoreCode.js delete mode 100644 test/response/extractData.js delete mode 100644 test/response/handleError.js create mode 100644 test/util/flatten_test.ts create mode 100644 util/flatten.ts diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 441d990..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - "env": { - "commonjs": true, - "es6": true, - "node": true, - "mocha": true - }, - "extends": "eslint:recommended", - "globals": { - "Atomics": "readonly", - "SharedArrayBuffer": "readonly" - }, - "parserOptions": { - "ecmaVersion": 2018 - }, - "rules": { - } -}; \ No newline at end of file diff --git a/.gitignore b/.gitignore index d8084de..c96d628 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ -/test.config.local.js -.nyc_output node_modules /package-lock.json +/deno.lock +/coverage +/npm +.env diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 55ab38b..665dea2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,27 +1,57 @@ stages: + - check - build - deploy -build: - stage: build - image: node:lts +check: + stage: check + image: denoland/deno cache: paths: - - node_modules + - ~/.deno + script: + - deno lint + - deno test --coverage --allow-net --allow-env --allow-read test/ + - deno coverage --lcov coverage/ > coverage/cov.lcov + - deno run --allow-read --allow-write https://deno.land/x/lcov_cobertura/mod.ts coverage/cov.lcov -o coverage/cobertura-coverage.xml + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage/cobertura-coverage.xml + +build_node: + stage: build + image: denoland/deno + variables: + MODULE_VERSION: ${CI_COMMIT_REF_NAME} + only: + - /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/ script: - - npm install - - npm run lint - - npm run test-ci + - deno run -A scripts/build_npm.ts ${MODULE_VERSION} artifacts: paths: - - node_modules + - npm/ + +deploy_jsr: + stage: deploy + image: denoland/deno + variables: + MODULE_VERSION: ${CI_COMMIT_REF_NAME} + only: + - /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/ + script: + - sed -i -e "s/\${MODULE_VERSION}/${MODULE_VERSION}/g" deno.json + - deno publish -deploy: +deploy_npm: stage: deploy - image: node:lts + image: node + dependencies: + - build_node only: - /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/ script: + - cd npm - echo '//registry.npmjs.org/:_authToken=${NPM_TOKEN}' > .npmrc - - npm version -no-git-tag-version $CI_COMMIT_REF_NAME - npm publish diff --git a/LICENSE b/LICENSE index efb41e1..e1b2787 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 lumnn +Copyright (c) 2024 lumnn Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Magento2Api.ts b/Magento2Api.ts new file mode 100644 index 0000000..7f6b15e --- /dev/null +++ b/Magento2Api.ts @@ -0,0 +1,85 @@ +import { OAuth } from "./OAuth.ts" +import { hmacSha256 } from "./hash/hmacSha256.ts"; +import { flatten } from "./util/flatten.ts" + +export class Magento2Api { + oauth: undefined|OAuth + options: MagentoApiOptions + + constructor(options: MagentoApiOptions) { + this.options = options + + if (options.url.endsWith("/")) { + options.url = options.url.replace(/\/+$/, '') + } + + if (options.consumerKey && options.consumerSecret) { + this.oauth = new OAuth({ + consumer: { key: options.consumerKey, secret: options.consumerSecret }, + signatureMethod: 'HMAC-SHA256', + hashMethods: { + "HMAC-SHA256": hmacSha256 + } + }) + } + } + + async request(method: string, path: string, data?: any, options?: RequestOptions): Promise { + const request = await this.buildRequest(method, path, data, options) + return fetch(request) + } + + async buildRequest(method: string, path: string, data?: any, options?: RequestOptions): Promise { + if (path[0] !== '/') { + const version = this.options.apiVersion || 1 + let storeCode = options?.storeCode || this.options.storeCode || '' + if (storeCode) { + storeCode += "/" + } + path = `/rest/${storeCode}V${version}/${path}` + } + + const query = new URLSearchParams(flatten(options?.params)).toString() + + const url = this.options.url + path + (query ? `?${query}` : '') + const body = data ? JSON.stringify(data) : null + + const request = new Request(url, { + method, + ...options, + headers: { + "Content-Type": "application/json", + ...(options?.headers ? options.headers : {}) + }, + body + }) + + if (this.oauth && this.options.accessToken && this.options.tokenSecret) { + await this.oauth.authRequest(request, { key: this.options.accessToken, secret: this.options.tokenSecret }) + } + + return request + } + + async $(method: string, path: string, data: any, options?: RequestOptions): Promise { return (await this.request(method, path, data, options)).json() as Promise } + async $get(path: string, options?: RequestOptions): Promise { return (await this.request('get', path, null, options)).json() as Promise } + async $post(path: string, data: any, options?: RequestOptions): Promise { return (await this.request('post', path, data, options)).json() as Promise } + async $patch(path: string, data: any, options?: RequestOptions): Promise { return (await this.request('patch', path, data, options)).json() as Promise } + async $put(path: string, data: any, options?: RequestOptions): Promise { return (await this.request('patch', path, data, options)).json() as Promise } + async $delete(path: string, options?: RequestOptions): Promise { return (await this.request('delete', path, null, options)).json() as Promise } +} + +export type MagentoApiOptions = { + url: string, + storeCode?: string, + apiVersion?: number, + consumerKey?: string, + consumerSecret?: string, + accessToken?: string, + tokenSecret?: string, +} + +type RequestOptions = RequestInit & { + params?: any, + storeCode?: string, +} \ No newline at end of file diff --git a/OAuth.ts b/OAuth.ts new file mode 100644 index 0000000..25153de --- /dev/null +++ b/OAuth.ts @@ -0,0 +1,177 @@ +export type OAuthOptions = { + consumer: OAuthKey + version?: string + realm?: string + signatureMethod?: string + hashMethods?: { + [name: string]: OAuthHashFn + } + nonceLength?: number +} + +type OAuthRequest = + & Pick + & Partial> +type OAuthKey = { + key: string + secret: string +} + +type OAuthHashFn = (key: string, content: string) => Promise + +/** + * A OAuth 1.0a implementation, planned for Magento 2 usage. + * + * @see https://oauth.net/core/1.0a/ + */ +export class OAuth { + consumer: OAuthKey + version: string + realm?: string + signatureMethod: string + hashMethods: { + [name: string]: OAuthHashFn + } + nonceLength: number + + constructor(opts: OAuthOptions) { + this.consumer = opts.consumer + this.version = opts.version || "1.0" + this.realm = opts.realm + this.signatureMethod = opts.signatureMethod || "PLAINTEXT" + + this.nonceLength = opts.nonceLength || 32 + + this.hashMethods = { + PLAINTEXT: (key: string) => Promise.resolve(key), + ...opts.hashMethods, + } + } + + /** + * Returns signed Authorization header value + */ + async authorize(request: OAuthRequest, token: OAuthKey): Promise { + const oauthData = { + oauth_consumer_key: this.consumer.key, + oauth_nonce: this.nonce(), + oauth_signature_method: this.signatureMethod, + oauth_timestamp: this.timestamp(), + oauth_token: token.key, + oauth_version: this.version, + } + + const signingParams: Iterable<[string, string]> = [ + ...Object.entries(oauthData), + ...(await this.collectRequestParams(request)), + ] + .sort((a, b) => a[0].localeCompare(b[0])) + .map((p) => [p[0], p[1] = encodeRFC3986URIComponent(p[1])]) + + const signedData = [ + request.method, + this.constructRequestUrl(request), + (new URLSearchParams(signingParams)).toString(), + ] + .filter((p) => p) + .map(encodeRFC3986URIComponent) + .join("&") + + const signatureMethod = oauthData.oauth_signature_method + + if (!signatureMethod || !this.hashMethods[signatureMethod]) { + throw new Error(`Unknown signing method: ${signatureMethod}`) + } + + const oauth = Object.entries(oauthData) + oauth.push([ + "oauth_signature", + await this.hashMethods[signatureMethod](this.hashKey(token), signedData), + ]) + oauth.sort((a, b) => a[0].localeCompare(b[0])) + + const oauthStr = oauth.map((h) => + `${h[0]}="${encodeRFC3986URIComponent(h[1])}"` + ).join(",") + const realm = this.realm ? `realm="${this.realm}",` : `` + + return `OAuth ${realm}${oauthStr}` + } + + /** + * Signs Request and adds Authorize header + */ + async authRequest(request: Request, token: OAuthKey): Promise { + const auth = await this.authorize(request, token) + request.headers.set("authorization", auth) + + return auth + } + + /** + * Collects request params used for signing in a entries array format + */ + async collectRequestParams( + request: Omit, + ): Promise { + const params: string[][] = [] + + const url = new URL(request.url) + params.push(...url.searchParams.entries()) + + if ( + request.headers?.get("content-type") === + "application/x-www-form-urlencoded" && request.body + ) { + const reader = request.body.getReader() + let body = "" + let part + + do { + part = await reader.read() + body += part.value + } while (part.value !== undefined) + + reader.releaseLock() + + params.push(...(new URLSearchParams(body)).entries()) + } + + return params + .filter((p) => p[1] !== undefined) + } + + /** + * Constructs url for signing according to RFC section 9.1.2 Construct Request URL + */ + constructRequestUrl(request: Pick): string { + const url = new URL(request.url) + url.search = "" + + return url.toString() + } + + hashKey(token: OAuthKey): string { + return `${encodeRFC3986URIComponent(this.consumer.secret)}&${ + encodeRFC3986URIComponent(token.secret) + }` + } + + nonce(): string { + const array = new Uint8Array((this.nonceLength || 32) / 2) + crypto.getRandomValues(array) + return Array.from(array, (dec: number) => dec.toString(16).padStart(2, "0")) + .join("") + } + + timestamp(): string { + return Math.floor(Date.now() / 1000).toString() + } +} + +function encodeRFC3986URIComponent(str: string): string { + return encodeURIComponent(str).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ) +} diff --git a/README.md b/README.md index 45b72a1..3221e82 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ - - -# Magento2 API Wrapper +# Magento 2 API [![License: MIT](https://img.shields.io/npm/l/magento2-api-wrapper)](LICENSE) [![NPM](https://img.shields.io/npm/v/magento2-api-wrapper)](https://www.npmjs.com/package/magento2-api-wrapper) @@ -8,75 +6,96 @@ [![Builds](https://gitlab.com/lumnn/magento2-api-wrapper/badges/master/pipeline.svg)](#) [![Coverage](https://gitlab.com/lumnn/magento2-api-wrapper/badges/master/coverage.svg)](#) -> Small Magento 2 API client that's ready to use and extend +> Small Magento 2 API client that's ready to use. Works in browsers, node and +> Deno. -_Note: Currently only works in Node environment. But, it should be easy to make it -compatible in browsers. Feel free to submit a merge request or contact me._ +- Dependency free +- Works in Browser, node, Deno +- As minimal as it can get -## Install +## Node.js + +### Install ```sh npm install magento2-api-wrapper ``` -## Usage +or + +```sh +npm jsr install @lumnn/magento2-api +``` + +### Usage **As a guest** ```js -const Magento2Api = require('magento2-api-wrapper') +import { Magento2Api } from "magento2-api-wrapper" +// or +// const Magento2Api = require('magento2-api-wrapper') -var consumer = new Magento2Api({ api: { url: 'https://localhost' }}) +var consumer = new Magento2Api({ baseUrl: "https://localhost" }) -consumer.get('directory/countries') - .then(data => console.log) +consumer.$get("directory/countries") + .then((data) => console.log) // or in async functions -var countries = await customer.get('directory/countries') +var countries = await customer.$get("directory/countries") ``` **As a admin/customer** ```js // Api Keys: Magento Admin > System > Extensions > Integration -var admin = new Magento2Api({ api: { - url: 'https://localhost', - consumerKey: 'xxx', - consumerSecret: 'xxx', - accessToken: 'xxx', - tokenSecret: 'xxx', -}}) - -admin.get('products', { +var admin = new Magento2Api({ + baseUrl: "https://localhost", + consumerKey: "xxx", + consumerSecret: "xxx", + accessToken: "xxx", + tokenSecret: "xxx", +}) + +admin.$get("products", { params: { searchCriteria: { currentPage: 1, pageSize: 1, - } - } + }, + }, }) - .then(data => console.log) + .then((data) => console.log) ``` -**Responses:** Successfull response returns plain Magento data. Not wrapped as in Axios. +**Responses:** Successfull response returns plain Magento data. Not wrapped as +in Axios. + +## Deno + +Above examples should be pretty much similar only difference is in how module is +imported + +```ts +import { Magento2Api } from "@lumnn/magento2-api" +``` + +## Methods / Properties + +Basic request method to trigger any kind of request -### Methods / Properties +- `.request(method: string, path: string, data: any, options?: RequestOptions): Promise` -It provides all same methods as Axios: https://github.com/axios/axios#request-method-aliases +Additionally following helper methods are available that simplify the process of +getting JSON data and adding types to responses (supports generics) -- `.axios: Axios` - get axios instance -- `.baseUrl: string` - get base url -- `.getStoreBaseUrl(storeCode: string): string` - gets store scoped base url -- `.request(config): Promise` -- `.get(url: string, config?: Object): Promise` -- `.delete(url: string, config?: Object): Promise` -- `.head(url: string, config?: Object): Promise` -- `.options(url: string, config?: Object): Promise` -- `.post(url: string, data?: Object, config?: Object): Promise` -- `.put(url: string, data?: Object, config?: Object): Promise` -- `.patch(url: string, data?: Object, config?: Object): Promise` +- `.$get(url: string, options?: RequestOptions): Promise` +- `.$delete(url: string, options?: RequestOptions): Promise` +- `.$post(url: string, data: any, options?: RequestOptions): Promise` +- `.$put(url: string, data: any, options?: RequestOptions): Promise` +- `.$patch(url: string, data: any, options?: RequestOptions): Promise` -### Options +## Options **Constructor Options** @@ -85,58 +104,25 @@ It provides all same methods as Axios: https://github.com/axios/axios#request-me - `api.consumerSecret`: `string` - _(optional)_ - for authentication - `api.accessToken`: `string` - _(optional)_ - for authentication - `api.tokenSecret`: `string` - _(optional)_ - for authentication -- `axios`: `Object` - _(optional)_ - extra Axios configuration. May be used for example to -allow self-signed certificates **Method options** -When executing any of the methods like `.get`, `.post` you may use theese extra -axios config options: +When executing any of the methods like `.get`, `.post` you may use extra config +options on top of regular Request config: +- `params`: `object` - an object for easier GET parameter building - `storeCode`: `string` - setting storeCode will change base url so it's like -`https://example.org/rest/{storeCode}/V1/` + `https://example.org/rest/{storeCode}/V1/` -### Errors +## Useful Examples -Errors are thrown in same situations as in Axios. The error has extra property: +### Allowing self-signed certificate -- `magento`: `Object` - A Magento 2 Api instance - -### Interceptors - -Adding interceptors works exactly same as in axios. Well, you'll add them to the -axios instance directly, so the best would be to refer here to axios documentation: https://github.com/axios/axios#interceptors - -```js -const Magento2Api = require('magento2-api-wrapper') - -var testApi = new Magento2Api({ api: { url: 'https://localhost' }}) - -testApi.axios.interceptors.response.use(function (data) { - // on successfull response -}) -``` - -### Useful Examples - -#### Allowing self-signed certificate - -```js -const Magento2Api = require('magento2-api-wrapper') -const https = require('https') - -var selfSignedApi = new Magento2Api({ - api: { url: 'https://localhost' }, - axios: { - httpsAgent: new https.Agent({ - rejectUnauthorized: false - }), - } -}) -``` +Deno: `--unsafely-ignore-certificate-errors=localhost`. +Node: `NODE_TLS_REJECT_UNAUTHORIZED=0` ## Run tests ```sh -npm run test +deno test ``` diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..5afd367 --- /dev/null +++ b/deno.json @@ -0,0 +1,37 @@ +{ + "name": "@lumnn/magento2-api", + "version": "0.0.2", + "exports": "./Magento2Api.ts", + "tasks": { + "dev": "deno run --watch **/*.ts" + }, + "test": { + "files": { + "exclude": ["/test"] + } + }, + "publish": { + "exclude": [ + "./npm", + "./scripts", + "./coverage" + ] + }, + "lint": { + "exclude": [ + "./npm" + ], + "rules": { + "exclude": [ + "no-explicit-any" + ] + } + }, + "fmt": { + "indentWidth": 2, + "semiColons": false, + "exclude": [ + "./npm/" + ] + } +} diff --git a/hash/createHmacHasher.ts b/hash/createHmacHasher.ts new file mode 100644 index 0000000..796a8f2 --- /dev/null +++ b/hash/createHmacHasher.ts @@ -0,0 +1,28 @@ +export function createHmacHasher(hash: string) { + return async function hmacHasher( + key: string, + content: string, + ): Promise { + // encoder to convert string to Uint8Array + const enc = new TextEncoder() + const algo = { name: "HMAC", hash } + + const cryptKey = await crypto.subtle.importKey( + "raw", + enc.encode(key), + algo, + false, + ["sign", "verify"], + ) + + const signature = await crypto.subtle.sign( + algo, + cryptKey, + enc.encode(content), + ) + + const b = new Uint8Array(signature) + // base64 digest + return btoa(String.fromCharCode(...b)) + } +} diff --git a/hash/hmacSha1.ts b/hash/hmacSha1.ts new file mode 100644 index 0000000..a3dd5d7 --- /dev/null +++ b/hash/hmacSha1.ts @@ -0,0 +1,3 @@ +import { createHmacHasher } from "./createHmacHasher.ts" + +export const hmacSha1 = createHmacHasher("SHA-1") diff --git a/hash/hmacSha256.ts b/hash/hmacSha256.ts new file mode 100644 index 0000000..b84cfaf --- /dev/null +++ b/hash/hmacSha256.ts @@ -0,0 +1,3 @@ +import { createHmacHasher } from "./createHmacHasher.ts" + +export const hmacSha256 = createHmacHasher("SHA-256") diff --git a/package.json b/package.json deleted file mode 100644 index 1443d76..0000000 --- a/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "magento2-api-wrapper", - "description": "Basic wrapper for Magento 2 API using Axios", - "keywords": [ - "magento2", - "api", - "ecommerce", - "node", - "javascript" - ], - "homepage": "https://gitlab.com/lumnn/magento2-api-wrapper", - "bugs": { - "url": "https://gitlab.com/lumnn/magento2-api-wrapper/issues" - }, - "repository": "gitlab:lumnn/magento2-api-wrapper", - "main": "src/index.js", - "typings": "src/index.d.ts", - "author": "lumnn", - "scripts": { - "test": "nyc --reporter=text mocha --recursive", - "test-ci": "nyc --reporter=text mocha test/index.js test/request test/response", - "lint": "eslint ." - }, - "license": "MIT", - "nyc": { - "include": [ - "src/**/*.js" - ] - }, - "dependencies": { - "axios": "^0.21.0", - "oauth-1.0a": "^2.2.6", - "qs": "^6.9.4" - }, - "devDependencies": { - "axios-mock-adapter": "^1.19.0", - "eslint": "^7.12.1", - "eslint-config-standard": "^16.0.2", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-node": "^11.1.0", - "mocha": "^8.2.1", - "nyc": "^15.1.0" - } -} diff --git a/scripts/build_npm.ts b/scripts/build_npm.ts new file mode 100644 index 0000000..5161e5b --- /dev/null +++ b/scripts/build_npm.ts @@ -0,0 +1,42 @@ +// ex. scripts/build_npm.ts +import { build, emptyDir } from "https://deno.land/x/dnt/mod.ts" + +await emptyDir("./npm") + +await build({ + entryPoints: ["./Magento2Api.ts"], + outDir: "./npm", + shims: { + // see JS docs for overview and more options + deno: true, + }, + package: { + // package.json properties + name: "magento2-api-wrapper", + author: "lumnn", + version: Deno.args[0], + description: + "Minimal Magento 2 API library. Both node and browser compatible", + license: "MIT", + keywords: [ + "magento2", + "api", + "ecommerce", + "node", + "javascript", + "browser", + ], + repository: { + type: "git", + url: "git+https://gitlab.com/lumnn/magento2-api-wrapper.git", + }, + bugs: { + url: "https://gitlab.com/lumnn/magento2-api-wrapper/issues", + }, + }, + postBuild() { + // steps to run after building and before running the tests + Deno.copyFileSync("LICENSE", "npm/LICENSE") + Deno.copyFileSync("README.md", "npm/README.md") + }, +}) diff --git a/src/index.d.ts b/src/index.d.ts deleted file mode 100644 index 8565ead..0000000 --- a/src/index.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { AxiosInstance, AxiosError, AxiosResponse, AxiosRequestConfig } from 'axios' - -interface Magento2ApiConfig { - url: string; - consumerKey?: string; - consumerSecret?: string; - accessToken?: string; - tokenSecret?: string; -} - -interface Magento2ApiConstructorOptions { - api: Magento2ApiConfig; - axios?: AxiosRequestConfig; -} - -interface Magento2ApiAxiosRequestConfig extends AxiosRequestConfig { - storeCode?: string -} - -interface Magento2ApiAxiosResponse extends AxiosResponse { - magento: Magento2Api -} - -export default class Magento2Api { - constructor (config: Magento2ApiConstructorOptions); - axios: AxiosInstance; - baseUrl: string; - getStoreBaseUrl (storeCode: string): string; - request (config: Magento2ApiAxiosRequestConfig): Promise; - get(url: string, config?: Magento2ApiAxiosRequestConfig): Promise; - delete(url: string, config?: Magento2ApiAxiosRequestConfig): Promise; - head(url: string, config?: Magento2ApiAxiosRequestConfig): Promise; - options(url: string, config?: Magento2ApiAxiosRequestConfig): Promise; - post(url: string, data?: any, config?: Magento2ApiAxiosRequestConfig): Promise; - put(url: string, data?: any, config?: Magento2ApiAxiosRequestConfig): Promise; - patch(url: string, data?: any, config?: Magento2ApiAxiosRequestConfig): Promise; -} - -interface Magento2ApiError extends AxiosError { - magento: Magento2Api; -} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 0cef28a..0000000 --- a/src/index.js +++ /dev/null @@ -1,130 +0,0 @@ -const crypto = require('crypto') -const axios = require('axios') -const Qs = require('qs') -const buildFullPath = require('axios/lib/core/buildFullPath') -const buildURL = require('axios/lib/helpers/buildURL') -const OAuth = require('oauth-1.0a') -const addStoreCodeRequestInterceptor = require('./request/addStoreCode') -const handleResponseError = require('./response/handleError') -const extractData = require('./response/extractData') - -var axiosInstances = new WeakMap() -var oauthInstances = new WeakMap() - -function hashFunctionSha256 (baseString, key) { - return crypto - .createHmac('sha256', key) - .update(baseString) - .digest('base64') -} - -function addOAuthHeaderInterceptor (config) { - if (!this.apiParams.consumerKey) { - return config - } - - if (!oauthInstances.has(this)) { - oauthInstances.set(this, new OAuth({ - consumer: { - key: this.apiParams.consumerKey, - secret: this.apiParams.consumerSecret - }, - signature_method: 'HMAC-SHA256', - hash_function: hashFunctionSha256 - })) - } - - const oauth = oauthInstances.get(this) - const token = { - key: this.apiParams.accessToken, - secret: this.apiParams.tokenSecret - } - - var fullPath = buildFullPath(config.baseURL, decodeURIComponent(config.url)); - fullPath = buildURL(fullPath, config.params, config.paramsSerializer); - - const authorization = oauth.authorize({ - method: config.method.toUpperCase(), - url: fullPath - }, token) - - const header = oauth.toHeader(authorization) - - Object.assign(config.headers.common, header) - - return config -} - -class Magento2Api { - constructor ({ api, axios } = {}) { - if (!api) { - throw new TypeError("Expected object as a parameter") - } - api.apiVersion = api.apiVersion || '1' - api.url = api.url.trim('/') - - this.apiParams = api - this.axiosOptions = axios - } - - get baseUrl () { - return this.apiParams.url + '/rest/V' + this.apiParams.apiVersion + '/' - } - - get axios () { - if (!axiosInstances.has(this)) { - const instance = axios.create({ - ...this.axiosOptions, - baseURL: this.baseUrl, - paramsSerializer: function (params) { - return Qs.stringify(params, { encodeValuesOnly: true }) - }, - }) - instance.interceptors.request.use(addOAuthHeaderInterceptor.bind(this)) - instance.interceptors.request.use(addStoreCodeRequestInterceptor.bind(this)) - instance.interceptors.response.use(extractData, handleResponseError.bind(this)) - - axiosInstances.set(this, instance) - } - - return axiosInstances.get(this) - } - - getStoreBaseUrl (storeCode) { - return this.apiParams.url + '/rest/' + storeCode + '/V' + this.apiParams.apiVersion + '/' - } - - request () { - return this.axios.request.apply(null, arguments) - } - - get () { - return this.axios.get.apply(null, arguments) - } - - delete () { - return this.axios.delete.apply(null, arguments) - } - - head () { - return this.axios.head.apply(null, arguments) - } - - options () { - return this.axios.options.apply(null, arguments) - } - - post () { - return this.axios.post.apply(null, arguments) - } - - put () { - return this.axios.put.apply(null, arguments) - } - - patch () { - return this.axios.patch.apply(null, arguments) - } -} - -module.exports = Magento2Api diff --git a/src/request/addStoreCode.js b/src/request/addStoreCode.js deleted file mode 100644 index d36d09d..0000000 --- a/src/request/addStoreCode.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = function addStoreCodeRequestInterceptor (config) { - if (!config.storeCode) { - return config - } - - config.baseURL = this.getStoreBaseUrl(config.storeCode) - - return config -} diff --git a/src/response/extractData.js b/src/response/extractData.js deleted file mode 100644 index dc73dcf..0000000 --- a/src/response/extractData.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = function extractDataResponseInterceptor (response) { - return response.data -} diff --git a/src/response/handleError.js b/src/response/handleError.js deleted file mode 100644 index a3712d0..0000000 --- a/src/response/handleError.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = function handleError (e) { - e.magento = this - - throw e -} diff --git a/test.config.js b/test.config.js deleted file mode 100644 index 165dd17..0000000 --- a/test.config.js +++ /dev/null @@ -1,13 +0,0 @@ -try { - module.exports = require('./test.config.local.js') -} catch (e) { - module.exports = { - api: { - url: process.env.MAGENTO2API_URL, - consumerKey: process.env.MAGENTO2API_CONSUMER_KEY, - consumerSecret: process.env.MAGENTO2API_CONSUMER_SECRET, - accessToken: process.env.MAGENTO2API_ACCESS_TOKEN, - tokenSecret: process.env.MAGENTO2API_TOKEN_SECRET, - } - } -} diff --git a/test/OAuth_test.ts b/test/OAuth_test.ts new file mode 100644 index 0000000..91da985 --- /dev/null +++ b/test/OAuth_test.ts @@ -0,0 +1,125 @@ +import { assertEquals } from "https://deno.land/std@0.217.0/assert/mod.ts" +import { OAuth } from "../OAuth.ts" + +Deno.test("authorize()", async (t) => { + const consumer = { + key: "1434affd-4d69-4a1a-bace-cc5c6fe493bc", + secret: "932a216f-fb94-43b6-a2d2-e9c6b345cbea", + } + const oauth = new OAuth({ consumer }) + + // for consistent test results + const timestamp = "1318622958" + oauth.timestamp = function () { + return timestamp + } + + // for consistent test results + const nonce = "kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg" + oauth.nonce = function () { + return nonce + } + + const token = { + key: "370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb", + secret: "LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE", + } + + const request = { + url: "https://api.twitter.com/1/statuses/update.json?include_entities=true", + method: "POST", + } + + await t.step("without realm", async () => { + assertEquals( + await oauth.authorize(request, token), + `OAuth ` + [ + `oauth_consumer_key="${consumer.key}"`, + `oauth_nonce="${nonce}"`, + `oauth_signature="${consumer.secret}%26${token.secret}"`, + `oauth_signature_method="PLAINTEXT"`, + `oauth_timestamp="${timestamp}"`, + `oauth_token="${token.key}"`, + `oauth_version="1.0"`, + ].join(","), + ) + }) + + await t.step("with realm", async () => { + oauth.realm = "https://example.org" + + assertEquals( + await oauth.authorize(request, token), + `OAuth ` + [ + `realm="${oauth.realm}"`, + `oauth_consumer_key="${consumer.key}"`, + `oauth_nonce="${nonce}"`, + `oauth_signature="${consumer.secret}%26${token.secret}"`, + `oauth_signature_method="PLAINTEXT"`, + `oauth_timestamp="${timestamp}"`, + `oauth_token="${token.key}"`, + `oauth_version="1.0"`, + ].join(","), + ) + }) +}) + +Deno.test("authorize() 2", async () => { + const consumer = { + key: "abcd", + secret: "efgh", + } + + const oauth = new OAuth({ + consumer, + signatureMethod: "PLAINTEXT", + }) + + // for consistent test results + const timestamp = "1462028868" + oauth.timestamp = function () { + return timestamp + } + + // for consistent test results + const nonce = "FDRMnsTvyF1" + oauth.nonce = function () { + return nonce + } + + const token = { + key: "ijkl", + secret: "mnop", + } + + const request = { + url: "http://host.net/resource", + method: "GET", + } + + assertEquals( + await oauth.authorize(request, token), + `OAuth oauth_consumer_key="abcd",oauth_nonce="FDRMnsTvyF1",oauth_signature="efgh%26mnop",oauth_signature_method="PLAINTEXT",oauth_timestamp="1462028868",oauth_token="ijkl",oauth_version="1.0"`, + ) +}) + +Deno.test("nonce() generates random string", () => { + const oauth = new OAuth({ + consumer: { key: "test", secret: "test" }, + }) + + const nonce = oauth.nonce() + console.log(nonce) + assertEquals(nonce.length, 32, "length to be 32 chars by default") +}) + +Deno.test("nonce() size can be changed", () => { + const oauth = new OAuth({ + consumer: { key: "test", secret: "test" }, + nonceLength: 16, + }) + + const nonce = oauth.nonce() + console.log(nonce) + assertEquals(nonce.length, 16, "length to be 32 chars by default") +}) diff --git a/test/adminEndpoints.js b/test/adminEndpoints.js deleted file mode 100644 index 411b3e0..0000000 --- a/test/adminEndpoints.js +++ /dev/null @@ -1,25 +0,0 @@ -const testConfig = require('../test.config.js') -const Magento2Api = require('../src/index') -const assert = require('assert') - -describe("Real database - Admin Endpoints", function () { - var api = new Magento2Api(testConfig) - - it('works with admin endpoints', async function () { - var response = await api.axios.get('store/storeConfigs') - assert.ok(response) - }) - - it('correctly signs GET requests with parameters', async function () { - var response = await api.get('products', { - params: { - searchCriteria: { - currentPage: 1, - pageSize: 1, - } - } - }) - - assert.ok(response) - }) -}) diff --git a/test/e2e/loadConfig.ts b/test/e2e/loadConfig.ts new file mode 100644 index 0000000..92ece61 --- /dev/null +++ b/test/e2e/loadConfig.ts @@ -0,0 +1,20 @@ +import { MagentoApiOptions } from "../../Magento2Api.ts" +import { load } from "https://deno.land/std@0.218.2/dotenv/mod.ts" + +export async function loadConfig(): Promise { + await load({ export: true }) + + const url = Deno.env.get("M2_TEST_URL") + + if (!url) { + throw new Error("Test server not configured") + } + + return { + url, + consumerKey: Deno.env.get("M2_TEST_CONSUMER_KEY"), + consumerSecret: Deno.env.get("M2_TEST_CONSUMER_SECRET"), + accessToken: Deno.env.get("M2_TEST_ACCESS_TOKEN"), + tokenSecret: Deno.env.get("M2_TEST_TOKEN_SECRET"), + } +} diff --git a/test/e2e/localhostAdmin_test.ts b/test/e2e/localhostAdmin_test.ts new file mode 100644 index 0000000..851726b --- /dev/null +++ b/test/e2e/localhostAdmin_test.ts @@ -0,0 +1,39 @@ +import { assertEquals } from "https://deno.land/std@0.217.0/assert/assert_equals.ts" +import { Magento2Api } from "../../Magento2Api.ts" +import { loadConfig } from "./loadConfig.ts" + +Deno.test("localhost - admin", async (t) => { + let config + try { + config = await loadConfig() + } catch (e) { + console.log(`skipped - ${e}`) + return + } + + console.log(config.url) + const magento = new Magento2Api({ + ...config, + }) + + await t.step("GETs without params", async () => { + const response = await magento.request("GET", "store/storeConfigs") + + console.log(await response.json()) + assertEquals(response.status, 200) + }) + + await t.step("GETs with params", async () => { + const response = await magento.request("GET", "products", null, { + params: { + searchCriteria: { + currentPage: 1, + pageSize: 1, + }, + }, + }) + + console.log(await response.json()) + assertEquals(response.status, 200) + }) +}) diff --git a/test/e2e/localhostGuest_test.ts b/test/e2e/localhostGuest_test.ts new file mode 100644 index 0000000..c6eb8da --- /dev/null +++ b/test/e2e/localhostGuest_test.ts @@ -0,0 +1,23 @@ +import { assertEquals } from "https://deno.land/std@0.217.0/assert/assert_equals.ts" +import { Magento2Api } from "../../Magento2Api.ts" +import { loadConfig } from "./loadConfig.ts" + +Deno.test("localhost - guest", async () => { + let config + try { + config = await loadConfig() + } catch (e) { + console.log(`skipped - ${e}`) + return + } + + console.log(config.url) + const magento = new Magento2Api({ + url: config.url, + }) + + const response = await magento.request("GET", "directory/countries") + + assertEquals(response.status, 200) + console.log(await response.json()) +}) diff --git a/test/guestEndpoints.js b/test/guestEndpoints.js deleted file mode 100644 index 69a7994..0000000 --- a/test/guestEndpoints.js +++ /dev/null @@ -1,11 +0,0 @@ -const Magento2Api = require('../src/index') -const assert = require('assert') - -describe("Real database - Guest Endpoints", function () { - var api = new Magento2Api({ api: { url: 'https://localhost' }}) - - it('works with guest endpoints', async function () { - var response = await api.get('directory/countries') - assert.ok(response) - }) -}) diff --git a/test/hash/hmacSha256_test.ts b/test/hash/hmacSha256_test.ts new file mode 100644 index 0000000..e0d4680 --- /dev/null +++ b/test/hash/hmacSha256_test.ts @@ -0,0 +1,9 @@ +import { assertEquals } from "https://deno.land/std@0.217.0/assert/assert_equals.ts" +import { hmacSha256 } from "../../hash/hmacSha256.ts" + +Deno.test("hmacSha256()", async () => { + assertEquals( + await hmacSha256("key", "data"), + "UDH+PZicbRU3oBP6bnOdojRj/a7DtwE32Cjjas4iG9A=", + ) +}) diff --git a/test/index.js b/test/index.js deleted file mode 100644 index 2dfc7bf..0000000 --- a/test/index.js +++ /dev/null @@ -1,147 +0,0 @@ -const assert = require('assert') -const Magento2Api = require('../src/index') -const MockAdapter = require('axios-mock-adapter') - -describe('Magento2Api Instance', function () { - it("won't instantiate work with empty api object", async function () { - assert.throws( - () => new Magento2Api() - ) - - assert.throws( - () => new Magento2Api({}) - ) - }) - - it("instantiate with just url", async function () { - var api = new Magento2Api({ api: { url: 'https://localhost' }}) - assert.ok(api) - }) - - it('gets axios', function () { - var api = new Magento2Api({ api: { url: 'https://localhost' }}) - assert.ok(api.axios) - }) - - it('instantiates with all details', function () { - var api = new Magento2Api({ api: { - url: 'https://localhost', - consumerKey: 'probably', - consumerSecret: 'this', - accessToken: 'wont', - tokenSecret: 'work', - }}) - assert.ok(api) - }) - - it('correctly encodes parameter values', async function () { - var api = new Magento2Api({ api: { url: 'https://localhost' }}) - - var uri = api.axios.getUri({ - method: "GET", - url: "non-existent-path", - params: { - test: '/', - other: [ 'brackets', '[]' ], - } - }) - - assert.strictEqual( - uri, - 'non-existent-path?test=%2F&other[0]=brackets&other[1]=%5B%5D' - ) - }) - - it('authenticates', async function () { - var api = new Magento2Api({ api: { - url: 'https://localhost', - consumerKey: 'probably', - consumerSecret: 'this', - accessToken: 'wont', - tokenSecret: 'work', - }}) - var mock = new MockAdapter(api.axios) - mock.onAny().reply(500) - - try { - await api.post('/test') - } catch (e) { - assert(e.config.headers.Authorization) - assert.match(e.config.headers.Authorization, /^OAuth oauth_consumer_key="probably"/) - assert.match(e.config.headers.Authorization, /oauth_signature_method="HMAC-SHA256"/) - assert.match(e.config.headers.Authorization, /oauth_version="1\.0"/) - - return - } - - assert.fail("The request in this test should fail") - }) - - it('adds magento api to error object', async function () { - var api = new Magento2Api({ api: { - url: 'https://localhost', - }}) - var mock = new MockAdapter(api.axios) - mock.onAny().reply(500) - - try { - await api.get('') - } catch (e) { - assert.strictEqual(e.magento, api) - return - } - - assert.fail("The request in this test should fail") - }) -}) - -describe('Magento2Api instance requests', async function () { - var api = new Magento2Api({ api: { url: 'https://localhost' }}) - var mock = new MockAdapter(api.axios) - var data = { - test: 1, - two: 2 - } - - mock.onAny('https://localhost/rest/V1/test').reply(200, data) - mock.onAny().reply(() => { - assert.fail("Incorrect request was made") - }) - - it('offers requests', async function () { - var response = await api.request({ - 'method': 'get', - 'url': '/test' - }) - - assert.deepStrictEqual(data, response) - }) - - it('offers get request', async function () { - await api.get('test') - }) - - it('offers delete request', async function () { - await api.delete('test') - }) - - it('offers head request', async function () { - await api.head('test') - }) - - it('offers options request', async function () { - await api.options('test') - }) - - it('offers post request', async function () { - await api.post('test') - }) - - it('offers put request', async function () { - await api.put('test') - }) - - it('offers patch request', async function () { - await api.patch('test') - }) -}) diff --git a/test/request/addStoreCode.js b/test/request/addStoreCode.js deleted file mode 100644 index fce661f..0000000 --- a/test/request/addStoreCode.js +++ /dev/null @@ -1,21 +0,0 @@ -const assert = require('assert') -const addStoreCode = require('../../src/request/addStoreCode') -const Magento2Api = require('../../src/index') - -describe('Add Store Code Request Interceptor', function () { - var api = new Magento2Api({ api: { url: 'https://localhost' }}) - - it("doesn't change config if no store code passed", function () { - var config = addStoreCode.call(api, {}) - - assert.strictEqual(config.baseURL, undefined) - }) - - it("sets baseURL to store scoped", function() { - var config = addStoreCode.call(api, { - storeCode: 'test' - }) - - assert.strictEqual(config.baseURL, 'https://localhost/rest/test/V1/') - }) -}) diff --git a/test/response/extractData.js b/test/response/extractData.js deleted file mode 100644 index c1ed7f6..0000000 --- a/test/response/extractData.js +++ /dev/null @@ -1,16 +0,0 @@ -const assert = require('assert') -const extractData = require('../../src/response/extractData') - -describe('Extract Data Response Interceptor', function () { - it("extracts data property from object", function() { - var responseBody = {} - - var response = { - data: responseBody - } - - var extracted = extractData(response) - - assert.strictEqual(extracted, responseBody, "extracted data is not same as passed object") - }) -}) diff --git a/test/response/handleError.js b/test/response/handleError.js deleted file mode 100644 index 9213166..0000000 --- a/test/response/handleError.js +++ /dev/null @@ -1,14 +0,0 @@ -const assert = require('assert') -const Magento2Api = require('../../src/index') - -describe('Error Response Interceptor', function () { - it("adds magento instnace to error", async function() { - // some inexistent url should be here. Hope its enough for test purposes - var api = new Magento2Api({ api: { url: 'https://localhost:15505' }}) - try { - await api.get('/inexistent') - } catch (e) { - assert(e.magento === api, 'Error .magento property must be same instance as api instance') - } - }) -}) diff --git a/test/util/flatten_test.ts b/test/util/flatten_test.ts new file mode 100644 index 0000000..44e9ab4 --- /dev/null +++ b/test/util/flatten_test.ts @@ -0,0 +1,68 @@ +import { assertObjectMatch } from "https://deno.land/std@0.217.0/assert/assert_object_match.ts" +import { flatten } from "../../util/flatten.ts" + +Deno.test("flatten() to plain objects", () => { + assertObjectMatch( + flatten({ foo: "bar", test: true }), + { + foo: "bar", + test: true, + }, + ) +}) + +Deno.test("flatten() to handle arrays", () => { + assertObjectMatch( + flatten({ foo: [0, 1, 2] }), + { + "foo[0]": 0, + "foo[1]": 1, + "foo[2]": 2, + }, + ) +}) + +Deno.test("flatten() to handle objects", () => { + assertObjectMatch( + flatten({ foo: { bar: 1, baz: false } }), + { + "foo[bar]": 1, + "foo[baz]": false, + }, + ) +}) + +Deno.test("flatten() to handle deeper nest", () => { + const flat = flatten({ foo: { bar: { baz: { bor: "box" } } } }) + + assertObjectMatch( + flat, + { + "foo[bar][baz][bor]": "box", + }, + JSON.stringify(flat), + ) +}) + +Deno.test("flatten() to handle mixed nest", () => { + const flat = flatten({ + foo: { + bar: { + baz: { bor: "box" }, + test: ["zero", "one"], + another: false, + }, + }, + }) + + assertObjectMatch( + flat, + { + "foo[bar][baz][bor]": "box", + "foo[bar][test][0]": "zero", + "foo[bar][test][1]": "one", + "foo[bar][another]": false, + }, + JSON.stringify(flat), + ) +}) diff --git a/util/flatten.ts b/util/flatten.ts new file mode 100644 index 0000000..c722821 --- /dev/null +++ b/util/flatten.ts @@ -0,0 +1,43 @@ +/** + * Flattens object/array to an object which keys are nested using square brackets + */ +export function flatten( + data: { [name: string]: any }, +): { [name: string]: any } { + const flatten = flattenToArray(data) + + return Object.fromEntries(flatten.map((v) => [ + Array.isArray(v[0]) + ? (v[0].shift() + (v[0].map((p) => `[${p}]`)).join("")) + : v[0], + v[1], + ])) +} + +function flattenToArray( + data: { [name: string]: any }, +): [string | string[], any][] { + const flat: [string | string[], any][] = [] + + for (const name in data) { + const value = data[name] + + // copy non nested values + if (typeof value !== "object") { + flat.push([name, value]) + continue + } + + const flatValues = flattenToArray(value) + .map((v) => { + return [ + Array.isArray(v[0]) ? [name, ...v[0]] : [name, v[0]], + v[1], + ] as [string[], any] + }) + + flat.push(...flatValues) + } + + return flat +} -- GitLab From c6254476b1b01988679f5f67dac1f2cf038a0301 Mon Sep 17 00:00:00 2001 From: Kamil Date: Tue, 5 Mar 2024 10:59:01 +0100 Subject: [PATCH 2/6] Add fetch method to allow customizations --- Magento2Api.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Magento2Api.ts b/Magento2Api.ts index 7f6b15e..03b2179 100644 --- a/Magento2Api.ts +++ b/Magento2Api.ts @@ -5,9 +5,13 @@ import { flatten } from "./util/flatten.ts" export class Magento2Api { oauth: undefined|OAuth options: MagentoApiOptions + requestMiddleware: ((request: Request) => any)[] + responseMiddleware: ((response: Response, error?: Error) => any)[] constructor(options: MagentoApiOptions) { this.options = options + this.requestMiddleware = [] + this.responseMiddleware = [] if (options.url.endsWith("/")) { options.url = options.url.replace(/\/+$/, '') @@ -26,6 +30,13 @@ export class Magento2Api { async request(method: string, path: string, data?: any, options?: RequestOptions): Promise { const request = await this.buildRequest(method, path, data, options) + return this.fetch(request) + } + + /** + * Internal method that could be overriden to decorate more response / reqest / add exception etc. + */ + fetch(request: Request): Promise { return fetch(request) } @@ -82,4 +93,4 @@ export type MagentoApiOptions = { type RequestOptions = RequestInit & { params?: any, storeCode?: string, -} \ No newline at end of file +} -- GitLab From d06a6f4e166eb6576b2ad50e5e7ba4d5b50d8cf5 Mon Sep 17 00:00:00 2001 From: Kamil Date: Tue, 5 Mar 2024 12:45:26 +0100 Subject: [PATCH 3/6] Correct issue with signing encoded requests --- Magento2Api.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Magento2Api.ts b/Magento2Api.ts index 03b2179..bf43275 100644 --- a/Magento2Api.ts +++ b/Magento2Api.ts @@ -18,7 +18,7 @@ export class Magento2Api { } if (options.consumerKey && options.consumerSecret) { - this.oauth = new OAuth({ + this.oauth = new MagentoOAuth({ consumer: { key: options.consumerKey, secret: options.consumerSecret }, signatureMethod: 'HMAC-SHA256', hashMethods: { @@ -94,3 +94,10 @@ type RequestOptions = RequestInit & { params?: any, storeCode?: string, } + +class MagentoOAuth extends OAuth { + // avoids few sign request issues + constructRequestUrl(request: Pick): string { + return decodeURIComponent(super.constructRequestUrl(request)) + } +} -- GitLab From 8391c5d18a960a3312354bb8d64f8745e51888bc Mon Sep 17 00:00:00 2001 From: Kamil Date: Tue, 5 Mar 2024 15:31:34 +0100 Subject: [PATCH 4/6] Correct signature base string building and add few tests --- Magento2Api.ts | 2 +- OAuth.ts | 50 ++++++++++++++++++++++++++++++---------------- test/OAuth_test.ts | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/Magento2Api.ts b/Magento2Api.ts index bf43275..9c67ce5 100644 --- a/Magento2Api.ts +++ b/Magento2Api.ts @@ -98,6 +98,6 @@ type RequestOptions = RequestInit & { class MagentoOAuth extends OAuth { // avoids few sign request issues constructRequestUrl(request: Pick): string { - return decodeURIComponent(super.constructRequestUrl(request)) + return super.constructRequestUrl(request).replace(/%2F/g, '/') } } diff --git a/OAuth.ts b/OAuth.ts index 25153de..6da9ff4 100644 --- a/OAuth.ts +++ b/OAuth.ts @@ -19,6 +19,15 @@ type OAuthKey = { type OAuthHashFn = (key: string, content: string) => Promise +type OAuthData = { + oauth_consumer_key: string + oauth_nonce: string + oauth_signature_method: string + oauth_timestamp: string + oauth_token: string + oauth_version: string +} + /** * A OAuth 1.0a implementation, planned for Magento 2 usage. * @@ -61,32 +70,18 @@ export class OAuth { oauth_version: this.version, } - const signingParams: Iterable<[string, string]> = [ - ...Object.entries(oauthData), - ...(await this.collectRequestParams(request)), - ] - .sort((a, b) => a[0].localeCompare(b[0])) - .map((p) => [p[0], p[1] = encodeRFC3986URIComponent(p[1])]) - - const signedData = [ - request.method, - this.constructRequestUrl(request), - (new URLSearchParams(signingParams)).toString(), - ] - .filter((p) => p) - .map(encodeRFC3986URIComponent) - .join("&") - const signatureMethod = oauthData.oauth_signature_method if (!signatureMethod || !this.hashMethods[signatureMethod]) { throw new Error(`Unknown signing method: ${signatureMethod}`) } + const signatureBaseString = await this.getSignatureBaseString(request, oauthData) + const oauth = Object.entries(oauthData) oauth.push([ "oauth_signature", - await this.hashMethods[signatureMethod](this.hashKey(token), signedData), + await this.hashMethods[signatureMethod](this.hashKey(token), signatureBaseString), ]) oauth.sort((a, b) => a[0].localeCompare(b[0])) @@ -108,6 +103,26 @@ export class OAuth { return auth } + async getSignatureBaseString( + request: OAuthRequest, + oauthData: OAuthData, + ): Promise { + const signingParams: string[][] = [ + ...Object.entries(oauthData), + ...(await this.collectRequestParams(request)), + ] + .sort((a, b) => a[0].localeCompare(b[0])) + + return [ + request.method, + this.constructRequestUrl(request), + (new URLSearchParams(signingParams)).toString(), + ] + .filter((p) => p) + .map(encodeRFC3986URIComponent) + .join("&") + } + /** * Collects request params used for signing in a entries array format */ @@ -147,6 +162,7 @@ export class OAuth { constructRequestUrl(request: Pick): string { const url = new URL(request.url) url.search = "" + url.hash = "" return url.toString() } diff --git a/test/OAuth_test.ts b/test/OAuth_test.ts index 91da985..a1aa941 100644 --- a/test/OAuth_test.ts +++ b/test/OAuth_test.ts @@ -123,3 +123,41 @@ Deno.test("nonce() size can be changed", () => { console.log(nonce) assertEquals(nonce.length, 16, "length to be 32 chars by default") }) + +const oauthSignatureTest = new OAuth({ consumer: { key: "abcd", secret: "efgh" }}) + +const oauthData = { + oauth_consumer_key: "abcd", + oauth_nonce: "6MDdBa31nmI", + oauth_signature_method: "PLAINTEXT", + oauth_timestamp: "1462028641", + oauth_token: "ijkl", + oauth_version: "1.0" +} + +Deno.test("getSignatureBaseString() to build valid signature string", async () => { + const signatureBase = await oauthSignatureTest.getSignatureBaseString({ + url: 'http://host.net/resource?test[1]=false', + method: 'GET' + }, oauthData) + + assertEquals(signatureBase, "GET&http%3A%2F%2Fhost.net%2Fresource&oauth_consumer_key%3Dabcd%26oauth_nonce%3D6MDdBa31nmI%26oauth_signature_method%3DPLAINTEXT%26oauth_timestamp%3D1462028641%26oauth_token%3Dijkl%26oauth_version%3D1.0%26test%255B1%255D%3Dfalse") +}) + +Deno.test("getSignatureBaseString() to build valid signature string when hash passed", async () => { + const signatureBase = await oauthSignatureTest.getSignatureBaseString({ + url: 'http://host.net/resource?test[1]=false#hash', + method: 'GET' + }, oauthData) + + assertEquals(signatureBase, "GET&http%3A%2F%2Fhost.net%2Fresource&oauth_consumer_key%3Dabcd%26oauth_nonce%3D6MDdBa31nmI%26oauth_signature_method%3DPLAINTEXT%26oauth_timestamp%3D1462028641%26oauth_token%3Dijkl%26oauth_version%3D1.0%26test%255B1%255D%3Dfalse") +}) + +Deno.test("getSignatureBaseString() to build valid signature string with query string passed with containing encoded params", async () => { + const signatureBase = await oauthSignatureTest.getSignatureBaseString({ + url: 'https://localhost/rest/V1/products?fields=items%5Bsku%5D&searchCriteria%5Bpage_size%5D=1&searchCriteria%5Bfilter_groups%5D%5B0%5D%5Bfilters%5D%5B0%5D%5Bfield%5D=entity_id&searchCriteria%5Bfilter_groups%5D%5B0%5D%5Bfilters%5D%5B0%5D%5Bvalue%5D=88826&searchCriteria%5Bfilter_groups%5D%5B0%5D%5Bfilters%5D%5B0%5D%5Bcondition_type%5D=eq', + method: 'GET' + }, oauthData) + + assertEquals(signatureBase, "GET&https%3A%2F%2Flocalhost%2Frest%2FV1%2Fproducts&fields%3Ditems%255Bsku%255D%26oauth_consumer_key%3Dabcd%26oauth_nonce%3D6MDdBa31nmI%26oauth_signature_method%3DPLAINTEXT%26oauth_timestamp%3D1462028641%26oauth_token%3Dijkl%26oauth_version%3D1.0%26searchCriteria%255Bfilter_groups%255D%255B0%255D%255Bfilters%255D%255B0%255D%255Bcondition_type%255D%3Deq%26searchCriteria%255Bfilter_groups%255D%255B0%255D%255Bfilters%255D%255B0%255D%255Bfield%255D%3Dentity_id%26searchCriteria%255Bfilter_groups%255D%255B0%255D%255Bfilters%255D%255B0%255D%255Bvalue%255D%3D88826%26searchCriteria%255Bpage_size%255D%3D1") +}) -- GitLab From f5c8f2135ca990458a575e06a96352381a54be18 Mon Sep 17 00:00:00 2001 From: Kamil Date: Thu, 7 Mar 2024 22:07:14 +0100 Subject: [PATCH 5/6] Correct signing requests with query string having special characters --- Magento2Api.ts | 4 +++- OAuth.ts | 11 ++++++----- test/OAuth_test.ts | 9 +++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Magento2Api.ts b/Magento2Api.ts index 9c67ce5..7434983 100644 --- a/Magento2Api.ts +++ b/Magento2Api.ts @@ -50,7 +50,9 @@ export class Magento2Api { path = `/rest/${storeCode}V${version}/${path}` } - const query = new URLSearchParams(flatten(options?.params)).toString() + const query = options?.params + ? Object.entries(flatten(options.params)).map(p => `${encodeURIComponent(p[0])}=${encodeURIComponent(p[1])}`).join('&') + : null const url = this.options.url + path + (query ? `?${query}` : '') const body = data ? JSON.stringify(data) : null diff --git a/OAuth.ts b/OAuth.ts index 6da9ff4..357982b 100644 --- a/OAuth.ts +++ b/OAuth.ts @@ -116,7 +116,7 @@ export class OAuth { return [ request.method, this.constructRequestUrl(request), - (new URLSearchParams(signingParams)).toString(), + signingParams.map(p => `${encodeURIComponent(p[0])}=${encodeURIComponent(p[1])}`).join('&'), ] .filter((p) => p) .map(encodeRFC3986URIComponent) @@ -186,8 +186,9 @@ export class OAuth { } function encodeRFC3986URIComponent(str: string): string { - return encodeURIComponent(str).replace( - /[!'()*]/g, - (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, - ) + return encodeURIComponent(str) + .replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ) } diff --git a/test/OAuth_test.ts b/test/OAuth_test.ts index a1aa941..3533802 100644 --- a/test/OAuth_test.ts +++ b/test/OAuth_test.ts @@ -161,3 +161,12 @@ Deno.test("getSignatureBaseString() to build valid signature string with query s assertEquals(signatureBase, "GET&https%3A%2F%2Flocalhost%2Frest%2FV1%2Fproducts&fields%3Ditems%255Bsku%255D%26oauth_consumer_key%3Dabcd%26oauth_nonce%3D6MDdBa31nmI%26oauth_signature_method%3DPLAINTEXT%26oauth_timestamp%3D1462028641%26oauth_token%3Dijkl%26oauth_version%3D1.0%26searchCriteria%255Bfilter_groups%255D%255B0%255D%255Bfilters%255D%255B0%255D%255Bcondition_type%255D%3Deq%26searchCriteria%255Bfilter_groups%255D%255B0%255D%255Bfilters%255D%255B0%255D%255Bfield%255D%3Dentity_id%26searchCriteria%255Bfilter_groups%255D%255B0%255D%255Bfilters%255D%255B0%255D%255Bvalue%255D%3D88826%26searchCriteria%255Bpage_size%255D%3D1") }) + +Deno.test("getSignatureBaseString() to work correctly with query string containing pluses", async () => { + const signatureBase = await oauthSignatureTest.getSignatureBaseString({ + url: 'https://localhost?test=+123+', + method: 'GET' + }, oauthData) + + assertEquals(signatureBase, "GET&https%3A%2F%2Flocalhost%2F&oauth_consumer_key%3Dabcd%26oauth_nonce%3D6MDdBa31nmI%26oauth_signature_method%3DPLAINTEXT%26oauth_timestamp%3D1462028641%26oauth_token%3Dijkl%26oauth_version%3D1.0%26test%3D%2520123%2520") +}) -- GitLab From 5d24d1ba8c7d85e8266b289a27d24066e8c2cc58 Mon Sep 17 00:00:00 2001 From: Kamil Date: Thu, 14 Mar 2024 18:30:24 +0100 Subject: [PATCH 6/6] Correct method shortcut --- Magento2Api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Magento2Api.ts b/Magento2Api.ts index 7434983..dfb3978 100644 --- a/Magento2Api.ts +++ b/Magento2Api.ts @@ -78,7 +78,7 @@ export class Magento2Api { async $get(path: string, options?: RequestOptions): Promise { return (await this.request('get', path, null, options)).json() as Promise } async $post(path: string, data: any, options?: RequestOptions): Promise { return (await this.request('post', path, data, options)).json() as Promise } async $patch(path: string, data: any, options?: RequestOptions): Promise { return (await this.request('patch', path, data, options)).json() as Promise } - async $put(path: string, data: any, options?: RequestOptions): Promise { return (await this.request('patch', path, data, options)).json() as Promise } + async $put(path: string, data: any, options?: RequestOptions): Promise { return (await this.request('put', path, data, options)).json() as Promise } async $delete(path: string, options?: RequestOptions): Promise { return (await this.request('delete', path, null, options)).json() as Promise } } -- GitLab