diff options
| author | Li Zhineng <[email protected]> | 2025-07-23 17:35:38 +0800 |
|---|---|---|
| committer | Li Zhineng <[email protected]> | 2025-07-23 17:35:38 +0800 |
| commit | 45d2b6ad1d490817edb98df933779b19753a2e48 (patch) | |
| tree | 1fa187ae3f765753285f3587c928a6feec07e228 | |
| download | wave-45d2b6ad1d490817edb98df933779b19753a2e48.tar.gz wave-45d2b6ad1d490817edb98df933779b19753a2e48.zip | |
first commit
| -rw-r--r-- | .editorconfig | 11 | ||||
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | .prettierrc | 3 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | jest.config.ts | 8 | ||||
| -rw-r--r-- | package.json | 35 | ||||
| -rw-r--r-- | src/index.test.ts | 391 | ||||
| -rw-r--r-- | src/index.ts | 347 | ||||
| -rw-r--r-- | tsconfig.json | 18 |
10 files changed, 838 insertions, 0 deletions
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 @@ -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: ['<rootDir>/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<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() 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" + ] + } +} |
