handlerTypeArgument(Class extends TypedFunction, ?>> 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