diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/index.test.ts | 391 | ||||
| -rw-r--r-- | src/index.ts | 347 |
2 files changed, 738 insertions, 0 deletions
diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..8d73700 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,391 @@ +import { Factory, Response } from './' + +describe('HTTP Client', () => { + beforeEach(() => { + // @ts-expect-error: TypeScript complaints because 'wx' is not defined on + // the global object in Node.js. We're here to mock the global 'wx' object + // for testing purposes. + global.wx = { + request: jest.fn(), + uploadFile: jest.fn() + } + }) + + afterEach(() => { + // @ts-expect-error: Clean up the mocked global 'wx' object + delete global.wx + }) + + describe('Response', () => { + it.each([[200], [400]])( + 'retrieves the status code', + (statusCode: number) => { + const response = new Response({ + data: {}, + statusCode, + header: {}, + cookies: [] + } as unknown as WechatMiniprogram.RequestSuccessCallbackResult) + expect(response.status()).toBe(statusCode) + } + ) + + it.each([ + [200, true], + [201, false], + [299, false], + [300, false], + [304, false], + [400, false], + [500, false] + ])('should correctly determine if response is OK', (statusCode, ok) => { + const response = new Response({ + data: '', + statusCode, + header: {}, + cookies: [] + } as unknown as WechatMiniprogram.RequestSuccessCallbackResult) + expect(response.ok()).toBe(ok) + }) + + it.each([ + [200, true], + [201, true], + [299, true], + [300, false], + [304, false], + [400, false], + [500, false] + ])( + 'should correctly determine if response is successful', + (statusCode, successful) => { + const response = new Response({ + data: '', + statusCode, + header: {}, + cookies: [] + } as unknown as WechatMiniprogram.RequestSuccessCallbackResult) + expect(response.successful()).toBe(successful) + } + ) + + it.each([ + [200, false], + [201, false], + [300, false], + [304, false], + [400, true], + [401, true], + [402, true], + [499, true], + [500, false] + ])( + 'should correctly determine if response is a client error', + (statusCode, clientError) => { + const response = new Response({ + data: '', + statusCode, + header: {}, + cookies: [] + } as unknown as WechatMiniprogram.RequestSuccessCallbackResult) + expect(response.clientError()).toBe(clientError) + } + ) + + it.each([ + [200, false], + [201, false], + [300, false], + [304, false], + [400, false], + [500, true], + [503, true] + ])( + 'should correctly determine if response is a server error', + (statusCode, serverError) => { + const response = new Response({ + data: '', + statusCode, + header: {}, + cookies: [] + } as unknown as WechatMiniprogram.RequestSuccessCallbackResult) + expect(response.serverError()).toBe(serverError) + } + ) + + it.each([ + [{ foo: 'bar' }, { foo: 'bar' }], + ['foo', 'foo'] + ])('retrieves the data', (data, expected) => { + const response = new Response({ + data, + statusCode: 200, + header: {}, + cookies: [] + } as unknown as WechatMiniprogram.RequestSuccessCallbackResult) + expect(response.data()).toStrictEqual(expected) + }) + + it.each([ + ['{"foo":"bar"}', { foo: 'bar' }], + [{ foo: 'bar' }, { foo: 'bar' }] + ])('decodes the data as JSON string', (data, expected) => { + const response = new Response({ + data, + statusCode: 200, + header: {}, + cookies: [] + } as unknown as WechatMiniprogram.RequestSuccessCallbackResult) + expect(response.json()).toStrictEqual(expected) + }) + + it('returns an empty object when it fails to decode the data as JSON string', () => { + const response = new Response({ + data: '-', + statusCode: 200, + header: {}, + cookies: [] + } as unknown as WechatMiniprogram.RequestSuccessCallbackResult) + expect(response.json()).toStrictEqual({}) + }) + + it('retrieves the raw response', () => { + const rawResponse = { + data: '', + statusCode: 200, + header: {}, + cookies: [] + } as unknown as WechatMiniprogram.RequestSuccessCallbackResult + const response = new Response(rawResponse) + expect(response.raw()).toStrictEqual(rawResponse) + }) + }) + + describe('PendingRequest', () => { + it.each([ + ['get', 'GET'], + ['post', 'POST'], + ['put', 'PUT'], + ['delete', 'DELETE'] + ] as const)('sends HTTP request', (method, httpMethod: string) => { + const factory = new Factory() + factory.new()[method]('http://example.com') + expect(wx.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://example.com', + method: httpMethod + }) + ) + }) + + it.each([ + [{ foo: 'bar' }, '?foo=bar'], + [{ foo: 'bar', baz: 'qux' }, '?foo=bar&baz=qux'], + [{}, ''] + ])('sends HTTP request with query string', (query, expected) => { + const factory = new Factory() + factory.new().get('http://example.com', query) + expect(wx.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `http://example.com${expected}`, + method: 'GET', + data: {} + }) + ) + }) + + it('sends HTTP request with body', () => { + const factory = new Factory() + factory.new().post('http://example.com', { foo: 'bar' }) + expect(wx.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://example.com', + method: 'POST', + data: { foo: 'bar' }, + dataType: 'json' + }) + ) + }) + + it('sends a POST request with query string', () => { + const factory = new Factory() + factory + .new() + .withQuery({ greeting: 'world' }) + .post('http://example.com', { foo: 'bar' }) + expect(wx.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://example.com?greeting=world', + method: 'POST', + data: { foo: 'bar' } + }) + ) + }) + + it('uploads a file with POST request', () => { + const factory = new Factory() + factory.new().upload({ + url: 'http://example.com', + filePath: '/tmp/foo.jpg', + name: 'file' + }) + expect(wx.uploadFile).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://example.com', + filePath: '/tmp/foo.jpg', + name: 'file', + header: expect.objectContaining({ + 'content-type': 'multipart/form-data' + }) + }) + ) + }) + + it('can customize HTTP header', () => { + const factory = new Factory() + factory.new().withHeader('X-Foo', 'bar').get('http://example.com') + expect(wx.request).toHaveBeenCalledWith( + expect.objectContaining({ + header: expect.objectContaining({ + 'X-Foo': 'bar' + }) + }) + ) + }) + + it('can retrieve the request task', () => { + ;(wx.request as jest.Mock).mockImplementationOnce(() => ({ + abort: jest.fn() + })) + const factory = new Factory() + const { task } = factory.new().get('http://example.com') + expect(task.abort).toBeDefined() + }) + + it('can retrieve the HTTP response', async () => { + ;(wx.request as jest.Mock).mockImplementationOnce(({ success }) => { + success({ + data: {}, + statusCode: 200, + header: {}, + cookies: [] + }) + return { abort: jest.fn() } + }) + const factory = new Factory() + const response = await factory.new().get('http://example.com') + expect(response).toBeInstanceOf(Response) + }) + + it.each([ + ['http://example.com', '/', 'http://example.com'], + ['http://example.com/', '/', 'http://example.com'], + ['http://example.com/', '', 'http://example.com'], + ['http://example.com', '/api', 'http://example.com/api'], + ['http://example.com', 'http://other.com', 'http://other.com'], + ['http://example.com', 'https://other.com', 'https://other.com'], + ['http://example.com', 'http://other.com/api', 'http://other.com/api'] + ])('can customize base url', (baseUrl, path, url) => { + const factory = new Factory() + factory.new().baseUrl(baseUrl).get(path) + expect(wx.request).toHaveBeenCalledWith(expect.objectContaining({ url })) + }) + + test('the request builder is immutable', () => { + const factory = new Factory() + const request = factory.new() + request.withHeader('X-Foo', 'bar') + request.get('/') + expect(wx.request).toHaveBeenCalledWith( + expect.objectContaining({ + header: expect.not.objectContaining({ + 'X-Foo': 'bar' + }) + }) + ) + }) + + it('sends HTTP request with HTML forms', () => { + const factory = new Factory() + factory.new().asForm().post('http://example.com', { foo: 'bar' }) + expect(wx.request).toHaveBeenCalledWith( + expect.objectContaining({ + data: { foo: 'bar' }, + header: expect.objectContaining({ + 'content-type': 'application/x-www-form-urlencoded' + }) + }) + ) + }) + + describe('middleware', () => { + it('should be applied', () => { + const middleware = jest.fn((request, next) => next(request)) + const factory = new Factory() + factory.new().use(middleware).get('http://example.com') + expect(middleware).toHaveBeenCalled() + }) + + it('can modify HTTP request', () => { + const middleware = (request: any, next: any) => + next(request.withHeader('X-Foo', 'bar')) + const factory = new Factory() + factory.new().use(middleware).get('http://example.com') + expect(wx.request).toHaveBeenCalledWith( + expect.objectContaining({ + header: expect.objectContaining({ 'X-Foo': 'bar' }) + }) + ) + }) + + it('can retrieve HTTP response', () => { + ;(wx.request as jest.Mock).mockImplementationOnce(({ success }) => { + success({ + data: {}, + statusCode: 200, + header: {}, + cookies: [] + }) + return { abort: jest.fn() } + }) + const middleware = (request: any, next: any) => { + return next(request.withHeader('X-Foo', 'bar')).then( + (response: any) => { + expect(response).toBeInstanceOf(Response) + } + ) + } + const factory = new Factory() + factory.new().use(middleware).get('http://example.com') + }) + }) + }) + + describe('Factory', () => { + it('sets global base url', () => { + const factory = new Factory() + factory.baseUrl('http://example.com') + factory.new().get('/') + factory.new().get('/api') + expect(wx.request).toHaveBeenCalledWith( + expect.objectContaining({ url: 'http://example.com' }) + ) + expect(wx.request).toHaveBeenCalledWith( + expect.objectContaining({ url: 'http://example.com/api' }) + ) + }) + + it('crafts a JSON request by default', () => { + const factory = new Factory() + factory.baseUrl('http://example.com') + factory.new().get('/') + expect(wx.request).toHaveBeenCalledWith( + expect.objectContaining({ + header: expect.objectContaining({ + 'content-type': 'application/json' + }) + }) + ) + }) + }) +}) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..04a1870 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,347 @@ +type MessageHeader = Record<string, string> +type QueryString = Record<string, string> +type HttpMethod = WechatMiniprogram.RequestOption['method'] +type RequestTask = WechatMiniprogram.RequestTask | WechatMiniprogram.UploadTask +type RequestData = Record<string, unknown> +type ResponseData = WechatMiniprogram.RequestSuccessCallbackResult['data'] +type MiddlewareNext<T extends ResponseData> = ( + request: PendingRequest +) => RequestPromise<Response<SuccessCallbackResult<T>, T>> +type Middleware<T extends ResponseData> = ( + request: PendingRequest, + next: MiddlewareNext<T> +) => RequestPromise<Response<SuccessCallbackResult<T>, T>> +type SuccessCallbackResult<T extends ResponseData> = + | WechatMiniprogram.RequestSuccessCallbackResult<T> + | WechatMiniprogram.UploadFileSuccessCallbackResult +type RequestPromise<T> = Promise<T> & { + task: RequestTask +} + +export class Response< + T extends SuccessCallbackResult<U>, + U extends ResponseData +> { + #response + + constructor(response: T) { + this.#response = response + } + + status() { + return this.#response.statusCode + } + + ok() { + return this.status() === 200 + } + + successful() { + const status = this.status() + return status >= 200 && status < 300 + } + + clientError() { + const status = this.status() + return status >= 400 && status < 500 + } + + serverError() { + const status = this.status() + return status >= 500 && status < 600 + } + + data(): T extends SuccessCallbackResult<infer U> ? U : never { + return this.#response.data as T extends SuccessCallbackResult<infer U> + ? U + : never + } + + json() { + const data = this.data() + if (typeof data === 'string') { + try { + return JSON.parse(data) + } catch { + return {} + } + } + return data + } + + raw() { + return this.#response + } +} + +class PendingRequest { + #httpMethod!: HttpMethod + + #baseUrl: string | null = null + + #url!: string + + #query: QueryString = {} + + #header: MessageHeader = {} + + #data: RequestData = {} + + #middlewares: Middleware<any>[] = [] + + method(method: HttpMethod) { + const request = this.#clone() + request.#httpMethod = method + return request + } + + baseUrl(baseUrl: string | null) { + const request = this.#clone() + request.#baseUrl = baseUrl + return request + } + + url(url: string) { + const request = this.#clone() + request.#url = url + return request + } + + withQuery(query: QueryString) { + const request = this.#clone() + request.#query = query + return request + } + + hasHeader(name: string) { + return name in this.#header + } + + withHeader(name: string, value: string) { + const request = this.#clone() + request.#header[name] = value + return request + } + + getHeaderLine(name: string) { + return this.#header[name] ?? null + } + + header() { + return this.#header + } + + asJson() { + return this.withHeader('content-type', 'application/json') + } + + asForm() { + return this.withHeader('content-type', 'application/x-www-form-urlencoded') + } + + asMultipart() { + return this.withHeader('content-type', 'multipart/form-data') + } + + isJson() { + return this.getHeaderLine('content-type') === 'application/json' + } + + withBody(data: RequestData) { + const request = this.#clone() + request.#data = data + return request + } + + body() { + return this.#data + } + + use(fn: Middleware<any> | Middleware<any>[]) { + const request = this.#clone() + const handlers = Array.isArray(fn) ? fn : [fn] + for (const handler of handlers) { + request.#middlewares.push(handler) + } + return request + } + + get<T extends ResponseData>(url: string, data: QueryString = {}) { + return this.method('GET').url(url).withQuery(data).#send<T>() + } + + post<T extends ResponseData>(url: string, data: RequestData = {}) { + return this.method('POST').url(url).withBody(data).#send<T>() + } + + put<T extends ResponseData>(url: string, data: RequestData = {}) { + return this.method('PUT').url(url).withBody(data).#send<T>() + } + + delete<T extends ResponseData>(url: string, data: QueryString = {}) { + return this.method('DELETE').url(url).withQuery(data).#send<T>() + } + + upload( + option: Pick< + WechatMiniprogram.UploadFileOption, + 'url' | 'filePath' | 'name' + > + ) { + const request = this.url(option.url).asMultipart() + + return this.#throughMiddlewares(request)( + this.#dispatchUpload(option.name, option.filePath) + ) + } + + #dispatchUpload(name: string, filePath: string) { + return (request: PendingRequest) => { + let resolveHandler: ( + value: Response< + WechatMiniprogram.UploadFileSuccessCallbackResult, + string + > + ) => void + let rejectHandler: ( + error: WechatMiniprogram.GeneralCallbackResult + ) => void + + const promise = new Promise((resolve, reject) => { + resolveHandler = resolve + rejectHandler = reject + }) as RequestPromise< + Response<WechatMiniprogram.UploadFileSuccessCallbackResult, string> + > + + promise.task = wx.uploadFile({ + url: request.#buildUrl(), + filePath, + name, + header: request.#header, + enableHttp2: true, + success(res) { + resolveHandler(new Response(res)) + }, + fail(err) { + rejectHandler(err) + } + }) + + return promise + } + } + + #send<T extends ResponseData>(): RequestPromise< + Response<SuccessCallbackResult<T>, T> + > { + return this.#throughMiddlewares<T>(this)(this.#dispatchRequest<T>) + } + + #dispatchRequest<T extends ResponseData>(request: PendingRequest) { + let resolveHandler: ( + value: Response<WechatMiniprogram.RequestSuccessCallbackResult<T>, T> + ) => void + let rejectHandler: (error: WechatMiniprogram.RequestFailCallbackErr) => void + + const promise = new Promise((resolve, reject) => { + resolveHandler = resolve + rejectHandler = reject + }) as RequestPromise< + Response<WechatMiniprogram.RequestSuccessCallbackResult<T>, T> + > + + promise.task = wx.request<T>({ + url: request.#buildUrl(), + data: request.body(), + header: request.#header, + method: request.#httpMethod, + dataType: request.isJson() ? 'json' : '其他', + responseType: 'text', + useHighPerformanceMode: true, + success: (res) => { + resolveHandler(new Response(res)) + }, + fail: (err) => { + rejectHandler(err) + } + }) + + return promise + } + + #throughMiddlewares<T extends ResponseData>(request: PendingRequest) { + return (handler: MiddlewareNext<T>) => + this.#middlewares.reduceRight( + (next: MiddlewareNext<T>, middleware) => (request: PendingRequest) => + middleware(request, next), + handler + )(request) + } + + #clone() { + const request = new PendingRequest() + request.#httpMethod = this.#httpMethod + request.#baseUrl = this.#baseUrl + request.#url = this.#url + request.#query = { ...this.#query } + request.#header = { ...this.#header } + request.#data = { ...this.#data } + request.#middlewares = [...this.#middlewares] + return request + } + + #buildUrl() { + let url = this.#url.endsWith('/') ? this.#url.slice(0, -1) : this.#url + + if (this.#baseUrl !== null && !url.match(/^[0-9a-zA-Z]+:\/\//)) { + const baseUrl = this.#baseUrl.endsWith('/') + ? this.#baseUrl.slice(0, -1) + : this.#baseUrl + const path = url.startsWith('/') ? url.slice(1) : url + url = path === '' ? baseUrl : `${baseUrl}/${path}` + } + + const query = this.#buildQueryString() + + return query === '' ? url : `${url}?${query}` + } + + #buildQueryString() { + return Object.keys(this.#query) + .map((name) => + [encodeURIComponent(name), encodeURIComponent(this.#query[name])].join( + '=' + ) + ) + .join('&') + } +} + +export class Factory { + #baseUrl: string | null = null + + #middlewares: Middleware<any>[] = [] + + new() { + return new PendingRequest() + .baseUrl(this.#baseUrl) + .use(this.#middlewares) + .asJson() + } + + baseUrl(baseUrl: string) { + this.#baseUrl = baseUrl + return this + } + + use(fn: Middleware<any> | Middleware<any>[]) { + const handlers = Array.isArray(fn) ? fn : [fn] + for (const handler of handlers) { + this.#middlewares.push(handler) + } + return this + } +} + +export const Client = new Factory() |
