datadog 2.35.0 → 2.37.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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +85 -1
  3. data/ext/datadog_profiling_native_extension/clock_id_from_mach.c +5 -12
  4. data/ext/datadog_profiling_native_extension/collectors_cpu_and_wall_time_worker.c +71 -31
  5. data/ext/datadog_profiling_native_extension/collectors_discrete_dynamic_sampler.c +1 -1
  6. data/ext/datadog_profiling_native_extension/collectors_idle_sampling_helper.c +18 -4
  7. data/ext/datadog_profiling_native_extension/collectors_stack.c +37 -18
  8. data/ext/datadog_profiling_native_extension/collectors_stack.h +8 -2
  9. data/ext/datadog_profiling_native_extension/collectors_thread_context.c +596 -341
  10. data/ext/datadog_profiling_native_extension/collectors_thread_context.h +11 -7
  11. data/ext/datadog_profiling_native_extension/datadog_ruby_common.c +7 -8
  12. data/ext/datadog_profiling_native_extension/datadog_ruby_common.h +0 -12
  13. data/ext/datadog_profiling_native_extension/extconf.rb +13 -2
  14. data/ext/datadog_profiling_native_extension/gvl_profiling_helper.c +4 -43
  15. data/ext/datadog_profiling_native_extension/gvl_profiling_helper.h +15 -47
  16. data/ext/datadog_profiling_native_extension/heap_recorder.c +44 -26
  17. data/ext/datadog_profiling_native_extension/private_vm_api_access.c +14 -35
  18. data/ext/datadog_profiling_native_extension/profiling.c +41 -4
  19. data/ext/datadog_profiling_native_extension/ruby_helpers.c +40 -34
  20. data/ext/datadog_profiling_native_extension/stack_recorder.c +24 -3
  21. data/ext/datadog_profiling_native_extension/stack_recorder.h +1 -0
  22. data/ext/datadog_profiling_native_extension/unsafe_api_calls_check.h +4 -2
  23. data/ext/libdatadog_api/datadog_ruby_common.c +7 -8
  24. data/ext/libdatadog_api/datadog_ruby_common.h +0 -12
  25. data/ext/libdatadog_api/di.c +76 -0
  26. data/ext/libdatadog_api/extconf.rb +6 -0
  27. data/ext/libdatadog_extconf_helpers.rb +1 -1
  28. data/lib/datadog/ai_guard/api_client.rb +6 -4
  29. data/lib/datadog/appsec/api_security/route_extractor.rb +13 -4
  30. data/lib/datadog/appsec/component.rb +1 -1
  31. data/lib/datadog/appsec/configuration.rb +13 -0
  32. data/lib/datadog/appsec/context.rb +4 -2
  33. data/lib/datadog/appsec/contrib/aws_lambda/waf_addresses.rb +37 -4
  34. data/lib/datadog/appsec/contrib/excon/ssrf_detection_middleware.rb +59 -4
  35. data/lib/datadog/appsec/contrib/faraday/ssrf_detection_middleware.rb +59 -5
  36. data/lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb +64 -19
  37. data/lib/datadog/appsec/contrib/graphql/integration.rb +1 -0
  38. data/lib/datadog/appsec/contrib/rack/buffered_input.rb +83 -0
  39. data/lib/datadog/appsec/contrib/rack/gateway/request.rb +41 -3
  40. data/lib/datadog/appsec/contrib/rack/gateway/watcher.rb +24 -7
  41. data/lib/datadog/appsec/contrib/rack/input_peeker.rb +79 -0
  42. data/lib/datadog/appsec/contrib/rack/request_middleware.rb +21 -0
  43. data/lib/datadog/appsec/contrib/rails/gateway/request.rb +35 -1
  44. data/lib/datadog/appsec/contrib/rails/gateway/watcher.rb +17 -1
  45. data/lib/datadog/appsec/contrib/rest_client/request_ssrf_detection_patch.rb +60 -7
  46. data/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb +24 -3
  47. data/lib/datadog/appsec/default_header_tags.rb +10 -6
  48. data/lib/datadog/appsec/ext.rb +1 -0
  49. data/lib/datadog/appsec/metrics/collector.rb +18 -1
  50. data/lib/datadog/appsec/metrics/exporter.rb +9 -3
  51. data/lib/datadog/appsec/remote.rb +9 -3
  52. data/lib/datadog/appsec/route_normalizer/rails_route_pattern.rb +176 -0
  53. data/lib/datadog/appsec/route_normalizer/route_pattern.rb +378 -0
  54. data/lib/datadog/appsec/route_normalizer/route_text.rb +57 -0
  55. data/lib/datadog/appsec/route_normalizer.rb +80 -0
  56. data/lib/datadog/appsec/utils/http/body_reader.rb +61 -0
  57. data/lib/datadog/core/configuration/components.rb +83 -2
  58. data/lib/datadog/core/configuration/components_state.rb +6 -1
  59. data/lib/datadog/core/configuration/settings.rb +1 -5
  60. data/lib/datadog/core/configuration/supported_configurations.rb +4 -0
  61. data/lib/datadog/core/evp.rb +11 -0
  62. data/lib/datadog/core/remote/client/capabilities.rb +34 -7
  63. data/lib/datadog/core/remote/component.rb +1 -1
  64. data/lib/datadog/core/telemetry/event/app_started.rb +8 -22
  65. data/lib/datadog/core/utils/at_fork_monkey_patch.rb +1 -1
  66. data/lib/datadog/core/utils/forking.rb +3 -1
  67. data/lib/datadog/core/utils/spawn_monkey_patch.rb +3 -1
  68. data/lib/datadog/core.rb +3 -0
  69. data/lib/datadog/di/base.rb +7 -2
  70. data/lib/datadog/di/code_tracker.rb +5 -2
  71. data/lib/datadog/di/component.rb +110 -36
  72. data/lib/datadog/di/error.rb +10 -0
  73. data/lib/datadog/di/fatal_exceptions.rb +26 -0
  74. data/lib/datadog/di/instrumenter.rb +381 -165
  75. data/lib/datadog/di/probe.rb +14 -0
  76. data/lib/datadog/di/probe_file_loader.rb +8 -3
  77. data/lib/datadog/di/probe_manager.rb +67 -9
  78. data/lib/datadog/di/probe_notification_builder.rb +4 -1
  79. data/lib/datadog/di/probe_notifier_worker.rb +52 -32
  80. data/lib/datadog/di/redactor.rb +16 -1
  81. data/lib/datadog/di/remote.rb +175 -14
  82. data/lib/datadog/di/serializer.rb +12 -5
  83. data/lib/datadog/di/transport/input.rb +8 -4
  84. data/lib/datadog/di.rb +63 -0
  85. data/lib/datadog/error_tracking/collector.rb +2 -1
  86. data/lib/datadog/error_tracking/component.rb +2 -2
  87. data/lib/datadog/kit/appsec/events/v2.rb +60 -2
  88. data/lib/datadog/kit/tracing/method_tracer.rb +4 -1
  89. data/lib/datadog/open_feature/component.rb +29 -6
  90. data/lib/datadog/open_feature/configuration.rb +8 -0
  91. data/lib/datadog/open_feature/ext.rb +2 -0
  92. data/lib/datadog/open_feature/flag_evaluation/aggregator.rb +286 -0
  93. data/lib/datadog/open_feature/flag_evaluation/writer.rb +433 -0
  94. data/lib/datadog/open_feature/hooks/flag_eval_evp_hook.rb +101 -0
  95. data/lib/datadog/open_feature/hooks/{flag_eval_hook.rb → flag_eval_metrics_hook.rb} +1 -1
  96. data/lib/datadog/open_feature/native_evaluator.rb +22 -0
  97. data/lib/datadog/open_feature/provider.rb +59 -27
  98. data/lib/datadog/open_feature/transport.rb +55 -1
  99. data/lib/datadog/opentelemetry/sdk/logs_exporter.rb +5 -12
  100. data/lib/datadog/opentelemetry/sdk/metrics_exporter.rb +4 -11
  101. data/lib/datadog/opentelemetry/sdk/propagator.rb +9 -3
  102. data/lib/datadog/opentelemetry/sdk/span_processor.rb +4 -1
  103. data/lib/datadog/opentelemetry/sdk.rb +19 -0
  104. data/lib/datadog/profiling/collectors/cpu_and_wall_time_worker.rb +1 -1
  105. data/lib/datadog/profiling/collectors/idle_sampling_helper.rb +5 -3
  106. data/lib/datadog/profiling/collectors/thread_context.rb +1 -0
  107. data/lib/datadog/profiling/component.rb +13 -15
  108. data/lib/datadog/profiling/ext/dir_monkey_patches.rb +3 -3
  109. data/lib/datadog/ruby_version.rb +25 -0
  110. data/lib/datadog/symbol_database/component.rb +453 -128
  111. data/lib/datadog/symbol_database/configuration.rb +12 -4
  112. data/lib/datadog/symbol_database/extensions.rb +19 -0
  113. data/lib/datadog/symbol_database/extractor.rb +326 -150
  114. data/lib/datadog/symbol_database/file_hash.rb +3 -1
  115. data/lib/datadog/symbol_database/remote.rb +8 -3
  116. data/lib/datadog/symbol_database/scope_batcher.rb +7 -3
  117. data/lib/datadog/symbol_database/uploader.rb +9 -2
  118. data/lib/datadog/symbol_database.rb +22 -0
  119. data/lib/datadog/tracing/configuration/ext.rb +13 -0
  120. data/lib/datadog/tracing/configuration/settings.rb +17 -0
  121. data/lib/datadog/tracing/contrib/configuration/resolver.rb +7 -0
  122. data/lib/datadog/tracing/contrib/grpc/distributed/propagation.rb +2 -0
  123. data/lib/datadog/tracing/contrib/grpc.rb +1 -0
  124. data/lib/datadog/tracing/contrib/http/distributed/propagation.rb +2 -0
  125. data/lib/datadog/tracing/contrib/http.rb +1 -0
  126. data/lib/datadog/tracing/contrib/karafka/distributed/propagation.rb +2 -0
  127. data/lib/datadog/tracing/contrib/karafka.rb +1 -0
  128. data/lib/datadog/tracing/contrib/rack/middlewares.rb +3 -1
  129. data/lib/datadog/tracing/contrib/rack/route_inference.rb +3 -1
  130. data/lib/datadog/tracing/contrib/sidekiq/distributed/propagation.rb +2 -0
  131. data/lib/datadog/tracing/contrib/sidekiq.rb +1 -0
  132. data/lib/datadog/tracing/contrib/waterdrop/distributed/propagation.rb +2 -0
  133. data/lib/datadog/tracing/contrib/waterdrop.rb +1 -0
  134. data/lib/datadog/tracing/distributed/baggage.rb +2 -1
  135. data/lib/datadog/tracing/distributed/propagation.rb +33 -1
  136. data/lib/datadog/tracing/distributed/trace_context.rb +11 -2
  137. data/lib/datadog/tracing/ext.rb +9 -0
  138. data/lib/datadog/tracing/remote.rb +34 -3
  139. data/lib/datadog/tracing/trace_digest.rb +7 -0
  140. data/lib/datadog/tracing/trace_operation.rb +4 -1
  141. data/lib/datadog/tracing/tracer.rb +1 -0
  142. data/lib/datadog/version.rb +1 -1
  143. data/lib/datadog.rb +5 -1
  144. metadata +22 -6
