lib/uri_template.rb
# -*- encoding : utf-8 -*-
# The MIT License (MIT)
#
# Copyright (c) 2011-2014 Hannes Georg
#
# 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.
# A base module for all implementations of a uri template.
module URITemplate
# Helper module which defines class methods for all uri template
# classes.
module ClassMethods
# Tries to convert the given argument into an {URITemplate}.
# Returns nil if this fails.
#
# @return [nil|{URITemplate}]
def try_convert(x)
if x.kind_of? self
return x
elsif x.kind_of? String
return self.new(x)
else
return nil
end
end
# Same as {.try_convert} but raises an ArgumentError when the given argument could not be converted.
#
# @raise ArgumentError if the argument is unconvertable
# @return {URITemplate}
def convert(x)
o = self.try_convert(x)
if o.nil?
raise ArgumentError, "Expected to receive something that can be converted into a URITemplate of type #{self.inspect}, but got #{x.inspect}"
end
return o
end
def included(base)
base.extend(ClassMethods)
end
end
extend ClassMethods
# @api private
SCHEME_REGEX = /\A[a-z]+:/i.freeze
# @api private
HOST_REGEX = /\A(?:[a-z]+:)?\/\/[^\/]+/i.freeze
# @api private
URI_SPLIT = /\A(?:([a-z]+):)?(?:\/\/)?([^\/]+)?/i.freeze
autoload :Utils, 'uri_template/utils'
autoload :Token, 'uri_template/token'
autoload :Literal, 'uri_template/literal'
autoload :Expression, 'uri_template/expression'
autoload :RFC6570, 'uri_template/rfc6570'
autoload :Colon, 'uri_template/colon'
# A hash with all available implementations.
# @see resolve_class
VERSIONS = {
:rfc6570 => :RFC6570,
:default => :RFC6570,
:colon => :Colon,
:latest => :RFC6570
}
# Looks up which implementation to use.
# Extracts all symbols from args and looks up the first in {VERSIONS}.
#
# @return Array an array of the class to use and the unused parameters.
#
# @example
# URITemplate.resolve_class() #=> [ URITemplate::RFC6570, [] ]
# URITemplate.resolve_class(:colon) #=> [ URITemplate::Colon, [] ]
# URITemplate.resolve_class("template",:rfc6570) #=> [ URITemplate::RFC6570, ["template"] ]
#
# @raise ArgumentError when no class was found.
#
def self.resolve_class(*args)
symbols, rest = args.partition{|x| x.kind_of? Symbol }
version = symbols.fetch(0, :default)
raise ArgumentError, "Unknown template version #{version.inspect}, defined versions: #{VERSIONS.keys.inspect}" unless VERSIONS.key?(version)
return self.const_get(VERSIONS[version]), rest
end
# Creates an uri template using an implementation.
# The args should at least contain a pattern string.
# Symbols in the args are used to determine the actual implementation.
#
# @example
# tpl = URITemplate.new('{x}') # a new template using the default implementation
# tpl.expand('x'=>'y') #=> 'y'
#
# @example
# tpl = URITemplate.new(:colon,'/:x') # a new template using the colon implementation
#
def self.new(*args)
klass, rest = resolve_class(*args)
return klass.new(*rest)
end
# Tries to coerce two URITemplates into a common representation.
# Returns an array with two {URITemplate}s and two booleans indicating which of the two were converted or raises an ArgumentError.
#
# @example
# URITemplate.coerce( URITemplate.new(:rfc6570,'{x}'), '{y}' ) #=> [URITemplate.new(:rfc6570,'{x}'), URITemplate.new(:rfc6570,'{y}'), false, true]
# URITemplate.coerce( '{y}', URITemplate.new(:rfc6570,'{x}') ) #=> [URITemplate.new(:rfc6570,'{y}'), URITemplate.new(:rfc6570,'{x}'), true, false]
#
# @return [Tuple<URITemplate,URITemplate,Bool,Bool>]
def self.coerce(a,b)
if a.kind_of? URITemplate
if a.class == b.class
return [a,b,false,false]
end
b_as_a = a.class.try_convert(b)
if b_as_a
return [a,b_as_a,false,true]
end
end
if b.kind_of? URITemplate
a_as_b = b.class.try_convert(a)
if a_as_b
return [a_as_b, b, true, false]
end
end
bc = self.try_convert(b)
if bc.kind_of? URITemplate
a_as_b = bc.class.try_convert(a)
if a_as_b
return [a_as_b, bc, true, true]
end
end
raise ArgumentError, "Expected at least on URITemplate, but got #{a.inspect} and #{b.inspect}" unless a.kind_of? URITemplate or b.kind_of? URITemplate
raise ArgumentError, "Cannot coerce #{a.inspect} and #{b.inspect} into a common representation."
end
# Applies a method to a URITemplate with another URITemplate as argument.
# This is a useful shorthand since both URITemplates are automatically coerced.
#
# @example
# tpl = URITemplate.new('foo')
# URITemplate.apply( tpl, :/, 'bar' ).pattern #=> 'foo/bar'
# URITemplate.apply( 'baz', :/, tpl ).pattern #=> 'baz/foo'
# URITemplate.apply( 'bla', :/, 'blub' ).pattern #=> 'bla/blub'
#
def self.apply(a, method, b, *args)
a,b,_,_ = self.coerce(a,b)
a.send(method,b,*args)
end
# @api private
def self.coerce_first_arg(meth)
alias_method( (meth.to_s + '_without_coercion').to_sym , meth )
class_eval(<<-RUBY)
def #{meth}(other, *args, &block)
this, other, this_converted, _ = URITemplate.coerce( self, other )
if this_converted
return this.#{meth}(other,*args, &block)
end
return #{meth}_without_coercion(other,*args, &block)
end
RUBY
end
# A base class for all errors which will be raised upon invalid syntax.
module Invalid
end
# A base class for all errors which will be raised when a variable value
# is not allowed for a certain expansion.
module InvalidValue
end
# Expands this uri template with the given variables.
# The variables should be converted to strings using {Utils#object_to_param}.
#
# The keys in the variables hash are converted to
# strings in order to support the Ruby 1.9 hash syntax.
#
# If the variables are given as an array, they will be matched against the variables in the template based on their order.
#
# @raise {Unconvertable} if a variable could not be converted to a string.
# @raise {InvalidValue} if a value is not suiteable for a certain variable ( e.g. a string when a list is expected ).
#
# @param variables [#map, Array]
# @return String
def expand(variables = {})
variables = normalize_variables(variables)
tokens.map{|part|
part.expand(variables)
}.join
end
# Works like #expand with two differences:
#
# - the result is a uri template instead of string
# - undefined variables are left in the template
#
# @see {#expand}
# @param variables [#map, Array]
# @return [URITemplate]
def expand_partial(variables = {})
variables = normalize_variables(variables)
self.class.new(tokens.map{|part|
part.expand_partial(variables)
}.flatten(1))
end
def normalize_variables( variables )
raise ArgumentError, "Expected something that responds to :map, but got: #{variables.inspect}" unless variables.respond_to? :map
if variables.kind_of?(Array)
return Hash[self.variables.zip(variables)]
else
# Stringify variables
arg = variables.map{ |k, v| [k.to_s, v] }
if arg.any?{|elem| !elem.kind_of?(Array) }
raise ArgumentError, "Expected the output of variables.map to return an array of arrays but got #{arg.inspect}"
end
return Hash[arg]
end
end
private :normalize_variables
# @abstract
# Returns the type of this template. The type is a symbol which can be used in {.resolve_class} to resolve the type of this template.
#
# @return [Symbol]
def type
raise "Please implement #type on #{self.class.inspect}."
end
# @abstract
# Returns the tokens of this templates. Tokens should include either {Literal} or {Expression}.
#
# @return [Array<URITemplate::Token>]
def tokens
raise "Please implement #tokens on #{self.class.inspect}."
end
# Returns an array containing all variables. Repeated variables are ignored. The concrete order of the variables may change.
# @example
# URITemplate.new('{foo}{bar}{baz}').variables #=> ['foo','bar','baz']
# URITemplate.new('{a}{c}{a}{b}').variables #=> ['a','c','b']
#
# @return [Array<String>]
def variables
@variables ||= tokens.map(&:variables).flatten.uniq.freeze
end
# Returns the number of static characters in this template.
# This method is useful for routing, since it's often pointful to use the url with fewer variable characters.
# For example 'static' and 'sta\\{var\\}' both match 'static', but in most cases 'static' should be prefered over 'sta\\{var\\}' since it's more specific.
#
# @example
# URITemplate.new('/xy/').static_characters #=> 4
# URITemplate.new('{foo}').static_characters #=> 0
# URITemplate.new('a{foo}b').static_characters #=> 2
#
# @return Numeric
def static_characters
@static_characters ||= tokens.select(&:literal?).map{|t| t.string.size }.inject(0,:+)
end
# Returns whether this uri-template includes a host name
#
# This method is usefull to check wheter this template will generate
# or match a uri with a host.
#
# @see #scheme?
#
# @example
# URITemplate.new('/foo').host? #=> false
# URITemplate.new('//example.com/foo').host? #=> true
# URITemplate.new('//{host}/foo').host? #=> true
# URITemplate.new('http://example.com/foo').host? #=> true
# URITemplate.new('{scheme}://example.com/foo').host? #=> true
#
def host?
return scheme_and_host[1]
end
# Returns whether this uri-template includes a scheme
#
# This method is usefull to check wheter this template will generate
# or match a uri with a scheme.
#
# @see #host?
#
# @example
# URITemplate.new('/foo').scheme? #=> false
# URITemplate.new('//example.com/foo').scheme? #=> false
# URITemplate.new('http://example.com/foo').scheme? #=> true
# URITemplate.new('{scheme}://example.com/foo').scheme? #=> true
#
def scheme?
return scheme_and_host[0]
end
# Returns the pattern for this template.
#
# @return String
def pattern
@pattern ||= tokens.map(&:to_s).join
end
alias to_s pattern
# Compares two template patterns.
def eq(other)
return self.pattern == other.pattern
end
coerce_first_arg :eq
alias == eq
# Tries to concatenate two templates, as if they were path segments.
# Removes double slashes or insert one if they are missing.
#
# @example
# tpl = URITemplate::RFC6570.new('/xy/')
# (tpl / '/z/' ).pattern #=> '/xy/z/'
# (tpl / 'z/' ).pattern #=> '/xy/z/'
# (tpl / '{/z}' ).pattern #=> '/xy{/z}'
# (tpl / 'a' / 'b' ).pattern #=> '/xy/a/b'
#
# @param other [URITemplate, String, ...]
# @return URITemplate
def path_concat(other)
if other.host? or other.scheme?
raise ArgumentError, "Expected to receive a relative template but got an absoulte one: #{other.inspect}. If you think this is a bug, please report it."
end
return self if other.tokens.none?
return other if self.tokens.none?
tail = self.tokens.last
head = other.tokens.first
if tail.ends_with_slash?
if head.starts_with_slash?
return self.class.new( remove_double_slash(self.tokens, other.tokens).join )
end
elsif !head.starts_with_slash?
return self.class.new( (self.tokens + ['/'] + other.tokens).join)
end
return self.class.new( (self.tokens + other.tokens).join )
end
coerce_first_arg :path_concat
alias / path_concat
# Concatenate two template with conversion.
#
# @example
# tpl = URITemplate::RFC6570.new('foo')
# (tpl + '{bar}' ).pattern #=> 'foo{bar}'
#
# @param other [URITemplate, String, ...]
# @return URITemplate
def concat(other)
if other.host? or other.scheme?
raise ArgumentError, "Expected to receive a relative template but got an absoulte one: #{other.inspect}. If you think this is a bug, please report it."
end
return self if other.tokens.none?
return other if self.tokens.none?
return self.class.new( self.to_s + other.to_s )
end
coerce_first_arg :concat
alias + concat
alias >> concat
# @api private
def remove_double_slash( first_tokens, second_tokens )
if first_tokens.last.literal?
return first_tokens[0..-2] + [ first_tokens.last.to_s[0..-2] ] + second_tokens
elsif second_tokens.first.literal?
return first_tokens + [ second_tokens.first.to_s[1..-1] ] + second_tokens[1..-1]
else
raise ArgumentError, "Cannot remove double slashes from #{first_tokens.inspect} and #{second_tokens.inspect}."
end
end
private :remove_double_slash
# @api private
def scheme_and_host
return @scheme_and_host if @scheme_and_host
read_chars = ""
@scheme_and_host = [false,false]
tokens.each do |token|
if token.expression?
read_chars << "x"
if token.scheme?
read_chars << ':'
end
if token.host?
read_chars << '//'
end
read_chars << "x"
elsif token.literal?
read_chars << token.string
end
if read_chars =~ SCHEME_REGEX
@scheme_and_host = [true, true]
break
elsif read_chars =~ HOST_REGEX
@scheme_and_host[1] = true
break
elsif read_chars =~ /(^|[^:\/])\/(?!\/)/
break
end
end
return @scheme_and_host
end
private :scheme_and_host
alias absolute? host?
# Opposite of {#absolute?}
def relative?
!absolute?
end
end