diff --git a/packages/rest-client/.npmignore b/packages/rest-client/.npmignore new file mode 100644 index 000000000..987e688aa --- /dev/null +++ b/packages/rest-client/.npmignore @@ -0,0 +1,7 @@ +__tests__ +src/ +coverage/ +node_modules +.npmignore +tsconfig.json +yarn.lock \ No newline at end of file diff --git a/packages/rest-client/README.md b/packages/rest-client/README.md new file mode 100644 index 000000000..e4dbd96ed --- /dev/null +++ b/packages/rest-client/README.md @@ -0,0 +1,12 @@ +# @accounts/rest-client + +[![npm](https://img.shields.io/npm/v/@accounts/rest-client.svg?maxAge=2592000)](https://www.npmjs.com/package/@accounts/rest-client) +[![CircleCI](https://circleci.com/gh/accounts-js/rest.svg?style=shield)](https://circleci.com/gh/accounts-js/rest) +[![codecov](https://codecov.io/gh/accounts-js/rest/branch/master/graph/badge.svg)](https://codecov.io/gh/accounts-js/rest) +![MIT License](https://img.shields.io/badge/license-MIT-blue.svg) + +## Install + +``` +yarn add @accounts/rest-client +``` diff --git a/packages/rest-client/__tests__/auth-fetch.ts b/packages/rest-client/__tests__/auth-fetch.ts new file mode 100644 index 000000000..c890a7b1d --- /dev/null +++ b/packages/rest-client/__tests__/auth-fetch.ts @@ -0,0 +1,51 @@ +import fetch from 'node-fetch'; +import { authFetch } from '../src/auth-fetch'; + +window.fetch = jest.fn().mockImplementation(() => ({ + status: 200, + json: jest.fn().mockImplementation(() => ({ test: 'test' })), +})); + +describe('authFetch', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call fetch', async () => { + const accounts = { + refreshSession: jest.fn(() => Promise.resolve()), + tokens: jest.fn(() => Promise.resolve({})), + }; + await authFetch(accounts, 'path', {}); + expect(accounts.refreshSession).toBeCalled(); + expect(accounts.tokens).toBeCalled(); + }); + + it('should set access token header', async () => { + const accounts = { + refreshSession: jest.fn(() => Promise.resolve()), + tokens: jest.fn(() => Promise.resolve({ accessToken: 'accessToken' })), + }; + await authFetch(accounts, 'path', {}); + expect(accounts.refreshSession).toBeCalled(); + expect(accounts.tokens).toBeCalled(); + expect(window.fetch.mock.calls[0][1].headers['accounts-access-token']).toBe( + 'accessToken' + ); + }); + + it('should pass other headers', async () => { + const accounts = { + refreshSession: jest.fn(() => Promise.resolve()), + tokens: jest.fn(() => Promise.resolve({ accessToken: 'accessToken' })), + }; + await authFetch(accounts, 'path', { + headers: { + toto: 'toto', + }, + }); + expect(accounts.refreshSession).toBeCalled(); + expect(accounts.tokens).toBeCalled(); + expect(window.fetch.mock.calls[0][1].headers.toto).toBe('toto'); + }); +}); diff --git a/packages/rest-client/__tests__/rest-client.ts b/packages/rest-client/__tests__/rest-client.ts new file mode 100644 index 000000000..49a95e6fe --- /dev/null +++ b/packages/rest-client/__tests__/rest-client.ts @@ -0,0 +1,251 @@ +import fetch from 'node-fetch'; +import { RestClient } from '../src/rest-client'; + +window.fetch = jest.fn().mockImplementation(() => ({ + status: 200, + json: jest.fn().mockImplementation(() => ({ test: 'test' })), +})); + +window.Headers = fetch.Headers; + +describe('RestClient', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have a way to configure api host address and root path', () => { + const client = new RestClient({ + apiHost: 'http://localhost:3000/', + rootPath: 'accounts', + }); + + expect(client.options.apiHost).toBe('http://localhost:3000/'); + expect(client.options.rootPath).toBe('accounts'); + + return client.fetch('try').then(() => { + expect(window.fetch.mock.calls[0][0]).toBe( + 'http://localhost:3000/accounts/try' + ); + }); + }); + + describe('fetch', () => { + const client = new RestClient({ + apiHost: 'http://localhost:3000/', + rootPath: 'accounts', + }); + + it('should enable custom headers', () => + client + .fetch('route', {}, { origin: 'localhost:3000' }) + .then(() => + expect(window.fetch.mock.calls[0][1].headers.origin).toBe( + 'localhost:3000' + ) + )); + + it('should throw error', async () => { + window.fetch = jest.fn().mockImplementation(() => ({ + status: 400, + json: jest.fn().mockImplementation(() => ({ test: 'test' })), + })); + + try { + await client.fetch('route', {}, { origin: 'localhost:3000' }); + throw new Error(); + } catch (err) { + expect(window.fetch.mock.calls[0][1].headers.origin).toBe( + 'localhost:3000' + ); + } + window.fetch = jest.fn().mockImplementation(() => ({ + status: 200, + json: jest.fn().mockImplementation(() => ({ test: 'test' })), + })); + }); + + it('should throw if server did not return a response', async () => { + window.fetch = jest.fn().mockImplementation(() => null); + + try { + await client.fetch('route', {}, { origin: 'localhost:3000' }); + throw new Error(); + } catch (err) { + expect(window.fetch.mock.calls[0][1].headers.origin).toBe( + 'localhost:3000' + ); + expect(err.message).toBe('Server did not return a response'); + } + window.fetch = jest.fn().mockImplementation(() => ({ + status: 200, + json: jest.fn().mockImplementation(() => ({ test: 'test' })), + })); + }); + }); + + describe('loginWithService', () => { + const client = new RestClient({ + apiHost: 'http://localhost:3000', + rootPath: '/accounts', + }); + + it('should call fetch with authenticate path', async () => { + await client.loginWithService('password', { + user: { + username: 'toto', + }, + password: 'password', + }); + expect(window.fetch.mock.calls[0][0]).toBe( + 'http://localhost:3000/accounts/password/authenticate' + ); + expect(window.fetch.mock.calls[0][1].body).toBe( + '{"user":{"username":"toto"},"password":"password"}' + ); + }); + }); + + describe('impersonate', () => { + const client = new RestClient({ + apiHost: 'http://localhost:3000', + rootPath: '/accounts', + }); + + it('should call fetch with impersonate path', () => + client + .impersonate('token', 'user') + .then(() => + expect(window.fetch.mock.calls[0][0]).toBe( + 'http://localhost:3000/accounts/impersonate' + ) + )); + }); + + describe('refreshTokens', () => { + const client = new RestClient({ + apiHost: 'http://localhost:3000', + rootPath: '/accounts', + }); + + it('should call fetch with refreshTokens path', () => + client + .refreshTokens('accessToken', 'refreshToken') + .then(() => + expect(window.fetch.mock.calls[0][0]).toBe( + 'http://localhost:3000/accounts/refreshTokens' + ) + )); + }); + + describe('logout', () => { + const client = new RestClient({ + apiHost: 'http://localhost:3000', + rootPath: '/accounts', + }); + + it('should call fetch with logout path', () => + client + .logout('accessToken') + .then(() => + expect(window.fetch.mock.calls[0][0]).toBe( + 'http://localhost:3000/accounts/logout' + ) + )); + }); + + describe('getUser', () => { + const client = new RestClient({ + apiHost: 'http://localhost:3000', + rootPath: '/accounts', + }); + + it('should call fetch with user path', () => + client + .getUser('accessToken') + .then(() => + expect(window.fetch.mock.calls[0][0]).toBe( + 'http://localhost:3000/accounts/user' + ) + )); + }); + + describe('createUser', () => { + const client = new RestClient({ + apiHost: 'http://localhost:3000', + rootPath: '/accounts', + }); + + it('should call fetch with register path', () => + client + .createUser('user') + .then(() => + expect(window.fetch.mock.calls[0][0]).toBe( + 'http://localhost:3000/accounts/password/register' + ) + )); + }); + + describe('resetPassword', () => { + const client = new RestClient({ + apiHost: 'http://localhost:3000', + rootPath: '/accounts', + }); + + it('should call fetch with resetPassword path', () => + client + .resetPassword('token', 'resetPassword') + .then(() => + expect(window.fetch.mock.calls[0][0]).toBe( + 'http://localhost:3000/accounts/password/resetPassword' + ) + )); + }); + + describe('verifyEmail', () => { + const client = new RestClient({ + apiHost: 'http://localhost:3000', + rootPath: '/accounts', + }); + + it('should call fetch with verifyEmail path', () => + client + .verifyEmail('token') + .then(() => + expect(window.fetch.mock.calls[0][0]).toBe( + 'http://localhost:3000/accounts/password/verifyEmail' + ) + )); + }); + + describe('sendVerificationEmail', () => { + const client = new RestClient({ + apiHost: 'http://localhost:3000', + rootPath: '/accounts', + }); + + it('should call fetch with verifyEmail path', () => + client + .sendVerificationEmail('email') + .then(() => + expect(window.fetch.mock.calls[0][0]).toBe( + 'http://localhost:3000/accounts/password/sendVerificationEmail' + ) + )); + }); + + describe('sendResetPasswordEmail', () => { + const client = new RestClient({ + apiHost: 'http://localhost:3000', + rootPath: '/accounts', + }); + + it('should call fetch with verifyEmail path', () => + client + .sendResetPasswordEmail('email') + .then(() => + expect(window.fetch.mock.calls[0][0]).toBe( + 'http://localhost:3000/accounts/password/sendResetPasswordEmail' + ) + )); + }); +}); diff --git a/packages/rest-client/package.json b/packages/rest-client/package.json new file mode 100644 index 000000000..d6ae191e2 --- /dev/null +++ b/packages/rest-client/package.json @@ -0,0 +1,61 @@ +{ + "name": "@accounts/rest-client", + "version": "0.1.0-beta.2", + "description": "REST client for accounts", + "main": "lib/index", + "typings": "lib/index", + "publishConfig": { + "access": "public" + }, + "scripts": { + "start": "tsc --watch", + "precompile": "rimraf ./lib", + "compile": "tsc", + "prepublish": "npm run compile", + "test": "npm run testonly", + "testonly": "jest", + "coverage": "npm run testonly -- --coverage", + "coveralls": "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage" + }, + "jest": { + "testEnvironment": "jsdom", + "transform": { + ".(ts|tsx)": "/../../node_modules/ts-jest/preprocessor.js" + }, + "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx)$", + "moduleFileExtensions": [ + "ts", + "js" + ], + "mapCoverage": true + }, + "repository": { + "type": "git", + "url": "https://github.com/js-accounts/rest/tree/master/packages/rest-client" + }, + "keywords": [ + "rest", + "graphql", + "grant", + "auth", + "authentication", + "accounts", + "users", + "oauth" + ], + "author": "Tim Mikeladze", + "license": "MIT", + "devDependencies": { + "@accounts/client": "0.1.0-beta.3", + "@accounts/common": "0.1.0-beta.3", + "@types/lodash": "4.14.104", + "node-fetch": "2.1.1" + }, + "peerDependencies": { + "@accounts/client": "^0.1.0-beta.0", + "@accounts/common": "^0.1.0-beta.0" + }, + "dependencies": { + "lodash": "^4.17.4" + } +} diff --git a/packages/rest-client/src/auth-fetch.ts b/packages/rest-client/src/auth-fetch.ts new file mode 100644 index 000000000..37599432a --- /dev/null +++ b/packages/rest-client/src/auth-fetch.ts @@ -0,0 +1,34 @@ +import { forIn } from 'lodash'; +import { AccountsClient } from '@accounts/client'; + +const headers: { [key: string]: string } = { + 'Content-Type': 'application/json', +}; + +export const authFetch = async ( + accounts: AccountsClient, + path: string, + request: any +) => { + await accounts.refreshSession(); + const { accessToken } = await accounts.tokens(); + const headersCopy = { ...headers }; + + if (accessToken) { + headersCopy['accounts-access-token'] = accessToken; + } + + /* tslint:disable no-string-literal */ + if (request['headers']) { + forIn(request['headers'], (v: string, k: string) => { + headersCopy[v] = k; + }); + } + /* tslint:enable no-string-literal */ + + const fetchOptions = { + ...request, + headers: headersCopy, + }; + return fetch(path, fetchOptions); +}; diff --git a/packages/rest-client/src/index.ts b/packages/rest-client/src/index.ts new file mode 100644 index 000000000..5f16eacb7 --- /dev/null +++ b/packages/rest-client/src/index.ts @@ -0,0 +1,2 @@ +export { RestClient } from './rest-client'; +export { authFetch } from './auth-fetch'; diff --git a/packages/rest-client/src/rest-client.ts b/packages/rest-client/src/rest-client.ts new file mode 100644 index 000000000..75d8cb72b --- /dev/null +++ b/packages/rest-client/src/rest-client.ts @@ -0,0 +1,195 @@ +import { forIn, isPlainObject } from 'lodash'; +import { TransportInterface, AccountsClient } from '@accounts/client'; +import { + AccountsError, + CreateUserType, + LoginReturnType, + UserObjectType, + ImpersonateReturnType, +} from '@accounts/common'; + +export interface OptionsType { + apiHost: string; + rootPath: string; +} + +const headers: { [key: string]: string } = { + 'Content-Type': 'application/json', +}; + +export class RestClient implements TransportInterface { + private options: OptionsType; + + constructor(options: OptionsType) { + this.options = options; + } + + public async fetch( + route: string, + args: object, + customHeaders: object = {} + ): Promise { + const fetchOptions = { + headers: this._loadHeadersObject(customHeaders), + ...args, + }; + const res = await fetch( + `${this.options.apiHost}${this.options.rootPath}/${route}`, + fetchOptions + ); + + if (res) { + if (res.status >= 400 && res.status < 600) { + const { message, loginInfo, errorCode } = await res.json(); + throw new AccountsError(message, loginInfo, errorCode); + } + return res.json(); + } else { + throw new Error('Server did not return a response'); + } + } + + public loginWithService( + provider: string, + data: any, + customHeaders?: object + ): Promise { + const args = { + method: 'POST', + body: JSON.stringify({ + ...data, + }), + }; + return this.fetch(`${provider}/authenticate`, args, customHeaders); + } + + public impersonate( + accessToken: string, + username: string, + customHeaders?: object + ): Promise { + const args = { + method: 'POST', + body: JSON.stringify({ + accessToken, + username, + }), + }; + return this.fetch('impersonate', args, customHeaders); + } + + public refreshTokens( + accessToken: string, + refreshToken: string, + customHeaders?: object + ): Promise { + const args = { + method: 'POST', + body: JSON.stringify({ + accessToken, + refreshToken, + }), + }; + return this.fetch('refreshTokens', args, customHeaders); + } + + public logout(accessToken: string, customHeaders?: object): Promise { + const args = { + method: 'POST', + body: JSON.stringify({ + accessToken, + }), + }; + return this.fetch('logout', args, customHeaders); + } + + public async getUser( + accessToken: string, + customHeaders?: object + ): Promise { + const args = { + method: 'POST', + body: JSON.stringify({ + accessToken, + }), + }; + return this.fetch('user', args, customHeaders); + } + + public async createUser( + user: CreateUserType, + customHeaders?: object + ): Promise { + const args = { + method: 'POST', + body: JSON.stringify({ user }), + }; + return this.fetch('password/register', args, customHeaders); + } + + public resetPassword( + token: string, + newPassword: string, + customHeaders?: object + ): Promise { + const args = { + method: 'POST', + body: JSON.stringify({ + token, + newPassword, + }), + }; + return this.fetch('password/resetPassword', args, customHeaders); + } + + public verifyEmail(token: string, customHeaders?: object): Promise { + const args = { + method: 'POST', + body: JSON.stringify({ + token, + }), + }; + return this.fetch('password/verifyEmail', args, customHeaders); + } + + public sendVerificationEmail( + email: string, + customHeaders?: object + ): Promise { + const args = { + method: 'POST', + body: JSON.stringify({ + email, + }), + }; + return this.fetch('password/sendVerificationEmail', args, customHeaders); + } + + public sendResetPasswordEmail( + email: string, + customHeaders?: object + ): Promise { + const args = { + method: 'POST', + body: JSON.stringify({ + email, + }), + }; + return this.fetch('password/sendResetPasswordEmail', args, customHeaders); + } + + private _loadHeadersObject(plainHeaders: object): { [key: string]: string } { + if (isPlainObject(plainHeaders)) { + const customHeaders = headers; + forIn(plainHeaders, (v: string, k: string) => { + customHeaders[k] = v; + }); + + return customHeaders; + } + + return headers; + } +} + +export default RestClient; diff --git a/packages/rest-client/tsconfig.json b/packages/rest-client/tsconfig.json new file mode 100644 index 000000000..e0945a464 --- /dev/null +++ b/packages/rest-client/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "lib": ["dom", "es6", "es2015", "es2016", "es2017"], + "typeRoots": [ + "node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "__tests__", + "lib" + ] +} diff --git a/packages/rest-client/yarn.lock b/packages/rest-client/yarn.lock new file mode 100644 index 000000000..6a982f55b --- /dev/null +++ b/packages/rest-client/yarn.lock @@ -0,0 +1,15 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/lodash@4.14.104": + version "4.14.104" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.104.tgz#53ee2357fa2e6e68379341d92eb2ecea4b11bb80" + +lodash@^4.17.4: + version "4.17.5" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511" + +node-fetch@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.1.1.tgz#369ca70b82f50c86496104a6c776d274f4e4a2d4"