001/*
002 *   Copyright 2024 Vonage
003 *
004 *   Licensed under the Apache License, Version 2.0 (the "License");
005 *   you may not use this file except in compliance with the License.
006 *   You may obtain a copy of the License at
007 *
008 *        http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *   Unless required by applicable law or agreed to in writing, software
011 *   distributed under the License is distributed on an "AS IS" BASIS,
012 *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *   See the License for the specific language governing permissions and
014 *   limitations under the License.
015 */
016package com.vonage.client.meetings;
017
018import com.vonage.client.*;
019import com.vonage.client.auth.JWTAuthMethod;
020import com.vonage.client.common.HalPageResponse;
021import com.vonage.client.common.HttpMethod;
022import org.apache.http.HttpEntity;
023import org.apache.http.HttpResponse;
024import org.apache.http.StatusLine;
025import org.apache.http.client.HttpClient;
026import org.apache.http.client.methods.RequestBuilder;
027import org.apache.http.client.utils.URLEncodedUtils;
028import org.apache.http.entity.mime.MultipartEntityBuilder;
029import java.io.IOException;
030import java.net.URI;
031import java.nio.charset.Charset;
032import java.nio.file.Path;
033import java.util.*;
034import java.util.function.Function;
035
036/**
037 * Meetings API client.
038 *
039 * @deprecated Support for this API will be removed in the next major release.
040 */
041@Deprecated
042public class MeetingsClient {
043        HttpClient httpClient;
044
045        final RestEndpoint<ListRoomsRequest, ListRoomsResponse> listRooms, searchThemeRooms;
046        final RestEndpoint<UUID, MeetingRoom> getRoom;
047        final RestEndpoint<MeetingRoom, MeetingRoom> createRoom;
048        final RestEndpoint<UpdateRoomRequest, MeetingRoom> updateRoom;
049        final RestEndpoint<Void, ListThemesResponse> listThemes;
050        final RestEndpoint<UUID, Theme> getTheme;
051        final RestEndpoint<Theme, Theme> createTheme, updateTheme;
052        final RestEndpoint<DeleteThemeRequest, Void> deleteTheme;
053        final RestEndpoint<String, ListRecordingsResponse> listRecordings;
054        final RestEndpoint<UUID, Recording> getRecording;
055        final RestEndpoint<UUID, Void> deleteRecording;
056        final RestEndpoint<Void, ListDialNumbersResponse> listDialNumbers;
057        final RestEndpoint<UpdateApplicationRequest, Application> updateApplication;
058        final RestEndpoint<FinalizeLogosRequest, Void> finalizeLogos;
059        final RestEndpoint<Void, GetLogoUploadUrlsResponse> getLogoUploadUrls;
060
061        /**
062         * Constructor.
063         *
064         * @param wrapper (REQUIRED) shared HTTP wrapper object used for making REST calls.
065         */
066        public MeetingsClient(HttpWrapper wrapper) {
067                super();
068                httpClient = wrapper.getHttpClient();
069
070                @SuppressWarnings("unchecked")
071                class Endpoint<T, R> extends DynamicEndpoint<T, R> {
072                        Endpoint(Function<T, String> pathGetter, HttpMethod method, R... type) {
073                                super(DynamicEndpoint.<T, R> builder(type)
074                                        .authMethod(JWTAuthMethod.class).requestMethod(method)
075                                        .responseExceptionType(MeetingsResponseException.class)
076                                        .wrapper(wrapper).pathGetter((de, req) -> {
077                                                String base = de.getHttpWrapper().getHttpConfig().getApiEuBaseUri();
078                                                return base + "/v1/meetings/" + pathGetter.apply(req);
079                                        })
080                                );
081                        }
082                }
083
084                listRooms = new Endpoint<>(req -> "rooms", HttpMethod.GET);
085                getRoom = new Endpoint<>(roomId -> "rooms/" + roomId, HttpMethod.GET);
086                createRoom = new Endpoint<>(req -> "rooms", HttpMethod.POST);
087                updateRoom = new Endpoint<>(req -> "rooms/" + req.roomId, HttpMethod.PATCH);
088                searchThemeRooms = new Endpoint<>(req -> "themes/" + req.themeId + "/rooms", HttpMethod.GET);
089                listThemes = new Endpoint<>(req -> "themes", HttpMethod.GET);
090                getTheme = new Endpoint<>(themeId -> "themes/" + themeId, HttpMethod.GET);
091                createTheme = new Endpoint<>(req -> "themes", HttpMethod.POST);
092                updateTheme = new Endpoint<>(theme -> "themes/" + theme.getThemeId(), HttpMethod.PATCH);
093                deleteTheme = new Endpoint<>(req -> "themes/" + req.themeId, HttpMethod.DELETE);
094                listRecordings = new Endpoint<>(sid -> "sessions/" + sid + "/recordings", HttpMethod.GET);
095                getRecording = new Endpoint<>(rid -> "recordings/" + rid, HttpMethod.GET);
096                deleteRecording = new Endpoint<>(rid -> "recordings/" + rid, HttpMethod.DELETE);
097                listDialNumbers = new Endpoint<>(req -> "dial-in-numbers", HttpMethod.GET);
098                updateApplication = new Endpoint<>(req -> "applications", HttpMethod.PATCH);
099                finalizeLogos = new Endpoint<>(req -> "themes/" + req.themeId + "/finalizeLogos", HttpMethod.PUT);
100                getLogoUploadUrls = new Endpoint<>(req -> "themes/logos-upload-urls", HttpMethod.GET);
101        }
102
103        static UUID validateThemeId(UUID themeId) {
104                return Objects.requireNonNull(themeId,  "Theme ID is required.");
105        }
106
107        static UUID validateRoomId(UUID roomId) {
108                return Objects.requireNonNull(roomId,  "Room ID is required.");
109        }
110
111        static UUID validateRecordingId(UUID recordingId) {
112                return Objects.requireNonNull(recordingId,  "Recording ID is required.");
113        }
114
115        static String validateSessionId(String sessionId) {
116                if (sessionId == null || sessionId.trim().isEmpty()) {
117                        throw new IllegalArgumentException("Session ID cannot be null or empty.");
118                }
119                return sessionId;
120        }
121
122        private int parseNextFromHalResponse(HalPageResponse response) {
123                final URI nextUrl = response.getLinks().getNextUrl();
124                return URLEncodedUtils.parse(nextUrl, Charset.defaultCharset())
125                                .stream().filter(nvp -> "start_id".equals(nvp.getName()))
126                                .findFirst().map(nvp -> Integer.parseInt(nvp.getValue()))
127                                .orElseThrow(() -> new VonageClientException("Couldn't navigate to next page: "+nextUrl));
128
129        }
130
131        private List<MeetingRoom> getAllRoomsFromResponseRecursively(
132                        RestEndpoint<ListRoomsRequest, ListRoomsResponse> endpoint, ListRoomsRequest initialRequest) {
133
134                final int initialPageSize = initialRequest.pageSize != null ? initialRequest.pageSize : 1000;
135                ListRoomsRequest request = new ListRoomsRequest(
136                                initialRequest.startId, initialRequest.endId, initialPageSize, initialRequest.themeId
137                );
138                ListRoomsResponse response = endpoint.execute(request);
139
140                if (response.getTotalItems() <= response.getPageSize()) {
141                        return response.getMeetingRooms();
142                }
143                else {
144                        List<MeetingRoom> rooms = new ArrayList<>(response.getMeetingRooms());
145                        do {
146                                request = new ListRoomsRequest(
147                                                parseNextFromHalResponse(response), null, initialPageSize, request.themeId
148                                );
149                                response = endpoint.execute(request);
150                                rooms.addAll(response.getMeetingRooms());
151                        }
152                        while (response.getPageSize() >= initialPageSize);
153                        return rooms;
154                }
155        }
156
157        /**
158         * Get all listed rooms in the application.
159         *
160         * @return The list of all meeting rooms.
161         *
162         * @throws MeetingsResponseException If there is an error encountered when processing the request.
163         */
164        public List<MeetingRoom> listRooms() {
165                return getAllRoomsFromResponseRecursively(listRooms,
166                                new ListRoomsRequest(null, null, null, null)
167                );
168        }
169
170        /**
171         * Get details of an existing room.
172         *
173         * @param roomId ID of the room to retrieve.
174         *
175         * @return The meeting room associated with the ID.
176         *
177         * @throws MeetingsResponseException If there is an error encountered when processing the request.
178         */
179        public MeetingRoom getRoom(UUID roomId) {
180                return getRoom.execute(validateRoomId(roomId));
181        }
182
183        /**
184         * Create a new room.
185         *
186         * @param room Properties of the meeting room.
187         *
188         * @return Details of the created meeting room.
189         *
190         * @throws MeetingsResponseException If there is an error encountered when processing the request.
191         */
192        public MeetingRoom createRoom(MeetingRoom room) {
193                return createRoom.execute(Objects.requireNonNull(room, "Meeting room is required."));
194        }
195
196        /**
197         * Update an existing room.
198         *
199         * @param roomId ID of the meeting room to be updated.
200         * @param roomUpdate Properties of the meeting room to change.
201         *
202         * @return Details of the updated meeting room.
203         *
204         * @throws MeetingsResponseException If there is an error encountered when processing the request.
205         */
206        public MeetingRoom updateRoom(UUID roomId, UpdateRoomRequest roomUpdate) {
207                Objects.requireNonNull(roomUpdate, "Room update request properties is required.");
208                roomUpdate.roomId = validateRoomId(roomId);
209                return updateRoom.execute(roomUpdate);
210        }
211
212        /**
213         * Get rooms that are associated with a theme ID.
214         *
215         * @param themeId The theme ID to filter by.
216         *
217         * @return The list of rooms which use the theme.
218         *
219         * @throws MeetingsResponseException If there is an error encountered when processing the request.
220         */
221        public List<MeetingRoom> searchRoomsByTheme(UUID themeId) {
222                return getAllRoomsFromResponseRecursively(searchThemeRooms,
223                                new ListRoomsRequest(null, null, null, validateThemeId(themeId))
224                );
225        }
226
227        /**
228         * Get all application themes.
229         *
230         * @return The list of themes.
231         *
232         * @throws MeetingsResponseException If there is an error encountered when processing the request.
233         */
234        public List<Theme> listThemes() {
235                return listThemes.execute(null);
236        }
237
238        /**
239         * Retrieve details of a theme by ID.
240         *
241         * @param themeId The theme ID.
242         *
243         * @return The theme associated with the ID.
244         *
245         * @throws MeetingsResponseException If there is an error encountered when processing the request.
246         */
247        public Theme getTheme(UUID themeId) {
248                return getTheme.execute(validateThemeId(themeId));
249        }
250
251        /**
252         * Create a new theme.
253         *
254         * @param theme The partial theme properties.
255         *
256         * @return The full created theme details.
257         *
258         * @throws MeetingsResponseException If there is an error encountered when processing the request.
259         */
260        public Theme createTheme(Theme theme) {
261                Objects.requireNonNull(theme, "Theme creation properties are required.");
262                Objects.requireNonNull(theme.getBrandText(), "Brand text is required.");
263                Objects.requireNonNull(theme.getMainColor(), "Main color is required.");
264                return createTheme.execute(theme);
265        }
266
267        /**
268         * Update an existing theme.
269         *
270         * @param themeId ID of the theme to update.
271         * @param theme The partial theme properties to update.
272         *
273         * @return The fully updated theme details.
274         *
275         * @throws MeetingsResponseException If there is an error encountered when processing the request.
276         */
277        public Theme updateTheme(UUID themeId, Theme theme) {
278                Objects.requireNonNull(theme, "Theme update properties are required.");
279                theme.setThemeIdAndFlagUpdate(validateThemeId(themeId));
280                return updateTheme.execute(theme);
281        }
282
283        /**
284         * Delete a theme by its ID.
285         *
286         * @param themeId ID of the theme to delete.
287         * @param force Whether to delete the theme even if theme is used by rooms or as application default theme.
288         *
289         * @throws MeetingsResponseException If there is an error encountered when processing the request.
290         */
291        public void deleteTheme(UUID themeId, boolean force) {
292                deleteTheme.execute(new DeleteThemeRequest(validateThemeId(themeId), force));
293        }
294
295        /**
296         * Get recordings of a meeting session.
297         *
298         * @param sessionId The session ID to filter recordings by.
299         *
300         * @return The list of recordings for the session.
301         *
302         * @throws MeetingsResponseException If there is an error encountered when processing the request.
303         */
304        public List<Recording> listRecordings(String sessionId) {
305                ListRecordingsResponse response = listRecordings.execute(validateSessionId(sessionId));
306                List<Recording> recordings = response.getRecordings();
307                return recordings != null ? recordings : Collections.emptyList();
308        }
309
310        /**
311         * Get details of a recording.
312         *
313         * @param recordingId ID of the recording to retrieve.
314         *
315         * @return The recording properties.
316         *
317         * @throws MeetingsResponseException If there is an error encountered when processing the request.
318         */
319        public Recording getRecording(UUID recordingId) {
320                return getRecording.execute(validateRecordingId(recordingId));
321        }
322
323        /**
324         * Delete a recording.
325         *
326         * @param recordingId ID of the recording to delete.
327         *
328         * @throws MeetingsResponseException If there is an error encountered when processing the request.
329         */
330        public void deleteRecording(UUID recordingId) {
331                deleteRecording.execute(validateRecordingId(recordingId));
332        }
333
334        /**
335         * Get numbers that can be used to dial into a meeting.
336         *
337         * @return The list of dial-in numbers, along with their country code.
338         *
339         * @throws MeetingsResponseException If there is an error encountered when processing the request.
340         */
341        public List<DialInNumber> listDialNumbers() {
342                return listDialNumbers.execute(null);
343        }
344
345        /**
346         * Update an existing application.
347         *
348         * @param updateRequest Properties of the application to update.
349         *
350         * @return The updated application details.
351         *
352         * @throws MeetingsResponseException If there is an error encountered when processing the request.
353         */
354        public Application updateApplication(UpdateApplicationRequest updateRequest) {
355                return updateApplication.execute(Objects.requireNonNull(
356                                updateRequest, "Application update properties are required.")
357                );
358        }
359
360        /**
361         * Change logos to be permanent for a given theme.
362         *
363         * @param themeId The theme ID containing the logos.
364         * @param keys List of temporary theme's logo keys to make permanent
365         */
366        void finalizeLogos(UUID themeId, List<String> keys) {
367                if (keys == null || keys.isEmpty()) {
368                        throw new IllegalArgumentException("Logo keys are required.");
369                }
370                finalizeLogos.execute(new FinalizeLogosRequest(validateThemeId(themeId), keys));
371        }
372
373        /**
374         * Get URLs that can be used to upload logos for a theme via a POST.
375         *
376         * @return List of URLs and respective credentials / tokens needed for uploading logos to them.
377         */
378        List<LogoUploadsUrlResponse> listLogoUploadUrls() {
379                return getLogoUploadUrls.execute(null);
380        }
381
382        /**
383         * Finds the appropriate response object from {@linkplain #listLogoUploadUrls()} for the given logo type.
384         *
385         * @param logoType The logo type to get details for.
386         * @return The URL and credential fields for uploading the logo.
387         */
388        LogoUploadsUrlResponse getUploadDetailsForLogoType(LogoType logoType) {
389                return listLogoUploadUrls().stream()
390                                .filter(r -> logoType.equals(r.getFields().getLogoType()))
391                                .findFirst()
392                                .orElseThrow(() -> new IllegalArgumentException("Logo type "+logoType+" is unavailable."));
393        }
394
395        /**
396         * Uploads a logo to the cloud so that it can be used in themes.
397         *
398         * @param logoFile Absolute path to the image.
399         * @param details Credentials, logo key and URL to facilitate the upload request.
400         */
401        void uploadLogo(Path logoFile, LogoUploadsUrlResponse details) {
402                try {
403                        LogoUploadsUrlResponse.Fields fields = details.getFields();
404                        HttpEntity entity = MultipartEntityBuilder.create()
405                                        .addTextBody("Content-Type", fields.getContentType())
406                                        .addTextBody("key", fields.getKey())
407                                        .addTextBody("logoType", fields.getLogoType().toString())
408                                        .addTextBody("bucket", fields.getBucket())
409                                        .addTextBody("X-Amz-Algorithm", fields.getAmzAlgorithm())
410                                        .addTextBody("X-Amz-Credential", fields.getAmzCredential())
411                                        .addTextBody("X-Amz-Date", fields.getAmzDate())
412                                        .addTextBody("X-Amz-Security-Token", fields.getAmzSecurityToken())
413                                        .addTextBody("Policy", fields.getPolicy())
414                                        .addTextBody("X-Amz-Signature", fields.getAmzSignature())
415                                        .addBinaryBody("file", logoFile.toFile())
416                                        .build();
417                        HttpResponse response = httpClient.execute(
418                                        RequestBuilder.post(details.getUrl()).setEntity(entity).build()
419                        );
420                        StatusLine status = response.getStatusLine();
421                        int statusCode = status.getStatusCode();
422                        if (statusCode != 204) {
423                                MeetingsResponseException mrx = new MeetingsResponseException(
424                                                "Logo upload failed ("+statusCode+"): "+status.getReasonPhrase()
425                                );
426                                mrx.setStatusCode(statusCode);
427                                throw mrx;
428                        }
429                }
430                catch (IOException ex) {
431                        throw new VonageUnexpectedException(ex);
432                }
433        }
434
435        /**
436         * Upload a logo image and associates it with a theme.
437         *
438         * @param themeId ID of the theme which the logo will be associated with.
439         * @param logoType The logo type to upload.
440         * @param pngFile Absolute path to the logo image. For restrictions, refer to
441         * <a href=https://developer.vonage.com/en/meetings/code-snippets/theme-management#uploading-icons-and-logos>
442         * the documentation</a>. Generally, the image must be a PNG under 1MB, square, under 300x300 pixels and
443         * have a transparent background.
444         *
445         * @throws MeetingsResponseException If there is an error encountered when processing the request.
446         */
447        public void updateThemeLogo(UUID themeId, LogoType logoType, Path pngFile) {
448                LogoUploadsUrlResponse target = getUploadDetailsForLogoType(
449                                Objects.requireNonNull(logoType, "Logo type cannot be null.")
450                );
451                uploadLogo(Objects.requireNonNull(pngFile, "Image file cannot be null."), target);
452                finalizeLogos(themeId, Collections.singletonList(target.getFields().getKey()));
453        }
454}