froggy-docs 1.1.1 → 1.1.4

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,536 @@ 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
+ final isGetOrHead = method.toUpperCase() == 'GET' || method.toUpperCase() == 'HEAD';
116
+ if (!hasFiles && !isGetOrHead) headers['Content-Type'] = 'application/json';
117
+ if (_authHeader.isNotEmpty) headers['Authorization'] = _authHeader;
118
+ for (final hp in headerParams) {
119
+ final name = hp['name'] as String;
120
+ final val = (_headerValues[key] ?? {})[name] ?? '';
121
+ if (val.isNotEmpty) headers[name] = val;
76
122
  }
77
123
 
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']);
124
+ http.Response resp;
125
+
126
+ if (hasFiles) {
127
+ final req = http.MultipartRequest(method.toUpperCase(), uri);
128
+ req.headers.addAll(headers);
129
+ final savedValues = _bodyValues[key] ?? {};
130
+ final savedFiles = _fileValues[key] ?? {};
131
+
132
+ for (final entry in (bodyProps ?? {}).entries) {
133
+ final name = entry.key;
134
+ final prop = entry.value as Map<String, dynamic>;
135
+ if (prop['format'] == 'binary') {
136
+ final fileData = savedFiles[name];
137
+ if (fileData != null) {
138
+ req.files.add(http.MultipartFile.fromBytes(
139
+ name,
140
+ fileData['bytes'] as List<int>,
141
+ filename: fileData['name'] as String,
142
+ ));
143
+ }
144
+ } else {
145
+ final val = savedValues[name] ?? '';
146
+ if (val.isNotEmpty) req.fields[name] = val;
86
147
  }
87
148
  }
88
- body = jsonEncode(bodyData);
149
+
150
+ final streamed = await req.send();
151
+ resp = await http.Response.fromStream(streamed);
152
+ } else if (isGetOrHead) {
153
+ resp = await http.get(uri, headers: headers);
154
+ } else {
155
+ final req = http.Request(method.toUpperCase(), uri);
156
+ req.headers.addAll(headers);
157
+
158
+ final savedValues = _bodyValues[key] ?? {};
159
+ final bodyData = <String, dynamic>{};
160
+ for (final entry in (bodyProps ?? {}).entries) {
161
+ final val = savedValues[entry.key] ?? '';
162
+ if (val.isNotEmpty) bodyData[entry.key] = val;
163
+ }
164
+ req.body = jsonEncode(bodyData);
165
+
166
+ final client = http.Client();
167
+ try {
168
+ final streamed = await client.send(req);
169
+ resp = await http.Response.fromStream(streamed);
170
+ } finally {
171
+ client.close();
172
+ }
89
173
  }
90
174
 
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);
175
+ String responseText;
176
+ try {
177
+ final json = jsonDecode(resp.body);
178
+ responseText = const JsonEncoder.withIndent(' ').convert(json);
179
+ } catch (_) {
180
+ responseText = resp.body;
110
181
  }
111
182
 
112
183
  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
- }
184
+ _responses[key] =
185
+ resp.statusCode >= 200 && resp.statusCode < 300
186
+ ? responseText
187
+ : 'Error ${resp.statusCode}: $responseText';
122
188
  });
123
189
  } catch (e) {
124
- setState(() {
125
- _responses[key] = 'Error: $e';
126
- });
190
+ setState(() => _responses[key] = 'Error: $e');
127
191
  } finally {
128
192
  setState(() => _loading[key] = false);
129
193
  }
130
194
  }
131
195
 
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
- }
196
+ @override
197
+ Component build(BuildContext context) {
198
+ return div(
199
+ classes: 'app-container',
200
+ [
201
+ _buildSidebar(),
202
+ _buildMain(),
203
+ if (_toast != null)
204
+ div(classes: 'toast', [Component.text(_toast!)]),
205
+ ],
206
+ );
160
207
  }
