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

refactor: use Fetch API for HttpService #2349

Merged
merged 8 commits into from Jul 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/api/jest.config.js
Expand Up @@ -6,4 +6,5 @@ module.exports = {
transform: {
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
},
};
coverageThreshold: {},
}
144 changes: 144 additions & 0 deletions packages/api/src/Domain/Http/FetchRequestHandler.spec.ts
@@ -0,0 +1,144 @@
import { Environment } from '@standardnotes/models'
import { HttpVerb } from '@standardnotes/responses'
import { FetchRequestHandler } from './FetchRequestHandler'
import { HttpErrorResponseBody, HttpRequest } from '@standardnotes/responses'

import { ErrorMessage } from '../Error'

describe('FetchRequestHandler', () => {
const snjsVersion = 'snjsVersion'
const appVersion = 'appVersion'
const environment = Environment.Web
const requestHandler = new FetchRequestHandler(snjsVersion, appVersion, environment)

it('should create a request', () => {
const httpRequest: HttpRequest = {
url: 'http://localhost:3000/test',
verb: HttpVerb.Get,
external: false,
authentication: 'authentication',
customHeaders: [],
params: {
key: 'value',
},
}

const request = requestHandler['createRequest'](httpRequest)

expect(request).toBeInstanceOf(Request)
expect(request.url).toBe(httpRequest.url)
expect(request.method).toBe(httpRequest.verb)
expect(request.headers.get('X-SNJS-Version')).toBe(snjsVersion)
expect(request.headers.get('X-Application-Version')).toBe(`${Environment[environment]}-${appVersion}`)
expect(request.headers.get('Content-Type')).toBe('application/json')
})

it('should get url for url and params', () => {
const urlWithoutExistingParams = requestHandler['urlForUrlAndParams']('url', { key: 'value' })
expect(urlWithoutExistingParams).toBe('url?key=value')

const urlWithExistingParams = requestHandler['urlForUrlAndParams']('url?key=value', { key2: 'value2' })
expect(urlWithExistingParams).toBe('url?key=value&key2=value2')
})

it('should create request body if not GET', () => {
const body = requestHandler['createRequestBody']({
url: 'url',
verb: HttpVerb.Post,
external: false,
authentication: 'authentication',
customHeaders: [],
params: {
key: 'value',
},
})

expect(body).toBe('{"key":"value"}')
})

it('should not create request body if GET', () => {
const body = requestHandler['createRequestBody']({
url: 'url',
verb: HttpVerb.Get,
external: false,
authentication: 'authentication',
customHeaders: [],
params: {
key: 'value',
},
})

expect(body).toBeUndefined()
})

it('should handle json response', async () => {
const fetchResponse = new Response('{"key":"value"}', {
status: 200,
headers: {
'Content-Type': 'application/json',
},
})

const response = await requestHandler['handleFetchResponse'](fetchResponse)

expect(response).toEqual({
status: 200,
headers: new Map<string, string | null>([['content-type', 'application/json']]),
data: {
key: 'value',
},
key: 'value',
})
})

it('should handle non-json response', async () => {
const fetchResponse = new Response('body', {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
})

const response = await requestHandler['handleFetchResponse'](fetchResponse)

expect(response.status).toBe(200)
expect(response.headers).toEqual(new Map<string, string | null>([['content-type', 'text/plain']]))
expect(response.data).toBeInstanceOf(ArrayBuffer)
})

it('should have ratelimit error when forbidden', async () => {
const fetchResponse = new Response('body', {
status: 403,
headers: {
'Content-Type': 'text/plain',
},
})

const response = await requestHandler['handleFetchResponse'](fetchResponse)

expect(response.status).toBe(403)
expect(response.headers).toEqual(new Map<string, string | null>([['content-type', 'text/plain']]))
expect((response.data as HttpErrorResponseBody).error).toEqual({
message: ErrorMessage.RateLimited,
})
})

describe('should return ErrorResponse when status is not >=200 and <500', () => {
it('should add unknown error message when response has no data', async () => {
const fetchResponse = new Response('', {
status: 599,
headers: {
'Content-Type': 'text/plain',
},
})

const response = await requestHandler['handleFetchResponse'](fetchResponse)

expect(response.status).toBe(599)
expect(response.headers).toEqual(new Map<string, string | null>([['content-type', 'text/plain']]))
expect((response.data as HttpErrorResponseBody).error).toEqual({
message: 'Unknown error',
})
})
})
})
178 changes: 178 additions & 0 deletions packages/api/src/Domain/Http/FetchRequestHandler.ts
@@ -0,0 +1,178 @@
import {
HttpErrorResponse,
HttpRequest,
HttpRequestParams,
HttpResponse,
HttpStatusCode,
HttpVerb,
isErrorResponse,
} from '@standardnotes/responses'
import { RequestHandlerInterface } from './RequestHandlerInterface'
import { Environment } from '@standardnotes/models'
import { isString } from 'lodash'
import { ErrorMessage } from '../Error'