@@ -23,12 +23,28 @@ module Datadog
23
23
  def watch_request_action(gateway = Instrumentation.gateway)
24
24
  gateway.watch('rails.request.action') do |stack, gateway_request|
25
25
  context = gateway_request.env[AppSec::Ext::CONTEXT_KEY]
26
+ limit = Datadog.configuration.appsec.body_parsing_size_limit
26
27
 
27
28
  persistent_data = {
28
- 'server.request.body' => gateway_request.parsed_body,
29
29
  'server.request.path_params' => gateway_request.route_params
30
30
  }
31
31
 
32
+ unless limit.zero?
33
+ byte_length = gateway_request.body_bytesize(limit)
34
+
35
+ if byte_length
36
+ # NOTE: Params may be parsed before this hook, leaving the
37
+ # body stream at EOF. Keep byte_length unset for that
38
+ # case, but still inspect cached params
39
+ persistent_data['server.request.body.byte_length'] = byte_length if byte_length.positive?
40
+
41
+ if byte_length <= limit
42
+ body = gateway_request.parsed_body
43
+ persistent_data['server.request.body'] = body unless body.nil? || body.empty?
44
+ end
45
+ end
46
+ end
47
+
32
48
  result = context.run_waf(persistent_data, {}, Datadog.configuration.appsec.waf_timeout)