161
208
 
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));
209
+ Map<String, List<Map<String, String>>> _getEndpointsByTag() {
210
+ final byTag = <String, List<Map<String, String>>>{};
211
+ final paths = (apiData?['paths'] as Map<String, dynamic>?) ?? {};
212
+ for (final pathEntry in paths.entries) {
213
+ final pathStr = pathEntry.key;
214
+ for (final methodEntry
215
+ in (pathEntry.value as Map<String, dynamic>).entries) {
216
+ final method = methodEntry.key;
217
+ final spec = methodEntry.value as Map<String, dynamic>;
218
+ final tags =
219
+ (spec['tags'] as List<dynamic>?)?.map((t) => t.toString()).toList()
220
+ ?? ['Untagged'];
221
+ for (final tag in tags) {
222
+ byTag.putIfAbsent(tag, () => []).add({
223
+ 'path': pathStr,
224
+ 'method': method,
225
+ });
172
226
  }
173
227
  }
174
228
  }
175
229
  return byTag;
176
230
  }
177
231
 
178
- @override
179
- Component build(BuildContext context) {
180
- return div(classes: 'app-container', [
181
- _buildSidebar(),
182
- _buildMainContent(),
183
- ]);
184
- }
185
-
186
232
  Component _buildSidebar() {
187
233
  final byTag = _getEndpointsByTag();
234
+ final navGroups = <Component>[];
235
+
236
+ for (final entry in byTag.entries) {
237
+ final tag = entry.key;
238
+ final endpoints = entry.value;
239
+ if (_searchQuery.isNotEmpty &&
240
+ !tag.toLowerCase().contains(_searchQuery.toLowerCase()) &&
241
+ !endpoints.any(
242
+ (ep) => ep['path']!
243
+ .toLowerCase()
244
+ .contains(_searchQuery.toLowerCase()),
245
+ )) {
246
+ continue;
247
+ }
248
+ navGroups.add(div(
249
+ classes: 'tag-group',
250
+ [
251
+ div(classes: 'tag-header', [Component.text('▶ $tag')]),
252
+ div(
253
+ classes: 'tag-endpoints',
254
+ endpoints
255
+ .map<Component>(
256
+ (ep) => a(
257
+ href: '#${ep['method']!.toUpperCase()}-${ep['path']!}',
258
+ classes: 'nav-item',
259
+ [
260
+ span(
261
+ classes: 'method-tag ${ep['method']!.toUpperCase()}',
262
+ [Component.text(ep['method']!.toUpperCase())],
263
+ ),
264
+ Component.text(' ${ep['path']!}'),
265
+ ],
266
+ ),
267
+ )
268
+ .toList(),
269
+ ),
270
+ ],
271
+ ));
272
+ }
188
273
 
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'),
274
+ return div(
275
+ classes: 'sidebar',
276
+ [
277
+ div(classes: 'sidebar-header', [
278
+ h2([Component.text('🐸 FroggyDocs')]),
200
279
  ]),
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
- ]);
280
+ div(classes: 'search-container', [
281
+ input<String>(
282
+ type: InputType.search,
283
+ classes: 'search-box',
284
+ attributes: const {'placeholder': 'Search tags or endpoints...'},
285
+ onInput: (val) => setState(() => _searchQuery = val),
286
+ ),
287
+ ]),
288
+ nav(classes: 'nav-list', navGroups),
289
+ ],
290
+ );
214
291
  }
