Add support for custom parameter types with `Optional<T>`

Closes #4798
diff --git a/core-common/src/main/java/org/glassfish/jersey/internal/inject/ParamConverterConfigurator.java b/core-common/src/main/java/org/glassfish/jersey/internal/inject/ParamConverterConfigurator.java
index affd12a..c5eff7f 100644
--- a/core-common/src/main/java/org/glassfish/jersey/internal/inject/ParamConverterConfigurator.java
+++ b/core-common/src/main/java/org/glassfish/jersey/internal/inject/ParamConverterConfigurator.java
@@ -32,7 +32,7 @@
     @Override
     public void init(InjectionManager injectionManager, BootstrapBag bootstrapBag) {
         InstanceBinding<ParamConverters.AggregatedProvider> aggregatedConverters =
-                Bindings.service(new ParamConverters.AggregatedProvider())
+                Bindings.service(new ParamConverters.AggregatedProvider(injectionManager))
                     .to(ParamConverterProvider.class);
         injectionManager.register(aggregatedConverters);
     }
diff --git a/core-common/src/main/java/org/glassfish/jersey/internal/inject/ParamConverters.java b/core-common/src/main/java/org/glassfish/jersey/internal/inject/ParamConverters.java
index 3f39654..e4249b8 100644
--- a/core-common/src/main/java/org/glassfish/jersey/internal/inject/ParamConverters.java
+++ b/core-common/src/main/java/org/glassfish/jersey/internal/inject/ParamConverters.java
@@ -21,11 +21,11 @@
 import java.lang.reflect.Constructor;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
-import java.lang.reflect.ParameterizedType;
 import java.lang.reflect.Type;
 import java.security.AccessController;
 import java.text.ParseException;
 import java.util.Date;
+import java.util.List;
 import java.util.Optional;
 
 import javax.inject.Inject;
@@ -37,6 +37,7 @@
 
 import org.glassfish.jersey.internal.LocalizationMessages;
 import org.glassfish.jersey.internal.util.ReflectionHelper;
+import org.glassfish.jersey.internal.util.collection.ClassTypePair;
 import org.glassfish.jersey.message.internal.HttpDateFormat;
 
 /**
@@ -257,11 +258,11 @@
     public static class OptionalProvider implements ParamConverterProvider {
 
         // Delegates to this provider when the type of Optional is extracted.
-        private final AggregatedProvider aggregated;
+        private final InjectionManager manager;
 
         @Inject
-        public OptionalProvider(AggregatedProvider aggregated) {
-            this.aggregated = aggregated;
+        public OptionalProvider(InjectionManager manager) {
+            this.manager = manager;
         }
 
         @Override
@@ -273,18 +274,20 @@
                     if (value == null) {
                         return (T) Optional.empty();
                     } else {
-                        ParameterizedType parametrized = (ParameterizedType) genericType;
-                        Type type = parametrized.getActualTypeArguments()[0];
-                        T val = aggregated.getConverter((Class<T>) type, type, annotations).fromString(value.toString());
-                        if (val != null) {
-                            return (T) Optional.of(val);
-                        } else {
-                            /*
-                             *  In this case we don't send Optional.empty() because 'value' is not null.
-                             *  But we return null because the provider didn't find how to parse it.
-                             */
-                            return null;
+                        final List<ClassTypePair> ctps = ReflectionHelper.getTypeArgumentAndClass(genericType);
+                        final ClassTypePair ctp = (ctps.size() == 1) ? ctps.get(0) : null;
+
+                        for (ParamConverterProvider provider : Providers.getProviders(manager, ParamConverterProvider.class)) {
+                            final ParamConverter<?> converter = provider.getConverter(ctp.rawClass(), ctp.type(), annotations);
+                            if (converter != null) {
+                                return (T) Optional.of(value).map(s -> converter.fromString(value));
+                            }
                         }
+                        /*
+                         *  In this case we don't send Optional.empty() because 'value' is not null.
+                         *  But we return null because the provider didn't find how to parse it.
+                         */
+                        return null;
                     }
                 }
 
@@ -313,8 +316,8 @@
         /**
          * Create new aggregated {@link ParamConverterProvider param converter provider}.
          */
