001/*
002 * Copyright 2023 the original author or authors.
003 * <p>
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 * <p>
008 * https://www.apache.org/licenses/LICENSE-2.0
009 * <p>
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 de.cuioss.portal.restclient;
017
018import de.cuioss.portal.configuration.connections.impl.ConnectionMetadata;
019import de.cuioss.tools.logging.CuiLogger;
020import de.cuioss.tools.string.MoreStrings;
021import jakarta.ws.rs.core.Configurable;
022import jakarta.ws.rs.core.Configuration;
023import jakarta.ws.rs.core.Response;
024import org.eclipse.microprofile.rest.client.RestClientBuilder;
025import org.eclipse.microprofile.rest.client.ext.QueryParamStyle;
026import org.eclipse.microprofile.rest.client.ext.ResponseExceptionMapper;
027
028import javax.net.ssl.HostnameVerifier;
029import javax.net.ssl.SSLContext;
030import java.io.Closeable;
031import java.io.Serializable;
032import java.net.MalformedURLException;
033import java.net.URI;
034import java.net.URL;
035import java.util.Map;
036import java.util.concurrent.TimeUnit;
037
038/**
039 * Builder for a JAVA MicroProfile based REST client.
040 * <p>
041 * To enable log debugging / tracing set package
042 * de.cuioss.portal.core.restclient to TRACE level in your logger configuration.
043 */
044@SuppressWarnings("UnusedReturnValue")
045public class CuiRestClientBuilder {
046
047    private static final CuiLogger log = new CuiLogger(CuiRestClientBuilder.class);
048
049    private static final String DISABLE_DEFAULT_MAPPER_PROPERTY_KEY = "microprofile.rest.client.disable.default.mapper";
050    public static final String RESPONSE_EXCEPTION_MAPPER = "org.jboss.resteasy.microprofile.client.DefaultResponseExceptionMapper";
051
052    private final RestClientBuilder mpRestClientBuilder;
053    private boolean traceLogEnabled;
054    private final CuiLogger logger;
055
056    /**
057     * Creates a REST client builder.
058     *
059     * <p>
060     * Enables the trace-logging, if either the given logger or the
061     * {@link CuiRestClientBuilder} logger returns true for
062     * {@link CuiLogger#isTraceEnabled()}.
063     * </p>
064     *
065     * @param logger for trace-logging.
066     */
067    public CuiRestClientBuilder(final CuiLogger logger) {
068        mpRestClientBuilder = RestClientBuilder.newBuilder();
069        this.logger = logger;
070        traceLogEnabled = logger.isTraceEnabled() || log.isTraceEnabled();
071
072        // Advice RestEasy not to add its default exception handler.
073        // It would serve the request before we can trace-log anything.
074        // Furthermore, it throws an Exception in case the service interfaces return
075        // type is
076        // javax.ws.rs.core.Response.
077        // Both things we don't admire.
078        // Also see: https://github.com/eclipse/microprofile-rest-client/issues/195
079        disableDefaultExceptionHandler();
080        // register(DefaultResponseExceptionMapper.class, Integer.MIN_VALUE - 1);
081    }
082
083    /**
084     * Debugs a given Response to the given logger
085     *
086     * @param response must not be null
087     * @param log      must not be null
088     */
089    public static void debugResponse(final Response response, final CuiLogger log) {
090        log.debug("""
091                        -- Client response filter --
092                        Status: {}
093                        StatusInfo: {}
094                        Allowed Methods: {}
095                        EntityTag: {}
096                        Cookies: {}
097                        Date: {}
098                        Headers: {}
099                        Language: {}
100                        LastModified: {}
101                        Links: {}
102                        Location: {}
103                        MediaType: {}
104                        """, response.getStatus(), response.getStatusInfo(), response.getAllowedMethods(),
105                response.getEntityTag(), response.getCookies(), response.getDate(), response.getHeaders(),
106                response.getLanguage(), response.getLastModified(), response.getLinks(), response.getLocation(),
107                response.getMediaType());
108    }
109
110    /**
111     * Sets various properties based on the given <code>connectionMeta</code>.
112     * <ul>
113     * <li>service url</li>
114     * <li>tracing enabled</li>
115     * <li>ssl context</li>
116     * <li>login credentials</li>
117     * <li>context map</li>
118     * <li>hostname verifier</li>
119     * <li>connection timeout</li>
120     * <li>read timeout</li>
121     * </ul>
122     *
123     * @return this builder
124     */
125    @SuppressWarnings("squid:S3510") // owolff: False Positive, By design
126    public CuiRestClientBuilder connectionMetadata(final ConnectionMetadata connectionMeta) {
127        url(connectionMeta.getServiceUrl());
128
129        sslContext(connectionMeta.resolveSSLContext());
130        switch (connectionMeta.getAuthenticationType()) {
131            case BASIC:
132                basicAuth(connectionMeta.getLoginCredentials().getUsername(),
133                        connectionMeta.getLoginCredentials().getPassword());
134                break;
135            case TOKEN_FROM_USER:
136            case TOKEN_APPLICATION:
137                mpRestClientBuilder.register(new TokenFilter(connectionMeta.getTokenResolver()));
138                break;
139            default:
140                break;
141        }
142        for (final Map.Entry<Serializable, Serializable> entry : connectionMeta.getContextMap().entrySet()) {
143            mpRestClientBuilder.property(String.valueOf(entry.getKey()), String.valueOf(entry.getValue()));
144        }
145        if (connectionMeta.isDisableHostNameVerification()) {
146            hostnameVerifier((hostname, sslSession) -> true); // NOSONAR:
147            // owolff: This is documented to be only used in the context of testing
148        }
149        if (connectionMeta.getConnectionTimeout() > 0) {
150            connectTimeout(connectionMeta.getConnectionTimeout(), connectionMeta.getConnectionTimeoutUnit());
151        }
152        if (connectionMeta.getReadTimeout() > 0) {
153            readTimeout(connectionMeta.getReadTimeout(), connectionMeta.getReadTimeoutUnit());
154        }
155        if (!MoreStrings.isBlank(connectionMeta.getProxyHost()) && null != connectionMeta.getProxyPort()
156                && connectionMeta.getProxyPort() > 0) {
157            proxyAddress(connectionMeta.getProxyHost(), connectionMeta.getProxyPort());
158        }
159        return this;
160    }
161
162    /**
163     * @param value Enable|Disable trace logging capabilities for this REST client.
164     *              Defaults to {@link CuiLogger#isTraceEnabled()} for the given
165     *              logger. This is unrelated to the distributed tracing
166     *              capabilities.
167     * @return this builder
168     * @see LogClientRequestFilter
169     * @see LogReaderInterceptor
170     */
171    public CuiRestClientBuilder traceLogEnabled(final boolean value) {
172        traceLogEnabled = value;
173        return this;
174    }
175
176    /**
177     * @param component to be registered
178     * @return this builder
179     * @see Configurable#register(java.lang.Object)
180     */
181    public CuiRestClientBuilder register(final Object component) {
182        mpRestClientBuilder.register(component);
183        return this;
184    }
185
186    /**
187     * @param component to be registered
188     * @param priority  overwrite value for the components
189     *                  {@link jakarta.annotation.Priority}
190     * @return this builder
191     * @see Configurable#register(Object, int)
192     */
193    public CuiRestClientBuilder register(final Object component, final int priority) {
194        mpRestClientBuilder.register(component, priority);
195        return this;
196    }
197
198    /**
199     * @param key   property key to be registered
200     * @param value property value to be registered
201     * @return this builder
202     * @see Configurable#property(String, Object)
203     */
204    public CuiRestClientBuilder property(final String key, final Object value) {
205        mpRestClientBuilder.property(key, value);
206        return this;
207    }
208
209    /**
210     * Enables the RestEasy default exception mapper for this MP REST client. Per
211     * default, this exception mapper is disabled. It registers it with priority
212     * {@link Integer#MIN_VALUE}, instead of {@link Integer#MAX_VALUE}, to allow
213     * trace-logging of responses.
214     * <p>
215     * Effect: Every response code of >=400 throws a general
216     * {@link jakarta.ws.rs.WebApplicationException}.
217     *
218     * @return this builder
219     */
220    public CuiRestClientBuilder enableDefaultExceptionHandler() {
221        try {
222            Class<?> defaultResponseExceptionMapper = Class.forName(RESPONSE_EXCEPTION_MAPPER, false,
223                    CuiRestClientBuilder.class.getClassLoader());
224            register(defaultResponseExceptionMapper.getDeclaredConstructor().newInstance(), Integer.MIN_VALUE);
225            disableDefaultExceptionHandler();
226        } catch (final Exception e) {
227            log.error(
228                    "Portal-541: Could not load org.jboss.resteasy.microprofile.client.DefaultResponseExceptionMapper",
229                    e);
230        }
231        return this;
232    }
233
234    /**
235     * Disables the RestEasy default exception mapper for this MP REST client. Per
236     * default, this exception mapper is disabled.
237     * <p>
238     * Effect: Exceptions like {@link jakarta.ws.rs.BadRequestException} are thrown
239     * instead of a general {@link jakarta.ws.rs.WebApplicationException}.
240     *
241     * @return this builder
242     */
243    public CuiRestClientBuilder disableDefaultExceptionHandler() {
244        property(DISABLE_DEFAULT_MAPPER_PROPERTY_KEY, true);
245        return this;
246    }
247
248    /**
249     * Adds the target url
250     *
251     * @param url to be passed to he contained builder
252     * @return this builder
253     */
254    public CuiRestClientBuilder url(final String url) {
255        try {
256            mpRestClientBuilder.baseUrl(new URL(url));
257        } catch (final MalformedURLException e) {
258            throw new IllegalArgumentException("The URL '" + url + "' could not be parsed!", e);
259        }
260        return this;
261    }
262
263    /**
264     * Adds the target url
265     *
266     * @param url to be passed to he contained builder
267     * @return this builder
268     */
269    public CuiRestClientBuilder url(final URL url) {
270        mpRestClientBuilder.baseUrl(url);
271        return this;
272    }
273
274    /**
275     * Adds the target uri
276     *
277     * @param uri to be passed to he contained builder
278     * @return this builder
279     */
280    public CuiRestClientBuilder uri(final URI uri) {
281        mpRestClientBuilder.baseUri(uri);
282        return this;
283    }
284
285    /**
286     * Adds the credentials for basic-auth
287     *
288     * @param username to be passed to he contained builder
289     * @param password to be passed to he contained builder
290     * @return this builder
291     */
292    public CuiRestClientBuilder basicAuth(final String username, final String password) {
293        mpRestClientBuilder.register(new BasicAuthenticationFilter(username, password));
294        return this;
295    }
296
297    /**
298     * Adds the credentials for bearer-auth
299     *
300     * @param token to be passed to he contained builder
301     * @return this builder
302     */
303    public CuiRestClientBuilder bearerAuthToken(final String token) {
304        mpRestClientBuilder.register(new BearerTokenAuthFilter(token));
305        return this;
306    }
307
308    /**
309     * Adds the ResponseExceptionMapper
310     *
311     * @param mapper to be passed to he contained builder
312     * @return this builder
313     */
314    public CuiRestClientBuilder registerExceptionMapper(final ResponseExceptionMapper<?> mapper) {
315        mpRestClientBuilder.register(mapper);
316        return this;
317    }
318
319    /**
320     * Adds the sslContext
321     *
322     * @param sslContext to be passed to he contained builder
323     * @return this builder
324     */
325    public CuiRestClientBuilder sslContext(final SSLContext sslContext) {
326        mpRestClientBuilder.sslContext(sslContext);
327        return this;
328    }
329
330    /**
331     * Adds the connection timeout
332     *
333     * @param amount   to be passed to he contained builder
334     * @param timeUnit to be passed to he contained builder
335     * @return this builder
336     */
337    public CuiRestClientBuilder connectTimeout(long amount, TimeUnit timeUnit) {
338        mpRestClientBuilder.connectTimeout(amount, timeUnit);
339        return this;
340    }
341
342    /**
343     * Adds the read timeout
344     *
345     * @param amount   to be passed to he contained builder
346     * @param timeUnit to be passed to he contained builder
347     * @return this builder
348     */
349    public CuiRestClientBuilder readTimeout(long amount, TimeUnit timeUnit) {
350        mpRestClientBuilder.readTimeout(amount, timeUnit);
351        return this;
352    }
353
354    /**
355     * Adds the QueryParamStyle
356     *
357     * @param queryParamStyle to be passed to he contained builder
358     * @return this builder
359     */
360    public CuiRestClientBuilder queryParamStyle(QueryParamStyle queryParamStyle) {
361        mpRestClientBuilder.queryParamStyle(queryParamStyle);
362        return this;
363    }
364
365    /**
366     * Adds the proxy address
367     *
368     * @param host to be passed to he contained builder
369     * @param port to be passed to he contained builder
370     * @return this builder
371     */
372    public CuiRestClientBuilder proxyAddress(String host, int port) {
373        mpRestClientBuilder.proxyAddress(host, port);
374        return this;
375    }
376
377    /**
378     * Adds the followRedirects
379     *
380     * @param followRedirects to be passed to he contained builder
381     * @return this builder
382     */
383    public CuiRestClientBuilder followRedirects(boolean followRedirects) {
384        mpRestClientBuilder.followRedirects(followRedirects);
385        return this;
386    }
387
388    /**
389     * Adds the hostnameVerifier
390     *
391     * @param hostnameVerifier to be passed to he contained builder
392     * @return this builder
393     */
394    public CuiRestClientBuilder hostnameVerifier(HostnameVerifier hostnameVerifier) {
395        mpRestClientBuilder.hostnameVerifier(hostnameVerifier);
396        return this;
397    }
398
399    /**
400     * @return the current configuration of the contained builder
401     */
402    public Configuration getConfiguration() {
403        return mpRestClientBuilder.getConfiguration();
404    }
405
406    /**
407     * Create an implementation of the service interface T using the rest client.
408     *
409     * @param clazz the service interface which also must extend
410     *              {@link java.io.Closeable}
411     * @param <T>   the service type
412     * @return T the service class
413     */
414    public <T extends Closeable> T build(final Class<T> clazz) {
415        if (traceLogEnabled) {
416            log.debug("trace logging engaged");
417            register(new LogClientRequestFilter(logger));
418            register(new LogClientResponseFilter(logger, "First ClientResponseFilter") {
419
420            }, Integer.MAX_VALUE);
421            register(new LogClientResponseFilter(logger, "Last ClientResponseFilter") {
422
423            }, Integer.MIN_VALUE);
424            register(new LogReaderInterceptor(logger));
425        }
426
427        return mpRestClientBuilder.build(clazz);
428    }
429
430
431}