better code: share api module
Massimo Melina committed
Apr 15, 2023 at 17:03 UTC
f171990cd5cd22554a2437db042ec1fd5c2af651
11 files changed
+203
-263
admin/src/AccountForm.ts
+3
-3
@@ -46,7 +46,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
46
fields: [
47
{ k: 'username', label: group ? 'Group name' : undefined, autoComplete: 'off', required: true, xl: group ? 12 : 4,
48
getError: v => v !== account.username && apiCall('get_account', { username: v })
49
- .then(got => got.username === account.username ? "usernames are case-insensitive" : "already used", () => false),
49
+ .then(got => got?.username === account.username ? "usernames are case-insensitive" : "already used", () => false),
50
},
51
!group && { k: 'password', md: 6, xl: 4, type: 'password', autoComplete: 'new-password', required: add,
52
label: add ? "Password" : "Change password"
@@ -81,7 +81,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
81
apiCall('del_account', { username }).then() // best effort, don't wait
82
throw e
83
}
84
- done(got.username)
84
+ done(got?.username)
85
toast("Account created", 'success')
86
return
87
}
@@ -92,7 +92,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
92
if (password)
93
await apiNewPassword(username, password)
94
setTimeout(() => toast("Account modified", 'success'), 1) // workaround: showing a dialog at this point is causing a crash if we are in a dialog
95
- done(got.username) // username may have been changed, so we pass it back
95
+ done(got?.username) // username may have been changed, so we pass it back
96
}
97
}
98
})
admin/src/api.ts
+13
-144
@@ -1,13 +1,23 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { createElement as h, useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
-import { Dict, err2msg, Falsy, getCookie, IconBtn, pendingPromise, spinner, useStateMounted, wantArray } from './misc'
3
+import { createElement as h, useEffect, useMemo, useRef, useState } from 'react'
4
+import { Dict, err2msg, Falsy, IconBtn, spinner, useStateMounted, wantArray } from './misc'
5
import { Alert } from '@mui/material'
6
import _ from 'lodash'
7
import { state } from './state'
8
import { Refresh } from '@mui/icons-material'
9
import produce, { Draft } from 'immer'
10
-import { try_ } from './misc'
10
+import { ApiError, apiEvents, setDefaultApiCallOptions, useApi } from '@hfs/shared/api'
11
+export * from '@hfs/shared/api'
12
+
13
+setDefaultApiCallOptions({
14
+ async onResponse(res: Response, body: any) {
15
+ if (res.status === 401) {
16
+ state.loginRequired = body?.any !== false || 403
17
+ throw new ApiError(res.status, "Unauthorized")
18
+ }
19
+ }
20
+})
21
22
export function useApiEx<T=any>(...args: Parameters<typeof useApi>) {
23
const [data, error, reload] = useApi<T>(...args)
@@ -22,147 +32,6 @@ export function useApiEx<T=any>(...args: Parameters<typeof useApi>) {
32
return { data, error, reload, loading, element }
33
}
34
25
-const PREFIX = (window as any).HFS?.prefixUrl + '/~/api/'
26
-
27
-const timeoutByApi: Dict = {
28
- loginSrp1: 90, // support antibrute
29
- get_status: 20 // can be lengthy on slow machines because of the find-process-on-busy-port feature
30
-}
31
-export function apiCall(cmd: string, params?: Dict, { timeout=undefined }={}) {
32
- const csrf = getCsrf()
33
- if (csrf)
34
- params = { csrf, ...params }
35
-
36
- const controller = new AbortController()
37
- if (timeout !== false)
38
- setTimeout(() => controller.abort('timeout'), 1000*(timeoutByApi[cmd] ?? timeout ?? 10))
39
- return Object.assign(fetch(PREFIX+cmd, {
40
- method: 'POST',
41
- headers: { 'content-type': 'application/json' },
42
- signal: controller.signal,
43
- body: params && JSON.stringify(params),
44
- }).then(async res => {
45
- if (res.ok)
46
- return res.json().then(json => {
47
- console.debug('API', cmd, params, '>>', json)
48
- return json
49
- })
50
- let msg = await res.text() || 'Failed API ' + cmd
51
- console.warn(msg + (params ? ' ' + JSON.stringify(params) : ''))
52
- if (res.status === 401) {
53
- state.loginRequired = try_(() => JSON.parse(msg)?.any) !== false || 403
54
- msg = "Unauthorized"
55
- }
56
- throw new ApiError(res.status, msg)
57
- }, err => {
58
- if (err?.message?.includes('fetch'))
59
- throw Error("Network error")
60
- throw err
61
- }), {
62
- abort() {
63
- controller.abort('cancel')
64
- }
65
- })
66
-}
67
-
68
-export class ApiError extends Error {
69
- constructor(readonly code:number, message: string) {
70
- super(message);
71
- }
72
-}
73
-
74
-export function useApi<T=any>(cmd: string | Falsy, params?: object) : [T | undefined, undefined | Error, ()=>void] {
75
- const [ret, setRet] = useStateMounted<T | undefined>(undefined)
76
- const [err, setErr] = useStateMounted<Error | undefined>(undefined)
77
- const [forcer, setForcer] = useStateMounted(0)
78
- const loadingRef = useRef<ReturnType<typeof apiCall>>()
79
- const reloadingRef = useRef<any>()
80
- useEffect(()=>{
81
- loadingRef.current?.abort()
82
- setRet(undefined)
83
- setErr(undefined)
84
- if (!cmd) return
85
- let aborted = false
86
- const req = apiCall(cmd, params)
87
- const wholePromise = req.then(x => aborted || setRet(x), x => aborted || setErr(x))
88
- .finally(()=> loadingRef.current = undefined)
89
- loadingRef.current = Object.assign(wholePromise, {
90
- abort() {
91
- aborted = true
92
- req.abort()
93
- }
94
- })
95
- reloadingRef.current?.resolve(wholePromise)
96
- }, [cmd, JSON.stringify(params), forcer]) //eslint-disable-line -- json-ize to detect deep changes
97
- const reload = useCallback(() => loadingRef.current
98
- || setForcer(v => v+1) || (reloadingRef.current = pendingPromise()),
99
- [setForcer])
100
- return [ret, err, reload]
101
-}
102
-
103
-type EventHandler = (type:string, data?:any) => void
104
-
105
-export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
106
- console.debug('API EVENTS', cmd, params)
107
- const csrf = getCsrf()
108
- const processed: Record<string,string> = { csrf: csrf && JSON.stringify(csrf) }
109
- for (const k in params) {
110
- const v = params[k]
111
- if (v === undefined) continue
112
- processed[k] = JSON.stringify(v)
113
- }
114
- const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(processed))
115
- source.onopen = () => cb('connected')
116
- source.onerror = err => cb('error', err)
117
- source.onmessage = ({ data }) => {
118
- if (!data) {
119
- cb('closed')
120
- return source.close()
121
- }
122
- try { data = JSON.parse(data) }
123
- catch {
124
- return cb('string', data)
125
- }
126
- console.debug('SSE msg', data)
127
- cb('msg', data)
128
- }
129
- return source
130
-}
131
-
132
-function getCsrf() {
133
- return getCookie('csrf')
134
-}
135
-
136
-export function useApiEvents(cmd: string, params: Dict={}) {
137
- const [data, setData] = useStateMounted<any>(undefined)
138
- const [error, setError] = useStateMounted<any>(undefined)
139
- const [loading, setLoading] = useStateMounted(false)
140
- useEffect(() => {
141
- const src = apiEvents(cmd, params, (type, data) => {
142
- switch (type) {
143
- case 'error':
144
- setError("Connection error")
145
- return stop()
146
- case 'closed':
147
- return stop()
148
- case 'msg':
149
- if (src?.readyState === src?.CLOSED)
150
- return stop()
151
- return setData(data)
152
- }
153
- })
154
- return () => {
155
- src.close()
156
- stop()
157
- }
158
-
159
- function stop() {
160
- setLoading(false)
161
- }
162
- }, [cmd, JSON.stringify(params)]) //eslint-disable-line
163
- return { data, loading, error }
164
-}
165
-
35
export function useApiList<T=any>(cmd:string|Falsy, params: Dict={}, { addId=false, map=((x:any)=>x) }={}) {
36
const [list, setList] = useStateMounted<T[]>([])
37
const [props, setProps] = useStateMounted<any>(undefined)
frontend/src/UserPanel.ts
+1
-1
@@ -4,7 +4,7 @@ import { useSnapState } from './state'
4
import { createElement as h } from 'react'
5
import { alertDialog, closeDialog, newDialog, promptDialog } from './dialog'
6
import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a'
7
-import { apiCall } from './api'
7
+import { apiCall } from '@hfs/shared/api'
8
import { logout } from './login'
9
import { MenuButton } from './menu'
10
import { hIcon } from './misc'
frontend/src/api.ts
deleted
-102
@@ -1,102 +0,0 @@
1
-// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
-
3
-import { useEffect, useRef, useState } from 'react';
4
-import { Dict, Falsy, getCookie, getPrefixUrl, working } from './misc'
5
-
6
-const PREFIX = getPrefixUrl() + '/~/api/'
7
-
8
-interface ApiCallOptions { noModal?:true }
9
-export function apiCall(cmd: string, params?: Dict, options: ApiCallOptions={}) {
10
- const stop = options.noModal ? undefined : working()
11
- const csrf = getCsrf()
12
- if (csrf)
13
- params = { csrf, ...params }
14
- const controller = new AbortController()
15
- return Object.assign(fetch(PREFIX + cmd, {
16
- method: 'POST',
17
- headers: { 'content-type': 'application/json' },
18
- signal: controller.signal,
19
- body: params && JSON.stringify(params),
20
- }).then(async res => {
21
- stop?.()
22
- if (res.ok)
23
- return res.json()
24
- const msg = await res.text() || `Failed API ${cmd}: ${res.statusText}`
25
- console.warn(msg, params ? ' ' + JSON.stringify(params) : '')
26
- throw new ApiError(res.status, msg)
27
- }, err => {
28
- stop?.()
29
- if (err?.message?.includes('fetch'))
30
- throw Error("Network error")
31
- throw err
32
- }), {
33
- abort() {
34
- controller.abort('cancel')
35
- }
36
- })
37
-}
38
-
39
-export class ApiError extends Error {
40
- constructor(readonly code:number, message: string) {
41
- super(message);
42
- }
43
-}
44
-
45
-export function useApi(cmd: string | Falsy, params?: Dict, options: ApiCallOptions={}) : any {
46
- const [ret, setRet] = useState()
47
- const loadingRef = useRef<ReturnType<typeof apiCall>>()
48
- useEffect(()=>{
49
- loadingRef.current?.abort()
50
- setRet(undefined)
51
- if (!cmd) return
52
- const p = loadingRef.current = apiCall(cmd, params, options)
53
- p.then(setRet, setRet)
54
- }, [cmd, JSON.stringify(params)]) //eslint-disable-line
55
- return ret
56
-}
57
-
58
-type EventHandler = (type:string, data?:any) => void
59
-
60
-export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
61
- const processed: Record<string,string> = {}
62
- for (const k in params) {
63
- const v = params[k]
64
- if (v !== undefined)
65
- processed[k] = JSON.stringify(v)
66
- }
67
- const csrf = getCsrf()
68
- if (csrf)
69
- processed.csrf = JSON.stringify(csrf)
70
- const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(processed))
71
- source.onopen = () => cb('connected')
72
- source.onerror = err => cb('error', err)
73
- source.onmessage = ({ data }) => {
74
- if (!data) {
75
- cb('closed')
76
- return source.close()
77
- }
78
- try { data = JSON.parse(data) }
79
- catch {
80
- return cb('string', data)
81
- }
82
- cb('msg', data)
83
- }
84
- return source
85
-}
86
-
87
-function getCsrf() {
88
- return getCookie('csrf')
89
-}
90
-
91
-export async function getNotification(channel: string, cb: (name: string, data:any) => void): Promise<EventSource> {
92
- return new Promise(resolve => {
93
- const ret = apiEvents('get_notifications', { channel }, (type, entries) => {
94
- if (type === 'connected')
95
- return resolve(ret)
96
- if (type !== 'msg') return
97
- for (const { name, data } of entries)
98
- if (name)
99
- cb(name, data)
100
- })
101
- })
102
-}
\ No newline at end of file
frontend/src/login.ts
+1
-1
@@ -1,6 +1,6 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { apiCall } from './api'
3
+import { apiCall } from '@hfs/shared/api'
4
import { state, useSnapState } from './state'
5
import { alertDialog, newDialog } from './dialog'
6
import { getPrefixUrl, hIcon, srpSequence, working } from './misc'
frontend/src/menu.ts
+1
-1
@@ -12,7 +12,7 @@ import _ from 'lodash'
12
import { closeDialog } from '@hfs/shared/dialogs'
13
import { showUpload, uploadState } from './upload'
14
import { useSnapshot } from 'valtio'
15
-import { apiCall } from './api'
15
+import { apiCall } from '@hfs/shared/api'
16
import { reloadList, usePath } from './useFetchList'
17
import { t, useI18N } from './i18n'
18
frontend/src/misc.ts
+2
-9
@@ -9,7 +9,7 @@ import { state } from './state'
9
import { t } from './i18n'
10
import * as dialogLib from './dialog'
11
import _ from 'lodash'
12
-import { apiCall } from './api'
12
+import { apiCall, setDefaultApiCallOptions } from '@hfs/shared/api'
13
import { reloadList } from './useFetchList'
14
import { logout } from './login'
15
export * from '@hfs/shared'
@@ -75,11 +75,4 @@ Object.assign((window as any).HFS ||= {}, {
75
}
76
})
77
78
-export function getHFS() {
79
- return (window as any).HFS
80
-}
81
-
82
-export function getPrefixUrl() {
83
- return getHFS().prefixUrl
84
-}
85
-
78
+setDefaultApiCallOptions({ modal: working })
frontend/src/upload.ts
+1
-1
@@ -7,7 +7,7 @@ import _ from 'lodash'
7
import { proxy, ref, subscribe, useSnapshot } from 'valtio'
8
import { alertDialog, confirmDialog, promptDialog } from './dialog'
9
import { reloadList } from './useFetchList'
10
-import { apiCall, getNotification } from './api'
10
+import { apiCall, getNotification } from '@hfs/shared/api'
11
import { state, useSnapState } from './state'
12
import { Link } from 'react-router-dom'
13
import { t } from './i18n'
frontend/src/useFetchList.ts
+1
-1
@@ -2,7 +2,7 @@
2
3
import { state, useSnapState } from './state'
4
import { useEffect, useRef } from 'react'
5
-import { apiEvents } from './api'
5
+import { apiEvents } from '@hfs/shared/api'
6
import { DirList } from './BrowseFiles'
7
import _ from 'lodash'
8
import { subscribeKey } from 'valtio/utils'
shared/api.ts
new
+172
@@ -0,0 +1,172 @@
1
+// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+import _ from 'lodash';
4
+import { useCallback, useEffect, useRef } from 'react';
5
+import { Dict, Falsy, getCookie, getPrefixUrl, pendingPromise, useStateMounted } from '.'
6
+
7
+const PREFIX = getPrefixUrl() + '/~/api/'
8
+
9
+const timeoutByApi: Dict = {
10
+ loginSrp1: 90, // support antibrute
11
+ get_status: 20 // can be lengthy on slow machines because of the find-process-on-busy-port feature
12
+}
13
+
14
+interface ApiCallOptions {
15
+ timeout?: number | false
16
+ modal?: undefined | ((cmd: string, params?: Dict) => (() => unknown))
17
+ onResponse?: (res: Response, body: any) => any
18
+}
19
+
20
+const defaultApiCallOptions: ApiCallOptions = {}
21
+export function setDefaultApiCallOptions(options: Partial<ApiCallOptions>) {
22
+ Object.assign(defaultApiCallOptions, options)
23
+}
24
+
25
+export function apiCall<T=any>(cmd: string, params?: Dict, options: ApiCallOptions={}) {
26
+ _.defaults(options, defaultApiCallOptions)
27
+ const stop = options.modal?.(cmd, params)
28
+ const csrf = getCsrf()
29
+ if (csrf)
30
+ params = { csrf, ...params }
31
+ const controller = new AbortController()
32
+ if (options.timeout !== false)
33
+ setTimeout(() => controller.abort('timeout'), 1000*(timeoutByApi[cmd] ?? options.timeout ?? 10))
34
+ return Object.assign(fetch(PREFIX + cmd, {
35
+ method: 'POST',
36
+ headers: { 'content-type': 'application/json' },
37
+ signal: controller.signal,
38
+ body: params && JSON.stringify(params),
39
+ }).then(async res => {
40
+ stop?.()
41
+ let body: any = await res.text()
42
+ try { body = JSON.parse(body) }
43
+ catch {}
44
+ console.debug(res.ok ? 'API' : 'API FAILED', cmd, params, '>>', body)
45
+ await options.onResponse?.(res, body)
46
+ if (!res.ok)
47
+ throw new ApiError(res.status, body || `Failed API ${cmd}: ${res.statusText}`)
48
+ return body as T
49
+ }, err => {
50
+ stop?.()
51
+ if (err?.message?.includes('fetch'))
52
+ throw Error("Network error")
53
+ throw err
54
+ }), {
55
+ abort() {
56
+ controller.abort('cancel')
57
+ }
58
+ })
59
+}
60
+
61
+export class ApiError extends Error {
62
+ constructor(readonly code:number, message: string) {
63
+ super(message);
64
+ }
65
+}
66
+
67
+export function useApi<T=any>(cmd: string | Falsy, params?: object) : [T | undefined, undefined | Error, ()=>void] {
68
+ const [ret, setRet] = useStateMounted<T | undefined>(undefined)
69
+ const [err, setErr] = useStateMounted<Error | undefined>(undefined)
70
+ const [forcer, setForcer] = useStateMounted(0)
71
+ const loadingRef = useRef<ReturnType<typeof apiCall>>()
72
+ const reloadingRef = useRef<any>()
73
+ useEffect(()=>{
74
+ loadingRef.current?.abort()
75
+ setRet(undefined)
76
+ setErr(undefined)
77
+ if (!cmd) return
78
+ let aborted = false
79
+ const req = apiCall<T>(cmd, params)
80
+ const wholePromise = req.then(x => aborted || setRet(x), x => aborted || setErr(x))
81
+ .finally(()=> loadingRef.current = undefined)
82
+ loadingRef.current = Object.assign(wholePromise, {
83
+ abort() {
84
+ aborted = true
85
+ req.abort()
86
+ }
87
+ })
88
+ reloadingRef.current?.resolve(wholePromise)
89
+ }, [cmd, JSON.stringify(params), forcer]) //eslint-disable-line -- json-ize to detect deep changes
90
+ const reload = useCallback(() => loadingRef.current
91
+ || setForcer(v => v+1) || (reloadingRef.current = pendingPromise()),
92
+ [setForcer])
93
+ return [ret, err, reload]
94
+}
95
+
96
+type EventHandler = (type:string, data?:any) => void
97
+
98
+export function apiEvents(cmd: string, params: Dict, cb:EventHandler) {
99
+ console.debug('API EVENTS', cmd, params)
100
+ const processed: Record<string,string> = {}
101
+ for (const k in params) {
102
+ const v = params[k]
103
+ if (v !== undefined)
104
+ processed[k] = JSON.stringify(v)
105
+ }
106
+ const csrf = getCsrf()
107
+ if (csrf)
108
+ processed.csrf = JSON.stringify(csrf)
109
+ const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(processed))
110
+ source.onopen = () => cb('connected')
111
+ source.onerror = err => cb('error', err)
112
+ source.onmessage = ({ data }) => {
113
+ if (!data) {
114
+ cb('closed')
115
+ return source.close()
116
+ }
117
+ try { data = JSON.parse(data) }
118
+ catch {
119
+ return cb('string', data)
120
+ }
121
+ console.debug('SSE msg', data)
122
+ cb('msg', data)
123
+ }
124
+ return source
125
+}
126
+
127
+function getCsrf() {
128
+ return getCookie('csrf')
129
+}
130
+
131
+export function useApiEvents(cmd: string, params: Dict={}) {
132
+ const [data, setData] = useStateMounted<any>(undefined)
133
+ const [error, setError] = useStateMounted<any>(undefined)
134
+ const [loading, setLoading] = useStateMounted(false)
135
+ useEffect(() => {
136
+ const src = apiEvents(cmd, params, (type, data) => {
137
+ switch (type) {
138
+ case 'error':
139
+ setError("Connection error")
140
+ return stop()
141
+ case 'closed':
142
+ return stop()
143
+ case 'msg':
144
+ if (src?.readyState === src?.CLOSED)
145
+ return stop()
146
+ return setData(data)
147
+ }
148
+ })
149
+ return () => {
150
+ src.close()
151
+ stop()
152
+ }
153
+
154
+ function stop() {
155
+ setLoading(false)
156
+ }
157
+ }, [cmd, JSON.stringify(params)]) //eslint-disable-line
158
+ return { data, loading, error }
159
+}
160
+
161
+export async function getNotification(channel: string, cb: (name: string, data:any) => void): Promise<EventSource> {
162
+ return new Promise(resolve => {
163
+ const ret = apiEvents('get_notifications', { channel }, (type, entries) => {
164
+ if (type === 'connected')
165
+ return resolve(ret)
166
+ if (type !== 'msg') return
167
+ for (const { name, data } of entries)
168
+ if (name)
169
+ cb(name, data)
170
+ })
171
+ })
172
+}
shared/index.ts
+8
@@ -156,3 +156,11 @@ export function pendingPromise<T>() {
156
export function isMobile() {
157
return window.innerWidth < 800
158
}
159
+
160
+export function getHFS() {
161
+ return (window as any).HFS
162
+}
163
+
164
+export function getPrefixUrl() {
165
+ return getHFS().prefixUrl
166
+}