215
292
 
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([]);
293
+ Component _buildMain() {
294
+ final paths = (apiData?['paths'] as Map<String, dynamic>?) ?? {};
295
+ final cards = <Component>[];
296
+
297
+ for (final pathEntry in paths.entries) {
298
+ for (final methodEntry
299
+ in (pathEntry.value as Map<String, dynamic>).entries) {
300
+ cards.add(_buildEndpointCard(
301
+ pathEntry.key,
302
+ methodEntry.key,
303
+ methodEntry.value as Map<String, dynamic>,
304
+ ));
305
+ }
306
+ }
226
307
 
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)]),
308
+ return div(
309
+ classes: 'main-content',
310
+ [
311
+ div(classes: 'header', [
312
+ h1([
313
+ Component.text(
314
+ apiData?['info']?['title']?.toString() ?? 'API Documentation',
315
+ ),
236
316
  ]),
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;
317
+ button(
318
+ classes: 'theme-toggle',
319
+ onClick: () => setState(() {
320
+ _isDark = !_isDark;
251
321
  _applyTheme();
252
322
  }),
253
- },
323
+ [Component.text(_isDark ? '☀️' : '🌙')],
324
+ ),
325
+ ]),
326
+ div(classes: 'settings-section', [
327
+ span([Component.text('Authorization: ')]),
328
+ input<String>(
329
+ type: InputType.text,
330
+ classes: 'auth-input',
331
+ attributes: const {'placeholder': 'Bearer token'},
332
+ onInput: (val) => setState(() => _authHeader = val),
333
+ ),
334
+ ]),
335
+ div(id: 'apiList', cards),
336
+ ],
337
+ );
338
+ }
339
+
340
+ Component _buildEndpointCard(
341
+ String path,
342
+ String method,
343
+ Map<String, dynamic> spec,
344
+ ) {
345
+ final key = '$method-$path';
346
+ final hasAuth = spec['security'] != null;
347
+ final isExpanded = _expandedBody[key] ?? false;
348
+ final isLoading = _loading[key] ?? false;
349
+ final response = _responses[key];
350
+
351
+ final jsonProps = spec['requestBody']?['content']?['application/json']
352
+ ?['schema']?['properties'] as Map<String, dynamic>?;
353
+ final multipartProps = spec['requestBody']?['content']
354
+ ?['multipart/form-data']?['schema']?['properties']
355
+ as Map<String, dynamic>?;
356
+ final bodyProps = jsonProps ?? multipartProps;
357
+ final hasFiles = multipartProps != null;
358
+
359
+ final allParams = (spec['parameters'] as List<dynamic>?) ?? [];
360
+ final queryParams = allParams
361
+ .whereType<Map<String, dynamic>>()
362
+ .where((param) => param['in'] == 'query')
363
+ .toList();
364
+ final headerParams = allParams
365
+ .whereType<Map<String, dynamic>>()
366
+ .where((param) => param['in'] == 'header')
367
+ .toList();
368
+ final responseSchemas = spec['responses'] as Map<String, dynamic>?;
369
+
370
+ final children = <Component>[
371
+ div(classes: 'endpoint-header', [
372
+ span(
373
+ classes: 'method-tag ${method.toUpperCase()}',
374
+ [Component.text(method.toUpperCase())],
254
375
  ),
376
+ span(classes: 'path-text', [Component.text(path)]),
377
+ if (hasAuth) span(classes: 'auth-badge', [Component.text('🔒 Auth')]),
255
378
  ]),
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
- ),
379
+ p(classes: 'api-description', [
380
+ Component.text(spec['summary']?.toString() ?? 'No description'),
264
381
  ]),
265
- if (apiData == null)
266
- div(classes: 'api-section', [
267
- p([text('Loading...')]),
268
- ])
269
- else
270
- _buildApiList(),
271
- ]);
382
+ ];
383
+
384
+ if (bodyProps != null && bodyProps.isNotEmpty) {
385
+ children.add(button(
386
+ classes: 'try-it-out-btn${isExpanded ? '' : ' secondary'}',
387
+ onClick: () => setState(() => _expandedBody[key] = !isExpanded),
388
+ [Component.text(isExpanded ? 'Hide Request Body' : 'Show Request Body')],
389
+ ));
390
+ if (isExpanded) {
391
+ children.add(_buildFormSection(key, bodyProps, hasFiles));
392
+ }
393
+ }
394
+
395
+ if (queryParams.isNotEmpty) {
396
+ children.add(_buildQuerySection(key, queryParams));
397
+ }
398
+ if (headerParams.isNotEmpty) {
399
+ children.add(_buildHeadersSection(key, headerParams));
400
+ }
401
+ if (responseSchemas != null) {
402
+ children.add(_buildResponseSchemas(responseSchemas));
403
+ }
404
+
405
+ children.add(button(
406
+ classes: 'try-it-out-btn',
407
+ disabled: isLoading,
408
+ onClick: isLoading
409
+ ? null
410
+ : () => _executeRequest(
411
+ method,
412
+ path,
413
+ hasAuth,
414
+ bodyProps,
415
+ queryParams,
416
+ headerParams,
417
+ hasFiles,
418
+ ),
419
+ [Component.text(isLoading ? 'Loading...' : 'Try It Out')],
420
+ ));
421
+
422
+ if (response != null) {
423
+ children.add(div(classes: 'response-section', [
424
+ h4([Component.text('Response:')]),
425
+ pre(classes: 'response-body', [Component.text(response)]),
426
+ ]));
427
+ }
428
+
429
+ return div(
430
+ id: '${method.toUpperCase()}-$path',
431
+ classes: 'api-section',
432
+ children,
433
+ );
272
434
  }
