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.
@@ -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:http/http.dart' as http;
4
- import 'dart:convert';
5
- import 'dart:async';
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? timer;
18
- bool isDark = false;
19
- String searchQuery = '';
20
- String authHeader = '';
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>> _requestBodyValues = {};
24
- final Map<String, bool> _expandedEndpoints = {};
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
- _loadData();
30
- timer = Timer.periodic(const Duration(seconds: 3), (_) => _loadData());
31
-
41
+ _loadSpec();
42
+ _timer = Timer.periodic(const Duration(seconds: 3), (_) => _loadSpec());
32
43
  try {
33
- if (html.window.matchMedia('(prefers-color-scheme: dark)').matches) {
34
- isDark = true;
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
- timer?.cancel();
53
+ _timer?.cancel();
54
+ _toastTimer?.cancel();
43
55
  super.dispose();
44
56
  }
45
57
 
46
58
  void _applyTheme() {
47
59
  try {
48
- html.document.documentElement?.setAttribute('data-theme', isDark ? 'dark' : 'light');
60
+ web.document.documentElement
61
+ ?.setAttribute('data-theme', _isDark ? 'dark' : 'light');
49
62
  } catch (_) {}
50
63
  }
51
64
 
52
- Future<void> _loadData() async {
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(() => apiData = data as Map<String, dynamic>);
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(String method, String path, bool hasAuth, Map<String, dynamic>? props) async {
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
- final baseUrl = html.window.location.origin;
68
- final url = '$baseUrl$path';
69
-
70
- final headers = <String, String>{
71
- 'Content-Type': 'application/json',
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
- if (authHeader.isNotEmpty) {
75
- headers['Authorization'] = authHeader;
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
- String body = '{}';
79
- if (props != null && props.isNotEmpty) {
80
- final Map<String, dynamic> bodyData = {};
81
- final savedValues = _requestBodyValues[key] ?? {};
82
- for (var prop in props.entries) {
83
- final value = savedValues[prop.key] ?? _getDefaultValue(prop.value['type']);
84
- if (value.isNotEmpty) {
85
- bodyData[prop.key] = _parseValue(value, prop.value['type']);
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
- body = jsonEncode(bodyData);
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
- http.Response resp;
92
- switch (method.toUpperCase()) {
93
- case 'GET':
94
- resp = await http.get(Uri.parse(url), headers: headers);
95
- break;
96
- case 'POST':
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
- if (resp.statusCode >= 200 && resp.statusCode < 300) {
114
- try {
115
- _responses[key] = JsonEncoder.withIndent(' ').convert(jsonDecode(resp.body));
116
- } catch (_) {
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
- String _getDefaultValue(String? type) {
133
- switch (type?.toString().toLowerCase()) {
134
- case 'number':
135
- return '0';
136
- case 'boolean':
137
- return 'true';
138
- case 'array':
139
- return '[]';
140
- case 'object':
141
- return '{}';
142
- default:
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<MapEntry<String, dynamic>>> _getEndpointsByTag() {
163
- final Map<String, List<MapEntry<String, dynamic>>> byTag = {};
164
- final paths = apiData?['paths'] as Map<String, dynamic>? ?? {};
165
-
166
- for (var path in paths.entries) {
167
- for (var method in (path.value as Map<String, dynamic>).entries) {
168
- final tags = (method.value['tags'] as List?) ?? ['Untagged'];
169
- for (var tag in tags) {
170
- byTag.putIfAbsent(tag.toString(), () => []);
171
- byTag[tag.toString()]!.add(MapEntry(path.key, method));
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
- final filtered = byTag.entries.where((e) {
190
- if (searchQuery.isEmpty) return true;
191
- if (e.key.toLowerCase().contains(searchQuery.toLowerCase())) return true;
192
- return e.value.any((ep) => ep.key.toLowerCase().contains(searchQuery.toLowerCase()));
193
- }).toList();
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
- div(classes: 'search-container', [
203
- input(
204
- type: InputType.text,
205
- classes: 'search-box',
206
- attributes: {'placeholder': 'Search tags or endpoints...'},
207
- onInput: (val) => setState(() => searchQuery = val.toString()),
208
- ),
209
- ]),
210
- nav(classes: 'nav-list', [
211
- for (var tagGroup in filtered) _buildTagGroup(tagGroup.key, tagGroup.value),
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 _buildTagGroup(String tag, List<MapEntry<String, dynamic>> endpoints) {
217
- final matchesSearch = searchQuery.isEmpty || tag.toLowerCase().contains(searchQuery.toLowerCase());
218
-
219
- // When searching by tag name, show ALL endpoints in that tag
220
- // When searching by endpoint path, filter endpoints
221
- final filtered = (searchQuery.isEmpty || matchesSearch)
222
- ? endpoints
223
- : endpoints.where((ep) => ep.key.toLowerCase().contains(searchQuery.toLowerCase())).toList();
224
-
225
- if (searchQuery.isNotEmpty && filtered.isEmpty) return div([]);
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(classes: 'tag-group', [
228
- div(classes: 'tag-header', [
229
- span([text('▶ $tag')]),
230
- ]),
231
- div(classes: 'tag-endpoints', [
232
- for (var ep in filtered)
233
- a(classes: 'nav-item', href: '#${ep.value.key.toUpperCase()}-${ep.key}', [
234
- span(classes: 'method-tag ${ep.value.key.toUpperCase()}', [text(ep.value.key.toUpperCase())]),
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
- div(classes: 'settings-section', [
257
- div([text('Authorization: ')]),
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
- if (apiData == null)
266
- div(classes: 'api-section', [
267
- p([text('Loading...')]),
268
- ])
269
- else
270
- _buildApiList(),
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 _buildApiList() {
275
- final paths = apiData!['paths'] as Map<String, dynamic>? ?? {};
276
- return div([
277
- for (var path in paths.entries)
278
- for (var method in (path.value as Map<String, dynamic>).entries)
279
- _buildEndpoint(path.key, method.key, method.value),
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 _buildEndpoint(String path, String method, dynamic spec) {
284
- final props =
285
- spec['requestBody']?['content']?['application/json']?['schema']?['properties'] as Map<String, dynamic>?;
286
- final hasAuth = spec['security'] != null;
287
- final key = '$method-$path';
288
- final response = _responses[key];
289
- final isLoading = _loading[key] ?? false;
290
- final isExpanded = _expandedEndpoints[key] ?? false;
291
- final savedValues = _requestBodyValues[key] ?? {};
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
- classes: 'api-section',
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
- if (props != null && props.isNotEmpty) ...[
305
- button(
306
- classes: 'try-it-out-btn',
307
- [text(isExpanded ? 'Hide Request Body' : 'Show Request Body')],
308
- events: {'click': (_) => setState(() => _expandedEndpoints[key] = !isExpanded)},
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
- if (isExpanded) ...[
311
- h3(classes: 'section-title', [text('Request Body')]),
312
- div(classes: 'request-body-form', [
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
- button(
338
- classes: 'try-it-out-btn ${props != null && props.isNotEmpty ? 'secondary' : ''}',
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
- if (response != null) ...[
344
- div(classes: 'response-section', [
345
- h4([text('Response:')]),
346
- pre(classes: 'response-body', [text(response)]),
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
- if (props != null && props.isNotEmpty) ...[
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
  }