Private/Classes/15-RouteEntry.ps1

<#
    Represents an argument parsed from the path declared by the Route attribute
#>

class RouteArgument
{
    # Argument name i.e. 'id' from {id}
    [string]$Name

    # Decalred datatpye, or object if none e.g. {id:intr
    [type]$DataType

    # Ordered position within the route
    [int]$Position
    RouteArgument([string]$name, [type]$type, [int]$position)
    {
        $this.Name = $name
        $this.DataType = $type
        $this.Position = $position
    }

    [string]ToString()
    {
        return "$($this.DataType.Name) $($this.Name)"
    }
}

<#
    This class represnts a route mapping.
    It maps a route (/controller/method/{arg}) to a controller class method
    and is used to select and to inovke a route an incoming request
#>

class RouteEntry
{
    # Regex to match numbers
    hidden static [System.Text.RegularExpressions.Regex]$NumberRegex = [System.Text.RegularExpressions.Regex]::new('^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$')

    # Regex to match arguments (name with optional data type) from the value of a Route() attribute
    hidden static [System.Text.RegularExpressions.Regex]$TokenRx = [System.Text.RegularExpressions.Regex]::new('\{(?<token>\w+)(:(?<datatype>\w+))?\}')

    # Built by the constructor, this is the regex that is used to match an incoming request path with this RouteEntry instance
    hidden [System.Text.RegularExpressions.Regex]$RouteMatcherRx

    # The method that will be called if this RouteEntry is selected
    hidden [System.Reflection.MethodInfo]$Method

    # Arguments to the above method
    hidden [System.Reflection.ParameterInfo[]]$MethodArguments

    # Value to return for GetHashCode()
    hidden [int]$HashCode

    # Number of segments in the route path
    hidden [int]$PathSegmentCount

    # HTTP verb the route is associated with
    [string]$RequestMethod

    # The full route, including controller prefix
    [string]$Route

    # Arguments parsed from the Route attribute's path
    [RouteArgument[]]$RouteArguments

    <#
        Hide default constructor
    #>

    hidden RouteEntry()
    {
    }

    <#
        Construct route entry
    #>

    RouteEntry([string]$controllerPrefix, [System.Reflection.MethodInfo]$method, [HttpRequestMethod]$requestMethod, [Route]$route)
    {
        if ($method.ReturnType -eq [void])
        {
            # All route controller methods must return something - usually Object
            throw "$(Format-MethodSignature -Method $method) - void return type not allowed."
        }

        $this.Method = $method
        $this.MethodArguments = $method.GetParameters()
        $this.RequestMethod = $requestMethod.RequestMethod

        $this.Route = ($controllerPrefix + '/' + $route.Route.Trim('/')).TrimEnd('/')
        $this.PathSegmentCount = ($this.Route -split '/').Length

        # Parse the route
        $mc = [RouteEntry]::TokenRx.Matches($this.Route)

        $argList = [System.Collections.Generic.List[RouteArgument]]::new()

        $position = 0
        $mc |
            ForEach-Object {

            $argumentName = $_.Groups['token'].Value

            # Check method has a matching argument
            if (-not ($this.MethodArguments | Where-Object { $_.Name -eq $argumentName}))
            {
                throw "$(Format-MethodSignature -Method $method) does not contain a parameter named '$argumentName'"
            }

            if ($_.Groups['datatype'].Success)
            {
                # If an explicit datatype was provided on the route, check the method argument is that type
                $dataType = Invoke-Expression ["$($_.Groups['datatype'].Value)"]

                if (-not ($this.MethodArguments | Where-Object {$_.Name -eq $argumentName -and $_.ParameterType -eq $dataType}))
                {
                    throw "$(Format-MethodSignature -Method $method) does not contain a parameter named '$argumentName' with type '$($dataType.Name)'"
                }
            }
            else
            {
                $dataType = [object]
            }

            $argList.Add((New-Object RouteArgument ($argumentName, $dataType, $position++)))
        }

        $this.RouteArguments = $argList.ToArray()
        $this.RouteMatcherRx = New-Object System.Text.RegularExpressions.Regex ([RouteEntry]::TokenRx.Replace($this.Route, '([\w+~\.\-\%\@]+)'))
        $this.HashCode = ($this.RequestMethod + $this.route.ToLowerInvariant()).GetHashCode();
    }

    <#
        Score a path against this route entry.
        The higher the score, the better the match.
        A score of zero is a definite no-match
    #>

