feat: move api-client as core's monorepo

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2022-12-20 21:26:21 +08:00
parent 06ab476c8a
commit a281f45ab4
101 changed files with 7990 additions and 297 deletions

View File

@@ -1 +1,15 @@
assets/types/type.declare.ts
node_modules
dist
out
packages/*/node_modules
packages/*/dist
packages/*/out
packages/*/lib
packages/*/build
packages/*/coverage
packages/*/test
packages/*/tests
packages/*/esm
packages/*/types

45
.github/workflows/api-client.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Build @mx-space/api-client
on:
push:
paths:
- 'package.json'
- 'packages/api-client/**'
pull_request:
branches: [master, main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x, 16.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Cache pnpm modules
uses: actions/cache@v3
env:
cache-name: cache-pnpm-modules
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/package.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/package.json') }}
- uses: pnpm/action-setup@v2.2.4
with:
version: 7.x.x
run_install: true
- run: pnpm run test
- run: pnpm run package
env:
CI: true

13
.prettierignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
dist
out
packages/*/node_modules
packages/*/dist
packages/*/out
packages/*/lib
packages/*/build
packages/*/coverage
packages/*/test
packages/*/tests
packages/*/esm
packages/*/types

View File

@@ -33,12 +33,12 @@
"dev": "npm run start",
"repl": "npm run start -- --entryFile repl",
"bundle": "rimraf out && npm run build && cd dist/src && npx ncc build main.js -o ../../out -m -t && cd ../.. && chmod +x out/index.js",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"packages/**/*.ts\"",
"start": "cross-env NODE_ENV=development nest start -w --path tsconfig.json",
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
"start:cluster": "cross-env NODE_ENV=development nest start --watch -- --cluster --workers 2",
"start:prod": "cross-env NODE_ENV=production node dist/src/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint": "eslint \"{src,apps,libs,test,packages}/**/*.ts\" --fix",
"prod": "cross-env NODE_ENV=production pm2-runtime start ecosystem.config.js",
"prod:pm2": "cross-env NODE_ENV=production pm2 restart ecosystem.config.js",
"prod:stop": "pm2 stop ecosystem.config.js",

6
packages/api-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
esm
lib
dist
build
types

View File

@@ -0,0 +1,3 @@
registry=https://registry.npmjs.org
strict-peer-dependencies=false

View File

@@ -0,0 +1,7 @@
import { axiosAdaptor } from '~/adaptors/axios'
import { testAdaptor } from '../helpers/adaptor-test'
describe('test axios adaptor', () => {
testAdaptor(axiosAdaptor)
})

View File

@@ -0,0 +1,9 @@
import '../helpers/global-fetch'
import { defaultKyAdaptor } from '~/adaptors/ky'
import { testAdaptor } from '../helpers/adaptor-test'
describe('test ky adaptor', () => {
testAdaptor(defaultKyAdaptor)
})

View File

@@ -0,0 +1,7 @@
import { umiAdaptor } from '~/adaptors/umi-request'
import { testAdaptor } from '../helpers/adaptor-test'
describe('test umi-request adaptor', () => {
testAdaptor(umiAdaptor)
})

View File

@@ -0,0 +1,389 @@
import camelcaseKeys from 'camelcase-keys'
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { AggregateController } from '~/controllers'
import { TimelineType } from '~/models/aggregate'
describe('test aggregate client', () => {
const client = mockRequestInstance(AggregateController)
test('GET /aggregate', async () => {
const mocked = mockResponse(
'/aggregate',
// https://api.innei.ren/v2/aggregate
{
user: {
id: '5ea4fe632507ba128f4c938c',
introduce: '这是我的小世界呀',
mail: 'i@innei.ren',
url: 'https://innei.ren',
name: 'Innei',
social_ids: {
bili_id: 26578164,
netease_id: 84302804,
github: 'Innei',
},
username: 'Innei',
created: '2020-04-26T03:22:11.784Z',
modified: '2020-11-13T09:38:49.014Z',
last_login_time: '2021-11-10T04:47:09.329Z',
avatar: 'https://cdn.innei.ren/avatar.png',
},
seo: {
title: '静かな森',
description: '致虚极,守静笃。',
keywords: ['blog', 'mx-space', 'space', '静かな森'],
},
categories: [
{
id: '5eb2c62a613a5ab0642f1f7a',
type: 0,
count: 34,
name: '编程',
slug: 'programming',
created: '2020-05-06T14:14:02.339Z',
},
{
id: '5eb2c62a613a5ab0642f1f7b',
type: 0,
count: 19,
name: '折腾',
slug: 'Z-Turn',
created: '2020-05-06T14:14:02.356Z',
},
{
id: '5eb2c62a613a5ab0642f1f7c',
type: 0,
count: 18,
name: '学习',
slug: 'learning-process',
created: '2020-05-06T14:14:02.364Z',
},
{
id: '5eb2c62a613a5ab0642f1f7e',
type: 0,
count: 11,
name: '技术',
slug: 'technology',
created: '2020-05-06T14:14:02.375Z',
},
{
id: '5ed09730a0a8f94af569c96c',
type: 0,
count: 9,
slug: 'website',
name: '站点日志',
created: '2020-05-29T05:01:36.315Z',
},
{
id: '5ed5be418f3d6b6cb9ab7700',
type: 0,
count: 2,
slug: 'reprinta',
name: '转载',
created: '2020-06-02T02:49:37.424Z',
},
],
page_meta: [
{
id: '5e0318319332d06503619337',
title: '自述',
slug: 'about',
order: 1,
},
{
id: '5ea52aafa27a8a01dee55f53',
order: 1,
title: '栈',
slug: 'stack',
},
{
id: '5eb3b6e032c759467b0ad71e',
order: 0,
title: '历史',
slug: 'history',
},
{
id: '5eb54fc06c9cc86c3692349f',
order: 0,
title: '留言',
slug: 'message',
},
{
id: '5f0aaeeaddf2006d12773b12',
order: 0,
title: '此站点',
slug: 'about-site',
},
{
id: '601bce41a0630165aa48b9d0',
order: 0,
title: '迭代',
slug: 'sprint',
},
],
url: {
ws_url: 'https://api.innei.ren',
server_url: 'https://api.innei.ren/v2',
web_url: 'https://innei.ren',
},
},
)
const data = await client.aggregate.getAggregateData()
expect(data.$raw.data).toEqual(mocked)
expect(data.user.name).toEqual(mocked.user.name)
expect(data.url.webUrl).toEqual(mocked.url.web_url)
})
test('GET /aggregate/top', async () => {
const mocked = mockResponse(
'/aggregate/top', // 20211114224602
// https://api.innei.ren/v2/aggregate/top
{
notes: [
{
id: '618e689174afb47066ab4548',
title: '结束了,秋招',
created: '2021-11-12T13:13:53.769Z',
nid: 104,
},
{
id: '6166c860035bf29e2c32ec40',
title: '致逝去的青春',
created: '2021-10-13T11:52:00.327Z',
nid: 103,
},
{
id: '61586b2f769f07b6852f3bf0',
title: '论就业压力',
created: '2021-10-02T14:22:39.934Z',
nid: 102,
},
{
id: '6149403ac0209bf8c57dcd15',
title: '关于开源',
created: '2021-09-21T02:15:22.161Z',
nid: 101,
},
{
id: '614206d4685e0c58294b1177',
title: '最近这段日子',
created: '2021-09-15T14:44:36.061Z',
nid: 99,
},
{
id: '612db5905c2f6f4d3ba136d2',
title: '告别',
created: '2021-08-31T04:52:32.865Z',
nid: 97,
},
],
posts: [
{
id: '61586f7e769f07b6852f3da0',
slug: 'host-an-entire-Mix-Space-using-Docker',
title: '终于可以使用 Docker 托管整个 Mix Space 了',
created: '2021-10-02T14:41:02.742Z',
category: {
name: '站点日志',
slug: 'website',
},
},
{
id: '614c539cfdf566c5d93a383f',
slug: 'docker-node-ncc',
title: '再遇 Docker容器化 Node 应用',
created: '2021-09-23T10:14:52.491Z',
category: {
name: '技术',
slug: 'technology',
},
},
{
id: '613c91d0326cfffc61923ea2',
slug: 'github-ci-cd',
title: '使用 GitHub CI 云构建和自动部署',
created: '2021-09-11T11:24:00.424Z',
category: {
name: '技术',
slug: 'technology',
},
},
{
id: '611748895c2f6f4d3ba0d9b3',
title: 'pageproxy为 spa 提供初始数据注入',
slug: 'pageproxy-spa-inject',
created: '2021-08-14T04:37:29.880Z',
category: {
name: '编程',
slug: 'programming',
},
},
{
id: '60cffff50ec52e0349cbb29f',
title: '曲折的 Vue 3 重构后台之路',
slug: 'mx-space-vue-3',
created: '2021-06-21T02:56:53.126Z',
category: {
name: '编程',
slug: 'programming',
},
},
{
id: '60b0a9852e75e2d635406879',
title: '2021 年了,你不还来试试 TailwindCSS 吗',
slug: 'tailwind-2021',
created: '2021-05-28T08:27:49.346Z',
category: {
name: '编程',
slug: 'programming',
},
},
],
says: [
{
id: '5eb52a73505ad56acfd25c94',
source: '网络',
text: '找不到路,就自己走一条出来。',
author: '魅影陌客',
created: '2020-05-08T09:46:27.694Z',
},
{
id: '5eb52a94505ad56acfd25c95',
source: '网络',
text: '生活中若没有朋友,就像生活中没有阳光一样。',
author: '能美',
created: '2020-05-08T09:47:00.436Z',
},
{
id: '5eb52aa7505ad56acfd25c97',
source: '古城荆棘王',
text: '没有期盼就不会出现奇迹。',
author: 'M崽',
created: '2020-05-08T09:47:19.285Z',
},
{
id: '5eb9672de2369f53ff02a73c',
source: '生活',
text: '别让生活蹂躏了你眉间的温柔。',
author: '结局',
created: '2020-05-11T14:54:37.319Z',
},
{
id: '5ebc8c4a5f0af03c7db9d56f',
source: '佛教禅语',
text: '忌妒别人,不会给自己增加任何的好处。忌妒别人,也不可能减少别人的成就。',
author: 'hitokoto',
created: '2020-05-14T00:09:46.926Z',
},
{
id: '5ec0a02165d4d8495bc2e9f2',
source: '凪的新生活',
text: '你还是和原来一样带着面具生活,真是令人作呕。',
created: '2020-05-17T02:23:29.570Z',
},
],
},
)
const data = await client.aggregate.getTop()
expect(data.$raw.data).toEqual(mocked)
expect(data.posts[0].title).toEqual(
'终于可以使用 Docker 托管整个 Mix Space 了',
)
expect(data.notes).toBeDefined()
})
it('should filter undefined value in url query, get `/top`', async () => {
mockResponse('/aggregate/top?size=1', { notes: [{ title: '1 ' }] })
const data = await client.aggregate.getTop(1)
expect(data.notes.length).toEqual(1)
})
test('GET /timeline', async () => {
const mocked = mockResponse('/aggregate/timeline', {
data: {
posts: [
{
id: '5eb2c62a613a5ab0642f1fb8',
title: '如何配置zsh',
slug: 'zshrc',
created: '2018-09-04T10:34:00.000Z',
modified: '2020-11-13T21:41:43.774Z',
category: {
id: '5eb2c62a613a5ab0642f1f7b',
type: 0,
count: 22,
name: '折腾',
slug: 'Z-Turn',
created: '2020-05-06T14:14:02.356Z',
},
url: '/posts/Z-Turn/zshrc',
},
],
notes: [
{
id: '5eb35d6f5ae43bbd0c90b8c0',
title: '回顾快要逝去的寒假',
created: '2019-02-19T11:59:00.000Z',
modified: '2020-11-15T09:43:33.199Z',
nid: 11,
},
],
},
})
const data = await client.aggregate.getTimeline()
expect(data.$raw.data).toEqual(mocked)
expect(data.data.posts?.[0].url).toEqual(mocked.data.posts[0].url)
expect(data.data.notes).toBeDefined()
})
test('GET /timeline', async () => {
const mocked = mockResponse('/aggregate/timeline?type=1', {
data: {
notes: [
{
id: '5eb35d6f5ae43bbd0c90b8c0',
title: '回顾快要逝去的寒假',
created: '2019-02-19T11:59:00.000Z',
modified: '2020-11-15T09:43:33.199Z',
nid: 11,
},
],
},
})
const data = await client.aggregate.getTimeline({ type: TimelineType.Note })
expect(data.$raw.data).toEqual(mocked)
expect(data.data.notes?.[0]).toEqual(mocked.data.notes[0])
expect(data.data.posts).toBeUndefined()
})
test('GET /stat', async () => {
const mocked = mockResponse('/aggregate/stat', {
all_comments: 464,
categories: 6,
comments: 260,
link_apply: 0,
links: 43,
notes: 89,
pages: 6,
posts: 93,
says: 26,
recently: 19,
unread_comments: 0,
online: 0,
today_max_online: '3',
today_online_total: '2565',
call_time: 1054126,
uv: 67733,
today_ip_access_count: 138,
})
const data = await client.aggregate.getStat()
expect(data.$raw.data).toEqual(mocked)
expect(data).toEqual(camelcaseKeys(mocked))
})
})

View File

@@ -0,0 +1,284 @@
import camelcaseKeys from 'camelcase-keys'
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { CategoryController } from '~/controllers'
describe('test Category client', () => {
const client = mockRequestInstance(CategoryController)
test('GET /categories', async () => {
const mocked = mockResponse('/categories', {
data: [
{
id: '5eb2c62a613a5ab0642f1f7a',
type: 0,
count: 34,
name: '编程',
slug: 'programming',
created: '2020-05-06T14:14:02.339Z',
},
],
})
const data = await client.category.getAllCategories()
expect(data.$raw.data).toEqual(mocked)
expect(data.data).toEqual(mocked.data)
})
describe('GET /categories/:id', () => {
test('get by slug', async () => {
const mocked = mockResponse('/categories/programming', {
data: {
id: '5eb2c62a613a5ab0642f1f7a',
type: 0,
count: 2,
name: '编程',
slug: 'programming',
created: '2020-05-06T14:14:02.339Z',
children: [
{
id: '611748895c2f6f4d3ba0d9b3',
title: 'pageproxy为 spa 提供初始数据注入',
slug: 'pageproxy-spa-inject',
created: '2021-08-14T04:37:29.880Z',
},
{
id: '60cffff50ec52e0349cbb29f',
title: '曲折的 Vue 3 重构后台之路',
slug: 'mx-space-vue-3',
created: '2021-06-21T02:56:53.126Z',
},
],
},
})
const data = await client.category.getCategoryByIdOrSlug('programming')
expect(data).toEqual(mocked.data)
expect(data.count).toEqual(mocked.data.count)
})
test('get by id', async () => {
const mocked = mockResponse('/categories/5eb2c62a613a5ab0642f1f7a', {
data: {
id: '5eb2c62a613a5ab0642f1f7a',
type: 0,
count: 2,
name: '编程',
slug: 'programming',
created: '2020-05-06T14:14:02.339Z',
children: [
{
id: '611748895c2f6f4d3ba0d9b3',
title: 'pageproxy为 spa 提供初始数据注入',
slug: 'pageproxy-spa-inject',
created: '2021-08-14T04:37:29.880Z',
},
{
id: '60cffff50ec52e0349cbb29f',
title: '曲折的 Vue 3 重构后台之路',
slug: 'mx-space-vue-3',
created: '2021-06-21T02:56:53.126Z',
},
],
},
})
const data = await client.category.getCategoryByIdOrSlug(
'5eb2c62a613a5ab0642f1f7a',
)
expect(data).toEqual(mocked.data)
expect(data.count).toEqual(mocked.data.count)
})
})
test('GET /categories/:tagName', async () => {
const mocked = mockResponse('/categories/react?tag=1', {
tag: 'react',
data: [
{
id: '607bfcedc98328a0d941a409',
title: '虚拟列表与 Scroll Restoration',
slug: 'visualize-list-scroll-restoration',
category: {
id: '5eb2c62a613a5ab0642f1f7a',
type: 0,
name: '编程',
slug: 'programming',
},
created: '2021-04-18T09:33:33.271Z',
},
],
})
const data = await client.category.getTagByName('react')
expect(data.tag).toEqual('react')
expect(data.data).toEqual(mocked.data)
})
test('GET /categories?type=0', async () => {
const mocked = mockResponse('/categories?type=0', {
data: [
{
id: '5eb2c62a613a5ab0642f1f7a',
type: 0,
count: 34,
name: '编程',
slug: 'programming',
created: '2020-05-06T14:14:02.339Z',
},
],
})
const data = await client.category.getAllCategories()
expect(data.data).toEqual(mocked.data)
expect(data.$raw.data).toEqual(mocked)
})
test('GET /categories?type=1', async () => {
const mocked = mockResponse('/categories?type=1', {
data: [
{
count: 2,
name: 'docker',
},
{
count: 1,
name: 'react',
},
],
})
const data = await client.category.getAllTags()
expect(data.data).toEqual(mocked.data)
expect(data.$raw.data).toEqual(mocked)
})
describe('GET /categories?ids=', () => {
it('should get with ids array', async () => {
const mocked = mockResponse(
'/categories?ids=5ed5be418f3d6b6cb9ab7700,5eb2c62a613a5ab0642f1f7b',
{
entries: {
'5ed5be418f3d6b6cb9ab7700': {
id: '5ed5be418f3d6b6cb9ab7700',
type: 0,
count: 2,
slug: 'reprint',
name: '转载',
created: '2020-06-02T02:49:37.424Z',
children: [
{
id: '6005562e6b14b33be8afc1c3',
allow_comment: true,
copyright: false,
tags: [],
count: {
read: 221,
like: 3,
},
title: '[reprint] Your Own Time Zone',
slug: 'your-own-time-zone',
category_id: '5ed5be418f3d6b6cb9ab7700',
modified: '2021-01-18T09:41:37.380Z',
created: '2021-01-18T09:34:38.550Z',
},
],
},
'5eb2c62a613a5ab0642f1f7b': {
id: '5eb2c62a613a5ab0642f1f7b',
type: 0,
count: 19,
name: '折腾',
slug: 'Z-Turn',
created: '2020-05-06T14:14:02.356Z',
children: [
{
id: '5eb2c62a613a5ab0642f1f95',
title: '从零开始的 Redux',
slug: 'learn-redux',
created: '2020-01-08T08:24:00.000Z',
modified: '2020-11-14T06:50:19.164Z',
category_id: '5eb2c62a613a5ab0642f1f7b',
copyright: true,
count: {
read: 309,
like: 2,
},
allow_comment: true,
tags: [],
},
],
},
},
},
)
const data = await client.category.getCategoryDetail([
'5ed5be418f3d6b6cb9ab7700',
'5eb2c62a613a5ab0642f1f7b',
])
expect(data).toEqual(
new Map([
[
'5ed5be418f3d6b6cb9ab7700',
camelcaseKeys(mocked.entries['5ed5be418f3d6b6cb9ab7700'], {
deep: true,
}),
],
[
'5eb2c62a613a5ab0642f1f7b',
camelcaseKeys(mocked.entries['5eb2c62a613a5ab0642f1f7b'], {
deep: true,
}),
],
]),
)
expect(data.$raw.data).toEqual(mocked)
})
it('should get with single id', async () => {
const mocked = mockResponse('/categories?ids=5ed5be418f3d6b6cb9ab7700', {
entries: {
'5ed5be418f3d6b6cb9ab7700': {
id: '5ed5be418f3d6b6cb9ab7700',
type: 0,
count: 2,
slug: 'reprint',
name: '转载',
created: '2020-06-02T02:49:37.424Z',
children: [
{
id: '6005562e6b14b33be8afc1c3',
allow_comment: true,
copyright: false,
tags: [],
count: {
read: 221,
like: 3,
},
title: '[reprint] Your Own Time Zone',
slug: 'your-own-time-zone',
category_id: '5ed5be418f3d6b6cb9ab7700',
modified: '2021-01-18T09:41:37.380Z',
created: '2021-01-18T09:34:38.550Z',
},
],
},
},
})
const data = await client.category.getCategoryDetail(
'5ed5be418f3d6b6cb9ab7700',
)
expect(data).toEqual(
camelcaseKeys(mocked.entries['5ed5be418f3d6b6cb9ab7700'], {
deep: true,
}),
)
expect(data.$raw.data).toEqual(mocked)
})
})
})

View File

@@ -0,0 +1,113 @@
import camelcaseKeys from 'camelcase-keys'
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { CommentController } from '~/controllers/comment'
describe('test note client', () => {
const client = mockRequestInstance(CommentController)
test('get comment by id', async () => {
mockResponse('/comments/11111', {
ref_type: 'Page',
state: 1,
children: [],
comments_index: 1,
id: '6188b80b6290547080c9e1f3',
author: 'yss',
text: '做的框架模板不错. (•౪• ) ',
url: 'https://gitee.com/kmyss/',
key: '#26',
ref: '5e0318319332d06503619337',
created: '2021-11-08T05:39:23.010Z',
avatar:
'https://sdn.geekzu.org/avatar/8675fa376c044b0d93a23374549c4248?d=retro',
})
const data = await client.comment.getById('11111')
expect(data.children).toEqual([])
expect(data.text).toBeDefined()
})
test('get comment by ref id', async () => {
const comments = [
{
ref_type: 'Page',
state: 1,
children: [],
comments_index: 1,
id: '6188b80b6290547080c9e1f3',
author: 'yss',
text: '做的框架模板不错. (•౪• ) ',
url: 'https://gitee.com/kmyss/',
key: '#26',
ref: '5e0318319332d06503619337',
created: '2021-11-08T05:39:23.010Z',
avatar:
'https://sdn.geekzu.org/avatar/8675fa376c044b0d93a23374549c4248?d=retro',
},
]
mockResponse(
'/comments/ref/5e0318319332d06503619337',
{
data: comments,
pagination: {
total: 23,
current_page: 1,
total_page: 3,
size: 10,
has_next_page: true,
has_prev_page: false,
},
},
'get',
)
const data = await client.comment.getByRefId('5e0318319332d06503619337')
expect(data.data).toEqual(camelcaseKeys(comments, { deep: true }))
expect(data.pagination.total).toEqual(23)
expect(data.pagination.hasNextPage).toEqual(true)
})
it('should comment successfully', async () => {
mockResponse(
'/comments/1',
{
id: '1',
text: 'bar',
},
'post',
)
const data = await client.comment.comment('1', {
author: 'foo',
text: 'bar',
mail: 'xx@aa.com',
})
expect(data).toEqual({
id: '1',
text: 'bar',
})
})
it('should reply comment successfully', async () => {
mockResponse(
'/comments/reply/1',
{
id: '1',
text: 'bar',
},
'post',
)
const data = await client.comment.reply('1', {
author: 'f',
text: 'bar',
mail: 'a@q.com',
})
expect(data).toEqual({ id: '1', text: 'bar' })
})
})

View File

@@ -0,0 +1,95 @@
import camelcaseKeys from 'camelcase-keys'
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { LinkController } from '~/controllers'
describe('test link client, /links', () => {
const client = mockRequestInstance(LinkController)
test('GET /', async () => {
const mocked = mockResponse('/links?size=10&page=1', {
data: [
{
type: 0,
state: 0,
id: '615c191ed5db15a1000e3ca6',
url: 'https://barry-flynn.github.io/',
avatar: 'https://i.loli.net/2021/09/09/5belKgmrkjN8ZQ7.jpg',
description: '星河滚烫,无问西东。',
name: '百里飞洋の博客',
created: '2021-10-05T09:21:34.257Z',
hide: false,
},
// ...
],
pagination: {
total: 43,
current_page: 1,
total_page: 5,
size: 10,
has_next_page: true,
has_prev_page: false,
},
})
const data = await client.link.getAllPaginated(1, 10)
expect(data.$raw.data).toEqual(mocked)
expect(data).toEqual(camelcaseKeys(mocked, { deep: true }))
})
it('should `friend` == `link`', () => {
expect(client.link).toEqual(client.friend)
})
test('GET /all', async () => {
const mocked = mockResponse('/links/all', {
data: [],
})
const data = await client.link.getAll()
expect(data.$raw.data).toEqual(mocked)
expect(data).toEqual(camelcaseKeys(mocked, { deep: true }))
})
test('GET /:id', async () => {
const mocked = mockResponse('/links/5eaabe10cd5bca719652179d', {
id: '5eaabe10cd5bca719652179d',
name: '静かな森',
url: 'https://innei.ren',
avatar: 'https://cdn.innei.ren/avatar.png',
created: '2020-04-30T12:01:20.738Z',
type: 0,
description: '致虚极,守静笃。',
state: 0,
})
const data = await client.link.getById('5eaabe10cd5bca719652179d')
expect(data.$raw.data).toEqual(mocked)
expect(data).toEqual(camelcaseKeys(mocked, { deep: true }))
})
test('GET /audit', async () => {
const mocked = mockResponse('/links/audit', {
can: true,
})
const allowed = await client.link.canApplyLink()
expect(allowed).toEqual(mocked.can)
})
test('POST /audit', async () => {
const payload = {
author: '',
avatar: '',
name: '',
url: '',
description: '',
email: '',
}
mockResponse('/links/audit', 'OK', 'post', payload)
const res = await client.link.applyLink(payload)
expect(res).toEqual('OK')
})
})

View File

@@ -0,0 +1,120 @@
import { vi } from 'vitest'
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { axiosAdaptor } from '~/adaptors/axios'
import { NoteController } from '~/controllers'
import { RequestError } from '~/core'
const { spyOn } = vi
describe('test note client', () => {
const client = mockRequestInstance(NoteController)
it('should get note list', async () => {
mockResponse('/notes', {
data: [],
pagination: {},
})
const data = await client.note.getList()
expect(data).toEqual({ data: [], pagination: {} })
})
it('should get post list filter filed', async () => {
const mocked = mockResponse('/notes?page=1&size=1&select=created+title', {
data: [{}],
})
const data = await client.note.getList(1, 1, {
select: ['created', 'title'],
})
expect(data).toEqual(mocked)
})
it('should get latest note', async () => {
mockResponse('/notes/latest', { data: { title: '1' } })
const data = await client.note.getLatest()
expect(data.data.title).toBe('1')
})
it('should get middle list of note', async () => {
mockResponse('/notes/list/1', {
data: [
{
id: '1',
},
{
id: '2',
},
],
size: 2,
})
const data = await client.note.getMiddleList('1')
expect(data).toEqual({
data: [
{
id: '1',
},
{
id: '2',
},
],
size: 2,
})
})
it('should get single note', async () => {
mockResponse('/notes/1', { title: '1' })
const data = await client.note.getNoteById('1')
expect(data).toStrictEqual({ title: '1' })
expect(data.$raw).toBeDefined()
})
it('should get note by nid', async () => {
mockResponse('/notes/nid/1', { data: { title: '1' } })
const data = await client.note.getNoteById(1)
expect(data.data.title).toBe('1')
})
it('should get note by nid query single result', async () => {
mockResponse('/notes/nid/1', { title: '1' })
const data = await client.note.getNoteById(1, undefined, true)
expect(data.title).toBe('1')
})
it('should like note', async () => {
mockResponse('/notes/like/1', null)
const data = await client.note.likeIt('1')
expect(data).toBeNull()
})
it('should forbidden if no password provide', async () => {
spyOn(axiosAdaptor, 'get').mockRejectedValue({
response: {
data: {
message: 'password required',
},
status: 403,
},
})
await expect(client.note.getNoteById('1')).rejects.toThrowError(
RequestError,
)
})
test('GET /notes/topics/:id', async () => {
mockResponse('/notes/topics/11111111', { data: [], pagination: {} })
const data = await client.note.getNoteByTopicId('11111111')
expect(data).toEqual({ data: [], pagination: {} })
})
})

View File

@@ -0,0 +1,49 @@
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { PageController } from '~/controllers/page'
describe('test page client', () => {
const client = mockRequestInstance(PageController)
it('should get page list', async () => {
mockResponse('/pages?size=10&page=1', {
data: [],
pagination: {},
})
const data = await client.page.getList()
expect(data).toEqual({ data: [], pagination: {} })
})
it('should get post list filter filed', async () => {
const mocked = mockResponse('/pages?page=1&size=1&select=created+title', {
data: [{}],
})
const data = await client.page.getList(1, 1, {
select: ['created', 'title'],
})
expect(data).toEqual(mocked)
})
it('should get single page', async () => {
mockResponse('/pages/1', {
title: '1',
})
const data = await client.page.getById('1')
expect(data).toStrictEqual({ title: '1' })
expect(data.$raw).toBeDefined()
})
it('should get by slug', async () => {
mockResponse('/pages/slug/about', {
title: 'about',
text: 'about!',
})
const data = await client.page.getBySlug('about')
expect(data.title).toBe('about')
expect(data.text).toBe('about!')
})
})

View File

@@ -0,0 +1,77 @@
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { PostController } from '~/controllers'
describe('test post client', () => {
const client = mockRequestInstance(PostController)
it('should get post list', async () => {
mockResponse('/posts', { data: [] })
const data = await client.post.getList()
expect(data).toEqual({ data: [] })
})
it('should get post list filter filed', async () => {
const mocked = mockResponse('/posts?page=1&size=1&select=created+title', {
data: [
{
id: '61586f7e769f07b6852f3da0',
title: '终于可以使用 Docker 托管整个 Mix Space 了',
created: '2021-10-02T14:41:02.742Z',
category: null,
},
{
id: '614c539cfdf566c5d93a383f',
title: '再遇 Docker容器化 Node 应用',
created: '2021-09-23T10:14:52.491Z',
category: null,
},
],
})
const data = await client.post.getList(1, 1, {
select: ['created', 'title'],
})
expect(data).toEqual(mocked)
})
it('should get latest post', async () => {
mockResponse('/posts/latest', { title: '1' })
const data = await client.post.getLatest()
expect(data.title).toBe('1')
})
it('should get single post by id', async () => {
mockResponse('/posts/613c91d0326cfffc61923ea2', {
title: '1',
})
const data = await client.post.getPost('613c91d0326cfffc61923ea2')
expect(data).toStrictEqual({ title: '1' })
expect(data.$raw).toBeDefined()
})
it('should get single post by slug and category', async () => {
mockResponse('/posts/website/host-an-entire-Mix-Space-using-Docker', {
title: '1',
})
const data = await client.post.getPost(
'website',
'host-an-entire-Mix-Space-using-Docker',
)
expect(data).toStrictEqual({ title: '1' })
expect(data.$raw).toBeDefined()
})
it('should thumbs-up post', async () => {
mockResponse('/posts/_thumbs-up?id=1', null)
const data = await client.post.thumbsUp('1')
expect(data).toBeNull()
})
})

View File

@@ -0,0 +1,49 @@
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { RecentlyController } from '~/controllers'
describe('test recently client, /recently', () => {
const client = mockRequestInstance(RecentlyController)
test('GET /', async () => {
const mocked = mockResponse('/recently?before=616182162657089e483aac5c', {
data: [
{
id: '615c58cbf41656a119b1a4a9',
content: 'x',
created: '2021-10-05T13:53:15.891Z',
},
],
})
const data = await client.recently.getList('616182162657089e483aac5c')
expect(data).toEqual(mocked)
})
test('GET /latest', async () => {
const mocked = mockResponse('/recently/latest', {
id: '615c58cbf41656a119b1a4a9',
content: 'xxx',
created: '2021-10-05T13:53:15.891Z',
})
const data = await client.recently.getLatestOne()
expect(data).toEqual(mocked)
})
test('GET /all', async () => {
const mocked = mockResponse('/recently/all', {
data: [
{
id: '615c58cbf41656a119b1a4a9',
content: 'x',
created: '2021-10-05T13:53:15.891Z',
},
],
})
const data = await client.recently.getAll()
expect(data).toEqual(mocked)
})
it('should `recently` == `shorthand`', () => {
expect(client.recently).toEqual(client.shorthand)
})
})

View File

@@ -0,0 +1,106 @@
import camelcaseKeys from 'camelcase-keys'
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { SayController } from '~/controllers/say'
describe('test say client', () => {
const client = mockRequestInstance(SayController)
test('GET /says/all', async () => {
const mocked = mockResponse('/says/all', {
data: [
{
id: '5eb52a73505ad56acfd25c94',
source: '网络',
text: '找不到路,就自己走一条出来。',
author: '魅影陌客',
created: '2020-05-08T09:46:27.694Z',
},
{
id: '5eb52a94505ad56acfd25c95',
source: '网络',
text: '生活中若没有朋友,就像生活中没有阳光一样。',
author: '能美',
created: '2020-05-08T09:47:00.436Z',
},
],
})
const data = await client.say.getAll()
expect(data.$raw.data).toEqual(mocked)
expect(data.data).toEqual(mocked.data)
expect(data.data[0].text).toEqual('找不到路,就自己走一条出来。')
})
describe('GET /says', () => {
it('should get without page and size', async () => {
const mocked = mockResponse('/says', {
data: [
{
id: '61397d9892992823d7329bc9',
text: '每位师傅各有所长,我都会一点点。',
author: '陆沉',
source: '',
created: '2021-09-09T03:20:56.769Z',
},
{
id: '60853492fbfab397775cc12d',
text: '我不是一个优秀的人,只是我们观测的角度不同。',
created: '2021-04-25T09:21:22.115Z',
},
],
pagination: {
total: 26,
current_page: 1,
total_page: 3,
size: 10,
has_next_page: true,
has_prev_page: false,
},
})
const data = await client.say.getAllPaginated()
expect(data.$raw.data).toEqual(mocked)
expect(data.data).toEqual(camelcaseKeys(mocked.data, { deep: true }))
expect(data.data[0].text).toEqual('每位师傅各有所长,我都会一点点。')
})
it('should with page and size', async () => {
const mocked = await mockResponse('/says?size=1&page=1', {
data: [
{
id: '61397d9892992823d7329bc9',
text: '每位师傅各有所长,我都会一点点。',
author: '陆沉',
source: '',
created: '2021-09-09T03:20:56.769Z',
},
],
pagination: {
total: 26,
current_page: 1,
total_page: 26,
size: 1,
has_next_page: true,
has_prev_page: false,
},
})
const data = await client.say.getAllPaginated(1, 1)
expect(data.$raw.data).toEqual(mocked)
})
})
test('GET /says/:id', async () => {
const mocked = mockResponse('/says/61397d9892992823d7329bc9', {
id: '61397d9892992823d7329bc9',
text: '每位师傅各有所长,我都会一点点。',
author: '陆沉',
source: '',
created: '2021-09-09T03:20:56.769Z',
})
const data = await client.say.getById('61397d9892992823d7329bc9')
expect(data.$raw.data).toEqual(mocked)
expect(data.id).toEqual('61397d9892992823d7329bc9')
})
})

View File

@@ -0,0 +1,81 @@
import camelcaseKeys from 'camelcase-keys'
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { SearchController } from '~/controllers'
import mockData from '../mock/algolia.json'
describe('test search client, /search', () => {
const client = mockRequestInstance(SearchController)
test('GET /search/post', async () => {
const mocked = mockResponse('/search/post?keyword=1', {
data: [
{
modified: '2020-11-14T16:15:36.162Z',
id: '5eb2c62a613a5ab0642f1f80',
title: '打印沙漏(C#实现)',
slug: 'acm-test',
created: '2019-01-31T13:02:00.000Z',
category: {
type: 0,
id: '5eb2c62a613a5ab0642f1f7a',
count: 56,
name: '编程',
slug: 'programming',
created: '2020-05-06T14:14:02.339Z',
},
},
],
pagination: {
total: 86,
current_page: 1,
total_page: 9,
size: 10,
has_next_page: true,
has_prev_page: false,
},
})
const data = await client.search.search('post', '1')
expect(data).toEqual(camelcaseKeys(mocked, { deep: true }))
expect(data.data[0].id).toEqual('5eb2c62a613a5ab0642f1f80')
})
test('GET /search/note', async () => {
const mocked = mockResponse('/search/note?keyword=1', {
data: [
{
modified: '2020-11-15T09:43:33.199Z',
id: '5eb35d6f5ae43bbd0c90b8c0',
title: '回顾快要逝去的寒假',
created: '2019-02-19T11:59:00.000Z',
nid: 11,
},
],
pagination: {
total: 86,
current_page: 1,
total_page: 9,
size: 10,
has_next_page: true,
has_prev_page: false,
},
})
const data = await client.search.search('note', '1')
expect(data).toEqual(camelcaseKeys(mocked, { deep: true }))
expect(data.data[0].id).toEqual('5eb35d6f5ae43bbd0c90b8c0')
})
test('GET /search/algolia', async () => {
mockResponse('/search/algolia', mockData)
const data = await client.search.searchByAlgolia('algolia')
expect(data.data[0].id).toEqual('5fe97d1d5b11408f99ada0fd')
expect(data.raw).toBeDefined()
expect(data.$raw.data).toEqual(mockData)
})
})

View File

@@ -0,0 +1,19 @@
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { ServerlessController } from '~/controllers'
describe('test Snippet client', () => {
const client = mockRequestInstance(ServerlessController)
test('GET /:reference/:name', async () => {
const mocked = mockResponse('/serverless/api/ping', { message: 'pong' })
const data = await client.serverless.getByReferenceAndName<{}>(
'api',
'ping',
)
expect(data).toEqual(mocked)
expect(data.$raw.data).toEqual(mocked)
})
})

View File

@@ -0,0 +1,38 @@
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { SnippetController } from '~/controllers'
describe('test Snippet client', () => {
const client = mockRequestInstance(SnippetController)
// test('GET /:id', async () => {
// const mocked = mockResponse('/snippets/61a0cac4b4aefa000fcc1822', {
// id: '61a0cac4b4aefa000fcc1822',
// type: 'json',
// private: true,
// reference: 'theme',
// raw: '{}',
// name: 'config',
// created: '2021-11-26T11:53:40.863Z',
// updated: '2021-11-26T11:53:40.863Z',
// })
// const data = await client.snippet.getById('61a0cac4b4aefa000fcc1822')
// expect(data).toEqual(mocked)
// expect(data.$raw.data).toEqual(mocked)
// expect(data.raw).toEqual('{}')
// })
test('GET /:reference/:name', async () => {
const mocked = mockResponse('/snippets/theme/config', {})
const data = await client.snippet.getByReferenceAndName<{}>(
'theme',
'config',
)
expect(data).toEqual(mocked)
expect(data.$raw.data).toEqual(mocked)
})
})

View File

@@ -0,0 +1,17 @@
import camelcaseKeys from 'camelcase-keys'
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { TopicController } from '~/controllers/topic'
describe('test topic client', () => {
const client = mockRequestInstance(TopicController)
test('GET /topics/slug/:slug', async () => {
const mocked = mockResponse('/topics/slug/111', {
name: 'name-topic',
})
const data = await client.topic.getTopicBySlug('111')
expect(data).toEqual(camelcaseKeys(mocked))
})
})

View File

@@ -0,0 +1,59 @@
import camelcaseKeys from 'camelcase-keys'
import { mockRequestInstance } from '~/__tests__/helpers/instance'
import { mockResponse } from '~/__tests__/helpers/response'
import { UserController } from '~/controllers'
describe('test user client', () => {
const client = mockRequestInstance(UserController)
test('GET /master', async () => {
const mocked = mockResponse('/master', {
id: '5ea4fe632507ba128f4c938c',
introduce: '这是我的小世界呀',
mail: 'i@innei.ren',
url: 'https://innei.ren',
name: 'Innei',
social_ids: {
bili_id: 26578164,
netease_id: 84302804,
github: 'Innei',
},
username: 'Innei',
created: '2020-04-26T03:22:11.784Z',
modified: '2020-11-13T09:38:49.014Z',
last_login_time: '2021-11-17T13:42:48.209Z',
avatar: 'https://cdn.innei.ren/avatar.png',
})
const data = await client.user.getMasterInfo()
expect(data.id).toEqual(mocked.id)
expect(data).toEqual(camelcaseKeys(mocked, { deep: true }))
expect(data.$raw.data).toEqual(mocked)
})
test('POST /login', async () => {
const mocked = mockResponse(
'/master/login',
{
id: '5ea4fe632507ba128f4c938c',
},
'post',
)
const data = await client.user.login('test', 'test')
expect(data.id).toEqual(mocked.id)
expect(data.$raw.data).toEqual(mocked)
})
test('GET /check_logged', async () => {
const mocked = mockResponse('/check_logged?token=bearer token', {
isGuest: true,
})
const data = await client.user.checkTokenValid('token')
expect(data).toEqual(mocked)
})
it('should call `master.xx` work', () => {
expect(client.master.getMasterInfo).toBeInstanceOf(Function)
expect(client.master).toEqual(client.user)
})
})

View File

@@ -0,0 +1,293 @@
import { AxiosError, AxiosResponse } from 'axios'
import { vi } from 'vitest'
import { axiosAdaptor } from '~/adaptors/axios'
import { umiAdaptor } from '~/adaptors/umi-request'
import {
NoteController,
PostController,
allContollerNames,
allControllers,
} from '~/controllers'
import { RequestError, createClient } from '~/core'
import { IRequestAdapter } from '~/interfaces/adapter'
import { ClientOptions } from '~/interfaces/client'
const { spyOn } = vi
// axios wrapper test
const generateClient = <
Response = AxiosResponse<unknown>,
AdaptorType extends IRequestAdapter = typeof axiosAdaptor,
>(
adapter?: AdaptorType,
options?: ClientOptions,
) =>
createClient(adapter ?? axiosAdaptor)<Response>(
'http://127.0.0.1:2323',
options,
)
describe('test client', () => {
it('should create new client with axios', () => {
const client = generateClient()
expect(client).toBeDefined()
})
describe('client `get` method', () => {
test('case 1', async () => {
spyOn(axiosAdaptor, 'get').mockImplementation((url) => {
if (url === 'http://127.0.0.1:2323/a/a?foo=bar') {
return Promise.resolve({ data: { ok: 1 } })
}
return Promise.resolve({ data: null })
})
const client = generateClient()
const data = await client.proxy.a.a.get({ params: { foo: 'bar' } })
expect(data).toStrictEqual({ ok: 1 })
})
test('case 2', async () => {
spyOn(axiosAdaptor, 'get').mockImplementation((url) => {
if (url === 'http://127.0.0.1:2323/a/a') {
return Promise.resolve({ data: { ok: 1 } })
}
return Promise.resolve({ data: null })
})
const client = generateClient()
const data = await client.proxy.a.a.get()
expect(data).toStrictEqual({ ok: 1 })
{
spyOn(axiosAdaptor, 'get').mockImplementation((url) => {
if (url === 'http://127.0.0.1:2323/a/b') {
return Promise.resolve({ data: { ok: 1 } })
}
return Promise.resolve({ data: null })
})
const client = generateClient()
const data = await client.proxy.a.b.get()
expect(data).toStrictEqual({ ok: 1 })
}
})
})
it('should throw error if not inject other client', () => {
const client = generateClient()
allContollerNames.forEach((name) => {
expect(() => (client as any)[name].name).toThrow(
`${
name.charAt(0).toUpperCase() + name.slice(1)
} Client not inject yet, please inject with client.injectClients(...)`,
)
})
})
it('should work if inject client', () => {
const client = generateClient()
client.injectControllers(allControllers)
allContollerNames.forEach((name) => {
expect(() => (client as any)[name].name).toBeDefined()
})
})
it('should inject single client worked', () => {
const client = generateClient()
client.injectControllers(PostController)
expect(client.post.name).toBeDefined()
})
it('should inject multi client worked', () => {
const client = generateClient()
client.injectControllers(PostController, NoteController)
expect(client.post.name).toBeDefined()
expect(client.note.name).toBeDefined()
})
it('should inject controller when init', () => {
const client = createClient(axiosAdaptor)('http://127.0.0.1:2323', {
controllers: [PostController, NoteController],
})
expect(client.post.name).toBeDefined()
expect(client.note.name).toBeDefined()
})
it('should infer response wrapper type', async () => {
const client = generateClient<AxiosResponse>(axiosAdaptor)
client.injectControllers(PostController)
spyOn(axiosAdaptor, 'get').mockImplementation((url) => {
if (url === 'http://127.0.0.1:2323/posts/latest') {
return Promise.resolve({ data: { ok: 1 }, status: 200 })
}
return Promise.resolve({ data: null })
})
const data = await client.post.getLatest()
expect(data.$raw.status).toBe(200)
})
it('should infer axios instance type', async () => {
const client = generateClient<AxiosResponse>(axiosAdaptor)
spyOn(axiosAdaptor, 'get').mockImplementation((url) => {
if (url === 'http://127.0.0.1:2323/a') {
return Promise.resolve({ data: { ok: 1 }, status: 200 })
}
return Promise.resolve({ data: null })
})
expect(client.instance.default).toBe(axiosAdaptor.default)
const res = await client.proxy.a.get()
expect(res.$raw.status).toBe(200)
{
spyOn(umiAdaptor, 'get').mockImplementation((url) => {
if (url === 'http://127.0.0.1:2323/a') {
return Promise.resolve({
data: { ok: 1 },
response: { status: 200, body: {} },
})
}
return Promise.resolve({ data: null })
})
const client2 = createClient(umiAdaptor)('http://127.0.0.1:2323')
expect(client2.instance.default).toBe(umiAdaptor.default)
const res = await client2.proxy.a.get()
expect(res.$raw.response.status).toBe(200)
expect(res.$raw.response.body).toStrictEqual({})
}
})
it('should resolve joint path call toString()', () => {
const client = generateClient()
{
const path = client.proxy.foo.bar.toString()
expect(path).toBe('/foo/bar')
}
{
const path = client.proxy.foo.bar.toString(true)
expect(path).toBe('http://127.0.0.1:2323/foo/bar')
}
})
it('should do not json convert case if payload is string or other primitive type', async () => {
const client = generateClient<AxiosResponse>(axiosAdaptor)
spyOn(axiosAdaptor, 'get').mockImplementation((url) => {
if (url === 'http://127.0.0.1:2323/a') {
return Promise.resolve({ data: 'foo', status: 200 })
}
return Promise.resolve({ data: null })
})
const data = await client.proxy.a.get()
expect(data).toBe('foo')
})
it('should throw exception with custom message and code', async () => {
const client = generateClient<AxiosResponse>(axiosAdaptor, {
// @ts-ignore
getCodeMessageFromException: (e: AxiosError) => {
return {
code: e.response?.status,
message: e.message,
}
},
})
spyOn(axiosAdaptor, 'get').mockImplementation(() => {
return Promise.reject(
new AxiosError(
'not found',
'NOT_FOUND',
{},
{},
{
status: 404,
config: {},
data: {},
headers: {},
statusText: 'not found',
},
),
)
})
try {
await client.proxy.a.get()
} catch (er: any) {
expect(er).toBeInstanceOf(RequestError)
expect(er.status).toBe(404)
}
})
it('should throw custom exception', async () => {
class MyRequestError extends Error {
constructor(
message: string,
public status: number,
public path: string,
public raw: any,
) {
super(message)
}
toResponse() {
return {
status: this.status,
}
}
}
const client = generateClient<AxiosResponse>(axiosAdaptor, {
// @ts-ignore
customThrowResponseError(err) {
return new MyRequestError(
err.message,
err.response?.status,
err.path,
err.raw,
)
},
})
spyOn(axiosAdaptor, 'get').mockImplementation(() => {
return Promise.reject(
new AxiosError(
'not found',
'NOT_FOUND',
{},
{},
{
status: 404,
config: {},
data: {},
headers: {},
statusText: 'not found',
},
),
)
})
try {
await client.proxy.a.get()
} catch (er: any) {
expect(er).toBeInstanceOf(MyRequestError)
expect(er.toResponse).toBeDefined()
expect(er.status).toBe(404)
}
})
})

View File

@@ -0,0 +1,108 @@
import { allControllers } from '~/controllers'
import { HTTPClient, RequestError, createClient } from '~/core'
import { IRequestAdapter } from '~/interfaces/adapter'
import { createMockServer } from './e2e-mock-server'
export const testAdaptor = (adaptor: IRequestAdapter) => {
let client: HTTPClient
const { app, close, port } = createMockServer()
afterAll(() => {
close()
})
beforeAll(() => {
client = createClient(adaptor)(`http://localhost:${port}`)
client.injectControllers(allControllers)
})
test('get', async () => {
app.get('/posts/1', (req, res) => {
res.send({
id: '1',
category_id: '11',
})
})
const res = await client.post.getPost('1')
expect(res).toStrictEqual({
id: '1',
categoryId: '11',
})
expect(res.$raw.data.category_id).toEqual('11')
})
test('post', async () => {
app.post('/comments/1', (req, res) => {
const { body } = req
res.send({
...body,
})
})
const dto = {
text: 'hello',
author: 'test',
mail: '1@ee.com',
}
const res = await client.comment.comment('1', dto)
expect(res).toStrictEqual(dto)
})
test('get with search query', async () => {
app.get('/search/post', (req, res) => {
if (req.query.keyword) {
return res.send({ result: 1 })
}
res.send(null)
})
const res = await client.search.search('post', 'keyword')
expect(res).toStrictEqual({ result: 1 })
})
test('rawResponse rawRequest should defined', async () => {
app.get('/search/post', (req, res) => {
if (req.query.keyword) {
return res.send({ result: 1 })
}
res.send(null)
})
const res = await client.search.search('post', 'keyword')
expect(res.$raw).toBeDefined()
expect(res.$raw.data).toBeDefined()
})
it('should error catch', async () => {
app.get('/error', (req, res) => {
res.status(500).send({
message: 'error message',
})
})
await expect(client.proxy.error.get()).rejects.toThrowError(RequestError)
})
it('should use number as path', async () => {
app.get('/1/1', (req, res) => {
res.send({ data: 1, foo_bar: 'foo' })
})
const res = await client.proxy(1)(1).get<{ data: number; fooBar: string }>()
expect(res).toStrictEqual({ data: 1, fooBar: 'foo' })
expect(res.$raw.data).toStrictEqual({ data: 1, foo_bar: 'foo' })
expect(res.$request).toBeDefined()
})
it('should get string payload', async () => {
app.get('/string', (req, res) => {
res.send('x')
})
const res = await client.proxy('string').get<string>()
expect(res).toStrictEqual('x')
})
}

View File

@@ -0,0 +1,22 @@
import cors from 'cors'
import express from 'express'
import { AddressInfo } from 'net'
type Express = ReturnType<typeof express>
export const createMockServer = (options: { port?: number } = {}) => {
const { port = 0 } = options
const app: Express = express()
app.use(express.json())
app.use(cors())
const server = app.listen(port)
return {
app,
port: (server.address() as AddressInfo).port,
server,
close() {
server.close()
},
}
}

View File

@@ -0,0 +1,39 @@
// @ts-nocheck
/* eslint-disable */
import AbortController from 'abort-controller'
import fetch, { Headers, Request, Response } from 'node-fetch'
const TEN_MEGABYTES = 1000 * 1000 * 10
if (!globalThis.fetch) {
globalThis.fetch = (url, options) =>
fetch(url, { highWaterMark: TEN_MEGABYTES, ...options })
}
if (!globalThis.Headers) {
globalThis.Headers = Headers
}
if (!globalThis.Request) {
globalThis.Request = Request
}
if (!globalThis.Response) {
globalThis.Response = Response
}
if (!globalThis.AbortController) {
globalThis.AbortController = AbortController
}
if (!globalThis.ReadableStream) {
try {
// eslint-disable-next-line node/file-extension-in-import, node/no-unsupported-features/es-syntax
globalThis.ReadableStream = await import(
'web-streams-polyfill/ponyfill/es2018'
)
} catch {}
}
export {}

View File

@@ -0,0 +1,11 @@
import { axiosAdaptor } from '~/adaptors/axios'
import { HTTPClient, createClient } from '~/core'
import { IController } from '~/interfaces/controller'
export const mockRequestInstance = (
injectController: new (client: HTTPClient) => IController,
) => {
const client = createClient(axiosAdaptor)('https://api.innei.ren/v2')
client.injectControllers(injectController)
return client
}

View File

@@ -0,0 +1,76 @@
import { isEqual } from 'lodash'
import { URLSearchParams } from 'url'
import { vi } from 'vitest'
import { axiosAdaptor } from '~/adaptors/axios'
const { spyOn } = vi
export const buildResponseDataWrapper = (data: any) => ({ data })
export const mockResponse = <T>(
path: string,
data: T,
method = 'get',
requestBody?: any,
) => {
const exceptUrlObject = new URL(
path.startsWith('http')
? path
: `https://example.com/${path.replace(/^\//, '')}`,
)
// @ts-ignore
spyOn(axiosAdaptor, method).mockImplementation(
// @ts-ignore
async (requestUrl: string, options: any) => {
const requestUrlObject = new URL(requestUrl)
if (requestBody) {
const { data } = options || {}
if (!isEqual(requestBody, data)) {
throw new Error(
`body not equal, got: ${JSON.stringify(
data,
)} except: ${JSON.stringify(requestBody)}`,
)
}
}
if (
requestUrlObject.pathname.endsWith(exceptUrlObject.pathname) &&
(exceptUrlObject.search
? isSearchEqual(
exceptUrlObject.searchParams,
requestUrlObject.searchParams,
)
: true)
) {
return buildResponseDataWrapper(data)
} else {
return buildResponseDataWrapper({
error: 1,
requestPath: requestUrlObject.pathname + requestUrlObject.search,
expectPath: path,
})
}
},
)
return data
}
const isSearchEqual = (a: URLSearchParams, b: URLSearchParams) => {
const keys = Array.from(a.keys()).sort()
if (keys.toString() !== Array.from(b.keys()).sort().toString()) {
return false
}
return keys.every((key) => {
const res = a.get(key) === b.get(key)
if (!res) {
console.log(
`key ${key} not equal, receive ${a.get(key)} want ${b.get(key)}`,
)
}
return res
})
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
import { autoBind } from '~/utils/auto-bind'
describe('test auto bind', () => {
it('should bind in class', () => {
class A {
constructor() {
autoBind(this)
}
name = 'A'
foo() {
return this?.name
}
}
expect(new A().foo()).toBe('A')
function tester(caller: any) {
return caller()
}
expect(tester(new A().foo)).toBe('A')
function tester2<T extends (...args: any) => any>(caller: T) {
return caller.call({})
}
expect(tester2(new A().foo)).toBe('A')
})
})

View File

@@ -0,0 +1,86 @@
import camelcaseKeysLib from 'camelcase-keys'
import { camelcaseKeys } from '~/utils/camelcase-keys'
describe('test camelcase keys', () => {
it('case 1 normal', () => {
const obj = {
tool: 'too',
tool_name: 'too_name',
a_b: 1,
a: 1,
b: {
c_d: 1,
},
}
expect(camelcaseKeys(obj)).toStrictEqual(
camelcaseKeysLib(obj, {
deep: true,
}),
)
})
it('case 2: key has number', () => {
const obj = {
b147da0eaecbea00aeb62055: {
data: {},
},
a_c11ab_Ac: [
{
a_b: 1,
},
1,
],
}
expect(camelcaseKeys(obj)).toStrictEqual({
b147da0eaecbea00aeb62055: {
data: {},
},
aC11abAc: [
{
aB: 1,
},
1,
],
})
})
it('case 3: not a object', () => {
const value = 1
expect(camelcaseKeys(value)).toBe(value)
})
it('case 4: nullable value', () => {
let value = null
expect(camelcaseKeys(value)).toBe(value)
value = undefined
expect(camelcaseKeys(value)).toBe(value)
value = NaN
expect(camelcaseKeys(value)).toBe(value)
})
it('case 5: array', () => {
const arr = [
{
a_b: 1,
},
null,
undefined,
+0,
-0,
Infinity,
{
a_b: 1,
},
]
expect(camelcaseKeys(arr)).toStrictEqual(
camelcaseKeysLib(arr, { deep: true }),
)
})
})

View File

@@ -0,0 +1,33 @@
import { destructureData } from '~/utils'
describe('test utils', () => {
test('destructureData', () => {
const d = destructureData({ data: { a: 1, b: 2 } })
expect(d).toEqual({ a: 1, b: 2 })
const d2 = destructureData({ data: { a: 1, b: 2 }, c: 3 })
expect(d2).toEqual({ data: { a: 1, b: 2 }, c: 3 })
const d3 = destructureData({ data: [{ a: 1 }] })
expect(d3).toEqual({ data: [{ a: 1 }] })
const d4 = destructureData({ a: 1 })
expect(d4).toEqual({ a: 1 })
const d5 = destructureData([])
expect(d5).toEqual([])
const d6 = destructureData({ data: [] })
expect(d6).toEqual({ data: [] })
const d7 = destructureData(
(() => {
const d = { data: { a: 1 } }
Object.defineProperty(d, '$raw', { value: { a: 1 }, enumerable: false })
return d
})(),
)
expect(d7).toEqual({ a: 1 })
expect(d7.$raw).toBeTruthy()
})
})

View File

@@ -0,0 +1,8 @@
import { resolveFullPath } from '~/utils/path'
describe('TEST path.utils', () => {
it('should resolve full path', () => {
const path = resolveFullPath('http://localhost:3000', '/api/v1/users')
expect(path).toBe('http://localhost:3000/api/v1/users')
})
})

View File

@@ -0,0 +1,44 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios'
import { IRequestAdapter } from '~/interfaces/adapter'
// eslint-disable-next-line spaced-comment
const $http = /*#__PURE__*/ axios.create({})
// ignore axios `method` declare not assignable to `Method`
export const axiosAdaptor: IRequestAdapter<
AxiosInstance,
AxiosResponse<unknown>
> = Object.preventExtensions({
get default() {
return $http
},
responseWrapper: {} as any as AxiosResponse<unknown>,
get(url, options) {
// @ts-ignore
return $http.get(url, options)
},
post(url, options) {
const { data, ...config } = options || {}
// @ts-ignore
return $http.post(url, data, config)
},
put(url, options) {
const { data, ...config } = options || {}
// @ts-ignore
return $http.put(url, data, config)
},
delete(url, options) {
const { ...config } = options || {}
// @ts-ignore
return $http.delete(url, config)
},
patch(url, options) {
const { data, ...config } = options || {}
// @ts-ignore
return $http.patch(url, data, config)
},
})
// eslint-disable-next-line import/no-default-export
export default axiosAdaptor

View File

@@ -0,0 +1,76 @@
import ky, { Options, ResponsePromise } from 'ky'
import { KyInstance } from 'ky/distribution/types/ky'
import { IRequestAdapter } from '~/interfaces/adapter'
// eslint-disable-next-line spaced-comment
const $http: KyInstance = /*#__PURE__*/ ky.create({})
// TODO post data not only json,
const getDataFromKyResponse = async (response: ResponsePromise) => {
const res = await response
const isJsonType = res.headers
.get('content-type')
?.includes('application/json')
const json = isJsonType ? await res.clone().json() : null
const result: Awaited<ResponsePromise> & {
data: any
} = {
...res,
data: json ?? (await res.clone().text()),
}
return result
}
export const createKyAdaptor = (ky: KyInstance) => {
const adaptor: IRequestAdapter<KyInstance, ResponsePromise> =
Object.preventExtensions({
get default() {
return ky
},
responseWrapper: {} as any as ResponsePromise,
get(url, options) {
return getDataFromKyResponse(ky.get(url, options))
},
post(url, options) {
const data = options.data
delete options.data
const kyOptions: Options = {
...options,
json: data,
}
return getDataFromKyResponse(ky.post(url, kyOptions))
},
put(url, options) {
const data = options.data
delete options.data
const kyOptions: Options = {
...options,
json: data,
}
return getDataFromKyResponse(ky.put(url, kyOptions))
},
patch(url, options) {
const data = options.data
delete options.data
const kyOptions: Options = {
...options,
json: data,
}
return getDataFromKyResponse(ky.patch(url, kyOptions))
},
delete(url, options) {
return getDataFromKyResponse(ky.delete(url, options))
},
})
return adaptor
}
export const defaultKyAdaptor = createKyAdaptor($http)
// eslint-disable-next-line import/no-default-export
export default defaultKyAdaptor

View File

@@ -0,0 +1,38 @@
import { RequestMethod, RequestResponse, extend } from 'umi-request'
import { IRequestAdapter } from '~/interfaces/adapter'
// eslint-disable-next-line spaced-comment
const $http = /*#__PURE__*/ extend({
getResponse: true,
requestType: 'json',
responseType: 'json',
})
export const umiAdaptor: IRequestAdapter<
RequestMethod<true>,
RequestResponse
> = Object.preventExtensions({
get default() {
return $http
},
responseWrapper: {} as any as RequestResponse,
get(url, options) {
return $http.get(url, options)
},
post(url, options) {
return $http.post(url, options)
},
put(url, options) {
return $http.put(url, options)
},
delete(url, options) {
return $http.delete(url, options)
},
patch(url, options) {
return $http.patch(url, options)
},
})
// eslint-disable-next-line import/no-default-export
export default umiAdaptor

View File

@@ -0,0 +1,70 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import { SortOrder } from '~/interfaces/options'
import { IRequestHandler, RequestProxyResult } from '~/interfaces/request'
import {
AggregateRoot,
AggregateStat,
AggregateTop,
TimelineData,
TimelineType,
} from '~/models/aggregate'
import { sortOrderToNumber } from '~/utils'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
aggregate: AggregateController<ResponseWrapper>
}
}
export class AggregateController<ResponseWrapper> implements IController {
base = 'aggregate'
name = 'aggregate'
constructor(private client: HTTPClient) {
autoBind(this)
}
public get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
/**
* 获取聚合数据
*/
getAggregateData(): RequestProxyResult<AggregateRoot, ResponseWrapper> {
return this.proxy.get<AggregateRoot>()
}
/**
* 获取最新发布的内容
*/
getTop(size = 5) {
return this.proxy.top.get<AggregateTop>({ params: { size } })
}
getTimeline(options?: {
sort?: SortOrder
type?: TimelineType
year?: number
}) {
const { sort, type, year } = options || {}
return this.proxy.timeline.get<{ data: TimelineData }>({
params: {
sort: sort && sortOrderToNumber(sort),
type,
year,
},
})
}
/**
* 获取聚合数据统计
*/
getStat() {
return this.proxy.stat.get<AggregateStat>()
}
}

