Merge pull request #10 from levchak0910/improve-request

feat: improve request
This commit is contained in:
mutoe 2020-10-19 19:07:26 +08:00 committed by GitHub
commit 6bc058b8ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 241 additions and 205 deletions

View File

@ -2,8 +2,12 @@ import router from './routes'
import { createApp } from 'vue'
import App from './App.vue'
import store from './store'
import registerGlobalComponents from './plugins/global-components'
import { request } from './services'
import parseStorageGet from './utils/parse-storage-get'
const token = parseStorageGet('user')?.token
request.setAuthorizationHeader(token)
const app = createApp(App)
app.use(router)

View File

@ -1,6 +1,8 @@
import { MutationTree } from 'vuex'
import { Store } from './index'
import { request } from '../services'
export const MUTATION = {
UPDATE_USER: 'UPDATE_USER',
}
@ -9,9 +11,11 @@ const mutations: MutationTree<Store> = {
[MUTATION.UPDATE_USER] (state, user: User|null) {
if (!user) {
localStorage.removeItem('user')
request.deleteAuthorizationHeader()
state.user = null
} else {
localStorage.setItem('user', JSON.stringify(user))
request.setAuthorizationHeader(user.token)
state.user = user
}
},

View File

@ -0,0 +1,3 @@
export default function params2query (params: Record<string, string | number | boolean>): string {
return Object.entries(params).map(([key, value]) => `${key}=${value.toString()}`).join('&')
}

View File

@ -1,208 +1,186 @@
import FetchRequest from 'src/utils/request'
import params2query from 'src/utils/params-to-query'
import mockFetch from 'src/utils/test/mock-fetch'
import wrapTests from 'src/utils/test/wrap-tests'
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
async json () {
return {}
},
})
mockFetch({ type: 'body' })
})
afterEach(() => {
jest.clearAllMocks()
})
describe('# Request GET', function () {
it('should implement GET method', async function () {
const request = new FetchRequest()
await request.get('/path')
const PREFIX = '/prefix'
const SUB_PREFIX = '/sub-prefix'
const PATH = '/path'
const PARAMS = { q1: 'q1', q2: 'q2' }
expect(global.fetch).toBeCalledTimes(1)
expect(global.fetch).toBeCalledWith('/path', expect.objectContaining({
method: 'GET',
}))
type SafeMethod = 'get' | 'delete'
type UnsafeMethod = 'post' | 'put' | 'patch'
type Method = SafeMethod | UnsafeMethod
const SAFE_METHODS: SafeMethod[] = ['get', 'delete']
const UNSAFE_METHODS: UnsafeMethod[] = ['post', 'put', 'patch']
function isSafe (method: Method): method is SafeMethod {
return ['get', 'delete'].includes(method)
}
async function triggerMethod<T = any> (request: FetchRequest, method: Method, options?: any): Promise<T> {
let body: T
if (isSafe(method)) body = await request[method]<T>(PATH, options)
else body = await request[method]<T>(PATH, {}, options)
return body
}
function forAllMethods (task: string, fn: (method: Method) => void): void {
wrapTests<Method>({
task,
fn,
list: [...UNSAFE_METHODS, ...SAFE_METHODS],
testName: method => `for method: ${method}`,
})
}
forAllMethods('# Should be implemented', async (method) => {
const request = new FetchRequest()
triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining({
method: method.toUpperCase(),
}))
})
describe('# Should implement prefix', () => {
forAllMethods('should implement global prefix', async (method) => {
const request = new FetchRequest({ prefix: PREFIX })
triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(`${PREFIX}${PATH}`, expect.any(Object))
})
it('should can set prefix of request url with global', async function () {
const request = new FetchRequest({ prefix: '/prefix' })
await request.get('/path')
expect(global.fetch).toBeCalledWith('/prefix/path', expect.any(Object))
})
it('should can be set prefix of request url with single request', async function () {
const request = new FetchRequest()
await request.get('/path', { prefix: '/prefix' })
expect(global.fetch).toBeCalledWith('/prefix/path', expect.any(Object))
})
it('can be convert query object to query string in request url', async function () {
const request = new FetchRequest()
await request.get('/path', { params: { foo: 'bar' } })
expect(global.fetch).toBeCalledWith('/path?foo=bar', expect.any(Object))
})
it('should converted response body to json', async function () {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
async json () {
return {
foo: 'bar',
}
},
})
const request = new FetchRequest()
const response = await request.get('/path')
expect(response).toMatchObject({ foo: 'bar' })
})
it('should throw Error with response when request status code is not 2xx', async function () {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 400,
statusText: 'Bad request',
async json () {
return {}
},
})
forAllMethods('should implement local prefix', async (method) => {
const request = new FetchRequest()
await expect(request.post('/path')).rejects.toThrow('Bad request')
triggerMethod(request, method, { prefix: SUB_PREFIX })
expect(global.fetch).toBeCalledWith(`${SUB_PREFIX}${PATH}`, expect.any(Object))
})
forAllMethods('should implement global + local prefix', async (method) => {
const request = new FetchRequest({ prefix: PREFIX })
triggerMethod(request, method, { prefix: SUB_PREFIX })
expect(global.fetch).toBeCalledWith(`${SUB_PREFIX}${PATH}`, expect.any(Object))
})
})
describe('# Request POST', function () {
it('should implement POST method', async function () {
const request = new FetchRequest()
await request.post('/path')
describe('# Should convert query object to query string in request url', () => {
forAllMethods('should implement global query', async (method) => {
const request = new FetchRequest({ params: PARAMS })
expect(global.fetch).toBeCalledTimes(1)
expect(global.fetch).toBeCalledWith('/path', expect.objectContaining({
method: 'POST',
}))
triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(`${PATH}?${params2query(PARAMS)}`, expect.any(Object))
})
it('should can set prefix of request url with global', async function () {
const request = new FetchRequest({ prefix: '/prefix' })
await request.post('/path')
expect(global.fetch).toBeCalledWith('/prefix/path', expect.any(Object))
})
it('should can be set prefix of request url with single request', async function () {
const request = new FetchRequest()
await request.post('/path', {}, { prefix: '/prefix' })
expect(global.fetch).toBeCalledWith('/prefix/path', expect.any(Object))
})
it('should can be send json data in request body', async function () {
const request = new FetchRequest()
await request.post('/path', { foo: 'bar' })
expect(global.fetch).toBeCalledWith('/path', expect.objectContaining({
body: JSON.stringify({ foo: 'bar' }),
}))
})
it('can be convert query object to query string in request url', async function () {
const request = new FetchRequest()
await request.post('/path', {}, { params: { foo: 'bar' } })
expect(global.fetch).toBeCalledWith('/path?foo=bar', expect.any(Object))
})
it('should converted response body to json', async function () {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
async json () {
return {
foo: 'bar',
}
},
})
const request = new FetchRequest()
const response = await request.post('/path')
expect(response).toMatchObject({ foo: 'bar' })
})
it('should throw Error with response when request status code is not 2xx', async function () {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 400,
statusText: 'Bad request',
async json () {
return {}
},
})
forAllMethods('should implement local query', async (method) => {
const request = new FetchRequest()
await expect(request.post('/path')).rejects.toThrow('Bad request')
triggerMethod(request, method, { params: PARAMS })
expect(global.fetch).toBeCalledWith(`${PATH}?${params2query(PARAMS)}`, expect.any(Object))
})
it('should throw Error with 4xx code', function () {
global.fetch = jest.fn().mockReturnValue({
ok: true,
status: 422,
async json () {
return {
errors: {
some: ['error'],
},
}
},
})
forAllMethods('should implement global + local query', async (method) => {
const options = { params: { q1: 'q1', q2: 'q2' } }
const localOptions = { params: { q1: 'q11', q3: 'q3' } }
const expectedOptions = { params: { q1: 'q11', q2: 'q2', q3: 'q3' } }
const request = new FetchRequest(options)
const request = new FetchRequest()
triggerMethod(request, method, localOptions)
expect(() => {
request.get('/')
}).toThrow()
expect(global.fetch).toBeCalledWith(`${PATH}?${params2query(expectedOptions.params)}`, expect.any(Object))
})
})
describe('# Request DELETE', function () {
it('should implement DELETE method', async function () {
const request = new FetchRequest()
await request.delete('/path')
describe('# Should work with headers', function () {
forAllMethods('should add headers', async function (method) {
const options = { headers: { h1: 'h1', h2: 'h2' } }
const request = new FetchRequest(options)
expect(global.fetch).toBeCalledTimes(1)
expect(global.fetch).toBeCalledWith('/path', expect.objectContaining({
method: 'DELETE',
}))
await triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining(options))
})
forAllMethods('should merge headers', async function (method) {
const options = { headers: { h1: 'h1', h2: 'h2' } }
const localOptions = { headers: { h1: 'h11', h3: 'h3' } }
const expectedOptions = { headers: { h1: 'h11', h2: 'h2', h3: 'h3' } }
const request = new FetchRequest(options)
await triggerMethod(request, method, localOptions)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining(expectedOptions))
})
})
describe('# Request PUT', function () {
it('should implement PUT method', async function () {
forAllMethods('# Should converted response body to json', async function (method) {
const DATA = { foo: 'bar' }
mockFetch({ type: 'body', ...DATA })
const request = new FetchRequest()
const body = await triggerMethod(request, method)
expect(body).toMatchObject(DATA)
})
forAllMethods('# Should throw Error with response when request status code is not 2xx', async function (method) {
mockFetch({
type: 'full',
ok: false,
status: 400,
statusText: 'Bad request',
json: async () => ({}),
})
const request = new FetchRequest()
await expect(triggerMethod(request, method)).rejects.toThrow('Bad request')
})
describe('# Authorization header', function () {
const TOKEN = 'token'
const OPTIONS = { headers: { Authorization: `Token ${TOKEN}` } }
forAllMethods('should add authorization header', async function (method) {
const request = new FetchRequest()
await request.put('/path')
request.setAuthorizationHeader(TOKEN)
triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining(OPTIONS))
})
forAllMethods('should remove authorization header', async function (method) {
const request = new FetchRequest(OPTIONS)
await triggerMethod(request, method)
expect(global.fetch).toBeCalledTimes(1)
expect(global.fetch).toBeCalledWith('/path', expect.objectContaining({
method: 'PUT',
}))
})
})
describe('# Request PATCH', function () {
it('should implement PATCH method', async function () {
const request = new FetchRequest()
await request.patch('/path')
expect(global.fetch).toBeCalledTimes(1)
expect(global.fetch).toBeCalledWith('/path', expect.objectContaining({
method: 'PATCH',
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining(OPTIONS))
request.deleteAuthorizationHeader()
await triggerMethod(request, method)
expect(global.fetch).toBeCalledTimes(2)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining({
headers: {},
}))
})
})

View File

@ -1,4 +1,5 @@
import parseStorageGet from './parse-storage-get'
import merge from 'deepmerge'
import params2query from './params-to-query'
interface FetchRequestOptions {
prefix: string;
@ -8,32 +9,33 @@ interface FetchRequestOptions {
}
export default class FetchRequest {
defaultOptions: FetchRequestOptions = {
private defaultOptions: FetchRequestOptions = {
prefix: '',
headers: {},
params: {},
responseInterceptor: (response) => response,
}
public options: FetchRequestOptions
private options: FetchRequestOptions
constructor (options: Partial<FetchRequestOptions> = {}) {
this.options = Object.assign({}, this.defaultOptions, options)
this.options = merge(this.defaultOptions, options)
}
private generateFinalUrl = (url: string, options: Partial<FetchRequestOptions> = {}) => {
const prefix = options.prefix || this.options.prefix || ''
const params = options.params || {}
const prefix = options.prefix ?? this.options.prefix
const params = merge(this.options.params, options.params ?? {})
let finalUrl = `${prefix}${url}`
if (Object.keys(params).length) {
const queryString = Object.keys(params).map(key => `${key}=${params[key]}`).join('&')
finalUrl += `?${queryString}`
}
if (Object.keys(params).length) finalUrl += `?${params2query(params)}`
return finalUrl
}
private generateFinalHeaders = (options: Partial<FetchRequestOptions> = {}) => {
return merge(this.options.headers, options.headers ?? {})
}
private handleResponse = (response: Response) => {
this.options.responseInterceptor(response)
return response.json()
@ -51,74 +53,69 @@ export default class FetchRequest {
}
get<T = any> (url: string, options: Partial<FetchRequestOptions> = {}): Promise<T> {
options.headers = options.headers ?? {}
const token = parseStorageGet('user')?.token
if (token) options.headers.Authorization = `Token ${token}`
const finalUrl = this.generateFinalUrl(url, options)
const headers = this.generateFinalHeaders(options)
return fetch(finalUrl, {
method: 'GET',
headers: this.options.headers,
headers,
})
.then(this.handleResponse)
}
post<T = any> (url: string, data: Record<string, any> = {}, options: Partial<FetchRequestOptions> = {}): Promise<T> {
options.headers = options.headers ?? {}
const token = parseStorageGet('user')?.token
if (token) options.headers.Authorization = `Token ${token}`
const finalUrl = this.generateFinalUrl(url, options)
const headers = this.generateFinalHeaders(options)
return fetch(finalUrl, {
method: 'POST',
body: JSON.stringify(data),
headers: this.options.headers,
headers,
})
.then(this.handleResponse)
}
delete<T = any> (url: string, options: Partial<FetchRequestOptions> = {}): Promise<T> {
options.headers = options.headers ?? {}
const token = parseStorageGet('user')?.token
if (token) options.headers.Authorization = `Token ${token}`
const finalUrl = this.generateFinalUrl(url, options)
const headers = this.generateFinalHeaders(options)
return fetch(finalUrl, {
method: 'DELETE',
headers: this.options.headers,
headers,
})
.then(this.handleResponse)
}
put<T = any> (url: string, data: Record<string, any> = {}, options: Partial<FetchRequestOptions> = {}): Promise<T> {
options.headers = options.headers ?? {}
const token = parseStorageGet('user')?.token
if (token) options.headers.Authorization = `Token ${token}`
const finalUrl = this.generateFinalUrl(url, options)
const headers = this.generateFinalHeaders(options)
return fetch(finalUrl, {
method: 'PUT',
body: JSON.stringify(data),
headers: this.options.headers,
headers,
})
.then(this.handleResponse)
}
patch<T = any> (url: string, data: Record<string, any> = {}, options: Partial<FetchRequestOptions> = {}): Promise<T> {
options.headers = options.headers ?? {}
const token = parseStorageGet('user')?.token
if (token) options.headers.Authorization = `Token ${token}`
const finalUrl = this.generateFinalUrl(url, options)
const headers = this.generateFinalHeaders(options)
return fetch(finalUrl, {
method: 'PATCH',
body: JSON.stringify(data),
headers: this.options.headers,
headers,
})
.then(this.handleResponse)
}
public setAuthorizationHeader (token: string): void {
if (!this.options.headers) this.options.headers = {}
this.options.headers.Authorization = `Token ${token}`
}
public deleteAuthorizationHeader (): void {
delete this.options?.headers?.Authorization
}
}

View File

@ -0,0 +1,27 @@
interface FetchResponseBody {
type: 'body'
}
interface FetchResponseFull {
type: 'full'
ok: boolean,
status: number,
statusText:string
json: (...args: any) => Promise<any>
}
export default function mockFetch (data: FetchResponseBody | FetchResponseFull): void {
let response
const { type, ...body } = data
if (type === 'body') {
response = {
ok: true,
status: 200,
json: async () => body,
}
} else {
response = body
}
global.fetch = jest.fn().mockResolvedValue(response)
}

View File

@ -0,0 +1,23 @@
interface WrapTestsProps <Item> {
task: string
list: Item[]
fn: (item: Item) => void,
only?: boolean,
testName?: (item: Item, index: number) => string
}
function wrapTests<Item> ({ task, list, fn, testName, only = false }: WrapTestsProps<Item>): void {
const descFn = only ? describe.only : describe
descFn(task, () => {
list.forEach((item, index) => {
const name = testName ? testName(item, index) : ''
it(name, () => fn(item))
})
})
}
wrapTests.only = function <Item> ({ task, list, fn, testName }: WrapTestsProps<Item>): ReturnType<typeof wrapTests> {
wrapTests({ task, list, fn, testName, only: true })
}
export default wrapTests