33
49
 
34
50
  if result.match?
@@ -3,8 +3,9 @@
3
3
  require_relative '../../event'
4
4
  require_relative '../../trace_keeper'
5
5
  require_relative '../../security_event'
6
- require_relative '../../utils/http/media_type'
7
6
  require_relative '../../utils/http/body'
7
+ require_relative '../../utils/http/body_reader'
8
+ require_relative '../../utils/http/media_type'
8
9
 
9
10
  module Datadog
10
11
  module AppSec
@@ -35,7 +36,7 @@ module Datadog
35
36
  end
36
37
 
37
38
  if !is_redirect && sample_body
38
- body = parse_body(payload.to_s, content_type: headers['content-type'])
39
+ body = parse_request_body(payload, content_type: headers['content-type'])
39
40
  ephemeral_data['server.io.net.request.body'] = body if body
40
41
  end
41
42
 
@@ -74,7 +75,7 @@ module Datadog
74
75
  context.state[:downstream_redirect_url] = URI.join(url, headers['location']).to_s if is_redirect && sample_body
75
76
 
76
77
  if sample_body && !is_redirect
77
- body = parse_body(response.body, content_type: headers['content-type'])
78
+ body = parse_response_body(response.body, headers: headers, context: context)
78
79
  ephemeral_data['server.io.net.response.body'] = body if body
