diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 45f6540c..3d013efd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -49,12 +49,12 @@ jobs: # queries: security-extended,security-and-quality - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - - name: Autobuild - uses: github/codeql-action/autobuild@29b1f65c5e92e24fe6b6647da1eaabe529cec70f # v2.3.3 - with: - working-directory: ${{ matrix.working-directory }} - + + - name: Build + run: | + (cd functions-framework-api/ && mvn install) + (cd invoker/ && mvn clean install) + (cd function-maven-plugin && mvn install) - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@29b1f65c5e92e24fe6b6647da1eaabe529cec70f # v2.3.3 diff --git a/function-maven-plugin/pom.xml b/function-maven-plugin/pom.xml index ca3a98a5..eab73245 100644 --- a/function-maven-plugin/pom.xml +++ b/function-maven-plugin/pom.xml @@ -46,7 +46,7 @@ com.google.cloud.functions.invoker java-function-invoker - 1.2.1 + 1.2.3-SNAPSHOT diff --git a/functions-framework-api/pom.xml b/functions-framework-api/pom.xml index ebc27ba4..3bdeffa7 100644 --- a/functions-framework-api/pom.xml +++ b/functions-framework-api/pom.xml @@ -26,7 +26,7 @@ com.google.cloud.functions functions-framework-api - 1.0.5-SNAPSHOT + 1.0.6-SNAPSHOT UTF-8 diff --git a/functions-framework-api/src/main/java/com/google/cloud/functions/TypedFunction.java b/functions-framework-api/src/main/java/com/google/cloud/functions/TypedFunction.java new file mode 100644 index 00000000..16baf0b6 --- /dev/null +++ b/functions-framework-api/src/main/java/com/google/cloud/functions/TypedFunction.java @@ -0,0 +1,59 @@ +// Copyright 2019 Google LLC +// +// 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 com.google.cloud.functions; + +import java.lang.reflect.Type; + +/** + * Represents a Cloud Function with a strongly typed interface that is activated by an HTTP request. + */ +@FunctionalInterface +public interface TypedFunction { + /** + * Called to service an incoming HTTP request. This interface is implemented by user code to + * provide the action for a given HTTP function. If this method throws any exception (including + * any {@link Error}) then the HTTP response will have a 500 status code. + * + * @param arg the payload of the event, deserialized from the original JSON string. + * @return invocation result or null to indicate the body of the response should be empty. + * @throws Exception to produce a 500 status code in the HTTP response. + */ + public ResponseT apply(RequestT arg) throws Exception; + + /** + * Called to get the the format object that handles request decoding and response encoding. If + * null is returned a default JSON format is used. + * + * @return the {@link WireFormat} to use for serialization + */ + public default WireFormat getWireFormat() { + return null; + } + + /** + * Describes how to deserialize request object and serialize response objects for an HTTP + * invocation. + */ + public interface WireFormat { + /** Serialize is expected to encode the object to the provided HttpResponse. */ + void serialize(Object object, HttpResponse response) throws Exception; + + /** + * Deserialize is expected to read an object of {@code Type} from the HttpRequest. The Type is + * determined through reflection on the user's function. + */ + Object deserialize(HttpRequest request, Type type) throws Exception; + } +} diff --git a/invoker/conformance/pom.xml b/invoker/conformance/pom.xml index 553d688e..354b3568 100644 --- a/invoker/conformance/pom.xml +++ b/invoker/conformance/pom.xml @@ -4,12 +4,12 @@ java-function-invoker-parent com.google.cloud.functions.invoker - 1.2.2-SNAPSHOT + 1.2.3-SNAPSHOT com.google.cloud.functions.invoker conformance - 1.2.2-SNAPSHOT + 1.2.3-SNAPSHOT GCF Confromance Tests diff --git a/invoker/core/pom.xml b/invoker/core/pom.xml index 860c0b4c..dde36f33 100644 --- a/invoker/core/pom.xml +++ b/invoker/core/pom.xml @@ -4,12 +4,12 @@ com.google.cloud.functions.invoker java-function-invoker-parent - 1.2.2-SNAPSHOT + 1.2.3-SNAPSHOT com.google.cloud.functions.invoker java-function-invoker - 1.2.2-SNAPSHOT + 1.2.3-SNAPSHOT GCF Java Invoker Application that invokes a GCF Java function. This application is a @@ -44,6 +44,7 @@ com.google.cloud.functions functions-framework-api + 1.0.6-SNAPSHOT javax.servlet @@ -114,7 +115,7 @@ com.google.cloud.functions.invoker java-function-invoker-testfunction - 1.2.2-SNAPSHOT + 1.2.3-SNAPSHOT test-jar test diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java index 7a66fefd..21115666 100644 --- a/invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java @@ -17,7 +17,6 @@ import com.google.cloud.functions.HttpFunction; import com.google.cloud.functions.invoker.http.HttpRequestImpl; import com.google.cloud.functions.invoker.http.HttpResponseImpl; -import java.io.IOException; import java.util.logging.Level; import java.util.logging.Logger; import javax.servlet.http.HttpServlet; @@ -72,18 +71,7 @@ public void service(HttpServletRequest req, HttpServletResponse res) { res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } finally { Thread.currentThread().setContextClassLoader(oldContextLoader); - try { - // We can't use HttpServletResponse.flushBuffer() because we wrap the PrintWriter - // returned by HttpServletResponse in our own BufferedWriter to match our API. - // So we have to flush whichever of getWriter() or getOutputStream() works. - try { - respImpl.getOutputStream().flush(); - } catch (IllegalStateException e) { - respImpl.getWriter().flush(); - } - } catch (IOException e) { - // Too bad, can't flush. - } + respImpl.flush(); } } } diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/TypedFunctionExecutor.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/TypedFunctionExecutor.java new file mode 100644 index 00000000..a6edfc32 --- /dev/null +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/TypedFunctionExecutor.java @@ -0,0 +1,165 @@ +package com.google.cloud.functions.invoker; + +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; +import com.google.cloud.functions.TypedFunction; +import com.google.cloud.functions.TypedFunction.WireFormat; +import com.google.cloud.functions.invoker.http.HttpRequestImpl; +import com.google.cloud.functions.invoker.http.HttpResponseImpl; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class TypedFunctionExecutor extends HttpServlet { + private static final String APPLY_METHOD = "apply"; + private static final Logger logger = Logger.getLogger("com.google.cloud.functions.invoker"); + + private final Type argType; + private final TypedFunction function; + private final WireFormat format; + + private TypedFunctionExecutor( + Type argType, TypedFunction func, WireFormat format) { + this.argType = argType; + this.function = func; + this.format = format; + } + + public static TypedFunctionExecutor forClass(Class functionClass) { + if (!TypedFunction.class.isAssignableFrom(functionClass)) { + throw new RuntimeException( + "Class " + + functionClass.getName() + + " does not implement " + + TypedFunction.class.getName()); + } + @SuppressWarnings("unchecked") + Class> typedFunctionClass = + (Class>) functionClass.asSubclass(TypedFunction.class); + + Optional argType = handlerTypeArgument(typedFunctionClass); + if (argType.isEmpty()) { + throw new RuntimeException( + "Class " + + typedFunctionClass.getName() + + " does not implement " + + TypedFunction.class.getName()); + } + + TypedFunction typedFunction; + try { + typedFunction = typedFunctionClass.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException( + "Class " + + typedFunctionClass.getName() + + " must declare a valid default constructor to be usable as a strongly typed" + + " function. Could not use constructor: " + + e.toString()); + } + + WireFormat format = typedFunction.getWireFormat(); + if (format == null) { + format = LazyDefaultFormatHolder.defaultFormat; + } + + @SuppressWarnings("unchecked") + TypedFunctionExecutor executor = + new TypedFunctionExecutor( + argType.orElseThrow(), (TypedFunction) typedFunction, format); + return executor; + } + + /** + * Returns the {@code ReqT} of a concrete class that implements {@link TypedFunction + * TypedFunction}. Returns an empty {@link Optional} if {@code ReqT} can't be + * determined. + */ + static Optional handlerTypeArgument(Class> functionClass) { + return Arrays.stream(functionClass.getMethods()) + .filter(method -> method.getName().equals(APPLY_METHOD) && method.getParameterCount() == 1) + .map(method -> method.getGenericParameterTypes()[0]) + .filter(type -> type != Object.class) + .findFirst(); + } + + /** Executes the user's method, can handle all HTTP type methods. */ + @Override + public void service(HttpServletRequest req, HttpServletResponse res) { + HttpRequestImpl reqImpl = new HttpRequestImpl(req); + HttpResponseImpl resImpl = new HttpResponseImpl(res); + ClassLoader oldContextClassLoader = Thread.currentThread().getContextClassLoader(); + + try { + Thread.currentThread().setContextClassLoader(function.getClass().getClassLoader()); + handleRequest(reqImpl, resImpl); + } finally { + Thread.currentThread().setContextClassLoader(oldContextClassLoader); + resImpl.flush(); + } + } + + private void handleRequest(HttpRequest req, HttpResponse res) { + Object reqObj; + try { + reqObj = format.deserialize(req, argType); + } catch (Throwable t) { + logger.log(Level.SEVERE, "Failed to parse request for " + function.getClass().getName(), t); + res.setStatusCode(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + Object resObj; + try { + resObj = function.apply(reqObj); + } catch (Throwable t) { + logger.log(Level.SEVERE, "Failed to execute " + function.getClass().getName(), t); + res.setStatusCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + return; + } + + try { + format.serialize(resObj, res); + } catch (Throwable t) { + logger.log( + Level.SEVERE, "Failed to serialize response for " + function.getClass().getName(), t); + res.setStatusCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + return; + } + } + + private static class LazyDefaultFormatHolder { + static final WireFormat defaultFormat = new GsonWireFormat(); + } + + private static class GsonWireFormat implements TypedFunction.WireFormat { + private final Gson gson = new GsonBuilder().create(); + + @Override + public void serialize(Object object, HttpResponse response) throws Exception { + if (object == null) { + response.setStatusCode(HttpServletResponse.SC_NO_CONTENT); + return; + } + try (BufferedWriter bodyWriter = response.getWriter()) { + gson.toJson(object, bodyWriter); + } + } + + @Override + public Object deserialize(HttpRequest request, Type type) throws Exception { + try (BufferedReader bodyReader = request.getReader()) { + return gson.fromJson(bodyReader, type); + } + } + } +} diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpResponseImpl.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpResponseImpl.java index b455eb47..c02246f0 100644 --- a/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpResponseImpl.java +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpResponseImpl.java @@ -86,22 +86,32 @@ public OutputStream getOutputStream() throws IOException { @Override public synchronized BufferedWriter getWriter() throws IOException { if (writer == null) { - // Unfortunately this means that we get two intermediate objects between the - // object we return - // and the underlying Writer that response.getWriter() wraps. We could try - // accessing the - // PrintWriter.out field via reflection, but that sort of access to non-public - // fields of - // platform classes is now frowned on and may draw warnings or even fail in - // subsequent - // versions. - // We could instead wrap the OutputStream, but that would require us to deduce - // the appropriate - // Charset, using logic like this: + // Unfortunately this means that we get two intermediate objects between the object we return + // and the underlying Writer that response.getWriter() wraps. We could try accessing the + // PrintWriter.out field via reflection, but that sort of access to non-public fields of + // platform classes is now frowned on and may draw warnings or even fail in subsequent + // versions. We could instead wrap the OutputStream, but that would require us to deduce the + // appropriate Charset, using logic like this: // https://github.com/eclipse/jetty.project/blob/923ec38adf/jetty-server/src/main/java/org/eclipse/jetty/server/Response.java#L731 // We may end up doing that if performance is an issue. writer = new BufferedWriter(response.getWriter()); } return writer; } + + public void flush() { + try { + // We can't use HttpServletResponse.flushBuffer() because we wrap the + // PrintWriter returned by HttpServletResponse in our own BufferedWriter + // to match our API. So we have to flush whichever of getWriter() or + // getOutputStream() works. + try { + getOutputStream().flush(); + } catch (IllegalStateException e) { + getWriter().flush(); + } + } catch (IOException e) { + // Too bad, can't flush. + } + } } diff --git a/invoker/core/src/main/java/com/google/cloud/functions/invoker/runner/Invoker.java b/invoker/core/src/main/java/com/google/cloud/functions/invoker/runner/Invoker.java index d35e21ea..4936da4e 100644 --- a/invoker/core/src/main/java/com/google/cloud/functions/invoker/runner/Invoker.java +++ b/invoker/core/src/main/java/com/google/cloud/functions/invoker/runner/Invoker.java @@ -20,8 +20,10 @@ import com.beust.jcommander.Parameter; import com.beust.jcommander.ParameterException; import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.TypedFunction; import com.google.cloud.functions.invoker.BackgroundFunctionExecutor; import com.google.cloud.functions.invoker.HttpFunctionExecutor; +import com.google.cloud.functions.invoker.TypedFunctionExecutor; import com.google.cloud.functions.invoker.gcf.JsonLogHandler; import java.io.File; import java.io.IOException; @@ -81,9 +83,10 @@ public class Invoker { static { if (isGcf()) { - // If we're running with Google Cloud Functions, we'll get better-looking logs if we arrange - // for them to be formatted using StackDriver's "structured logging" JSON format. Remove the - // JDK's standard logger and replace it with the JSON one. + // If we're running with Google Cloud Functions, we'll get better-looking logs + // if we arrange for them to be formatted using StackDriver's "structured + // logging" JSON format. Remove the JDK's standard logger and replace it with + // the JSON one. for (Handler handler : rootLogger.getHandlers()) { rootLogger.removeHandler(handler); } @@ -252,11 +255,10 @@ public void startServer() throws Exception { *
{@code
    * // Create an invoker
    * Invoker invoker = new Invoker(
-   *         8081,
-   *         "org.example.MyHttpFunction",
-   *         "http",
-   *         Thread.currentThread().getContextClassLoader()
-   * );
+   *     8081,
+   *     "org.example.MyHttpFunction",
+   *     "http",
+   *     Thread.currentThread().getContextClassLoader());
    *
    * // Start the test server
    * invoker.startTestServer();
