froggy-docs 1.1.1 → 1.1.3
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.
- package/.claude/settings.local.json +21 -0
- package/.github/workflows/froggy-docs.yml +59 -0
- package/ABOUT.md +99 -0
- package/MILESTONE.md +29 -17
- package/bin/froggy_docs.dart +3 -45
- package/frontend/lib/app.dart +487 -280
- package/frontend/lib/components/file_field.dart +75 -0
- package/frontend/web/app.js +224 -67
- package/frontend/web/froggy_docs.json +288 -4
- package/lib/froggy_docs.dart +1 -0
- package/lib/src/cli_runner.dart +123 -0
- package/lib/src/parser_engine.dart +351 -22
- package/lib/src/watcher_engine.dart +46 -14
- package/lib/src/web_server.dart +40 -17
- package/package.js +0 -0
- package/package.json +4 -4
- package/bin/froggy-docs +0 -0
package/frontend/lib/app.dart
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import 'dart:async';
|
|
2
|
+
import 'dart:convert';
|
|
3
|
+
|
|
4
|
+
import 'package:http/http.dart' as http;
|
|
1
5
|
import 'package:jaspr/dom.dart';
|
|
2
6
|
import 'package:jaspr/jaspr.dart';
|
|
3
|
-
import 'package:
|
|
4
|
-
|
|
5
|
-
import 'dart
|
|
6
|
-
import 'dart:html' as html;
|
|
7
|
+
import 'package:web/web.dart' as web;
|
|
8
|
+
|
|
9
|
+
import 'components/file_field.dart';
|
|
7
10
|
|
|
8
11
|
class App extends StatefulComponent {
|
|
9
12
|
const App({super.key});
|
|
@@ -14,24 +17,32 @@ class App extends StatefulComponent {
|
|
|
14
17
|
|
|
15
18
|
class _AppState extends State<App> {
|
|
16
19
|
Map<String, dynamic>? apiData;
|
|
17
|
-
Timer?
|
|
18
|
-
bool
|
|
19
|
-
String
|
|
20
|
-
String
|
|
20
|
+
Timer? _timer;
|
|
21
|
+
bool _isDark = false;
|
|
22
|
+
String _searchQuery = '';
|
|
23
|
+
String _authHeader = '';
|
|
21
24
|
final Map<String, String> _responses = {};
|
|
22
25
|
final Map<String, bool> _loading = {};
|
|
23
|
-
final Map<String, Map<String, String>>
|
|
24
|
-
final Map<String,
|
|
26
|
+
final Map<String, Map<String, String>> _bodyValues = {};
|
|
27
|
+
final Map<String, Map<String, dynamic>> _fileValues = {};
|
|
28
|
+
final Map<String, Map<String, String>> _queryValues = {};
|
|
29
|
+
final Map<String, Map<String, String>> _headerValues = {};
|
|
30
|
+
final Map<String, bool> _expandedBody = {};
|
|
31
|
+
final Map<String, bool> _expandedQuery = {};
|
|
32
|
+
final Map<String, bool> _expandedHeaders = {};
|
|
33
|
+
String? _toast;
|
|
34
|
+
Timer? _toastTimer;
|
|
35
|
+
// ignore: unused_field
|
|
36
|
+
int _specVersion = 0;
|
|
25
37
|
|
|
26
38
|
@override
|
|
27
39
|
void initState() {
|
|
28
40
|
super.initState();
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
_loadSpec();
|
|
42
|
+
_timer = Timer.periodic(const Duration(seconds: 3), (_) => _loadSpec());
|
|
32
43
|
try {
|
|
33
|
-
if (
|
|
34
|
-
|
|
44
|
+
if (web.window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
45
|
+
_isDark = true;
|
|
35
46
|
_applyTheme();
|
|
36
47
|
}
|
|
37
48
|
} catch (_) {}
|
|
@@ -39,339 +50,535 @@ class _AppState extends State<App> {
|
|
|
39
50
|
|
|
40
51
|
@override
|
|
41
52
|
void dispose() {
|
|
42
|
-
|
|
53
|
+
_timer?.cancel();
|
|
54
|
+
_toastTimer?.cancel();
|
|
43
55
|
super.dispose();
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
void _applyTheme() {
|
|
47
59
|
try {
|
|
48
|
-
|
|
60
|
+
web.document.documentElement
|
|
61
|
+
?.setAttribute('data-theme', _isDark ? 'dark' : 'light');
|
|
49
62
|
} catch (_) {}
|
|
50
63
|
}
|
|
51
64
|
|
|
52
|
-
Future<void>
|
|
65
|
+
Future<void> _loadSpec() async {
|
|
53
66
|
try {
|
|
54
67
|
final resp = await http.get(Uri.parse('/froggy_docs.json'));
|
|
55
68
|
if (resp.statusCode == 200) {
|
|
56
|
-
final data = jsonDecode(resp.body)
|
|
57
|
-
setState(()
|
|
69
|
+
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
|
70
|
+
setState(() {
|
|
71
|
+
final old = apiData;
|
|
72
|
+
apiData = data;
|
|
73
|
+
if (old != null && jsonEncode(old) != jsonEncode(data)) {
|
|
74
|
+
_specVersion++;
|
|
75
|
+
_toast = '🔄 Spec hot-reloaded!';
|
|
76
|
+
_toastTimer?.cancel();
|
|
77
|
+
_toastTimer = Timer(const Duration(seconds: 3), () {
|
|
78
|
+
setState(() => _toast = null);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
});
|
|
58
82
|
}
|
|
59
83
|
} catch (_) {}
|
|
60
84
|
}
|
|
61
85
|
|
|
62
|
-
Future<void> _executeRequest(
|
|
86
|
+
Future<void> _executeRequest(
|
|
87
|
+
String method,
|
|
88
|
+
String path,
|
|
89
|
+
bool hasAuth,
|
|
90
|
+
Map<String, dynamic>? bodyProps,
|
|
91
|
+
List<Map<String, dynamic>> queryParams,
|
|
92
|
+
List<Map<String, dynamic>> headerParams,
|
|
93
|
+
bool hasFiles,
|
|
94
|
+
) async {
|
|
63
95
|
final key = '$method-$path';
|
|
64
96
|
setState(() => _loading[key] = true);
|
|
65
97
|
|
|
66
98
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
99
|
+
var uri = Uri.parse('${web.window.location.origin}$path');
|
|
100
|
+
|
|
101
|
+
if (queryParams.isNotEmpty) {
|
|
102
|
+
final savedQuery = _queryValues[key] ?? {};
|
|
103
|
+
final params = <String, String>{};
|
|
104
|
+
for (final qp in queryParams) {
|
|
105
|
+
final name = qp['name'] as String;
|
|
106
|
+
final val = savedQuery[name] ?? '';
|
|
107
|
+
if (val.isNotEmpty) params[name] = val;
|
|
108
|
+
}
|
|
109
|
+
if (params.isNotEmpty) {
|
|
110
|
+
uri = uri.replace(queryParameters: params);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
73
113
|
|
|
74
|
-
|
|
75
|
-
|
|
114
|
+
final headers = <String, String>{};
|
|
115
|
+
if (!hasFiles) headers['Content-Type'] = 'application/json';
|
|
116
|
+
if (_authHeader.isNotEmpty) headers['Authorization'] = _authHeader;
|
|
117
|
+
for (final hp in headerParams) {
|
|
118
|
+
final name = hp['name'] as String;
|
|
119
|
+
final val = (_headerValues[key] ?? {})[name] ?? '';
|
|
120
|
+
if (val.isNotEmpty) headers[name] = val;
|
|
76
121
|
}
|
|
77
122
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
final
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
123
|
+
http.Response resp;
|
|
124
|
+
|
|
125
|
+
if (hasFiles) {
|
|
126
|
+
final req = http.MultipartRequest(method.toUpperCase(), uri);
|
|
127
|
+
req.headers.addAll(headers);
|
|
128
|
+
final savedValues = _bodyValues[key] ?? {};
|
|
129
|
+
final savedFiles = _fileValues[key] ?? {};
|
|
130
|
+
|
|
131
|
+
for (final entry in (bodyProps ?? {}).entries) {
|
|
132
|
+
final name = entry.key;
|
|
133
|
+
final prop = entry.value as Map<String, dynamic>;
|
|
134
|
+
if (prop['format'] == 'binary') {
|
|
135
|
+
final fileData = savedFiles[name];
|
|
136
|
+
if (fileData != null) {
|
|
137
|
+
req.files.add(http.MultipartFile.fromBytes(
|
|
138
|
+
name,
|
|
139
|
+
fileData['bytes'] as List<int>,
|
|
140
|
+
filename: fileData['name'] as String,
|
|
141
|
+
));
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
final val = savedValues[name] ?? '';
|
|
145
|
+
if (val.isNotEmpty) req.fields[name] = val;
|
|
86
146
|
}
|
|
87
147
|
}
|
|
88
|
-
|
|
148
|
+
|
|
149
|
+
final streamed = await req.send();
|
|
150
|
+
resp = await http.Response.fromStream(streamed);
|
|
151
|
+
} else {
|
|
152
|
+
final req = http.Request(method.toUpperCase(), uri);
|
|
153
|
+
req.headers.addAll(headers);
|
|
154
|
+
|
|
155
|
+
if (method.toUpperCase() != 'GET' && method.toUpperCase() != 'HEAD') {
|
|
156
|
+
final savedValues = _bodyValues[key] ?? {};
|
|
157
|
+
final bodyData = <String, dynamic>{};
|
|
158
|
+
for (final entry in (bodyProps ?? {}).entries) {
|
|
159
|
+
final val = savedValues[entry.key] ?? '';
|
|
160
|
+
if (val.isNotEmpty) bodyData[entry.key] = val;
|
|
161
|
+
}
|
|
162
|
+
req.body = jsonEncode(bodyData);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
final client = http.Client();
|
|
166
|
+
try {
|
|
167
|
+
final streamed = await client.send(req);
|
|
168
|
+
resp = await http.Response.fromStream(streamed);
|
|
169
|
+
} finally {
|
|
170
|
+
client.close();
|
|
171
|
+
}
|
|
89
172
|
}
|
|
90
173
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
resp = await http.post(Uri.parse(url), headers: headers, body: body);
|
|
98
|
-
break;
|
|
99
|
-
case 'PUT':
|
|
100
|
-
resp = await http.put(Uri.parse(url), headers: headers, body: body);
|
|
101
|
-
break;
|
|
102
|
-
case 'DELETE':
|
|
103
|
-
resp = await http.delete(Uri.parse(url), headers: headers);
|
|
104
|
-
break;
|
|
105
|
-
case 'PATCH':
|
|
106
|
-
resp = await http.patch(Uri.parse(url), headers: headers, body: body);
|
|
107
|
-
break;
|
|
108
|
-
default:
|
|
109
|
-
resp = await http.get(Uri.parse(url), headers: headers);
|
|
174
|
+
String responseText;
|
|
175
|
+
try {
|
|
176
|
+
final json = jsonDecode(resp.body);
|
|
177
|
+
responseText = const JsonEncoder.withIndent(' ').convert(json);
|
|
178
|
+
} catch (_) {
|
|
179
|
+
responseText = resp.body;
|
|
110
180
|
}
|
|
111
181
|
|
|
112
182
|
setState(() {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
_responses[key] = resp.body;
|
|
118
|
-
}
|
|
119
|
-
} else {
|
|
120
|
-
_responses[key] = 'Error ${resp.statusCode}: ${resp.body}';
|
|
121
|
-
}
|
|
183
|
+
_responses[key] =
|
|
184
|
+
resp.statusCode >= 200 && resp.statusCode < 300
|
|
185
|
+
? responseText
|
|
186
|
+
: 'Error ${resp.statusCode}: $responseText';
|
|
122
187
|
});
|
|
123
188
|
} catch (e) {
|
|
124
|
-
setState(()
|
|
125
|
-
_responses[key] = 'Error: $e';
|
|
126
|
-
});
|
|
189
|
+
setState(() => _responses[key] = 'Error: $e');
|
|
127
190
|
} finally {
|
|
128
191
|
setState(() => _loading[key] = false);
|
|
129
192
|
}
|
|
130
193
|
}
|
|
131
194
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return '';
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
dynamic _parseValue(String value, String? type) {
|
|
148
|
-
switch (type?.toString().toLowerCase()) {
|
|
149
|
-
case 'number':
|
|
150
|
-
return num.tryParse(value) ?? 0;
|
|
151
|
-
case 'boolean':
|
|
152
|
-
return value.toLowerCase() == 'true';
|
|
153
|
-
case 'array':
|
|
154
|
-
return [];
|
|
155
|
-
case 'object':
|
|
156
|
-
return {};
|
|
157
|
-
default:
|
|
158
|
-
return value;
|
|
159
|
-
}
|
|
195
|
+
@override
|
|
196
|
+
Component build(BuildContext context) {
|
|
197
|
+
return div(
|
|
198
|
+
classes: 'app-container',
|
|
199
|
+
[
|
|
200
|
+
_buildSidebar(),
|
|
201
|
+
_buildMain(),
|
|
202
|
+
if (_toast != null)
|
|
203
|
+
div(classes: 'toast', [Component.text(_toast!)]),
|
|
204
|
+
],
|
|
205
|
+
);
|
|
160
206
|
}
|
|
161
207
|
|
|
162
|
-
Map<String, List<
|
|
163
|
-
final
|
|
164
|
-
final paths = apiData?['paths'] as Map<String, dynamic>? ?? {};
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
for (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
208
|
+
Map<String, List<Map<String, String>>> _getEndpointsByTag() {
|
|
209
|
+
final byTag = <String, List<Map<String, String>>>{};
|
|
210
|
+
final paths = (apiData?['paths'] as Map<String, dynamic>?) ?? {};
|
|
211
|
+
for (final pathEntry in paths.entries) {
|
|
212
|
+
final pathStr = pathEntry.key;
|
|
213
|
+
for (final methodEntry
|
|
214
|
+
in (pathEntry.value as Map<String, dynamic>).entries) {
|
|
215
|
+
final method = methodEntry.key;
|
|
216
|
+
final spec = methodEntry.value as Map<String, dynamic>;
|
|
217
|
+
final tags =
|
|
218
|
+
(spec['tags'] as List<dynamic>?)?.map((t) => t.toString()).toList()
|
|
219
|
+
?? ['Untagged'];
|
|
220
|
+
for (final tag in tags) {
|
|
221
|
+
byTag.putIfAbsent(tag, () => []).add({
|
|
222
|
+
'path': pathStr,
|
|
223
|
+
'method': method,
|
|
224
|
+
});
|
|
172
225
|
}
|
|
173
226
|
}
|
|
174
227
|
}
|
|
175
228
|
return byTag;
|
|
176
229
|
}
|
|
177
230
|
|
|
178
|
-
@override
|
|
179
|
-
Component build(BuildContext context) {
|
|
180
|
-
return div(classes: 'app-container', [
|
|
181
|
-
_buildSidebar(),
|
|
182
|
-
_buildMainContent(),
|
|
183
|
-
]);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
231
|
Component _buildSidebar() {
|
|
187
232
|
final byTag = _getEndpointsByTag();
|
|
233
|
+
final navGroups = <Component>[];
|
|
234
|
+
|
|
235
|
+
for (final entry in byTag.entries) {
|
|
236
|
+
final tag = entry.key;
|
|
237
|
+
final endpoints = entry.value;
|
|
238
|
+
if (_searchQuery.isNotEmpty &&
|
|
239
|
+
!tag.toLowerCase().contains(_searchQuery.toLowerCase()) &&
|
|
240
|
+
!endpoints.any(
|
|
241
|
+
(ep) => ep['path']!
|
|
242
|
+
.toLowerCase()
|
|
243
|
+
.contains(_searchQuery.toLowerCase()),
|
|
244
|
+
)) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
navGroups.add(div(
|
|
248
|
+
classes: 'tag-group',
|
|
249
|
+
[
|
|
250
|
+
div(classes: 'tag-header', [Component.text('▶ $tag')]),
|
|
251
|
+
div(
|
|
252
|
+
classes: 'tag-endpoints',
|
|
253
|
+
endpoints
|
|
254
|
+
.map<Component>(
|
|
255
|
+
(ep) => a(
|
|
256
|
+
href: '#${ep['method']!.toUpperCase()}-${ep['path']!}',
|
|
257
|
+
classes: 'nav-item',
|
|
258
|
+
[
|
|
259
|
+
span(
|
|
260
|
+
classes: 'method-tag ${ep['method']!.toUpperCase()}',
|
|
261
|
+
[Component.text(ep['method']!.toUpperCase())],
|
|
262
|
+
),
|
|
263
|
+
Component.text(' ${ep['path']!}'),
|
|
264
|
+
],
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
.toList(),
|
|
268
|
+
),
|
|
269
|
+
],
|
|
270
|
+
));
|
|
271
|
+
}
|
|
188
272
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return aside(classes: 'sidebar', [
|
|
196
|
-
div(classes: 'sidebar-header', [
|
|
197
|
-
h2([
|
|
198
|
-
span([text('🐸')], attributes: {'style': 'font-size: 24px;'}),
|
|
199
|
-
text(' FroggyDocs'),
|
|
273
|
+
return div(
|
|
274
|
+
classes: 'sidebar',
|
|
275
|
+
[
|
|
276
|
+
div(classes: 'sidebar-header', [
|
|
277
|
+
h2([Component.text('🐸 FroggyDocs')]),
|
|
200
278
|
]),
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
),
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
]),
|
|
213
|
-
]);
|
|
279
|
+
div(classes: 'search-container', [
|
|
280
|
+
input<String>(
|
|
281
|
+
type: InputType.search,
|
|
282
|
+
classes: 'search-box',
|
|
283
|
+
attributes: const {'placeholder': 'Search tags or endpoints...'},
|
|
284
|
+
onInput: (val) => setState(() => _searchQuery = val),
|
|
285
|
+
),
|
|
286
|
+
]),
|
|
287
|
+
nav(classes: 'nav-list', navGroups),
|
|
288
|
+
],
|
|
289
|
+
);
|
|
214
290
|
}
|
|
215
291
|
|
|
216
|
-
Component
|
|
217
|
-
final
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
292
|
+
Component _buildMain() {
|
|
293
|
+
final paths = (apiData?['paths'] as Map<String, dynamic>?) ?? {};
|
|
294
|
+
final cards = <Component>[];
|
|
295
|
+
|
|
296
|
+
for (final pathEntry in paths.entries) {
|
|
297
|
+
for (final methodEntry
|
|
298
|
+
in (pathEntry.value as Map<String, dynamic>).entries) {
|
|
299
|
+
cards.add(_buildEndpointCard(
|
|
300
|
+
pathEntry.key,
|
|
301
|
+
methodEntry.key,
|
|
302
|
+
methodEntry.value as Map<String, dynamic>,
|
|
303
|
+
));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
226
306
|
|
|
227
|
-
return div(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
span([text(ep.key)]),
|
|
307
|
+
return div(
|
|
308
|
+
classes: 'main-content',
|
|
309
|
+
[
|
|
310
|
+
div(classes: 'header', [
|
|
311
|
+
h1([
|
|
312
|
+
Component.text(
|
|
313
|
+
apiData?['info']?['title']?.toString() ?? 'API Documentation',
|
|
314
|
+
),
|
|
236
315
|
]),
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
Component _buildMainContent() {
|
|
242
|
-
return div(classes: 'main-content', [
|
|
243
|
-
header(classes: 'header', [
|
|
244
|
-
h1([text((apiData?['info'] as Map<String, dynamic>?)?['title'] as String? ?? 'API Documentation')]),
|
|
245
|
-
button(
|
|
246
|
-
classes: 'theme-toggle',
|
|
247
|
-
[text(isDark ? '☀️' : '🌙')],
|
|
248
|
-
events: {
|
|
249
|
-
'click': (_) => setState(() {
|
|
250
|
-
isDark = !isDark;
|
|
316
|
+
button(
|
|
317
|
+
classes: 'theme-toggle',
|
|
318
|
+
onClick: () => setState(() {
|
|
319
|
+
_isDark = !_isDark;
|
|
251
320
|
_applyTheme();
|
|
252
321
|
}),
|
|
253
|
-
|
|
322
|
+
[Component.text(_isDark ? '☀️' : '🌙')],
|
|
323
|
+
),
|
|
324
|
+
]),
|
|
325
|
+
div(classes: 'settings-section', [
|
|
326
|
+
span([Component.text('Authorization: ')]),
|
|
327
|
+
input<String>(
|
|
328
|
+
type: InputType.text,
|
|
329
|
+
classes: 'auth-input',
|
|
330
|
+
attributes: const {'placeholder': 'Bearer token'},
|
|
331
|
+
onInput: (val) => setState(() => _authHeader = val),
|
|
332
|
+
),
|
|
333
|
+
]),
|
|
334
|
+
div(id: 'apiList', cards),
|
|
335
|
+
],
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
Component _buildEndpointCard(
|
|
340
|
+
String path,
|
|
341
|
+
String method,
|
|
342
|
+
Map<String, dynamic> spec,
|
|
343
|
+
) {
|
|
344
|
+
final key = '$method-$path';
|
|
345
|
+
final hasAuth = spec['security'] != null;
|
|
346
|
+
final isExpanded = _expandedBody[key] ?? false;
|
|
347
|
+
final isLoading = _loading[key] ?? false;
|
|
348
|
+
final response = _responses[key];
|
|
349
|
+
|
|
350
|
+
final jsonProps = spec['requestBody']?['content']?['application/json']
|
|
351
|
+
?['schema']?['properties'] as Map<String, dynamic>?;
|
|
352
|
+
final multipartProps = spec['requestBody']?['content']
|
|
353
|
+
?['multipart/form-data']?['schema']?['properties']
|
|
354
|
+
as Map<String, dynamic>?;
|
|
355
|
+
final bodyProps = jsonProps ?? multipartProps;
|
|
356
|
+
final hasFiles = multipartProps != null;
|
|
357
|
+
|
|
358
|
+
final allParams = (spec['parameters'] as List<dynamic>?) ?? [];
|
|
359
|
+
final queryParams = allParams
|
|
360
|
+
.whereType<Map<String, dynamic>>()
|
|
361
|
+
.where((param) => param['in'] == 'query')
|
|
362
|
+
.toList();
|
|
363
|
+
final headerParams = allParams
|
|
364
|
+
.whereType<Map<String, dynamic>>()
|
|
365
|
+
.where((param) => param['in'] == 'header')
|
|
366
|
+
.toList();
|
|
367
|
+
final responseSchemas = spec['responses'] as Map<String, dynamic>?;
|
|
368
|
+
|
|
369
|
+
final children = <Component>[
|
|
370
|
+
div(classes: 'endpoint-header', [
|
|
371
|
+
span(
|
|
372
|
+
classes: 'method-tag ${method.toUpperCase()}',
|
|
373
|
+
[Component.text(method.toUpperCase())],
|
|
254
374
|
),
|
|
375
|
+
span(classes: 'path-text', [Component.text(path)]),
|
|
376
|
+
if (hasAuth) span(classes: 'auth-badge', [Component.text('🔒 Auth')]),
|
|
255
377
|
]),
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
input(
|
|
259
|
-
type: InputType.text,
|
|
260
|
-
classes: 'auth-input',
|
|
261
|
-
attributes: {'placeholder': 'Bearer token'},
|
|
262
|
-
onInput: (val) => setState(() => authHeader = val.toString()),
|
|
263
|
-
),
|
|
378
|
+
p(classes: 'api-description', [
|
|
379
|
+
Component.text(spec['summary']?.toString() ?? 'No description'),
|
|
264
380
|
]),
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
381
|
+
];
|
|
382
|
+
|
|
383
|
+
if (bodyProps != null && bodyProps.isNotEmpty) {
|
|
384
|
+
children.add(button(
|
|
385
|
+
classes: 'try-it-out-btn${isExpanded ? '' : ' secondary'}',
|
|
386
|
+
onClick: () => setState(() => _expandedBody[key] = !isExpanded),
|
|
387
|
+
[Component.text(isExpanded ? 'Hide Request Body' : 'Show Request Body')],
|
|
388
|
+
));
|
|
389
|
+
if (isExpanded) {
|
|
390
|
+
children.add(_buildFormSection(key, bodyProps, hasFiles));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (queryParams.isNotEmpty) {
|
|
395
|
+
children.add(_buildQuerySection(key, queryParams));
|
|
396
|
+
}
|
|
397
|
+
if (headerParams.isNotEmpty) {
|
|
398
|
+
children.add(_buildHeadersSection(key, headerParams));
|
|
399
|
+
}
|
|
400
|
+
if (responseSchemas != null) {
|
|
401
|
+
children.add(_buildResponseSchemas(responseSchemas));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
children.add(button(
|
|
405
|
+
classes: 'try-it-out-btn',
|
|
406
|
+
disabled: isLoading,
|
|
407
|
+
onClick: isLoading
|
|
408
|
+
? null
|
|
409
|
+
: () => _executeRequest(
|
|
410
|
+
method,
|
|
411
|
+
path,
|
|
412
|
+
hasAuth,
|
|
413
|
+
bodyProps,
|
|
414
|
+
queryParams,
|
|
415
|
+
headerParams,
|
|
416
|
+
hasFiles,
|
|
417
|
+
),
|
|
418
|
+
[Component.text(isLoading ? 'Loading...' : 'Try It Out')],
|
|
419
|
+
));
|
|
420
|
+
|
|
421
|
+
if (response != null) {
|
|
422
|
+
children.add(div(classes: 'response-section', [
|
|
423
|
+
h4([Component.text('Response:')]),
|
|
424
|
+
pre(classes: 'response-body', [Component.text(response)]),
|
|
425
|
+
]));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return div(
|
|
429
|
+
id: '${method.toUpperCase()}-$path',
|
|
430
|
+
classes: 'api-section',
|
|
431
|
+
children,
|
|
432
|
+
);
|
|
272
433
|
}
|
|
273
434
|
|
|
274
|
-
Component
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
435
|
+
Component _buildFormSection(
|
|
436
|
+
String key,
|
|
437
|
+
Map<String, dynamic> props,
|
|
438
|
+
bool hasFiles,
|
|
439
|
+
) {
|
|
440
|
+
final fields = props.entries.map<Component>((entry) {
|
|
441
|
+
final name = entry.key;
|
|
442
|
+
final value = entry.value as Map<String, dynamic>;
|
|
443
|
+
final isFile = value['format'] == 'binary';
|
|
444
|
+
final description = value['description']?.toString() ?? '';
|
|
445
|
+
final fieldType = value['type']?.toString() ?? 'string';
|
|
446
|
+
final savedValue = (_bodyValues[key] ?? {})[name] ?? '';
|
|
447
|
+
|
|
448
|
+
return FileField(
|
|
449
|
+
endpointKey: key,
|
|
450
|
+
fieldName: name,
|
|
451
|
+
description: description,
|
|
452
|
+
isFile: isFile,
|
|
453
|
+
fieldType: fieldType,
|
|
454
|
+
savedValue: savedValue,
|
|
455
|
+
onTextChanged: (val) => setState(() {
|
|
456
|
+
_bodyValues[key] ??= {};
|
|
457
|
+
_bodyValues[key]![name] = val;
|
|
458
|
+
}),
|
|
459
|
+
onFileSelected: (bytes, fileName) => setState(() {
|
|
460
|
+
_fileValues[key] ??= {};
|
|
461
|
+
_fileValues[key]![name] = {'bytes': bytes, 'name': fileName};
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
}).toList();
|
|
465
|
+
|
|
466
|
+
return div(classes: 'request-body-form', [
|
|
467
|
+
h3(classes: 'section-title', [
|
|
468
|
+
Component.text(hasFiles ? 'Multipart Form Data' : 'Request Body'),
|
|
469
|
+
]),
|
|
470
|
+
...fields,
|
|
280
471
|
]);
|
|
281
472
|
}
|
|
282
473
|
|
|
283
|
-
Component
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
final
|
|
288
|
-
final
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
474
|
+
Component _buildQuerySection(
|
|
475
|
+
String key,
|
|
476
|
+
List<Map<String, dynamic>> params,
|
|
477
|
+
) {
|
|
478
|
+
final isExpanded = _expandedQuery[key] ?? false;
|
|
479
|
+
final children = <Component>[
|
|
480
|
+
button(
|
|
481
|
+
classes: 'collapsible-btn',
|
|
482
|
+
onClick: () => setState(() => _expandedQuery[key] = !isExpanded),
|
|
483
|
+
[Component.text(isExpanded ? '▲ Query Params' : '▼ Query Params')],
|
|
484
|
+
),
|
|
485
|
+
];
|
|
486
|
+
|
|
487
|
+
if (isExpanded) {
|
|
488
|
+
for (final qp in params) {
|
|
489
|
+
final name = qp['name'] as String;
|
|
490
|
+
final desc = qp['description']?.toString() ?? '';
|
|
491
|
+
final schema = qp['schema'] as Map<String, dynamic>? ?? {};
|
|
492
|
+
final type = schema['type']?.toString() ?? 'string';
|
|
493
|
+
final defaultVal = schema['default']?.toString() ?? '';
|
|
494
|
+
final savedVal = (_queryValues[key] ?? {})[name] ?? '';
|
|
495
|
+
|
|
496
|
+
children.add(div(classes: 'form-field', [
|
|
497
|
+
label([Component.text('$name ($type)')]),
|
|
498
|
+
if (desc.isNotEmpty) span(classes: 'field-desc', [Component.text(desc)]),
|
|
499
|
+
input<String>(
|
|
500
|
+
type: InputType.text,
|
|
501
|
+
classes: 'form-input',
|
|
502
|
+
value: savedVal.isEmpty ? defaultVal : savedVal,
|
|
503
|
+
attributes: {'placeholder': defaultVal},
|
|
504
|
+
onInput: (val) => setState(() {
|
|
505
|
+
_queryValues[key] ??= {};
|
|
506
|
+
_queryValues[key]![name] = val;
|
|
507
|
+
}),
|
|
508
|
+
),
|
|
509
|
+
]));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
292
512
|
|
|
293
|
-
return section
|
|
294
|
-
|
|
295
|
-
attributes: {'id': '${method.toUpperCase()}-$path'},
|
|
296
|
-
[
|
|
297
|
-
div(classes: 'endpoint-header', [
|
|
298
|
-
span(classes: 'method-tag ${method.toUpperCase()}', [text(method.toUpperCase())]),
|
|
299
|
-
span(classes: 'path-text', [text(path)]),
|
|
300
|
-
if (hasAuth) span(classes: 'auth-badge', [text('🔒 Auth')]),
|
|
301
|
-
]),
|
|
302
|
-
p(classes: 'api-description', [text(spec['summary'] ?? 'No description')]),
|
|
513
|
+
return div(classes: 'query-section', children);
|
|
514
|
+
}
|
|
303
515
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
516
|
+
Component _buildHeadersSection(
|
|
517
|
+
String key,
|
|
518
|
+
List<Map<String, dynamic>> params,
|
|
519
|
+
) {
|
|
520
|
+
final isExpanded = _expandedHeaders[key] ?? false;
|
|
521
|
+
final children = <Component>[
|
|
522
|
+
button(
|
|
523
|
+
classes: 'collapsible-btn',
|
|
524
|
+
onClick: () => setState(() => _expandedHeaders[key] = !isExpanded),
|
|
525
|
+
[Component.text(isExpanded ? '▲ Headers' : '▼ Headers')],
|
|
526
|
+
),
|
|
527
|
+
];
|
|
528
|
+
|
|
529
|
+
if (isExpanded) {
|
|
530
|
+
for (final hp in params) {
|
|
531
|
+
final name = hp['name'] as String;
|
|
532
|
+
final desc = hp['description']?.toString() ?? '';
|
|
533
|
+
final schema = hp['schema'] as Map<String, dynamic>? ?? {};
|
|
534
|
+
final type = schema['type']?.toString() ?? 'string';
|
|
535
|
+
final savedVal = (_headerValues[key] ?? {})[name] ?? '';
|
|
536
|
+
|
|
537
|
+
children.add(div(classes: 'form-field', [
|
|
538
|
+
label([Component.text('$name ($type)')]),
|
|
539
|
+
if (desc.isNotEmpty) span(classes: 'field-desc', [Component.text(desc)]),
|
|
540
|
+
input<String>(
|
|
541
|
+
type: InputType.text,
|
|
542
|
+
classes: 'form-input',
|
|
543
|
+
value: savedVal,
|
|
544
|
+
onInput: (val) => setState(() {
|
|
545
|
+
_headerValues[key] ??= {};
|
|
546
|
+
_headerValues[key]![name] = val;
|
|
547
|
+
}),
|
|
309
548
|
),
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
for (var prop in props.entries)
|
|
314
|
-
div(classes: 'form-field', [
|
|
315
|
-
label([text(prop.key), text(' (${prop.value['type'] ?? 'string'})')]),
|
|
316
|
-
if (prop.value['description'] != null && (prop.value['description'] as String).isNotEmpty)
|
|
317
|
-
span(classes: 'field-desc', [text(prop.value['description'])]),
|
|
318
|
-
input(
|
|
319
|
-
type: InputType.text,
|
|
320
|
-
classes: 'form-input',
|
|
321
|
-
attributes: {
|
|
322
|
-
'placeholder': _getDefaultValue(prop.value['type']),
|
|
323
|
-
'value': savedValues[prop.key] ?? '',
|
|
324
|
-
},
|
|
325
|
-
onInput: (val) {
|
|
326
|
-
setState(() {
|
|
327
|
-
_requestBodyValues[key] ??= {};
|
|
328
|
-
_requestBodyValues[key]![prop.key] = val.toString();
|
|
329
|
-
});
|
|
330
|
-
},
|
|
331
|
-
),
|
|
332
|
-
]),
|
|
333
|
-
]),
|
|
334
|
-
],
|
|
335
|
-
],
|
|
549
|
+
]));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
336
552
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
[text(isLoading ? 'Loading...' : 'Try It Out')],
|
|
340
|
-
events: {'click': (_) => _executeRequest(method, path, hasAuth, props)},
|
|
341
|
-
),
|
|
553
|
+
return div(classes: 'headers-section', children);
|
|
554
|
+
}
|
|
342
555
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
556
|
+
Component _buildResponseSchemas(Map<String, dynamic> responses) {
|
|
557
|
+
final items = responses.entries.map<Component>((entry) {
|
|
558
|
+
final code = entry.key;
|
|
559
|
+
final resp = entry.value as Map<String, dynamic>;
|
|
560
|
+
final desc = resp['description']?.toString() ?? '';
|
|
561
|
+
final content =
|
|
562
|
+
resp['content']?['application/json'] as Map<String, dynamic>?;
|
|
563
|
+
final schema = content?['schema'] as Map<String, dynamic>?;
|
|
564
|
+
final example = content?['example'];
|
|
565
|
+
|
|
566
|
+
return div(classes: 'response-schema', [
|
|
567
|
+
div(classes: 'response-code', [Component.text('$code - $desc')]),
|
|
568
|
+
if (schema != null)
|
|
569
|
+
div(classes: 'schema-type', [
|
|
570
|
+
Component.text('Type: ${schema['type'] ?? 'object'}'),
|
|
347
571
|
]),
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
h3(classes: 'section-title', [text('Parameters')]),
|
|
352
|
-
table(classes: 'params-table', [
|
|
353
|
-
thead([
|
|
354
|
-
tr([
|
|
355
|
-
th([text('Field')]),
|
|
356
|
-
th([text('Type')]),
|
|
357
|
-
th([text('Description')]),
|
|
358
|
-
]),
|
|
359
|
-
]),
|
|
360
|
-
tbody([
|
|
361
|
-
for (var prop in props.entries)
|
|
362
|
-
tr([
|
|
363
|
-
td([
|
|
364
|
-
b([text(prop.key)]),
|
|
365
|
-
]),
|
|
366
|
-
td([
|
|
367
|
-
span(classes: 'type-label', [text((prop.value['type'] ?? 'string').toString())]),
|
|
368
|
-
]),
|
|
369
|
-
td([text((prop.value['description'] ?? '-').toString())]),
|
|
370
|
-
]),
|
|
371
|
-
]),
|
|
572
|
+
if (example != null)
|
|
573
|
+
pre(classes: 'schema-example', [
|
|
574
|
+
Component.text(const JsonEncoder.withIndent(' ').convert(example)),
|
|
372
575
|
]),
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
576
|
+
]);
|
|
577
|
+
}).toList();
|
|
578
|
+
|
|
579
|
+
return div(classes: 'response-schemas', [
|
|
580
|
+
h3(classes: 'section-title', [Component.text('Responses')]),
|
|
581
|
+
...items,
|
|
582
|
+
]);
|
|
376
583
|
}
|
|
377
584
|
}
|