Skip to content

Commit

Permalink
feat(apis/user): complete User with displayName and avatar form…
Browse files Browse the repository at this point in the history
… Casdoor

Signed-off-by: Aofei Sheng <aofei@aofeisheng.com>
  • Loading branch information
aofei committed Oct 17, 2024
1 parent caa3fbe commit b34dbe6
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 103 deletions.
15 changes: 15 additions & 0 deletions spx-gui/src/apis/casdoor-user.ts
Original file line number Diff line number Diff line change
@@ -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<CasdoorUser> {
return casdoorClient.get('/api/get-user', {
id: `${casdoorConfig.organizationName}/${name}`
}) as Promise<CasdoorUser>
}
33 changes: 33 additions & 0 deletions spx-gui/src/apis/common/casdoor-client.ts
Original file line number Diff line number Diff line change
@@ -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<RequestOptions, 'method'>) {
if (params != null) path = withQueryParams(path, params)
return this.request(path, null, { ...options, method: 'GET' })
}
}
104 changes: 13 additions & 91 deletions spx-gui/src/apis/common/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<string | null>

// 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<unknown> {
private request = useRequest(apiBaseUrl, async (resp) => {
if (!resp.ok) {
const body = await resp.json()
if (!isApiExceptionPayload(body)) {
Expand All @@ -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<never>((_, 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<RequestOptions, 'method'>) {
url = params == null ? url : withQueryParams(url, params)
return this.request(url, null, { ...options, method: 'GET' })
}
})

post(url: string, payload?: unknown, options?: Omit<RequestOptions, 'method'>) {
return this.request(url, payload, { ...options, method: 'POST' })
get(path: string, params?: QueryParams, options?: Omit<RequestOptions, 'method'>) {
if (params != null) path = withQueryParams(path, params)
return this.request(path, null, { ...options, method: 'GET' })
}

put(url: string, payload?: unknown, options?: Omit<RequestOptions, 'method'>) {
return this.request(url, payload, { ...options, method: 'PUT' })
post(path: string, payload?: unknown, options?: Omit<RequestOptions, 'method'>) {
return this.request(path, payload, { ...options, method: 'POST' })
}

delete(url: string, params?: QueryParams, options?: Omit<RequestOptions, 'method'>) {
url = params == null ? url : withQueryParams(url, params)
return this.request(url, null, { ...options, method: 'DELETE' })
put(path: string, payload?: unknown, options?: Omit<RequestOptions, 'method'>) {
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<RequestOptions, 'method'>) {
if (params != null) path = withQueryParams(path, params)
return this.request(path, null, { ...options, method: 'DELETE' })
}
return url
}
8 changes: 8 additions & 0 deletions spx-gui/src/apis/common/exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,11 @@ const codeMessages: Record<ApiExceptionCode, LocaleMessage> = {
zh: '服务器出问题了'
}
}

export class CasdoorApiException extends Exception {
name = 'CasdoorApiError'
userMessage = null
constructor(message: string) {
super(message)
}
}
86 changes: 86 additions & 0 deletions spx-gui/src/apis/common/index.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>

let tokenProvider: TokenProvider = async () => null
export function setTokenProvider(provider: TokenProvider) {
tokenProvider = provider
}

/** ReponseHandler handles the response from fetch() */
export type ResponseHandler = (resp: Response) => Promise<unknown>

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<never>((_, 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
Expand Down Expand Up @@ -39,3 +124,4 @@ export function timeStringify(time: number) {
}

export const client = new Client()
export const casdoorClient = new CasdoorClient()
27 changes: 17 additions & 10 deletions spx-gui/src/apis/user.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand All @@ -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, 'displayName' | 'avatar'>): User {
async function completeUserWithCasdoor(user: Omit<User, 'displayName' | 'avatar'>): Promise<User> {
// 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<User> {
return __adaptUser(await (client.get(`/user/${encodeURIComponent(name)}`) as Promise<User>))
const user = await (client.get(`/user/${encodeURIComponent(name)}`) as Promise<User>)
return completeUserWithCasdoor(user)
}

export type UpdateProfileParams = Pick<User, 'description'>

export async function updateProfile(params: UpdateProfileParams) {
return __adaptUser(await (client.put(`/user`, params) as Promise<User>))
const user = await (client.put(`/user`, params) as Promise<User>)
return completeUserWithCasdoor(user)
}

export type ListUserParams = PaginationParams & {
Expand All @@ -52,7 +57,9 @@ export async function listUsers(params: ListUserParams) {
const { total, data } = await (client.get('/users/list', params) as Promise<ByPage<User>>)
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))
}
}

Expand Down
4 changes: 2 additions & 2 deletions spx-gui/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ 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)
dayjs.extend(relativeTime)

const initApiClient = async () => {
const userStore = useUserStore()
client.setAuthProvider(userStore.getFreshAccessToken)
setTokenProvider(userStore.getFreshAccessToken)
}

async function initApp() {
Expand Down

0 comments on commit b34dbe6

Please sign in to comment.