View File

@@ -0,0 +1,37 @@
import { IRequestHandler, RequestProxyResult } from '~/interfaces/request'
import { PaginateResult } from '~/models/base'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core'
export type SortOptions = {
sortBy?: string
sortOrder?: 1 | -1 | 'asc' | 'desc'
}
export abstract class BaseCrudController<T, ResponseWrapper> {
base!: string
constructor(protected client: HTTPClient) {
autoBind(this)
}
public get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
getById(id: string): RequestProxyResult<T, ResponseWrapper> {
return this.proxy(id).get<T>()
}
getAll() {
return this.proxy.all.get<{ data: T[] }>()
}
/**
* 带分页的查询
* @param page
* @param perPage
*/
getAllPaginated(page?: number, perPage?: number, sortOption?: SortOptions) {
return this.proxy.get<PaginateResult<T>>({
params: { page, size: perPage, ...sortOption },
})
}
}

View File

@@ -0,0 +1,119 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import {
IRequestHandler,
RequestProxyResult,
ResponseProxyExtraRaw,
} from '~/interfaces/request'
import { attachRawFromOneToAnthor, destructureData } from '~/utils'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core/client'
import { RequestError } from '../core/error'
import {
CategoryEntries,
CategoryModel,
CategoryType,
CategoryWithChildrenModel,
TagModel,
} from '../models/category'
import { PostModel } from '../models/post'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
category: CategoryController<ResponseWrapper>
}
}
export class CategoryController<ResponseWrapper> implements IController {
name = 'category'
base = 'categories'
constructor(private client: HTTPClient) {
autoBind(this)
}
public get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
getAllCategories(): RequestProxyResult<
{ data: CategoryModel[] },
ResponseWrapper
> {
return this.proxy.get({
params: {
type: CategoryType.Category,
},
})
}
getAllTags(): RequestProxyResult<{ data: TagModel[] }, ResponseWrapper> {
return this.proxy.get<{ data: TagModel[] }>({
params: {
type: CategoryType.Tag,
},
})
}
async getCategoryDetail(
id: string,
): Promise<ResponseProxyExtraRaw<CategoryWithChildrenModel>>
async getCategoryDetail(
ids: string[],
): Promise<ResponseProxyExtraRaw<Map<string, CategoryWithChildrenModel>>>
async getCategoryDetail(ids: string | string[]): Promise<any> {
if (typeof ids === 'string') {
const data = await this.proxy.get<CategoryEntries>({
params: {
ids,
},
})
const result = Object.values(data.entries)[0]
attachRawFromOneToAnthor(data, result)
return result
} else if (Array.isArray(ids)) {
const data = await this.proxy.get<CategoryEntries>({
params: {
ids: ids.join(','),
},
})
const entries = data?.entries
if (!entries) {
throw new RequestError(
'data structure error',
500,
data.$request.path,
data,
)
}
const map = new Map<string, CategoryWithChildrenModel>(
Object.entries(entries).map(([id, value]) => [id.toLowerCase(), value]),
)
attachRawFromOneToAnthor(data, map)
return map
}
}
async getCategoryByIdOrSlug(idOrSlug: string) {
const res = await this.proxy(idOrSlug).get<CategoryWithChildrenModel>()
return destructureData(res) as typeof res
}
async getTagByName(name: string) {
const res = await this.proxy(name).get<{
tag: string
data: Pick<PostModel, 'id' | 'title' | 'slug' | 'category' | 'created'>[]
}>({
params: {
tag: 1,
},
})
return res
}
}

