Skip to content

Commit 51abf98

Browse files
wip: chore: refactor out axios
1 parent 1818c5d commit 51abf98

File tree

14 files changed

+349
-493
lines changed

14 files changed

+349
-493
lines changed

ui/package.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
"@uiw/codemirror-theme-material": "^4.24.2",
1616
"@uiw/react-codemirror": "^4.24.2",
1717
"@vitejs/plugin-react": "^5.0.0",
18-
"axios": "^1.11.0",
1918
"detect-browser": "^5.3.0",
2019
"fractional-indexing": "^3.2.0",
2120
"mobx": "^6.13.7",
@@ -60,8 +59,7 @@
6059
"rimraf": "^6.0.1",
6160
"tree-kill": "^1.2.0",
6261
"typescript": "^5.9.2",
63-
"typescript-eslint": "^8.38.0",
64-
"wait-on": "^9.0.0"
62+
"typescript-eslint": "^8.38.0"
6563
},
6664
"browserslist": {
6765
"production": [
@@ -75,4 +73,4 @@
7573
"last 1 safari version"
7674
]
7775
}
78-
}
76+
}

ui/src/CurrentUser.ts

Lines changed: 116 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import axios, {AxiosError, AxiosResponse} from 'axios';
21
import * as config from './config';
3-
import {detect} from 'detect-browser';
4-
import {SnackReporter} from './snack/SnackManager';
5-
import {observable, runInAction, action} from 'mobx';
6-
import {IClient, IUser} from './types';
2+
import { detect } from 'detect-browser';
3+
import { SnackReporter } from './snack/SnackManager';
4+
import { observable, runInAction, action } from 'mobx';
5+
import { IClient, IUser } from './types';
6+
import { jsonBody, jsonTransform, ResponseTransformer } from './apiAuth';
77

88
const tokenKey = 'gotify-login-key';
99

