Skip to content

Commit

Permalink
feat: add Integration.API.getToken() method for OAuth
Browse files Browse the repository at this point in the history
This commit removes misleading type definitions for `fx:token` and adds a separate method for obtaining access token from hAPI with OAuth.
  • Loading branch information
pheekus committed Dec 4, 2020
1 parent 8a6f05d commit 9024c01
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 48 deletions.
89 changes: 65 additions & 24 deletions src/integration/API.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as Core from '../core';
import * as Rels from './Rels';

import { Headers, fetch } from 'cross-fetch';
import { storageV8N, v8n } from '../core/v8n';
Expand All @@ -8,8 +7,6 @@ import { Graph } from './Graph';
import { LogLevel } from 'consola';
import MemoryStorage from 'fake-storage';

type LocalToken = Rels.Token['props'] & { date_created: string };

/** In order to facilitate any major, unforeseen breaking changes in the future, we require each request to include API version. We hope to rarely (never?) change it but by requiring it up front, we can ensure what you get today is what you’ll get tomorrow. */
type IntegrationAPIVersion = '1';

Expand All @@ -25,6 +22,23 @@ type IntegrationAPIInit = {
cache?: Storage;
};

type GrantOpts = ({ code: string } | { refreshToken: string }) & {
clientSecret: string;
clientId: string;
version?: IntegrationAPIVersion;
base?: URL; // pathname ending with "/" !!!
};

type Token = {
refresh_token: string;
access_token: string;
expires_in: number;
token_type: string;
scope: string;
};

type StoredToken = Token & { date_created: string };

/** JS SDK for building integrations with [Foxy Hypermedia API](https://api.foxycart.com/docs). Hypermedia API is designed to give you complete control over all aspects of your Foxy accounts, whether working with a single store or automating the provisioning of thousands. Anything you can do within the Foxy administration, you can also do through the API. This means that you can embed Foxy into any application (CMS, LMS, CRM, etc.) and expose as much or as little of Foxy's functionality as desired. */
export class API extends Core.API<Graph> {
static readonly REFRESH_THRESHOLD = 5 * 60 * 1000;
Expand All @@ -46,8 +60,49 @@ export class API extends Core.API<Graph> {
storage: v8n().optional(storageV8N),
version: v8n().optional(v8n().passesAnyOf(v8n().exact('1'))),
}),

getAccessToken: v8n()
.passesAnyOf(v8n().schema({ code: v8n().string() }), v8n().schema({ refreshToken: v8n().string() }))
.schema({
base: v8n().optional(v8n().instanceOf(URL)),
clientId: v8n().string(),
clientSecret: v8n().string(),
version: v8n().optional(v8n().passesAnyOf(v8n().exact('1'))),
}),
};

/**
* Fetches a new access token in exchange for an authorization code
* or a refresh token. See more in our [authentication docs](https://api.foxycart.com/docs/authentication).
*
* @param opts Request options.
* @returns Access token with additional info on success, null on failure.
*/
static async getToken(opts: GrantOpts): Promise<Token | null> {
API.v8n.getAccessToken.check(opts);

const headers = new Headers();
const body = new URLSearchParams();
const url = new URL('token', opts.base ?? API.BASE_URL).toString();

headers.set('FOXY-API-VERSION', opts.version ?? API.VERSION);
headers.set('Content-Type', 'application/x-www-form-urlencoded');

body.set('client_id', opts.clientId);
body.set('client_secret', opts.clientSecret);

if ('code' in opts) {
body.set('code', opts.code);
body.set('grant_type', 'authorization_code');
} else {
body.set('grant_type', 'refresh_token');
body.set('refresh_token', opts.refreshToken);
}

const response = await fetch(url, { body, headers, method: 'POST' });
return response.ok ? await response.json() : null;
}

readonly refreshToken: string;

readonly clientSecret: string;
Expand All @@ -74,7 +129,7 @@ export class API extends Core.API<Graph> {
}