View File

@@ -0,0 +1,69 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import { PaginationParams } from '~/interfaces/params'
import { IRequestHandler } from '~/interfaces/request'
import { PaginateResult } from '~/models/base'
import { CommentModel } from '~/models/comment'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core'
import { CommentDto } from '../dtos/comment'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
comment: CommentController<ResponseWrapper>
}
}
export class CommentController<ResponseWrapper> implements IController {
base = 'comments'
name = 'comment'
constructor(private readonly client: HTTPClient) {
autoBind(this)
}
get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
/**
* 根据 comment id 获取评论, 包括子评论
*/
getById(id: string) {
return this.proxy(id).get<CommentModel & { ref: string }>()
}
/**
* 获取文章的评论列表
* @param refId 文章 Id
*/
getByRefId(refId: string, pagination: PaginationParams = {}) {
const { page, size } = pagination
return this.proxy
.ref(refId)
.get<PaginateResult<CommentModel & { ref: string }>>({
params: { page: page || 1, size: size || 10 },
})
}
/**
* 评论
*/
comment(refId: string, data: CommentDto) {
return this.proxy(refId).post<CommentModel>({
data,
})
}
/**
* 回复评论
*/
reply(commentId: string, data: CommentDto) {
return this.proxy.reply(commentId).post<CommentModel>({
data,
})
}
}

