Skip to content

Commit 5264e35

Browse files
authored
feat: Define strongly typed function interface (#186)
Introduces a new Typed signature to the functions framework which provides automatic request deserialization and response serialization.
1 parent d6396d1 commit 5264e35

File tree

20 files changed

+488
-57
lines changed

20 files changed

+488
-57
lines changed

.github/workflows/codeql.yml

+6-6
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,12 @@ jobs:
4949
# queries: security-extended,security-and-quality
5050

5151

52-
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
53-
- name: Autobuild
54-
uses: github/codeql-action/autobuild@29b1f65c5e92e24fe6b6647da1eaabe529cec70f # v2.3.3
55-
with:
56-
working-directory: ${{ matrix.working-directory }}
57-
52+
53+
- name: Build
54+
run: |
55+
(cd functions-framework-api/ && mvn install)
56+
(cd invoker/ && mvn clean install)
57+
(cd function-maven-plugin && mvn install)
5858
5959
- name: Perform CodeQL Analysis
6060
uses: github/codeql-action/analyze@29b1f65c5e92e24fe6b6647da1eaabe529cec70f # v2.3.3

function-maven-plugin/pom.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
<dependency>
4747
<groupId>com.google.cloud.functions.invoker</groupId>
4848
<artifactId>java-function-invoker</artifactId>
49-
<version>1.2.1</version>
49+
<version>1.2.3-SNAPSHOT</version>
5050
</dependency>
5151

5252
<dependency>

functions-framework-api/pom.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
<groupId>com.google.cloud.functions</groupId>
2828
<artifactId>functions-framework-api</artifactId>
29-
<version>1.0.5-SNAPSHOT</version>
29+
<version>1.0.6-SNAPSHOT</version>
3030

3131
<properties>
3232
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright 2019 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.cloud.functions;
16+
17+
import java.lang.reflect.Type;
18+
19+
/**
20+
* Represents a Cloud Function with a strongly typed interface that is activated by an HTTP request.
21+
*/
22+
@FunctionalInterface
23+
public interface TypedFunction<RequestT, ResponseT> {
24+
/**
25+
* Called to service an incoming HTTP request. This interface is implemented by user code to
26+
* provide the action for a given HTTP function. If this method throws any exception (including
27+
* any {@link Error}) then the HTTP response will have a 500 status code.
28+
*
29+
* @param arg the payload of the event, deserialized from the original JSON string.
30+
* @return invocation result or null to indicate the body of the response should be empty.
31+
* @throws Exception to produce a 500 status code in the HTTP response.
32+
*/
33+
public ResponseT apply(RequestT arg) throws Exception;
34+
35+
/**
36+
* Called to get the the format object that handles request decoding and response encoding. If
37+
* null is returned a default JSON format is used.
38+
*
39+
* @return the {@link WireFormat} to use for serialization
40+
*/
41+
public default WireFormat getWireFormat() {
42+
return null;
43+
}
44+
45+
/**
46+
* Describes how to deserialize request object and serialize response objects for an HTTP
47+
* invocation.
48+
*/
49+
public interface WireFormat {
50+
/** Serialize is expected to encode the object to the provided HttpResponse. */
51+
void serialize(Object object, HttpResponse response) throws Exception;
52+
53+
/**
54+
* Deserialize is expected to read an object of {@code Type} from the HttpRequest. The Type is
55+
* determined through reflection on the user's function.
56+
*/
57+
Object deserialize(HttpRequest request, Type type) throws Exception;
58+
}
59+
}

invoker/conformance/pom.xml

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
<parent>
55
<artifactId>java-function-invoker-parent</artifactId>
66
<groupId>com.google.cloud.functions.invoker</groupId>
7-
<version>1.2.2-SNAPSHOT</version>
7+
<version>1.2.3-SNAPSHOT</version>
88
</parent>
99

1010
<groupId>com.google.cloud.functions.invoker</groupId>
1111
<artifactId>conformance</artifactId>
12-
<version>1.2.2-SNAPSHOT</version>
12+
<version>1.2.3-SNAPSHOT</version>
1313

1414
<name>GCF Confromance Tests</name>
1515
<description>

invoker/core/pom.xml

+4-3
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
<parent>
55
<groupId>com.google.cloud.functions.invoker</groupId>
66
<artifactId>java-function-invoker-parent</artifactId>
7-
<version>1.2.2-SNAPSHOT</version>
7+
<version>1.2.3-SNAPSHOT</version>
88
</parent>
99

1010
<groupId>com.google.cloud.functions.invoker</groupId>
1111
<artifactId>java-function-invoker</artifactId>
12-
<version>1.2.2-SNAPSHOT</version>
12+
<version>1.2.3-SNAPSHOT</version>
1313
<name>GCF Java Invoker</name>
1414
<description>
1515
Application that invokes a GCF Java function. This application is a
@@ -44,6 +44,7 @@
4444
<dependency>
4545
<groupId>com.google.cloud.functions</groupId>
4646
<artifactId>functions-framework-api</artifactId>
47+
<version>1.0.6-SNAPSHOT</version>
4748
</dependency>
4849
<dependency>
4950
<groupId>javax.servlet</groupId>
@@ -114,7 +115,7 @@
114115
<dependency>
115116
<groupId>com.google.cloud.functions.invoker</groupId>
116117
<artifactId>java-function-invoker-testfunction</artifactId>
117-
<version>1.2.2-SNAPSHOT</version>
118+
<version>1.2.3-SNAPSHOT</version>
118119
<type>test-jar</type>
119120
<scope>test</scope>
120121
</dependency>

invoker/core/src/main/java/com/google/cloud/functions/invoker/HttpFunctionExecutor.java

+1-13
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
import com.google.cloud.functions.HttpFunction;
1818
import com.google.cloud.functions.invoker.http.HttpRequestImpl;
1919
import com.google.cloud.functions.invoker.http.HttpResponseImpl;
20-
import java.io.IOException;
2120
import java.util.logging.Level;
2221
import java.util.logging.Logger;
2322
import javax.servlet.http.HttpServlet;
@@ -72,18 +71,7 @@ public void service(HttpServletRequest req, HttpServletResponse res) {
7271
res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
7372
} finally {
7473
Thread.currentThread().setContextClassLoader(oldContextLoader);
75-
try {
76-
// We can't use HttpServletResponse.flushBuffer() because we wrap the PrintWriter
77-
// returned by HttpServletResponse in our own BufferedWriter to match our API.
78-
// So we have to flush whichever of getWriter() or getOutputStream() works.
79-
try {
80-
respImpl.getOutputStream().flush();
81-
} catch (IllegalStateException e) {
82-
respImpl.getWriter().flush();
83-
}
84-
} catch (IOException e) {
85-
// Too bad, can't flush.
86-
}
74+
respImpl.flush();
8775
}
8876
}
8977
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package com.google.cloud.functions.invoker;
2+
3+
import com.google.cloud.functions.HttpRequest;
4+
import com.google.cloud.functions.HttpResponse;
5+
import com.google.cloud.functions.TypedFunction;
6+
import com.google.cloud.functions.TypedFunction.WireFormat;
7+
import com.google.cloud.functions.invoker.http.HttpRequestImpl;
8+
import com.google.cloud.functions.invoker.http.HttpResponseImpl;
9+
import com.google.gson.Gson;
10+
import com.google.gson.GsonBuilder;
11+
import java.io.BufferedReader;
12+
import java.io.BufferedWriter;
13+
import java.lang.reflect.Type;
14+
import java.util.Arrays;
15+
import java.util.Optional;
16+
import java.util.logging.Level;
17+
import java.util.logging.Logger;
18+
import javax.servlet.http.HttpServlet;
19+
import javax.servlet.http.HttpServletRequest;
20+
import javax.servlet.http.HttpServletResponse;
21+
22+
public class TypedFunctionExecutor extends HttpServlet {
23+
private static final String APPLY_METHOD = "apply";
24+
private static final Logger logger = Logger.getLogger("com.google.cloud.functions.invoker");
25+
26+
private final Type argType;
27+
private final TypedFunction<Object, Object> function;
28+
private final WireFormat format;
29+
30+
private TypedFunctionExecutor(
31+
Type argType, TypedFunction<Object, Object> func, WireFormat format) {
32+
this.argType = argType;
33+
this.function = func;
34+
this.format = format;
35+
}
36+
37+
public static TypedFunctionExecutor forClass(Class<?> functionClass) {
38+
if (!TypedFunction.class.isAssignableFrom(functionClass)) {
39+
throw new RuntimeException(
40+
"Class "
41+
+ functionClass.getName()
42+
+ " does not implement "
43+
+ TypedFunction.class.getName());
44+
}
45+
@SuppressWarnings("unchecked")
46+
Class<? extends TypedFunction<?, ?>> typedFunctionClass =
47+
(Class<? extends TypedFunction<?, ?>>) functionClass.asSubclass(TypedFunction.class);
48+
49+
Optional<Type> argType = handlerTypeArgument(typedFunctionClass);
50+
if (argType.isEmpty()) {
51+
throw new RuntimeException(
52+
"Class "
53+
+ typedFunctionClass.getName()
54+
+ " does not implement "
55+
+ TypedFunction.class.getName());
56+
}
57+
58+
TypedFunction<?, ?> typedFunction;
59+
try {
60+
typedFunction = typedFunctionClass.getDeclaredConstructor().newInstance();
61+
} catch (Exception e) {
62+
throw new RuntimeException(
63+
"Class "
64+
+ typedFunctionClass.getName()
65+
+ " must declare a valid default constructor to be usable as a strongly typed"
66+
+ " function. Could not use constructor: "
67+
+ e.toString());
68+
}
69+
70+
WireFormat format = typedFunction.getWireFormat();
71+
if (format == null) {
72+
format = LazyDefaultFormatHolder.defaultFormat;
73+
}
74+
75+
@SuppressWarnings("unchecked")
76+
TypedFunctionExecutor executor =
77+
new TypedFunctionExecutor(
78+
argType.orElseThrow(), (TypedFunction<Object, Object>) typedFunction, format);
79+
return executor;
80+
}
81+
82+
/**
83+
* Returns the {@code ReqT} of a concrete class that implements {@link TypedFunction
84+
* TypedFunction<ReqT, RespT>}. Returns an empty {@link Optional} if {@code ReqT} can't be
85+
* determined.
86+
*/
87+
static Optional<Type> handlerTypeArgument(Class<? extends TypedFunction<?, ?>> functionClass) {
88+
return Arrays.stream(functionClass.getMethods())
89+
.filter(method -> method.getName().equals(APPLY_METHOD) && method.getParameterCount() == 1)
90+
.map(method -> method.getGenericParameterTypes()[0])
91+
.filter(type -> type != Object.class)
92+
.findFirst();
93+
}
94+
95+
/** Executes the user's method, can handle all HTTP type methods. */
96+
@Override
97+
public void service(HttpServletRequest req, HttpServletResponse res) {
98+
HttpRequestImpl reqImpl = new HttpRequestImpl(req);
99+
HttpResponseImpl resImpl = new HttpResponseImpl(res);
100+
ClassLoader oldContextClassLoader = Thread.currentThread().getContextClassLoader();
101+
102+
try {
103+
Thread.currentThread().setContextClassLoader(function.getClass().getClassLoader());
104+
handleRequest(reqImpl, resImpl);
105+
} finally {
106+
Thread.currentThread().setContextClassLoader(oldContextClassLoader);
107+
resImpl.flush();
108+
}
109+
}
110+
111+
private void handleRequest(HttpRequest req, HttpResponse res) {
112+
Object reqObj;
113+
try {
114+
reqObj = format.deserialize(req, argType);
115+
} catch (Throwable t) {
116+
logger.log(Level.SEVERE, "Failed to parse request for " + function.getClass().getName(), t);
117+
res.setStatusCode(HttpServletResponse.SC_BAD_REQUEST);
118+
return;
119+
}
120+
121+
Object resObj;
122+
try {
123+
resObj = function.apply(reqObj);
124+
} catch (Throwable t) {
125+
logger.log(Level.SEVERE, "Failed to execute " + function.getClass().getName(), t);
126+
res.setStatusCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
127+
return;
128+
}
129+
130+
try {
131+
format.serialize(resObj, res);
132+
} catch (Throwable t) {
133+
logger.log(
134+
Level.SEVERE, "Failed to serialize response for " + function.getClass().getName(), t);
135+
res.setStatusCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
136+
return;
137+
}
138+
}
139+
140+
private static class LazyDefaultFormatHolder {
141+
static final WireFormat defaultFormat = new GsonWireFormat();
142+
}
143+
144+
private static class GsonWireFormat implements TypedFunction.WireFormat {
145+
private final Gson gson = new GsonBuilder().create();
146+
147+
@Override
148+
public void serialize(Object object, HttpResponse response) throws Exception {
149+
if (object == null) {
150+
response.setStatusCode(HttpServletResponse.SC_NO_CONTENT);
151+
return;
152+
}
153+
try (BufferedWriter bodyWriter = response.getWriter()) {
154+
gson.toJson(object, bodyWriter);
155+
}
156+
}
157+
158+
@Override
159+
public Object deserialize(HttpRequest request, Type type) throws Exception {
160+
try (BufferedReader bodyReader = request.getReader()) {
161+
return gson.fromJson(bodyReader, type);
162+
}
163+
}
164+
}
165+
}

