julewire-rack 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0505d309f127982b88645a56045d2f472d36acb14efe781f6f08290559f8763e
4
+ data.tar.gz: 00b881c32d5761ab740773b80098c9c47950ceb574e65d38f1d4c1e14d36adbe
5
+ SHA512:
6
+ metadata.gz: '078de25613aef97e313246e95e5207771a61439ed22f4b5ca97c1276593bd00863285504486275505d187fab88ab63dedbe69913b617e0e9e4c29acdc7abde7a'
7
+ data.tar.gz: c35d17e54f37c11328f0582497a44084faf7f3ac28f9d5418b637d905d8327441efe71b4af276c4a88c9bc60c3e47daa27893044d8353db3a260c1b2fb34556b
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ ## Unreleased
2
+
3
+ ## 1.0.0 - 2026-06-21
4
+
5
+ - Initial release: Rack-family request lifecycle support primitives for Julewire
6
+ integrations.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alexander Grebennik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # Julewire Rack
2
+
3
+ Rack-family request lifecycle support for Julewire integrations.
4
+
5
+ This gem is deliberately framework-neutral. It is the place for Rack request
6
+ primitives that can be shared by Rails, Grape, and other Rack-based adapters.
7
+ Rails and ActiveSupport EventReporter helpers stay in `julewire-rails_support`.
8
+
9
+ It owns capture helpers for request bodies, response bodies, headers, content
10
+ types, and JSON-ish payload extraction. It does not install middleware by
11
+ itself; framework gems decide when those primitives belong in a request
12
+ pipeline.
13
+
14
+ Applications normally install a framework adapter, not this support gem. Use it
15
+ directly when writing a Rack-shaped Julewire integration.
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/julewire/rack/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "julewire-rack"
7
+ spec.version = Julewire::Rack::VERSION
8
+ spec.authors = ["Alexander Grebennik"]
9
+ spec.email = ["slbug@users.noreply.github.com", "sl.bug.sl@gmail.com"]
10
+
11
+ spec.summary = "Rack request lifecycle support for Julewire."
12
+ spec.description = "Rack-family support primitives for Julewire request integrations."
13
+ spec.homepage = "https://github.com/slbug/julewire"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.4"
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "https://github.com/slbug/julewire/tree/main/gems/rack"
18
+ spec.metadata["changelog_uri"] = "https://github.com/slbug/julewire/blob/main/gems/rack/CHANGELOG.md"
19
+
20
+ spec.metadata["rubygems_mfa_required"] = "true"
21
+
22
+ spec.files = Dir.chdir(__dir__) do
23
+ Dir[
24
+ "CHANGELOG.md",
25
+ "LICENSE.txt",
26
+ "README.md",
27
+ "julewire-rack.gemspec",
28
+ "lib/**/*.rb"
29
+ ]
30
+ end
31
+ spec.executables = []
32
+ spec.require_paths = ["lib"]
33
+
34
+ spec.add_dependency "julewire-core", ">= 1.0"
35
+ spec.add_dependency "rack", ">= 3.2"
36
+ spec.add_dependency "zeitwerk", ">= 2.8.1"
37
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rack
5
+ module Capture
6
+ module BodyContentType
7
+ JSON_ONLY = ["application/json", %r{\Aapplication/.+\+json\z}].freeze
8
+ BINARY_MEDIA_TYPES = %w[
9
+ application/gzip
10
+ application/octet-stream
11
+ application/pdf
12
+ application/x-gzip
13
+ application/zip
14
+ ].freeze
15
+ BINARY_MEDIA_PREFIXES = %w[
16
+ audio/
17
+ font/
18
+ image/
19
+ video/
20
+ ].freeze
21
+
22
+ class << self
23
+ def allowed?(target, selector:)
24
+ media_type = media_type_for(target)
25
+ return false if binary?(media_type)
26
+ return true if selector == true
27
+ return false unless selector
28
+
29
+ return false if media_type.empty?
30
+
31
+ Array(selector).any? { matches?(media_type, it) }
32
+ end
33
+
34
+ def binary?(media_type)
35
+ value = media_type.to_s
36
+ return true if BINARY_MEDIA_TYPES.include?(value)
37
+
38
+ BINARY_MEDIA_PREFIXES.any? { value.start_with?(it) }
39
+ end
40
+
41
+ def media_type_for(target)
42
+ normalized_media_type(raw_content_type(target))
43
+ end
44
+
45
+ def raw_content_type(target)
46
+ direct_content_type(target) || header_content_type(target)
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ def direct_content_type(target)
52
+ return target.media_type if target.respond_to?(:media_type)
53
+ return target.content_mime_type.to_s if target.respond_to?(:content_mime_type) && target.content_mime_type
54
+ return target.content_type if target.respond_to?(:content_type)
55
+
56
+ target.get_header("CONTENT_TYPE") if target.respond_to?(:get_header)
57
+ end
58
+
59
+ def header_content_type(target)
60
+ header_value(target.headers, "content-type") if target.respond_to?(:headers)
61
+ end
62
+
63
+ def header_value(headers, key)
64
+ return unless headers.respond_to?(:[])
65
+
66
+ headers[key] || headers[key.upcase] || headers[key.split("-").map(&:capitalize).join("-")]
67
+ end
68
+
69
+ def matches?(media_type, matcher)
70
+ case matcher
71
+ when Regexp
72
+ matcher.match?(media_type)
73
+ else
74
+ media_type == normalized_media_type(matcher)
75
+ end
76
+ end
77
+
78
+ def normalized_media_type(value)
79
+ value.to_s.partition(";").first.strip.downcase
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rack
5
+ module Capture
6
+ class BufferedResponseBody
7
+ class << self
8
+ def call(response, **) = new(response, **).summary_fields
9
+ end
10
+
11
+ def initialize(response, content_types:, limit:, mode: Settings::STRING_BODY)
12
+ @response = response
13
+ @content_types = content_types
14
+ @limit = limit
15
+ @mode = mode
16
+ @captured = +""
17
+ @total_bytes = 0
18
+ @truncated = false
19
+ end
20
+
21
+ def summary_fields
22
+ return {} unless BodyContentType.allowed?(@response, selector: @content_types)
23
+
24
+ body_parts = response_body_parts
25
+ return {} unless body_parts
26
+
27
+ if @limit.nil?
28
+ fields = unlimited_single_part_fields(body_parts)
29
+ return fields if fields
30
+ end
31
+
32
+ capture(body_parts)
33
+ return {} if @total_bytes.zero? && @captured.empty?
34
+
35
+ JsonBody.fields(:response, @captured, bytes: @total_bytes, truncated: @truncated, mode: @mode)
36
+ end
37
+
38
+ private
39
+
40
+ def response_body_parts
41
+ return unless @response.respond_to?(:stream)
42
+
43
+ stream = @response.stream
44
+ return if stream.respond_to?(:to_path)
45
+ return unless stream.respond_to?(:to_ary)
46
+
47
+ stream.to_ary
48
+ rescue StandardError
49
+ nil
50
+ end
51
+
52
+ def capture(body_parts)
53
+ body_parts.each do |part|
54
+ body = body_string(part)
55
+ next unless body
56
+
57
+ capture_part(body)
58
+ end
59
+ end
60
+
61
+ def body_string(part)
62
+ return part.to_str if part.respond_to?(:to_str)
63
+
64
+ nil
65
+ rescue StandardError
66
+ nil
67
+ end
68
+
69
+ def unlimited_single_part_fields(body_parts)
70
+ return unless body_parts.length == 1
71
+
72
+ body = body_string(body_parts.first)
73
+ return {} unless body && !body.empty?
74
+
75
+ JsonBody.fields(:response, body, bytes: body.bytesize, truncated: false, mode: @mode)
76
+ end
77
+
78
+ def capture_part(body)
79
+ bytesize = body.bytesize
80
+ @total_bytes += bytesize
81
+
82
+ return @captured << body if @limit.nil?
83
+
84
+ remaining = @limit - @captured.bytesize
85
+ if remaining <= 0
86
+ @truncated = true if bytesize.positive?
87
+ return
88
+ end
89
+
90
+ @captured << body.byteslice(0, remaining)
91
+ @truncated = true if bytesize > remaining
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rack
5
+ module Capture
6
+ class HeaderSelection
7
+ SENSITIVE_HEADERS = %w[
8
+ authorization
9
+ cookie
10
+ proxy-authorization
11
+ set-cookie
12
+ x-api-key
13
+ ].freeze
14
+ SENSITIVE_HEADER_SET = SENSITIVE_HEADERS.to_h { [it, true] }.freeze
15
+
16
+ class << self
17
+ def build(selector)
18
+ return new(true) if selector == true
19
+ return unless selector
20
+
21
+ new(Array(selector).to_h { [normalize_name(it), true] })
22
+ end
23
+
24
+ def normalize_name(name) = name.to_s.tr("_", "-").downcase
25
+ end
26
+
27
+ def initialize(selection)
28
+ @selection = selection
29
+ end
30
+
31
+ def include?(name)
32
+ @selection == true ? !SENSITIVE_HEADER_SET.key?(name) : @selection.key?(name)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rack
5
+ module Capture
6
+ module Headers
7
+ class << self
8
+ def request(request, selector:)
9
+ env = request.env if request.respond_to?(:env)
10
+ return {} unless env.respond_to?(:each)
11
+
12
+ selection = HeaderSelection.build(selector)
13
+ return {} unless selection
14
+
15
+ capture_headers(env, selection) { request_header_name(it) }
16
+ end
17
+
18
+ def response(headers, selector:)
19
+ return {} unless headers.respond_to?(:each)
20
+
21
+ selection = HeaderSelection.build(selector)
22
+ return {} unless selection
23
+
24
+ capture_headers(headers, selection) { HeaderSelection.normalize_name(it) }
25
+ end
26
+
27
+ private
28
+
29
+ def capture_headers(headers, selection)
30
+ captured = {}
31
+ headers.each do |key, value|
32
+ name = yield key
33
+ next unless name && selection.include?(name)
34
+
35
+ captured[name] = header_value(value)
36
+ end
37
+ captured
38
+ end
39
+
40
+ def request_header_name(key)
41
+ name = key.to_s
42
+ return "content-type" if name == "CONTENT_TYPE"
43
+ return "content-length" if name == "CONTENT_LENGTH"
44
+ return unless name.start_with?("HTTP_")
45
+
46
+ HeaderSelection.normalize_name(name.delete_prefix("HTTP_"))
47
+ end
48
+
49
+ def header_value(value)
50
+ return value.join(", ") if value.is_a?(Array)
51
+
52
+ value.to_s
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Julewire
6
+ module Rack
7
+ module Capture
8
+ module JsonBody
9
+ BODY_KEYS = { request: :request_body, response: :response_body }.freeze
10
+ BODY_BYTES_KEYS = { request: :request_body_bytes, response: :response_body_bytes }.freeze
11
+ BODY_TRUNCATED_KEYS = { request: :request_body_truncated, response: :response_body_truncated }.freeze
12
+ JSON_KEYS = { request: :request_body_json, response: :response_body_json }.freeze
13
+ PARSE_ERROR_KEYS = { request: :request_body_parse_error, response: :response_body_parse_error }.freeze
14
+
15
+ class << self
16
+ def fields(prefix, body, bytes:, truncated:, mode:)
17
+ fields = {
18
+ BODY_BYTES_KEYS.fetch(prefix) => bytes,
19
+ BODY_TRUNCATED_KEYS.fetch(prefix) => truncated
20
+ }
21
+ return fields_with_raw_body(fields, prefix, body) unless mode == Settings::JSON_BODY
22
+ return fields if truncated
23
+
24
+ append_parsed_fields(fields, prefix, body)
25
+ end
26
+
27
+ private
28
+
29
+ def fields_with_raw_body(fields, prefix, body)
30
+ fields[BODY_KEYS.fetch(prefix)] = body
31
+ fields
32
+ end
33
+
34
+ def append_parsed_fields(fields, prefix, body)
35
+ fields[JSON_KEYS.fetch(prefix)] = JSON.parse(body)
36
+ fields
37
+ rescue JSON::ParserError => e
38
+ fields[PARSE_ERROR_KEYS.fetch(prefix)] = e.class.name
39
+ fields
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rack
5
+ module Capture
6
+ class RequestBody
7
+ class << self
8
+ def call(request, **) = new(request, **).summary_fields
9
+ end
10
+
11
+ def initialize(request, content_types:, limit:, mode:)
12
+ @request = request
13
+ @content_types = content_types
14
+ @limit = limit
15
+ @mode = mode
16
+ end
17
+
18
+ def summary_fields
19
+ return {} unless BodyContentType.allowed?(@request, selector: @content_types)
20
+
21
+ captured, bytes, truncated = capture_body
22
+ return {} if captured.nil? || (captured.empty? && !truncated)
23
+
24
+ JsonBody.fields(:request, captured, bytes: bytes, truncated: truncated, mode: @mode)
25
+ end
26
+
27
+ private
28
+
29
+ def capture_body
30
+ body = bounded_body
31
+ bytes = content_length || body.bytesize
32
+ captured, truncated = capture(body, total_bytes: bytes)
33
+ [captured, bytes, truncated]
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ def bounded_body
39
+ return request_body if @limit.nil?
40
+
41
+ length = content_length
42
+ return request_body if length && length <= @limit
43
+
44
+ read_body_stream(limit: @limit + 1)
45
+ end
46
+
47
+ def request_body
48
+ # Rack/Rails may already buffer raw_post; the byte cap applies after that read.
49
+ return @request.raw_post if @request.respond_to?(:raw_post)
50
+
51
+ read_body_stream(limit: nil)
52
+ end
53
+
54
+ def read_body_stream(limit:)
55
+ io = @request.body
56
+ original_position = nil
57
+ body = nil
58
+ begin
59
+ original_position = body_stream_position(io)
60
+ body = io.read(limit)
61
+ ensure
62
+ restore_body_stream(io, original_position)
63
+ end
64
+ body.to_str
65
+ end
66
+
67
+ def body_stream_position(io)
68
+ io.pos
69
+ rescue StandardError
70
+ nil
71
+ end
72
+
73
+ def restore_body_stream(io, original_position)
74
+ io.rewind
75
+ rescue StandardError
76
+ nil
77
+ ensure
78
+ restore_body_stream_position(io, original_position)
79
+ end
80
+
81
+ def restore_body_stream_position(io, original_position)
82
+ return unless original_position
83
+
84
+ io.pos = original_position
85
+ rescue StandardError
86
+ nil
87
+ end
88
+
89
+ def capture(body, total_bytes:)
90
+ return [body, false] if @limit.nil? || total_bytes <= @limit
91
+
92
+ [body.byteslice(0, @limit), true]
93
+ end
94
+
95
+ def content_length
96
+ value = @request.content_length if @request.respond_to?(:content_length)
97
+ value = @request.get_header("CONTENT_LENGTH") if value.nil?
98
+ integer = Integer(value)
99
+ integer if integer.positive?
100
+ rescue StandardError
101
+ nil
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rack
5
+ module Capture
6
+ class Settings
7
+ JSON_BODY = :json
8
+ STRING_BODY = :string
9
+ CAPTURE_BODY_VALUES = [false, true, JSON_BODY, "json"].freeze
10
+ private_constant :CAPTURE_BODY_VALUES
11
+
12
+ include Julewire::Core::Integration::Settings
13
+
14
+ setting :body, default: false, predicate: true, validate: :validate_body
15
+ setting :body_bytes, default: 65_536, validate: byte_limit
16
+ setting :body_content_types, default: BodyContentType::JSON_ONLY
17
+ setting :headers, default: false, predicate: true
18
+
19
+ def body_mode
20
+ case body
21
+ when JSON_BODY, "json"
22
+ JSON_BODY
23
+ else
24
+ STRING_BODY
25
+ end
26
+ end
27
+
28
+ def enabled? = headers? || body?
29
+
30
+ private
31
+
32
+ def validate_body(value)
33
+ return value if CAPTURE_BODY_VALUES.include?(value)
34
+
35
+ raise Error, "body must be false, true, or :json"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rack
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "julewire/core"
5
+
6
+ module Julewire
7
+ module Rack
8
+ class Error < Julewire::Error; end
9
+ end
10
+
11
+ loader = Zeitwerk::Loader.for_gem_extension(self)
12
+ loader.setup
13
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "julewire/rack"
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: julewire-rack
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Alexander Grebennik
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: julewire-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '3.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: zeitwerk
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 2.8.1
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 2.8.1
54
+ description: Rack-family support primitives for Julewire request integrations.
55
+ email:
56
+ - slbug@users.noreply.github.com
57
+ - sl.bug.sl@gmail.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - CHANGELOG.md
63
+ - LICENSE.txt
64
+ - README.md
65
+ - julewire-rack.gemspec
66
+ - lib/julewire-rack.rb
67
+ - lib/julewire/rack.rb
68
+ - lib/julewire/rack/capture/body_content_type.rb
69
+ - lib/julewire/rack/capture/buffered_response_body.rb
70
+ - lib/julewire/rack/capture/header_selection.rb
71
+ - lib/julewire/rack/capture/headers.rb
72
+ - lib/julewire/rack/capture/json_body.rb
73
+ - lib/julewire/rack/capture/request_body.rb
74
+ - lib/julewire/rack/capture/settings.rb
75
+ - lib/julewire/rack/version.rb
76
+ homepage: https://github.com/slbug/julewire
77
+ licenses:
78
+ - MIT
79
+ metadata:
80
+ homepage_uri: https://github.com/slbug/julewire
81
+ source_code_uri: https://github.com/slbug/julewire/tree/main/gems/rack
82
+ changelog_uri: https://github.com/slbug/julewire/blob/main/gems/rack/CHANGELOG.md
83
+ rubygems_mfa_required: 'true'
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '3.4'
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 4.0.14
99
+ specification_version: 4
100
+ summary: Rack request lifecycle support for Julewire.
101
+ test_files: []