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;
017
018import com.vonage.client.auth.AuthMethod;
019import com.vonage.client.common.HttpMethod;
020import org.apache.http.HttpResponse;
021import org.apache.http.client.methods.RequestBuilder;
022import org.apache.http.entity.ByteArrayEntity;
023import org.apache.http.entity.ContentType;
024import org.apache.http.entity.StringEntity;
025import org.apache.http.util.EntityUtils;
026import java.io.IOException;
027import java.lang.reflect.Constructor;
028import java.lang.reflect.InvocationTargetException;
029import java.net.URI;
030import java.util.*;
031import java.util.function.BiFunction;
032import java.util.function.Consumer;
033
034/**
035 * Enables convenient declaration of endpoints without directly implementing {@link AbstractMethod}.
036 * This decouples the endpoint's implementation from the underlying HTTP library.
037 *
038 * @param <T> The request body type.
039 * @param <R> The response body type.
040 *
041 * @since 7.7.0
042 */
043@SuppressWarnings("unchecked")
044public class DynamicEndpoint<T, R> extends AbstractMethod<T, R> {
045        protected Set<Class<? extends AuthMethod>> authMethods;
046        protected String contentType, accept;
047        protected HttpMethod requestMethod;
048        protected BiFunction<DynamicEndpoint<T, R>, ? super T, String> pathGetter;
049        protected Class<? extends RuntimeException> responseExceptionType;
050        protected Class<R> responseType;
051        protected T cachedRequestBody;
052
053        protected DynamicEndpoint(Builder<T, R> builder) {
054                super(builder.wrapper);
055                authMethods = Objects.requireNonNull(builder.authMethods, "At least one auth method must be defined.");
056                requestMethod = Objects.requireNonNull(builder.requestMethod, "HTTP request method is required.");
057                pathGetter = Objects.requireNonNull(builder.pathGetter, "Path function is required.");
058                responseType = Objects.requireNonNull(builder.responseType, "Response type is required.");
059                responseExceptionType = builder.responseExceptionType;
060                contentType = builder.contentType;
061                if ((accept = builder.accept) == null &&
062                                (Jsonable.class.isAssignableFrom(responseType) || isJsonableArrayResponse())
063                ) {
064                        accept = ContentType.APPLICATION_JSON.getMimeType();
065                }
066        }
067
068        /**
069         * This trick enables initialisation of the builder whilst inferring the response type {@code <R>}
070         * without directly providing the class by using varargs as a parameter. See usages for examples.
071         *
072         * @param responseType The response type array, not provided directly but via a varargs parameter.
073         *
074         * @return A new Builder.
075         *
076         * @param <T> The request type.
077         * @param <R> The response type.
078         * @since 7.9.0
079         */
080        public static <T, R> Builder<T, R> builder(R[] responseType) {
081                return builder((Class<R>) responseType.getClass().getComponentType());
082        }
083
084        public static <T, R> Builder<T, R> builder(Class<R> responseType) {
085                return new Builder<>(responseType);
086        }
087
088        public static final class Builder<T, R> {
089                private final Class<R> responseType;
090                private Set<Class<? extends AuthMethod>> authMethods;
091                private HttpWrapper wrapper;
092                private String contentType, accept;
093                private HttpMethod requestMethod;
094                private BiFunction<DynamicEndpoint<T, R>, ? super T, String> pathGetter;
095                private Class<? extends RuntimeException> responseExceptionType;
096
097                Builder(Class<R> responseType) {
098                        this.responseType = responseType;
099                }
100
101                public Builder<T, R> wrapper(HttpWrapper wrapper) {
102                        this.wrapper = wrapper;
103                        return this;
104                }
105
106                public Builder<T, R> requestMethod(HttpMethod requestMethod) {
107                        this.requestMethod = requestMethod;
108                        return this;
109                }
110
111                public Builder<T, R> pathGetter(BiFunction<DynamicEndpoint<T, R>, T, String> pathGetter) {
112                        this.pathGetter = pathGetter;
113                        return this;
114                }
115
116                public Builder<T, R> authMethod(Class<? extends AuthMethod> primary, Class<? extends AuthMethod>... others) {
117                        authMethods = new LinkedHashSet<>(2);
118                        authMethods.add(Objects.requireNonNull(primary, "Primary auth method cannot be null."));
119                        if (others != null) {
120                                for (Class<? extends AuthMethod> amc : others) {
121                                        if (amc != null) {
122                                                authMethods.add(amc);
123                                        }
124                                }
125                        }
126                        return this;
127                }
128
129                public Builder<T, R> responseExceptionType(Class<? extends RuntimeException> responseExceptionType) {
130                        this.responseExceptionType = responseExceptionType;
131                        return this;
132                }
133
134                public Builder<T, R> urlFormEncodedContentType(boolean formEncoded) {
135                        return contentTypeHeader(formEncoded ? "application/x-www-form-urlencoded" : null);
136                }
137
138                public Builder<T, R> contentTypeHeader(String contentType) {
139                        this.contentType = contentType;
140                        return this;
141                }
142
143                public Builder<T, R> acceptHeader(String accept) {
144                        this.accept = accept;
145                        return this;
146                }
147
148                public DynamicEndpoint<T, R> build() {
149                        return new DynamicEndpoint<>(this);
150                }
151        }
152
153        static RequestBuilder createRequestBuilderFromRequestMethod(HttpMethod requestMethod) {
154                switch (requestMethod) {
155                        case GET: return RequestBuilder.get();
156                        case POST: return RequestBuilder.post();
157                        case PATCH: return RequestBuilder.patch();
158                        case DELETE: return RequestBuilder.delete();
159                        case PUT: return RequestBuilder.put();
160                        default: throw new IllegalStateException("Unknown request method.");
161                }
162        }
163
164        @Override
165        protected final Set<Class<? extends AuthMethod>> getAcceptableAuthMethods() {
166                return authMethods;
167        }
168
169        private boolean isJsonableArrayResponse() {
170                return responseType.isArray() && Jsonable.class.isAssignableFrom(responseType.getComponentType());
171        }
172
173        private String getRequestHeader(T requestBody) {
174                if (contentType != null) {
175                        return contentType;
176                }
177                else if (requestBody instanceof Jsonable) {
178                        return ContentType.APPLICATION_JSON.getMimeType();
179                }
180                else if (requestBody instanceof BinaryRequest) {
181                        return ((BinaryRequest) requestBody).getContentType();
182                }
183                else {
184                        return null;
185                }
186        }
187
188        private static void applyQueryParams(Map<String, ?> params, RequestBuilder rqb) {
189                params.forEach((k, v) -> {
190                        Consumer<Object> logic = obj -> rqb.addParameter(k, String.valueOf(obj));
191                        if (v instanceof Object[]) {
192                                for (Object nested : (Object[]) v) {
193                                        logic.accept(nested);
194                                }
195                        }
196                        else if (v instanceof Iterable<?>) {
197                                for (Object nested : (Iterable<?>) v) {
198                                        logic.accept(nested);
199                                }
200                        }
201                        else {
202                                logic.accept(v);
203                        }
204                });
205        }
206
207        public static URI buildUri(String base, Map<String, ?> requestParams) {
208                RequestBuilder requestBuilder = RequestBuilder.get(base);
209                applyQueryParams(requestParams, requestBuilder);
210                return requestBuilder.build().getURI();
211        }
212
213        @Override
214        protected final RequestBuilder makeRequest(T requestBody) {
215                if (requestBody instanceof Jsonable && responseType.isAssignableFrom(requestBody.getClass())) {
216                        cachedRequestBody = requestBody;
217                }
218                RequestBuilder rqb = createRequestBuilderFromRequestMethod(requestMethod);
219                String header = getRequestHeader(requestBody);
220                if (header != null) {
221                        rqb.setHeader("Content-Type", header);
222                }
223                if (accept != null) {
224                        rqb.setHeader("Accept", accept);
225                }
226                if (requestBody instanceof QueryParamsRequest) {
227                        applyQueryParams(((QueryParamsRequest) requestBody).makeParams(), rqb);
228                }
229                if (requestBody instanceof Jsonable) {
230                        rqb.setEntity(new StringEntity(((Jsonable) requestBody).toJson(), ContentType.APPLICATION_JSON));
231                }
232                else if (requestBody instanceof BinaryRequest) {
233                        BinaryRequest bin = (BinaryRequest) requestBody;
234                        rqb.setEntity(new ByteArrayEntity(bin.toByteArray(), ContentType.getByMimeType(bin.getContentType())));
235                }
236                else if (requestBody instanceof byte[]) {
237                        rqb.setEntity(new ByteArrayEntity((byte[]) requestBody));
238                }
239                return rqb.setUri(pathGetter.apply(this, requestBody));
240        }
241
242        @Override
243        protected final R parseResponse(HttpResponse response) throws IOException {
244                int statusCode = response.getStatusLine().getStatusCode();
245                try {
246                        if (statusCode >= 200 && statusCode < 300) {
247                                return parseResponseSuccess(response);
248                        }
249                        else if (statusCode >= 300 && statusCode < 400) {
250                                return parseResponseRedirect(response);
251                        }
252                        else {
253                                return parseResponseFailure(response);
254                        }
255                }
256                catch (InvocationTargetException ex) {
257                        Throwable wrapped = ex.getTargetException();
258                        if (wrapped instanceof RuntimeException) {
259                                throw (RuntimeException) wrapped;
260                        }
261                        else {
262                                throw new VonageUnexpectedException(wrapped);
263                        }
264                }
265                catch (ReflectiveOperationException ex) {
266                        throw new VonageUnexpectedException(ex);
267                }
268                finally {
269                        cachedRequestBody = null;
270                }
271        }
272
273        protected R parseResponseFromString(String response) {
274                return null;
275        }
276
277        private R parseResponseRedirect(HttpResponse response) throws ReflectiveOperationException, IOException {
278                final String location = response.getFirstHeader("Location").getValue();
279
280                if (java.net.URI.class.equals(responseType)) {
281                        return (R) URI.create(location);
282                }
283                else if (String.class.equals(responseType)) {
284                        return (R) location;
285                }
286                else {
287                        return parseResponseSuccess(response);
288                }
289        }
290
291        private R parseResponseSuccess(HttpResponse response) throws IOException, ReflectiveOperationException {
292                if (Void.class.equals(responseType)) {
293                        return null;
294                }
295                else if (byte[].class.equals(responseType)) {
296                        return (R) EntityUtils.toByteArray(response.getEntity());
297                }
298                else {
299                        String deser = EntityUtils.toString(response.getEntity());
300
301                        if (responseType.equals(String.class)) {
302                                return (R) deser;
303                        }
304
305                        if (cachedRequestBody instanceof Jsonable) {
306                                ((Jsonable) cachedRequestBody).updateFromJson(deser);
307                                return (R) cachedRequestBody;
308                        }
309
310                        if (Jsonable.class.isAssignableFrom(responseType)) {
311                                return (R) Jsonable.fromJson(deser, (Class<? extends Jsonable>) responseType);
312                        }
313                        else if (Collection.class.isAssignableFrom(responseType) || isJsonableArrayResponse()) {
314                                return Jsonable.createDefaultObjectMapper().readValue(deser, responseType);
315                        }
316                        else {
317                                R customParsedResponse = parseResponseFromString(deser);
318                                if (customParsedResponse == null) {
319                                        throw new IllegalStateException("Unhandled return type: " + responseType);
320                                }
321                                else {
322                                        return customParsedResponse;
323                                }
324                        }
325                }
326        }
327
328        private R parseResponseFailure(HttpResponse response) throws IOException, ReflectiveOperationException {
329                String exMessage = EntityUtils.toString(response.getEntity());
330                if (responseExceptionType != null) {
331                        if (VonageApiResponseException.class.isAssignableFrom(responseExceptionType)) {
332                                VonageApiResponseException varex = Jsonable.fromJson(exMessage,
333                                                (Class<? extends VonageApiResponseException>) responseExceptionType
334                                );
335                                if (varex.title == null) {
336                                        varex.title = response.getStatusLine().getReasonPhrase();
337                                }
338                                varex.statusCode = response.getStatusLine().getStatusCode();
339                                throw varex;
340                        }
341                        else {
342                                for (Constructor<?> constructor : responseExceptionType.getDeclaredConstructors()) {
343                                        Class<?>[] params = constructor.getParameterTypes();
344                                        if (params.length == 1 && String.class.equals(params[0])) {
345                                                if (!constructor.isAccessible()) {
346                                                        constructor.setAccessible(true);
347                                                }
348                                                throw (RuntimeException) constructor.newInstance(exMessage);
349                                        }
350                                }
351                        }
352                }
353                R customParsedResponse = parseResponseFromString(exMessage);
354                if (customParsedResponse == null) {
355                        throw new VonageApiResponseException(exMessage);
356                }
357                else {
358                        return customParsedResponse;
359                }
360        }
361}