79
80
  end
80
81
 
@@ -94,13 +95,65 @@ module Datadog
94
95
  true
95
96
  end
96
97
 
97
- def parse_body(body, content_type:)
98
- return if body.empty?
99
-
98
+ def parse_request_body(body, content_type:)
100
99
  media_type = Utils::HTTP::MediaType.parse(content_type)
101
100
  return unless media_type
102
101
 
103
- Utils::HTTP::Body.parse(body, media_type: media_type)
102
+ # NOTE: Request body analysis is best-effort, non-rewindable payloads are skipped
103
+ limit = Datadog.configuration.appsec.api_security.downstream_body_analysis.max_downstream_body_bytes
104
+ content = read_body(body, limit: limit)
105
+ return if content.nil? || content.bytesize > limit
106
+
107
+ Utils::HTTP::Body.parse(content, media_type: media_type)
108
+ end
109
+
110
+ def parse_response_body(body, headers:, context:)
111
+ return unless readable_body?(body)
112
+
113
+ media_type = Utils::HTTP::MediaType.parse(headers['content-type'])
114
+ if !media_type || media_type.type != 'application'
115
+ context.metrics.record_ignored_downstream_response_body(:content_type_invalid)
116
+ return
117
+ end
118
+
119
+ subtype = media_type.subtype
120
+ if subtype != 'json' && !subtype.end_with?('+json') && subtype != 'x-www-form-urlencoded'
121
+ context.metrics.record_ignored_downstream_response_body(:content_type_invalid)
122
+ return
123
+ end
124
+
125
+ content_length_value = headers['content-length']
126
+ if !content_length_value.is_a?(String) || !content_length_value.match?(/\A[1-9][0-9]*\z/)
127
+ context.metrics.record_ignored_downstream_response_body(:content_length_missing)
128
+ return
129
+ end
130
+
131
+ content_length = content_length_value.to_i
132
+ max = Datadog.configuration.appsec.api_security.downstream_body_analysis.max_downstream_body_bytes
133
+ if content_length > max
134
+ context.metrics.record_ignored_downstream_response_body(:content_length_too_big)
135
+ return
136
+ end
137
+
138
+ content = read_body(body, limit: content_length)
139
+ return if content.nil?
140
+
141
+ if content.bytesize > content_length
142
+ context.metrics.record_ignored_downstream_response_body(:content_exceed_content_length)
143
+ return
144
+ end
145
+
146
+ Utils::HTTP::Body.parse(content, media_type: media_type)
147
+ end
148
+
149
+ def readable_body?(body)
150
+ body.is_a?(String) || (body.respond_to?(:read) && body.respond_to?(:rewind))
151
+ end
152
+
153
+ def read_body(body, limit:)
154
+ Utils::HTTP::BodyReader.read(body, limit: limit, rewind_before_read: true)
155
+ rescue
156
+ nil
104
157
  end
