Skip to content

Commit 841afb8

Browse files
committed
Enhancing Node HTTP conneg and response handling
1 parent f9fb643 commit 841afb8

File tree

11 files changed

+218
-31
lines changed

11 files changed

+218
-31
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.IO;
6+
using System.Net;
7+
using System.Net.Http;
8+
using System.Net.Http.Formatting;
9+
using System.Net.Http.Headers;
10+
using System.Text;
11+
using System.Threading;
12+
using System.Threading.Tasks;
13+
14+
namespace Microsoft.Azure.WebJobs.Script.WebHost
15+
{
16+
public class PlaintextMediaTypeFormatter : MediaTypeFormatter
17+
{
18+
private static readonly Type StringType = typeof(string);
19+
20+
public PlaintextMediaTypeFormatter()
21+
{
22+
SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/plain"));
23+
SupportedEncodings.Add(Encoding.UTF8);
24+
SupportedEncodings.Add(Encoding.Unicode);
25+
}
26+
public override bool CanReadType(Type type)
27+
{
28+
return type == StringType;
29+
}
30+
31+
public override bool CanWriteType(Type type)
32+
{
33+
return type == StringType;
34+
}
35+
36+
public override async Task<object> ReadFromStreamAsync(Type type, Stream readStream, HttpContent content, IFormatterLogger formatterLogger)
37+
{
38+
Encoding selectedEncoding = SelectCharacterEncoding(content.Headers);
39+
using (var reader = new StreamReader(readStream, selectedEncoding))
40+
{
41+
return await reader.ReadToEndAsync();
42+
}
43+
}
44+
45+
public override Task WriteToStreamAsync(Type type, object value, Stream writeStream, HttpContent content, TransportContext transportContext, CancellationToken cancellationToken)
46+
{
47+
if (value == null)
48+
{
49+
return Task.CompletedTask;
50+
}
51+
52+
Encoding selectedEncoding = SelectCharacterEncoding(content.Headers);
53+
using (var writer = new StreamWriter(writeStream, selectedEncoding, bufferSize: 1024, leaveOpen: true))
54+
{
55+
return writer.WriteAsync((string)value);
56+
}
57+
}
58+
}
59+
}

