diff --git a/api/src/main/java/org/eclipse/microprofile/rest/client/RestClientBuilder.java b/api/src/main/java/org/eclipse/microprofile/rest/client/RestClientBuilder.java index 7279c06..a28590f 100644 --- a/api/src/main/java/org/eclipse/microprofile/rest/client/RestClientBuilder.java +++ b/api/src/main/java/org/eclipse/microprofile/rest/client/RestClientBuilder.java @@ -269,6 +269,20 @@ default RestClientBuilder baseUri(String uri) { */ RestClientBuilder queryParamStyle(QueryParamStyle style); + /** + * Add an arbitrary header. + * + * @param name + * - the name of the header + * @param name + * - the value of the HTTP header to add to the request. + * @return the current builder with the header added to the request. + * @throws NullPointerException + * if the value is null. + * @since 4.0 + */ + RestClientBuilder header(String name, Object value); + /** * Based on the configured RestClientBuilder, creates a new instance of the given REST interface to invoke API calls * against. diff --git a/api/src/test/java/org/eclipse/microprofile/rest/client/BuilderImpl1.java b/api/src/test/java/org/eclipse/microprofile/rest/client/BuilderImpl1.java index 1a38d65..daac7ad 100644 --- a/api/src/test/java/org/eclipse/microprofile/rest/client/BuilderImpl1.java +++ b/api/src/test/java/org/eclipse/microprofile/rest/client/BuilderImpl1.java @@ -90,6 +90,11 @@ public RestClientBuilder queryParamStyle(QueryParamStyle style) { throw new IllegalStateException("not implemented"); } + @Override + public RestClientBuilder header(String name, Object value) { + throw new IllegalStateException("not implemented"); + } + @Override public T build(Class clazz) { throw new IllegalStateException("not implemented"); diff --git a/api/src/test/java/org/eclipse/microprofile/rest/client/BuilderImpl2.java b/api/src/test/java/org/eclipse/microprofile/rest/client/BuilderImpl2.java index 64051ba..83f9322 100644 --- a/api/src/test/java/org/eclipse/microprofile/rest/client/BuilderImpl2.java +++ b/api/src/test/java/org/eclipse/microprofile/rest/client/BuilderImpl2.java @@ -90,6 +90,11 @@ public RestClientBuilder queryParamStyle(QueryParamStyle style) { throw new IllegalStateException("not implemented"); } + @Override + public RestClientBuilder header(String name, Object value) { + throw new IllegalStateException("not implemented"); + } + @Override public T build(Class clazz) { throw new IllegalStateException("not implemented"); diff --git a/spec/src/main/asciidoc/clientexamples.asciidoc b/spec/src/main/asciidoc/clientexamples.asciidoc index 33e11ea..de26040 100644 --- a/spec/src/main/asciidoc/clientexamples.asciidoc +++ b/spec/src/main/asciidoc/clientexamples.asciidoc @@ -149,6 +149,19 @@ implementation must invoke the `DefaultClientHeadersFactoryImpl`. This default f `org.eclipse.microprofile.rest.client.propagateHeaders` +You can also configure headers on a per instance basis using the `RestClientBuilder.header(String name, Object value)` method. Headers added via this method will be merged with the headers added via `@ClientHeaderParam` annotations, `@HeaderParam` annotations, and `ClientHeadersFactory` implementations. +**Note: The method will throw a `NullPointerException` if the value is `null`.** + +Example: + +[source, java] +---- +RedirectClient client = RestClientBuilder.newBuilder() + .baseUri(someUri) + .header("Some-Header", headerValueObj) + .build(SomeClient.class); +---- + === Following Redirect Responses By default, a Rest Client instance will not automatically follow redirect responses. Redirect responses are typically responses with status codes in the 300 range and include `Location` header that indicates the URL of the redirected resource. diff --git a/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/ClientBuilderHeaderTest.java b/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/ClientBuilderHeaderTest.java new file mode 100644 index 0000000..fbe0e67 --- /dev/null +++ b/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/ClientBuilderHeaderTest.java @@ -0,0 +1,99 @@ +/* + * Copyright 2024 Contributors to the Eclipse Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.eclipse.microprofile.rest.client.tck; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.fail; + +import java.util.Arrays; +import java.util.stream.Collectors; + +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.eclipse.microprofile.rest.client.tck.interfaces.SimpleGetApi; +import org.eclipse.microprofile.rest.client.tck.providers.ReturnWithAllClientHeadersFilter; +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.shrinkwrap.api.Archive; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.Test; + +import com.github.tomakehurst.wiremock.client.MappingBuilder; + +public class ClientBuilderHeaderTest extends WiremockArquillianTest { + @Deployment + public static Archive createDeployment() { + return ShrinkWrap.create(WebArchive.class, ClientBuilderHeaderTest.class.getSimpleName() + ".war") + .addClasses( + SimpleGetApi.class, + ReturnWithAllClientHeadersFilter.class, + WiremockArquillianTest.class); + } + + private static void stub(String expectedHeaderName, String... expectedHeaderValue) { + String expectedIncomingHeader = Arrays.stream(expectedHeaderValue) + .collect(Collectors.joining(",")); + String outputBody = expectedIncomingHeader.replace(',', '-'); + MappingBuilder mappingBuilder = get(urlEqualTo("/")); + + // headers can be sent either in a single line with comma-separated values or in multiple lines + // this should match both cases: + Arrays.stream(expectedHeaderValue) + .forEach(val -> mappingBuilder.withHeader(expectedHeaderName, containing(val))); + stubFor( + mappingBuilder + .willReturn( + aResponse().withStatus(200) + .withBody(outputBody))); + } + @BeforeTest + public void resetWiremock() { + setupServer(); + } + + @Test + public void testHeaderBuilderMethod() { + stub("BuilderHeader", "BuilderHeaderValue"); + + RestClientBuilder builder = RestClientBuilder.newBuilder().baseUri(getServerURI()); + builder.header("BuilderHeader", "BuilderHeaderValue"); + SimpleGetApi client = builder.build(SimpleGetApi.class); + + assertEquals(client.executeGet(), + "BuilderHeaderValue"); + } + + @Test + public void testHeaderBuilderMethodNullValue() { + stub("BuilderHeader", "BuilderHeaderValue"); + + RestClientBuilder builder = RestClientBuilder.newBuilder().baseUri(getServerURI()); + try { + builder.header("BuilderHeader", null); + } catch (NullPointerException npe) { + return; + } + fail("header(\"builderHeader\", null) should have thrown a NullPointerException"); + } +} diff --git a/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/ClientHeaderParamTest.java b/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/ClientHeaderParamTest.java index 2fb8320..27930ce 100644 --- a/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/ClientHeaderParamTest.java +++ b/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/ClientHeaderParamTest.java @@ -25,9 +25,11 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; import static org.testng.Assert.fail; import java.util.Arrays; +import java.util.List; import java.util.stream.Collectors; import org.eclipse.microprofile.rest.client.RestClientBuilder; @@ -43,6 +45,7 @@ import com.github.tomakehurst.wiremock.client.MappingBuilder; +import jakarta.json.JsonArray; import jakarta.json.JsonObject; public class ClientHeaderParamTest extends WiremockArquillianTest { @@ -62,6 +65,8 @@ private static ClientHeaderParamClient client(Class... providers) { for (Class provider : providers) { builder.register(provider); } + builder.header("BuilderHeader", "BuilderHeaderValue") + .header("InterfaceAndBuilderHeader", "builder"); return builder.build(ClientHeaderParamClient.class); } catch (Throwable t) { t.printStackTrace(); @@ -177,6 +182,7 @@ public void testHeaderNotSentWhenExceptionThrownAndRequiredIsFalse() { assertEquals(headers.getString("OverrideableExplicit"), "overrideableInterfaceExplicit"); assertEquals(headers.getString("InterfaceHeaderComputed"), "interfaceComputed"); assertEquals(headers.getString("MethodHeaderExplicit"), "SomeValue"); + assertEquals(headers.getString("BuilderHeaderValue"), "BuilderHeaderValue"); } @Test @@ -192,4 +198,21 @@ public void testMultivaluedHeaderInterfaceExplicit() { assertEquals(client().methodComputeMultiValuedHeaderFromOtherClass(), "abc-xyz"); } + + @Test + public void testInterfaceAndBuilderHeaderBothExist() { + stub("InterfaceAndBuilderHeader", "builder", "interface", "method"); + + JsonObject headers = client(ReturnWithAllClientHeadersFilter.class).getAllHeaders(); + JsonArray header = headers.getJsonArray("InterfaceAndBuilderHeader"); + final List headerValues = + header.stream().map(v -> v.toString().toLowerCase()).collect(Collectors.toList()); + + assertTrue(headerValues.contains("builder"), + "Header InterfaceAndBuilderHeader did not container \"builder\": " + headers); + assertTrue(headerValues.contains("interface"), + "Header InterfaceAndBuilderHeader did not container \"interface\": " + headers); + assertTrue(headerValues.contains("method"), + "Header InterfaceAndBuilderHeader did not container \"method\": " + headers); + } } diff --git a/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/ClientHeadersFactoryTest.java b/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/ClientHeadersFactoryTest.java index 16cae3c..d94f43e 100644 --- a/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/ClientHeadersFactoryTest.java +++ b/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/ClientHeadersFactoryTest.java @@ -23,6 +23,8 @@ import static org.testng.Assert.assertTrue; import java.net.URI; +import java.util.List; +import java.util.stream.Collectors; import org.eclipse.microprofile.rest.client.RestClientBuilder; import org.eclipse.microprofile.rest.client.tck.ext.CustomClientHeadersFactory; @@ -35,6 +37,7 @@ import org.jboss.shrinkwrap.api.spec.WebArchive; import org.testng.annotations.Test; +import jakarta.json.JsonArray; import jakarta.json.JsonObject; public class ClientHeadersFactoryTest extends Arquillian { @@ -53,6 +56,8 @@ private static ClientHeadersFactoryClient client(Class... providers) { for (Class provider : providers) { builder.register(provider); } + builder.header("myHeader", "myHeaderValue") + .header("InterfaceAndBuilderHeader", "builder"); return builder.build(ClientHeadersFactoryClient.class); } catch (Throwable t) { t.printStackTrace(); @@ -79,5 +84,22 @@ public void testClientHeadersFactoryInvoked() { assertEquals(headers.getString("MethodHeader"), "methodValueModified"); assertEquals(headers.getString("ArgHeader"), "argValueModified"); assertEquals(headers.getString("FactoryHeader"), "factoryValue"); + assertEquals(headers.getString("BuilderHeader"), "BuilderHeaderValue"); + } + + @Test + public void testInterfaceAndBuilderHeaderBothExist() { + + JsonObject headers = client(ReturnWithAllClientHeadersFilter.class).getAllHeaders(); + JsonArray header = headers.getJsonArray("InterfaceAndBuilderHeader"); + final List headerValues = + header.stream().map(v -> v.toString().toLowerCase()).collect(Collectors.toList()); + + assertTrue(headerValues.contains("builder"), + "Header InterfaceAndBuilderHeader did not container \"builder\": " + headers); + assertTrue(headerValues.contains("interface"), + "Header InterfaceAndBuilderHeader did not container \"interface\": " + headers); + assertTrue(headerValues.contains("method"), + "Header InterfaceAndBuilderHeader did not container \"method\": " + headers); } } diff --git a/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/interfaces/ClientHeaderParamClient.java b/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/interfaces/ClientHeaderParamClient.java index 1cd6d14..435df27 100644 --- a/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/interfaces/ClientHeaderParamClient.java +++ b/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/interfaces/ClientHeaderParamClient.java @@ -31,6 +31,7 @@ @ClientHeaderParam(name = "OverrideableComputed", value = "{computeForInterface2}") @ClientHeaderParam(name = "OptionalInterfaceHeader", value = "{fail}", required = false) @ClientHeaderParam(name = "InterfaceMultiValuedHeaderExplicit", value = {"abc", "xyz"}) +@ClientHeaderParam(name = "InterfaceAndBuilderHeader", value = "interface") @Path("/") public interface ClientHeaderParamClient { @GET @@ -85,6 +86,10 @@ public interface ClientHeaderParamClient { // CHECKSTYLE:OFF String methodComputeMultiValuedHeaderFromOtherClass(); + @GET + @ClientHeaderParam(name = "InterfaceAndBuilderHeader", value = "method") + JsonObject getAllHeaders(); + default String computeForInterface() { return "interfaceComputed"; } diff --git a/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/interfaces/ClientHeadersFactoryClient.java b/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/interfaces/ClientHeadersFactoryClient.java index 662f90d..ad323c8 100644 --- a/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/interfaces/ClientHeadersFactoryClient.java +++ b/tck/src/main/java/org/eclipse/microprofile/rest/client/tck/interfaces/ClientHeadersFactoryClient.java @@ -24,6 +24,7 @@ import jakarta.json.JsonObject; import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.Path; @@ -34,4 +35,8 @@ public interface ClientHeadersFactoryClient { @DELETE @ClientHeaderParam(name = "MethodHeader", value = "methodValue") JsonObject delete(@HeaderParam("ArgHeader") String argHeader); + + @GET + @ClientHeaderParam(name = "InterfaceAndBuilderHeader", value = "method") + JsonObject getAllHeaders(); }