add api sdk
This commit is contained in:
@@ -8,16 +8,17 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/ci-build.yml"
|
||||
- "backend/**"
|
||||
- "frontend/**"
|
||||
- "frontend/apps/**"
|
||||
- ".dockerignore"
|
||||
- "docker-init.sh"
|
||||
- "Dockerfile"
|
||||
- "!**.md"
|
||||
- "!frontend/packages/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-build.yml"
|
||||
- "backend/**"
|
||||
- "frontend/**"
|
||||
- "frontend/apps/**"
|
||||
- ".dockerignore"
|
||||
- "docker-init.sh"
|
||||
- "Dockerfile"
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
name: "@remark42/api"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- ".github/workflows/ci-frontend-api.yml"
|
||||
- "frontend/packages/**"
|
||||
- "!**.md"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-frontend-api.yml"
|
||||
- "frontend/packages/**"
|
||||
- "!**.md"
|
||||
|
||||
jobs:
|
||||
type-check:
|
||||
name: Type check
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Run type check
|
||||
run: pnpm type-check:api
|
||||
working-directory: ./frontend
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint:api
|
||||
working-directory: ./frontend/
|
||||
|
||||
test:
|
||||
name: Tests & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Test & Coverage
|
||||
run: pnpm coverage:api
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Submit coverage
|
||||
run: ${{ github.workspace }}/frontend/apps/remark42/node_modules/.bin/codecov
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -10,10 +10,15 @@
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "if [ -z \"$CI\" ]; then cd .. && husky install frontend/.husky; else echo \"Skip Husky Hooks\"; fi",
|
||||
"lint-staged": "lint-staged"
|
||||
"lint-staged": "lint-staged",
|
||||
"test:api": "turbo run test --filter=@remark42/api",
|
||||
"coverage:api": "turbo run coverage --filter=@remark42/api",
|
||||
"type-check:api": "turbo run type-check --filter=@remark42/api",
|
||||
"lint:api": "turbo run lint --filter=@remark42/api"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "^8.0.1",
|
||||
"lint-staged": "^13.0.3"
|
||||
"lint-staged": "^13.0.3",
|
||||
"turbo": "^1.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
coverage
|
||||
@@ -0,0 +1,22 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
'plugin:@typescript-eslint/strict',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
overrides: [],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: './tsconfig.eslint.json',
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
coverage
|
||||
dist
|
||||
**/*.js
|
||||
**/*.d.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
'./**/*.ts': ['pnpm lint-staged:lint'],
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
coverage/**
|
||||
dist/**
|
||||
node_modules/**
|
||||
src/**
|
||||
test/**
|
||||
.editorconfig
|
||||
.eslintrc.cjs
|
||||
.prettierrc
|
||||
files.txt
|
||||
jest.config.ts
|
||||
pnpm-lock.yaml
|
||||
postpublish.sh
|
||||
prepublish.sh
|
||||
tsconfig.common.json
|
||||
tsconfig.dev.json
|
||||
tsconfig.json
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
16.17.0
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"printWidth": 100,
|
||||
"quoteProps": "consistent",
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# @remark42/api
|
||||
|
||||
Implementation of API methods for Remark42
|
||||
|
||||
## Development
|
||||
|
||||
- If you don't have `pnpm` installed run `npm i -g pnpm`
|
||||
- Install dependencies `pnpm i`
|
||||
- Run development mode with `pnpm run dev`
|
||||
- Build lib with `pnpm run build`
|
||||
- Run tests with `pnpm run test`
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ClientParams, User } from './index'
|
||||
import { API_BASE } from '../consts'
|
||||
import { createFetcher } from '../lib/fetcher'
|
||||
|
||||
export type BlockTTL = 'permanently' | '43200m' | '10080m' | '1440m'
|
||||
export interface BlockUserResponse {
|
||||
block: boolean
|
||||
site_id: string
|
||||
user_id: string
|
||||
}
|
||||
|
||||
export function createAdminClient({ siteId, baseUrl }: ClientParams) {
|
||||
const fetcher = createFetcher(siteId, `${baseUrl}${API_BASE}`)
|
||||
|
||||
async function toggleUserVerification(id: string, verified: 0 | 1): Promise<void> {
|
||||
return fetcher.put(`/verify/${id}`, { verified })
|
||||
}
|
||||
|
||||
async function toggleCommentPin(id: string, pinned: 0 | 1): Promise<void> {
|
||||
return fetcher.put(`/pin/${id}`, { pinned })
|
||||
}
|
||||
|
||||
async function toggleCommenting(url: string, ro: 0 | 1): Promise<void> {
|
||||
return fetcher.put('/readonly', { url, ro })
|
||||
}
|
||||
|
||||
async function toggleUserBlock(id: string, ttl?: BlockTTL): Promise<BlockUserResponse> {
|
||||
const params = ttl
|
||||
? {
|
||||
block: 1,
|
||||
ttl: ttl === 'permanently' ? 0 : ttl,
|
||||
}
|
||||
: { block: 0 }
|
||||
|
||||
return fetcher.put<BlockUserResponse>(`/user/${id}`, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request list of blocked users
|
||||
*/
|
||||
async function getBlockedUsers(): Promise<User[]> {
|
||||
return fetcher.get<User[]>('/blocked')
|
||||
}
|
||||
|
||||
/**
|
||||
* Block user from commenting
|
||||
* @param id user ID
|
||||
* @param ttl block duration
|
||||
*/
|
||||
async function blockUser(id: string, ttl: BlockTTL): Promise<BlockUserResponse> {
|
||||
return toggleUserBlock(id, ttl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock user from commenting
|
||||
* @param id user ID
|
||||
*/
|
||||
async function unblockUser(id: string): Promise<BlockUserResponse> {
|
||||
return toggleUserBlock(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark user as verified
|
||||
* @param id user ID
|
||||
*/
|
||||
async function verifyUser(id: string): Promise<void> {
|
||||
return toggleUserVerification(id, 1)
|
||||
}
|
||||
/**
|
||||
* Mark user as unverified
|
||||
* @param id user ID
|
||||
*/
|
||||
async function unverifyUser(id: string): Promise<void> {
|
||||
return toggleUserVerification(id, 0)
|
||||
}
|
||||
/**
|
||||
* Approve request to remove user data
|
||||
* @param token token from email
|
||||
*/
|
||||
async function approveRemovingRequest(token: string): Promise<void> {
|
||||
return fetcher.get('/deleteme', { token })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark comment as pinned
|
||||
* @param id comment ID
|
||||
*/
|
||||
async function pinComment(id: string): Promise<void> {
|
||||
return toggleCommentPin(id, 1)
|
||||
}
|
||||
/**
|
||||
* Mark comment as unpinned
|
||||
* @param id comment ID
|
||||
*/
|
||||
async function unpinComment(id: string): Promise<void> {
|
||||
return toggleCommentPin(id, 0)
|
||||
}
|
||||
/**
|
||||
* Remove comment
|
||||
* @param url page URL
|
||||
* @param id comment ID
|
||||
*/
|
||||
async function removeComment(url: string, id: string): Promise<void> {
|
||||
return fetcher.delete(`/comment/${id}`, { url })
|
||||
}
|
||||
/**
|
||||
* Enable commenting on a page
|
||||
* @param url page URL
|
||||
*/
|
||||
async function enableCommenting(url: string) {
|
||||
return toggleCommenting(url, 1)
|
||||
}
|
||||
/**
|
||||
* Disable commenting on a page
|
||||
* @param url page URL
|
||||
*/
|
||||
async function disableCommenting(url: string) {
|
||||
return toggleCommenting(url, 0)
|
||||
}
|
||||
|
||||
return {
|
||||
getBlockedUsers,
|
||||
blockUser,
|
||||
unblockUser,
|
||||
verifyUser,
|
||||
unverifyUser,
|
||||
approveRemovingRequest,
|
||||
pinComment,
|
||||
unpinComment,
|
||||
removeComment,
|
||||
enableCommenting,
|
||||
disableCommenting,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ClientParams, User } from './index'
|
||||
import { createFetcher } from '../lib/fetcher'
|
||||
|
||||
export function createAuthClient({ siteId, baseUrl }: ClientParams) {
|
||||
const fetcher = createFetcher(siteId, `${baseUrl}/auth`)
|
||||
|
||||
async function anonymous(user: string): Promise<User> {
|
||||
return fetcher.get<User>('/anonymous/login', { user, aud: siteId })
|
||||
}
|
||||
|
||||
async function email(email: string, username: string): Promise<(token: string) => Promise<User>> {
|
||||
const EMAIL_SIGNIN_ENDPOINT = '/email/login'
|
||||
|
||||
await fetcher.get<undefined>(EMAIL_SIGNIN_ENDPOINT, { address: email, user: username })
|
||||
|
||||
return function tokenVerification(token: string): Promise<User> {
|
||||
return fetcher.get<User>(EMAIL_SIGNIN_ENDPOINT, { token })
|
||||
}
|
||||
}
|
||||
|
||||
async function telegram() {
|
||||
const TELEGRAM_SIGNIN_ENDPOINT = '/telegram/login'
|
||||
|
||||
const { bot, token } = await fetcher.get<{ bot: string; token: string }>(
|
||||
TELEGRAM_SIGNIN_ENDPOINT
|
||||
)
|
||||
|
||||
return {
|
||||
bot,
|
||||
token,
|
||||
verify() {
|
||||
return fetcher.get(TELEGRAM_SIGNIN_ENDPOINT, { token })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
return fetcher.get<void>('/logout')
|
||||
}
|
||||
|
||||
return {
|
||||
anonymous,
|
||||
email,
|
||||
telegram,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createAdminClient } from './admin'
|
||||
import { createAuthClient } from './auth'
|
||||
import { createPublicClient } from './public'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
/** url to avatar */
|
||||
picture: string
|
||||
admin: boolean
|
||||
block: boolean
|
||||
verified: boolean
|
||||
/** subscription to email notification */
|
||||
email_subscription?: boolean
|
||||
/** users with Patreon auth can have paid status */
|
||||
paid_sub?: boolean
|
||||
}
|
||||
|
||||
export type OAuthProvider =
|
||||
| 'facebook'
|
||||
| 'twitter'
|
||||
| 'google'
|
||||
| 'yandex'
|
||||
| 'github'
|
||||
| 'microsoft'
|
||||
| 'patreon'
|
||||
| 'telegram'
|
||||
| 'dev'
|
||||
export type FormProvider = 'email' | 'anonymous'
|
||||
export type Provider = OAuthProvider | FormProvider
|
||||
|
||||
export interface ClientParams {
|
||||
siteId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
admin: ReturnType<typeof createAdminClient>
|
||||
auth: ReturnType<typeof createAuthClient>
|
||||
public: ReturnType<typeof createPublicClient>
|
||||
}
|
||||
|
||||
let client: Client | undefined
|
||||
|
||||
export function createClient(params: ClientParams): Client {
|
||||
if (client === undefined) {
|
||||
client = {
|
||||
auth: createAuthClient(params),
|
||||
admin: createAdminClient(params),
|
||||
public: createPublicClient(params),
|
||||
}
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { ClientParams, Provider, User } from './index'
|
||||
import { createFetcher } from '../lib/fetcher'
|
||||
import { API_BASE } from '../consts'
|
||||
|
||||
export interface Config {
|
||||
version: string
|
||||
auth_providers: Provider[]
|
||||
edit_duration: number
|
||||
max_comment_size: number
|
||||
admins: string[]
|
||||
admin_email: string
|
||||
low_score: number
|
||||
critical_score: number
|
||||
positive_score: boolean
|
||||
readonly_age: number
|
||||
max_image_size: number
|
||||
simple_view: boolean
|
||||
anon_vote: boolean
|
||||
email_notifications: boolean
|
||||
telegram_bot_username: string
|
||||
emoji_enabled: boolean
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
/** comment id */
|
||||
id: string
|
||||
/** parent id */
|
||||
pid: string
|
||||
/** comment text, after md processing */
|
||||
text: string
|
||||
/** original comment text */
|
||||
orig?: string
|
||||
user: User
|
||||
locator: {
|
||||
/** site id */
|
||||
site: string
|
||||
/** page url */
|
||||
url: string
|
||||
}
|
||||
score: number
|
||||
voted_ips: { Timestamp: string; Value: boolean }[]
|
||||
/**
|
||||
* vote delta,
|
||||
* if user hasn't voted delta will be 0,
|
||||
* -1/+1 for downvote/upvote
|
||||
*/
|
||||
vote: 0 | 1 | -1
|
||||
/** comment controversy */
|
||||
controversy?: number
|
||||
/** pointer to have empty default in json response */
|
||||
edit?: {
|
||||
time: string
|
||||
summary: string
|
||||
}
|
||||
/** timestamp */
|
||||
time: string
|
||||
pin?: boolean
|
||||
delete?: boolean
|
||||
/** page title */
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface CommentsTree {
|
||||
comment: Comment
|
||||
replies: Comment[]
|
||||
}
|
||||
|
||||
export interface CommentPayload {
|
||||
title?: string
|
||||
pid?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type Sort = '-active' | '+active'
|
||||
export interface GetUserCommentsParams {
|
||||
url: string
|
||||
sort?: Sort
|
||||
limit?: number
|
||||
skip?: number
|
||||
}
|
||||
export type Vote = -1 | 1
|
||||
|
||||
export function createPublicClient({ siteId: site, baseUrl }: ClientParams) {
|
||||
const fetcher = createFetcher(site, `${baseUrl}${API_BASE}`)
|
||||
|
||||
/**
|
||||
* Get server config
|
||||
*/
|
||||
async function getConfig(): Promise<Config> {
|
||||
return fetcher.get('/config')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current authorized user
|
||||
*/
|
||||
async function getUser(): Promise<User | null> {
|
||||
return fetcher.get<User | null>('/user').catch(() => null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comments
|
||||
*/
|
||||
async function getComments(url: string): Promise<CommentsTree>
|
||||
async function getComments(params: GetUserCommentsParams): Promise<Comment[]>
|
||||
async function getComments(
|
||||
params: string | GetUserCommentsParams
|
||||
): Promise<Comment[] | CommentsTree> {
|
||||
if (typeof params === 'string') {
|
||||
return fetcher.get('/comments', { url: params })
|
||||
}
|
||||
|
||||
return fetcher.get<CommentsTree>('/find', { ...params, format: 'tree' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new comment
|
||||
*/
|
||||
async function addComment(url: string, payload: CommentPayload): Promise<Comment> {
|
||||
const locator = { site, url }
|
||||
return fetcher.post('/comment', {}, { ...payload, locator })
|
||||
}
|
||||
|
||||
/**
|
||||
* Update comment
|
||||
*/
|
||||
async function updateComment(url: string, id: string, text: string): Promise<Comment> {
|
||||
return fetcher.put(`/comment/${id}`, { url }, { text })
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove comment on a page
|
||||
*/
|
||||
async function removeComment(url: string, id: string): Promise<void> {
|
||||
return fetcher.put(`/comment/${id}`, { url }, { delete: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Vote for a comment
|
||||
*/
|
||||
async function vote(url: string, id: string, vote: Vote): Promise<{ id: string; vote: number }> {
|
||||
return fetcher.put<{ id: string; vote: number }>(`/vote/${id}`, { url, vote })
|
||||
}
|
||||
|
||||
return {
|
||||
getConfig,
|
||||
getUser,
|
||||
getComments,
|
||||
addComment,
|
||||
updateComment,
|
||||
removeComment,
|
||||
vote,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Base path to API */
|
||||
export const API_BASE = '/api/v1'
|
||||
|
||||
/** Header name for JWT token */
|
||||
export const JWT_HEADER = 'X-JWT'
|
||||
|
||||
/** Header name for XSRF token */
|
||||
export const XSRF_HEADER = 'X-XSRF-TOKEN'
|
||||
|
||||
/** Cookie field with XSRF token */
|
||||
export const XSRF_COOKIE = 'XSRF-TOKEN'
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './clients'
|
||||
export * from './clients/admin'
|
||||
export * from './clients/auth'
|
||||
export * from './clients/public'
|
||||
@@ -0,0 +1,11 @@
|
||||
export function getCookie(name: string): string | undefined {
|
||||
const matches = document.cookie.match(
|
||||
new RegExp(`(?:^|; )${name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1')}=([^;]*)`)
|
||||
)
|
||||
|
||||
if (matches === null) {
|
||||
return
|
||||
}
|
||||
|
||||
return decodeURIComponent(matches[1])
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { JWT_HEADER, XSRF_COOKIE, XSRF_HEADER } from '../consts'
|
||||
import { getCookie } from '../lib/cookies'
|
||||
|
||||
export type QueryParams = Record<string, string | number | string[] | number[] | undefined>
|
||||
export type Payload = BodyInit | Record<string, unknown> | null
|
||||
export type BodylessMethod = <T>(url: string, query?: QueryParams) => Promise<T>
|
||||
export type BodyMethod = <T>(url: string, query?: QueryParams, body?: Payload) => Promise<T>
|
||||
export interface Client {
|
||||
get: BodylessMethod
|
||||
put: BodyMethod
|
||||
post: BodyMethod
|
||||
delete: BodylessMethod
|
||||
}
|
||||
|
||||
/** JWT token received from server and will be send by each request, if it present */
|
||||
let token: string | undefined
|
||||
|
||||
export const createFetcher = (site: string, baseUrl: string): Client => {
|
||||
const client = {
|
||||
get: <T>(uri: string, query?: QueryParams): Promise<T> => request<T>('get', uri, query),
|
||||
put: <T>(uri: string, query?: QueryParams, body?: Payload): Promise<T> =>
|
||||
request<T>('put', uri, query, body),
|
||||
post: <T>(uri: string, query?: QueryParams, body?: Payload): Promise<T> =>
|
||||
request<T>('post', uri, query, body),
|
||||
delete: <T>(uri: string, query?: QueryParams, body?: Payload): Promise<T> =>
|
||||
request<T>('delete', uri, query, body),
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetcher is abstraction on top of fetch
|
||||
*
|
||||
* @method - a string to set http method
|
||||
* @uri – uri to API endpoint
|
||||
* @query - collection of query params. They will be concatenated to URL. `siteId` will be added automatically.
|
||||
* @body - data for sending to the server. If you pass object it will be stringified. If you pass form data it will be sent as is. Content type headers will be added automatically.
|
||||
*/
|
||||
async function request<T>(
|
||||
method: string,
|
||||
uri: string,
|
||||
query: QueryParams = {},
|
||||
body?: Payload
|
||||
): Promise<T> {
|
||||
const searchParams = new URLSearchParams({ site, ...query })
|
||||
searchParams.sort()
|
||||
const url = `${baseUrl}${uri}?${searchParams.toString()}`
|
||||
const headers = new Headers()
|
||||
const params: RequestInit = { method, headers }
|
||||
|
||||
// Save token in memory and pass it into headers in case if storing cookies is disabled
|
||||
if (token) {
|
||||
headers.set(JWT_HEADER, token)
|
||||
}
|
||||
|
||||
// An HTTP header cannot be empty.
|
||||
// Although some webservers allow this (nginx, Apache), others answer 400 Bad Request (lighttpd).
|
||||
const xsrfToken = getCookie(XSRF_COOKIE)
|
||||
if (typeof xsrfToken === 'string') {
|
||||
headers.set(XSRF_HEADER, xsrfToken)
|
||||
}
|
||||
|
||||
if (typeof body === 'object' && body !== null && !(body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
params.body = JSON.stringify(body)
|
||||
} else {
|
||||
params.body = body
|
||||
}
|
||||
|
||||
return fetch(url, params).then<T>((res) => {
|
||||
if ([401, 403].includes(res.status)) {
|
||||
token = undefined
|
||||
|
||||
return Promise.reject('Unauthorized')
|
||||
}
|
||||
|
||||
token = res.headers.get(JWT_HEADER) ?? token
|
||||
|
||||
return res
|
||||
.text()
|
||||
.catch(Object)
|
||||
.then((data: string) => {
|
||||
if (res.status < 200 || res.status > 299) {
|
||||
return Promise.reject(data)
|
||||
}
|
||||
try {
|
||||
return JSON.parse(data) as T
|
||||
} catch (e) {
|
||||
return data as unknown as T
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@remark42/api",
|
||||
"version": "0.6.0-alpha.11",
|
||||
"description": "Implementation of API methods for Remark42",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/umputun/remark42.git#master"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/umputun/remark42/issues"
|
||||
},
|
||||
"homepage": "https://github.com/umputun/remark42/tree/master/frontend/packages/api#readme",
|
||||
"keywords": [
|
||||
"remark42",
|
||||
"comments"
|
||||
],
|
||||
"author": "Paul Mineev",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "tsc -w --incremental",
|
||||
"build:cjs": "tsc --module CommonJS",
|
||||
"build:esm": "tsc --module ESNext",
|
||||
"build": "npm-run-all cleanup --parallel build:*",
|
||||
"postpublish": "pnpm run cleanup",
|
||||
"cleanup": "rm -rf *.js *.d.ts **/*.js **/*.d.ts *.tsbuildinfo",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"lint": "eslint --ext .cjs,.mjs,.ts --max-warnings=0",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint-staged:lint": "eslint --fix --ext .cjs,.mjs,.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.33.1",
|
||||
"@typescript-eslint/parser": "^5.33.1",
|
||||
"@vitest/coverage-c8": "^0.22.1",
|
||||
"eslint": "^8.18.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"jsdom": "^20.0.0",
|
||||
"msw": "^0.44.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.7.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tslib": "^2.4.0",
|
||||
"typescript": "^4.7.4",
|
||||
"vitest": "^0.22.1",
|
||||
"whatwg-fetch": "^3.6.2"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"./*.js",
|
||||
"./*.d.ts",
|
||||
"./clients/*.d.ts",
|
||||
"./lib/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect } from 'vitest'
|
||||
import { mockEndpoint } from '../test-utils'
|
||||
import { BlockTTL, createAdminClient } from '../../clients/admin'
|
||||
|
||||
interface Context {
|
||||
client: ReturnType<typeof createAdminClient>
|
||||
}
|
||||
|
||||
describe<Context>('Admin Client', (adminClient) => {
|
||||
beforeEach<Context>((ctx) => {
|
||||
ctx.client = createAdminClient({ siteId: 'mysite', baseUrl: '/remark42' })
|
||||
})
|
||||
|
||||
adminClient('should return list of blocked users', async ({ client }) => {
|
||||
const data = [{ id: 1 }, { id: 2 }]
|
||||
|
||||
mockEndpoint('/remark42/api/v1/blocked', { body: data })
|
||||
await expect(client.getBlockedUsers()).resolves.toEqual(data)
|
||||
})
|
||||
|
||||
const ttlCases: [BlockTTL, string][] = [
|
||||
['permanently', '0'],
|
||||
['1440m', '1440m'],
|
||||
['43200m', '43200m'],
|
||||
]
|
||||
|
||||
ttlCases.forEach(([ttl, expected]) => {
|
||||
adminClient(`should block user with ttl: ${ttl}`, async ({ client }) => {
|
||||
const data = { block: true, site_id: 'remark42', user_id: '1' }
|
||||
const ref = mockEndpoint('/remark42/api/v1/user/1', { method: 'put', body: data })
|
||||
|
||||
await expect(client.blockUser('1', ttl)).resolves.toEqual(data)
|
||||
expect(ref.req.url.searchParams.get('ttl')).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
adminClient('should unblock user', async ({ client }) => {
|
||||
const data = { block: false, site_id: 'remark42', user_id: '1' }
|
||||
const ref = mockEndpoint('/remark42/api/v1/user/1', { method: 'put', body: data })
|
||||
|
||||
await expect(client.unblockUser('1')).resolves.toEqual(data)
|
||||
expect(ref.req.url.searchParams.get('block')).toBe('0')
|
||||
})
|
||||
|
||||
adminClient('should mark user as verified', async ({ client }) => {
|
||||
const ref = mockEndpoint('/remark42/api/v1/verify/1', { method: 'put' })
|
||||
|
||||
await client.verifyUser('1')
|
||||
expect(ref.req.url.searchParams.get('verified')).toBe('1')
|
||||
})
|
||||
|
||||
adminClient('should mark user as unverified', async ({ client }) => {
|
||||
const ref = mockEndpoint('/remark42/api/v1/verify/1', { method: 'put' })
|
||||
|
||||
await client.unverifyUser('1')
|
||||
expect(ref.req.url.searchParams.get('verified')).toBe('0')
|
||||
})
|
||||
|
||||
adminClient('should approve removing request', async ({ client }) => {
|
||||
const ref = mockEndpoint('/remark42/api/v1/deleteme')
|
||||
|
||||
await client.approveRemovingRequest('token')
|
||||
expect(ref.req.url.searchParams.get('token')).toBe('token')
|
||||
})
|
||||
|
||||
adminClient('should pin comment', async ({ client }) => {
|
||||
const ref = mockEndpoint('/remark42/api/v1/pin/1', { method: 'put' })
|
||||
|
||||
await client.pinComment('1')
|
||||
expect(ref.req.url.searchParams.get('pinned')).toBe('1')
|
||||
})
|
||||
|
||||
adminClient('should unpin comment', async ({ client }) => {
|
||||
const ref = mockEndpoint('/remark42/api/v1/pin/1', { method: 'put' })
|
||||
|
||||
await client.unpinComment('1')
|
||||
expect(ref.req.url.searchParams.get('pinned')).toBe('0')
|
||||
})
|
||||
|
||||
adminClient('should remove comment', async ({ client }) => {
|
||||
const ref = mockEndpoint('/remark42/api/v1/comment/1', { method: 'delete' })
|
||||
const url = '/post/1'
|
||||
|
||||
await client.removeComment(url, '1')
|
||||
expect(ref.req.url.searchParams.get('url')).toBe(url)
|
||||
})
|
||||
|
||||
adminClient('should enable commenting on a page', async ({ client }) => {
|
||||
const ref = mockEndpoint('/remark42/api/v1/readonly', { method: 'put' })
|
||||
const url = '/post/1'
|
||||
|
||||
await client.enableCommenting(url)
|
||||
expect(ref.req.url.searchParams.get('ro')).toBe('1')
|
||||
expect(ref.req.url.searchParams.get('url')).toBe(url)
|
||||
})
|
||||
|
||||
adminClient('should disable commenting on a page', async ({ client }) => {
|
||||
const ref = mockEndpoint('/remark42/api/v1/readonly', { method: 'put' })
|
||||
const url = '/post/1'
|
||||
|
||||
await client.disableCommenting('/post/1')
|
||||
expect(ref.req.url.searchParams.get('ro')).toBe('0')
|
||||
expect(ref.req.url.searchParams.get('url')).toBe(url)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect } from 'vitest'
|
||||
import { mockEndpoint } from '../test-utils'
|
||||
import { createAuthClient } from '../../clients/auth'
|
||||
|
||||
interface Content {
|
||||
client: ReturnType<typeof createAuthClient>
|
||||
}
|
||||
|
||||
describe<Content>('Auth Client', (authClient) => {
|
||||
beforeEach<Content>((ctx) => {
|
||||
ctx.client = createAuthClient({ siteId: 'mysite', baseUrl: '/remark42' })
|
||||
})
|
||||
|
||||
authClient('should authorize as anonymouse', async ({ client }) => {
|
||||
const data = { id: 1 }
|
||||
const ref = mockEndpoint('/remark42/auth/anonymous/login', { body: data })
|
||||
|
||||
await expect(client.anonymous('username')).resolves.toEqual(data)
|
||||
expect(ref.req.url.searchParams.get('aud')).toBe('mysite')
|
||||
expect(ref.req.url.searchParams.get('user')).toBe('username')
|
||||
})
|
||||
|
||||
authClient('should authorize with email', async ({ client }) => {
|
||||
const data = { id: 1 }
|
||||
const ref = mockEndpoint('/remark42/auth/email/login', { body: data })
|
||||
const tokenVerification = await client.email('username@example.com', 'username')
|
||||
|
||||
expect(ref.req.url.searchParams.get('address')).toBe('username@example.com')
|
||||
expect(ref.req.url.searchParams.get('user')).toBe('username')
|
||||
|
||||
await expect(tokenVerification('token')).resolves.toEqual(data)
|
||||
expect(ref.req.url.searchParams.get('token')).toBe('token')
|
||||
})
|
||||
|
||||
authClient('should authorize with telegram', async ({ client }) => {
|
||||
const data = {
|
||||
bot: 'remark42bot',
|
||||
token: 'token',
|
||||
}
|
||||
const user = { id: 1 }
|
||||
|
||||
mockEndpoint('/remark42/auth/telegram/login', { body: data })
|
||||
|
||||
const telegramAuth = await client.telegram()
|
||||
|
||||
expect(telegramAuth.bot).toBe(data.bot)
|
||||
expect(telegramAuth.token).toBe(data.token)
|
||||
|
||||
const ref = mockEndpoint('/remark42/auth/telegram/login', { body: user })
|
||||
|
||||
await expect(telegramAuth.verify()).resolves.toEqual(user)
|
||||
expect(ref.req.url.searchParams.get('token')).toBe('token')
|
||||
})
|
||||
|
||||
authClient('should logout', async ({ client }) => {
|
||||
mockEndpoint('/remark42/auth/logout')
|
||||
await expect(client.logout()).resolves.toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createClient } from '../..'
|
||||
|
||||
describe('Client', () => {
|
||||
it('should create a client', () => {
|
||||
const params = { siteId: 'mysite', baseUrl: '/remark42' }
|
||||
const client = createClient(params)
|
||||
|
||||
expect(client).toBeDefined()
|
||||
expect(client.admin).toBeDefined()
|
||||
expect(client.auth).toBeDefined()
|
||||
expect(client.public).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect } from 'vitest'
|
||||
import { mockEndpoint } from '../test-utils'
|
||||
import { createPublicClient, GetUserCommentsParams, Vote } from '../../clients/public'
|
||||
|
||||
interface Context {
|
||||
client: ReturnType<typeof createPublicClient>
|
||||
}
|
||||
|
||||
describe<Context>('Public Client', (publicClient) => {
|
||||
beforeEach<Context>((ctx) => {
|
||||
ctx.client = createPublicClient({ siteId: 'mysite', baseUrl: '/remark42' })
|
||||
})
|
||||
|
||||
publicClient('getConfig: should return config', async ({ client }) => {
|
||||
const data = { x: 1, y: 2 }
|
||||
|
||||
mockEndpoint('/remark42/api/v1/config', { body: data })
|
||||
await expect(client.getConfig()).resolves.toEqual(data)
|
||||
})
|
||||
|
||||
publicClient('getComments: should return page comments', async ({ client }) => {
|
||||
const data = { post: { id: '1' }, node: [{ id: 1 }] }
|
||||
const ref = mockEndpoint('/remark42/api/v1/comments', { body: data })
|
||||
|
||||
await expect(client.getComments('/post/1')).resolves.toEqual(data)
|
||||
expect(ref.req.url.searchParams.get('url')).toBe('/post/1')
|
||||
})
|
||||
|
||||
const commentRequestCases: GetUserCommentsParams[] = [
|
||||
{ url: '' },
|
||||
{ url: '' },
|
||||
{ url: '', limit: 10 },
|
||||
{ url: '', skip: 10 },
|
||||
{ url: '', skip: 10, limit: 0 },
|
||||
]
|
||||
|
||||
commentRequestCases.forEach((params) => {
|
||||
publicClient(
|
||||
`getComments: should return user comments with params: ${JSON.stringify(params)}`,
|
||||
async ({ client }) => {
|
||||
const data = [{ id: 1 }, { id: 2 }]
|
||||
const ref = mockEndpoint('/remark42/api/v1/find', { body: data })
|
||||
|
||||
await expect(client.getComments(params)).resolves.toEqual(data)
|
||||
expect(ref.req.url.searchParams.get('limit')).toBe(
|
||||
params.limit === undefined ? null : `${params.limit}`
|
||||
)
|
||||
expect(ref.req.url.searchParams.get('skip')).toBe(
|
||||
params.skip === undefined ? null : `${params.skip}`
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
publicClient('addComment: should add comment', async ({ client }) => {
|
||||
const data = { id: '1', text: 'test' }
|
||||
const ref = mockEndpoint('/remark42/api/v1/comment', { method: 'post', body: data })
|
||||
|
||||
await expect(client.addComment('/post/my-first-post', { text: 'test' })).resolves.toEqual(data)
|
||||
await expect(ref.req.json()).resolves.toEqual({
|
||||
text: data.text,
|
||||
locator: {
|
||||
site: 'mysite',
|
||||
url: '/post/my-first-post',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
publicClient('updateComment: should update comment', async ({ client }) => {
|
||||
const data = { id: 1, body: 'test' }
|
||||
const ref = mockEndpoint('/remark42/api/v1/comment/1', { method: 'put', body: data })
|
||||
|
||||
await expect(client.updateComment('/post/my-first-post', '1', 'test')).resolves.toEqual(data)
|
||||
await expect(ref.req.json()).resolves.toEqual({ text: 'test' })
|
||||
expect(ref.req.url.searchParams.get('url')).toBe('/post/my-first-post')
|
||||
})
|
||||
|
||||
publicClient('should remove comment', async ({ client }) => {
|
||||
const ref = mockEndpoint('/remark42/api/v1/comment/1', { method: 'put' })
|
||||
|
||||
await expect(client.removeComment('/post/my-first-post', '1')).resolves.toBe('')
|
||||
expect(ref.req.url.searchParams.get('url')).toBe('/post/my-first-post')
|
||||
})
|
||||
|
||||
const voteRequestCases: { vote: Vote; value: string }[] = [
|
||||
{ vote: 1, value: 'upvote' },
|
||||
{ vote: -1, value: 'downvote' },
|
||||
]
|
||||
voteRequestCases.forEach(({ vote, value }) => {
|
||||
publicClient(`vote: should ${value} for comment`, async ({ client }) => {
|
||||
const data = { id: 1, vote: 2 }
|
||||
const ref = mockEndpoint('/remark42/api/v1/vote/1', { method: 'put', body: data })
|
||||
|
||||
await expect(client.vote('/post/my-first-post', '1', vote)).resolves.toEqual(data)
|
||||
expect(ref.req.url.searchParams.get('url')).toBe('/post/my-first-post')
|
||||
expect(ref.req.url.searchParams.get('vote')).toBe(`${vote}`)
|
||||
})
|
||||
})
|
||||
|
||||
const userCases = [null, { id: '1', username: 'user' }]
|
||||
userCases.forEach((user) => {
|
||||
publicClient('should return user', async ({ client }) => {
|
||||
mockEndpoint('/remark42/api/v1/user', { body: user })
|
||||
await expect(client.getUser()).resolves.toEqual(user)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { test, expect } from 'vitest'
|
||||
import { createClient } from '..'
|
||||
|
||||
test('create client', () => {
|
||||
expect(() => createClient({ siteId: 'site', baseUrl: '' })).not.toThrow()
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect } from 'vitest'
|
||||
import { mockEndpoint } from '../test-utils'
|
||||
import { JWT_HEADER, XSRF_COOKIE, XSRF_HEADER } from '../../consts'
|
||||
import { Client, createFetcher } from '../../lib/fetcher'
|
||||
|
||||
interface Context {
|
||||
client: Client
|
||||
}
|
||||
|
||||
describe<Context>('Fetcher', (fetcher) => {
|
||||
beforeEach<Context>((ctx) => {
|
||||
ctx.client = createFetcher('remark42', '')
|
||||
})
|
||||
|
||||
fetcher('get', async ({ client }) => {
|
||||
const ref = mockEndpoint('/test')
|
||||
|
||||
await client.get('/test')
|
||||
expect(ref.req.method).toBe('GET')
|
||||
})
|
||||
|
||||
fetcher('post', async ({ client }) => {
|
||||
const ref = mockEndpoint('/test', { method: 'post' })
|
||||
|
||||
await client.post('/test')
|
||||
expect(ref.req.method).toBe('POST')
|
||||
})
|
||||
|
||||
fetcher('put', async ({ client }) => {
|
||||
const ref = mockEndpoint('/test', { method: 'put' })
|
||||
|
||||
await client.put('/test')
|
||||
expect(ref.req.method).toBe('PUT')
|
||||
})
|
||||
|
||||
fetcher('delete', async ({ client }) => {
|
||||
const ref = mockEndpoint('/test', { method: 'delete' })
|
||||
|
||||
await client.delete('/test')
|
||||
expect(ref.req.method).toBe('DELETE')
|
||||
})
|
||||
fetcher('should send json', async ({ client }) => {
|
||||
const data = { name: 'test' }
|
||||
const ref = mockEndpoint('/test', { method: 'post', body: data })
|
||||
|
||||
await expect(client.post('/test', {}, data)).resolves.toStrictEqual(data)
|
||||
await expect(ref.req.json()).resolves.toStrictEqual(data)
|
||||
expect(ref.req.headers.get('Content-Type')).toBe('application/json')
|
||||
})
|
||||
|
||||
fetcher('should send text', async ({ client }) => {
|
||||
const data = 'text'
|
||||
const ref = mockEndpoint('/test', { method: 'post', body: data })
|
||||
|
||||
await expect(client.post('/test', {}, data)).resolves.toBe(data)
|
||||
await expect(ref.req.text()).resolves.toBe(data)
|
||||
expect(ref.req.headers.get('Content-Type')).toMatch('text/plain')
|
||||
})
|
||||
|
||||
fetcher('should send query', async ({ client }) => {
|
||||
const ref = mockEndpoint('/test')
|
||||
|
||||
await expect(client.get('/test', { x: 1, p: 2 })).resolves.toBe('')
|
||||
expect(ref.req.url.searchParams.get('x')).toBe('1')
|
||||
expect(ref.req.url.searchParams.get('p')).toBe('2')
|
||||
})
|
||||
|
||||
fetcher('should sort query params', async ({ client }) => {
|
||||
const ref = mockEndpoint('/test')
|
||||
|
||||
await expect(client.get('/test', { x: 1, p: 2 })).resolves.toBe('')
|
||||
expect(ref.req.url.search).toBe('?p=2&site=remark42&x=1')
|
||||
})
|
||||
|
||||
fetcher(
|
||||
'should set active token and then clean it on unauthorized response',
|
||||
async ({ client }) => {
|
||||
let ref = mockEndpoint('/user', { headers: { [JWT_HEADER]: 'token' } })
|
||||
|
||||
// token should be saved
|
||||
await client.get('/user')
|
||||
// the first call should be without token
|
||||
expect(ref.req.headers.get(JWT_HEADER)).toBe(null)
|
||||
// the second call should be with token
|
||||
await client.get('/user')
|
||||
// check if the second call was with token
|
||||
expect(ref.req.headers.get(JWT_HEADER)).toBe('token')
|
||||
|
||||
// unauthorized response should clean token
|
||||
ref = mockEndpoint('/user', { status: 401 })
|
||||
// the third call should be with token but token should be cleaned after it
|
||||
await expect(client.get('/user')).rejects.toBe('Unauthorized')
|
||||
// the fourth call should be without token
|
||||
await expect(client.get('/user')).rejects.toBe('Unauthorized')
|
||||
// check if the fourth call was with token
|
||||
expect(ref.req.headers.get(JWT_HEADER)).toBe(null)
|
||||
}
|
||||
)
|
||||
|
||||
fetcher('should add XSRF header if we have it in cookies', async ({ client }) => {
|
||||
const ref = mockEndpoint('/user')
|
||||
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
writable: true,
|
||||
value: `${XSRF_COOKIE}=token`,
|
||||
})
|
||||
|
||||
await client.get('/user')
|
||||
expect(ref.req.headers.get(XSRF_HEADER)).toBe('token')
|
||||
})
|
||||
|
||||
fetcher('should throw error on api response with status code 400', async ({ client }) => {
|
||||
mockEndpoint('/user', { status: 400 })
|
||||
|
||||
await expect(client.get('/user')).rejects.toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'whatwg-fetch'
|
||||
import { afterAll, afterEach, beforeAll } from 'vitest'
|
||||
import { server } from './test-utils'
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: 'error' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
server.close()
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { rest, RestRequest } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
|
||||
export const server = setupServer()
|
||||
|
||||
interface RequestRef {
|
||||
req: RestRequest
|
||||
}
|
||||
|
||||
export function mockEndpoint(
|
||||
url: string,
|
||||
params: {
|
||||
method?: 'get' | 'put' | 'post' | 'delete'
|
||||
body?: number | string | null | Record<string, unknown> | unknown[]
|
||||
status?: number
|
||||
headers?: Record<string, string | string[]>
|
||||
} = {}
|
||||
): RequestRef {
|
||||
const { body, method = 'get', status = 200, headers } = params
|
||||
const result = { req: {} } as RequestRef
|
||||
|
||||
server.use(
|
||||
rest[method](url, (req, res, ctx) => {
|
||||
const transformers = [ctx.status(status), ctx.json(body)]
|
||||
|
||||
if (headers) {
|
||||
transformers.push(ctx.set(headers))
|
||||
}
|
||||
|
||||
result.req = req
|
||||
return res(...transformers)
|
||||
})
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": [".turbo", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"lib": ["DOM"],
|
||||
"moduleResolution": "node",
|
||||
"baseUrl": "./",
|
||||
"declaration": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["index.ts", "clients", "lib"],
|
||||
"exclude": ["tests", "coverage"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Generated
+1512
-127
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
|
||||
Reference in New Issue
Block a user