export class FetchRequestHandler implements RequestHandlerInterface {
constructor(
protected readonly snjsVersion: string,
protected readonly appVersion: string,
protected readonly environment: Environment,
) {}

async handleRequest<T>(httpRequest: HttpRequest): Promise<HttpResponse<T>> {
const request = this.createRequest(httpRequest)

const response = await this.runRequest<T>(request, this.createRequestBody(httpRequest))

return response
}

private createRequest(httpRequest: HttpRequest): Request {
if (httpRequest.params && httpRequest.verb === HttpVerb.Get && Object.keys(httpRequest.params).length > 0) {
httpRequest.url = this.urlForUrlAndParams(httpRequest.url, httpRequest.params)
}

const headers: Record<string, string> = {}

if (!httpRequest.external) {
headers['X-SNJS-Version'] = this.snjsVersion

const appVersionHeaderValue = `${Environment[this.environment]}-${this.appVersion}`
headers['X-Application-Version'] = appVersionHeaderValue

if (httpRequest.authentication) {
headers['Authorization'] = 'Bearer ' + httpRequest.authentication
}
}

let contentTypeIsSet = false
if (httpRequest.customHeaders && httpRequest.customHeaders.length > 0) {
httpRequest.customHeaders.forEach(({ key, value }) => {
headers[key] = value
if (key === 'Content-Type') {
contentTypeIsSet = true
}
})
}
if (!contentTypeIsSet && !httpRequest.external) {
headers['Content-Type'] = 'application/json'
}

return new Request(httpRequest.url, {
method: httpRequest.verb,
headers,
})
}

private async runRequest<T>(request: Request, body?: string | Uint8Array | undefined): Promise<HttpResponse<T>> {
const fetchResponse = await fetch(request, {
body,
})

const response = await this.handleFetchResponse<T>(fetchResponse)

return response
}

private async handleFetchResponse<T>(fetchResponse: Response): Promise<HttpResponse<T>> {
const httpStatus = fetchResponse.status
const response: HttpResponse<T> = {
status: httpStatus,
headers: new Map<string, string | null>(),
data: {} as T,
}
fetchResponse.headers.forEach((value, key) => {
;(<Map<string, string | null>>response.headers).set(key, value)
})

try {
if (httpStatus !== HttpStatusCode.NoContent) {
let body

const contentTypeHeader = response.headers?.get('content-type') || response.headers?.get('Content-Type')

if (contentTypeHeader?.includes('application/json')) {
body = JSON.parse(await fetchResponse.text())
} else {
body = await fetchResponse.arrayBuffer()
}
/**
* v0 APIs do not have a `data` top-level object. In such cases, mimic
* the newer response body style by putting all the top-level
* properties inside a `data` object.
*/
if (!body.data) {
response.data = body
}
if (!isString(body)) {
Object.assign(response, body)
}
}
} catch (error) {
console.error(error)
}

if (httpStatus >= HttpStatusCode.Success && httpStatus < HttpStatusCode.InternalServerError) {
if (httpStatus === HttpStatusCode.Forbidden && isErrorResponse(response)) {
if (!response.data.error) {
response.data.error = {
message: ErrorMessage.RateLimited,
}
} else {
response.data.error.message = ErrorMessage.RateLimited
}
}
return response
} else {
const errorResponse = response as HttpErrorResponse
if (!errorResponse.data) {
errorResponse.data = {
error: {
message: 'Unknown error',
},
}
}

if (isString(errorResponse.data)) {
errorResponse.data = {
error: {
message: errorResponse.data,
},
}
}

if (!errorResponse.data.error) {
errorResponse.data.error = {
message: 'Unknown error',
}
}

return errorResponse
}
}

private urlForUrlAndParams(url: string, params: HttpRequestParams) {
const keyValueString = Object.keys(params as Record<string, unknown>)
.map((key) => {
return key + '=' + encodeURIComponent((params as Record<string, unknown>)[key] as string)
})
.join('&')

if (url.includes('?')) {
return url + '&' + keyValueString
} else {
return url + '?' + keyValueString
}
}

private createRequestBody(httpRequest: HttpRequest): string | Uint8Array | undefined {
if (
httpRequest.params !== undefined &&
[HttpVerb.Post, HttpVerb.Put, HttpVerb.Patch, HttpVerb.Delete].includes(httpRequest.verb)
) {
return JSON.stringify(httpRequest.params)
}

return httpRequest.rawBytes
}
}