Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improving Table filter parameter validation #984

Merged
merged 2 commits into from
Jan 26, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 44 additions & 12 deletions src/Microsoft.Azure.WebJobs.Host/Bindings/AttributeCloner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs.Host.Bindings.Path;
using Microsoft.Azure.WebJobs.Host.Tables;

namespace Microsoft.Azure.WebJobs.Host.Bindings
{
Expand Down Expand Up @@ -77,8 +78,9 @@ public AttributeCloner(
BindingTemplate template;
if (TryCreateAutoResolveBindingTemplate(propInfo, nameResolver, out template))
{
var policy = ResolutionPolicy.GetPolicy(propInfo);
template.ValidateContractCompatibility(bindingDataContract);
getArgFuncs[i] = (bindingData) => TemplateBind(template, bindingData);
getArgFuncs[i] = (bindingData) => policy.TemplateBind(template, bindingData);
}
else
{
Expand All @@ -104,8 +106,9 @@ public AttributeCloner(
BindingTemplate template;
if (TryCreateAutoResolveBindingTemplate(prop, nameResolver, out template))
{
var policy = ResolutionPolicy.GetPolicy(prop);
template.ValidateContractCompatibility(bindingDataContract);
setProperties.Add((newAttr, bindingData) => prop.SetValue(newAttr, TemplateBind(template, bindingData)));
setProperties.Add((newAttr, bindingData) => prop.SetValue(newAttr, policy.TemplateBind(template, bindingData)));
}
else
{
Expand Down Expand Up @@ -182,16 +185,7 @@ internal static bool TryAutoResolveValue(TAttribute attribute, PropertyInfo prop
}

return true;
}

private static string TemplateBind(BindingTemplate template, IReadOnlyDictionary<string, object> bindingData)
{
if (bindingData == null)
{
return template.Pattern;
}
return template.Bind(bindingData);
}
}

// Get a attribute with %% resolved, but not runtime {} resolved.
public TAttribute GetNameResolvedAttribute()
Expand Down Expand Up @@ -311,6 +305,44 @@ internal TAttribute ResolveFromBindings(IReadOnlyDictionary<string, object> bind
return newAttr;
}

// Resolution policy for { } in binding templates.
// The default policy is just a direct substitution for the binding data.
// Derived policies can enforce formatting / escaping when they do injection.
private class ResolutionPolicy
{
public virtual string TemplateBind(BindingTemplate template, IReadOnlyDictionary<string, object> bindingData)
{
if (bindingData == null)
{
return template.Pattern;
}
return template.Bind(bindingData);
}

// Expand on [AutoResolve] property to make this pluggable,
public static ResolutionPolicy GetPolicy(PropertyInfo propInfo)
{
if (propInfo.DeclaringType == typeof(TableAttribute) && propInfo.Name == "Filter")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume the plan is that we open this up when I need it for DocDB?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. This is a a highly tactical change to get Table.Filter in quickly. Note that we could revert a bunch of files around bindings, bindng provider, flow the delegates, etc - those changes are more complex and not necessary to fix table.filter.


In reply to: 98059901 [](ancestors = 98059901)

Copy link
Member Author

@mathewc mathewc Jan 26, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this PR I think we should just put an attribute in place allowing this to be specified rather than be hardcoded like this. Kind of like what I did for ParameterResolver. We can still iterate on it, but it would be good to put what we think we want in place now.

{
return new TableFilterResolutionPolicy();
}
else
{
return new ResolutionPolicy();
}
}
}

// Table.Filter's { } resolution does OData escaping.
private class TableFilterResolutionPolicy : ResolutionPolicy
{
public override string TemplateBind(BindingTemplate template, IReadOnlyDictionary<string, object> bindingData)
{
var filter = TableFilterFormatter.Format(template, bindingData);
return filter;
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happened to the 'Properties' bag? I'm still going to need that for DocDB to pass data from the template binding to my binding rules.

Copy link
Member Author

@mathewc mathewc Jan 26, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All that should be done in a separate work item I think. You'll be most of the way there with this work.


// If no name resolver is specified, then any %% becomes an error.
private class EmptyNameResolver : INameResolver
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ internal static class BindingDataPathHelper
/// </summary>
/// <param name="bindingData">The binding data to convert.</param>
/// <returns>A collection of path compatible parameters.</returns>
public static IReadOnlyDictionary<string, string> ConvertParameters(IReadOnlyDictionary<string, object> bindingData)
public static Dictionary<string, string> ConvertParameters(IReadOnlyDictionary<string, object> bindingData)
{
if (bindingData == null)
{
Expand Down
6 changes: 5 additions & 1 deletion src/Microsoft.Azure.WebJobs.Host/GlobalSuppressions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Scope = "member", Target = "Microsoft.Azure.WebJobs.IConverterManagerExtensions.#AddConverter`2(Microsoft.Azure.WebJobs.IConverterManager,System.Func`2<!!0,!!1>)")]
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Scope = "member", Target = "Microsoft.Azure.WebJobs.IConverterManagerExtensions.#AddConverter`3(Microsoft.Azure.WebJobs.IConverterManager,System.Func`3<!!0,!!2,!!1>)")]
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1005:AvoidExcessiveParametersOnGenericTypes", Scope = "type", Target = "Microsoft.Azure.WebJobs.FuncConverter`3")]
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "WindowsAzure", Scope = "member", Target = "Microsoft.Azure.WebJobs.Host.AzureStorageDeploymentValidator.#Validate()")]
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "WindowsAzure", Scope = "member", Target = "Microsoft.Azure.WebJobs.Host.AzureStorageDeploymentValidator.#Validate()")]
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "1", Scope = "member", Target = "Microsoft.Azure.WebJobs.Host.Bindings.AsyncCollectorBinding`2.#BuildAsync(!0,Microsoft.Azure.WebJobs.Host.Bindings.BindingContext)")]
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "1", Scope = "member", Target = "Microsoft.Azure.WebJobs.Host.Bindings.ExactTypeBindingProvider`2+ExactBinding.#BuildAsync(!0,Microsoft.Azure.WebJobs.Host.Bindings.BindingContext)")]
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "1", Scope = "member", Target = "Microsoft.Azure.WebJobs.Host.Bindings.GenericItemBindingProvider`1+Binding.#BuildAsync(!0,Microsoft.Azure.WebJobs.Host.Bindings.BindingContext)")]
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "1", Scope = "member", Target = "Microsoft.Azure.WebJobs.Host.Bindings.ItemBindingProvider`1+Binding.#BuildAsync(!0,Microsoft.Azure.WebJobs.Host.Bindings.BindingContext)")]
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ private TableAttributeBindingProvider(INameResolver nameResolver, IStorageAccoun
new PocoEntityArgumentBindingProvider()); // Supports all types; must come after other providers
}