View File

@@ -0,0 +1,74 @@
import { AggregateController } from './aggregate'
import { CategoryController } from './category'
import { CommentController } from './comment'
import { LinkController } from './link'
import { NoteController } from './note'
import { PageController } from './page'
import { PostController } from './post'
import { ProjectController } from './project'
import { RecentlyController } from './recently'
import { SayController } from './say'
import { SearchController } from './search'
import { ServerlessController } from './severless'
import { SnippetController } from './snippet'
import { TopicController } from './topic'
import { UserController } from './user'
export const allControllers = [
AggregateController,
CategoryController,
CommentController,
LinkController,
NoteController,
PageController,
PostController,
ProjectController,
RecentlyController,
TopicController,
SayController,
SearchController,
SnippetController,
ServerlessController,
UserController,
]
export const allContollerNames = [
'aggregate',
'category',
'comment',
'link',
'note',
'page',
'post',
'project',
'topic',
'recently',
'say',
'search',
'snippet',
'serverless',
'user',
// alias,
'friend',
'master',
'shorthand',
] as const
export {
AggregateController,
CategoryController,
CommentController,
LinkController,
NoteController,
PageController,
PostController,
ProjectController,
RecentlyController,
SayController,
SearchController,
SnippetController,
ServerlessController,
UserController,
TopicController,
}

