Skip to content

Commit 1489030

Browse files
authored
feat: implement typed function signature (#158)
* feat: implement typed function signature * Implement code review feedback * added typed signature support to the testing framework
1 parent cbfd204 commit 1489030

File tree

10 files changed

+379
-18
lines changed

10 files changed

+379
-18
lines changed

lib/functions_framework.rb

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,9 @@ class << self
115115
attr_accessor :logger
116116

117117
##
118-
# Define a function that response to HTTP requests.
118+
# Define a function that responds to HTTP requests.
119119
#
120-
# You must provide a name for the function, and a block that implemets the
120+
# You must provide a name for the function, and a block that implements the
121121
# function. The block should take a single `Rack::Request` argument. It
122122
# should return one of the following:
123123
# * A standard 3-element Rack response array. See
@@ -142,10 +142,40 @@ def http name = DEFAULT_TARGET, &block
142142
self
143143
end
144144

145+
## Define a Typed function that responds to HTTP requests.
146+
#
147+
# You must provide a name for the function, and a block that implements the
148+
# function. The block should take a single argument representing the request
149+
# payload. If a `request_type` is provided, the argument object will be of
150+
# the given decoded type; otherwise, it will be a JSON hash. The block
151+
# should return a JSON hash or an object that implements `#to_json`.
152+
#
153+
# ## Example
154+
# FunctionsFramework.typed "my-sum-function" do |add_request|
155+
# {sum: add_request["num1"] + add_response["num2"]}
156+
# end
157+
#
158+
# ## Example with Type
159+
# FunctionsFramework.typed "identity",
160+
# request_class: MyCustomType do |custom_type|
161+
# custom_type
162+
# end
163+
#
164+
# @param name [String] The function name. Defaults to {DEFAULT_TARGET}
165+
# @param request_class [#decode_json] An optional class which will be used to
166+
# decode the request if it implements a `decode_json` static method.
167+
# @param block [Proc] The function code as a proc @return [self]
168+
# @return [self]
169+
#
170+
def typed name = DEFAULT_TARGET, request_class: nil, &block
171+
global_registry.add_typed name, request_class: request_class, &block
172+
self
173+
end
174+
145175
##
146176
# Define a function that responds to CloudEvents.
147177
#
148-
# You must provide a name for the function, and a block that implemets the
178+
# You must provide a name for the function, and a block that implements the
149179
# function. The block should take one argument: the event object of type
150180
# [`CloudEvents::Event`](https://cloudevents.github.io/sdk-ruby/latest/CloudEvents/Event).
151181
# Any return value is ignored.

lib/functions_framework/function.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,23 @@ def self.http name, callable: nil, &block
7070
new name, :http, callable: callable, &block
7171
end
7272

73+
##
74+
# Create a new Typed function definition.
75+
#
76+
# @param name [String] The function name
77+
# @param callable [Class,#call] A callable object or class.
78+
# @param request_class [#decode_json] A class that can be read from JSON.
79+
# @param block [Proc] The function code as a block.
80+
# @return [FunctionsFramework::Function]
81+
#
82+
def self.typed name, request_class: nil, callable: nil, &block
83+
if request_class && !(request_class.respond_to? :decode_json)
84+
raise ::ArgumentError, "Type does not implement 'decode_json' class method"
85+
end
86+
87+
new name, :typed, callable: callable, request_class: request_class, &block
88+
end
89+
7390
##
7491
# Create a new CloudEvents function definition.
7592
#
@@ -102,9 +119,10 @@ def self.startup_task callable: nil, &block
102119
# @param callable [Class,#call] A callable object or class.
103120
# @param block [Proc] The function code as a block.
104121
#
105-
def initialize name, type, callable: nil, &block
122+
def initialize name, type, callable: nil, request_class: nil, &block
106123
@name = name
107124
@type = type
125+
@request_class = request_class
108126
@callable = @callable_class = nil
109127
if callable.respond_to? :call
110128
@callable = callable
@@ -129,6 +147,11 @@ def initialize name, type, callable: nil, &block
129147
#
130148
attr_reader :type
131149

150+
##
151+
# @return [#decode_json] The class for the request parameter. Only used for typed functions.
152+
#
153+
attr_reader :request_class
154+
132155
##
133156
# Populate the given globals hash with this function's info.
134157
#

lib/functions_framework/registry.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,29 @@ def add_http name, &block
8181
self
8282
end
8383

84+
##
85+
# Add a Typed function to the registry.
86+
#
87+
# You must provide a name for the function, and a block that implements the
88+
# function. The block should take a single `Hash` argument which will be the
89+
# JSON decoded request payload. It should return a `Hash` response which
90+
# will be JSON encoded and written to the response.
91+
#
92+
# @param name [String] The function name.
93+
# @param request_class [#decode_json] An optional class which will be used
94+
# to decode the request.
95+
# @param block [Proc] The function code as a proc
96+
# @return [self]
97+
#
98+
def add_typed name, request_class: nil, &block
99+
name = name.to_s
100+
@mutex.synchronize do
101+
raise ::ArgumentError, "Function already defined: #{name}" if @functions.key? name
102+
@functions[name] = Function.typed name, request_class: request_class, &block
103+
end
104+
self
105+
end
106+
84107
##
85108
# Add a CloudEvent function to the registry.
86109
#

lib/functions_framework/server.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ def initialize function, globals
5454
HttpApp.new function, globals, @config
5555
when :cloud_event
5656
EventApp.new function, globals, @config
57+
when :typed
58+
TypedApp.new function, globals, @config
5759
else
5860
raise "Unrecognized function type: #{function.type}"
5961
end
@@ -405,6 +407,11 @@ def error_response message
405407
string_response message, 500
406408
end
407409

410+
def bad_request message
411+
message = "Bad Request" unless @config.show_error_details?
412+
string_response message, 400
413+
end
414+
408415
def flush_streams
409416
$stdout.flush
410417
$stderr.flush
@@ -436,6 +443,43 @@ def call env
436443
end
437444
end
438445

446+
## @private
447+
class TypedApp < AppBase
448+
def initialize function, globals, config
449+
super config
450+
@function = function
451+
@globals = globals
452+
end
453+
454+
def call env
455+
return notfound_response if excluded_path? env
456+
begin
457+
logger = env[::Rack::RACK_LOGGER] ||= @config.logger
458+
request = ::Rack::Request.new env
459+
logger.info "FunctionsFramework: Handling Typed #{request.request_method} request"
460+
461+
begin
462+
req = if @function.request_class
463+
request_class.decode_json request.body.read.to_s
464+
else
465+
JSON.parse request.body.read.to_s
466+
end
467+
rescue JSON::ParserError => e
468+
return bad_request e.message
469+
end
470+
471+
res = @function.call req, globals: @globals, logger: logger
472+
return string_response res.to_json, 200, content_type: "application/json" if res
473+
474+
string_response "", 204
475+
rescue ::StandardError => e
476+
interpret_response e
477+
end
478+
ensure
479+
flush_streams
480+
end
481+
end
482+
439483
## @private
440484
class EventApp < AppBase
441485
def initialize function, globals, config

lib/functions_framework/testing.rb

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -134,16 +134,42 @@ def run_startup_tasks name, logger: nil, lenient: false
134134
#
135135
def call_http name, request, globals: nil, logger: nil
136136
globals ||= run_startup_tasks name, logger: logger, lenient: true
137-
function = Testing.current_registry[name]
138-
case function&.type
139-
when :http
137+
Testing.call :http, name, readable_name: "HTTP" do |function|
140138
Testing.interpret_response do
141139
function.call request, globals: globals, logger: logger
142140
end
143-
when nil
144-
raise "Unknown function name #{name}"
145-
else
146-
raise "Function #{name} is not an HTTP function"
141+
end
142+
end
143+
144+
##
145+
# Call the given Typed function for testing. The underlying function must
146+
# be of type `:typed`. Returns the Rack response.
147+
#
148+
# By default, the startup tasks will be run for the given function if they
149+
# have not already been run. You can, however, disable running startup
150+
# tasks by providing an explicit globals hash.
151+
#
152+
# By default, the {FunctionsFramework.logger} will be used, but you can
153+
# override that by providing your own logger. In particular, to disable
154+
# logging, you can pass `Logger.new(nil)`.
155+
#
156+
# @param name [String] The name of the function to call
157+
# @param request [Rack::Request] The Rack request to send
158+
# @param globals [Hash] Do not run startup tasks, and instead provide the
159+
# globals directly. Optional.
160+
# @param logger [Logger] Use the given logger instead of the Functions
161+
# Framework's global logger. Optional.
162+
# @return [Rack::Response]
163+
#
164+
def call_typed name, request, globals: nil, logger: nil
165+
globals ||= run_startup_tasks name, logger: logger, lenient: true
166+
Testing.call :typed, name, readable_name: "Typed" do |function|
167+
Testing.interpret_response do
168+
config = FunctionsFramework::Server::Config.new
169+
config.logger = logger
170+
app = FunctionsFramework::Server::TypedApp.new function, globals, config
171+
app.call request.env
172+
end
147173
end
148174
end
149175

@@ -169,15 +195,9 @@ def call_http name, request, globals: nil, logger: nil
169195
#
170196
def call_event name, event, globals: nil, logger: nil
171197
globals ||= run_startup_tasks name, logger: logger, lenient: true
172-
function = Testing.current_registry[name]
173-
case function&.type
174-
when :cloud_event
198+
Testing.call :cloud_event, name, readable_name: "CloudEvent" do |function|
175199
function.call event, globals: globals, logger: logger
176200
nil
177-
when nil
178-
raise "Unknown function name #{name}"
179-
else
180-
raise "Function #{name} is not a CloudEvent function"
181201
end
182202
end
183203

@@ -316,6 +336,20 @@ def current_globals name, globals = nil
316336
end
317337
end
318338

339+
## @private
340+
def call type, name, readable_name: nil
341+
readable_name ||= type
342+
function = Testing.current_registry[name]
343+
case function&.type
344+
when type
345+
yield function
346+
when nil
347+
raise "Unknown function name #{name}"
348+
else
349+
raise "Function #{name} is not a #{readable_name} function"
350+
end
351+
end
352+
319353
## @private
320354
def interpret_response
321355
response =
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright 2023 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+
# https://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+
require "functions_framework"
16+
17+
# Create a simple Typed function called "simple_typed"
18+
FunctionsFramework.typed "simple_typed" do |request|
19+
logger.info "I received a request: #{request['value']}"
20+
{
21+
value: request["value"] + 1
22+
}
23+
end

0 commit comments

Comments
 (0)