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}