273
435
 
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),
436
+ Component _buildFormSection(
437
+ String key,
438
+ Map<String, dynamic> props,
439
+ bool hasFiles,
440
+ ) {
441
+ final fields = props.entries.map<Component>((entry) {
442
+ final name = entry.key;
443
+ final value = entry.value as Map<String, dynamic>;
444
+ final isFile = value['format'] == 'binary';
445
+ final description = value['description']?.toString() ?? '';
446
+ final fieldType = value['type']?.toString() ?? 'string';
447
+ final savedValue = (_bodyValues[key] ?? {})[name] ?? '';
448
+
449
+ return FileField(
450
+ endpointKey: key,
451
+ fieldName: name,
452
+ description: description,
453
+ isFile: isFile,
454
+ fieldType: fieldType,
455
+ savedValue: savedValue,
456
+ onTextChanged: (val) => setState(() {
457
+ _bodyValues[key] ??= {};
458
+ _bodyValues[key]![name] = val;
459
+ }),
460
+ onFileSelected: (bytes, fileName) => setState(() {
461
+ _fileValues[key] ??= {};
462
+ _fileValues[key]![name] = {'bytes': bytes, 'name': fileName};
463
+ }),
464
+ );
465
+ }).toList();
466
+
467
+ return div(classes: 'request-body-form', [
468
+ h3(classes: 'section-title', [
469
+ Component.text(hasFiles ? 'Multipart Form Data' : 'Request Body'),
470
+ ]),
471
+ ...fields,
280
472
  ]);
281
473
  }
