-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(rest-api-link): add support for text/plain and multipart/form-da…
…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
1 parent
b615cac
commit 94e21ad
Showing
7 changed files
with
644 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
81 changes: 81 additions & 0 deletions
81
services/data/src/links/RestAPILink/queryToRequestOptions/multipartFormDataMatchers.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
}) |
34 changes: 34 additions & 0 deletions
34
services/data/src/links/RestAPILink/queryToRequestOptions/multipartFormDataMatchers.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
101 changes: 101 additions & 0 deletions
101
services/data/src/links/RestAPILink/queryToRequestOptions/requestContentType.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
}) |
97 changes: 97 additions & 0 deletions
97
services/data/src/links/RestAPILink/queryToRequestOptions/requestContentType.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.