105
158
 
106
159
  # NOTE: Starting version 2.1.0 headers are already normalized via internal
@@ -27,9 +27,30 @@ module Datadog
27
27
 
28
28
  context.state[:web_framework] = 'sinatra'
29
29
 
30
- persistent_data = {
31
- 'server.request.body' => gateway_request.form_hash
32
- }
30
+ request = gateway_request.request
31
+ next stack.call(request) unless gateway_request.collectable_body?
32
+
33
+ # NOTE: A limit of 0 disables request body collection entirely.
34
+ limit = Datadog.configuration.appsec.body_parsing_size_limit
35
+ next stack.call(request) if limit.zero?
36
+
37
+ persistent_data = {}
38
+ byte_length = gateway_request.body_bytesize(limit)
39
+
40
+ if byte_length
41
+ persistent_data['server.request.body.byte_length'] = byte_length
42
+
43
+ if byte_length <= limit
44
+ body = gateway_request.form_hash
45
+ persistent_data['server.request.body'] = body if body
46
+ end
47
+ # NOTE: Body was parsed before measurement, keep byte_length unset
48
+ elsif gateway_request.env.key?('rack.request.form_hash')
49
+ body = gateway_request.env['rack.request.form_hash']
50
+ persistent_data['server.request.body'] = body if body
51
+ end
52
+
53
+ next stack.call(request) if persistent_data.empty?
33
54
 
34
55
  result = context.run_waf(persistent_data, {}, Datadog.configuration.appsec.waf_timeout)
35
56
 
@@ -10,20 +10,24 @@ module Datadog
10
10
  #
11
11
  # @api private
12
12
  module DefaultHeaderTags
13
- WAF_VENDOR_HEADERS_TAGS = %w[
13
+ # NOTE: Contains additional WAF vendor headers
14
+ REQUEST_HEADERS_TAGS = %w[
15
+ accept
16
+ content-type
17
+ user-agent
18
+ akamai-user-risk
14
19
  x-amzn-trace-id
15
- cloudfront-viewer-ja3-fingerprint
16
- cf-ray
17
20
  x-cloud-trace-context
18
21
  x-appgw-trace-id
19
22
  x-sigsci-requestid
20
23
  x-sigsci-tags
21
- akamai-user-risk
24
+ cf-ray
25
+ cloudfront-viewer-ja3-fingerprint
22
26
  ].freeze
23
27
 
24
28
  RESPONSE_HEADERS_TAGS = %w[
25
- content-length
26
29
  content-type
30
+ content-length
27
31
  content-encoding
28
32
  content-language
29
33
  ].freeze
@@ -31,7 +35,7 @@ module Datadog
31
35
  module_function
32
36
 
33
37
  def tag_request(span, headers)
34
- WAF_VENDOR_HEADERS_TAGS.each do |name|
38
+ REQUEST_HEADERS_TAGS.each do |name|
35
39
  value = headers.get(name)
36
40
  span.set_tag("http.request.headers.#{name}", value) if value
37
41
  end
@@ -17,6 +17,7 @@ module Datadog
17
17
  EXPLOIT_PREVENTION_EVENT_CATEGORY = 'exploit'
18
18
 
19
19
  TAG_APPSEC_ENABLED = '_dd.appsec.enabled'
20
+ TAG_NORMALIZED_ROUTE = '_dd.appsec.normalized_route'
20
21
  TAG_METASTRUCT_STACK_TRACE = '_dd.stack'
21
22
 
22
23
  TELEMETRY_METRICS_NAMESPACE = 'appsec'
@@ -17,7 +17,15 @@ module Datadog
17
17
  keyword_init: true
18
18
  )
19
19
 
20
- attr_reader :waf, :rasp
20
+ DownstreamResponseStore = Struct.new(
21
+ :content_type_invalid,
22
+ :content_length_missing,
23
+ :content_length_too_big,
24
+ :content_exceed_content_length,
25
+ keyword_init: true
26
+ )
27
+
28
+ attr_reader :waf, :rasp, :downstream_responses
21
29
 
