Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rest-api-link): add support for text/plain and multipart/form-data #651

Merged
merged 7 commits into from
Oct 27, 2020
27 changes: 16 additions & 11 deletions services/data/src/links/RestAPILink/queryToRequestOptions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { ResolvedResourceQuery, FetchType } from '../../engine'
import {
requestContentType,
requestBodyForContentType,
requestHeadersForContentType,
} from './queryToRequestOptions/requestContentType'

const getMethod = (type: FetchType): string => {
switch (type) {
Expand All @@ -17,15 +22,15 @@ const getMethod = (type: FetchType): string => {

export const queryToRequestOptions = (
type: FetchType,
{ data }: ResolvedResourceQuery,
query: ResolvedResourceQuery,
signal?: AbortSignal
): RequestInit => ({
method: getMethod(type),
body: data ? JSON.stringify(data) : undefined,
headers: data
? {
'Content-Type': 'application/json',
}
: undefined,
signal,
})
): RequestInit => {
const contentType = requestContentType(type, query)

return {
method: getMethod(type),
body: requestBodyForContentType(contentType, query),
headers: requestHeadersForContentType(contentType),
signal,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
isFileResourceUpload,
isMessageConversationAttachment,
isStaticContentUpload,
isAppInstall,
} from './multipartFormDataMatchers'

describe('isFileResourceUpload', () => {
it('returns true for a POST to "fileResources"', () => {
expect(
isFileResourceUpload('create', {
resource: 'fileResources',
})
).toEqual(true)
})
it('retuns false for a POST to a different resource', () => {
expect(
isFileResourceUpload('create', {
resource: 'notFileResources',
})
).toEqual(false)
})
})

describe('isMessageConversationAttachment', () => {
it('returns true for a POST to "messageConversations/attachments"', () => {
expect(
isMessageConversationAttachment('create', {
resource: 'messageConversations/attachments',
})
).toEqual(true)
})
it('retuns false for a POST to a different resource', () => {
expect(
isMessageConversationAttachment('create', {
resource: 'messageConversations/notAttachments',
})
).toEqual(false)
})
})

describe('isStaticContentUpload', () => {
it('returns true for a POST to "staticContent/logo_banner"', () => {
expect(
isStaticContentUpload('create', {
resource: 'staticContent/logo_banner',
})
).toEqual(true)
})
it('returns true for a POST to "staticContent/logo_front"', () => {
expect(
isStaticContentUpload('create', {
resource: 'staticContent/logo_front',
})
).toEqual(true)
})
it('returns false for a request to a different resource', () => {
expect(
isStaticContentUpload('create', {
resource: 'staticContent/no_logo',
})
).toEqual(false)
})
})

describe('isAppInstall', () => {
it('returns true for a POST to "fileResources"', () => {
expect(
isAppInstall('create', {
resource: 'apps',
})
).toEqual(true)
})
it('retuns false for a POST to a different resource', () => {
expect(
isAppInstall('create', {
resource: 'notApps',
})
).toEqual(false)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ResolvedResourceQuery, FetchType } from '../../../engine'

/*
* Requests that expect a "multipart/form-data" Content-Type have been collected by scanning
* the developer documentation:
* https://docs.dhis2.org/master/en/developer/html/dhis2_developer_manual_full.html
*/

// POST to 'fileResources' (upload a file resource)
export const isFileResourceUpload = (
type: FetchType,
{ resource }: ResolvedResourceQuery
) => type === 'create' && resource === 'fileResources'

// POST to 'messageConversations/attachments' (upload a message conversation attachment)
export const isMessageConversationAttachment = (
type: FetchType,
{ resource }: ResolvedResourceQuery
) => type === 'create' && resource === 'messageConversations/attachments'

// POST to `staticContent/${key}` (upload staticContent: logo_banner | logo_front)
export const isStaticContentUpload = (
type: FetchType,
{ resource }: ResolvedResourceQuery
) => {
const pattern = /^staticContent\/(?:logo_banner|logo_front)$/
return type === 'create' && pattern.test(resource)
}

// POST to 'apps' (install an app)
export const isAppInstall = (
type: FetchType,
{ resource }: ResolvedResourceQuery
) => type === 'create' && resource === 'apps'
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
requestContentType,
requestHeadersForContentType,
requestBodyForContentType,
FORM_DATA_ERROR_MSG,
} from './requestContentType'

describe('requestContentType', () => {
it('returns "application/json" for a normal resource', () => {
expect(
requestContentType('create', { resource: 'test', data: 'test' })
).toEqual('application/json')
})
it('returns "multipart/form-data" for a specific resource that expects it', () => {
expect(
requestContentType('create', {
resource: 'fileResources',
data: 'test',
})
).toEqual('multipart/form-data')
})
it('returns "text/plain" for a specific resource that expects it', () => {
expect(
requestContentType('create', {
resource: 'messageConversations/feedback',
data: 'test',
})
).toEqual('text/plain')
})
})

describe('requestHeadersForContentType', () => {
it('returns undefined if contentType is null', () => {
expect(requestHeadersForContentType(null)).toEqual(undefined)
})
it('returns undefined if contentType is "multipart/form-data"', () => {
expect(requestHeadersForContentType('multipart/form-data')).toEqual(
undefined
)
})
it('returns a headers object with the contentType for "application/json"', () => {
expect(requestHeadersForContentType('application/json')).toEqual({
'Content-Type': 'application/json',
})
})
it('returns a headers object with the contentType for "text/plain"', () => {
expect(requestHeadersForContentType('text/plain')).toEqual({
'Content-Type': 'text/plain',
})
})
})

describe('requestBodyForContentType', () => {
it('returns undefined if data is undefined', () => {
expect(
requestBodyForContentType('application/json', { resource: 'test' })
).toEqual(undefined)
})
it('JSON stringifies the data if contentType is "application/json"', () => {
const dataIn = { a: 'AAAA', b: 1, c: true }
const dataOut = JSON.stringify(dataIn)

expect(
requestBodyForContentType('application/json', {
resource: 'test',
data: dataIn,
})
).toEqual(dataOut)
})
it('converts to FormData if contentType is "multipart/form-data"', () => {
const file = new File(['foo'], 'foo.txt', { type: 'text/plain' })
const data = { a: 'AAA', file }

const result = requestBodyForContentType('multipart/form-data', {
resource: 'test',
data,
})

expect(result instanceof FormData).toEqual(true)
expect(result.get('a')).toEqual('AAA')
expect(result.get('file')).toEqual(file)
})
it('throws an error if contentType is "multipart/form-data" and data does have own string-keyd properties', () => {
expect(() => {
requestBodyForContentType('multipart/form-data', {
resource: 'test',
data: new File(['foo'], 'foo.txt', { type: 'text/plain' }),
})
}).toThrow(new Error(FORM_DATA_ERROR_MSG))
})
it('returns the data as received if contentType is "text/plain"', () => {
const data = 'Something'

expect(
requestBodyForContentType('text/plain', {
resource: 'messageConversations/feedback',
data,
})
).toEqual(data)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { ResolvedResourceQuery, FetchType } from '../../../engine'
import * as textPlainMatchers from './textPlainMatchers'
import * as multipartFormDataMatchers from './multipartFormDataMatchers'

type RequestContentType =
| 'application/json'
| 'text/plain'
| 'multipart/form-data'
| null

const resourceExpectsTextPlain = (
type: FetchType,
query: ResolvedResourceQuery
) =>
Object.values(textPlainMatchers).some(textPlainMatcher =>
textPlainMatcher(type, query)
)

const resourceExpectsMultipartFormData = (
type: FetchType,
query: ResolvedResourceQuery
) =>
Object.values(multipartFormDataMatchers).some(multipartFormDataMatcher =>
multipartFormDataMatcher(type, query)
)

export const FORM_DATA_ERROR_MSG =
'Could not convert data to FormData: object does not have own enumerable string-keyed properties'

const convertToFormData = (data: Record<string, any>): FormData => {
const dataEntries = Object.entries(data)

if (dataEntries.length === 0) {
throw new Error(FORM_DATA_ERROR_MSG)
}

return dataEntries.reduce((formData, [key, value]) => {
formData.append(key, value)
return formData
}, new FormData())
}

export const requestContentType = (
type: FetchType,
query: ResolvedResourceQuery
) => {
if (!query.data) {
return null
}

if (resourceExpectsTextPlain(type, query)) {
return 'text/plain'
}

if (resourceExpectsMultipartFormData(type, query)) {
return 'multipart/form-data'
}

return 'application/json'
}

export const requestHeadersForContentType = (
contentType: RequestContentType
) => {
/*
* Explicitely setting Content-Type to 'multipart/form-data' produces
* a "multipart boundary not found" error. By not setting a Content-Type
* the browser will correctly set it for us and also apply multipart
* boundaries if the request body is an instance of FormData
* See https://stackoverflow.com/a/39281156/1143502
*/
if (!contentType || contentType === 'multipart/form-data') {
return undefined
}

return { 'Content-Type': contentType }
}

export const requestBodyForContentType = (
contentType: RequestContentType,
{ data }: ResolvedResourceQuery
) => {
if (typeof data === 'undefined') {
return undefined
}

if (contentType === 'application/json') {
return JSON.stringify(data)
}

if (contentType === 'multipart/form-data') {
return convertToFormData(data)
}

// 'text/plain'
return data
}
Loading