From 6510bb901d121b93e2b72789160beae23d2cca12 Mon Sep 17 00:00:00 2001 From: jansupol <15908245+jansupol@users.noreply.github.com> Date: Mon, 25 Nov 2019 14:49:59 +0100 Subject: [PATCH] new InvocationBuilderListener SPI (#4313) * new InvocationBuilderListener SPI Signed-off-by: Jan Supol --- .../InvocationBuilderListenerStage.java | 193 ++++++++++++ .../jersey/client/JerseyWebTarget.java | 12 +- .../client/spi/InvocationBuilderListener.java | 284 ++++++++++++++++++ .../spi/InvocationBuilderListenerTest.java | 195 ++++++++++++ 4 files changed, 681 insertions(+), 3 deletions(-) create mode 100644 core-client/src/main/java/org/glassfish/jersey/client/InvocationBuilderListenerStage.java create mode 100644 core-client/src/main/java/org/glassfish/jersey/client/spi/InvocationBuilderListener.java create mode 100644 core-client/src/test/java/org/glassfish/jersey/client/spi/InvocationBuilderListenerTest.java diff --git a/core-client/src/main/java/org/glassfish/jersey/client/InvocationBuilderListenerStage.java b/core-client/src/main/java/org/glassfish/jersey/client/InvocationBuilderListenerStage.java new file mode 100644 index 0000000000..dc947c6611 --- /dev/null +++ b/core-client/src/main/java/org/glassfish/jersey/client/InvocationBuilderListenerStage.java @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2019 Oracle and/or its affiliates. All rights reserved. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + */ + +package org.glassfish.jersey.client; + +import org.glassfish.jersey.client.spi.InvocationBuilderListener; +import org.glassfish.jersey.internal.inject.InjectionManager; +import org.glassfish.jersey.internal.inject.Providers; +import org.glassfish.jersey.model.internal.RankedComparator; + +import javax.ws.rs.client.Invocation; +import javax.ws.rs.core.CacheControl; +import javax.ws.rs.core.Configuration; +import javax.ws.rs.core.Cookie; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import java.net.URI; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Client request processing stage. During a request creation, when the {@link Invocation.Builder} + * would be created, this class is utilized. + */ +/* package */ class InvocationBuilderListenerStage { + final Iterator invocationBuilderListenerIterator; + + /* package */ InvocationBuilderListenerStage(InjectionManager injectionManager) { + final RankedComparator comparator = + new RankedComparator<>(RankedComparator.Order.ASCENDING); + invocationBuilderListenerIterator = Providers + .getAllProviders(injectionManager, InvocationBuilderListener.class, comparator).iterator(); + } + + /* package */ void invokeListener(JerseyInvocation.Builder builder) { + while (invocationBuilderListenerIterator.hasNext()) { + invocationBuilderListenerIterator.next().onNewBuilder(new InvocationBuilderContextImpl(builder)); + } + } + + private static class InvocationBuilderContextImpl implements InvocationBuilderListener.InvocationBuilderContext { + private final JerseyInvocation.Builder builder; + + private InvocationBuilderContextImpl(JerseyInvocation.Builder builder) { + this.builder = builder; + } + + @Override + public InvocationBuilderListener.InvocationBuilderContext accept(String... mediaTypes) { + builder.accept(mediaTypes); + return this; + } + + @Override + public InvocationBuilderListener.InvocationBuilderContext accept(MediaType... mediaTypes) { + builder.accept(mediaTypes); + return this; + } + + @Override + public InvocationBuilderListener.InvocationBuilderContext acceptLanguage(Locale... locales) { + builder.acceptLanguage(locales); + return this; + } + + @Override + public InvocationBuilderListener.InvocationBuilderContext acceptLanguage(String... locales) { + builder.acceptLanguage(locales); + return this; + } + + @Override + public InvocationBuilderListener.InvocationBuilderContext acceptEncoding(String... encodings) { + builder.acceptEncoding(encodings); + return this; + } + + @Override + public InvocationBuilderListener.InvocationBuilderContext cookie(Cookie cookie) { + builder.cookie(cookie); + return this; + } + + @Override + public InvocationBuilderListener.InvocationBuilderContext cookie(String name, String value) { + builder.cookie(name, value); + return this; + } + + @Override + public InvocationBuilderListener.InvocationBuilderContext cacheControl(CacheControl cacheControl) { + builder.cacheControl(cacheControl); + return this; + } + + @Override + public List getAccepted() { + return getHeader(HttpHeaders.ACCEPT); + } + + @Override + public List getAcceptedLanguages() { + return getHeader(HttpHeaders.ACCEPT_LANGUAGE); + } + + @Override + public List getCacheControls() { + return (List) (List) builder.request().getHeaders().get(HttpHeaders.CACHE_CONTROL); + } + + @Override + public Configuration getConfiguration() { + return builder.request().getConfiguration(); + } + + @Override + public Map getCookies() { + return builder.request().getCookies(); + } + + @Override + public List getEncodings() { + return getHeader(HttpHeaders.ACCEPT_ENCODING); + } + + @Override + public List getHeader(String name) { + return builder.request().getRequestHeader(name); + } + + @Override + public MultivaluedMap getHeaders() { + return builder.request().getHeaders(); + } + + @Override + public Object getProperty(String name) { + return builder.request().getProperty(name); + } + + @Override + public Collection getPropertyNames() { + return builder.request().getPropertyNames(); + } + + @Override + public URI getUri() { + return builder.request().getUri(); + } + + + @Override + public InvocationBuilderListener.InvocationBuilderContext header(String name, Object value) { + builder.header(name, value); + return this; + } + + @Override + public InvocationBuilderListener.InvocationBuilderContext headers(MultivaluedMap headers) { + builder.headers(headers); + return this; + } + + @Override + public InvocationBuilderListener.InvocationBuilderContext property(String name, Object value) { + builder.property(name, value); + return this; + } + + @Override + public void removeProperty(String name) { + builder.request().removeProperty(name); + } + } +} + diff --git a/core-client/src/main/java/org/glassfish/jersey/client/JerseyWebTarget.java b/core-client/src/main/java/org/glassfish/jersey/client/JerseyWebTarget.java index 6c4f029fdc..ed986d74d9 100644 --- a/core-client/src/main/java/org/glassfish/jersey/client/JerseyWebTarget.java +++ b/core-client/src/main/java/org/glassfish/jersey/client/JerseyWebTarget.java @@ -188,14 +188,15 @@ private static void checkForNullValues(String name, Object[] values) { @Override public JerseyInvocation.Builder request() { checkNotClosed(); - return new JerseyInvocation.Builder(getUri(), config.snapshot()); + JerseyInvocation.Builder b = new JerseyInvocation.Builder(getUri(), config.snapshot()); + return onBuilder(b); } @Override public JerseyInvocation.Builder request(String... acceptedResponseTypes) { checkNotClosed(); JerseyInvocation.Builder b = new JerseyInvocation.Builder(getUri(), config.snapshot()); - b.request().accept(acceptedResponseTypes); + onBuilder(b).request().accept(acceptedResponseTypes); return b; } @@ -203,7 +204,7 @@ public JerseyInvocation.Builder request(String... acceptedResponseTypes) { public JerseyInvocation.Builder request(MediaType... acceptedResponseTypes) { checkNotClosed(); JerseyInvocation.Builder b = new JerseyInvocation.Builder(getUri(), config.snapshot()); - b.request().accept(acceptedResponseTypes); + onBuilder(b).request().accept(acceptedResponseTypes); return b; } @@ -358,4 +359,9 @@ public JerseyWebTarget preInitialize() { public String toString() { return "JerseyWebTarget { " + targetUri.toTemplate() + " }"; } + + private static JerseyInvocation.Builder onBuilder(JerseyInvocation.Builder builder) { + new InvocationBuilderListenerStage(builder.request().getInjectionManager()).invokeListener(builder); + return builder; + } } diff --git a/core-client/src/main/java/org/glassfish/jersey/client/spi/InvocationBuilderListener.java b/core-client/src/main/java/org/glassfish/jersey/client/spi/InvocationBuilderListener.java new file mode 100644 index 0000000000..d0cd24c311 --- /dev/null +++ b/core-client/src/main/java/org/glassfish/jersey/client/spi/InvocationBuilderListener.java @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2019 Oracle and/or its affiliates. All rights reserved. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + */ + +package org.glassfish.jersey.client.spi; + +import org.glassfish.jersey.Beta; +import org.glassfish.jersey.spi.Contract; + +import javax.ws.rs.ConstrainedTo; +import javax.ws.rs.RuntimeType; +import javax.ws.rs.client.ClientRequestContext; +import javax.ws.rs.client.Invocation; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.CacheControl; +import javax.ws.rs.core.Configuration; +import javax.ws.rs.core.Cookie; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import java.net.URI; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Implementations of this interface will be notified when a new Invocation.Builder + * is created. This will allow implementations to access the invocation builders, + * and is intended for global providers. For example, the Invocation.Builder properties can be + * accessed to set properties that are available on the {@link javax.ws.rs.client.ClientRequestContext}. + *

+ * In order for the InvocationBuilderListener to be called, the implementation of the interface needs + * to be registered on the {@code Client} the same way the {@code ClientRequestFilter} is registered, for instance. + * + * If multiple {@code InvocationBuilderListeners} are to be utilized, the order of execution is driven by the {@code Priority}, + * the lower the priority value, the higher the priority, the sooner the execution. + * + * @since 2.30 + */ +@Beta +@Contract +@ConstrainedTo(RuntimeType.CLIENT) +public interface InvocationBuilderListener { + + /** + * An {@link javax.ws.rs.client.Invocation.Builder} subset of setter methods. + */ + public interface InvocationBuilderContext { + /** + * Add the accepted response media types. + * + * @param mediaTypes accepted response media types. + * @return the updated context. + */ + InvocationBuilderContext accept(String... mediaTypes); + + /** + * Add the accepted response media types. + * + * @param mediaTypes accepted response media types. + * @return the updated context. + */ + InvocationBuilderContext accept(MediaType... mediaTypes); + + /** + * Add acceptable languages. + * + * @param locales an array of the acceptable languages. + * @return the updated context. + */ + InvocationBuilderContext acceptLanguage(Locale... locales); + + /** + * Add acceptable languages. + * + * @param locales an array of the acceptable languages. + * @return the updated context. + */ + InvocationBuilderContext acceptLanguage(String... locales); + + /** + * Add acceptable encodings. + * + * @param encodings an array of the acceptable encodings. + * @return the updated context. + */ + InvocationBuilderContext acceptEncoding(String... encodings); + + /** + * Add a cookie to be set. + * + * @param cookie to be set. + * @return the updated context. + */ + InvocationBuilderContext cookie(Cookie cookie); + + /** + * Add a cookie to be set. + * + * @param name the name of the cookie. + * @param value the value of the cookie. + * @return the updated context. + */ + InvocationBuilderContext cookie(String name, String value); + + /** + * Set the cache control data of the message. + * + * @param cacheControl the cache control directives, if {@code null} + * any existing cache control directives will be removed. + * @return the updated context. + */ + InvocationBuilderContext cacheControl(CacheControl cacheControl); + + /** + * Get the accepted response media types. + * + * @return accepted response media types. + */ + List getAccepted(); + + /** + * Get acceptable languages. + * + * @return acceptable languages. + */ + List getAcceptedLanguages(); + + /** + * Get the cache control data of the message. + * + * @return the cache control data of the message. + */ + List getCacheControls(); + + /** + * Get runtime configuration. + * + * @return runtime configuration. + */ + Configuration getConfiguration(); + + /** + * Get any cookies that accompanied the request. + * + * @return a read-only map of cookie name (String) to {@link javax.ws.rs.core.Cookie}. + */ + Map getCookies(); + + /** + * Get acceptable encodings. + * + * @return acceptable encodings. + */ + List getEncodings(); + + /** + * Get the values of a HTTP request header. The returned List is read-only. + * + * @param name the header name, case insensitive. + * @return a read-only list of header values. + */ + List getHeader(String name); + + /** + * Get the mutable message headers multivalued map. + * + * @return mutable multivalued map of message headers. + */ + MultivaluedMap getHeaders(); + + /** + * Returns the property with the given name registered in the current request/response + * exchange context, or {@code null} if there is no property by that name. + *

+ * A property allows filters and interceptors to exchange + * additional custom information not already provided by this interface. + *

+ *

+ * A list of supported properties can be retrieved using {@link #getPropertyNames()}. + * Custom property names should follow the same convention as package names. + *

+ * + * @param name a {@code String} specifying the name of the property. + * @return an {@code Object} containing the value of the property, or + * {@code null} if no property exists matching the given name. + * @see #getPropertyNames() + */ + Object getProperty(String name); + + /** + * Returns an immutable {@link Collection collection} containing the property names + * available within the context of the current request/response exchange context. + *

+ * Use the {@link #getProperty} method with a property name to get the value of + * a property. + *

+ * + * @return an immutable {@link Collection collection} of property names. + * @see #getProperty + */ + Collection getPropertyNames(); + + /** + * Get the request URI. + * + * @return request URI. + */ + URI getUri(); + + /** + * Add an arbitrary header. + * + * @param name the name of the header + * @param value the value of the header, the header will be serialized + * using a {@link javax.ws.rs.ext.RuntimeDelegate.HeaderDelegate} if + * one is available via {@link javax.ws.rs.ext.RuntimeDelegate#createHeaderDelegate(java.lang.Class)} + * for the class of {@code value} or using its {@code toString} method + * if a header delegate is not available. If {@code value} is {@code null} + * then all current headers of the same name will be removed. + * @return the updated context. + */ + InvocationBuilderContext header(String name, Object value); + + /** + * Replaces all existing headers with the newly supplied headers. + * + * @param headers new headers to be set, if {@code null} all existing + * headers will be removed. + * @return the updated context. + */ + InvocationBuilderContext headers(MultivaluedMap headers); + + /** + * Set a new property in the context of a request represented by this invocation builder. + *

+ * The property is available for a later retrieval via {@link ClientRequestContext#getProperty(String)} + * or {@link javax.ws.rs.ext.InterceptorContext#getProperty(String)}. + * If a property with a given name is already set in the request context, + * the existing value of the property will be updated. + * Setting a {@code null} value into a property effectively removes the property + * from the request property bag. + *

+ * + * @param name property name. + * @param value (new) property value. {@code null} value removes the property + * with the given name. + * @return the updated context. + * @see Invocation#property(String, Object) + */ + InvocationBuilderContext property(String name, Object value); + + /** + * Removes a property with the given name from the current request/response + * exchange context. After removal, subsequent calls to {@link #getProperty} + * to retrieve the property value will return {@code null}. + * + * @param name a {@code String} specifying the name of the property to be removed. + */ + void removeProperty(String name); + } + + /** + * Whenever an {@link Invocation.Builder} is created, (i.e. when + * {@link WebTarget#request()}, {@link WebTarget#request(String...)}, + * {@link WebTarget#request(MediaType...)} is called), this method would be invoked. + * + * @param context the updated {@link InvocationBuilderContext}. + */ + void onNewBuilder(InvocationBuilderContext context); + +} diff --git a/core-client/src/test/java/org/glassfish/jersey/client/spi/InvocationBuilderListenerTest.java b/core-client/src/test/java/org/glassfish/jersey/client/spi/InvocationBuilderListenerTest.java new file mode 100644 index 0000000000..ddb6e76fae --- /dev/null +++ b/core-client/src/test/java/org/glassfish/jersey/client/spi/InvocationBuilderListenerTest.java @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2019 Oracle and/or its affiliates. All rights reserved. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + */ + +package org.glassfish.jersey.client.spi; + +import org.glassfish.jersey.internal.PropertiesDelegate; +import org.hamcrest.Matchers; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import javax.annotation.Priority; +import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.ClientRequestContext; +import javax.ws.rs.client.ClientRequestFilter; +import javax.ws.rs.client.Entity; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.CacheControl; +import javax.ws.rs.core.Configuration; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.RuntimeDelegate; +import java.io.IOException; +import java.util.Date; +import java.util.Locale; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +public class InvocationBuilderListenerTest { + + private static final String PROPERTY_NAME = "test_property"; + private static final String ONE = "one"; + + private WebTarget target; + + @Before + public void setUp() { + target = ClientBuilder.newClient().target("http://localhost:8080").register(AbortRequestFilter.class) + .register(new PropertySetterInvocationBuilderListener(a -> a.property(key(ONE), ONE))); + } + + @Test + public void testRequest() throws ExecutionException, InterruptedException { + try (Response r = target.request().async().get().get()) { + assertDefault(r); + } + } + + @Test + public void testRequestString() { + try (Response r = target.request(MediaType.TEXT_HTML).build("GET").invoke()) { + assertDefault(r); + } + } + + @Test + public void testRequestMediaType() throws ExecutionException, InterruptedException { + try (Response r = target.request(MediaType.TEXT_PLAIN_TYPE).rx().get().toCompletableFuture().get()) { + assertDefault(r); + } + } + + @Test + public void testConfigurationProperties() { + String value = "OTHER_VALUE"; + try (Response r = target.property(key(ConfigurationInvocationBuilderListener.OTHER_PROPERTY), value) + .register(ConfigurationInvocationBuilderListener.class).request().get()) { + Assert.assertTrue( + r.readEntity(String.class).contains(key(ConfigurationInvocationBuilderListener.OTHER_PROPERTY) + "=" + value) + ); + } + } + + @Test + public void testGetters() { + try (Response r = target.register(SetterInvocationBuilderListener.class, 100) + .register(GetterInvocationBuilderListener.class, 200).request().get()) { + assertDefault(r); + } + } + + private void assertDefault(Response response) { + Assert.assertEquals(key(ONE) + "=" + ONE, response.readEntity(String.class)); + } + + private static String key(String keySuffix) { + return new StringBuilder().append(PROPERTY_NAME).append('_').append(keySuffix).toString(); + } + + public static class PropertySetterInvocationBuilderListener implements InvocationBuilderListener { + + private final Consumer builderConsumer; + + public PropertySetterInvocationBuilderListener(Consumer builderConsumer) { + this.builderConsumer = builderConsumer; + } + + @Override + public void onNewBuilder(InvocationBuilderContext context) { + builderConsumer.accept(context); + } + } + + public static class AbortRequestFilter implements ClientRequestFilter { + + @Override + public void filter(ClientRequestContext requestContext) throws IOException { + StringBuilder sb = new StringBuilder(); + for (String propertyName : requestContext.getPropertyNames()) { + if (propertyName.startsWith(PROPERTY_NAME)) { + sb.append(propertyName).append("=").append(requestContext.getProperty(propertyName)); + } + } + requestContext.abortWith(Response.ok().entity(sb.toString()).build()); + } + } + + public static class ConfigurationInvocationBuilderListener implements InvocationBuilderListener { + static final String OTHER_PROPERTY = "OTHER_PROPERTY"; + + @Override + public void onNewBuilder(InvocationBuilderContext context) { + context.property(key(OTHER_PROPERTY), context.getConfiguration().getProperty(key(OTHER_PROPERTY))); + } + } + + public static class SetterInvocationBuilderListener implements InvocationBuilderListener { + + @Override + public void onNewBuilder(InvocationBuilderContext context) { + context.accept(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON_PATCH_JSON_TYPE) + .acceptEncoding("GZIP") + .acceptLanguage(Locale.GERMAN) + .acceptLanguage(new Locale.Builder().setLanguage("sr").setScript("Latn").setRegion("RS").build()) + .property(PROPERTY_NAME, PROPERTY_NAME) + .cacheControl(CacheControl.valueOf(PROPERTY_NAME)) + .cookie("Cookie", "CookieValue") + .header(HttpHeaders.CONTENT_ID, PROPERTY_NAME); + } + } + + public static class GetterInvocationBuilderListener implements InvocationBuilderListener { + + @Override + public void onNewBuilder(InvocationBuilderContext context) { + Date date = new Date(); + RuntimeDelegate.HeaderDelegate localeDelegate = RuntimeDelegate.getInstance().createHeaderDelegate(Locale.class); + Assert.assertThat(context.getAccepted(), + Matchers.containsInAnyOrder(MediaType.APPLICATION_JSON, MediaType.APPLICATION_JSON_PATCH_JSON)); + Assert.assertThat(context.getEncodings(), Matchers.contains("GZIP")); + Assert.assertThat(context.getAcceptedLanguages(), + Matchers.containsInAnyOrder(localeDelegate.toString(Locale.GERMAN), + localeDelegate.toString( + new Locale.Builder().setLanguage("sr").setScript("Latn").setRegion("RS").build() + ) + ) + ); + + Assert.assertThat(context.getHeader(HttpHeaders.CONTENT_ID), Matchers.contains(PROPERTY_NAME)); + context.getHeaders().add(HttpHeaders.DATE, date); + Assert.assertThat(context.getHeader(HttpHeaders.DATE), Matchers.notNullValue()); + Assert.assertThat(context.getHeaders().getFirst(HttpHeaders.DATE), Matchers.is(date)); + + Assert.assertNotNull(context.getUri()); + Assert.assertTrue(context.getUri().toASCIIString().startsWith("http://")); + + Assert.assertThat(context.getPropertyNames(), Matchers.contains(PROPERTY_NAME)); + Assert.assertThat(context.getProperty(PROPERTY_NAME), Matchers.is(PROPERTY_NAME)); + context.removeProperty(PROPERTY_NAME); + Assert.assertTrue(context.getPropertyNames().isEmpty()); + + Assert.assertThat(context.getCacheControls().get(0).toString(), + Matchers.is(CacheControl.valueOf(PROPERTY_NAME).toString()) + ); + Assert.assertThat(context.getCookies().size(), Matchers.is(1)); + Assert.assertThat(context.getCookies().get("Cookie"), Matchers.notNullValue()); + } + } +}