22
30
  def initialize
23
31
  @mutex = Mutex.new
@@ -31,6 +39,11 @@ module Datadog
31
39
  evals: 0, matches: 0, errors: 0, timeouts: 0, duration_ns: 0,
32
40
  duration_ext_ns: 0, inputs_truncated: 0, downstream_requests: 0
33
41
  )
42
+
43
+ @downstream_responses = DownstreamResponseStore.new(
44
+ content_type_invalid: 0, content_length_missing: 0,
45
+ content_length_too_big: 0, content_exceed_content_length: 0
46
+ )
34
47
  end
35
48
 
36
49
  def record_waf(result)
@@ -57,6 +70,10 @@ module Datadog
57
70
  @rasp.downstream_requests += 1 if type == Ext::RASP_SSRF && phase == Ext::RASP_REQUEST_PHASE
58
71
  end
59
72
  end
73
+
74
+ def record_ignored_downstream_response_body(reason)
75
+ @mutex.synchronize { @downstream_responses[reason] += 1 }
76
+ end
60
77
  end
61
78
  end
62
79
  end
@@ -3,7 +3,7 @@
3
3
  module Datadog
4
4
  module AppSec
5
5
  module Metrics
6
- # A class responsible for exporting WAF and RASP call metrics.
6
+ # A class responsible for exporting WAF and RASP call metrics
7
7
  module Exporter
8
8
  module_function
9
9
 
@@ -31,9 +31,15 @@ module Datadog
31
31
  end
32
32
  end
33
33
 
34
- # private
34
+ def export_downstream_response_metrics(metrics, span)
35
+ metrics.each_pair do |reason, count|
36
+ next if count.zero?
35
37
 
36
- def convert_ns_to_us(value)
38
+ span.set_tag("_dd.appsec.downstream_request.response_body_ignored.#{reason}", count)
39
+ end
40
+ end
41
+
42
+ private_class_method def convert_ns_to_us(value)
37
43
  value / 1000.0
38
44
  end
39
45
  end
@@ -78,17 +78,23 @@ module Datadog
78
78
  engine = AppSec.security_engine
79
79
  next unless engine
80
80
 
81
+ # We must process delete operations before insert and update operations
82
+ # to prevent duplicate-rule errors when the config name changes
83
+ changes.each do |change|
84
+ next unless change.type == :delete
85
+
86
+ engine.remove_config_at_path(change.path.to_s)
87
+ end
88
+
81
89
  changes.each do |change|
82
90
  content = repository[change.path]
83
- next unless content || change.type == :delete
91
+ next unless content
84
92
 
85
93
  case change.type
86
94
  when :insert, :update
87
95
  # @type var content: Core::Remote::Configuration::Content
88
96
  engine.add_or_update_config(parse_content(content), path: change.path.to_s)
89
97
  content.applied
90
- when :delete
91
- engine.remove_config_at_path(change.path.to_s)
92
98
  end
93
99
  end