src/WebJobs.Script.WebHost/Global.asax.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ protected void Application_Start()
1616
using (metricsLogger.LatencyEvent(MetricEventNames.ApplicationStartLatency))
1717
{
1818
GlobalConfiguration.Configure(c => WebApiConfig.Register(c));
19+
GlobalConfiguration.Configuration.Formatters.Add(new PlaintextMediaTypeFormatter());
1920

2021
var scriptHostManager = GlobalConfiguration.Configuration.DependencyResolver.GetService<WebScriptHostManager>();
2122

src/WebJobs.Script.WebHost/WebJobs.Script.WebHost.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@
416416
<Compile Include="Diagnostics\WebHostMetricsLogger.cs" />
417417
<Compile Include="Extensions\DependencyResolverExtensions.cs" />
418418
<Compile Include="Filters\AuthorizationLevelAttribute.cs" />
419+
<Compile Include="Formatting\PlaintextMediaTypeFormatter.cs" />
419420
<Compile Include="Global.asax.cs">
420421
<DependentUpon>Global.asax</DependentUpon>
421422
</Compile>

src/WebJobs.Script/Binding/HttpBinding.cs

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
using System.Net.Http.Headers;
1313
using System.Reflection.Emit;
1414
using System.Threading.Tasks;
15+
using System.Web.Http;
16+
using System.Web.Http.Results;
1517
using Microsoft.Azure.WebJobs.Script.Description;
1618
using Newtonsoft.Json;
1719
using Newtonsoft.Json.Linq;
@@ -20,7 +22,7 @@ namespace Microsoft.Azure.WebJobs.Script.Binding
2022
{
2123
public class HttpBinding : FunctionBinding, IResultProcessingBinding
2224
{
23-
public HttpBinding(ScriptHostConfiguration config, BindingMetadata metadata, FileAccess access) :
25+
public HttpBinding(ScriptHostConfiguration config, BindingMetadata metadata, FileAccess access) :
2426
base(config, metadata, access)
2527
{
2628
}
@@ -88,21 +90,7 @@ public override async Task BindAsync(BindingContext context)
8890
}
8991
}
9092

91-
HttpResponseMessage response = null;
92-
if (content is string)
93-
{
94-
// for raw strings, we compose the content ourselves, otherwise WebApi
95-
// will serialize it as JSON and add quotes/double quotes to the string
96-
response = new HttpResponseMessage(statusCode)
97-
{
98-
Content = new StringContent((string)content)
99-
};
100-
}
101-
else
102-
{
103-
// let WebApi do its default serialization and content negotiation
104-
response = request.CreateResponse(statusCode, content);
105-
}
93+
HttpResponseMessage response = CreateResponse(request, statusCode, content, headers);
10694

10795
if (headers != null)
10896
{
@@ -115,7 +103,69 @@ public override async Task BindAsync(BindingContext context)
115103

116104
request.Properties[ScriptConstants.AzureFunctionsHttpResponseKey] = response;
117105
}
118-
106+
107+
private static HttpResponseMessage CreateResponse(HttpRequestMessage request, HttpStatusCode statusCode, object content, JObject headers)
108+
{
109+
JToken contentType = null;
110+
MediaTypeHeaderValue mediaType = null;
111+
if ((headers?.TryGetValue("content-type", StringComparison.OrdinalIgnoreCase, out contentType) ?? false) &&
112+
MediaTypeHeaderValue.TryParse(contentType.Value<string>(), out mediaType))
113+
{
114+
MediaTypeFormatter writer = request.GetConfiguration()
115+
.Formatters.FindWriter(content.GetType(), mediaType);
116+
117+
if (writer != null)
118+
{
119+
return new HttpResponseMessage(statusCode)
120+
{
121+
Content = new ObjectContent(content.GetType(), content, writer, mediaType)
122+
};
123+
}
124+
125+
HttpContent resultContent = CreateResultContent(content, mediaType.MediaType);
126+
127+
if (resultContent != null)
128+
{
129+
return new HttpResponseMessage(statusCode)
130+
{
131+
Content = resultContent
132+
};
133+
}
134+
}
135+
136+
return CreateNegotiatedResponse(request, statusCode, content);
137+
}
138+
139+
private static HttpContent CreateResultContent(object content, string mediaType)
140+
{
141+
if (content is string)
142+
{
143+
return new StringContent((string)content, null, mediaType);
144+
}
145+
else if (content is byte[])
146+
{
147+
return new ByteArrayContent((byte[])content);
148+
}
149+
else if (content is Stream)
150+
{
151+
return new StreamContent((Stream)content);
152+
}
153+
154+
return null;
155+
}
156+
157+
private static HttpResponseMessage CreateNegotiatedResponse(HttpRequestMessage request, HttpStatusCode statusCode, object content)
158+
{
159+
var configuration = request.GetConfiguration();
160+
IContentNegotiator negotiator = configuration.Services.GetContentNegotiator();
161+
var result = negotiator.Negotiate(content.GetType(), request, configuration.Formatters);
162+
163+
return new HttpResponseMessage(statusCode)
164+
{
165+
Content = new ObjectContent(content.GetType(), content, result.Formatter, result.MediaType)
166+
};
167+
}
168+
119169
public void ProcessResult(IDictionary<string, object> functionArguments, object[] systemArguments, string triggerInputName, object result)
120170
{
121171
if (result == null)

src/WebJobs.Script/GlobalSuppressions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,5 @@
107107
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1822:MarkMembersAsStatic", Scope = "member", Target = "Microsoft.Azure.WebJobs.Script.Config.ScriptSettingsManager.#SetSetting(System.String,System.String)")]
108108
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Azure.WebJobs.Script.Description.FunctionLoader`1.#.ctor(System.Func`2<System.Threading.CancellationToken,System.Threading.Tasks.Task`1<!0>>)")]
109109
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Azure.WebJobs.Script.Description.FunctionLoader`1.#Reset()")]
110-
110+
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Azure.WebJobs.Script.Binding.HttpBinding.#CreateResponse(System.Net.Http.HttpRequestMessage,System.Net.HttpStatusCode,System.Object,Newtonsoft.Json.Linq.JObject)")]
111+
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Azure.WebJobs.Script.Binding.HttpBinding.#CreateNegotiatedResponse(System.Net.Http.HttpRequestMessage,System.Net.HttpStatusCode,System.Object)")]

src/WebJobs.Script/Host/ScriptHostManager.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,14 @@ protected virtual void Dispose(bool disposing)
313313
ScriptHost[] instances = GetLiveInstancesAndClear();
314314
foreach (var instance in instances)
315315
{
316-
instance.Dispose();
316+
try
317+
{
318+
instance.Dispose();
319+
}
320+
catch (Exception exc) when (!exc.IsFatal())
321+
{
322+
// Best effort
323+
}
317324
}
318325

319326
_stopEvent.Dispose();

test/WebJobs.Script.Tests/EndToEndTestFixture.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
using System;
55
using System.Diagnostics;
66
using System.Linq;
7+
using System.Web.Http;
78
using Microsoft.Azure.WebJobs.Host;
89
using Microsoft.Azure.WebJobs.Script.Config;
910
using Microsoft.Azure.WebJobs.Script.Tests.ApiHub;
11+
using Microsoft.Azure.WebJobs.Script.WebHost;
1012
using Microsoft.ServiceBus;
1113
using Microsoft.WindowsAzure.Storage;
1214
using Microsoft.WindowsAzure.Storage.Blob;
@@ -41,6 +43,9 @@ protected EndToEndTestFixture(string rootPath, string testId)
4143
FileLoggingMode = FileLoggingMode.Always
4244
};
4345

46+
RequestConfiguration = new HttpConfiguration();
47+
RequestConfiguration.Formatters.Add(new PlaintextMediaTypeFormatter());
48+
4449
// Reset the timer logs first, since one of the tests will
4550
// be checking them
4651
TestHelpers.ClearFunctionLogs("TimerTrigger");
@@ -72,6 +77,8 @@ protected EndToEndTestFixture(string rootPath, string testId)
7277

7378
public string FixtureId { get; private set; }
7479

80+
public HttpConfiguration RequestConfiguration { get; }
81+
7582
public CloudQueue GetNewQueue(string queueName)
7683
{
7784
var queue = QueueClient.GetQueueReference(string.Format("{0}-{1}", queueName, FixtureId));

test/WebJobs.Script.Tests/NodeEndToEndTests.cs

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ public async Task HttpTrigger_Get()
318318
RequestUri = new Uri(string.Format("http://localhost/api/httptrigger?name=Mathew%20Charles&location=Seattle")),
319319
Method = HttpMethod.Get,
320320
};
321-
request.SetConfiguration(new HttpConfiguration());
321+
request.SetConfiguration(Fixture.RequestConfiguration);
322322
request.Headers.Add("test-header", "Test Request Header");
323323

324324
Dictionary<string, object> arguments = new Dictionary<string, object>
@@ -349,6 +349,47 @@ public async Task HttpTrigger_Get()
349349
Assert.Equal("Test Request Header", reqHeaders["test-header"]);
350350
}
351351

