diff --git a/.gitignore b/.gitignore index f363516..d8e4125 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,5 @@ dist # TernJS port file .tern-port -lib \ No newline at end of file +lib +debug diff --git a/README.md b/README.md index 94d0d16..f5a5c41 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,7 @@ const api = new MediaWikiApi('https://zh.moegirl.org.cn/api.php') **在浏览器中直接使用/Use directly in the browser** ```js -import('https://unpkg.com/wiki-saikou').then(() => { - const { MediaWikiApi } = globalThis.WikiSaikou +import('https://unpkg.com/wiki-saikou?module').then(({ MediaWikiApi }) => { const api = new MediaWikiApi('https://zh.moegirl.org.cn/api.php') // ... }) @@ -66,25 +65,25 @@ Below is the documentation of MediaWikiApi. **Main methods**: -#### `new MediaWikiApi(baseURL?: string, options?: AxiosRequestConfig)` +#### `new MediaWikiApi(baseURL?: string, options?: LylaRequestOptions)` - `baseURL`: API endpoint of your target wiki site (e.g. https://mediawiki.org/w/api.php) - **Not required but with conditions**: If you are using it in the browser environment, and the website runs MediaWiki. The instance will automatically use the API endpoint of current wiki. -- `options`: {AxiosRequestConfig} +- `options`: {LylaRequestOptions} #### `login(username: string, password: string): Promise<{ result: 'Success' | 'Failed'; lguserid: number; lgusername: string }>` Login your account. -#### `get(params: MwApiParams, options?: AxiosRequestConfig): Promise>` +#### `get(params: MwApiParams, options?: LylaRequestOptions): Promise>` Make `GET` request -#### `post(body: MwApiParams, options?: AxiosRequestConfig): Promise>` +#### `post(body: MwApiParams, options?: LylaRequestOptions): Promise>` Make `POST` request -#### `postWithToken(tokenType: MwTokenName, body: MwApiParams, options?: AxiosRequestConfig): Promise>` +#### `postWithToken(tokenType: MwTokenName, body: MwApiParams, options?: LylaRequestOptions): Promise>` Make `POST` request with specified token. @@ -101,20 +100,20 @@ type MwTokenName = ### Auxiliary utilities -#### `get ajax` {AxiosInstance} +#### `get request` {AxiosInstance} -Get `AxiosInstance` of current MediaWikiApi instance +Get `Lyla` instance of current MediaWikiApi instance -#### `MediaWikiApi.adjustParamValue(params: MwApiParams): Record` (static) +#### `MediaWikiApi.normalizeParamValue(params: MwApiParams[keyof MwApiParams]): string | File | undefined` (static) -Adjust input params to standard MediaWiki request params. +Normalize input params to standard MediaWiki request params. - `string[] → string`: `['foo', 'bar', 'baz'] → 'foo|bar|baz` - `false → undefined`: remove false items -#### `MediaWikiApi.createAxiosInstance(payload: { baseURL: string; params: MwApiParams; options: AxiosRequestConfig })` (static) +#### `MediaWikiApi.createLylaInstance(baseURL: string, options?: LylaRequestOptions): Lyla` (static) -Create your own axios instance. +Create your own Lyla instance. **Warning: The instance created by this method does not include responsive getters/setters (described below) and the out of box cookie controls.** diff --git a/debug/index.cjs b/debug/index.cjs deleted file mode 100644 index c537daa..0000000 --- a/debug/index.cjs +++ /dev/null @@ -1,13 +0,0 @@ -const { MediaWikiApi } = require('..') -const { env } = require('node:process') - -const api = new MediaWikiApi('https://zh.moegirl.org.cn/api.php', { - headers: { - 'api-user-agent': env.MOEGIRL_API_USER_AGENT || '', - }, -}) - -const username = env.MOEGIRL_USERNAME || '' -const password = env.MOEGIRL_PASSWORD || '' - -api.login(username, password).then(console.info) diff --git a/debug/index.mjs b/debug/index.mjs deleted file mode 100644 index b641f2a..0000000 --- a/debug/index.mjs +++ /dev/null @@ -1,13 +0,0 @@ -import { MediaWikiApi } from '../dist/index.js' -import { env } from 'node:process' - -const api = new MediaWikiApi('https://zh.moegirl.org.cn/api.php', { - headers: { - 'api-user-agent': env.MOEGIRL_API_USER_AGENT || '', - }, -}) - -const username = env.MOEGIRL_USERNAME || '' -const password = env.MOEGIRL_PASSWORD || '' - -api.login(username, password).then(console.info) diff --git a/package.json b/package.json index ab40b6d..1be8f51 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "wiki-saikou", - "version": "1.4.1", + "version": "2.0.0", "description": "The library provides the out of box accessing to MediaWiki API in both browsers & Node.js, and the syntax is very similar to vanilla `new mw.Api()`. TypeScript definition included~", "main": "./lib/index.js", "types": "./lib/index.d.ts", "browser": "./dist/index.umd.js", + "module": "./dist/index.mjs", "files": [ "dist", "lib" @@ -40,8 +41,8 @@ }, "homepage": "https://github.com/moegirlwiki/wiki-saikou#readme", "dependencies": { - "@vue/reactivity": "^3.3.4", - "axios": "^1.4.0" + "@lylajs/core": "^1.2.0", + "@vue/reactivity": "^3.3.4" }, "devDependencies": { "@types/chai": "^4.3.5", @@ -50,6 +51,7 @@ "@types/node": "^18.16.19", "chai": "^4.3.7", "chai-as-promised": "^7.1.1", + "dotenv": "^16.3.1", "esbuild-register": "^3.4.2", "mocha": "^10.2.0", "rimraf": "^5.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bf535f..2741f97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,16 +1,16 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false dependencies: + '@lylajs/core': + specifier: ^1.2.0 + version: 1.2.0 '@vue/reactivity': specifier: ^3.3.4 version: 3.3.4 - axios: - specifier: ^1.4.0 - version: 1.4.0 devDependencies: '@types/chai': @@ -31,6 +31,9 @@ devDependencies: chai-as-promised: specifier: ^7.1.1 version: 7.1.1(chai@4.3.7) + dotenv: + specifier: ^16.3.1 + version: 16.3.1 esbuild-register: specifier: ^3.4.2 version: 3.4.2(esbuild@0.18.11) @@ -520,6 +523,10 @@ packages: wrap-ansi-cjs: /wrap-ansi@7.0.0 dev: true + /@lylajs/core@1.2.0: + resolution: {integrity: sha512-JFkOzbi3i6g2Ho4Ec9AknOuL8lyr8jinQh3XPuv8QSJ0PIJG1wGMDgKPUEOeXJbKY5tKu7Y97+dP7oidpAehvw==} + dev: false + /@microsoft/api-extractor-model@7.27.4(@types/node@18.16.19): resolution: {integrity: sha512-HjqQFmuGPOS20rtnu+9Jj0QrqZyR59E+piUWXPMZTTn4jaZI+4UmsHSf3Id8vyueAhOBH2cgwBuRTE5R+MfSMw==} dependencies: @@ -813,20 +820,6 @@ packages: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true - /asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - dev: false - - /axios@1.4.0: - resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==} - dependencies: - follow-redirects: 1.15.1 - form-data: 4.0.0 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - dev: false - /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true @@ -957,13 +950,6 @@ packages: engines: {node: '>=0.1.90'} dev: true - /combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - dependencies: - delayed-stream: 1.0.0 - dev: false - /commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} requiresBuild: true @@ -1017,11 +1003,6 @@ packages: type-detect: 4.0.8 dev: true - /delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - dev: false - /diff@3.5.0: resolution: {integrity: sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==} engines: {node: '>=0.3.1'} @@ -1039,6 +1020,11 @@ packages: path-type: 4.0.0 dev: true + /dotenv@16.3.1: + resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} + engines: {node: '>=12'} + dev: true + /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true @@ -1196,16 +1182,6 @@ packages: hasBin: true dev: true - /follow-redirects@1.15.1: - resolution: {integrity: sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - dev: false - /foreground-child@3.1.1: resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} engines: {node: '>=14'} @@ -1214,15 +1190,6 @@ packages: signal-exit: 4.0.2 dev: true - /form-data@4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} - engines: {node: '>= 6'} - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - dev: false - /fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -1551,18 +1518,6 @@ packages: picomatch: 2.3.1 dev: true - /mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - dev: false - - /mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - dependencies: - mime-db: 1.52.0 - dev: false - /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -1783,10 +1738,6 @@ packages: source-map-js: 1.0.2 dev: true - /proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: false - /punycode@2.1.1: resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==} engines: {node: '>=6'} diff --git a/src/index.ts b/src/index.ts index a75cda6..88d2d61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,18 +6,28 @@ * @license MIT */ -import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' import { Ref, ref, computed, ComputedRef } from '@vue/reactivity' +import { + LylaAdapterMeta, + createLyla, + LylaRequestOptions, + LylaResponse, +} from './modules/lyla-adapter-fetch' +import { Lyla } from '@lylajs/core' + +type LylaResponseWith = LylaResponse & { + data: T +} export class MediaWikiApi { baseURL: Ref - #defaultOptions: Ref> + #requestHandler: ComputedRef> + #defaultOptions: Ref #defaultParams: Ref #tokens: Record - #axiosInstance: ComputedRef cookies: Record = {} - constructor(baseURL?: string, options?: AxiosRequestConfig) { + constructor(baseURL?: string, options?: LylaRequestOptions) { // For MediaWiki environment if (!baseURL && typeof window === 'object' && (window as any).mediaWiki) { const scriptPath: string | undefined = ( @@ -43,112 +53,200 @@ export class MediaWikiApi { } this.defaultOptions = options || {} - // Init AxiosInstance - this.#axiosInstance = computed(() => { - const instance = MediaWikiApi.createAxiosInstance({ - baseURL: this.baseURL.value, - params: this.#defaultParams.value, - options: this.#defaultOptions.value, + this.#requestHandler = computed(() => { + const options: LylaRequestOptions = { + ...this.#defaultOptions.value, + } + + options.hooks ??= {} + options.hooks.onInit ??= [] + options.hooks.onBeforeRequest ??= [] + options.hooks.onAfterResponse ??= [] + + // Inject default query params + options.hooks.onInit?.unshift((ctx) => { + // @ts-ignore FIXME: Type error during vite build, too bad! + ctx.query = { + ...this.#defaultParams.value, + ...ctx.query, + } + + // Fix baseURL + !ctx.url && (ctx.url = this.baseURL.value) + try { + ctx.url = new URL( + ctx.url, + this.baseURL.value.startsWith('http') + ? this.baseURL.value + : globalThis.location?.href + ).toString() + } catch (_) {} + + return ctx }) - // Cookie handling for nodejs + + // Handle cookies for Node.js if (!('document' in globalThis)) { - instance.interceptors.response.use((ctx) => { - const rawCookies = ctx.headers['set-cookie'] + options.hooks.onBeforeRequest.push((ctx) => { + ctx.headers = ctx.headers || {} + ctx.headers['cookie'] = Object.keys(this.cookies) + .map((name) => `${name}=${this.cookies[name]}`) + .join(';') + return ctx + }) + options.hooks.onAfterResponse.push((ctx) => { + const cookieHeaders = (ctx.detail.headers as Headers).get( + 'set-cookie' + ) + const rawCookies = cookieHeaders?.split(',').map((i) => i.trim()) rawCookies?.forEach((i) => { const [name, ...value] = i.split(';')[0].split('=') this.cookies[name] = value.join('=') }) return ctx }) - instance.interceptors.request.use((ctx) => { - ctx.headers = ctx.headers || {} - ctx.headers['cookie'] = '' - for (const name in this.cookies) { - ctx.headers['cookie'] += `${name}=${this.cookies[name]};` - } - return ctx - }) } - return instance + + return MediaWikiApi.createLylaInstance(this.baseURL.value, options) }) } - static adjustParamValue(item: MwApiParams) { + static normalizeParamValue(item: MwApiParams[keyof MwApiParams]) { if (Array.isArray(item)) { return item.join('|') } else if (typeof item === 'boolean') { - return item ? '' : undefined + return item ? '1' : undefined + } else if (typeof item === 'number') { + return '' + item } else { return item } } - static createAxiosInstance({ - baseURL, - params, - options, - }: { - baseURL: string - params: MwApiParams - options: AxiosRequestConfig - }) { - const instance = axios.create({ - baseURL, - timeout: 30 * 1000, - params, - ...options, - }) - instance.interceptors.request.use((ctx) => { - Object.keys(ctx.params).forEach((item) => { - ctx.params[item] = MediaWikiApi.adjustParamValue(ctx.params[item]) - }) + static createLylaInstance(baseURL: string, options: LylaRequestOptions = {}) { + options.hooks ??= {} + options.hooks.onInit ??= [] + options.hooks.onBeforeRequest ??= [] + options.hooks.onAfterResponse ??= [] + options.hooks.onResponseError ??= [] + + options.hooks.onInit.push((ctx) => { + // console.info( + // '[onInit] beforeTransform', + // ctx.method, + // ctx.url, + // ctx.query, + // ctx.body + // ) + + if (ctx.method?.toLowerCase() !== 'post') { + return ctx + } + + // Transform json to formdata + if (ctx.json) { + const form = new URLSearchParams('') + for (const key in ctx.json) { + const data = MediaWikiApi.normalizeParamValue(ctx.json[key]) + if (typeof data === 'undefined') continue + form.append(key, '' + data) + } + ctx.body = form + ctx.json = undefined + } + + if ( + (globalThis.FormData && ctx.body instanceof FormData) || + ctx.body instanceof URLSearchParams + ) { + const body = ctx.body + // Adjust params + body.forEach((value, key) => { + const data = MediaWikiApi.normalizeParamValue(value) + if (typeof data === 'undefined' || data === null) { + body.delete(key) + } else if (data !== value) { + body.set(key, data as any) + } + }) + // Adjust query + ctx.query ??= {} + ctx.query.format ??= '' + body.get('format') || 'json' + ctx.query.formatversion ??= '' + body.get('formatversion') || '2' + body.has('origin') && (ctx.query.origin = '' + body.get('origin')) + } + return ctx }) - instance.interceptors.request.use((ctx) => { - if (ctx.method?.toLowerCase() === 'post') { - ctx.data = { - ...ctx.params, - ...ctx.data, - } - ctx.params = { - format: ctx.params?.format, - formatversion: ctx.params?.formatversion, - origin: - encodeURIComponent(ctx.params?.origin || '')?.replace( - /\./g, - '%2E' - ) || undefined, - } - ctx.headers = ctx.headers || {} - ctx.headers['content-type'] = 'application/x-www-form-urlencoded' - const body = new URLSearchParams('') - for (const key in ctx.data) { - const data = MediaWikiApi.adjustParamValue(ctx.data[key]) - if (typeof data === 'undefined') continue - body.append(key, data.toString()) + + // Adjust query + options.hooks.onInit.push((ctx) => { + ctx.query ??= {} + for (const key in ctx.query) { + const data = MediaWikiApi.normalizeParamValue(ctx.query[key]) + if (typeof data === 'undefined' || data === null) { + delete ctx.query[key] + } else if (data !== ctx.query[key]) { + ctx.query[key] = '' + data } - ctx.data = body.toString() } + + // console.info('[onInit]', ctx.method?.toUpperCase(), ctx.url, { + // query: ctx.query, + // body: ctx.body, + // headers: ctx.headers, + // }) return ctx }) - instance.interceptors.response.use((ctx) => { - if (ctx.data?.error || ctx.data?.errors?.length) { - return Promise.reject(ctx) + + // Adjust origin param + options.hooks.onBeforeRequest.push((ctx) => { + const url = new URL(ctx.url!) + if (url.searchParams.has('origin')) { + const origin = encodeURIComponent( + url.searchParams.get('origin') || '' + ).replace(/\./g, '%2E') + delete ctx.query + url.searchParams.delete('origin') + ctx.url = `${url}${url.search ? '&' : '?'}origin=${origin}` } return ctx }) - return instance + + /** + * response.data shortcut compatibility + */ + options.hooks.onAfterResponse.push((ctx) => { + Object.defineProperty(ctx, 'data', { + get() { + try { + return JSON.parse(ctx.body as string) + } catch (_) { + return ctx.body + } + }, + }) + return ctx + }) + + // @ts-ignore FIXME: Type error during vite build, too bad! + const { lyla } = createLyla({ + baseUrl: baseURL, + ...options, + }) + + return lyla } /** Syntactic Sugar */ - // AxiosInstance - get ajax() { - return this.#axiosInstance.value + // request handler + get request() { + return this.#requestHandler.value } // userOptions get defaultOptions() { return this.#defaultOptions.value } - set defaultOptions(options: AxiosRequestConfig) { + set defaultOptions(options: LylaRequestOptions) { this.#defaultOptions.value = options } // defaultParams @@ -160,20 +258,17 @@ export class MediaWikiApi { } /** Base methods encapsulation */ - get( - params: MwApiParams, - options?: AxiosRequestConfig - ): Promise> { - return this.ajax.get('', { - params, + get(query: MwApiParams, options?: LylaRequestOptions) { + return this.request.get(this.baseURL.value, { + query: query as any, ...options, - }) + }) as Promise> } - post( - data: MwApiParams, - config?: AxiosRequestConfig - ): Promise> { - return this.ajax.post('', data, config) + post(data: MwApiParams, options?: LylaRequestOptions) { + return this.request.post(this.baseURL.value, { + json: data, + ...options, + }) as Promise> } async login( @@ -208,19 +303,23 @@ export class MediaWikiApi { } return data.login } - async getUserInfo(): Promise<{ - id: number - name: string - groups: string[] - rights: string[] - blockid?: number - blockedby?: string - blockedbyid?: number - blockedtimestamp?: string - blockreason?: string - blockexpiry?: string - }> { - const { data } = await this.get({ + async getUserInfo() { + const { data } = await this.get<{ + query: { + userinfo: { + id: number + name: string + groups: string[] + rights: string[] + blockid?: number + blockedby?: string + blockedbyid?: number + blockedtimestamp?: string + blockreason?: string + blockexpiry?: string + } + } + }>({ action: 'query', meta: 'userinfo', uiprop: ['groups', 'rights', 'blockinfo'], @@ -247,40 +346,40 @@ export class MediaWikiApi { return this.#tokens[`${type}token`] } - async postWithToken( + async postWithToken( tokenType: MwTokenName, body: MwApiParams, options?: { tokenName?: string; retry?: number; noCache?: boolean } - ): Promise> { + ): Promise> { const { tokenName = 'token', retry = 3, noCache = false } = options || {} if (retry < 1) { return Promise.reject({ error: { - code: 'internal-retry-limit-exceeded', + code: 'WIKI_SAIKOU_TOKEN_RETRY_LIMIT_EXCEEDED', info: 'The limit of the number of times to automatically re-acquire the token has been exceeded', }, }) } - return this.post({ - [tokenName]: await this.token(tokenType, noCache), + const token = await this.token(tokenType, noCache) + return this.post({ + [tokenName]: token, ...body, + }).catch(({ data }) => { + if ( + [data?.errors?.[0].code, data?.error?.code].includes('badtoken') || + ['NeedToken', 'WrongToken'].includes(data?.login?.result) + ) { + return this.postWithToken(tokenType, data, { + tokenName, + retry: retry - 1, + noCache: true, + }) + } + return Promise.reject(data) }) - .finally(() => { - delete this.#tokens[`${tokenType}token`] - }) - .catch(({ data }) => { - if ([data?.errors?.[0].code, data?.error?.code].includes('badtoken')) { - return this.postWithToken(tokenType, body, { - tokenName, - retry: retry - 1, - noCache: true, - }) - } - return Promise.reject(data) - }) } - postWithEditToken(body: MwApiParams) { - return this.postWithToken('csrf', body) + postWithEditToken(body: MwApiParams) { + return this.postWithToken('csrf', body) } async getMessages(ammessages: string[], amlang = 'zh', options: MwApiParams) { @@ -304,18 +403,27 @@ export class MediaWikiApi { return result } - async parseWikitext(wikitext: string, title?: string): Promise { - const { data } = await this.post({ - action: 'parse', - title, - text: wikitext, - }) + async parseWikitext( + wikitext: string, + title?: string, + extraBody?: MwApiParams, + options?: LylaRequestOptions + ): Promise { + const { data } = await this.post( + { + action: 'parse', + title, + text: wikitext, + ...extraBody, + }, + options + ) return data.parse.text } } export class MediaWikiForeignApi extends MediaWikiApi { - constructor(baseURL?: string, options?: AxiosRequestConfig) { + constructor(baseURL?: string, options?: LylaRequestOptions) { super(baseURL, { withCredentials: true, ...options, @@ -331,7 +439,7 @@ export { MediaWikiApi as MwApi, MediaWikiForeignApi as ForeignApi } // Types export type MwApiParams = Record< string, - string | number | string[] | undefined | boolean + string | number | string[] | undefined | boolean | File > export type MwTokenName = | 'createaccount' diff --git a/src/modules/lyla-adapter-fetch/adapter.ts b/src/modules/lyla-adapter-fetch/adapter.ts new file mode 100644 index 0000000..e48fc45 --- /dev/null +++ b/src/modules/lyla-adapter-fetch/adapter.ts @@ -0,0 +1,101 @@ +import type { + LylaAdapter, + LylaAdapterMeta as LylaCoreAdapterMeta, +} from '@lylajs/core' + +export interface LylaAdapterMeta extends LylaCoreAdapterMeta { + method: + | 'get' + | 'GET' + | 'post' + | 'POST' + | 'put' + | 'PUT' + | 'patch' + | 'PATCH' + | 'head' + | 'HEAD' + | 'delete' + | 'DELETE' + | 'options' + | 'OPTIONS' + | 'connect' + | 'CONNECT' + | 'trace' + | 'TRACE' + networkErrorDetail: TypeError + requestBody: string | FormData + responseDetail: Response + responseType: 'arraybuffer' | 'blob' | 'text' + body: BodyInit +} + +function transformHeaders(headers: Headers): Record { + if (!headers) return {} + + const headerMap: Record = {} + headers.forEach((value, key) => { + headerMap[key] = value + }) + + return headerMap +} + +export const adapter: LylaAdapter = ({ + url, + method, + headers, + body, + responseType, + withCredentials, + onDownloadProgress, + onUploadProgress, + onResponse, + onNetworkError, +}): { + abort: () => void +} => { + const abortController = new AbortController() + const request = fetch(url, { + method, + headers, + body, + credentials: withCredentials ? 'include' : 'same-origin', + signal: abortController.signal, + }) + + request.then(async (response) => { + let body: any + if (responseType === 'blob') { + try { + body = await response.clone().blob() + } catch (error) {} + } else if (responseType === 'arraybuffer') { + try { + body = await response.clone().arrayBuffer() + } catch (error) {} + } + if (!body) { + body = await response.clone().text() + } + + onResponse( + { + status: response.status, + headers: transformHeaders(response.headers), + body, + }, + response + ) + }) + + request.catch((error) => { + onNetworkError(error) + }) + + return { + abort() { + abortController.abort() + }, + } +} diff --git a/src/modules/lyla-adapter-fetch/index.ts b/src/modules/lyla-adapter-fetch/index.ts new file mode 100644 index 0000000..b6ca3a3 --- /dev/null +++ b/src/modules/lyla-adapter-fetch/index.ts @@ -0,0 +1,13 @@ +export { adapter } from './adapter' +export type { LylaAdapterMeta } from './adapter' +export { lyla, isLylaError, createLyla } from './instance' +export type { + Lyla, + LylaError, + LylaProgress, + LylaRequestOptions, + LylaResponse, + LylaResponseError, + LylaDataConversionError, +} from './reexports' +export { LYLA_ERROR, LylaAbortController } from './reexports' diff --git a/src/modules/lyla-adapter-fetch/instance.ts b/src/modules/lyla-adapter-fetch/instance.ts new file mode 100644 index 0000000..ab56e40 --- /dev/null +++ b/src/modules/lyla-adapter-fetch/instance.ts @@ -0,0 +1,17 @@ +import { createLyla as coreCreateLyla } from '@lylajs/core' +import { adapter } from './adapter' +import type { + LylaRequestOptions, + LylaRequestOptionsWithContext, +} from './reexports' + +export const { lyla, isLylaError } = coreCreateLyla(adapter, { + context: undefined, +}) + +export const createLyla = ( + options: LylaRequestOptionsWithContext, + ...overrides: LylaRequestOptions[] +) => { + return coreCreateLyla(adapter, options, ...overrides) +} diff --git a/src/modules/lyla-adapter-fetch/reexports.ts b/src/modules/lyla-adapter-fetch/reexports.ts new file mode 100644 index 0000000..ba7b33a --- /dev/null +++ b/src/modules/lyla-adapter-fetch/reexports.ts @@ -0,0 +1,37 @@ +import type { + LylaRequestOptions as LylaCoreRequestOptions, + LylaResponse as LylaCoreResponse, + Lyla as LylaCore, + LylaRequestOptionsWithContext as LylaCoreRequestOptionsWithContext, +} from '@lylajs/core' +import type { + LylaResponseError as LylaCoreResponseError, + LylaError as LylaCoreError, + LylaDataConversionError as LylaCoreDataConversionError, +} from '@lylajs/core' +import type { LylaAdapterMeta } from './adapter' + +// core +export type Lyla = LylaCore +export type LylaRequestOptions = LylaCoreRequestOptions< + C, + LylaAdapterMeta +> +export type LylaRequestOptionsWithContext = + LylaCoreRequestOptionsWithContext +export type LylaResponse = LylaCoreResponse< + T, + C, + LylaAdapterMeta +> + +// error +export type LylaResponseError = LylaCoreResponseError< + C, + LylaAdapterMeta +> +export type LylaDataConversionError = + LylaCoreDataConversionError +export type LylaError = LylaCoreError + +export { LYLA_ERROR, LylaProgress, LylaAbortController } from '@lylajs/core' diff --git a/test/1.core.spec.ts b/test/1.core.spec.ts index e958771..3e70434 100644 --- a/test/1.core.spec.ts +++ b/test/1.core.spec.ts @@ -1,3 +1,4 @@ +import 'dotenv/config' import { describe, it } from 'mocha' import { expect } from 'chai' import { env } from 'process' @@ -10,6 +11,17 @@ const api = new MediaWikiApi('https://zh.moegirl.org.cn/api.php', { }) describe('MediaWikiApi', () => { + it('[CORE] normalize param values', () => { + expect(MediaWikiApi.normalizeParamValue(true)).to.eq('1') + expect(MediaWikiApi.normalizeParamValue(false)).to.be.undefined + expect(MediaWikiApi.normalizeParamValue(123)).to.eq('123') + expect(MediaWikiApi.normalizeParamValue(['foo', 'bar'])).to.eq('foo|bar') + if (globalThis.File) { + const fakeFile = new File(['foo'], 'foo.txt', { type: 'text/plain' }) + expect(MediaWikiApi.normalizeParamValue(fakeFile)).to.instanceOf(File) + } + }) + it('[GET] siteinfo', async () => { const { data } = await api.get({ action: 'query', meta: 'siteinfo' }) expect(data.query.general).to.be.an('object') @@ -21,7 +33,7 @@ describe('MediaWikiApi', () => { it('[GET] userinfo', async () => { const info = await api.getUserInfo() expect(info.id).to.be.a('number') - expect(info.groups).to.be.an('array') + expect(info.name).to.be.an('string') }) it('[GET] array as param', async () => { @@ -47,23 +59,23 @@ describe('MediaWikiApi', () => { expect(data.parse.links).to.be.an('array') }) - it('[CORE] reactivity', async () => { - api.baseURL.value = 'https://commons.moegirl.org.cn/api.php' - api.defaultParams = { - key1: 'value1', - } - api.defaultParams.key2 = 'value2' - api.defaultOptions = { - timeout: 114514, - } + // it('[CORE] reactivity', async () => { + // api.baseURL.value = 'https://commons.moegirl.org.cn/api.php' + // api.defaultParams = { + // key1: 'value1', + // } + // api.defaultParams.key2 = 'value2' + // api.defaultOptions = { + // timeout: 114514, + // } - expect(api.ajax.defaults.baseURL).to.equal( - 'https://commons.moegirl.org.cn/api.php' - ) - expect(api.ajax.defaults.params).to.deep.equal({ - key1: 'value1', - key2: 'value2', - }) - expect(api.ajax.defaults.timeout).to.equal(114514) - }) + // expect(api.ajax.defaults.baseURL).to.equal( + // 'https://commons.moegirl.org.cn/api.php' + // ) + // expect(api.ajax.defaults.params).to.deep.equal({ + // key1: 'value1', + // key2: 'value2', + // }) + // expect(api.ajax.defaults.timeout).to.equal(114514) + // }) }) diff --git a/test/2.foreign.spec.ts b/test/2.foreign.spec.ts index e28748e..df26c64 100644 --- a/test/2.foreign.spec.ts +++ b/test/2.foreign.spec.ts @@ -1,3 +1,4 @@ +import 'dotenv/config' import { describe, it } from 'mocha' import { expect } from 'chai' import { env } from 'process' diff --git a/test/3.authorization.spec.ts b/test/3.authorization.spec.ts index 0d00e3d..7508fcc 100644 --- a/test/3.authorization.spec.ts +++ b/test/3.authorization.spec.ts @@ -1,3 +1,4 @@ +import 'dotenv/config' import { describe, it } from 'mocha' import { expect } from 'chai' import { env } from 'process' @@ -20,7 +21,7 @@ describe('Authorization', () => { it('Login success', async () => { const login = await api.login(username, password).catch((e) => { - console.error('LOGIN FAIL', e.data) + console.error('LOGIN FAIL', e) return Promise.reject(e) }) expect(login.result).to.equal('Success') diff --git a/test/4.actions.spec.ts b/test/4.actions.spec.ts index 8151609..d50f69e 100644 --- a/test/4.actions.spec.ts +++ b/test/4.actions.spec.ts @@ -1,3 +1,4 @@ +import 'dotenv/config' import { describe, it } from 'mocha' import { expect } from 'chai' import { env } from 'process' diff --git a/vite.config.ts b/vite.config.ts index 9ab0259..56bf05c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,10 +8,11 @@ export default defineConfig({ name: 'WikiSaikou', fileName: 'index', entry: resolve(__dirname, 'src/index.ts'), - formats: ['umd'], + formats: ['umd', 'es', 'iife'], }, sourcemap: true, }, + esbuild: {}, define: { // @FIX Uncaught ReferenceError: process is not defined // @link https://github.com/vitejs/vite/issues/9186