import _ from 'lodash' import URI from 'urijs' import { instrumentedFetch } from './instrumented_fetch' import { transformRequestData } from './transform_request_data' import transformResponse from './transform_response' import { Accumulator, GetRequest, PostRequest, RequestData, WalkRequest } from './types' export type MakeRequestArgs = { action: 'GET' | 'POST' | 'PATCH' | 'DELETE' url: string data?: Partial headers: Record } export const makeRequest = ({ action = 'GET', url, data = {}, headers = {} }: MakeRequestArgs) => { // break apart url so we can reconstruct the url with the query params let uri = new URI(url) const query = transformRequestData(data) const urlQuery = uri.query(true) // This likely doesn't matter but will enforce consistent ordering of query params const combinedQuery = Object.entries({ ...query, ...urlQuery }) .sort() .reduce((obj, [key, value]) => { // @ts-ignore obj[key] = value return obj }, {}) if (action === 'GET') { uri = uri.query(combinedQuery) } const body = ['POST', 'PATCH'].includes(action) ? JSON.stringify(data) : undefined return instrumentedFetch(uri.toString(), { method: action, headers, body, }).then(response => { if (response.ok) { return response.text().then(t => (t === '' ? '' : JSON.parse(t))) } else { return Promise.reject(response) } }) } const STANDARD_META_ATTRIBUTES = [ 'total_count', 'count', 'prev', 'next', 'can_order_by', 'can_query_by', 'can_include', 'can_filter', 'parent', ] export const concatRecords = (records: Accumulator, moreRecords: Accumulator) => ({ data: [...records.data, ...moreRecords.data], included: [...records.included, ...moreRecords.included], // Because custom meta can be computed on a per-page bases, // it is safer to remove all custom meta when concatenating multiple pages of data meta: _.pick(moreRecords.meta, STANDARD_META_ATTRIBUTES), }) type Walk = (args: MakeRequestArgs | WalkRequest) => Promise type WalkArgs = MakeRequestArgs & { data?: { fields?: Record } } export const throwErrorIfFieldsMissing = async (walk: Walk, args: WalkArgs) => { const fields = args?.data?.fields if (!fields) { throw new Error(`Must pass fields for request: ${args.url}`) } const fieldTypes = Object.keys(fields) const response = await walk(args) const dataTypes = _(response.data).castArray().map('type').value() const includedTypes = _.map(response.included, 'type') const responseTypes = _.uniq(dataTypes.concat(includedTypes)) const missingFields = _.difference(responseTypes, fieldTypes).filter(t => t) if (missingFields.length > 0) { throw new Error( `Must include fields for types: ${JSON.stringify(missingFields)} for request ${args.url}` ) } return transformResponse(response) } export const friendlyErrors = (walk: Walk, args: WalkArgs) => throwErrorIfQueryParams(args.url) .then(() => throwErrorIfFieldsMissing(walk, args)) .then(transformResponse) export const noQueryParamsFriendlyErrors = (request: typeof makeRequest, args: WalkArgs) => throwErrorIfQueryParams(args.url) .then(() => request(args)) .then(transformResponse) const passthrough = async (walk: (args: T) => Promise, args: T) => Promise.resolve(transformResponse(await walk(args))) export const ensureFieldsInDev = __DEV__ ? friendlyErrors : passthrough export const ensureNoQueryParamsInDev = __DEV__ ? noQueryParamsFriendlyErrors : passthrough export const throwErrorIfQueryParams = (url: string) => { if (new URI(url).search().length) { throw new Error('Must pass query params as data arg') } return Promise.resolve() } export const transformGetToPost = (args: GetRequest): PostRequest => { const { data, ...rest } = args const { fields, include } = data const fieldsArray = Object.entries(fields).map(([key, value]) => { if (Array.isArray(value)) { return [key, value.join(',')] } return [key, value] }) return { ...rest, data: { fields: Object.fromEntries(fieldsArray), include, }, } }