prauga-flexdoc 0.4.7 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efc2b18bbb41bb01b8f97489c9ef029bccca00456403d0ac2976b1ac3f1c0cd7
4
- data.tar.gz: 82fdc6f5d6938c7e2354adeca9d24243f14819ce2be9713fe844f425e4b04f38
3
+ metadata.gz: 2cfa79303d5ae57884ca1c3fc8157eecf7852a764c04edeece1f72302cb9f881
4
+ data.tar.gz: e2946cba95ac973864b0f81e8b0e9a46165fa1685b2bff3b1bfa7e3b07e8bda3
5
5
  SHA512:
6
- metadata.gz: 5e3482cdbc2905f508ac91edf0fddcaf1f9e80827b39f8a66b344c90927b4d278968844725bc91ab838f289a820a67ccad2eb890f4cae075e2611d673b5366d0
7
- data.tar.gz: 1d533bffac5d5e3c400f17259b38f12fb903e2b3c37c70936644eeecd5a942d5d1a5a806926b79ac0687ed59ec989cd8e2e58c39fdf9e5300a2c4ddac37026b7
6
+ metadata.gz: fe41dcf5a51516ac2993bd1ef08e6c698d9f9998c53c856975023c0a2000a017fa0cdbb94417eb415f5035f1b7b86dd16d7eaf279d0a7db81ec504607250db66
7
+ data.tar.gz: 164d984ad22590f7dafd522888de0ab3d93d46a533f355dbb2ae602383137153fe674767d7f651f760478429666eea833beebf54b5e57b9f281d3dd462642754
data/README.md CHANGED
@@ -53,6 +53,28 @@ This first native slice supports the canonical JSON and multipart envelopes, Bas
53
53
 
54
54
  The Ruby executor blocks link-local/cloud-metadata targets and validates DNS results before connecting. It then pins `Net::HTTP` to one of the validated addresses with `ipaddr=` while retaining the original hostname for the HTTP `Host` header and TLS SNI/certificate verification. Environment proxy routing is disabled for native execution, so the validated destination cannot be bypassed through `http_proxy`/`HTTP_PROXY`. Private-network relaxation is not part of this slice.
55
55
 
56
+ ### Execution evidence
57
+
58
+ An executor that reports nothing leaves an operator guessing whether a failing Try It is a policy rejection, a slow upstream or traffic that never carried an execute marker. Pass a metric sink to emit the same metric names, labels and reason vocabulary as the Node, Python, Go and Rust hosts, so one collector reads a mixed fleet:
59
+
60
+ ```ruby
61
+ observation = Prauga::FlexDoc::HostExecutionObservation.new
62
+
63
+ executor = Prauga::FlexDoc::HostExecution.new(
64
+ allowed_origins: ["https://api.example.internal"],
65
+ metric_sink: observation.sink
66
+ )
67
+
68
+ # Whenever an operator asks for evidence:
69
+ report = Prauga::FlexDoc.host_execution_observation_report(observation)
70
+ ```
71
+
72
+ The sink receives `HostExecutionMetric` values carrying a name, kind, value and labels, and nothing else: no URL, header, body or credential reaches it. Bridge it to Prometheus or OpenTelemetry where such a stack exists; where none does, `HostExecutionObservation` folds the same updates into a mutex-guarded aggregate safe to share across threaded or forked-with-threads servers, and `host_execution_observation_report` produces the shared `flexdoc.host-execution.observation/1` document every other runtime also emits. Under a forking server each worker keeps its own window, so treat the export as per-process evidence.
73
+
74
+ Every non-successful execution carries one of the stable categories in `HostExecutionObservability::HOST_EXECUTION_REASONS`, which is why rejections and upstream failures are separable at all — the human-readable messages interpolate origins and field names, so they are unbounded and unusable as a metric label. Requests arriving without `X-FlexDoc-Execute` are counted by `flexdoc_execute_unmarked_total` and deliberately move no lifecycle metric, since they produced no validated envelope.
75
+
76
+ The report declares `browser-direct-transport-mix` in its gaps: a browser-direct execution never reaches this process, so the transport mix cannot be derived here. See [host-execution observability](../../docs/host-execution-observability.md) for the full metric contract and the browser half of a review. Metric delivery is best effort: a sink that raises cannot fail an execution.
77
+
56
78
  ## Rails
