IuHttp.java
/*
* Copyright © 2024 Indiana University
* All rights reserved.
*
* BSD 3-Clause License
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* - Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* - Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* - Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package edu.iu.client;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.util.Collection;
import java.util.function.BiPredicate;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import edu.iu.IuException;
import edu.iu.IuObject;
import edu.iu.IuRuntimeEnvironment;
import edu.iu.IuWebUtils;
import edu.iu.UnsafeConsumer;
import jakarta.json.JsonObject;
import jakarta.json.JsonValue;
/**
* Provides common base-level whitelisting, logging, and exception handling
* utilities for {@link HttpRequest} and {@link HttpResponse}.
*
* <p>
* All requests are handled via a cached {@link HttpClient} instance configured
* with {@link HttpClient#newHttpClient default settings}.
* </p>
*/
public class IuHttp {
private static final Logger LOG = Logger.getLogger(IuHttp.class.getName());
static {
IuObject.assertNotOpen(IuHttp.class);
}
private static final Collection<URI> ALLOWED_URI = IuRuntimeEnvironment.env("iu.http.allowedUri",
a -> Stream.of(a.split(",")).map(URI::create).collect(Collectors.toUnmodifiableList()));
private static final Collection<URI> ALLOWED_INSECURE_URI = IuRuntimeEnvironment.envOptional(
"iu.http.allowedInsecureUri",
a -> Stream.of(a.split(",")).map(URI::create).collect(Collectors.toUnmodifiableList()));
private static final HttpClient HTTP = HttpClient.newHttpClient();
/**
* Validates a 200 OK response.
*/
public static final HttpResponseValidator OK = expectStatus(200);
/**
* Validates a 204 NO CONTENT response and returns null.
*/
public static final HttpResponseHandler<?> NO_CONTENT = validate(a -> null, IuHttp.expectStatus(204));
/**
* Validates 200 OK then parses the response as a JSON object.
*/
public static final HttpResponseHandler<JsonValue> READ_JSON = validate(IuJson::parse, IuHttp.OK);
/**
* Validates 200 OK then parses the response as a JSON object.
*/
public static final HttpResponseHandler<JsonObject> READ_JSON_OBJECT = validate(a -> IuJson.parse(a).asJsonObject(),
IuHttp.OK);
/**
* Creates an HTTP response handler.
*
* @param <T> value type
* @param bodyDeserializer function that deserializes the response body
* @param responseValidators one or more verification checks to apply to the
* response before passing to the handler
* @return decorated response handler
*/
public static <T> HttpResponseHandler<T> validate(Function<InputStream, T> bodyDeserializer,
HttpResponseValidator... responseValidators) {
return response -> {
for (final var responseValidator : responseValidators)
responseValidator.accept(response);
return bodyDeserializer.apply(response.body());
};
}
/**
* Gets a {@link HttpResponseValidator} that verifies an expected status code.
*
* @param expectedStatusCode status code
* @return {@link HttpResponseValidator}
*/
public static HttpResponseValidator expectStatus(int expectedStatusCode) {
return response -> {
final var statusCode = response.statusCode();
if (statusCode != expectedStatusCode)
throw new HttpException(response, "Expected " + IuWebUtils.describeStatus(expectedStatusCode)
+ ", found " + IuWebUtils.describeStatus(statusCode));
};
}
/**
* Gets a {@link HttpResponseValidator} that tests response headers.
*
* @param headerValidator test response headers
* @return {@link HttpResponseValidator}
*/
public static HttpResponseValidator checkHeaders(BiPredicate<String, String> headerValidator) {
return response -> {
for (final var headerEntry : response.headers().map().entrySet()) {
final var name = headerEntry.getKey();
for (final var value : headerEntry.getValue())
if (!headerValidator.test(name, value))
throw new HttpException(response, "Invalid header " + name);
}
};
}
/**
* Sends an HTTP GET request to a public URI.
*
* @param uri public URI
*
* @return {@link HttpResponse}
* @throws HttpException If the response has error status code.
*/
public static HttpResponse<InputStream> get(URI uri) throws HttpException {
return send(uri, null);
}
/**
* Sends an HTTP GET request to a public URI.
*
* @param <T> response type
*
* @param uri public URI
* @param responseHandler function that converts HTTP response data to the
* response type.
*
* @return response value
* @throws HttpException If the response has error status code.
*/
public static <T> T get(URI uri, HttpResponseHandler<T> responseHandler) throws HttpException {
return responseHandler.apply(send(uri, null));
}
/**
* Sends a synchronous HTTP request.
*
* @param uri request URI
* @param requestConsumer receives the {@link HttpRequest.Builder} before
* sending to the server.
*
* @return {@link HttpResponse}
* @throws HttpException If the response has error status code.
*/
public static HttpResponse<InputStream> send(URI uri, UnsafeConsumer<HttpRequest.Builder> requestConsumer)
throws HttpException {
return send(HttpException.class, uri, requestConsumer);
}
/**
* Determines whether or not an allow list contains a root of a URI.
*
* @param allowList allowed root URIs
* @param uri {@link URI} to check
* @return true if the allow list is non-null and contains at least one entry
* that is a root of the supplied URI
*/
static boolean isAllowed(Collection<URI> allowList, URI uri) {
if (allowList == null)
return false;
else
return allowList.stream().anyMatch(allowedUri -> IuWebUtils.isRootOf(allowedUri, uri));
}
/**
* Sends a synchronous HTTP request.
*
* @param <E> additional exception type
*
* @param uri request URI
* @param requestConsumer receives the {@link HttpRequest.Builder} before
* sending to the server.
* @param exceptionClass additional checked exception type to allow thrown from
* requestConsumer
*
* @return {@link HttpResponse}
* @throws HttpException If the response has error status code.
* @throws E from requestConsumer
*/
public static <E extends Exception> HttpResponse<InputStream> send(Class<E> exceptionClass, URI uri,
UnsafeConsumer<HttpRequest.Builder> requestConsumer) throws HttpException, E {
if (!"https".equals(uri.getScheme())) {
if (!isAllowed(ALLOWED_INSECURE_URI, uri))
throw new IllegalArgumentException(
"Insecure URI not allowed, must be relative to " + ALLOWED_INSECURE_URI);
else
LOG.info(() -> "Allowing insecure URI " + uri);
} else if (!isAllowed(ALLOWED_URI, uri))
throw new IllegalArgumentException("URI not allowed, must be relative to " + ALLOWED_URI);
return IuException.checked(HttpException.class, exceptionClass, () -> {
final var requestBuilder = HttpRequest.newBuilder(uri);
if (requestConsumer != null)
requestConsumer.accept(requestBuilder);
final var request = requestBuilder.build();
final var sb = new StringBuilder();
sb.append(request.method());
sb.append(' ').append(request.uri());
final var requestHeaders = request.headers();
final var requestHeaderMap = requestHeaders.map();
if (!requestHeaderMap.isEmpty())
// TODO: apply security filter
sb.append(' ').append(requestHeaderMap.keySet());
final HttpResponse<InputStream> response;
try {
response = HTTP.send(request, BodyHandlers.ofInputStream());
} catch (Throwable e) {
final var m = "HTTP connection failed " + sb;
LOG.log(Level.INFO, e, () -> m);
throw new IllegalStateException(m, e);
}
final var status = response.statusCode();
sb.append(" ").append(IuWebUtils.describeStatus(status));
final var responseHeaders = response.headers();
final var responseHeaderMap = responseHeaders.map();
if (!responseHeaderMap.isEmpty())
// TODO: apply security filter
sb.append(' ').append(responseHeaderMap.keySet());
if (response.statusCode() >= 400) {
final var m = sb.toString();
final var e = new HttpException(response, m);
LOG.log(Level.INFO, m, e);
throw e;
} else
LOG.fine(sb::toString);
return response;
});
}
/**
* Sends a synchronous HTTP request expecting 200 OK and accepting all response
* headers.
*
* @param <T> response type
*
* @param uri request URI
* @param requestConsumer receives the {@link HttpRequest.Builder} before
* sending to the server.
* @param responseHandler function that converts HTTP response data to the
* response type.
*
* @return response value
* @throws HttpException If the response has error status code.
*/
public static <T> T send(URI uri, UnsafeConsumer<HttpRequest.Builder> requestConsumer,
HttpResponseHandler<T> responseHandler) throws HttpException {
return send(HttpException.class, uri, requestConsumer, responseHandler);
}
/**
* Sends a synchronous HTTP request expecting 200 OK and accepting all response
* headers.
*
* @param <T> response type
* @param <E> additional exception type
*
* @param uri request URI
* @param requestConsumer receives the {@link HttpRequest.Builder} before
* sending to the server.
* @param exceptionClass additional checked exception class to allow from
* requestConsumer
* @param responseHandler function that converts HTTP response data to the
* response type.
*
* @return response value
* @throws HttpException If the response has error status code.
* @throws E from requestConsumer
*/
public static <T, E extends Exception> T send(Class<E> exceptionClass, URI uri,
UnsafeConsumer<HttpRequest.Builder> requestConsumer, HttpResponseHandler<T> responseHandler)
throws HttpException, E {
return responseHandler.apply(send(exceptionClass, uri, requestConsumer));
}
private IuHttp() {
}
}