Skip to content

Commit

Permalink
Merge pull request #18 from roblillack/17-add-support-for-variable-fi…
Browse files Browse the repository at this point in the history
…le-extensions

Add support for variable file extensions
  • Loading branch information
roblillack committed Mar 23, 2021
2 parents 5df5ba6 + 05ef321 commit 60bd1e5
Show file tree
Hide file tree
Showing 7 changed files with 584 additions and 20 deletions.
1 change: 0 additions & 1 deletion go.mod
Expand Up @@ -7,7 +7,6 @@ require (
github.com/codegangsta/cli v1.20.0
github.com/fsnotify/fsnotify v1.4.9
github.com/robfig/config v0.0.0-20141207224736-0f78529c8c7e
github.com/robfig/pathtree v0.0.0-20140121041023-41257a1839e9
golang.org/x/net v0.0.0-20200707034311-ab3426394381
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1 // indirect
)
2 changes: 0 additions & 2 deletions go.sum
Expand Up @@ -6,8 +6,6 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/robfig/config v0.0.0-20141207224736-0f78529c8c7e h1:3/9k/etUfgykjM3Rx8X0echJzo7gNNeND/ubPkqYw1k=
github.com/robfig/config v0.0.0-20141207224736-0f78529c8c7e/go.mod h1:Zerq1qYbCKtIIU9QgPydffGlpYfZ8KI/si49wuTLY/Q=
github.com/robfig/pathtree v0.0.0-20140121041023-41257a1839e9 h1:UfIkqMA/eAkbd4vGO76WgCzF4ER1biu0H85Ndx76Zl8=
github.com/robfig/pathtree v0.0.0-20140121041023-41257a1839e9/go.mod h1:JaRC3xDjyqUuG0WqmqTTv7FXV+y5EotwIUQcFWgSsxA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
Expand Down
21 changes: 21 additions & 0 deletions internal/pathtree/LICENSE
@@ -0,0 +1,21 @@
Copyright (C) 2013 Rob Figueiredo
All Rights Reserved.

MIT LICENSE

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
294 changes: 294 additions & 0 deletions internal/pathtree/tree.go
@@ -0,0 +1,294 @@
// pathtree implements a tree for fast path lookup.
//
// Restrictions
//
// - Paths must be a '/'-separated list of strings, like a URL or Unix filesystem.
// - All paths must begin with a '/'.
// - Path elements may not contain a '/'.
// - Path elements beginning with a ':' or '*' will be interpreted as wildcards.
// - Trailing slashes are inconsequential.
//
// Wildcards
//
// Wildcards are named path elements that may match any strings in that
// location. Two different kinds of wildcards are permitted:
// - :var - names beginning with ':' will match any single path element.
// - *var - names beginning with '*' will match one or more path elements.
// (however, no path elements may come after a star wildcard)
//
// Extensions
//
// Single element wildcards in the last path element can optionally end with an
// extension. This allows for routes like '/users/:id.json', which will not
// conflict with '/users/:id'.
//
// Additionally, extensions might be variable, too, to allow for paths like
// `/users/:id.:extension` and `/assets/logo.:ext`.
//
// Algorithm
//
// Paths are mapped to the tree in the following way:
// - Each '/' is a Node in the tree. The root node is the leading '/'.
// - Each Node has edges to other nodes. The edges are named according to the
// possible path elements at that depth in the path.
// - Any Node may have an associated Leaf. Leafs are terminals containing the
// data associated with the path as traversed from the root to that Node.
//
// Edges are implemented as a map from the path element name to the next node in
// the path.
package pathtree

import (
"errors"
"fmt"
"strings"
)

type Node struct {
edges map[string]*Node // the various path elements leading out of this node.
wildcard *Node // if set, this node had a wildcard as its path element.
leaf *Leaf // if set, this is a terminal node for this leaf.
extensions map[string]*Leaf // if set, this is a terminal node with a leaf that ends in a specific extension.
wildcardExtLeaf *Leaf // if set, this is a terminal node with a leaf for wildcard file extensions.
star *Leaf // if set, this path ends in a star.
leafs int // counter for # leafs in the tree
}

type Leaf struct {
Value interface{} // the value associated with this node
Wildcards []string // the wildcard names, in order they appear in the path
ExtWildcard string // if set, this is the wildcard used for the file extension
order int // the order this leaf was added
}

// New returns a new path tree.
func New() *Node {
return &Node{edges: make(map[string]*Node)}
}

// Add a path and its associated value to the tree.
// - key must begin with "/"
// - key must not duplicate any existing key.
// Returns an error if those conditions do not hold.
func (n *Node) Add(key string, val interface{}) error {
if key == "" || key[0] != '/' {
return errors.New("Path must begin with /")
}
n.leafs++
return n.add(n.leafs, splitPath(key), nil, val)
}

