From 4ac55ac30aff2c954cfc71b74429b2ee98ca65b4 Mon Sep 17 00:00:00 2001 From: Kyle Welch Date: Wed, 23 Jan 2019 14:56:24 -0600 Subject: [PATCH] feat(users): Add users collection to SDK (#52) Added a collection of helpful wrapper methods for interacting with the Users API. --- README.md | 2 +- docs/README.md | 3 +- docs/users.md | 66 ++++++++++++++++ src/__tests__/__fixtures__/index.ts | 16 ++++ src/__tests__/index.spec.ts | 42 ++++++++++- src/__tests__/users.spec.ts | 112 ++++++++++++++++++++++++++++ src/baseApi.ts | 54 ++++++++++++++ src/index.ts | 51 ++++++++----- src/request.ts | 23 ++++-- src/types.ts | 11 ++- src/users.ts | 27 +++++++ tsconfig.json | 2 +- 12 files changed, 379 insertions(+), 30 deletions(-) create mode 100644 docs/users.md create mode 100644 src/__tests__/users.spec.ts create mode 100644 src/baseApi.ts create mode 100644 src/users.ts diff --git a/README.md b/README.md index 42b0708..1ecd891 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The official JavaScript SDK for the [Eventbrite v3 API](https://www.eventbrite.c > NOTE: This library is still in **beta** as we flesh out the API of the SDK. -## ToC +## Table of Contents * [Installation](#installation) * [Quick Usage](#quick-usage) diff --git a/docs/README.md b/docs/README.md index 29bda20..5544a6c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,11 +2,12 @@ This SDK interface closely mirors the [Eventbrite v3 REST API](https://www.eventbrite.com/developer/v3/) endpoints that it wraps. The SDK provides many conveniences for making requests and processing responses to make it easier to use in the JavaScript environment. -## ToC +## Table of Contents - [Including the package](#including-the-package) - [Configuring a SDK object](#configuring-a-sdk-object) - [`request()`](./request.md) +- [Users](./users.md) ## Including the package diff --git a/docs/users.md b/docs/users.md new file mode 100644 index 0000000..0f4532e --- /dev/null +++ b/docs/users.md @@ -0,0 +1,66 @@ +# Users + +This is a collection of method that are intended to be helpful wrappers around the [Users API endpoints](user-api-docs). + +View the [User response object](user-object-reference) for details on the properties you'll get back with each response. + +## Table on Contents + +- [`sdk.users.me()`](#me) +- [`sdk.users.get(id)`](#getById) + + + +## `sdk.users.me()` +Gets details about the current logged in user. + +**Read [`/users/me` documentation](user-get-me) for more details.** + +### API +```js +sdk.users.me(): Promise +``` + +### Example + +```js +const eventbrite = require('eventbrite'); + +// Create configured Eventbrite SDK +const sdk = eventbrite({token: 'OATH_TOKEN_HERE'}); + +sdk.users.me().then((user) => { + console.log(`Hi ${user.name}!`); +}); +``` + + + +## `sdk.users.get(id)` +Gets the details for a specific user by their user id. + +**Read [`/users/:id` documentation](user-get-me) for more details.** + +### API +```js +sdk.users.get(id: string): Promise +``` + +### Example + +```js +const eventbrite = require('eventbrite'); + +// Create configured Eventbrite SDK +const sdk = eventbrite({token: 'OATH_TOKEN_HERE'}); + +sdk.users.get('1234567890').then((user) => { + console.log(`Hi ${user.name}!`); +}); +``` + + +[user-api-docs]: https://www.eventbrite.com/platform/api#/reference/user +[user-object-reference]: https://www.eventbrite.com/platform/api#/reference/user/retrieve-a-user +[user-by-id]: https://www.eventbrite.com/platform/api#/reference/user/retrieve-a-user +[user-get-me]: https://www.eventbrite.com/platform/api#/reference/user/retrieve/retrieve-your-user \ No newline at end of file diff --git a/src/__tests__/__fixtures__/index.ts b/src/__tests__/__fixtures__/index.ts index 25bc4ef..4d446ff 100644 --- a/src/__tests__/__fixtures__/index.ts +++ b/src/__tests__/__fixtures__/index.ts @@ -14,6 +14,22 @@ export const MOCK_USERS_ME_RESPONSE_DATA = { image_id: null as string, }; +export const MOCK_TRANSFORMED_USERS_ME_RESPONSE_DATA = { + emails: [ + { + email: 'engineer@eventbrite.com', + verified: true, + primary: true, + }, + ], + id: '142429416488', + name: 'Eventbrite Engineer', + firstName: 'Eventbrite', + lastName: 'Engineer', + isPublic: false, + imageId: null as string, +}; + export const MOCK_INTERNAL_ERROR_RESPONSE_DATA = { status_code: 500, error: 'INTERNAL_ERROR', diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index ea5f3b5..ddb799f 100644 --- a/src/__tests__/index.spec.ts +++ b/src/__tests__/index.spec.ts @@ -5,7 +5,10 @@ import { restoreMockFetch, getMockResponse, } from './utils'; -import {MOCK_USERS_ME_RESPONSE_DATA} from './__fixtures__'; +import { + MOCK_USERS_ME_RESPONSE_DATA, + MOCK_TRANSFORMED_USERS_ME_RESPONSE_DATA, +} from './__fixtures__'; describe('configurations', () => { it('does not error when creating sdk object w/o configuration', () => { @@ -90,4 +93,41 @@ describe('request', () => { }) ); }); + + describe('users collection', () => { + it('should return an object of functions', () => { + const {users} = eventbrite({ + token: MOCK_TOKEN, + baseUrl: MOCK_BASE_URL, + }); + + expect(users).toBeDefined(); + Object.keys(users).forEach((key) => { + const value = (users as any)[key]; + + expect(value).toBeInstanceOf(Function); + }); + }); + + it('makes request to API base url override w/ specified token', async() => { + const {users} = eventbrite({ + token: MOCK_TOKEN, + baseUrl: MOCK_BASE_URL, + }); + + await expect(users.me()).resolves.toEqual( + MOCK_TRANSFORMED_USERS_ME_RESPONSE_DATA + ); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + expect(getMockFetch()).toHaveBeenCalledWith( + `${MOCK_BASE_URL}/users/me/`, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Bearer ${MOCK_TOKEN}`, + }), + }) + ); + }); + }); }); diff --git a/src/__tests__/users.spec.ts b/src/__tests__/users.spec.ts new file mode 100644 index 0000000..1f42dd2 --- /dev/null +++ b/src/__tests__/users.spec.ts @@ -0,0 +1,112 @@ +import { + mockFetch, + getMockFetch, + getMockResponse, + restoreMockFetch, +} from './utils'; +import { + MOCK_USERS_ME_RESPONSE_DATA, + MOCK_TRANSFORMED_USERS_ME_RESPONSE_DATA, +} from './__fixtures__'; + +import request from '../request'; +import {UserApi} from '../users'; + +describe('me()', () => { + it('calls fetch and calls fetch with appropriate defaults', async() => { + const users = new UserApi(request); + + mockFetch(getMockResponse(MOCK_USERS_ME_RESPONSE_DATA)); + + await expect(users.me()).resolves.toEqual( + MOCK_TRANSFORMED_USERS_ME_RESPONSE_DATA + ); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + expect(getMockFetch()).toHaveBeenCalledWith( + '/users/me/', + expect.objectContaining({}) + ); + + restoreMockFetch(); + }); + + it('handle token missing requests', async() => { + const users = new UserApi(request); + + mockFetch( + getMockResponse( + { + status_code: 401, + error_description: + 'An OAuth token is required for all requests', + error: 'NO_AUTH', + }, + {status: 401} + ) + ); + + await expect(users.me()).rejects.toMatchObject({ + response: expect.objectContaining({ + status: 401, + statusText: 'Unauthorized', + ok: false, + }), + parsedError: { + description: 'An OAuth token is required for all requests', + error: 'NO_AUTH', + }, + }); + + restoreMockFetch(); + }); +}); + +describe('get(id)', () => { + it('calls fetch and calls fetch with appropriate defaults', async() => { + const users = new UserApi(request); + + mockFetch(getMockResponse(MOCK_USERS_ME_RESPONSE_DATA)); + + await expect(users.get('142429416488')).resolves.toEqual( + MOCK_TRANSFORMED_USERS_ME_RESPONSE_DATA + ); + + expect(getMockFetch()).toHaveBeenCalledTimes(1); + expect(getMockFetch()).toHaveBeenCalledWith( + '/users/142429416488/', + expect.objectContaining({}) + ); + + restoreMockFetch(); + }); + + it('should handle not found users', async() => { + const users = new UserApi(request); + + mockFetch( + getMockResponse( + { + status_code: 404, + error_description: 'The user you requested does not exist.', + error: 'NOT_FOUND', + }, + {status: 404} + ) + ); + + await expect(users.get('123')).rejects.toMatchObject({ + response: expect.objectContaining({ + status: 404, + statusText: 'Not Found', + ok: false, + }), + parsedError: { + description: 'The user you requested does not exist.', + error: 'NOT_FOUND', + }, + }); + + restoreMockFetch(); + }); +}); diff --git a/src/baseApi.ts b/src/baseApi.ts new file mode 100644 index 0000000..222212f --- /dev/null +++ b/src/baseApi.ts @@ -0,0 +1,54 @@ +import {JSONRequest} from './types'; + +const SNAKE_CASE_MATCH = /_\w/g; +const snakeToCamel = (str: string) => + str.replace(SNAKE_CASE_MATCH, (chars: string) => chars[1].toUpperCase()); + +const transformKeysSnakeToCamel = ( + obj: T +) => + Object.keys(obj).reduce((memo, key) => { + let newValue = obj[key]; + const camelKey = snakeToCamel(key); + + if ( + newValue && + typeof newValue === 'object' && + !Array.isArray(newValue) + ) { + newValue = transformKeysSnakeToCamel(newValue); + } + + return { + ...memo, + [camelKey]: newValue, + }; + }, {}) as T; + +/** + * Returns a function that sends a request, and transforms its results + */ +const makeJsonRequest = ( + request: JSONRequest, + transformers: Array<(obj: T) => T> +) => (url: string, options?: RequestInit) => + request(url, options).then((response: T) => + transformers.reduce((acc, transformer) => { + let memo = acc; + + memo = transformer(response); + return memo; + }, response) + ); + +/** + * Base API class for creating new API Classes. + * Also encapsulates default transformers such as snake to camel. + */ +export abstract class BaseApi { + request: JSONRequest; + + constructor(req: JSONRequest) { + this.request = makeJsonRequest(req, [transformKeysSnakeToCamel]); + } +} diff --git a/src/index.ts b/src/index.ts index 0a5a4f4..2ae9ac3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,30 +1,43 @@ -import {Sdk, SdkConfig} from './types'; +import {Sdk, SdkConfig, JSONRequest} from './types'; import request from './request'; +import {UserApi} from './users'; export * from './constants'; const DEFAULT_API_URL = 'https://www.eventbriteapi.com/v3'; +type MakeRequestFunction = (baseUrl: string, token: string) => JSONRequest; + +const makeRequest: MakeRequestFunction = (baseUrl: string, token: string) => ( + endpoint, + options = {} +) => { + const url = `${baseUrl}${endpoint}`; + let requestOptions = options; + + if (token) { + requestOptions = { + ...requestOptions, + headers: { + ...(requestOptions.headers || {}), + Authorization: `Bearer ${token}`, + }, + }; + } + + return request(url, requestOptions); +}; + const eventbrite = ({ baseUrl = DEFAULT_API_URL, token, -}: SdkConfig = {}): Sdk => ({ - request: (endpoint, options = {}) => { - const url = `${baseUrl}${endpoint}`; - let requestOptions = options; - - if (token) { - requestOptions = { - ...requestOptions, - headers: { - ...(requestOptions.headers || {}), - Authorization: `Bearer ${token}`, - }, - }; - } - - return request(url, requestOptions); - }, -}); +}: SdkConfig = {}): Sdk => { + const jsonRequest = makeRequest(baseUrl, token); + + return { + request: jsonRequest, + users: new UserApi(jsonRequest), + }; +}; export default eventbrite; diff --git a/src/request.ts b/src/request.ts index 4b908be..18ab058 100644 --- a/src/request.ts +++ b/src/request.ts @@ -14,7 +14,9 @@ const _checkStatus = (res: Response): Promise => { return Promise.resolve(res); }; -const _tryParseJSON = (res: Response): Promise => { +const _tryParseJSON = ( + res: Response +): Promise => { try { return ( res @@ -35,10 +37,10 @@ const _tryParseJSON = (res: Response): Promise => { * with our JSON API. Parses the JSON, provides appropriate headers, and asserts * a valid status from the server. */ -export const _fetchJSON = ( +const _fetchJSON = ( url: string, {headers = {}, method = 'GET', mode, ...options}: RequestInit = {} -): Promise<{}> => { +): Promise => { let fetchHeaders = headers as HeadersInit; if (method !== 'GET') { @@ -58,7 +60,7 @@ export const _fetchJSON = ( return fetch(url, fetchOptions) .then(_checkStatus) - .then(_tryParseJSON); + .then(_tryParseJSON); }; const _hasArgumentsError = (responseData: JSONResponseData): boolean => @@ -141,9 +143,18 @@ const _catchStatusError = (res: Response): Promise => ); }); +export interface DefaultApiResponse { + [key: string]: any; +} + /** * Low-level method that makes fetch requests, returning the response formatted as JSON. * It parses errors from API v3 and throws exceptions with those errors */ -export default (url: string, options?: RequestInit): Promise<{}> => - _fetchJSON(url, options).catch(_catchStatusError); +const jsonRequest = ( + url: string, + options?: RequestInit +): Promise => + _fetchJSON(url, options).catch(_catchStatusError); + +export default jsonRequest; diff --git a/src/types.ts b/src/types.ts index bc95fdd..159a2b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,18 @@ +import {UserApi} from './users'; + export interface SdkConfig { token?: string; baseUrl?: string; } + +export type JSONRequest = ( + apiPath: string, + options?: RequestInit +) => Promise; + export interface Sdk { - request: (apiPath: string, options?: RequestInit) => Promise<{}>; + request: JSONRequest; + users: UserApi; } export interface ArgumentErrors { diff --git a/src/users.ts b/src/users.ts new file mode 100644 index 0000000..c508390 --- /dev/null +++ b/src/users.ts @@ -0,0 +1,27 @@ +import {BaseApi} from './baseApi'; + +export interface Email { + email?: string; + primary?: boolean; +} + +export interface User { + id?: string; + firstName?: string; + lastName?: string; + imageId?: string; + email?: Email[]; +} + +/** + * API for working with Users + */ +export class UserApi extends BaseApi { + me() { + return this.request('/users/me/'); + } + + get(id: string) { + return this.request(`/users/${id}/`); + } +} diff --git a/tsconfig.json b/tsconfig.json index 56608ac..7f623a1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "lib": ["es2015", "dom"], + "lib": ["es2017", "dom"], "noImplicitAny": true, "noEmit": true },