352+
[Theory]
353+
[InlineData("application/json", "\"testinput\"")]
354+
[InlineData("application/xml", "<string xmlns=\"http://schemas.microsoft.com/2003/10/Serialization/\">testinput</string>")]
355+
[InlineData("text/plain", "testinput")]
356+
public async Task HttpTrigger_GetWithAccept_NegotiatesContent(string accept, string expectedBody)
357+
{
358+
HttpRequestMessage request = new HttpRequestMessage
359+
{
360+
RequestUri = new Uri(string.Format("http://localhost/api/httptrigger-scenarios")),
361+
Method = HttpMethod.Get,
362+
};
363+
request.SetConfiguration(Fixture.RequestConfiguration);
364+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept));
365+
366+
JObject value = new JObject()
367+
{
368+
{ "status", "200" },
369+
{ "body", "testinput" }
370+
};
371+
JObject input = new JObject()
372+
{
373+
{ "scenario", "echo" },
374+
{ "value", value }
375+
};
376+
request.Content = new StringContent(input.ToString());
377+
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
378+
379+
Dictionary<string, object> arguments = new Dictionary<string, object>
380+
{
381+
{ "req", request }
382+
};
383+
await Fixture.Host.CallAsync("HttpTrigger-scenarios", arguments);
384+
385+
HttpResponseMessage response = (HttpResponseMessage)request.Properties[ScriptConstants.AzureFunctionsHttpResponseKey];
386+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
387+
Assert.Equal(accept, response.Content.Headers.ContentType.MediaType);
388+
389+
string body = await response.Content.ReadAsStringAsync();
390+
Assert.Equal(expectedBody, body);
391+
}
392+
352393
[Fact]
353394
public async Task HttpTriggerExpressApi_Get()
354395
{
@@ -396,7 +437,8 @@ public async Task HttpTriggerPromise_TestBinding()
396437
RequestUri = new Uri(string.Format("http://localhost/api/httptriggerpromise")),
397438
Method = HttpMethod.Get,
398439
};
399-
request.SetConfiguration(new HttpConfiguration());
440+
request.SetConfiguration(Fixture.RequestConfiguration);
441+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain"));
400442