// Adds a leaf to a terminal node.
// If the last wildcard contains an extension, add it to the 'extensions' map.
func (n *Node) addLeaf(leaf *Leaf) error {
if leaf.ExtWildcard != "" {
if n.wildcardExtLeaf != nil {
return errors.New("duplicate path")
}
n.wildcardExtLeaf = leaf
return nil
}

extension := stripExtensionFromLastSegment(leaf.Wildcards)
if extension != "" && leaf.ExtWildcard == "" {
if n.extensions == nil {
n.extensions = make(map[string]*Leaf)
}
if n.extensions[extension] != nil {
return fmt.Errorf("duplicate path for extension %s", extension)
}
n.extensions[extension] = leaf
return nil
}

if n.leaf != nil {
return errors.New("duplicate path")
}
n.leaf = leaf
return nil
}

func (n *Node) add(order int, elements, wildcards []string, val interface{}) error {
if len(elements) == 0 {
leaf := &Leaf{
order: order,
Value: val,
Wildcards: wildcards,
}
if len(wildcards) > 0 {
base, ext := extensionForPath(wildcards[len(wildcards)-1])
if len(ext) > 2 && ext[1] == ':' {
leaf.ExtWildcard = ext[2:]
leaf.Wildcards[len(leaf.Wildcards)-1] = base
}
}
return n.addLeaf(leaf)
}

var el string
el, elements = elements[0], elements[1:]
if el == "" {
return errors.New("empty path elements are not allowed")
}

// Handle wildcards.
switch el[0] {
case ':':
if n.wildcard == nil {
n.wildcard = New()
}
return n.wildcard.add(order, elements, append(wildcards, el[1:]), val)
case '*':
if n.star != nil {
return fmt.Errorf("duplicate path: %v %v", elements, wildcards)
}
n.star = &Leaf{
order: order,
Value: val,
Wildcards: append(wildcards, el[1:]),
}
return nil
}

// Non-wildcard path element with variable extension, create a "normal" node with
// just an ExtWildcard leaf.
base, ext := extensionForPath(el)
if len(ext) > 2 && ext[1] == ':' {
if len(elements) == 0 {
el = base
wildcards = []string{ext[1:]}

e, ok := n.edges[base]
if !ok {
e = New()
n.edges[base] = e
}

return e.addLeaf(&Leaf{
order: order,
Value: val,
Wildcards: wildcards,
ExtWildcard: ext[2:],
})
}
}

// It's a normal path element.
e, ok := n.edges[el]
if !ok {
e = New()
n.edges[el] = e
}

return e.add(order, elements, wildcards, val)
}

// Find a given path. Any wildcards traversed along the way are expanded and
// returned, along with the value.
func (n *Node) Find(key string) (leaf *Leaf, expansions []string) {
if len(key) == 0 || key[0] != '/' {
return nil, nil
}

return n.find(splitPath(key), nil)
}

func (n *Node) find(elements, exp []string) (leaf *Leaf, expansions []string) {

if len(elements) == 0 {
if len(exp) > 0 {
lastExp := exp[len(exp)-1]
base, ext := extensionForPath(lastExp)

if ext != "" {
// If this node has explicit extensions, check if the path matches one.
if n.extensions != nil {
if leaf := n.extensions[ext]; leaf != nil {
exp[len(exp)-1] = base
return leaf, exp
}
}

if n.wildcardExtLeaf != nil {
exp[len(exp)-1] = base
expansions := append(exp, ext[1:])
return n.wildcardExtLeaf, expansions
}
}
}

return n.leaf, exp
}

// If this node has a star, calculate the star expansions in advance.
var starExpansion string
if n.star != nil {
starExpansion = strings.Join(elements, "/")
}

// Peel off the next element and look up the associated edge.
var el string
el, elements = elements[0], elements[1:]
if nextNode, ok := n.edges[el]; ok {
leaf, expansions = nextNode.find(elements, exp)
}

// Handle fixed path elements with variable extension
if leaf == nil && len(elements) == 0 {
if base, ext := extensionForPath(el); ext != "" {
if nextNode, ok := n.edges[base]; ok && nextNode.wildcardExtLeaf != nil {
return nextNode.wildcardExtLeaf, append(exp, ext[1:])
}
}
}

// Handle colon
if n.wildcard != nil {
wildcardLeaf, wildcardExpansions := n.wildcard.find(elements, append(exp, el))
if wildcardLeaf != nil && (leaf == nil || leaf.order > wildcardLeaf.order) {
leaf = wildcardLeaf
expansions = wildcardExpansions
}
}

// Handle star
if n.star != nil && (leaf == nil || leaf.order > n.star.order) {
leaf = n.star
expansions = append(exp, starExpansion)
}

return
}

func extensionForPath(path string) (string, string) {
dotPosition := strings.LastIndex(path, ".")
if dotPosition != -1 {
return path[:dotPosition], path[dotPosition:]
}
return "", ""
}

func splitPath(key string) []string {
elements := strings.Split(key, "/")
if elements[0] == "" {
elements = elements[1:]
}
if elements[len(elements)-1] == "" {
elements = elements[:len(elements)-1]
}
return elements
}

// stripExtensionFromLastSegment determines if a string slice representing a path
// ends with a file extension, removes the extension from the input, and returns it.
func stripExtensionFromLastSegment(segments []string) string {
if len(segments) == 0 {
return ""
}
lastSegment := segments[len(segments)-1]
prefix, extension := extensionForPath(lastSegment)
if extension != "" {
segments[len(segments)-1] = prefix
}
return extension
}

0 comments on commit 60bd1e5

Please sign in to comment.