From 45d2b6ad1d490817edb98df933779b19753a2e48 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Wed, 23 Jul 2025 17:35:38 +0800 Subject: first commit --- .editorconfig | 11 ++ .gitignore | 3 + .prettierrc | 3 + LICENSE | 21 +++ README.md | 1 + jest.config.ts | 8 ++ package.json | 35 +++++ src/index.test.ts | 391 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 347 ++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 18 +++ 10 files changed, 838 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 LICENSE create mode 100644 README.md create mode 100644 jest.config.ts create mode 100644 package.json create mode 100644 src/index.test.ts create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f6fcd03 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# EditorConfig is awesome: https://editorconfig.org + +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace=true +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a99321 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..eca9e73 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +semi: false +singleQuote: true +trailingComma: none diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..25e5857 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Zhineng Li + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c77f46 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# The HTTP Client for WeChat Mini Program diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..1c6e6c1 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,8 @@ +import type { Config } from 'jest' + +const config: Config = { + preset: 'ts-jest', + roots: ['/src'] +} + +export default config diff --git a/package.json b/package.json new file mode 100644 index 0000000..baf4b26 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "@beautiful-bubble/wave", + "version": "0.1.0", + "description": "The HTTP client.", + "keywords": [ + "wechat-miniprogram", + "http-client" + ], + "homepage": "https://github.com/Beautiful-Bubble/http-client#readme", + "bugs": { + "url": "https://github.com/Beautiful-Bubble/http-client/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Beautiful-Bubble/http-client.git" + }, + "license": "MIT", + "author": "Zhineng Li", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "test": "jest", + "test:watch": "jest --watch", + "format": "prettier --write --cache ." + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "jest": "^30.0.5", + "miniprogram-api-typings": "^4.1.0", + "prettier": "3.6.2", + "ts-jest": "^29.4.0", + "typescript": "^5.8.3" + } +} 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 +type QueryString = Record +type HttpMethod = WechatMiniprogram.RequestOption['method'] +type RequestTask = WechatMiniprogram.RequestTask | WechatMiniprogram.UploadTask +type RequestData = Record +type ResponseData = WechatMiniprogram.RequestSuccessCallbackResult['data'] +type MiddlewareNext = ( + request: PendingRequest +) => RequestPromise, T>> +type Middleware = ( + request: PendingRequest, + next: MiddlewareNext +) => RequestPromise, T>> +type SuccessCallbackResult = + | WechatMiniprogram.RequestSuccessCallbackResult + | WechatMiniprogram.UploadFileSuccessCallbackResult +type RequestPromise = Promise & { + task: RequestTask +} + +export class Response< + T extends SuccessCallbackResult, + 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 ? U : never { + return this.#response.data as T extends SuccessCallbackResult + ? 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[] = [] + + 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 | Middleware[]) { + const request = this.#clone() + const handlers = Array.isArray(fn) ? fn : [fn] + for (const handler of handlers) { + request.#middlewares.push(handler) + } + return request + } + + get(url: string, data: QueryString = {}) { + return this.method('GET').url(url).withQuery(data).#send() + } + + post(url: string, data: RequestData = {}) { + return this.method('POST').url(url).withBody(data).#send() + } + + put(url: string, data: RequestData = {}) { + return this.method('PUT').url(url).withBody(data).#send() + } + + delete(url: string, data: QueryString = {}) { + return this.method('DELETE').url(url).withQuery(data).#send() + } + + 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 + > + + 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(): RequestPromise< + Response, T> + > { + return this.#throughMiddlewares(this)(this.#dispatchRequest) + } + + #dispatchRequest(request: PendingRequest) { + let resolveHandler: ( + value: Response, T> + ) => void + let rejectHandler: (error: WechatMiniprogram.RequestFailCallbackErr) => void + + const promise = new Promise((resolve, reject) => { + resolveHandler = resolve + rejectHandler = reject + }) as RequestPromise< + Response, T> + > + + promise.task = wx.request({ + 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(request: PendingRequest) { + return (handler: MiddlewareNext) => + this.#middlewares.reduceRight( + (next: MiddlewareNext, 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[] = [] + + new() { + return new PendingRequest() + .baseUrl(this.#baseUrl) + .use(this.#middlewares) + .asJson() + } + + baseUrl(baseUrl: string) { + this.#baseUrl = baseUrl + return this + } + + use(fn: Middleware | Middleware[]) { + const handlers = Array.isArray(fn) ? fn : [fn] + for (const handler of handlers) { + this.#middlewares.push(handler) + } + return this + } +} + +export const Client = new Factory() diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6d53b39 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2016", + "lib": ["ES2020"], + "module": "commonjs", + "declaration": true, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "typeRoots": [ + "./node_modules/@types", + "./node_modules/miniprogram-api-typings" + ] + } +} -- cgit v1.2.3