Skip to content

Commit bb812fb

Browse files
garethgeorgejskeet
authored andcommitted
feat: Implement strongly typed function signatures for dotnet
1 parent d526aa1 commit bb812fb

File tree

8 files changed

+553
-1
lines changed

8 files changed

+553
-1
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
using Microsoft.AspNetCore.Http;
16+
using Microsoft.Extensions.Logging.Abstractions;
17+
using System;
18+
using System.Globalization;
19+
using System.IO;
20+
using System.Text;
21+
using System.Threading;
22+
using System.Threading.Tasks;
23+
using Xunit;
24+
25+
namespace Google.Cloud.Functions.Framework.Tests;
26+
27+
public class TypedFunctionAdapterTest
28+
{
29+
[Fact]
30+
public async Task InvalidRequest_400BadRequestError()
31+
{
32+
var adapter = new TypedFunctionAdapter<string, int>(
33+
new TestTypedFunction(),
34+
new AlwaysFailsToParseRequestReader(),
35+
new Int32ResponseWriter(),
36+
new NullLogger<TypedFunctionAdapter<string, int>>()
37+
);
38+
var context = new DefaultHttpContext();
39+
await adapter.HandleAsync(context);
40+
Assert.Equal(400, context.Response.StatusCode);
41+
}
42+
43+
[Fact]
44+
public async Task TestRequest_ExecutesFunction()
45+
{
46+
var payload = "Hello World!";
47+
48+
var adapter = new TypedFunctionAdapter<string, int>(
49+
new TestTypedFunction(),
50+
new StringRequestReader(),
51+
new Int32ResponseWriter(),
52+
new NullLogger<TypedFunctionAdapter<string, int>>()
53+
);
54+
55+
var context = new DefaultHttpContext
56+
{
57+
Request =
58+
{
59+
ContentType = "text/plain",
60+
Body = new MemoryStream(Encoding.UTF8.GetBytes(payload)),
61+
},
62+
Response =
63+
{
64+
Body = new MemoryStream(),
65+
}
66+
};
67+
68+
await adapter.HandleAsync(context);
69+
Assert.Equal(200, context.Response.StatusCode);
70+
context.Response.Body.Position = 0;
71+
// The function counts the number of characters in the payload,
72+
// and returns a text representation of that number.
73+
Assert.Equal("12", new StreamReader(context.Response.Body).ReadToEnd());
74+
}
75+
76+
class TestTypedFunction : ITypedFunction<string, int>
77+
{
78+
public Task<int> HandleAsync(string request, CancellationToken cancellationToken) =>
79+
Task.FromResult(request.Length);
80+
}
81+
82+
class StringRequestReader : IHttpRequestReader<string>
83+
{
84+
public Task<string> ReadRequestAsync(HttpRequest request) =>
85+
new StreamReader(request.Body).ReadToEndAsync();
86+
}
87+
88+
class Int32ResponseWriter : IHttpResponseWriter<int>
89+
{
90+
public Task WriteResponseAsync(HttpResponse httpResponse, int functionResponse) =>
91+
httpResponse.WriteAsync(functionResponse.ToString(CultureInfo.InvariantCulture));
92+
}
93+
94+
class AlwaysFailsToParseRequestReader : IHttpRequestReader<string>
95+
{
96+
public Task<string> ReadRequestAsync(HttpRequest request) =>
97+
Task.FromException<string>(new Exception("Injected parse failure"));
98+
}
99+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
using Microsoft.AspNetCore.Http;
16+
using System.Threading.Tasks;
17+
18+
namespace Google.Cloud.Functions.Framework;
19+
20+
/// <summary>
21+
/// Responsible for reading a strongly typed request object from an
22+
/// HTTP request.
23+
/// </summary>
24+
public interface IHttpRequestReader<TRequest>
25+
{
26+
/// <summary>
27+
/// Asynchronously reads a strongly typed request object.
28+
/// </summary>
29+
/// <returns>A task representing the asynchronous operation.</returns>
30+
public Task<TRequest> ReadRequestAsync(HttpRequest httpRequest);
31+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
using Microsoft.AspNetCore.Http;
16+
using System.Threading.Tasks;
17+
18+
namespace Google.Cloud.Functions.Framework;
19+
20+
/// <summary>
21+
/// Responsible for writing a strongly typed response object to an
22+
/// HTTP response.
23+
/// </summary>
24+
public interface IHttpResponseWriter<TResponse>
25+
{
26+
/// <summary>
27+
/// Asynchronously writes a strongly typed response object to
28+
/// an HTTP response.
29+
/// </summary>
30+
/// <returns>A task representing the asynchronous operation.</returns>
31+
Task WriteResponseAsync(HttpResponse httpResponse, TResponse functionResponse);
32+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
using System.Threading;
16+
using System.Threading.Tasks;
17+
18+
namespace Google.Cloud.Functions.Framework;
19+
20+
/// <summary>
21+
/// A typed function accepting a structured request and asynchronously
22+
/// returning a structured response.
23+
/// </summary>
24+
/// <typeparam name="TRequest">Type of the request object.</typeparam>
25+
/// <typeparam name="TResponse">Type of the response object.</typeparam>
26+
public interface ITypedFunction<TRequest, TResponse>
27+
{
28+
/// <summary>
29+
/// Asynchronously handles an incoming request, expected to return
30+
/// a structured response.
31+
/// </summary>
32+
/// <param name="request">The request payload, deserialized from the incoming request.</param>
33+
/// <param name="cancellationToken">A cancellation token which indicates if the request is aborted.</param>
34+
/// <returns>A task representing the asynchronous operation.</returns>
35+
Task<TResponse> HandleAsync(TRequest request, CancellationToken cancellationToken);
36+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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+
using Microsoft.AspNetCore.Http;
16+
using Microsoft.Extensions.Logging;
17+
using System;
18+
using System.Threading.Tasks;
19+
20+
namespace Google.Cloud.Functions.Framework;
21+
22+
/// <summary>
23+
/// An adapter to implement an HTTP Function based on a <see cref="ITypedFunction{TRequest, TResponse}"/>,
24+
/// with built-in request deserialization and response serialization.
25+
/// </summary>
26+
public sealed class TypedFunctionAdapter<TRequest, TResult> : IHttpFunction
27+
{
28+
private readonly ITypedFunction<TRequest, TResult> _function;
29+
private readonly IHttpRequestReader<TRequest> _requestReader;
30+
private readonly IHttpResponseWriter<TResult> _responseWriter;
31+
private readonly ILogger _logger;
32+
33+
/// <summary>
34+
/// Constructs a new instance based on the given TypedFunction.
35+
/// </summary>
36+
public TypedFunctionAdapter(
37+
ITypedFunction<TRequest, TResult> function,
38+
IHttpRequestReader<TRequest> requestReader,
39+
IHttpResponseWriter<TResult> responseWriter,
40+
ILogger<TypedFunctionAdapter<TRequest, TResult>> logger)
41+
{
42+
_function = Preconditions.CheckNotNull(function, nameof(function));
43+
_requestReader = Preconditions.CheckNotNull(requestReader, nameof(requestReader));
44+
_responseWriter = Preconditions.CheckNotNull(responseWriter, nameof(responseWriter));
45+
_logger = Preconditions.CheckNotNull(logger, nameof(logger));
46+
}
47+
48+
/// <summary>
49+
/// Handles an HTTP request by extracting the CloudEvent from it, deserializing the data, and passing
50+
/// both the event and the data to the original CloudEvent Function.
51+
/// The request fails if it does not contain a CloudEvent.
52+
/// </summary>
53+
/// <param name="context">The HTTP context containing the request and response.</param>
54+
/// <returns>A task representing the asynchronous operation.</returns>
55+
public async Task HandleAsync(HttpContext context)
56+
{
57+
TRequest data;
58+
try
59+
{
60+
data = await _requestReader.ReadRequestAsync(context.Request);
61+
}
62+
catch (Exception e)
63+
{
64+
_logger.LogError(e, e.Message);
65+
context.Response.StatusCode = StatusCodes.Status400BadRequest;
66+
return;
67+
}
68+
69+
TResult res = await _function.HandleAsync(data, context.RequestAborted);
70+
try
71+
{
72+
await _responseWriter.WriteResponseAsync(context.Response, res);
73+
}
74+
catch (Exception e)
75+
{
76+
_logger.LogError(e, e.Message);
77+
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
78+
return;
79+
}
80+
}
81+
}

0 commit comments

Comments
 (0)