rails-http-lab 0.1.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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +15 -0
  3. data/README.md +60 -0
  4. data/app/assets/javascripts/rails_http_lab/application.js +1318 -0
  5. data/app/assets/stylesheets/rails_http_lab/application.css +336 -0
  6. data/app/controllers/rails_http_lab/application_controller.rb +47 -0
  7. data/app/controllers/rails_http_lab/collections_controller.rb +20 -0
  8. data/app/controllers/rails_http_lab/environments_controller.rb +33 -0
  9. data/app/controllers/rails_http_lab/folders_controller.rb +47 -0
  10. data/app/controllers/rails_http_lab/requests_controller.rb +99 -0
  11. data/app/controllers/rails_http_lab/runs_controller.rb +34 -0
  12. data/app/controllers/rails_http_lab/ui_controller.rb +7 -0
  13. data/app/views/layouts/rails_http_lab.html.erb +14 -0
  14. data/app/views/rails_http_lab/ui/index.html.erb +103 -0
  15. data/config/routes.rb +24 -0
  16. data/lib/generators/rails_http_lab/install/install_generator.rb +41 -0
  17. data/lib/generators/rails_http_lab/install/templates/initializer.rb.tt +20 -0
  18. data/lib/rails-http-lab.rb +1 -0
  19. data/lib/rails_http_lab/bruno/block.rb +44 -0
  20. data/lib/rails_http_lab/bruno/document.rb +36 -0
  21. data/lib/rails_http_lab/bruno/parser.rb +207 -0
  22. data/lib/rails_http_lab/bruno/serializer.rb +68 -0
  23. data/lib/rails_http_lab/bruno.rb +12 -0
  24. data/lib/rails_http_lab/configuration.rb +51 -0
  25. data/lib/rails_http_lab/engine.rb +25 -0
  26. data/lib/rails_http_lab/execution/response.rb +20 -0
  27. data/lib/rails_http_lab/execution/runner.rb +187 -0
  28. data/lib/rails_http_lab/execution/variable_resolver.rb +30 -0
  29. data/lib/rails_http_lab/execution.rb +3 -0
  30. data/lib/rails_http_lab/storage/filesystem.rb +123 -0
  31. data/lib/rails_http_lab/storage/tree.rb +120 -0
  32. data/lib/rails_http_lab/storage.rb +2 -0
  33. data/lib/rails_http_lab/version.rb +3 -0
  34. data/lib/rails_http_lab.rb +14 -0
  35. metadata +92 -0
