nitlink 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 76d7bf9f8436e8b45762a03ae9a73b613db238f7
4
+ data.tar.gz: 4af8d8f6a3c09991d4254355be30c8e9148a9551
5
+ SHA512:
6
+ metadata.gz: 29b158182dad5e2199db2b2620affc893ec8af246df861ba91cf41e7119ca1515cf17d6830995fa7f6ffc478ba08faab7b067de829a03a79ec5d2ced9d80d409
7
+ data.tar.gz: e789dc5dafd4415e9b335be5e33b84503564b0a8db932ea203222374094e7f12ac9a840f4fc166c2e52b75e63555094e0d49f1f42a4ab261f012293df8bc3ec6
@@ -0,0 +1,4 @@
1
+ require_relative './nitlink/parser'
2
+
3
+ module Nitlink
4
+ end
@@ -0,0 +1,6 @@
1
+ module Nitlink
2
+ class MalformedLinkHeaderError < StandardError; end
3
+ class EncodedParamSyntaxError < StandardError; end
4
+ class UnsupportedCharsetError < StandardError; end
5
+ class UnknownResponseTypeError < StandardError; end
6
+ end
@@ -0,0 +1,4 @@
1
+ module Nitlink
2
+ Link = Struct.new(:target, :relation_type, :context, :target_attributes) do
3
+ end
4
+ end
@@ -0,0 +1,16 @@
1
+ require 'hashwithindifferentaccess'
2
+
3
+ module Nitlink
4
+ class LinkCollection < Array
5
+ def by_rel(relation_type)
6
+ raise ArgumentError.new('relation_type cannot be blank') if (!relation_type || relation_type.empty?)
7
+ find { |link| link.relation_type == relation_type.downcase.to_s }
8
+ end
9
+
10
+ def to_h
11
+ hash = HashWithIndifferentAccess.new
12
+ each { |link| hash[link.relation_type] ||= link }
13
+ hash
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ require 'cgi'
2
+
3
+ module Nitlink
4
+ class ParamDecoder
5
+ def decode(param_value)
6
+ charset, language, value_chars = param_value.split("'")
7
+
8
+ raise syntax_error(param_value) unless charset && language && value_chars
9
+ raise wrong_charset(charset) unless charset.downcase == 'utf-8'
10
+
11
+ CGI.unescape(value_chars)
12
+ end
13
+
14
+ private
15
+
16
+ def syntax_error(val)
17
+ EncodedParamSyntaxError.new(%Q{Syntax error decoding encoded parameter value "#{ val }", must be in the form: charset "'" [ language ] "'" value-chars})
18
+ end
19
+
20
+ def wrong_charset(charset)
21
+ UnsupportedCharsetError.new("Invalid charset #{charset}, encoded parameter values must use the UTF-8 character encoding")
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,58 @@
1
+ module Nitlink
2
+ class ParamExtractor
3
+ QUOTED_VALUE = /\A"(.*)"\Z/m
4
+ QUOTED_PAIR = /\\./m
5
+
6
+ LEADING_OWS = /\A[\x09\x20]+/
7
+ TRAILING_OWS = /[\x09\x20]+\Z/
8
+
9
+ def extract(rest)
10
+ @rest = rest
11
+ parameter_strings = Splitter.new(rest).split_on_unquoted(';')
12
+ raw_params = parameter_strings.map do |parameter_str|
13
+ strip_ows(parameter_str).split('=', 2)
14
+ end
15
+
16
+ return format(raw_params)
17
+ end
18
+
19
+ private
20
+
21
+ def format(raw_params)
22
+ raw_params.map do |raw_param_name, raw_param_value|
23
+ next if !raw_param_name
24
+ param_name = rstrip_ows(raw_param_name.downcase)
25
+
26
+ if raw_param_value
27
+ param_value = lstrip_ows(raw_param_value)
28
+ param_value = format_quoted_value(param_value) if quoted?(param_value)
29
+ else
30
+ param_value = nil
31
+ end
32
+
33
+ [param_name, param_value]
34
+ end.compact
35
+ end
36
+
37
+ def format_quoted_value(quoted_value)
38
+ without_quotes = quoted_value.strip[QUOTED_VALUE, 1]
39
+ without_quotes.gsub(QUOTED_PAIR) { |match| match.chars.to_a.last }
40
+ end
41
+
42
+ def quoted?(param_value)
43
+ param_value =~ QUOTED_VALUE
44
+ end
45
+
46
+ def lstrip_ows(str)
47
+ str.gsub(LEADING_OWS, '')
48
+ end
49
+
50
+ def rstrip_ows(str)
51
+ str.gsub(TRAILING_OWS, '')
52
+ end
53
+
54
+ def strip_ows(str)
55
+ rstrip_ows(lstrip_ows str)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,101 @@
1
+ require_relative './exceptions'
2
+ require_relative './splitter'
3
+ require_relative './link_collection'
4
+ require_relative './link'
5
+ require_relative './response_normalizer'
6
+ require_relative './param_extractor'
7
+ require_relative './param_decoder'
8
+
9
+ module Nitlink
10
+ class Parser
11
+ SINGLE_LINK = /\A\s*<([^>]*)>(.*)/
12
+ RWS = /[\x09\x20]+/
13
+
14
+ attr_reader :options
15
+
16
+ def parse(response, http_method = 'GET')
17
+ @http_method = http_method
18
+ @request_uri, @status, link_header, @content_location_header = ResponseNormalizer.new.metadata(response)
19
+
20
+ links = LinkCollection.new
21
+ return links unless link_header
22
+
23
+ unfolded_header = link_header.gsub(/\r?\n[\x20\x09]+/, '')
24
+ link_strings = Splitter.new(unfolded_header).split_on_unquoted(',')
25
+
26
+ parse_links(link_strings, links)
27
+ end
28
+
29
+ private
30
+
31
+ def parse_links(link_strings, link_collection)
32
+ link_strings.each do |link_string|
33
+ well_formed, target_string, rest = link_string.match(SINGLE_LINK).to_a
34
+ raise malformed(link_string) unless well_formed
35
+
36
+ link_parameters = ParamExtractor.new.extract(rest)
37
+ create_links(target_string, link_parameters).each { |link| link_collection.push(link) }
38
+ end
39
+
40
+ link_collection
41
+ end
42
+
43
+ def create_links(target_string, link_parameters)
44
+ target, relation_types, context, target_attributes = link_attributes(target_string, link_parameters)
45
+ relation_types.map do |relation_type|
46
+ Link.new(target, relation_type, context, target_attributes)
47
+ end
48
+ end
49
+
50
+ def link_attributes(target_string, link_parameters)
51
+ target = @request_uri.merge(target_string)
52
+
53
+ relations_string = first_match(link_parameters, 'rel') || ''
54
+ relation_types = relations_string.split(RWS)
55
+
56
+ context_string = first_match(link_parameters, 'anchor') || identity
57
+ context = (context_string && @request_uri.scheme) ? @request_uri.merge(context_string) : nil
58
+
59
+ target_attributes = extract_target_attributes(link_parameters)
60
+ [target, relation_types, context, target_attributes]
61
+ end
62
+
63
+ def first_match(link_parameters, param_name)
64
+ (link_parameters.find { |name, value| name == param_name } || []).last
65
+ end
66
+
67
+ def extract_target_attributes(link_parameters)
68
+ target_attributes = []
69
+ link_parameters.each do |param_name, param_value|
70
+ next if %(rel anchor).include?(param_name)
71
+ next if %(media title title* type).include?(param_name) && first_match(target_attributes, param_name)
72
+
73
+ begin
74
+ param_value = decode(param_value) if param_name.end_with?('*')
75
+ rescue EncodedParamSyntaxError, UnsupportedCharsetError
76
+ next
77
+ end
78
+
79
+ target_attributes.push [param_name, param_value]
80
+ end
81
+
82
+ Hash[target_attributes]
83
+ end
84
+
85
+ def malformed(link_string)
86
+ MalformedLinkHeaderError.new("Malformed link header (#{ link_string })")
87
+ end
88
+
89
+ def decode(param_value)
90
+ ParamDecoder.new.decode(param_value)
91
+ end
92
+
93
+ def identity
94
+ if %w(GET HEAD).include?(@http_method.upcase) && [200, 203, 204, 206, 304].include?(@status)
95
+ @request_uri
96
+ else
97
+ @content_location_header
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,14 @@
1
+ require_relative '../nitlink'
2
+
3
+ module Nitlink
4
+ module ResponseDecorator
5
+ def links
6
+ Nitlink::Parser.new.parse(self)
7
+ end
8
+ end
9
+ end
10
+
11
+ decoratable_responses = ['Curl::Easy', 'Excon::Response', 'Faraday::Response', 'HTTP::Message', 'HTTP::Response', 'HTTParty::Response', 'Net::HTTPResponse', 'Patron::Response', 'RestClient::Response', 'Typhoeus::Response', 'Unirest::HttpResponse']
12
+ decoratable_responses.select { |klass| Object.const_defined?(klass) }.each do |klass|
13
+ Object.const_get(klass).class_eval { include Nitlink::ResponseDecorator }
14
+ end
@@ -0,0 +1,75 @@
1
+ require 'uri'
2
+ require 'hashwithindifferentaccess'
3
+
4
+ module Nitlink
5
+ class ResponseNormalizer
6
+ def metadata(response)
7
+ response_class = response.class.name
8
+
9
+ uri, status, (link, content_location) = case response_class
10
+ when 'Curl::Easy'
11
+ [response.url, response.response_code, grab_headers(headers_from_string response.header_str)]
12
+ when 'Excon::Response'
13
+ scheme = response.port == 443 ? 'https' : 'http'
14
+ # We have to reconstruct to URL annoyingly
15
+ uri = URI::HTTP.new(scheme, nil, response.host, nil, nil, response.path, nil, nil, nil)
16
+
17
+ [uri, response.status, grab_headers(response.headers)]
18
+ when 'Faraday::Response'
19
+ response = response.to_hash
20
+ [response[:url], response[:status], grab_headers(response[:response_headers])]
21
+ when 'HTTP::Message'
22
+ [response.header.request_uri, response.status, grab_headers(Hash[response.header.all])]
23
+ when 'HTTP::Response'
24
+ [response.uri, response.status, grab_headers(response.headers.to_h)]
25
+ when 'HTTParty::Response'
26
+ [response.request.uri, response.code, grab_headers(response.response.to_hash)]
27
+ when 'Patron::Response'
28
+ [response.url, response.status, grab_headers(response.headers)]
29
+ when 'RestClient::Response'
30
+ [response.request.url, response.code, grab_headers(response.net_http_res.to_hash)]
31
+ when 'Tempfile', 'StringIO'
32
+ # ↑ returned by OpenURI
33
+ [response.base_uri, response.status[0], grab_headers(response.meta)]
34
+ when 'Typhoeus::Response'
35
+ [response.request.base_url, response.code, grab_headers(response.headers)]
36
+ # :nocov:
37
+ when 'Unirest::HttpResponse'
38
+ return metadata(response.raw_body)
39
+ # :nocov:
40
+ when 'Hash'
41
+ response = HashWithIndifferentAccess.new(response)
42
+ response[:headers] = headers_from_string(response[:headers]) if String === response[:headers]
43
+
44
+ [response[:request_uri], response[:status], grab_headers(response[:headers])]
45
+ else
46
+ if defined?(Net::HTTPResponse) && Net::HTTPResponse === response
47
+ [response.uri, response.code, grab_headers(response.to_hash)]
48
+ else
49
+ raise unknown_type(response)
50
+ end
51
+ end
52
+
53
+ [URI.parse(uri.to_s), (status ? Integer(status) : status), link, content_location]
54
+ end
55
+
56
+ private
57
+
58
+ def headers_from_string(header_str)
59
+ headers = header_str.split("\n").map do |header|
60
+ header.strip.split(/\s*:\s*/, 2)
61
+ end
62
+
63
+ Hash[headers.reject(&:empty?)]
64
+ end
65
+
66
+ def grab_headers(headers)
67
+ normalized_headers = Hash[headers.map { |key, value| [key.to_s.downcase, Array(value).join(',')] }]
68
+ [normalized_headers['link'], normalized_headers['content-location']]
69
+ end
70
+
71
+ def unknown_type(response)
72
+ UnknownResponseTypeError.new("Unknown response type #{response.class.name}")
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,40 @@
1
+ require 'strscan'
2
+
3
+ module Nitlink
4
+ class Splitter
5
+ def initialize(string)
6
+ @string = string
7
+ @scanner = StringScanner.new(string)
8
+ end
9
+
10
+ def split_on_unquoted(seperator)
11
+ # 0 = start of string
12
+ split_positions, ignored_split_positions = [0], []
13
+ in_quote = false
14
+
15
+ until @scanner.eos?
16
+ char = @scanner.getch
17
+ @scanner.getch if in_quote && char == "\\"
18
+
19
+ in_quote = !in_quote if char == '"'
20
+ @scanner.skip_until(/>/) and next if char == '<' && in_url?
21
+
22
+ if char == seperator
23
+ ignored_split_positions = []
24
+ (in_quote ? ignored_split_positions : split_positions) << @scanner.pos
25
+ end
26
+ end
27
+ split_positions << @string.length # end of string
28
+ split_positions += ignored_split_positions if in_quote # dangling quote
29
+
30
+ split_positions.sort.each_cons(2).inject([]) do |split_parts, (start_pos, end_pos)|
31
+ split_parts << @string[start_pos...end_pos].chomp(seperator)
32
+ end
33
+ end
34
+
35
+ def in_url?
36
+ preceeding = @scanner.string[0...(@scanner.pos - 1)].strip
37
+ preceeding.end_with?(',') || preceeding.empty?
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,3 @@
1
+ module Nitlink
2
+ VERSION = '1.0.0'
3
+ end
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nitlink
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Alex Peattie
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-11-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: hashwithindifferentaccess
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.1.1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.1.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.13'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 3.5.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 3.5.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: simplecov
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.12.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.12.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: simplecov-shield
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 0.1.0
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 0.1.0
97
+ description: |-
98
+ Nitlink is a nice, nitpicky gem for parsing Link headers, which aims to stick
99
+ as closely as possible to RFC 5988. Has support for UTF-8 encoded parameters, URI resolution, boolean parameters,
100
+ weird edge cases and more.
101
+ email:
102
+ - me@alexpeattie.com
103
+ executables: []
104
+ extensions: []
105
+ extra_rdoc_files: []
106
+ files:
107
+ - lib/nitlink.rb
108
+ - lib/nitlink/exceptions.rb
109
+ - lib/nitlink/link.rb
110
+ - lib/nitlink/link_collection.rb
111
+ - lib/nitlink/param_decoder.rb
112
+ - lib/nitlink/param_extractor.rb
113
+ - lib/nitlink/parser.rb
114
+ - lib/nitlink/response.rb
115
+ - lib/nitlink/response_normalizer.rb
116
+ - lib/nitlink/splitter.rb
117
+ - lib/nitlink/version.rb
118
+ homepage: https://github.com/alexpeattie/nitlink
119
+ licenses:
120
+ - MIT
121
+ metadata: {}
122
+ post_install_message:
123
+ rdoc_options: []
124
+ require_paths:
125
+ - lib
126
+ required_ruby_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: 1.9.3
131
+ required_rubygems_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ requirements: []
137
+ rubyforge_project:
138
+ rubygems_version: 2.5.1
139
+ signing_key:
140
+ specification_version: 4
141
+ summary: Nitlink is a nitpicky gem for parsing Link headers (per RFC 5988)
142
+ test_files: []