specy_docs 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.
@@ -0,0 +1,383 @@
1
+ :root {
2
+ --bg: #0f1419;
3
+ --surface: #1a222c;
4
+ --surface-2: #243040;
5
+ --border: #2f3d4d;
6
+ --text: #e7eef6;
7
+ --muted: #8fa3b8;
8
+ --accent: #5ec8c0;
9
+ --get: #3d9a6a;
10
+ --put: #3b7dd8;
11
+ --post: #c48a2a;
12
+ --delete: #c45a5a;
13
+ --status-success: #2f8f5b;
14
+ --status-redirect: #3b7dd8;
15
+ --status-client-error: #c48a2a;
16
+ --status-server-error: #c45a5a;
17
+ --status-unknown: #6b7c8f;
18
+ --font: "IBM Plex Sans", "Segoe UI", sans-serif;
19
+ --mono: "IBM Plex Mono", "SF Mono", Menlo, monospace;
20
+ }
21
+
22
+ * {
23
+ box-sizing: border-box;
24
+ }
25
+
26
+ body {
27
+ margin: 0;
28
+ min-height: 100vh;
29
+ font-family: var(--font);
30
+ color: var(--text);
31
+ background:
32
+ radial-gradient(circle at top left, #1b2a33 0%, transparent 40%),
33
+ var(--bg);
34
+ }
35
+
36
+ .layout {
37
+ display: grid;
38
+ grid-template-columns: minmax(260px, 320px) 1fr;
39
+ min-height: 100vh;
40
+ }
41
+
42
+ .sidebar {
43
+ border-right: 1px solid var(--border);
44
+ background: color-mix(in srgb, var(--surface) 92%, black);
45
+ padding: 1.5rem 1rem;
46
+ }
47
+
48
+ .sidebar__header {
49
+ padding: 0 0.5rem 1.25rem;
50
+ border-bottom: 1px solid var(--border);
51
+ margin-bottom: 1rem;
52
+ }
53
+
54
+ .eyebrow {
55
+ margin: 0 0 0.35rem;
56
+ color: var(--accent);
57
+ font-size: 0.75rem;
58
+ letter-spacing: 0.08em;
59
+ text-transform: uppercase;
60
+ }
61
+
62
+ .sidebar__header h1 {
63
+ margin: 0;
64
+ font-size: 1.25rem;
65
+ font-weight: 600;
66
+ }
67
+
68
+ .endpoint-list {
69
+ display: flex;
70
+ flex-direction: column;
71
+ gap: 1.25rem;
72
+ }
73
+
74
+ .endpoint-group__title {
75
+ margin: 0 0 0.45rem;
76
+ padding: 0 0.5rem;
77
+ color: var(--muted);
78
+ font-size: 0.72rem;
79
+ font-weight: 700;
80
+ letter-spacing: 0.08em;
81
+ text-decoration: underline;
82
+ text-underline-offset: 0.2em;
83
+ text-transform: uppercase;
84
+ }
85
+
86
+ .endpoint-group__list {
87
+ display: flex;
88
+ flex-direction: column;
89
+ gap: 0.35rem;
90
+ }
91
+
92
+ .endpoint-button {
93
+ appearance: none;
94
+ width: 100%;
95
+ text-align: left;
96
+ border: 1px solid transparent;
97
+ border-radius: 0.5rem;
98
+ background: transparent;
99
+ color: var(--text);
100
+ padding: 0.7rem 0.75rem;
101
+ cursor: pointer;
102
+ font: inherit;
103
+ }
104
+
105
+ .endpoint-button:hover,
106
+ .endpoint-button[aria-current="true"] {
107
+ background: var(--surface-2);
108
+ border-color: var(--border);
109
+ }
110
+
111
+ .endpoint-button__path {
112
+ display: block;
113
+ font-family: var(--mono);
114
+ font-size: 0.82rem;
115
+ word-break: break-all;
116
+ }
117
+
118
+ .endpoint-button__methods {
119
+ display: flex;
120
+ flex-wrap: wrap;
121
+ gap: 0.3rem;
122
+ margin-top: 0.45rem;
123
+ }
124
+
125
+ .main {
126
+ padding: 2rem;
127
+ }
128
+
129
+ .status {
130
+ color: var(--muted);
131
+ }
132
+
133
+ .detail[hidden] {
134
+ display: none;
135
+ }
136
+
137
+ .detail__path {
138
+ margin: 0 0 1.5rem;
139
+ font-family: var(--mono);
140
+ font-size: 1.35rem;
141
+ word-break: break-all;
142
+ }
143
+
144
+ .method-tabs {
145
+ border: 1px solid var(--border);
146
+ border-radius: 0.75rem;
147
+ background: var(--surface);
148
+ overflow: hidden;
149
+ }
150
+
151
+ .method-tabs__list {
152
+ display: flex;
153
+ flex-wrap: wrap;
154
+ gap: 0.35rem;
155
+ padding: 0.65rem 0.75rem 0;
156
+ border-bottom: 1px solid var(--border);
157
+ background: color-mix(in srgb, var(--surface-2) 70%, transparent);
158
+ }
159
+
160
+ .method-tabs__tab {
161
+ appearance: none;
162
+ display: inline-flex;
163
+ align-items: center;
164
+ gap: 0.45rem;
165
+ margin-bottom: -1px;
166
+ padding: 0.55rem 0.85rem;
167
+ border: 1px solid transparent;
168
+ border-bottom: none;
169
+ border-radius: 0.2rem 0.2rem 0 0;
170
+ background: transparent;
171
+ color: var(--muted);
172
+ cursor: pointer;
173
+ font: inherit;
174
+ font-family: var(--mono);
175
+ font-size: 0.78rem;
176
+ font-weight: 700;
177
+ letter-spacing: 0.04em;
178
+ }
179
+
180
+ .method-tabs__tab:hover {
181
+ color: var(--text);
182
+ background: color-mix(in srgb, var(--surface) 80%, transparent);
183
+ }
184
+
185
+ .method-tabs__tab[aria-selected="true"] {
186
+ color: var(--text);
187
+ background: var(--surface);
188
+ border-color: var(--border);
189
+ }
190
+
191
+ .method-tabs__tab--get[aria-selected="true"] { box-shadow: inset 0 4px 0 var(--get); }
192
+ .method-tabs__tab--put[aria-selected="true"] { box-shadow: inset 0 4px 0 var(--put); }
193
+ .method-tabs__tab--post[aria-selected="true"] { box-shadow: inset 0 4px 0 var(--post); }
194
+ .method-tabs__tab--delete[aria-selected="true"] { box-shadow: inset 0 4px 0 var(--delete); }
195
+
196
+ .method-tabs__count {
197
+ display: inline-flex;
198
+ align-items: center;
199
+ justify-content: center;
200
+ min-width: 1.35rem;
201
+ padding: 0.05rem 0.35rem;
202
+ border-radius: 0px;
203
+ background: var(--surface-2);
204
+ color: var(--muted);
205
+ font-size: 0.68rem;
206
+ font-weight: 600;
207
+ }
208
+
209
+ .method-tabs__tab[aria-selected="true"] .method-tabs__count {
210
+ color: var(--text);
211
+ }
212
+
213
+ .method-tabs__panel {
214
+ padding: 0;
215
+ }
216
+
217
+ .method-tabs__panel[hidden] {
218
+ display: none;
219
+ }
220
+
221
+ .method-tabs__panel .capture-accordion:first-child {
222
+ border-top: 0;
223
+ }
224
+
225
+ .method-badge {
226
+ display: inline-flex;
227
+ align-items: center;
228
+ justify-content: center;
229
+ min-width: 3.5rem;
230
+ padding: 0.15rem 0.45rem;
231
+ border-radius: 0.3rem;
232
+ font-family: var(--mono);
233
+ font-size: 0.7rem;
234
+ font-weight: 700;
235
+ letter-spacing: 0.04em;
236
+ text-transform: uppercase;
237
+ color: #fff;
238
+ }
239
+
240
+ .method-badge--get { background: var(--get); }
241
+ .method-badge--put { background: var(--put); }
242
+ .method-badge--post { background: var(--post); }
243
+ .method-badge--delete { background: var(--delete); }
244
+
245
+ .capture-list {
246
+ display: flex;
247
+ flex-direction: column;
248
+ }
249
+
250
+ .capture-accordion {
251
+ border-top: 1px solid var(--border);
252
+ }
253
+
254
+ .capture-accordion__summary {
255
+ display: grid;
256
+ grid-template-columns: 1fr auto;
257
+ grid-template-rows: auto auto;
258
+ column-gap: 0.75rem;
259
+ row-gap: 0.35rem;
260
+ align-items: start;
261
+ padding: 0.9rem 1rem;
262
+ cursor: pointer;
263
+ list-style: none;
264
+ }
265
+
266
+ .capture-accordion__summary::-webkit-details-marker {
267
+ display: none;
268
+ }
269
+
270
+ .capture-accordion__summary::after {
271
+ content: "▸";
272
+ grid-column: 2;
273
+ grid-row: 1 / span 2;
274
+ align-self: center;
275
+ color: var(--muted);
276
+ }
277
+
278
+ .capture-accordion[open] > .capture-accordion__summary::after {
279
+ content: "▾";
280
+ }
281
+
282
+ .capture-accordion__description {
283
+ grid-column: 1;
284
+ grid-row: 1;
285
+ font-size: 0.95rem;
286
+ font-weight: 600;
287
+ line-height: 1.35;
288
+ }
289
+
290
+ .capture-accordion__meta {
291
+ grid-column: 1;
292
+ grid-row: 2;
293
+ display: flex;
294
+ flex-wrap: wrap;
295
+ align-items: center;
296
+ gap: 0.5rem;
297
+ }
298
+
299
+ .capture-accordion__path {
300
+ color: var(--muted);
301
+ font-family: var(--mono);
302
+ font-size: 0.78rem;
303
+ word-break: break-all;
304
+ }
305
+
306
+ .status-pill {
307
+ display: inline-flex;
308
+ align-items: center;
309
+ justify-content: center;
310
+ min-width: 2.75rem;
311
+ padding: 0.12rem 0.45rem;
312
+ border-radius: 999px;
313
+ font-family: var(--mono);
314
+ font-size: 0.72rem;
315
+ font-weight: 700;
316
+ color: #fff;
317
+ }
318
+
319
+ .status-pill--success { background: var(--status-success); }
320
+ .status-pill--redirect { background: var(--status-redirect); }
321
+ .status-pill--client-error { background: var(--status-client-error); }
322
+ .status-pill--server-error { background: var(--status-server-error); }
323
+ .status-pill--unknown { background: var(--status-unknown); }
324
+
325
+ .capture-accordion__body {
326
+ padding: 0 1rem 1rem;
327
+ }
328
+
329
+ .capture__grid {
330
+ display: grid;
331
+ grid-template-columns: 1fr;
332
+ gap: 1rem;
333
+ }
334
+
335
+ .panel h3 {
336
+ margin: 0 0 0.5rem;
337
+ font-size: 0.8rem;
338
+ letter-spacing: 0.06em;
339
+ text-transform: uppercase;
340
+ color: var(--muted);
341
+ }
342
+
343
+ .panel pre,
344
+ .panel .code-block {
345
+ margin: 0;
346
+ padding: 0.85rem;
347
+ border-radius: 0.5rem;
348
+ background: #0b1015;
349
+ border: 1px solid var(--border);
350
+ overflow: auto;
351
+ font-family: var(--mono);
352
+ font-size: 0.78rem;
353
+ line-height: 1.45;
354
+ white-space: pre-wrap;
355
+ word-break: break-word;
356
+ }
357
+
358
+ .token-key {
359
+ color: #7dcfff;
360
+ }
361
+
362
+ .token-string {
363
+ color: #9ece6a;
364
+ }
365
+
366
+ .token-number {
367
+ color: #ff9e64;
368
+ }
369
+
370
+ .token-literal {
371
+ color: #bb9af7;
372
+ }
373
+
374
+ @media (max-width: 900px) {
375
+ .layout {
376
+ grid-template-columns: 1fr;
377
+ }
378
+
379
+ .sidebar {
380
+ border-right: 0;
381
+ border-bottom: 1px solid var(--border);
382
+ }
383
+ }
@@ -0,0 +1,34 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en-GB">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title><%= @config.title %></title>
7
+ <link rel="stylesheet" href="<%= app_css_path %>" />
8
+ </head>
9
+ <body>
10
+ <div class="layout">
11
+ <aside class="sidebar">
12
+ <header class="sidebar__header">
13
+ <p class="eyebrow"><%= @config.eyebrow %></p>
14
+ <h1><%= @config.title %></h1>
15
+ </header>
16
+ <nav id="endpoint-list" class="endpoint-list" aria-label="Endpoints"></nav>
17
+ </aside>
18
+
19
+ <main class="main">
20
+ <p id="status" class="status">Loading report…</p>
21
+ <article id="detail" class="detail" hidden></article>
22
+ </main>
23
+ </div>
24
+
25
+ <script>
26
+ window.SPECY_DOCS_CONFIG = {
27
+ reportPath: <%= raw report_path.to_json %>,
28
+ maskedHeaderKeys: <%= raw @config.masked_header_keys.to_json %>,
29
+ rakeTaskHint: "bin/rails specy_docs:report"
30
+ };
31
+ </script>
32
+ <script src="<%= app_js_path %>" type="module"></script>
33
+ </body>
34
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ SpecyDocs::Engine.routes.draw do
4
+ root to: 'docs#show'
5
+ get 'report.json', to: 'docs#report', as: :report
6
+ get 'app.js', to: 'docs#javascript', as: :app_js
7
+ get 'styles.css', to: 'docs#stylesheet', as: :app_css
8
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'digest'
6
+
7
+ module SpecyDocs
8
+ module Capturable
9
+ def self.included(base)
10
+ base.after(:each) do |example|
11
+ next unless SpecyDocs::Capturable.should_capture?(example)
12
+ next unless defined?(response) && response.present?
13
+
14
+ SpecyDocs::Capturable.record(example, request, response)
15
+ end
16
+ end
17
+
18
+ def self.should_capture?(example)
19
+ return false unless matches_capture_path?(example)
20
+
21
+ if SpecyDocs.configuration.opt_in
22
+ !!example.metadata[:specy]
23
+ else
24
+ example.metadata[:specy] != false
25
+ end
26
+ end
27
+
28
+ def self.matches_capture_path?(example)
29
+ configured = Array(SpecyDocs.configuration.capture_paths).map(&:to_s).reject(&:empty?)
30
+ return true if configured.empty?
31
+
32
+ file_path = example.metadata[:absolute_file_path].presence || example.metadata[:file_path]
33
+ return false if file_path.blank?
34
+
35
+ absolute = Pathname(file_path).expand_path.to_s
36
+
37
+ configured.any? do |capture_path|
38
+ base = Pathname(capture_path)
39
+ base = Rails.root.join(capture_path) unless base.absolute?
40
+ prefix = "#{base.expand_path}#{File::SEPARATOR}"
41
+ absolute == base.expand_path.to_s || absolute.start_with?(prefix)
42
+ end
43
+ end
44
+
45
+ def self.record(example, request, response)
46
+ capture_file = SpecyDocs.configuration.resolved_capture_path
47
+ FileUtils.mkdir_p(capture_file.dirname)
48
+
49
+ key = example.id
50
+ captures = load_captures(capture_file)
51
+ captures[key] = {
52
+ description: example.full_description,
53
+ location: "#{example.metadata[:file_path]}:#{example.metadata[:line_number]}",
54
+ request: {
55
+ method: request.request_method,
56
+ path: request.path,
57
+ query_string: request.query_string.presence,
58
+ headers: filtered_headers(request),
59
+ body: raw_body(request)
60
+ },
61
+ response: {
62
+ status: response.status,
63
+ headers: response.headers.slice('Content-Type', 'Location'),
64
+ body: parsed_body(response)
65
+ },
66
+ captured_at: Time.current.iso8601
67
+ }
68
+
69
+ File.write(capture_file, JSON.pretty_generate(captures))
70
+ end
71
+
72
+ def self.load_captures(capture_file = SpecyDocs.configuration.resolved_capture_path)
73
+ return {} unless capture_file.exist?
74
+
75
+ JSON.parse(File.read(capture_file))
76
+ rescue JSON::ParserError
77
+ {}
78
+ end
79
+
80
+ def self.raw_body(request)
81
+ request_body = request.body
82
+ return nil unless request_body
83
+
84
+ body = request_body.read
85
+ request_body.rewind
86
+ body.presence
87
+ end
88
+
89
+ def self.filtered_headers(request)
90
+ request.headers.env
91
+ .select { |k, _| k.start_with?('HTTP_') || k == 'CONTENT_TYPE' }
92
+ end
93
+
94
+ def self.parsed_body(response)
95
+ return nil if response.body.blank?
96
+
97
+ JSON.parse(response.body)
98
+ rescue JSON::ParserError
99
+ response.body
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecyDocs
4
+ class Configuration
5
+ attr_accessor :capture_path,
6
+ :report_path,
7
+ :masked_header_keys,
8
+ :title,
9
+ :eyebrow,
10
+ :opt_in,
11
+ :capture_paths
12
+
13
+ def initialize
14
+ @capture_path = nil
15
+ @report_path = nil
16
+ @masked_header_keys = []
17
+ @title = 'API Docs'
18
+ @eyebrow = 'specy-docs'
19
+ @opt_in = true
20
+ @capture_paths = []
21
+ end
22
+
23
+ def resolved_capture_path
24
+ Pathname(capture_path || Rails.root.join('tmp/specy_docs/captures.json'))
25
+ end
26
+
27
+ def resolved_report_path
28
+ Pathname(report_path || Rails.root.join('tmp/specy_docs/report.json'))
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+ require 'action_controller/railtie'
5
+ require 'action_view/railtie'
6
+
7
+ module SpecyDocs
8
+ class Engine < ::Rails::Engine
9
+ isolate_namespace SpecyDocs
10
+
11
+ rake_tasks do
12
+ load File.expand_path('tasks/specy_docs_tasks.rake', __dir__)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module SpecyDocs
7
+ class ReportGenerator
8
+ class InvalidCaptureFileError < StandardError; end
9
+
10
+ HTTP_METHODS = %w[get put post delete].freeze
11
+
12
+ def self.call(capture_file: nil, output_file: nil)
13
+ new(
14
+ capture_file: capture_file || SpecyDocs.configuration.resolved_capture_path,
15
+ output_file: output_file || SpecyDocs.configuration.resolved_report_path
16
+ ).call
17
+ end
18
+
19
+ def initialize(capture_file:, output_file:)
20
+ @capture_file = Pathname(capture_file)
21
+ @output_file = Pathname(output_file)
22
+ end
23
+
24
+ def call
25
+ report = build_report
26
+ write_report(report)
27
+ report
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :capture_file, :output_file
33
+
34
+ def build_report
35
+ return {} unless capture_file.exist?
36
+
37
+ grouped_captures = captures.each_value.with_object({}) do |capture, grouped|
38
+ path = capture.dig('request', 'path')
39
+ method = capture.dig('request', 'method')&.downcase
40
+ next unless path.present? && HTTP_METHODS.include?(method)
41
+
42
+ template = route_template(path, method)
43
+ grouped[template] ||= HTTP_METHODS.index_with { [] }
44
+ grouped[template][method] << capture
45
+ end
46
+
47
+ grouped_captures.sort.to_h
48
+ end
49
+
50
+ def write_report(report)
51
+ FileUtils.mkdir_p(output_file.dirname)
52
+ File.write(output_file, JSON.pretty_generate(report))
53
+ end
54
+
55
+ def captures
56
+ JSON.parse(capture_file.read)
57
+ rescue JSON::ParserError => e
58
+ raise InvalidCaptureFileError, "Invalid capture file: #{e.message}"
59
+ end
60
+
61
+ # Resolves a concrete path to its Rails route template via the route's path.spec.
62
+ # Domain-like params with dots are matched as [^/]+ so (.:format) does not steal TLDs.
63
+ def route_template(path, method)
64
+ route = Rails.application.routes.routes.find do |candidate|
65
+ next if catch_all_route?(candidate)
66
+ next unless verb_matches?(candidate, method)
67
+
68
+ path_matches_template?(path, candidate)
69
+ end
70
+
71
+ return path unless route
72
+
73
+ strip_optional_format(route.path.spec.to_s)
74
+ end
75
+
76
+ def verb_matches?(route, method)
77
+ verb = route.verb
78
+ verb.blank? || verb.match?(method.upcase)
79
+ end
80
+
81
+ def path_matches_template?(path, route)
82
+ template = strip_optional_format(route.path.spec.to_s)
83
+ pattern = template.split('/').map do |segment|
84
+ segment.start_with?(':') ? '[^/]+' : Regexp.escape(segment)
85
+ end.join('/')
86
+
87
+ /\A#{pattern}\z/.match?(path)
88
+ end
89
+
90
+ def strip_optional_format(spec)
91
+ spec.sub(/\(\.:format\)\z/, '')
92
+ end
93
+
94
+ def catch_all_route?(route)
95
+ route.path.spec.to_s.include?('*')
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :specy_docs do
4
+ desc 'Build SpecyDocs report JSON from captured request specs'
5
+ task report: :environment do
6
+ output_file = SpecyDocs.configuration.resolved_report_path
7
+ SpecyDocs::ReportGenerator.call
8
+ puts "Wrote SpecyDocs report to #{output_file}"
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecyDocs
4
+ VERSION = '0.1.0'
5
+ end