57
79
 
58
80
  In `config/routes.rb`:
@@ -9,6 +9,8 @@ require "socket"
9
9
  require "timeout"
10
10
  require "uri"
11
11
 
12
+ require_relative "host_execution_observability"
13
+
12
14
  module Prauga
13
15
  module FlexDoc
14
16
  HostExecutionFile = Data.define(:filename, :content_type, :data)
@@ -33,7 +35,8 @@ module Prauga
33
35
 
34
36
  attr_reader :allowed_origins
35
37
 
36
- def initialize(allowed_origins:)
38
+ def initialize(allowed_origins:, metric_sink: nil)
39
+ @metric_sink = metric_sink
37
40
  normalized = Array(allowed_origins).filter_map do |raw|
38
41
  value = raw.to_s.strip
39
42
  next if value.empty?
@@ -52,38 +55,90 @@ module Prauga
52
55
  @allowed_origins = normalized.uniq.freeze
53
56
  end
54
57
 
58
+ attr_reader :metric_sink
59
+
55
60
  def capabilities
56
61
  []
57
62
  end
58
63
 
64
+ # Validate the marker and map executor failures to canonical JSON errors.
65
+ #
66
+ # This is the single choke point every transport reaches, so it is also where
67
+ # execution evidence is emitted: one started/completed pair per validated
68
+ # envelope, and an unmarked counter for requests that never became executions.
59
69
  def handle(marker:, envelope:, files: {})
60
- return HostExecutionResult.new(status: 403, body: { "error" => "Missing X-FlexDoc-Execute header." }) unless marker == "1"
70
+ unless marker == "1"
71
+ # Counted outside the lifecycle: an unmarked request never became an
72
+ # execution, and folding it into rejections would double-count attempts.
73
+ metric("flexdoc_execute_unmarked_total", "counter", 1, { "reason" => "marker-missing" })
74
+ return HostExecutionResult.new(status: 403, body: { "error" => "Missing X-FlexDoc-Execute header." })
75
+ end
61
76
 
62
- HostExecutionResult.new(status: 200, body: execute(envelope, files))
63
- rescue ExecutionError => error
64
- HostExecutionResult.new(status: error.status, body: { "error" => error.message })
77
+ metric("flexdoc_execute_requests_total", "counter", 1)
78
+ metric("flexdoc_execute_in_flight", "gauge", 1)
79
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
80
+ begin
81
+ body = execute(envelope, files)
82
+ rescue ExecutionError => error
83
+ complete(error.status >= 500 ? "error" : "rejected", started, error.reason, error.status)
84
+ return HostExecutionResult.new(status: error.status, body: { "error" => error.message })
85
+ rescue StandardError
86
+ complete("error", started, "upstream-error", 502)
87
+ raise
88
+ end
89
+ complete("success", started)
90
+ HostExecutionResult.new(status: 200, body:)
65
91
  end
66
92
 
67
93
  private
68
94
 
69
95
  class ExecutionError < StandardError
70
- attr_reader :status
96
+ attr_reader :status, :reason
71
97
 
72
- def initialize(status, message)
98
+ # Messages interpolate origins, field names and methods, so they are
99
+ # unbounded and cannot be aggregated. The reason can, and it matches the
100
+ # Node, Python, Go and Rust vocabulary exactly.
101
+ def initialize(status, message, reason)
73
102
  @status = status
103
+ @reason = reason
74
104
  super(message)
75
105
  end
76
106
  end
77
107
 