94
100
 
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'route_text'
4
+ require_relative 'route_pattern'
5
+
6
+ module Datadog
7
+ module AppSec
8
+ module RouteNormalizer
9
+ # Normalizes Rails route patterns into route format, inspired by
10
+ # OpenAPI v3 path templating
11
+ #
12
+ # NOTE: Uses the parsed Journey AST when available
13
+ #
14
+ # @api private
15
+ class RailsRoutePattern
16
+ DOT_CHAR = '.'
17
+ GROUP_OPEN_CHAR = '('
18
+ OPTIONAL_GROUP_PATTERN = /\(([^()]*)\)/
19
+ NAMED_PARAM_PREFIX_CHAR = ':'
20
+ GLOB_PARAM_PREFIX_CHAR = '*'
21
+
22
+ def initialize(pattern)
23
+ @pattern = pattern
24
+ end
25
+
26
+ def normalize(path_params:, path:)
27
+ @path_params = path_params
28
+ @path = path
29
+
30
+ if @pattern.is_a?(String)
31
+ # NOTE: Journey groups without route params are never kept
32
+ # Example: `/foo(/bar)` with request `/foo/bar` normalizes to `/foo`
33
+ pattern = remove_paramless_optional_groups(@pattern)
34
+ return RoutePattern.new(pattern).normalize(path: path)
35
+ end
36
+
37
+ route_path = @pattern.path
38
+ route_spec = route_path.spec
39
+
40
+ unless route_spec.respond_to?(:type)
41
+ return RoutePattern.new(route_spec.to_s).normalize(path: path)
42
+ end
43
+
44
+ if route_path.names.empty?
45
+ route_string = route_spec.to_s
46
+ return RouteText.escape(route_string) unless route_string.include?(GROUP_OPEN_CHAR)
47
+ end
48
+
49
+ buffer = Buffer.new
50
+ visit_route_node(route_spec, buffer)
51
+ buffer.to_path
52
+ end
53
+
54
+ private
55
+
56
+ def remove_paramless_optional_groups(pattern)
57
+ result = pattern
58
+
59
+ loop do
60
+ substituted = result.gsub(OPTIONAL_GROUP_PATTERN) do
61
+ # NOTE: OPTIONAL_GROUP_PATTERN always captures a string for each gsub match
62
+ group = ::Regexp.last_match(1) # : String
63
+ optional_group_has_route_params?(group) ? "(#{group})" : ''
64
+ end
65
+
66
+ return result if substituted == result
67
+
68
+ result = substituted
69
+ end
70
+ end
71
+
72
+ def visit_route_node(node, buffer)
73
+ case node.type
74
+ when :CAT
75
+ visit_route_node(node.left, buffer)
76
+ visit_route_node(node.right, buffer)
77
+ when :SLASH
78
+ buffer.flush
79
+ when :LITERAL
80
+ buffer.add_literal(node.left)
81
+ when :DOT
82
+ buffer.add_literal(DOT_CHAR)
83
+ when :SYMBOL, :STAR
84
+ buffer.add_param(node.name)
85
+ when :GROUP
86
+ visit_route_node(node.left, buffer) if group_present?(node.left)
87
+ end
88
+ end
89
+
90
+ def group_present?(node)
91
+ param_names = collect_group_param_names(node, [])
92
+ return false if param_names.empty?
93
+
94
+ param_names.all? { |name| param_matched_path?(name.to_sym) }
95
+ end
96
+
97
+ def param_matched_path?(name)
98
+ return @path_params[name].is_a?(String) unless name == :format
99
+
100
+ format = @path_params[:format]
101
+ return false if !format.is_a?(String) || format.empty?
102
+
103
+ dot_index = @path.length - format.length - 1
104
+ return false if dot_index < 0 || @path[dot_index] != DOT_CHAR
105
+
106
+ @path.end_with?(format)
107
+ end
108
+
109
+ def collect_group_param_names(node, memo)
110
+ case node.type
111
+ when :SYMBOL, :STAR
112
+ memo << node.name
113
+ when :CAT
114
+ collect_group_param_names(node.left, memo)
115
+ collect_group_param_names(node.right, memo)
116
+ end
117
+
118
+ memo
119
+ end
120
+
121
+ def optional_group_has_route_params?(group)
122
+ group.include?(NAMED_PARAM_PREFIX_CHAR) || group.include?(GLOB_PARAM_PREFIX_CHAR)
123
+ end
124
+
125
+ class Buffer
126
+ def initialize
127
+ @segments = []
128
+ @text = +''
129
+ @params = []
130
+ @nameless_param_count = 0
131
+ end
132
+
133
+ def add_literal(text)
134
+ @text << text
135
+ end
136
+
137
+ def add_param(name)
138
+ @params << name
139
+ end
140
+
141
+ def flush
142
+ return if @text.empty? && @params.empty?
143
+
144
+ @segments << if @params.empty?
145
+ RouteText.escape(@text)
146
+ else
147
+ render_params(@params)
148
+ end
149
+
150
+ @text = +''
151
+ @params.clear
152
+ end
153
+
154
+ def to_path
155
+ flush
156
+
157
+ "/#{@segments.join('/')}"
158
+ end
159
+
160
+ private
161
+
162
+ def render_params(params)
163
+ names = params.map do |name| # $ String
164
+ next name unless name.empty?
165
+
166
+ @nameless_param_count += 1
167
+ "param#{@nameless_param_count}"
168
+ end
169
+
170
+ "{#{names.join('+')}}"
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end