Skip to content

Commit 4593c43

Browse files
authored
Make 'HttpResponseContext' not needed in PS function script (#113)
To properly convert a value for a specific output binding name implicitly, we need to know the metadata of the binding referred by that name, such as the type of the output binding. The changes in this PR register the output binding metadata to a concurrent dictionary with the Runspace's InstanceId as the key before start running a function script, and then unregister after the function script finishes running. When a function script that calls `Push-OutputBinding` is running, we can figure out the current Runspace by `Runspace.DefaultRunspace`, and then we can query for the output binding metadata by using the InstanceId. This approach take into the concurrency support that we will have in future. `Push-OutputBinding` is able to get the metadata about the function script that is running even with multiple Runspaces/PowerShellManagers processing requests at the same time.
1 parent 6e53357 commit 4593c43

File tree

13 files changed

+214
-78
lines changed

13 files changed

+214
-78
lines changed

src/Http/HttpResponseContext.cs

Lines changed: 0 additions & 42 deletions
This file was deleted.

src/Modules/Microsoft.Azure.Functions.PowerShellWorker/Microsoft.Azure.Functions.PowerShellWorker.psd1

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
#
2+
# Copyright (c) Microsoft. All rights reserved.
3+
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
4+
#
5+
16
@{
27

38
# Script module or binary module file associated with this manifest.

src/Modules/Microsoft.Azure.Functions.PowerShellWorker/Microsoft.Azure.Functions.PowerShellWorker.psm1

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@
33
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
44
#
55

6-
# This holds the current state of the output bindings
6+
using namespace System.Management.Automation
7+
using namespace System.Management.Automation.Runspaces
8+
using namespace Microsoft.Azure.Functions.PowerShellWorker
9+
10+
# This holds the current state of the output bindings.
711
$script:_OutputBindings = @{}
12+
# This loads the resource strings.
13+
Import-LocalizedData LocalizedData -FileName PowerShellWorker.Resource.psd1
814

915
<#
1016
.SYNOPSIS
@@ -49,6 +55,50 @@ function Get-OutputBinding {
4955
}
5056
}
5157

58+
# Helper private function that validates the output value and does necessary conversion.
59+
function Convert-OutputBindingValue {
60+
param(
61+
[Parameter(Mandatory=$true)]
62+
[string]
63+
$Name,
64+
65+
[Parameter(Mandatory=$true)]
66+
[object]
67+
$Value
68+
)
69+
70+
# Check if we can get the binding metadata of the current running function.
71+
$funcMetadataType = "FunctionMetadata" -as [type]
72+
if ($null -eq $funcMetadataType) {
73+
return $Value
74+
}
75+
76+
# Get the runspace where we are currently running in and then get all output bindings.
77+
$bindingMap = $funcMetadataType::GetOutputBindingInfo([Runspace]::DefaultRunspace.InstanceId)
78+
if ($null -eq $bindingMap) {
79+
return $Value
80+
}
81+
82+
# Get the binding information of given output binding name.
83+
$bindingInfo = $bindingMap[$Name]
84+
if ($bindingInfo.Type -ne "http") {
85+
return $Value
86+
}
87+
88+
# Nothing to do if the value is already a HttpResponseContext object.
89+
if ($Value -as [HttpResponseContext]) {
90+
return $Value
91+
}
92+
93+
try {
94+
return [LanguagePrimitives]::ConvertTo($Value, [HttpResponseContext])
95+
} catch [PSInvalidCastException] {
96+
$conversionMsg = $_.Exception.Message
97+
$errorMsg = $LocalizedData.InvalidHttpOutputValue -f $Name, $conversionMsg
98+
throw $errorMsg
99+
}
100+
}
101+
52102
# Helper private function that sets an OutputBinding.
53103
function Push-KeyValueOutputBinding {
54104
param (
@@ -63,10 +113,13 @@ function Push-KeyValueOutputBinding {
63113
[switch]
64114
$Force
65115
)
66-
if(!$script:_OutputBindings.ContainsKey($Name) -or $Force.IsPresent) {
116+
117+
if (!$script:_OutputBindings.ContainsKey($Name) -or $Force.IsPresent) {
118+
$Value = Convert-OutputBindingValue -Name $Name -Value $Value
67119
$script:_OutputBindings[$Name] = $Value
68120
} else {
69-
throw "Output binding '$Name' is already set. To override the value, use -Force."
121+
$errorMsg = $LocalizedData.OutputBindingAlreadySet -f $Name
122+
throw $errorMsg
70123
}
71124
}
72125

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#
2+
# Copyright (c) Microsoft. All rights reserved.
3+
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
4+
#
5+
6+
ConvertFrom-StringData @'
7+
###PSLOC
8+
OutputBindingAlreadySet=Output binding '{0}' is already set. To override the value, use -Force.
9+
InvalidHttpOutputValue=The given value for the 'http' output binding '{0}' cannot be converted to the type 'HttpResponseContext'. The conversion failed with the following error: {1}
10+
###PSLOC
11+
'@

src/PowerShell/PowerShellManager.cs

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,36 @@ internal string ConvertToJson(object fromObj)
206206
.InvokeAndClearCommands<string>()[0];
207207
}
208208

209-
private Dictionary<string, ParameterMetadata> RetriveParameterMetadata(
210-
AzFunctionInfo functionInfo,
211-
out string moduleName)
209+
/// <summary>
210+
/// Helper method to prepend the FunctionApp module folder to the module path.
211+
/// </summary>
212+
internal void PrependToPSModulePath(string directory)
213+
{
214+
// Adds the passed in directory to the front of the PSModulePath using the path separator of the OS.
215+
string psModulePath = Environment.GetEnvironmentVariable("PSModulePath");
216+
Environment.SetEnvironmentVariable("PSModulePath", $"{directory}{Path.PathSeparator}{psModulePath}");
217+
}
218+
219+
/// <summary>
220+
/// Helper method to set the output binding metadata for the function that is about to run.
221+
/// </summary>
222+
internal void RegisterFunctionMetadata(AzFunctionInfo functionInfo)
223+
{
224+
var outputBindings = new ReadOnlyDictionary<string, BindingInfo>(functionInfo.OutputBindings);
225+
FunctionMetadata.OutputBindingCache.AddOrUpdate(_pwsh.Runspace.InstanceId,
226+
outputBindings,
227+
(key, value) => outputBindings);
228+
}
229+
230+
/// <summary>
231+
/// Helper method to clear the output binding metadata for the function that has done running.
232+
/// </summary>
233+
internal void UnregisterFunctionMetadata()
234+
{
235+
FunctionMetadata.OutputBindingCache.TryRemove(_pwsh.Runspace.InstanceId, out _);
236+
}
237+
238+
private Dictionary<string, ParameterMetadata> RetriveParameterMetadata(AzFunctionInfo functionInfo, out string moduleName)
212239
{
213240
moduleName = null;
214241
string scriptPath = functionInfo.ScriptPath;
@@ -232,13 +259,6 @@ private Dictionary<string, ParameterMetadata> RetriveParameterMetadata(
232259
}
233260
}
234261

235-
internal void PrependToPSModulePath(string directory)
236-
{
237-
// Adds the passed in directory to the front of the PSModulePath using the path separator of the OS.
238-
string psModulePath = Environment.GetEnvironmentVariable("PSModulePath");
239-
Environment.SetEnvironmentVariable("PSModulePath", $"{directory}{Path.PathSeparator}{psModulePath}");
240-
}
241-
242262
private void ResetRunspace(string moduleName)
243263
{
244264
// Reset the runspace to the Initial Session State

src/Public/FunctionMetadata.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// Copyright (c) Microsoft. All rights reserved.
3+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
4+
//
5+
6+
using System;
7+
using System.Collections.Concurrent;
8+
using System.Collections.ObjectModel;
9+
using Microsoft.Azure.WebJobs.Script.Grpc.Messages;
10+
using Google.Protobuf.Collections;
11+
12+
namespace Microsoft.Azure.Functions.PowerShellWorker
13+
{
14+
/// <summary>
15+
/// Function metadata for the PowerShellWorker module to query.
16+
/// </summary>
17+
public static class FunctionMetadata
18+
{
19+
internal static ConcurrentDictionary<Guid, ReadOnlyDictionary<string, BindingInfo>> OutputBindingCache
20+
= new ConcurrentDictionary<Guid, ReadOnlyDictionary<string, BindingInfo>>();
21+
22+
/// <summary>
23+
/// Get the binding metadata for the given Runspace instance id.
24+
/// </summary>
25+
public static ReadOnlyDictionary<string, BindingInfo> GetOutputBindingInfo(Guid runspaceInstanceId)
26+
{
27+
ReadOnlyDictionary<string, BindingInfo> outputBindings = null;
28+
OutputBindingCache.TryGetValue(runspaceInstanceId, out outputBindings);
29+
return outputBindings;
30+
}
31+
}
32+
}

src/Http/HttpRequestContext.cs renamed to src/Public/HttpContext.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
//
55

66
using System;
7+
using System.Collections;
78
using System.Collections.Generic;
9+
using System.Net;
810

911
namespace Microsoft.Azure.Functions.PowerShellWorker
1012
{
@@ -58,4 +60,35 @@ public HttpRequestContext()
5860
/// </summary>
5961
public object RawBody { get; internal set; }
6062
}
63+
64+
/// <summary>
65+
/// Custom type represent the context of the Http response.
66+
/// </summary>
67+
public class HttpResponseContext
68+
{
69+
/// <summary>
70+
/// Gets or sets the Body of the Http response.
71+
/// </summary>
72+
public object Body { get; set; }
73+
74+
/// <summary>
75+
/// Gets or sets the ContentType of the Http response.
76+
/// </summary>
77+
public string ContentType { get; set; } = "text/plain";
78+
79+
/// <summary>
80+
/// Gets or sets the EnableContentNegotiation of the Http response.
81+
/// </summary>
82+
public bool EnableContentNegotiation { get; set; }
83+
84+
/// <summary>
85+
/// Gets or sets the Headers of the Http response.
86+
/// </summary>
87+
public IDictionary Headers { get; set; }
88+
89+
/// <summary>
90+
/// Gets or sets the StatusCode of the Http response.
91+
/// </summary>
92+
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
93+
}
6194
}

src/RequestProcessor.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ internal StreamingMessage ProcessInvocationRequest(StreamingMessage request)
143143
{
144144
// Load information about the function
145145
var functionInfo = _functionLoader.GetFunctionInfo(invocationRequest.FunctionId);
146+
_powerShellManager.RegisterFunctionMetadata(functionInfo);
146147

147148
Hashtable results = functionInfo.Type == AzFunctionType.OrchestrationFunction
148149
? InvokeOrchestrationFunction(functionInfo, invocationRequest)
@@ -155,6 +156,10 @@ internal StreamingMessage ProcessInvocationRequest(StreamingMessage request)
155156
status.Status = StatusResult.Types.Status.Failure;
156157
status.Exception = e.ToRpcException();
157158
}
159+
finally
160+
{
161+
_powerShellManager.UnregisterFunctionMetadata();
162+
}
158163

159164
return response;
160165
}

src/Utility/TypeExtensions.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,15 @@ private static RpcHttp ToRpcHttp(this HttpResponseContext httpResponseContext, P
143143
rpcHttp.Body = httpResponseContext.Body.ToTypedData(psHelper);
144144
}
145145

146+
rpcHttp.EnableContentNegotiation = httpResponseContext.EnableContentNegotiation;
147+
146148
// Add all the headers. ContentType is separated for convenience
147-
foreach (var item in httpResponseContext.Headers)
149+
if (httpResponseContext.Headers != null)
148150
{
149-
rpcHttp.Headers.Add(item.Key, item.Value);
151+
foreach (DictionaryEntry item in httpResponseContext.Headers)
152+
{
153+
rpcHttp.Headers.Add(item.Key.ToString(), item.Value.ToString());
154+
}
150155
}
151156

152157
// Allow the user to set content-type in the Headers

test/E2E/TestFunctionApp/TestBasicHttpTrigger/run.ps1

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@ $name = $req.Query.Name
1414
if (-not $name) { $name = $req.Body.Name }
1515

1616
if($name) {
17-
$status = 202
18-
$body = "Hello " + $name
17+
# Cast the value to HttpResponseContext explicitly.
18+
Push-OutputBinding -Name res -Value ([HttpResponseContext]@{
19+
StatusCode = 202
20+
Body = "Hello " + $name
21+
})
1922
}
2023
else {
21-
$status = "400"
22-
$body = "Please pass a name on the query string or in the request body."
24+
# Convert value to HttpResponseContext implicitly for 'http' output.
25+
Push-OutputBinding -Name res -Value @{
26+
StatusCode = "400"
27+
Body = "Please pass a name on the query string or in the request body."
28+
}
2329
}
24-
25-
# You associate values to output bindings by calling 'Push-OutputBinding'.
26-
Push-OutputBinding -Name res -Value ([HttpResponseContext]@{
27-
StatusCode = $status
28-
Body = $body
29-
})

test/E2E/TestFunctionApp/TestBasicHttpTriggerWithTriggerMetadata/run.ps1

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@ $name = $TriggerMetadata.req.Query.Name
1414
if (-not $name) { $name = $TriggerMetadata.req.Body.Name }
1515

1616
if($name) {
17-
$status = [System.Net.HttpStatusCode]::Accepted
18-
$body = "Hello " + $name
17+
# Cast the value to HttpResponseContext explicitly.
18+
Push-OutputBinding -Name res -Value ([HttpResponseContext]@{
19+
StatusCode = [System.Net.HttpStatusCode]::Accepted
20+
Body = "Hello " + $name
21+
})
1922
}
2023
else {
21-
$status = 400
22-
$body = "Please pass a name on the query string or in the request body."
24+
# Convert value to HttpResponseContext implicitly for 'http' output.
25+
Push-OutputBinding -Name res -Value @{
26+
StatusCode = 400
27+
Body = "Please pass a name on the query string or in the request body."
28+
}
2329
}
24-
25-
# You associate values to output bindings by calling 'Push-OutputBinding'.
26-
Push-OutputBinding -Name res -Value ([HttpResponseContext]@{
27-
StatusCode = $status
28-
Body = $body
29-
})

test/Unit/PowerShell/PowerShellManagerTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,5 +145,19 @@ public void PrependingToPSModulePathShouldWork()
145145
Environment.SetEnvironmentVariable("PSModulePath", modulePathBefore);
146146
}
147147
}
148+
149+
[Fact]
150+
public void RegisterAndUnregisterFunctionMetadataShouldWork()
151+
{
152+
var logger = new ConsoleLogger();
153+
var manager = new PowerShellManager(logger);
154+
var functionInfo = GetAzFunctionInfo("dummy-path", string.Empty);
155+
156+
Assert.Empty(FunctionMetadata.OutputBindingCache);
157+
manager.RegisterFunctionMetadata(functionInfo);
158+
Assert.Single(FunctionMetadata.OutputBindingCache);
159+
manager.UnregisterFunctionMetadata();
160+
Assert.Empty(FunctionMetadata.OutputBindingCache);
161+
}
148162
}
149163
}

test/Unit/Utility/TypeExtensionsTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ public void TestObjectToTypedDataRpcHttpContentTypeInHeader()
368368
var input = new HttpResponseContext
369369
{
370370
Body = data,
371-
Headers = { { "content-type", "text/html" } }
371+
Headers = new Hashtable { { "content-type", "text/html" } }
372372
};
373373
var expected = new TypedData
374374
{

0 commit comments

Comments
 (0)