    [int]MatchScore([string]$route)
    {
        # First, and quickest check - this RouteEntry and input route have the same number of path segments?
        if ($this.PathSegmentCount -ne ($route -split '/').Length)
        {
            return 0
        }

        # Try to match the route by regex
        $match = $this.RouteMatcherRx.Match($route)

        if (-not $match.Success)
        {
            return 0
        }

        # Number of parameters in route matched by regex is not the same as the number of route arguments we expect?
        # Match group zero is the entire string.
        if ($match.Groups.Count - 1 -ne $this.RouteArguments.Length)
        {
            return 0
        }

        # Route with no arguments
        if ($this.RouteArguments.Length -eq 0)
        {
            return 100
        }

        $score = 0

        for ($i = 0; $i -lt $match.Groups.Count - 1; ++$i)
        {
            # Route argument array is in the same order as the matches in the match collection
            $routeArg = $this.RouteArguments[$i]

            # If the route argument has a data type that implements Parse(), then test if the value is parseable
            if (([object], [string]) -notcontains $routeArg.DataType)
            {
                try
                {
                    # Try parsing the matched path segment as the required datatype (which must be s simple value type) for the route argument

                    # Match group containing matched argument value
                    $thisSegmentValue = $match.Groups[$i + 1].Value
                    Invoke-Expression("[$($routeArg.DataType.FullName)]::Parse(`'$thisSegmentValue`')")

                    # The value was parsed as type for the argument - that's an exact match so continue along the path
                    $score += 100
                    continue
                }
                catch
                {
                    # Failed to parse - no match.
                    return 0
                }
            }

            # Get corresponding controller method argument by name
            $methodArg = $this.MethodArguments | Where-Object { $_.Name -eq $routeArg.Name }

            $argumentIsString = -not ([RouteEntry]::NumberRegex.IsMatch($match.Groups[$i + 1].Value))

            $parameterIsString = $methodArg.ParameterType -eq [string]

            if ($argumentIsString -and $parameterIsString)
            {
                $score += 100
                continue
            }

            if ($routeArg.DataType -eq [object])
            {
                # Lowest quality match
                $score += 1
                continue
            }

            if ($routeArg.DataType -eq [string])
            {
                # Next lowest quality
                $score += 10
                continue
            }

        }

        return $score
    }

    <#
        Invoke class method for this route, returning its result.
    #>

    [object]Invoke([string]$route)
    {
        # type[] with no contents to select controller class default constructor
        $typeArr = New-Object Type[] 0

        # object[] with no contents to use to invoke controller class default constructor
        $objArr = New-Object object[] 0

        # Get constructor
        $ctor = $this.Method.ReflectedType.GetConstructor($typeArr)

        if (-not $ctor)
        {
            throw "Cannot find default constructor for $($this.Method.ReflectedType)"
        }

        # Get controller class instance
        $controller = $ctor.Invoke($objArr)

        # Now collect the values to pass to the selected method
        $methodArgumentValues = [System.Collections.Generic.List[object]]::new()

        # This will match, as this RouteEntry was selected by this regex
        $match = $this.RouteMatcherRx.Match($route)

        for ($i = 0; $i -lt $this.MethodArguments.Length; ++$i)
        {
            # For each method argument, in order
            $methodArg = $this.MethodArguments[$i]

            # Get the corresponding route argument and hence its position in the match
            $routeArg = $this.RouteArguments | Where-Object { $_.Name -eq $methodArg.Name }
            $value = $match.Groups[$routeArg.Position + 1].Value

            # Now parse the value as the appropriate type for the method argument
            if (([string], [object]) -contains $methodArg.ParameterType)
            {
                # Add the string value for a method argument of type string or object
                $methodArgumentValues.Add(([System.Web.HttpUtility]::UrlDecode($value)))
            }
            else
            {
                # Try to parse the value as the correct type for the method argument
                $methodArgumentValues.Add((Invoke-Expression "[$($methodArg.ParameterType.FullName)]::Parse(`'$value`')"))
            }
        }

        # Invoke the method
        try
        {
            return $this.Method.Invoke($controller, $methodArgumentValues.ToArray())
        }
        catch
        {
            # Add a note property to the exception with the method signature that was invoked (see exception handling in Invoke-Route)
            Add-Member -InputObject $_.Exception -MemberType NoteProperty -Name 'InvokedMethodSignature' -Value (Format-MethodSignature -Method $this.Method)
            throw
        }

        return $null
    }

    [int]GetHashCode()
    {
        return $this.HashCode
    }

    [string]ToString()
    {
        return (Format-MethodSignature -Method $this.Method)
    }
}