Skip to content

Commit

Permalink
feat(rest-api-link): add support for text/plain and multipart/form-da…
Browse files Browse the repository at this point in the history
…ta (#651)

* feat(rest-api-link): set correct content-type for endpoints

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

* feat(rest-api-link): adjust request body according to Content-Type

* fix(rest-api-link): throw error if data can't be converted to form-data

* fix(rest-api-link): prevent boundary error for multipart/form-data

* fix(rest-api-link): add additional multipart/form-data endpoints

* fix(rest-api-link): adjust types for convertToFormData (object>FromData)
  • Loading branch information
HendrikThePendric authored Oct 27, 2020
1 parent b615cac commit 94e21ad
Show file tree
Hide file tree
Showing 7 changed files with 644 additions and 11 deletions.
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

0 comments on commit 94e21ad

Please sign in to comment.