diff --git a/samples/IslandGateway.Sample/appsettings.json b/samples/IslandGateway.Sample/appsettings.json index 15312c135..f9e562809 100644 --- a/samples/IslandGateway.Sample/appsettings.json +++ b/samples/IslandGateway.Sample/appsettings.json @@ -34,7 +34,11 @@ { "RouteId": "backend1/route1", "BackendId": "backend1", - "Rule": "Host('localhost') && Path('/{**catchall}')" + "Match": { + "Methods": [ "GET", "POST" ], + "Host": "localhost", + "Path": "/{**catchall}" + } } ] } diff --git a/src/IslandGateway.Core/Abstractions/RouteDiscovery/Contract/GatewayMatch.cs b/src/IslandGateway.Core/Abstractions/RouteDiscovery/Contract/GatewayMatch.cs new file mode 100644 index 000000000..15efacb6d --- /dev/null +++ b/src/IslandGateway.Core/Abstractions/RouteDiscovery/Contract/GatewayMatch.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; + +namespace IslandGateway.Core.Abstractions +{ + /// + /// Describes the matching criteria for a route. + /// + public class GatewayMatch : IDeepCloneable + { + /// + /// Only match requests that use these optional HTTP methods. E.g. GET, POST. + /// + public IReadOnlyList Methods { get; set; } + + /// + /// Only match requests with the given Host header. + /// + public string Host { get; set; } + + /// + /// Only match requests with the given Path pattern. + /// + public string Path { get; set; } + + // TODO: + /// + /// Only match requests that contain all of these query parameters. + /// + // public ICollection> QueryParameters { get; set; } + + // TODO: + /// + /// Only match requests that contain all of these request headers. + /// + // public ICollection> Headers { get; set; } + + GatewayMatch IDeepCloneable.DeepClone() + { + return new GatewayMatch() + { + Methods = Methods?.ToArray(), + Host = Host, + Path = Path, + // Headers = Headers.DeepClone(); // TODO: + }; + } + } +} diff --git a/src/IslandGateway.Core/Abstractions/RouteDiscovery/Contract/GatewayRoute.cs b/src/IslandGateway.Core/Abstractions/RouteDiscovery/Contract/GatewayRoute.cs index 3a5d9ce6b..3eaac9254 100644 --- a/src/IslandGateway.Core/Abstractions/RouteDiscovery/Contract/GatewayRoute.cs +++ b/src/IslandGateway.Core/Abstractions/RouteDiscovery/Contract/GatewayRoute.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System; @@ -7,7 +7,7 @@ namespace IslandGateway.Core.Abstractions { /// - /// Describes a route that matches incoming requests based on a + /// Describes a route that matches incoming requests based on a the criteria /// and proxies matching requests to the backend identified by its . /// public sealed class GatewayRoute : IDeepCloneable @@ -17,10 +17,7 @@ public sealed class GatewayRoute : IDeepCloneable /// public string RouteId { get; set; } - /// - /// Rule that incoming requests must match for this route to apply. E.g. Host('example.com'). - /// - public string Rule { get; set; } + public GatewayMatch Match { get; private set; } = new GatewayMatch(); /// /// Optionally, a priority value for this route. Routes with higher numbers take precedence over lower numbers. @@ -44,7 +41,7 @@ GatewayRoute IDeepCloneable.DeepClone() return new GatewayRoute { RouteId = RouteId, - Rule = Rule, + Match = Match.DeepClone(), Priority = Priority, BackendId = BackendId, Metadata = Metadata?.DeepClone(StringComparer.Ordinal), diff --git a/src/IslandGateway.Core/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/IslandGateway.Core/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 16e57c161..c3b7bf3c3 100644 --- a/src/IslandGateway.Core/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/src/IslandGateway.Core/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using IslandGateway.Common.Abstractions.Telemetry; @@ -43,8 +43,6 @@ public static IIslandGatewayBuilder AddInMemoryRepos(this IIslandGatewayBuilder public static IIslandGatewayBuilder AddConfigBuilder(this IIslandGatewayBuilder builder) { builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); return builder; diff --git a/src/IslandGateway.Core/IslandGateway.Core.csproj b/src/IslandGateway.Core/IslandGateway.Core.csproj index 4af24a10c..8ed1eb153 100644 --- a/src/IslandGateway.Core/IslandGateway.Core.csproj +++ b/src/IslandGateway.Core/IslandGateway.Core.csproj @@ -10,10 +10,6 @@ - - - - diff --git a/src/IslandGateway.Core/Service/Config/ConfigErrors.cs b/src/IslandGateway.Core/Service/Config/ConfigErrors.cs index e87e53b8a..186bb9af5 100644 --- a/src/IslandGateway.Core/Service/Config/ConfigErrors.cs +++ b/src/IslandGateway.Core/Service/Config/ConfigErrors.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. namespace IslandGateway.Core.Service @@ -14,7 +14,6 @@ internal static class ConfigErrors internal const string RouteUnknownBackend = "Route_UnknownBackend"; internal const string RouteNoBackends = "Route_NoBackends"; internal const string RouteUnsupportedAction = "Route_UnsupportedAction"; - internal const string RouteBadRule = "Route_BadRule"; internal const string ParsedRouteMissingId = "ParsedRoute_MissingId"; internal const string ParsedRouteRuleHasNoMatchers = "ParsedRoute_RuleHasNoMatchers"; diff --git a/src/IslandGateway.Core/Service/Config/DynamicConfigBuilder.cs b/src/IslandGateway.Core/Service/Config/DynamicConfigBuilder.cs index 5fd2fc718..dd08cb92d 100644 --- a/src/IslandGateway.Core/Service/Config/DynamicConfigBuilder.cs +++ b/src/IslandGateway.Core/Service/Config/DynamicConfigBuilder.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System; @@ -16,26 +16,22 @@ internal class DynamicConfigBuilder : IDynamicConfigBuilder private readonly IBackendsRepo _backendsRepo; private readonly IBackendEndpointsRepo _endpointsRepo; private readonly IRoutesRepo _routesRepo; - private readonly IRouteParser _routeParser; private readonly IRouteValidator _parsedRouteValidator; public DynamicConfigBuilder( IBackendsRepo backendsRepo, IBackendEndpointsRepo endpointsRepo, IRoutesRepo routesRepo, - IRouteParser routeParser, IRouteValidator parsedRouteValidator) { Contracts.CheckValue(backendsRepo, nameof(backendsRepo)); Contracts.CheckValue(endpointsRepo, nameof(endpointsRepo)); Contracts.CheckValue(routesRepo, nameof(routesRepo)); - Contracts.CheckValue(routeParser, nameof(routeParser)); Contracts.CheckValue(parsedRouteValidator, nameof(parsedRouteValidator)); _backendsRepo = backendsRepo; _endpointsRepo = endpointsRepo; _routesRepo = routesRepo; - _routeParser = routeParser; _parsedRouteValidator = parsedRouteValidator; } @@ -118,14 +114,16 @@ private async Task> GetRoutesAsync(IConfigErrorReporter error continue; } - var parsedResult = _routeParser.ParseRoute(route, errorReporter); - if (!parsedResult.IsSuccess) - { - // routeParser already reported error message - continue; - } + var parsedRoute = new ParsedRoute { + RouteId = route.RouteId, + Methods = route.Match.Methods, + Host = route.Match.Host, + Path = route.Match.Path, + Priority = route.Priority, + BackendId = route.BackendId, + Metadata = route.Metadata, + }; - var parsedRoute = parsedResult.Value; if (!_parsedRouteValidator.ValidateRoute(parsedRoute, errorReporter)) { // parsedRouteValidator already reported error message diff --git a/src/IslandGateway.Core/Service/Config/IRouteParser.cs b/src/IslandGateway.Core/Service/Config/IRouteParser.cs deleted file mode 100644 index 69656bcb4..000000000 --- a/src/IslandGateway.Core/Service/Config/IRouteParser.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using IslandGateway.Core.Abstractions; -using IslandGateway.Core.ConfigModel; - -namespace IslandGateway.Core.Service -{ - /// - /// Provides a method to parse a route into a format convenient for internal use in Island Gateway. - /// - internal interface IRouteParser - { - /// - /// Parses a route into a format convenient for internal use in Island Gateway. - /// - Result ParseRoute(GatewayRoute route, IConfigErrorReporter errorReporter); - } -} diff --git a/src/IslandGateway.Core/Service/Config/RouteParser.cs b/src/IslandGateway.Core/Service/Config/RouteParser.cs deleted file mode 100644 index 07f03fb55..000000000 --- a/src/IslandGateway.Core/Service/Config/RouteParser.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using IslandGateway.Core.Abstractions; -using IslandGateway.Core.ConfigModel; -using IslandGateway.Utilities; - -namespace IslandGateway.Core.Service -{ - internal class RouteParser : IRouteParser - { - private readonly IRuleParser _ruleParser; - - public RouteParser(IRuleParser ruleParser) - { - Contracts.CheckValue(ruleParser, nameof(ruleParser)); - _ruleParser = ruleParser; - } - - public Result ParseRoute(GatewayRoute route, IConfigErrorReporter errorReporter) - { - if (route.Rule == null) - { - errorReporter.ReportError(ConfigErrors.RouteBadRule, route.RouteId, $"Route '{route.RouteId}' did not specify a rule"); - return Result.Failure(); - } - - var parsedRule = _ruleParser.Parse(route.Rule); - if (!parsedRule.IsSuccess) - { - errorReporter.ReportError(ConfigErrors.RouteBadRule, route.RouteId, $"Route '{route.RouteId}' has an invalid rule: {parsedRule.Error} (rule: {route.Rule}"); - return Result.Failure(); - } - - var parsedRoute = new ParsedRoute - { - RouteId = route.RouteId, - Rule = route.Rule, - Matchers = parsedRule.Value, - Priority = route.Priority, - BackendId = route.BackendId, - Metadata = route.Metadata, - }; - - return Result.Success(parsedRoute); - } - } -} diff --git a/src/IslandGateway.Core/Service/Config/RuleParsing/IRuleParser.cs b/src/IslandGateway.Core/Service/Config/RuleParsing/IRuleParser.cs deleted file mode 100644 index d22d04a13..000000000 --- a/src/IslandGateway.Core/Service/Config/RuleParsing/IRuleParser.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Collections.Generic; -using IslandGateway.Core.Abstractions; - -namespace IslandGateway.Core.Service -{ - - /// - /// Interface for a class that parses Core Gateway rules - /// such as HostName('abc.example.com') && PathPrefix('/a/b') - /// and produces the corresponding AST. - /// - internal interface IRuleParser - { - /// - /// Parses a rule and produces the corresponding AST or an error value. - /// - Result, string> Parse(string rule); - } -} diff --git a/src/IslandGateway.Core/Service/Config/RuleParsing/Matchers/HostMatcher.cs b/src/IslandGateway.Core/Service/Config/RuleParsing/Matchers/HostMatcher.cs deleted file mode 100644 index 4f280fad9..000000000 --- a/src/IslandGateway.Core/Service/Config/RuleParsing/Matchers/HostMatcher.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using IslandGateway.Utilities; - -namespace IslandGateway.Core.Service -{ - internal sealed class HostMatcher : RuleMatcherBase - { - public HostMatcher(string name, string[] args) - : base(name, args) - { - Contracts.Check(args.Length == 1, $"Expected 1 argument, found {args.Length}."); - Contracts.CheckNonEmpty(args[0], $"{nameof(args)}[0]"); - } - - public string Host => Args[0]; - } -} diff --git a/src/IslandGateway.Core/Service/Config/RuleParsing/Matchers/MethodMatcher.cs b/src/IslandGateway.Core/Service/Config/RuleParsing/Matchers/MethodMatcher.cs deleted file mode 100644 index 95e6f7d62..000000000 --- a/src/IslandGateway.Core/Service/Config/RuleParsing/Matchers/MethodMatcher.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using IslandGateway.Utilities; - -namespace IslandGateway.Core.Service -{ - internal sealed class MethodMatcher : RuleMatcherBase - { - public MethodMatcher(string name, string[] args) - : base(name, args) - { - Contracts.Check(args.Length >= 1, $"Expected at least 1 argument, found {args.Length}."); - } - - public string[] Methods => Args; - } -} diff --git a/src/IslandGateway.Core/Service/Config/RuleParsing/Matchers/PathMatcher.cs b/src/IslandGateway.Core/Service/Config/RuleParsing/Matchers/PathMatcher.cs deleted file mode 100644 index fd86e11ab..000000000 --- a/src/IslandGateway.Core/Service/Config/RuleParsing/Matchers/PathMatcher.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using IslandGateway.Utilities; - -namespace IslandGateway.Core.Service -{ - internal sealed class PathMatcher : RuleMatcherBase - { - public PathMatcher(string name, string[] args) - : base(name, args) - { - Contracts.Check(args.Length == 1, $"Expected 1 argument, found {args.Length}."); - Contracts.CheckNonEmpty(args[0], $"{nameof(args)}[0]"); - } - - public string Pattern => Args[0]; - } -} diff --git a/src/IslandGateway.Core/Service/Config/RuleParsing/RouteValidator.cs b/src/IslandGateway.Core/Service/Config/RuleParsing/RouteValidator.cs index 6a03c00a8..f2f391f99 100644 --- a/src/IslandGateway.Core/Service/Config/RuleParsing/RouteValidator.cs +++ b/src/IslandGateway.Core/Service/Config/RuleParsing/RouteValidator.cs @@ -1,9 +1,8 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System; using System.Collections.Generic; -using System.Linq; using System.Text.RegularExpressions; using IslandGateway.Core.Abstractions; using IslandGateway.Core.ConfigModel; @@ -14,6 +13,7 @@ namespace IslandGateway.Core.Service { internal class RouteValidator : IRouteValidator { + // TODO: IDN support. How strictly do we need to validate this anyways? This is app config, not external input. /// /// Regex explanation: /// Either: @@ -42,6 +42,7 @@ internal class RouteValidator : IRouteValidator "HEAD", "OPTIONS", "GET", "PUT", "POST", "PATCH", "DELETE", "TRACE", }; + // Note this performs all validation steps without short circuiting in order to report all possible errors. public bool ValidateRoute(ParsedRoute route, IConfigErrorReporter errorReporter) { Contracts.CheckValue(route, nameof(route)); @@ -54,121 +55,76 @@ public bool ValidateRoute(ParsedRoute route, IConfigErrorReporter errorReporter) success = false; } - if ((route.Matchers?.Count ?? 0) == 0) - { - errorReporter.ReportError(ConfigErrors.ParsedRouteRuleHasNoMatchers, route.RouteId, $"Route '{route.RouteId}' rule has no matchers."); - success = false; - } - - if (route.Matchers != null && !route.Matchers.Any(m => m is HostMatcher)) - { - errorReporter.ReportError(ConfigErrors.ParsedRouteRuleMissingHostMatcher, route.RouteId, $"Route '{route.RouteId}' rule is missing required matcher 'Host()'."); - success = false; - } - - if (route.Matchers != null && route.Matchers.Count(m => m is HostMatcher) > 1) - { - errorReporter.ReportError(ConfigErrors.ParsedRouteRuleMultipleHostMatchers, route.RouteId, $"Route '{route.RouteId}' rule has more than one 'Host()' matcher."); - success = false; - } - - if (route.Matchers != null && route.Matchers.Count(m => m is PathMatcher) > 1) - { - errorReporter.ReportError(ConfigErrors.ParsedRouteRuleMultiplePathMatchers, route.RouteId, $"Route '{route.RouteId}' rule has more than one 'Path()' matchers."); - success = false; - } - - if (route.Matchers != null && !ValidateAllMatchers(route.RouteId, route.Matchers, errorReporter)) - { - success = false; - } + success &= ValidateHost(route.Host, route.RouteId, errorReporter); + success &= ValidatePath(route.Path, route.RouteId, errorReporter); + success &= ValidateMethods(route.Methods, route.RouteId, errorReporter); return success; } - private static bool ValidateAllMatchers(string routeId, IList matchers, IConfigErrorReporter errorReporter) + private static bool ValidateHost(string host, string routeId, IConfigErrorReporter errorReporter) { - var success = true; - - foreach (var matcher in matchers) + // TODO: Why is Host required? I'd only expect Host OR Path to be required, with Path being the more common usage. + if (string.IsNullOrEmpty(host)) { - bool roundSuccess; - string errorMessage; - - switch (matcher) - { - case HostMatcher hostMatcher: - roundSuccess = ValidateHostMatcher(hostMatcher, out errorMessage); - break; - case PathMatcher pathMatcher: - roundSuccess = ValidatePathMatcher(pathMatcher, out errorMessage); - break; - case MethodMatcher methodMatcher: - roundSuccess = ValidateMethodMatcher(methodMatcher, out errorMessage); - break; - default: - roundSuccess = false; - errorMessage = "Unknown matcher"; - break; - } - - if (!roundSuccess) - { - errorReporter.ReportError(ConfigErrors.ParsedRouteRuleInvalidMatcher, routeId, $"Invalid matcher '{matcher}'. {errorMessage}"); - success = false; - } + errorReporter.ReportError(ConfigErrors.ParsedRouteRuleMissingHostMatcher, routeId, $"Route '{routeId}' is missing required field 'Host'."); + return false; } - return success; - } - - private static bool ValidateHostMatcher(HostMatcher hostMatcher, out string errorMessage) - { - if (!_hostNameRegex.IsMatch(hostMatcher.Host)) + if (!_hostNameRegex.IsMatch(host)) { - errorMessage = $"Invalid host name '{hostMatcher.Host}'"; + errorReporter.ReportError(ConfigErrors.ParsedRouteRuleInvalidMatcher, routeId, $"Invalid host name '{host}'"); return false; } - errorMessage = null; return true; } - private static bool ValidatePathMatcher(PathMatcher pathMatcher, out string errorMessage) + private static bool ValidatePath(string path, string routeId, IConfigErrorReporter errorReporter) { + // Path is optional + if (string.IsNullOrEmpty(path)) + { + return true; + } + try { - RoutePatternFactory.Parse(pathMatcher.Pattern); + RoutePatternFactory.Parse(path); } catch (RoutePatternException ex) { - errorMessage = $"Invalid path pattern '{pathMatcher.Pattern}': {ex.Message}"; + errorReporter.ReportError(ConfigErrors.ParsedRouteRuleInvalidMatcher, routeId, $"Invalid path pattern '{path}': {ex.Message}"); return false; } - errorMessage = null; return true; } - private static bool ValidateMethodMatcher(MethodMatcher methodMatcher, out string errorMessage) + private static bool ValidateMethods(IReadOnlyList methods, string routeId, IConfigErrorReporter errorReporter) { + // Methods are optional + if (methods == null) + { + return true; + } + var seenMethods = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var method in methodMatcher.Methods) + foreach (var method in methods) { if (!seenMethods.Add(method)) { - errorMessage = $"Duplicate verb '{method}'"; + errorReporter.ReportError(ConfigErrors.ParsedRouteRuleInvalidMatcher, routeId, $"Duplicate verb '{method}'"); return false; } if (!_validMethods.Contains(method)) { - errorMessage = $"Unsupported verb '{method}'"; + errorReporter.ReportError(ConfigErrors.ParsedRouteRuleInvalidMatcher, routeId, $"Unsupported verb '{method}'"); return false; } } - errorMessage = null; return true; } } diff --git a/src/IslandGateway.Core/Service/Config/RuleParsing/RuleMatcherBase.cs b/src/IslandGateway.Core/Service/Config/RuleParsing/RuleMatcherBase.cs deleted file mode 100644 index 2a419cfbd..000000000 --- a/src/IslandGateway.Core/Service/Config/RuleParsing/RuleMatcherBase.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Linq; -using IslandGateway.Utilities; - -namespace IslandGateway.Core.Service -{ - internal abstract class RuleMatcherBase - { - protected RuleMatcherBase(string name, string[] args) - { - Contracts.CheckNonEmpty(name, nameof(name)); - Contracts.CheckValue(args, nameof(args)); - Name = name; - Args = args; - } - - internal string Name { get; } - internal string[] Args { get; } - - public override string ToString() - { - return $"{Name}({FormatArgs()})"; - - string FormatArgs() - { - return string.Join(", ", Args.Select(FormatArg)); - - static string FormatArg(string arg) - { - return $"'{arg.Replace("'", "''")}'"; - } - } - } - } -} diff --git a/src/IslandGateway.Core/Service/Config/RuleParsing/RuleParser.cs b/src/IslandGateway.Core/Service/Config/RuleParsing/RuleParser.cs deleted file mode 100644 index 40317e2e4..000000000 --- a/src/IslandGateway.Core/Service/Config/RuleParsing/RuleParser.cs +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Collections.Generic; -using IslandGateway.Core.Abstractions; -using IslandGateway.Utilities; -using Superpower; -using Superpower.Display; -using Superpower.Model; -using Superpower.Parsers; -using Superpower.Tokenizers; - -namespace IslandGateway.Core.Service -{ - /// - /// Interface for a class that parses Core Gateway rules - /// such as HostName('abc.example.com') && PathPrefix('/a/b') - /// and produces the corresponding AST. - /// - internal class RuleParser : IRuleParser - { - internal enum RuleToken - { - None, - - [Token(Category = "identifier", Example = "Host, PathPrefix, Path, ...")] - Identifier, - - [Token(Category = "parentheses", Example = "(")] - LParen, - - [Token(Category = "parentheses", Example = ")")] - RParen, - - [Token(Category = "string literal", Example = "'example ''with'' quotes'")] - StringLiteral, - - [Token(Category = "comma", Example = ",")] - Comma, - - [Token(Category = "operator", Example = "&&")] - LogicalAnd, - - [Token(Category = "operator", Example = "||")] - LogicalOr, - } - - public Result, string> Parse(string rule) - { - Contracts.CheckValue(rule, nameof(rule)); - - MatcherNode[] parsedNodes; - try - { - var tokens = Lexer.Tokenize(rule); - parsedNodes = Parser.Parse(tokens); - } - catch (ParseException ex) - { - return Result, string>.Failure($"Parse error: {ex.Message}"); - } - - var results = new List(parsedNodes.Length); - foreach (var node in parsedNodes) - { - RuleMatcherBase matcher; - switch (node.MatcherName.ToUpperInvariant()) - { - case "HOST": - if (node.Arguments.Length != 1) - { - return Result, string>.Failure($"'Host' matcher requires one argument, found {node.Arguments.Length}"); - } - matcher = new HostMatcher("Host", node.Arguments); - break; - case "PATH": - if (node.Arguments.Length != 1) - { - return Result, string>.Failure($"'Path' matcher requires one argument, found {node.Arguments.Length}"); - } - matcher = new PathMatcher("Path", node.Arguments); - break; - case "METHOD": - if (node.Arguments.Length < 1) - { - return Result, string>.Failure($"'Method' matcher requires at least one argument, found {node.Arguments.Length}"); - } - matcher = new MethodMatcher("Method", node.Arguments); - break; - case "QUERY": - case "HEADER": - default: - return Result, string>.Failure($"Unsupported matcher '{node.MatcherName}'"); - } - - results.Add(matcher); - } - - return Result, string>.Success(results); - } - - internal static class Lexer - { - private static readonly Tokenizer Tokenizer = new TokenizerBuilder() - .Ignore(Span.WhiteSpace) - .Match(Character.EqualTo('('), RuleToken.LParen) - .Match(Character.EqualTo(')'), RuleToken.RParen) - .Match(Character.EqualTo(','), RuleToken.Comma) - .Match(Span.EqualTo("&&"), RuleToken.LogicalAnd) - .Match(Span.EqualTo("||"), RuleToken.LogicalOr) - .Match(Identifier.CStyle, RuleToken.Identifier) - .Match(QuotedString.SqlStyle, RuleToken.StringLiteral) - .Build(); - - public static TokenList Tokenize(string input) - { - return Tokenizer.Tokenize(input); - } - } - - internal static class Parser - { - private static readonly TokenListParser SingleArgument = - from stringLiteral in Token.EqualTo(RuleToken.StringLiteral).Apply(QuotedString.SqlStyle) - select stringLiteral; - - private static readonly TokenListParser MatcherInvocation = - from identifier in Token.EqualTo(RuleToken.Identifier) - from open in Token.EqualTo(RuleToken.LParen) - from arg in SingleArgument.ManyDelimitedBy(Token.EqualTo(RuleToken.Comma)) - from close in Token.EqualTo(RuleToken.RParen) - select new MatcherNode { MatcherName = identifier.ToStringValue(), Arguments = arg }; - - private static readonly TokenListParser MatcherInvocations = - from matchers in MatcherInvocation.ManyDelimitedBy(Token.EqualTo(RuleToken.LogicalAnd)).AtEnd() - select matchers; - - public static MatcherNode[] Parse(TokenList tokens) - { - return MatcherInvocations.Parse(tokens); - } - } - - internal class MatcherNode - { - public string MatcherName { get; set; } - public string[] Arguments { get; set; } - } - } -} diff --git a/src/IslandGateway.Core/Service/ConfigModel/ParsedRoute.cs b/src/IslandGateway.Core/Service/ConfigModel/ParsedRoute.cs index 3213d568b..99788b683 100644 --- a/src/IslandGateway.Core/Service/ConfigModel/ParsedRoute.cs +++ b/src/IslandGateway.Core/Service/ConfigModel/ParsedRoute.cs @@ -1,11 +1,13 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Collections.Generic; +using System.Text; using IslandGateway.Core.Service; namespace IslandGateway.Core.ConfigModel { + // TODO: Do we even need the ParsedRoute? It now matches the GatewayRoute 1:1 internal class ParsedRoute { /// @@ -14,15 +16,31 @@ internal class ParsedRoute public string RouteId { get; set; } /// - /// Gets or sets the original rule expression for this route. + /// Only match requests that use these optional HTTP methods. E.g. GET, POST. /// - public string Rule { get; set; } + public IReadOnlyList Methods { get; set; } /// - /// Gets or sets the parsed matchers for this route. This is computed - /// from the original route's rule. + /// Only match requests with the given Host header. /// - public IList Matchers { get; set; } + public string Host { get; set; } + + /// + /// Only match requests with the given Path pattern. + /// + public string Path { get; set; } + + // TODO: + /// + /// Only match requests that contain all of these query parameters. + /// + // public ICollection> QueryParameters { get; set; } + + // TODO: + /// + /// Only match requests that contain all of these request headers. + /// + // public ICollection> Headers { get; set; } /// /// Gets or sets the priority of this route. @@ -40,5 +58,29 @@ internal class ParsedRoute /// Arbitrary key-value pairs that further describe this route. /// public IDictionary Metadata { get; set; } + + internal string GetMatcherSummary() + { + var builder = new StringBuilder(); + + if (!string.IsNullOrEmpty(Host)) + { + builder.AppendFormat("Host({0});", Host); + } + + if (!string.IsNullOrEmpty(Path)) + { + builder.AppendFormat("Path({0});", Path); + } + + if (Methods != null && Methods.Count > 0) + { + builder.Append("Methods("); + builder.AppendJoin(',', Methods); + builder.Append(");"); + } + + return builder.ToString(); + } } } diff --git a/src/IslandGateway.Core/Service/DynamicEndpoints/RuntimeRouteBuilder.cs b/src/IslandGateway.Core/Service/DynamicEndpoints/RuntimeRouteBuilder.cs index 77d0dbf83..0b89213b4 100644 --- a/src/IslandGateway.Core/Service/DynamicEndpoints/RuntimeRouteBuilder.cs +++ b/src/IslandGateway.Core/Service/DynamicEndpoints/RuntimeRouteBuilder.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Collections.Generic; @@ -39,23 +39,14 @@ public RouteConfig Build(ParsedRoute source, BackendInfo backendOrNull, RouteInf var aspNetCoreEndpoints = new List(1); var newRouteConfig = new RouteConfig( route: runtimeRoute, - rule: source.Rule, + matcherSummary: source.GetMatcherSummary(), priority: source.Priority, backendOrNull: backendOrNull, aspNetCoreEndpoints: aspNetCoreEndpoints.AsReadOnly()); // TODO: Handle arbitrary AST's properly - string pathPattern; - var pathMatcher = (PathMatcher)source.Matchers?.FirstOrDefault(m => m is PathMatcher); - if (pathMatcher != null) - { - pathPattern = pathMatcher.Pattern; - } - else - { - // Catch-all pattern when no matcher was specified - pathPattern = "/{**catchall}"; - } + // Catch-all pattern when no path was specified + var pathPattern = string.IsNullOrEmpty(source.Path) ? "/{**catchall}" : source.Path; // TODO: Propagate route priority var endpointBuilder = new AspNetCore.Routing.RouteEndpointBuilder( @@ -65,10 +56,14 @@ public RouteConfig Build(ParsedRoute source, BackendInfo backendOrNull, RouteInf endpointBuilder.DisplayName = source.RouteId; endpointBuilder.Metadata.Add(newRouteConfig); - var hostMatcher = source.Matchers?.FirstOrDefault(m => m is HostMatcher) as HostMatcher; - if (hostMatcher != null) + if (source.Host != null) + { + endpointBuilder.Metadata.Add(new AspNetCore.Routing.HostAttribute(source.Host)); + } + + if (source.Methods != null && source.Methods.Count > 0) { - endpointBuilder.Metadata.Add(new AspNetCore.Routing.HostAttribute(hostMatcher.Host)); + endpointBuilder.Metadata.Add(new AspNetCore.Routing.HttpMethodMetadata(source.Methods)); } var endpoint = endpointBuilder.Build(); diff --git a/src/IslandGateway.Core/Service/Management/IslandGatewayConfigManager.cs b/src/IslandGateway.Core/Service/Management/IslandGatewayConfigManager.cs index 132bcf344..4ab0f180d 100644 --- a/src/IslandGateway.Core/Service/Management/IslandGatewayConfigManager.cs +++ b/src/IslandGateway.Core/Service/Management/IslandGatewayConfigManager.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System; @@ -177,9 +177,7 @@ private void UpdateRuntimeRoutes(DynamicConfigRoot config) { var currentRouteConfig = route.Config.Value; if (currentRouteConfig == null || - currentRouteConfig.Rule != configRoute.Rule || - currentRouteConfig.Priority != configRoute.Priority || - currentRouteConfig.BackendOrNull != backendOrNull) + currentRouteConfig.HasConfigChanged(configRoute, backendOrNull)) { // Config changed, so update runtime route changed = true; diff --git a/src/IslandGateway.Core/Service/RuntimeModel/RouteConfig.cs b/src/IslandGateway.Core/Service/RuntimeModel/RouteConfig.cs index 817bd66a2..42cc83bab 100644 --- a/src/IslandGateway.Core/Service/RuntimeModel/RouteConfig.cs +++ b/src/IslandGateway.Core/Service/RuntimeModel/RouteConfig.cs @@ -1,7 +1,9 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Collections.Generic; +using IslandGateway.Core.ConfigModel; +using IslandGateway.Core.Service; using IslandGateway.Utilities; using AspNetCore = Microsoft.AspNetCore; @@ -15,13 +17,13 @@ namespace IslandGateway.Core.RuntimeModel /// /// All members must remain immutable to avoid thread safety issues. /// Instead, instances of are replaced - /// in ther entirety when values need to change. + /// in their entirety when values need to change. /// internal sealed class RouteConfig { public RouteConfig( RouteInfo route, - string rule, + string matcherSummary, int? priority, BackendInfo backendOrNull, IReadOnlyList aspNetCoreEndpoints) @@ -30,7 +32,7 @@ public RouteConfig( Contracts.CheckValue(aspNetCoreEndpoints, nameof(aspNetCoreEndpoints)); Route = route; - Rule = rule; + MatcherSummary = matcherSummary; Priority = priority; BackendOrNull = backendOrNull; AspNetCoreEndpoints = aspNetCoreEndpoints; @@ -38,12 +40,19 @@ public RouteConfig( public RouteInfo Route { get; } - public string Rule { get; } + internal string MatcherSummary{ get; } public int? Priority { get; } public BackendInfo BackendOrNull { get; } public IReadOnlyList AspNetCoreEndpoints { get; } + + public bool HasConfigChanged(ParsedRoute newConfig, BackendInfo backendOrNull) + { + return Priority != newConfig.Priority + || BackendOrNull != backendOrNull + || !MatcherSummary.Equals(newConfig.GetMatcherSummary()); + } } } diff --git a/test/IslandGateway.Core.Tests/Abstractions/RouteDiscovery/Contract/GatewayRouteTests.cs b/test/IslandGateway.Core.Tests/Abstractions/RouteDiscovery/Contract/GatewayRouteTests.cs index c3299afaf..35f643da1 100644 --- a/test/IslandGateway.Core.Tests/Abstractions/RouteDiscovery/Contract/GatewayRouteTests.cs +++ b/test/IslandGateway.Core.Tests/Abstractions/RouteDiscovery/Contract/GatewayRouteTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Collections.Generic; @@ -22,7 +22,12 @@ public void DeepClone_Works() var sut = new GatewayRoute { RouteId = "route1", - Rule = "Host('example.com')", + Match = + { + Methods = new[] { "GET", "POST" }, + Host = "example.com", + Path = "/", + }, Priority = 2, BackendId = "backend1", Metadata = new Dictionary @@ -37,7 +42,11 @@ public void DeepClone_Works() // Assert clone.Should().NotBeSameAs(sut); clone.RouteId.Should().Be(sut.RouteId); - clone.Rule.Should().Be(sut.Rule); + clone.Match.Should().NotBeSameAs(sut.Match); + clone.Match.Methods.Should().NotBeSameAs(sut.Match.Methods); + clone.Match.Methods.Should().BeEquivalentTo(sut.Match.Methods); + clone.Match.Host.Should().Be(sut.Match.Host); + clone.Match.Path.Should().Be(sut.Match.Path); clone.Priority.Should().Be(sut.Priority); clone.BackendId.Should().Be(sut.BackendId); clone.Metadata.Should().NotBeNull(); @@ -57,7 +66,9 @@ public void DeepClone_Nulls_Works() // Assert clone.Should().NotBeSameAs(sut); clone.RouteId.Should().BeNull(); - clone.Rule.Should().BeNull(); + clone.Match.Methods.Should().BeNull(); + clone.Match.Host.Should().BeNull(); + clone.Match.Path.Should().BeNull(); clone.Priority.Should().BeNull(); clone.BackendId.Should().BeNull(); clone.Metadata.Should().BeNull(); diff --git a/test/IslandGateway.Core.Tests/Service/Config/DynamicConfigBuilderTests.cs b/test/IslandGateway.Core.Tests/Service/Config/DynamicConfigBuilderTests.cs index 637936d47..ad787a65a 100644 --- a/test/IslandGateway.Core.Tests/Service/Config/DynamicConfigBuilderTests.cs +++ b/test/IslandGateway.Core.Tests/Service/Config/DynamicConfigBuilderTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Collections.Generic; @@ -107,18 +107,13 @@ public async Task BuildConfigAsync_ValidRoute_Works() .Setup(r => r.GetBackendsAsync(It.IsAny())) .ReturnsAsync(new List()); - var route1 = new GatewayRoute { RouteId = "route1", Rule = "Host('example.com')", Priority = 1, BackendId = "backend1" }; + var route1 = new GatewayRoute { RouteId = "route1", Match = { Host = "example.com" }, Priority = 1, BackendId = "backend1" }; Mock() .Setup(r => r.GetRoutesAsync(It.IsAny())) .ReturnsAsync(new[] { route1 }); - var parsedRoute1 = new ParsedRoute(); - Mock() - .Setup(r => r.ParseRoute(route1, errorReporter)) - .Returns(Result.Success(parsedRoute1)); - Mock() - .Setup(r => r.ValidateRoute(parsedRoute1, errorReporter)) + .Setup(r => r.ValidateRoute(It.IsAny(), errorReporter)) .Returns(true); // Act @@ -131,7 +126,7 @@ public async Task BuildConfigAsync_ValidRoute_Works() result.Value.Should().NotBeNull(); result.Value.Backends.Should().BeEmpty(); result.Value.Routes.Should().HaveCount(1); - result.Value.Routes[0].Should().BeSameAs(parsedRoute1); + result.Value.Routes[0].RouteId.Should().BeSameAs(route1.RouteId); } [Fact] @@ -143,15 +138,12 @@ public async Task BuildConfigAsync_RouteParseError_SkipsRoute() .Setup(r => r.GetBackendsAsync(It.IsAny())) .ReturnsAsync(new List()); - var route1 = new GatewayRoute { RouteId = "route1", Rule = "Host('example.com')", Priority = 1, BackendId = "backend1" }; + var route1 = new GatewayRoute { RouteId = "route1", Match = { Host = "example.com" }, Priority = 1, BackendId = "backend1" }; Mock() .Setup(r => r.GetRoutesAsync(It.IsAny())) .ReturnsAsync(new[] { route1 }); var parsedRoute1 = new ParsedRoute(); - Mock() - .Setup(r => r.ParseRoute(route1, errorReporter)) - .Returns(Result.Failure()); // Act var configManager = Create(); @@ -173,15 +165,12 @@ public async Task BuildConfigAsync_RouteValidationError_SkipsRoute() .Setup(r => r.GetBackendsAsync(It.IsAny())) .ReturnsAsync(new List()); - var route1 = new GatewayRoute { RouteId = "route1", Rule = "Host('example.com')", Priority = 1, BackendId = "backend1" }; + var route1 = new GatewayRoute { RouteId = "route1", Match = { Host = "example.com" }, Priority = 1, BackendId = "backend1" }; Mock() .Setup(r => r.GetRoutesAsync(It.IsAny())) .ReturnsAsync(new[] { route1 }); var parsedRoute1 = new ParsedRoute(); - Mock() - .Setup(r => r.ParseRoute(route1, errorReporter)) - .Returns(Result.Success(parsedRoute1)); Mock() .Setup(r => r.ValidateRoute(parsedRoute1, errorReporter)) diff --git a/test/IslandGateway.Core.Tests/Service/Config/RouteParserTests.cs b/test/IslandGateway.Core.Tests/Service/Config/RouteParserTests.cs deleted file mode 100644 index e072921cc..000000000 --- a/test/IslandGateway.Core.Tests/Service/Config/RouteParserTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Collections.Generic; -using FluentAssertions; -using IslandGateway.Core.Abstractions; -using Tests.Common; -using Xunit; - -namespace IslandGateway.Core.Service.Tests -{ - public class RouteParserTests : TestAutoMockBase - { - [Fact] - public void Constructor_Works() - { - Create(); - } - - [Fact] - public void ParseRoute_ValidRoute_Works() - { - // Arrange - const string TestRouteId = "route1"; - const string TestBackendId = "backend1"; - const string TestRule = "Host('example.com')"; - const int TestPriority = 2; - - var matchers = new[] { new HostMatcher("Host", new[] { "example.com" }) }; - var ruleParseResult = Result, string>.Success(matchers); - Mock() - .Setup(r => r.Parse(TestRule)) - .Returns(ruleParseResult); - var routeParser = Create(); - var route = new GatewayRoute - { - RouteId = TestRouteId, - Rule = TestRule, - Priority = TestPriority, - BackendId = TestBackendId, - Metadata = new Dictionary - { - { "key", "value" }, - }, - }; - var errorReporter = new TestConfigErrorReporter(); - - // Act - var result = routeParser.ParseRoute(route, errorReporter); - - // Assert - result.IsSuccess.Should().BeTrue(); - result.Value.RouteId.Should().Be(TestRouteId); - result.Value.Rule.Should().Be(TestRule); - result.Value.Priority.Should().Be(TestPriority); - result.Value.Matchers.Should().BeSameAs(matchers); - result.Value.BackendId.Should().Be(TestBackendId); - result.Value.Metadata["key"].Should().Be("value"); - errorReporter.Errors.Should().BeEmpty(); - } - - [Fact] - public void ParseRoute_NullRule_ReportsError() - { - // Arrange - var route = new GatewayRoute { RouteId = null }; - var errorReporter = new TestConfigErrorReporter(); - var routeParser = Create(); - - // Act - var result = routeParser.ParseRoute(route, errorReporter); - - // Assert - result.IsSuccess.Should().BeFalse(); - errorReporter.Errors.Should().Contain(err => err.ErrorCode == ConfigErrors.RouteBadRule); - } - - [Fact] - public void ParseRoute_ParseError_ReportsError() - { - // Arrange - const string TestRule = "bad rule"; - const string TestParserErrorMessage = "parser error message"; - - var ruleParseResult = Result, string>.Failure(TestParserErrorMessage); - Mock() - .Setup(r => r.Parse(TestRule)) - .Returns(ruleParseResult); - var route = new GatewayRoute - { - RouteId = null, - Rule = TestRule, - }; - var errorReporter = new TestConfigErrorReporter(); - var routeParser = Create(); - - // Act - var result = routeParser.ParseRoute(route, errorReporter); - - // Assert - result.IsSuccess.Should().BeFalse(); - errorReporter.Errors.Should().Contain(err => err.ErrorCode == ConfigErrors.RouteBadRule && err.Message.Contains(TestParserErrorMessage)); - } - } -} diff --git a/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/Matchers/HostMatcherTests.cs b/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/Matchers/HostMatcherTests.cs deleted file mode 100644 index c0a033f30..000000000 --- a/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/Matchers/HostMatcherTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using FluentAssertions; -using Xunit; - -namespace IslandGateway.Core.Service.Tests -{ - public class HostMatcherTests - { - [Fact] - public void Constructor_Works() - { - // arrange - const string TestHost = "example.com"; - - // Act - var matcher = new HostMatcher("Host", new[] { TestHost }); - - // Assert - matcher.Host.Should().Be(TestHost); - } - - [Fact] - public void Constructor_InvalidArgCount_Throws() - { - // Arrange - Action action1 = () => new HostMatcher("Host", new string[0]); - Action action2 = () => new HostMatcher("Host", new[] { "a", "b" }); - - // Act & Assert - action1.Should().ThrowExactly(); - action2.Should().ThrowExactly(); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - public void Constructor_EmptyHostName_Throws(string hostName) - { - // Arrange - Action action = () => new HostMatcher("Host", new[] { hostName }); - - // Act & Assert - action.Should().ThrowExactly(); - } - } -} diff --git a/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/Matchers/MethodMatcherTests.cs b/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/Matchers/MethodMatcherTests.cs deleted file mode 100644 index eb8e6bd94..000000000 --- a/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/Matchers/MethodMatcherTests.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using FluentAssertions; -using Xunit; - -namespace IslandGateway.Core.Service.Tests -{ - public class MethodMatcherTests - { - [Fact] - public void Constructor_Works() - { - // arrange - var methods = new[] { "GET" }; - - // Act - var matcher = new MethodMatcher("Method", methods); - - // Assert - matcher.Methods.Should().BeSameAs(methods); - } - - [Fact] - public void Constructor_InvalidArgCount_Throws() - { - // Arrange - Action action = () => new MethodMatcher("Method", new string[0]); - - // Act & Assert - action.Should().ThrowExactly(); - } - } -} diff --git a/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/Matchers/PathMatcherTests.cs b/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/Matchers/PathMatcherTests.cs deleted file mode 100644 index edd01fc8f..000000000 --- a/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/Matchers/PathMatcherTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using FluentAssertions; -using Xunit; - -namespace IslandGateway.Core.Service.Tests -{ - public class PathMatcherTests - { - [Fact] - public void Constructor_Works() - { - // arrange - const string TestPattern = "/a"; - - // Act - var matcher = new PathMatcher("Path", new[] { TestPattern }); - - // Assert - matcher.Pattern.Should().Be(TestPattern); - } - - [Fact] - public void Constructor_InvalidArgCount_Throws() - { - // Arrange - Action action1 = () => new PathMatcher("Path", new string[0]); - Action action2 = () => new PathMatcher("Path", new[] { "a", "b" }); - - // Act & Assert - action1.Should().ThrowExactly(); - action2.Should().ThrowExactly(); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - public void Constructor_EmptyHostName_Throws(string hostName) - { - // Arrange - Action action = () => new PathMatcher("Path", new[] { hostName }); - - // Act & Assert - action.Should().ThrowExactly(); - } - } -} diff --git a/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/RouteValidatorTests.cs b/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/RouteValidatorTests.cs index a1a76224d..17f88e8ee 100644 --- a/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/RouteValidatorTests.cs +++ b/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/RouteValidatorTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using FluentAssertions; @@ -11,14 +11,6 @@ namespace IslandGateway.Core.Service.Tests { public class RouteValidatorTests : TestAutoMockBase { - private readonly RouteParser _routeParser; - - public RouteValidatorTests() - { - Provide(); - _routeParser = Create(); - } - [Fact] public void Constructor_Works() { @@ -26,23 +18,24 @@ public void Constructor_Works() } [Theory] - [InlineData("Host('example.com') && Path('/a/')")] - [InlineData("Host('example.com') && Path('/a/**')")] - [InlineData("Path('/a/**') && Host('example.com')")] - [InlineData("Path('/a/**') && Host('example.com') && Method('GET')")] - [InlineData("Host('example.com') && Method('get')")] - [InlineData("Host('example.com') && Method('gEt', 'put')")] - [InlineData("Host('example.com') && Method('gEt', 'put', 'POST', 'traCE', 'PATCH', 'DELETE', 'HEAd')")] - [InlineData("Host('*.example.com')")] - [InlineData("Host('a-b.example.com')")] - [InlineData("Host('a-b.b-c.example.com')")] - public void Accepts_ValidRules(string rule) + [InlineData("example.com", "/a/", null)] + [InlineData("example.com", "/a/**", null)] + [InlineData("example.com", "/a/**", "GET")] + [InlineData("example.com", null, "get")] + [InlineData("example.com", null, "gEt,put")] + [InlineData("example.com", null, "gEt,put,POST,traCE,PATCH,DELETE,Head")] + [InlineData("*.example.com", null, null)] + [InlineData("a-b.example.com", null, null)] + [InlineData("a-b.b-c.example.com", null, null)] + public void Accepts_ValidRules(string host, string path, string methods) { // Arrange - var route = new GatewayRoute + var route = new ParsedRoute { RouteId = "route1", - Rule = rule, + Host = host, + Path = path, + Methods = methods?.Split(","), BackendId = "be1", }; @@ -73,15 +66,15 @@ public void Rejects_MissingRouteId(string routeId) } [Theory] - [InlineData("")] - [InlineData("Method('GET')")] - public void Rejects_MissingHost(string rule) + [InlineData(null)] + [InlineData("/")] + public void Rejects_MissingHost(string path) { // Arrange - var route = new GatewayRoute + var route = new ParsedRoute { RouteId = "route1", - Rule = rule, + Path = path, BackendId = "be1", }; @@ -94,24 +87,24 @@ public void Rejects_MissingHost(string rule) } [Theory] - [InlineData("Host('.example.com')")] - [InlineData("Host('example*.com')")] - [InlineData("Host('example.*.com')")] - [InlineData("Host('example.*a.com')")] - [InlineData("Host('*example.com')")] - [InlineData("Host('-example.com')")] - [InlineData("Host('example-.com')")] - [InlineData("Host('-example-.com')")] - [InlineData("Host('a.-example.com')")] - [InlineData("Host('a.example-.com')")] - [InlineData("Host('a.-example-.com')")] - public void Rejects_InvalidHost(string rule) + [InlineData(".example.com")] + [InlineData("example*.com")] + [InlineData("example.*.com")] + [InlineData("example.*a.com")] + [InlineData("*example.com")] + [InlineData("-example.com")] + [InlineData("example-.com")] + [InlineData("-example-.com")] + [InlineData("a.-example.com")] + [InlineData("a.example-.com")] + [InlineData("a.-example-.com")] + public void Rejects_InvalidHost(string host) { // Arrange - var route = new GatewayRoute + var route = new ParsedRoute { RouteId = "route1", - Rule = rule, + Host = host, BackendId = "be1", }; @@ -124,17 +117,18 @@ public void Rejects_InvalidHost(string rule) } [Theory] - [InlineData("Host('example.com') && Path('/{***a}')")] - [InlineData("Host('example.com') && Path('/{')")] - [InlineData("Host('example.com') && Path('/}')")] - [InlineData("Host('example.com') && Path('/{ab/c}')")] - public void Rejects_InvalidPath(string rule) + [InlineData("/{***a}")] + [InlineData("/{")] + [InlineData("/}")] + [InlineData("/{ab/c}")] + public void Rejects_InvalidPath(string path) { // Arrange - var route = new GatewayRoute + var route = new ParsedRoute { RouteId = "route1", - Rule = rule, + Host = "example.com", + Path = path, BackendId = "be1", }; @@ -147,16 +141,17 @@ public void Rejects_InvalidPath(string rule) } [Theory] - [InlineData("Host('example.com') && Method('')")] - [InlineData("Host('example.com') && Method('gett')")] - [InlineData("Host('example.com') && Method('get', 'post', 'get')")] - public void Rejects_InvalidMethod(string rule) + [InlineData("")] + [InlineData("gett")] + [InlineData("get,post,get")] + public void Rejects_InvalidMethod(string methods) { // Arrange - var route = new GatewayRoute + var route = new ParsedRoute { RouteId = "route1", - Rule = rule, + Host = "example.com", + Methods = methods.Split(","), BackendId = "be1", }; @@ -168,50 +163,9 @@ public void Rejects_InvalidMethod(string rule) result.ErrorReporter.Errors.Should().Contain(err => err.ErrorCode == ConfigErrors.ParsedRouteRuleInvalidMatcher && err.Message.Contains("verb")); } - [Fact] - public void Rejects_MultipleHosts() - { - // Arrange - var route = new GatewayRoute - { - RouteId = "route1", - Rule = "Host('example.com') && Host('example.com')", - BackendId = "be1", - }; - - // Act - var result = RunScenario(route); - - // Assert - result.IsSuccess.Should().BeFalse(); - result.ErrorReporter.Errors.Should().Contain(err => err.ErrorCode == ConfigErrors.ParsedRouteRuleMultipleHostMatchers); - } - - [Fact] - public void Rejects_MultiplePaths() - { - // Arrange - var route = new GatewayRoute - { - RouteId = "route1", - Rule = "Path('/a') && Path('/a')", - BackendId = "be1", - }; - - // Act - var result = RunScenario(route); - - // Assert - result.IsSuccess.Should().BeFalse(); - result.ErrorReporter.Errors.Should().Contain(err => err.ErrorCode == ConfigErrors.ParsedRouteRuleMultiplePathMatchers); - } - - private (bool IsSuccess, TestConfigErrorReporter ErrorReporter) RunScenario(GatewayRoute route) + private (bool IsSuccess, TestConfigErrorReporter ErrorReporter) RunScenario(ParsedRoute parsedRoute) { var errorReporter = new TestConfigErrorReporter(); - var parseResult = _routeParser.ParseRoute(route, errorReporter); - parseResult.IsSuccess.Should().BeTrue(); - var parsedRoute = parseResult.Value; var validator = Create(); var isSuccess = validator.ValidateRoute(parsedRoute, errorReporter); diff --git a/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/RuleParserTests.cs b/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/RuleParserTests.cs deleted file mode 100644 index ee031b3f9..000000000 --- a/test/IslandGateway.Core.Tests/Service/Config/RuleParsing/RuleParserTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using FluentAssertions; -using IslandGateway.Core.Abstractions; -using Tests.Common; -using Xunit; - -namespace IslandGateway.Core.Service.Tests -{ - public class RuleParserTests : TestAutoMockBase - { - [Fact] - public void Constructor_Works() - { - Create(); - } - - [Fact] - public void NullInput_ThrowsArgumentNullException() - { - // Arrange - var parser = Create(); - - // Act & Assert - Action action = () => parser.Parse(null); - action.Should().ThrowExactly(); - } - - [Theory] - [InlineData("Host('example.com')")] - [InlineData("Host('example.com') ", "Host('example.com')")] - [InlineData(" Host('example.com')", "Host('example.com')")] - [InlineData("Host ( 'example.com' )", "Host('example.com')")] - [InlineData("Host('example.com') && Path('/a')")] - [InlineData("Host('example.com') && Method('get')")] - [InlineData("Host('example.com') && Method('get', 'post')")] - [InlineData("Host('example.com')&&Path('/a') ", "Host('example.com') && Path('/a')")] - [InlineData("Host('*.example.com') && Path('/a''')")] - public void ValidRule_Works(string input, string expectedOutputIfDifferent = null) - { - // Arrange - var parser = Create(); - - // Act - var output = parser.Parse(input); - - // Assert - output.IsSuccess.Should().BeTrue(); - Serialize(output).Should().Be(expectedOutputIfDifferent ?? input); - } - - [Theory] - [InlineData("Host(\"example.com\")", "Parse error: Syntax error (line 1, column 6): unexpected `\"`.")] - [InlineData("Host(@'example.com')", "Parse error: Syntax error (line 1, column 6): unexpected `@`.")] - [InlineData("&& Host('example.com')", "Parse error: Syntax error (line 1, column 1): unexpected operator `&&`.")] - [InlineData("Host('example.com')&&", "Parse error: Syntax error: unexpected end of input, expected `Host, PathPrefix, Path, ...`.")] - [InlineData("Host(a)", "Parse error: Syntax error (line 1, column 6): unexpected identifier `a`, expected `)`.")] - [InlineData("Host(example.com)", "Parse error: Syntax error (line 1, column 13): unexpected `.`.")] - [InlineData("Host('example.com' && PathPrefix('/a'))", "Parse error: Syntax error (line 1, column 20): unexpected operator `&&`, expected `)`.")] - public void InvalidRuleSyntax_ProducesGoodErrorMessage(string input, string expectedOutput) - { - // Arrange - var parser = Create(); - - // Act - var output = parser.Parse(input); - - // Assert - output.IsSuccess.Should().BeFalse(); - Serialize(output).Should().Be(expectedOutput ?? input); - } - - [Theory] - [InlineData("Host()", "'Host' matcher requires one argument, found 0")] - [InlineData("Path()", "'Path' matcher requires one argument, found 0")] - [InlineData("Method()", "'Method' matcher requires at least one argument, found 0")] - [InlineData("Host('a','b')", "'Host' matcher requires one argument, found 2")] - [InlineData("Path('a','b')", "'Path' matcher requires one argument, found 2")] - [InlineData("Host('a','b', 'c' )", "'Host' matcher requires one argument, found 3")] - public void InvalidRuleSemantics_ProducesGoodErrorMessage(string input, string expectedOutput) - { - // Arrange - var parser = Create(); - - // Act - var output = parser.Parse(input); - - // Assert - output.IsSuccess.Should().BeFalse(); - Serialize(output).Should().Be(expectedOutput ?? input); - } - - private static string Serialize(Result, string> parsed) - { - if (!parsed.IsSuccess) - { - return parsed.Error; - } - - return string.Join(" && ", parsed.Value); - } - } -} diff --git a/test/IslandGateway.Core.Tests/Service/DynamicEndpoints/RuntimeRouteBuilderTests.cs b/test/IslandGateway.Core.Tests/Service/DynamicEndpoints/RuntimeRouteBuilderTests.cs index c768e3510..a74c94c58 100644 --- a/test/IslandGateway.Core.Tests/Service/DynamicEndpoints/RuntimeRouteBuilderTests.cs +++ b/test/IslandGateway.Core.Tests/Service/DynamicEndpoints/RuntimeRouteBuilderTests.cs @@ -1,9 +1,10 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System; using System.Collections.Generic; using FluentAssertions; +using FluentAssertions.Common; using IslandGateway.Core.ConfigModel; using IslandGateway.Core.RuntimeModel; using IslandGateway.Core.Service.Management; @@ -31,12 +32,8 @@ public void BuildEndpoints_HostAndPath_Works() var parsedRoute = new ParsedRoute { RouteId = "route1", - Rule = "Host('example.com') && Path('/a')", - Matchers = new List - { - new HostMatcher("Host", new[] { "example.com" }), - new PathMatcher("Path", new[] { "/a" }), - }, + Host = "example.com", + Path = "/a", Priority = 12, }; var backend = new BackendInfo("backend1", new EndpointManager(), new Mock().Object); @@ -48,7 +45,7 @@ public void BuildEndpoints_HostAndPath_Works() // Assert config.BackendOrNull.Should().BeSameAs(backend); config.Priority.Should().Be(12); - config.Rule.Should().Be("Host('example.com') && Path('/a')"); + config.MatcherSummary.Should().Be(parsedRoute.GetMatcherSummary()); config.AspNetCoreEndpoints.Should().HaveCount(1); var routeEndpoint = config.AspNetCoreEndpoints[0] as AspNetCore.Routing.RouteEndpoint; routeEndpoint.DisplayName.Should().Be("route1"); @@ -68,11 +65,7 @@ public void BuildEndpoints_JustHost_Works() var parsedRoute = new ParsedRoute { RouteId = "route1", - Rule = "Host('example.com')", - Matchers = new List - { - new HostMatcher("Host", new[] { "example.com" }), - }, + Host = "example.com", Priority = 12, }; var backend = new BackendInfo("backend1", new EndpointManager(), new Mock().Object); @@ -84,7 +77,7 @@ public void BuildEndpoints_JustHost_Works() // Assert config.BackendOrNull.Should().BeSameAs(backend); config.Priority.Should().Be(12); - config.Rule.Should().Be("Host('example.com')"); + config.MatcherSummary.Should().Be(parsedRoute.GetMatcherSummary()); config.AspNetCoreEndpoints.Should().HaveCount(1); var routeEndpoint = config.AspNetCoreEndpoints[0] as AspNetCore.Routing.RouteEndpoint; routeEndpoint.DisplayName.Should().Be("route1"); @@ -104,11 +97,7 @@ public void BuildEndpoints_JustHostWithWildcard_Works() var parsedRoute = new ParsedRoute { RouteId = "route1", - Rule = "Host('*.example.com')", - Matchers = new List - { - new HostMatcher("Host", new[] { "*.example.com" }), - }, + Host = "*.example.com", Priority = 12, }; var backend = new BackendInfo("backend1", new EndpointManager(), new Mock().Object); @@ -120,7 +109,7 @@ public void BuildEndpoints_JustHostWithWildcard_Works() // Assert config.BackendOrNull.Should().BeSameAs(backend); config.Priority.Should().Be(12); - config.Rule.Should().Be("Host('*.example.com')"); + config.MatcherSummary.Should().Be(parsedRoute.GetMatcherSummary()); config.AspNetCoreEndpoints.Should().HaveCount(1); var routeEndpoint = config.AspNetCoreEndpoints[0] as AspNetCore.Routing.RouteEndpoint; routeEndpoint.DisplayName.Should().Be("route1"); @@ -140,11 +129,7 @@ public void BuildEndpoints_JustPath_Works() var parsedRoute = new ParsedRoute { RouteId = "route1", - Rule = "Path('/a')", - Matchers = new List - { - new PathMatcher("Path", new[] { "/a" }), - }, + Path = "/a", Priority = 12, }; var backend = new BackendInfo("backend1", new EndpointManager(), new Mock().Object); @@ -156,7 +141,7 @@ public void BuildEndpoints_JustPath_Works() // Assert config.BackendOrNull.Should().BeSameAs(backend); config.Priority.Should().Be(12); - config.Rule.Should().Be("Path('/a')"); + config.MatcherSummary.Should().Be(parsedRoute.GetMatcherSummary()); config.AspNetCoreEndpoints.Should().HaveCount(1); var routeEndpoint = config.AspNetCoreEndpoints[0] as AspNetCore.Routing.RouteEndpoint; routeEndpoint.DisplayName.Should().Be("route1"); @@ -175,7 +160,6 @@ public void BuildEndpoints_NullMatchers_Works() var parsedRoute = new ParsedRoute { RouteId = "route1", - Rule = "Host('example.com')", Priority = 12, }; var backend = new BackendInfo("backend1", new EndpointManager(), new Mock().Object); @@ -187,7 +171,7 @@ public void BuildEndpoints_NullMatchers_Works() // Assert config.BackendOrNull.Should().BeSameAs(backend); config.Priority.Should().Be(12); - config.Rule.Should().Be("Host('example.com')"); + config.MatcherSummary.Should().Be(""); config.AspNetCoreEndpoints.Should().HaveCount(1); var routeEndpoint = config.AspNetCoreEndpoints[0] as AspNetCore.Routing.RouteEndpoint; routeEndpoint.DisplayName.Should().Be("route1"); @@ -206,11 +190,7 @@ public void BuildEndpoints_InvalidPath_BubblesOutException() var parsedRoute = new ParsedRoute { RouteId = "route1", - Rule = "Path('/{invalid')", - Matchers = new List - { - new PathMatcher("Path", new[] { "/{invalid" }), - }, + Path = "/{invalid", Priority = 12, }; var backend = new BackendInfo("backend1", new EndpointManager(), new Mock().Object); diff --git a/test/IslandGateway.Core.Tests/Service/Proxy/ProxyInvokerTests.cs b/test/IslandGateway.Core.Tests/Service/Proxy/ProxyInvokerTests.cs index 27b5fb210..11b4bea8f 100644 --- a/test/IslandGateway.Core.Tests/Service/Proxy/ProxyInvokerTests.cs +++ b/test/IslandGateway.Core.Tests/Service/Proxy/ProxyInvokerTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System; @@ -62,7 +62,7 @@ public async Task InvokeAsync_Works() var aspNetCoreEndpoints = new List(); var routeConfig = new RouteConfig( route: new RouteInfo("route1"), - rule: "Host('example.com') && Path('/')", + matcherSummary: null, priority: null, backendOrNull: backend1, aspNetCoreEndpoints: aspNetCoreEndpoints.AsReadOnly());