@@ -303,6 +305,9 @@ private void startServer(boolean join) throws Exception {
         case "cloudevent":
           servlet = BackgroundFunctionExecutor.forClass(functionClass);
           break;
+        case "typed":
+          servlet = TypedFunctionExecutor.forClass(functionClass);
+          break;
         default:
           String error =
               String.format(
@@ -350,9 +355,9 @@ private Class loadFunctionClass() throws ClassNotFoundException {
         if (firstException == null) {
           firstException = e;
         }
-        // This might be a nested class like com.example.Foo.Bar. That will actually appear as
-        // com.example.Foo$Bar as far as Class.forName is concerned. So we try to replace every dot
-        // from the last to the first with a $ in the hope of finding a class we can load.
+        // This might be a nested class like com.example.Foo.Bar. That will actually
+        // appear as com.example.Foo$Bar as far as Class.forName is concerned. So we try to replace
+        // every dot from the last to the first with a $ in the hope of finding a class we can load.
         int lastDot = target.lastIndexOf('.');
         if (lastDot < 0) {
           throw firstException;
@@ -366,6 +371,9 @@ private HttpServlet servletForDeducedSignatureType(Class functionClass) {
     if (HttpFunction.class.isAssignableFrom(functionClass)) {
       return HttpFunctionExecutor.forClass(functionClass);
     }
+    if (TypedFunction.class.isAssignableFrom(functionClass)) {
+      return TypedFunctionExecutor.forClass(functionClass);
+    }
     Optional maybeExecutor =
         BackgroundFunctionExecutor.maybeForClass(functionClass);
     if (maybeExecutor.isPresent()) {
@@ -432,8 +440,8 @@ private void logServerInfo() {
   }
 
   private static boolean isGcf() {
-    // This environment variable is set in the GCF environment but won't be set when invoking
-    // the Functions Framework directly. We don't use its value, just whether it is set.
+    // This environment variable is set in the GCF environment but won't be set when invoking the
+    // Functions Framework directly. We don't use its value, just whether it is set.
     return System.getenv("K_SERVICE") != null;
   }
 
@@ -509,7 +517,8 @@ private static boolean isCloudEventsApiClass(String name) {
 
     private static ClassLoader getSystemOrBootstrapClassLoader() {
       try {
-        // We're still building against the Java 8 API, so we have to use reflection for now.
+        // We're still building against the Java 8 API, so we have to use reflection for
+        // now.
         Method getPlatformClassLoader = ClassLoader.class.getMethod("getPlatformClassLoader");
         return (ClassLoader) getPlatformClassLoader.invoke(null);
       } catch (ReflectiveOperationException e) {
diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutorTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutorTest.java
index d00b0b4f..2b7211c9 100644
--- a/invoker/core/src/test/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutorTest.java
+++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/BackgroundFunctionExecutorTest.java
@@ -100,7 +100,6 @@ public void parseLegacyEventPubSubEmulator() throws IOException {
       assertThat(context.eventType()).isEqualTo("google.pubsub.topic.publish");
       assertThat(context.eventId()).isEqualTo("1");
       assertThat(context.timestamp()).isNotNull();
-      ;
 
       JsonObject data = event.getData().getAsJsonObject();
       assertThat(data.get("data").getAsString()).isEqualTo("eyJmb28iOiJiYXIifQ==");
diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/IntegrationTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/IntegrationTest.java
index 335cc7de..f84ddbdd 100644
--- a/invoker/core/src/test/java/com/google/cloud/functions/invoker/IntegrationTest.java
+++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/IntegrationTest.java
@@ -383,6 +383,46 @@ public void typedBackground() throws Exception {
     }
   }
 
+  @Test
+  public void typedFunction() throws Exception {
+    URL resourceUrl = getClass().getResource("/typed_nameconcat_request.json");
+    assertThat(resourceUrl).isNotNull();
+    String originalJson = Resources.toString(resourceUrl, StandardCharsets.UTF_8);
+    testFunction(
+        SignatureType.TYPED,
+        fullTarget("Typed"),
+        ImmutableList.of(),
+        ImmutableList.of(
+            TestCase.builder()
+                .setRequestText(originalJson)
+                .setExpectedResponseText("{\"fullName\":\"JohnDoe\"}")
+                .build()));
+  }
+
+  @Test
+  public void typedVoidFunction() throws Exception {
+    testFunction(
+        SignatureType.TYPED,
+        fullTarget("TypedVoid"),
+        ImmutableList.of(),
+        ImmutableList.of(
+            TestCase.builder().setRequestText("{}").setExpectedResponseCode(204).build()));
+  }
+
+  @Test
+  public void typedCustomFormat() throws Exception {
+    testFunction(
+        SignatureType.TYPED,
+        fullTarget("TypedCustomFormat"),
+        ImmutableList.of(),
+        ImmutableList.of(
+            TestCase.builder()
+                .setRequestText("abc\n123\n$#@\n")
+                .setExpectedResponseText("abc123$#@")
+                .setExpectedResponseCode(200)
+                .build()));
+  }
+
   private void backgroundTest(String target) throws Exception {
     File snoopFile = snoopFile();
     String gcfRequestText = sampleLegacyEvent(snoopFile);
@@ -571,6 +611,23 @@ public void classpathOptionBackground() throws Exception {
         ImmutableList.of(TestCase.builder().setRequestText(json.toString()).build()));
   }
 
+  /** Like {@link #classpathOptionHttp} but for typed functions. */
+  @Test
+  public void classpathOptionTyped() throws Exception {
+    URL resourceUrl = getClass().getResource("/typed_nameconcat_request.json");
+    assertThat(resourceUrl).isNotNull();
+    String originalJson = Resources.toString(resourceUrl, StandardCharsets.UTF_8);
+    testFunction(
+        SignatureType.TYPED,
+        "com.example.functionjar.Typed",
+        ImmutableList.of("--classpath", functionJarString()),
+        ImmutableList.of(
+            TestCase.builder()
+                .setRequestText(originalJson)
+                .setExpectedResponseText("{\"fullName\":\"JohnDoe\"}")
+                .build()));
+  }
+
   // In these tests, we test a number of different functions that express the same functionality
   // in different ways. Each function is invoked with a complete HTTP body that looks like a real
   // event. We start with a fixed body and insert into its JSON an extra property that tells the
@@ -659,7 +716,8 @@ private void testFunction(
   private enum SignatureType {
     HTTP("http"),
     BACKGROUND("event"),
-    CLOUD_EVENT("cloudevent");
+    CLOUD_EVENT("cloudevent"),
+    TYPED("typed");
 
     private final String name;
 
diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/TypedFunctionExecutorTest.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/TypedFunctionExecutorTest.java
new file mode 100644
index 00000000..969d1dcc
--- /dev/null
+++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/TypedFunctionExecutorTest.java
@@ -0,0 +1,37 @@
+package com.google.cloud.functions.invoker;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.cloud.functions.TypedFunction;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class TypedFunctionExecutorTest {
+  private static class NameConcatRequest {
+    String firstName;
+    String lastName;
+  }
+
+  private static class NameConcatResponse {
+    String fullName;
+  }
+
+  private static class NameConcatFunction
+      implements TypedFunction {
+    @Override
+    public NameConcatResponse apply(NameConcatRequest arg) throws Exception {
+      NameConcatResponse resp = new NameConcatResponse();
+      resp.fullName = arg.firstName + arg.lastName;
+      return resp;
+    }
+  }
+
+  @Test
+  public void canDetermineTypeArgument() {
+    assertThat(TypedFunctionExecutor.handlerTypeArgument(NameConcatFunction.class))
+        .hasValue(NameConcatRequest.class);
+  }
+}
diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Typed.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Typed.java
new file mode 100644
index 00000000..af1c9356
--- /dev/null
+++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/Typed.java
@@ -0,0 +1,25 @@
+package com.google.cloud.functions.invoker.testfunctions;
+
+import com.google.cloud.functions.TypedFunction;
+
+public class Typed implements TypedFunction {
+
+  @Override
+  public NameConcatResponse apply(NameConcatRequest arg) throws Exception {
+    return new NameConcatResponse().setFullName(arg.firstName + arg.lastName);
+  }
+}
+
+class NameConcatRequest {
+  String firstName;
+  String lastName;
+}
+
+class NameConcatResponse {
+  String fullName;
+
+  NameConcatResponse setFullName(String fullName) {
+    this.fullName = fullName;
+    return this;
+  }
+}
diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedCustomFormat.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedCustomFormat.java
new file mode 100644
index 00000000..4597f216
--- /dev/null
+++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedCustomFormat.java
@@ -0,0 +1,38 @@
+package com.google.cloud.functions.invoker.testfunctions;
+
+import com.google.cloud.functions.HttpRequest;
+import com.google.cloud.functions.HttpResponse;
+import com.google.cloud.functions.TypedFunction;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.List;
+
+public class TypedCustomFormat implements TypedFunction, String> {
+
+  @Override
+  public String apply(List arg) throws Exception {
+    return String.join("", arg);
+  }
+
+  @Override
+  public WireFormat getWireFormat() {
+    return new CustomFormat();
+  }
+}
+
+class CustomFormat implements TypedFunction.WireFormat {
+  @Override
+  public Object deserialize(HttpRequest request, Type type) throws Exception {
+    List req = new ArrayList<>();
+    String line;
+    while ((line = request.getReader().readLine()) != null) {
+      req.add(line);
+    }
+    return req;
+  }
+
+  @Override
+  public void serialize(Object object, HttpResponse response) throws Exception {
+    response.getWriter().write((String) object);
+  }
+}
diff --git a/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedVoid.java b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedVoid.java
new file mode 100644
index 00000000..8e0cd00a
--- /dev/null
+++ b/invoker/core/src/test/java/com/google/cloud/functions/invoker/testfunctions/TypedVoid.java
@@ -0,0 +1,12 @@
+package com.google.cloud.functions.invoker.testfunctions;
+
+import com.google.cloud.functions.TypedFunction;
+
+public class TypedVoid implements TypedFunction {
+  @Override
+  public Void apply(Request arg) throws Exception {
+    return null;
+  }
+}
+
+class Request {}
diff --git a/invoker/core/src/test/resources/typed_nameconcat_request.json b/invoker/core/src/test/resources/typed_nameconcat_request.json
new file mode 100644
index 00000000..f6b4d425
--- /dev/null
+++ b/invoker/core/src/test/resources/typed_nameconcat_request.json
@@ -0,0 +1,4 @@
+{
+  "firstName": "John",
+  "lastName": "Doe"
+}
\ No newline at end of file
diff --git a/invoker/pom.xml b/invoker/pom.xml
index 7c2b515e..498db704 100644
--- a/invoker/pom.xml
+++ b/invoker/pom.xml
@@ -10,7 +10,7 @@
 
   com.google.cloud.functions.invoker
   java-function-invoker-parent
-  1.2.2-SNAPSHOT
+  1.2.3-SNAPSHOT
   pom
   GCF Java Invoker Parent
   
diff --git a/invoker/testfunction/pom.xml b/invoker/testfunction/pom.xml
index d6571a6d..72a0126a 100644
--- a/invoker/testfunction/pom.xml
+++ b/invoker/testfunction/pom.xml
@@ -4,12 +4,12 @@
   
     com.google.cloud.functions.invoker
     java-function-invoker-parent
-    1.2.2-SNAPSHOT
+    1.2.3-SNAPSHOT
   
 
   com.google.cloud.functions.invoker
   java-function-invoker-testfunction
-  1.2.2-SNAPSHOT
+  1.2.3-SNAPSHOT
   Example GCF Function Jar
   
     An example of a GCF function packaged into a jar. We use this in tests.
@@ -19,6 +19,7 @@
     
       com.google.cloud.functions
       functions-framework-api
+      1.0.6-SNAPSHOT