invoker/core/src/main/java/com/google/cloud/functions/invoker/http/HttpResponseImpl.java

+22-12
Original file line numberDiff line numberDiff line change
@@ -86,22 +86,32 @@ public OutputStream getOutputStream() throws IOException {
8686
@Override
8787
public synchronized BufferedWriter getWriter() throws IOException {
8888
if (writer == null) {
89-
// Unfortunately this means that we get two intermediate objects between the
90-
// object we return
91-
// and the underlying Writer that response.getWriter() wraps. We could try
92-
// accessing the
93-
// PrintWriter.out field via reflection, but that sort of access to non-public
94-
// fields of
95-
// platform classes is now frowned on and may draw warnings or even fail in
96-
// subsequent
97-
// versions.
98-
// We could instead wrap the OutputStream, but that would require us to deduce
99-
// the appropriate
100-
// Charset, using logic like this:
89+
// Unfortunately this means that we get two intermediate objects between the object we return
90+
// and the underlying Writer that response.getWriter() wraps. We could try accessing the
91+
// PrintWriter.out field via reflection, but that sort of access to non-public fields of
92+
// platform classes is now frowned on and may draw warnings or even fail in subsequent
93+
// versions. We could instead wrap the OutputStream, but that would require us to deduce the
94+
// appropriate Charset, using logic like this:
10195
// https://github.com/eclipse/jetty.project/blob/923ec38adf/jetty-server/src/main/java/org/eclipse/jetty/server/Response.java#L731
10296
// We may end up doing that if performance is an issue.
10397
writer = new BufferedWriter(response.getWriter());
10498
}
10599
return writer;
106100
}
101+
102+
public void flush() {
103+
try {
104+
// We can't use HttpServletResponse.flushBuffer() because we wrap the
105+
// PrintWriter returned by HttpServletResponse in our own BufferedWriter
106+
// to match our API. So we have to flush whichever of getWriter() or
107+
// getOutputStream() works.
108+
try {
109+
getOutputStream().flush();
110+
} catch (IllegalStateException e) {
111+
getWriter().flush();
112+
}
113+
} catch (IOException e) {
114+
// Too bad, can't flush.
115+
}
116+
}
107117
}

0 commit comments

Comments
 (0)