// [Table] has some pre-existing behavior where the storage account can be specified outside of the [Queue] attribute.
// [Table] has some pre-existing behavior where the storage account can be specified outside of the [Table] attribute.
// The storage account is pulled from the ParameterInfo (which could pull in a [Storage] attribute on the container class)
// Resolve everything back down to a single attribute so we can use the binding helpers.
// This pattern should be rare since other extensions can just keep everything directly on the primary attribute.
Expand Down
111 changes: 111 additions & 0 deletions src/Microsoft.Azure.WebJobs.Host/Tables/TableFilterFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Azure.WebJobs.Host.Bindings;
using Microsoft.Azure.WebJobs.Host.Bindings.Path;

namespace Microsoft.Azure.WebJobs.Host.Tables
{
internal static class TableFilterFormatter
{
public static string Format(BindingTemplate template, IReadOnlyDictionary<string, object> bindingData)
{
if (!template.ParameterNames.Any())
{
return template.Pattern;
}

if (template.ParameterNames.Count() == 1)
{
// Special case where the entire filter expression
// is a single parameter. We let this go through as is
string parameterName = template.ParameterNames.Single();
if (template.Pattern == $"{{{parameterName}}}")
{
return template.Bind(bindingData);
}
}

// each distinct parameter can occur one or more times in the template
// so group by parameter name
var parameterGroups = template.ParameterNames.GroupBy(p => p);

// for each parameter, classify it as a string literal or other
// and perform value validation
var convertedBindingData = BindingDataPathHelper.ConvertParameters(bindingData);
foreach (var parameterGroup in parameterGroups)
{
// to classify as a string literal, ALL occurrences in the template
// must be string literals (e.g. of the form '{p}')
// note that this will also capture OData expressions of the form
// datetime'{p}', guid'{p}', X'{p}' which is fine, because single quotes
// aren't valid for those values anyways.
bool isStringLiteral = true;
string parameterName = parameterGroup.Key;
string stringParameterFormat = $"'{{{parameterName}}}'";
int count = 0, idx = 0;
while (idx >= 0 && idx < template.Pattern.Length && count++ < parameterGroup.Count())
{
idx = template.Pattern.IndexOf(stringParameterFormat, idx, StringComparison.OrdinalIgnoreCase);
if (idx < 0)
{
isStringLiteral = false;
break;
}
idx++;
}

// validate and format the value based on its classification
string value = null;
if (convertedBindingData.TryGetValue(parameterName, out value))
{
if (isStringLiteral)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

@mathewc mathewc Jan 26, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formatting DateTimes when we have the type info as ISO is a good idea. I've added formatting for DateTime and DateTimeOffset. See new if block below.

{
convertedBindingData[parameterName] = value.Replace("'", "''");
}
else if (!TryValidateNonStringLiteral(value))
{
throw new InvalidOperationException($"An invalid parameter value was specified for filter parameter '{parameterName}'.");
}
}

// perform any OData specific formatting on the values
object originalValue;
if (bindingData.TryGetValue(parameterName, out originalValue))
{
if (originalValue is DateTime)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (originalValue is DateTime) [](start = 20, length = 30)

originalValue's type may often be a string - especially if this is coming in from an untyped or query string parameter. The way we know to format as a date time is from the Odata string context. Ie, "Prop eq datetime'{x}'" is a datetime, but "Prop eq '{x}'" is a string literal.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have this same issue with guid and binary - although those are likely rarer cases.


In reply to: 98048689 [](ancestors = 98048689)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, guid is already handled. Guid formatting is just ToString on the guid (that's what Azure SDK does). We already do this. For Binary, that is too complicated, and I think we should leave that to the user. I.e., if they have a byte[] property, we make them do the base 64. I don't think we should concern ourselves with that right now.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, for this type formatting, we're only helping when we have the type info. That will always be the case for core SDK. For dynamic scenarios like Azure Functions non-C#, yes these will be strings, so for now we expect users to format those values. That story can improve in the future with an AF type model for trigger values.

{
// OData DateTime literals should be ISO 8601 formatted (e.g. 2009-03-18T04:25:03Z)
convertedBindingData[parameterName] = ((DateTime)originalValue).ToUniversalTime().ToString("o");
}
else if (originalValue is DateTimeOffset)
{
convertedBindingData[parameterName] = ((DateTimeOffset)originalValue).UtcDateTime.ToString("o");
}
}
}

return template.Bind(convertedBindingData);
}

internal static bool TryValidateNonStringLiteral(string value)
{
// value must be one of the odata supported non string literal types:
// bool, int, long, double
bool boolValue;
long longValue;
double doubleValue;
if (bool.TryParse(value, out boolValue) ||
long.TryParse(value, out longValue) ||
double.TryParse(value, out doubleValue))
{
return true;
}

return false;
}
}
}
1 change: 1 addition & 0 deletions src/Microsoft.Azure.WebJobs.Host/WebJobs.Host.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@
<Compile Include="Storage\IStorageAccountExtensions.cs" />
<Compile Include="Storage\StorageAccountType.cs" />
<Compile Include="Queues\PoisonQueueMessageEventArgs.cs" />
<Compile Include="Tables\TableFilterFormatter.cs" />
<Compile Include="Triggers\StrategyTriggerBinding.cs" />
<Compile Include="Bindings\ConstantValueProvider.cs" />
<Compile Include="Bindings\ConverterManager.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using Microsoft.WindowsAzure.Storage.Queue;
using Microsoft.WindowsAzure.Storage.Table;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;

namespace Microsoft.Azure.WebJobs.Host.EndToEndTests
Expand Down Expand Up @@ -41,12 +42,10 @@ public class AzureStorageEndToEndTests : IClassFixture<AzureStorageEndToEndTests
private static int _badMessage2Calls;

private static EventWaitHandle _startWaitHandle;

private static EventWaitHandle _functionChainWaitHandle;

private CloudStorageAccount _storageAccount;

private RandomNameResolver _resolver;
private static object TestResult;

public AzureStorageEndToEndTests(TestFixture fixture)
{
Expand Down Expand Up @@ -172,6 +171,14 @@ public static void BadMessage_String(
_badMessage2Calls++;
}

[NoAutomaticTrigger]
public static void TableWithFilter(
[QueueTrigger("test")] Person person,
[Table(TableName, Filter = "(Age gt {Age}) and (Location eq '{Location}')")] JArray results)
{
TestResult = results;
}

// Uncomment the Fact attribute to run
// [Fact(Timeout = 20 * 60 * 1000)]
public void AzureStorageEndToEndSlow()
Expand All @@ -185,6 +192,83 @@ public void AzureStorageEndToEndFast()
EndToEndTest(uploadBlobBeforeHostStart: true);
}

[Fact]
public async Task TableFilterTest()
{
// Reinitialize the name resolver to avoid conflicts
_resolver = new RandomNameResolver();

JobHostConfiguration hostConfig = new JobHostConfiguration()
{
NameResolver = _resolver,
TypeLocator = new FakeTypeLocator(
this.GetType(),
typeof(BlobToCustomObjectBinder))
};

// write test entities
string testTableName = _resolver.ResolveInString(TableName);
CloudTableClient tableClient = _storageAccount.CreateCloudTableClient();
CloudTable table = tableClient.GetTableReference(testTableName);
await table.CreateIfNotExistsAsync();
var operation = new TableBatchOperation();
operation.Insert(new Person
{
PartitionKey = "1",
RowKey = "1",
Name = "Lary",
Age = 20,
Location = "Seattle"
});
operation.Insert(new Person
{
PartitionKey = "1",
RowKey = "2",
Name = "Moe",
Age = 35,
Location = "Seattle"
});
operation.Insert(new Person
{
PartitionKey = "1",
RowKey = "3",
Name = "Curly",
Age = 45,
Location = "Texas"
});
operation.Insert(new Person
{
PartitionKey = "1",
RowKey = "4",
Name = "Bill",
Age = 28,
Location = "Tam O'Shanter"
});
await table.ExecuteBatchAsync(operation);

JobHost host = new JobHost(hostConfig);
var methodInfo = this.GetType().GetMethod("TableWithFilter", BindingFlags.Public | BindingFlags.Static);
var input = new Person { Age = 25, Location = "Seattle" };
string json = JsonConvert.SerializeObject(input);
var arguments = new { person = json };
await host.CallAsync(methodInfo, arguments);

// wait for test results to appear
await TestHelpers.Await(() => TestResult != null);

JArray results = (JArray)TestResult;
Assert.Equal(1, results.Count);

input = new Person { Age = 25, Location = "Tam O'Shanter" };
json = JsonConvert.SerializeObject(input);
arguments = new { person = json };
await host.CallAsync(methodInfo, arguments);
await TestHelpers.Await(() => TestResult != null);
results = (JArray)TestResult;
Assert.Equal(1, results.Count);
Assert.Equal("Bill", (string)results[0]["Name"]);
}

private void EndToEndTest(bool uploadBlobBeforeHostStart)
{
// Reinitialize the name resolver to avoid conflicts
Expand Down Expand Up @@ -379,6 +463,13 @@ private class CustomTableEntity : TableEntity
public int Number { get; set; }
}

public class Person : TableEntity
{
public int Age { get; set; }
public string Location { get; set; }
public string Name { get; set; }
}

public class TestFixture : IDisposable
{
public TestFixture()
Expand Down
Loading