diff --git a/spx-gui/src/apis/casdoor-user.ts b/spx-gui/src/apis/casdoor-user.ts new file mode 100644 index 00000000..78b51339 --- /dev/null +++ b/spx-gui/src/apis/casdoor-user.ts @@ -0,0 +1,15 @@ +import { casdoorClient } from './common' +import { casdoorConfig } from '@/utils/env' + +export type CasdoorUser = { + id: string + name: string + displayName: string + avatar: string +} + +export async function getCasdoorUser(name: string): Promise { + return casdoorClient.get('/api/get-user', { + id: `${casdoorConfig.organizationName}/${name}` + }) as Promise +} diff --git a/spx-gui/src/apis/common/casdoor-client.ts b/spx-gui/src/apis/common/casdoor-client.ts new file mode 100644 index 00000000..774b8a22 --- /dev/null +++ b/spx-gui/src/apis/common/casdoor-client.ts @@ -0,0 +1,33 @@ +import { casdoorConfig } from '@/utils/env' +import { CasdoorApiException } from './exception' +import { useRequest, withQueryParams, type RequestOptions, type QueryParams } from '.' + +/** Response body when exception encountered for Casdoor API calling */ +export type CasdoorApiExceptionPayload = { + /** Message for developer reading */ + msg: string +} + +function isCasdoorApiExceptionPayload(body: any): body is CasdoorApiExceptionPayload { + return body && typeof body.msg === 'string' +} + +export class CasdoorClient { + private request = useRequest(casdoorConfig.serverUrl, async (resp) => { + if (!resp.ok) { + const body = await resp.json() + if (!isCasdoorApiExceptionPayload(body)) { + throw new Error('casdoor api call failed') + } + throw new CasdoorApiException(body.msg) + } + if (resp.status === 204) return null + const body = await resp.json() + return body.data + }) + + get(path: string, params?: QueryParams, options?: Omit) { + if (params != null) path = withQueryParams(path, params) + return this.request(path, null, { ...options, method: 'GET' }) + } +} diff --git a/spx-gui/src/apis/common/client.ts b/spx-gui/src/apis/common/client.ts index 9770dc96..7f68697e 100644 --- a/spx-gui/src/apis/common/client.ts +++ b/spx-gui/src/apis/common/client.ts @@ -3,16 +3,8 @@ */ import { apiBaseUrl } from '@/utils/env' -import { Exception } from '@/utils/exception' import { ApiException } from './exception' - -export type RequestOptions = { - method: string - headers?: Headers - /** Timeout duration in milisecond, from request-sent to server-response-got */ - timeout?: number - signal?: AbortSignal -} +import { useRequest, withQueryParams, type RequestOptions, type QueryParams } from '.' /** Response body when exception encountered for API calling */ export type ApiExceptionPayload = { @@ -26,41 +18,8 @@ function isApiExceptionPayload(body: any): body is ApiExceptionPayload { return body && typeof body.code === 'number' && typeof body.msg === 'string' } -/** AuthProvider provide value for header Authorization */ -export type AuthProvider = () => Promise - -// milisecond -const defaultTimeout = 10 * 1000 - -class TimeoutException extends Exception { - name = 'TimeoutException' - userMessage = { en: 'request timeout', zh: '请求超时' } - constructor() { - super('request timeout') - } -} - export class Client { - private getAuth: AuthProvider = async () => null - - setAuthProvider(provider: AuthProvider) { - this.getAuth = provider - } - - private async prepareRequest(url: string, payload: unknown, options?: RequestOptions) { - url = apiBaseUrl + url - const method = options?.method ?? 'GET' - const body = payload != null ? JSON.stringify(payload) : null - const authorization = await this.getAuth() - const headers = options?.headers ?? new Headers() - headers.set('Content-Type', 'application/json') - if (authorization != null) { - headers.set('Authorization', authorization) - } - return new Request(url, { method, headers, body }) - } - - private async handleResponse(resp: Response): Promise { + private request = useRequest(apiBaseUrl, async (resp) => { if (!resp.ok) { const body = await resp.json() if (!isApiExceptionPayload(body)) { @@ -70,60 +29,23 @@ export class Client { } if (resp.status === 204) return null return resp.json() - } - - private async request(url: string, payload: unknown, options?: RequestOptions) { - const req = await this.prepareRequest(url, payload, options) - const timeout = options?.timeout ?? defaultTimeout - const ctrl = new AbortController() - if (options?.signal != null) { - // TODO: Reimplement this using `AbortSignal.any()` once it is widely supported. - options.signal.throwIfAborted() - options.signal.addEventListener('abort', () => ctrl.abort(options.signal?.reason)) - } - const resp = await Promise.race([ - fetch(req, { signal: ctrl.signal }), - new Promise((_, reject) => setTimeout(() => reject(new TimeoutException()), timeout)) - ]).catch((e) => { - if (e instanceof TimeoutException) ctrl.abort() - throw e - }) - return this.handleResponse(resp) - } - - get(url: string, params?: QueryParams, options?: Omit) { - url = params == null ? url : withQueryParams(url, params) - return this.request(url, null, { ...options, method: 'GET' }) - } + }) - post(url: string, payload?: unknown, options?: Omit) { - return this.request(url, payload, { ...options, method: 'POST' }) + get(path: string, params?: QueryParams, options?: Omit) { + if (params != null) path = withQueryParams(path, params) + return this.request(path, null, { ...options, method: 'GET' }) } - put(url: string, payload?: unknown, options?: Omit) { - return this.request(url, payload, { ...options, method: 'PUT' }) + post(path: string, payload?: unknown, options?: Omit) { + return this.request(path, payload, { ...options, method: 'POST' }) } - delete(url: string, params?: QueryParams, options?: Omit) { - url = params == null ? url : withQueryParams(url, params) - return this.request(url, null, { ...options, method: 'DELETE' }) + put(path: string, payload?: unknown, options?: Omit) { + return this.request(path, payload, { ...options, method: 'PUT' }) } -} -type QueryParams = { - [k: string]: unknown -} - -function withQueryParams(url: string, params: QueryParams) { - const usp = new URLSearchParams() - Object.keys(params).forEach((k) => { - const v = params[k] - if (v != null) usp.append(k, v + '') - }) - const querystring = usp.toString() - if (querystring !== '') { - const sep = url.includes('?') ? '&' : '?' - url = url + sep + querystring + delete(path: string, params?: QueryParams, options?: Omit) { + if (params != null) path = withQueryParams(path, params) + return this.request(path, null, { ...options, method: 'DELETE' }) } - return url } diff --git a/spx-gui/src/apis/common/exception.ts b/spx-gui/src/apis/common/exception.ts index 3c5b9e9f..b61435cd 100644 --- a/spx-gui/src/apis/common/exception.ts +++ b/spx-gui/src/apis/common/exception.ts @@ -48,3 +48,11 @@ const codeMessages: Record = { zh: '服务器出问题了' } } + +export class CasdoorApiException extends Exception { + name = 'CasdoorApiError' + userMessage = null + constructor(message: string) { + super(message) + } +} diff --git a/spx-gui/src/apis/common/index.ts b/spx-gui/src/apis/common/index.ts index 78b21fc4..89890b3d 100644 --- a/spx-gui/src/apis/common/index.ts +++ b/spx-gui/src/apis/common/index.ts @@ -1,4 +1,89 @@ +import { Exception } from '@/utils/exception' import { Client } from './client' +import { CasdoorClient } from './casdoor-client' + +/** TokenProvider provides access token used for the Authorization header */ +export type TokenProvider = () => Promise + +let tokenProvider: TokenProvider = async () => null +export function setTokenProvider(provider: TokenProvider) { + tokenProvider = provider +} + +/** ReponseHandler handles the response from fetch() */ +export type ResponseHandler = (resp: Response) => Promise + +export type RequestOptions = { + method: string + headers?: Headers + /** Timeout duration in milisecond, from request-sent to server-response-got */ + timeout?: number + signal?: AbortSignal +} + +class TimeoutException extends Exception { + name = 'TimeoutException' + userMessage = { en: 'request timeout', zh: '请求超时' } + constructor() { + super('request timeout') + } +} + +export function useRequest( + baseUrl: string = '', + responseHandler?: ResponseHandler, + defaultTimeout: number = 10 * 1000 // 10 seconds +) { + async function prepareRequest(path: string, payload: unknown, options?: RequestOptions) { + const url = baseUrl + path + const method = options?.method ?? 'GET' + const body = payload != null ? JSON.stringify(payload) : null + const token = await tokenProvider() + const headers = options?.headers ?? new Headers() + headers.set('Content-Type', 'application/json') + if (token != null) { + headers.set('Authorization', `Bearer ${token}`) + } + return new Request(url, { method, headers, body }) + } + + return async function request(path: string, payload: unknown, options?: RequestOptions) { + const req = await prepareRequest(path, payload, options) + const timeout = options?.timeout ?? defaultTimeout + const ctrl = new AbortController() + if (options?.signal != null) { + // TODO: Reimplement this using `AbortSignal.any()` once it is widely supported. + options.signal.throwIfAborted() + options.signal.addEventListener('abort', () => ctrl.abort(options.signal?.reason)) + } + const resp = await Promise.race([ + fetch(req, { signal: ctrl.signal }), + new Promise((_, reject) => setTimeout(() => reject(new TimeoutException()), timeout)) + ]).catch((e) => { + if (e instanceof TimeoutException) ctrl.abort() + throw e + }) + return responseHandler != null ? responseHandler(resp) : resp + } +} + +export type QueryParams = { + [k: string]: unknown +} + +export function withQueryParams(url: string, params: QueryParams) { + const usp = new URLSearchParams() + Object.keys(params).forEach((k) => { + const v = params[k] + if (v != null) usp.append(k, v + '') + }) + const querystring = usp.toString() + if (querystring !== '') { + const sep = url.includes('?') ? '&' : '?' + url = url + sep + querystring + } + return url +} export type PaginationParams = { pageSize?: number @@ -39,3 +124,4 @@ export function timeStringify(time: number) { } export const client = new Client() +export const casdoorClient = new CasdoorClient() diff --git a/spx-gui/src/apis/user.ts b/spx-gui/src/apis/user.ts index 96b7821a..e0aa1d8a 100644 --- a/spx-gui/src/apis/user.ts +++ b/spx-gui/src/apis/user.ts @@ -1,5 +1,6 @@ import { client, type ByPage, type PaginationParams } from './common' import { ApiException, ApiExceptionCode } from './common/exception' +import { getCasdoorUser } from './casdoor-user' export type User = { /** Unique identifier */ @@ -12,29 +13,33 @@ export type User = { username: string /** Brief bio or description of the user */ description: string - /** Name to display, TODO: from Casdoor? */ + + /** Name to display, from Casdoor */ displayName: string - /** Avatar URL, TODO: from Casdoor? */ + /** Avatar URL, from Casdoor */ avatar: string } -// TODO: remove me -function __adaptUser(user: Omit): User { +async function completeUserWithCasdoor(user: Omit): Promise { + // TODO: cache the result of `getCasdoorUser` to avoid redundant requests? + const casdoorUser = await getCasdoorUser(user.username) return { - displayName: user.username, - avatar: 'https://avatars.githubusercontent.com/u/1492263?v=4', - ...user + ...user, + displayName: casdoorUser.displayName, + avatar: casdoorUser.avatar } } export async function getUser(name: string): Promise { - return __adaptUser(await (client.get(`/user/${encodeURIComponent(name)}`) as Promise)) + const user = await (client.get(`/user/${encodeURIComponent(name)}`) as Promise) + return completeUserWithCasdoor(user) } export type UpdateProfileParams = Pick export async function updateProfile(params: UpdateProfileParams) { - return __adaptUser(await (client.put(`/user`, params) as Promise)) + const user = await (client.put(`/user`, params) as Promise) + return completeUserWithCasdoor(user) } export type ListUserParams = PaginationParams & { @@ -52,7 +57,9 @@ export async function listUsers(params: ListUserParams) { const { total, data } = await (client.get('/users/list', params) as Promise>) return { total, - data: data.map(__adaptUser) + // There is a performance issue here, as we are calling `completeUserWithCasdoor` for each user. Unfortunately, + // Casdoor doesn't provide a batch API for fetching multiple user profiles by their usernames. + data: await Promise.all(data.map(completeUserWithCasdoor)) } } diff --git a/spx-gui/src/main.ts b/spx-gui/src/main.ts index bdae064a..d7025f42 100644 --- a/spx-gui/src/main.ts +++ b/spx-gui/src/main.ts @@ -10,7 +10,7 @@ import { initI18n } from './i18n' import App from './App.vue' import { initRouter } from './router' import { initStore, useUserStore } from './stores' -import { client } from './apis/common' +import { setTokenProvider } from './apis/common' import { CustomTransformer } from './components/editor/preview/stage-viewer/custom-transformer' dayjs.extend(localizedFormat) @@ -18,7 +18,7 @@ dayjs.extend(relativeTime) const initApiClient = async () => { const userStore = useUserStore() - client.setAuthProvider(userStore.getFreshAccessToken) + setTokenProvider(userStore.getFreshAccessToken) } async function initApp() {