summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/index.test.ts391
-rw-r--r--src/index.ts347
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()