78
- def bad_request(message) = raise(ExecutionError.new(400, message))
79
- def forbidden(message) = raise(ExecutionError.new(403, message))
80
- def upstream(message) = raise(ExecutionError.new(502, message))
108
+ def bad_request(message, reason = "request-invalid") = raise(ExecutionError.new(400, message, reason))
109
+ def unsupported(message, reason = "auth-unsupported") = raise(ExecutionError.new(400, message, reason))
110
+ def forbidden(message, reason = "destination-forbidden") = raise(ExecutionError.new(403, message, reason))
111
+ def upstream(message, reason = "upstream-error") = raise(ExecutionError.new(502, message, reason))
112
+
113
+ def complete(outcome, started, reason = nil, status = nil)
114
+ metric("flexdoc_execute_in_flight", "gauge", -1)
115
+ metric("flexdoc_execute_completions_total", "counter", 1, { "outcome" => outcome })
116
+ elapsed = [Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, 0.0].max
117
+ metric("flexdoc_execute_duration_seconds", "histogram", elapsed, { "outcome" => outcome })
118
+ return if reason.nil?
119
+
120
+ if outcome == "rejected"
121
+ metric("flexdoc_execute_rejections_total", "counter", 1,
122
+ { "source" => "route", "statusCode" => (status || 400).to_s, "reason" => reason })
123
+ else
124
+ metric("flexdoc_execute_errors_total", "counter", 1, { "reason" => reason })
125
+ end
126
+ end
127
+
128
+ def metric(name, kind, value, labels = {})
129
+ return if @metric_sink.nil?
130
+
131
+ @metric_sink.call(HostExecutionMetric.new(name:, kind:, value:, labels:))
132
+ rescue StandardError
133
+ # Observability must never decide whether an execution succeeds.
134
+ nil
135
+ end
81
136
 
82
137
  def execute(envelope, files)
83
- root = envelope.is_a?(Hash) ? envelope : bad_request("Host execution body must be a JSON object.")
84
- bad_request("Session cookie jars are not implemented by the Ruby host executor.") if root["cookieJar"] == "session"
138
+ root = envelope.is_a?(Hash) ? envelope : bad_request("Host execution body must be a JSON object.", "body-malformed")
139
+ unsupported("Session cookie jars are not implemented by the Ruby host executor.") if root["cookieJar"] == "session"
85
140
  certificate_id = root["certificateId"].to_s
86
- bad_request("Client certificates are not implemented by the Ruby host executor.") unless certificate_id.strip.empty?
141
+ unsupported("Client certificates are not implemented by the Ruby host executor.") unless certificate_id.strip.empty?
87
142
 
88
143
  draft = root["request"]
89
144
  bad_request("Host execution body requires a canonical request draft.") unless draft.is_a?(Hash)
@@ -123,9 +178,9 @@ module Prauga
123
178
  status = response[:status]
124
179
 
125
180
  if status.between?(300, 399) && response[:location]
126
- forbidden("Host execution exceeded the redirect safety limit.") if redirect_count == MAX_REDIRECTS
181
+ forbidden("Host execution exceeded the redirect safety limit.", "redirect-forbidden") if redirect_count == MAX_REDIRECTS
127
182
  next_uri = request_uri.merge(response[:location])
128
- forbidden("Host execution does not follow cross-origin redirects.") unless origin_of(next_uri) == origin_of(request_uri)
183
+ forbidden("Host execution does not follow cross-origin redirects.", "redirect-forbidden") unless origin_of(next_uri) == origin_of(request_uri)
129
184
  assert_allowed!(next_uri)
130
185
  if status == 303
131
186
  method = "GET"
@@ -146,13 +201,13 @@ module Prauga
146
201
  }
147
202
  end
148
203
  end
149
- forbidden("Host execution exceeded the redirect safety limit.")
204
+ forbidden("Host execution exceeded the redirect safety limit.", "redirect-forbidden")
150
205
  rescue Timeout::Error
151
- upstream("Host execution request timed out after #{timeout_ms} ms.")
206
+ upstream("Host execution request timed out after #{timeout_ms} ms.", "upstream-timeout")
152
207
  rescue ExecutionError
153
208
  raise
154
209
  rescue StandardError => error
155
- upstream("Host execution request failed: #{error.message}")
210
+ upstream("Host execution request failed: #{error.message}", "upstream-unreachable")
156
211
  end
157
212
 
158
213
  def perform_request(method, uri, headers, body, timeout_ms, validated_ip)
@@ -188,7 +243,7 @@ module Prauga
188
243
  end
189
244
  response.read_body do |chunk|