@@ -0,0 +1,187 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "rails_http_lab/execution/variable_resolver"
5
+ require "rails_http_lab/execution/response"
6
+
7
+ module RailsHttpLab
8
+ module Execution
9
+ # Executes a Bruno::Document as an HTTP request, returning Response.
10
+ #
11
+ # Pipeline:
12
+ # 1. Resolve {{vars}} in url, headers, query, body, auth fields.
13
+ # 2. Build query string from params:query (merge with URL query).
14
+ # 3. Build Net::HTTP::<Verb>.
15
+ # 4. Apply auth (bearer/basic/apikey).
16
+ # 5. Set body per body:<type>.
17
+ # 6. Dispatch with timeout, capture timings + size.
18
+ class Runner
19
+ VERBS = {
20
+ "get" => Net::HTTP::Get,
21
+ "post" => Net::HTTP::Post,
22
+ "put" => Net::HTTP::Put,
23
+ "patch" => Net::HTTP::Patch,
24
+ "delete" => Net::HTTP::Delete,
25
+ "head" => Net::HTTP::Head,
26
+ "options" => Net::HTTP::Options
27
+ }.freeze
28
+
29
+ def initialize(document, resolver: VariableResolver.new, timeout: nil, max_body: nil)
30
+ @doc = document
31
+ @resolver = resolver
32
+ @timeout = timeout || RailsHttpLab.config.executor_timeout
33
+ @max_body = max_body || RailsHttpLab.config.executor_max_body
34
+ end
35
+
36
+ def run
37
+ verb_block = @doc.verb_block
38
+ raise Error, "no HTTP verb block in document" unless verb_block
39
+
40
+ sent_request = nil
41
+
42
+ url = @resolver.resolve(verb_block["url"].to_s)
43
+ body_kind = verb_block["body"].to_s
44
+ auth_kind = verb_block["auth"].to_s
45
+
46
+ uri = URI.parse(url)
47
+ merge_query!(uri)
48
+
49
+ klass = VERBS.fetch(verb_block.name)
50
+ request = klass.new(uri.request_uri)
51
+
52
+ apply_headers!(request)
53
+ apply_auth!(request, auth_kind)
54
+ apply_body!(request, body_kind)
55
+
56
+ sent_request = summarize_request(request, uri)
57
+ dispatch(uri, request, sent_request)
58
+ rescue URI::InvalidURIError => e
59
+ Response.new(error: "Invalid URL: #{e.message}", request: sent_request)
60
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
61
+ Response.new(error: "Timeout: #{e.message}", request: sent_request)
62
+ rescue StandardError => e
63
+ Response.new(error: "#{e.class}: #{e.message}", request: sent_request)
64
+ end
65
+
66
+ private
67
+
68
+ def dispatch(uri, request, sent_request)
69
+ started = monotonic_now
70
+ http = Net::HTTP.new(uri.host, uri.port)
71
+ http.use_ssl = uri.scheme == "https"
72
+ http.open_timeout = @timeout
73
+ http.read_timeout = @timeout
74
+
75
+ resp = http.request(request)
76
+ body = resp.body.to_s
77
+ body = body[0, @max_body] + "\n[...truncated]" if body.bytesize > @max_body
78
+
79
+ Response.new(
80
+ status: resp.code.to_i,
81
+ headers: resp.each_header.to_h,
82
+ body: body,
83
+ duration_ms: ((monotonic_now - started) * 1000).round,
84
+ size_bytes: resp.body.to_s.bytesize,
85
+ request: sent_request
86
+ )
87
+ end
88
+
89
+ # Captures the resolved request after all headers/auth/body have been
90
+ # applied so the UI can show an equivalent cURL command.
91
+ def summarize_request(request, uri)
92
+ headers = {}
93
+ request.each_header { |k, v| headers[k] = v }
94
+ {
95
+ method: request.method.to_s.upcase,
96
+ url: uri.to_s,
97
+ headers: headers,
98
+ body: request.body
99
+ }
100
+ end
101
+
102
+ def monotonic_now
103
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
104
+ end
105
+
106
+ def merge_query!(uri)
107
+ params_block = @doc.block("params:query") || @doc.block("query")
108
+ return unless params_block&.kv?
109
+
110
+ existing = uri.query.to_s
111
+ extra = params_block.pairs.reject { |k, _| k.start_with?("~") }.map do |k, v|
112
+ "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(@resolver.resolve(v.to_s))}"
113
+ end
114
+ merged = [existing, extra.join("&")].reject(&:empty?).join("&")
115
+ uri.query = merged unless merged.empty?
116
+ end
117
+
118
+ def apply_headers!(request)
119
+ block = @doc.block("headers")
120
+ return unless block&.kv?
121
+ block.pairs.each do |k, v|
122
+ next if k.start_with?("~")
123
+ request[k] = @resolver.resolve(v.to_s)
124
+ end
125
+ end
126
+
127
+ def apply_auth!(request, kind)
128
+ case kind
129
+ when "bearer"
130
+ token = @resolver.resolve(@doc.block("auth:bearer")&.[]("token").to_s)
131
+ request["Authorization"] = "Bearer #{token}" unless token.empty?
132
+ when "basic"
133
+ user = @resolver.resolve(@doc.block("auth:basic")&.[]("username").to_s)
134
+ pass = @resolver.resolve(@doc.block("auth:basic")&.[]("password").to_s)
135
+ request.basic_auth(user, pass)
136
+ when "apikey"
137
+ block = @doc.block("auth:apikey")
138
+ return unless block
139
+ key = @resolver.resolve(block["key"].to_s)
140
+ val = @resolver.resolve(block["value"].to_s)
141
+ placement = block["placement"].to_s
142
+ if placement == "queryparams"
143
+ uri = request.uri || URI.parse("")
144
+ # placement in query is handled at URL level; skip here
145
+ else
146
+ request[key] = val unless key.empty?
147
+ end
148
+ end
149
+ end
150
+
151
+ def apply_body!(request, kind)
152
+ case kind
153
+ when "json"
154
+ raw = @doc.block("body:json")&.raw.to_s
155
+ request.body = @resolver.resolve(raw.strip)
156
+ request["Content-Type"] ||= "application/json"
157
+ when "text"
158
+ request.body = @resolver.resolve(@doc.block("body:text")&.raw.to_s)
159
+ request["Content-Type"] ||= "text/plain"
160
+ when "xml"
161
+ request.body = @resolver.resolve(@doc.block("body:xml")&.raw.to_s)
162
+ request["Content-Type"] ||= "application/xml"
163
+ when "formUrlEncoded", "form-urlencoded"
164
+ block = @doc.block("body:form-urlencoded")
165
+ if block&.kv?
166
+ pairs = block.pairs.reject { |k, _| k.start_with?("~") }
167
+ request.set_form_data(pairs.to_h.transform_values { |v| @resolver.resolve(v.to_s) })
168
+ end
169
+ when "multipartForm", "multipart-form"
170
+ # v1: text fields only.
171
+ block = @doc.block("body:multipart-form")
172
+ if block&.kv?
173
+ pairs = block.pairs.reject { |k, _| k.start_with?("~") }
174
+ request.set_form(pairs.map { |k, v| [k, @resolver.resolve(v.to_s)] }, "multipart/form-data")
175
+ end
176
+ when "graphql"
177
+ query = @doc.block("body:graphql")&.raw.to_s
178
+ variables = @doc.block("body:graphql:vars")&.raw.to_s
179
+ payload = { "query" => query }
180
+ payload["variables"] = JSON.parse(variables) rescue payload["variables"] = variables if !variables.empty?
181
+ request.body = JSON.generate(payload)
182
+ request["Content-Type"] ||= "application/json"
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,30 @@
1
+ module RailsHttpLab
2
+ module Execution
3
+ # Replaces {{var}} with the value from a flat hash of variables.
4
+ # Unknown vars are left as-is so the user can see what's missing.
5
+ class VariableResolver
6
+ VAR_RE = /\{\{\s*([\w.-]+)\s*\}\}/
7
+
8
+ def initialize(vars = {})
9
+ @vars = stringify_keys(vars)
10
+ end
11
+
12
+ def resolve(str)
13
+ return str unless str.is_a?(String)
14
+ str.gsub(VAR_RE) { |m| @vars.fetch(Regexp.last_match(1), m) }
15
+ end
16
+
17
+ def self.from_environment_document(doc)
18
+ return new({}) unless doc
19
+ pairs = doc.block("vars")&.pairs || []
20
+ new(pairs.to_h)
21
+ end
22
+
23
+ private
24
+
25
+ def stringify_keys(h)
26
+ h.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,3 @@
1
+ require "rails_http_lab/execution/variable_resolver"
2
+ require "rails_http_lab/execution/response"
3
+ require "rails_http_lab/execution/runner"
@@ -0,0 +1,123 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "pathname"
4
+
5
+ module RailsHttpLab
6
+ module Storage
7
+ # Filesystem-backed CRUD for Bruno collections under config.storage_path.
8
+ #
9
+ # All paths are relative to the storage root. The class refuses to operate
10
+ # on absolute paths, paths containing "..", or paths that escape the root
11
+ # after resolution.
12
+ class Filesystem
13
+ def initialize(root: RailsHttpLab.config.resolved_storage_path)
14
+ @root = Pathname.new(root.to_s)
15
+ end
16
+
17
+ attr_reader :root
18
+
19
+ def root_exists?
20
+ @root.directory?
21
+ end
22
+
23
+ def ensure_root!
24
+ FileUtils.mkdir_p(@root) unless @root.directory?
25
+ unless (@root + "bruno.json").file?
26
+ File.write(@root + "bruno.json", default_bruno_json)
27
+ end
28
+ end
29
+
30
+ def read_bru(relative_path)
31
+ abs = safe_path(relative_path)
32
+ raise NotFoundError, "no such file: #{relative_path}" unless abs.file?
33
+ Bruno.parse(abs.read)
34
+ end
35
+
36
+ def write_bru(relative_path, document)
37
+ abs = safe_path(relative_path)
38
+ FileUtils.mkdir_p(abs.dirname)
39
+ File.write(abs, Bruno.dump(document))
40
+ document
41
+ end
42
+
43
+ def delete(relative_path)
44
+ abs = safe_path(relative_path)
45
+ return false unless abs.exist?
46
+ if abs.directory?
47
+ FileUtils.rm_rf(abs)
48
+ else
49
+ abs.delete
50
+ end
51
+ true
52
+ end
53
+
54
+ # Moves a file or directory from one relative path to another. Both paths
55
+ # are validated through safe_path. Refuses if destination already exists.
56
+ def rename(from, to)
57
+ abs_from = safe_path(from)
58
+ raise NotFoundError, "no such path: #{from}" unless abs_from.exist?
59
+ abs_to = safe_path(to)
60
+ raise Error, "destination exists: #{to}" if abs_to.exist?
61
+ FileUtils.mkdir_p(abs_to.dirname)
62
+ FileUtils.mv(abs_from.to_s, abs_to.to_s)
63
+ abs_to
64
+ end
65
+
66
+ def create_folder(relative_path, display_name: nil)
67
+ abs = safe_path(relative_path)
68
+ FileUtils.mkdir_p(abs)
69
+ folder_meta = abs + "folder.bru"
70
+ unless folder_meta.file?
71
+ name = display_name || abs.basename.to_s
72
+ doc = Bruno::Document.new(blocks: [
73
+ Bruno::Block.new(name: "meta", mode: :kv, pairs: [
74
+ ["name", name],
75
+ ["seq", next_seq(abs.dirname).to_s]
76
+ ])
77
+ ])
78
+ File.write(folder_meta, Bruno.dump(doc))
79
+ end
80
+ relative_path
81
+ end
82
+
83
+ def read_bruno_json
84
+ path = @root + "bruno.json"
85
+ return nil unless path.file?
86
+ JSON.parse(path.read)
87
+ end
88
+
89
+ private
90
+
91
+ def safe_path(relative_path)
92
+ rel = relative_path.to_s
93
+ raise OutsideStorageError, "empty path" if rel.empty?
94
+ raise OutsideStorageError, "absolute path forbidden: #{rel}" if rel.start_with?("/")
95
+ raise OutsideStorageError, "traversal forbidden: #{rel}" if rel.split("/").include?("..")
96
+ abs = (@root + rel).cleanpath
97
+ unless abs.to_s == @root.to_s || abs.to_s.start_with?(@root.to_s + "/")
98
+ raise OutsideStorageError, "outside storage root: #{rel}"
99
+ end
100
+ abs
101
+ end
102
+
103
+ def default_bruno_json
104
+ JSON.pretty_generate(
105
+ "version" => "1",
106
+ "name" => "My APIs",
107
+ "type" => "collection",
108
+ "ignore" => ["node_modules", ".git"]
109
+ ) + "\n"
110
+ end
111
+
112
+ def next_seq(dir)
113
+ existing = Dir.glob(File.join(dir, "*.bru")).map do |f|
114
+ doc = Bruno.parse(File.read(f))
115
+ (doc.block("meta")&.[]("seq") || "0").to_i
116
+ rescue StandardError
117
+ 0
118
+ end
119
+ existing.empty? ? 1 : existing.max + 1
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,120 @@
1
+ require "pathname"
2
+
3
+ module RailsHttpLab
4
+ module Storage
5
+ # Builds a hierarchical view of the storage root for the sidebar.
6
+ # Returns:
7
+ # {
8
+ # name: "My APIs",
9
+ # type: "collection",
10
+ # children: [
11
+ # { type: "folder", name: "...", path: "...", children: [...] },
12
+ # { type: "request", name: "...", path: "...", method: "GET", seq: 1 }
13
+ # ],
14
+ # environments: [{ name: "Local", path: "environments/Local.bru" }]
15
+ # }
16
+ class Tree
17
+ def initialize(root: RailsHttpLab.config.resolved_storage_path)
18
+ @root = Pathname.new(root.to_s)
19
+ end
20
+
21
+ def build
22
+ return empty_tree unless @root.directory?
23
+
24
+ collection = read_collection_manifest
25
+ children = list_children(@root, relative_prefix: "")
26
+ envs = list_environments
27
+
28
+ {
29
+ name: collection["name"] || @root.basename.to_s,
30
+ type: "collection",
31
+ children: children,
32
+ environments: envs
33
+ }
34
+ end
35
+
36
+ private
37
+
38
+ def empty_tree
39
+ { name: "(missing)", type: "collection", children: [], environments: [] }
40
+ end
41
+
42
+ def read_collection_manifest
43
+ f = @root + "bruno.json"
44
+ return {} unless f.file?
45
+ JSON.parse(f.read)
46
+ rescue JSON::ParserError
47
+ {}
48
+ end
49
+
50
+ def list_children(dir, relative_prefix:)
51
+ return [] unless dir.directory?
52
+
53
+ entries = dir.children.sort_by { |c| c.basename.to_s.downcase }
54
+ out = []
55
+
56
+ entries.each do |child|
57
+ rel = relative_prefix.empty? ? child.basename.to_s : "#{relative_prefix}/#{child.basename}"
58
+
59
+ if child.directory?
60
+ next if child.basename.to_s == "environments" && relative_prefix.empty?
61
+ folder_meta = read_folder_meta(child)
62
+ out << {
63
+ type: "folder",
64
+ name: folder_meta[:name] || child.basename.to_s,
65
+ path: rel,
66
+ seq: folder_meta[:seq],
67
+ children: list_children(child, relative_prefix: rel)
68
+ }
69
+ elsif child.file? && child.extname == ".bru" && child.basename.to_s != "folder.bru"
70
+ meta = read_request_meta(child)
71
+ out << {
72
+ type: "request",
73
+ name: meta[:name] || child.basename(".bru").to_s,
74
+ path: rel,
75
+ method: meta[:method],
76
+ seq: meta[:seq]
77
+ }
78
+ end
79
+ end
80
+
81
+ # Top-level entries (collections) are always alphabetical.
82
+ # Nested entries respect Bruno's seq ordering, falling back to name.
83
+ if relative_prefix.empty?
84
+ out.sort_by { |c| c[:name].to_s.downcase }
85
+ else
86
+ out.sort_by { |c| [c[:seq] || 9999, c[:name].to_s.downcase] }
87
+ end
88
+ end
89
+
90
+ def list_environments
91
+ envs_dir = @root + "environments"
92
+ return [] unless envs_dir.directory?
93
+ envs_dir.children.select { |c| c.file? && c.extname == ".bru" }.map do |f|
94
+ { name: f.basename(".bru").to_s, path: "environments/#{f.basename}" }
95
+ end.sort_by { |e| e[:name].downcase }
96
+ end
97
+
98
+ def read_folder_meta(dir)
99
+ f = dir + "folder.bru"
100
+ return {} unless f.file?
101
+ doc = Bruno.parse(f.read)
102
+ meta = doc.block("meta")
103
+ return {} unless meta
104
+ { name: meta["name"], seq: meta["seq"]&.to_i }
105
+ rescue StandardError
106
+ {}
107
+ end
108
+
109
+ def read_request_meta(file)
110
+ doc = Bruno.parse(file.read)
111
+ meta = doc.block("meta")
112
+ method = doc.http_method
113
+ return { method: method } unless meta
114
+ { name: meta["name"], seq: meta["seq"]&.to_i, method: method }
115
+ rescue StandardError
116
+ { method: nil }
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,2 @@
1
+ require "rails_http_lab/storage/filesystem"
2
+ require "rails_http_lab/storage/tree"
@@ -0,0 +1,3 @@
1
+ module RailsHttpLab
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,14 @@
1
+ require "rails_http_lab/version"
2
+ require "rails_http_lab/configuration"
3
+
4
+ module RailsHttpLab
5
+ class Error < StandardError; end
6
+ class ParseError < Error; end
7
+ class NotFoundError < Error; end
8
+ class OutsideStorageError < Error; end
9
+ end
10
+
11
+ require "rails_http_lab/bruno"
12
+ require "rails_http_lab/storage"
13
+ require "rails_http_lab/execution"
14
+ require "rails_http_lab/engine" if defined?(Rails)
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-http-lab
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jackson Pires
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ description: Mounts a Bruno-like UI inside your Rails app for ad-hoc HTTP requests,
27
+ persisting collections as .bru files that are interchangeable with Bruno.
28
+ email:
29
+ - jackson@linkana.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE.txt
35
+ - README.md
36
+ - app/assets/javascripts/rails_http_lab/application.js
37
+ - app/assets/stylesheets/rails_http_lab/application.css
38
+ - app/controllers/rails_http_lab/application_controller.rb
39
+ - app/controllers/rails_http_lab/collections_controller.rb
40
+ - app/controllers/rails_http_lab/environments_controller.rb
41
+ - app/controllers/rails_http_lab/folders_controller.rb
42
+ - app/controllers/rails_http_lab/requests_controller.rb
43
+ - app/controllers/rails_http_lab/runs_controller.rb
44
+ - app/controllers/rails_http_lab/ui_controller.rb
45
+ - app/views/layouts/rails_http_lab.html.erb
46
+ - app/views/rails_http_lab/ui/index.html.erb
47
+ - config/routes.rb
48
+ - lib/generators/rails_http_lab/install/install_generator.rb
49
+ - lib/generators/rails_http_lab/install/templates/initializer.rb.tt
50
+ - lib/rails-http-lab.rb
51
+ - lib/rails_http_lab.rb
52
+ - lib/rails_http_lab/bruno.rb
53
+ - lib/rails_http_lab/bruno/block.rb
54
+ - lib/rails_http_lab/bruno/document.rb
55
+ - lib/rails_http_lab/bruno/parser.rb
56
+ - lib/rails_http_lab/bruno/serializer.rb
57
+ - lib/rails_http_lab/configuration.rb
58
+ - lib/rails_http_lab/engine.rb
59
+ - lib/rails_http_lab/execution.rb
60
+ - lib/rails_http_lab/execution/response.rb
61
+ - lib/rails_http_lab/execution/runner.rb
62
+ - lib/rails_http_lab/execution/variable_resolver.rb
63
+ - lib/rails_http_lab/storage.rb
64
+ - lib/rails_http_lab/storage/filesystem.rb
65
+ - lib/rails_http_lab/storage/tree.rb
66
+ - lib/rails_http_lab/version.rb
67
+ homepage: https://github.com/jacksonpires/rails-http-lab
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ source_code_uri: https://github.com/jacksonpires/rails-http-lab
72
+ bug_tracker_uri: https://github.com/jacksonpires/rails-http-lab/issues
73
+ rubygems_mfa_required: 'true'
74
+ allowed_push_host: https://rubygems.org
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '3.1'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 4.0.3
90
+ specification_version: 4
91
+ summary: In-app HTTP request lab for Rails, with Bruno-compatible storage.
92
+ test_files: []