-        public AggregatedProvider() {
-            providers = new ParamConverterProvider[] {
+        public AggregatedProvider(InjectionManager manager) {
+            this.providers = new ParamConverterProvider[] {
                     // ordering is important (e.g. Date provider must be executed before String Constructor
                     // as Date has a deprecated String constructor
                     new DateProvider(),
@@ -323,7 +326,7 @@
                     new CharacterProvider(),
                     new TypeFromString(),
                     new StringConstructor(),
-                    new OptionalProvider(this)
+                    new OptionalProvider(manager)
             };
         }
 
diff --git a/core-server/src/test/java/org/glassfish/jersey/server/internal/inject/ParamConverterInternalTest.java b/core-server/src/test/java/org/glassfish/jersey/server/internal/inject/ParamConverterInternalTest.java
index 85e647b..e6546de 100644
--- a/core-server/src/test/java/org/glassfish/jersey/server/internal/inject/ParamConverterInternalTest.java
+++ b/core-server/src/test/java/org/glassfish/jersey/server/internal/inject/ParamConverterInternalTest.java
@@ -285,7 +285,7 @@
     public void testDateParamConverterIsChosenForDateString() {
         initiateWebApplication();
         final ParamConverter<Date> converter =
-                new ParamConverters.AggregatedProvider().getConverter(Date.class, Date.class, null);
+                new ParamConverters.AggregatedProvider(null).getConverter(Date.class, Date.class, null);
 
         assertEquals("Unexpected date converter provider class",
                 ParamConverters.DateProvider.class, converter.getClass().getEnclosingClass());
diff --git a/tests/e2e-server/src/test/java/org/glassfish/jersey/tests/e2e/server/OptionalParamConverterTest.java b/tests/e2e-server/src/test/java/org/glassfish/jersey/tests/e2e/server/OptionalParamConverterTest.java
index ebbc92a..5306b21 100644
--- a/tests/e2e-server/src/test/java/org/glassfish/jersey/tests/e2e/server/OptionalParamConverterTest.java
+++ b/tests/e2e-server/src/test/java/org/glassfish/jersey/tests/e2e/server/OptionalParamConverterTest.java
@@ -16,6 +16,11 @@
 
 package org.glassfish.jersey.tests.e2e.server;
 
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Type;
+import java.text.ParseException;
+import java.time.Instant;
+import java.util.Date;
 import java.util.List;
 import java.util.Optional;
 
@@ -24,12 +29,18 @@
 import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.Application;
 import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.ParamConverter;
+import javax.ws.rs.ext.ParamConverterProvider;
+import javax.ws.rs.ext.Provider;
 
+import org.glassfish.jersey.internal.LocalizationMessages;
+import org.glassfish.jersey.internal.inject.ExtractorException;
 import org.glassfish.jersey.server.ResourceConfig;
 import org.glassfish.jersey.test.JerseyTest;
 import org.junit.Test;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 
 public class OptionalParamConverterTest extends JerseyTest {
 
@@ -51,6 +62,18 @@
         }
 
         @GET
+        @Path("/fromDate")
+        public Response fromDate(@QueryParam(PARAM_NAME) Optional<Date> data) throws ParseException {
+            return Response.ok(data.orElse(new Date(1609459200000L))).build();
+        }
+
+        @GET
+        @Path("/fromInstant")
+        public Response fromInstant(@QueryParam(PARAM_NAME) Optional<Instant> data) {
+            return Response.ok(data.orElse(Instant.parse("2021-01-01T00:00:00Z")).toString()).build();
+        }
+
+        @GET
         @Path("/fromList")
         public Response fromList(@QueryParam(PARAM_NAME) List<Optional<Integer>> data) {
             StringBuilder builder = new StringBuilder("");
@@ -61,9 +84,41 @@
         }
     }
 
+    @Provider
+    public static class InstantParamConverterProvider implements ParamConverterProvider {
+        @Override
+        public <T> ParamConverter<T> getConverter(Class<T> rawType, Type genericType, Annotation[] annotations) {
+            if (rawType.equals(Instant.class)) {
+                return new ParamConverter<T>() {
+                    @Override
+                    public T fromString(String value) {
+                        if (value == null) {
+                            throw new IllegalArgumentException(LocalizationMessages.METHOD_PARAMETER_CANNOT_BE_NULL("value"));
+                        }
+                        try {
+                            return rawType.cast(Instant.parse(value));
+                        } catch (Exception e) {
+                            throw new ExtractorException(e);
+                        }
+                    }
+
+                    @Override
+                    public String toString(T value) {
+                        if (value == null) {
+                            throw new IllegalArgumentException();
+                        }
+                        return value.toString();
+                    }
+                };
+            } else {
+                return null;
+            }
+        }
+    }
+
     @Override
     protected Application configure() {
-        return new ResourceConfig(OptionalResource.class);
+        return new ResourceConfig(OptionalResource.class, InstantParamConverterProvider.class);
     }
 
     @Test
@@ -77,22 +132,81 @@
     }
 
     @Test
-    public void fromOptionalInt() {
-        Response empty = target("/OptionalResource/fromInteger").request().get();
+    public void fromOptionalInteger() {
+        Response missing = target("/OptionalResource/fromInteger").request().get();
+        Response empty = target("/OptionalResource/fromInteger").queryParam(PARAM_NAME, "").request().get();
         Response notEmpty = target("/OptionalResource/fromInteger").queryParam(PARAM_NAME, 1).request().get();
+        Response invalid = target("/OptionalResource/fromInteger").queryParam(PARAM_NAME, "invalid").request().get();
+        assertEquals(200, missing.getStatus());
+        assertEquals(Integer.valueOf(0), missing.readEntity(Integer.class));
         assertEquals(200, empty.getStatus());
         assertEquals(Integer.valueOf(0), empty.readEntity(Integer.class));
         assertEquals(200, notEmpty.getStatus());
         assertEquals(Integer.valueOf(1), notEmpty.readEntity(Integer.class));
+        assertEquals(404, invalid.getStatus());
+        assertFalse(invalid.hasEntity());
+    }
+
+    @Test
+    public void fromOptionalDate() {
+        Response missing = target("/OptionalResource/fromDate").request().get();
+        Response empty = target("/OptionalResource/fromDate").queryParam(PARAM_NAME, "").request().get();
+        Response notEmpty = target("/OptionalResource/fromDate").queryParam(PARAM_NAME, "Sat, 01 May 2021 12:00:00 GMT")
+                .request().get();
+        Response invalid = target("/OptionalResource/fromDate").queryParam(PARAM_NAME, "invalid").request().get();
+        assertEquals(200, missing.getStatus());
+        assertEquals(new Date(1609459200000L), missing.readEntity(Date.class));
+        assertEquals(404, empty.getStatus());
+        assertFalse(empty.hasEntity());
+        assertEquals(200, notEmpty.getStatus());
+        assertEquals(new Date(1619870400000L), notEmpty.readEntity(Date.class));
+        assertEquals(404, invalid.getStatus());
+        assertFalse(invalid.hasEntity());
+    }
+
+    @Test
+    public void fromOptionalInstant() {
+        Response missing = target("/OptionalResource/fromInstant").request().get();
+        Response empty = target("/OptionalResource/fromInstant").queryParam(PARAM_NAME, "").request().get();
+        Response notEmpty = target("/OptionalResource/fromInstant").queryParam(PARAM_NAME, "2021-05-01T12:00:00Z")
+                .request().get();
+        Response invalid = target("/OptionalResource/fromInstant").queryParam(PARAM_NAME, "invalid").request().get();
+        assertEquals(200, missing.getStatus());
+        assertEquals("2021-01-01T00:00:00Z", missing.readEntity(String.class));
+        assertEquals(404, empty.getStatus());
+        assertFalse(empty.hasEntity());
+        assertEquals(200, notEmpty.getStatus());
+        assertEquals("2021-05-01T12:00:00Z", notEmpty.readEntity(String.class));
+        assertEquals(404, invalid.getStatus());
+        assertFalse(invalid.hasEntity());
     }
 
     @Test
     public void fromOptionalList() {
-        Response empty = target("/OptionalResource/fromList").request().get();
-        Response notEmpty = target("/OptionalResource/fromList").queryParam(PARAM_NAME, 1)
+        Response missing = target("/OptionalResource/fromList").request().get();
+        Response empty = target("/OptionalResource/fromList")
+                .queryParam(PARAM_NAME, "").request().get();
+        Response partiallyEmpty = target("/OptionalResource/fromList")
+                .queryParam(PARAM_NAME, 1)
+                .queryParam(PARAM_NAME, "").request().get();
+        Response invalid = target("/OptionalResource/fromList")
+                .queryParam(PARAM_NAME, "invalid").request().get();
+        Response partiallyInvalid = target("/OptionalResource/fromList")
+                .queryParam(PARAM_NAME, 1)
+                .queryParam(PARAM_NAME, "invalid").request().get();
+        Response notEmpty = target("/OptionalResource/fromList")
+                .queryParam(PARAM_NAME, 1)
                 .queryParam(PARAM_NAME, 2).request().get();
+        assertEquals(200, missing.getStatus());
+        assertEquals("", missing.readEntity(String.class));
         assertEquals(200, empty.getStatus());
-        assertEquals("", empty.readEntity(String.class));
+        assertEquals("0", empty.readEntity(String.class));
+        assertEquals(200, partiallyEmpty.getStatus());
+        assertEquals("10", partiallyEmpty.readEntity(String.class));
+        assertEquals(404, invalid.getStatus());
+        assertFalse(invalid.hasEntity());
+        assertEquals(404, partiallyInvalid.getStatus());
+        assertFalse(partiallyInvalid.hasEntity());
         assertEquals(200, notEmpty.getStatus());
         assertEquals("12", notEmpty.readEntity(String.class));
     }