diff --git a/README.md b/README.md index 5d30ed0..c2dd2c7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,16 @@ Coming soon... ## Usage -Coming soon... +```js +const sdk = require('eventbrite')({token: 'OATH_TOKEN_HERE'}); + +// See: https://www.eventbrite.com/developer/v3/endpoints/users/#ebapi-get-users-id +sdk.request('/users/me').then(res => { + // handle response data +}); +``` + +Read more on [getting a token](https://www.eventbrite.com/developer/v3/api_overview/authentication/#ebapi-getting-a-token). ## Contributing @@ -16,7 +25,7 @@ Coming soon... ## Project philosophy -We take the stability of this SDK **very** seriously. `brite-rest` follows the [SemVer](http://semver.org/) standard for versioning. +We take the stability of this SDK **very** seriously. `eventbrite` follows the [SemVer](http://semver.org/) standard for versioning. ## License diff --git a/definitions/url-lib.d.ts b/definitions/url-lib.d.ts new file mode 100644 index 0000000..a87241f --- /dev/null +++ b/definitions/url-lib.d.ts @@ -0,0 +1,12 @@ +declare module "url-lib" { + export function formatQuery(queryParams: {}): string; + export function formatQuery(queryParamsList: Array<{}>): string; + + export function formatUrl(urlPath: string, queryParams: {}): string; + export function formatUrl( + urlPath: string, + queryParamsList: Array<{}> + ): string; + + export function parseQuery(serializedQuery: string): {}; +} diff --git a/package.json b/package.json index ca8f820..6afd41b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,14 @@ "jsnext:main": "lib/esm/index.js", "browser": "dist/brite-rest.js", "types": "lib/cjs/index.d.ts", - "keywords": ["rest", "api", "sdk", "events", "tickets", "eventbrite"], + "keywords": [ + "rest", + "api", + "sdk", + "events", + "tickets", + "eventbrite" + ], "repository": { "type": "git", "url": "https://github.com/eventbrite/eventbrite-sdk-javascript.git" @@ -33,11 +40,15 @@ "validate": "npm-run-all --parallel check:static test:ci" }, "lint-staged": { - "*.{ts,js}": ["yarn format", "git add"] + "*.{ts,js}": [ + "yarn format", + "git add" + ] }, "dependencies": { "isomorphic-fetch": "^2.2.1", - "lodash": "^4.17.5" + "lodash": "^4.17.5", + "url-lib": "^2.0.2" }, "resolutions": { "babel-core": "^7.0.0-bridge.0" @@ -52,6 +63,7 @@ "@types/isomorphic-fetch": "^0.0.34", "@types/jest": "^22.1.3", "@types/lodash": "^4.14.104", + "@types/node": "^9.4.6", "babel-eslint": "^7.0.0", "eslint": "^3.0.0", "eslint-config-eventbrite": "^4.1.0", diff --git a/src/.eslintrc.json b/src/.eslintrc.json index 907f0c3..7b2c18f 100644 --- a/src/.eslintrc.json +++ b/src/.eslintrc.json @@ -8,7 +8,8 @@ "parser": "typescript-eslint-parser", "plugins": ["typescript"], "rules": { - "no-undef": "off" + "no-undef": "off", + "camelcase": "off" }, "settings": { "import/resolver": { diff --git a/src/__tests__/__fixtures__/index.ts b/src/__tests__/__fixtures__/index.ts new file mode 100644 index 0000000..c16640f --- /dev/null +++ b/src/__tests__/__fixtures__/index.ts @@ -0,0 +1,21 @@ +export const MOCK_USERS_ME_RESPONSE_DATA = { + emails: [ + { + email: 'engineer@eventbrite.com', + verified: true, + primary: true, + }, + ], + id: '142429416488', + name: 'Eventbrite Engineer', + first_name: 'Eventbrite', + last_name: 'Engineer', + is_public: false, + image_id: null as string, +}; + +export const MOCK_ERROR_RESPONSE_DATA = { + status_code: 400, + error: 'INVALID_TEST', + error_description: 'This is an invalid test', +}; diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts new file mode 100644 index 0000000..f132c46 --- /dev/null +++ b/src/__tests__/index.spec.ts @@ -0,0 +1,90 @@ +import eventbrite from '../'; +import { + mockFetch, + getMockFetch, + restoreMockFetch, + getMockResponse +} from './utils'; +import {MOCK_USERS_ME_RESPONSE_DATA} from './__fixtures__'; + +describe('configurations', () => { + it('does not error when creating sdk object w/o configuration', () => { + expect(() => eventbrite()).not.toThrow(); + }); +}); + +describe('request', () => { + const MOCK_TOKEN = 'MOCK_TOKEN'; + const MOCK_BASE_URL = '/api/v3'; + + beforeEach(() => { + mockFetch(getMockResponse(MOCK_USERS_ME_RESPONSE_DATA)); + }); + + afterEach(() => { + restoreMockFetch(); + }); + + it('makes request to API base url default w/ no token when no configuration is specified', async () => { + const {request} = eventbrite(); + + await expect(request('/users/me/')).resolves.toEqual( + MOCK_USERS_ME_RESPONSE_DATA + ); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + expect(getMockFetch()).toHaveBeenCalledWith( + 'https://www.eventbriteapi.com/v3/users/me/', + expect.objectContaining({}) + ); + }); + + it('makes request to API base url override w/ specified token', async () => { + const {request} = eventbrite({ + token: MOCK_TOKEN, + baseUrl: MOCK_BASE_URL, + }); + + await expect(request('/users/me/')).resolves.toEqual( + MOCK_USERS_ME_RESPONSE_DATA + ); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + expect(getMockFetch()).toHaveBeenCalledWith( + `${MOCK_BASE_URL}/users/me/?token=${MOCK_TOKEN}`, + expect.objectContaining({}) + ); + }); + + it('properly appends token to API URL when endpoint already contains query parameters', async () => { + const {request} = eventbrite({ + token: MOCK_TOKEN, + }); + + await expect( + request('/users/me/orders/?time_filter=past') + ).resolves.toEqual(MOCK_USERS_ME_RESPONSE_DATA); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + expect(getMockFetch()).toHaveBeenCalledWith( + `https://www.eventbriteapi.com/v3/users/me/orders/?time_filter=past&token=${MOCK_TOKEN}`, + expect.objectContaining({}) + ); + }); + + it('properly passes through request options', async () => { + const {request} = eventbrite(); + const requestOptions = { + method: 'POST', + body: JSON.stringify({plan: 'package2'}), + }; + + await request('/users/:id/assortment/', requestOptions); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + expect(getMockFetch()).toHaveBeenCalledWith( + 'https://www.eventbriteapi.com/v3/users/:id/assortment/', + expect.objectContaining(requestOptions) + ); + }); +}); diff --git a/src/__tests__/request.spec.ts b/src/__tests__/request.spec.ts new file mode 100644 index 0000000..70984c2 --- /dev/null +++ b/src/__tests__/request.spec.ts @@ -0,0 +1,216 @@ +import request, {checkStatus, fetchJSON, catchStatusError} from '../request'; +import { + mockFetch, + getMockFetch, + restoreMockFetch, + getMockResponse +} from './utils'; +import { + MOCK_USERS_ME_RESPONSE_DATA, + MOCK_ERROR_RESPONSE_DATA +} from './__fixtures__'; + +const TEST_URL = 'https://www.eventbriteapi.com/v3/users/me/'; + +const getSuccessfulCodeRes = () => getMockResponse(MOCK_USERS_ME_RESPONSE_DATA); +const getUnsuccessfulCodeRes = () => + getMockResponse(MOCK_ERROR_RESPONSE_DATA, {status: 400}); + +describe('checkStatus', () => { + describe('on receiving an invalid status', () => { + const response = getUnsuccessfulCodeRes(); + + it('returns a rejected promise', async () => { + await expect(checkStatus(response)).rejects.toBe(response); + }); + }); + + describe('on receiving a valid status', () => { + it('returns a fulfilled promise', async () => { + const response = getSuccessfulCodeRes(); + + await expect(checkStatus(response)).resolves.toBe(response); + }); + }); +}); + +describe('fetchJSON', () => { + afterEach(() => { + restoreMockFetch(); + }); + + describe('on receiving successful status code', () => { + beforeEach(() => { + mockFetch(getSuccessfulCodeRes()); + }); + + it('calls fetch with appropriate defaults', async () => { + await fetchJSON(TEST_URL); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + expect(getMockFetch()).toHaveBeenCalledWith( + TEST_URL, + expect.objectContaining({ + credentials: 'same-origin', + }) + ); + }); + + it('adds "application/json" content type when method is not GET', async () => { + await fetchJSON(TEST_URL, {method: 'POST', body: '{}'}); + + expect(getMockFetch()).toHaveBeenCalledWith( + TEST_URL, + expect.objectContaining({ + credentials: 'same-origin', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + method: 'POST', + body: '{}', + }) + ); + }); + + it('respects overrides in options', async () => { + await fetchJSON(TEST_URL, {credentials: 'omit'}); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + expect(getMockFetch()).toHaveBeenCalledWith( + TEST_URL, + expect.objectContaining({ + credentials: 'omit', + }) + ); + }); + + it('respects overrides in option headers', async () => { + await fetchJSON(TEST_URL, { + headers: { + 'X-TEST': 'testHeader', + 'X-CSRFToken': 'testCSRF', + 'Content-Type': 'application/xml', + }, + }); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + expect(getMockFetch()).toHaveBeenCalledWith( + TEST_URL, + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-CSRFToken': 'testCSRF', + 'X-TEST': 'testHeader', + 'Content-Type': 'application/xml', + }), + }) + ); + }); + + it('merges overrides with defaults in option headers', async () => { + await fetchJSON(TEST_URL, { + headers: { + 'X-TEST': 'testHeader', + 'X-CSRFToken': 'testCSRF', + }, + method: 'POST', + body: '{}', + }); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + expect(getMockFetch()).toHaveBeenCalledWith( + TEST_URL, + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-CSRFToken': 'testCSRF', + 'X-TEST': 'testHeader', + 'Content-Type': 'application/json', + }), + method: 'POST', + body: '{}', + }) + ); + }); + + it('should parse the response JSON', async () => { + await expect(fetchJSON(TEST_URL)).resolves.toEqual( + MOCK_USERS_ME_RESPONSE_DATA + ); + }); + }); + + describe('on receiving an unsuccessful status code', () => { + beforeEach(() => { + mockFetch(getUnsuccessfulCodeRes()); + }); + + it('should throw an error', async () => { + await expect(fetchJSON(TEST_URL)).rejects.toEqual( + expect.objectContaining({status: 400}) + ); + }); + }); +}); + +describe('catchStatusError', () => { + describe('when response is invalid JSON', () => { + it('should reject without parsed errors, only response', async () => { + const response = new Response('{sa;dfsdfi'); + + await expect(catchStatusError(response)).rejects.toEqual({ + response, + }); + }); + }); + + describe('when response is valid JSON', () => { + it('should reject with parsed errors', async () => { + const response = getUnsuccessfulCodeRes(); + + await expect(catchStatusError(response)).rejects.toEqual({ + response, + parsedError: { + error: 'INVALID_TEST', + description: 'This is an invalid test', + }, + }); + }); + }); +}); + +describe('request', () => { + afterEach(() => { + restoreMockFetch(); + }); + + describe('when no status error', () => { + beforeEach(() => { + mockFetch(getSuccessfulCodeRes()); + }); + + it('calls fetch and return JSON data', async () => { + await expect(request(TEST_URL)).resolves.toEqual( + MOCK_USERS_ME_RESPONSE_DATA + ); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + }); + }); + + describe('when there is a status error', () => { + it('calls fetch and rejects with parsed error', async () => { + const response = getUnsuccessfulCodeRes(); + + mockFetch(response); + + await expect(request(TEST_URL)).rejects.toEqual({ + response, + parsedError: { + error: 'INVALID_TEST', + description: 'This is an invalid test', + }, + }); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts new file mode 100644 index 0000000..68ecbc4 --- /dev/null +++ b/src/__tests__/utils.ts @@ -0,0 +1,36 @@ +// NOTE: Typescript is pretty unhappy with us mocking and reading from `global.fetch` since +// `isomorphic-fetch` doesn't properly describe that `global.fetch` is now a thing. It also +// does understand what `jest.spyOn` is doing to `global.fetch`. Therefore we have some +// Typescript definitions along with helpers to consolidate everything into a single place. + +declare type Fetch = (url: string, options: RequestInit) => Promise; + +// A fetch function that also has a `mockRestore` function property as well on it +interface MockedFetch extends Fetch { + mockRestore: () => void; +} +declare global { + namespace NodeJS { + interface Global { + // eslint-disable-next-line no-unused-vars + fetch: MockedFetch; + } + } +} + +export const getMockResponse = ( + mockResponseData: any = {}, + responseConfig: ResponseInit = {status: 200} +) => new Response(JSON.stringify(mockResponseData), responseConfig); + +export const mockFetch = (response: Response = getMockResponse()) => { + jest + .spyOn(global, 'fetch') + .mockImplementation(() => Promise.resolve(response)); +}; + +export const getMockFetch = () => global.fetch; + +export const restoreMockFetch = () => { + global.fetch.mockRestore(); +}; diff --git a/src/index.spec.ts b/src/index.spec.ts deleted file mode 100644 index 9a16f6a..0000000 --- a/src/index.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import briteRest from './'; - -describe('configurations', () => { - it('does not error when creating sdk object w/o token', () => { - expect(() => briteRest()).not.toThrow(); - }); -}); - -// TODO: remove when build process has stabilized -describe('build functionality', () => { - it('should compile while using async await', async () => { - const result = await new Promise((resolve) => resolve(1)); - - expect(result).toEqual(1); - }); - - it('should work with spread operator', () => { - const parent = {first: 1}; - const child = {...parent, second: 2}; - - expect(child).toEqual({first: 1, second: 2}); - }); -}); diff --git a/src/index.ts b/src/index.ts index 2a077fc..3b055db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,32 @@ -export interface Sdk {} +/// -const briteRest = (): Sdk => ({}); +import {formatUrl} from 'url-lib'; +import request from './request'; -export default briteRest; +const DEFAULT_API_URL = 'https://www.eventbriteapi.com/v3'; + +export interface SdkConfig { + token?: string; + baseUrl?: string; +} + +export interface Sdk { + request: (apiPath: string, options?: RequestInit) => Promise<{}>; +} + +const eventbrite = ({ + baseUrl = DEFAULT_API_URL, + token, +}: SdkConfig = {}): Sdk => ({ + request: (endpoint, options?) => { + let url = `${baseUrl}${endpoint}`; + + if (token) { + url = formatUrl(url, {token}); + } + + return request(url, options); + }, +}); + +export default eventbrite; diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 0000000..1f94562 --- /dev/null +++ b/src/request.ts @@ -0,0 +1,145 @@ +import {isObject} from 'lodash'; +import 'isomorphic-fetch'; + +/** + * Return a promise that is resolved or rejected depending on the response's + * status code. + */ +export const checkStatus = (res: Response): Promise => { + if (res.status >= 300) { + return Promise.reject(res); + } + return Promise.resolve(res); +}; + +/** + * Calls fetch on provided url with default options necessary for interacting + * with our JSON API. Parses the JSON, provides appropriate headers, and asserts + * a valid status from the server. + */ +export const fetchJSON = ( + url: string, + {headers, method, mode, ...options}: RequestInit = {} +): Promise<{}> => { + let fetchHeaders = headers as HeadersInit; + + if (method !== 'GET') { + fetchHeaders = { + 'Content-Type': 'application/json', + ...headers, + }; + } + + const fetchOptions = { + method, + mode, + headers: fetchHeaders, + credentials: mode === 'cors' ? 'include' : 'same-origin', + ...options, + } as RequestInit; + + return fetch(url, fetchOptions) + .then(checkStatus) + .then((res: Response) => { + let resJSON = {}; + + if ('json' in res) { + resJSON = res.json(); + } + + return resJSON; + }); +}; + +export interface ArgumentErrors { + [key: string]: [string]; +} + +export interface ParsedResponseError { + error: string; + description: string; + argumentErrors: ArgumentErrors; +} + +export interface JSONResponseData { + error?: string; + error_description?: string; + error_detail?: { + ARGUMENTS_ERROR?: ArgumentErrors; + [propName: string]: any; + }; +} + +const hasArgumentsError = (responseData: JSONResponseData): boolean => + isObject(responseData['error_detail']) && + isObject(responseData['error_detail']['ARGUMENTS_ERROR']); + +/** + * Parse v3 errors into an array of objects representing the errors returned by + * the API. The format of the parsed errors looks like: + * + * { + * status_code: 400, + * error: 'ERROR_CODE', + * description: 'Description of the error + * } + * + * An ARGUMENTS_ERROR looks like: + * + * { + * error: 'ARGUMENTS_ERROR', + * description: 'Some of the fields were invalid or something', + * argumentErrors: { + * attr1: ['INVALID'], + * attr2: ['This field is required'] + * } + * } + * + */ +export const parseError = ( + responseData: JSONResponseData +): ParsedResponseError => { + if (!responseData.error) { + // Weird error format, return null + return null; + } + + let error = { + error: responseData.error, + description: responseData['error_description'], + } as ParsedResponseError; + + if (hasArgumentsError(responseData)) { + error.argumentErrors = responseData['error_detail']['ARGUMENTS_ERROR']; + } + + return error; +}; + +/** + * Designed to work with `checkStatus`, or any function that + * raises an error on an invalid status. The error raised should have a `response` + * property with the original response object. + * + * Example usage: + * + * fetchJSON('/api/v3/test/path', {'body': someData}) + * .catch(catchStatusError) + * .catch(({response, parsedError}) => doSomethingOnError()) + * .then(doSomethingOnSuccess); + */ +export const catchStatusError = (response: Response): Promise => + new Promise((resolve, reject) => { + response + .json() + .then((responseData) => parseError(responseData)) + .then((parsedError) => reject({response, parsedError})) + .catch(() => reject({response})); + }); + +/** + * fetchV3 is a simple wrapper for http/fetchJSON that parses v3 errors received + * by the API. + */ +export default (url: string, options?: RequestInit): Promise<{}> => + fetchJSON(url, options).catch(catchStatusError); diff --git a/yarn.lock b/yarn.lock index abbd40d..c51ef5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -489,6 +489,10 @@ version "4.14.104" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.104.tgz#53ee2357fa2e6e68379341d92eb2ecea4b11bb80" +"@types/node@^9.4.6": + version "9.4.6" + resolved "https://registry.npmjs.org/@types/node/-/node-9.4.6.tgz#d8176d864ee48753d053783e4e463aec86b8d82e" + abab@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e" @@ -4512,6 +4516,10 @@ unicode-property-aliases-ecmascript@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.3.tgz#ac3522583b9e630580f916635333e00c5ead690d" +url-lib@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/url-lib/-/url-lib-2.0.2.tgz#26708f42f4c23ec821e3617044fab22e250e7afc" + user-home@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/user-home/-/user-home-2.0.0.tgz#9c70bfd8169bc1dcbf48604e0f04b8b49cde9e9f"