Skip to content

Commit

Permalink
feat: Define strongly typed function interface (#186)
Browse files Browse the repository at this point in the history
Introduces a new Typed signature to the functions framework which provides automatic request deserialization and response serialization.
  • Loading branch information
garethgeorge authored May 23, 2023
1 parent d6396d1 commit 5264e35
Show file tree
Hide file tree
Showing 20 changed files with 488 additions and 57 deletions.
12 changes: 6 additions & 6 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion function-maven-plugin/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
<dependency>
<groupId>com.google.cloud.functions.invoker</groupId>
<artifactId>java-function-invoker</artifactId>
<version>1.2.1</version>
<version>1.2.3-SNAPSHOT</version>
</dependency>

<dependency>
Expand Down
2 changes: 1 addition & 1 deletion functions-framework-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

<groupId>com.google.cloud.functions</groupId>
<artifactId>functions-framework-api</artifactId>
<version>1.0.5-SNAPSHOT</version>
<version>1.0.6-SNAPSHOT</version>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RequestT, ResponseT> {
/**
* 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;
}
}
4 changes: 2 additions & 2 deletions invoker/conformance/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
<parent>
<artifactId>java-function-invoker-parent</artifactId>
<groupId>com.google.cloud.functions.invoker</groupId>
<version>1.2.2-SNAPSHOT</version>
<version>1.2.3-SNAPSHOT</version>
</parent>

<groupId>com.google.cloud.functions.invoker</groupId>
<artifactId>conformance</artifactId>
<version>1.2.2-SNAPSHOT</version>
<version>1.2.3-SNAPSHOT</version>

<name>GCF Confromance Tests</name>
<description>
Expand Down
7 changes: 4 additions & 3 deletions invoker/core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
<parent>
<groupId>com.google.cloud.functions.invoker</groupId>
<artifactId>java-function-invoker-parent</artifactId>
<version>1.2.2-SNAPSHOT</version>
<version>1.2.3-SNAPSHOT</version>
</parent>

<groupId>com.google.cloud.functions.invoker</groupId>
<artifactId>java-function-invoker</artifactId>
<version>1.2.2-SNAPSHOT</version>
<version>1.2.3-SNAPSHOT</version>
<name>GCF Java Invoker</name>
<description>
Application that invokes a GCF Java function. This application is a
Expand Down Expand Up @@ -44,6 +44,7 @@
<dependency>
<groupId>com.google.cloud.functions</groupId>
<artifactId>functions-framework-api</artifactId>
<version>1.0.6-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
Expand Down Expand Up @@ -114,7 +115,7 @@
<dependency>
<groupId>com.google.cloud.functions.invoker</groupId>
<artifactId>java-function-invoker-testfunction</artifactId>
<version>1.2.2-SNAPSHOT</version>
<version>1.2.3-SNAPSHOT</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Object, Object> function;
private final WireFormat format;

private TypedFunctionExecutor(
Type argType, TypedFunction<Object, Object> 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<? extends TypedFunction<?, ?>> typedFunctionClass =
(Class<? extends TypedFunction<?, ?>>) functionClass.asSubclass(TypedFunction.class);

Optional<Type> 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<Object, Object>) typedFunction, format);
return executor;
}

/**
* Returns the {@code ReqT} of a concrete class that implements {@link TypedFunction
* TypedFunction<ReqT, RespT>}. Returns an empty {@link Optional} if {@code ReqT} can't be
* determined.
*/
static Optional<Type> 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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}
}
Loading

0 comments on commit 5264e35

Please sign in to comment.