View File

@@ -0,0 +1,47 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { LinkModel } from '~/models/link'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core'
import { BaseCrudController } from './base'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
link: LinkController<ResponseWrapper>
friend: LinkController<ResponseWrapper>
}
}
export class LinkController<ResponseWrapper> extends BaseCrudController<
LinkModel,
ResponseWrapper
> {
constructor(protected readonly client: HTTPClient) {
super(client)
autoBind(this)
}
// 是否可以申请友链
async canApplyLink() {
const { can } = await this.proxy.audit.get<{ can: boolean }>()
return can
}
// 申请友链
async applyLink(
data: Pick<
LinkModel,
'avatar' | 'name' | 'description' | 'url' | 'email'
> & {
author: string
},
) {
return await this.proxy.audit.post<never>({ data })
}
name = ['link', 'friend']
base = 'links'
}

View File

@@ -0,0 +1,129 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import { IRequestHandler, RequestProxyResult } from '~/interfaces/request'
import { SelectFields } from '~/interfaces/types'
import { PaginateResult } from '~/models/base'
import { NoteModel, NoteWrappedPayload } from '~/models/note'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core/client'
import { SortOptions } from './base'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
note: NoteController<ResponseWrapper>
}
}
export type NoteListOptions = {
select?: SelectFields<keyof NoteModel>
year?: number
sortBy?: 'weather' | 'mood' | 'title' | 'created' | 'modified'
sortOrder?: 1 | -1
}
export class NoteController<ResponseWrapper> implements IController {
base = 'notes'
name = 'note'
constructor(private client: HTTPClient) {
autoBind(this)
}
get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
/**
* 最新日记
*/
getLatest() {
return this.proxy.latest.get<NoteWrappedPayload>()
}
/**
* 获取一篇日记, 根据 Id 查询需要鉴权
* @param id id | nid
* @param password 访问密码
*/
getNoteById(
id: string,
): Promise<RequestProxyResult<NoteModel, ResponseWrapper>>
getNoteById(id: number): Promise<NoteWrappedPayload>
getNoteById(id: number, password: string): Promise<NoteWrappedPayload>
getNoteById(
id: number,
password: undefined,
singleResult: true,
): Promise<RequestProxyResult<NoteModel, ResponseWrapper>>
getNoteById(
id: number,
password: string,
singleResult: true,
): Promise<RequestProxyResult<NoteModel, ResponseWrapper>>
getNoteById(...rest: any[]): any {
const [id, password = undefined, singleResult = false] = rest
if (typeof id === 'number') {
return this.proxy.nid(id.toString()).get<NoteWrappedPayload>({
params: { password, single: singleResult ? '1' : undefined },
})
} else {
return this.proxy(id).get<NoteModel>()
}
}
/**
* 日记列表分页
*/
getList(page = 1, perPage = 10, options: NoteListOptions = {}) {
const { select, sortBy, sortOrder, year } = options
return this.proxy.get<PaginateResult<NoteModel>>({
params: {
page,
size: perPage,
select: select?.join(' '),
sortBy,
sortOrder,
year,
},
})
}
/**
* 获取当前日记的上下各 n / 2 篇日记
*/
getMiddleList(id: string, size = 5) {
return this.proxy.list(id).get<{
data: Pick<NoteModel, 'id' | 'title' | 'nid' | 'created'>[]
size: number
}>({
params: { size },
})
}
/**
* 喜欢这篇日记
*/
likeIt(id: string | number) {
return this.proxy.like(id).get<never>()
}
/**
* 获取专栏内的所有日记
*/
getNoteByTopicId(
topicId: string,
page = 1,
size = 10,
sortOptions: SortOptions = {},
) {
return this.proxy.topics(topicId).get<PaginateResult<NoteModel>>({
params: { page, size, ...sortOptions },
})
}
}

View File

