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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6f5818e923a111c6ea1eea3993742da3e1e273f730001a9001b643a9b0bb0308
4
+ data.tar.gz: 170add497082a8e34fd9a58fb4e3fa29db0758c3f0989140b8222f2f1cde253c
5
+ SHA512:
6
+ metadata.gz: 9d9f5595c0f2d424c51364bbe0b5611a511dd4173c607be7c2d83f7ea3d80c3e65286cd15b85a1c30b84ccdd8eac4217b820f272451821dd5590948051f7dd2e
7
+ data.tar.gz: 94823fe80e78e972da517fbe771b5fdededd227d4006c4c63112352858c9daac39440fd0e9c9c18b06321c2b5fcab72f56cf27203af13e10239025d86ff75d97
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-07-19
9
+
10
+ ### Added
11
+
12
+ - Initial release of SpecyDocs, a mountable Rails engine for API docs from RSpec captures
13
+ - Capture request/response examples from RSpec request specs via `SpecyDocs::Capturable`
14
+ - `SpecyDocs.setup_rspec` helper for include filters that follow configuration
15
+ - Opt-in (`specy: true`) and opt-out (`specy: false`) capture modes
16
+ - Optional `capture_paths` for legacy controller specs without `type: :request`
17
+ - Report generation via `bin/rails specy_docs:report`
18
+ - Mountable browsable docs UI
19
+ - Configurable title, eyebrow, paths, and masked header keys
20
+ - Support for API-only Rails apps (`config.api_only = true`)
21
+
22
+ [0.1.0]: https://github.com/thabotitus/specy_docs/releases/tag/v0.1.0
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # SpecyDocs
2
+
3
+ Capture RSpec request examples and serve a mountable docs UI.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ # Gemfile
9
+ gem 'specy_docs'
10
+ ```
11
+
12
+ ```bash
13
+ bundle install
14
+ ```
15
+
16
+ ## Mount the engine
17
+
18
+ ```ruby
19
+ # config/routes.rb
20
+ mount SpecyDocs::Engine => '/docs'
21
+ ```
22
+
23
+ Any path works, e.g. `/api-docs` or `/internal/specy`.
24
+
25
+ ## Capture request specs
26
+
27
+ Prefer the setup helper so include filters follow your configuration:
28
+
29
+ ```ruby
30
+ # spec/rails_helper.rb
31
+ RSpec.configure do |config|
32
+ SpecyDocs.setup_rspec(config)
33
+ end
34
+ ```
35
+
36
+ Or include manually:
37
+
38
+ ```ruby
39
+ config.include SpecyDocs::Capturable, type: :request
40
+ ```
41
+
42
+ ### Capture folders (legacy controller specs without `type:`)
43
+
44
+ If controller (or other) specs are not tagged with `type: :request`, and adding a type breaks them, set folders to capture from instead. SpecyDocs will include `Capturable` by file path for those folders. `opt_in` / `specy` tags still apply.
45
+
46
+ ```ruby
47
+ # config/initializers/specy_docs.rb
48
+ SpecyDocs.configure do |config|
49
+ config.capture_paths = %w[spec/controllers spec/requests]
50
+ config.opt_in = true
51
+ end
52
+ ```
53
+
54
+ ```ruby
55
+ # spec/rails_helper.rb — must use setup_rspec (or include by file_path yourself)
56
+ RSpec.configure do |config|
57
+ SpecyDocs.setup_rspec(config)
58
+ end
59
+ ```
60
+
61
+ Only examples under those folders are candidates; then:
62
+
63
+ - `opt_in: true` → capture when `specy: true`
64
+ - `opt_in: false` → capture all in those folders except `specy: false`
65
+
66
+ ### Opt-in mode (default: `opt_in: true`)
67
+
68
+ Only examples (or groups) tagged with `specy: true` are captured:
69
+
70
+ ```ruby
71
+ RSpec.describe 'Certificates', type: :request do
72
+ it 'returns the certificate', specy: true do
73
+ get '/certificates/example.com'
74
+ expect(response).to have_http_status(:ok)
75
+ end
76
+
77
+ # Not captured
78
+ it 'returns not found' do
79
+ get '/certificates/missing.com'
80
+ expect(response).to have_http_status(:not_found)
81
+ end
82
+ end
83
+ ```
84
+
85
+ ### Opt-out mode (`opt_in: false`)
86
+
87
+ All matching request specs are captured unless tagged with `specy: false`:
88
+
89
+ ```ruby
90
+ # config/initializers/specy_docs.rb
91
+ SpecyDocs.configure do |config|
92
+ config.opt_in = false
93
+ end
94
+
95
+ RSpec.describe 'Status', type: :request do
96
+ it 'returns success status' do
97
+ get '/status' # captured
98
+ end
99
+
100
+ it 'skips noise', specy: false do
101
+ get '/status' # not captured
102
+ end
103
+ end
104
+ ```
105
+
106
+ ## Generate the report
107
+
108
+ ```bash
109
+ bin/rails specy_docs:report
110
+ ```
111
+
112
+ Then open the mounted path (e.g. `http://localhost:3000/docs`).
113
+
114
+ ## Optional configuration
115
+
116
+ ```ruby
117
+ # config/initializers/specy_docs.rb
118
+ SpecyDocs.configure do |config|
119
+ config.title = 'CDN API'
120
+ config.eyebrow = 'cdn-api'
121
+ config.opt_in = true # default; set false to capture all except `specy: false`
122
+ config.capture_paths = %w[spec/controllers spec/requests] # optional; empty = type: :request only
123
+ config.capture_path = Rails.root.join('tmp/specy_docs/captures.json')
124
+ config.report_path = Rails.root.join('tmp/specy_docs/report.json')
125
+ config.masked_header_keys = %w[
126
+ Authorization
127
+ ]
128
+ end
129
+ ```
130
+
131
+ ## Notes for API-only apps
132
+
133
+ SpecyDocs loads Action Controller and Action View railties inside the engine, so the UI renders even when the host app uses `config.api_only = true`.
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecyDocs
4
+ class ApplicationController < ActionController::Base
5
+ # Docs UI is read-only static assets + JSON; CSRF / same-origin JS checks
6
+ # block <script src> loads when serving application/javascript via the controller.
7
+ skip_forgery_protection
8
+ end
9
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecyDocs
4
+ class DocsController < ApplicationController
5
+ layout false
6
+ skip_after_action :verify_same_origin_request, only: %i[javascript stylesheet]
7
+
8
+ def show
9
+ @config = SpecyDocs.configuration
10
+ end
11
+
12
+ def report
13
+ path = SpecyDocs.configuration.resolved_report_path
14
+ if path.exist?
15
+ send_file path, type: 'application/json', disposition: 'inline'
16
+ else
17
+ render json: {}
18
+ end
19
+ end
20
+
21
+ def javascript
22
+ send_frontend('app.js', 'application/javascript')
23
+ end
24
+
25
+ def stylesheet
26
+ send_frontend('styles.css', 'text/css')
27
+ end
28
+
29
+ private
30
+
31
+ def send_frontend(filename, content_type)
32
+ file = SpecyDocs::Engine.root.join('app/frontend', filename)
33
+ raise ActionController::RoutingError, 'Not Found' unless file.exist?
34
+
35
+ send_file file, type: content_type, disposition: 'inline'
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,398 @@
1
+ const CONFIG = window.SPECY_DOCS_CONFIG || {};
2
+ const REPORT_PATH = CONFIG.reportPath || './report.json';
3
+ const MASKED_HEADER_KEYS = CONFIG.maskedHeaderKeys || [
4
+ 'Authorization'
5
+ ];
6
+ const RAKE_TASK_HINT = CONFIG.rakeTaskHint || 'bin/rails specy_docs:report';
7
+
8
+ const MASKED_VALUE = '*****';
9
+ const HTTP_METHODS = ['get', 'put', 'post', 'delete'];
10
+
11
+ const endpointList = document.getElementById('endpoint-list');
12
+ const detail = document.getElementById('detail');
13
+ const status = document.getElementById('status');
14
+
15
+ let report = {};
16
+
17
+ async function loadReport() {
18
+ const response = await fetch(REPORT_PATH);
19
+ if (!response.ok) {
20
+ throw new Error(`Failed to load ${REPORT_PATH} (${response.status})`);
21
+ }
22
+
23
+ report = await response.json();
24
+ const paths = Object.keys(report);
25
+
26
+ if (paths.length === 0) {
27
+ status.textContent = `No captures in report.json. Run: ${RAKE_TASK_HINT}`;
28
+ return;
29
+ }
30
+
31
+ status.hidden = true;
32
+ renderEndpointList(paths);
33
+ selectPath(paths[0]);
34
+ }
35
+
36
+ function renderEndpointList(paths) {
37
+ endpointList.innerHTML = '';
38
+
39
+ const groups = groupPathsByTopLevel(paths);
40
+
41
+ Object.entries(groups).forEach(([group, groupPaths]) => {
42
+ const section = document.createElement('section');
43
+ section.className = 'endpoint-group';
44
+
45
+ const heading = document.createElement('h2');
46
+ heading.className = 'endpoint-group__title';
47
+ heading.textContent = group;
48
+ section.appendChild(heading);
49
+
50
+ const list = document.createElement('div');
51
+ list.className = 'endpoint-group__list';
52
+
53
+ groupPaths.forEach((path) => {
54
+ const button = document.createElement('button');
55
+ button.type = 'button';
56
+ button.className = 'endpoint-button';
57
+ button.dataset.path = path;
58
+
59
+ const pathEl = document.createElement('span');
60
+ pathEl.className = 'endpoint-button__path';
61
+ pathEl.textContent = pathWithinGroup(path, group);
62
+
63
+ const methodsEl = document.createElement('span');
64
+ methodsEl.className = 'endpoint-button__methods';
65
+ availableMethods(path).forEach((method) => {
66
+ methodsEl.appendChild(methodBadge(method));
67
+ });
68
+
69
+ button.append(pathEl, methodsEl);
70
+ button.addEventListener('click', () => selectPath(path));
71
+ list.appendChild(button);
72
+ });
73
+
74
+ section.appendChild(list);
75
+ endpointList.appendChild(section);
76
+ });
77
+ }
78
+
79
+ function groupPathsByTopLevel(paths) {
80
+ return paths.reduce((groups, path) => {
81
+ const group = topLevelGroup(path);
82
+ groups[group] ||= [];
83
+ groups[group].push(path);
84
+ return groups;
85
+ }, {});
86
+ }
87
+
88
+ function topLevelGroup(path) {
89
+ return path.split('/').filter(Boolean)[0] || '/';
90
+ }
91
+
92
+ function pathWithinGroup(path, group) {
93
+ const prefix = `/${group}`;
94
+ if (path === prefix) return '/';
95
+ return path.startsWith(`${prefix}/`) ? path.slice(prefix.length) : path;
96
+ }
97
+
98
+ function availableMethods(path) {
99
+ return HTTP_METHODS.filter((method) => (report[path]?.[method] || []).length > 0);
100
+ }
101
+
102
+ function selectPath(path) {
103
+ endpointList.querySelectorAll('.endpoint-button').forEach((button) => {
104
+ button.setAttribute('aria-current', button.dataset.path === path ? 'true' : 'false');
105
+ });
106
+
107
+ detail.hidden = false;
108
+ detail.innerHTML = '';
109
+
110
+ const heading = document.createElement('h2');
111
+ heading.className = 'detail__path';
112
+ heading.textContent = path;
113
+ detail.appendChild(heading);
114
+
115
+ const methods = availableMethods(path);
116
+ if (methods.length === 0) {
117
+ const empty = document.createElement('p');
118
+ empty.className = 'status';
119
+ empty.textContent = 'No examples for this path.';
120
+ detail.appendChild(empty);
121
+ return;
122
+ }
123
+
124
+ detail.appendChild(renderMethodTabs(path, methods));
125
+ }
126
+
127
+ function renderMethodTabs(path, methods) {
128
+ const root = document.createElement('div');
129
+ root.className = 'method-tabs';
130
+
131
+ const tabList = document.createElement('div');
132
+ tabList.className = 'method-tabs__list';
133
+ tabList.setAttribute('role', 'tablist');
134
+ tabList.setAttribute('aria-label', 'HTTP methods');
135
+
136
+ const panels = document.createElement('div');
137
+ panels.className = 'method-tabs__panels';
138
+
139
+ methods.forEach((method, index) => {
140
+ const captures = report[path][method];
141
+ const selected = index === 0;
142
+ const tabId = `method-tab-${method}`;
143
+ const panelId = `method-panel-${method}`;
144
+
145
+ const tab = document.createElement('button');
146
+ tab.type = 'button';
147
+ tab.className = `method-tabs__tab method-tabs__tab--${method}`;
148
+ tab.id = tabId;
149
+ tab.setAttribute('role', 'tab');
150
+ tab.setAttribute('aria-selected', selected ? 'true' : 'false');
151
+ tab.setAttribute('aria-controls', panelId);
152
+ tab.tabIndex = selected ? 0 : -1;
153
+
154
+ const label = document.createElement('span');
155
+ label.className = 'method-tabs__label';
156
+ label.textContent = method.toUpperCase();
157
+
158
+ const count = document.createElement('span');
159
+ count.className = 'method-tabs__count';
160
+ count.textContent = String(captures.length);
161
+
162
+ tab.append(label, count);
163
+ tab.addEventListener('click', () => activateMethodTab(root, method));
164
+ tabList.appendChild(tab);
165
+
166
+ const panel = document.createElement('div');
167
+ panel.className = 'method-tabs__panel';
168
+ panel.id = panelId;
169
+ panel.setAttribute('role', 'tabpanel');
170
+ panel.setAttribute('aria-labelledby', tabId);
171
+ panel.hidden = !selected;
172
+
173
+ const accordions = document.createElement('div');
174
+ accordions.className = 'capture-list';
175
+ const accordionGroup = `${path}::${method}`;
176
+ captures.forEach((capture, captureIndex) => {
177
+ accordions.appendChild(renderCaptureAccordion(capture, captureIndex, accordionGroup));
178
+ });
179
+ panel.appendChild(accordions);
180
+ panels.appendChild(panel);
181
+ });
182
+
183
+ root.append(tabList, panels);
184
+ return root;
185
+ }
186
+
187
+ function activateMethodTab(root, method) {
188
+ root.querySelectorAll('[role="tab"]').forEach((tab) => {
189
+ const selected = tab.id === `method-tab-${method}`;
190
+ tab.setAttribute('aria-selected', selected ? 'true' : 'false');
191
+ tab.tabIndex = selected ? 0 : -1;
192
+ });
193
+
194
+ root.querySelectorAll('[role="tabpanel"]').forEach((panel) => {
195
+ panel.hidden = panel.id !== `method-panel-${method}`;
196
+ });
197
+ }
198
+
199
+ function renderCaptureAccordion(capture, index, accordionGroup) {
200
+ const details = document.createElement('details');
201
+ details.className = 'capture-accordion';
202
+ details.name = accordionGroup;
203
+ if (index === 0) {
204
+ details.open = true;
205
+ }
206
+
207
+ const summary = document.createElement('summary');
208
+ summary.className = 'capture-accordion__summary';
209
+
210
+ const description = document.createElement('span');
211
+ description.className = 'capture-accordion__description';
212
+ description.textContent = shortenDescription(capture.description) || `Example ${index + 1}`;
213
+
214
+ const meta = document.createElement('span');
215
+ meta.className = 'capture-accordion__meta';
216
+
217
+ const pathEl = document.createElement('span');
218
+ pathEl.className = 'capture-accordion__path';
219
+ pathEl.textContent = capture.request?.path || 'unknown path';
220
+
221
+ meta.append(pathEl, statusPill(capture.response?.status));
222
+
223
+ summary.append(description, meta);
224
+ details.appendChild(summary);
225
+
226
+ const body = document.createElement('div');
227
+ body.className = 'capture-accordion__body';
228
+
229
+ const grid = document.createElement('div');
230
+ grid.className = 'capture__grid';
231
+ grid.appendChild(
232
+ renderPanel('Request', {
233
+ method: capture.request?.method,
234
+ path: capture.request?.path,
235
+ query_string: capture.request?.query_string,
236
+ headers: maskHeaders(capture.request?.headers),
237
+ body: capture.request?.body
238
+ })
239
+ );
240
+
241
+ const queryParams = parseQueryString(capture.request?.query_string);
242
+ if (queryParams) {
243
+ grid.appendChild(renderPanel('Query', queryParams));
244
+ }
245
+
246
+ const requestBody = parseJsonLike(capture.request?.body);
247
+ if (requestBody !== null && requestBody !== undefined && requestBody !== '') {
248
+ grid.appendChild(renderPanel('Body', requestBody));
249
+ }
250
+
251
+ grid.appendChild(
252
+ renderPanel('Response', {
253
+ status: capture.response?.status,
254
+ headers: maskHeaders(capture.response?.headers),
255
+ body: capture.response?.body
256
+ })
257
+ );
258
+ body.appendChild(grid);
259
+ details.appendChild(body);
260
+
261
+ return details;
262
+ }
263
+
264
+ function parseQueryString(queryString) {
265
+ if (!queryString || typeof queryString !== 'string') {
266
+ return null;
267
+ }
268
+
269
+ const params = {};
270
+ new URLSearchParams(queryString).forEach((value, key) => {
271
+ if (Object.prototype.hasOwnProperty.call(params, key)) {
272
+ params[key] = Array.isArray(params[key]) ? [...params[key], value] : [params[key], value];
273
+ } else {
274
+ params[key] = value;
275
+ }
276
+ });
277
+
278
+ return Object.keys(params).length > 0 ? params : null;
279
+ }
280
+
281
+ function parseJsonLike(value) {
282
+ if (value === null || value === undefined || value === '') {
283
+ return null;
284
+ }
285
+
286
+ if (typeof value !== 'string') {
287
+ return value;
288
+ }
289
+
290
+ try {
291
+ return JSON.parse(value);
292
+ } catch {
293
+ return value;
294
+ }
295
+ }
296
+
297
+ function maskHeaders(headers) {
298
+ if (!headers || typeof headers !== 'object') {
299
+ return headers;
300
+ }
301
+
302
+ const maskedKeys = new Set(MASKED_HEADER_KEYS.map((key) => key.toLowerCase()));
303
+
304
+ return Object.fromEntries(
305
+ Object.entries(headers).map(([key, value]) => [
306
+ key,
307
+ maskedKeys.has(key.toLowerCase()) ? MASKED_VALUE : value
308
+ ])
309
+ );
310
+ }
311
+
312
+ function renderPanel(title, data) {
313
+ const panel = document.createElement('div');
314
+ panel.className = 'panel';
315
+
316
+ const heading = document.createElement('h3');
317
+ heading.textContent = title;
318
+
319
+ const pre = document.createElement('pre');
320
+ pre.className = 'code-block';
321
+ pre.innerHTML = highlightJson(JSON.stringify(data, null, 2));
322
+
323
+ panel.append(heading, pre);
324
+ return panel;
325
+ }
326
+
327
+ function highlightJson(json) {
328
+ const escaped = escapeHtml(json);
329
+
330
+ return escaped.replace(
331
+ /("(?:\\.|[^"\\])*")\s*:|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|("(?:\\.|[^"\\])*")/g,
332
+ (match, key, literal, number, string) => {
333
+ if (key) {
334
+ return `<span class="token token-key">${key}</span>:`;
335
+ }
336
+ if (literal) {
337
+ return `<span class="token token-literal">${literal}</span>`;
338
+ }
339
+ if (number) {
340
+ return `<span class="token token-number">${number}</span>`;
341
+ }
342
+ return `<span class="token token-string">${string}</span>`;
343
+ }
344
+ );
345
+ }
346
+
347
+ function escapeHtml(value) {
348
+ return value
349
+ .replaceAll('&', '&amp;')
350
+ .replaceAll('<', '&lt;')
351
+ .replaceAll('>', '&gt;');
352
+ }
353
+
354
+ function methodBadge(method) {
355
+ const badge = document.createElement('span');
356
+ badge.className = `method-badge method-badge--${method}`;
357
+ badge.textContent = method;
358
+ return badge;
359
+ }
360
+
361
+ // Prefer starting at the HTTP verb in RSpec descriptions, e.g.
362
+ // "behaves like … ReportBaseController GET /show with valid params …"
363
+ // → "GET /show with valid params …"
364
+ function shortenDescription(description) {
365
+ if (!description) return '';
366
+
367
+ const methodMatch = description.match(/\b(GET|PUT|POST|DELETE)\b/);
368
+ if (methodMatch) {
369
+ return description.slice(methodMatch.index).trim();
370
+ }
371
+
372
+ return description
373
+ .replace(/^(?:[A-Z][\w]*)(?:::[A-Z][\w]*)*Controller\s+/, '')
374
+ .trim();
375
+ }
376
+
377
+ function statusPill(statusCode) {
378
+ const pill = document.createElement('span');
379
+ const tone = statusTone(statusCode);
380
+ pill.className = `status-pill status-pill--${tone}`;
381
+ pill.textContent = statusCode ?? '—';
382
+ return pill;
383
+ }
384
+
385
+ function statusTone(statusCode) {
386
+ const code = Number(statusCode);
387
+ if (!Number.isFinite(code)) return 'unknown';
388
+ if (code >= 200 && code <= 299) return 'success';
389
+ if (code >= 300 && code <= 399) return 'redirect';
390
+ if (code >= 400 && code <= 499) return 'client-error';
391
+ if (code >= 500 && code <= 599) return 'server-error';
392
+ return 'unknown';
393
+ }
394
+
395
+ loadReport().catch((error) => {
396
+ status.hidden = false;
397
+ status.textContent = `${error.message}. Generate with ${RAKE_TASK_HINT}.`;
398
+ });