Merge branch '21128-toolbar-context-menu'
[arvados-workbench2.git] / src / services / common-service / common-service.ts
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import { camelCase, isPlainObject, isArray, snakeCase } from "lodash";
6 import { AxiosInstance, AxiosPromise, AxiosRequestConfig } from "axios";
7 import uuid from "uuid/v4";
8 import { ApiActions } from "services/api/api-actions";
9 import QueryString from "query-string";
10 import { Session } from "models/session";
11
12 interface Errors {
13     status: number;
14     errors: string[];
15     errorToken: string;
16 }
17
18 export interface ListArguments {
19     limit?: number;
20     offset?: number;
21     filters?: string;
22     order?: string;
23     select?: string[];
24     distinct?: boolean;
25     count?: string;
26     includeOldVersions?: boolean;
27 }
28
29 export interface ListResults<T> {
30     clusterId?: string;
31     kind: string;
32     offset: number;
33     limit: number;
34     items: T[];
35     itemsAvailable: number;
36 }
37
38 export class CommonService<T> {
39     protected serverApi: AxiosInstance;
40     protected resourceType: string;
41     protected actions: ApiActions;
42     protected readOnlyFields: string[];
43
44     constructor(serverApi: AxiosInstance, resourceType: string, actions: ApiActions, readOnlyFields: string[] = []) {
45         this.serverApi = serverApi;
46         this.resourceType = resourceType;
47         this.actions = actions;
48         this.readOnlyFields = readOnlyFields;
49     }
50
51     static mapResponseKeys = (response: { data: any }) =>
52         CommonService.mapKeys(camelCase)(response.data)
53
54     static mapKeys = (mapFn: (key: string) => string) =>
55         (value: any): any => {
56             switch (true) {
57                 case isPlainObject(value):
58                     return Object
59                         .keys(value)
60                         .map(key => [key, mapFn(key)])
61                         .reduce((newValue, [key, newKey]) => ({
62                             ...newValue,
63                             [newKey]: (key === 'items') ? CommonService.mapKeys(mapFn)(value[key]) : value[key]
64                         }), {});
65                 case isArray(value):
66                     return value.map(CommonService.mapKeys(mapFn));
67                 default:
68                     return value;
69             }
70         }
71
72     protected validateUuid(uuid: string) {
73         if (uuid === "") {
74             throw new Error('UUID cannot be empty string');
75         }
76     }
77
78     static defaultResponse<R>(promise: AxiosPromise<R>, actions: ApiActions, mapKeys = true, showErrors = true): Promise<R> {
79         const reqId = uuid();
80         actions.progressFn(reqId, true);
81         return promise
82             .then(data => {
83                 actions.progressFn(reqId, false);
84                 return data;
85             })
86             .then((response: { data: any }) => {
87                 return mapKeys ? CommonService.mapResponseKeys(response) : response.data;
88             })
89             .catch(({ response }) => {
90                 if (response) {
91                     actions.progressFn(reqId, false);
92                     const errors = CommonService.mapResponseKeys(response) as Errors;
93                     errors.status = response.status;
94                     actions.errorFn(reqId, errors, showErrors);
95                     throw errors;
96                 }
97             });
98     }
99
100     create(data?: Partial<T>, showErrors?: boolean) {
101         return CommonService.defaultResponse(
102             this.serverApi
103                 .post<T>(`/${this.resourceType}`, data && CommonService.mapKeys(snakeCase)(data)),
104             this.actions,
105             true, // mapKeys
106             showErrors
107         );
108     }
109
110     delete(uuid: string, showErrors?: boolean): Promise<T> {
111         this.validateUuid(uuid);
112         return CommonService.defaultResponse(
113             this.serverApi
114                 .delete(`/${this.resourceType}/${uuid}`),
115             this.actions,
116             true, // mapKeys
117             showErrors
118         );
119     }
120
121     get(uuid: string, showErrors?: boolean, select?: string[], session?: Session) {
122         this.validateUuid(uuid);
123
124         const cfg: AxiosRequestConfig = {
125             params: {
126                 select: select
127                     ? `[${select.map(snakeCase).map(s => `"${s}"`).join(',')}]`
128                     : undefined
129             }
130         };
131         if (session) {
132             cfg.baseURL = session.baseUrl;
133             cfg.headers = { 'Authorization': 'Bearer ' + session.token };
134         }
135
136         return CommonService.defaultResponse(
137             this.serverApi
138                 .get<T>(`/${this.resourceType}/${uuid}`, cfg),
139             this.actions,
140             true, // mapKeys
141             showErrors
142         );
143     }
144
145     list(args: ListArguments = {}, showErrors?: boolean): Promise<ListResults<T>> {
146         const { filters, select, ...other } = args;
147         const params = {
148             ...CommonService.mapKeys(snakeCase)(other),
149             filters: filters ? `[${filters}]` : undefined,
150             select: select
151                 ? `[${select.map(snakeCase).map(s => `"${s}"`).join(', ')}]`
152                 : undefined
153         };
154
155         if (QueryString.stringify(params).length <= 1500) {
156             return CommonService.defaultResponse(
157                 this.serverApi.get(`/${this.resourceType}`, { params }),
158                 this.actions,
159                 true,
160                 showErrors
161             );
162         } else {
163             // Using the POST special case to avoid URI length 414 errors.
164             // We must use urlencoded post body since api doesn't support form data
165             // const formData = new FormData();
166             const formData = new URLSearchParams();
167             formData.append("_method", "GET");
168             Object.keys(params).forEach(key => {
169                 if (params[key] !== undefined) {
170                     formData.append(key, params[key]);
171                 }
172             });
173             return CommonService.defaultResponse(
174                 this.serverApi.post(`/${this.resourceType}`, formData, {}),
175                 this.actions,
176                 true,
177                 showErrors
178             );
179         }
180     }
181
182     update(uuid: string, data: Partial<T>, showErrors?: boolean) {
183         this.validateUuid(uuid);
184         return CommonService.defaultResponse(
185             this.serverApi
186                 .put<T>(`/${this.resourceType}/${uuid}`, data && CommonService.mapKeys(snakeCase)(data)),
187             this.actions,
188             undefined, // mapKeys
189             showErrors
190         );
191     }
192 }