190
245
  if response_body.bytesize + chunk.bytesize > MAX_EXECUTION_RESPONSE_BYTES
191
- upstream("Host execution response exceeded the 10 MiB safety limit.")
246
+ upstream("Host execution response exceeded the 10 MiB safety limit.", "body-too-large")
192
247
  end
193
248
  response_body << chunk.b
194
249
  end
@@ -212,14 +267,14 @@ module Prauga
212
267
  end
213
268
 
214
269
  addresses = Addrinfo.getaddrinfo(host, uri.port, nil, :STREAM)
215
- upstream("Host execution could not resolve target hostname.") if addresses.empty?
270
+ upstream("Host execution could not resolve target hostname.", "upstream-unreachable") if addresses.empty?
216
271
  if addresses.any? { |address| metadata_ip?(address.ip_address) }
217
272
  forbidden("Host execution blocks DNS resolutions to link-local and cloud metadata endpoints.")
218
273
  end
219
274
 
220
275
  addresses.first.ip_address
221
276
  rescue SocketError
222
- upstream("Host execution could not resolve target hostname.")
277
+ upstream("Host execution could not resolve target hostname.", "upstream-unreachable")
223
278
  end
224
279
 
225
280
  def parse_http_uri(raw)
@@ -301,12 +356,12 @@ module Prauga
301
356
  when "query"
302
357
  nil
303
358
  when "cookie"
304
- bad_request("Cookie authentication is not implemented by the Ruby host executor.")
359
+ unsupported("Cookie authentication is not implemented by the Ruby host executor.")
305
360
  else
306
361
  bad_request("Unsupported API key location: #{location}")
307
362
  end
308
363
  else
309
- bad_request("Authentication type #{string_value(auth["type"])} is not implemented by the Ruby host executor.")
364
+ unsupported("Authentication type #{string_value(auth["type"])} is not implemented by the Ruby host executor.")
310
365
  end
311
366
  end
312
367
 
@@ -354,7 +409,7 @@ module Prauga
354
409
  when "formdata"
355
410
  build_multipart(draft, files)
356
411
  else
357
- bad_request("Body mode #{mode} is not implemented by the Ruby host executor.")
412
+ unsupported("Body mode #{mode} is not implemented by the Ruby host executor.")
358
413
  end
359
414
  rescue ArgumentError
360
415
  bad_request("Binary host execution bodyBase64 is invalid.") if mode == "binary"
@@ -376,7 +431,7 @@ module Prauga
376
431
  bad_request("Host execution multipart file formData[#{index}] is missing.") unless file
377
432
  filename = non_empty(file.filename.to_s) || non_empty(string_value(entry["fileName"])) || "upload.bin"
378
433
  content_type = non_empty(file.content_type.to_s) || non_empty(string_value(entry["contentType"])) || "application/octet-stream"
379
- bad_request("Host execution multipart Content-Type is invalid.") if content_type.include?("\r") || content_type.include?("\n")
434
+ bad_request("Host execution multipart Content-Type is invalid.", "unsupported-media-type") if content_type.include?("\r") || content_type.include?("\n")
380
435
  output << "--#{boundary}\r\n"
381
436
  output << "Content-Disposition: form-data; name=\"#{quote_multipart(key)}\"; filename=\"#{quote_multipart(filename)}\"\r\n"
