diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ee8107917..e6f0ac107e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ This section is for maintaining a changelog for all breaking changes for the cli ## [Unreleased 2.x] ### Added +- [FEATURE] Enable Generic HTTP Actions in Java Client ([#910](https://github.com/opensearch-project/opensearch-java/pull/910)) ### Dependencies - Bumps `io.github.classgraph:classgraph` from 4.8.161 to 4.8.165 diff --git a/java-client/src/main/java/org/opensearch/client/opensearch/OpenSearchClient.java b/java-client/src/main/java/org/opensearch/client/opensearch/OpenSearchClient.java index 3b2d8d76ff..ee83bcc20d 100644 --- a/java-client/src/main/java/org/opensearch/client/opensearch/OpenSearchClient.java +++ b/java-client/src/main/java/org/opensearch/client/opensearch/OpenSearchClient.java @@ -123,6 +123,7 @@ import org.opensearch.client.opensearch.core.pit.ListAllPitResponse; import org.opensearch.client.opensearch.dangling_indices.OpenSearchDanglingIndicesClient; import org.opensearch.client.opensearch.features.OpenSearchFeaturesClient; +import org.opensearch.client.opensearch.generic.OpenSearchGenericClient; import org.opensearch.client.opensearch.indices.OpenSearchIndicesClient; import org.opensearch.client.opensearch.ingest.OpenSearchIngestClient; import org.opensearch.client.opensearch.nodes.OpenSearchNodesClient; @@ -155,6 +156,9 @@ public OpenSearchClient withTransportOptions(@Nullable TransportOptions transpor } // ----- Child clients + public OpenSearchGenericClient generic() { + return new OpenSearchGenericClient(this.transport, this.transportOptions); + } public OpenSearchCatClient cat() { return new OpenSearchCatClient(this.transport, this.transportOptions); diff --git a/java-client/src/main/java/org/opensearch/client/opensearch/generic/Bodies.java b/java-client/src/main/java/org/opensearch/client/opensearch/generic/Bodies.java new file mode 100644 index 0000000000..65921a41df --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/opensearch/generic/Bodies.java @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.generic; + +import jakarta.json.stream.JsonGenerator; +import jakarta.json.stream.JsonParser; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.opensearch.client.json.JsonpDeserializer; +import org.opensearch.client.json.JsonpMapper; + +public final class Bodies { + private static final String APPLICATION_JSON = "application/json; charset=UTF-8"; + + private Bodies() {} + + public static C json(Body body, JsonpDeserializer deserializer, JsonpMapper jsonpMapper) { + try (JsonParser parser = jsonpMapper.jsonProvider().createParser(body.body())) { + return deserializer.deserialize(parser, jsonpMapper); + } + } + + public static C json(Body body, Class clazz, JsonpMapper jsonpMapper) { + try (JsonParser parser = jsonpMapper.jsonProvider().createParser(body.body())) { + return jsonpMapper.deserialize(parser, clazz); + } + } + + public static Body json(C value, JsonpMapper jsonpMapper) throws IOException { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + try (JsonGenerator generator = jsonpMapper.jsonProvider().createGenerator(baos)) { + jsonpMapper.serialize(value, generator); + return Body.from(baos.toByteArray(), APPLICATION_JSON); + } + } + } + + public static Body json(String str) { + return Body.from(str.getBytes(StandardCharsets.UTF_8), APPLICATION_JSON); + } +} diff --git a/java-client/src/main/java/org/opensearch/client/opensearch/generic/Body.java b/java-client/src/main/java/org/opensearch/client/opensearch/generic/Body.java new file mode 100644 index 0000000000..9957c93507 --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/opensearch/generic/Body.java @@ -0,0 +1,90 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.generic; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import javax.annotation.Nullable; + +/** + * Generic HTTP request / response body. It is responsibility of the caller to close the body instance + * explicitly (or through {@link GenericResponse} instance) to release all associated resources. + */ +public interface Body extends AutoCloseable { + final int DEFAULT_BUFFER_SIZE = 8192; + + /** + * Constructs the generic response body out of {@link InputStream} with assumed content type + * @param body response body stream + * @param contentType content type + * @return generic response body instance + */ + static @Nullable Body from(@Nullable final InputStream body, @Nullable final String contentType) { + if (body == null) { + return null; + } else { + return new GenericInputStreamBody(body, contentType); + } + } + + /** + * Constructs the generic response body out of {@link InputStream} with assumed content type + * @param body response body stream + * @param contentType content type + * @return generic response body instance + */ + static @Nullable Body from(@Nullable final byte[] body, @Nullable final String contentType) { + if (body == null) { + return null; + } else { + return new GenericByteArrayBody(body, contentType); + } + } + + /** + * Content type of this body + * @return content type + */ + String contentType(); + + /** + * Gets the body as {@link InputStream} + * @return body as {@link InputStream} + */ + InputStream body(); + + /** + * Gets the body as {@link String} + * @return body as {@link String} + */ + default String bodyAsString() { + try (final ByteArrayOutputStream out = new ByteArrayOutputStream()) { + try (final InputStream in = body()) { + final byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + int read; + while ((read = in.read(buffer, 0, DEFAULT_BUFFER_SIZE)) >= 0) { + out.write(buffer, 0, read); + } + } + + out.flush(); + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } catch (final IOException ex) { + throw new UncheckedIOException(ex); + } + } + + /** + * Releases all resources associated with this body stream. + */ + void close() throws IOException; +} diff --git a/java-client/src/main/java/org/opensearch/client/opensearch/generic/GenericByteArrayBody.java b/java-client/src/main/java/org/opensearch/client/opensearch/generic/GenericByteArrayBody.java new file mode 100644 index 0000000000..aa8a3f873e --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/opensearch/generic/GenericByteArrayBody.java @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.generic; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import javax.annotation.Nullable; + +/** + * The HTTP request / response body that uses {@link byte[]} + */ +final class GenericByteArrayBody implements Body { + private final byte[] bytes; + private final String contentType; + + GenericByteArrayBody(final byte[] bytes, @Nullable final String contentType) { + this.bytes = bytes; + this.contentType = contentType; + } + + @Override + public String contentType() { + return contentType; + } + + @Override + public InputStream body() { + return new ByteArrayInputStream(bytes); + } + + @Override + public void close() throws IOException {} +} diff --git a/java-client/src/main/java/org/opensearch/client/opensearch/generic/GenericInputStreamBody.java b/java-client/src/main/java/org/opensearch/client/opensearch/generic/GenericInputStreamBody.java new file mode 100644 index 0000000000..40f4d241a0 --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/opensearch/generic/GenericInputStreamBody.java @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.generic; + +import java.io.IOException; +import java.io.InputStream; +import javax.annotation.Nullable; + +/** + * The HTTP request / response body that uses {@link InputStream} + */ +final class GenericInputStreamBody implements Body { + private final InputStream in; + private final String contentType; + + GenericInputStreamBody(final InputStream in, @Nullable final String contentType) { + this.in = in; + this.contentType = contentType; + } + + @Override + public String contentType() { + return contentType; + } + + @Override + public InputStream body() { + return in; + } + + @Override + public void close() throws IOException { + in.close(); + } +} diff --git a/java-client/src/main/java/org/opensearch/client/opensearch/generic/GenericRequest.java b/java-client/src/main/java/org/opensearch/client/opensearch/generic/GenericRequest.java new file mode 100644 index 0000000000..11cc85e852 --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/opensearch/generic/GenericRequest.java @@ -0,0 +1,172 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.generic; + +import static java.util.Collections.unmodifiableMap; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import javax.annotation.Nullable; +import org.opensearch.client.transport.GenericSerializable; + +/** + * Generic HTTP request to OpenSearch + */ +final class GenericRequest implements GenericSerializable, Request { + private final String method; + private final String endpoint; + private final Collection> headers; + private final Map parameters; + private final Body body; + + /** + * Create the {@linkplain GenericRequest}. + * @param method the HTTP method + * @param endpoint the path of the request (without scheme, host, port, or prefix) + * @param headers list of headers + */ + GenericRequest(String method, String endpoint, Collection> headers) { + this(method, endpoint, headers, Collections.emptyMap(), null); + } + + /** + * Create the {@linkplain GenericRequest}. + * @param method the HTTP method + * @param endpoint the path of the request (without scheme, host, port, or prefix) + * @param headers list of headers + * @param parameters query parameters + * @param body optional body + */ + GenericRequest( + String method, + String endpoint, + Collection> headers, + Map parameters, + @Nullable Body body + ) { + this.method = Objects.requireNonNull(method, "method cannot be null"); + this.endpoint = Objects.requireNonNull(endpoint, "endpoint cannot be null"); + this.headers = Objects.requireNonNull(headers, "headers cannot be null"); + this.parameters = Objects.requireNonNull(parameters, "parameters cannot be null"); + this.body = body; + } + + /** + * The HTTP method. + */ + @Override + public String getMethod() { + return method; + } + + /** + * The path of the request (without scheme, host, port, or prefix). + */ + @Override + public String getEndpoint() { + return endpoint; + } + + /** + * Query string parameters. The returned map is an unmodifiable view of the + * map in the request. + */ + @Override + public Map getParameters() { + return unmodifiableMap(parameters); + } + + @Override + public Collection> getHeaders() { + return Collections.unmodifiableCollection(headers); + } + + /** + * The body of the request. If {@code null} then no body + * is sent with the request. + */ + @Override + public Optional getBody() { + return Optional.ofNullable(body); + } + + /** + * Convert request to string representation + */ + @Override + public String toString() { + StringBuilder b = new StringBuilder(); + b.append("Request{"); + b.append("method='").append(method).append('\''); + b.append(", endpoint='").append(endpoint).append('\''); + if (false == parameters.isEmpty()) { + b.append(", params=").append(parameters); + } + if (body != null) { + b.append(", body=").append(body); + } + return b.append('}').toString(); + } + + /** + * Compare two requests for equality + * @param obj request instance to compare with + */ + @Override + public boolean equals(Object obj) { + if (obj == null || (obj.getClass() != getClass())) { + return false; + } + if (obj == this) { + return true; + } + + final GenericRequest other = (GenericRequest) obj; + return method.equals(other.method) + && endpoint.equals(other.endpoint) + && parameters.equals(other.parameters) + && headers.equals(other.headers) + && Objects.equals(body, other.body); + } + + /** + * Calculate the hash code of the request + */ + @Override + public int hashCode() { + return Objects.hash(method, endpoint, parameters, headers, body); + } + + @Override + public String serialize(OutputStream out) { + if (getBody().isPresent() == false) { + throw new IllegalStateException("The request has no content body provided"); + } + + final Body b = getBody().get(); + try (final InputStream in = b.body()) { + final byte[] buffer = new byte[Body.DEFAULT_BUFFER_SIZE]; + int read; + while ((read = in.read(buffer, 0, Body.DEFAULT_BUFFER_SIZE)) >= 0) { + out.write(buffer, 0, read); + } + } catch (final IOException ex) { + throw new UncheckedIOException(ex); + } + + return b.contentType(); + } +} diff --git a/java-client/src/main/java/org/opensearch/client/opensearch/generic/GenericResponse.java b/java-client/src/main/java/org/opensearch/client/opensearch/generic/GenericResponse.java new file mode 100644 index 0000000000..5a7a27cef8 --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/opensearch/generic/GenericResponse.java @@ -0,0 +1,116 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.generic; + +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Generic HTTP response from OpenSearch + */ +final class GenericResponse implements Response { + private final String protocol; + private final String method; + private final String uri; + private final int status; + private final String reason; + private final Collection> headers; + private final Body body; + + GenericResponse( + String uri, + String protocol, + String method, + int status, + String reason, + Collection> headers, + Body body + ) { + this.uri = Objects.requireNonNull(uri, "uri cannot be null"); + this.protocol = Objects.requireNonNull(protocol, "protocol cannot be null"); + this.method = Objects.requireNonNull(method, "method cannot be null"); + this.status = status; + this.reason = reason; + this.headers = Objects.requireNonNull(headers, "headers cannot be null"); + this.body = body; + } + + @Override + public Optional getBody() { + return Optional.ofNullable(body); + } + + @Override + public String getProtocol() { + return protocol; + } + + @Override + public String getMethod() { + return method; + } + + @Override + public String getReason() { + return reason; + } + + @Override + public int getStatus() { + return status; + } + + @Override + public String getUri() { + return uri; + } + + @Override + public Collection> getHeaders() { + return Collections.unmodifiableCollection(headers); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || (obj.getClass() != getClass())) { + return false; + } + if (obj == this) { + return true; + } + + final GenericResponse other = (GenericResponse) obj; + return method.equals(other.method) + && protocol.equals(other.protocol) + && uri.equals(other.uri) + && Objects.equals(reason, other.reason) + && headers.equals(other.headers) + && status == other.status + && Objects.equals(body, other.body); + } + + /** + * Calculate the hash code of the response + */ + @Override + public int hashCode() { + return Objects.hash(method, protocol, uri, status, reason, headers, body); + } + + @Override + public void close() throws IOException { + if (body != null) { + body.close(); + } + } +} diff --git a/java-client/src/main/java/org/opensearch/client/opensearch/generic/OpenSearchGenericClient.java b/java-client/src/main/java/org/opensearch/client/opensearch/generic/OpenSearchGenericClient.java new file mode 100644 index 0000000000..0d759af935 --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/opensearch/generic/OpenSearchGenericClient.java @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.generic; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import org.opensearch.client.ApiClient; +import org.opensearch.client.transport.OpenSearchTransport; +import org.opensearch.client.transport.TransportOptions; + +/** + * Client for the generic HTTP requests. + */ +public class OpenSearchGenericClient extends ApiClient { + /** + * Generic endpoint instance + */ + private static final class GenericEndpoint implements org.opensearch.client.transport.GenericEndpoint { + private final Request request; + + public GenericEndpoint(Request request) { + this.request = request; + } + + @Override + public String method(Request request) { + return request.getMethod(); + } + + @Override + public String requestUrl(Request request) { + return request.getEndpoint(); + } + + @Override + public boolean hasRequestBody() { + return request.getBody().isPresent(); + } + + @Override + public Map queryParameters(Request request) { + return request.getParameters(); + } + + @Override + public Map headers(Request request) { + return request.getHeaders().stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (e1, e2) -> e1)); + } + + @Override + public GenericResponse responseDeserializer( + String uri, + String method, + String protocol, + int status, + String reason, + List> headers, + String contentType, + InputStream body + ) { + return new GenericResponse(uri, protocol, method, status, reason, headers, Body.from(body, contentType)); + } + } + + public OpenSearchGenericClient(OpenSearchTransport transport) { + super(transport, null); + } + + public OpenSearchGenericClient(OpenSearchTransport transport, @Nullable TransportOptions transportOptions) { + super(transport, transportOptions); + } + + @Override + public OpenSearchGenericClient withTransportOptions(@Nullable TransportOptions transportOptions) { + return new OpenSearchGenericClient(this.transport, transportOptions); + } + + /** + * Executes generic HTTP request and returns generic HTTP response. + * @param request generic HTTP request + * @return generic HTTP response + * @throws IOException I/O exception + */ + public Response execute(Request request) throws IOException { + return transport.performRequest(request, new GenericEndpoint(request), this.transportOptions); + } + + /** + * Asynchronously executes generic HTTP request and returns generic HTTP response. + * @param request generic HTTP request + * @return generic HTTP response future + */ + public CompletableFuture executeAsync(Request request) { + return transport.performRequestAsync(request, new GenericEndpoint(request), this.transportOptions); + } +} diff --git a/java-client/src/main/java/org/opensearch/client/opensearch/generic/Request.java b/java-client/src/main/java/org/opensearch/client/opensearch/generic/Request.java new file mode 100644 index 0000000000..279c929d3a --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/opensearch/generic/Request.java @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.generic; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; + +/** + * Generic HTTP request to OpenSearch + */ +public interface Request { + + /** + * The HTTP method. + */ + String getMethod(); + + /** + * The path of the request (without scheme, host, port, or prefix). + * @return path of the request + */ + String getEndpoint(); + + /** + * Query string parameters. The returned map is an unmodifiable view of the + * map in the request. + * @return query string parameters + */ + Map getParameters(); + + /** + * List of headers + * @return list of headers + */ + Collection> getHeaders(); + + /** + * The optional body of the request. If {@code Optional.empty()} then no body + * is sent with the request. + * @return optional body of the request + */ + Optional getBody(); + +} diff --git a/java-client/src/main/java/org/opensearch/client/opensearch/generic/Requests.java b/java-client/src/main/java/org/opensearch/client/opensearch/generic/Requests.java new file mode 100644 index 0000000000..4c36ad089c --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/opensearch/generic/Requests.java @@ -0,0 +1,142 @@ +package org.opensearch.client.opensearch.generic; + +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nullable; +import org.opensearch.client.json.JsonpMapper; + +/** + * Helper class to construct requests instances + */ +public final class Requests { + private Requests() {} + + /** + * Creates a new builder for bodyless requests + * @return a new builder for bodyless requests + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a new builder for requests with JSON body + * @return a new builder for requests with JSON body + */ + public static JsonBodyBuilder builder(JsonpMapper mapper) { + return new JsonBodyBuilder(mapper); + } + + /** + * Create request instance + * @param method the HTTP method + * @param endpoint the path of the request (without scheme, host, port, or prefix) + * @param headers list of headers + * @param parameters query parameters + * @param body optional body + * @return request instance + */ + public static Request create( + String method, + String endpoint, + Collection> headers, + Map parameters, + @Nullable Body body + ) { + return new GenericRequest(method, endpoint, headers, parameters, body); + } + + /** + * A new builder for bodyless requests + */ + public static final class Builder { + private String method; + private String endpoint; + private Collection> headers = Collections.emptyList(); + private Map parameters = Collections.emptyMap(); + + private Builder() {} + + public Builder endpoint(final String endpoint) { + this.endpoint = Objects.requireNonNull(endpoint, "endpoint cannot be null"); + return this; + } + + public Builder query(final Map parameters) { + this.parameters = Objects.requireNonNull(parameters, "parameters cannot be null"); + return this; + } + + public Builder headers(final Collection> headers) { + this.headers = Objects.requireNonNull(headers, "headers cannot be null"); + return this; + } + + public Builder method(final String method) { + this.method = Objects.requireNonNull(method, "headers cannot be null"); + return this; + } + + public Request build() { + return new GenericRequest(method, endpoint, headers, parameters, null); + } + } + + /** + * A new builder for requests with JSON body + */ + public static final class JsonBodyBuilder { + private final JsonpMapper mapper; + private String method; + private String endpoint; + private Collection> headers = Collections.emptyList(); + private Map parameters = Collections.emptyMap(); + private Body body; + + private JsonBodyBuilder(final JsonpMapper mapper) { + this.mapper = Objects.requireNonNull(mapper, "mapper cannot be null"); + } + + public JsonBodyBuilder endpoint(final String endpoint) { + this.endpoint = Objects.requireNonNull(endpoint, "endpoint cannot be null"); + return this; + } + + public JsonBodyBuilder body(final Body body) { + this.body = Objects.requireNonNull(body, "body cannot be null"); + return this; + } + + public JsonBodyBuilder json(String str) { + this.body = Bodies.json(str); + return this; + } + + public JsonBodyBuilder json(C value) throws IOException { + this.body = Bodies.json(value, mapper); + return this; + } + + public JsonBodyBuilder query(final Map parameters) { + this.parameters = Objects.requireNonNull(parameters, "parameters cannot be null"); + return this; + } + + public JsonBodyBuilder headers(final Collection> headers) { + this.headers = Objects.requireNonNull(headers, "headers cannot be null"); + return this; + } + + public JsonBodyBuilder method(final String method) { + this.method = Objects.requireNonNull(method, "headers cannot be null"); + return this; + } + + public Request build() { + return new GenericRequest(method, endpoint, headers, parameters, body); + } + } +} diff --git a/java-client/src/main/java/org/opensearch/client/opensearch/generic/Response.java b/java-client/src/main/java/org/opensearch/client/opensearch/generic/Response.java new file mode 100644 index 0000000000..d119ad54ba --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/opensearch/generic/Response.java @@ -0,0 +1,67 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.generic; + +import java.io.IOException; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; + +/** + * Generic HTTP response from OpenSearch + */ +public interface Response extends AutoCloseable { + /** + * The optional body of the response. If {@code Optional.empty()} then no body + * was sent with the response. + * @return optional body of the response + */ + Optional getBody(); + + /** + * The HTTP protocol version + * @return HTTP protocol version + */ + String getProtocol(); + + /** + * The HTTP method + * @return HTTP method + */ + String getMethod(); + + /** + * The status message + * @return status message + */ + String getReason(); + + /** + * The status code + * @return status code + */ + int getStatus(); + + /** + * Full URI of the request + * @return full URI of the request + */ + String getUri(); + + /** + * List of headers + * @return list of headers + */ + Collection> getHeaders(); + + /** + * Releases all resources associated with this body stream. + */ + void close() throws IOException; +} diff --git a/java-client/src/main/java/org/opensearch/client/transport/GenericEndpoint.java b/java-client/src/main/java/org/opensearch/client/transport/GenericEndpoint.java new file mode 100644 index 0000000000..5616430909 --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/transport/GenericEndpoint.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.transport; + +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.opensearch.client.json.JsonpDeserializer; + +/** + * An endpoint with a raw, unparsed response body. The endpoind does not distinguish between errornous and successful response + * and always return the raw response body. + */ +public interface GenericEndpoint extends Endpoint { + default public boolean isError(int statusCode) { + return false; /* never return an error since errorDeserializer is JSON specific */ + } + + /** + * The error is never deserialized explicitly, represented as the instance of {@link ResponseT} instead. + */ + default public JsonpDeserializer errorDeserializer(int statusCode) { + return null; + } + + /** + * Constructs the {@link ResponseT} instance + * @param uri request URI + * @param method HTTP method + * @param protocol HTTP protocol version + * @param status status code + * @param reason reason phrase + * @param headers response headers + * @param body optional body + * @return the {@link ResponseT} instance + */ + ResponseT responseDeserializer( + final String uri, + final String method, + final String protocol, + int status, + final String reason, + final List> headers, + @Nullable final String contentType, + @Nullable final InputStream body + ); +} diff --git a/java-client/src/main/java/org/opensearch/client/transport/GenericSerializable.java b/java-client/src/main/java/org/opensearch/client/transport/GenericSerializable.java new file mode 100644 index 0000000000..14e07d7434 --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/transport/GenericSerializable.java @@ -0,0 +1,23 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.transport; + +import java.io.OutputStream; + +/** + * The request that takes care of serializing its body (content) into the {@link OutputStream} + */ +public interface GenericSerializable { + /** + * Serializes into the {@link OutputStream} and returns the content type + * @param out {@link OutputStream} to serialize into + * @return content type + */ + String serialize(OutputStream out); +} diff --git a/java-client/src/main/java/org/opensearch/client/transport/httpclient5/ApacheHttpClient5Transport.java b/java-client/src/main/java/org/opensearch/client/transport/httpclient5/ApacheHttpClient5Transport.java index 28be09ae05..74a72e804d 100644 --- a/java-client/src/main/java/org/opensearch/client/transport/httpclient5/ApacheHttpClient5Transport.java +++ b/java-client/src/main/java/org/opensearch/client/transport/httpclient5/ApacheHttpClient5Transport.java @@ -17,6 +17,7 @@ import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; +import java.util.AbstractMap; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -37,6 +38,7 @@ import java.util.concurrent.ConcurrentMap; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import java.util.zip.GZIPOutputStream; import javax.annotation.Nullable; import org.apache.commons.logging.Log; @@ -66,6 +68,7 @@ import org.apache.hc.core5.http.io.entity.HttpEntityWrapper; import org.apache.hc.core5.http.message.BasicHeader; import org.apache.hc.core5.http.message.RequestLine; +import org.apache.hc.core5.http.message.StatusLine; import org.apache.hc.core5.http.nio.AsyncRequestProducer; import org.apache.hc.core5.http.nio.AsyncResponseConsumer; import org.apache.hc.core5.net.URIBuilder; @@ -76,6 +79,8 @@ import org.opensearch.client.opensearch._types.ErrorResponse; import org.opensearch.client.opensearch._types.OpenSearchException; import org.opensearch.client.transport.Endpoint; +import org.opensearch.client.transport.GenericEndpoint; +import org.opensearch.client.transport.GenericSerializable; import org.opensearch.client.transport.JsonEndpoint; import org.opensearch.client.transport.OpenSearchTransport; import org.opensearch.client.transport.TransportException; @@ -535,15 +540,18 @@ private HttpUriRequestBase prepareLowLevelRequest( // Request has a body and must implement JsonpSerializable or NdJsonpSerializable ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ContentType contentType = JsonContentType; if (request instanceof NdJsonpSerializable) { writeNdJson((NdJsonpSerializable) request, baos); + } else if (request instanceof GenericSerializable) { + contentType = ContentType.parse(((GenericSerializable) request).serialize(baos)); } else { JsonGenerator generator = mapper.jsonProvider().createGenerator(baos); mapper.serialize(request, generator); generator.close(); } - addRequestBody(clientReq, new ByteArrayEntity(baos.toByteArray(), JsonContentType)); + addRequestBody(clientReq, new ByteArrayEntity(baos.toByteArray(), contentType)); } setHeaders(clientReq, options.headers()); @@ -630,6 +638,31 @@ private ResponseT decodeResponse( ; } return response; + } else if (endpoint instanceof GenericEndpoint) { + @SuppressWarnings("unchecked") + final GenericEndpoint rawEndpoint = (GenericEndpoint) endpoint; + + String contentType = null; + InputStream content = null; + if (entity != null) { + contentType = entity.getContentType(); + content = entity.getContent(); + } + + final RequestLine requestLine = clientResp.getRequestLine(); + final StatusLine statusLine = clientResp.getStatusLine(); + return rawEndpoint.responseDeserializer( + requestLine.getUri(), + requestLine.getMethod(), + requestLine.getProtocolVersion().format(), + statusLine.getStatusCode(), + statusLine.getReasonPhrase(), + Arrays.stream(clientResp.getHeaders()) + .map(h -> new AbstractMap.SimpleEntry(h.getName(), h.getValue())) + .collect(Collectors.toList()), + contentType, + content + ); } else { throw new TransportException("Unhandled endpoint type: '" + endpoint.getClass().getName() + "'"); } diff --git a/java-client/src/main/java/org/opensearch/client/transport/rest_client/RestClientTransport.java b/java-client/src/main/java/org/opensearch/client/transport/rest_client/RestClientTransport.java index e2a40a2fff..7e56409ef6 100644 --- a/java-client/src/main/java/org/opensearch/client/transport/rest_client/RestClientTransport.java +++ b/java-client/src/main/java/org/opensearch/client/transport/rest_client/RestClientTransport.java @@ -37,9 +37,12 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.util.AbstractMap; +import java.util.Arrays; import java.util.Iterator; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; import javax.annotation.Nullable; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.HttpEntity; @@ -47,6 +50,8 @@ import org.apache.hc.core5.http.io.entity.BufferedHttpEntity; import org.apache.hc.core5.http.io.entity.ByteArrayEntity; import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.message.RequestLine; +import org.apache.hc.core5.http.message.StatusLine; import org.opensearch.client.Cancellable; import org.opensearch.client.RequestOptions; import org.opensearch.client.Response; @@ -59,6 +64,8 @@ import org.opensearch.client.opensearch._types.ErrorResponse; import org.opensearch.client.opensearch._types.OpenSearchException; import org.opensearch.client.transport.Endpoint; +import org.opensearch.client.transport.GenericEndpoint; +import org.opensearch.client.transport.GenericSerializable; import org.opensearch.client.transport.JsonEndpoint; import org.opensearch.client.transport.OpenSearchTransport; import org.opensearch.client.transport.TransportException; @@ -207,15 +214,18 @@ private org.opensearch.client.Request prepareLowLevelRequest( // Request has a body and must implement JsonpSerializable or NdJsonpSerializable ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ContentType contentType = JsonContentType; if (request instanceof NdJsonpSerializable) { writeNdJson((NdJsonpSerializable) request, baos); + } else if (request instanceof GenericSerializable) { + contentType = ContentType.parse(((GenericSerializable) request).serialize(baos)); } else { JsonGenerator generator = mapper.jsonProvider().createGenerator(baos); mapper.serialize(request, generator); generator.close(); } - clientReq.setEntity(new ByteArrayEntity(baos.toByteArray(), JsonContentType)); + clientReq.setEntity(new ByteArrayEntity(baos.toByteArray(), contentType)); } // Request parameter intercepted by LLRC clientReq.addParameter("ignore", "400,401,403,404,405"); @@ -324,6 +334,31 @@ private ResponseT decodeResponse( ; } return response; + } else if (endpoint instanceof GenericEndpoint) { + @SuppressWarnings("unchecked") + final GenericEndpoint rawEndpoint = (GenericEndpoint) endpoint; + + String contentType = null; + InputStream content = null; + if (entity != null) { + contentType = entity.getContentType(); + content = entity.getContent(); + } + + final RequestLine requestLine = clientResp.getRequestLine(); + final StatusLine statusLine = clientResp.getStatusLine(); + return rawEndpoint.responseDeserializer( + requestLine.getUri(), + requestLine.getMethod(), + requestLine.getProtocolVersion().format(), + statusLine.getStatusCode(), + statusLine.getReasonPhrase(), + Arrays.stream(clientResp.getHeaders()) + .map(h -> new AbstractMap.SimpleEntry(h.getName(), h.getValue())) + .collect(Collectors.toList()), + contentType, + content + ); } else { throw new TransportException("Unhandled endpoint type: '" + endpoint.getClass().getName() + "'"); } diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/AbstractGenericClientIT.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/AbstractGenericClientIT.java new file mode 100644 index 0000000000..346c293208 --- /dev/null +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/AbstractGenericClientIT.java @@ -0,0 +1,225 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.integTest; + +import static org.hamcrest.CoreMatchers.equalTo; + +import java.io.IOException; +import java.util.Arrays; +import java.util.stream.Collectors; +import org.junit.Test; +import org.opensearch.client.json.JsonpDeserializer; +import org.opensearch.client.opensearch.core.SearchResponse; +import org.opensearch.client.opensearch.generic.Bodies; +import org.opensearch.client.opensearch.generic.Requests; +import org.opensearch.client.opensearch.generic.Response; +import org.opensearch.client.opensearch.indices.CreateIndexResponse; + +public abstract class AbstractGenericClientIT extends OpenSearchJavaClientTestCase { + + @Test + public void shouldReturnSearchResults() throws Exception { + final String index = "search_request"; + createIndex(index); + + try ( + Response response = javaClient().generic() + .execute( + Requests.builder(javaClient()._transport().jsonpMapper()) + .endpoint("/" + index + "/_search") + .method("POST") + .json( + "{" + + " \"sort\": [" + + " {" + + " \"name\": \"asc\"" + + " }" + + " ]," + + " \"fields\": [" + + " \"name\"" + + " ]," + + " \"query\": {" + + " \"bool\": {" + + " \"filter\": [" + + " {" + + " \"term\": {" + + " \"size\": \"huge\"" + + " }" + + " }" + + " ]" + + " }" + + " }," + + " \"_source\": true\n" + + "}" + ) + .build() + ) + ) { + assertThat(response.getStatus(), equalTo(200)); + assertThat(response.getBody().isPresent(), equalTo(true)); + + final SearchResponse r = Bodies.json( + response.getBody().get(), + SearchResponse.createSearchResponseDeserializer(JsonpDeserializer.of(ShopItem.class)), + javaClient()._transport().jsonpMapper() + ); + assertEquals(r.hits().hits().size(), 2); + + assertTrue( + Arrays.stream(r.hits().hits().get(0).fields().get("name").to(String[].class)) + .collect(Collectors.toList()) + .contains("hummer") + ); + + assertTrue( + Arrays.stream(r.hits().hits().get(1).fields().get("name").to(String[].class)) + .collect(Collectors.toList()) + .contains("jammer") + ); + } + } + + private void createTestDocuments(String index) throws IOException { + createTestDocument(index, "1", createItem("hummer", "huge", "yes", 2)); + createTestDocument(index, "2", createItem("jammer", "huge", "yes", 1)); + createTestDocument(index, "3", createItem("hammer", "large", "yes", 3)); + createTestDocument(index, "4", createItem("drill", "large", "yes", 3)); + createTestDocument(index, "5", createItem("jack", "medium", "yes", 2)); + createTestDocument(index, "6", createItem("wrench", "medium", "no", 3)); + createTestDocument(index, "7", createItem("screws", "small", "no", 1)); + createTestDocument(index, "8", createItem("nuts", "small", "no", 2)); + } + + private void createTestDocument(String index, String id, ShopItem document) throws IOException { + try ( + Response response = javaClient().generic() + .execute( + Requests.builder(javaClient()._transport().jsonpMapper()) + .endpoint("/" + index + "/_doc/" + id) + .method("PUT") + .json(document) + .build() + ) + ) { + assertThat(response.getStatus(), equalTo(201)); + assertThat(response.getBody().isPresent(), equalTo(true)); + } + } + + private void createIndex(String index) throws IOException { + try ( + Response response = javaClient().generic() + .execute( + Requests.builder(javaClient()._transport().jsonpMapper()) + .endpoint("/" + index) + .method("PUT") + .json( + "{" + + " \"settings\": {" + + " \"index\": {" + + " \"sort.field\": \"name\"," + + " \"sort.order\": \"asc\"" + + " }" + + " }," + + " \"mappings\": {" + + " \"properties\": {" + + " \"name\": {" + + " \"type\": \"keyword\"," + + " \"doc_values\": true" + + " }," + + " \"size\": {" + + " \"type\": \"keyword\"," + + " \"doc_values\": true" + + " }" + + " }" + + " }" + + "}" + ) + .build() + ) + ) { + assertThat(response.getStatus(), equalTo(200)); + assertThat(response.getBody().isPresent(), equalTo(true)); + + final CreateIndexResponse r = Bodies.json( + response.getBody().get(), + CreateIndexResponse._DESERIALIZER, + javaClient()._transport().jsonpMapper() + ); + assertThat(r.acknowledged(), equalTo(true)); + } + + createTestDocuments(index); + refreshIndex(index); + } + + private void refreshIndex(String index) throws IOException { + try ( + Response response = javaClient().generic() + .execute( + Requests.builder(javaClient()._transport().jsonpMapper()).endpoint("/" + index + "/_refresh").method("POST").build() + ) + ) { + assertThat(response.getStatus(), equalTo(200)); + assertThat(response.getBody().isPresent(), equalTo(true)); + } + } + + private ShopItem createItem(String name, String size, String company, int quantity) { + return new ShopItem(name, size, company, quantity); + } + + public static class ShopItem { + private String name; + private String size; + private String company; + private int quantity; + + public ShopItem() {} + + public ShopItem(String name, String size, String company, int quantity) { + this.name = name; + this.size = size; + this.company = company; + this.quantity = quantity; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSize() { + return size; + } + + public void setSize(String size) { + this.size = size; + } + + public String getCompany() { + return company; + } + + public void setCompany(String company) { + this.company = company; + } + + public int getQuantity() { + return quantity; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } + } +} diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/AbstractPingAndInfoIT.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/AbstractPingAndInfoIT.java index 9310764dba..d951799c33 100644 --- a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/AbstractPingAndInfoIT.java +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/AbstractPingAndInfoIT.java @@ -8,12 +8,17 @@ package org.opensearch.client.opensearch.integTest; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; + import java.io.IOException; +import java.util.Collections; import java.util.Map; -import org.apache.hc.client5.http.classic.methods.HttpGet; -import org.opensearch.client.Request; import org.opensearch.client.opensearch.OpenSearchClient; import org.opensearch.client.opensearch.core.InfoResponse; +import org.opensearch.client.opensearch.generic.Bodies; +import org.opensearch.client.opensearch.generic.Requests; +import org.opensearch.client.opensearch.generic.Response; import org.opensearch.client.transport.endpoints.BooleanResponse; public abstract class AbstractPingAndInfoIT extends OpenSearchJavaClientTestCase { @@ -27,19 +32,28 @@ public void testInfo() throws IOException { InfoResponse info = openSearchClient.info(); // compare with what the low level client outputs - Map infoAsMap = entityAsMap(adminClient().performRequest(new Request(HttpGet.METHOD_NAME, "/"))); - assertEquals(infoAsMap.get("cluster_name"), info.clusterName()); - assertEquals(infoAsMap.get("cluster_uuid"), info.clusterUuid()); - - @SuppressWarnings("unchecked") - Map versionMap = (Map) infoAsMap.get("version"); - assertEquals(versionMap.get("build_date"), info.version().buildDate()); - assertEquals(versionMap.get("build_flavor"), info.version().buildFlavor()); - assertEquals(versionMap.get("build_hash"), info.version().buildHash()); - assertEquals(versionMap.get("build_snapshot"), info.version().buildSnapshot()); - assertEquals(versionMap.get("build_type"), info.version().buildType()); - assertEquals(versionMap.get("distribution"), info.version().distribution()); - assertEquals(versionMap.get("lucene_version"), info.version().luceneVersion()); - assertTrue(versionMap.get("number").toString().startsWith(info.version().number())); + try (Response response = javaClient().generic().execute(Requests.builder().endpoint("/").method("GET").build())) { + assertThat(response.getStatus(), equalTo(200)); + assertThat(response.getProtocol(), equalTo("HTTP/1.1")); + assertThat(response.getBody().isEmpty(), is(false)); + + Map infoAsMap = response.getBody() + .map(b -> Bodies.json(b, Map.class, javaClient()._transport().jsonpMapper())) + .orElseGet(Collections::emptyMap); + + assertEquals(infoAsMap.get("cluster_name"), info.clusterName()); + assertEquals(infoAsMap.get("cluster_uuid"), info.clusterUuid()); + + @SuppressWarnings("unchecked") + Map versionMap = (Map) infoAsMap.get("version"); + assertEquals(versionMap.get("build_date"), info.version().buildDate()); + assertEquals(versionMap.get("build_flavor"), info.version().buildFlavor()); + assertEquals(versionMap.get("build_hash"), info.version().buildHash()); + assertEquals(versionMap.get("build_snapshot"), info.version().buildSnapshot()); + assertEquals(versionMap.get("build_type"), info.version().buildType()); + assertEquals(versionMap.get("distribution"), info.version().distribution()); + assertEquals(versionMap.get("lucene_version"), info.version().luceneVersion()); + assertTrue(versionMap.get("number").toString().startsWith(info.version().number())); + } } } diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/AbstractRequestIT.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/AbstractRequestIT.java index d78e124f51..f14fd42740 100644 --- a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/AbstractRequestIT.java +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/AbstractRequestIT.java @@ -91,7 +91,6 @@ import org.opensearch.client.opensearch.indices.IndexSettingsAnalysis; import org.opensearch.client.opensearch.indices.IndexState; import org.opensearch.client.opensearch.indices.Translog; -import org.opensearch.client.opensearch.model.ModelTestCase; import org.opensearch.client.transport.endpoints.BooleanResponse; public abstract class AbstractRequestIT extends OpenSearchJavaClientTestCase { @@ -342,7 +341,6 @@ public void testDataIngestion() throws Exception { public void testCatRequest() throws IOException { // Cat requests should have the "format=json" added by the transport NodesResponse nodes = javaClient().cat().nodes(_0 -> _0); - System.out.println(ModelTestCase.toJson(nodes, javaClient()._transport().jsonpMapper())); InfoResponse info = javaClient().info(); String version = info.version().number(); diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/httpclient5/GenericClientIT.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/httpclient5/GenericClientIT.java new file mode 100644 index 0000000000..e480b9f641 --- /dev/null +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/httpclient5/GenericClientIT.java @@ -0,0 +1,13 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.integTest.httpclient5; + +import org.opensearch.client.opensearch.integTest.AbstractGenericClientIT; + +public class GenericClientIT extends AbstractGenericClientIT implements HttpClient5TransportSupport {} diff --git a/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/restclient/GenericClientIT.java b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/restclient/GenericClientIT.java new file mode 100644 index 0000000000..ae486c20d3 --- /dev/null +++ b/java-client/src/test/java11/org/opensearch/client/opensearch/integTest/restclient/GenericClientIT.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.integTest.restclient; + +import java.io.IOException; +import org.apache.hc.core5.http.HttpHost; +import org.opensearch.client.json.jackson.JacksonJsonpMapper; +import org.opensearch.client.opensearch.integTest.AbstractGenericClientIT; +import org.opensearch.client.transport.OpenSearchTransport; +import org.opensearch.client.transport.rest_client.RestClientTransport; +import org.opensearch.common.settings.Settings; + +public class GenericClientIT extends AbstractGenericClientIT { + @Override + public OpenSearchTransport buildTransport(Settings settings, HttpHost[] hosts) throws IOException { + return new RestClientTransport(buildClient(settings, hosts), new JacksonJsonpMapper()); + } +}