282
474
 
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] ?? {};
475
+ Component _buildQuerySection(
476
+ String key,
477
+ List<Map<String, dynamic>> params,
478
+ ) {
479
+ final isExpanded = _expandedQuery[key] ?? false;
480
+ final children = <Component>[
481
+ button(
482
+ classes: 'collapsible-btn',
483
+ onClick: () => setState(() => _expandedQuery[key] = !isExpanded),
484
+ [Component.text(isExpanded ? '▲ Query Params' : '▼ Query Params')],
485
+ ),
486
+ ];
487
+
488
+ if (isExpanded) {
489
+ for (final qp in params) {
490
+ final name = qp['name'] as String;
491
+ final desc = qp['description']?.toString() ?? '';
492
+ final schema = qp['schema'] as Map<String, dynamic>? ?? {};
493
+ final type = schema['type']?.toString() ?? 'string';
494
+ final defaultVal = schema['default']?.toString() ?? '';
495
+ final savedVal = (_queryValues[key] ?? {})[name] ?? '';
496
+
497
+ children.add(div(classes: 'form-field', [
498
+ label([Component.text('$name ($type)')]),
499
+ if (desc.isNotEmpty) span(classes: 'field-desc', [Component.text(desc)]),
500
+ input<String>(
501
+ type: InputType.text,
502
+ classes: 'form-input',
503
+ value: savedVal.isEmpty ? defaultVal : savedVal,
504
+ attributes: {'placeholder': defaultVal},
505
+ onInput: (val) => setState(() {
506
+ _queryValues[key] ??= {};
507
+ _queryValues[key]![name] = val;
508
+ }),
509
+ ),
510
+ ]));
511
+ }
512
+ }
292
513
 
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')]),
514
+ return div(classes: 'query-section', children);
515
+ }
303
516
 
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)},
517
+ Component _buildHeadersSection(
518
+ String key,
519
+ List<Map<String, dynamic>> params,
520
+ ) {
521
+ final isExpanded = _expandedHeaders[key] ?? false;
522
+ final children = <Component>[
523
+ button(
524
+ classes: 'collapsible-btn',
525
+ onClick: () => setState(() => _expandedHeaders[key] = !isExpanded),
526
+ [Component.text(isExpanded ? '▲ Headers' : '▼ Headers')],
527
+ ),
528
+ ];
529
+
530
+ if (isExpanded) {
531
+ for (final hp in params) {
532
+ final name = hp['name'] as String;
533
+ final desc = hp['description']?.toString() ?? '';
534
+ final schema = hp['schema'] as Map<String, dynamic>? ?? {};
535
+ final type = schema['type']?.toString() ?? 'string';
536
+ final savedVal = (_headerValues[key] ?? {})[name] ?? '';
537
+
538
+ children.add(div(classes: 'form-field', [
539
+ label([Component.text('$name ($type)')]),
540
+ if (desc.isNotEmpty) span(classes: 'field-desc', [Component.text(desc)]),
541
+ input<String>(
542
+ type: InputType.text,
543
+ classes: 'form-input',
544
+ value: savedVal,
545
+ onInput: (val) => setState(() {
546
+ _headerValues[key] ??= {};
547
+ _headerValues[key]![name] = val;
548
+ }),
309
549
  ),
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
- ],
550
+ ]));
551
+ }
552
+ }
336
553
 
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
- ),
554
+ return div(classes: 'headers-section', children);
555
+ }
342
556
 
343
- if (response != null) ...[
344
- div(classes: 'response-section', [
345
- h4([text('Response:')]),
346
- pre(classes: 'response-body', [text(response)]),
557
+ Component _buildResponseSchemas(Map<String, dynamic> responses) {
558
+ final items = responses.entries.map<Component>((entry) {
559
+ final code = entry.key;
560
+ final resp = entry.value as Map<String, dynamic>;
561
+ final desc = resp['description']?.toString() ?? '';
562
+ final content =
563
+ resp['content']?['application/json'] as Map<String, dynamic>?;
564
+ final schema = content?['schema'] as Map<String, dynamic>?;
565
+ final example = content?['example'];
566
+
567
+ return div(classes: 'response-schema', [
568
+ div(classes: 'response-code', [Component.text('$code - $desc')]),
569
+ if (schema != null)
570
+ div(classes: 'schema-type', [
571
+ Component.text('Type: ${schema['type'] ?? 'object'}'),
347
572
  ]),
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
- ]),
573
+ if (example != null)
574
+ pre(classes: 'schema-example', [
575
+ Component.text(const JsonEncoder.withIndent(' ').convert(example)),
372
576
  ]),
373
- ],
374
- ],
375
- );
577
+ ]);
578
+ }).toList();
579
+
580
+ return div(classes: 'response-schemas', [
581
+ h3(classes: 'section-title', [Component.text('Responses')]),
582
+ ...items,
583
+ ]);
376
584
  }
377
585
  }