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 +7 -0
- data/CHANGELOG.md +22 -0
- data/MIT-LICENSE +21 -0
- data/README.md +133 -0
- data/app/controllers/specy_docs/application_controller.rb +9 -0
- data/app/controllers/specy_docs/docs_controller.rb +38 -0
- data/app/frontend/app.js +398 -0
- data/app/frontend/styles.css +383 -0
- data/app/views/specy_docs/docs/show.html.erb +34 -0
- data/config/routes.rb +8 -0
- data/lib/specy_docs/capturable.rb +102 -0
- data/lib/specy_docs/configuration.rb +31 -0
- data/lib/specy_docs/engine.rb +15 -0
- data/lib/specy_docs/report_generator.rb +98 -0
- data/lib/specy_docs/tasks/specy_docs_tasks.rake +10 -0
- data/lib/specy_docs/version.rb +5 -0
- data/lib/specy_docs.rb +46 -0
- metadata +78 -0
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
|
data/app/frontend/app.js
ADDED
|
@@ -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('&', '&')
|
|
350
|
+
.replaceAll('<', '<')
|
|
351
|
+
.replaceAll('>', '>');
|
|
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
|
+
});
|