@@ -0,0 +1,65 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import { IRequestHandler } from '~/interfaces/request'
import { SelectFields } from '~/interfaces/types'
import { PaginateResult } from '~/models/base'
import { PageModel } from '~/models/page'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
page: PageController<ResponseWrapper>
}
}
export type PageListOptions = {
select?: SelectFields<keyof PageModel>
sortBy?: 'order' | 'subtitle' | 'title' | 'created' | 'modified'
sortOrder?: 1 | -1
}
export class PageController<ResponseWrapper> implements IController {
constructor(private readonly client: HTTPClient) {
autoBind(this)
}
base = 'pages'
name = 'page'
get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
/**
* 页面列表
*/
getList(page = 1, perPage = 10, options: PageListOptions = {}) {
const { select, sortBy, sortOrder } = options
return this.proxy.get<PaginateResult<PageModel>>({
params: {
page,
size: perPage,
select: select?.join(' '),
sortBy,
sortOrder,
},
})
}
/**
* 页面详情
*/
getById(id: string) {
return this.proxy(id).get<PageModel>()
}
/**
* 根据路径获取页面
* @param slug 路径
* @returns
*/
getBySlug(slug: string) {
return this.proxy.slug(slug).get<PageModel>({})
}
}

View File

@@ -0,0 +1,95 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import { IRequestHandler, RequestProxyResult } from '~/interfaces/request'
import { SelectFields } from '~/interfaces/types'
import { PaginateResult } from '~/models/base'
import { PostModel } from '~/models/post'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core/client'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
post: PostController<ResponseWrapper>
}
}
export type PostListOptions = {
select?: SelectFields<keyof PostModel>
year?: number
sortBy?: 'categoryId' | 'title' | 'created' | 'modified'
sortOrder?: 1 | -1
}
export class PostController<ResponseWrapper> implements IController {
constructor(private client: HTTPClient) {
autoBind(this)
}
base = 'posts'
name = 'post'
public get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
/**
* 获取文章列表分页
* @param page
* @param perPage
* @returns
*/
getList(page = 1, perPage = 10, options: PostListOptions = {}) {
const { select, sortBy, sortOrder, year } = options
return this.proxy.get<PaginateResult<PostModel>>({
params: {
page,
size: perPage,
select: select?.join(' '),
sortBy,
sortOrder,
year,
},
})
}
/**
* 根据分类和路径查找文章
* @param categoryName
* @param slug
*/
getPost(
categoryName: string,
slug: string,
): RequestProxyResult<PostModel, ResponseWrapper>
/**
* 根据 ID 查找文章
* @param id
*/
getPost(id: string): RequestProxyResult<PostModel, ResponseWrapper>
getPost(idOrCategoryName: string, slug?: string): any {
if (arguments.length == 1) {
return this.proxy(idOrCategoryName).get<PostModel>()
} else {
return this.proxy(idOrCategoryName)(slug).get<PostModel>()
}
}
/**
* 获取最新的文章
*/
getLatest() {
return this.proxy.latest.get<PostModel>()
}
/**
* 点赞
*/
thumbsUp(id: string) {
return this.proxy('_thumbs-up').get<void>({ params: { id } })
}
}

View File

@@ -0,0 +1,28 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { ProjectModel } from '~/models/project'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core'
import { BaseCrudController } from './base'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
project: ProjectController<ResponseWrapper>
}
}
export class ProjectController<ResponseWrapper> extends BaseCrudController<
ProjectModel,
ResponseWrapper
> {
constructor(protected readonly client: HTTPClient) {
super(client)
autoBind(this)
}
base = 'projects'
name = 'project'
}

View File

@@ -0,0 +1,54 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import { IRequestHandler } from '~/interfaces/request'
import { RecentlyModel } from '~/models/recently'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
recently: RecentlyController<ResponseWrapper>
shorthand: RecentlyController<ResponseWrapper>
}
}
export class RecentlyController<ResponseWrapper> implements IController {
base = 'recently'
name = ['recently', 'shorthand']
constructor(private readonly client: HTTPClient) {
autoBind(this)
}
get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
/**
* 获取最新一条
*/
getLatestOne() {
return this.proxy.latest.get<RecentlyModel>()
}
getAll() {
return this.proxy.all.get<{ data: RecentlyModel[] }>()
}
getList(
before?: string | undefined,
after?: string | undefined,
size?: number | number,
) {
return this.proxy.get<{ data: RecentlyModel[] }>({
params: {
before,
after,
size,
},
})
}
}

View File

@@ -0,0 +1,41 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import { IRequestHandler } from '~/interfaces/request'
import { SayModel } from '~/models/say'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core'
import { BaseCrudController } from './base'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
say: SayController<ResponseWrapper>
}
}
export class SayController<ResponseWrapper>
extends BaseCrudController<SayModel, ResponseWrapper>
implements IController
{
base = 'says'
name = 'say'
constructor(protected client: HTTPClient) {
super(client)
autoBind(this)
}
public get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
/**
* 获取随机一条
*/
getRandom() {
return this.proxy.random.get<{ data: SayModel | null }>()
}
}

View File

@@ -0,0 +1,109 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import { IRequestHandler, RequestProxyResult } from '~/interfaces/request'
import { PaginateResult } from '~/models/base'
import { NoteModel } from '~/models/note'
import { PostModel } from '~/models/post'
import { autoBind } from '~/utils/auto-bind'
import { PageModel } from '..'
import { HTTPClient } from '../core'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
search: SearchController<ResponseWrapper>
}
}
export type SearchType = 'post' | 'note'
export type SearchOption = {
orderBy?: string
order?: number
rawAlgolia?: boolean
}
export class SearchController<ResponseWrapper> implements IController {
base = 'search'
name = 'search'
constructor(private readonly client: HTTPClient) {
autoBind(this)
}
get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
search(
type: 'note',
keyword: string,
options?: Omit<SearchOption, 'rawAlgolia'>,
): Promise<
RequestProxyResult<
PaginateResult<
Pick<NoteModel, 'modified' | 'id' | 'title' | 'created' | 'nid'>
>,
ResponseWrapper
>
>
search(
type: 'post',
keyword: string,
options?: Omit<SearchOption, 'rawAlgolia'>,
): Promise<
RequestProxyResult<
PaginateResult<
Pick<
PostModel,
'modified' | 'id' | 'title' | 'created' | 'slug' | 'category'
>
>,
ResponseWrapper
>
>
search(
type: SearchType,
keyword: string,
options: Omit<SearchOption, 'rawAlgolia'> = {},
): any {
return this.proxy(type).get({
params: { keyword, ...options },
})
}
/**
* 从 algolya 搜索
* https://www.algolia.com/doc/api-reference/api-methods/search/
* @param keyword
* @param options
* @returns
*/
searchByAlgolia(keyword: string, options?: SearchOption) {
return this.proxy('algolia').get<
RequestProxyResult<
PaginateResult<
| (Pick<
PostModel,
'modified' | 'id' | 'title' | 'created' | 'slug' | 'category'
> & { type: 'post' })
| (Pick<
NoteModel,
'id' | 'created' | 'id' | 'modified' | 'title' | 'nid'
> & { type: 'note' })
| (Pick<
PageModel,
'id' | 'title' | 'created' | 'modified' | 'slug'
> & { type: 'page' })
> & {
/**
* @see: algoliasearch <https://www.algolia.com/doc/api-reference/api-methods/search/>
*/
raw?: any
},
ResponseWrapper
>
>({ params: { keyword, ...options } })
}
}

View File

@@ -0,0 +1,32 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import { IRequestHandler } from '~/interfaces/request'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
serverless: ServerlessController<ResponseWrapper>
}
}
export class ServerlessController<ResponseWrapper> implements IController {
base = 'serverless'
name = 'serverless'
constructor(protected client: HTTPClient) {
autoBind(this)
}
get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
getByReferenceAndName<T = unknown>(reference: string, name: string) {
return this.proxy(reference)(name).get<T>()
}
}

View File

@@ -0,0 +1,36 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import { IRequestHandler } from '~/interfaces/request'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
snippet: SnippetController<ResponseWrapper>
}
}
export class SnippetController<ResponseWrapper> implements IController {
base = 'snippets'
name = 'snippet'
constructor(protected client: HTTPClient) {
autoBind(this)
}
get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
// getById(id: string) {
// return this.proxy(id).get<Omit<SnippetModel, 'data'>>()
// }
getByReferenceAndName<T = unknown>(reference: string, name: string) {
return this.proxy(reference)(name).get<T>()
}
}

View File

@@ -0,0 +1,38 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import { IRequestHandler } from '~/interfaces/request'
import { TopicModel } from '~/models/topic'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core'
import { BaseCrudController } from './base'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
topic: TopicController<ResponseWrapper>
}
}
export class TopicController<ResponseWrapper>
extends BaseCrudController<TopicModel, ResponseWrapper>
implements IController
{
base = 'topics'
name = 'topic'
constructor(protected client: HTTPClient) {
super(client)
autoBind(this)
}
public get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
getTopicBySlug(slug: string) {
return this.proxy.slug(slug).get<TopicModel>()
}
}

View File

@@ -0,0 +1,62 @@
import { IRequestAdapter } from '~/interfaces/adapter'
import { IController } from '~/interfaces/controller'
import { IRequestHandler } from '~/interfaces/request'
import { TLogin, UserModel } from '~/models/user'
import { autoBind } from '~/utils/auto-bind'
import { HTTPClient } from '../core'
declare module '../core/client' {
interface HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
user: UserController<ResponseWrapper>
master: UserController<ResponseWrapper>
}
}
export class UserController<ResponseWrapper> implements IController {
constructor(private readonly client: HTTPClient) {
autoBind(this)
}
base = 'master'
name = ['user', 'master']
public get proxy(): IRequestHandler<ResponseWrapper> {
return this.client.proxy(this.base)
}
getMasterInfo() {
return this.proxy.get<UserModel>()
}
login(username: string, password: string) {
return this.proxy.login.post<TLogin>({
data: {
username,
password,
},
})
}
loginWithToken(token?: string) {
return this.proxy.login.put<{ token: string }>({
params: token
? {
token: `bearer ${token.replace(/^Bearer\s/i, '')}`,
}
: undefined,
})
}
checkTokenValid(token: string) {
return this.proxy.check_logged.get<{ ok: number; isGuest: boolean }>({
params: {
token: `bearer ${token.replace(/^Bearer\s/i, '')}`,
},
})
}
}

View File

@@ -0,0 +1,43 @@
import { HTTPClient } from '.'
export function attachRequestMethod<T extends HTTPClient<any, any>>(target: T) {
Object.defineProperty(target, '$$get', {
value(url: string, options?: any) {
// HINT: method get only accept search params;
const { params = {}, ...rest } = options
const qs = handleSearchParams(params)
return target.instance.get(`${url}${qs ? `${`?${qs}`}` : ''}`, rest)
},
})
;(['put', 'post', 'patch', 'delete'] as const).forEach((method) => {
Object.defineProperty(target, `$$${method}`, {
value(path: string, options?: any) {
return target.instance[method](path, options)
},
})
})
}
// FIXME: only support string value
function handleSearchParams(obj: URLSearchParams | Record<string, string>) {
if (!obj && typeof obj !== 'object') {
throw new TypeError('params must be object.')
}
if (obj instanceof URLSearchParams) {
return obj.toString()
}
const search = new URLSearchParams()
Object.entries<any>(obj).forEach(([k, v]) => {
if (
typeof v === 'undefined' ||
Object.prototype.toString.call(v) === '[object Null]'
) {
return
}
search.set(k, v)
})
return search.toString()
}

View File