401443
Dictionary<string, object> arguments = new Dictionary<string, object>
402444
{
@@ -632,7 +674,8 @@ public async Task WebHookTrigger_GenericJson()
632674
Method = HttpMethod.Post,
633675
Content = new StringContent(testObject.ToString())
634676
};
635-
request.SetConfiguration(new HttpConfiguration());
677+
request.SetConfiguration(Fixture.RequestConfiguration);
678+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain"));
636679
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
637680

638681
Dictionary<string, object> arguments = new Dictionary<string, object>
@@ -656,7 +699,8 @@ public async Task WebHookTrigger_NoContent()
656699
RequestUri = new Uri(string.Format("http://localhost/api/webhooktrigger?code=1388a6b0d05eca2237f10e4a4641260b0a08f3a5")),
657700
Method = HttpMethod.Post,
658701
};
659-
request.SetConfiguration(new HttpConfiguration());
702+
request.SetConfiguration(Fixture.RequestConfiguration);
703+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain"));
660704

661705
Dictionary<string, object> arguments = new Dictionary<string, object>
662706
{
@@ -666,7 +710,7 @@ public async Task WebHookTrigger_NoContent()
666710

667711
HttpResponseMessage response = (HttpResponseMessage)request.Properties[ScriptConstants.AzureFunctionsHttpResponseKey];
668712
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
669-
713+
670714
string body = await response.Content.ReadAsStringAsync();
671715
Assert.Equal(string.Format("No content"), body);
672716
}

test/WebJobs.Script.Tests/PowerShellEndToEndTests.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Management.Automation;
88
using System.Net;
99
using System.Net.Http;
10+
using System.Net.Http.Headers;
1011
using System.Threading.Tasks;
1112
using System.Web.Http;
1213
using Microsoft.Azure.WebJobs.Host;
@@ -73,9 +74,10 @@ public async Task HttpTrigger_CustomRoute()
7374
HttpRequestMessage request = new HttpRequestMessage
7475
{
7576
RequestUri = new Uri("http://localhost/api/products/produce/789?code=1388a6b0d05eca2237f10e4a4641260b0a08f3a5&name=testuser"),
76-
Method = HttpMethod.Get
77+
Method = HttpMethod.Get,
7778
};
78-
request.SetConfiguration(new HttpConfiguration());
79+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain"));
80+
request.SetConfiguration(Fixture.RequestConfiguration);
7981

8082
var routeData = new Dictionary<string, object>
8183
{

0 commit comments

Comments
 (0)