382
437
  output << "Content-Type: #{content_type}\r\n\r\n"
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Prauga
6
+ module FlexDoc
7
+ # Execution evidence for the Ruby host executor.
8
+ #
9
+ # This mirrors the Node, Python, Go and Rust contract deliberately: the same
10
+ # reason vocabulary, the same metric names and labels, and the same export
11
+ # schema. An operator running a mixed fleet should read one document shape
12
+ # regardless of which runtime served the execute route, and a collector
13
+ # written for one runtime should not need a second parser for another.
14
+ module HostExecutionObservability
15
+ # Stable low-cardinality categories for a non-successful execution.
16
+ #
17
+ # Rejection messages interpolate request values such as origins, field
18
+ # names and methods, so they are unbounded and cannot be used as a metric
19
+ # label. These can.
20
+ HOST_EXECUTION_REASONS = %w[
21
+ marker-missing
22
+ execution-disabled
23
+ admission-saturated
24
+ destination-forbidden
25
+ redirect-forbidden
26
+ body-malformed
27
+ body-too-large
28
+ unsupported-media-type
29
+ request-invalid
30
+ auth-unsupported
31
+ upstream-timeout
32
+ upstream-unreachable
33
+ upstream-error
34
+ ].freeze
35
+
36
+ OBSERVATION_SCHEMA = "flexdoc.host-execution.observation/1"
37
+
38
+ # Evidence an API host cannot observe by itself, declared rather than omitted.
39
+ OBSERVATION_GAPS = %w[browser-direct-transport-mix].freeze
40
+
41
+ OUTCOMES = %w[success rejected error].freeze
42
+
43
+ def self.host_execution_reason?(value)
44
+ HOST_EXECUTION_REASONS.include?(value)
45
+ end
46
+ end
47
+
48
+ # One dependency-free metric update that can be bridged to Prometheus or
49
+ # OpenTelemetry without FlexDoc owning a registry.
50
+ HostExecutionMetric = Data.define(:name, :kind, :value, :labels) do
51
+ def initialize(name:, kind:, value:, labels: {})
52
+ super
53
+ end
54
+ end
55
+
56
+ # Aggregates host-execution evidence for one window, free of request content.
57
+ #
58
+ # Only counters and durations are retained. No URL, header, body, credential
59
+ # or per-request timestamp reaches this recorder, so the aggregate cannot
60
+ # carry request content by construction.
61
+ #
62
+ # Durations are retained up to +duration_sample_capacity+ and then replaced by
63
+ # reservoir sampling, so memory stays bounded for a host that runs
64
+ # indefinitely while percentiles stay representative of the whole window
65
+ # rather than only its opening. Counts stay exact regardless.
66
+ class HostExecutionObservation
67
+ def initialize(duration_sample_capacity: 8192, clock: -> { Time.now }, random: -> { Kernel.rand })
68
+ @capacity = [duration_sample_capacity.to_i, 1].max
69
+ @clock = clock
70
+ @random = random
71
+ @mutex = Mutex.new
72
+ reset
73
+ end
74
+
75
+ # Discard all counts and start a new window.
76
+ def reset
77
+ @mutex.synchronize { clear_state }
78
+ nil
79
+ end
80
+
81
+ # Fold one metric update into the aggregate. Safe to pass as a metric sink
82
+ # from concurrent request handlers.
83
+ def record(metric)
84
+ @mutex.synchronize do
85
+ timestamp = @clock.call
86
+ @window_start ||= timestamp
87
+ @window_end = timestamp
88
+
89
+ case metric.name
90
+ when "flexdoc_execute_requests_total"
91
+ @started += 1
92
+ when "flexdoc_execute_in_flight"
93
+ @in_flight = [@in_flight + metric.value.to_i, 0].max
94
+ @peak_in_flight = [@peak_in_flight, @in_flight].max
95
+ when "flexdoc_execute_completions_total"
96
+ @completed += 1
97
+ outcome = metric.labels["outcome"] || metric.labels[:outcome]
98
+ @outcomes[outcome] += 1 if @outcomes.key?(outcome)
99
+ when "flexdoc_execute_rejections_total"
100
+ tally(@rejections, metric.labels["reason"] || metric.labels[:reason])
101
+ when "flexdoc_execute_unmarked_total"
102
+ # Deliberately not folded into rejections: those describe validated
103
+ # envelopes, and merging the two would double-count attempts.
104
+ @unmarked += 1
105
+ when "flexdoc_execute_errors_total"
106
+ tally(@errors, metric.labels["reason"] || metric.labels[:reason])
107
+ when "flexdoc_execute_duration_seconds"
108
+ record_duration(metric.value.to_f)
109
+ end
110
+ end
111
+ nil
112
+ end
113
+
114
+ # A callable sink usable directly as the executor's +metric_sink+.
115
+ def sink
116
+ method(:record)
117
+ end
118
+
119
+ # Current aggregate; safe to call at any time.
120
+ def snapshot
121
+ @mutex.synchronize do
122
+ ordered = @durations.sort
123
+ {
124
+ "windowStart" => @window_start&.utc&.iso8601(3),
125
+ "windowEnd" => @window_end&.utc&.iso8601(3),
126
+ "startedExecutions" => @started,
127
+ "unmarkedRequests" => @unmarked,
128
+ "completedExecutions" => @completed,
129
+ "outcomes" => @outcomes.dup,
130
+ "inFlight" => @in_flight,
131
+ "peakInFlight" => @peak_in_flight,
132
+ "rejectionsByReason" => @rejections.dup,
133
+ "errorsByReason" => @errors.dup,
134
+ "durations" => duration_summary(ordered)
135
+ }
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def clear_state
142
+ @window_start = nil
143
+ @window_end = nil
144
+ @started = 0
145
+ @unmarked = 0
146
+ @completed = 0
147
+ @in_flight = 0
148
+ @peak_in_flight = 0
149
+ @observed_durations = 0
150
+ @outcomes = HostExecutionObservability::OUTCOMES.to_h { |outcome| [outcome, 0] }
151
+ @rejections = {}
152
+ @errors = {}
153
+ @durations = []
154
+ end
155
+
156
+ def tally(counts, reason)
157
+ return unless HostExecutionObservability.host_execution_reason?(reason)
158
+
159
+ counts[reason] = counts.fetch(reason, 0) + 1
160
+ end
161
+
162
+ def record_duration(seconds)
163
+ milliseconds = [seconds * 1000, 0.0].max
164
+ @observed_durations += 1
165
+ if @durations.length < @capacity
166
+ @durations << milliseconds
167
+ return
168
+ end
169
+ candidate = (@random.call * @observed_durations).to_i
170
+ @durations[candidate] = milliseconds if candidate < @capacity
171
+ end
172
+
173
+ def duration_summary(ordered)
174
+ return nil if ordered.empty?
175
+
176
+ {
177
+ "sampleCount" => ordered.length,
178
+ "sampled" => @observed_durations > ordered.length,
179
+ "minMs" => ordered.first,
180
+ "p50Ms" => percentile(ordered, 0.5),
181
+ "p95Ms" => percentile(ordered, 0.95),
182
+ "p99Ms" => percentile(ordered, 0.99),
183
+ "maxMs" => ordered.last
184
+ }
185
+ end
186
+
187
+ def percentile(ordered, fraction)
188
+ return ordered.first if ordered.length == 1
189
+
190
+ rank = (fraction * ordered.length).ceil.clamp(1, ordered.length)
191
+ ordered[rank - 1]
192
+ end
193
+ end
194
+
195
+ # Build the operator export document for a host-execution observation.
196
+ #
197
+ # The document is aggregate-only and is meant to be written to disk or handed
198
+ # to an operator; FlexDoc never transmits it. Browser-direct executions never
199
+ # reach an API host, so the transport mix cannot be derived here, and that gap
200
+ # is declared so a review cannot mistake this document for complete evidence.
201
+ def self.host_execution_observation_report(observation, generated_at: nil)
202
+ {
203
+ "schema" => HostExecutionObservability::OBSERVATION_SCHEMA,
204
+ "generatedAt" => generated_at || Time.now.utc.iso8601(3),
205
+ "runtime" => "ruby",
206
+ "observation" => observation.snapshot,
207
+ "gaps" => HostExecutionObservability::OBSERVATION_GAPS.dup
208
+ }
209
+ end
210
+ end
211
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Prauga
4
4
  module FlexDoc
5
- VERSION = "0.4.7"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prauga-flexdoc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.7
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Prauga
@@ -113,6 +113,7 @@ files:
113
113
  - lib/prauga/flexdoc/config.rb
114
114
  - lib/prauga/flexdoc/host.rb
115
115
  - lib/prauga/flexdoc/host_execution.rb
116
+ - lib/prauga/flexdoc/host_execution_observability.rb
116
117
  - lib/prauga/flexdoc/rack_app.rb
117
118
  - lib/prauga/flexdoc/rails.rb
118
119
  - lib/prauga/flexdoc/response.rb