@@ -0,0 +1,252 @@
import {
IAdaptorRequestResponseType,
IRequestAdapter,
} from '~/interfaces/adapter'
import { ClientOptions } from '~/interfaces/client'
import { IController } from '~/interfaces/controller'
import { RequestOptions } from '~/interfaces/instance'
import { IRequestHandler, Method } from '~/interfaces/request'
import { Class } from '~/interfaces/types'
import { isPlainObject } from '~/utils'
import { camelcaseKeys } from '~/utils/camelcase-keys'
import { resolveFullPath } from '~/utils/path'
import { allContollerNames } from '../controllers'
import { attachRequestMethod } from './attach-request'
import { RequestError } from './error'
const methodPrefix = '_$'
export type { HTTPClient }
class HTTPClient<
T extends IRequestAdapter = IRequestAdapter,
ResponseWrapper = unknown,
> {
private readonly _proxy: IRequestHandler<ResponseWrapper>
constructor(
private readonly _endpoint: string,
private _adaptor: T,
private options: Omit<ClientOptions, 'controllers'> = {},
) {
this._endpoint = _endpoint
.replace(/\/*$/, '')
.replace('localhost', '127.0.0.1')
this._proxy = this.buildRoute(this)()
options.transformResponse =
options.transformResponse || ((data) => camelcaseKeys(data))
this.initGetClient()
attachRequestMethod(this)
}
private initGetClient() {
for (const name of allContollerNames) {
Object.defineProperty(this, name, {
get() {
const client = Reflect.get(this, `${methodPrefix}${name}`)
if (!client) {
throw new ReferenceError(
`${
name.charAt(0).toUpperCase() + name.slice(1)
} Client not inject yet, please inject with client.injectClients(...)`,
)
}
return client
},
configurable: false,
enumerable: false,
})
}
}
public injectControllers(...Controller: Class<IController>[]): void
public injectControllers(Controller: Class<IController>[]): void
public injectControllers(Controller: any, ...rest: any[]) {
Controller = Array.isArray(Controller) ? Controller : [Controller, ...rest]
for (const Client of Controller) {
const cl = new Client(this)
if (Array.isArray(cl.name)) {
for (const name of cl.name) {
attach.call(this, name, cl)
}
} else {
attach.call(this, cl.name, cl)
}
}
function attach(this: any, name: string, cl: IController) {
Object.defineProperty(this, `${methodPrefix}${name.toLowerCase()}`, {
get() {
return cl
},
enumerable: false,
configurable: false,
})
}
}
get endpoint() {
return this._endpoint
}
get instance() {
return this._adaptor
}
public request(options: {
url: string
method?: string
data?: any
params?: any
}) {
return (this as any)[`$$${String(options.method || 'get').toLowerCase()}`](
options.url,
options,
) as Promise<IAdaptorRequestResponseType<any>>
}
public get proxy() {
return this._proxy
}
private buildRoute(manager: this): () => IRequestHandler<ResponseWrapper> {
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {}
const methods = ['get', 'post', 'delete', 'patch', 'put']
const reflectors = [
'toString',
'valueOf',
'inspect',
'constructor',
Symbol.toPrimitive,
]
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this
return () => {
const route = ['']
const handler: any = {
get(target: any, name: Method) {
if (reflectors.includes(name))
return (withBase?: boolean) => {
if (withBase) {
const path = resolveFullPath(that.endpoint, route.join('/'))
route.length = 0
return path
} else {
const path = route.join('/')
route.length = 0
return path.startsWith('/') ? path : `/${path}`
}
}
if (methods.includes(name)) {
return async (options: RequestOptions) => {
const url = resolveFullPath(that.endpoint, route.join('/'))
route.length = 0
let res: Record<string, any> & { data: any }
try {
res = await manager.request({
method: name,
...options,
url,
})
} catch (e: any) {
let message = e.message
let code =
e.code ||
e.status ||
e.statusCode ||
e.response?.status ||
e.response?.statusCode ||
e.response?.code ||
500
if (that.options.getCodeMessageFromException) {
const errorInfo = that.options.getCodeMessageFromException(e)
message = errorInfo.message || message
code = errorInfo.code || code
}
throw that.options.customThrowResponseError
? that.options.customThrowResponseError(e)
: new RequestError(message, code, url, e)
}
const data = res.data
if (!data) {
return null
}
const transform =
(Array.isArray(data) || isPlainObject(data)) &&
that.options.transformResponse
? that.options.transformResponse(data)
: data
if (transform && typeof transform === 'object') {
Object.defineProperty(transform, '$raw', {
get() {
return res
},
enumerable: false,
configurable: false,
})
// attach request config onto response
Object.defineProperty(transform, '$request', {
get() {
return {
url,
method: name,
options,
}
},
enumerable: false,
})
}
return transform
}
}
route.push(name)
return new Proxy(noop, handler)
},
// @ts-ignore
apply(target: any, _, args) {
route.push(...args.filter((x: string) => x !== null))
return new Proxy(noop, handler)
},
}
return new Proxy(noop, handler) as any
}
}
}
export function createClient<T extends IRequestAdapter>(adapter: T) {
return <
ResponseWrapper = T extends { responseWrapper: infer Type }
? Type extends undefined
? unknown
: Type
: unknown,
>(
endpoint: string,
options?: ClientOptions,
) => {
const client = new HTTPClient<T, ResponseWrapper>(
endpoint,
adapter,
options,
)
const { controllers } = options || {}
if (controllers) {
client.injectControllers(controllers)
}
return client
}
}

View File

@@ -0,0 +1,10 @@
export class RequestError extends Error {
constructor(
message: string,
public status: number,
public path: string,
public raw: any,
) {
super(message)
}
}

View File

@@ -0,0 +1,2 @@
export * from './client'
export * from './error'

View File

@@ -0,0 +1,9 @@
export class CommentDto {
author!: string
text!: string
mail!: string
url?: string
}

View File

@@ -0,0 +1,10 @@
import { createClient } from './core'
export * from './controllers'
export { createClient, RequestError } from './core'
export type { HTTPClient } from './core'
export * from './models'
export { camelcaseKeys as simpleCamelcaseKeys } from './utils/camelcase-keys'
// eslint-disable-next-line import/no-default-export
export default createClient

View File

@@ -0,0 +1,36 @@
import { RequestOptions } from './instance'
export type IAdaptorRequestResponseType<P> = Promise<
Record<string, any> & { data: P }
>
export type IRequestAdapter<T = any, Response = undefined> = Readonly<
(Response extends undefined ? {} : { responseWrapper: Response }) & {
default: T
get<P = unknown>(
url: string,
options?: Omit<RequestOptions, 'data'>,
): IAdaptorRequestResponseType<P>
post<P = unknown>(
url: string,
options: Partial<RequestOptions>,
): IAdaptorRequestResponseType<P>
patch<P = unknown>(
url: string,
options: Partial<RequestOptions>,
): IAdaptorRequestResponseType<P>
delete<P = unknown>(
url: string,
options?: Omit<RequestOptions, 'data'>,
): IAdaptorRequestResponseType<P>
put<P = unknown>(
url: string,
options: Partial<RequestOptions>,
): IAdaptorRequestResponseType<P>
}
>

View File

@@ -0,0 +1,15 @@
import { IController } from './controller'
import { Class } from './types'
interface IClientOptions {
controllers: Class<IController>[]
getCodeMessageFromException: <T = Error>(
error: T,
) => {
message?: string | undefined | null
code?: number | undefined | null
}
customThrowResponseError: <T extends Error = Error>(err: any) => T
transformResponse: <T = any>(data: any) => T
}
export type ClientOptions = Partial<IClientOptions>

View File

@@ -0,0 +1,5 @@
export interface IController {
base: string
name: string | string[]
}

View File

@@ -0,0 +1,8 @@
export interface RequestOptions {
method?: string
data?: Record<string, any>
params?: Record<string, any> | URLSearchParams
headers?: Record<string, string>
[key: string]: any
}

View File

@@ -0,0 +1 @@
export type SortOrder = 'asc' | 'desc'

View File

@@ -0,0 +1,4 @@
export interface PaginationParams {
size?: number
page?: number
}

View File

@@ -0,0 +1,83 @@
import { RequestOptions } from './instance'
type NoStringIndex<T> = { [K in keyof T as string extends K ? never : K]: T[K] }
export type Method = 'get' | 'delete' | 'post' | 'put' | 'patch'
export interface IRequestHandler<ResponseWrapper> {
(path?: string | number): IRequestHandler<ResponseWrapper>
// @ts-ignore
get<P = unknown>(
options?: Omit<NoStringIndex<RequestOptions>, 'data'>,
): RequestProxyResult<P, ResponseWrapper>
// @ts-ignore
post<P = unknown>(
options?: RequestOptions,
): RequestProxyResult<P, ResponseWrapper>
// @ts-ignore
patch<P = unknown>(
options?: RequestOptions,
): RequestProxyResult<P, ResponseWrapper>
// @ts-ignore
delete<P = unknown>(
options?: Omit<NoStringIndex<RequestOptions>, 'data'>,
): RequestProxyResult<P, ResponseWrapper>
// @ts-ignore
put<P = unknown>(
options?: RequestOptions,
): RequestProxyResult<P, ResponseWrapper>
// @ts-ignore
toString(withBase?: boolean): string
// @ts-ignore
valueOf(withBase?: boolean): string
[key: string]: IRequestHandler<ResponseWrapper>
}
export type RequestProxyResult<
T,
ResponseWrapper,
R = ResponseWrapper extends unknown
? { data: T; [key: string]: any }
: ResponseWrapper extends { data: T }
? ResponseWrapper
: Omit<ResponseWrapper, 'data'> & { data: T },
> = Promise<ResponseProxyExtraRaw<T, R, ResponseWrapper>>
type CamelToSnake<T extends string, P extends string = ''> = string extends T
? string
: T extends `${infer C0}${infer R}`
? CamelToSnake<
R,
`${P}${C0 extends Lowercase<C0> ? '' : '_'}${Lowercase<C0>}`
>
: P
type CamelKeysToSnake<T> = {
[K in keyof T as CamelToSnake<Extract<K, string>>]: T[K]
}
type ResponseWrapperType<Response, RawData, T> = {
$raw: Response extends { data: infer T }
? Response
: Response extends unknown
? {
[i: string]: any
data: RawData extends unknown ? CamelKeysToSnake<T> : RawData
}
: Response
$request: {
path: string
method: string
[k: string]: string
}
}
export type ResponseProxyExtraRaw<
T,
RawData = unknown,
Response = unknown,
> = T extends object
? T & ResponseWrapperType<Response, RawData, T>
: T extends unknown
? T & ResponseWrapperType<Response, RawData, T>
: unknown

View File

@@ -0,0 +1,3 @@
export type Class<T> = new (...args: any[]) => T
export type SelectFields<T extends string> = `${'+' | '-' | ''}${T}`[]

View File

@@ -0,0 +1,71 @@
import { CategoryModel } from './category'
import { NoteModel } from './note'
import { PageModel } from './page'
import { PostModel } from './post'
import { SayModel } from './say'
import { SeoOptionModel } from './setting'
import { UserModel } from './user'
export interface AggregateRoot {
user: UserModel
seo: SeoOptionModel
url: Url
categories: CategoryModel[]
pageMeta: Pick<PageModel, 'title' | 'id' | 'slug' | 'order'>[] | null
}
export interface Url {
wsUrl: string
serverUrl: string
webUrl: string
}
export interface AggregateTop {
notes: Pick<NoteModel, 'id' | 'title' | 'created' | 'nid'>[]
posts: Pick<PostModel, 'id' | 'slug' | 'created' | 'title' | 'category'>[]
says: SayModel[]
}
export enum TimelineType {
Post,
Note,
}
export interface TimelineData {
notes?: Pick<
NoteModel,
| 'id'
| 'nid'
| 'title'
| 'weather'
| 'mood'
| 'created'
| 'modified'
| 'hasMemory'
>[]
posts?: (Pick<
PostModel,
'id' | 'title' | 'slug' | 'created' | 'modified' | 'category'
> & { url: string })[]
}
export interface AggregateStat {
allComments: number
categories: number
comments: number
linkApply: number
links: number
notes: number
pages: number
posts: number
says: number
recently: number
unreadComments: number
online: number
todayMaxOnline: string
todayOnlineTotal: string
callTime: number
uv: number
todayIpAccessCount: number
}

View File

@@ -0,0 +1,45 @@
export interface Count {
read: number
like: number
}
export interface Image {
height: number
width: number
type: string
accent?: string
src: string
}
export interface Pager {
total: number
size: number
currentPage: number
totalPage: number
hasPrevPage: boolean
hasNextPage: boolean
}
export interface PaginateResult<T> {
data: T[]
pagination: Pager
}
export interface BaseModel {
created: string
id: string
}
export interface BaseCommentIndexModel extends BaseModel {
commentsIndex?: number
allowComment: boolean
}
export interface TextBaseModel extends BaseCommentIndexModel {
title: string
text: string
images?: Image[]
modified: string | null
meta?: Record<string, any>
}

View File

@@ -0,0 +1,25 @@
import { BaseModel } from './base'
import { PostModel } from './post'
export enum CategoryType {
Category,
Tag,
}
export interface CategoryModel extends BaseModel {
type: CategoryType
count: number
slug: string
name: string
}
export type CategoryWithChildrenModel = CategoryModel & {
children: Pick<PostModel, 'id' | 'title' | 'slug' | 'modified' | 'created'>[]
}
export type CategoryEntries = {
entries: Record<string, CategoryWithChildrenModel>
}
export interface TagModel {
count: number
name: string
}

View File

@@ -0,0 +1,41 @@
import { BaseModel } from './base'
import { CategoryModel } from './category'
export enum RefType {
Page = 'Page',
Post = 'Post',
Note = 'Note',
}
export interface CommentModel extends BaseModel {
refType: RefType
ref: string
state: number
commentsIndex: number
author: string
text: string
mail?: string
url?: string
ip?: string
agent?: string
key: string
pin?: boolean
avatar: string
parent?: CommentModel | string
children: CommentModel[]
isWhispers?: boolean
}
export interface CommentRef {
id: string
categoryId?: string
slug: string
title: string
category?: CategoryModel
}
export enum CommentState {
Unread,
Read,
Junk,
}

View File

@@ -0,0 +1,14 @@
export * from './aggregate'
export * from './base'
export * from './category'
export * from './comment'
export * from './link'
export * from './note'
export * from './page'
export * from './post'
export * from './project'
export * from './recently'
export * from './say'
export * from './setting'
export * from './snippet'
export * from './user'

View File

@@ -0,0 +1,25 @@
import { BaseModel } from './base'
export enum LinkType {
Friend,
Collection,
}
export enum LinkState {
Pass,
Audit,
Outdate,
Banned,
Reject,
}
export interface LinkModel extends BaseModel {
name: string
url: string
avatar: string
description?: string
type: LinkType
state: LinkState
hide: boolean
email: string
}

View File

@@ -0,0 +1,40 @@
import { TextBaseModel } from './base'
import { TopicModel } from './topic'
export interface NoteModel extends TextBaseModel {
hide: boolean
count: {
read: number
like: number
}
mood?: string
weather?: string
hasMemory?: boolean
secret?: Date
password?: string | null
nid: number
music?: NoteMusicRecord[]
location?: string
coordinates?: Coordinate
topic?: TopicModel
topicId?: string
}
export interface NoteMusicRecord {
type: string
id: string
}
export interface Coordinate {
latitude: number
longitude: number
}
export interface NoteWrappedPayload {
data: NoteModel
next?: Partial<NoteModel>
prev?: Partial<NoteModel>
}

View File

@@ -0,0 +1,20 @@
import { TextBaseModel } from './base'
export enum EnumPageType {
'md' = 'md',
'html' = 'html',
'frame' = 'frame',
}
export interface PageModel extends TextBaseModel {
created: string
slug: string
subtitle?: string
order?: number
type?: EnumPageType
options?: object
}

View File

@@ -0,0 +1,28 @@
import { Count, Image, TextBaseModel } from './base'
import { CategoryModel } from './category'
export interface PostModel extends TextBaseModel {
summary?: string
copyright: boolean
tags: string[]
count: Count
text: string
title: string
slug: string
categoryId: string
images: Image[]
category: CategoryModel
pin?: string | null
pinOrder?: number
related?: Pick<
PostModel,
| 'id'
| 'category'
| 'categoryId'
| 'created'
| 'modified'
| 'title'
| 'slug'
| 'summary'
>[]
}

View File

@@ -0,0 +1,12 @@
import { BaseModel } from './base'
export interface ProjectModel extends BaseModel {
name: string
previewUrl?: string
docUrl?: string
projectUrl?: string
images?: string[]
description: string
avatar?: string
text: string
}

View File

@@ -0,0 +1,27 @@
import { BaseModel } from './base'
export enum RecentlyRefTypes {
Post = 'Post',
Note = 'Note',
Page = 'Page',
}
export type RecentlyRefType = {
title: string
url: string
}
export interface RecentlyModel extends BaseModel {
content: string
ref?: RecentlyRefType & { [key: string]: any }
refId?: string
refType?: RecentlyRefTypes
/**
* @deprecated
*/
project?: string
/**
* @deprecated
*/
language?: string
}

View File

@@ -0,0 +1,7 @@
import { BaseModel } from './base'
export interface SayModel extends BaseModel {
text: string
source?: string
author?: string
}

View File

@@ -0,0 +1,68 @@
export declare class SeoOptionModel {
title: string
description: string
icon?: string
keywords?: string[]
}
export declare class UrlOptionModel {
webUrl: string
adminUrl: string
serverUrl: string
wsUrl: string
}
declare class MailOptionModel {
port: number
host: string
}
export declare class MailOptionsModel {
enable: boolean
user: string
pass: string
options?: MailOptionModel
}
export declare class CommentOptionsModel {
antiSpam: boolean
spamKeywords?: string[]
blockIps?: string[]
disableNoChinese?: boolean
}
export declare class BackupOptionsModel {
enable: boolean
secretId?: string
secretKey?: string
bucket?: string
region: string
}
export declare class BaiduSearchOptionsModel {
enable: boolean
token?: string
}
export declare class AlgoliaSearchOptionsModel {
enable: boolean
apiKey?: string
appId?: string
indexName?: string
}
export declare class AdminExtraModel {
background?: string
gaodemapKey?: string
title?: string
/**
* 是否开启后台反代访问
*/
enableAdminProxy?: boolean
}
export interface IConfig {
seo: SeoOptionModel
url: UrlOptionModel
mailOptions: MailOptionsModel
commentOptions: CommentOptionsModel
backupOptions: BackupOptionsModel
baiduSearchOptions: BaiduSearchOptionsModel
algoliaSearchOptions: AlgoliaSearchOptionsModel
adminExtra: AdminExtraModel
}
export declare type IConfigKeys = keyof IConfig

View File

@@ -0,0 +1,19 @@
import { BaseModel } from './base'
export enum SnippetType {
JSON = 'json',
Function = 'function',
Text = 'text',
YAML = 'yaml',
}
export interface SnippetModel<T = unknown> extends BaseModel {
type: SnippetType
private: boolean
raw: string
name: string
reference: string
comment?: string
metatype?: string
schema?: string
data: T
}

View File

@@ -0,0 +1,9 @@
import { BaseModel } from './base'
export interface TopicModel extends BaseModel {
description?: string
introduce: string
name: string
slug: string
icon?: string
}

View File

@@ -0,0 +1,27 @@
import { BaseModel } from './base'
export interface UserModel extends BaseModel {
introduce: string
mail: string
url: string
name: string
socialIds: Record<string, string>
username: string
modified: string
v: number
lastLoginTime: string
lastLoginIp?: string
avatar: string
postID: string
}
export type TLogin = {
token: string
expiresIn: number
// 登陆足迹
lastLoginTime: null | string
lastLoginIp?: null | string
} & Pick<
UserModel,
'name' | 'username' | 'created' | 'url' | 'mail' | 'avatar' | 'id'
>

View File

@@ -0,0 +1,98 @@
{
"name": "@mx-space/api-client",
"version": "1.0.0-beta.1",
"type": "module",
"description": "A api client for mx-space server@next",
"author": "Innei",
"license": "MIT",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "types/index.d.ts",
"unpkg": "dist/index.umd.min.js",
"typesVersions": {
"*": {
".": [
"./types/index.d.ts"
],
"./adaptors/*": [
"./types/adaptors/*.d.ts"
]
}
},
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json",
"./adaptors/*": {
"types": "./types/adaptors/*.d.ts",
"import": {
"type": "./types/adaptors/*.d.ts",
"default": "./dist/adaptors/*.js"
},
"require": {
"type": "./types/adaptors/*.d.ts",
"default": "./dist/adaptors/*.cjs"
}
}
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"bump": {
"before": [
"git pull --rebase",
"pnpm i",
"npm run package"
],
"after": [
"npm publish --access=public"
]
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --ignore-path ./.prettierignore --write ",
"eslint --cache"
]
},
"engines": {
"pnpm": ">=6"
},
"scripts": {
"prebuild": "rm -rf lib && rm -rf esm",
"build": "tsc --build tsconfig.build.json && tsc --build tsconfig.cjs.json",
"postbuild": "tsc-alias -p tsconfig.build.json && tsc-alias -p tsconfig.cjs.json && npm run types",
"types": "rm -rf types && tsc --build tsconfig.types.json && tsc-alias -p tsconfig.types.json",
"package": "NODE_ENV=production npm run build && rollup -c",
"prepackage": "rm -rf dist",
"test": "vitest",
"dev": "vite"
},
"devDependencies": {
"@rollup/plugin-commonjs": "22.0.2",
"@rollup/plugin-node-resolve": "14.0.1",
"@rollup/plugin-typescript": "8.5.0",
"@types/cors": "2.8.12",
"@types/express": "4.17.15",
"@types/lodash": "4.14.186",
"abort-controller": "3.0.0",
"axios": "*",
"camelcase-keys": "*",
"cors": "2.8.5",
"dts-bundle-generator": "7.0.0",
"express": "4.18.2",
"isomorphic-unfetch": "3.1.0",
"ky": "0.31.3",
"lodash": "4.17.21",
"node-fetch": "3.2.10",
"rollup": "2.79.1",
"rollup-plugin-peer-deps-external": "2.2.4",
"rollup-plugin-terser": "7.0.2",
"tsc-alias": "1.7.0",
"umi-request": "1.4.0"
}
}

1668
packages/api-client/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
# MApi Client
这是一个适用于 MServer v3 的 JS SDK封装了常用接口请求方法以及返回类型的声明以快速开发前端应用。
## 迁移到 v1
不再提供 camelcase-keys 的 re-export此库不再依赖 camelcase-keys 库,如有需要可自行安装。
```diff
- import { camelcaseKeysDeep, camelcaseKeys } from '@mx-space/api-client'
+ import { simpleCamelcaseKeys as camelcaseKeysDeep } from '@mx-space/api-client'
```
## 如何使用
此 SDK 框架无关,不捆绑任何一个网络请求库,只需要提供适配器。你需要手动传入符合接口标准的适配器。
此项目提供 `axios``umi-request` 两个适配器。
`axios` 为例。
```ts
import {
AggregateController,
CategoryController,
NoteController,
PostController,
allControllers, // ...
createClient,
} from '@mx-space/api-client'
import { axiosAdaptor } from '@mx-space/api-client/adaptors/axios'
const endpoint = 'https://api.innei.dev/v2'
const client = createClient(axiosAdaptor)(endpoint)
// `default` is AxiosInstance
// you can do anything else on this
// interceptor or re-configure
const $axios = axiosAdaptor.default
// re-config (optional)
$axios.defaults.timeout = 10000
// set interceptors (optional)
$axios.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
config.headers!['Authorization'] = 'bearer ' + getToken()
}
return config
},
(error) => {
if (__DEV__) {
console.log(error.message)
}
return Promise.reject(error)
},
)
// inject controller first.
client.injectControllers([
PostController,
NoteController,
AggregateController,
CategoryController,
])
// or you can inject allControllers
client.injectControllers(allControllers)
// then you can request `post` `note` and `aggregate` controller
client.post.post.getList(page, 10, { year }).then((data) => {
// do anything
})
```
**为什么要手动注入控制器**
按需加载,可以减少打包体积 (Tree Shake)
**为什么不依赖请求库**
可以防止项目中出现两个请求库,减少打包体积
**如果不使用 axios应该如何编写适配器**
参考 `src/adaptors/axios.ts``src/adaptors/umi-request.ts`
**如何使用 proxy 来访问 sdk 内未包含的请求**
如请求 `GET /notes/something/other/123456/info`,可以使用
```ts
client.note.proxy.something.other('123456').info.get()
```
**从 proxy 获取请求地址但不发出**
```ts
client.note.proxy.something.other('123456').info.toString() // /notes/something/other/123456/info
client.note.proxy.something.other('123456').info.toString(true) // http://localhost:2333/notes/something/other/123456/info
```

View File

@@ -0,0 +1,150 @@
// @ts-check
import { execSync } from 'child_process'
import globby from 'globby'
import path, { resolve } from 'path'
import peerDepsExternal from 'rollup-plugin-peer-deps-external'
import { terser } from 'rollup-plugin-terser'
import commonjs from '@rollup/plugin-commonjs'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import typescript from '@rollup/plugin-typescript'
const packageJson = require('./package.json')
const umdName = packageJson.name
const globals = {
...packageJson.devDependencies,
// @ts-ignore
...(packageJson.dependencies || []),
}
const dir = 'dist'
/**
* @type {Partial<import('rollup').RollupOptions>}
*/
const baseRollupConfig = {
plugins: [
nodeResolve(),
commonjs({ include: 'node_modules/**' }),
typescript({ tsconfig: './tsconfig.json', declaration: false }),
// @ts-ignore
peerDepsExternal(),
],
external: [...Object.keys(globals), 'lodash', 'lodash-es'],
treeshake: true,
}
/**
* @returns {import('rollup').RollupOptions[]}
*/
const buildAdaptorConfig = () => {
const paths = globby.sync('./adaptors/*.ts')
const filename = (path_) => path.parse(path_.split('/').pop()).name
return paths.map((path) => {
const libName = filename(path)
execSync(
`npx dts-bundle-generator -o dist/adaptors/${libName}.d.ts ${resolve(
__dirname,
'adaptors/',
)}/${libName}.ts` + ` --external-types ${libName}`,
)
return {
input: path,
output: [
{
file: `${dir}/adaptors/${libName}.umd.js`,
format: 'umd',
sourcemap: true,
name: umdName,
},
{
file: `${dir}/adaptors/${libName}.umd.min.js`,
format: 'umd',
sourcemap: true,
name: umdName,
plugins: [terser()],
},
{
file: `${dir}/adaptors/${libName}.cjs`,
format: 'cjs',
sourcemap: true,
},
{
file: `${dir}/adaptors/${libName}.min.cjs`,
format: 'cjs',
sourcemap: true,
plugins: [terser()],
},
{
file: `${dir}/adaptors/${libName}.js`,
format: 'es',
sourcemap: true,
},
{
file: `${dir}/adaptors/${libName}.min.js`,
format: 'es',
sourcemap: true,
plugins: [terser()],
},
],
...baseRollupConfig,
}
})
}
/**
* @type {import('rollup').RollupOptions[]}
*/
const config = [
{
input: './index.ts',
output: [
{
file: `${dir}/index.umd.js`,
format: 'umd',
sourcemap: true,
name: umdName,
},
{
file: `${dir}/index.umd.min.js`,
format: 'umd',
sourcemap: true,
name: umdName,
plugins: [terser()],
},
{
file: `${dir}/index.cjs`,
format: 'cjs',
sourcemap: true,
},
{
file: `${dir}/index.min.cjs`,
format: 'cjs',
sourcemap: true,
plugins: [terser()],
},
{
file: `${dir}/index.js`,
format: 'es',
sourcemap: true,
},
{
file: `${dir}/index.min.js`,
format: 'es',
sourcemap: true,
plugins: [terser()],
},
],
...baseRollupConfig,
},
...buildAdaptorConfig(),
]
// eslint-disable-next-line import/no-default-export
export default config

3
packages/api-client/test.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
import 'vitest/globals'
export {}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["__tests__/**/*.ts"]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.build.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "./lib"
}
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"declaration": true,
"outDir": "./esm",
"baseUrl": ".",
"jsx": "react",
"target": "ES2020",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"paths": {
"~/*": ["*"]
}
},
"exclude": ["esm/*", "build/*", "node_modules/*", "lib/*"]
}

