nitlink 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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: []