ruby_llm-skills 0.3.0 → 0.5.0.pre1

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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Skills
5
+ module Marketplace
6
+ # Knobs for fetching marketplaces: caps, the GitHub token, and the
7
+ # guard a marketplace author's URL must pass.
8
+ #
9
+ # @example
10
+ # RubyLLM::Skills::Marketplace.configure do |config|
11
+ # config.github_token = ENV["MARKETPLACE_GITHUB_TOKEN"]
12
+ # config.url_guard = ->(uri) { raise "nope" unless uri.host.end_with?(".example.com") }
13
+ # end
14
+ #
15
+ class Config
16
+ MEBIBYTE = 1024 * 1024
17
+
18
+ # Sent to GitHub hosts only; lifts the rate limit and reaches private repositories.
19
+ attr_accessor :github_token
20
+ # One plugin archive (compressed and expanded), one file inside it, files per plugin, skills per plugin.
21
+ attr_accessor :max_archive_bytes, :max_file_bytes, :max_files, :max_skills
22
+ attr_accessor :user_agent
23
+ # Called with each URI hop of a URL a marketplace author supplied
24
+ # (a hosted marketplace.json, an archive source). Raise to refuse;
25
+ # return an IP address string to pin the connection to the address
26
+ # that passed, so a DNS answer cannot change between check and connect.
27
+ attr_accessor :url_guard
28
+
29
+ def initialize
30
+ @github_token = ENV.fetch("GITHUB_TOKEN", nil)
31
+ @max_archive_bytes = 64 * MEBIBYTE
32
+ @max_file_bytes = 16 * MEBIBYTE
33
+ @max_files = 2000
34
+ @max_skills = 200
35
+ @user_agent = "ruby_llm-skills/#{RubyLLM::Skills::VERSION}"
36
+ @url_guard = nil
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module RubyLLM
6
+ module Skills
7
+ module Marketplace
8
+ # Where a marketplace's bytes come from: one adapter per source kind.
9
+ # +head+ answers "did the upstream move?" cheaply (a commit sha or a
10
+ # tree hash), +catalog_files+ fetches only the marketplace file(s),
11
+ # +source_tree+ fetches one plugin's content (the marketplace's own tree
12
+ # for relative sources, memoized so ten relative plugins cost one
13
+ # download; another repository's archive for github / gitlab sources;
14
+ # a .tar.gz for archive sources). Nothing here runs git.
15
+ #
16
+ module Fetcher
17
+ Head = Data.define(:sha, :etag, :unchanged) do
18
+ def unchanged? = unchanged
19
+ end
20
+
21
+ # +files+ is the plugin root as `{ path => bytes }`; +sha+ identifies the
22
+ # content (a commit, a tree hash, an archive digest).
23
+ Fetched = Data.define(:files, :sha, :upstream_url)
24
+
25
+ # @param source [Locator::Source]
26
+ # @return [Base]
27
+ def self.for(source)
28
+ case source.kind
29
+ when "github" then Github.new(source)
30
+ when "gitlab" then Gitlab.new(source)
31
+ when "url" then Url.new(source)
32
+ when "directory" then Directory.new(source)
33
+ else raise FetchError, "unknown marketplace kind #{source.kind.inspect}"
34
+ end
35
+ end
36
+
37
+ class Base
38
+ attr_reader :source
39
+
40
+ def initialize(source)
41
+ @source = source
42
+ @tarballs = {}
43
+ @shas = {}
44
+ end
45
+
46
+ def head(previous_sha: nil, previous_etag: nil)
47
+ raise NotImplementedError
48
+ end
49
+
50
+ # `{ path => bytes }` for the marketplace files that exist at +head+.
51
+ def catalog_files(head)
52
+ raise NotImplementedError
53
+ end
54
+
55
+ # The plugin content for one marketplace entry.
56
+ #
57
+ # @param plugin_source [Manifest::PluginSource]
58
+ # @param head [Head]
59
+ # @return [Fetched]
60
+ def source_tree(plugin_source, head:)
61
+ case plugin_source.kind
62
+ when "relative" then relative_tree(plugin_source, head)
63
+ when "github" then repo_tree(GithubRepo.new(plugin_source.repo), plugin_source)
64
+ when "gitlab" then repo_tree(GitlabRepo.new(plugin_source.repo), plugin_source)
65
+ when "archive" then archive_tree(plugin_source)
66
+ else raise FetchError, "unsupported source (#{plugin_source.reason})"
67
+ end
68
+ end
69
+
70
+ # The commit a source resolves to right now, without downloading it
71
+ # (nil for an archive, whose identity is its digest).
72
+ def source_sha(plugin_source, head:)
73
+ case plugin_source.kind
74
+ when "relative" then head.sha
75
+ when "github" then repo_sha(GithubRepo.new(plugin_source.repo), plugin_source)
76
+ when "gitlab" then repo_sha(GitlabRepo.new(plugin_source.repo), plugin_source)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def relative_tree(plugin_source, head)
83
+ Fetched.new(files: read_tree(head.sha, plugin_source.path), sha: head.sha, upstream_url: tree_url(head.sha, plugin_source.path))
84
+ end
85
+
86
+ def repo_sha(repo, plugin_source)
87
+ plugin_source.sha || @shas[[repo.class, repo.repo, plugin_source.ref]] ||= repo.commit(plugin_source.ref || repo.default_branch).first
88
+ end
89
+
90
+ def repo_tree(repo, plugin_source)
91
+ sha = repo_sha(repo, plugin_source)
92
+ Fetched.new(files: Tarball.read(tarball(repo, sha), subdir: plugin_source.path), sha: sha, upstream_url: repo.tree_url(sha, plugin_source.path))
93
+ end
94
+
95
+ # One download per repository commit however many plugins live in it.
96
+ def tarball(repo, sha)
97
+ @tarballs[[repo.class, repo.repo, sha]] ||= repo.tarball(sha)
98
+ end
99
+
100
+ def archive_tree(plugin_source)
101
+ response = Http.get(plugin_source.url, public: true)
102
+ raise FetchError, "archive returned HTTP #{response.status}" unless response.success?
103
+
104
+ digest = Digest::SHA256.hexdigest(response.body)
105
+ raise FetchError, "archive sha256 mismatch for #{plugin_source.url}" if plugin_source.sha256 && plugin_source.sha256 != digest
106
+
107
+ files = Tarball.read(response.body, strip_root: false)
108
+ Fetched.new(files: strip_single_root(files), sha: digest, upstream_url: plugin_source.url)
109
+ end
110
+
111
+ PLUGIN_MARKERS = %w[skills/ commands/ agents/ .claude-plugin/ .codex-plugin/ .cursor-plugin/ SKILL.md plugin.json].freeze
112
+
113
+ # An archive may carry the plugin at its top or one folder down: a
114
+ # single root directory that itself holds a plugin marker (a
115
+ # `skills/` directory, a manifest, a root `SKILL.md`) is a wrapper
116
+ # and is stripped; a root that is the plugin's own `skills/` is not.
117
+ def strip_single_root(files)
118
+ roots = files.keys.map { |path| path.split("/", 2) }
119
+ return files if roots.any? { |parts| parts.size == 1 } || roots.map(&:first).uniq.size != 1
120
+
121
+ root = roots.first.first
122
+ wrapper = PLUGIN_MARKERS.any? do |marker|
123
+ marker.end_with?("/") ? files.keys.any? { |path| path.start_with?("#{root}/#{marker}") } : files.key?("#{root}/#{marker}")
124
+ end
125
+ return files unless wrapper
126
+
127
+ files.transform_keys { |path| path.split("/", 2).last }
128
+ end
129
+
130
+ def subtree(files, subdir, where)
131
+ prefix = Manifest.presence(subdir)
132
+ return files if prefix.nil?
133
+
134
+ subset = files.select { |path, _| path.start_with?("#{prefix}/") }.transform_keys { |path| path.delete_prefix("#{prefix}/") }
135
+ raise FetchError, "#{where}/#{prefix} has no files" if subset.empty?
136
+
137
+ subset
138
+ end
139
+ end
140
+
141
+ # A repository over one of the repo clients (GitHub, GitLab).
142
+ class Repository < Base
143
+ def repo
144
+ raise NotImplementedError
145
+ end
146
+
147
+ def head(previous_sha: nil, previous_etag: nil)
148
+ ref = source.ref || repo.default_branch
149
+ result = repo.commit(ref, etag: previous_etag)
150
+ return Head.new(sha: previous_sha, etag: previous_etag, unchanged: true) if result == :not_modified
151
+
152
+ sha, etag = result
153
+ Head.new(sha: sha, etag: etag, unchanged: sha == previous_sha)
154
+ end
155
+
156
+ def catalog_files(head)
157
+ Manifest::PATHS.each_with_object({}) do |path, files|
158
+ body = repo.file(head.sha, path)
159
+ files[path] = body if body
160
+ end
161
+ end
162
+
163
+ private
164
+
165
+ def read_tree(sha, subdir)
166
+ Tarball.read(tarball(repo, sha), subdir: subdir)
167
+ end
168
+
169
+ def tree_url(sha, subdir)
170
+ repo.tree_url(sha, subdir)
171
+ end
172
+ end
173
+
174
+ class Github < Repository
175
+ def repo
176
+ @repo ||= GithubRepo.new(source.locator)
177
+ end
178
+ end
179
+
180
+ class Gitlab < Repository
181
+ def repo
182
+ @repo ||= GitlabRepo.new(source.locator)
183
+ end
184
+ end
185
+
186
+ # A bare `https://…/marketplace.json`: the file is the whole upstream,
187
+ # so relative sources cannot be fetched (as in Claude Code); github,
188
+ # gitlab and archive entries work.
189
+ class Url < Base
190
+ def head(previous_sha: nil, previous_etag: nil)
191
+ response = fetch(etag: previous_etag)
192
+ return Head.new(sha: previous_sha, etag: previous_etag, unchanged: true) if response.not_modified?
193
+
194
+ @body = response.body
195
+ sha = Digest::SHA256.hexdigest(response.body)
196
+ Head.new(sha: sha, etag: response.etag, unchanged: sha == previous_sha)
197
+ end
198
+
199
+ def catalog_files(_head)
200
+ {Manifest::PATHS.first => @body || fetch.body}
201
+ end
202
+
203
+ private
204
+
205
+ def fetch(etag: nil)
206
+ response = Http.get(source.locator, public: true, max_bytes: Manifest::MAX_BYTES, etag: etag)
207
+ raise FetchError, "marketplace URL returned HTTP #{response.status}" unless response.success? || response.not_modified?
208
+
209
+ response
210
+ end
211
+
212
+ def read_tree(_sha, _subdir)
213
+ raise FetchError, "relative sources are not available from a hosted marketplace.json"
214
+ end
215
+
216
+ def tree_url(_sha, _subdir)
217
+ source.locator
218
+ end
219
+ end
220
+
221
+ # A local folder holding a marketplace file. The tree's sha256 is the
222
+ # head, so an update is a local read that does nothing until the
223
+ # folder changes.
224
+ class Directory < Base
225
+ def root
226
+ dir = File.expand_path(source.locator)
227
+ raise FetchError, "marketplace directory #{source.locator} does not exist" unless File.directory?(dir)
228
+
229
+ dir
230
+ end
231
+
232
+ def head(previous_sha: nil, previous_etag: nil)
233
+ @files = nil
234
+ sha = Tarball.tree_sha256(files)
235
+ Head.new(sha: sha, etag: nil, unchanged: sha == previous_sha)
236
+ end
237
+
238
+ def catalog_files(_head)
239
+ files.slice(*Manifest::PATHS)
240
+ end
241
+
242
+ private
243
+
244
+ def files
245
+ @files ||= Tarball.from_directory(root)
246
+ end
247
+
248
+ def read_tree(_sha, subdir)
249
+ subtree(files, subdir, source.locator)
250
+ end
251
+
252
+ # A local folder has no public tree to link to.
253
+ def tree_url(_sha, _subdir)
254
+ nil
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module RubyLLM
6
+ module Skills
7
+ module Marketplace
8
+ # One GitHub repository over the REST API and its archive hosts: the
9
+ # default branch and a ref's head commit from api.github.com, single
10
+ # files from raw.githubusercontent.com, the tree as a tarball from
11
+ # codeload.github.com. The optional token lifts the rate limit and
12
+ # reaches private repositories; it is sent to these hosts only.
13
+ #
14
+ class GithubRepo
15
+ API = "api.github.com"
16
+ RAW = "raw.githubusercontent.com"
17
+ CODELOAD = "codeload.github.com"
18
+ HOSTS = [API, RAW, CODELOAD].freeze
19
+ SMALL = 16 * 1024 * 1024
20
+
21
+ attr_reader :repo
22
+
23
+ def initialize(repo, token: Marketplace.config.github_token)
24
+ @repo = Manifest.normalize_repo(repo)
25
+ raise FetchError, "invalid GitHub repository #{repo.inspect}" unless @repo.match?(Manifest::REPO_PATTERN)
26
+
27
+ @token = Manifest.presence(token)
28
+ end
29
+
30
+ def default_branch
31
+ Http.json(api("repos/#{repo}")).fetch("default_branch", "main").to_s
32
+ end
33
+
34
+ # The head commit of +ref+ (a branch, tag or sha): [sha, etag], or
35
+ # :not_modified when the ETag still matches.
36
+ def commit(ref, etag: nil)
37
+ response = api("repos/#{repo}/commits/#{ERB::Util.url_encode(ref)}", etag: etag, allow_not_modified: true)
38
+ return :not_modified if response.not_modified?
39
+
40
+ sha = Http.json(response)["sha"].to_s
41
+ raise FetchError, "#{repo}@#{ref}: no commit sha in the response" unless sha.match?(Manifest::SHA_PATTERN)
42
+
43
+ [sha, response.etag]
44
+ end
45
+
46
+ # One file at +sha+, or nil when it does not exist.
47
+ def file(sha, path)
48
+ response = Http.get("https://#{RAW}/#{repo}/#{sha}/#{path}", hosts: HOSTS, headers: headers, max_bytes: SMALL)
49
+ return nil if response.not_found?
50
+ raise FetchError, "#{repo}: #{path} returned HTTP #{response.status}" unless response.success?
51
+
52
+ response.body
53
+ end
54
+
55
+ def tarball(sha)
56
+ response = Http.get("https://#{CODELOAD}/#{repo}/tar.gz/#{sha}", hosts: HOSTS, headers: headers)
57
+ raise FetchError, "#{repo}: archive returned HTTP #{response.status}" unless response.success?
58
+
59
+ response.body
60
+ end
61
+
62
+ # `{ "tag" =>, "url" =>, "name" =>, "published_at" => }` for a tag that has a GitHub release, else nil.
63
+ def release(tag)
64
+ response = api("repos/#{repo}/releases/tags/#{ERB::Util.url_encode(tag)}", allow_not_found: true)
65
+ return nil if response.not_found?
66
+
67
+ data = Http.json(response)
68
+ {"tag" => tag, "url" => data["html_url"].to_s, "name" => data["name"].to_s, "published_at" => data["published_at"].to_s}
69
+ end
70
+
71
+ def tree_url(sha, subdir = nil)
72
+ ["https://github.com/#{repo}/tree/#{sha}", Manifest.presence(subdir)].compact.join("/")
73
+ end
74
+
75
+ private
76
+
77
+ def api(path, etag: nil, allow_not_modified: false, allow_not_found: false)
78
+ response = Http.get("https://#{API}/#{path}", hosts: HOSTS, headers: headers.merge("Accept" => "application/vnd.github+json"),
79
+ max_bytes: SMALL, etag: etag)
80
+ return response if response.success? || (allow_not_modified && response.not_modified?) || (allow_not_found && response.not_found?)
81
+ if [403, 429].include?(response.status) && response.headers["x-ratelimit-remaining"].to_s == "0"
82
+ raise FetchError, "GitHub rate limit reached for #{repo}"
83
+ end
84
+
85
+ raise FetchError, "#{repo}: GitHub API returned HTTP #{response.status} for #{path.split("/").last}"
86
+ end
87
+
88
+ def headers
89
+ @token ? {"Authorization" => "Bearer #{@token}"} : {}
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module RubyLLM
6
+ module Skills
7
+ module Marketplace
8
+ # One gitlab.com project over API v4: a ref's head commit, single raw
9
+ # files and the repository archive. Public projects only.
10
+ #
11
+ class GitlabRepo
12
+ HOST = "gitlab.com"
13
+ HOSTS = [HOST].freeze
14
+ SMALL = 16 * 1024 * 1024
15
+
16
+ attr_reader :repo
17
+
18
+ def initialize(project)
19
+ @repo = Manifest.normalize_repo(project)
20
+ raise FetchError, "invalid GitLab project #{project.inspect}" unless @repo.match?(Manifest::PROJECT_PATTERN)
21
+ end
22
+
23
+ def default_branch
24
+ Http.json(api("")).fetch("default_branch", "main").to_s
25
+ end
26
+
27
+ def commit(ref, etag: nil)
28
+ response = api("/repository/commits/#{ERB::Util.url_encode(ref)}", etag: etag, allow_not_modified: true)
29
+ return :not_modified if response.not_modified?
30
+
31
+ sha = Http.json(response)["id"].to_s
32
+ raise FetchError, "#{repo}@#{ref}: no commit id in the response" unless sha.match?(Manifest::SHA_PATTERN)
33
+
34
+ [sha, response.etag]
35
+ end
36
+
37
+ def file(sha, path)
38
+ response = Http.get("#{base}/repository/files/#{ERB::Util.url_encode(path)}/raw?ref=#{ERB::Util.url_encode(sha)}", hosts: HOSTS, max_bytes: SMALL)
39
+ return nil if response.not_found?
40
+ raise FetchError, "#{repo}: #{path} returned HTTP #{response.status}" unless response.success?
41
+
42
+ response.body
43
+ end
44
+
45
+ def tarball(sha)
46
+ response = Http.get("#{base}/repository/archive.tar.gz?sha=#{ERB::Util.url_encode(sha)}", hosts: HOSTS)
47
+ raise FetchError, "#{repo}: archive returned HTTP #{response.status}" unless response.success?
48
+
49
+ response.body
50
+ end
51
+
52
+ def tree_url(sha, subdir = nil)
53
+ ["https://#{HOST}/#{repo}/-/tree/#{sha}", Manifest.presence(subdir)].compact.join("/")
54
+ end
55
+
56
+ private
57
+
58
+ def base
59
+ "https://#{HOST}/api/v4/projects/#{ERB::Util.url_encode(repo)}"
60
+ end
61
+
62
+ def api(path, etag: nil, allow_not_modified: false)
63
+ response = Http.get("#{base}#{path}", hosts: HOSTS, max_bytes: SMALL, etag: etag)
64
+ return response if response.success? || (allow_not_modified && response.not_modified?)
65
+ raise FetchError, "GitLab rate limit reached for #{repo}" if response.status == 429
66
+
67
+ raise FetchError, "#{repo}: GitLab API returned HTTP #{response.status}"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+ require "json"
7
+
8
+ module RubyLLM
9
+ module Skills
10
+ module Marketplace
11
+ # The one HTTP door for marketplace fetches: HTTPS only, a body cap,
12
+ # redirects followed by hand, conditional requests through ETags.
13
+ #
14
+ # Two admission modes: +hosts:+ for the adapters' own well-known hosts,
15
+ # +public: true+ for a URL a marketplace author supplied, where every
16
+ # hop must pass +Config#url_guard+ when one is configured.
17
+ #
18
+ module Http
19
+ OPEN_TIMEOUT = 10
20
+ READ_TIMEOUT = 120
21
+ MAX_REDIRECTS = 3
22
+
23
+ Response = Data.define(:status, :body, :headers) do
24
+ def success? = status.between?(200, 299)
25
+
26
+ def not_modified? = status == 304
27
+
28
+ def not_found? = status == 404
29
+
30
+ def etag
31
+ value = headers["etag"].to_s
32
+ value.empty? ? nil : value
33
+ end
34
+ end
35
+
36
+ class << self
37
+ # @param url [String] an https URL
38
+ # @param hosts [Array<String>] hosts admitted by name
39
+ # @param public [Boolean] admit any https host that passes the url_guard
40
+ # @param headers [Hash] extra request headers
41
+ # @param max_bytes [Integer] abandon the body past this many bytes
42
+ # @param etag [String, nil] sent as If-None-Match
43
+ # @return [Response]
44
+ # @raise [FetchError]
45
+ def get(url, hosts: [], public: false, headers: {}, max_bytes: Marketplace.config.max_archive_bytes, etag: nil)
46
+ uri = parse(url)
47
+ request_headers = {"User-Agent" => Marketplace.config.user_agent}.merge(headers)
48
+ request_headers["If-None-Match"] = etag if etag
49
+
50
+ (MAX_REDIRECTS + 1).times do
51
+ address = admit!(uri, hosts: hosts, public: public)
52
+ response = perform(uri, request_headers, max_bytes, address: address)
53
+ location = response.headers["location"]
54
+ return response unless response.status.between?(300, 399) && location
55
+
56
+ uri = parse(URI.join(uri.to_s, location).to_s)
57
+ end
58
+
59
+ raise FetchError, "#{uri.host}: too many redirects"
60
+ rescue Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError, IOError => e
61
+ raise FetchError, "#{uri&.host}: #{e.class.name}"
62
+ end
63
+
64
+ def json(response)
65
+ parsed = JSON.parse(response.body.to_s)
66
+ parsed.is_a?(Hash) ? parsed : {}
67
+ rescue JSON::ParserError
68
+ raise FetchError, "malformed JSON response"
69
+ end
70
+
71
+ private
72
+
73
+ def parse(url)
74
+ URI.parse(url.to_s)
75
+ rescue URI::InvalidURIError
76
+ raise FetchError, "invalid URL #{url.to_s.inspect}"
77
+ end
78
+
79
+ # The address to connect to (nil: by name) once the hop is admitted.
80
+ def admit!(uri, hosts:, public:)
81
+ host = uri.host.to_s.downcase
82
+ raise FetchError, "refusing #{uri}: not an https URL" unless uri.is_a?(URI::HTTPS) && !host.empty?
83
+ raise FetchError, "refusing #{uri}: URLs with credentials are not allowed" if uri.userinfo
84
+ return nil if hosts.map(&:downcase).include?(host)
85
+ raise FetchError, "refusing #{host.inspect}: not an allowlisted https host" unless public
86
+
87
+ address = Marketplace.config.url_guard&.call(uri)
88
+ address.is_a?(String) ? address : nil
89
+ end
90
+
91
+ # Streams the body and stops reading the moment it passes the cap,
92
+ # so an upstream that answers with gigabytes never occupies more
93
+ # than the cap in memory. Pinned to +address+ when given, Net::HTTP
94
+ # still names the host for SNI and the certificate check.
95
+ def perform(uri, headers, max_bytes, address: nil)
96
+ body = String.new(encoding: Encoding::BINARY)
97
+ http = Net::HTTP.new(uri.host, uri.port)
98
+ http.ipaddr = address if address
99
+ http.use_ssl = true
100
+ http.open_timeout = OPEN_TIMEOUT
101
+ http.read_timeout = READ_TIMEOUT
102
+ http.start do
103
+ request = Net::HTTP::Get.new(uri.request_uri, headers)
104
+ http.request(request) do |response|
105
+ response.read_body do |chunk|
106
+ raise FetchError, "#{uri.host}: response exceeds #{max_bytes} bytes" if body.bytesize + chunk.bytesize > max_bytes
107
+
108
+ body << chunk.b
109
+ end
110
+ return Response.new(status: response.code.to_i, body: body, headers: response_headers(response))
111
+ end
112
+ end
113
+ end
114
+
115
+ def response_headers(response)
116
+ response.each_header.to_h { |key, value| [key.downcase, value] }
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end