diff --git a/docs/ExpressionHelpers.md b/docs/ExpressionHelpers.md index 160f4f5f..de122400 100644 --- a/docs/ExpressionHelpers.md +++ b/docs/ExpressionHelpers.md @@ -62,7 +62,7 @@ if (IsCompanyIp(context.ExpressionContext) && !IsHealthCheck(context.ExpressionC A policy configuration argument may be a call to a factory method, also from a referenced project. The factory must return a single `new TConfig { ... }` expression and take no parameters other than policy section contexts, such as -`IInboundContext`. +`IInboundContext`, or `IHaveExpressionContext`. ```csharp context.RateLimitByKey(Limits.PerProduct(context)); @@ -94,6 +94,44 @@ public static class TextHelpers Helpers are inlined from source, so the library must be a project reference. Calling a helper, property or non-constant field of a compiled library is reported, although its constants can still be used. +## What the analyzer checks + +The analyzer reports code that API Management rejects when the policy is saved: types and members outside the set it +allows in policy expressions (`APIM001`, `APIM002`), and type names it can't resolve on their own (`APIM003`). + +It checks: + +- every helper declared in a policy document or fragment class, including helpers in a class nested inside it. + A helper it inherits is checked when the base class is a document itself, not otherwise. A helper is a method + that takes the expression context, or one the document's sections, policy configuration factories or other + helpers call; it may take a section context instead, whose `ExpressionContext` is the gateway's `context`. The + `[Expression]` attribute isn't needed for these. +- methods marked `[Expression]` and every method of a class marked `[Expression]`. +- expression lambdas passed to a policy. + +A helper in any other class is only checked when its class is marked `[Expression]`. Code marked `[Expression]` may +only call document helpers and other marked helpers, while an unmarked document helper may call any helper declared in +source. + +The starting points are the sections, the configuration factories, the methods marked `[Expression]` and the helpers +that take the expression context or a section context, of every document and helper library in the project. A +section is what the compiler compiles: `Inbound`, `Outbound`, `Backend` or `OnError` of a document, or the first +`Fragment` method of a fragment, taking one section context. A method of a document that none of them reaches, such as a `ToString()` override or a `void` method that isn't a +section, never becomes a policy expression and isn't checked, nor are the helpers only it calls. Code of another +document or library reaches a helper when it names the document, `Shared.Helper()`, directly or through a using alias. +Calls are matched by name, so an overload of a helper is checked with it. Properties declared in source can't be used +as helpers; the compiler reports them. + +### Ambiguous type names + +API Management resolves type names against all the namespaces it imports, whatever the `using` directives of the +source file are. `Formatting` exists in both `Newtonsoft.Json` and `System.Xml`, so it has to be written with its +namespace: + +```csharp +JsonConvert.SerializeObject(value, Newtonsoft.Json.Formatting.Indented) +``` + ## Named values `context.NamedValue("name")` compiles to a raw `{{name}}` token, so the named value is used as code. @@ -116,7 +154,9 @@ non-constant field of a compiled library is reported, although its constants can pair of parentheses to keep them. - At either end of an interpolation hole a named value is always parenthesized, `{({{name}})}`, so it can't run into the hole's braces. -- An expression that is only a named value compiles to the plain attribute value, `{{name}}`. +- An expression that is only a named value, returned from a `string` helper or a `[NamedValue]` helper, compiles to + the plain attribute value, `{{name}}`. Returned from a helper of another type it stays an expression, `@({{name}})`, + which the gateway evaluates as code. ## C# language version diff --git a/docs/OutputFormat.md b/docs/OutputFormat.md index c3118f09..a92d0f38 100644 --- a/docs/OutputFormat.md +++ b/docs/OutputFormat.md @@ -21,6 +21,27 @@ The generated document is only valid for the matching API Management content for `Microsoft.ApiManagement/service/apis/policies` or `.../policyFragments` Bicep resource has a `format` property that must be set to the same value (`rawxml` or `xml`) you compiled with. +## Decompiling + +The decompiler takes the same option for the format of its input, with the same default: + +```shell +# default: rawxml +dotnet azure-apim-policy-decompiler --s .\target --o .\src + +# policies written or exported as xml +dotnet azure-apim-policy-decompiler --s .\target --o .\src --policy-format xml +``` + +| `--policy-format` | How a policy is read | +| --- | --- | +| `rawxml` (default) | A value that begins with an expression is C# as it is written: an entity in it, such as the string `"&"`, is kept. Everything else is XML, an `@(...)` further into a value or anywhere in the markup of a liquid `set-body` included | +| `xml` | The document has to be well-formed XML, and an entity in an expression is decoded like any other | + +Give the format the policies were compiled or exported in. A `rawxml` policy read as `xml` usually isn't +well-formed XML and fails to parse; an `xml` policy read as `rawxml` decompiles to expressions that keep their +entities and don't parse as C#. The decompiler reports either and names this option. + ## Which format should I use? - **`rawxml` (default)** keeps expressions verbatim, so the generated files stay readable — including multi-line diff --git a/src/Analyzers/ExpressionDefinition.Analyzer.cs b/src/Analyzers/ExpressionDefinition.Analyzer.cs index d70db138..815e2af0 100644 --- a/src/Analyzers/ExpressionDefinition.Analyzer.cs +++ b/src/Analyzers/ExpressionDefinition.Analyzer.cs @@ -26,6 +26,13 @@ public override void Initialize(AnalysisContext context) SyntaxKind.ParenthesizedLambdaExpression); } + // The types an expression helper can be declared to return. The gateway types the expression itself and + // does not convert its value, so this follows the types it accepts as the value of a variable, with two + // additions the set-variable compiler reports there: System.Uri and enums, which a helper may return for use + // inside a larger expression. object is here because the decompiler declares the helper of a set-variable + // value as object. Nullable and array forms and enums are handled in IsAllowedReturnType: a nullable type is + // allowed when its type is, although the gateway's list omits bool?, sbyte? and TimeSpan?, which haven't been + // probed. private readonly static IReadOnlyCollection AllowedExpressionReturnTypes = new HashSet() { "System.Boolean", @@ -39,14 +46,42 @@ public override void Initialize(AnalysisContext context) "System.Int16", "System.Int32", "System.Int64", + "System.Object", + "System.SByte", + "System.Single", "System.String", + "System.TimeSpan", "System.UInt16", "System.UInt32", "System.UInt64", "System.Uri", + "Newtonsoft.Json.Linq.JArray", + "Newtonsoft.Json.Linq.JConstructor", + "Newtonsoft.Json.Linq.JContainer", "Newtonsoft.Json.Linq.JObject", + "Newtonsoft.Json.Linq.JProperty", + "Newtonsoft.Json.Linq.JRaw", + "Newtonsoft.Json.Linq.JToken", + "Newtonsoft.Json.Linq.JValue", }; + private static bool IsAllowedReturnType(ITypeSymbol type) + { + switch (type) + { + case { TypeKind: TypeKind.Enum }: + return true; + // the gateway accepts byte[] and sbyte[] as the value of an expression, no other array + case IArrayTypeSymbol { Rank: 1 } array: + return array.ElementType.SpecialType is + SpecialType.System_Byte or SpecialType.System_SByte; + case INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable: + return IsAllowedReturnType(nullable.TypeArguments[0]); + default: + return AllowedExpressionReturnTypes.Contains(type.ToFullyQualifiedString()); + } + } + private const string ContextParamType = "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IExpressionContext"; @@ -74,7 +109,7 @@ private static void AnalyzeMethod(SyntaxNodeAnalysisContext context) else { var fullTypeName = type.ToFullyQualifiedString(); - if (!AllowedExpressionReturnTypes.Contains(fullTypeName)) + if (!IsAllowedReturnType(type)) { var diagnostic = Diagnostic.Create(Rules.Expression.ReturnTypeNotAllowed, method.ReturnType.GetLocation(), fullTypeName); diff --git a/src/Analyzers/ExpressionTypeUsed.Analyzer.cs b/src/Analyzers/ExpressionTypeUsed.Analyzer.cs index 32590b64..ea4212d8 100644 --- a/src/Analyzers/ExpressionTypeUsed.Analyzer.cs +++ b/src/Analyzers/ExpressionTypeUsed.Analyzer.cs @@ -27,6 +27,37 @@ public override void Initialize(AnalysisContext context) SyntaxKind.ObjectCreationExpression, SyntaxKind.ObjectInitializerExpression, SyntaxKind.AnonymousObjectCreationExpression); + context.RegisterSyntaxNodeAction(AnalyzeTypeName, SyntaxKind.IdentifierName); + } + + // API Management resolves type names against every namespace it imports, so a name declared in two of them is + // rejected unless it is written with its namespace, whatever the using directives of the source file are. + private readonly static IReadOnlyDictionary> AmbiguousTypeNames = + new Dictionary>() + { + { "Formatting", new HashSet() { "Newtonsoft.Json.Formatting", "System.Xml.Formatting" } }, + }; + + private static void AnalyzeTypeName(SyntaxNodeAnalysisContext context) + { + var name = (IdentifierNameSyntax)context.Node; + if (!AmbiguousTypeNames.TryGetValue(name.Identifier.ValueText, out var candidates) || + name.Parent is MemberAccessExpressionSyntax memberAccess && memberAccess.Name == name || + name.Parent is QualifiedNameSyntax qualifiedName && qualifiedName.Right == name || + name.Parent is AliasQualifiedNameSyntax || + !IsPartOfPolicyExpression(context)) + { + return; + } + + // The compiler writes a using alias out as the name it stands for, so that is not a bare name. + if (context.SemanticModel.GetAliasInfo(name) is null && + context.SemanticModel.GetSymbolInfo(name).Symbol is INamedTypeSymbol type && + candidates.Contains(type.ToFullyQualifiedString())) + { + context.ReportDiagnostic(Diagnostic.Create(Rules.TypeUsed.AmbiguousTypeName, name.GetLocation(), + name.Identifier.ValueText, type.ToFullyQualifiedString())); + } } private readonly static IReadOnlyCollection AllowedTypes = new HashSet() @@ -174,12 +205,21 @@ public override void Initialize(AnalysisContext context) #region Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions + "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.Authorization", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.BasicAuthCredentials", + "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.ByteArrayExtensions", + "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.CarbonIntensityCategory", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.DictionaryExtensions", + "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IFoundry", + "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.ISustainabilityInfo", + "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.X509Certificate2Extensions", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IApi", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IExpressionContext", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IContextApi", + "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IBackend", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IDeployment", + "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IGateway", + "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IWorkspace", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IGroup", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.Jwt", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.ILastError", @@ -188,6 +228,7 @@ public override void Initialize(AnalysisContext context) "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IOperation", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IPrivateEndpointConnection", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IProduct", + "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.ProductState", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IRequest", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IResponse", "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.ISubscription", @@ -204,6 +245,9 @@ public override void Initialize(AnalysisContext context) new Dictionary>() { { "Newtonsoft.Json.JsonConvert", new HashSet() { "SerializeObject", "DeserializeObject" } }, + // The gateway accepts the type but none of its members: ToString(), Equals() or GetType() called on + // a value typed object, or on a type that doesn't declare them itself, is rejected. + { "System.Object", new HashSet() }, { "System.DateTime", new HashSet() { @@ -287,10 +331,7 @@ private static void AnalyzeNode(SyntaxNodeAnalysisContext context) { var node = context.Node; - var underUnderExpressionMethod = node.IsPartOfPolicyExpressionMethod(context.SemanticModel); - var underUnderExpressionLambda = node.IsPartOfPolicyExpressionDelegate(context.SemanticModel); - - if (!underUnderExpressionMethod && !underUnderExpressionLambda) + if (!IsPartOfPolicyExpression(context)) { return; } @@ -307,6 +348,33 @@ private static void AnalyzeNode(SyntaxNodeAnalysisContext context) return; } + // A type named to qualify a member, Shared.Nested.Line() or TimeZoneInfo.AdjustmentRule.X, isn't a use of + // its own containing type; it is checked as the type of the member used. + if (nodeSymbol is INamedTypeSymbol) + { + return; + } + + // A helper may take a section context and read its ExpressionContext, which the compiler maps to the + // gateway's context. + // The receiver has to be one of the authoring library's section contexts itself. + if (nodeSymbol is IPropertySymbol { Name: "ExpressionContext" } && + node is MemberAccessExpressionSyntax { Expression: var receiver } && + context.SemanticModel.GetTypeInfo(receiver).Type?.IsSectionContext() == true) + { + return; + } + + // Invoking a delegate typed member, like context.Trace("message"), is a use of that member and not of the + // delegate type. + if (nodeSymbol is IMethodSymbol { MethodKind: MethodKind.DelegateInvoke } && + node is InvocationExpressionSyntax delegateInvocation && + context.SemanticModel.GetSymbolInfo(delegateInvocation.Expression).Symbol is + { Kind: SymbolKind.Property or SymbolKind.Field } delegateMember) + { + nodeSymbol = delegateMember; + } + // Members of an expression helper library ([Expression] on the class) are analysed in their own project, so // calls to its methods and uses of its constants are allowed, from source or a referenced assembly. if (nodeSymbol.IsExpressionLibraryMember() && @@ -316,10 +384,15 @@ private static void AnalyzeNode(SyntaxNodeAnalysisContext context) return; } - // Calls to [Expression] helpers (also from a referenced assembly) are expanded by the compiler, and source - // constants are folded into literals, except values of source enums, which don't exist in API Management. A - // helper passed as a method group isn't expanded. - if (nodeSymbol is IMethodSymbol && node is InvocationExpressionSyntax && nodeSymbol.HasExpressionAttribute() || + // Calls to [Expression] helpers (also from a referenced assembly) and to the helpers of a policy document are + // expanded by the compiler, and source constants are folded into literals, except values of source enums, + // which don't exist in API Management. A helper passed as a method group isn't expanded. A document helper + // that isn't marked [Expression] may call any helper declared in source, as it could before it was analysed. + if (nodeSymbol is IMethodSymbol && node is InvocationExpressionSyntax && + (nodeSymbol.HasExpressionAttribute() || nodeSymbol.IsDocumentMember() || + !nodeSymbol.DeclaringSyntaxReferences.IsDefaultOrEmpty && + !node.IsPartOfMarkedPolicyExpressionMethod(context.SemanticModel) && + !node.IsPartOfPolicyExpressionDelegate(context.SemanticModel)) || !nodeSymbol.DeclaringSyntaxReferences.IsDefaultOrEmpty && nodeSymbol is IFieldSymbol { IsConst: true, ContainingType.TypeKind: not TypeKind.Enum } field && !(field.Type.TypeKind == TypeKind.Enum && !field.Type.DeclaringSyntaxReferences.IsDefaultOrEmpty)) @@ -333,9 +406,15 @@ private static void AnalyzeNode(SyntaxNodeAnalysisContext context) return; } + // API Management accepts anonymous types: new { a = 1 }.a + if (symbol.IsAnonymousType) + { + return; + } + // Indexers are named "this[]" in Roslyn; the allow lists use their metadata name ("Item"). var memberName = nodeSymbol is IPropertySymbol { IsIndexer: true } ? nodeSymbol.MetadataName : nodeSymbol.Name; - var typeName = (symbol.IsGenericType ? symbol.OriginalDefinition : symbol)?.ToFullyQualifiedString() ?? ""; + var typeName = GetTypeName(symbol); if (AllowedTypes.Contains(typeName)) { if (AllowedInTypes.TryGetValue(typeName, out var allowed) && !allowed.Contains(memberName)) @@ -355,4 +434,24 @@ private static void AnalyzeNode(SyntaxNodeAnalysisContext context) context.ReportDiagnostic(Diagnostic.Create(Rules.TypeUsed.DisallowedType, node.GetLocation(), typeName)); } } + + private static bool IsPartOfPolicyExpression(SyntaxNodeAnalysisContext context) + { + return context.Node.IsPartOfPolicyExpressionMethod(context.SemanticModel) || + context.Node.IsPartOfPolicyExpressionDelegate(context.SemanticModel); + } + + private static string GetTypeName(INamedTypeSymbol symbol) + { + if (!symbol.IsGenericType) + { + return symbol.ToFullyQualifiedString(); + } + + // System.Nullable and System.Tuple are listed without type parameters and are allowed with any number of them. + var nameWithoutTypeParameters = symbol.OriginalDefinition.ToFullyQualifiedStringWithoutTypeParameters(); + return AllowedTypes.Contains(nameWithoutTypeParameters) + ? nameWithoutTypeParameters + : symbol.OriginalDefinition.ToFullyQualifiedString(); + } } \ No newline at end of file diff --git a/src/Analyzers/ExpressionTypeUsed.Rules.cs b/src/Analyzers/ExpressionTypeUsed.Rules.cs index 4086de76..75b64ef8 100644 --- a/src/Analyzers/ExpressionTypeUsed.Rules.cs +++ b/src/Analyzers/ExpressionTypeUsed.Rules.cs @@ -33,9 +33,19 @@ public static class TypeUsed helpLinkUri: "TODO", customTags: new[] { "APIM", "ApiManagement" }); + public readonly static DiagnosticDescriptor AmbiguousTypeName = new DiagnosticDescriptor( + "APIM003", + "Ambiguous type name used", + "Type name '{0}' is ambiguous in API Management, use '{1}'", + "Type", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Description.", + helpLinkUri: "TODO", + customTags: new[] { "APIM", "ApiManagement" }); public readonly static ImmutableArray All = ImmutableArray.Create( - DisallowedType, DisallowedMember + DisallowedType, DisallowedMember, AmbiguousTypeName ); } } \ No newline at end of file diff --git a/src/Analyzers/Extensions/SymbolExtensions.cs b/src/Analyzers/Extensions/SymbolExtensions.cs index 3649fe58..af443cea 100644 --- a/src/Analyzers/Extensions/SymbolExtensions.cs +++ b/src/Analyzers/Extensions/SymbolExtensions.cs @@ -14,5 +14,11 @@ public static class SymbolExtensions miscellaneousOptions: SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers ); + private readonly static SymbolDisplayFormat FormatWithoutTypeParameters = + Format.WithGenericsOptions(SymbolDisplayGenericsOptions.None); + public static string ToFullyQualifiedString(this ISymbol symbol) => symbol.ToDisplayString(Format); + + public static string ToFullyQualifiedStringWithoutTypeParameters(this ISymbol symbol) => + symbol.ToDisplayString(FormatWithoutTypeParameters); } \ No newline at end of file diff --git a/src/Analyzers/Extensions/SyntaxExtensions.cs b/src/Analyzers/Extensions/SyntaxExtensions.cs index 94aec4a4..f710b371 100644 --- a/src/Analyzers/Extensions/SyntaxExtensions.cs +++ b/src/Analyzers/Extensions/SyntaxExtensions.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; @@ -36,14 +39,384 @@ public static bool HasExpressionAttribute(this ISymbol symbol) .Any(attribute => attribute.AttributeClass?.ToFullyQualifiedString() == ExpressionAttribute); } - // A node inside an [Expression] method, or inside any method of an expression helper library (by symbol, so - // every part of a partial class is included). + // A node inside an [Expression] method, inside any method of an expression helper library (by symbol, so + // every part of a partial class is included), or inside a helper of a policy document or fragment that the + // compiler turns into a policy expression without the attribute. public static bool IsPartOfPolicyExpressionMethod(this SyntaxNode syntax, SemanticModel model) { return syntax.Ancestors() .OfType() - .Any(method => method.AttributeLists.ContainsExpressionAttribute(model) || - model.GetDeclaredSymbol(method)?.IsExpressionLibraryMember() == true); + .Any(method => IsMarkedExpressionMethod(method, model) || + model.GetDeclaredSymbol(method) is IMethodSymbol symbol && + IsDocumentExpressionHelper(symbol, model.Compilation)); + } + + private static readonly ConditionalWeakTable>> ReachedMethods = + new ConditionalWeakTable>>(); + + // The compiler expands a helper of a document only where an expression calls it, so a method of a document is + // expression code when it takes the expression context or is called, directly or through other helpers, from + // a section, a configuration factory or such a method, of its own document or of another. A method nothing + // calls, such as a ToString() override, never becomes a policy expression. + private static bool IsDocumentExpressionHelper(IMethodSymbol method, Compilation compilation) => + method.IsDocumentMember() && !IsSectionOrConfigurationFactory(method) && + ReachedMethods.GetValue(compilation, c => new Lazy>(() => FindReachedMethods(c))) + .Value.Contains(method); + + // A document with the classes nested in it and the documents derived from it, or an expression helper library: + // the methods a bare call from one of them can mean, and the names a qualified call to one of them is written + // with. + private sealed class Family + { + public List Methods { get; } = new List(); + + // Nested, Document, Derived: the last name of a qualified receiver + public HashSet Receivers { get; } = new HashSet(); + + // those, the namespace and the classes the document is nested in: the other names of the receiver + public HashSet Qualifiers { get; } = new HashSet(); + } + + // The methods of every document and expression helper library of the compilation that are reached from a + // root, in one pass. Calls are matched by name (Helper(), this.Helper(), Nested.Helper(), Shared.Helper()), + // which needs no semantic model for other files; an overload that isn't the one called is included with it. + private static HashSet FindReachedMethods(Compilation compilation) + { + var types = compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type) + .OfType() + .Where(type => !type.DeclaringSyntaxReferences.IsDefaultOrEmpty) + .ToList(); + var families = new List(); + var familiesOf = new Dictionary>(SymbolEqualityComparer.Default); + foreach (var type in types.Where(type => type.ContainingType is null || !IsFamilyMember(type.ContainingType))) + { + // a document, an expression helper library, or a class with an [Expression] method that may call a document + if (!type.IsDocument() && !type.HasExpressionAttribute() && + !type.GetMembers().OfType().Any(HasExpressionAttribute)) + { + continue; + } + + var family = new Family(); + var members = new Stack(); + members.Push(type); + // a helper of a base document is expanded where a derived document calls it + foreach (var derived in types.Where(candidate => Derives(candidate, type))) + { + members.Push(derived); + } + + while (members.Count > 0) + { + var member = members.Pop(); + family.Receivers.Add(member.Name); + // the namespace and the classes each is nested in, which differ for a derived document: + // Other.Derived.Helper(), Outer.Document.Nested.Helper() + for (var space = member.ContainingNamespace; space is { IsGlobalNamespace: false }; space = space.ContainingNamespace) + { + family.Qualifiers.Add(space.Name); + } + + for (var outer = member.ContainingType; outer is not null; outer = outer.ContainingType) + { + family.Qualifiers.Add(outer.Name); + } + + // a section may implement IDocument explicitly: void IDocument.Inbound(IInboundContext context) + foreach (var method in member.GetMembers().OfType().Where(method => + method.MethodKind is MethodKind.Ordinary or MethodKind.ExplicitInterfaceImplementation)) + { + family.Methods.Add(method); + if (!familiesOf.TryGetValue(method, out var owners)) + { + familiesOf[method] = owners = new List(); + } + + owners.Add(family); + } + + foreach (var nested in member.GetTypeMembers()) + { + members.Push(nested); + } + } + + family.Qualifiers.UnionWith(family.Receivers); + families.Add(family); + } + + var reached = new HashSet(SymbolEqualityComparer.Default); + var pending = new Queue(); + foreach (var root in familiesOf.Keys.Where(IsRoot)) + { + if (reached.Add(root)) + { + pending.Enqueue(root); + } + } + + var aliasesByTree = new Dictionary>(); + while (pending.Count > 0) + { + var caller = pending.Dequeue(); + var own = familiesOf[caller]; + foreach (var reference in caller.DeclaringSyntaxReferences) + { + var aliases = AliasesOf(reference.SyntaxTree, aliasesByTree); + foreach (var invocation in reference.GetSyntax().DescendantNodes().OfType()) + { + IEnumerable candidates; + string name; + switch (invocation.Expression) + { + // Helper(), this.Helper(), base.Helper(): a method of the caller's own document + case SimpleNameSyntax simple: + name = simple.Identifier.ValueText; + candidates = own.SelectMany(family => family.Methods); + break; + case MemberAccessExpressionSyntax access when Unparenthesized(access.Expression) is + ThisExpressionSyntax or BaseExpressionSyntax: + name = access.Name.Identifier.ValueText; + candidates = own.SelectMany(family => family.Methods); + break; + // Nested.Helper(), Shared.Helper(), Ns.Shared.Nested.Helper(): the families the receiver names; + // value.ToString() is a call on something else + case MemberAccessExpressionSyntax access when Segments(access.Expression, aliases) is { } segments: + name = access.Name.Identifier.ValueText; + candidates = families.Where(family => IsReceiver(segments, family)).SelectMany(family => family.Methods); + break; + default: + continue; + } + + foreach (var method in candidates.Where(method => method.Name == name && reached.Add(method))) + { + pending.Enqueue(method); + } + } + } + } + + return reached; + + static bool IsFamilyMember(INamedTypeSymbol type) + { + for (var outer = type; outer is not null; outer = outer.ContainingType) + { + if (outer.IsDocument() || outer.HasExpressionAttribute() || + outer.GetMembers().OfType().Any(HasExpressionAttribute)) + { + return true; + } + } + + return false; + } + + static bool Derives(INamedTypeSymbol candidate, INamedTypeSymbol type) + { + for (var baseType = candidate.BaseType; baseType is not null; baseType = baseType.BaseType) + { + // Derived : Base has the constructed type as its base + if (SymbolEqualityComparer.Default.Equals(baseType.OriginalDefinition, type.OriginalDefinition)) + { + return true; + } + } + + return false; + } + + // The methods the compiler starts from: the sections, the configuration factories, the expression methods + // and the helpers that take a section context or the expression context. A factory is a root wherever it is + // called from. A void method that isn't a section, Audit(IInboundContext context), is never compiled, so + // nothing is compiled from it either. + static bool IsRoot(IMethodSymbol method) => + IsSection(method) || IsConfigurationFactory(method) || method.HasExpressionAttribute() || + method.IsExpressionLibraryMember() || + !method.ReturnsVoid && (TakesSectionContext(method) || method.Parameters.Any(parameter => + parameter.Type.ToFullyQualifiedString() == ExpressionContext)); + } + + // A section as the compiler finds it: a void method of the document class itself, named for a section of its + // kind of document, taking one section context. It may implement the interface explicitly. + private static bool IsSection(IMethodSymbol method) + { + if (!method.ReturnsVoid || !TakesOneSectionContext(method)) + { + return false; + } + + var name = SectionName(method); + var interfaces = method.ContainingType.AllInterfaces.Select(implemented => implemented.ToFullyQualifiedString()).ToList(); + if (interfaces.Contains(Document) && name is "Inbound" or "Outbound" or "Backend" or "OnError") + { + return true; + } + + // the compiler compiles the first Fragment method taking a section context of the class declaration it + // compiles; an overload taking another is left. Members of a partial class come in file order, so the first + // of each part is taken. + var part = Part(method); + return interfaces.Contains(Fragment) && name == "Fragment" && + SymbolEqualityComparer.Default.Equals(method, method.ContainingType.GetMembers().OfType() + .First(candidate => SectionName(candidate) == "Fragment" && TakesOneSectionContext(candidate) && + Part(candidate) == part)); + + static string SectionName(IMethodSymbol method) => + method.ExplicitInterfaceImplementations.FirstOrDefault()?.Name ?? method.Name; + + static SyntaxNode? Part(IMethodSymbol method) => + method.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax().Parent; + + // IHaveExpressionContext is what the section contexts share, not a section's own + static bool TakesOneSectionContext(IMethodSymbol method) => + method.Parameters.Length == 1 && method.Parameters[0].Type.IsSectionContext() && + method.Parameters[0].Type.ToFullyQualifiedString() != SectionContext; + } + + // using S = Some.Namespace.Shared; gives the name S the meaning of Shared in the file + private static Dictionary AliasesOf(SyntaxTree tree, Dictionary> cache) + { + if (!cache.TryGetValue(tree, out var aliases)) + { + cache[tree] = aliases = tree.GetRoot().DescendantNodes(node => node is not TypeDeclarationSyntax) + .OfType() + .Where(directive => directive.Alias is not null && directive.Name is not null) + .GroupBy(directive => directive.Alias!.Name.Identifier.ValueText) + .ToDictionary(group => group.Key, group => LastName(group.First().Name!)); + } + + return aliases; + } + + private static string LastName(NameSyntax name) => name switch + { + QualifiedNameSyntax qualified => qualified.Right.Identifier.ValueText, + AliasQualifiedNameSyntax aliased => aliased.Name.Identifier.ValueText, + SimpleNameSyntax simple => simple.Identifier.ValueText, + _ => name.ToString() + }; + + // The names of a receiver made of names only, Ns.Shared.Nested or global::Shared, with an alias replaced by + // what it stands for; null for a receiver with anything else in it, such as a call or an index. A value's + // members, context.Request.Headers, are names too and match no family. + private static List? Segments(ExpressionSyntax receiver, Dictionary aliases) + { + var segments = new List(); + while (true) + { + switch (receiver) + { + case MemberAccessExpressionSyntax access: + segments.Add(access.Name.Identifier.ValueText); + receiver = access.Expression; + continue; + case AliasQualifiedNameSyntax aliased: + segments.Add(aliased.Name.Identifier.ValueText); + break; + case SimpleNameSyntax simple: + segments.Add(aliases.TryGetValue(simple.Identifier.ValueText, out var target) ? target : simple.Identifier.ValueText); + break; + default: + return null; + } + + segments.Reverse(); + return segments; + } + } + + // The last name is a type of the family, the others its namespace, the classes it is nested in or its types. + private static bool IsReceiver(List segments, Family family) => + family.Receivers.Contains(segments[segments.Count - 1]) && + segments.All(family.Qualifiers.Contains); + + private static ExpressionSyntax Unparenthesized(ExpressionSyntax expression) + { + // (this) and ((Document)this) + while (true) + { + switch (expression) + { + case ParenthesizedExpressionSyntax parenthesized: + expression = parenthesized.Expression; + continue; + case CastExpressionSyntax cast: + expression = cast.Expression; + continue; + default: + return expression; + } + } + } + + private const string ExpressionContext = + "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.IExpressionContext"; + + // A node inside a method that is expression code because it, or its class, is marked [Expression]. + public static bool IsPartOfMarkedPolicyExpressionMethod(this SyntaxNode syntax, SemanticModel model) + { + return syntax.Ancestors() + .OfType() + .Any(method => IsMarkedExpressionMethod(method, model)); + } + + private static bool IsMarkedExpressionMethod(MethodDeclarationSyntax method, SemanticModel model) + { + return method.AttributeLists.ContainsExpressionAttribute(model) || + model.GetDeclaredSymbol(method)?.IsExpressionLibraryMember() == true; + } + + private const string Authoring = "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring"; + private const string SectionContext = Authoring + ".IHaveExpressionContext"; + + // The methods of a document that hold policies rather than expression code, told apart by their return type, + // which approximates how the compiler treats them: a section returns nothing, a policy configuration factory + // returns a configuration. A helper returning anything else is expanded into an expression whatever it takes, + // a section context included. + private static bool IsSectionOrConfigurationFactory(IMethodSymbol method) => + method.ReturnsVoid || IsConfigurationFactory(method); + + // the configurations are the records of the authoring namespace; its interfaces and enums aren't ones + private static bool IsConfigurationFactory(IMethodSymbol method) => + method.ReturnType is INamedTypeSymbol { TypeKind: TypeKind.Class } && + method.ReturnType.ContainingNamespace?.ToDisplayString() == Authoring; + + private static bool TakesSectionContext(IMethodSymbol method) => + method.Parameters.Any(parameter => parameter.Type.IsSectionContext()); + + // The section context interfaces of the authoring library, IHaveExpressionContext itself included, as for the + // compiler; a type of the user's that implements one is not one. + public static bool IsSectionContext(this ITypeSymbol type) => + type is INamedTypeSymbol { TypeKind: TypeKind.Interface } && + type.ContainingNamespace?.ToDisplayString() == Authoring && + (type.ToFullyQualifiedString() == SectionContext || + type.AllInterfaces.Any(implemented => implemented.ToFullyQualifiedString() == SectionContext)); + + private const string Document = Authoring + ".IDocument"; + private const string Fragment = Authoring + ".IFragment"; + + public static bool IsDocument(this INamedTypeSymbol type) => + type.AllInterfaces.Any(implemented => implemented.ToFullyQualifiedString() is Document or Fragment); + + // A member declared in the source of a policy document or fragment class, or of a class nested in one. + public static bool IsDocumentMember(this ISymbol symbol) + { + if (symbol.DeclaringSyntaxReferences.IsDefaultOrEmpty) + { + return false; + } + + for (var type = symbol.ContainingType; type is not null; type = type.ContainingType) + { + if (type.AllInterfaces.Any(implemented => implemented.ToFullyQualifiedString() is Document or Fragment)) + { + return true; + } + } + + return false; } // A member of a class marked [Expression] (an expression helper library), from source or metadata. diff --git a/src/Authoring/Configs/CacheLookupConfig.cs b/src/Authoring/Configs/CacheLookupConfig.cs index 7a8d26bc..f209e8c4 100644 --- a/src/Authoring/Configs/CacheLookupConfig.cs +++ b/src/Authoring/Configs/CacheLookupConfig.cs @@ -29,22 +29,22 @@ public record CacheLookupConfig public string? CachingType { get; init; } /// - /// Controls how the gateway interacts with response caching at downstream servers.
- /// Valid values: "none" disables downstream caching, "public" honors cache directives from downstream servers, "private" means the response is not cached in shared caches, and "internal" means API Management will handle caching ignoring downstream directives. + /// Controls whether downstream caches (clients and proxies) may cache the response.
+ /// Valid values: "none" (default) - downstream caching is not allowed, "private" - downstream private caching is allowed, "public" - private and shared downstream caching is allowed. ///
[ExpressionAllowed] public string? DownstreamCachingType { get; init; } /// - /// When true, the gateway will revalidate cached entries that have become stale, as per the Cache-Control directive.
- /// This may improve cache hit ratio at the cost of additional backend load. + /// When downstream caching is enabled, turns the must-revalidate cache control directive in gateway responses on or off.
+ /// Default is true. ///
[ExpressionAllowed] public bool? MustRevalidate { get; init; } /// - /// When true, responses with private cache directives will be stored in the API Management cache.
- /// Use with caution, as this may lead to private information shared between clients. + /// When true, allows caching of requests that contain an Authorization header.
+ /// Default is false. Use with caution, as this may lead to private information shared between clients. ///
[ExpressionAllowed] public bool? AllowPrivateResponseCaching { get; init; } diff --git a/src/Authoring/Configs/CacheLookupValueConfig.cs b/src/Authoring/Configs/CacheLookupValueConfig.cs index 01b182fe..3c47867d 100644 --- a/src/Authoring/Configs/CacheLookupValueConfig.cs +++ b/src/Authoring/Configs/CacheLookupValueConfig.cs @@ -29,9 +29,9 @@ public record CacheLookupValueConfig public object? DefaultValue { get; init; } /// - /// Type of cache to use. Valid values are "internal" (per-gateway private cache) - /// or "external" (shared cache as configured in the policy). - /// If not specified, "external" is used. + /// Type of cache to use. Valid values are "internal" (built-in cache), "external" (external cache) + /// or "prefer-external" (external cache if configured, otherwise the built-in cache). + /// If not specified, "prefer-external" is used. /// public string? CachingType { get; init; } } \ No newline at end of file diff --git a/src/Authoring/Configs/CacheRemoveValueConfig.cs b/src/Authoring/Configs/CacheRemoveValueConfig.cs index 3c8faaac..349ff486 100644 --- a/src/Authoring/Configs/CacheRemoveValueConfig.cs +++ b/src/Authoring/Configs/CacheRemoveValueConfig.cs @@ -23,4 +23,12 @@ public record CacheRemoveValueConfig /// Policy expressions are not allowed. /// public string? CachingType { get; init; } + + /// + /// Optional. Set to true to fail the request if the cache removal operation fails, + /// or false to ignore cache removal errors. Default is false.
+ /// Policy expressions are allowed. + ///
+ [ExpressionAllowed] + public bool? FailOnCacheRemovalError { get; init; } } \ No newline at end of file diff --git a/src/Authoring/Configs/CertificateAuthenticationConfig.cs b/src/Authoring/Configs/CertificateAuthenticationConfig.cs index 7f01970a..0a9e2c21 100644 --- a/src/Authoring/Configs/CertificateAuthenticationConfig.cs +++ b/src/Authoring/Configs/CertificateAuthenticationConfig.cs @@ -8,7 +8,7 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; /// This policy establishes a secure TLS channel with the backend and sends the client certificate as part of the TLS handshake. /// /// -/// You must provide exactly one of the following: Thumbprint, CertificateId, or a combination of Body and Password. +/// You must provide exactly one of the following: Thumbprint, CertificateId, or Body (with Password when the certificate is password protected). /// public record CertificateAuthenticationConfig : IAuthenticationConfig { @@ -17,7 +17,7 @@ public record CertificateAuthenticationConfig : IAuthenticationConfig /// The certificate must be added to the API Management service. /// /// - /// This property is mutually exclusive with CertificateId and Body+Password. + /// This property is mutually exclusive with CertificateId and Body. /// [ExpressionAllowed] public string? Thumbprint { get; init; } @@ -27,7 +27,7 @@ public record CertificateAuthenticationConfig : IAuthenticationConfig /// The certificate must be added to the API Management service. /// /// - /// This property is mutually exclusive with Thumbprint and Body+Password. + /// This property is mutually exclusive with Thumbprint and Body. /// [ExpressionAllowed] public string? CertificateId { get; init; } @@ -36,7 +36,7 @@ public record CertificateAuthenticationConfig : IAuthenticationConfig /// Specifies the raw client certificate as a base64-encoded string. /// /// - /// This property must be used with Password and is mutually exclusive with Thumbprint and CertificateId. + /// Use Password as well when the certificate is password protected. This property is mutually exclusive with Thumbprint and CertificateId. /// [ExpressionAllowed] public byte[]? Body { get; init; } @@ -45,7 +45,7 @@ public record CertificateAuthenticationConfig : IAuthenticationConfig /// Specifies the password for the client certificate, if the certificate is password-protected. /// /// - /// This property must be used with Body and is mutually exclusive with Thumbprint and CertificateId. + /// This property can only be used with Body and is mutually exclusive with Thumbprint and CertificateId. /// [ExpressionAllowed] public string? Password { get; init; } diff --git a/src/Authoring/Configs/CheckHeaderConfig.cs b/src/Authoring/Configs/CheckHeaderConfig.cs index 7886609c..79e0e664 100644 --- a/src/Authoring/Configs/CheckHeaderConfig.cs +++ b/src/Authoring/Configs/CheckHeaderConfig.cs @@ -33,8 +33,9 @@ public record CheckHeaderConfig public required bool IgnoreCase { get; init; } /// - /// Array of expected header values. Policy expressions are allowed. + /// Array of allowed header values. When omitted, only the presence of the header is checked. + /// Policy expressions are allowed. /// [ExpressionAllowed] - public required string[] Values { get; init; } + public string[]? Values { get; init; } } \ No newline at end of file diff --git a/src/Authoring/Configs/ClaimConfig.cs b/src/Authoring/Configs/ClaimConfig.cs index dd8990ff..375964e2 100644 --- a/src/Authoring/Configs/ClaimConfig.cs +++ b/src/Authoring/Configs/ClaimConfig.cs @@ -9,8 +9,9 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; public record ClaimConfig { /// - /// Specifies the name of the claim. + /// Specifies the name of the claim. Policy expressions are allowed. /// + [ExpressionAllowed] public required string Name { get; init; } /// @@ -20,8 +21,9 @@ public record ClaimConfig public string? Match { get; init; } /// - /// Specifies the separator for multiple claim values. + /// Specifies the separator for multiple claim values. Policy expressions are allowed. /// + [ExpressionAllowed] public string? Separator { get; init; } /// diff --git a/src/Authoring/Configs/CorsConfig.cs b/src/Authoring/Configs/CorsConfig.cs index b98c9b0c..34fffb36 100644 --- a/src/Authoring/Configs/CorsConfig.cs +++ b/src/Authoring/Configs/CorsConfig.cs @@ -19,12 +19,12 @@ public record CorsConfig public bool? AllowCredentials { get; init; } /// - /// Specifies whether preflight OPTIONS requests will be terminated automatically without forwarding to the backend.
- /// Default value is "true". Set to "false" to pass OPTIONS requests to the backend.
+ /// Controls the processing of cross-origin requests that don't match the policy settings.
+ /// When true, such a request is terminated with an empty 200 OK response; when false, it continues without the CORS headers.
/// Policy expressions are allowed. ///
[ExpressionAllowed] - public string? TerminateUnmatchedRequest { get; init; } + public bool? TerminateUnmatchedRequest { get; init; } /// /// List of origins allowed to make cross-origin calls to your API.
diff --git a/src/Authoring/Configs/EmitMetricConfig.cs b/src/Authoring/Configs/EmitMetricConfig.cs index 5b291e24..a24f1a06 100644 --- a/src/Authoring/Configs/EmitMetricConfig.cs +++ b/src/Authoring/Configs/EmitMetricConfig.cs @@ -23,7 +23,7 @@ public record EmitMetricConfig /// /// Optional. Specifies the metric namespace. - /// Defaults to "apim" if not specified. + /// Defaults to "API Management" if not specified. /// public string? Namespace { get; init; } diff --git a/src/Authoring/Configs/EmitTokenMetricConfig.cs b/src/Authoring/Configs/EmitTokenMetricConfig.cs index a5c87f1f..ddaa8ca4 100644 --- a/src/Authoring/Configs/EmitTokenMetricConfig.cs +++ b/src/Authoring/Configs/EmitTokenMetricConfig.cs @@ -12,12 +12,11 @@ public record EmitTokenMetricConfig /// Required. The dimensions to include with the metric.
/// These dimensions can be used to filter and group the metrics in monitoring systems. ///
- [ExpressionAllowed] public required MetricDimensionConfig[] Dimensions { get; init; } /// /// Optional. The namespace to use for the metrics.
- /// If not specified, the default namespace is used. + /// If not specified, the default namespace is used. Policy expressions are allowed. ///
[ExpressionAllowed] public string? Namespace { get; init; } diff --git a/src/Authoring/Configs/HeaderConfig.cs b/src/Authoring/Configs/HeaderConfig.cs index f2d37567..0454acdd 100644 --- a/src/Authoring/Configs/HeaderConfig.cs +++ b/src/Authoring/Configs/HeaderConfig.cs @@ -12,7 +12,7 @@ public record HeaderConfig public required string Name { get; init; } /// - /// Specifies the action to take if the header already exists. Possible values are "override" and "skip". Policy expressions are allowed. + /// Specifies the action to take if the header already exists. Possible values are "override" (default), "skip", "append" and "delete". Policy expressions are allowed. /// [ApimDefaultValue("override", "exists-action")] [ExpressionAllowed] diff --git a/src/Authoring/Configs/IpFilterConfig.cs b/src/Authoring/Configs/IpFilterConfig.cs index df997f28..d65d47f1 100644 --- a/src/Authoring/Configs/IpFilterConfig.cs +++ b/src/Authoring/Configs/IpFilterConfig.cs @@ -5,12 +5,12 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; /// /// Configuration for the IpFilter policy.
-/// Specifies the action to take (allow or deny), IP addresses, and/or IP address ranges. +/// Specifies the action to take ("allow" or "forbid"), IP addresses, and/or IP address ranges. ///
public record IpFilterConfig { /// - /// Specifies the action to take (allow or deny). Policy expressions are allowed. + /// Specifies the action to take ("allow" or "forbid"). Policy expressions are allowed. /// [ExpressionAllowed] public required string Action { get; init; } diff --git a/src/Authoring/Configs/JsonToXmlConfig.cs b/src/Authoring/Configs/JsonToXmlConfig.cs index 873c1f41..e42201ac 100644 --- a/src/Authoring/Configs/JsonToXmlConfig.cs +++ b/src/Authoring/Configs/JsonToXmlConfig.cs @@ -21,9 +21,9 @@ public record JsonToXmlConfig public bool? ConsiderAcceptHeader { get; init; } /// - /// Specifies whether to parse dates in the JSON content. Policy expressions are allowed. + /// When set to false, date values are simply copied during transformation. Default is true. + /// Policy expressions aren't allowed. /// - [ExpressionAllowed] public bool? ParseDate { get; init; } /// diff --git a/src/Authoring/Configs/LlmContentSafetyConfig.cs b/src/Authoring/Configs/LlmContentSafetyConfig.cs index 35129789..e6547f1b 100644 --- a/src/Authoring/Configs/LlmContentSafetyConfig.cs +++ b/src/Authoring/Configs/LlmContentSafetyConfig.cs @@ -20,6 +20,25 @@ public record LlmContentSafetyConfig [ExpressionAllowed] public bool? ShieldPrompt { get; init; } + /// + /// Specifies whether to enforce content safety checks on chat completions (responses). Policy expressions are allowed. + /// + [ExpressionAllowed] + public bool? EnforceOnCompletions { get; init; } + + /// + /// Specifies the size, in characters, of the sliding window used to buffer streamed completions for content safety checks. + /// Must be between 200 and 1000. Policy expressions are allowed. + /// + [ExpressionAllowed] + public int? WindowSize { get; init; } + + /// + /// Specifies the number of characters by which consecutive sliding windows overlap. Policy expressions are allowed. + /// + [ExpressionAllowed] + public int? WindowOverlapSize { get; init; } + /// /// Specifies the content safety categories to be evaluated. /// diff --git a/src/Authoring/Configs/ManagedIdentityAuthenticationConfig.cs b/src/Authoring/Configs/ManagedIdentityAuthenticationConfig.cs index ceea8253..61f39c97 100644 --- a/src/Authoring/Configs/ManagedIdentityAuthenticationConfig.cs +++ b/src/Authoring/Configs/ManagedIdentityAuthenticationConfig.cs @@ -15,8 +15,8 @@ public record ManagedIdentityAuthenticationConfig : IAuthenticationConfig /// /// Optional. The client ID of the user-assigned managed identity.
/// If this property is not specified, the system-assigned managed identity is used. + /// Policy expressions aren't allowed. ///
- [ExpressionAllowed] public string? ClientId { get; init; } /// diff --git a/src/Authoring/Configs/MetricDimensionConfig.cs b/src/Authoring/Configs/MetricDimensionConfig.cs index b4589faa..36178699 100644 --- a/src/Authoring/Configs/MetricDimensionConfig.cs +++ b/src/Authoring/Configs/MetricDimensionConfig.cs @@ -10,12 +10,14 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; public class MetricDimensionConfig { /// - /// Required. The name of the dimension. + /// Required. The name of the dimension. Can be a static value or a policy expression. /// + [ExpressionAllowed] public required string Name { get; init; } /// - /// Required. The value for the dimension. Can be a static value or a policy expression. + /// The value for the dimension. Can be a static value or a policy expression. + /// Can only be omitted if the name matches one of the default dimensions. /// Examples: "gpt-4", "completions", or expressions like "@(context.Request.Headers.GetValueOrDefault("x-operation-id"))". /// [ExpressionAllowed] diff --git a/src/Authoring/Configs/MockResponseConfig.cs b/src/Authoring/Configs/MockResponseConfig.cs index dd30c3dc..0279756b 100644 --- a/src/Authoring/Configs/MockResponseConfig.cs +++ b/src/Authoring/Configs/MockResponseConfig.cs @@ -15,7 +15,7 @@ public record MockResponseConfig public int? StatusCode { get; init; } /// - /// Value of Content-Type HTTP header to be returned. Default is application/json.
+ /// Value of Content-Type HTTP header to be returned. Used to select the corresponding example or schema.
/// Policy expressions aren't allowed. ///
public string? ContentType { get; init; } diff --git a/src/Authoring/Configs/PublishToDaprConfig.cs b/src/Authoring/Configs/PublishToDaprConfig.cs index 63fdbcd6..b17eea54 100644 --- a/src/Authoring/Configs/PublishToDaprConfig.cs +++ b/src/Authoring/Configs/PublishToDaprConfig.cs @@ -39,7 +39,9 @@ public record PublishToDaprConfig /// /// Specifies the time in seconds to wait for the Dapr sidecar to respond. Valid values are 1 to 240. + /// Policy expressions are allowed. /// + [ExpressionAllowed] public int? Timeout { get; init; } /// diff --git a/src/Authoring/Configs/RetryConfig.cs b/src/Authoring/Configs/RetryConfig.cs index cc0622a0..e4a9dbc2 100644 --- a/src/Authoring/Configs/RetryConfig.cs +++ b/src/Authoring/Configs/RetryConfig.cs @@ -30,7 +30,7 @@ public record RetryConfig /// A positive number in seconds specifying the wait interval between retry attempts. Policy expressions are allowed. /// [ExpressionAllowed] - public int? Interval { get; init; } + public required int Interval { get; init; } /// /// A positive number in seconds specifying the maximum wait interval between retry attempts. Used to implement an exponential retry algorithm. Policy expressions are allowed. diff --git a/src/Authoring/Configs/SemanticCacheLookupConfig.cs b/src/Authoring/Configs/SemanticCacheLookupConfig.cs index 4a91764b..0c3dfc52 100644 --- a/src/Authoring/Configs/SemanticCacheLookupConfig.cs +++ b/src/Authoring/Configs/SemanticCacheLookupConfig.cs @@ -10,11 +10,11 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; public record SemanticCacheLookupConfig { /// - /// Required. The similarity threshold (between 0 and 1) that determines whether a cached response is returned.
- /// Higher values require greater similarity between the current request and cached requests.
- /// Recommended values are between 0.7 and 0.9. + /// Required. The score threshold (between 0.0 and 1.0) that determines how closely an incoming prompt must match a cached prompt to return its stored response.
+ /// Lower values require higher semantic similarity for a match.
+ /// Start with a low value such as 0.05; values above 0.2 may lead to cache mismatches.
+ /// Policy expressions aren't allowed. ///
- [ExpressionAllowed] public required decimal ScoreThreshold { get; init; } /// @@ -26,7 +26,7 @@ public record SemanticCacheLookupConfig /// /// Required. Authentication setting for the embeddings backend service.
- /// Must be configured as a named value in API Management. + /// Must be set to "system-assigned". ///
[ExpressionAllowed] public required string EmbeddingsBackendAuth { get; init; } @@ -40,11 +40,10 @@ public record SemanticCacheLookupConfig public bool? IgnoreSystemMessages { get; init; } /// - /// Optional. Maximum number of messages to consider when comparing chat completions.
- /// Default is 4 messages. + /// Optional. If specified, the number of remaining dialog messages after which caching is skipped. ///
[ExpressionAllowed] - public uint MaxMessageCount { get; init; } + public int? MaxMessageCount { get; init; } /// /// Optional. Identifier of a named cache instance to use for semantic cache lookup.
diff --git a/src/Authoring/Configs/SendRequestConfig.cs b/src/Authoring/Configs/SendRequestConfig.cs index 2fd82227..2aa16e02 100644 --- a/src/Authoring/Configs/SendRequestConfig.cs +++ b/src/Authoring/Configs/SendRequestConfig.cs @@ -9,8 +9,9 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; public record SendRequestConfig { /// - /// Specifies the name of the variable to store the response. + /// Specifies the name of the variable to store the response. Policy expressions are allowed. /// + [ExpressionAllowed] public required string ResponseVariableName { get; init; } /// diff --git a/src/Authoring/Configs/SendServiceBusMessageConfig.cs b/src/Authoring/Configs/SendServiceBusMessageConfig.cs index 805a811f..018b3ba4 100644 --- a/src/Authoring/Configs/SendServiceBusMessageConfig.cs +++ b/src/Authoring/Configs/SendServiceBusMessageConfig.cs @@ -36,6 +36,37 @@ public record SendServiceBusMessageConfig [ExpressionAllowed] public string? ClientId { get; init; } + /// + /// Optional. Message identifier. Must be a valid GUID. If omitted, API Management generates a GUID. + /// Policy expressions are allowed. + /// + [ExpressionAllowed] + public string? MessageId { get; init; } + + /// + /// Optional. Service Bus session identifier used to group related messages. Must be a valid GUID. + /// Policy expressions are allowed. + /// + [ExpressionAllowed] + public string? SessionId { get; init; } + + /// + /// Optional. How long the message remains available for processing before it expires, + /// as a TimeSpan value, for example "00:10:00". + /// + public string? TimeToLive { get; init; } + + /// + /// Optional. Name of a context variable that receives information about the Service Bus send operation. + /// + public string? ResponseVariableName { get; init; } + + /// + /// Optional. Whether a Service Bus send failure should allow policy execution to continue. + /// Default is false. + /// + public bool? IgnoreError { get; init; } + /// /// Optional. A collection of message properties to pass with the message payload. /// diff --git a/src/Authoring/Configs/SetBodyConfig.cs b/src/Authoring/Configs/SetBodyConfig.cs index 6afcd4eb..9339e2c2 100644 --- a/src/Authoring/Configs/SetBodyConfig.cs +++ b/src/Authoring/Configs/SetBodyConfig.cs @@ -10,7 +10,10 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; public record SetBodyConfig { /// - /// Optional. Specifies a template to use for the body content. + /// Optional. Specifies a template to use for the body content. The only supported value is "liquid".
+ /// With a liquid template the gateway returns the characters <, > and & that are written as text + /// XML-escaped (&lt;, &gt;, &amp;), also inside a CDATA section. They are unaffected inside liquid tags + /// and when the content is well-formed XML, which the compiler writes as markup. ///
public string? Template { get; init; } diff --git a/src/Authoring/Configs/StatusConfig.cs b/src/Authoring/Configs/StatusConfig.cs index 49d33a71..0f5bb3d2 100644 --- a/src/Authoring/Configs/StatusConfig.cs +++ b/src/Authoring/Configs/StatusConfig.cs @@ -19,5 +19,5 @@ public record StatusConfig /// A description of the reason for returning the status code. Policy expressions are allowed. ///
[ExpressionAllowed] - public required string Reason { get; init; } + public string? Reason { get; init; } }; \ No newline at end of file diff --git a/src/Authoring/Configs/TokenLimitConfig.cs b/src/Authoring/Configs/TokenLimitConfig.cs index 44b47f2d..0105bf1e 100644 --- a/src/Authoring/Configs/TokenLimitConfig.cs +++ b/src/Authoring/Configs/TokenLimitConfig.cs @@ -16,9 +16,8 @@ public record TokenLimitConfig public required string CounterKey { get; init; } /// - /// Indicates whether to estimate the token count of the prompt. Policy expressions are allowed. + /// Indicates whether to estimate the token count of the prompt. /// - [ExpressionAllowed] public required bool EstimatePromptTokens { get; init; } /// @@ -28,14 +27,16 @@ public record TokenLimitConfig public int? TokensPerMinute { get; init; } /// - /// Specifies the maximum number of tokens that can be consumed per time period. Policy expressions are allowed. + /// Specifies the maximum number of tokens that can be consumed per time period. Policy expressions are allowed + /// and have to return a long. /// [ExpressionAllowed] - public int? TokenQuota { get; init; } + public long? TokenQuota { get; init; } /// - /// Specifies the time period for the token quota. Valid values are "day", "week", and "month". + /// Specifies the time period for the token quota. Valid values are "Hourly", "Daily", "Weekly", "Monthly" and "Yearly". /// + [ExpressionAllowed] public string? TokenQuotaPeriod { get; init; } /// diff --git a/src/Authoring/Configs/ValidateAzureAdTokenConfig.cs b/src/Authoring/Configs/ValidateAzureAdTokenConfig.cs index ac996ec5..b668b940 100644 --- a/src/Authoring/Configs/ValidateAzureAdTokenConfig.cs +++ b/src/Authoring/Configs/ValidateAzureAdTokenConfig.cs @@ -15,6 +15,12 @@ public record ValidateAzureAdTokenConfig [ExpressionAllowed] public required string TenantId { get; init; } + /// + /// Microsoft Entra endpoint used to acquire tokens in environments such as national clouds, + /// for example "https://login.microsoftonline.us". The "https://" prefix is optional. + /// + public string? AuthenticationEndpoint { get; init; } + /// /// The name of the header containing the token. /// @@ -61,8 +67,9 @@ public record ValidateAzureAdTokenConfig public string[]? ClientApplicationIds { get; init; } /// - /// The expected audiences for the token. + /// The expected audiences for the token. Policy expressions are allowed. /// + [ExpressionAllowed] public string[]? Audiences { get; init; } /// diff --git a/src/Authoring/Configs/ValidateContentConfig.cs b/src/Authoring/Configs/ValidateContentConfig.cs index 172155b2..17336919 100644 --- a/src/Authoring/Configs/ValidateContentConfig.cs +++ b/src/Authoring/Configs/ValidateContentConfig.cs @@ -80,8 +80,9 @@ public record ContentTypeMap public required string To { get; init; } /// - /// Optional condition for when the mapping should be applied. + /// Optional condition for when the mapping should be applied, given as a policy expression. /// + [ExpressionAllowed] public bool? When { get; init; } } diff --git a/src/Authoring/Configs/XslTransformConfig.cs b/src/Authoring/Configs/XslTransformConfig.cs index 55ecc94e..e4671e1f 100644 --- a/src/Authoring/Configs/XslTransformConfig.cs +++ b/src/Authoring/Configs/XslTransformConfig.cs @@ -10,9 +10,8 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; public record XslTransformConfig { /// - /// The XSLT stylesheet to use for the transformation. Policy expressions are allowed. + /// The XSLT stylesheet to use for the transformation, as XML. /// - [ExpressionAllowed] public required string StyleSheet { get; init; } /// diff --git a/src/Authoring/Expressions/ByteArrayExtensions.cs b/src/Authoring/Expressions/ByteArrayExtensions.cs new file mode 100644 index 00000000..0076b66b --- /dev/null +++ b/src/Authoring/Expressions/ByteArrayExtensions.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Security.Cryptography; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; + +public static class ByteArrayExtensions +{ + /// + /// Encrypts the input with the named symmetric algorithm, for example "Aes". + /// + public static byte[] Encrypt(this byte[] input, string alg, byte[] key, byte[] iv) + { + using var algorithm = Create(alg); + return input.Encrypt(algorithm, key, iv); + } + + public static byte[] Encrypt(this byte[] input, SymmetricAlgorithm alg) + { + using var encryptor = alg.CreateEncryptor(); + return encryptor.TransformFinalBlock(input, 0, input.Length); + } + + public static byte[] Encrypt(this byte[] input, SymmetricAlgorithm alg, byte[] key, byte[] iv) + { + using var encryptor = alg.CreateEncryptor(key, iv); + return encryptor.TransformFinalBlock(input, 0, input.Length); + } + + /// + /// Decrypts the input with the named symmetric algorithm, for example "Aes". + /// + public static byte[] Decrypt(this byte[] input, string alg, byte[] key, byte[] iv) + { + using var algorithm = Create(alg); + return input.Decrypt(algorithm, key, iv); + } + + public static byte[] Decrypt(this byte[] input, SymmetricAlgorithm alg) + { + using var decryptor = alg.CreateDecryptor(); + return decryptor.TransformFinalBlock(input, 0, input.Length); + } + + public static byte[] Decrypt(this byte[] input, SymmetricAlgorithm alg, byte[] key, byte[] iv) + { + using var decryptor = alg.CreateDecryptor(key, iv); + return decryptor.TransformFinalBlock(input, 0, input.Length); + } + + private static SymmetricAlgorithm Create(string alg) => alg.ToUpperInvariant() switch + { + "AES" => Aes.Create(), + "TRIPLEDES" or "3DES" => TripleDES.Create(), + _ => throw new NotSupportedException($"Symmetric algorithm '{alg}' is not supported by the toolkit's emulation") + }; +} diff --git a/src/Authoring/Expressions/IBackend.cs b/src/Authoring/Expressions/IBackend.cs new file mode 100644 index 00000000..3716f147 --- /dev/null +++ b/src/Authoring/Expressions/IBackend.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; + +public interface IBackend +{ + string AzureRegion { get; } + + string Id { get; } + + /// + /// The type of the backend, "Single" or "Pool". The gateway exposes it as a string. + /// + string Type { get; } +} diff --git a/src/Authoring/Expressions/IDeployment.cs b/src/Authoring/Expressions/IDeployment.cs index 6642fa7c..450801b8 100644 --- a/src/Authoring/Expressions/IDeployment.cs +++ b/src/Authoring/Expressions/IDeployment.cs @@ -7,6 +7,8 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; public interface IDeployment { + IGateway Gateway { get; } + string GatewayId { get; } string Region { get; } @@ -15,5 +17,7 @@ public interface IDeployment string ServiceName { get; } + ISustainabilityInfo SustainabilityInfo { get; } + IReadOnlyDictionary Certificates { get; } } \ No newline at end of file diff --git a/src/Authoring/Expressions/IExpressionContext.cs b/src/Authoring/Expressions/IExpressionContext.cs index 8baeb387..29a7f1cd 100644 --- a/src/Authoring/Expressions/IExpressionContext.cs +++ b/src/Authoring/Expressions/IExpressionContext.cs @@ -6,11 +6,18 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; public interface IExpressionContext { IContextApi Api { get; } + /// + /// The backend entity the request is sent to. Null until one is selected, for example with set-backend-service. + /// + IBackend? Backend { get; } IDeployment Deployment { get; } TimeSpan Elapsed { get; } ILastError LastError { get; } IOperation Operation { get; } - IProduct Product { get; } + /// + /// The product of the subscription the request was made with. Null for a request without a subscription. + /// + IProduct? Product { get; } IRequest Request { get; } Guid RequestId { get; } IResponse Response { get; } @@ -19,6 +26,10 @@ public interface IExpressionContext bool Tracing { get; } IUser User { get; } IReadOnlyDictionary Variables { get; } + /// + /// The workspace of the API. Null for an API that is not in a workspace. + /// + IWorkspace? Workspace { get; } Action Trace { get; } /// diff --git a/src/Authoring/Expressions/IFoundry.cs b/src/Authoring/Expressions/IFoundry.cs new file mode 100644 index 00000000..dd3d49f7 --- /dev/null +++ b/src/Authoring/Expressions/IFoundry.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; + +public interface IFoundry +{ + /// + /// The model deployment ID in Microsoft Foundry associated with the request. + /// + string Deployment { get; } +} diff --git a/src/Authoring/Expressions/IGateway.cs b/src/Authoring/Expressions/IGateway.cs new file mode 100644 index 00000000..0ec042f0 --- /dev/null +++ b/src/Authoring/Expressions/IGateway.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; + +public interface IGateway +{ + string Id { get; } + + string InstanceId { get; } + + bool IsManaged { get; } +} diff --git a/src/Authoring/Expressions/IProduct.cs b/src/Authoring/Expressions/IProduct.cs index 61c2b8e0..f6f5973b 100644 --- a/src/Authoring/Expressions/IProduct.cs +++ b/src/Authoring/Expressions/IProduct.cs @@ -11,7 +11,7 @@ public interface IProduct string Id { get; } string Name { get; } ProductState State { get; } - int? SubscriptionLimit { get; } + int? SubscriptionsLimit { get; } bool SubscriptionRequired { get; } } diff --git a/src/Authoring/Expressions/IRequest.cs b/src/Authoring/Expressions/IRequest.cs index 1e091224..658e5c21 100644 --- a/src/Authoring/Expressions/IRequest.cs +++ b/src/Authoring/Expressions/IRequest.cs @@ -25,5 +25,7 @@ public interface IRequest IPrivateEndpointConnection? PrivateEndpointConnection { get; } + IFoundry? Foundry { get; } + IAzureVnetInfo? AzureVnetInfo { get; } } \ No newline at end of file diff --git a/src/Authoring/Expressions/ISustainabilityInfo.cs b/src/Authoring/Expressions/ISustainabilityInfo.cs new file mode 100644 index 00000000..ab96ec97 --- /dev/null +++ b/src/Authoring/Expressions/ISustainabilityInfo.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; + +public interface ISustainabilityInfo +{ + CarbonIntensityCategory CurrentCarbonIntensity { get; } +} + +/// +/// How carbon intensive the grid of the region is, as a category. +/// +public enum CarbonIntensityCategory +{ + NotAvailable, + VeryLow, + Low, + Medium, + High, + VeryHigh +} diff --git a/src/Authoring/Expressions/IUrl.cs b/src/Authoring/Expressions/IUrl.cs index 969fa0a3..c8fe1533 100644 --- a/src/Authoring/Expressions/IUrl.cs +++ b/src/Authoring/Expressions/IUrl.cs @@ -7,8 +7,13 @@ public interface IUrl { string Host { get; } string Path { get; } - string Port { get; } + int Port { get; } IReadOnlyDictionary Query { get; } string QueryString { get; } string Scheme { get; } + + /// + /// The whole URL as text. + /// + string ToString(); } \ No newline at end of file diff --git a/src/Authoring/Expressions/IWorkspace.cs b/src/Authoring/Expressions/IWorkspace.cs new file mode 100644 index 00000000..d82e2c53 --- /dev/null +++ b/src/Authoring/Expressions/IWorkspace.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; + +public interface IWorkspace +{ + string Id { get; } + + string Name { get; } +} diff --git a/src/Authoring/Expressions/X509Certificate2Extensions.cs b/src/Authoring/Expressions/X509Certificate2Extensions.cs new file mode 100644 index 00000000..24c2b90b --- /dev/null +++ b/src/Authoring/Expressions/X509Certificate2Extensions.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; + +public static class X509Certificate2Extensions +{ + /// + /// Performs an X.509 chain validation without checking certificate revocation status. + /// + public static bool VerifyNoRevocation(this X509Certificate2 input) + { + using var chain = new X509Chain(); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + return chain.Build(input); + } +} diff --git a/src/Authoring/IBackendContext.cs b/src/Authoring/IBackendContext.cs index 5722db17..dcf6305b 100644 --- a/src/Authoring/IBackendContext.cs +++ b/src/Authoring/IBackendContext.cs @@ -79,7 +79,7 @@ public interface IBackendContext : IHaveExpressionContext /// Provides a unified caching solution with stampede protection by combining cache lookup and store operations.
/// On cache hit, the cached value is assigned to the specified variable. On cache miss, the nested value block is executed /// and the resulting variable value is stored in the cache.
- /// Compiled to cache-value policy. + /// Compiled to the cache-value policy, which is not part of the public policy reference. ///
/// /// Configuration specifying the cache key, variable name, expiration, refresh interval, default value, and optional caching type. @@ -320,7 +320,7 @@ public interface IBackendContext : IHaveExpressionContext /// Compiled to set-variable policy. ///
/// - /// Specifies the name of the variable to be set. Policy expressions are allowed. + /// Specifies the name of the variable to be set. Policy expressions are not allowed. /// /// /// Specifies the value of the variable to be set. Policy expressions are allowed. diff --git a/src/Authoring/IFragmentContext.cs b/src/Authoring/IFragmentContext.cs index 675a454d..a78e17c3 100644 --- a/src/Authoring/IFragmentContext.cs +++ b/src/Authoring/IFragmentContext.cs @@ -78,7 +78,7 @@ public interface IFragmentContext : IHaveExpressionContext /// /// Emits metrics about token usage from Azure OpenAI service calls.
/// This policy can be used to monitor and analyze token usage patterns.
- /// Compiled to azure-openai-emit-token-metric policy. + /// Compiled to the azure-openai-emit-token-metric policy, an earlier name of llm-emit-token-metric that the gateway still accepts. ///
/// /// Configuration specifying the dimensions to include with the metric and optionally the namespace to use. @@ -89,7 +89,7 @@ public interface IFragmentContext : IHaveExpressionContext /// Searches a cache for semantically similar Azure OpenAI prompts and returns cached responses if found.
/// Uses vector embeddings to match prompt similarity against cached items based on a threshold.
/// When a match is found, the policy short-circuits the request and returns the cached response.
- /// Compiled to azure-openai-semantic-cache-lookup policy. + /// Compiled to the azure-openai-semantic-cache-lookup policy, an earlier name of llm-semantic-cache-lookup that the gateway still accepts. ///
/// /// Configuration specifying the similarity threshold, embedding backend, and other cache parameters. @@ -98,21 +98,25 @@ public interface IFragmentContext : IHaveExpressionContext /// /// Stores the current Azure OpenAI request and response in the semantic cache for future lookup.
- /// This policy must be placed in the outbound section to capture both the request and response.
+ /// The fragment has to be included in the outbound section for this policy to capture both the request and response.
/// When stored, the entries can later be found by the azure-openai-semantic-cache-lookup policy.
- /// Compiled to azure-openai-semantic-cache-store policy. + /// Compiled to the azure-openai-semantic-cache-store policy, an earlier name of llm-semantic-cache-store that the gateway still accepts. ///
/// /// Duration in seconds for which the cached entry is valid. Policy expressions are allowed. /// - void AzureOpenAiSemanticCacheStore([ExpressionAllowed] uint duration); + /// + /// Set to true to cache the current HTTP response. If omitted, only responses with status code 200 OK are cached. + /// Policy expressions are allowed. + /// + void AzureOpenAiSemanticCacheStore([ExpressionAllowed] uint duration, [ExpressionAllowed] bool? cacheResponse = null); /// /// Limits tokens used by Azure OpenAI services to prevent overconsumption.
/// This policy can enforce rate limits (tokens per minute) and/or quotas (tokens per period).
/// Helps protect backend services and manage costs by controlling token usage.
/// Can estimate prompt token count and track consumption via custom headers or variables.
- /// Compiled to azure-openai-token-limit policy. + /// Compiled to the azure-openai-token-limit policy, an earlier name of llm-token-limit that the gateway still accepts. ///
/// /// Configuration specifying the counter key, rate limits, quotas, and optional header/variable names for tracking token usage. @@ -159,7 +163,7 @@ public interface IFragmentContext : IHaveExpressionContext /// Specifies the duration in seconds that the response should be cached. Policy expressions are allowed. /// /// - /// Indicates whether the response should be cached. If set to false, the response will not be cached. Policy expressions are allowed. + /// Set to true to cache the current HTTP response. If omitted (null), only responses with status code 200 OK are cached. Policy expressions are allowed. /// void CacheStore([ExpressionAllowed] int duration, [ExpressionAllowed] bool? cacheResponse); @@ -176,7 +180,7 @@ public interface IFragmentContext : IHaveExpressionContext /// Provides a unified caching solution with stampede protection by combining cache lookup and store operations.
/// On cache hit, the cached value is assigned to the specified variable. On cache miss, the nested value block is executed /// and the resulting variable value is stored in the cache.
- /// Compiled to cache-value policy. + /// Compiled to the cache-value policy, which is not part of the public policy reference. ///
/// /// Configuration specifying the cache key, variable name, expiration, refresh interval, default value, and optional caching type. @@ -210,6 +214,7 @@ public interface IFragmentContext : IHaveExpressionContext /// Enables cross-domain calls from Adobe Flash and Microsoft Silverlight browser-based clients.
/// This policy adds appropriate CORS headers to allow browser-based clients to make cross-domain requests.
/// It creates a cross-domain policy XML document that is consumed by Silverlight and Flash clients.
+ /// API Management accepts this policy only at global scope.
/// Compiled to cross-domain policy. ///
/// @@ -293,7 +298,7 @@ public interface IFragmentContext : IHaveExpressionContext /// Compiled to ip-filter policy. ///
/// - /// Configuration specifying the action to take (allow or deny), IP addresses, and/or IP address ranges. + /// Configuration specifying the action to take ("allow" or "forbid"), IP addresses, and/or IP address ranges. /// void IpFilter(IpFilterConfig config); @@ -342,7 +347,7 @@ public interface IFragmentContext : IHaveExpressionContext /// Emits metrics about token usage from Language Model service calls.
/// This policy captures and records token usage information for monitoring and analysis purposes.
/// Use this policy to track token consumption across LLM operations with custom dimensions.
- /// Compiled to llm-emit-token-metric policy. + /// Compiled to llm-emit-token-metric policy. ///
/// /// Configuration specifying the dimensions to include with the metric and optionally the namespace to use. @@ -353,7 +358,7 @@ public interface IFragmentContext : IHaveExpressionContext /// Searches a cache for semantically similar LLM prompts and returns cached responses if found.
/// Uses vector embeddings to match prompt similarity against cached items based on a threshold.
/// When a match is found, the policy short-circuits the request and returns the cached response.
- /// Compiled to llm-semantic-cache-lookup policy. + /// Compiled to llm-semantic-cache-lookup policy. ///
/// /// Configuration specifying the similarity threshold, embedding backend, and other cache parameters. @@ -362,21 +367,25 @@ public interface IFragmentContext : IHaveExpressionContext /// /// Stores the current LLM request and response in the semantic cache for future lookup.
- /// This policy must be placed in the outbound section to capture both the request and response.
+ /// The fragment has to be included in the outbound section for this policy to capture both the request and response.
/// When stored, the entries can later be found by the llm-semantic-cache-lookup policy.
/// Compiled to llm-semantic-cache-store policy. ///
/// /// Duration in seconds for which the cached entry is valid. Policy expressions are allowed. /// - void LlmSemanticCacheStore([ExpressionAllowed] uint duration); + /// + /// Set to true to cache the current HTTP response. If omitted, only responses with status code 200 OK are cached. + /// Policy expressions are allowed. + /// + void LlmSemanticCacheStore([ExpressionAllowed] uint duration, [ExpressionAllowed] bool? cacheResponse = null); /// /// Limits tokens used by Language Model services to prevent overconsumption.
/// This policy can enforce rate limits (tokens per minute) and/or quotas (tokens per period).
/// Helps protect backend services and manage costs by controlling token usage.
/// Can estimate prompt token count and track consumption via custom headers or variables.
- /// Compiled to llm-token-limit policy. + /// Compiled to llm-token-limit policy. ///
/// /// Configuration specifying the counter key, rate limits, quotas, and optional header/variable names for tracking token usage. @@ -399,7 +408,7 @@ public interface IFragmentContext : IHaveExpressionContext /// Compiled to mock-response policy. ///
/// - /// Optional configuration specifying status code, content type, headers, and other response characteristics. + /// Optional configuration specifying the status code and content type used to select the mocked response. /// When null, returns a default empty 200 OK response. /// void MockResponse(MockResponseConfig? config = null); @@ -640,7 +649,7 @@ public interface IFragmentContext : IHaveExpressionContext /// Compiled to set-variable policy. ///
/// - /// Specifies the name of the variable to be set. Policy expressions are allowed. + /// Specifies the name of the variable to be set. Policy expressions are not allowed. /// /// /// Specifies the value of the variable to be set. Policy expressions are allowed. @@ -753,12 +762,10 @@ public interface IFragmentContext : IHaveExpressionContext /// /// Transforms XML in request or response body using XSL transform (XSLT).
- /// Can transform XML in the request body, response body, or a context variable.
/// Compiled to xsl-transform policy. ///
/// - /// Configuration specifying the transformation parameters, including where to apply the transformation (request, response, or variable), - /// the XSL stylesheet to use, content type, and error handling options. + /// Configuration specifying the XSL stylesheet to use and its parameters. /// void XslTransform(XslTransformConfig config); } diff --git a/src/Authoring/IInboundContext.cs b/src/Authoring/IInboundContext.cs index 3f191e55..241f65da 100644 --- a/src/Authoring/IInboundContext.cs +++ b/src/Authoring/IInboundContext.cs @@ -72,7 +72,7 @@ public interface IInboundContext : IHaveExpressionContext /// /// Emits metrics about token usage from Azure OpenAI service calls.
/// This policy can be used to monitor and analyze token usage patterns.
- /// Compiled to azure-openai-emit-token-metric policy. + /// Compiled to the azure-openai-emit-token-metric policy, an earlier name of llm-emit-token-metric that the gateway still accepts. ///
/// /// Configuration specifying the dimensions to include with the metric and optionally the namespace to use. @@ -83,7 +83,7 @@ public interface IInboundContext : IHaveExpressionContext /// Searches a cache for semantically similar Azure OpenAI prompts and returns cached responses if found.
/// Uses vector embeddings to match prompt similarity against cached items based on a threshold.
/// When a match is found, the policy short-circuits the request and returns the cached response.
- /// Compiled to azure-openai-semantic-cache-lookup policy. + /// Compiled to the azure-openai-semantic-cache-lookup policy, an earlier name of llm-semantic-cache-lookup that the gateway still accepts. ///
/// /// Configuration specifying the similarity threshold, embedding backend, and other cache parameters. @@ -95,7 +95,7 @@ public interface IInboundContext : IHaveExpressionContext /// This policy can enforce rate limits (tokens per minute) and/or quotas (tokens per period).
/// Helps protect backend services and manage costs by controlling token usage.
/// Can estimate prompt token count and track consumption via custom headers or variables.
- /// Compiled to azure-openai-token-limit policy. + /// Compiled to the azure-openai-token-limit policy, an earlier name of llm-token-limit that the gateway still accepts. ///
/// /// Configuration specifying the counter key, rate limits, quotas, and optional header/variable names for tracking token usage. @@ -152,7 +152,7 @@ public interface IInboundContext : IHaveExpressionContext /// Provides a unified caching solution with stampede protection by combining cache lookup and store operations.
/// On cache hit, the cached value is assigned to the specified variable. On cache miss, the nested value block is executed /// and the resulting variable value is stored in the cache.
- /// Compiled to cache-value policy. + /// Compiled to the cache-value policy, which is not part of the public policy reference. /// /// /// Configuration specifying the cache key, variable name, expiration, refresh interval, default value, and optional caching type. @@ -186,6 +186,7 @@ public interface IInboundContext : IHaveExpressionContext /// Enables cross-domain calls from Adobe Flash and Microsoft Silverlight browser-based clients.
/// This policy adds appropriate CORS headers to allow browser-based clients to make cross-domain requests.
/// It creates a cross-domain policy XML document that is consumed by Silverlight and Flash clients.
+ /// API Management accepts this policy only at global scope.
/// Compiled to cross-domain policy. /// /// @@ -260,7 +261,7 @@ public interface IInboundContext : IHaveExpressionContext /// Compiled to ip-filter policy. /// /// - /// Configuration specifying the action to take (allow or deny), IP addresses, and/or IP address ranges. + /// Configuration specifying the action to take ("allow" or "forbid"), IP addresses, and/or IP address ranges. /// void IpFilter(IpFilterConfig config); @@ -300,7 +301,7 @@ public interface IInboundContext : IHaveExpressionContext /// Emits metrics about token usage from Language Model service calls.
/// This policy captures and records token usage information for monitoring and analysis purposes.
/// Use this policy to track token consumption across LLM operations with custom dimensions.
- /// Compiled to llm-emit-token-metric policy. + /// Compiled to llm-emit-token-metric policy. /// /// /// Configuration specifying the dimensions to include with the metric and optionally the namespace to use. @@ -311,7 +312,7 @@ public interface IInboundContext : IHaveExpressionContext /// Searches a cache for semantically similar LLM prompts and returns cached responses if found.
/// Uses vector embeddings to match prompt similarity against cached items based on a threshold.
/// When a match is found, the policy short-circuits the request and returns the cached response.
- /// Compiled to llm-semantic-cache-lookup policy. + /// Compiled to llm-semantic-cache-lookup policy. /// /// /// Configuration specifying the similarity threshold, embedding backend, and other cache parameters. @@ -323,7 +324,7 @@ public interface IInboundContext : IHaveExpressionContext /// This policy can enforce rate limits (tokens per minute) and/or quotas (tokens per period).
/// Helps protect backend services and manage costs by controlling token usage.
/// Can estimate prompt token count and track consumption via custom headers or variables.
- /// Compiled to llm-token-limit policy. + /// Compiled to llm-token-limit policy. /// /// /// Configuration specifying the counter key, rate limits, quotas, and optional header/variable names for tracking token usage. @@ -346,7 +347,7 @@ public interface IInboundContext : IHaveExpressionContext /// Compiled to mock-response policy. /// /// - /// Optional configuration specifying status code, content type, headers, and other response characteristics. + /// Optional configuration specifying the status code and content type used to select the mocked response. /// When null, returns a default empty 200 OK response. /// void MockResponse(MockResponseConfig? config = null); @@ -587,7 +588,7 @@ public interface IInboundContext : IHaveExpressionContext /// Compiled to set-variable policy. /// /// - /// Specifies the name of the variable to be set. Policy expressions are allowed. + /// Specifies the name of the variable to be set. Policy expressions are not allowed. /// /// /// Specifies the value of the variable to be set. Policy expressions are allowed. @@ -682,12 +683,10 @@ public interface IInboundContext : IHaveExpressionContext /// /// Transforms XML in request or response body using XSL transform (XSLT).
- /// Can transform XML in the request body, response body, or a context variable.
/// Compiled to xsl-transform policy. ///
/// - /// Configuration specifying the transformation parameters, including where to apply the transformation (request, response, or variable), - /// the XSL stylesheet to use, content type, and error handling options. + /// Configuration specifying the XSL stylesheet to use and its parameters. /// void XslTransform(XslTransformConfig config); } diff --git a/src/Authoring/IOnErrorContext.cs b/src/Authoring/IOnErrorContext.cs index 3493d91a..f786865d 100644 --- a/src/Authoring/IOnErrorContext.cs +++ b/src/Authoring/IOnErrorContext.cs @@ -80,7 +80,7 @@ public interface IOnErrorContext : IHaveExpressionContext /// Provides a unified caching solution with stampede protection by combining cache lookup and store operations.
/// On cache hit, the cached value is assigned to the specified variable. On cache miss, the nested value block is executed /// and the resulting variable value is stored in the cache.
- /// Compiled to cache-value policy. + /// Compiled to the cache-value policy, which is not part of the public policy reference. /// /// /// Configuration specifying the cache key, variable name, expiration, refresh interval, default value, and optional caching type. @@ -181,7 +181,7 @@ public interface IOnErrorContext : IHaveExpressionContext /// Compiled to mock-response policy. /// /// - /// Optional configuration specifying status code, content type, headers, and other response characteristics. + /// Optional configuration specifying the status code and content type used to select the mocked response. /// When null, returns a default empty 200 OK response. /// void MockResponse(MockResponseConfig? config = null); @@ -345,7 +345,7 @@ public interface IOnErrorContext : IHaveExpressionContext /// Compiled to set-variable policy. /// /// - /// Specifies the name of the variable to be set. Policy expressions are allowed. + /// Specifies the name of the variable to be set. Policy expressions are not allowed. /// /// /// Specifies the value of the variable to be set. Policy expressions are allowed. @@ -413,12 +413,10 @@ public interface IOnErrorContext : IHaveExpressionContext /// /// Transforms XML in request or response body using XSL transform (XSLT).
- /// Can transform XML in the request body, response body, or a context variable.
/// Compiled to xsl-transform policy. ///
/// - /// Configuration specifying the transformation parameters, including where to apply the transformation (request, response, or variable), - /// the XSL stylesheet to use, content type, and error handling options. + /// Configuration specifying the XSL stylesheet to use and its parameters. /// void XslTransform(XslTransformConfig config); } diff --git a/src/Authoring/IOutboundContext.cs b/src/Authoring/IOutboundContext.cs index a840aa06..fabfeb7d 100644 --- a/src/Authoring/IOutboundContext.cs +++ b/src/Authoring/IOutboundContext.cs @@ -52,12 +52,16 @@ public interface IOutboundContext : IHaveExpressionContext /// Stores the current Azure OpenAI request and response in the semantic cache for future lookup.
/// This policy must be placed in the outbound section to capture both the request and response.
/// When stored, the entries can later be found by the azure-openai-semantic-cache-lookup policy.
- /// Compiled to azure-openai-semantic-cache-store policy. + /// Compiled to the azure-openai-semantic-cache-store policy, an earlier name of llm-semantic-cache-store that the gateway still accepts. /// /// /// Duration in seconds for which the cached entry is valid. Policy expressions are allowed. /// - void AzureOpenAiSemanticCacheStore([ExpressionAllowed] uint duration); + /// + /// Set to true to cache the current HTTP response. If omitted, only responses with status code 200 OK are cached. + /// Policy expressions are allowed. + /// + void AzureOpenAiSemanticCacheStore([ExpressionAllowed] uint duration, [ExpressionAllowed] bool? cacheResponse = null); /// /// The base policy used to specify when parent scope policy should be executed @@ -92,7 +96,7 @@ public interface IOutboundContext : IHaveExpressionContext /// Specifies the duration in seconds that the response should be cached. Policy expressions are allowed. /// /// - /// Indicates whether the response should be cached. If set to false, the response will not be cached. Policy expressions are allowed. + /// Set to true to cache the current HTTP response. If omitted (null), only responses with status code 200 OK are cached. Policy expressions are allowed. /// void CacheStore([ExpressionAllowed] int duration, [ExpressionAllowed] bool? cacheResponse); @@ -109,7 +113,7 @@ public interface IOutboundContext : IHaveExpressionContext /// Provides a unified caching solution with stampede protection by combining cache lookup and store operations.
/// On cache hit, the cached value is assigned to the specified variable. On cache miss, the nested value block is executed /// and the resulting variable value is stored in the cache.
- /// Compiled to cache-value policy. + /// Compiled to the cache-value policy, which is not part of the public policy reference. ///
/// /// Configuration specifying the cache key, variable name, expiration, refresh interval, default value, and optional caching type. @@ -231,7 +235,11 @@ public interface IOutboundContext : IHaveExpressionContext /// /// Duration in seconds for which the cached entry is valid. Policy expressions are allowed. /// - void LlmSemanticCacheStore([ExpressionAllowed] uint duration); + /// + /// Set to true to cache the current HTTP response. If omitted, only responses with status code 200 OK are cached. + /// Policy expressions are allowed. + /// + void LlmSemanticCacheStore([ExpressionAllowed] uint duration, [ExpressionAllowed] bool? cacheResponse = null); /// /// Sends messages in the specified format to an Azure Event Hub defined by a Logger entity.
@@ -249,7 +257,7 @@ public interface IOutboundContext : IHaveExpressionContext /// Compiled to mock-response policy. ///
/// - /// Optional configuration specifying status code, content type, headers, and other response characteristics. + /// Optional configuration specifying the status code and content type used to select the mocked response. /// When null, returns a default empty 200 OK response. /// void MockResponse(MockResponseConfig? config = null); @@ -420,7 +428,7 @@ public interface IOutboundContext : IHaveExpressionContext /// Compiled to set-variable policy. /// /// - /// Specifies the name of the variable to be set. Policy expressions are allowed. + /// Specifies the name of the variable to be set. Policy expressions are not allowed. /// /// /// Specifies the value of the variable to be set. Policy expressions are allowed. @@ -488,12 +496,10 @@ public interface IOutboundContext : IHaveExpressionContext /// /// Transforms XML in request or response body using XSL transform (XSLT).
- /// Can transform XML in the request body, response body, or a context variable.
/// Compiled to xsl-transform policy. ///
/// - /// Configuration specifying the transformation parameters, including where to apply the transformation (request, response, or variable), - /// the XSL stylesheet to use, content type, and error handling options. + /// Configuration specifying the XSL stylesheet to use and its parameters. /// void XslTransform(XslTransformConfig config); } diff --git a/src/Core/Compiling/CompilerUtils.cs b/src/Core/Compiling/CompilerUtils.cs index a5547d0a..a60ef70b 100644 --- a/src/Core/Compiling/CompilerUtils.cs +++ b/src/Core/Compiling/CompilerUtils.cs @@ -10,7 +10,9 @@ using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; using Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling.Diagnostics; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Serialization; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling; @@ -19,6 +21,16 @@ public static class CompilerUtils { public static string ProcessParameter(this ExpressionSyntax expression, IDocumentCompilationContext context) { + // null has no policy representation; it used to be written out as the text "null" + if (IsNull(expression, context)) + { + context.Report(Diagnostic.Create( + CompilationErrors.NotSupportedParameter, + expression.GetLocation() + )); + return ""; + } + var semanticModel = context.Compilation.ContainsSyntaxTree(expression.SyntaxTree) ? CachedModel(context.Compilation, expression.SyntaxTree) : null; @@ -39,7 +51,7 @@ public static string ProcessParameter(this ExpressionSyntax expression, IDocumen case LiteralExpressionSyntax syntax: return syntax.Token.ValueText; case InvocationExpressionSyntax syntax: - return FindCode(syntax, context); + return WithConversionToTarget(FindCode(syntax, context), syntax, semanticModel); case MemberAccessExpressionSyntax syntax: return FindCode(syntax, context); // case InterpolatedStringExpressionSyntax syntax: @@ -65,6 +77,53 @@ public static string ProcessParameter(this ExpressionSyntax expression, IDocumen } } + // C# converts an int helper to the long of the property it is assigned to, such as TokenQuota, but the + // emitted expression is still typed int, which API Management rejects there. The conversion is written out. + private static string WithConversionToTarget(string code, ExpressionSyntax helperCall, SemanticModel? model) + { + if (model is null || + !PolicyExpressionCompiler.TryGetImplicitNumericConversion(model, helperCall, out var target)) + { + return code; + } + + var type = target.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + if (code.StartsWith("@(", StringComparison.Ordinal) && code.EndsWith(')')) + { + return $"@(({type})({code[2..^1]}))"; + } + + // In a multi-statement expression every value it returns is converted. A raw named value, {{name}}, + // isn't C# and is kept out of the way while the code is read. + if (code.StartsWith("@{", StringComparison.Ordinal) && code.EndsWith('}')) + { + return "@" + RazorCodeFormatter.WithNamedValuesProtected(code[1..], body => + SyntaxFactory.ParseStatement(body) is BlockSyntax block && !block.ContainsDiagnostics + ? new ReturnConversionRewriter(type).Visit(block).ToFullString() + : body); + } + + return code; + } + + private sealed class ReturnConversionRewriter(string type) : CSharpSyntaxRewriter + { + public override SyntaxNode? VisitReturnStatement(ReturnStatementSyntax node) => + node.Expression is { } returned + ? node.WithExpression(SyntaxFactory.ParseExpression($"({type})({returned.WithoutTrivia().ToFullString()})") + .WithTriviaFrom(returned)) + : node; + + // a lambda or a local function returns its own value + public override SyntaxNode? VisitParenthesizedLambdaExpression(ParenthesizedLambdaExpressionSyntax node) => node; + + public override SyntaxNode? VisitSimpleLambdaExpression(SimpleLambdaExpressionSyntax node) => node; + + public override SyntaxNode? VisitAnonymousMethodExpression(AnonymousMethodExpressionSyntax node) => node; + + public override SyntaxNode? VisitLocalFunctionStatement(LocalFunctionStatementSyntax node) => node; + } + public static string FindCode(this InvocationExpressionSyntax syntax, IDocumentCompilationContext context) { return new PolicyExpressionCompiler(context).CompileInvocation(syntax); @@ -133,8 +192,42 @@ public static InitializerValue Process( continue; } + // Prop = null is the same as not setting the property + if (IsNull(assignment.Right, context)) + { + continue; + } + + // A callback, such as RetryConfig.ConditionEvaluator, is only run by the emulator and isn't part of + // the policy. + if (assignment.Right is AnonymousFunctionExpressionSyntax || IsDelegateProperty(context, assignment.Left)) + { + continue; + } + var name = assignment.Left.ToString(); - result[name] = assignment.Right.ProcessExpression(context); + var value = assignment.Right.ProcessExpression(context); + if (value.Value is null && IsScalarProperty(context, assignment.Left)) + { + // an object or a collection where a single value is expected would otherwise be dropped silently + context.Report(Diagnostic.Create( + CompilationErrors.NotSupportedParameter, + assignment.Right.GetLocation() + )); + continue; + } + + if (value.UnnamedValues is null && IsCollectionProperty(context, assignment.Left)) + { + // a collection that isn't written in place, such as a method call, has no items to emit + context.Report(Diagnostic.Create( + CompilationErrors.NotSupportedParameter, + assignment.Right.GetLocation() + )); + continue; + } + + result[name] = value; } return new InitializerValue @@ -145,6 +238,83 @@ public static InitializerValue Process( }; } + // null, or a constant that is null: default or default(string) for a reference type, (string?)null, a null + // const, and default for a nullable value type. default for a value type is zero or false and is a value like + // any other. + internal static bool IsNull(ExpressionSyntax expression, IDocumentCompilationContext context) + { + if (expression.IsKind(SyntaxKind.NullLiteralExpression)) + { + return true; + } + + // (bool?)null is not a constant either + switch (expression) + { + case CastExpressionSyntax cast: + return IsNull(cast.Expression, context); + case ParenthesizedExpressionSyntax parenthesized: + return IsNull(parenthesized.Expression, context); + } + + if (!context.Compilation.ContainsSyntaxTree(expression.SyntaxTree)) + { + return false; + } + + var model = CachedModel(context.Compilation, expression.SyntaxTree); + if (model.GetConstantValue(expression) is { HasValue: true, Value: null }) + { + return true; + } + + // default for a nullable value type, such as bool?, is null without being a constant + return expression is LiteralExpressionSyntax or DefaultExpressionSyntax && + expression.Kind() is SyntaxKind.DefaultLiteralExpression or SyntaxKind.DefaultExpression && + model.GetTypeInfo(expression).ConvertedType is INamedTypeSymbol + { + OriginalDefinition.SpecialType: SpecialType.System_Nullable_T + }; + } + + private static bool IsDelegateProperty(IDocumentCompilationContext context, ExpressionSyntax property) + { + return context.Compilation.ContainsSyntaxTree(property.SyntaxTree) && + CachedModel(context.Compilation, property.SyntaxTree).GetTypeInfo(property).Type is + { TypeKind: TypeKind.Delegate }; + } + + // Collections of the policy configurations are arrays; byte[] is a single binary value. + private static bool IsCollectionProperty(IDocumentCompilationContext context, ExpressionSyntax property) + { + return context.Compilation.ContainsSyntaxTree(property.SyntaxTree) && + CachedModel(context.Compilation, property.SyntaxTree).GetTypeInfo(property).Type is + IArrayTypeSymbol { ElementType.SpecialType: not SpecialType.System_Byte }; + } + + private static bool IsScalarProperty(IDocumentCompilationContext context, ExpressionSyntax property) + { + if (!context.Compilation.ContainsSyntaxTree(property.SyntaxTree)) + { + return false; + } + + var type = CachedModel(context.Compilation, property.SyntaxTree).GetTypeInfo(property).Type; + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable) + { + type = nullable.TypeArguments[0]; + } + + return type switch + { + IArrayTypeSymbol array => array.ElementType.SpecialType == SpecialType.System_Byte, + { SpecialType: SpecialType.System_Object } => false, + { TypeKind: TypeKind.Enum } => true, + not null => type.SpecialType != SpecialType.None, + _ => false + }; + } + public static InitializerValue Process( this ArrayCreationExpressionSyntax creationSyntax, IDocumentCompilationContext context) @@ -202,15 +372,53 @@ public static InitializerValue ProcessExpression( public static bool AddAttribute(this XElement element, IReadOnlyDictionary parameters, string key, string attName) { - if (parameters.TryGetValue(key, out var value)) + if (parameters.TryGetValue(key, out var value) && value.Value is not null) { - element.Add(new XAttribute(attName, value.Value!)); + element.Add(new XAttribute(attName, value.Value)); return true; } return false; } + // The authoring library's [NamedValue], by its type, or by its name alone when the type can't be resolved, + // an ambiguous one included. + public static bool IsNamedValueAttribute(AttributeData attribute) => + attribute.AttributeClass is { } type && + (type.TypeKind == TypeKind.Error + ? type.Name is "NamedValue" or "NamedValueAttribute" + : type.ToDisplayString() == "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.NamedValueAttribute"); + + /// + /// Reports a literal value outside the range the gateway accepts. A value given by an expression + /// can't be checked. + /// + public static bool ReportIfOutOfRange( + this IDocumentCompilationContext context, + IReadOnlyDictionary parameters, + string key, + string policy, + int minimum, + int maximum = int.MaxValue) + { + if (!parameters.TryGetValue(key, out var parameter) || + !int.TryParse(parameter.Value, out var value) || + (value >= minimum && value <= maximum)) + { + return false; + } + + context.Report(Diagnostic.Create( + CompilationErrors.ValueOutOfRange, + parameter.Node.GetLocation(), + policy, + key, + value, + minimum, + maximum)); + return true; + } + /// /// Adds an XML attribute from config parameters, but skips emission when the value /// matches the APIM default declared via on the config property. diff --git a/src/Core/Compiling/Diagnostics/CompilationErrors.cs b/src/Core/Compiling/Diagnostics/CompilationErrors.cs index ffee7c05..8285b262 100644 --- a/src/Core/Compiling/Diagnostics/CompilationErrors.cs +++ b/src/Core/Compiling/Diagnostics/CompilationErrors.cs @@ -358,4 +358,70 @@ public static class CompilationErrors description: "Description.", helpLinkUri: "TODO", customTags: ["APIM", "ApiManagement"]); + + public readonly static DiagnosticDescriptor ParameterRequiresAnotherParameter = new( + "APIM2030", + "Parameter requires another parameter", + "Parameter '{1}' of '{0}' policy can only be used when '{2}' is defined", + "PolicyDocumentCompilation", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Description.", + helpLinkUri: "TODO", + customTags: ["APIM", "ApiManagement"]); + + public readonly static DiagnosticDescriptor PolicyNotAvailableInSection = new( + "APIM2031", + "Policy is not available in this section", + "Policy method '{0}' is not available on '{1}', the policy is not allowed in this section", + "PolicyDocumentCompilation", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Description.", + helpLinkUri: "TODO", + customTags: ["APIM", "ApiManagement"]); + + public readonly static DiagnosticDescriptor PolicyIdWithoutPolicy = new( + "APIM2033", + "Policy id without a policy", + "WithId('{0}') is not followed by a policy in this block, so the id would be given to an unrelated policy", + "PolicyDocumentCompilation", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Description.", + helpLinkUri: "TODO", + customTags: ["APIM", "ApiManagement"]); + + public readonly static DiagnosticDescriptor PolicyAllowedOncePerSection = new( + "APIM2032", + "Policy is allowed once per section", + "Policy '{0}' is used {1} times in this section, API Management allows one per section", + "PolicyDocumentCompilation", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Description.", + helpLinkUri: "TODO", + customTags: ["APIM", "ApiManagement"]); + + public readonly static DiagnosticDescriptor ValueTypeNotAccepted = new( + "APIM2034", + "Value type is not accepted", + "Policy '{0}' does not accept an expression of type '{1}', API Management rejects it; convert the value, for example with ToString()", + "PolicyDocumentCompilation", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Description.", + helpLinkUri: "https://learn.microsoft.com/en-us/azure/api-management/set-variable-policy#allowed-types", + customTags: ["APIM", "ApiManagement"]); + + public readonly static DiagnosticDescriptor NamedValueNameNotConstant = new( + "APIM2040", + "Named value name is not a constant", + "Named value name '{0}' must be a compile-time constant string, API Management resolves named values when the policy is saved", + "PolicyDocumentCompilation", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Description.", + helpLinkUri: "TODO", + customTags: ["APIM", "ApiManagement"]); } diff --git a/src/Core/Compiling/DirectoryCompiler.cs b/src/Core/Compiling/DirectoryCompiler.cs index d23ab208..564698a5 100644 --- a/src/Core/Compiling/DirectoryCompiler.cs +++ b/src/Core/Compiling/DirectoryCompiler.cs @@ -22,7 +22,7 @@ public class DirectoryCompiler(DocumentCompiler compiler) public Task Compile(DirectoryCompilerOptions options) { var files = Directory.GetFiles(options.SourceFolder, "*.cs", SearchOption.AllDirectories) - .Where(p => PathUtils.IsNotInObjOrBinFolder(Path.GetFullPath(p))); + .Where(p => PathUtils.IsNotInObjOrBinFolder(p, options.SourceFolder)); DirectoryCompilerResult result = new(); foreach (var file in files) diff --git a/src/Core/Compiling/DocumentCompiler.cs b/src/Core/Compiling/DocumentCompiler.cs index b1af3fc9..ad1c69a9 100644 --- a/src/Core/Compiling/DocumentCompiler.cs +++ b/src/Core/Compiling/DocumentCompiler.cs @@ -6,6 +6,7 @@ using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; using Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling.Diagnostics; using Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling.Syntax; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Serialization; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -24,7 +25,10 @@ public IDocumentCompilationResult Compile(Compilation compilation, ClassDeclarat { var semanticModel = compilation.GetSemanticModel(document.SyntaxTree); var documentType = document.ExtractDocumentType(semanticModel); - var methods = document.DescendantNodes().OfType(); + // Only the document's own section methods: not those of nested classes, nor overloads taking anything + // other than the section context, which would otherwise each become another section. + var methods = document.Members.OfType() + .Where(method => TakesSectionContext(method, semanticModel)); var rootElement = new XElement(documentType == DocumentType.Fragment ? "fragment" : "policies"); var context = new DocumentCompilationContext(compilation, document, rootElement); document.ValidateDocumentName(semanticModel, context); @@ -37,6 +41,33 @@ public IDocumentCompilationResult Compile(Compilation compilation, ClassDeclarat return context; } + // A section returns nothing and takes one section context of the authoring library: an overload such as + // Inbound(int) isn't one, nor is one taking IHaveExpressionContext, which is what the section contexts share, + // nor a helper returning a value. The name as written decides only when the type can't be resolved. + private static bool TakesSectionContext(MethodDeclarationSyntax method, SemanticModel model) + { + if (method.ParameterList.Parameters is not [{ Type: { } type }] || + method.ReturnType is not PredefinedTypeSyntax { Keyword.ValueText: "void" }) + { + return false; + } + + if (model.GetTypeInfo(type).Type is { TypeKind: not TypeKind.Error } symbol) + { + return PolicyExpressionCompiler.IsAuthoringSectionContext(symbol) && + symbol.Name != nameof(IHaveExpressionContext); + } + + var name = type switch + { + QualifiedNameSyntax qualified => qualified.Right, + AliasQualifiedNameSyntax aliased => aliased.Name, + _ => type as SimpleNameSyntax + }; + return name is not null && name.Identifier.ValueText != nameof(IHaveExpressionContext) && + PolicyExpressionCompiler.IsSectionContextName(name.Identifier.ValueText); + } + private void CompilePolicy(DocumentCompilationContext context, IEnumerable methods) { foreach (var method in methods) @@ -66,6 +97,29 @@ private void CompileFragment(DocumentCompilationContext context, IEnumerable !found.Ancestors().Any(RawXmlContent.IsMarkupBody)); + if (count > 1) + { + context.Report(Diagnostic.Create( + CompilationErrors.PolicyAllowedOncePerSection, + method.Identifier.GetLocation(), + policy, + count)); + } } } @@ -88,9 +142,13 @@ private void CompileSection(DocumentCompilationContext context, string section, policyCount)); } + ReportPoliciesAllowedOnce(context, sectionElement, method); + context.AddPolicy(sectionElement); } + private static readonly string[] OncePerSection = ["cors", "quota", "rate-limit"]; + private bool ValidateMethodBody(MethodDeclarationSyntax method, DocumentCompilationContext context) { if (method.Body is null) diff --git a/src/Core/Compiling/LanguageVersionCheck.cs b/src/Core/Compiling/LanguageVersionCheck.cs index 74fcb675..78a8bf97 100644 --- a/src/Core/Compiling/LanguageVersionCheck.cs +++ b/src/Core/Compiling/LanguageVersionCheck.cs @@ -15,6 +15,10 @@ internal static class LanguageVersionCheck { private static readonly ConditionalWeakTable DownlevelCompilations = new(); + // A line break inside an interpolation is a C# 11 feature of the source only: the emitted expression is + // normalized, which puts every interpolation on one line. + private const string NewlineInInterpolation = "CS8967"; + // API Management compiles policy expressions as C# 7, so the helper code emitted into this expression is bound // again as C# 7.3 and any newer language feature it uses is reported at its source location. public static void ReportNewerLanguageFeatures(IDocumentCompilationContext context, IEnumerable sources) @@ -40,6 +44,7 @@ is not CSharpCompilation compilation || foreach (var diagnostic in model.GetDiagnostics(source.Span)) { if (diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.Id != NewlineInInterpolation && !existing.Contains((diagnostic.Location.SourceSpan, diagnostic.Id)) && reported.Add((diagnostic.Location.SourceSpan, diagnostic.Id))) { diff --git a/src/Core/Compiling/Policy/AuthenticationManageIdentityReturnValueCompiler.cs b/src/Core/Compiling/Policy/AuthenticationManageIdentityReturnValueCompiler.cs deleted file mode 100644 index f0cf8c04..00000000 --- a/src/Core/Compiling/Policy/AuthenticationManageIdentityReturnValueCompiler.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Xml.Linq; - -using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling.Policy; - -public class AuthenticationManageIdentityReturnValueCompiler : IReturnValueMethodPolicyHandler -{ - public string MethodName => nameof(IInboundContext.AuthenticationManagedIdentity); - - public void Handle(IDocumentCompilationContext context, InvocationExpressionSyntax node, string variableName) - { - var policy = new XElement("authentication-managed-identity"); - var resource = node.ArgumentList.Arguments[0].Expression.ProcessParameter(context); - policy.Add(new XAttribute("resource", resource)); - policy.Add(new XAttribute("output-token-variable-name", variableName)); - - context.AddPolicy(policy); - } -} \ No newline at end of file diff --git a/src/Core/Compiling/Policy/CacheLookupCompiler.cs b/src/Core/Compiling/Policy/CacheLookupCompiler.cs index 3eb39819..c402da29 100644 --- a/src/Core/Compiling/Policy/CacheLookupCompiler.cs +++ b/src/Core/Compiling/Policy/CacheLookupCompiler.cs @@ -16,7 +16,7 @@ public class CacheLookupCompiler : IMethodPolicyHandler public void Handle(IDocumentCompilationContext context, InvocationExpressionSyntax node) { - if (!node.TryExtractingConfigParameter(context, "check-header", out var values)) + if (!node.TryExtractingConfigParameter(context, "cache-lookup", out var values)) { return; } diff --git a/src/Core/Compiling/Policy/CacheRemoveValueCompiler.cs b/src/Core/Compiling/Policy/CacheRemoveValueCompiler.cs index d30e8d98..265562a6 100644 --- a/src/Core/Compiling/Policy/CacheRemoveValueCompiler.cs +++ b/src/Core/Compiling/Policy/CacheRemoveValueCompiler.cs @@ -35,6 +35,8 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt } element.AddAttribute(values, nameof(CacheRemoveValueConfig.CachingType), "caching-type"); + element.AddAttribute(values, nameof(CacheRemoveValueConfig.FailOnCacheRemovalError), + "fail-on-cache-removal-error"); context.AddPolicy(element); } diff --git a/src/Core/Compiling/Policy/CacheStoreCompiler.cs b/src/Core/Compiling/Policy/CacheStoreCompiler.cs index 526deaf1..492bffa2 100644 --- a/src/Core/Compiling/Policy/CacheStoreCompiler.cs +++ b/src/Core/Compiling/Policy/CacheStoreCompiler.cs @@ -30,13 +30,9 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt element.Add(new XAttribute("duration", arguments[0].Expression.ProcessParameter(context))); - if (arguments.Count == 2) + if (arguments.Count == 2 && !CompilerUtils.IsNull(arguments[1].Expression, context)) { - var cacheResponseValue = arguments[1].Expression.ProcessParameter(context); - if (cacheResponseValue != "null") - { - element.Add(new XAttribute("cache-response", cacheResponseValue)); - } + element.Add(new XAttribute("cache-response", arguments[1].Expression.ProcessParameter(context))); } context.AddPolicy(element); diff --git a/src/Core/Compiling/Policy/CheckHeaderCompiler.cs b/src/Core/Compiling/Policy/CheckHeaderCompiler.cs index 99010947..97788536 100644 --- a/src/Core/Compiling/Policy/CheckHeaderCompiler.cs +++ b/src/Core/Compiling/Policy/CheckHeaderCompiler.cs @@ -68,33 +68,14 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt return; } - if (!values.TryGetValue(nameof(CheckHeaderConfig.Values), out var headerValues)) + if (values.TryGetValue(nameof(CheckHeaderConfig.Values), out var headerValues)) { - context.Report(Diagnostic.Create( - CompilationErrors.RequiredParameterNotDefined, - node.GetLocation(), - "check-header", - nameof(CheckHeaderConfig.Values) - )); - return; - } - - var elements = (headerValues.UnnamedValues ?? []) - .Select(origin => new XElement("value", origin.Value!)) - .ToArray(); - if (elements.Length == 0) - { - context.Report(Diagnostic.Create( - CompilationErrors.RequiredParameterIsEmpty, - headerValues.Node.GetLocation(), - "check-header", - nameof(CheckHeaderConfig.Values) - )); - return; + var elements = (headerValues.UnnamedValues ?? []) + .Select(origin => new XElement("value", origin.Value!)) + .ToArray(); + element.Add(elements); } - element.Add(elements); - context.AddPolicy(element); } } \ No newline at end of file diff --git a/src/Core/Compiling/Policy/CorsCompiler.cs b/src/Core/Compiling/Policy/CorsCompiler.cs index a9555ba5..f6451b09 100644 --- a/src/Core/Compiling/Policy/CorsCompiler.cs +++ b/src/Core/Compiling/Policy/CorsCompiler.cs @@ -96,11 +96,23 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt "cors", nameof(CorsConfig.AllowedMethods) )); + return; } allowedMethodsElement.Add(methods); element.Add(allowedMethodsElement); } + else if (values.TryGetValue(nameof(CorsConfig.PreflightResultMaxAge), out var preflightResultMaxAge)) + { + context.Report(Diagnostic.Create( + CompilationErrors.ParameterRequiresAnotherParameter, + preflightResultMaxAge.Node.GetLocation(), + "cors", + nameof(CorsConfig.PreflightResultMaxAge), + nameof(CorsConfig.AllowedMethods) + )); + return; + } if (values.TryGetValue(nameof(CorsConfig.ExposeHeaders), out var exposeHeaders)) { @@ -115,6 +127,7 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt "cors", nameof(CorsConfig.ExposeHeaders) )); + return; } element.Add(new XElement("expose-headers", exposeHeadersElements)); diff --git a/src/Core/Compiling/Policy/EmitTokenMetricCompiler.cs b/src/Core/Compiling/Policy/EmitTokenMetricCompiler.cs index f46f3e68..e7b53c7a 100644 --- a/src/Core/Compiling/Policy/EmitTokenMetricCompiler.cs +++ b/src/Core/Compiling/Policy/EmitTokenMetricCompiler.cs @@ -65,6 +65,12 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt { if (!dimension.TryGetValues(out var result)) { + context.Report(Diagnostic.Create( + CompilationErrors.PolicyArgumentIsNotOfRequiredType, + dimension.Node.GetLocation(), + $"{_policyName}.dimension", + nameof(MetricDimensionConfig) + )); continue; } diff --git a/src/Core/Compiling/Policy/ForwardRequestCompiler.cs b/src/Core/Compiling/Policy/ForwardRequestCompiler.cs index d7b540a0..25a7fae3 100644 --- a/src/Core/Compiling/Policy/ForwardRequestCompiler.cs +++ b/src/Core/Compiling/Policy/ForwardRequestCompiler.cs @@ -41,46 +41,28 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt var element = new XElement("forward-request"); if (node.ArgumentList.Arguments.Count == 1) { - if (node.ArgumentList.Arguments[0].Expression is not ObjectCreationExpressionSyntax config) + var argument = node.ArgumentList.Arguments[0].Expression; + if (!argument.TryExtractingConfig(context, "forward-request", out var values)) { - context.Report(Diagnostic.Create( - CompilationErrors.PolicyArgumentIsNotAnObjectCreation, - node.ArgumentList.Arguments[0].Expression.GetLocation(), - "forward-request")); return; } - var initializer = config.Process(context); - if (initializer.Type != nameof(ForwardRequestConfig)) + if (values.ContainsKey(nameof(ForwardRequestConfig.Timeout)) + && values.ContainsKey(nameof(ForwardRequestConfig.TimeoutMs))) { context.Report(Diagnostic.Create( - CompilationErrors.PolicyArgumentIsNotOfRequiredType, - config.GetLocation(), + CompilationErrors.OnlyOneOfTwoShouldBeDefined, + argument.GetLocation(), "forward-request", - nameof(ForwardRequestConfig) + nameof(ForwardRequestConfig.Timeout), + nameof(ForwardRequestConfig.TimeoutMs) )); - return; } - if (initializer.NamedValues is not null) + foreach ((string key, InitializerValue value) in values) { - if (initializer.NamedValues.ContainsKey(nameof(ForwardRequestConfig.Timeout)) - && initializer.NamedValues.ContainsKey(nameof(ForwardRequestConfig.TimeoutMs))) - { - context.Report(Diagnostic.Create( - CompilationErrors.OnlyOneOfTwoShouldBeDefined, - config.GetLocation(), - "forward-request", - nameof(ForwardRequestConfig.Timeout), - nameof(ForwardRequestConfig.TimeoutMs) - )); - } - - foreach ((string key, InitializerValue value) in initializer.NamedValues) - { - var name = FieldToAttribute.GetValueOrDefault(key, key); - element.Add(new XAttribute(name, value.Value!)); - } + var name = FieldToAttribute.GetValueOrDefault(key, key); + element.Add(new XAttribute(name, value.Value!)); } } diff --git a/src/Core/Compiling/Policy/InlinePolicyCompiler.cs b/src/Core/Compiling/Policy/InlinePolicyCompiler.cs index 1d050d2b..634c56f9 100644 --- a/src/Core/Compiling/Policy/InlinePolicyCompiler.cs +++ b/src/Core/Compiling/Policy/InlinePolicyCompiler.cs @@ -58,32 +58,52 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt } } - private static XElement CreateRazorFromString(LiteralExpressionSyntax literal) + // The policy is read as API Management's rawxml format, whether or not it happens to be well-formed XML: + // an expression is C# as it is written, so characters reserved in XML are not escaped in it and an + // entity such as & in it is not unescaped. + private static XElement CreateRazorFromString(LiteralExpressionSyntax literal) => + CreateFromRawXml(literal.Token.ValueText); + + // Razor-like rawxml: expressions are written unescaped, so they are replaced with markers + // for the XML reader and put back afterwards. + private static XElement CreateFromRawXml(string text) { - var cleanXml = RazorCodeFormatter.ToCleanXml(literal.Token.ValueText, out var markerToCode); + var cleanXml = RazorCodeFormatter.ToCleanXml(text, out var markerToCode); var xml = XElement.Parse(cleanXml); - foreach (XElement element in xml.DescendantsAndSelf()) + foreach (var attribute in xml.DescendantsAndSelf().Attributes()) + { + attribute.Value = RestoreExpressions(attribute.Value, markerToCode); + } + + foreach (var node in xml.DescendantNodes()) { - if (element.HasAttributes) + if (node is XText textNode) { - foreach (var a in element.Attributes()) - { - if (markerToCode.TryGetValue(a.Value, out var attributeCode)) - { - a.Value = attributeCode; - } - } + textNode.Value = RestoreExpressions(textNode.Value, markerToCode); } - - // Only replace text on leaf elements — setting .Value on a parent - // removes all child elements and replaces them with a text node. - if (!element.HasElements && markerToCode.TryGetValue(element.Value, out var valueCode)) + else if (node is XComment comment) { - element.Value = valueCode; + comment.Value = RestoreExpressions(comment.Value, markerToCode); } } return xml; } -} \ No newline at end of file + + // A value is not always exactly one expression, so every marker in it is replaced. + private static string RestoreExpressions(string value, IReadOnlyDictionary markerToCode) + { + if (!value.Contains("__expression__", StringComparison.Ordinal)) + { + return value; + } + + foreach (var (marker, code) in markerToCode) + { + value = value.Replace(marker, code, StringComparison.Ordinal); + } + + return value; + } +} diff --git a/src/Core/Compiling/Policy/InvokeDaprBindingCompiler.cs b/src/Core/Compiling/Policy/InvokeDaprBindingCompiler.cs index 5f168f07..94a44929 100644 --- a/src/Core/Compiling/Policy/InvokeDaprBindingCompiler.cs +++ b/src/Core/Compiling/Policy/InvokeDaprBindingCompiler.cs @@ -110,22 +110,4 @@ private static void HandleMataData(IDocumentCompilationContext context, Initiali parentElement.Add(element); } - - private static void HandleData(IDocumentCompilationContext context, InitializerValue dataValue, - XElement parentElement) - { - if (dataValue.Value is null) - { - return; - } - - XElement dataElement = new("data"); - - foreach (InitializerValue item in dataValue.UnnamedValues) - { - dataElement.Add(new XElement("item", item.Value)); - } - - parentElement.Add(dataElement); - } -} \ No newline at end of file +} diff --git a/src/Core/Compiling/Policy/IpFilterCompiler.cs b/src/Core/Compiling/Policy/IpFilterCompiler.cs index 58c605b5..216850d6 100644 --- a/src/Core/Compiling/Policy/IpFilterCompiler.cs +++ b/src/Core/Compiling/Policy/IpFilterCompiler.cs @@ -37,7 +37,7 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt bool atLeastOneAddress = false; if (values.TryGetValue(nameof(IpFilterConfig.Addresses), out var addresses)) { - foreach (var address in addresses.UnnamedValues!) + foreach (var address in addresses.UnnamedValues ?? []) { element.Add(new XElement("address", address.Value!)); atLeastOneAddress = true; @@ -47,7 +47,7 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt bool atLeastOneRange = false; if (values.TryGetValue(nameof(IpFilterConfig.AddressRanges), out var ranges)) { - foreach (var range in ranges.UnnamedValues!) + foreach (var range in ranges.UnnamedValues ?? []) { if (range.Type != nameof(AddressRange)) { @@ -63,7 +63,13 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt var rangeValues = range.NamedValues; if (rangeValues is null) { - return; + context.Report(Diagnostic.Create( + CompilationErrors.PolicyArgumentIsNotOfRequiredType, + range.Node.GetLocation(), + "ip-filter.address-range", + nameof(AddressRange) + )); + continue; } var rangeElement = new XElement("address-range"); diff --git a/src/Core/Compiling/Policy/LlmContentSafetyCompiler.cs b/src/Core/Compiling/Policy/LlmContentSafetyCompiler.cs index 98c23cee..18deb559 100644 --- a/src/Core/Compiling/Policy/LlmContentSafetyCompiler.cs +++ b/src/Core/Compiling/Policy/LlmContentSafetyCompiler.cs @@ -35,6 +35,9 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt } element.AddAttribute(values, nameof(LlmContentSafetyConfig.ShieldPrompt), "shield-prompt"); + element.AddAttribute(values, nameof(LlmContentSafetyConfig.EnforceOnCompletions), "enforce-on-completions"); + element.AddAttribute(values, nameof(LlmContentSafetyConfig.WindowSize), "window-size"); + element.AddAttribute(values, nameof(LlmContentSafetyConfig.WindowOverlapSize), "window-overlap-size"); if (values.TryGetValue(nameof(LlmContentSafetyConfig.Categories), out var categoriesValue)) { @@ -108,7 +111,7 @@ private static void HandleBlockLists(InitializerValue blockListsValue, XElement return; } - var blockListsElement = new XElement("block-lists"); + var blockListsElement = new XElement("blocklists"); foreach (var idValue in idsValue.UnnamedValues ?? []) { diff --git a/src/Core/Compiling/Policy/MockResponseCompiler.cs b/src/Core/Compiling/Policy/MockResponseCompiler.cs index 6d2243d2..04e889ae 100644 --- a/src/Core/Compiling/Policy/MockResponseCompiler.cs +++ b/src/Core/Compiling/Policy/MockResponseCompiler.cs @@ -43,7 +43,7 @@ private void HandleConfig(IDocumentCompilationContext context, XElement element, CompilationErrors.PolicyArgumentIsNotOfRequiredType, value.Node.GetLocation(), "mock-response", - nameof(AddressRange) + nameof(MockResponseConfig) )); return; } diff --git a/src/Core/Compiling/Policy/QuotaByKeyCompiler.cs b/src/Core/Compiling/Policy/QuotaByKeyCompiler.cs index 0a2d378e..1b9fb085 100644 --- a/src/Core/Compiling/Policy/QuotaByKeyCompiler.cs +++ b/src/Core/Compiling/Policy/QuotaByKeyCompiler.cs @@ -61,6 +61,11 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt return; } + if (context.ReportIfOutOfRange(values, nameof(QuotaByKeyConfig.RenewalPeriod), "quota-by-key", 300)) + { + return; + } + element.AddAttribute(values, nameof(QuotaByKeyConfig.IncrementCondition), "increment-condition"); element.AddAttribute(values, nameof(QuotaByKeyConfig.IncrementCount), "increment-count"); element.AddAttribute(values, nameof(QuotaByKeyConfig.FirstPeriodStart), "first-period-start"); diff --git a/src/Core/Compiling/Policy/QuotaCompiler.cs b/src/Core/Compiling/Policy/QuotaCompiler.cs index 577733d6..78e14410 100644 --- a/src/Core/Compiling/Policy/QuotaCompiler.cs +++ b/src/Core/Compiling/Policy/QuotaCompiler.cs @@ -51,7 +51,7 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt if (values.TryGetValue(nameof(QuotaConfig.Apis), out var apis)) { - foreach (var api in apis.UnnamedValues!) + foreach (var api in apis.UnnamedValues ?? []) { if (api.Type != nameof(ApiQuota)) { @@ -73,7 +73,7 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt if (api.NamedValues!.TryGetValue(nameof(ApiQuota.Operations), out var operations)) { - foreach (var operation in operations.UnnamedValues!) + foreach (var operation in operations.UnnamedValues ?? []) { if (operation.Type != nameof(OperationQuota)) { diff --git a/src/Core/Compiling/Policy/RateLimitByKeyCompiler.cs b/src/Core/Compiling/Policy/RateLimitByKeyCompiler.cs index 11aafc8a..22466766 100644 --- a/src/Core/Compiling/Policy/RateLimitByKeyCompiler.cs +++ b/src/Core/Compiling/Policy/RateLimitByKeyCompiler.cs @@ -45,6 +45,11 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt return; } + if (context.ReportIfOutOfRange(values, nameof(RateLimitByKeyConfig.RenewalPeriod), "rate-limit-by-key", 1, 300)) + { + return; + } + if (!element.AddAttribute(values, nameof(RateLimitByKeyConfig.CounterKey), "counter-key")) { context.Report(Diagnostic.Create( diff --git a/src/Core/Compiling/Policy/RateLimitCompiler.cs b/src/Core/Compiling/Policy/RateLimitCompiler.cs index 171d6486..c147c0de 100644 --- a/src/Core/Compiling/Policy/RateLimitCompiler.cs +++ b/src/Core/Compiling/Policy/RateLimitCompiler.cs @@ -28,7 +28,7 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt context.Report(Diagnostic.Create( CompilationErrors.RequiredParameterNotDefined, node.GetLocation(), - "rate-limit-by-key", + "rate-limit", nameof(RateLimitConfig.Calls) )); return; @@ -39,12 +39,17 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt context.Report(Diagnostic.Create( CompilationErrors.RequiredParameterNotDefined, node.GetLocation(), - "rate-limit-by-key", + "rate-limit", nameof(RateLimitConfig.RenewalPeriod) )); return; } + if (context.ReportIfOutOfRange(values, nameof(RateLimitConfig.RenewalPeriod), "rate-limit", 1, 300)) + { + return; + } + element.AddAttribute(values, nameof(RateLimitConfig.RetryAfterHeaderName), "retry-after-header-name"); element.AddAttribute(values, nameof(RateLimitConfig.RetryAfterVariableName), "retry-after-variable-name"); element.AddAttribute(values, nameof(RateLimitConfig.RemainingCallsHeaderName), "remaining-calls-header-name"); @@ -54,7 +59,7 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt if (values.TryGetValue(nameof(RateLimitConfig.Apis), out var apis)) { - foreach (var api in apis.UnnamedValues!) + foreach (var api in apis.UnnamedValues ?? []) { if (!Handle(context, "api", api, out var apiElement)) { @@ -63,9 +68,9 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt element.Add(apiElement); - if (api.NamedValues!.TryGetValue(nameof(ApiRateLimit.Operations), out var operations)) + if (api.NamedValues?.TryGetValue(nameof(ApiRateLimit.Operations), out var operations) ?? false) { - foreach (var operation in operations.UnnamedValues!) + foreach (var operation in operations.UnnamedValues ?? []) { if (Handle(context, "operation", operation, out var operationElement)) { @@ -82,7 +87,15 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt private bool Handle(IDocumentCompilationContext context, string name, InitializerValue value, out XElement element) { element = new XElement(name); - var values = value.NamedValues!; + if (value.NamedValues is not { } values) + { + context.Report(Diagnostic.Create( + CompilationErrors.PolicyArgumentIsNotAnObjectCreation, + value.Node.GetLocation(), + $"rate-limit.{name}" + )); + return false; + } var isNameAdded = element.AddAttribute(values, nameof(EntityLimitConfig.Name), "name"); var isIdAdded = element.AddAttribute(values, nameof(EntityLimitConfig.Id), "id"); @@ -121,6 +134,7 @@ private bool Handle(IDocumentCompilationContext context, string name, Initialize return false; } - return true; + // the same range as the policy's own renewal period + return !context.ReportIfOutOfRange(values, nameof(EntityLimitConfig.RenewalPeriod), name, 1, 300); } } \ No newline at end of file diff --git a/src/Core/Compiling/Policy/RetryCompiler.cs b/src/Core/Compiling/Policy/RetryCompiler.cs index 25491180..b3a0fee8 100644 --- a/src/Core/Compiling/Policy/RetryCompiler.cs +++ b/src/Core/Compiling/Policy/RetryCompiler.cs @@ -84,6 +84,11 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt return; } + if (context.ReportIfOutOfRange(config, nameof(RetryConfig.Count), "retry", 1, 50)) + { + return; + } + if (!element.AddAttribute(config, nameof(RetryConfig.Interval), "interval")) { context.Report(Diagnostic.Create( diff --git a/src/Core/Compiling/Policy/SemanticCacheStoreCompiler.cs b/src/Core/Compiling/Policy/SemanticCacheStoreCompiler.cs index b12ca3fd..8add95c5 100644 --- a/src/Core/Compiling/Policy/SemanticCacheStoreCompiler.cs +++ b/src/Core/Compiling/Policy/SemanticCacheStoreCompiler.cs @@ -31,7 +31,7 @@ protected BaseSemanticCacheStoreCompiler(string methodName, string policyName) public void Handle(IDocumentCompilationContext context, InvocationExpressionSyntax node) { var arguments = node.ArgumentList.Arguments; - if (arguments.Count != 1) + if (arguments.Count is > 2 or < 1) { context.Report(Diagnostic.Create( CompilationErrors.ArgumentCountMissMatchForPolicy, @@ -42,6 +42,12 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt var element = new XElement(_policyName); element.Add(new XAttribute("duration", arguments[0].Expression.ProcessParameter(context))); + + if (arguments.Count == 2 && !CompilerUtils.IsNull(arguments[1].Expression, context)) + { + element.Add(new XAttribute("cache-response", arguments[1].Expression.ProcessParameter(context))); + } + context.AddPolicy(element); } } \ No newline at end of file diff --git a/src/Core/Compiling/Policy/SendOneWayRequestCompiler.cs b/src/Core/Compiling/Policy/SendOneWayRequestCompiler.cs index 4034319b..f33bc2dd 100644 --- a/src/Core/Compiling/Policy/SendOneWayRequestCompiler.cs +++ b/src/Core/Compiling/Policy/SendOneWayRequestCompiler.cs @@ -24,6 +24,11 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt XElement element = new("send-one-way-request"); + if (SendRequestCompiler.ReportMissingUrlOrMethod(context, node, values, "send-one-way-request")) + { + return; + } + element.AddAttribute(values, nameof(SendOneWayRequestConfig.Mode), "mode"); element.AddAttribute(values, nameof(SendOneWayRequestConfig.Timeout), "timeout"); @@ -75,9 +80,6 @@ private void HandleAuthentication(IDocumentCompilationContext context, XElement AuthenticationCertificateCompiler.HandleCertificateAuthentication(context, element, values, authentication.Node); break; - case nameof(BasicAuthenticationConfig): - AuthenticationBasicCompiler.HandleBasicAuthentication(context, element, values, authentication.Node); - break; case nameof(ManagedIdentityAuthenticationConfig): AuthenticationManagedIdentityCompiler.HandleManagedIdentityAuthentication(context, element, values, authentication.Node); diff --git a/src/Core/Compiling/Policy/SendRequestCompiler.cs b/src/Core/Compiling/Policy/SendRequestCompiler.cs index 474e1a3e..b505c7ee 100644 --- a/src/Core/Compiling/Policy/SendRequestCompiler.cs +++ b/src/Core/Compiling/Policy/SendRequestCompiler.cs @@ -34,6 +34,11 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt return; } + if (ReportMissingUrlOrMethod(context, node, values, "send-request")) + { + return; + } + element.AddAttribute(values, nameof(SendRequestConfig.Mode), "mode"); element.AddAttribute(values, nameof(SendRequestConfig.Timeout), "timeout"); element.AddAttribute(values, nameof(SendRequestConfig.IgnoreError), "ignore-error"); @@ -71,6 +76,41 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt context.AddPolicy(element); } + // The gateway rejects a request without a URL or method unless the mode is copy. A mode given by an + // expression can't be checked. + internal static bool ReportMissingUrlOrMethod( + IDocumentCompilationContext context, + InvocationExpressionSyntax node, + IReadOnlyDictionary values, + string policy) + { + // SendRequestConfig and SendOneWayRequestConfig name these properties the same; the gateway reads attribute + // values as written, so copy is the only spelling + if (values.TryGetValue(nameof(SendRequestConfig.Mode), out var mode) && + (mode.Value == "copy" || mode.Value is { } written && (written.StartsWith('@') || written.Contains("{{")))) + { + return false; + } + + var missing = false; + foreach (var name in new[] { nameof(SendRequestConfig.Url), nameof(SendRequestConfig.Method) }) + { + // an empty or is as much missing as none + if (!values.TryGetValue(name, out var value) || string.IsNullOrWhiteSpace(value.Value)) + { + context.Report(Diagnostic.Create( + CompilationErrors.RequiredParameterNotDefined, + node.GetLocation(), + policy, + name + )); + missing = true; + } + } + + return missing; + } + private void HandleAuthentication(IDocumentCompilationContext context, XElement element, InitializerValue authentication) { @@ -86,9 +126,6 @@ private void HandleAuthentication(IDocumentCompilationContext context, XElement AuthenticationCertificateCompiler.HandleCertificateAuthentication(context, element, values, authentication.Node); break; - case nameof(BasicAuthenticationConfig): - AuthenticationBasicCompiler.HandleBasicAuthentication(context, element, values, authentication.Node); - break; case nameof(ManagedIdentityAuthenticationConfig): AuthenticationManagedIdentityCompiler.HandleManagedIdentityAuthentication(context, element, values, authentication.Node); diff --git a/src/Core/Compiling/Policy/SendServiceBusMessageCompiler.cs b/src/Core/Compiling/Policy/SendServiceBusMessageCompiler.cs index e5120832..9cd5d207 100644 --- a/src/Core/Compiling/Policy/SendServiceBusMessageCompiler.cs +++ b/src/Core/Compiling/Policy/SendServiceBusMessageCompiler.cs @@ -43,6 +43,12 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt element.AddAttribute(values, nameof(SendServiceBusMessageConfig.Namespace), "namespace"); element.AddAttribute(values, nameof(SendServiceBusMessageConfig.ClientId), "client-id"); + element.AddAttribute(values, nameof(SendServiceBusMessageConfig.MessageId), "message-id"); + element.AddAttribute(values, nameof(SendServiceBusMessageConfig.SessionId), "session-id"); + element.AddAttribute(values, nameof(SendServiceBusMessageConfig.TimeToLive), "time-to-live"); + element.AddAttribute(values, nameof(SendServiceBusMessageConfig.ResponseVariableName), + "response-variable-name"); + element.AddAttribute(values, nameof(SendServiceBusMessageConfig.IgnoreError), "ignore-error"); if (values.TryGetValue(nameof(SendServiceBusMessageConfig.MessageProperties), out var messageProperties)) { diff --git a/src/Core/Compiling/Policy/SetBodyCompiler.cs b/src/Core/Compiling/Policy/SetBodyCompiler.cs index 10e013d0..ce5a95bb 100644 --- a/src/Core/Compiling/Policy/SetBodyCompiler.cs +++ b/src/Core/Compiling/Policy/SetBodyCompiler.cs @@ -5,6 +5,7 @@ using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; using Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling.Diagnostics; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Serialization; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -84,7 +85,7 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt if (useValueElement) element.Add(new XElement("value", value)); else - element.Add(value); + RawXmlContent.AddTo(element, value); context.AddPolicy(element); } @@ -116,6 +117,28 @@ public static void HandleBody(IDocumentCompilationContext context, XElement elem var useValueElement = config.TryGetValue(nameof(BodyConfig.UseValueElement), out var useVal) && useVal.Value == "true"; + if (config.TryGetValue(nameof(BodyConfig.Template), out var template) && template.Value != "liquid") + { + context.Report(Diagnostic.Create( + CompilationErrors.ValueShouldBe, + template.Node.GetLocation(), + "set-body.template", + "liquid" + )); + return; + } + + if (config.TryGetValue(nameof(BodyConfig.XsiNil), out var xsiNil) && xsiNil.Value is not ("blank" or "null")) + { + context.Report(Diagnostic.Create( + CompilationErrors.ValueShouldBe, + xsiNil.Node.GetLocation(), + "set-body.xsi-nil", + "blank' or 'null" + )); + return; + } + var bodyElement = new XElement("set-body"); bodyElement.AddAttribute(config, nameof(BodyConfig.Template), "template"); bodyElement.AddAttribute(config, nameof(BodyConfig.XsiNil), "xsi-nil"); @@ -124,7 +147,7 @@ public static void HandleBody(IDocumentCompilationContext context, XElement elem if (useValueElement) bodyElement.Add(new XElement("value", content.Value!)); else - bodyElement.Add(content.Value!); + RawXmlContent.AddTo(bodyElement, content.Value!); element.Add(bodyElement); } } \ No newline at end of file diff --git a/src/Core/Compiling/Policy/SetVariableCompiler.cs b/src/Core/Compiling/Policy/SetVariableCompiler.cs index e5d904b8..6e386f2e 100644 --- a/src/Core/Compiling/Policy/SetVariableCompiler.cs +++ b/src/Core/Compiling/Policy/SetVariableCompiler.cs @@ -6,6 +6,7 @@ using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; using Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling.Diagnostics; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling.Policy; @@ -26,7 +27,135 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt } var name = node.ArgumentList.Arguments[0].Expression.ProcessParameter(context); - var value = node.ArgumentList.Arguments[1].Expression.ProcessParameter(context); + var valueExpression = node.ArgumentList.Arguments[1].Expression; + if (FindRejectedValueType(context, valueExpression) is { } rejected) + { + context.Report(Diagnostic.Create( + CompilationErrors.ValueTypeNotAccepted, + valueExpression.GetLocation(), + "set-variable", + rejected.ToDisplayString())); + return; + } + + var value = TypedConstant(context, valueExpression) ?? valueExpression.ProcessParameter(context); context.AddPolicy(new XElement("set-variable", new XAttribute("name", name), new XAttribute("value", value))); } -} \ No newline at end of file + + // A literal value attribute is a string in the gateway: value="5" stores "5", and a policy expression + // that reads the variable as an int fails when the policy runs. A constant that isn't a string is + // written as an expression, value="@(5)", which keeps the type it has in C#. + private static string? TypedConstant(IDocumentCompilationContext context, ExpressionSyntax expression) + { + if (!context.Compilation.ContainsSyntaxTree(expression.SyntaxTree)) + { + return null; + } + + var model = CompilerUtils.CachedModel(context.Compilation, expression.SyntaxTree); + var constant = model.GetConstantValue(expression); + if (!constant.HasValue || constant.Value is null or string || + model.GetTypeInfo(expression).Type is not { TypeKind: not TypeKind.Enum } type) + { + return null; + } + + // NaN and the infinities have no literal: they stay the plain text they were + if (constant.Value is double.NaN or double.PositiveInfinity or double.NegativeInfinity or + float.NaN or float.PositiveInfinity or float.NegativeInfinity) + { + return null; + } + + var text = SymbolDisplay.FormatPrimitive(constant.Value, quoteStrings: true, useHexadecimalNumbers: false); + var literal = type.SpecialType switch + { + SpecialType.System_Boolean or SpecialType.System_Int32 or SpecialType.System_Char => text, + SpecialType.System_Int64 => text + "L", + SpecialType.System_UInt32 => text + "u", + SpecialType.System_UInt64 => text + "UL", + SpecialType.System_Single => text + "f", + SpecialType.System_Decimal => text + "m", + SpecialType.System_Double => text.Contains('.') || text.Contains('E') ? text : text + "d", + SpecialType.System_Byte or SpecialType.System_SByte or SpecialType.System_Int16 or + SpecialType.System_UInt16 => $"({type.ToDisplayString()}){text}", + _ => null + }; + return literal is null ? null : $"@({literal})"; + } + + // The gateway types the expression itself, not the helper that holds it, and accepts a list of value types: + // scalars, most of their nullable forms, byte[] and sbyte[], and the Newtonsoft types. Of what it rejects, + // this reports Uri, enums, anonymous types, tuples, other arrays and every type of System.Collections and + // System.Linq. It rejects object too, but an expression typed object in C# is often a call to another helper + // whose own expression has an accepted type, so that isn't reported: the helper it calls is looked at instead. + private static ITypeSymbol? FindRejectedValueType( + IDocumentCompilationContext context, + ExpressionSyntax value, + HashSet? visited = null) + { + if (!context.Compilation.ContainsSyntaxTree(value.SyntaxTree)) + { + return null; + } + + var model = CompilerUtils.CachedModel(context.Compilation, value.SyntaxTree); + switch (value) + { + case ParenthesizedExpressionSyntax parenthesized: + return FindRejectedValueType(context, parenthesized.Expression, visited); + // a cast to object keeps the value it is given + case CastExpressionSyntax cast when model.GetTypeInfo(cast).Type is { SpecialType: SpecialType.System_Object }: + return FindRejectedValueType(context, cast.Expression, visited); + } + + if (value is not InvocationExpressionSyntax invocation || + model.GetSymbolInfo(invocation).Symbol is not IMethodSymbol method || + method.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax() is not MethodDeclarationSyntax declaration || + !context.Compilation.ContainsSyntaxTree(declaration.SyntaxTree)) + { + // A value written in place, such as DayOfWeek.Monday, would otherwise be emitted as plain text. + return model.GetTypeInfo(value).Type is { } type && IsRejectedValueType(type) ? type : null; + } + + if (method.GetAttributes().Any(CompilerUtils.IsNamedValueAttribute) || + !(visited ??= new HashSet(SymbolEqualityComparer.Default)).Add(method)) + { + return null; + } + + // a helper declared with a rejected type returns one whatever it returns: DayOfWeek? Day() => null + if (IsRejectedValueType(method.ReturnType)) + { + return method.ReturnType; + } + + var returned = declaration.ExpressionBody is { } arrow + ? [arrow.Expression] + : declaration.Body?.DescendantNodes(node => node is not (LambdaExpressionSyntax or LocalFunctionStatementSyntax)) + .OfType() + .Select(statement => statement.Expression) + .OfType() ?? []; + return returned + .Select(expression => FindRejectedValueType(context, expression, visited)) + .FirstOrDefault(type => type is not null); + } + + private static bool IsRejectedValueType(ITypeSymbol? type) => type switch + { + null or IDynamicTypeSymbol or IErrorTypeSymbol => false, + { TypeKind: TypeKind.Enum } => true, + // a nullable value is as its value; the list omits bool?, sbyte? and TimeSpan?, which haven't been probed + INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable => + IsRejectedValueType(nullable.TypeArguments[0]), + // the gateway's list of value types has byte[] and sbyte[] as its only arrays and no collection + IArrayTypeSymbol array => array.Rank != 1 || array.ElementType.SpecialType is not + (SpecialType.System_Byte or SpecialType.System_SByte), + INamedTypeSymbol named => named.IsAnonymousType || named.IsTupleType || + named.ToDisplayString() == "System.Uri" || + named.ContainingNamespace?.ToDisplayString() is { } space && + (space is "System.Collections" or "System.Linq" || space.StartsWith("System.Collections.")) || + named.OriginalDefinition.ToDisplayString().StartsWith("System.Tuple<"), + _ => false + }; +} diff --git a/src/Core/Compiling/Policy/TokenLimitCompiler.cs b/src/Core/Compiling/Policy/TokenLimitCompiler.cs index e422a247..9c81d05f 100644 --- a/src/Core/Compiling/Policy/TokenLimitCompiler.cs +++ b/src/Core/Compiling/Policy/TokenLimitCompiler.cs @@ -64,12 +64,12 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt element.AddAttribute(values, nameof(TokenLimitConfig.TokensPerMinute), "tokens-per-minute"); var quotaAdded = element.AddAttribute(values, nameof(TokenLimitConfig.TokenQuota), "token-quota"); - if (tokensPerMinuteAdded == quotaAdded) + if (!tokensPerMinuteAdded && !quotaAdded) { context.Report(Diagnostic.Create( - CompilationErrors.OnlyOneOfTwoShouldBeDefined, + CompilationErrors.AtLeastOneOfTwoShouldBeDefined, node.GetLocation(), - "llm-token-limit", + _policyName, nameof(TokenLimitConfig.TokensPerMinute), nameof(TokenLimitConfig.TokenQuota) )); diff --git a/src/Core/Compiling/Policy/ValidateAzureAdTokenCompiler.cs b/src/Core/Compiling/Policy/ValidateAzureAdTokenCompiler.cs index 133ca28f..b41a39a4 100644 --- a/src/Core/Compiling/Policy/ValidateAzureAdTokenCompiler.cs +++ b/src/Core/Compiling/Policy/ValidateAzureAdTokenCompiler.cs @@ -35,6 +35,8 @@ public void Handle(IDocumentCompilationContext context, InvocationExpressionSynt return; } + element.AddAttribute(values, nameof(ValidateAzureAdTokenConfig.AuthenticationEndpoint), + "authentication-endpoint"); element.AddAttribute(values, nameof(ValidateAzureAdTokenConfig.HeaderName), "header-name"); element.AddAttribute(values, nameof(ValidateAzureAdTokenConfig.QueryParameterName), "query-parameter-name"); element.AddAttribute(values, nameof(ValidateAzureAdTokenConfig.TokenValue), "token-value"); @@ -80,6 +82,7 @@ private static XElement HandleDecryptionKeys(IDocumentCompilationContext context "validate-azure-ad-token.decryption-keys.key", nameof(DecryptionKey) )); + continue; } XElement decryptionElement = new("key"); @@ -92,6 +95,7 @@ private static XElement HandleDecryptionKeys(IDocumentCompilationContext context "validate-azure-ad-token.decryption-keys.key", nameof(DecryptionKey.CertificateId) )); + continue; } listElement.Add(decryptionElement); diff --git a/src/Core/Compiling/Policy/XmlToJsonCompiler.cs b/src/Core/Compiling/Policy/XmlToJsonCompiler.cs index 1d82d7f2..e0cec704 100644 --- a/src/Core/Compiling/Policy/XmlToJsonCompiler.cs +++ b/src/Core/Compiling/Policy/XmlToJsonCompiler.cs @@ -16,7 +16,7 @@ public class XmlToJsonCompiler : IMethodPolicyHandler public void Handle(IDocumentCompilationContext context, InvocationExpressionSyntax node) { - if (!node.TryExtractingConfigParameter(context, "xml-to=json", out var values)) + if (!node.TryExtractingConfigParameter(context, "xml-to-json", out var values)) { return; } diff --git a/src/Core/Compiling/Policy/XslTransformCompiler.cs b/src/Core/Compiling/Policy/XslTransformCompiler.cs index 7a079541..a4799e68 100644 --- a/src/Core/Compiling/Policy/XslTransformCompiler.cs +++ b/src/Core/Compiling/Policy/XslTransformCompiler.cs @@ -94,7 +94,7 @@ private static void HandleParameters(IDocumentCompilationContext context, Initia CompilationErrors.RequiredParameterNotDefined, paramValue.Node.GetLocation(), "xsl-transform.parameter", - nameof(XslTransformConfig.StyleSheet) + nameof(XslTransformParameter.Value) )); continue; } diff --git a/src/Core/Compiling/PolicyExpressionCompiler.HelperInliningRewriter.cs b/src/Core/Compiling/PolicyExpressionCompiler.HelperInliningRewriter.cs index 1af88adc..db2a01a5 100644 --- a/src/Core/Compiling/PolicyExpressionCompiler.HelperInliningRewriter.cs +++ b/src/Core/Compiling/PolicyExpressionCompiler.HelperInliningRewriter.cs @@ -4,6 +4,7 @@ using System.Collections.Immutable; using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling.Diagnostics; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -12,6 +13,14 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling; internal sealed partial class PolicyExpressionCompiler { + private void ReportNonConstantNamedValue(InvocationExpressionSyntax invocation) + { + context.Report(Diagnostic.Create( + CompilationErrors.NamedValueNameNotConstant, + invocation.GetLocation(), + invocation.ArgumentList.Arguments.ToString())); + } + private sealed class HelperInliningRewriter( PolicyExpressionCompiler compiler, SemanticModel model, @@ -193,6 +202,18 @@ private SyntaxToken Renamed(SyntaxNode declaration, SyntaxToken identifier) => : rewritten; } + // A returned value that C# converts to the helper's numeric return type has that conversion written out. + public override SyntaxNode? VisitReturnStatement(ReturnStatementSyntax node) + { + var visited = (ReturnStatementSyntax)base.VisitReturnStatement(node)!; + return node.Expression is { } written && visited.Expression is { } result && + TryGetImplicitNumericConversion(model, written, out var returnType) + ? visited.WithExpression( + (compiler.ConvertRawNamedValue(returnType, result) ?? CastTo(returnType, result).Expression) + .WithTriviaFrom(result)) + : visited; + } + // An explicit cast of a named value, directly or from a helper or argument, converts all of its text like // an implicit conversion: to string it's the string "{{x}}", to another type (int)({{x}}). A cast of the // parenthesized call, as the decompiler writes a token used as code, keeps the raw token. @@ -360,7 +381,18 @@ private bool TryGetNamedValueName(InvocationExpressionSyntax node, out string na invoked.MethodKind is not (MethodKind.Ordinary or MethodKind.ReducedExtension) || !IsUserSource(invoked)) { - return base.VisitInvocationExpression(node); + var visited = base.VisitInvocationExpression(node); + + // API Management has no NamedValue method: a call can only be written as {{name}}, which needs a + // name known at compile time (a constant, or a helper parameter bound to one). + if (invoked is { Name: nameof(IExpressionContext.NamedValue) } && + IsExpressionContext(invoked.ContainingType) && + !(visited is InvocationExpressionSyntax call && HasNamedValueName(call))) + { + compiler.ReportNonConstantNamedValue(node); + } + + return visited; } return compiler.InlineSourceInvocation( @@ -369,6 +401,15 @@ invoked.MethodKind is not (MethodKind.Ordinary or MethodKind.ReducedExtension) | node; } + // context?.NamedValue("x") is rewritten with its conditional access. + private static bool HasNamedValueName(InvocationExpressionSyntax call) => + NamedValueRewriter.IsNamedValueCall(call, out _) || + call is + { + Expression: MemberBindingExpressionSyntax, + ArgumentList.Arguments: [{ NameColon: null, Expression: LiteralExpressionSyntax literal }] + } && literal.IsKind(SyntaxKind.StringLiteralExpression); + public override SyntaxNode? VisitAnonymousObjectMemberDeclarator(AnonymousObjectMemberDeclaratorSyntax node) { var visited = (AnonymousObjectMemberDeclaratorSyntax)base.VisitAnonymousObjectMemberDeclarator(node)!; diff --git a/src/Core/Compiling/PolicyExpressionCompiler.cs b/src/Core/Compiling/PolicyExpressionCompiler.cs index 87aa6955..6b814cd0 100644 --- a/src/Core/Compiling/PolicyExpressionCompiler.cs +++ b/src/Core/Compiling/PolicyExpressionCompiler.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; @@ -107,6 +108,16 @@ public string CompileInvocation(InvocationExpressionSyntax invocation) return nestedNamedValue; } + // API Management types an expression by its code and rejects one typed object, so the cast to object + // that a helper declared to return object leaves around its result is dropped. + while (lowered is ExpressionSyntax expression && expression.Unparenthesized() is CastExpressionSyntax + { + Type: PredefinedTypeSyntax { Keyword.RawKind: (int)SyntaxKind.ObjectKeyword } + } cast) + { + lowered = cast.Expression; + } + return FinalizeCode(lowered); SyntaxNode? CompileRootBody(bool renameAll) @@ -114,12 +125,29 @@ public string CompileInvocation(InvocationExpressionSyntax invocation) var rewriter = new HelperInliningRewriter( this, declarationModel, bindings, stack, method, _renamer.CreateRenames(body, declarationModel, renameAll)); var visited = rewriter.Visit(body); + if (visited is ExpressionSyntax result && body is ExpressionSyntax written && + TryGetImplicitNumericConversion(declarationModel, written, out var returnType)) + { + visited = ConvertRawNamedValue(returnType, result) ?? CastTo(returnType, result).Expression; + } + return rewriter.HasUnsupportedWrite ? null : visited; } } public string CompileCondition(ExpressionSyntax condition) { + // A boolean constant is a valid condition as is: + if (condition.IsKind(SyntaxKind.TrueLiteralExpression)) + { + return "true"; + } + + if (condition.IsKind(SyntaxKind.FalseLiteralExpression)) + { + return "false"; + } + var model = CompilerUtils.CachedModel(context.Compilation, condition.SyntaxTree); if (condition is InvocationExpressionSyntax namedValueInvocation && TryResolveMethod(namedValueInvocation, model, out var namedValueMethod) && @@ -510,7 +538,45 @@ first is not null && (SymbolEqualityComparer.Default.Equals(first, second) || first.ToDisplayString(EmittedTypeFormat) == second.ToDisplayString(EmittedTypeFormat)); - private static ExpressionSyntax CastTo(ITypeSymbol type, ExpressionSyntax expression) => + // C# converts 300 to uint when a helper declared to return uint returns it, but the emitted expression + // is only the 300, which API Management types as int. The conversion has to be written out. + internal static bool TryGetImplicitNumericConversion( + SemanticModel model, + ExpressionSyntax expression, + [NotNullWhen(true)] out ITypeSymbol? target) + { + target = null; + if (model.SyntaxTree != expression.SyntaxTree) + { + return false; + } + + var info = model.GetTypeInfo(expression); + var converted = info.ConvertedType; + if (converted is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable) + { + // a helper declared to return uint? converts 300 the same way + converted = nullable.TypeArguments[0]; + } + + if (info.Type is not { } type || converted is null || + !IsNumeric(type) || !IsNumeric(converted) || + SymbolEqualityComparer.Default.Equals(type, converted)) + { + return false; + } + + target = converted; + return true; + } + + private static bool IsNumeric(ITypeSymbol type) => type.SpecialType is + SpecialType.System_SByte or SpecialType.System_Byte or SpecialType.System_Int16 or + SpecialType.System_UInt16 or SpecialType.System_Int32 or SpecialType.System_UInt32 or + SpecialType.System_Int64 or SpecialType.System_UInt64 or SpecialType.System_Single or + SpecialType.System_Double or SpecialType.System_Decimal; + + private static ParenthesizedExpressionSyntax CastTo(ITypeSymbol type, ExpressionSyntax expression) => SyntaxFactory.ParenthesizedExpression(SyntaxFactory.CastExpression( SyntaxFactory.ParseTypeName(type.ToDisplayString(EmittedTypeFormat)), ParenthesizeIfNeeded(expression))); @@ -604,14 +670,17 @@ type is INamedTypeSymbol Name: { } name, ContainingNamespace: { } containingNamespace } && - name is (nameof(IHaveExpressionContext) + IsSectionContextName(name) && + containingNamespace.ToDisplayString() == + "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring"; + + internal static bool IsSectionContextName(string name) => + name is nameof(IHaveExpressionContext) or nameof(IInboundContext) or nameof(IOutboundContext) or nameof(IBackendContext) or nameof(IOnErrorContext) - or nameof(IFragmentContext)) && - containingNamespace.ToDisplayString() == - "Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring"; + or nameof(IFragmentContext); private static bool IsSafeSourceHelper( InvocationExpressionSyntax invocation, @@ -674,38 +743,36 @@ private bool TryGetMethodDeclaration( private bool TryGetNamedValue(IMethodSymbol method, out string value) { - var attribute = method.GetAttributes().FirstOrDefault(candidate => - candidate.AttributeClass?.Name == "NamedValueAttribute"); - if (attribute?.ConstructorArguments is - [{ Value: string configuredValue }]) - { - value = configuredValue.Contains("{{", StringComparison.Ordinal) - ? configuredValue - : $"{{{{{configuredValue}}}}}"; - return true; + value = string.Empty; + var attribute = method.GetAttributes().FirstOrDefault(CompilerUtils.IsNamedValueAttribute); + if (attribute is null) + { + return false; } - if (TryGetMethodDeclaration(method, out var declaration)) + string? configured = null; + if (attribute.ConstructorArguments is [{ Value: string bound }]) { - var attributeSyntax = declaration.AttributeLists - .SelectMany(list => list.Attributes) - .FirstOrDefault(candidate => candidate.Name.ToString() is "NamedValue" or "NamedValueAttribute"); - var argument = attributeSyntax?.ArgumentList?.Arguments.FirstOrDefault()?.Expression; - if (argument is not null) - { - var constant = ModelFor(argument.SyntaxTree)?.GetConstantValue(argument); - if (constant is { HasValue: true, Value: string syntaxValue }) - { - value = syntaxValue.Contains("{{", StringComparison.Ordinal) - ? syntaxValue - : $"{{{{{syntaxValue}}}}}"; - return true; - } - } + configured = bound; + } + // an attribute whose type couldn't be resolved has no bound arguments: its argument is read as written + else if (attribute.ApplicationSyntaxReference?.GetSyntax() is AttributeSyntax + { + ArgumentList.Arguments: [{ Expression: var argument }, ..] + }) + { + configured = ModelFor(argument.SyntaxTree)?.GetConstantValue(argument) is { HasValue: true, Value: string written } + ? written + : null; } - value = string.Empty; - return false; + if (configured is null) + { + return false; + } + + value = configured.Contains("{{", StringComparison.Ordinal) ? configured : $"{{{{{configured}}}}}"; + return true; } private void ReportCannotFindMethod(InvocationExpressionSyntax invocation) diff --git a/src/Core/Compiling/ProjectCompiler.cs b/src/Core/Compiling/ProjectCompiler.cs index 2d2256f4..504302a3 100644 --- a/src/Core/Compiling/ProjectCompiler.cs +++ b/src/Core/Compiling/ProjectCompiler.cs @@ -46,8 +46,9 @@ public async Task Compile(ProjectCompilerOptions options, return result; } + var projectFolder = Path.GetDirectoryName(Path.GetFullPath(options.ProjectPath))!; var onlyUserSyntaxTrees = - compilation.SyntaxTrees.Where(t => PathUtils.IsNotInObjOrBinFolder(Path.GetFullPath(t.FilePath))); + compilation.SyntaxTrees.Where(t => PathUtils.IsNotInObjOrBinFolder(t.FilePath, projectFolder)); foreach (var syntaxTree in onlyUserSyntaxTrees) { diff --git a/src/Core/Compiling/Syntax/BlockCompiler.cs b/src/Core/Compiling/Syntax/BlockCompiler.cs index 6cf50c6d..160fcaf0 100644 --- a/src/Core/Compiling/Syntax/BlockCompiler.cs +++ b/src/Core/Compiling/Syntax/BlockCompiler.cs @@ -39,7 +39,20 @@ public void Compile(IDocumentCompilationContext context, SyntaxNode node) statement.GetLocation(), statement.Kind().ToString() )); + // an id doesn't move past a statement that isn't a policy + context.PendingPolicyId = null; } } + + // context.WithId("id"); as the last statement of a block has no policy to give its id to + if (context.PendingPolicyId is { } id) + { + context.Report(Diagnostic.Create( + CompilationErrors.PolicyIdWithoutPolicy, + block.CloseBraceToken.GetLocation(), + id + )); + context.PendingPolicyId = null; + } } } \ No newline at end of file diff --git a/src/Core/Compiling/Syntax/ExpressionStatementCompiler.cs b/src/Core/Compiling/Syntax/ExpressionStatementCompiler.cs index fc3b88a1..0e204856 100644 --- a/src/Core/Compiling/Syntax/ExpressionStatementCompiler.cs +++ b/src/Core/Compiling/Syntax/ExpressionStatementCompiler.cs @@ -30,6 +30,7 @@ public void Compile(IDocumentCompilationContext context, SyntaxNode node) statement.Expression.GetType().Name, nameof(InvocationExpressionSyntax) )); + context.PendingPolicyId = null; return; } @@ -44,13 +45,36 @@ public void Compile(IDocumentCompilationContext context, SyntaxNode node) invocation.Expression.GetType().Name, nameof(MemberAccessExpressionSyntax) )); + context.PendingPolicyId = null; return; } var name = memberAccess.Name.ToString(); + if (name == "WithId" && TrySetPendingPolicyId(context, invocation)) + { + // context.WithId("id"); on its own gives the id to the next policy, which lets an + // if statement (compiled to choose) carry one + return; + } + if (_handlers.TryGetValue(name, out var handler)) { + if (IsMissingOnSectionContext(context, memberAccess, name, out var sectionContext)) + { + context.Report(Diagnostic.Create( + CompilationErrors.PolicyNotAvailableInSection, + memberAccess.Name.GetLocation(), + name, + sectionContext + )); + context.PendingPolicyId = null; + return; + } + handler.Handle(context, invocation); + + // A handler that reported an error added no policy. Its id must not move on to the next one. + context.PendingPolicyId = null; } else { @@ -59,6 +83,7 @@ public void Compile(IDocumentCompilationContext context, SyntaxNode node) memberAccess.GetLocation(), name )); + context.PendingPolicyId = null; } } @@ -82,12 +107,13 @@ memberAccess.Expression is InvocationExpressionSyntax innerInvocation && innerInvocation.Expression is MemberAccessExpressionSyntax innerMemberAccess && innerMemberAccess.Name.ToString() == "WithId") { - if (context.PendingPolicyId is null && innerInvocation.ArgumentList.Arguments.Count == 1) + if (innerInvocation.ArgumentList.Arguments.Count == 1) { var argExpression = innerInvocation.ArgumentList.Arguments[0].Expression; var idValue = ExtractConstantStringValue(context, argExpression); if (idValue is not null) { + ReportPendingPolicyId(context, innerInvocation); context.PendingPolicyId = idValue; } } @@ -98,6 +124,80 @@ innerInvocation.Expression is MemberAccessExpressionSyntax innerMemberAccess && return invocation; } + // Handlers are found by method name, so without this check a policy would compile in any section when the + // C# itself isn't compiled first (directory mode): context.ForwardRequest() in Inbound, Base() in a fragment. + private static bool IsMissingOnSectionContext( + IDocumentCompilationContext context, + MemberAccessExpressionSyntax memberAccess, + string name, + out string sectionContext) + { + sectionContext = string.Empty; + if (!context.Compilation.ContainsSyntaxTree(memberAccess.SyntaxTree)) + { + return false; + } + + var model = CompilerUtils.CachedModel(context.Compilation, memberAccess.SyntaxTree); + if (model.GetTypeInfo(memberAccess.Expression).Type is not { } receiver || + !PolicyExpressionCompiler.IsAuthoringSectionContext(receiver)) + { + return false; + } + + // A section is compiled by its name whatever context it takes: OnError(IOutboundContext context) is + // on-error, so its policies are checked against IOnErrorContext, not against what it is written with. + var section = SectionContextOf(context, memberAccess) ?? receiver; + sectionContext = section.Name; + return section.GetMembers(name).IsEmpty && + section.AllInterfaces.All(inherited => inherited.GetMembers(name).IsEmpty); + } + + private static INamedTypeSymbol? SectionContextOf(IDocumentCompilationContext context, SyntaxNode node) + { + var contextName = node.Ancestors().OfType().FirstOrDefault()?.Identifier.ValueText switch + { + "Inbound" => "IInboundContext", + "Outbound" => "IOutboundContext", + "Backend" => "IBackendContext", + "OnError" => "IOnErrorContext", + "Fragment" => "IFragmentContext", + _ => null + }; + return contextName is null + ? null + : context.Compilation.GetTypeByMetadataName( + $"Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.{contextName}"); + } + + private static bool TrySetPendingPolicyId( + IDocumentCompilationContext context, + InvocationExpressionSyntax withIdInvocation) + { + if (withIdInvocation.ArgumentList.Arguments.Count != 1 || + ExtractConstantStringValue(context, withIdInvocation.ArgumentList.Arguments[0].Expression) is not { } id) + { + return false; + } + + ReportPendingPolicyId(context, withIdInvocation); + context.PendingPolicyId = id; + return true; + } + + // An id that is still waiting for its policy when the next id is given was not followed by one. + private static void ReportPendingPolicyId(IDocumentCompilationContext context, SyntaxNode nextId) + { + if (context.PendingPolicyId is { } pending) + { + context.Report(Diagnostic.Create( + CompilationErrors.PolicyIdWithoutPolicy, + nextId.GetLocation(), + pending + )); + } + } + /// /// Extracts a constant string value from an expression, handling both string literals and const field references. /// diff --git a/src/Core/Decompiling/DecompileOptions.cs b/src/Core/Decompiling/DecompileOptions.cs index 36b9434d..e51fc401 100644 --- a/src/Core/Decompiling/DecompileOptions.cs +++ b/src/Core/Decompiling/DecompileOptions.cs @@ -12,4 +12,26 @@ public record DecompileOptions /// For fragments the fragment ID is used instead. /// public string? DocumentId { get; init; } + + /// + /// The format the policy text is written in: rawxml unless stated, as for the policy compiler. + /// + public PolicyFormat PolicyFormat { get; init; } = PolicyFormat.RawXml; +} + +/// +/// The format of the policy text given to the decompiler, as in the policy compiler's --policy-format. +/// +public enum PolicyFormat +{ + /// + /// API Management's rawxml: a value that begins with a policy expression holds C# as it is written, so an + /// entity in it is not decoded. Everything else is XML. + /// + RawXml, + + /// + /// XML: a policy expression is XML-escaped like any other text, and the document has to be well-formed. + /// + Xml } diff --git a/src/Core/Decompiling/Policy/AuthenticationCertificateDecompiler.cs b/src/Core/Decompiling/Policy/AuthenticationCertificateDecompiler.cs index e292abd6..6101a126 100644 --- a/src/Core/Decompiling/Policy/AuthenticationCertificateDecompiler.cs +++ b/src/Core/Decompiling/Policy/AuthenticationCertificateDecompiler.cs @@ -11,10 +11,19 @@ public class AuthenticationCertificateDecompiler : IPolicyDecompiler public void Decompile(CodeWriter writer, XElement element, string contextVar, PolicyDecompilerContext context) { + var body = element.Attribute("body")?.Value; + if (body != null && !context.IsExpression(body) && !PolicyDecompilerContext.ContainsNamedValueToken(body)) + { + // The config's Body is a byte[]; a literal value has no typed representation. + new InlinePolicyDecompiler().Decompile(writer, element, contextVar, context); + return; + } + var prefix = PolicyDecompilerContext.GetContextPrefix(element, contextVar); var props = new List(); context.AddOptionalStringProp(props, element, "thumbprint", "Thumbprint"); context.AddOptionalStringProp(props, element, "certificate-id", "CertificateId"); + context.AddOptionalProp(props, element, "body", "Body", "byte[]"); context.AddOptionalStringProp(props, element, "password", "Password"); PolicyDecompilerContext.EmitConfigCall(writer, prefix, "AuthenticationCertificate", "CertificateAuthenticationConfig", props); } diff --git a/src/Core/Decompiling/Policy/CacheRemoveValueDecompiler.cs b/src/Core/Decompiling/Policy/CacheRemoveValueDecompiler.cs index 4939e483..7c115803 100644 --- a/src/Core/Decompiling/Policy/CacheRemoveValueDecompiler.cs +++ b/src/Core/Decompiling/Policy/CacheRemoveValueDecompiler.cs @@ -15,6 +15,7 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po var props = new List(); context.AddRequiredStringProp(props, element, "key", "Key"); context.AddOptionalStringProp(props, element, "caching-type", "CachingType"); + context.AddOptionalBoolProp(props, element, "fail-on-cache-removal-error", "FailOnCacheRemovalError"); PolicyDecompilerContext.EmitConfigCall(writer, prefix, "CacheRemoveValue", "CacheRemoveValueConfig", props); } } diff --git a/src/Core/Decompiling/Policy/ChooseDecompiler.cs b/src/Core/Decompiling/Policy/ChooseDecompiler.cs index e8f2bc09..b0fc3932 100644 --- a/src/Core/Decompiling/Policy/ChooseDecompiler.cs +++ b/src/Core/Decompiling/Policy/ChooseDecompiler.cs @@ -14,10 +14,21 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po var whens = element.Elements("when").ToList(); var otherwise = element.Element("otherwise"); + if (!CanBeIfStatement(element, whens, context)) + { + new InlinePolicyDecompiler().Decompile(writer, element, contextVar, context); + return; + } + + if (element.Attribute("id")?.Value is { } id) + { + writer.AppendLine($"{contextVar}.WithId({PolicyDecompilerContext.Literal(id)});"); + } + for (int i = 0; i < whens.Count; i++) { var when = whens[i]; - var condition = when.Attribute("condition")?.Value ?? "true"; + var condition = when.Attribute("condition")!.Value; var conditionExpr = context.HandleConditionExpression(condition, "Condition"); if (i == 0) @@ -45,4 +56,21 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po writer.AppendLine("}"); } } + + // The condition of an if statement has to be a boolean helper call (an expression or a named value) + // or the constant true or false. The gateway accepts nothing else: it rejects "True", "1", an empty or + // missing condition and a choose without a when. Such a document is kept as written. + private static bool CanBeIfStatement(XElement element, List whens, PolicyDecompilerContext context) + { + if (whens.Count == 0) + { + return false; + } + + return whens.All(when => + when.Attribute("condition")?.Value is { } condition && + (condition.Trim() is "true" or "false" || + context.IsExpression(condition.Trim()) || + PolicyDecompilerContext.ContainsNamedValueToken(condition))); + } } diff --git a/src/Core/Decompiling/Policy/CorsDecompiler.cs b/src/Core/Decompiling/Policy/CorsDecompiler.cs index fd185512..9a26f657 100644 --- a/src/Core/Decompiling/Policy/CorsDecompiler.cs +++ b/src/Core/Decompiling/Policy/CorsDecompiler.cs @@ -14,7 +14,7 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po var prefix = PolicyDecompilerContext.GetContextPrefix(element, contextVar); var props = new List(); context.AddOptionalBoolProp(props, element, "allow-credentials", "AllowCredentials"); - context.AddOptionalStringProp(props, element, "terminate-unmatched-request", "TerminateUnmatchedRequest"); + context.AddOptionalBoolProp(props, element, "terminate-unmatched-request", "TerminateUnmatchedRequest"); var origins = element.Element("allowed-origins")?.Elements("origin") .Select(e => PolicyDecompilerContext.GetElementText(e)).ToList(); @@ -33,11 +33,7 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po var methodsEl = element.Element("allowed-methods"); if (methodsEl != null) { - var preflightMaxAge = methodsEl.Attribute("preflight-result-max-age")?.Value; - if (preflightMaxAge != null) - { - props.Add($"PreflightResultMaxAge = {preflightMaxAge}"); - } + context.AddOptionalUIntProp(props, methodsEl, "preflight-result-max-age", "PreflightResultMaxAge"); var methods = methodsEl.Elements("method").Select(e => PolicyDecompilerContext.GetElementText(e)).ToList(); if (methods.Count > 0) diff --git a/src/Core/Decompiling/Policy/InlinePolicyDecompiler.cs b/src/Core/Decompiling/Policy/InlinePolicyDecompiler.cs index 52e42f34..77b3b5c2 100644 --- a/src/Core/Decompiling/Policy/InlinePolicyDecompiler.cs +++ b/src/Core/Decompiling/Policy/InlinePolicyDecompiler.cs @@ -11,7 +11,8 @@ public class InlinePolicyDecompiler : IPolicyDecompiler public void Decompile(CodeWriter writer, XElement element, string contextVar, PolicyDecompilerContext context) { - var xmlString = element.ToString(SaveOptions.DisableFormatting); + // Raw policy text: expressions are written as code, not XML-escaped, which is what InlinePolicy expects. + var xmlString = PolicyDecompilerContext.ToRawXml(element); var escaped = PolicyDecompilerContext.EscapeStringForVerbatim(xmlString); writer.AppendLine($"{contextVar}.InlinePolicy(@\"{escaped}\");"); } diff --git a/src/Core/Decompiling/Policy/JsonToXmlDecompiler.cs b/src/Core/Decompiling/Policy/JsonToXmlDecompiler.cs index ffe984ae..b0ce2c80 100644 --- a/src/Core/Decompiling/Policy/JsonToXmlDecompiler.cs +++ b/src/Core/Decompiling/Policy/JsonToXmlDecompiler.cs @@ -11,13 +11,26 @@ public class JsonToXmlDecompiler : IPolicyDecompiler public void Decompile(CodeWriter writer, XElement element, string contextVar, PolicyDecompilerContext context) { + var nsSep = element.Attribute("namespace-separator")?.Value; + var nsSepIsCode = nsSep != null && + (context.IsExpression(nsSep) || PolicyDecompilerContext.ContainsNamedValueToken(nsSep)); + if (nsSep != null && !nsSepIsCode && nsSep.Length != 1) + { + // The config's NamespaceSeparator is a single char; any other literal has no typed representation. + new InlinePolicyDecompiler().Decompile(writer, element, contextVar, context); + return; + } + var prefix = PolicyDecompilerContext.GetContextPrefix(element, contextVar); var props = new List(); context.AddRequiredStringProp(props, element, "apply", "Apply"); context.AddOptionalBoolProp(props, element, "consider-accept-header", "ConsiderAcceptHeader"); context.AddOptionalBoolProp(props, element, "parse-date", "ParseDate"); - var nsSep = element.Attribute("namespace-separator")?.Value; - if (nsSep != null && nsSep.Length > 0) + if (nsSepIsCode) + { + props.Add($"NamespaceSeparator = {context.HandleValue(nsSep!, "NamespaceSeparator", "char")}"); + } + else if (nsSep != null) { props.Add($"NamespaceSeparator = '{PolicyDecompilerContext.EscapeChar(nsSep[0])}'"); } diff --git a/src/Core/Decompiling/Policy/LlmContentSafetyDecompiler.cs b/src/Core/Decompiling/Policy/LlmContentSafetyDecompiler.cs index 84c58906..0b3f947c 100644 --- a/src/Core/Decompiling/Policy/LlmContentSafetyDecompiler.cs +++ b/src/Core/Decompiling/Policy/LlmContentSafetyDecompiler.cs @@ -16,6 +16,9 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po context.AddRequiredStringProp(props, element, "backend-id", "BackendId"); context.AddOptionalBoolProp(props, element, "shield-prompt", "ShieldPrompt"); + context.AddOptionalBoolProp(props, element, "enforce-on-completions", "EnforceOnCompletions"); + context.AddOptionalIntProp(props, element, "window-size", "WindowSize"); + context.AddOptionalIntProp(props, element, "window-overlap-size", "WindowOverlapSize"); var categoriesElement = element.Element("categories"); if (categoriesElement != null) @@ -34,7 +37,7 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po var categoryConfigs = categories.Select(c => { var name = c.Attribute("name")?.Value ?? ""; - var threshold = c.Attribute("threshold")?.Value ?? "0"; + var threshold = context.HandleIntValue(c.Attribute("threshold")?.Value ?? "0", "Threshold"); return $"new ContentSafetyCategory {{ Name = {PolicyDecompilerContext.Literal(name)}, Threshold = {threshold} }}"; }); catProps.Add($"Categories = new ContentSafetyCategory[] {{ {string.Join(", ", categoryConfigs)} }}"); @@ -43,7 +46,7 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po props.Add($"Categories = new ContentSafetyCategories {{ {string.Join(", ", catProps)} }}"); } - var blockListsElement = element.Element("block-lists"); + var blockListsElement = element.Element("blocklists") ?? element.Element("block-lists"); if (blockListsElement != null) { var ids = blockListsElement.Elements("id") diff --git a/src/Core/Decompiling/Policy/SemanticCacheLookupDecompiler.cs b/src/Core/Decompiling/Policy/SemanticCacheLookupDecompiler.cs index 7fcc32fe..aa1bebbf 100644 --- a/src/Core/Decompiling/Policy/SemanticCacheLookupDecompiler.cs +++ b/src/Core/Decompiling/Policy/SemanticCacheLookupDecompiler.cs @@ -24,19 +24,29 @@ protected BaseSemanticCacheLookupDecompiler(string policyName, string methodName public void Decompile(CodeWriter writer, XElement element, string contextVar, PolicyDecompilerContext context) { + var scoreValue = element.Attribute("score-threshold")?.Value ?? "0"; + if (!context.IsExpression(scoreValue) && !PolicyDecompilerContext.IsNamedValueToken(scoreValue) && + PolicyDecompilerContext.ContainsNamedValueToken(scoreValue)) + { + // A threshold put together from named values and text has no decimal representation. + new InlinePolicyDecompiler().Decompile(writer, element, contextVar, context); + return; + } + var prefix = PolicyDecompilerContext.GetContextPrefix(element, contextVar); var props = new List(); - var scoreValue = element.Attribute("score-threshold")?.Value ?? "0"; if (context.IsExpression(scoreValue)) props.Add($"ScoreThreshold = {context.HandleValue(scoreValue, "ScoreThreshold", "decimal")}"); + else if (PolicyDecompilerContext.IsNamedValueToken(scoreValue)) + props.Add($"ScoreThreshold = {context.NamedValueCall(scoreValue, "decimal")}"); else - props.Add($"ScoreThreshold = {scoreValue}"); + props.Add($"ScoreThreshold = {scoreValue}m"); context.AddRequiredExprStringProp(props, element, "embeddings-backend-id", "EmbeddingsBackendId"); context.AddRequiredExprStringProp(props, element, "embeddings-backend-auth", "EmbeddingsBackendAuth"); context.AddOptionalBoolExprProp(props, element, "ignore-system-messages", "IgnoreSystemMessages"); - context.AddOptionalUIntProp(props, element, "max-message-count", "MaxMessageCount"); + context.AddOptionalIntProp(props, element, "max-message-count", "MaxMessageCount"); context.AddOptionalStringProp(props, element, "cache-id", "CacheId"); var varyByElements = element.Elements("vary-by").ToList(); diff --git a/src/Core/Decompiling/Policy/SemanticCacheStoreDecompiler.cs b/src/Core/Decompiling/Policy/SemanticCacheStoreDecompiler.cs index 30cc8373..69031ce3 100644 --- a/src/Core/Decompiling/Policy/SemanticCacheStoreDecompiler.cs +++ b/src/Core/Decompiling/Policy/SemanticCacheStoreDecompiler.cs @@ -27,6 +27,15 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po var prefix = PolicyDecompilerContext.GetContextPrefix(element, contextVar); var duration = element.Attribute("duration")?.Value ?? "0"; var durationExpr = context.HandleUintValue(duration, "Duration"); - writer.AppendLine($"{prefix}{_methodName}({durationExpr});"); + var cacheResponse = element.Attribute("cache-response")?.Value; + if (cacheResponse != null) + { + var cacheResponseExpr = context.HandleBoolValue(cacheResponse, "CacheResponse"); + writer.AppendLine($"{prefix}{_methodName}({durationExpr}, {cacheResponseExpr});"); + } + else + { + writer.AppendLine($"{prefix}{_methodName}({durationExpr});"); + } } } diff --git a/src/Core/Decompiling/Policy/SendOneWayRequestDecompiler.cs b/src/Core/Decompiling/Policy/SendOneWayRequestDecompiler.cs index c510b99e..608ca410 100644 --- a/src/Core/Decompiling/Policy/SendOneWayRequestDecompiler.cs +++ b/src/Core/Decompiling/Policy/SendOneWayRequestDecompiler.cs @@ -11,6 +11,12 @@ public class SendOneWayRequestDecompiler : IPolicyDecompiler public void Decompile(CodeWriter writer, XElement element, string contextVar, PolicyDecompilerContext context) { + if (SendRequestDecompilerHelper.HasLiteralCertificateBody(element, context)) + { + new InlinePolicyDecompiler().Decompile(writer, element, contextVar, context); + return; + } + var prefix = PolicyDecompilerContext.GetContextPrefix(element, contextVar); var props = new List(); context.AddOptionalStringProp(props, element, "mode", "Mode"); diff --git a/src/Core/Decompiling/Policy/SendRequestDecompiler.cs b/src/Core/Decompiling/Policy/SendRequestDecompiler.cs index 4ea4f431..7d2aafde 100644 --- a/src/Core/Decompiling/Policy/SendRequestDecompiler.cs +++ b/src/Core/Decompiling/Policy/SendRequestDecompiler.cs @@ -11,6 +11,12 @@ public class SendRequestDecompiler : IPolicyDecompiler public void Decompile(CodeWriter writer, XElement element, string contextVar, PolicyDecompilerContext context) { + if (SendRequestDecompilerHelper.HasLiteralCertificateBody(element, context)) + { + new InlinePolicyDecompiler().Decompile(writer, element, contextVar, context); + return; + } + var prefix = PolicyDecompilerContext.GetContextPrefix(element, contextVar); var props = new List(); context.AddRequiredStringProp(props, element, "response-variable-name", "ResponseVariableName"); diff --git a/src/Core/Decompiling/Policy/SendRequestDecompilerHelper.cs b/src/Core/Decompiling/Policy/SendRequestDecompilerHelper.cs index 6a59a5f8..0e523a22 100644 --- a/src/Core/Decompiling/Policy/SendRequestDecompilerHelper.cs +++ b/src/Core/Decompiling/Policy/SendRequestDecompilerHelper.cs @@ -7,6 +7,12 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Decompiling.Policy; internal static class SendRequestDecompilerHelper { + // A certificate body written as a literal can't be assigned to the byte[] Body property, so the + // request is kept as written, like a standalone authentication-certificate. + public static bool HasLiteralCertificateBody(XElement element, PolicyDecompilerContext context) => + element.Element("authentication-certificate")?.Attribute("body")?.Value is { } body && + !context.IsExpression(body) && !PolicyDecompilerContext.ContainsNamedValueToken(body); + public static void EmitAuthentication(PolicyDecompilerContext context, XElement element, List props) { var authBasic = element.Element("authentication-basic"); @@ -26,6 +32,10 @@ public static void EmitAuthentication(PolicyDecompilerContext context, XElement var certId = authCert.Attribute("certificate-id")?.Value; if (thumb != null) certProps.Add($"Thumbprint = {context.HandleValue(thumb, "Thumbprint")}"); if (certId != null) certProps.Add($"CertificateId = {context.HandleValue(certId, "CertificateId")}"); + var certBody = authCert.Attribute("body")?.Value; + if (certBody != null) certProps.Add($"Body = {context.HandleValue(certBody, "Body", "byte[]")}"); + var certPassword = authCert.Attribute("password")?.Value; + if (certPassword != null) certProps.Add($"Password = {context.HandleValue(certPassword, "Password")}"); props.Add($"Authentication = new CertificateAuthenticationConfig {{ {string.Join(", ", certProps)} }}"); return; } @@ -37,6 +47,8 @@ public static void EmitAuthentication(PolicyDecompilerContext context, XElement var miProps = new List { $"Resource = {context.HandleValue(resource, "Resource")}" }; var clientId = authMi.Attribute("client-id")?.Value; if (clientId != null) miProps.Add($"ClientId = {context.HandleValue(clientId, "ClientId")}"); + context.AddOptionalStringProp(miProps, authMi, "output-token-variable-name", "OutputTokenVariableName"); + context.AddOptionalBoolProp(miProps, authMi, "ignore-error", "IgnoreError"); props.Add($"Authentication = new ManagedIdentityAuthenticationConfig {{ {string.Join(", ", miProps)} }}"); } } diff --git a/src/Core/Decompiling/Policy/SendServiceBusMessageDecompiler.cs b/src/Core/Decompiling/Policy/SendServiceBusMessageDecompiler.cs index da4c2bab..2c041fcd 100644 --- a/src/Core/Decompiling/Policy/SendServiceBusMessageDecompiler.cs +++ b/src/Core/Decompiling/Policy/SendServiceBusMessageDecompiler.cs @@ -18,6 +18,11 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po context.AddOptionalStringProp(props, element, "topic-name", "TopicName"); context.AddOptionalStringProp(props, element, "namespace", "Namespace"); context.AddOptionalStringProp(props, element, "client-id", "ClientId"); + context.AddOptionalStringProp(props, element, "message-id", "MessageId"); + context.AddOptionalStringProp(props, element, "session-id", "SessionId"); + context.AddOptionalStringProp(props, element, "time-to-live", "TimeToLive"); + context.AddOptionalStringProp(props, element, "response-variable-name", "ResponseVariableName"); + context.AddOptionalBoolProp(props, element, "ignore-error", "IgnoreError"); var messagePropertiesEl = element.Element("message-properties"); if (messagePropertiesEl != null) diff --git a/src/Core/Decompiling/Policy/SetBodyDecompiler.cs b/src/Core/Decompiling/Policy/SetBodyDecompiler.cs index 38441a50..13b0717f 100644 --- a/src/Core/Decompiling/Policy/SetBodyDecompiler.cs +++ b/src/Core/Decompiling/Policy/SetBodyDecompiler.cs @@ -3,6 +3,8 @@ using System.Xml.Linq; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Serialization; + namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Decompiling.Policy; public class SetBodyDecompiler : IPolicyDecompiler @@ -13,7 +15,8 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po { var prefix = PolicyDecompilerContext.GetContextPrefix(element, contextVar); - var valueChild = element.Element("value"); + // a value element that is all of the body; one among other content is part of a template + var valueChild = RawXmlContent.IsValueElementBody(element) ? element.Element("value") : null; string content; if (valueChild != null) { diff --git a/src/Core/Decompiling/Policy/SetHeaderDecompiler.cs b/src/Core/Decompiling/Policy/SetHeaderDecompiler.cs index 8a6bcbf5..5ebd1462 100644 --- a/src/Core/Decompiling/Policy/SetHeaderDecompiler.cs +++ b/src/Core/Decompiling/Policy/SetHeaderDecompiler.cs @@ -16,6 +16,15 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po var existsAction = element.Attribute("exists-action")?.Value ?? "override"; var values = element.Elements("value").ToList(); + // An exists-action that isn't one of the known literals (e.g. an expression), or a set without + // values, has no method form; the element is kept as written. + if (existsAction is not ("override" or "append" or "skip" or "delete") || + (existsAction != "delete" && values.Count == 0)) + { + new InlinePolicyDecompiler().Decompile(writer, element, contextVar, context); + return; + } + string methodName = existsAction switch { "append" => "AppendHeader", diff --git a/src/Core/Decompiling/Policy/SetQueryParameterDecompiler.cs b/src/Core/Decompiling/Policy/SetQueryParameterDecompiler.cs index 0cfd8019..fd231818 100644 --- a/src/Core/Decompiling/Policy/SetQueryParameterDecompiler.cs +++ b/src/Core/Decompiling/Policy/SetQueryParameterDecompiler.cs @@ -16,6 +16,15 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po var existsAction = element.Attribute("exists-action")?.Value ?? "override"; var values = element.Elements("value").ToList(); + // An exists-action that isn't one of the known literals (e.g. an expression), or a set without + // values, has no method form; the element is kept as written. + if (existsAction is not ("override" or "append" or "skip" or "delete") || + (existsAction != "delete" && values.Count == 0)) + { + new InlinePolicyDecompiler().Decompile(writer, element, contextVar, context); + return; + } + string methodName = existsAction switch { "append" => "AppendQueryParameter", diff --git a/src/Core/Decompiling/Policy/TokenLimitDecompiler.cs b/src/Core/Decompiling/Policy/TokenLimitDecompiler.cs index 04f8c78a..cb55a5cc 100644 --- a/src/Core/Decompiling/Policy/TokenLimitDecompiler.cs +++ b/src/Core/Decompiling/Policy/TokenLimitDecompiler.cs @@ -30,7 +30,16 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po context.AddRequiredExprStringProp(props, element, "counter-key", "CounterKey"); context.AddRequiredBoolExprProp(props, element, "estimate-prompt-tokens", "EstimatePromptTokens"); context.AddOptionalIntProp(props, element, "tokens-per-minute", "TokensPerMinute"); - context.AddOptionalIntProp(props, element, "token-quota", "TokenQuota"); + // an expression or a named value for token-quota has to return a long + if (element.Attribute("token-quota")?.Value is { } quota) + { + var quotaValue = context.IsExpression(quota) + ? context.CreateExpressionMethodReference(quota, "TokenQuota", "long") + : PolicyDecompilerContext.IsNamedValueToken(quota) + ? context.NamedValueCall(quota, "long") + : context.HandleIntValue(quota, "TokenQuota"); + props.Add($"TokenQuota = {quotaValue}"); + } context.AddOptionalStringProp(props, element, "token-quota-period", "TokenQuotaPeriod"); context.AddOptionalStringProp(props, element, "retry-after-header-name", "RetryAfterHeaderName"); context.AddOptionalStringProp(props, element, "retry-after-variable-name", "RetryAfterVariableName"); diff --git a/src/Core/Decompiling/Policy/ValidateAzureAdTokenDecompiler.cs b/src/Core/Decompiling/Policy/ValidateAzureAdTokenDecompiler.cs index 6ee0f76b..b5e6ef92 100644 --- a/src/Core/Decompiling/Policy/ValidateAzureAdTokenDecompiler.cs +++ b/src/Core/Decompiling/Policy/ValidateAzureAdTokenDecompiler.cs @@ -15,6 +15,7 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po var props = new List(); context.AddRequiredExprStringProp(props, element, "tenant-id", "TenantId"); + context.AddOptionalStringProp(props, element, "authentication-endpoint", "AuthenticationEndpoint"); context.AddOptionalStringProp(props, element, "header-name", "HeaderName"); context.AddOptionalStringProp(props, element, "query-parameter-name", "QueryParameterName"); context.AddOptionalStringProp(props, element, "token-value", "TokenValue"); diff --git a/src/Core/Decompiling/Policy/ValidateContentDecompiler.cs b/src/Core/Decompiling/Policy/ValidateContentDecompiler.cs index 7e145882..b79d6719 100644 --- a/src/Core/Decompiling/Policy/ValidateContentDecompiler.cs +++ b/src/Core/Decompiling/Policy/ValidateContentDecompiler.cs @@ -54,11 +54,7 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po typeProps.Add($"From = {PolicyDecompilerContext.Literal(from)}"); } - var when = t.Attribute("when")?.Value; - if (when != null) - { - typeProps.Add($"When = {when.ToLower()}"); - } + context.AddOptionalBoolProp(typeProps, t, "when", "When"); return $"new ContentTypeMap {{ {string.Join(", ", typeProps)} }}"; }); @@ -104,17 +100,10 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po contentProps.Add($"SchemaRef = {PolicyDecompilerContext.Literal(schemaRef)}"); } - var allowAdditional = c.Attribute("allow-additional-properties")?.Value; - if (allowAdditional != null) - { - contentProps.Add($"AllowAdditionalProperties = {allowAdditional.ToLower()}"); - } - - var caseInsensitive = c.Attribute("case-insensitive-property-names")?.Value; - if (caseInsensitive != null) - { - contentProps.Add($"CaseInsensitivePropertyNames = {caseInsensitive.ToLower()}"); - } + context.AddOptionalBoolProp(contentProps, c, "allow-additional-properties", + "AllowAdditionalProperties"); + context.AddOptionalBoolProp(contentProps, c, "case-insensitive-property-names", + "CaseInsensitivePropertyNames"); return $"new ValidateContent {{ {string.Join(", ", contentProps)} }}"; }); diff --git a/src/Core/Decompiling/Policy/ValidateHeadersDecompiler.cs b/src/Core/Decompiling/Policy/ValidateHeadersDecompiler.cs index 65cd0b42..baef41ae 100644 --- a/src/Core/Decompiling/Policy/ValidateHeadersDecompiler.cs +++ b/src/Core/Decompiling/Policy/ValidateHeadersDecompiler.cs @@ -17,6 +17,21 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po context.AddRequiredStringProp(props, element, "unspecified-header-action", "UnspecifiedHeaderAction"); context.AddOptionalStringProp(props, element, "errors-variable-name", "ErrorsVariableName"); + var headers = element.Elements("header").ToList(); + if (headers.Count > 0) + { + var headerConfigs = headers.Select(h => + { + var headerProps = new List + { + $"Name = {PolicyDecompilerContext.Literal(h.Attribute("name")?.Value ?? "")}" + }; + context.AddOptionalStringProp(headerProps, h, "action", "Action"); + return $"new ValidateHeader {{ {string.Join(", ", headerProps)} }}"; + }); + props.Add($"Headers = new ValidateHeader[] {{ {string.Join(", ", headerConfigs)} }}"); + } + PolicyDecompilerContext.EmitConfigCall(writer, prefix, "ValidateHeaders", "ValidateHeadersConfig", props); } } diff --git a/src/Core/Decompiling/Policy/ValidateParametersDecompiler.cs b/src/Core/Decompiling/Policy/ValidateParametersDecompiler.cs index 26feb8db..d3fa5b51 100644 --- a/src/Core/Decompiling/Policy/ValidateParametersDecompiler.cs +++ b/src/Core/Decompiling/Policy/ValidateParametersDecompiler.cs @@ -17,6 +17,42 @@ public void Decompile(CodeWriter writer, XElement element, string contextVar, Po context.AddRequiredStringProp(props, element, "unspecified-parameter-action", "UnspecifiedParameterAction"); context.AddOptionalStringProp(props, element, "errors-variable-name", "ErrorsVariableName"); + AddParameterGroup(context, props, element, "headers", "Headers", "ValidateHeaderParameters"); + AddParameterGroup(context, props, element, "query", "Query", "ValidateQueryParameters"); + AddParameterGroup(context, props, element, "path", "Path", "ValidatePathParameters"); + PolicyDecompilerContext.EmitConfigCall(writer, prefix, "ValidateParameters", "ValidateParametersConfig", props); } + + private static void AddParameterGroup( + PolicyDecompilerContext context, List props, XElement element, + string xmlName, string propName, string configTypeName) + { + var group = element.Element(xmlName); + if (group == null) + { + return; + } + + var groupProps = new List(); + context.AddOptionalStringProp(groupProps, group, "specified-parameter-action", "SpecifiedParameterAction"); + context.AddOptionalStringProp(groupProps, group, "unspecified-parameter-action", "UnspecifiedParameterAction"); + + var parameters = group.Elements("parameter").ToList(); + if (parameters.Count > 0) + { + var parameterConfigs = parameters.Select(p => + { + var parameterProps = new List + { + $"Name = {PolicyDecompilerContext.Literal(p.Attribute("name")?.Value ?? "")}" + }; + context.AddOptionalStringProp(parameterProps, p, "action", "Action"); + return $"new ValidateParameter {{ {string.Join(", ", parameterProps)} }}"; + }); + groupProps.Add($"Parameters = new ValidateParameter[] {{ {string.Join(", ", parameterConfigs)} }}"); + } + + props.Add($"{propName} = new {configTypeName} {{ {string.Join(", ", groupProps)} }}"); + } } diff --git a/src/Core/Decompiling/PolicyDecompiler.cs b/src/Core/Decompiling/PolicyDecompiler.cs index e4d610ef..2902d010 100644 --- a/src/Core/Decompiling/PolicyDecompiler.cs +++ b/src/Core/Decompiling/PolicyDecompiler.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Globalization; using System.Reflection; using System.Text; using System.Text.RegularExpressions; @@ -8,6 +9,7 @@ using System.Xml.Linq; using Microsoft.Azure.ApiManagement.PolicyToolkit.Decompiling.Policy; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Serialization; namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Decompiling; @@ -47,8 +49,7 @@ public string DecompileDocument( { _context.Reset(); - var preprocessed = PreprocessXml(xml); - var doc = XDocument.Parse(preprocessed); + var doc = ParsePolicyXml(xml, options?.PolicyFormat ?? PolicyFormat.RawXml); var policies = doc.Root ?? throw new ArgumentException("Invalid XML: missing root element."); @@ -89,7 +90,7 @@ public string DecompileDocument( writer.DecreaseIndent(); writer.AppendLine("}"); - return writer.ToString(); + return RequiredUsings.AddTo(writer.ToString(), _context.ExpressionMethods); } public string DecompileFragment( @@ -101,8 +102,7 @@ public string DecompileFragment( { _context.Reset(); - var preprocessed = PreprocessXml(xml); - var doc = XDocument.Parse(preprocessed); + var doc = ParsePolicyXml(xml, options?.PolicyFormat ?? PolicyFormat.RawXml); var fragment = doc.Root ?? throw new ArgumentException("Invalid XML: missing root element."); @@ -125,7 +125,7 @@ public string DecompileFragment( writer.DecreaseIndent(); writer.AppendLine("}"); - return writer.ToString(); + return RequiredUsings.AddTo(writer.ToString(), _context.ExpressionMethods); } #region Setup and Structure @@ -259,6 +259,30 @@ private static void EmitMultiLineBody(CodeWriter writer, string body) #region XML Preprocessing + /// + /// Parses policy XML into a document. Whitespace between elements is dropped, except inside a + /// set-body that carries markup (e.g. a liquid template), where it is part of the body content. + /// + private static XDocument ParsePolicyXml(string xml, PolicyFormat format) + { + var doc = XDocument.Parse(PreprocessXml(xml, format), LoadOptions.PreserveWhitespace); + var insignificant = doc.DescendantNodes() + .OfType() + .Where(text => text is not XCData && string.IsNullOrWhiteSpace(text.Value) && !IsInMarkupBody(text)) + .ToList(); + foreach (var text in insignificant) + { + text.Remove(); + } + + return doc; + } + + // Whitespace in a set-body written as markup, liquid or not, is body content: a SOAP body is kept as written. + private static bool IsInMarkupBody(XText text) => + text.Ancestors("set-body").FirstOrDefault() is { } body && !RawXmlContent.IsValueElementBody(body) && + (body.HasElements || RawXmlContent.IsMarkupBody(body)); + /// /// Preprocesses APIM policy XML to handle C# expressions that contain characters /// invalid in raw XML (unescaped quotes, angle brackets, ampersands inside @(...) and @{...}). @@ -268,22 +292,28 @@ private static void EmitMultiLineBody(CodeWriter writer, string body) /// producing output that is parseable by XDocument.Parse() while preserving /// expression content verbatim in the parsed DOM. /// - public static string PreprocessXml(string xml) + public static string PreprocessXml(string xml) => PreprocessXml(xml, PolicyFormat.RawXml); + + /// + /// Preprocesses policy text of the given format. As rawxml every expression is taken out before the text + /// is parsed and put back as it was written; as xml the text has to be well-formed and is parsed as it is. + /// + public static string PreprocessXml(string xml, PolicyFormat format) { - // Fast path: if the XML parses as-is, return it - try + if (!Enum.IsDefined(format)) { - XDocument.Parse(xml, LoadOptions.PreserveWhitespace); - return xml; + throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown policy format."); } - catch (XmlException) + + if (format == PolicyFormat.Xml) { - // Fall through to expression extraction + XDocument.Parse(xml, LoadOptions.PreserveWhitespace); + return PreserveAttributeLineBreaks(xml); } // Scan for all @(...) and @{...} expression spans var spans = CollectExpressionSpans(xml); - if (spans.Count == 0) return xml; + if (spans.Count == 0) return PreserveAttributeLineBreaks(xml); // Replace expressions with XML-safe placeholders var exprMap = new Dictionary(spans.Count, StringComparer.Ordinal); @@ -337,56 +367,110 @@ public static string PreprocessXml(string xml) // in expressions, making the output valid and re-parseable XML. if (exprMap.Count > 0 && doc.Root != null) { - RestorePlaceholders(doc.Root, exprMap); + RestorePlaceholders(doc.Root, exprMap, format); } return doc.ToString(SaveOptions.DisableFormatting); } + /// + /// XML parsing normalizes line breaks and tabs in attribute values to spaces, which would join the lines + /// of a multi-line expression (so a line comment would swallow the code after it). When an attribute + /// spans lines, the document is re-read without normalization and written back with the line breaks + /// as character references, which survive parsing. + /// + private static string PreserveAttributeLineBreaks(string xml) + { + if (xml.IndexOfAny(['\n', '\r', '\t']) < 0) + { + return xml; + } + + XDocument doc; + try + { + using var sr = new StringReader(xml); + using var xr = new XmlTextReader(sr) + { + DtdProcessing = DtdProcessing.Prohibit, + Normalization = false, + WhitespaceHandling = WhitespaceHandling.All + }; + doc = XDocument.Load(xr, LoadOptions.PreserveWhitespace); + } + catch (XmlException) + { + return xml; + } + + var spansLines = doc.Descendants() + .SelectMany(element => element.Attributes()) + .Any(attribute => attribute.Value.IndexOfAny(['\n', '\r', '\t']) >= 0); + return spansLines ? doc.ToString(SaveOptions.DisableFormatting) : xml; + } + /// /// Walks the DOM tree and replaces placeholder tokens with original expression text - /// in attribute values and leaf-element text content. - /// Decodes XML entities in restored expressions since the original text was extracted - /// from raw XML before entity decoding. + /// in attribute values, text, CDATA sections and comments. An expression a value begins with was written as + /// code and is kept as it was; one anywhere else was XML-escaped text and its entities are decoded. /// - private static void RestorePlaceholders(XElement root, Dictionary map) + private static void RestorePlaceholders(XElement root, Dictionary map, PolicyFormat format) { foreach (var element in root.DescendantsAndSelf()) { foreach (var attribute in element.Attributes().ToList()) { - var value = attribute.Value; - foreach (var kvp in map) - { - if (value.Contains(kvp.Key, StringComparison.Ordinal)) - { - value = value.Replace(kvp.Key, DecodeXmlEntities(kvp.Value), StringComparison.Ordinal); - } - } - - if (!ReferenceEquals(value, attribute.Value)) - { - attribute.Value = value; - } + attribute.Value = Restore(attribute.Value, map, format, IsInLiquidMarkup(element)); } + } - if (!element.HasElements && !string.IsNullOrEmpty(element.Value)) + // Text is restored node by node, so text next to child elements is covered and a CDATA section or a + // comment stays what it is. Their content isn't parsed, so an entity in it is not decoded. + foreach (var node in root.DescendantNodes().ToList()) + { + switch (node) { - var inner = element.Value; - foreach (var kvp in map) - { - if (inner.Contains(kvp.Key, StringComparison.Ordinal)) - { - inner = inner.Replace(kvp.Key, DecodeXmlEntities(kvp.Value), StringComparison.Ordinal); - } - } + case XCData cdata: + cdata.Value = Restore(cdata.Value, map, format: null); + break; + case XText text: + text.Value = Restore(text.Value, map, format, IsInLiquidMarkup(text.Parent)); + break; + case XComment comment: + comment.Value = Restore(comment.Value, map, format: null); + break; + } + } + } - if (!ReferenceEquals(inner, element.Value)) - { - element.Value = inner; - } + private static readonly Regex Entity = + new(@"&(" + RawXmlContent.Entities + @");", RegexOptions.Compiled); + + // The markup of a liquid body is template text: an @(...) in it is XML-escaped like the rest, in rawxml too. + private static bool IsInLiquidMarkup(XElement? element) => + element is not null && element.AncestorsAndSelf().Any(RawXmlContent.IsMarkupBody); + + // Without a format the content is not parsed (a CDATA section, a comment) and nothing is decoded. A value + // that begins with an expression was written as code and is kept; an @(...) further into a value, or + // anywhere in the markup of a liquid body, is text like the rest of it, which is XML-escaped in rawxml too, + // and is decoded. + private static string Restore(string value, Dictionary map, PolicyFormat? format, bool isText = false) + { + var start = value.TrimStart(); + var decode = format is not null && + (isText || !map.Keys.Any(key => start.StartsWith(key, StringComparison.Ordinal))); + foreach (var kvp in map) + { + if (value.Contains(kvp.Key, StringComparison.Ordinal)) + { + value = value.Replace( + kvp.Key, + decode ? DecodeXmlEntities(kvp.Value) : kvp.Value, + StringComparison.Ordinal); } } + + return value; } /// @@ -399,12 +483,35 @@ private static string DecodeXmlEntities(string text) if (!text.Contains('&')) return text; - return text - .Replace(""", "\"") - .Replace("'", "'") - .Replace("<", "<") - .Replace(">", ">") - .Replace("&", "&"); + // one pass, so the text an entity stands for is not decoded again: &lt; is < + return Entity.Replace(text, match => match.Groups[1].Value switch + { + "quot" => "\"", + "apos" => "'", + "lt" => "<", + "gt" => ">", + "amp" => "&", + var reference => DecodeCharacterReference(reference) ?? match.Value + }); + } + + // #65 or #x41; a number that is no character is left as it was written + private static string? DecodeCharacterReference(string reference) + { + var isHex = reference[1] == 'x'; + if (!int.TryParse( + reference.Substring(isHex ? 2 : 1), + isHex ? NumberStyles.AllowHexSpecifier : NumberStyles.None, + CultureInfo.InvariantCulture, + out var code) || + code is < 0 or > 0x10FFFF or (>= 0xD800 and <= 0xDFFF) || + // a character XML doesn't allow, such as a control character, can't be written back to the document + code <= char.MaxValue && !XmlConvert.IsXmlChar((char)code)) + { + return null; + } + + return char.ConvertFromUtf32(code); } /// @@ -412,7 +519,7 @@ private static string DecodeXmlEntities(string text) /// Uses a state machine that correctly handles strings, comments, char literals, /// and nested braces within C# expressions. /// - private static List<(int start, int length)> CollectExpressionSpans(string text) + internal static List<(int start, int length)> CollectExpressionSpans(string text) { var spans = new List<(int, int)>(); for (int i = 0; i < text.Length - 1; i++) @@ -440,7 +547,7 @@ private static string DecodeXmlEntities(string text) /// Handles nested braces, string literals (regular, verbatim, interpolated), /// char literals, line comments, and block comments. /// - private static bool TryScanBalanced(string text, int startAt, char open, char close, out int length) + internal static bool TryScanBalanced(string text, int startAt, char open, char close, out int length) { length = 0; int i = startAt; diff --git a/src/Core/Decompiling/PolicyDecompilerContext.cs b/src/Core/Decompiling/PolicyDecompilerContext.cs index d4a0b59d..3ae501c3 100644 --- a/src/Core/Decompiling/PolicyDecompilerContext.cs +++ b/src/Core/Decompiling/PolicyDecompilerContext.cs @@ -5,6 +5,8 @@ using System.Text.RegularExpressions; using System.Xml.Linq; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Serialization; + namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Decompiling; public record ExpressionMethodInfo( @@ -77,9 +79,23 @@ public static void EmitSimpleCall(CodeWriter writer, XElement element, string co #region Expression Handling - public bool IsExpression(string value) => - (value.StartsWith("@(") && value.EndsWith(")")) || - (value.StartsWith("@{") && value.EndsWith("}")); + public bool IsExpression(string value) => IsSingleExpression(value); + + /// + /// True when the whole value is one balanced @(...) or @{...} expression. A value such as + /// "@(a)-@(b)" is text with embedded expressions and has to be kept as a string. + /// + public static bool IsSingleExpression(string value) + { + if (value.Length < 3 || value[0] != '@' || (value[1] != '(' && value[1] != '{')) + { + return false; + } + + char open = value[1]; + char close = open == '(' ? ')' : '}'; + return PolicyDecompiler.TryScanBalanced(value, 0, open, close, out int length) && length == value.Length; + } public string HandleValue(string value, string suggestedName, string returnType = "string") { @@ -108,6 +124,10 @@ public string HandleIntValue(string value, string suggestedName) { return NamedValueCall(value, "int"); } + if (ContainsNamedValueToken(value)) + { + return CreateNamedValueStringExpression(value, suggestedName); + } if (TryParseIsoDuration(value, out var seconds)) { return seconds.ToString(); @@ -125,6 +145,10 @@ public string HandleBoolValue(string value, string suggestedName) { return NamedValueCall(value, "bool"); } + if (ContainsNamedValueToken(value)) + { + return CreateNamedValueStringExpression(value, suggestedName); + } return value.ToLowerInvariant(); } @@ -136,7 +160,11 @@ public string HandleUintValue(string value, string suggestedName) } if (IsNamedValueToken(value)) { - return NamedValueCall(value, "int"); + return NamedValueCall(value, "uint"); + } + if (ContainsNamedValueToken(value)) + { + return CreateNamedValueStringExpression(value, suggestedName); } return value; } @@ -151,36 +179,38 @@ public string HandleDoubleValue(string value, string suggestedName) { return NamedValueCall(value, "double"); } + if (ContainsNamedValueToken(value)) + { + return CreateNamedValueStringExpression(value, suggestedName); + } return value; } public string HandleConditionExpression(string value, string suggestedName) { value = value.Trim(); - if (value.StartsWith("@(") && value.EndsWith(")")) + if (IsExpression(value)) { var body = value.Substring(2, value.Length - 3); - return CreateExpressionMethod(body, suggestedName, "bool", false); + return CreateExpressionMethod(body, suggestedName, "bool", value[1] == '{'); } - if (value.StartsWith("@{") && value.EndsWith("}")) + if (IsNamedValueToken(value)) { - var body = value.Substring(2, value.Length - 3); - return CreateExpressionMethod(body, suggestedName, "bool", true); + return NamedValueCall(value, "bool"); + } + if (ContainsNamedValueToken(value)) + { + return CreateNamedValueStringExpression(value, suggestedName); } return value.ToLowerInvariant(); } public string CreateExpressionMethodReference(string value, string suggestedName, string returnType) { - if (value.StartsWith("@(") && value.EndsWith(")")) - { - var body = value.Substring(2, value.Length - 3); - return CreateExpressionMethod(body, suggestedName, returnType, false); - } - if (value.StartsWith("@{") && value.EndsWith("}")) + if (IsExpression(value)) { var body = value.Substring(2, value.Length - 3); - return CreateExpressionMethod(body, suggestedName, returnType, true); + return CreateExpressionMethod(body, suggestedName, returnType, value[1] == '{'); } return Literal(value); } @@ -195,17 +225,37 @@ public string CreateExpressionMethod(string body, string suggestedName, string r public string GenerateUniqueMethodName(string suggestedName) { - var name = $"{suggestedName}{_expressionCounter}"; + var name = $"{SanitizeIdentifier(suggestedName)}{_expressionCounter}"; _expressionCounter++; return name; } + /// + /// Makes a name usable as a C# identifier: characters that are not letters, digits or underscores + /// are dropped, and a name that is empty or starts with a digit gets an underscore prefix. + /// + public static string SanitizeIdentifier(string name) + { + var sb = new StringBuilder(name.Length); + foreach (char c in name) + { + if (char.IsLetterOrDigit(c) || c == '_') sb.Append(c); + } + if (sb.Length == 0 || char.IsDigit(sb[0])) sb.Insert(0, '_'); + return sb.ToString(); + } + public string CreateNamedValueStringExpression(string value, string suggestedName) { var parts = new List(); int pos = 0; foreach (Match match in NamedValueTokenPattern.Matches(value)) { + if (!IsValidTokenName(match.Groups[1].Value)) + { + continue; + } + if (match.Index > pos) { parts.Add(Literal(value.Substring(pos, match.Index - pos))); @@ -251,10 +301,11 @@ public static bool TryParseIsoDuration(string value, out long seconds) } public static bool IsNamedValueToken(string value) => - NamedValueTokenPattern.IsMatch(value) && value.StartsWith("{{") && value.EndsWith("}}"); + NamedValueTokenPattern.Match(value) is { Success: true } match && match.Length == value.Length && + IsValidTokenName(match.Groups[1].Value); public static bool ContainsNamedValueToken(string value) => - NamedValueTokenPattern.IsMatch(value); + NamedValueTokenPattern.Matches(value).Any(match => IsValidTokenName(match.Groups[1].Value)); public static string ReplaceNamedValueTokens(string body) { @@ -443,11 +494,12 @@ public static bool IsValidTokenName(string name) // A {{name}} token in a string, starting at the match position; names have API Management's name characters. private static readonly Regex StringTokenPattern = new( - @"\G\{\{\s*([A-Za-z0-9_.\-]+)\s*\}\}", + @"\G\{\{\s*(" + RazorCodeFormatter.NamedValueName + @")\s*\}\}", RegexOptions.Compiled); + // A {{name}} token in a value; a name is one with IsValidTokenName, as in a string public static readonly Regex NamedValueTokenPattern = new( - @"\{\{([A-Za-z][A-Za-z0-9_.\-]*)\}\}", + @"\{\{(" + RazorCodeFormatter.NamedValueName + @")\}\}", RegexOptions.Compiled); #endregion @@ -591,7 +643,7 @@ public void AddOptionalUIntProp(List props, XElement element, string xml var value = element.Attribute(xmlAttr)?.Value; if (value != null) { - props.Add($"{propName} = {HandleIntValue(value, propName)}"); + props.Add($"{propName} = {HandleUintValue(value, propName)}"); } } @@ -672,7 +724,8 @@ public string BuildInvokeRequestHeaderConfigString(XElement headerElement) public string BuildBodyConfigProperty(XElement bodyElement) { - var valueChild = bodyElement.Element("value"); + // a value element that is all of the body; one among other content is part of a template + var valueChild = RawXmlContent.IsValueElementBody(bodyElement) ? bodyElement.Element("value") : null; string content; if (valueChild != null) content = GetElementTextOrValue(valueChild); @@ -708,13 +761,90 @@ public static string GetContextPrefix(XElement element, string contextVar) public static string GetElementText(XElement element) { + if (RawXmlContent.IsMarkupBody(element)) + { + // The markup of a liquid body is template text and stays XML: an @(...) in it isn't an expression, + // and written raw its reserved characters would stop the body from being read back as markup. A + // CDATA section stays one too: the gateway returns it differently from the text it holds. + return string.Concat(element.Nodes().Select(node => node.ToString(SaveOptions.DisableFormatting))); + } if (element.Nodes().All(n => n is XText)) { return string.Concat(element.Nodes().OfType().Select(t => t.Value)); } + if (element.HasElements) + { + // Other markup content (e.g. a SOAP body) is kept as written instead of flattened to its text. + return GetInnerRawXml(element); + } return element.Value; } + /// + /// Serializes an element as raw policy text: like XML, but policy expressions are written verbatim + /// (unescaped quotes, angle brackets and ampersands), as in API Management's rawxml format. + /// + public static string ToRawXml(XElement element) + { + var clone = new XElement(element); + var expressions = new Dictionary(StringComparer.Ordinal); + + foreach (var node in clone.DescendantsAndSelf()) + { + foreach (var attribute in node.Attributes().Where(a => !a.IsNamespaceDeclaration).ToList()) + { + attribute.Value = ProtectExpressions(attribute.Value, expressions); + } + + foreach (var text in node.Nodes().OfType().Where(t => t is not XCData).ToList()) + { + text.Value = ProtectExpressions(text.Value, expressions); + } + } + + var xml = clone.ToString(SaveOptions.DisableFormatting); + foreach (var (placeholder, expression) in expressions) + { + xml = xml.Replace(placeholder, expression, StringComparison.Ordinal); + } + + return xml; + } + + public static string GetInnerRawXml(XElement element) + { + const string wrapperName = "apim-raw-content"; + var xml = ToRawXml(new XElement(wrapperName, element.Nodes())); + var start = $"<{wrapperName}>"; + var end = $""; + return xml.StartsWith(start, StringComparison.Ordinal) && xml.EndsWith(end, StringComparison.Ordinal) + ? xml.Substring(start.Length, xml.Length - start.Length - end.Length) + : string.Empty; + } + + private static string ProtectExpressions(string value, Dictionary expressions) + { + var spans = PolicyDecompiler.CollectExpressionSpans(value); + if (spans.Count == 0) + { + return value; + } + + var sb = new StringBuilder(value.Length); + int cursor = 0; + foreach (var (start, length) in spans) + { + sb.Append(value, cursor, start - cursor); + var placeholder = $"__APIM_RAW_EXPR_{expressions.Count}__"; + expressions[placeholder] = value.Substring(start, length); + sb.Append(placeholder); + cursor = start + length; + } + + sb.Append(value, cursor, value.Length - cursor); + return sb.ToString(); + } + public static string GetElementTextOrValue(XElement element) { return element.Value; @@ -761,7 +891,8 @@ public static string ToPascalCase(string kebabOrSnakeCase) { if (string.IsNullOrEmpty(kebabOrSnakeCase)) return "Value"; - var parts = kebabOrSnakeCase.Split('-', '_', '.'); + // Any character that can't be part of an identifier separates words. + var parts = Regex.Split(kebabOrSnakeCase, @"[^\p{L}\p{Nd}]+"); var sb = new StringBuilder(); foreach (var part in parts) { diff --git a/src/Core/Decompiling/RequiredUsings.cs b/src/Core/Decompiling/RequiredUsings.cs new file mode 100644 index 00000000..7f7249e9 --- /dev/null +++ b/src/Core/Decompiling/RequiredUsings.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Decompiling; + +/// +/// Policy expressions name .NET types without a namespace (JObject, Regex, Encoding), which API Management +/// resolves itself. The generated C# needs the using directives for the ones the expressions use. +/// +internal static class RequiredUsings +{ + private static readonly (string Namespace, string[] Names)[] Namespaces = + [ + ("System", + [ + "Convert", "Guid", "DateTime", "DateTimeOffset", "DateTimeKind", "TimeSpan", "TimeZoneInfo", "Math", + "String", "StringComparison", "StringSplitOptions", "Uri", "UriKind", "UriPartial", "Random", "Enum", + "Exception", "Array", "Tuple", "Func", "Action", "Nullable", "Int32", "Int64", "Boolean", "Double", + "Decimal", "Byte", "Char", "Object", "DayOfWeek", "Base64FormattingOptions", "BitConverter", "Version", + "Int16", "UInt16", "UInt32", "UInt64", "SByte", "Single", "MidpointRounding", "StringComparer", "TimeZone" + ]), + ("System.Collections.Generic", + [ + "List", "Dictionary", "HashSet", "KeyValuePair", "IEnumerable", "IDictionary", "IList", "ICollection", + "IReadOnlyDictionary", "IReadOnlyList", "IReadOnlyCollection", "Queue", "Stack", "SortedDictionary", + "LinkedList", "ISet", "IEnumerator" + ]), + ("System.Globalization", ["CultureInfo", "DateTimeStyles", "NumberStyles"]), + ("System.IO", ["Stream", "MemoryStream", "StreamReader", "StreamWriter", "StringReader", "StringWriter"]), + ("System.Linq", + [ + "Enumerable", "Where", "Select", "SelectMany", "Any", "All", "First", "FirstOrDefault", "Last", + "LastOrDefault", "Single", "SingleOrDefault", "Count", "Sum", "Min", "Max", "Average", "OrderBy", + "OrderByDescending", "ThenBy", "GroupBy", "Distinct", "ToList", "ToArray", "ToDictionary", "Skip", + "Take", "Contains", "Concat", "Union", "Intersect", "Except", "Aggregate", "Cast", "OfType", "Zip", + "Reverse", "ElementAt", "DefaultIfEmpty", "SequenceEqual", "ToLookup", + // a query expression: from x in y select z + "from" + ]), + ("System.Net", ["IPAddress", "WebUtility", "HttpStatusCode"]), + ("System.Security.Claims", ["Claim"]), + ("System.Security.Cryptography", + [ + "SHA1", "SHA256", "SHA384", "SHA512", "MD5", "HMACSHA1", "HMACSHA256", "HMACSHA384", "HMACSHA512", + "HMAC", "HashAlgorithm", "RSA", "RSAParameters", "RSAEncryptionPadding", "RSASignaturePadding", + "HashAlgorithmName", "Aes", "SymmetricAlgorithm", "CipherMode", "PaddingMode", "DSA", "KeyedHashAlgorithm", + "SHA1Managed", "SHA256Managed", "SHA384Managed", "SHA512Managed", "HMACMD5", "RNGCryptoServiceProvider", + "AsymmetricAlgorithm", "Oid" + ]), + ("System.Security.Cryptography.X509Certificates", + [ + "X509Certificate2", "X509Certificate", "X509ContentType", "X509NameType", "X500DistinguishedName", + "PublicKey", + // extension methods of RSACertificateExtensions + "GetRSAPrivateKey", "GetRSAPublicKey" + ]), + ("System.Text", ["Encoding", "StringBuilder"]), + ("System.Text.RegularExpressions", + [ + "Regex", "RegexOptions", "Match", "MatchCollection", "Group", "GroupCollection", "Capture", + "CaptureCollection" + ]), + ("System.Web", ["HttpUtility"]), + ("System.Xml", ["XmlDocument", "XmlNode", "XmlElement", "XmlAttribute", "XmlNodeList", "XmlConvert", "XmlNodeType"]), + ("System.Xml.Linq", + [ + "XElement", "XDocument", "XAttribute", "XNode", "XName", "XNamespace", "XText", "XComment", + "XContainer", "XCData", "XDeclaration", "XProcessingInstruction", "XObject", "SaveOptions", "LoadOptions", + "XDocumentType", "XNodeDocumentOrderComparer", "XNodeEqualityComparer" + ]), + ("Newtonsoft.Json", + [ + "JsonConvert", "Formatting", "JsonSerializerSettings", "NullValueHandling", "DateParseHandling", + "DateFormatHandling", "DateTimeZoneHandling", "DefaultValueHandling", "MissingMemberHandling", + "ReferenceLoopHandling", "JsonException", "JsonToken" + ]), + ("Newtonsoft.Json.Linq", + [ + "JObject", "JArray", "JToken", "JValue", "JProperty", "JContainer", "JConstructor", "JRaw", + "JTokenType" + ]), + ]; + + /// + /// The namespaces whose types or LINQ methods appear in the expressions, in the order they are written. + /// + public static IReadOnlyList For(IEnumerable methods) + { + var identifiers = Identifiers(methods); + return Namespaces + .Where(entry => entry.Names.Any(identifiers.Contains)) + .Select(entry => entry.Namespace) + .ToList(); + } + + private static HashSet Identifiers(IEnumerable methods) => + methods + .SelectMany(method => SyntaxFactory.ParseTokens(method.Body)) + .Where(token => token.IsKind(SyntaxKind.IdentifierToken)) + .Select(token => token.ValueText) + .ToHashSet(); + + /// + /// Puts the using directives the expressions need in front of the generated document. + /// + public static string AddTo(string document, IEnumerable methods) + { + methods = methods.ToList(); + var namespaces = For(methods); + var usings = string.Concat(namespaces.Select(name => $"using {name};{Environment.NewLine}")); + + // System.Net has an Authorization type of its own; in a policy expression the name is the gateway's. + if (namespaces.Contains("System.Net") && Identifiers(methods).Contains("Authorization")) + { + usings += "using Authorization = Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions.Authorization;" + + Environment.NewLine; + } + + return usings.Length == 0 ? document : usings + Environment.NewLine + document; + } +} diff --git a/src/Core/IO/FileUtils.cs b/src/Core/IO/FileUtils.cs index a87c2333..38f66131 100644 --- a/src/Core/IO/FileUtils.cs +++ b/src/Core/IO/FileUtils.cs @@ -33,10 +33,11 @@ public class Data public static string WriteToFile(Data data) { - // In xml format policy expressions are XML-encoded during serialization, so the - // C# must be reflowed on the document tree (raw text) before it is written; - // reformatting the encoded string would reparse entities like < as C#. - if (data.FormatCode && !data.RawXml) + // The C# of policy expressions is reflowed on the document tree (raw text) before it is + // written; reformatting the serialized string would reparse entities like < as C#. + // That applies to the xml format, and in the rawxml format to every value which is + // XML-encoded because it is not a single expression. + if (data.FormatCode) { RazorCodeFormatter.FormatExpressions(data.Element); } @@ -50,12 +51,6 @@ public static string WriteToFile(Data data) } var xml = codeBuilder.ToString(); - // In rawxml format expressions are written verbatim, so the C# can be reflowed - // on the serialized string. - if (data.FormatCode && data.RawXml) - { - xml = RazorCodeFormatter.Format(xml); - } var fileRelativePath = Path.GetDirectoryName(Path.GetRelativePath(data.SourceFolder, data.SourceFilePath))!; var targetFolder = Path.Combine(data.OutputFolder, fileRelativePath); diff --git a/src/Core/IO/PathUtils.cs b/src/Core/IO/PathUtils.cs index a9102303..791ee4be 100644 --- a/src/Core/IO/PathUtils.cs +++ b/src/Core/IO/PathUtils.cs @@ -12,11 +12,20 @@ public static bool IsNotInObjOrBinFolder(string path) !d.Equals("bin", StringComparison.OrdinalIgnoreCase)); } + /// + /// Checks only the folders below (the project or source folder), + /// so a project which itself is located under a folder named bin or obj is not excluded. + /// + public static bool IsNotInObjOrBinFolder(string path, string rootFolder) + { + return IsNotInObjOrBinFolder(Path.GetRelativePath(Path.GetFullPath(rootFolder), Path.GetFullPath(path))); + } + public static string PrepareOutputPath(string path, string extension) { var normalizedPath = path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); var unrootedPath = UnrootPath(normalizedPath); - return Path.HasExtension(path) ? path : Path.ChangeExtension(unrootedPath, extension); + return Path.HasExtension(unrootedPath) ? unrootedPath : Path.ChangeExtension(unrootedPath, extension); } public static string UnrootPath(string path) diff --git a/src/Core/Serialization/CustomXmlWriter.cs b/src/Core/Serialization/CustomXmlWriter.cs index 102b52e9..befec14e 100644 --- a/src/Core/Serialization/CustomXmlWriter.cs +++ b/src/Core/Serialization/CustomXmlWriter.cs @@ -42,9 +42,15 @@ public void Write(XElement element) WriteAttributes(element.Attributes()); } - if (element.HasElements) + if (RawXmlContent.IsMarkupBody(element)) { - WriteElements(element.Elements()); + // written exactly as it is: indentation inside a liquid template would change the body + _xmlWriter.WriteRaw(string.Concat( + element.Nodes().Select(node => node.ToString(SaveOptions.DisableFormatting)))); + } + else if (element.Nodes().Any(node => node is not XText or XCData)) + { + WriteNodes(element.Nodes()); } else if (!string.IsNullOrEmpty(element.Value)) { @@ -54,11 +60,31 @@ public void Write(XElement element) _xmlWriter.WriteEndElement(); } - private void WriteElements(IEnumerable elements) + // Text, CDATA and comments next to child elements are written in document order. + private void WriteNodes(IEnumerable nodes) { - foreach (var element in elements) + foreach (var node in nodes) { - Write(element); + switch (node) + { + case XElement element: + Write(element); + break; + case XCData cdata: + cdata.WriteTo(_xmlWriter); + break; + case XText text: + // Whitespace between nodes is indentation, the writer produces its own. + if (!string.IsNullOrWhiteSpace(text.Value)) + { + WriteValue(text.Value); + } + + break; + default: + node.WriteTo(_xmlWriter); + break; + } } } diff --git a/src/Core/Serialization/RawXmlContent.cs b/src/Core/Serialization/RawXmlContent.cs new file mode 100644 index 00000000..116adaf3 --- /dev/null +++ b/src/Core/Serialization/RawXmlContent.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +using Microsoft.Azure.ApiManagement.PolicyToolkit.Decompiling; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Serialization; + +/// +/// The XML body of a liquid set-body has to be written as markup, exactly as authored.
+/// Verified against the gateway: a liquid template written as escaped text is returned still escaped, so it +/// has to be markup, while a body without a template that is written as markup is returned empty, so it has +/// to stay text. +///
+public static class RawXmlContent +{ + /// + /// True for a liquid set-body that holds markup (an element or a CDATA section), which the writer must not + /// indent. + /// + public static bool IsMarkupBody(XElement element) => + element.Name.LocalName == "set-body" && element.Attribute("template")?.Value == "liquid" && + element.Nodes().Any(IsMarkup) && !IsValueElementBody(element); + + /// + /// True for a body given through a value element, which is a policy value and not a template written in + /// place: the value element is all there is in it. + /// + public static bool IsValueElementBody(XElement element) => + element.Nodes().Where(node => node is not XText text || !string.IsNullOrWhiteSpace(text.Value) || text is XCData) + .ToList() is [XElement { Name.LocalName: "value" }]; + + private static bool IsMarkup(XNode node) => node is XElement or XCData; + + /// + /// Adds the value to a liquid set-body as markup when it is well-formed XML with at least one element or + /// CDATA section, + /// also when it is apart from a < or & written as text; otherwise as plain text. Markup is written + /// back in its normal form, such as double-quoted attributes. + /// + public static void AddTo(XElement element, string value) + { + if (element.Attribute("template")?.Value == "liquid" && TryParse(value, out var nodes)) + { + element.Add(nodes); + } + else + { + element.Add(value); + } + } + + private static bool TryParse(string value, out List nodes) + { + nodes = []; + // a body that is one policy expression is code, not a template + if (!value.Contains('<') || PolicyDecompilerContext.IsSingleExpression(value.Trim())) + { + return false; + } + + // A < or & that doesn't start a tag or an entity, as in {% if item.Count < 2 %}, is text; the gateway + // reads it back from its escaped form inside a liquid tag. + return TryParseXml(value, out nodes) || TryParseXml(StrayCharacter.Replace(value, Escape), out nodes); + } + + // A CDATA section or a comment, which is kept as it is, or a < or & that starts neither a tag nor an entity. A + // tag starts with a letter of any script, _ or :. A policy has no DTD, so the only entities are XML's five and + // character references; an HTML entity such as   is text. + private static readonly Regex StrayCharacter = new( + @"||<(?![\p{L}_:/!?])|&(?!(?:" + Entities + @");)", + RegexOptions.Compiled | RegexOptions.Singleline); + + // What an entity may be in a policy, which has no DTD: XML's five and a character reference. + internal const string Entities = "lt|gt|amp|quot|apos|#[0-9]+|#x[0-9A-Fa-f]+"; + + private static string Escape(Match match) => match.Value switch + { + "<" => "<", + "&" => "&", + _ => match.Value + }; + + private static bool TryParseXml(string value, out List nodes) + { + nodes = []; + try + { + var wrapper = XElement.Parse($"{value}", LoadOptions.PreserveWhitespace); + nodes = wrapper.Nodes().ToList(); + return nodes.Any(IsMarkup); + } + catch (XmlException) + { + return false; + } + } +} diff --git a/src/Core/Serialization/RazorCodeFormatter.cs b/src/Core/Serialization/RazorCodeFormatter.cs index 749616db..7cd24be9 100644 --- a/src/Core/Serialization/RazorCodeFormatter.cs +++ b/src/Core/Serialization/RazorCodeFormatter.cs @@ -15,24 +15,33 @@ public static class RazorCodeFormatter { private readonly static Regex CSharpCodeStart = new Regex("(@\\()|(@{)", RegexOptions.Compiled); + // The characters of a named value's name, as API Management allows them. + internal const string NamedValueName = @"[A-Za-z0-9_.\-]+"; + // A named value token such as {{port}} can be used as code; it would parse as nested blocks. - private readonly static Regex NamedValueToken = new Regex(@"\{\{[A-Za-z0-9_.\-]+\}\}", RegexOptions.Compiled); + private readonly static Regex NamedValueToken = new Regex(@"\{\{" + NamedValueName + @"\}\}", RegexOptions.Compiled); /// /// Reflows the C# inside policy expressions (@(...) and @{...}) directly /// on the document tree, operating on the raw (unescaped) expression text stored in - /// attribute and leaf element values. + /// attribute values and text nodes. /// - /// This is used for the xml output format, where expressions are XML-encoded - /// during serialization and therefore cannot be reformatted from the serialized - /// string (the entities would be reparsed as C# and corrupted). Formatting the tree - /// before serialization lets the writer encode the already-formatted expressions. + /// Expressions which are XML-encoded during serialization cannot be reformatted from + /// the serialized string (the entities would be reparsed as C# and corrupted). Formatting + /// the tree before serialization lets the writer encode the already-formatted expressions. /// /// public static void FormatExpressions(XElement element) { + // The markup of a liquid set-body is template text: an @(...) in it isn't a policy expression. + var templates = element.DescendantsAndSelf().Where(RawXmlContent.IsMarkupBody).ToList(); foreach (var node in element.DescendantsAndSelf()) { + if (templates.Any(template => node == template || node.Ancestors().Contains(template))) + { + continue; + } + foreach (var attribute in node.Attributes()) { if (CSharpCodeStart.IsMatch(attribute.Value)) @@ -41,75 +50,100 @@ public static void FormatExpressions(XElement element) } } - if (!node.HasElements && CSharpCodeStart.IsMatch(node.Value)) + // Text nodes are formatted one by one, so sibling elements and comments are kept. + foreach (var text in node.Nodes().OfType()) { - node.Value = Format(node.Value); + if (CSharpCodeStart.IsMatch(text.Value)) + { + text.Value = Format(text.Value); + } } } } public static string Format(string code) { - var result = new StringBuilder(); - var lastIndex = 0; - foreach (Match match in CSharpCodeStart.Matches(code)) + return ReplaceExpressions(code, (cSharpCode, isMultiline) => { - result.Append(code, lastIndex, match.Index - lastIndex + 2); - var index = FindClosingIndex(code, match, out bool isMultiline); - if (isMultiline) result.AppendLine(); - - var cSharpCode = code.Substring(match.Index + 2, index - match.Index - 2).Trim(); var formattedCode = FormatCSharpCode(cSharpCode); - result.Append(formattedCode); - - if (isMultiline) result.AppendLine(); - - lastIndex = index; - } - - result.Append(code, lastIndex, code.Length - lastIndex); - return result.ToString(); + return isMultiline + ? $"@{{{Environment.NewLine}{formattedCode}{Environment.NewLine}}}" + : $"@({formattedCode})"; + }); } public static string ToCleanXml(string code, out IReadOnlyDictionary markerToCode) { var expressions = new Dictionary(); + var result = ReplaceExpressions(code, (cSharpCode, isMultiline) => + { + var marker = $"__expression__{Guid.NewGuid()}__"; + expressions.Add(marker, NormalizeExpression(cSharpCode, isMultiline)); + return marker; + }); + markerToCode = expressions; + return result; + } + + private static string NormalizeExpression(string cSharpCode, bool isMultiline) + { + var formatlessCode = WithNamedValuesProtected(cSharpCode, protectedCode => + new TriviaRemoverRewriter().Visit(CSharpSyntaxTree.ParseText(protectedCode).GetRoot()) + .NormalizeWhitespace("", "\n").ToFullString().Trim()); + return isMultiline ? $"@{{{formatlessCode}}}" : $"@({formatlessCode})"; + } + + // Replaces every expression, from its @ to its closing bracket, with the text returned by the callback. + // The callback gets the trimmed C# code and whether the expression is a multi-statement one. + // An expression without a closing bracket is left as it is. + private static string ReplaceExpressions(string code, Func replace) + { var result = new StringBuilder(); var lastIndex = 0; - foreach (Match match in CSharpCodeStart.Matches(code)) + var match = CSharpCodeStart.Match(code); + while (match.Success) { + if (!TryFindClosingIndex(code, match, out var index, out var isMultiline)) + { + match = CSharpCodeStart.Match(code, match.Index + 2); + continue; + } + result.Append(code, lastIndex, match.Index - lastIndex); - var index = FindClosingIndex(code, match, out var isMultiline); var cSharpCode = code.Substring(match.Index + 2, index - match.Index - 2).Trim(); - var formatlessCode = WithNamedValuesProtected(cSharpCode, protectedCode => - new TriviaRemoverRewriter().Visit(CSharpSyntaxTree.ParseText(protectedCode).GetRoot()) - .NormalizeWhitespace("", "\n").ToString()); - var marker = $"__expression__{Guid.NewGuid()}__"; - expressions.Add(marker, isMultiline ? $"@{{{formatlessCode}}}" : $"@({formatlessCode})"); - result.Append(marker); + result.Append(replace(cSharpCode, isMultiline)); lastIndex = index + 1; + match = CSharpCodeStart.Match(code, lastIndex); } result.Append(code, lastIndex, code.Length - lastIndex); - markerToCode = expressions; return result.ToString(); } - private static int FindClosingIndex(string code, Match match, out bool isMultiline) + // The code is read with the C# lexer, so brackets in string literals, character literals + // and comments are not counted. + private static bool TryFindClosingIndex(string code, Match match, out int index, out bool isMultiline) { - var index = match.Index + 2; - int open = 1; - var openCharacter = code[match.Index + 1]; - isMultiline = openCharacter == '{'; - var closeCharacter = isMultiline ? '}' : ')'; - do + isMultiline = code[match.Index + 1] == '{'; + var openKind = isMultiline ? SyntaxKind.OpenBraceToken : SyntaxKind.OpenParenToken; + var closeKind = isMultiline ? SyntaxKind.CloseBraceToken : SyntaxKind.CloseParenToken; + var start = match.Index + 2; + var open = 1; + foreach (var token in SyntaxFactory.ParseTokens(code, start, start)) { - var character = code[index++]; - if (character == openCharacter) ++open; - else if (character == closeCharacter) --open; - } while (open > 0); + if (token.IsKind(openKind)) + { + ++open; + } + else if (token.IsKind(closeKind) && --open == 0) + { + index = token.SpanStart; + return true; + } + } - return --index; + index = -1; + return false; } private static string FormatCSharpCode(string code) @@ -122,7 +156,7 @@ private static string FormatCSharpCode(string code) } // Replaces named value tokens with identifiers while the code is reformatted, then restores them. - private static string WithNamedValuesProtected(string code, Func format) + internal static string WithNamedValuesProtected(string code, Func format) { var tokens = new Dictionary(); var protectedCode = NamedValueToken.Replace(code, match => @@ -139,4 +173,4 @@ private static string WithNamedValuesProtected(string code, Func return formatted; } -} \ No newline at end of file +} diff --git a/src/Decompiling/Program.cs b/src/Decompiling/Program.cs index 5cc5d8f6..6e949859 100644 --- a/src/Decompiling/Program.cs +++ b/src/Decompiling/Program.cs @@ -7,6 +7,8 @@ using System.Xml.Linq; using Microsoft.Azure.ApiManagement.PolicyToolkit.Decompiling; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; var sourceOption = new Option("--s", "--source") { @@ -53,6 +55,21 @@ Description = "Policy scope" }; +var policyFormatOption = new Option("--pf", "--policy-format") +{ + DefaultValueFactory = _ => "rawxml", + Description = "Policy content format of the input: rawxml (default) or xml" +}; +policyFormatOption.Validators.Add(result => +{ + var value = result.GetValueOrDefault(); + // by name only: Enum.TryParse also takes a number or a list of names + if (value is not null && !Enum.GetNames().Contains(value, StringComparer.OrdinalIgnoreCase)) + { + result.AddError($"Invalid policy format value '{value}'. Use 'rawxml' (default) or 'xml'."); + } +}); + var docIdRootOption = new Option("--doc-id-root") { Description = "Root path for computing relative DocumentId (for traceability)" @@ -72,7 +89,7 @@ var noValidateOption = new Option("--no-validate") { - Description = "Skip validation" + Description = "Skip checking that the generated C# is syntactically valid" }; var verboseOption = new Option("--verbose", "-v") @@ -96,6 +113,7 @@ outputExtOption, namespaceOption, scopeOption, + policyFormatOption, docIdRootOption, documentSuffixOption, fragmentSuffixOption, @@ -120,6 +138,7 @@ var docIdRoot = parseResult.GetValue(docIdRootOption); var documentSuffix = parseResult.GetValue(documentSuffixOption)!; var fragmentSuffix = parseResult.GetValue(fragmentSuffixOption)!; + var noValidate = parseResult.GetValue(noValidateOption); var verbose = parseResult.GetValue(verboseOption); // Discover XML files @@ -182,13 +201,30 @@ await Console.Error.WriteLineAsync(source is null && inputDir is null && input i } var decompiler = new PolicyDecompiler(); - var decompileOptions = new DecompileOptions { Scope = scope }; + var decompileOptions = new DecompileOptions + { + Scope = scope, + PolicyFormat = Enum.Parse(parseResult.GetValue(policyFormatOption)!, ignoreCase: true) + }; int succeeded = 0; int failed = 0; int skipped = 0; + // A file found through more than one option is decompiled once. + xmlFiles = xmlFiles.DistinctBy(file => file.fullPath).ToList(); + + // Policies that share a directory can't all be named after it; those get the file name added. A file + // that isn't a policy document is skipped below and doesn't count. + var filesPerDirectory = xmlFiles + .Select(file => file.fullPath) + .Where(file => IsPolicyFile(file, decompileOptions.PolicyFormat)) + .GroupBy(file => Path.GetDirectoryName(file)!) + .ToDictionary(group => group.Key, group => group.Count()); + var usedTypeNames = new HashSet(StringComparer.Ordinal); + foreach (var (fullPath, basePath) in xmlFiles) { + var sharesDirectory = filesPerDirectory.GetValueOrDefault(Path.GetDirectoryName(fullPath)!) > 1; var relativePath = Path.GetRelativePath(basePath, fullPath); var relativeDir = Path.GetDirectoryName(relativePath) ?? ""; @@ -210,13 +246,21 @@ await Console.Error.WriteLineAsync(source is null && inputDir is null && input i try { var xml = await File.ReadAllTextAsync(fullPath); - var preprocessed = PolicyDecompiler.PreprocessXml(xml); XDocument doc; - try { doc = XDocument.Parse(preprocessed); } + try + { + doc = XDocument.Parse(PolicyDecompiler.PreprocessXml(xml, decompileOptions.PolicyFormat)); + } catch (Exception ex) { failed++; await Console.Error.WriteLineAsync($"Error parsing {relativePath}: {ex.Message}"); + if (decompileOptions.PolicyFormat == PolicyFormat.Xml) + { + await Console.Error.WriteLineAsync( + " The policy was read as xml. If its expressions are written as code, pass --policy-format rawxml or leave the option out."); + } + continue; } @@ -237,8 +281,9 @@ await Console.Error.WriteLineAsync(source is null && inputDir is null && input i string result; if (rootElement == "fragment") { - var fragmentId = GetFragmentId(fullPath, basePath); - var className = BuildClassName(fullPath, basePath, fragmentSuffix); + var fragmentId = GetFragmentId(fullPath, basePath, sharesDirectory); + var className = MakeUnique( + BuildClassName(fullPath, basePath, fragmentSuffix, sharesDirectory), namespaceName, usedTypeNames); if (verbose) { await Console.Out.WriteLineAsync($"Processing: {relativePath}"); @@ -249,7 +294,8 @@ await Console.Error.WriteLineAsync(source is null && inputDir is null && input i } else { - var className = BuildClassName(fullPath, basePath, documentSuffix); + var className = MakeUnique( + BuildClassName(fullPath, basePath, documentSuffix, sharesDirectory), namespaceName, usedTypeNames); if (verbose) { await Console.Out.WriteLineAsync($"Processing: {relativePath}"); @@ -261,6 +307,31 @@ await Console.Error.WriteLineAsync(source is null && inputDir is null && input i Directory.CreateDirectory(outputDir); await File.WriteAllTextAsync(outputFile, result); + + if (!noValidate) + { + var syntaxErrors = CSharpSyntaxTree.ParseText(result).GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToList(); + if (syntaxErrors.Count > 0) + { + failed++; + await Console.Error.WriteLineAsync( + $"Error validating {relativePath}: the generated C# in {outputFile} has syntax errors (use --no-validate to skip this check):"); + if (decompileOptions.PolicyFormat == PolicyFormat.RawXml) + { + await Console.Error.WriteLineAsync( + " The policy was read as rawxml. If its expressions are XML-escaped, pass --policy-format xml."); + } + + foreach (var error in syntaxErrors) + { + await Console.Error.WriteLineAsync($" {error}"); + } + continue; + } + } + succeeded++; if (verbose) @@ -306,7 +377,21 @@ static string SanitizeIdentifier(string name) return result; } -static string BuildClassName(string fullPath, string basePath, string suffix) +static bool IsPolicyFile(string path, PolicyFormat format) +{ + try + { + var root = XDocument.Parse(PolicyDecompiler.PreprocessXml(File.ReadAllText(path), format)).Root?.Name.LocalName; + return root is "policies" or "fragment"; + } + catch (Exception) + { + // reported as a failure when the file is processed + return false; + } +} + +static string BuildClassName(string fullPath, string basePath, string suffix, bool sharesDirectory) { var relativePath = Path.GetRelativePath(basePath, fullPath); var relativeDir = Path.GetDirectoryName(relativePath) ?? ""; @@ -327,6 +412,10 @@ static string BuildClassName(string fullPath, string basePath, string suffix) } var sanitized = SanitizeIdentifier(nameBasis); + if (sharesDirectory && segments.Length > 0) + { + sanitized += SanitizeIdentifier(Path.GetFileNameWithoutExtension(fullPath)).TrimStart('_'); + } if (!sanitized.EndsWith(suffix, StringComparison.Ordinal)) { sanitized += suffix; @@ -334,6 +423,17 @@ static string BuildClassName(string fullPath, string basePath, string suffix) return sanitized; } +// Different files can still map to one name (e.g. a-b.xml and a_b.xml); later ones get a number. +static string MakeUnique(string className, string namespaceName, HashSet usedTypeNames) +{ + var candidate = className; + for (var number = 2; !usedTypeNames.Add($"{namespaceName}.{candidate}"); number++) + { + candidate = $"{className}{number}"; + } + return candidate; +} + static string BuildNamespace(string baseNamespace, string relativeDir) { if (string.IsNullOrEmpty(relativeDir)) @@ -346,8 +446,14 @@ static string BuildNamespace(string baseNamespace, string relativeDir) return baseNamespace + "." + string.Join(".", segments); } -static string GetFragmentId(string fullPath, string basePath) +static string GetFragmentId(string fullPath, string basePath, bool sharesDirectory) { + // Several fragments in one directory can't all take the directory's name + if (sharesDirectory) + { + return Path.GetFileNameWithoutExtension(fullPath); + } + var relativePath = Path.GetRelativePath(basePath, fullPath); var relativeDir = Path.GetDirectoryName(relativePath) ?? ""; var segments = relativeDir.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) diff --git a/src/Decompiling/README.md b/src/Decompiling/README.md index 8243c194..e92910dd 100644 --- a/src/Decompiling/README.md +++ b/src/Decompiling/README.md @@ -53,6 +53,7 @@ Common options: - `--ext`: output file extension (`.cs` by default) - `--namespace`: base namespace for generated classes - `--scope`: document scope (`Operation` by default) +- `--pf` / `--policy-format`: format of the input, `rawxml` (default) or `xml`, as for the compiler - `--doc-id-root`: root path used to compute relative `DocumentId` values - `--document-suffix` / `--fragment-suffix`: name suffixes for generated class names - `--verbose`: print progress information while generating files diff --git a/src/Templates/content/create-policy-document/.template.config/template.json b/src/Templates/content/create-policy-document/.template.config/template.json index 33e100c0..e3c2fe0f 100644 --- a/src/Templates/content/create-policy-document/.template.config/template.json +++ b/src/Templates/content/create-policy-document/.template.config/template.json @@ -21,7 +21,7 @@ "defaultName": "PolicyDocument1", "primaryOutputs": [ { - "path": ".cs" + "path": "PolicyDocument1.cs" } ], "symbols": { diff --git a/src/Templates/content/create-policy-fragment/.template.config/template.json b/src/Templates/content/create-policy-fragment/.template.config/template.json index fa322fcb..3290ff65 100644 --- a/src/Templates/content/create-policy-fragment/.template.config/template.json +++ b/src/Templates/content/create-policy-fragment/.template.config/template.json @@ -21,7 +21,7 @@ "defaultName": "PolicyFragment1", "primaryOutputs": [ { - "path": ".cs" + "path": "PolicyFragment1.cs" } ], "symbols": { diff --git a/src/Templates/content/create-policy-fragment/PolicyFragment1.cs b/src/Templates/content/create-policy-fragment/PolicyFragment1.cs index d3c59a3c..6be90100 100644 --- a/src/Templates/content/create-policy-fragment/PolicyFragment1.cs +++ b/src/Templates/content/create-policy-fragment/PolicyFragment1.cs @@ -1,3 +1,5 @@ +using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; + namespace Company.PolicyProject1; [Document(Type = DocumentType.Fragment)] diff --git a/src/Templates/content/create-solution/.config/dotnet-tools.json b/src/Templates/content/create-solution/.config/dotnet-tools.json index 65380b97..ac295186 100644 --- a/src/Templates/content/create-solution/.config/dotnet-tools.json +++ b/src/Templates/content/create-solution/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling": { - "version": "1.0.0", + "version": "1.1.0", "commands": [ "azure-apim-policy-compiler" ] diff --git a/src/Templates/content/create-solution/src/Project.Source.csproj b/src/Templates/content/create-solution/src/Project.Source.csproj index 7cb52a9c..6a566bfe 100644 --- a/src/Templates/content/create-solution/src/Project.Source.csproj +++ b/src/Templates/content/create-solution/src/Project.Source.csproj @@ -6,7 +6,7 @@ enable - + \ No newline at end of file diff --git a/src/Templates/content/create-solution/test/Project.Test.csproj b/src/Templates/content/create-solution/test/Project.Test.csproj index 67052dab..8da0fda7 100644 --- a/src/Templates/content/create-solution/test/Project.Test.csproj +++ b/src/Templates/content/create-solution/test/Project.Test.csproj @@ -6,9 +6,9 @@ enable - + - + diff --git a/src/Testing/Emulator/Policies/CheckHeaderHandler.cs b/src/Testing/Emulator/Policies/CheckHeaderHandler.cs index cfa87ca2..2f0eab45 100644 --- a/src/Testing/Emulator/Policies/CheckHeaderHandler.cs +++ b/src/Testing/Emulator/Policies/CheckHeaderHandler.cs @@ -24,9 +24,11 @@ public List { public override string PolicyName => nameof(IOutboundContext.LlmSemanticCacheStore); + // The optional cacheResponse argument isn't simulated; only the duration reaches the callbacks. + protected override uint ExtractArgument(object?[]? args) => + args is [uint duration, _] ? duration : base.ExtractArgument(args); + protected override void Handle(GatewayContext context, uint duration) { // No-op by default in emulator. diff --git a/src/Testing/Emulator/Policies/ReturnResponseHandler.cs b/src/Testing/Emulator/Policies/ReturnResponseHandler.cs index 770771d0..bbe1ec36 100644 --- a/src/Testing/Emulator/Policies/ReturnResponseHandler.cs +++ b/src/Testing/Emulator/Policies/ReturnResponseHandler.cs @@ -51,7 +51,7 @@ private void Handle(GatewayContext context, ReturnResponseConfig config) if (config.Status is not null) { response.StatusCode = config.Status.Code; - response.StatusReason = config.Status.Reason; + response.StatusReason = config.Status.Reason ?? string.Empty; } foreach (var header in config.Headers ?? []) diff --git a/src/Testing/Emulator/Policies/SetStatusHandler.cs b/src/Testing/Emulator/Policies/SetStatusHandler.cs index ff46e3d0..5fcd8c10 100644 --- a/src/Testing/Emulator/Policies/SetStatusHandler.cs +++ b/src/Testing/Emulator/Policies/SetStatusHandler.cs @@ -18,6 +18,6 @@ internal class SetStatusHandler : PolicyHandler protected override void Handle(GatewayContext context, StatusConfig config) { context.Response.StatusCode = config.Code; - context.Response.StatusReason = config.Reason; + context.Response.StatusReason = config.Reason ?? string.Empty; } } \ No newline at end of file diff --git a/src/Testing/Emulator/PolicyHandler.cs b/src/Testing/Emulator/PolicyHandler.cs index c5e86971..22133fba 100644 --- a/src/Testing/Emulator/PolicyHandler.cs +++ b/src/Testing/Emulator/PolicyHandler.cs @@ -13,7 +13,7 @@ public List(); + var config = ExtractArgument(args); var callbackHook = CallbackSetup.Find(hook => hook.Item1(context, config)); if (callbackHook is not null) { @@ -27,6 +27,8 @@ public List args.ExtractArgument(); + protected abstract void Handle(GatewayContext context, TConfig config); } diff --git a/src/Testing/Expressions/MockBackend.cs b/src/Testing/Expressions/MockBackend.cs new file mode 100644 index 00000000..27d26ba7 --- /dev/null +++ b/src/Testing/Expressions/MockBackend.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Expressions; + +public class MockBackend : IBackend +{ + public string AzureRegion { get; set; } = "eastus"; + public string Id { get; set; } = "backend"; + public string Type { get; set; } = "Single"; +} diff --git a/src/Testing/Expressions/MockDeployment.cs b/src/Testing/Expressions/MockDeployment.cs index a2f025c2..b9d4721d 100644 --- a/src/Testing/Expressions/MockDeployment.cs +++ b/src/Testing/Expressions/MockDeployment.cs @@ -9,11 +9,17 @@ namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Expressions; public class MockDeployment : IDeployment { + public MockGateway Gateway { get; set; } = new MockGateway(); + IGateway IDeployment.Gateway => Gateway; + public string GatewayId { get; set; } = "managed"; public string Region { get; set; } = "eastus"; public string ServiceId { get; set; } = "contoso-dev-apim"; public string ServiceName { get; set; } = "contoso-dev-apim"; + public MockSustainabilityInfo SustainabilityInfo { get; set; } = new MockSustainabilityInfo(); + ISustainabilityInfo IDeployment.SustainabilityInfo => SustainabilityInfo; + public Dictionary Certificates { get; set; } = new Dictionary(); IReadOnlyDictionary IDeployment.Certificates => Certificates; } \ No newline at end of file diff --git a/src/Testing/Expressions/MockExpressionContext.cs b/src/Testing/Expressions/MockExpressionContext.cs index 95262bc9..d6f5aef5 100644 --- a/src/Testing/Expressions/MockExpressionContext.cs +++ b/src/Testing/Expressions/MockExpressionContext.cs @@ -35,6 +35,14 @@ static MockExpressionContext() public MockUser User { get; set; } = new MockUser(); IUser IExpressionContext.User => User; + // null like in the gateway, where there is no backend entity until one is selected and no workspace + // unless the API is in one + public MockBackend? Backend { get; set; } + IBackend? IExpressionContext.Backend => Backend; + + public MockWorkspace? Workspace { get; set; } + IWorkspace? IExpressionContext.Workspace => Workspace; + public MockDeployment Deployment { get; set; } = new MockDeployment(); IDeployment IExpressionContext.Deployment => Deployment; @@ -44,8 +52,9 @@ static MockExpressionContext() public MockOperation Operation { get; set; } = new MockOperation(); IOperation IExpressionContext.Operation => Operation; - public MockProduct Product { get; set; } = new MockProduct(); - IProduct IExpressionContext.Product => Product; + // null like in the gateway for a request made without a subscription + public MockProduct? Product { get; set; } + IProduct? IExpressionContext.Product => Product; public Action Trace { get; set; } = (message) => { }; diff --git a/src/Testing/Expressions/MockFoundry.cs b/src/Testing/Expressions/MockFoundry.cs new file mode 100644 index 00000000..2ce0f742 --- /dev/null +++ b/src/Testing/Expressions/MockFoundry.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Expressions; + +public class MockFoundry : IFoundry +{ + public string Deployment { get; set; } = "deployment"; +} diff --git a/src/Testing/Expressions/MockGateway.cs b/src/Testing/Expressions/MockGateway.cs new file mode 100644 index 00000000..08f7d611 --- /dev/null +++ b/src/Testing/Expressions/MockGateway.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Expressions; + +public class MockGateway : IGateway +{ + public string Id { get; set; } = "managed"; + public string InstanceId { get; set; } = "managed"; + public bool IsManaged { get; set; } = true; +} diff --git a/src/Testing/Expressions/MockProduct.cs b/src/Testing/Expressions/MockProduct.cs index fbbbe64c..66d7a7ca 100644 --- a/src/Testing/Expressions/MockProduct.cs +++ b/src/Testing/Expressions/MockProduct.cs @@ -13,6 +13,6 @@ public class MockProduct : IProduct public string Id { get; set; } public string Name { get; set; } public ProductState State { get; set; } - public int? SubscriptionLimit { get; set; } + public int? SubscriptionsLimit { get; set; } public bool SubscriptionRequired { get; set; } } \ No newline at end of file diff --git a/src/Testing/Expressions/MockRequest.cs b/src/Testing/Expressions/MockRequest.cs index ea33f6a4..caf876e3 100644 --- a/src/Testing/Expressions/MockRequest.cs +++ b/src/Testing/Expressions/MockRequest.cs @@ -40,6 +40,9 @@ public MockRequest(Uri uri) public MockPrivateEndpointConnection? PrivateEndpointConnection { get; set; } IPrivateEndpointConnection? IRequest.PrivateEndpointConnection => PrivateEndpointConnection; + public MockFoundry? Foundry { get; set; } + IFoundry? IRequest.Foundry => Foundry; + public MockAzureVnetInfo? AzureVnetInfo { get; set; } IAzureVnetInfo? IRequest.AzureVnetInfo => AzureVnetInfo; diff --git a/src/Testing/Expressions/MockSustainabilityInfo.cs b/src/Testing/Expressions/MockSustainabilityInfo.cs new file mode 100644 index 00000000..5293616d --- /dev/null +++ b/src/Testing/Expressions/MockSustainabilityInfo.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Expressions; + +public class MockSustainabilityInfo : ISustainabilityInfo +{ + public CarbonIntensityCategory CurrentCarbonIntensity { get; set; } = CarbonIntensityCategory.NotAvailable; +} diff --git a/src/Testing/Expressions/MockUrl.cs b/src/Testing/Expressions/MockUrl.cs index 531769ae..64bc6894 100644 --- a/src/Testing/Expressions/MockUrl.cs +++ b/src/Testing/Expressions/MockUrl.cs @@ -15,7 +15,7 @@ public MockUrl(Uri uri) { Scheme = uri.Scheme; Host = uri.Host; - Port = uri.Port.ToString(); + Port = uri.Port; Path = uri.AbsolutePath; if (!string.IsNullOrEmpty(uri.Query)) { @@ -25,7 +25,7 @@ public MockUrl(Uri uri) public string Scheme { get; set; } = "https"; public string Host { get; set; } = "contoso.example"; - public string Port { get; set; } = "443"; + public int Port { get; set; } = 443; public string Path { get; set; } = "/v2/mock/op"; public Dictionary Query { get; set; } = new Dictionary { }; @@ -55,7 +55,7 @@ public string QueryString public override string ToString() { - var port = (Scheme == "https" && Port == "443") || (Scheme == "http" && Port == "80") + var port = (Scheme == "https" && Port == 443) || (Scheme == "http" && Port == 80) ? "" : $":{Port}"; return $"{Scheme}://{Host}{port}{Path}{QueryString}"; diff --git a/src/Testing/Expressions/MockWorkspace.cs b/src/Testing/Expressions/MockWorkspace.cs new file mode 100644 index 00000000..9a877fca --- /dev/null +++ b/src/Testing/Expressions/MockWorkspace.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Expressions; + +public class MockWorkspace : IWorkspace +{ + public string Id { get; set; } = "workspace"; + public string Name { get; set; } = "workspace"; +} diff --git a/test/Test.Analyzers/ExpressionReturnTypeTests.cs b/test/Test.Analyzers/ExpressionReturnTypeTests.cs new file mode 100644 index 00000000..85363411 --- /dev/null +++ b/test/Test.Analyzers/ExpressionReturnTypeTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.ApiManagement.PolicyToolkit.Analyzers; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Analyzers.Test; +using Microsoft.CodeAnalysis; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Test.Analyzers; + +[TestClass] +public class ExpressionReturnTypeTests +{ + public static Task VerifyAsync(string source, params DiagnosticResult[] diags) + { + var test = new BaseAnalyzerTest(source, diags); + test.TestState.AdditionalReferences.Add( + MetadataReference.CreateFromFile(Path.Combine(AppContext.BaseDirectory, "Newtonsoft.Json.dll"))); + return test.RunAsync(); + } + + [TestMethod] + [DataRow("object")] + [DataRow("string")] + [DataRow("bool")] + [DataRow("char")] + [DataRow("sbyte")] + [DataRow("byte")] + [DataRow("short")] + [DataRow("ushort")] + [DataRow("int")] + [DataRow("uint")] + [DataRow("long")] + [DataRow("ulong")] + [DataRow("float")] + [DataRow("double")] + [DataRow("decimal")] + [DataRow("System.Guid")] + [DataRow("System.DateTime")] + [DataRow("System.TimeSpan")] + [DataRow("System.Uri")] + [DataRow("int?")] + [DataRow("float?")] + [DataRow("System.Guid?")] + [DataRow("System.DateTime?")] + [DataRow("System.Nullable")] + [DataRow("byte[]")] + [DataRow("sbyte[]")] + [DataRow("ProductState")] + [DataRow("System.DayOfWeek")] + [DataRow("System.DayOfWeek?")] + [DataRow("SourceEnum")] + [DataRow("Newtonsoft.Json.Linq.JToken")] + [DataRow("Newtonsoft.Json.Linq.JObject")] + [DataRow("Newtonsoft.Json.Linq.JArray")] + [DataRow("Newtonsoft.Json.Linq.JValue")] + [DataRow("Newtonsoft.Json.Linq.JContainer")] + [DataRow("Newtonsoft.Json.Linq.JProperty")] + [DataRow("Newtonsoft.Json.Linq.JConstructor")] + [DataRow("Newtonsoft.Json.Linq.JRaw")] + public async Task ShouldAllowReturnType(string type) + { + await VerifyAsync( + $$""" + enum SourceEnum { A } + + class Test + { + [Expression] + {{type}} Method(IExpressionContext context) => default; + } + """ + ); + } + + [TestMethod] + public async Task ShouldAllowNullableReferenceReturnType() + { + await VerifyAsync( + """ + #nullable enable + class Test + { + [Expression] + string? Method(IExpressionContext context) => null; + } + """ + ); + } + + [TestMethod] + [DataRow("System.IO.Stream", "System.IO.Stream")] + [DataRow("System.Collections.Generic.List", "System.Collections.Generic.List")] + [DataRow("System.IO.Stream[]", "System.IO.Stream[]")] + [DataRow("string[]", "System.String[]")] + [DataRow("byte?[]", "System.Byte?[]")] + [DataRow("System.Collections.ObjectModel.ReadOnlyCollection", "System.Collections.ObjectModel.ReadOnlyCollection")] + [DataRow("string[,]", "System.String[,]")] + [DataRow("string[][]", "System.String[][]")] + [DataRow("System.Threading.Tasks.Task", "System.Threading.Tasks.Task")] + public async Task ShouldReportReturnType(string type, string reported) + { + await VerifyAsync( + $$""" + class Test + { + [Expression] + {|#0:{{type}}|} Method(IExpressionContext context) => default; + } + """, + DiagnosticResult + .CompilerError(Rules.Expression.ReturnTypeNotAllowed.Id) + .WithLocation(0) + .WithArguments(reported) + ); + } +} diff --git a/test/Test.Analyzers/TypeUsedAllowListTests.cs b/test/Test.Analyzers/TypeUsedAllowListTests.cs new file mode 100644 index 00000000..78ef1a48 --- /dev/null +++ b/test/Test.Analyzers/TypeUsedAllowListTests.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.ApiManagement.PolicyToolkit.Analyzers; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Analyzers.Test; +using Microsoft.CodeAnalysis; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Test.Analyzers; + +[TestClass] +public class TypeUsedAllowListTests +{ + public static Task VerifyAsync(string source, params DiagnosticResult[] diags) + { + var test = new BaseAnalyzerTest(source, diags); + test.TestState.AdditionalReferences.Add( + MetadataReference.CreateFromFile(Path.Combine(AppContext.BaseDirectory, "Newtonsoft.Json.dll"))); + return test.RunAsync(); + } + + private static DiagnosticResult DisallowedType(int location, string type) => + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(location).WithArguments(type); + + [TestMethod] + public async Task ShouldAllowNullableMembers() + { + await VerifyAsync( + """ + public static class ExpressionLibrary + { + [Expression] + public static int Method(IExpressionContext context) + { + int? length = context.Request.Body?.As().Length; + System.DateTime? time = null; + return length.HasValue && !time.HasValue ? length.Value : length.GetValueOrDefault(1); + } + } + """ + ); + } + + [TestMethod] + public async Task ShouldAllowAnonymousTypeMembers() + { + await VerifyAsync( + """ + public static class ExpressionLibrary + { + [Expression] + public static int Method(IExpressionContext context) + { + var value = new { a = 1, b = context.Request.Method }; + return value.a + value.b.Length; + } + } + """ + ); + } + + [TestMethod] + public async Task ShouldReportMembersOfObject() + { + await VerifyAsync( + """ + public static class ExpressionLibrary + { + [Expression] + public static string Method(IExpressionContext context) + { + object value = context.Variables["x"]; + var text = {|#0:value.ToString()|} + {|#1:context.User.ToString()|}; + return text + context.Request.Url.ToString() + context.RequestId.ToString() + (value == null); + } + } + """, + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedMember.Id).WithLocation(0).WithArguments("ToString"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedMember.Id).WithLocation(1).WithArguments("ToString") + ); + } + + [TestMethod] + public async Task ShouldReportTypeNameAmbiguousInApiManagement() + { + await VerifyAsync( + """ + using Newtonsoft.Json; + + public static class ExpressionLibrary + { + [Expression] + public static string Method(IExpressionContext context) + { + {|#0:Formatting|} formatting = Newtonsoft.Json.Formatting.None; + return context.Request.Body.As().ToString({|#1:Formatting|}.Indented) + + formatting; + } + + public static Formatting NotAnExpression() => Formatting.Indented; + } + """, + DiagnosticResult.CompilerError(Rules.TypeUsed.AmbiguousTypeName.Id).WithLocation(0) + .WithArguments("Formatting", "Newtonsoft.Json.Formatting"), + DiagnosticResult.CompilerError(Rules.TypeUsed.AmbiguousTypeName.Id).WithLocation(1) + .WithArguments("Formatting", "Newtonsoft.Json.Formatting") + ); + } + + [TestMethod] + public async Task ShouldNotReportAnAliasForAnAmbiguousTypeName() + { + // the compiler writes a using alias out as the name it stands for + await VerifyAsync( + """ + using Formatting = Newtonsoft.Json.Formatting; + + public static class ExpressionLibrary + { + [Expression] + public static string Method(IExpressionContext context) => + context.Request.Body.As().ToString(Formatting.Indented); + } + """ + ); + } + + [TestMethod] + public async Task ShouldNotReportTypeThatIsOnlyNamed() + { + // the gateway checks the members an expression uses: a cast, as, is or typeof naming another type is accepted + await VerifyAsync( + """ + public static class ExpressionLibrary + { + [Expression] + public static bool Method(IExpressionContext context) + { + return (System.IO.Stream)null == null && + (context.Variables["x"] as System.Threading.Thread) == null && + typeof(System.IO.File) != null; + } + } + """ + ); + } + + [TestMethod] + public async Task ShouldAllowTupleMembers() + { + await VerifyAsync( + """ + public static class ExpressionLibrary + { + [Expression] + public static string Method(IExpressionContext context) + { + var pair = System.Tuple.Create(1, "a"); + var triple = new System.Tuple(pair.Item1, pair.Item2, true); + return triple.Item2 + triple.Item3.ToString(); + } + } + """ + ); + } + + [TestMethod] + public async Task ShouldAllowTrace() + { + await VerifyAsync( + """ + public static class ExpressionLibrary + { + [Expression] + public static string Method(IExpressionContext context) + { + context.Trace("message"); + return ""; + } + } + """ + ); + } + + [TestMethod] + public async Task ShouldReportInvocationOfDelegateLocal() + { + await VerifyAsync( + """ + public static class ExpressionLibrary + { + [Expression] + public static string Method(IExpressionContext context) + { + System.Func upper = value => value.ToUpper(); + return {|#0:upper("a")|}; + } + } + """, + DisallowedType(0, "System.Func") + ); + } + + [TestMethod] + public async Task ShouldAllowProductStateAndAuthorization() + { + await VerifyAsync( + """ + public static class ExpressionLibrary + { + [Expression] + public static string Method(IExpressionContext context) + { + if (context.Product.State == ProductState.Published) + { + return context.Product.State.ToString(); + } + + var authorization = (Authorization)context.Variables["auth"]; + return authorization.AccessToken + authorization.Claims.Count; + } + } + """ + ); + } +} diff --git a/test/Test.Analyzers/TypeUsedTests.cs b/test/Test.Analyzers/TypeUsedTests.cs index 8fe1b731..9b0b21b5 100644 --- a/test/Test.Analyzers/TypeUsedTests.cs +++ b/test/Test.Analyzers/TypeUsedTests.cs @@ -356,6 +356,466 @@ public static class ExpressionLibrary ); } + [TestMethod] + public async Task ShouldAnalyseUnattributedHelpersOfDocument() + { + await VerifyAsync( + """ + public class Document : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetHeader("X-Tenant", Tenant(context.ExpressionContext)); + context.SetHeader("X-Host", Host()); + context.SetHeader("X-Name", Limits().ToString()); + context.RateLimit(Limits()); + System.Environment.GetEnvironmentVariable("NotAnExpression"); + } + + // called from a section without the expression context: expanded all the same + static string Host() => {|#3:System.Environment.MachineName|}; + + // nothing of the document calls these, so they never become a policy expression + public override string ToString() => System.Environment.MachineName; + + public static Document Create() => new Document(); + + static string Unused(string value) => value + System.Environment.NewLine; + + // a void method that takes no section context isn't a section: nothing is compiled from it + public void Log() => Unused("x"); + + static string Tenant(IExpressionContext context) => + Tidy(context.Request.Headers.GetValueOrDefault("X-Tenant", "")) + {|#0:System.Environment.MachineName|}; + + static string Tidy(string value) => Text.Trim(value) + Nested.Line() + {|#1:System.Environment.NewLine|}; + + // a factory the section calls: the helper it uses is expanded + static RateLimitConfig Limits() => new RateLimitConfig { Calls = Calls(), RenewalPeriod = 1 }; + + static int Calls() => {|#7:System.Environment.ProcessorCount|}; + + static RateLimitConfig Limits(IInboundContext context) => + new RateLimitConfig { Calls = 1, RenewalPeriod = System.Environment.ProcessorCount }; + + static class Nested + { + public static string Line() => {|#2:System.Environment.NewLine|}; + } + } + + public static class Text + { + public static string Trim(string value) => value.Trim(); + } + """, + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(0) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(1) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(2) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(3) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(7) + .WithArguments("System.Environment") + ); + } + + [TestMethod] + public async Task ShouldFollowCallsToDocumentHelpersHoweverTheyAreWritten() + { + await VerifyAsync( + """ + using N = Mielek.Test.Document.Nested; + + public abstract class BaseDocument : IDocument + { + // expanded where a derived document calls it + protected static string Host() => {|#0:System.Environment.MachineName|}; + + protected string Name() => {|#4:System.Environment.MachineName|}; + } + + public class Document : BaseDocument + { + public void Inbound(IInboundContext context) + { + context.SetHeader("X-Host", Host()); + context.SetHeader("X-Name", base.Name()); + context.SetHeader("X-Paren", (this).Paren()); + context.SetHeader("X-Cast", ((Document)this).Cast()); + context.SetHeader("X-Line", N.Line()); + context.SetHeader("X-This", this.Instance()); + context.SetHeader("X-Full", global::Mielek.Test.Document.Nested.Full()); + context.SetHeader("X-Key", Has(context.ExpressionContext)); + } + + string Instance() => {|#2:System.Environment.MachineName|}; + + string Paren() => {|#5:System.Environment.MachineName|}; + + string Cast() => {|#6:System.Environment.MachineName|}; + + static string Has(IExpressionContext context) => context.Request.Headers.ContainsKey("a") ? "a" : "b"; + + public static class Nested + { + public static string Line() => {|#1:System.Environment.NewLine|}; + + public static string Full() => {|#3:System.Environment.NewLine|}; + } + + // context.Request.Headers.ContainsKey(...) is a call on a value, not on this class + public static class Headers + { + public static bool ContainsKey(string key) => System.Environment.Is64BitProcess; + } + } + """, + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(0) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(1) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(2) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(3) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(4) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(5) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(6) + .WithArguments("System.Environment") + ); + } + + [TestMethod] + public async Task ShouldStartFromASectionImplementedExplicitly() + { + await VerifyAsync( + """ + public class Document : IDocument + { + void IDocument.Inbound(IInboundContext context) + { + context.SetHeader("X-Host", Host()); + context.SetVariable("key", Key(context)); + } + + static string Host() => {|#0:System.Environment.MachineName|}; + + // a helper that takes a section context is expanded like any other: reading its ExpressionContext + // is the gateway's context, anything else in it is checked + static string Key(IHaveExpressionContext holder) => + holder.ExpressionContext.Request.IpAddress + {|#1:System.Environment.MachineName|}; + } + """, + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(0) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(1) + .WithArguments("System.Environment") + ); + } + + [TestMethod] + public async Task ShouldStartFromTheSectionsTheCompilerCompilesOnly() + { + await VerifyAsync( + """ + public class Document : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetHeader("X-Host", Host()); + } + + // not named for a section, or taking more than the section context: the compiler compiles neither, + // so what only they call isn't checked + public void Audit(IInboundContext context) + { + context.SetHeader("X-Audit", Secret()); + } + + public void Inbound(IInboundContext context, int retries) + { + context.SetHeader("X-Retries", Secret()); + } + + // nor is one taking what the section contexts share + public void Inbound(IHaveExpressionContext context) + { + Secret(); + } + + // a method returning an enum or an interface of the authoring library isn't a configuration factory + static DocumentScope Scope() + { + Secret(); + return DocumentScope.Api; + } + + static IHaveExpressionContext Holder() + { + Secret(); + return null!; + } + + // a fragment's section isn't one of a document's + public void Fragment(IFragmentContext context) + { + context.SetHeader("X-Fragment", Secret()); + } + + static string Host() => {|#0:System.Environment.MachineName|}; + + static string Secret() => System.Environment.MachineName; + } + + public class Piece : IFragment + { + public void Fragment(IFragmentContext context) + { + context.SetHeader("X-Host", Host()); + } + + public void Inbound(IInboundContext context) + { + context.SetHeader("X-Inbound", Secret()); + } + + // the compiler compiles the first Fragment method only + public void Fragment(IInboundContext context) + { + context.SetHeader("X-Second", Secret()); + } + + static string Host() => {|#1:System.Environment.MachineName|}; + + static string Secret() => System.Environment.MachineName; + } + """, + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(0) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(1) + .WithArguments("System.Environment") + ); + } + + [TestMethod] + public async Task ShouldStartFromTheFirstFragmentMethodOfEachPart() + { + // the members of a partial class come in file order; the compiler compiles the part marked [Document], so + // the first Fragment method of each part is a starting point, whichever file comes first + var test = new BaseAnalyzerTest( + """ + public partial class Piece + { + public void Fragment(IInboundContext context) + { + context.SetHeader("X-Other", Other()); + } + + static string Other() => {|#0:System.Environment.MachineName|}; + } + """, + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(0) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(1) + .WithArguments("System.Environment")); + test.TestState.Sources.Add( + """ + using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; + + namespace Mielek.Test; + + [Document] + public partial class Piece : IFragment + { + public void Fragment(IFragmentContext context) + { + context.SetHeader("X-Host", Host()); + } + + static string Host() => {|#1:System.Environment.MachineName|}; + } + """); + await test.RunAsync(); + } + + [TestMethod] + public async Task ShouldAnalyseWhatAnotherDocumentUsesOfADocument() + { + await VerifyAsync( + """ + using S = Mielek.Test.Shared; + + public class Shared : IDocument + { + public void Inbound(IInboundContext context) { } + + // a factory is a root wherever it is used from; the helper it calls is analysed + public static RateLimitConfig Limits() => new RateLimitConfig { Calls = Calls(), RenewalPeriod = 1 }; + + static int Calls() => {|#0:System.Environment.ProcessorCount|}; + + static string Section(IInboundContext context) => + context.ExpressionContext.Request.Url.Host + {|#1:System.Environment.MachineName|}; + + // a helper another document calls by a qualified name is analysed; one nothing calls is not + public static string Host() => {|#2:System.Environment.MachineName|}; + + public static string Unused() => System.Environment.MachineName; + + // reached through a helper of the other document, through an [Expression] class, and through an alias + public static string Deep() => {|#4:System.Environment.MachineName|}; + + public static string Lib() => {|#5:System.Environment.MachineName|}; + + public static string Aliased() => {|#6:System.Environment.MachineName|}; + + // a factory nothing calls is a root all the same + public static RateLimitConfig Spare() => new RateLimitConfig { Calls = SpareCalls(), RenewalPeriod = 1 }; + + static int SpareCalls() => {|#7:System.Environment.ProcessorCount|}; + + public static class Nested + { + public static string Line() => {|#3:System.Environment.NewLine|}; + } + } + + public class Document : IDocument + { + public void Inbound(IInboundContext context) + { + context.RateLimit(Shared.Limits()); + context.SetHeader("X-Host", Shared.Host()); + context.SetHeader("X-Line", Line()); + context.SetHeader("X-Own", Unused()); + context.SetHeader("X-Deep", Tag()); + context.SetHeader("X-Lib", Helpers.Tag()); + context.SetHeader("X-Alias", S.Aliased()); + } + + // a bare name in another document is its own method, not Shared's + static string Unused() => "own"; + + static string Tag() => Shared.Deep() + "!"; + + // naming Shared to qualify the call isn't a use of Shared + static string Line() => Shared.Nested.Line(); + } + + [Expression] + public static class Helpers + { + public static string Tag() => Shared.Lib() + "!"; + } + """, + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(0) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(1) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(2) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(3) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(4) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(5) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(6) + .WithArguments("System.Environment"), + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(7) + .WithArguments("System.Environment") + ); + } + + [TestMethod] + public async Task ShouldNotTakeAUsersSectionContextForTheGatewaysContext() + { + // the compiler maps the ExpressionContext of the authoring library's section contexts only + await VerifyAsync( + """ + public interface IMine : IHaveExpressionContext + { + string Extra { get; } + } + + public class Document : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetVariable("key", Key(null!)); + } + + static string Key(IMine holder) => {|#0:holder.ExpressionContext|}.Request.IpAddress; + } + """, + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(0) + .WithArguments("Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.IHaveExpressionContext") + ); + } + + [TestMethod] + public async Task ShouldFollowACallQualifiedWithTheClassADocumentIsNestedIn() + { + await VerifyAsync( + """ + public static class Outer + { + public class Document : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetHeader("X-Line", Outer.Document.Nested.Line()); + } + + public static class Nested + { + public static string Line() => {|#0:System.Environment.NewLine|}; + } + } + } + """, + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(0) + .WithArguments("System.Environment") + ); + } + + [TestMethod] + public async Task ShouldFollowACallQualifiedWithADerivedDocumentsNamespace() + { + var test = new BaseAnalyzerTest( + """ + public class Base : IDocument + { + public void Inbound(IInboundContext context) { } + + public static string Host() => {|#0:System.Environment.MachineName|}; + } + + public class Document : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetHeader("X-Host", Other.Derived.Host()); + } + } + """, + DiagnosticResult.CompilerError(Rules.TypeUsed.DisallowedType.Id).WithLocation(0) + .WithArguments("System.Environment")); + // the derived document is declared in another namespace, which a call to the base's helper is written with + test.TestState.Sources.Add( + """ + namespace Other; + + public class Derived : Mielek.Test.Base { } + """); + await test.RunAsync(); + } + [TestMethod] public async Task ShouldAllowDictionaryExtensions() { diff --git a/test/Test.Core/CompilerTestInitialize.cs b/test/Test.Core/CompilerTestInitialize.cs index 527b0865..7cf92eb9 100644 --- a/test/Test.Core/CompilerTestInitialize.cs +++ b/test/Test.Core/CompilerTestInitialize.cs @@ -19,7 +19,11 @@ public static class CompilerTestInitialize private static readonly IEnumerable References = [ MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + // the authoring library refers to the types of its members through System.Runtime + MetadataReference.CreateFromFile( + Path.Combine(Path.GetDirectoryName(typeof(object).Assembly.Location)!, "System.Runtime.dll")), MetadataReference.CreateFromFile(typeof(XElement).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location), MetadataReference.CreateFromFile(typeof(IDocument).Assembly.Location) ]; @@ -42,6 +46,31 @@ public static void CompilerCleanup() s_serviceProvider.Dispose(); } + /// A policy document whose inbound section holds the statements. + public static string InboundDocument(string statements, string members = "") => + $$""" + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) + { + {{statements}} + } + + {{members}} + } + """; + + /// The policy XML of a document whose inbound section holds the policy. + public static string InboundXml(string policy) => + $""" + + + {policy.ReplaceLineEndings("\n ")} + + + """; + public static IDocumentCompilationResult CompileDocument(this string document) => document.CompileDocument([]); public static IDocumentCompilationResult CompileDocument(this string document, params string[] separateDocuments) diff --git a/test/Test.Core/Compiling/AuthenticationCertificateTests.cs b/test/Test.Core/Compiling/AuthenticationCertificateTests.cs index 807aad57..db37bc51 100644 --- a/test/Test.Core/Compiling/AuthenticationCertificateTests.cs +++ b/test/Test.Core/Compiling/AuthenticationCertificateTests.cs @@ -155,4 +155,15 @@ public void ShouldCompileAuthenticationCertificatePolicy(string code, string exp { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldReportCollectionGivenForScalarProperty() + { + var result = CompilerTestInitialize.InboundDocument( + """ + context.AuthenticationCertificate(new CertificateAuthenticationConfig { Body = new byte[] { 1, 2 } }); + """).CompileDocument(); + + result.Errors.Should().Contain(error => error.Id == "APIM2005"); + } +} diff --git a/test/Test.Core/Compiling/AzureOpenAiSemanticCacheStoreTests.cs b/test/Test.Core/Compiling/AzureOpenAiSemanticCacheStoreTests.cs index 2ebe83b6..ebcc19a4 100644 --- a/test/Test.Core/Compiling/AzureOpenAiSemanticCacheStoreTests.cs +++ b/test/Test.Core/Compiling/AzureOpenAiSemanticCacheStoreTests.cs @@ -43,7 +43,7 @@ public void Outbound(IOutboundContext context) """ - + """, diff --git a/test/Test.Core/Compiling/AzureOpenAiTokenLimitTests.cs b/test/Test.Core/Compiling/AzureOpenAiTokenLimitTests.cs index a1663f7f..df0932de 100644 --- a/test/Test.Core/Compiling/AzureOpenAiTokenLimitTests.cs +++ b/test/Test.Core/Compiling/AzureOpenAiTokenLimitTests.cs @@ -161,7 +161,7 @@ public void Inbound(IInboundContext context) """ - + """, diff --git a/test/Test.Core/Compiling/CacheRemoveValueTests.cs b/test/Test.Core/Compiling/CacheRemoveValueTests.cs index 818a328e..b903dd49 100644 --- a/test/Test.Core/Compiling/CacheRemoveValueTests.cs +++ b/test/Test.Core/Compiling/CacheRemoveValueTests.cs @@ -112,4 +112,34 @@ public void ShouldCompileCacheRemoveValuePolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldCompileFailOnCacheRemovalError() + { + var code = CompilerTestInitialize.InboundDocument( + """ + context.CacheRemoveValue(new CacheRemoveValueConfig + { + Key = "cacheKey", + FailOnCacheRemovalError = true + }); + """); + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(CompilerTestInitialize.InboundXml( + """ + + """)); + } + + [TestMethod] + public void ShouldTreatNullConfigPropertyAsNotSet() + { + var code = CompilerTestInitialize.InboundDocument( + """ + context.CacheRemoveValue(new CacheRemoveValueConfig { Key = "k", CachingType = null }); + """); + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo( + CompilerTestInitialize.InboundXml("""""")); + } +} diff --git a/test/Test.Core/Compiling/CacheStoreTests.cs b/test/Test.Core/Compiling/CacheStoreTests.cs index e4788966..680fe1a2 100644 --- a/test/Test.Core/Compiling/CacheStoreTests.cs +++ b/test/Test.Core/Compiling/CacheStoreTests.cs @@ -57,7 +57,7 @@ public void Outbound(IOutboundContext context) context.CacheStore(Duration(context.ExpressionContext), StoreResponse(context.ExpressionContext)); } - uint Duration(IExpressionContext context) => context.User.Email.EndsWith("@contoso.example") ? 10 : 60; + int Duration(IExpressionContext context) => context.User.Email.EndsWith("@contoso.example") ? 10 : 60; bool StoreResponse(IExpressionContext context) => context.User.Email.EndsWith("@contoso.example"); } """, @@ -74,4 +74,32 @@ public void ShouldCompileCacheStorePolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } + + [TestMethod] + [DataRow("null")] + [DataRow("default")] + [DataRow("(bool?)null")] + public void ShouldLeaveOutCacheResponseThatIsNull(string cacheResponse) + { + var code = + $$""" + [Document] + public class PolicyDocument : IDocument + { + public void Outbound(IOutboundContext context) + { + context.CacheStore(10, {{cacheResponse}}); + } + } + """; + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo( + """ + + + + + + """); + } } \ No newline at end of file diff --git a/test/Test.Core/Compiling/CacheStoreValueTests.cs b/test/Test.Core/Compiling/CacheStoreValueTests.cs index 4f8f5539..eddaeb36 100644 --- a/test/Test.Core/Compiling/CacheStoreValueTests.cs +++ b/test/Test.Core/Compiling/CacheStoreValueTests.cs @@ -134,7 +134,7 @@ public void Inbound(IInboundContext context) Duration = Exp(context.ExpressionContext), }); } - uint Exp(IExpressionContext context) + int Exp(IExpressionContext context) => context.User.Email.EndsWith("@contoso.example") ? 10 : 60; } """, diff --git a/test/Test.Core/Compiling/CheckHeaderTests.cs b/test/Test.Core/Compiling/CheckHeaderTests.cs index ef65f7b9..6193b669 100644 --- a/test/Test.Core/Compiling/CheckHeaderTests.cs +++ b/test/Test.Core/Compiling/CheckHeaderTests.cs @@ -196,4 +196,72 @@ public void ShouldCompileCheckHeaderPolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldCompileExistenceOnlyCheck() + { + var code = CompilerTestInitialize.InboundDocument( + """ + context.CheckHeader(new CheckHeaderConfig + { + Name = "X-Required", + FailCheckHttpCode = 400, + FailCheckErrorMessage = "Missing header", + IgnoreCase = true + }); + """); + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(CompilerTestInitialize.InboundXml( + """ + + """)); + } + + [TestMethod] + public void ShouldReportValuesThatAreNotWrittenInPlace() + { + var code = + """ + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) + { + context.CheckHeader(new CheckHeaderConfig + { + Name = "X-Required", + FailCheckHttpCode = 400, + FailCheckErrorMessage = "Missing header", + IgnoreCase = true, + Values = Allowed() + }); + } + + static string[] Allowed() => new[] { "a" }; + } + """; + + code.CompileDocument().Errors.Should().ContainSingle(error => error.Id == "APIM2005"); + } + + [TestMethod] + public void ShouldCompileDefaultOfAValueTypeAsItsValue() + { + // default is false here, not "not set" + var code = CompilerTestInitialize.InboundDocument( + """ + context.CheckHeader(new CheckHeaderConfig + { + Name = "X-Required", + FailCheckHttpCode = 400, + FailCheckErrorMessage = "Missing header", + IgnoreCase = default + }); + """); + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(CompilerTestInitialize.InboundXml( + """ + + """)); + } +} diff --git a/test/Test.Core/Compiling/ChooseTests.cs b/test/Test.Core/Compiling/ChooseTests.cs index bc7e5f47..3c320299 100644 --- a/test/Test.Core/Compiling/ChooseTests.cs +++ b/test/Test.Core/Compiling/ChooseTests.cs @@ -149,4 +149,54 @@ public void ShouldCompileChoosePolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldCompileConstantConditionsAndIdOnIfStatement() + { + var code = CompilerTestInitialize.InboundDocument( + """ + context.WithId("route"); + if (true) + { + context.SetHeader("X-A", "1"); + } + else if (false) + { + context.SetHeader("X-B", "1"); + } + else + { + context.WithId("inner").SetHeader("X-C", "1"); + } + context.SetHeader("X-D", "1"); + """); + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo( + """ + + + + + + 1 + + + + + 1 + + + + + 1 + + + + + 1 + + + + """); + } +} diff --git a/test/Test.Core/Compiling/CorsTests.cs b/test/Test.Core/Compiling/CorsTests.cs index 4dd9cdcc..40c2157f 100644 --- a/test/Test.Core/Compiling/CorsTests.cs +++ b/test/Test.Core/Compiling/CorsTests.cs @@ -240,4 +240,20 @@ public void ShouldCompileCorsPolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldReportCorsPreflightMaxAgeWithoutAllowedMethods() + { + var result = CompilerTestInitialize.InboundDocument( + """ + context.Cors(new CorsConfig + { + AllowedOrigins = ["*"], + AllowedHeaders = ["*"], + PreflightResultMaxAge = 300 + }); + """).CompileDocument(); + + result.Errors.Should().ContainSingle(error => error.Id == "APIM2030"); + } +} diff --git a/test/Test.Core/Compiling/DocumentTypeTests.cs b/test/Test.Core/Compiling/DocumentTypeTests.cs index 6436abc3..b912db89 100644 --- a/test/Test.Core/Compiling/DocumentTypeTests.cs +++ b/test/Test.Core/Compiling/DocumentTypeTests.cs @@ -84,7 +84,7 @@ public class NamedPolicyFragment : IFragment public void Fragment(IFragmentContext context) { context.SetHeader("X-Named-Fragment", "named-value"); - context.Base(); + context.RemoveHeader("X-Test"); } } """, @@ -93,7 +93,7 @@ public void Fragment(IFragmentContext context) named-value - + """, DisplayName = "Should compile named policy fragment with multiple policies using Fragment method" @@ -111,7 +111,7 @@ public class NamedPolicyFragment : IFragment { public void Fragment(IFragmentContext context) { - context.Base(); + context.RemoveHeader("X-Test"); } } @@ -131,7 +131,7 @@ public class NamedPolicyFragment : IFragment public void Fragment(IFragmentContext context) { - context.Base(); + context.RemoveHeader("X-Test"); } } """, @@ -146,7 +146,7 @@ public class NamedPolicyFragment : IFragment public void Fragment(IFragmentContext context) { - context.Base(); + context.RemoveHeader("X-Test"); } } """, @@ -181,7 +181,7 @@ public class NamedPolicyFragment : IFragment { public void Fragment(IFragmentContext context) { - context.Base(); + context.RemoveHeader("X-Test"); } } """; diff --git a/test/Test.Core/Compiling/ForwardRequestTests.cs b/test/Test.Core/Compiling/ForwardRequestTests.cs index 2edc2b45..7242b8aa 100644 --- a/test/Test.Core/Compiling/ForwardRequestTests.cs +++ b/test/Test.Core/Compiling/ForwardRequestTests.cs @@ -340,4 +340,31 @@ public void ShouldCompileForwardRequestPolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldCompileForwardRequestFromConfigFactory() + { + var code = + """ + [Document] + public class PolicyDocument : IDocument + { + public void Backend(IBackendContext context) + { + context.ForwardRequest(Forward()); + } + + static ForwardRequestConfig Forward() => new ForwardRequestConfig { Timeout = 30 }; + } + """; + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo( + """ + + + + + + """); + } +} diff --git a/test/Test.Core/Compiling/InlinePolicyExpressionTests.cs b/test/Test.Core/Compiling/InlinePolicyExpressionTests.cs new file mode 100644 index 00000000..550e1a29 --- /dev/null +++ b/test/Test.Core/Compiling/InlinePolicyExpressionTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Xml.Linq; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling; + +[TestClass] +public class InlinePolicyExpressionTests +{ + private const string RawPolicy = + """@(context.Request.Body.As()["a"].ToString())"""; + + private const string Condition = """@(context.Request.Method == "GET" && context.Request.Headers.Count < 3)"""; + private const string Thing = """@(context.Request.Body.As()["a"].ToString())"""; + + [TestMethod] + public void ShouldCompileRawExpressions() + { + var policy = CompileInlinePolicy(RawPolicy); + + policy.Attribute("cond")!.Value.Should().Be(Condition); + policy.Element("thing")!.Value.Should().Be(Thing); + } + + [TestMethod] + [DataRow("""""", "x @(1) y")] + [DataRow("""""", "x @(\"1\") y")] + [DataRow("""""", "@(\"1\")@(\"2\")")] + public void ShouldRestoreExpressionsInsideAttributeValue(string policy, string expected) + { + var element = CompileInlinePolicy(policy); + + element.Attribute("b")!.Value.Should().Be(expected); + } + + [TestMethod] + [DataRow("""@{ return "}"; }""", "@{return \"}\";}")] + [DataRow("""@(")".Length < 2)""", "@(\")\".Length < 2)")] + [DataRow("""prefix @("a" + "b") suffix""", "prefix @(\"a\" + \"b\") suffix")] + public void ShouldRestoreExpressionsInsideElementValue(string policy, string expected) + { + var element = CompileInlinePolicy(policy); + + element.Value.Should().Be(expected); + } + + [TestMethod] + public void ShouldRestoreExpressionsInMixedContent() + { + var element = CompileInlinePolicy("""@("a" + "<")@("h")"""); + + element.ToString(SaveOptions.DisableFormatting).Should().NotContain("__expression__"); + element.Nodes().OfType().Select(t => t.Value).Should().Equal("@(\"a\" + \"<\")", "@(\"h\")"); + element.Element("d")!.Attribute("e")!.Value.Should().Be("@(\"f\")"); + element.Nodes().OfType().Single().Value.Should().Be(" @(\"g\") "); + } + + [TestMethod] + public void ShouldKeepLineBreaksOfMultiLineExpressionInAttribute() + { + var element = CompileInlinePolicy(""); + + element.Attribute("b")!.Value.Should().Be("@{var x = 1; // comment\nreturn x;}"); + } + + [TestMethod] + public void ShouldReportErrorForUnbalancedExpression() + { + var result = Compile(""""""); + + result.Errors.Should().ContainSingle(); + } + + private static XElement CompileInlinePolicy(string policy) + { + var result = Compile(policy); + result.Should().BeSuccessful(); + return result.Document.Element("inbound")!.Elements().Single(); + } + + private static IDocumentCompilationResult Compile(string policy) + { + var literal = Microsoft.CodeAnalysis.CSharp.SymbolDisplay.FormatLiteral(policy, quote: true); + var code = + $$""" + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) + { + context.InlinePolicy({{literal}}); + } + } + """; + return code.CompileDocument(); + } + + [TestMethod] + [DataRow("""""", DisplayName = "well-formed XML")] + [DataRow("""""", DisplayName = "not well-formed XML")] + public void ShouldReadExpressionAsWrittenWhetherOrNotThePolicyIsWellFormedXml(string policy) + { + // the policy is rawxml: an entity inside an expression is C# text, it is not unescaped + var element = CompileInlinePolicy(policy); + + element.Attribute("b")!.Value.Should().Be("@(\"a&b\")"); + } + + [TestMethod] + public void ShouldKeepLeadingCommentOfInlinePolicyExpression() + { + var code = CompilerTestInitialize.InboundDocument( + """" + context.InlinePolicy(""" + + """); + """"); + + var result = code.CompileDocument(); + + result.Should().BeSuccessful(); + result.Document.ToString().Should().Contain("// leading comment").And.Contain("/* block */"); + } + + [TestMethod] + public void ShouldKeepCDataSectionWhenWritingADocument() + { + var result = CompilerTestInitialize.InboundDocument( + """" + context.InlinePolicy(""" & b]]>"""); + """").CompileDocument(); + result.Should().BeSuccessful(); + + var written = new System.Text.StringBuilder(); + using (var writer = Serialization.CustomXmlWriter.Create( + written, new System.Xml.XmlWriterSettings { Indent = true, OmitXmlDeclaration = true })) + { + writer.Write(result.Document); + } + + written.ToString().Should().Contain(" & b]]>"); + } +} diff --git a/test/Test.Core/Compiling/IpFilterTests.cs b/test/Test.Core/Compiling/IpFilterTests.cs index aad14a84..a39c0fcd 100644 --- a/test/Test.Core/Compiling/IpFilterTests.cs +++ b/test/Test.Core/Compiling/IpFilterTests.cs @@ -72,4 +72,16 @@ public void ShouldCompileIpFilterPolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldReportNonInlineIpFilterAddresses() + { + var result = CompilerTestInitialize.InboundDocument( + """ + context.IpFilter(new IpFilterConfig { Action = "allow", Addresses = GetAddresses() }); + """, + """static string[] GetAddresses() => new[] { "10.0.0.1" };""").CompileDocument(); + + result.Errors.Should().Contain(error => error.Id == "APIM2005"); + } +} diff --git a/test/Test.Core/Compiling/LanguageVersionCheckInterpolationTests.cs b/test/Test.Core/Compiling/LanguageVersionCheckInterpolationTests.cs new file mode 100644 index 00000000..d9420d1b --- /dev/null +++ b/test/Test.Core/Compiling/LanguageVersionCheckInterpolationTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using static Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling.PolicyExpressionTestHelpers; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling; + +[TestClass] +public class LanguageVersionCheckInterpolationTests +{ + [TestMethod] + public void ShouldAcceptNewLineInInterpolationOfExpressionBody() + { + var result = CompileEvaluate( + """ + private static string Evaluate(IExpressionContext context) => + $"{context.Request.Method + .ToUpper()}-{ + context.Api.Name}"; + """); + + result.Should().BeSuccessful(); + Value(result).Should().Be("@($\"{context.Request.Method.ToUpper()}-{context.Api.Name}\")"); + } + + [TestMethod] + public void ShouldAcceptNewLineInInterpolationOfBlockBody() + { + var result = CompileEvaluate( + """ + private static string Evaluate(IExpressionContext context) + { + var name = context.Api.Name; + return $"{context.Request.Method + .ToUpper()}-{ + name}"; + } + """); + + result.Should().BeSuccessful(); + Value(result).Should().Contain("return $\"{context.Request.Method.ToUpper()}-{name}\";"); + } + + [TestMethod] + public void ShouldStillReportOtherNewerFeatureInInterpolationWithNewLine() + { + var result = CompileEvaluate( + """ + private static string Evaluate(IExpressionContext context) => + $"{context.Variables.Count switch { 0 => "none", + _ => "some" }}"; + """); + + result.Errors.Should().NotBeEmpty(); + result.Errors.Should().OnlyContain(error => error.Id == "APIM2019"); + result.Errors.Should().NotContain(error => error.GetMessage(null).Contains("Newlines")); + } +} diff --git a/test/Test.Core/Compiling/LimitConcurrencyTests.cs b/test/Test.Core/Compiling/LimitConcurrencyTests.cs index 4c7303c1..7cb4e9f8 100644 --- a/test/Test.Core/Compiling/LimitConcurrencyTests.cs +++ b/test/Test.Core/Compiling/LimitConcurrencyTests.cs @@ -48,6 +48,8 @@ public void Outbound(IOutboundContext context) () => { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); }); @@ -63,6 +65,8 @@ public void OnError(IOnErrorContext context) () => { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); }); @@ -83,12 +87,18 @@ public void OnError(IOnErrorContext context) - + + https://example.com/api + GET + - + + https://example.com/api + GET + diff --git a/test/Test.Core/Compiling/LlmContentSafetyTests.cs b/test/Test.Core/Compiling/LlmContentSafetyTests.cs index 7720b5ea..f7465bbf 100644 --- a/test/Test.Core/Compiling/LlmContentSafetyTests.cs +++ b/test/Test.Core/Compiling/LlmContentSafetyTests.cs @@ -125,10 +125,10 @@ public void Inbound(IInboundContext context) - + blockList1 blockList2 - + @@ -267,10 +267,10 @@ public void Inbound(IInboundContext context) - + @(context.Variables["blockListId"].ToString()) blockList2 - + @@ -349,10 +349,10 @@ public void Inbound(IInboundContext context) - + blockList1 blockList2 - + @@ -363,4 +363,30 @@ public void ShouldCompileLlmContentSafetyPolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldCompileBlocklistsAndStreamingWindowAttributes() + { + var code = CompilerTestInitialize.InboundDocument( + """ + context.LlmContentSafety(new LlmContentSafetyConfig + { + BackendId = "safety", + EnforceOnCompletions = true, + WindowSize = 500, + WindowOverlapSize = 100, + BlockLists = new ContentSafetyBlockLists { Ids = ["list-1", "list-2"] } + }); + """); + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(CompilerTestInitialize.InboundXml( + """ + + + list-1 + list-2 + + + """)); + } +} diff --git a/test/Test.Core/Compiling/LlmSemanticCacheStoreTests.cs b/test/Test.Core/Compiling/LlmSemanticCacheStoreTests.cs index 60f4d9f9..9a44651f 100644 --- a/test/Test.Core/Compiling/LlmSemanticCacheStoreTests.cs +++ b/test/Test.Core/Compiling/LlmSemanticCacheStoreTests.cs @@ -43,7 +43,7 @@ public void Outbound(IOutboundContext context) """ - + """, @@ -53,4 +53,33 @@ public void ShouldCompileLlmSemanticCacheStorePolicy(string code, string expecte { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + [DataRow("LlmSemanticCacheStore", "llm-semantic-cache-store")] + [DataRow("AzureOpenAiSemanticCacheStore", "azure-openai-semantic-cache-store")] + public void ShouldCompileSemanticCacheStoreWithCacheResponse(string method, string policy) + { + var code = + $$""" + [Document] + public class PolicyDocument : IDocument + { + public void Outbound(IOutboundContext context) + { + context.{{method}}(60, true); + context.{{method}}(120); + } + } + """; + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo( + $""" + + + <{policy} duration="60" cache-response="true" /> + <{policy} duration="120" /> + + + """); + } +} diff --git a/test/Test.Core/Compiling/LlmTokenLimitTests.cs b/test/Test.Core/Compiling/LlmTokenLimitTests.cs index 970c333d..9640f072 100644 --- a/test/Test.Core/Compiling/LlmTokenLimitTests.cs +++ b/test/Test.Core/Compiling/LlmTokenLimitTests.cs @@ -161,7 +161,7 @@ public void Inbound(IInboundContext context) """ - + """, @@ -314,4 +314,127 @@ public void ShouldCompileLlmTokenLimitPolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldCompileRateLimitTogetherWithQuota() + { + var code = CompilerTestInitialize.InboundDocument( + """ + context.LlmTokenLimit(new TokenLimitConfig + { + CounterKey = "key", + EstimatePromptTokens = true, + TokensPerMinute = 100, + TokenQuota = 1000, + TokenQuotaPeriod = "Daily" + }); + """); + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(CompilerTestInitialize.InboundXml( + """ + + """)); + } + + [TestMethod] + public void ShouldConvertEveryReturnOfAMultiStatementQuotaHelper() + { + var code = + """ + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) + { + context.LlmTokenLimit(new TokenLimitConfig + { + CounterKey = "counter-key", + EstimatePromptTokens = true, + TokenQuota = GetQuota(context.ExpressionContext), + TokenQuotaPeriod = "Daily" + }); + } + + int GetQuota(IExpressionContext context) + { + if (context.User.Groups.Count() > 1) + { + return 50000; + } + + var each = context.User.Groups.Select(group => { return 1; }).Sum(); + return each * 1000; + } + } + """; + + var result = code.CompileDocument(); + + result.Should().BeSuccessful(); + var quota = result.Document.Descendants("llm-token-limit").Single().Attribute("token-quota")!.Value; + quota.Should().Contain("return (long)(50000);").And.Contain("return (long)(each * 1000);") + .And.Contain("return 1;"); + } + + [TestMethod] + public void ShouldConvertAQuotaHelperThatReadsANamedValue() + { + var code = + """ + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) + { + context.LlmTokenLimit(new TokenLimitConfig + { + CounterKey = "a", + EstimatePromptTokens = true, + TokenQuota = Block(context.ExpressionContext), + TokenQuotaPeriod = "Daily" + }); + context.SetVariable("b", Wide(context.ExpressionContext)); + } + + int Block(IExpressionContext context) + { + if (context.User.Groups.Count() > 1) + { + return 1; + } + + return (int)context.NamedValue("n"); + } + + long Wide(IExpressionContext context) => Narrow(context); + + [NamedValue("n")] + int Narrow(IExpressionContext context) => context.NamedValue("n"); + } + """; + + var result = code.CompileDocument(); + + result.Should().BeSuccessful(); + var quota = result.Document.Descendants("llm-token-limit").Single().Attribute("token-quota")!.Value; + quota.Should().Contain("return (long)(1);").And.Contain("return (long)((int)({{n}}));"); + // the cast applies to all of the text the named value stands for + result.Document.Descendants("set-variable").Single().Attribute("value")!.Value.Should().Contain("(long)({{n}})"); + } + + [TestMethod] + public void ShouldReportTokenLimitWithoutRateOrQuota() + { + var result = CompilerTestInitialize.InboundDocument( + """ + context.AzureOpenAiTokenLimit(new TokenLimitConfig + { + CounterKey = "key", + EstimatePromptTokens = true + }); + """).CompileDocument(); + + result.Errors.Should().ContainSingle(error => + error.Id == "APIM9996" && error.GetMessage(null).Contains("azure-openai-token-limit")); + } +} diff --git a/test/Test.Core/Compiling/NamedValueNameTests.cs b/test/Test.Core/Compiling/NamedValueNameTests.cs new file mode 100644 index 00000000..81dc2411 --- /dev/null +++ b/test/Test.Core/Compiling/NamedValueNameTests.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using static Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling.PolicyExpressionTestHelpers; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Compiling; + +[TestClass] +public class NamedValueNameTests +{ + [TestMethod] + [DataRow("context.NamedValue(context.Request.Headers.GetValueOrDefault(\"name\", \"\"))", + DisplayName = "Name from the request")] + [DataRow("context.NamedValue(\"prefix-\" + context.Api.Name)", DisplayName = "Concatenated name")] + [DataRow("context.NamedValue(context.Api.Name).ToString()", DisplayName = "Member of the named value")] + public void ShouldReportNamedValueWithNonConstantName(string expression) + { + var result = CompileEvaluate( + $$""" + private static string Evaluate(IExpressionContext context) => {{expression}}; + """); + + result.Errors.Should().ContainSingle().Which.Id.Should().Be("APIM2040"); + } + + [TestMethod] + public void ShouldReportNamedValueWithLocalVariableName() + { + var result = CompileEvaluate( + """ + private static string Evaluate(IExpressionContext context) + { + var name = context.Api.Name; + return context.NamedValue(name); + } + """); + + result.Errors.Should().ContainSingle().Which.Id.Should().Be("APIM2040"); + } + + [TestMethod] + public void ShouldReportNamedValueWithNonConstantNameInHelper() + { + var result = CompileEvaluate( + """ + private static string Evaluate(IExpressionContext context) => Get(context, context.Api.Name); + private static string Get(IExpressionContext context, string name) => context.NamedValue(name); + """); + + result.Errors.Should().NotBeEmpty(); + result.Errors.Should().OnlyContain(error => error.Id == "APIM2040"); + } + + [TestMethod] + public void ShouldCompileNamedValueWithConstantName() + { + var result = CompileEvaluate( + """ + private const string Name = "host"; + private static string Evaluate(IExpressionContext context) => + "https://" + context.NamedValue(Name) + context.NamedValue("pa" + "th") + context.NamedValue(nameof(Name)); + """); + + result.Should().BeSuccessful(); + Value(result).Should().Contain("{{host}}").And.Contain("{{path}}").And.Contain("{{Name}}"); + } + + [TestMethod] + public void ShouldReportNamedValueWithNonConstantNameInExpressionMethod() + { + var result = + """ + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetVariable("value", Get(context.ExpressionContext)); + } + + [Expression] + private static string Get(IExpressionContext context) => context.NamedValue(context.Api.Name); + } + """.CompileDocument(); + + result.Errors.Should().ContainSingle().Which.Id.Should().Be("APIM2040"); + } +} diff --git a/test/Test.Core/Compiling/NamedValueTests.cs b/test/Test.Core/Compiling/NamedValueTests.cs index dada2a07..285f7eb3 100644 --- a/test/Test.Core/Compiling/NamedValueTests.cs +++ b/test/Test.Core/Compiling/NamedValueTests.cs @@ -29,6 +29,34 @@ public void Inbound(IInboundContext context) { """, DisplayName = "Should compile simple NamedValue to {{token}}" )] + [DataRow( + """ + // a second NamedValueAttribute in scope leaves the attribute unresolved: it is read by its name as written + public class NamedValueAttribute : Attribute + { + public NamedValueAttribute(string value) { } + } + + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) { + context.SetVariable("key", Key(context.ExpressionContext)); + } + + [NamedValue("Api-Key")] + string Key(IExpressionContext context) => throw new NotImplementedException(); + } + """, + """ + + + + + + """, + DisplayName = "Should compile NamedValue that can't be resolved by its name" + )] [DataRow( """ [Document] diff --git a/test/Test.Core/Compiling/PolicyExpressionCompilerTests.cs b/test/Test.Core/Compiling/PolicyExpressionCompilerTests.cs index 9e192f38..1b23ba8d 100644 --- a/test/Test.Core/Compiling/PolicyExpressionCompilerTests.cs +++ b/test/Test.Core/Compiling/PolicyExpressionCompilerTests.cs @@ -121,7 +121,7 @@ private static bool CanReadResponse(IExpressionContext context) } [TestMethod] - public void ShouldRenderConstantsAsPlainXmlAttributeValues() + public void ShouldRenderConstantsAsAttributeValuesThatKeepTheirType() { const string document = """ @@ -154,8 +154,10 @@ public void Inbound(IInboundContext context) .ToDictionary( element => element.Attribute("name")!.Value, element => element.Attribute("value")!.Value); - values["offset"].Should().Be("-0.5"); - values["count"].Should().Be("-5"); + // a set-variable value written as plain text is a string in the gateway, so a constant that isn't a + // string is written as an expression + values["offset"].Should().Be("@(-0.5)"); + values["count"].Should().Be("@(-5L)"); } [TestMethod] @@ -596,4 +598,94 @@ public static class Extensions result.Errors.Should().Contain(error => error.Id == "APIM2013"); } -} \ No newline at end of file + + [TestMethod] + public void ShouldCompileBackendWorkspaceAndGatewayContextMembers() + { + var code = + """ + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetHeader("X-Info", Info(context.ExpressionContext)); + } + + string Info(IExpressionContext context) => + context.Backend.Id + context.Workspace.Id + context.Deployment.Gateway.Id; + } + """; + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo( + """ + + + + @(context.Backend.Id + context.Workspace.Id + context.Deployment.Gateway.Id) + + + + """); + } + + [TestMethod] + public void ShouldWriteOutOnlyTheConversionsOfAReturnedValueThatTheGatewayNeeds() + { + var code = + """ + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetVariable("a", MaxAge(context.ExpressionContext)); + context.SetVariable("b", Count(context.ExpressionContext)); + context.SetVariable("c", Same(context.ExpressionContext)); + context.SetVariable("d", Block(context.ExpressionContext)); + context.SetVariable("e", Optional(context.ExpressionContext)); + context.SetVariable("f", Boxed(context.ExpressionContext)); + } + + object Boxed(IExpressionContext context) => Inner(context); + object Inner(IExpressionContext context) => context.Request.Method; + + uint? Optional(IExpressionContext context) => 300; + uint MaxAge(IExpressionContext context) => 300; + long Count(IExpressionContext context) => context.Response.StatusCode + 1; + int Same(IExpressionContext context) => context.Response.StatusCode; + uint Block(IExpressionContext context) + { + var status = context.Response.StatusCode; + if (status > 200) + { + return 1; + } + return (uint)status; + } + } + """; + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo( + """ + + + + + + + + + + + """); + } +} diff --git a/test/Test.Core/Compiling/RateLimitTests.cs b/test/Test.Core/Compiling/RateLimitTests.cs index 5f2848f8..ced45a8f 100644 --- a/test/Test.Core/Compiling/RateLimitTests.cs +++ b/test/Test.Core/Compiling/RateLimitTests.cs @@ -320,4 +320,57 @@ public void Inbound(IInboundContext context) { result.Errors.Should().ContainSingle(error => error.Id == "APIM2002"); } -} \ No newline at end of file + + [TestMethod] + [DataRow("context.RateLimit(new RateLimitConfig { Calls = 1, RenewalPeriod = 301 });", true)] + [DataRow("context.RateLimit(new RateLimitConfig { Calls = 1, RenewalPeriod = 300 });", false)] + [DataRow("context.RateLimitByKey(new RateLimitByKeyConfig { Calls = 1, RenewalPeriod = 0, CounterKey = \"k\" });", true)] + [DataRow("context.QuotaByKey(new QuotaByKeyConfig { Calls = 1, RenewalPeriod = 299, CounterKey = \"k\" });", true)] + [DataRow("context.QuotaByKey(new QuotaByKeyConfig { Calls = 1, RenewalPeriod = 300, CounterKey = \"k\" });", false)] + // the api and operation limits have the same range + [DataRow("context.RateLimit(new RateLimitConfig { Calls = 1, RenewalPeriod = 10, Apis = [new ApiRateLimit { Name = \"a\", Calls = 1, RenewalPeriod = 301 }] });", true)] + [DataRow("context.RateLimit(new RateLimitConfig { Calls = 1, RenewalPeriod = 10, Apis = [new ApiRateLimit { Name = \"a\", Calls = 1, RenewalPeriod = 10, Operations = [new OperationRateLimit { Name = \"o\", Calls = 1, RenewalPeriod = 0 }] }] });", true)] + [DataRow("context.RateLimit(new RateLimitConfig { Calls = 1, RenewalPeriod = 10, Apis = [new ApiRateLimit { Name = \"a\", Calls = 1, RenewalPeriod = 300, Operations = [new OperationRateLimit { Name = \"o\", Calls = 1, RenewalPeriod = 1 }] }] });", false)] + public void ShouldValidateRenewalPeriodRange(string policy, bool rejected) + { + var errors = CompilerTestInitialize.InboundDocument(policy).CompileDocument().Errors; + + if (rejected) + { + errors.Should().ContainSingle(error => error.Id == "APIM2020"); + } + else + { + errors.Should().BeEmpty(); + } + } + + [TestMethod] + public void ShouldReportRateLimitDiagnosticsWithRateLimitPolicyName() + { + var result = CompilerTestInitialize.InboundDocument( + """ + context.RateLimit(new RateLimitConfig { RenewalPeriod = 10 }); + """).CompileDocument(); + + result.Errors.Should().ContainSingle(error => + error.Id == "APIM2006" && error.GetMessage(null).Contains("'rate-limit'")); + } + + [TestMethod] + [DataRow("Apis = GetApis()", "APIM2005")] + [DataRow("Apis = [GetApi()]", "APIM2002")] + public void ShouldReportNonInlineRateLimitApis(string apis, string expected) + { + var result = CompilerTestInitialize.InboundDocument( + $$""" + context.RateLimit(new RateLimitConfig { Calls = 1, RenewalPeriod = 10, {{apis}} }); + """, + """ + static ApiRateLimit[] GetApis() => new[] { GetApi() }; + static ApiRateLimit GetApi() => new ApiRateLimit { Name = "a", Calls = 1, RenewalPeriod = 10 }; + """).CompileDocument(); + + result.Errors.Should().Contain(error => error.Id == expected); + } +} diff --git a/test/Test.Core/Compiling/RetryTests.cs b/test/Test.Core/Compiling/RetryTests.cs index 750f87e2..a0bbba95 100644 --- a/test/Test.Core/Compiling/RetryTests.cs +++ b/test/Test.Core/Compiling/RetryTests.cs @@ -51,6 +51,8 @@ public void Outbound(IOutboundContext context) () => { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); }); @@ -67,6 +69,8 @@ public void OnError(IOnErrorContext context) () => { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); }); @@ -89,12 +93,18 @@ public void OnError(IOnErrorContext context) - + + https://example.com/api + GET + - + + https://example.com/api + GET + @@ -380,4 +390,17 @@ public void ShouldCompileRetryPolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + [DataRow(0)] + [DataRow(51)] + public void ShouldReportRetryCountOutOfRange(int count) + { + var result = CompilerTestInitialize.InboundDocument( + $$""" + context.Retry(new RetryConfig { Condition = true, Count = {{count}}, Interval = 1 }, () => { }); + """).CompileDocument(); + + result.Errors.Should().ContainSingle(error => error.Id == "APIM2020"); + } +} diff --git a/test/Test.Core/Compiling/ReturnResponseTests.cs b/test/Test.Core/Compiling/ReturnResponseTests.cs index 7312b7fe..653fd834 100644 --- a/test/Test.Core/Compiling/ReturnResponseTests.cs +++ b/test/Test.Core/Compiling/ReturnResponseTests.cs @@ -205,4 +205,20 @@ public void ShouldCompileReturnResponsePolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + [DataRow("Template = \"razor\"")] + [DataRow("XsiNil = \"empty\"")] + public void ShouldValidateBodyNestedInReturnResponse(string property) + { + var result = CompilerTestInitialize.InboundDocument( + $$""" + context.ReturnResponse(new ReturnResponseConfig + { + Body = new BodyConfig { Content = "body", {{property}} } + }); + """).CompileDocument(); + + result.Errors.Should().ContainSingle(error => error.Id == "APIM9994"); + } +} diff --git a/test/Test.Core/Compiling/SectionAlignmentTests.cs b/test/Test.Core/Compiling/SectionAlignmentTests.cs index 053f2370..5ee34441 100644 --- a/test/Test.Core/Compiling/SectionAlignmentTests.cs +++ b/test/Test.Core/Compiling/SectionAlignmentTests.cs @@ -31,4 +31,182 @@ public class PolicyDocument : IDocument result.Should().BeSuccessful(); result.Document.Element(section)!.Element(policy).Should().NotBeNull(); } + + [TestMethod] + [DataRow("public void Inbound(IInboundContext context) { context.ForwardRequest(); }", "ForwardRequest")] + [DataRow("public void Inbound(IInboundContext context) { context.WithId(\"x\").CacheStore(10, null); context.Base(); }", "CacheStore")] + [DataRow("public void Backend(IBackendContext context) { context.EmitMetric(new EmitMetricConfig { Name = \"n\", Dimensions = [] }); }", "EmitMetric")] + // the section is on-error whatever context the method takes, and the gateway rejects an outbound-only policy in it + [DataRow("public void OnError(IOutboundContext context) { context.LlmSemanticCacheStore(10); }", "LlmSemanticCacheStore")] + public void ShouldReportPolicyUsedInSectionThatDoesNotAllowIt(string section, string method) + { + var result = + $$""" + [Document] + public class PolicyDocument : IDocument + { + {{section}} + } + """.CompileDocument(); + + result.Errors.Should().ContainSingle(error => + error.Id == "APIM2031" && error.GetMessage(null).Contains(method)); + // the id of the rejected policy doesn't move on to the next one + result.Document.Descendants().Where(element => element.Attribute("id") is not null).Should().BeEmpty(); + } + + [TestMethod] + public void ShouldNameTheSectionsContextWhenAPolicyIsNotAvailableInIt() + { + var result = + """ + [Document] + public class PolicyDocument : IDocument + { + public void OnError(IOutboundContext context) { context.LlmSemanticCacheStore(10); } + } + """.CompileDocument(); + + result.Errors.Should().ContainSingle(error => + error.Id == "APIM2031" && error.GetMessage(null).Contains("IOnErrorContext")); + } + + [TestMethod] + public void ShouldReportBaseInFragment() + { + var result = + """ + [Document] + public class Fragment : IFragment + { + public void Fragment(IFragmentContext context) { context.Base(); } + } + """.CompileDocument(); + + result.Errors.Should().ContainSingle(error => error.Id == "APIM2031"); + } + + [TestMethod] + public void ShouldOnlyTreatTheDocumentsOwnSectionContextMethodsAsSections() + { + var code = + """ + using Aliased = Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.IBackendContext; + + public interface IOnErrorContext { } + + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) + { + context.Base(); + } + + public void Backend(Aliased context) + { + context.Base(); + } + + public void OnError(Test.IOnErrorContext notTheAuthoringContext) + { + } + + public void Inbound(IInboundContext context, int extra) + { + context.SetHeader("X-Overload", "1"); + } + + public void Outbound(int notASectionContext) + { + } + + // what the section contexts share isn't a section's own context + public void Inbound(IHaveExpressionContext shared) + { + } + + // a section returns nothing: these are helpers + public string Outbound(IOutboundContext context) => "x"; + + public string OnError(Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring.IOnErrorContext context) + { + return "x"; + } + + public class Nested + { + public void Outbound(IOutboundContext context) + { + context.SetHeader("X-Nested", "1"); + } + } + } + """; + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo( + """ + + + + + + + + + """); + } + + [TestMethod] + public void ShouldReportPolicyAllowedOncePerSectionUsedTwice() + { + var result = CompilerTestInitialize.InboundDocument( + """ + if (IsGet(context.ExpressionContext)) + { + context.RateLimit(new RateLimitConfig { Calls = 1, RenewalPeriod = 60 }); + } + else + { + context.RateLimit(new RateLimitConfig { Calls = 5, RenewalPeriod = 60 }); + } + context.RateLimitByKey(new RateLimitByKeyConfig { Calls = 1, RenewalPeriod = 60, CounterKey = "a" }); + context.RateLimitByKey(new RateLimitByKeyConfig { Calls = 1, RenewalPeriod = 60, CounterKey = "b" }); + """.Replace("IsGet(context.ExpressionContext)", "true")).CompileDocument(); + + result.Errors.Should().ContainSingle(error => + error.Id == "APIM2032" && error.GetMessage(null).Contains("'rate-limit'")); + } + + [TestMethod] + public void ShouldNotCountElementsOfALiquidBodyAsPolicies() + { + var result = CompilerTestInitialize.InboundDocument( + """ + context.RateLimit(new RateLimitConfig { Calls = 1, RenewalPeriod = 60 }); + context.SetBody("12{{body.q}}", new SetBodyConfig { Template = "liquid" }); + """).CompileDocument(); + + result.Should().BeSuccessful(); + } + + [TestMethod] + public void ShouldReportPolicyAllowedOnceUsedTwiceInAFragment() + { + var result = + """ + [Document] + public class Limits : IFragment + { + public void Fragment(IFragmentContext context) + { + context.RateLimit(new RateLimitConfig { Calls = 1, RenewalPeriod = 60 }); + context.RateLimit(new RateLimitConfig { Calls = 5, RenewalPeriod = 60 }); + } + } + """.CompileDocument(); + + result.Errors.Should().ContainSingle(error => + error.Id == "APIM2032" && error.GetMessage(null).Contains("'rate-limit'")); + } } diff --git a/test/Test.Core/Compiling/SendOneWayRequestTests.cs b/test/Test.Core/Compiling/SendOneWayRequestTests.cs index 4116c778..9448afc0 100644 --- a/test/Test.Core/Compiling/SendOneWayRequestTests.cs +++ b/test/Test.Core/Compiling/SendOneWayRequestTests.cs @@ -88,6 +88,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendOneWayRequest(new SendOneWayRequestConfig { + Url = "https://example.com/api", + Method = "GET", Timeout = 100, }); } @@ -96,7 +98,10 @@ public void Inbound(IInboundContext context) """ - + + https://example.com/api + GET + """, @@ -110,6 +115,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendOneWayRequest(new SendOneWayRequestConfig { + Url = "https://example.com/api", + Method = "GET", Timeout = Exp(context.ExpressionContext), }); } @@ -120,7 +127,10 @@ public void Inbound(IInboundContext context) """ - + + https://example.com/api + GET + """, @@ -134,6 +144,7 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendOneWayRequest(new SendOneWayRequestConfig { + Method = "GET", Url = "https://test.example", }); } @@ -144,6 +155,7 @@ public void Inbound(IInboundContext context) https://test.example + GET @@ -158,6 +170,7 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendOneWayRequest(new SendOneWayRequestConfig { + Url = "https://example.com/api", Method = "POST", }); } @@ -167,6 +180,7 @@ public void Inbound(IInboundContext context) + https://example.com/api POST @@ -182,6 +196,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendOneWayRequest(new SendOneWayRequestConfig { + Url = "https://example.com/api", + Method = "GET", Headers = [ new HeaderConfig { Name = "content-type", @@ -202,6 +218,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET plain/text @@ -223,6 +241,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendOneWayRequest(new SendOneWayRequestConfig { + Url = "https://example.com/api", + Method = "GET", Headers = [ new HeaderConfig { Name = "content-type", @@ -246,6 +266,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET plain/text @@ -267,6 +289,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendOneWayRequest(new SendOneWayRequestConfig { + Url = "https://example.com/api", + Method = "GET", Body = new BodyConfig { Template = "liquid", XsiNil = "blank", @@ -281,6 +305,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET body @@ -296,6 +322,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendOneWayRequest(new SendOneWayRequestConfig { + Url = "https://example.com/api", + Method = "GET", Body = new BodyConfig { Content = Exp(context.ExpressionContext), }, @@ -308,6 +336,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET @("bo" + "dy") @@ -323,6 +353,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendOneWayRequest(new SendOneWayRequestConfig { + Url = "https://example.com/api", + Method = "GET", Authentication = new CertificateAuthenticationConfig { CertificateId = "example-domain-cert", }, @@ -334,6 +366,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET @@ -349,6 +383,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendOneWayRequest(new SendOneWayRequestConfig { + Url = "https://example.com/api", + Method = "GET", Authentication = new ManagedIdentityAuthenticationConfig { Resource = "test.example/resource", ClientId = "example-client-id", @@ -361,6 +397,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET @@ -376,6 +414,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendOneWayRequest(new SendOneWayRequestConfig { + Url = "https://example.com/api", + Method = "GET", Proxy = new ProxyConfig() { Url = "proxy.example", Username = "test-user", @@ -389,6 +429,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET diff --git a/test/Test.Core/Compiling/SendRequestTests.cs b/test/Test.Core/Compiling/SendRequestTests.cs index e54c3d69..b2af41a1 100644 --- a/test/Test.Core/Compiling/SendRequestTests.cs +++ b/test/Test.Core/Compiling/SendRequestTests.cs @@ -15,24 +15,32 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); } public void Backend(IBackendContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); } public void Outbound(IOutboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); } public void OnError(IOnErrorContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); } @@ -41,16 +49,28 @@ public void OnError(IOnErrorContext context) """ - + + https://example.com/api + GET + - + + https://example.com/api + GET + - + + https://example.com/api + GET + - + + https://example.com/api + GET + """, @@ -64,6 +84,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable", Mode = "new", }); @@ -73,7 +95,10 @@ public void Inbound(IInboundContext context) """ - + + https://example.com/api + GET + """, @@ -112,6 +137,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable", Timeout = 100, }); @@ -121,7 +148,10 @@ public void Inbound(IInboundContext context) """ - + + https://example.com/api + GET + """, @@ -135,6 +165,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable", Timeout = Exp(context.ExpressionContext), }); @@ -146,7 +178,10 @@ public void Inbound(IInboundContext context) """ - + + https://example.com/api + GET + """, @@ -160,6 +195,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable", IgnoreError = false, }); @@ -169,7 +206,10 @@ public void Inbound(IInboundContext context) """ - + + https://example.com/api + GET + """, @@ -183,6 +223,7 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Method = "GET", ResponseVariableName = "variable", Url = "https://test.example", }); @@ -194,6 +235,7 @@ public void Inbound(IInboundContext context) https://test.example + GET @@ -208,6 +250,7 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", ResponseVariableName = "variable", Method = "POST", }); @@ -218,6 +261,7 @@ public void Inbound(IInboundContext context) + https://example.com/api POST @@ -233,6 +277,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable", Headers = [ new HeaderConfig { @@ -254,6 +300,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET plain/text @@ -275,6 +323,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable", Headers = [ new HeaderConfig { @@ -299,6 +349,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET plain/text @@ -320,6 +372,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable", Body = new BodyConfig { Template = "liquid", @@ -335,6 +389,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET body @@ -350,6 +406,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable", Body = new BodyConfig { Content = Exp(context.ExpressionContext), @@ -363,6 +421,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET @("bo" + "dy") @@ -378,6 +438,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable", Authentication = new CertificateAuthenticationConfig { CertificateId = "example-domain-cert", @@ -390,6 +452,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET @@ -405,6 +469,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable", Authentication = new ManagedIdentityAuthenticationConfig { Resource = "test.example/resource", @@ -418,6 +484,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET @@ -433,6 +501,8 @@ public class PolicyDocument : IDocument public void Inbound(IInboundContext context) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable", Proxy = new ProxyConfig() { Url = "proxy.example", @@ -447,6 +517,8 @@ public void Inbound(IInboundContext context) + https://example.com/api + GET @@ -513,4 +585,34 @@ public void ShouldCompileSendRequestPolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldReportBasicAuthenticationInSendRequest() + { + var result = CompilerTestInitialize.InboundDocument( + """ + context.SendRequest(new SendRequestConfig + { + ResponseVariableName = "response", + Url = "https://example.org", + Method = "GET", + Authentication = new BasicAuthenticationConfig { Username = "user", Password = "password" } + }); + """).CompileDocument(); + + result.Errors.Should().ContainSingle(error => error.Id == "APIM9995"); + } + + [TestMethod] + [DataRow("context.SendRequest(new SendRequestConfig { ResponseVariableName = \"r\" });", 2)] + [DataRow("context.SendRequest(new SendRequestConfig { ResponseVariableName = \"r\", Url = \"https://example.com\" });", 1)] + [DataRow("context.SendOneWayRequest(new SendOneWayRequestConfig { Mode = \"new\" });", 2)] + [DataRow("context.SendRequest(new SendRequestConfig { ResponseVariableName = \"r\", Mode = \"copy\" });", 0)] + [DataRow("context.SendRequest(new SendRequestConfig { ResponseVariableName = \"r\", Mode = \"typo\" });", 2)] + // an empty value is missing too + [DataRow("context.SendRequest(new SendRequestConfig { ResponseVariableName = \"r\", Url = \"\", Method = \" \" });", 2)] + public void ShouldRequireUrlAndMethodUnlessTheRequestIsCopied(string policy, int errors) + { + CompilerTestInitialize.InboundDocument(policy).CompileDocument().Errors.Where(error => error.Id == "APIM2006").Should().HaveCount(errors); + } +} diff --git a/test/Test.Core/Compiling/SendServiceBusMessageTests.cs b/test/Test.Core/Compiling/SendServiceBusMessageTests.cs index 2d5c46a0..339f55cb 100644 --- a/test/Test.Core/Compiling/SendServiceBusMessageTests.cs +++ b/test/Test.Core/Compiling/SendServiceBusMessageTests.cs @@ -394,4 +394,29 @@ public void ShouldCompileSendServiceBusMessagePolicy(string code, string expecte { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } + + [TestMethod] + public void ShouldCompileMessageAndErrorHandlingAttributes() + { + var code = CompilerTestInitialize.InboundDocument( + """ + context.SendServiceBusMessage(new SendServiceBusMessageConfig + { + QueueName = "orders", + MessageId = "0f8fad5b-d9cb-469f-a165-70867728950e", + SessionId = "7c9e6679-7425-40de-944b-e07fc1f90ae7", + TimeToLive = "00:10:00", + ResponseVariableName = "result", + IgnoreError = true, + Payload = "payload" + }); + """); + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(CompilerTestInitialize.InboundXml( + """ + + payload + + """)); + } } diff --git a/test/Test.Core/Compiling/SetBodyTests.cs b/test/Test.Core/Compiling/SetBodyTests.cs index 2eae9adc..92e6bbe8 100644 --- a/test/Test.Core/Compiling/SetBodyTests.cs +++ b/test/Test.Core/Compiling/SetBodyTests.cs @@ -179,4 +179,51 @@ public void ShouldCompileForwardRequestPolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldWriteLiquidXmlBodyAsMarkupAndOtherBodiesAsText() + { + var code = CompilerTestInitialize.InboundDocument( + """ + context.SetBody("1 {{body.x}}", new SetBodyConfig { Template = "liquid" }); + context.SetBody("1"); + context.SetBody("{ \"a\": \"{{body.x}}\" }", new SetBodyConfig { Template = "liquid" }); + context.SetBody("{% if body.n < 2 && body.m %}

{{body.x}} & co

{% endif %}", new SetBodyConfig { Template = "liquid" }); + context.SetBody("@(context.RequestId)", new SetBodyConfig { Template = "liquid" }); + context.SetBody("@(1 < 2 ? \"\" : \"b\")", new SetBodyConfig { Template = "liquid", UseValueElement = true }); + context.SetBody("{% if body.n < 2 %}<_item>{{body.x}}<élément/>{% endif %}", new SetBodyConfig { Template = "liquid" }); + context.SetBody("

{{body.x}} © 

", new SetBodyConfig { Template = "liquid" }); + """); + + var result = code.CompileDocument(); + result.Should().BeSuccessful(); + + var written = new System.Text.StringBuilder(); + using (var writer = Serialization.CustomXmlWriter.Create( + written, new System.Xml.XmlWriterSettings { Indent = true, OmitXmlDeclaration = true })) + { + writer.Write(result.Document); + } + + var xml = written.ToString(); + // the gateway returns a liquid template written as escaped text still escaped: it has to be markup, + // written without indentation added inside it, in the normal form of the markup (double-quoted attributes) + xml.Should().Contain( + "
1 {{body.x}}"); + // and returns a body without a template that is written as markup empty: it has to stay text + xml.Should().Contain("<plain><a>1</a></plain>"); + xml.Should().Contain("{ \"a\": \"{{body.x}}\" }"); + // a body given through a value element is a policy value: its expression is written as code + xml.Should().Contain("@(1 < 2 ? \"\" : \"b\")"); + // only a body that is one whole expression is code; one that merely starts with @( is a template + xml.Should().Contain("@(context.RequestId)"); + // a comparison in a liquid tag doesn't stop the body from being markup: the gateway reads < in a tag + xml.Should().Contain( + "{% if body.n < 2 && body.m %}

{{body.x}} & co

{% endif %}
"); + // a tag may start with _ or a letter outside ASCII + xml.Should().Contain( + "{% if body.n < 2 %}<_item>{{body.x}}<élément />{% endif %}"); + // an HTML entity isn't one of XML's: it is text, and doesn't stop the body from being markup + xml.Should().Contain("

{{body.x}}&nbsp;&copy;\u00a0

"); + } +} diff --git a/test/Test.Core/Compiling/SetHeaderTests.cs b/test/Test.Core/Compiling/SetHeaderTests.cs index b9df6308..fd14eebb 100644 --- a/test/Test.Core/Compiling/SetHeaderTests.cs +++ b/test/Test.Core/Compiling/SetHeaderTests.cs @@ -248,4 +248,18 @@ public void Outbound(IOutboundContext context) """; result.Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + [DataRow("null")] + [DataRow("default(string)")] + [DataRow("(string?)null")] + public void ShouldReportNullPolicyArgument(string argument) + { + var result = CompilerTestInitialize.InboundDocument( + $""" + context.SetHeader("X-Null", {argument}); + """).CompileDocument(); + + result.Errors.Should().ContainSingle(error => error.Id == "APIM2005"); + } +} diff --git a/test/Test.Core/Compiling/SetVariableTests.cs b/test/Test.Core/Compiling/SetVariableTests.cs index b0061a4f..792e02ce 100644 --- a/test/Test.Core/Compiling/SetVariableTests.cs +++ b/test/Test.Core/Compiling/SetVariableTests.cs @@ -87,4 +87,153 @@ public void ShouldCompileSetVariablePolicy(string code, string expectedXml) { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldWriteNonStringConstantVariableValuesAsExpressions() + { + var code = + """ + [Document] + public class PolicyDocument : IDocument + { + private const int Limit = 10; + private const string Name = "text"; + + public void Inbound(IInboundContext context) + { + context.SetVariable("a", 5); + context.SetVariable("b", true); + context.SetVariable("c", 5L); + context.SetVariable("d", 5u); + context.SetVariable("e", 1.5); + context.SetVariable("f", 2d); + context.SetVariable("g", 1.5m); + context.SetVariable("h", 1.5f); + context.SetVariable("i", 'x'); + context.SetVariable("j", Limit + 1); + context.SetVariable("k", "5"); + context.SetVariable("l", Name); + } + } + """; + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo( + """ + + + + + + + + + + + + + + + + + """); + } + + [TestMethod] + public void ShouldReportVariableValueWrittenInPlaceOfATypeTheGatewayRejects() + { + var result = + """ + public enum Mode { On } + + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetVariable("v", Mode.On); + } + } + """.CompileDocument(); + + result.Errors.Should().ContainSingle(error => error.Id == "APIM2034"); + } + + [TestMethod] + // Uri values are reported too; the references of this test compilation don't resolve it + [DataRow("object", "new { a = 1 }")] + [DataRow("System.DayOfWeek", "System.DayOfWeek.Monday")] + [DataRow("System.DayOfWeek?", "context.Request.Method == \"GET\" ? System.DayOfWeek.Monday : (System.DayOfWeek?)null")] + [DataRow("System.DayOfWeek?", "null")] + [DataRow("object", "System.Tuple.Create(1, \"a\")")] + [DataRow("object", "new System.Collections.Generic.Dictionary()")] + [DataRow("int[]", "new[] { 1 }")] + [DataRow("string[]", "new[] { \"a\" }")] + [DataRow("System.Collections.Generic.List", "new System.Collections.Generic.List()")] + [DataRow("System.Collections.Generic.IEnumerable", "new[] { \"a\" }")] + [DataRow("System.Collections.ObjectModel.ReadOnlyCollection", "new System.Collections.ObjectModel.ReadOnlyCollection(new[] { \"a\" })")] + [DataRow("System.Collections.Hashtable", "new System.Collections.Hashtable()")] + [DataRow("System.Collections.Generic.HashSet", "new System.Collections.Generic.HashSet()")] + [DataRow("System.Collections.ObjectModel.ReadOnlyDictionary", "new System.Collections.ObjectModel.ReadOnlyDictionary(new System.Collections.Generic.Dictionary())")] + [DataRow("System.Collections.Generic.Dictionary.KeyCollection", "new System.Collections.Generic.Dictionary().Keys")] + [DataRow("System.Linq.IOrderedEnumerable", "System.Linq.Enumerable.OrderBy(new[] { \"a\" }, x => x)")] + [DataRow("byte?[]", "new byte?[] { 1 }")] + [DataRow("object", "Inner(context)")] + [DataRow("object", "(object)new { a = 1 }")] + [DataRow("object", "((object)(Inner(context)))")] + public void ShouldReportVariableValueOfATypeTheGatewayRejects(string returnType, string expression) + { + var result = + $$""" + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetVariable("v", Value(context.ExpressionContext)); + } + + {{returnType}} Value(IExpressionContext context) => {{expression}}; + + object Inner(IExpressionContext context) => new { a = 1 }; + } + """.CompileDocument(); + + result.Errors.Should().ContainSingle(error => error.Id == "APIM2034"); + } + + [TestMethod] + [DataRow("byte[]", "new byte[] { 1 }")] + [DataRow("sbyte[]", "new sbyte[] { 1 }")] + [DataRow("object", "context.RequestId")] + [DataRow("System.TimeSpan", "context.Elapsed")] + public void ShouldAcceptVariableValueOfATypeTheGatewayAccepts(string returnType, string expression) + { + var result = + $$""" + [Document] + public class PolicyDocument : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetVariable("v", Value(context.ExpressionContext)); + } + + {{returnType}} Value(IExpressionContext context) => {{expression}}; + } + """.CompileDocument(); + + result.Errors.Should().BeEmpty(); + } + + [TestMethod] + [DataRow("double.NaN", "NaN")] + [DataRow("float.PositiveInfinity", "Infinity")] + public void ShouldKeepNonFiniteConstantAsText(string constant, string expected) + { + // NaN and the infinities have no literal that could be written as an expression + var result = CompilerTestInitialize.InboundDocument($"context.SetVariable(\"v\", {constant});").CompileDocument(); + + result.Should().BeSuccessful(); + result.Document.Descendants("set-variable").Single().Attribute("value")!.Value.Should().Be(expected); + } +} diff --git a/test/Test.Core/Compiling/ValidateAzureAdTokenTests.cs b/test/Test.Core/Compiling/ValidateAzureAdTokenTests.cs index 36f04bf0..0c92886f 100644 --- a/test/Test.Core/Compiling/ValidateAzureAdTokenTests.cs +++ b/test/Test.Core/Compiling/ValidateAzureAdTokenTests.cs @@ -484,4 +484,38 @@ public void ShouldCompileValidateAzureAdTokenPolicy(string code, string expected { code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + public void ShouldCompileAuthenticationEndpoint() + { + var code = CompilerTestInitialize.InboundDocument( + """ + context.ValidateAzureAdToken(new ValidateAzureAdTokenConfig + { + TenantId = "tenant", + AuthenticationEndpoint = "https://login.microsoftonline.us" + }); + """); + + code.CompileDocument().Should().BeSuccessful().And.DocumentEquivalentTo(CompilerTestInitialize.InboundXml( + """ + + """)); + } + + [TestMethod] + public void ShouldReportInsteadOfThrowingForInvalidDecryptionKey() + { + var result = CompilerTestInitialize.InboundDocument( + """ + context.ValidateAzureAdToken(new ValidateAzureAdTokenConfig + { + TenantId = "tenant", + DecryptionKeys = [GetKey()] + }); + """, + """static DecryptionKey GetKey() => new DecryptionKey { CertificateId = "c" };""").CompileDocument(); + + result.Errors.Should().Contain(error => error.Id == "APIM2002"); + } +} diff --git a/test/Test.Core/Compiling/WaitTests.cs b/test/Test.Core/Compiling/WaitTests.cs index ad59cf09..a198ac73 100644 --- a/test/Test.Core/Compiling/WaitTests.cs +++ b/test/Test.Core/Compiling/WaitTests.cs @@ -14,6 +14,8 @@ public void Inbound(IInboundContext context) context.Wait(() => { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); }); @@ -23,6 +25,8 @@ public void Backend(IBackendContext context) context.Wait(() => { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); }); @@ -32,15 +36,19 @@ public void Outbound(IOutboundContext context) context.Wait(() => { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); }); } - public void OnError(OnErrorContext context) + public void OnError(IOnErrorContext context) { context.Wait(() => { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); }); @@ -51,22 +59,34 @@ public void OnError(OnErrorContext context) - + + https://example.com/api + GET + - + + https://example.com/api + GET + - + + https://example.com/api + GET + - + + https://example.com/api + GET + @@ -83,6 +103,8 @@ public void Inbound(IInboundContext context) context.Wait(() => { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); }, @@ -94,7 +116,10 @@ public void Inbound(IInboundContext context) - + + https://example.com/api + GET + @@ -111,6 +136,8 @@ public void Inbound(IInboundContext context) context.Wait(() => { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "variable" }); }, @@ -123,7 +150,10 @@ public void Inbound(IInboundContext context) - + + https://example.com/api + GET + @@ -149,6 +179,8 @@ public void Inbound(IInboundContext context) if(RequestCondition(context.ExpressionContext)) { context.SendRequest(new SendRequestConfig { + Url = "https://example.com/api", + Method = "GET", ResponseVariableName = "request" }); } @@ -169,7 +201,10 @@ public void Inbound(IInboundContext context) - + + https://example.com/api + GET + diff --git a/test/Test.Core/Compiling/WithIdTests.cs b/test/Test.Core/Compiling/WithIdTests.cs index 9fd25624..c198a154 100644 --- a/test/Test.Core/Compiling/WithIdTests.cs +++ b/test/Test.Core/Compiling/WithIdTests.cs @@ -352,4 +352,41 @@ public void Inbound(IInboundContext context) """; result.Should().BeSuccessful().And.DocumentEquivalentTo(expectedXml); } -} \ No newline at end of file + + [TestMethod] + [DataRow("""context.SetHeader("X-A", "1"); context.WithId("orphan");""")] + [DataRow("""context.WithId("orphan"); context.WithId("kept"); context.Base();""")] + [DataRow("""context.WithId("orphan"); context.WithId("kept").SetHeader("X-A", "1");""")] + public void ShouldReportWithIdThatNoPolicyFollows(string statements) + { + var result = CompilerTestInitialize.InboundDocument(statements).CompileDocument(); + + result.Errors.Should().ContainSingle(error => + error.Id == "APIM2033" && error.GetMessage(null).Contains("orphan")); + } + + [TestMethod] + [DataRow("context.NotAPolicy();")] + [DataRow("var x = 5;")] + [DataRow("context.ToString;")] + public void ShouldNotMoveAnIdPastAStatementThatIsNotAPolicy(string statement) + { + var result = CompilerTestInitialize.InboundDocument( + $"""context.WithId("lost"); {statement} context.Base();""").CompileDocument(); + + result.Document.Descendants().Where(element => element.Attribute("id") is not null).Should().BeEmpty(); + } + + [TestMethod] + public void ShouldNotMoveIdOfAPolicyThatFailedToTheNextPolicy() + { + var result = CompilerTestInitialize.InboundDocument( + """ + context.WithId("limit").RateLimit(new RateLimitConfig { RenewalPeriod = 10 }); + context.SetHeader("X-A", "1"); + """).CompileDocument(); + + result.Errors.Should().ContainSingle(error => error.Id == "APIM2006"); + result.Document.Descendants("set-header").Single().Attribute("id").Should().BeNull(); + } +} diff --git a/test/Test.Core/IO/FileUtilsTests.cs b/test/Test.Core/IO/FileUtilsTests.cs new file mode 100644 index 00000000..daa58021 --- /dev/null +++ b/test/Test.Core/IO/FileUtilsTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Xml; +using System.Xml.Linq; + +using Microsoft.Azure.ApiManagement.PolicyToolkit.IO; + +namespace Test.Core.IO; + +[TestClass] +public class FileUtilsTests +{ + private string _testFolder = null!; + + [TestInitialize] + public void Initialize() + { + _testFolder = Path.Combine(Path.GetTempPath(), $"policy-toolkit-file-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testFolder); + } + + [TestCleanup] + public void Cleanup() + { + if (Directory.Exists(_testFolder)) + { + Directory.Delete(_testFolder, true); + } + } + + [TestMethod] + public void WriteToFile_ShouldFormatExpressionsWithBracketsInLiteralsAndComments() + { + var element = new XElement("policies", + new XElement("inbound", + new XElement("set-variable", + new XAttribute("name", "a"), + new XAttribute("value", "@{var s = \"}\"; var t = '{'; return s + t + \"{{\";}")), + new XElement("set-body", "@{\n// comment with } and )\nreturn context.Request.Body.As();}"), + new XElement("set-header", + new XAttribute("name", "x @(\"a\"+\"b\") y"), + new XElement("value", "@(\")\"+1)")))); + + var result = Write(element, rawXml: true); + + result.Should().Be( + """ + + + + @{ + // comment with } and ) + return context.Request.Body.As(); + } + + @(")" + 1) + + + + """.ReplaceLineEndings()); + } + + private string Write(XElement element, bool rawXml) + { + var file = FileUtils.WriteToFile(new FileUtils.Data + { + Element = element, + SourceFolder = _testFolder, + SourceFilePath = Path.Combine(_testFolder, "Policy.cs"), + OutputFolder = Path.Combine(_testFolder, "output"), + OutputFilePath = "policy.xml", + FormatCode = true, + RawXml = rawXml, + XmlWriterSettings = new XmlWriterSettings + { + OmitXmlDeclaration = true, + ConformanceLevel = ConformanceLevel.Fragment, + Indent = true, + IndentChars = " ", + NewLineChars = Environment.NewLine, + }, + }); + var lines = File.ReadAllLines(file); + lines[0].Should().StartWith("")] + [DataRow("bold")] + [DataRow("text")] + [DataRow("")] + [DataRow("")] + [DataRow("a < b")] + public void ShouldWriteAllNodesInOrder(string elementString) + { + var element = XElement.Parse(elementString); + + var result = Serialize(element); + + result.Should().Be(elementString); + } + + [TestMethod] + public void ShouldWriteExpressionNextToElement() + { + var element = new XElement("foo", new XText("@(\"a\" + \"<\")"), new XElement("b")); + + Serialize(element).Should().Be("@(\"a\" + \"<\")"); + Serialize(element, rawExpressions: false).Should().Be("@(\"a\" + \"<\")"); + } + + [TestMethod] + public void ShouldKeepIndentationOfElementOnlyContent() + { + var element = XElement.Parse("
\n \n text\n \n \n", + LoadOptions.PreserveWhitespace); + + var result = Serialize(element, indent: true); + + result.Should().Be( + """ + + + text + + + + """.ReplaceLineEndings()); + } + + [TestMethod] + public void ShouldIndentCommentBetweenElements() + { + var element = XElement.Parse(""); + + var result = Serialize(element, indent: true); + + result.Should().Be( + """ + + + + + + """.ReplaceLineEndings()); + } + + private static string Serialize(XElement element, bool rawExpressions = true, bool indent = false) + { + var settings = new XmlWriterSettings + { + OmitXmlDeclaration = true, + ConformanceLevel = ConformanceLevel.Fragment, + Indent = indent, + IndentChars = " ", + NewLineChars = Environment.NewLine, + }; + var builder = new StringBuilder(); + using (var writer = CustomXmlWriter.Create(builder, settings, rawExpressions)) + { + writer.Write(element); + } + + return builder.ToString(); + } +} diff --git a/test/Test.Core/Serialization/RazorCodeFormatterLexicalTests.cs b/test/Test.Core/Serialization/RazorCodeFormatterLexicalTests.cs new file mode 100644 index 00000000..8a1a10dd --- /dev/null +++ b/test/Test.Core/Serialization/RazorCodeFormatterLexicalTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Xml.Linq; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Serialization; + +[TestClass] +public class RazorCodeFormatterLexicalTests +{ + [TestMethod] + public void ShouldNotCountBracketsInStringAndCharLiterals() + { + var code = "@{var s = \"}\"; var t = '{'; return s + t + \"{{\";}"; + + var result = RazorCodeFormatter.Format(code); + + result.Should().Be( + """ + @{ + var s = "}"; + var t = '{'; + return s + t + "{{"; + } + """.ReplaceLineEndings()); + } + + [TestMethod] + [DataRow("@(\")\" + context.RequestId)", "@(\")\" + context.RequestId)")] + [DataRow("@(@\"\"\")\" + context.RequestId)", "@(@\"\"\")\" + context.RequestId)")] + [DataRow("@(')'.ToString()+'\\''.ToString())", "@(')'.ToString() + '\\''.ToString())")] + [DataRow("@(\"\\\")\"+context.RequestId)", "@(\"\\\")\" + context.RequestId)")] + [DataRow("@($\"){context.RequestId}(\"+1)", "@($\"){context.RequestId}(\" + 1)")] + [DataRow("@(context.RequestId /* ) */+1)", "@(context.RequestId /* ) */ + 1)")] + public void ShouldFindClosingBracketOfOneLineCode(string expression, string formatted) + { + var result = RazorCodeFormatter.Format($""); + + result.Should().Be($""); + } + + [TestMethod] + public void ShouldNotCountBracketsInComments() + { + var code = + """ + @{ + // comment with } and ) + /* another } one */ + return 1; + } + """.ReplaceLineEndings(); + + var result = RazorCodeFormatter.Format(code); + + result.Should().Be(code); + } + + [TestMethod] + [DataRow("@{ return '{'; ")] + [DataRow("")] + [DataRow("@{ return \"unterminated; }")] + [DataRow("@(")] + [DataRow("@{")] + public void ShouldLeaveUnbalancedCodeAsIs(string code) + { + var result = RazorCodeFormatter.Format(code); + + result.Should().Be(code); + RazorCodeFormatter.ToCleanXml(code, out var markerToCode).Should().Be(code); + markerToCode.Should().BeEmpty(); + } + + [TestMethod] + public void ShouldFormatBalancedCodeBeforeUnbalancedCode() + { + var result = RazorCodeFormatter.Format("@{ return '{'; "); + + result.Should().Be("@{ return '{'; "); + } + + [TestMethod] + public void ShouldNotTreatExpressionStartInsideExpressionAsNewExpression() + { + var result = RazorCodeFormatter.Format("@(\"@(\"+1)@(2+2)"); + + result.Should().Be("@(\"@(\" + 1)@(2 + 2)"); + } + + [TestMethod] + public void ShouldReplaceCodeWithBracketInStringWithOneMarker() + { + var result = RazorCodeFormatter.ToCleanXml("@{ return \"}\"; }", out var markerToCode); + + markerToCode.Should().ContainSingle(); + markerToCode.Single().Value.Should().Be("@{return \"}\";}"); + result.Should().Be($"{markerToCode.Single().Key}"); + } + + [TestMethod] + public void ShouldFormatExpressionsOfMixedContentAndKeepSiblings() + { + var element = XElement.Parse("@(1+1)@(2+2)@(3+3)"); + + RazorCodeFormatter.FormatExpressions(element); + + element.ToString(SaveOptions.DisableFormatting).Should() + .Be("@(1 + 1)@(2 + 2)@(3 + 3)"); + } + + [TestMethod] + public void ShouldLeaveTheMarkupOfALiquidBodyAsItIs() + { + var element = XElement.Parse( + """@(3+3)@(4+4)"""); + + RazorCodeFormatter.FormatExpressions(element); + + element.ToString(SaveOptions.DisableFormatting).Should().Be( + """@(3+3)@(4 + 4)"""); + } +} diff --git a/test/Test.Decompiling/DirectoryRoundTripTests.cs b/test/Test.Decompiling/DirectoryRoundTripTests.cs index 0a483af9..65fe1206 100644 --- a/test/Test.Decompiling/DirectoryRoundTripTests.cs +++ b/test/Test.Decompiling/DirectoryRoundTripTests.cs @@ -37,7 +37,7 @@ public class DirectoryRoundTripTests private static readonly IEnumerable References = GetReferences(); private static ServiceProvider s_serviceProvider = null!; private static DocumentCompiler s_compiler = null!; - private static PolicyDecompiler s_decompiler = null!; + private static TestDecompiler s_decompiler = null!; private static string? s_policyDirectory; private static MetadataReference[] GetReferences() @@ -98,7 +98,7 @@ public static void ClassInit(TestContext context) .SetupCompiler() .BuildServiceProvider(); s_compiler = s_serviceProvider.GetRequiredService(); - s_decompiler = new PolicyDecompiler(); + s_decompiler = new TestDecompiler(); } [ClassCleanup] @@ -396,7 +396,7 @@ private static void RestoreNamedValuesInXml(XElement element, Dictionary().Remove(); var isFragment = doc.Root?.Name.LocalName == "fragment"; diff --git a/test/Test.Decompiling/RoundTripTests.cs b/test/Test.Decompiling/RoundTripTests.cs index c32def66..2938530e 100644 --- a/test/Test.Decompiling/RoundTripTests.cs +++ b/test/Test.Decompiling/RoundTripTests.cs @@ -72,7 +72,7 @@ private static void TryAddAssemblyReference(List refs, Type t private static ServiceProvider s_serviceProvider = null!; private static DocumentCompiler s_compiler = null!; - private static PolicyDecompiler s_decompiler = null!; + private static TestDecompiler s_decompiler = null!; [ClassInitialize] public static void ClassInit(TestContext context) @@ -82,7 +82,7 @@ public static void ClassInit(TestContext context) .SetupCompiler() .BuildServiceProvider(); s_compiler = s_serviceProvider.GetRequiredService(); - s_decompiler = new PolicyDecompiler(); + s_decompiler = new TestDecompiler(); } [ClassCleanup] @@ -200,6 +200,31 @@ public void NamedValueToken_RoundTrips() AssertRoundTrip(xml); } + [TestMethod] + [DataRow("_x", DisplayName = "Starting with an underscore")] + [DataRow("1x", DisplayName = "Starting with a digit")] + [DataRow("a.b-c_d", DisplayName = "With every name character")] + public void NamedValueTokenNamedWithAnyNameCharacters_IsANamedValue(string name) + { + var xml = $""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().Contain($"\"{name}\"").And.NotContain($"{{{{{name}}}}}"); + AssertRoundTrip(xml); + } + + [TestMethod] + public void NamedValueTokenWithoutALetter_IsText() + { + var xml = """"""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().Contain("\"{{123}}\""); + AssertRoundTrip(xml); + } + [TestMethod] public void NamedValueTokenConcatenatedWithVariable_RoundTrips() { @@ -358,7 +383,7 @@ public void RealPolicyFile_RoundTrips(string fileName) var filePath = Path.Combine("TestData", fileName); var rawXml = File.ReadAllText(filePath); // Preprocess to escape C# expressions, making it valid XML - var preprocessed = PolicyDecompiler.PreprocessXml(rawXml); + var preprocessed = TestDecompiler.Preprocess(rawXml); // Strip XML comments (decompiler doesn't preserve them) var doc = XDocument.Parse(preprocessed); doc.DescendantNodes().OfType().Remove(); @@ -378,7 +403,7 @@ public void ComplexPolicyFile_DecompilesAndCompiles(string fileName) { var filePath = Path.Combine("TestData", fileName); var rawXml = File.ReadAllText(filePath); - var preprocessed = PolicyDecompiler.PreprocessXml(rawXml); + var preprocessed = TestDecompiler.Preprocess(rawXml); var doc = XDocument.Parse(preprocessed); doc.DescendantNodes().OfType().Remove(); var xml = doc.ToString(SaveOptions.DisableFormatting); @@ -416,7 +441,7 @@ public void PolicyWithNamedValues_Decompiles(string fileName) { var filePath = Path.Combine("TestData", fileName); var rawXml = File.ReadAllText(filePath); - var preprocessed = PolicyDecompiler.PreprocessXml(rawXml); + var preprocessed = TestDecompiler.Preprocess(rawXml); var doc = XDocument.Parse(preprocessed); doc.DescendantNodes().OfType().Remove(); var xml = doc.ToString(SaveOptions.DisableFormatting); @@ -437,6 +462,487 @@ public void PolicyChildElements_RoundTrip(string sections) AssertRoundTrip($"{sections}"); } + [TestMethod] + [DataRow("""""", + DisplayName = "validate-parameters with headers, query and path")] + [DataRow("""""", + DisplayName = "validate-parameters group with expression and named value actions")] + [DataRow("""
""", + DisplayName = "validate-headers with headers")] + [DataRow("""""", + DisplayName = "authentication-certificate with body")] + [DataRow("""""", + DisplayName = "authentication-certificate with literal body")] + [DataRow("""""", + DisplayName = "Two named values in one value")] + [DataRow("""""", + DisplayName = "set-variable names that are not identifiers")] + [DataRow("""""", + DisplayName = "json-to-xml namespace-separator expression")] + [DataRow("""""", + DisplayName = "json-to-xml namespace-separator named value")] + [DataRow("""""", + DisplayName = "json-to-xml namespace-separator char")] + [DataRow("""""", + DisplayName = "json-to-xml namespace-separator longer than a char")] + [DataRow("""*
*
GET
""", + DisplayName = "cors preflight-result-max-age expression")] + [DataRow("""*
*
GET
""", + DisplayName = "cors preflight-result-max-age named value")] + [DataRow("""""", + DisplayName = "validate-content bool attributes as expression and named value")] + [DataRow("""v""", + DisplayName = "set-header with expression exists-action")] + [DataRow("""""", + DisplayName = "set-header override without values")] + [DataRow("""v""", + DisplayName = "set-query-parameter with named value exists-action")] + [DataRow("""""", + DisplayName = "set-query-parameter skip without values")] + [DataRow("""@(context.Request.IpAddress)-@(context.Request.Method)""", + DisplayName = "Two expressions in one element value")] + [DataRow("""""", + DisplayName = "Two expressions in one attribute value")] + [DataRow("""""", + DisplayName = "choose with id")] + [DataRow("""""", + DisplayName = "choose with condition true")] + [DataRow("""""", + DisplayName = "choose with condition false")] + [DataRow("""""", + DisplayName = "choose with named value conditions")] + [DataRow("""@(context.Variables.ContainsKey("x") && 1 < 2)""", + DisplayName = "Inline policy with characters XML escapes in expressions")] + [DataRow("""""", + DisplayName = "choose with an empty when")] + [DataRow("""@(1 < 2) & {{body.x}}""", + DisplayName = "liquid body with expression-like text in its markup")] + [DataRow("""@(context.Request.Method == "GET" && 1 < 2)""", + DisplayName = "nested liquid body with expression-like text in its markup")] + [DataRow("""{{body.x}} & b]]>""", + DisplayName = "liquid body that is one CDATA section")] + [DataRow("""{{body.x}}{{body.y}} tail 12""", + DisplayName = "liquid body whose template has a value element among other content")] + [DataRow("""""", + DisplayName = "types and extension methods that need a using directive")] + [DataRow("""""", + DisplayName = "Authorization next to a System.Net type")] + [DataRow("""""", + DisplayName = "retry with an expression condition")] + [DataRow("""""", + DisplayName = "choose with an empty otherwise")] + [DataRow("""https://example.orgGET""", + DisplayName = "send-request with a literal certificate body")] + [DataRow("""""", + DisplayName = "semantic cache lookup score threshold from a named value")] + [DataRow("""""", + DisplayName = "semantic cache lookup score threshold put together from a named value")] + [DataRow("""https://example.orgGET""", + DisplayName = "send-request with a certificate body from a named value")] + public void ConformancePolicy_RoundTrips(string sections) + { + var xml = $"{sections}"; + AssertRoundTrip(xml); + AssertValidCSharpSyntax(s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest")); + } + + [TestMethod] + [DataRow("""https://api.example.comGET""", + DisplayName = "send-request certificate password")] + [DataRow("""https://api.example.comGET""", + DisplayName = "send-request certificate body")] + [DataRow("""https://api.example.comGET""", + DisplayName = "send-request managed identity token variable and ignore-error")] + [DataRow("""https://api.example.comPOST""", + DisplayName = "send-one-way-request managed identity token variable and ignore-error")] + public void NestedAuthentication_RoundTrips(string policy) + { + AssertRoundTripSemantic($"{policy}"); + } + + [TestMethod] + public void ChooseWithNamedValueCondition_DecompilesToIfStatement() + { + var xml = """"""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().Contain("if (NamedValue_Flag0(context.ExpressionContext))"); + csharp.Should().NotContain("InlinePolicy"); + } + + [TestMethod] + public void ChooseWithIdAndConstantCondition_DecompilesToIfStatement() + { + var xml = """"""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().Contain("context.WithId(\"route\");"); + csharp.Should().Contain("if (true)"); + csharp.Should().Contain("else if (false)"); + csharp.Should().NotContain("InlinePolicy"); + } + + [TestMethod] + public void ChooseWithPaddedConstantCondition_DecompilesToIfStatement() + { + var xml = """"""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().Contain("if (true)"); + csharp.Should().NotContain("InlinePolicy"); + } + + [TestMethod] + [DataRow("""""")] + [DataRow("""""")] + [DataRow("""""")] + public void ChooseTheGatewayRejects_IsKeptAsWritten(string choose) + { + var csharp = s_decompiler.DecompileDocument( + $"{choose}", "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().Contain("InlinePolicy"); + } + + [TestMethod] + public void SetBodyWithoutTemplate_KeepsWhitespaceAsWritten() + { + var xml = """ + + + + + + + + + + + """; + var body = XDocument.Parse(xml, LoadOptions.PreserveWhitespace).Descendants("set-body").Single(); + var expected = string.Concat(body.Nodes().Select(n => n.ToString(SaveOptions.DisableFormatting))) + .ReplaceLineEndings("\n"); + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + var result = CompileCSharp(csharp); + + result.Errors.Should().BeEmpty("the decompiled C# should compile.\nGenerated C#:\n{0}", csharp); + // a body without a template is text: its markup is kept as written, whitespace included + var compiled = result.Document.Descendants("set-body").Single(); + compiled.Elements().Should().BeEmpty(); + compiled.Value.ReplaceLineEndings("\n").Should().Be(expected, "Generated C#:\n{0}", csharp); + } + + [TestMethod] + public void SetBodyWithMarkup_KeepsInnerXml() + { + var xml = """ + + + + + {{body.x}} + + + + + """; + var body = XDocument.Parse(xml, LoadOptions.PreserveWhitespace).Descendants("set-body").Single(); + var expected = string.Concat(body.Nodes().Select(n => n.ToString(SaveOptions.DisableFormatting))) + .ReplaceLineEndings("\n"); + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + var result = CompileCSharp(csharp); + + AssertValidCSharpSyntax(csharp); + result.Errors.Should().BeEmpty("the decompiled C# should compile.\nGenerated C#:\n{0}", csharp); + var compiled = result.Document.Descendants("set-body").Single(); + compiled.Attribute("template")!.Value.Should().Be("liquid"); + compiled.Elements().Should().ContainSingle(child => child.Name.LocalName == "Envelope"); + string.Concat(compiled.Nodes().Select(n => n.ToString(SaveOptions.DisableFormatting))) + .ReplaceLineEndings("\n").Should().Be(expected, "Generated C#:\n{0}", csharp); + } + + [TestMethod] + public void MultiLineAttributeExpression_KeepsCodeAfterLineComment() + { + var xml = """ + + + + + + + + + + + """; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + var result = CompileCSharp(csharp); + + AssertValidCSharpSyntax(csharp); + result.Errors.Should().BeEmpty("the decompiled C# should compile.\nGenerated C#:\n{0}", csharp); + var value = result.Document.Descendants("set-variable").Single().Attribute("value")!.Value; + value.Should().Contain("var a = 1;").And.Contain("return a + 41;"); + var condition = result.Document.Descendants("when").Single().Attribute("condition")!.Value; + condition.Should().Contain("context.Request.Method == \"GET\""); + } + + [TestMethod] + public void TypesNamedInExpressions_GetTheirUsingDirectives() + { + var xml = """"""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().Contain("using Newtonsoft.Json.Linq;") + .And.Contain("using System.Text.RegularExpressions;") + .And.Contain("using System.Text;") + .And.NotContain("using System.Xml.Linq;"); + AssertRoundTrip(xml); + } + + [TestMethod] + public void TokenQuotaNamedValue_GetsALongHelper() + { + var xml = """"""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().Contain("long NamedValue_Quota"); + AssertRoundTrip(xml); + } + + [TestMethod] + public void LiquidBody_KeepsTheWhitespaceAroundACDataSection() + { + var xml = "\n \n"; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + var body = CompileCSharp(csharp).Document.Descendants("set-body").Single(); + + string.Concat(body.Nodes().Select(node => node.ToString(SaveOptions.DisableFormatting))) + .Should().Be("\n \n"); + } + + [TestMethod] + public void RawPolicy_RestoresExpressionsNextToChildElementsAndKeepsCData() + { + // the unescaped quotes make this document one that is read through the raw policy fallback + var xml = """ + Hello @(context.Request.Method) !{{body.x}} & b]]> + """; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().NotContain("__APIM_EXPR_").And.Contain("Hello @(context.Request.Method) !"); + var body = CompileCSharp(csharp).Document.Elements("inbound").Elements("set-body").Single(); + body.Nodes().Should().ContainSingle().Which.Should().BeOfType() + .Which.Value.Should().Be("{{body.x}} & b"); + } + + [TestMethod] + public void RawPolicy_KeepsAnEntityThatIsPartOfAnExpressionWrittenAsCode() + { + // what the compiler writes in the rawxml format for an expression that escapes text itself + var xml = """ + + """; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().Contain("""Replace("&", "&").Replace("<", "<")"""); + } + + [TestMethod] + [DataRow(PolicyFormat.Xml, """context.Request.Method + "&";""")] + [DataRow(PolicyFormat.RawXml, """context.Request.Method + "&";""")] + public void PolicyFormat_DecidesHowAWellFormedExpressionIsRead(PolicyFormat format, string expected) + { + // what the compiler writes as rawxml for a helper returning Method + "&" is also well-formed XML + var xml = """@(context.Request.Method + "&")"""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest", + format: format); + + csharp.Should().Contain(expected); + } + + [TestMethod] + public void PolicyFormat_RawXmlKeepsEveryExpressionAndXmlNeedsWellFormedText() + { + var raw = """"""; + + s_decompiler.DecompileDocument(raw, "RoundTripPolicy", "RoundTripTest", + format: PolicyFormat.RawXml) + .Should().Contain("""== "GET" && true"""); + var asXml = () => s_decompiler.DecompileDocument(raw, "RoundTripPolicy", "RoundTripTest", + format: PolicyFormat.Xml); + asXml.Should().Throw(); + var asUnknown = () => s_decompiler.DecompileDocument(raw, "RoundTripPolicy", "RoundTripTest", + format: (PolicyFormat)7); + asUnknown.Should().Throw(); + } + + [TestMethod] + public void PolicyFormat_RawXmlRoundTripsWhatTheCompilerWrites() + { + // the rawxml the compiler writes: expressions as code, the markup of a liquid body XML-escaped; the raw + // quotes make the document one that is not well-formed XML + var xml = "" + + """""" + + """@(context.Request.Method + "&")""" + + ""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest", format: PolicyFormat.RawXml); + var result = CompileCSharp(csharp); + + result.Errors.Should().BeEmpty("the decompiled C# should compile.\nGenerated C#:\n{0}", csharp); + var written = new StringBuilder(); + using (var writer = CustomXmlWriter.Create(written, new XmlWriterSettings { OmitXmlDeclaration = true }, rawExpressions: true)) + { + writer.Write(result.Document); + } + + written.ToString().Should().Be(xml); + } + + [TestMethod] + public void PolicyFormat_RawXmlDecodesTheMarkupOfALiquidBody() + { + // what the compiler writes as rawxml for a liquid body: its markup XML-escaped, an @(...) in it included + var xml = "" + + """""" + + """@(1 < 2)@(context.Request.Method + "&")""" + + ""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest", format: PolicyFormat.RawXml); + + csharp.Should().Contain("""context.Request.Method + "&";""") + .And.Contain("""@(1 < 2)@(context.Request.Method + \"&\")"""); + } + + [TestMethod] + public void PolicyFormat_RawXmlDecodesAnExpressionThatIsNotTheStartOfItsValue() + { + // what the compiler writes as rawxml: a value that begins with an expression as code, any other value + // XML-escaped, an @(...) in it included + var xml = "" + + """""" + + """""" + + ""; + // a document without an expression keeps the line break of an attribute, as it does when read as xml + var plain = ""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest", + format: PolicyFormat.RawXml); + + csharp.Should().Contain("""context.Request.Method + "&";""").And.Contain("text @(1 & 2) & more"); + s_decompiler.DecompileDocument(plain, "RoundTripPolicy", "RoundTripTest", + format: PolicyFormat.RawXml) + .Should().Contain("plain & text\n second line"); + } + + [TestMethod] + public void QueryExpression_GetsTheLinqUsingDirective() + { + var xml = """"""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().Contain("using System.Linq;"); + CompileCSharp(csharp).Errors.Should().BeEmpty("the decompiled C# should compile.\nGenerated C#:\n{0}", csharp); + } + + [TestMethod] + public void DocumentWithoutSuchTypes_GetsNoExtraUsingDirectives() + { + var csharp = s_decompiler.DecompileDocument( + """@(context.Request.Method)""", + "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().StartWith("using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring;"); + } + + [TestMethod] + public void BaseInFragment_IsDecompiledAsWrittenSoCompilingReportsIt() + { + // the gateway saves in a fragment, but an API that includes the fragment fails when called + var csharp = s_decompiler.DecompileFragment( + "1", + "fragment-id", "RoundTripFragment", "RoundTripTest"); + + csharp.Should().Contain("context.Base();").And.NotContain("InlinePolicy"); + CompileCSharp(csharp).Errors.Should().ContainSingle(error => error.Id == "APIM2031"); + } + + [TestMethod] + public void InlinePolicyFallback_EmitsRawExpressions() + { + var xml = """"""; + + var csharp = s_decompiler.DecompileDocument(xml, "RoundTripPolicy", "RoundTripTest"); + + csharp.Should().Contain( + """context.InlinePolicy(@"");"""); + } + + [TestMethod] + [DataRow("@(a)", true)] + [DataRow("@{return a;}", true)] + [DataRow("@(a)-@(b)", false)] + [DataRow("@(a) @{return b;}", false)] + [DataRow("@(\")\" + a)", true)] + [DataRow("@(')' + a)", true)] + [DataRow("@(a /* ) */ + b)", true)] + [DataRow("@(a", false)] + [DataRow("text @(a)", false)] + public void IsExpression_RequiresOneBalancedExpression(string value, bool expected) + { + new PolicyDecompilerContext().IsExpression(value).Should().Be(expected); + } + + private static readonly Lazy s_buildReferences = new(() => + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) + .ToArray()); + + /// + /// Builds the decompiled C# as a project would, with every assembly of the test run as a reference: the + /// policy compiler only reads the syntax it needs and doesn't report a C# type error. + /// + private static void AssertBuilds(string csharp) + { + var compilation = CSharpCompilation.Create( + Guid.NewGuid().ToString(), + [CSharpSyntaxTree.ParseText(csharp)], + s_buildReferences.Value, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + compilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Should().BeEmpty("the decompiled C# should build.\nGenerated C#:\n{0}", csharp); + } + + private static void AssertValidCSharpSyntax(string csharp) + { + CSharpSyntaxTree.ParseText(csharp).GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Should().BeEmpty("the decompiled C# should be syntactically valid.\nGenerated C#:\n{0}", csharp); + } + private static void AssertRoundTrip(string originalXml) { // Step 1: Normalize the original XML through the same serialization pipeline @@ -451,6 +957,7 @@ private static void AssertRoundTrip(string originalXml) "the decompiled C# should compile without errors.\nGenerated C#:\n{0}", csharp); compilationResult.Document.Should().NotBeNull( "the compilation should produce a document.\nGenerated C#:\n{0}", csharp); + AssertBuilds(csharp); // Step 4: Serialize the compiled XElement through the same pipeline var compiledXml = SerializeXElement(compilationResult.Document); @@ -477,6 +984,7 @@ private static void AssertRoundTripSemantic(string originalXml) "the decompiled C# should compile without errors.\nGenerated C#:\n{0}", csharp); compilationResult.Document.Should().NotBeNull( "the compilation should produce a document.\nGenerated C#:\n{0}", csharp); + AssertBuilds(csharp); // Step 3: Normalize both XMLs for semantic comparison var originalDoc = XDocument.Parse(originalXml.Trim()); diff --git a/test/Test.Decompiling/TestData/invoke-dapr-binding.xml b/test/Test.Decompiling/TestData/invoke-dapr-binding.xml index 0af0e9cc..eb6ee5bb 100644 --- a/test/Test.Decompiling/TestData/invoke-dapr-binding.xml +++ b/test/Test.Decompiling/TestData/invoke-dapr-binding.xml @@ -4,7 +4,7 @@ api-gateway - @(context.RequestId) + @(context.RequestId.ToString()) @(context.Request.Body.As<string>()) diff --git a/test/Test.Decompiling/TestData/send-service-bus-message.xml b/test/Test.Decompiling/TestData/send-service-bus-message.xml index 66a59bd0..ff6a4ad7 100644 --- a/test/Test.Decompiling/TestData/send-service-bus-message.xml +++ b/test/Test.Decompiling/TestData/send-service-bus-message.xml @@ -3,7 +3,7 @@ - @(context.RequestId) + @(context.RequestId.ToString()) api-gateway @(context.Request.Body.As<string>()) diff --git a/test/Test.Decompiling/TestDecompiler.cs b/test/Test.Decompiling/TestDecompiler.cs new file mode 100644 index 00000000..46b48e88 --- /dev/null +++ b/test/Test.Decompiling/TestDecompiler.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Xml; +using System.Xml.Linq; + +using Microsoft.Azure.ApiManagement.PolicyToolkit.Decompiling; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Tests.Decompiling; + +/// +/// The decompiler for tests whose policy text is in either format: text that is well-formed XML is read as +/// xml and anything else as rawxml, unless the test states the format it is about. +/// +internal sealed class TestDecompiler +{ + private readonly PolicyDecompiler _decompiler = new(); + + public string DecompileDocument( + string xml, + string className, + string namespaceName, + DecompileOptions? options = null, + PolicyFormat? format = null) => + _decompiler.DecompileDocument(xml, className, namespaceName, WithFormat(options, format, xml)); + + public string DecompileFragment( + string xml, + string fragmentId, + string className, + string namespaceName, + DecompileOptions? options = null, + PolicyFormat? format = null) => + _decompiler.DecompileFragment(xml, fragmentId, className, namespaceName, WithFormat(options, format, xml)); + + public static string Preprocess(string xml) => PolicyDecompiler.PreprocessXml(xml, FormatOf(xml)); + + public static PolicyFormat FormatOf(string xml) + { + try + { + XDocument.Parse(xml); + return PolicyFormat.Xml; + } + catch (XmlException) + { + return PolicyFormat.RawXml; + } + } + + private static DecompileOptions WithFormat(DecompileOptions? options, PolicyFormat? format, string xml) => + (options ?? new DecompileOptions()) with { PolicyFormat = format ?? FormatOf(xml) }; +} diff --git a/test/Test.Decompiling/ToolCliTests.cs b/test/Test.Decompiling/ToolCliTests.cs index 9747dae7..2e312934 100644 --- a/test/Test.Decompiling/ToolCliTests.cs +++ b/test/Test.Decompiling/ToolCliTests.cs @@ -125,6 +125,108 @@ await File.WriteAllTextAsync(Path.Combine(nested, "other.xml"), } } + [TestMethod] + public async Task Decompiler_GivesPoliciesInOneDirectoryDistinctClassNames() + { + var temp = CreateTempDirectory(); + try + { + var orders = Path.Combine(temp, "source", "orders"); + Directory.CreateDirectory(orders); + const string xml = ""; + await File.WriteAllTextAsync(Path.Combine(orders, "policy.xml"), xml); + await File.WriteAllTextAsync(Path.Combine(orders, "get-order.xml"), xml); + await File.WriteAllTextAsync(Path.Combine(orders, "get_order.xml"), xml); + var single = Path.Combine(temp, "source", "single"); + Directory.CreateDirectory(single); + await File.WriteAllTextAsync(Path.Combine(single, "policy.xml"), xml); + // a file that isn't a policy is skipped and doesn't make the fragment share its directory + var limits = Path.Combine(temp, "source", "limits"); + Directory.CreateDirectory(limits); + await File.WriteAllTextAsync(Path.Combine(limits, "policy.xml"), ""); + await File.WriteAllTextAsync(Path.Combine(limits, "notes.xml"), ""); + + var output = Path.Combine(temp, "out"); + var result = await RunToolAsync("Decompiling", "--source", Path.Combine(temp, "source"), "--out", output); + + Assert.AreEqual(0, result.ExitCode, result.Error); + var classNames = new[] { "policy.cs", "get-order.cs", "get_order.cs" } + .Select(file => File.ReadAllText(Path.Combine(output, "orders", file))) + .Select(code => code.Split('\n').Single(line => line.StartsWith("public class ")).Trim()) + .ToList(); + CollectionAssert.AllItemsAreUnique(classNames); + CollectionAssert.Contains(classNames, "public class OrdersPolicy : IDocument"); + CollectionAssert.Contains(classNames, "public class OrdersGetOrderPolicy : IDocument"); + StringAssert.Contains(await File.ReadAllTextAsync(Path.Combine(output, "single", "policy.cs")), + "public class SinglePolicy : IDocument"); + StringAssert.Contains(await File.ReadAllTextAsync(Path.Combine(output, "limits", "policy.cs")), + "[Document(\"limits\""); + } + finally + { + Directory.Delete(temp, recursive: true); + } + } + + [TestMethod] + public async Task Decompiler_ValidatesGeneratedCode_UnlessNoValidate() + { + var temp = CreateTempDirectory(); + try + { + // "1 2" is not a number, so the generated C# does not parse. + var policy = Path.Combine(temp, "policy.xml"); + await File.WriteAllTextAsync(policy, + """"""); + + var validated = await RunToolAsync("Decompiling", "--source", policy, "--out", Path.Combine(temp, "a")); + var notValidated = await RunToolAsync("Decompiling", "--source", policy, "--out", Path.Combine(temp, "b"), + "--no-validate"); + + Assert.AreNotEqual(0, validated.ExitCode); + StringAssert.Contains(validated.Error, "syntax errors"); + Assert.AreEqual(0, notValidated.ExitCode, notValidated.Error); + Assert.IsTrue(File.Exists(Path.Combine(temp, "b", "policy.cs"))); + } + finally + { + Directory.Delete(temp, recursive: true); + } + } + + [TestMethod] + public async Task Decompiler_ReadsThePolicyInTheGivenFormat() + { + var temp = CreateTempDirectory(); + try + { + var policy = Path.Combine(temp, "policy.xml"); + await File.WriteAllTextAsync(policy, + """@(context.Request.Method + "&")"""); + + var raw = await RunToolAsync("Decompiling", "--source", policy, "--out", Path.Combine(temp, "a")); + var asXml = await RunToolAsync("Decompiling", "--source", policy, "--out", Path.Combine(temp, "b"), + "--policy-format", "xml"); + var invalid = await RunToolAsync("Decompiling", "--source", policy, "--out", Path.Combine(temp, "c"), + "--pf", "auto"); + var number = await RunToolAsync("Decompiling", "--source", policy, "--out", Path.Combine(temp, "d"), + "--pf", "1"); + + // rawxml is the default, as for the compiler: the entity is part of the expression + Assert.AreEqual(0, raw.ExitCode, raw.Error); + StringAssert.Contains(await File.ReadAllTextAsync(Path.Combine(temp, "a", "policy.cs")), "Method + \"&\";"); + Assert.AreEqual(0, asXml.ExitCode, asXml.Error); + StringAssert.Contains(await File.ReadAllTextAsync(Path.Combine(temp, "b", "policy.cs")), "Method + \"&\";"); + Assert.AreNotEqual(0, invalid.ExitCode); + StringAssert.Contains(invalid.Error, "Invalid policy format value 'auto'"); + Assert.AreNotEqual(0, number.ExitCode); + } + finally + { + Directory.Delete(temp, recursive: true); + } + } + private static string CreateTempDirectory() { var path = Path.Combine(Path.GetTempPath(), $"policy-toolkit-cli-{Guid.NewGuid():N}"); diff --git a/test/Test.Testing/Emulator/MockUrlTests.cs b/test/Test.Testing/Emulator/MockUrlTests.cs index 63d9ad85..577b6a44 100644 --- a/test/Test.Testing/Emulator/MockUrlTests.cs +++ b/test/Test.Testing/Emulator/MockUrlTests.cs @@ -25,7 +25,7 @@ public void MockUrl_ToString_DefaultValues() public void MockUrl_ToString_WithNonDefaultPort() { // Arrange - var url = new MockUrl { Port = "8443" }; + var url = new MockUrl { Port = 8443 }; // Act var result = url.ToString(); @@ -57,7 +57,7 @@ public void MockUrl_ToString_WithQueryString() public void MockUrl_ToString_HttpDefaultPort() { // Arrange - var url = new MockUrl { Scheme = "http", Port = "80" }; + var url = new MockUrl { Scheme = "http", Port = 80 }; // Act var result = url.ToString(); diff --git a/test/Test.Testing/Emulator/Policies/CheckHeaderTests.cs b/test/Test.Testing/Emulator/Policies/CheckHeaderTests.cs index d6e77d4f..89184106 100644 --- a/test/Test.Testing/Emulator/Policies/CheckHeaderTests.cs +++ b/test/Test.Testing/Emulator/Policies/CheckHeaderTests.cs @@ -21,8 +21,7 @@ public void Inbound(IInboundContext context) Name = "Test", FailCheckHttpCode = 401, FailCheckErrorMessage = "Request do not contain test header", - IgnoreCase = false, - Values = [] + IgnoreCase = false }); } } @@ -46,7 +45,7 @@ public void CheckHeader_Callback() public void CheckHeader_PassExistenceCheck() { var test = new SimpleCheckHeader().AsTestDocument(); - test.Context.Request.Headers["Test"] = ["test"]; + test.Context.Request.Headers["Test"] = ["test", "other"]; test.RunInbound(); diff --git a/test/Test.Testing/Emulator/Policies/RetryTests.cs b/test/Test.Testing/Emulator/Policies/RetryTests.cs index 6527f759..f40ecb09 100644 --- a/test/Test.Testing/Emulator/Policies/RetryTests.cs +++ b/test/Test.Testing/Emulator/Policies/RetryTests.cs @@ -39,6 +39,7 @@ public void Outbound(IOutboundContext context) { context.Retry(new RetryConfig { + Interval = 0, Condition = true, Count = 2, }, () => @@ -58,6 +59,7 @@ public void Inbound(IInboundContext context) { context.Retry(new RetryConfig { + Interval = 0, Condition = true, ConditionEvaluator = () => _attempts < 2, Count = 3, @@ -81,6 +83,7 @@ public void Inbound(IInboundContext context) { context.Retry(new RetryConfig { + Interval = 0, Condition = true, ConditionEvaluator = () => _attempts < 2, Count = 3, diff --git a/test/Test.Testing/Emulator/Policies/SemanticCacheStoreTests.cs b/test/Test.Testing/Emulator/Policies/SemanticCacheStoreTests.cs new file mode 100644 index 00000000..fdec899c --- /dev/null +++ b/test/Test.Testing/Emulator/Policies/SemanticCacheStoreTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Testing; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Document; + +namespace Test.Emulator.Emulator.Policies; + +[TestClass] +public class SemanticCacheStoreTests +{ + class SemanticCacheStores : IDocument + { + public void Outbound(IOutboundContext context) + { + context.LlmSemanticCacheStore(10); + context.AzureOpenAiSemanticCacheStore(20, true); + } + } + + [TestMethod] + public void SemanticCacheStore_RunsWithAndWithoutCacheResponse() + { + var test = new SemanticCacheStores().AsTestDocument(); + var durations = new List(); + test.SetupOutbound().LlmSemanticCacheStore().WithCallback((_, duration) => durations.Add(duration)); + test.SetupOutbound().AzureOpenAiSemanticCacheStore().WithCallback((_, duration) => durations.Add(duration)); + + test.RunOutbound(); + + durations.Should().Equal(10u, 20u); + } +} diff --git a/test/Test.Testing/Emulator/Policies/SetStatusTests.cs b/test/Test.Testing/Emulator/Policies/SetStatusTests.cs index c5b25938..9e3e2544 100644 --- a/test/Test.Testing/Emulator/Policies/SetStatusTests.cs +++ b/test/Test.Testing/Emulator/Policies/SetStatusTests.cs @@ -33,6 +33,25 @@ public void OnError(IOnErrorContext context) } } + class SetStatusWithoutReason : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetStatus(new StatusConfig() { Code = 404 }); + } + } + + [TestMethod] + public void SetStatus_WithoutReason() + { + var test = new SetStatusWithoutReason().AsTestDocument(); + + test.RunInbound(); + + test.Context.Response.StatusCode.Should().Be(404); + test.Context.Response.StatusReason.Should().BeEmpty(); + } + [TestMethod] public void SetStatus_Callback() {