private async __fetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
let token = JSON.parse(this.storage.getItem(API.ACCESS_TOKEN) ?? 'null') as LocalToken | null;
let token = JSON.parse(this.storage.getItem(API.ACCESS_TOKEN) ?? 'null') as StoredToken | null;

if (token !== null) {
const expiresAt = new Date(token.date_created).getTime() + token.expires_in * 1000;
Expand All @@ -88,24 +143,11 @@ export class API extends Core.API<Graph> {
}

if (token === null) {
const headers = new Headers();
const body = new URLSearchParams();
const url = new URL('token', this.base).toString();

headers.set('FOXY-API-VERSION', this.version);
headers.set('Content-Type', 'application/x-www-form-urlencoded');

body.set('client_id', this.clientId);
body.set('client_secret', this.clientSecret);
body.set('grant_type', 'refresh_token');
body.set('refresh_token', this.refreshToken);

this.console.trace("Access token isn't present in the storage. Fetching a new one...");
const response = await fetch(url, { body, headers, method: 'POST' });
const rawToken = await API.getToken(this);

if (response.ok) {
const props = (await response.json()) as Rels.Token['props'];
token = { ...props, date_created: new Date().toISOString() };
if (rawToken) {
token = { ...rawToken, date_created: new Date().toISOString() };
this.storage.setItem(API.ACCESS_TOKEN, JSON.stringify(token));
this.console.info('Access token updated.');
} else {
Expand All @@ -117,10 +159,9 @@ export class API extends Core.API<Graph> {
const method = init?.method?.toUpperCase() ?? 'GET';
const url = typeof input === 'string' ? input : input.url;

headers.set('FOXY-API-VERSION', this.version);
headers.set('Content-Type', 'application/json');

if (token !== null) headers.set('Authorization', `Bearer ${token.access_token}`);
if (!headers.get('Authorization') && token) headers.set('Authorization', `Bearer ${token.access_token}`);
if (!headers.get('Content-Type')) headers.set('Content-Type', 'application/json');
if (!headers.get('FOXY-API-VERSION')) headers.set('FOXY-API-VERSION', this.version);

this.console.trace(`${method} ${url}`);
return fetch(input, { ...init, headers });
Expand Down
5 changes: 2 additions & 3 deletions src/integration/Graph/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { PropertyHelpers } from './property_helpers';
import type { Reporting } from './reporting';
import type { Store } from './store';
import type { Stores } from './stores';
import type { Token } from './token';
import type { User } from './user';

export interface Graph extends Core.Graph {
Expand All @@ -21,8 +20,8 @@ export interface Graph extends Core.Graph {
'fx:stores': Stores;
/** The current store for your authentication token. */
'fx:store': Store;
/** The OAuth endpoint for obtaining a new access_token using an existing refresh_token. POST `www-form-url-encoded` data as follows: `grant_type=refresh_token&refresh_token={refresh_token}&client_id={client_id}&client_secret={client_secret}`. */
'fx:token': Token;
/** OAuth endpoint for obtaining an access + refresh token pair. Please use the `FoxySDK.Integration.API.getAccessToken()` method to work with this endpoint. */
'fx:token': never;
/** Your API home page. */
'fx:user': User;
};
Expand Down
18 changes: 0 additions & 18 deletions src/integration/Graph/token.d.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/integration/Rels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@ export * from './Graph/template_config';
export * from './Graph/template_set';
export * from './Graph/template_sets';
export * from './Graph/timezones';
export * from './Graph/token';
export * from './Graph/transaction';
export * from './Graph/transaction_log';
export * from './Graph/transaction_log_detail';
Expand Down
97 changes: 95 additions & 2 deletions tests/integration/API.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { Headers, Request, Response, fetch } from 'cross-fetch';
import { API as CoreAPI } from '../../src/core/API';
import { API as IntegrationAPI } from '../../src/integration/API';
import MemoryCache from 'fake-storage';
import { Token } from '../../src/integration/Rels';

const fetchMock = (fetch as unknown) as jest.MockInstance<unknown, unknown[]>;

Expand All @@ -27,7 +26,7 @@ const commonInit = {
refreshToken: '65redfghyuyjthgrhyjthrgdfghytredtyuytredrtyuy6trtyuhgfdr',
};

const sampleToken: Token['props'] = {
const sampleToken = {
access_token: 'w8a49rbvuznxmzs39xliwfa943fda84klkvniutgh34q1fjmnfma90iubl',
expires_in: IntegrationAPI.REFRESH_THRESHOLD * 3,
refresh_token: '65redfghyuyjthgrhyjthrgdfghytredtyuytredrtyuy6trtyuhgfdr',
Expand Down Expand Up @@ -61,6 +60,100 @@ describe('Integration', () => {
expect(typeof IntegrationAPI.VERSION).toBe('string');
});

it('errors when API.getToken() is called with incorrect arguments', async () => {
const incorrectOpts = ({
base: 'fax',
cOdE: 'why',
clientId: 0,
client_secret: {},
refreshToken: null,
version: -1,
} as unknown) as Parameters<typeof IntegrationAPI['getToken']>[0];

await expect(() => IntegrationAPI.getToken(incorrectOpts)).rejects.toThrow();
});

it('returns null on auth failure in API.getToken()', async () => {
fetchMock.mockImplementation(() => Promise.resolve(new Response(null, { status: 500 })));
expect(await IntegrationAPI.getToken({ ...commonInit })).toBeNull();
fetchMock.mockClear();
});

it('supports authorization_code grant in API.getToken()', async () => {
fetchMock.mockImplementation(() => Promise.resolve(new Response(JSON.stringify(sampleToken))));

const { clientId, clientSecret } = commonInit;
const url = new URL('token', IntegrationAPI.BASE_URL).toString();
const code = '1234567890';
const token = await IntegrationAPI.getToken({ clientId, clientSecret, code });

expect(token).toEqual(sampleToken);
expect(fetchMock).toHaveBeenNthCalledWith(1, url, {
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
}),
headers: new Headers({ ...commonHeaders, 'Content-Type': 'application/x-www-form-urlencoded' }),
method: 'POST',
});

fetchMock.mockClear();
});

it('supports refresh_token grant in API.getToken()', async () => {
fetchMock.mockImplementation(() => Promise.resolve(new Response(JSON.stringify(sampleToken))));

const { clientId, clientSecret, refreshToken } = commonInit;
const url = new URL('token', IntegrationAPI.BASE_URL).toString();
const token = await IntegrationAPI.getToken({ clientId, clientSecret, refreshToken });

expect(token).toEqual(sampleToken);
expect(fetchMock).toHaveBeenNthCalledWith(1, url, {
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
headers: new Headers({ ...commonHeaders, 'Content-Type': 'application/x-www-form-urlencoded' }),
method: 'POST',
});

fetchMock.mockClear();
});

it('supports custom version and base in API.getToken()', async () => {
fetchMock.mockImplementation(() => Promise.resolve(new Response(JSON.stringify(sampleToken))));

const { clientId, clientSecret } = commonInit;
const version = '1';
const base = new URL('https://api-development.foxycart.com/');
const url = new URL('token', base).toString();
const code = '1234567890';
const token = await IntegrationAPI.getToken({ base, clientId, clientSecret, code, version });

expect(token).toEqual(sampleToken);
expect(fetchMock).toHaveBeenNthCalledWith(1, url, {
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
}),

headers: new Headers({
'Content-Type': 'application/x-www-form-urlencoded',
'FOXY-API-VERSION': version,
}),

method: 'POST',
});

fetchMock.mockClear();
});

it('errors when constructed with incorrect arguments', () => {
const incorrectInit = ({
base: 0,
Expand Down

0 comments on commit 9024c01

Please sign in to comment.