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}