@@ -14,10 +14,10 @@ export class CurrentUser {
1414
@observable accessor loggedIn = false;
1515
@observable accessor refreshKey = 0;
1616
@observable accessor authenticating = true;
17-
@observable accessor user: IUser = {name: 'unknown', admin: false, id: -1};
17+
@observable accessor user: IUser = { name: 'unknown', admin: false, id: -1 };
1818
@observable accessor connectionErrorMessage: string | null = null;
1919

20-
public constructor(private readonly snack: SnackReporter) {}
20+
public constructor(private readonly snack: SnackReporter) { }
2121

2222
public token = (): string => {
2323
if (this.tokenCache !== null) {
@@ -33,32 +33,74 @@ export class CurrentUser {
3333
return '';
3434
};
3535

36+
public authenticatedFetch = async <T>(
37+
url: string,
38+
init: RequestInit,
39+
xform: ResponseTransformer<T>
40+
): Promise<T> => {
41+
const headers = new Headers(init?.headers);
42+
if (this.loggedIn && !headers.has('X-Gotify-Key')) headers.set('X-Gotify-Key', this.token());
43+
let response;
44+
try {
45+
response = await fetch(url, { ...init, headers });
46+
} catch (error) {
47+
this.snack('Gotify server is not reachable, try refreshing the page.');
48+
throw error;
49+
}
50+
if (response.ok) {
51+
try {
52+
return xform(response);
53+
} catch (error) {
54+
this.snack('Response transformation failed: ' + error);
55+
throw error;
56+
}
57+
}
58+
if (response.status === 401) {
59+
this.tryAuthenticate().then(() => this.snack('Could not complete request.'));
60+
}
61+
62+
let error = 'Unexpected status code: ' + response.status;
63+
if (response.status === 400 || response.status === 403 || response.status === 500) {
64+
if (response.headers.get('content-type')?.includes('application/json')) {
65+
const data = await response.json();
66+
error = data.error + ': ' + data.errorDescription;
67+
} else {
68+
const text = await response.text();
69+
error = 'Unexpected response: ' + text;
70+
}
71+
}
72+
this.snack(error);
73+
throw new Error(error);
74+
};
75+
3676
private readonly setToken = (token: string) => {
3777
this.tokenCache = token;
3878
window.localStorage.setItem(tokenKey, token);
3979
};
4080

41-
public register = async (name: string, pass: string): Promise<boolean> =>
42-
axios
43-
.create()
44-
.post(config.get('url') + 'user', {name, pass})
81+
public register = async (name: string, pass: string): Promise<boolean> => {
82+
runInAction(() => {
83+
this.loggedIn = false;
84+
});
85+
return this.authenticatedFetch(
86+
config.get('url') + 'user',
87+
jsonBody({ name, pass }),
88+
jsonTransform
89+
)
4590
.then(() => {
4691
this.snack('User Created. Logging in...');
4792
this.login(name, pass);
4893
return true;
4994
})
50-
.catch((error: AxiosError<{error?: string; errorDescription?: string}>) => {
51-
if (!error || !error.response) {
95+
.catch((error) => {
96+
if (error instanceof TypeError) {
5297
this.snack('No network connection or server unavailable.');
5398
return false;
5499
}
55-
const {data} = error.response;
56-
57-
this.snack(
58-
`Register failed: ${data?.error ?? 'unknown'}: ${data?.errorDescription ?? ''}`
59-
);
100+
this.snack(`Register failed: ${error?.message ?? error}`);
60101
return false;
61102
});
103+
};
62104

63105
public login = async (username: string, password: string) => {
64106
runInAction(() => {
@@ -67,17 +109,17 @@ export class CurrentUser {
67109
});
68110
const browser = detect();
69111
const name = (browser && browser.name + ' ' + browser.version) || 'unknown browser';
70-
axios
71-
.create()
72-
.request({
73-
url: config.get('url') + 'client',
74-
method: 'POST',
75-
data: {name},
76-
headers: {Authorization: 'Basic ' + btoa(username + ':' + password)},
77-
})
78-
.then((resp: AxiosResponse<IClient>) => {
112+
const fetchInit = jsonBody({ name });
113+
fetchInit.headers = new Headers(fetchInit.headers);
114+
fetchInit.headers.set('Authorization', 'Basic ' + btoa(username + ':' + password));
115+
return this.authenticatedFetch(
116+
config.get('url') + 'client',
117+
fetchInit,
118+
jsonTransform<IClient>
119+
)
120+
.then((resp) => {
79121
this.snack(`A client named '${name}' was created for your session.`);
80-
this.setToken(resp.data.token);
122+
this.setToken(resp.token);
81123
this.tryAuthenticate().catch(() => {
82124
console.log(
83125
'create client succeeded, but authenticated with given token failed'
@@ -92,59 +134,58 @@ export class CurrentUser {
92134
);
93135
};
94136

95-
public tryAuthenticate = async (): Promise<AxiosResponse<IUser>> => {
137+
public tryAuthenticate = async (): Promise<IUser> => {
96138
if (this.token() === '') {
97139
runInAction(() => {
98140
this.authenticating = false;
99141
});
100142
return Promise.reject();
101143
}
102144

103-
return axios
104-
.create()
105-
.get(config.get('url') + 'current/user', {headers: {'X-Gotify-Key': this.token()}})
106-
.then(
107-
action((passThrough) => {
108-
this.user = passThrough.data;
109-
this.loggedIn = true;
110-
this.authenticating = false;
111-
this.connectionErrorMessage = null;
112-
this.reconnectTime = 7500;
113-
return passThrough;
114-
})
115-
)
145+
return fetch(config.get('url') + 'current/user', { headers: { 'X-Gotify-Key': this.token() } })
146+
.then(async (response) => {
147+
if (response.ok) {
148+
const user = await response.json();
149+
runInAction(() => {
150+
this.user = user;
151+
this.loggedIn = true;
152+
this.authenticating = false;
153+
this.connectionErrorMessage = null;
154+
this.reconnectTime = 7500;
155+
});
156+
return user;
157+
}
158+
if (response.status >= 500) {
159+
this.connectionError(`${response.statusText} (code: ${response.status}).`);
160+
return Promise.reject(new Error('Server error'));
161+
}
162+
163+
this.connectionErrorMessage = null;
164+
165+
if (response.status >= 400 && response.status < 500) {
166+
this.logout();
167+
}
168+
throw new Error('Unexpected status code: ' + response.status);
169+
})
116170
.catch(
117-
action((error: AxiosError) => {
171+
action((error) => {
118172
this.authenticating = false;
119-
if (!error || !error.response) {
120-
this.connectionError('No network connection or server unavailable.');
121-
return Promise.reject(error);
122-
}
123-
124-
if (error.response.status >= 500) {
125-
this.connectionError(
126-
`${error.response.statusText} (code: ${error.response.status}).`
127-
);
128-
return Promise.reject(error);
129-
}
130-
131-
this.connectionErrorMessage = null;
132-
133-
if (error.response.status >= 400 && error.response.status < 500) {
134-
this.logout();
135-
}
173+
this.connectionError('No network connection or server unavailable.');
136174
return Promise.reject(error);
137175
})
138176
);
139177
};
140178

141179
public logout = async () => {
142-
await axios
143-
.get(config.get('url') + 'client')
144-
.then((resp: AxiosResponse<IClient[]>) => {
145-
resp.data
146-
.filter((client) => client.token === this.tokenCache)
147-
.forEach((client) => axios.delete(config.get('url') + 'client/' + client.id));
180+
await this.authenticatedFetch(config.get('url') + 'client', {}, jsonTransform<IClient[]>)
181+
.then((resp) => {
182+
resp.filter((client) => client.token === this.tokenCache).forEach((client) =>
183+
this.authenticatedFetch(
184+
config.get('url') + 'client/' + client.id,
185+
{},
186+
jsonTransform
187+
)
188+
);
148189
})
149190
.catch(() => Promise.resolve());
150191
window.localStorage.removeItem(tokenKey);
@@ -155,9 +196,15 @@ export class CurrentUser {
155196
};
156197

157198
public changePassword = (pass: string) => {
158-
axios
159-
.post(config.get('url') + 'current/user/password', {pass})
160-
.then(() => this.snack('Password changed'));
199+
this.authenticatedFetch(
200+
config.get('url') + 'current/user/password',
201+
jsonBody({ pass }),
202+
jsonTransform
203+
)
204+
.then(() => this.snack('Password changed'))
205+
.catch((error) => {
206+
this.snack(`Change password failed: ${error?.message ?? error}`);
207+
});
161208
};
162209

163210
public tryReconnect = (quiet = false) => {

ui/src/apiAuth.ts

Lines changed: 15 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,19 @@
1-
import axios from 'axios';
2-
import {CurrentUser} from './CurrentUser';
3-
import {SnackReporter} from './snack/SnackManager';
1+
export type ResponseTransformer<T> = (response: Response) => Promise<T>;
42

5-
export const initAxios = (currentUser: CurrentUser, snack: SnackReporter) => {
6-
axios.interceptors.request.use((config) => {
7-
if (!config.headers.has('x-gotify-key')) {
8-
config.headers['x-gotify-key'] = currentUser.token();
9-
}
10-
return config;
11-
});
3+
export const identityTransform: ResponseTransformer<Response> = Promise.resolve;
124

13-
axios.interceptors.response.use(undefined, (error) => {
14-
if (!error.response) {
15-
snack('Gotify server is not reachable, try refreshing the page.');
16-
return Promise.reject(error);
17-
}
5+
export const jsonTransform = <T>(response: Response): Promise<T> => response.json();
186

19-
const status = error.response.status;
7+
export const jsonBody: (body: any) => RequestInit = (body: any) => ({
8+
method: 'POST',
9+
headers: {
10+
'Content-Type': 'application/json',
11+
},
12+
body: JSON.stringify(body),
13+
});
2014

21-
if (status === 401) {
22-
currentUser.tryAuthenticate().then(() => snack('Could not complete request.'));
23-
}
24-
25-
if (status === 400 || status === 403 || status === 500) {
26-
snack(error.response.data.error + ': ' + error.response.data.errorDescription);
27-
}
28-
29-
return Promise.reject(error);
30-
});
31-
};
15+
export const multipartBody: (body: FormData) => RequestInit = (body: FormData) => ({
16+
method: 'POST',
17+
headers: { 'content-type': 'multipart/form-data' },
18+
body,
19+
});

0 commit comments

Comments
 (0)