001/* 002 * This file is part of the Keycloak Moodle authenticator 003 * Copyright (C) 2024 Michael N. Lipp 004 * 005 * This program is free software; you can redistribute it and/or modify it 006 * under the terms of the GNU Lesser General Public License as published 007 * by the Free Software Foundation; either version 3 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, but 011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public 013 * License for more details. 014 * 015 * You should have received a copy of the GNU Lesser General Public License along 016 * with this program; if not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.keycloak.moodleauth.moodle; 020 021import com.fasterxml.jackson.databind.DeserializationFeature; 022import com.fasterxml.jackson.databind.ObjectMapper; 023import java.io.IOException; 024import java.io.InputStream; 025import java.io.InputStreamReader; 026import java.io.PushbackReader; 027import java.net.URI; 028import java.net.URISyntaxException; 029import java.net.URLEncoder; 030import java.net.http.HttpClient; 031import java.net.http.HttpRequest; 032import java.net.http.HttpResponse; 033import java.net.http.HttpResponse.BodyHandlers; 034import java.nio.charset.Charset; 035import java.time.Duration; 036import java.util.AbstractMap; 037import java.util.Collection; 038import java.util.Collections; 039import java.util.HashMap; 040import java.util.Map; 041import java.util.concurrent.atomic.AtomicInteger; 042import java.util.logging.Level; 043import java.util.logging.Logger; 044import java.util.stream.Collectors; 045import java.util.stream.Stream; 046import org.jdrupes.keycloak.moodleauth.moodle.model.MoodleErrorValues; 047import org.jdrupes.keycloak.moodleauth.moodle.service.QueryValueEncoder; 048 049/** 050 * A class for invoking REST services. 051 */ 052@SuppressWarnings("PMD.DataflowAnomalyAnalysis") 053public class RestClient implements AutoCloseable { 054 055 @SuppressWarnings("PMD.FieldNamingConventions") 056 private static final Logger logger 057 = Logger.getLogger(RestClient.class.getName()); 058 protected static final ObjectMapper mapper = new ObjectMapper() 059 .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 060 061 private HttpClient httpClient; 062 private Map<String, Object> defaultParams; 063 private URI uri; 064 065 /** 066 * Instantiates a new rest client. 067 * 068 * @param uri the uri 069 * @param defaultParams the default params 070 */ 071 public RestClient(URI uri, Map<String, Object> defaultParams) { 072 createHttpClient(); 073 this.uri = uri; 074 this.defaultParams = new HashMap<>(defaultParams); 075 } 076 077 /** 078 * Instantiates a new rest client. 079 * 080 * @param uri the uri 081 */ 082 public RestClient(URI uri) { 083 this(uri, Collections.emptyMap()); 084 } 085 086 /** 087 * @param uri the uri to set 088 */ 089 @SuppressWarnings("PMD.LinguisticNaming") 090 public RestClient setUri(URI uri) { 091 this.uri = uri; 092 return this; 093 } 094 095 /** 096 * @return the uri 097 */ 098 public URI uri() { 099 return uri; 100 } 101 102 /** 103 * Sets the default params. 104 * 105 * @param params the params 106 * @return the rest client 107 */ 108 @SuppressWarnings("PMD.LinguisticNaming") 109 public RestClient setDefaultParams(Map<String, Object> params) { 110 this.defaultParams = new HashMap<>(params); 111 return this; 112 } 113 114 @Override 115 public void close() throws Exception { 116 httpClient = null; 117 } 118 119 private void createHttpClient() { 120 this.httpClient = HttpClient.newBuilder() 121 .connectTimeout(Duration.ofSeconds(20)).build(); 122 } 123 124 /** 125 * Invoke a request with the parameters specified. 126 * 127 * @param <T> the generic type 128 * @param resultType the result type 129 * @param queryParams parameters to be added to the query 130 * @param data to be send in the body 131 * @return the result 132 * @throws IOException Signals that an I/O exception has occurred. 133 */ 134 @SuppressWarnings({ "PMD.GuardLogStatement", "PMD.AvoidDuplicateLiterals", 135 "PMD.AvoidRethrowingException" }) 136 public <T> T invoke(Class<T> resultType, Map<String, Object> queryParams, 137 Map<String, Object> data) throws IOException { 138 try { 139 var query = Stream.concat(defaultParams.entrySet().stream(), 140 queryParams.entrySet().stream()) 141 .map(e -> URLEncoder.encode(e.getKey(), 142 Charset.forName("utf-8")) + "=" 143 + URLEncoder.encode(e.getValue().toString(), 144 Charset.forName("utf-8"))) 145 .collect(Collectors.joining("&")); 146 var formData = encodeData(data); 147 for (int attempt = 0; attempt < 10; attempt++) { 148 try { 149 return doInvoke(resultType, query, formData); 150 } catch (MoodleException e) { 151 if ("ex_unabletolock".equals(e.getMessage())) { 152 logger.log(Level.FINE, e, 153 () -> "Retrying due to: " + e.getMessage() 154 + " with query params " + queryParams); 155 continue; 156 } 157 throw e; 158 } catch (IOException e) { 159 logger.log(Level.FINE, e, 160 () -> "Reconnecting due to: " + e.getMessage()); 161 } 162 createHttpClient(); 163 Thread.sleep(1000); 164 } 165 // Final attempt 166 return doInvoke(resultType, query, formData); 167 } catch (InterruptedException e) { 168 throw new IOException(e); 169 } 170 } 171 172 /** 173 * Encodes the map following the non-standard conventions of 174 * PHP's `http_build_query` 175 * 176 * @param data the data 177 * @return the query string 178 * @see https://github.com/pear/PHP_Compat/blob/master/PHP/Compat/Function/http_build_query.php 179 */ 180 public static String encodeData(Map<String, Object> data) { 181 return encodeStream(data.entrySet().stream(), null); 182 } 183 184 /** 185 * Used by {@link #encodeData(Map)} and recursively invoked as required. 186 * 187 * @param data a stream of entries 188 * @param keyBase the key base of `null` for the top-lebel invocation 189 * @return the query string 190 */ 191 @SuppressWarnings({ "unchecked", "PMD.CognitiveComplexity" }) 192 public static String encodeStream(Stream<Map.Entry<String, Object>> data, 193 String keyBase) { 194 // Iterate over all entries in stream. 195 return data.map(e -> { 196 // Use entry's key as result's key or as "index" of existing key. 197 String key; 198 if (keyBase == null) { 199 key = e.getKey(); 200 } else { 201 key = keyBase + '[' + e.getKey() + ']'; 202 } 203 if (e.getValue() instanceof Map) { 204 return encodeStream( 205 ((Map<String, Object>) e.getValue()).entrySet().stream(), 206 key); 207 } 208 Stream<Object> valueStream = null; 209 if (e.getValue().getClass().isArray()) { 210 valueStream = Stream.of((Object[]) e.getValue()); 211 } else if (e.getValue() instanceof Collection) { 212 valueStream = ((Collection<Object>) e.getValue()).stream(); 213 } 214 if (valueStream != null) { 215 AtomicInteger counter = new AtomicInteger(); 216 return encodeStream(valueStream.map( 217 v -> new AbstractMap.SimpleEntry<>( 218 Integer.toString(counter.getAndIncrement()), v)), 219 key); 220 } 221 StringBuilder res = new StringBuilder() 222 .append(URLEncoder.encode(key, Charset.forName("utf-8"))) 223 .append('='); 224 if (e.getValue() instanceof QueryValueEncoder) { 225 res.append( 226 ((QueryValueEncoder) e.getValue()).asQueryValue()); 227 } else { 228 res.append(URLEncoder.encode(e.getValue().toString(), 229 Charset.forName("utf-8"))); 230 } 231 return res.toString(); 232 }).collect(Collectors.joining("&")); 233 } 234 235 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 236 private <T> T doInvoke(Class<T> resultType, String query, String formData) 237 throws IOException, InterruptedException { 238 URI fullUri; 239 try { 240 fullUri = new URI(uri.getScheme(), uri.getAuthority(), 241 uri.getPath(), null, null); 242 fullUri = new URI(fullUri.toString() 243 + (query == null ? "" : "?" + query) 244 + (uri.getRawFragment() == null ? "" 245 : "#" + uri.getRawFragment())); 246 } catch (URISyntaxException e) { 247 throw new IllegalArgumentException(e); 248 } 249 HttpRequest request = HttpRequest.newBuilder().uri(fullUri) 250 .header("Content-Type", "application/x-www-form-urlencoded") 251 .POST(HttpRequest.BodyPublishers.ofString(formData)).build(); 252 253 // Execute and get the response. 254 HttpResponse<InputStream> response 255 = httpClient.send(request, BodyHandlers.ofInputStream()); 256 if (response.body() == null) { 257 return null; 258 } 259 260 try (var resultData = new PushbackReader( 261 new InputStreamReader(response.body(), "utf-8"), 8)) { 262 if (resultType.isArray()) { 263 // Errors for requests returning an array are 264 // reported as JSON object. 265 char[] peekData = new char[2]; 266 int peeked = resultData.read(peekData, 0, peekData.length); 267 resultData.unread(peekData, 0, peeked); 268 if (peeked > 0 && peekData[0] != '[') { 269 mapper.readValue(resultData, MoodleErrorValues.class); 270 throw new MoodleException( 271 mapper.readValue(resultData, MoodleErrorValues.class)); 272 } 273 } 274 return mapper.readValue(resultData, resultType); 275 } catch (IOException e) { 276 throw new IOException("Unparsable result: " + e.getMessage(), e); 277 } 278 } 279}