View File

@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"rootDir": ".",
"outDir": "types",
"declaration": true,
"declarationMap": false,
"isolatedModules": false,
"noEmit": false,
"allowJs": false,
"emitDeclarationOnly": true
},
"exclude": ["__tests__/**/*", "**/*.test.ts"]
}

View File

@@ -0,0 +1,48 @@
// @ts-nocheck
// @copy: https://github.com/sindresorhus/auto-bind/blob/main/index.js
// Gets all non-builtin properties up the prototype chain.
const getAllProperties = (object) => {
const properties = new Set()
do {
for (const key of Reflect.ownKeys(object)) {
properties.add([object, key])
}
} while (
(object = Reflect.getPrototypeOf(object)) &&
object !== Object.prototype
)
return properties
}
export function autoBind(self, { include, exclude } = {}) {
const filter = (key) => {
const match = (pattern) =>
typeof pattern === 'string' ? key === pattern : pattern.test(key)
if (include) {
return include.some(match)
}
if (exclude) {
return !exclude.some(match)
}
return true
}
for (const [object, key] of getAllProperties(self.constructor.prototype)) {
if (key === 'constructor' || !filter(key)) {
continue
}
const descriptor = Reflect.getOwnPropertyDescriptor(object, key)
if (descriptor && typeof descriptor.value === 'function') {
self[key] = self[key].bind(self)
}
}
return self
}

View File

@@ -0,0 +1,26 @@
import { isPlainObject } from '.'
/**
* A simple camelCase function that only handles strings, but not handling symbol, date, or other complex case.
* If you need to handle more complex cases, please use camelcase-keys package.
*/
export const camelcaseKeys = <T = any>(obj: any): T => {
if (Array.isArray(obj)) {
return obj.map((x) => camelcaseKeys(x)) as any
}
if (isPlainObject(obj)) {
return Object.keys(obj).reduce((result: any, key) => {
result[camelcase(key)] = camelcaseKeys(obj[key])
return result
}, {}) as any
}
return obj
}
export function camelcase(str: string) {
return str.replace(/([-_][a-z])/gi, ($1) => {
return $1.toUpperCase().replace('-', '').replace('_', '')
})
}

View File

@@ -0,0 +1,53 @@
import { SortOrder } from '~/interfaces/options'
export const isPlainObject = (obj: any) =>
isObject(obj) &&
Object.prototype.toString.call(obj) === '[object Object]' &&
Object.getPrototypeOf(obj) === Object.prototype
export const sortOrderToNumber = (order: SortOrder) => {
return (
{
asc: 1,
desc: -1,
}[order] || 1
)
}
const isObject = (obj: any) => obj && typeof obj === 'object'
export const destructureData = (payload: any) => {
if (typeof payload !== 'object') {
return payload
}
if (payload === null) {
return payload
}
const data = payload.data
const dataIsPlainObject = isPlainObject(data)
if (dataIsPlainObject && Object.keys(payload).length === 1) {
const d = Object.assign({}, data)
// attach raw onto new data
attachRawFromOneToAnthor(payload, d)
return d
}
return payload
}
export const attachRawFromOneToAnthor = (from: any, to: any) => {
if (!from || !isObject(to)) {
return
}
from.$raw &&
Object.defineProperty(to, '$raw', {
value: { ...from.$raw },
enumerable: false,
})
from.$request &&
Object.defineProperty(to, '$request', {
value: { ...from.$request },
enumerable: false,
})
}

View File

@@ -0,0 +1,6 @@
export const resolveFullPath = (endpoint: string, path: string) => {
if (!path.startsWith('/')) {
path = `/${path}`
}
return `${endpoint}${path}`
}

View File

@@ -0,0 +1,14 @@
import tsPath from 'vite-tsconfig-paths'
import { defineConfig } from 'vitest/config'
// eslint-disable-next-line import/no-default-export
export default defineConfig({
test: {
globals: true,
include: ['__tests__/**/*.(spec|test).ts'],
},
// @ts-ignore
plugins: [tsPath()],
})

1077
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- packages/*

Some files were not shown because too many files have changed in this diff Show More