Merge branch 'foss42:main' into add-request-cancellation

This commit is contained in:
Sasank Tumpati
2024-12-07 11:32:08 +05:30
committed by GitHub
22 changed files with 498 additions and 122 deletions

View File

@ -1,15 +1,31 @@
## API Dash Roadmap
### L1 Priority (PRs will be actively reviewed)
- [x] Remaining Code Generators (https://github.com/foss42/apidash/discussions/80)
- [x] Environment Variables (https://github.com/foss42/apidash/issues/25)
- [ ] Git Support (https://github.com/foss42/apidash/issues/502)
- [x] Integration Testing (https://github.com/foss42/apidash/issues/119)
- [ ] Paste not working on iOS and desktop (Right Click -> Paste) (https://github.com/foss42/apidash/issues/490)
- [ ] WebSocket support (https://github.com/foss42/apidash/issues/15)
- [ ] SSE support (https://github.com/foss42/apidash/issues/116)
- [ ] MQTT support (https://github.com/foss42/apidash/issues/115)
- [ ] GraphQL support (https://github.com/foss42/apidash/issues/117)
- [ ] gRPC support (https://github.com/foss42/apidash/issues/14)
- [ ] API Testing Suite (https://github.com/foss42/apidash/discussions/96, https://github.com/foss42/apidash/issues/100)
- [ ] API Workflow Builder (https://github.com/foss42/apidash/issues/120)
- [x] Integration Testing (https://github.com/foss42/apidash/issues/119)
- [ ] Remaining Code Generators (https://github.com/foss42/apidash/discussions/80)
- [ ] Figuring out how to build for various Linux packaging formats (https://github.com/foss42/apidash/discussions/240) [Docs]
- [ ] Save items in Response headers/body in an Environment variable to be used by other requests (https://github.com/foss42/apidash/issues/465)
- [ ] Importers
- [ ] OpenAPI (https://github.com/foss42/apidash/issues/121)
- [ ] Insomnia (https://github.com/foss42/apidash/issues/125)
- [ ] Hurl (https://github.com/foss42/apidash/issues/123)
- [ ] HAR (https://github.com/foss42/apidash/issues/122)
### L2 Priority (Will require more design/technical research & thinking and PRs will consume more time)
- [ ] Embedded WebView in Response Previewer (https://github.com/foss42/apidash/issues/155)
- [ ] Figuring out how to build for various Linux packaging formats (https://github.com/foss42/apidash/discussions/240)
- [ ] Git Support (https://github.com/foss42/apidash/issues/502)
- [ ] API Testing Suite (https://github.com/foss42/apidash/discussions/96)
- [ ] Provide Mock Data (https://github.com/foss42/apidash/issues/496)
- [ ] Ability to stress test APIs (https://github.com/foss42/apidash/issues/100)
- [ ] API Workflow Builder (https://github.com/foss42/apidash/issues/120)
- [ ] OAuth 2.0 auth (https://github.com/foss42/apidash/issues/481)
- [ ] Add UI scaling (https://github.com/foss42/apidash/issues/466)

6
doc/dev_guide/README.md Normal file
View File

@ -0,0 +1,6 @@
# API Dash Developer Guide
1. [How to run API Dash locally?](https://github.com/foss42/apidash/blob/main/doc/dev_guide/setup_run.md)
2. [Platform-specific Additional Instructions](https://github.com/foss42/apidash/blob/main/doc/dev_guide/platform_specific_instructions.md)
3. [How to run tests?](https://github.com/foss42/apidash/blob/main/doc/dev_guide/testing.md)
4. [Integration Testing](https://github.com/foss42/apidash/blob/main/doc/dev_guide/integration_testing.md)

View File

@ -433,6 +433,7 @@ const kLabelDuplicate = "Duplicate";
const kLabelSelect = "Select";
const kLabelContinue = "Continue";
const kLabelCancel = "Cancel";
const kLabelOk = "Ok";
// Request Pane
const kLabelRequest = "Request";
const kLabelHideCode = "Hide Code";

View File

@ -8,31 +8,25 @@ class CurlFileImport {
final curl = Curl.parse(content);
final url = stripUriParams(curl.uri);
final method = HTTPVerb.values.byName(curl.method.toLowerCase());
final headers = curl.headers?.entries
.map((entry) => NameValueModel(
name: entry.key,
value: entry.value,
))
.toList();
final params = curl.uri.queryParameters.entries
.map((entry) => NameValueModel(
name: entry.key,
value: entry.value,
))
.toList();
// TODO: parse curl data to determine the type of body
final headers = mapToRows(curl.headers);
final params = mapToRows(curl.uri.queryParameters);
final body = curl.data;
// TODO: formdata with file paths must be set to empty as
// there will be permission issue while trying to access the path
final formData = curl.formData;
// Determine content type based on form data and headers
final ContentType contentType = curl.form
? ContentType.formdata
: (getContentTypeFromHeadersMap(curl.headers) ?? ContentType.text);
return HttpRequestModel(
method: method,
url: url,
headers: headers,
params: params,
body: body,
);
method: method,
url: url,
headers: headers,
params: params,
body: body,
bodyContentType: contentType,
formData: formData);
} catch (e) {
return null;
}

View File

@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:apidash/providers/providers.dart';
import 'package:apidash/widgets/widgets.dart';
import 'importer.dart';
void importToCollectionPane(
BuildContext context,
WidgetRef ref,
ScaffoldMessengerState sm,
) {
// TODO: The dialog must have a feature to paste contents in a text field
// Also, a mechanism can be added where on importing a file it shows the
// contents in the text field and then the user presses ok to add it to collection
showImportDialog(
context: context,
importFormat: ref.watch(importFormatStateProvider),
onImportFormatChange: (format) {
if (format != null) {
ref.read(importFormatStateProvider.notifier).state = format;
}
},
onFileDropped: (file) {
final importFormatType = ref.read(importFormatStateProvider);
sm.hideCurrentSnackBar();
file.readAsString().then(
(content) {
kImporter
.getHttpRequestModel(importFormatType, content)
.then((importedRequestModel) {
if (importedRequestModel != null) {
ref
.read(collectionStateNotifierProvider.notifier)
.addRequestModel(importedRequestModel);
// Solves - Do not use BuildContexts across async gaps
if (!context.mounted) return;
Navigator.of(context).pop();
} else {
var err = "Unable to parse ${file.name}";
sm.showSnackBar(getSnackBar(err, small: false));
}
});
},
onError: (e) {
var err = "Unable to import ${file.name}";
sm.showSnackBar(getSnackBar(err, small: false));
},
);
},
);
}

View File

@ -13,3 +13,5 @@ class Importer {
}
}
}
final kImporter = Importer();

View File

@ -1,16 +1,14 @@
import 'package:apidash_design_system/apidash_design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:apidash/importer/import_dialog.dart';
import 'package:apidash/providers/providers.dart';
import 'package:apidash/importer/importer.dart';
import 'package:apidash/extensions/extensions.dart';
import 'package:apidash/widgets/widgets.dart';
import 'package:apidash/models/models.dart';
import 'package:apidash/consts.dart';
import '../common_widgets/common_widgets.dart';
final kImporter = Importer();
class CollectionPane extends ConsumerWidget {
const CollectionPane({
super.key,
@ -19,6 +17,7 @@ class CollectionPane extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final collection = ref.watch(collectionStateNotifierProvider);
var sm = ScaffoldMessenger.of(context);
if (collection == null) {
return const Center(
child: CircularProgressIndicator(),
@ -37,32 +36,7 @@ class CollectionPane extends ConsumerWidget {
ref.read(collectionStateNotifierProvider.notifier).add();
},
onImport: () {
showImportDialog(
context: context,
importFormat: ref.watch(importFormatStateProvider),
onImportFormatChange: (format) {
if (format != null) {
ref.read(importFormatStateProvider.notifier).state = format;
}
},
onFileDropped: (file) {
final importFormatType = ref.read(importFormatStateProvider);
file.readAsString().then((content) {
kImporter
.getHttpRequestModel(importFormatType, content)
.then((importedRequestModel) {
if (importedRequestModel != null) {
ref
.read(collectionStateNotifierProvider.notifier)
.addRequestModel(importedRequestModel);
} else {
// TODO: Throw an error, unable to parse
}
});
});
Navigator.of(context).pop();
},
);
importToCollectionPane(context, ref, sm);
},
),
kVSpacer10,

View File

@ -0,0 +1,33 @@
import 'package:apidash/consts.dart';
import 'package:apidash_design_system/apidash_design_system.dart';
import 'package:flutter/material.dart';
showTextDialog(
BuildContext context, {
String? dialogTitle,
String? content,
String? buttonLabel,
}) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
icon: const Icon(Icons.edit_rounded),
iconColor: Theme.of(context).colorScheme.primary,
title: Text(dialogTitle ?? ""),
titleTextStyle: Theme.of(context).textTheme.titleLarge,
content: Container(
padding: kPt20,
width: 300,
child: Text(content ?? ""),
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(buttonLabel ?? kLabelOk)),
],
);
});
}

View File

@ -18,6 +18,7 @@ export 'dialog_about.dart';
export 'dialog_history_retention.dart';
export 'dialog_import.dart';
export 'dialog_rename.dart';
export 'dialog_text.dart';
export 'drag_and_drop_area.dart';
export 'dropdown_codegen.dart';
export 'dropdown_content_type.dart';

View File

@ -1 +1,2 @@
export 'string_extensions.dart';
export 'map_extensions.dart';

View File

@ -0,0 +1,26 @@
import 'dart:io';
extension MapExtension on Map {
bool hasKeyContentType() {
return keys.any((k) => (k is String)
? k.toLowerCase() == HttpHeaders.contentTypeHeader
: false);
}
String? getKeyContentType() {
if (isEmpty) {
return null;
}
bool present = hasKeyContentType();
if (present) {
return keys.firstWhere((e) => (e is String)
? e.toLowerCase() == HttpHeaders.contentTypeHeader
: false);
}
return null;
}
String? getValueContentType() {
return this[getKeyContentType()];
}
}

View File

@ -1,7 +1,7 @@
import 'dart:io';
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:seed/seed.dart';
import '../extensions/extensions.dart';
import '../utils/utils.dart'
show rowsToFormDataMapList, rowsToMap, getEnabledRows;
import '../consts.dart';
@ -43,8 +43,7 @@ class HttpRequestModel with _$HttpRequestModel {
Map<String, String> get enabledHeadersMap => rowsToMap(enabledHeaders) ?? {};
Map<String, String> get enabledParamsMap => rowsToMap(enabledParams) ?? {};
bool get hasContentTypeHeader => enabledHeadersMap.keys
.any((k) => k.toLowerCase() == HttpHeaders.contentTypeHeader);
bool get hasContentTypeHeader => enabledHeadersMap.hasKeyContentType();
bool get hasFormDataContentType => bodyContentType == ContentType.formdata;
bool get hasJsonContentType => bodyContentType == ContentType.json;
bool get hasTextContentType => bodyContentType == ContentType.text;

View File

@ -5,6 +5,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:collection/collection.dart' show mergeMaps;
import 'package:http/http.dart';
import 'package:http_parser/http_parser.dart';
import '../extensions/extensions.dart';
import '../utils/utils.dart';
import '../consts.dart';
@ -61,7 +62,7 @@ class HttpResponseModel with _$HttpResponseModel {
factory HttpResponseModel.fromJson(Map<String, Object?> json) =>
_$HttpResponseModelFromJson(json);
String? get contentType => getContentTypeFromHeaders(headers);
String? get contentType => headers?.getValueContentType();
MediaType? get mediaType => getMediaTypeFromHeaders(headers);
HttpResponseModel fromResponse({

View File

@ -0,0 +1,39 @@
import 'package:http_parser/http_parser.dart';
import '../consts.dart';
import '../extensions/extensions.dart';
ContentType? getContentTypeFromHeadersMap(
Map<String, String>? kvMap,
) {
if (kvMap != null && kvMap.hasKeyContentType()) {
var val = getMediaTypeFromHeaders(kvMap);
if (val != null) {
if (val.subtype.contains(kSubTypeJson)) {
return ContentType.json;
} else if (val.type == kTypeMultipart &&
val.subtype == kSubTypeFormData) {
return ContentType.formdata;
}
return ContentType.text;
}
}
return null;
}
MediaType? getMediaTypeFromHeaders(Map? headers) {
var contentType = headers?.getValueContentType();
MediaType? mediaType = getMediaTypeFromContentType(contentType);
return mediaType;
}
MediaType? getMediaTypeFromContentType(String? contentType) {
if (contentType != null) {
try {
MediaType mediaType = MediaType.parse(contentType);
return mediaType;
} catch (e) {
return null;
}
}
return null;
}

View File

@ -1,8 +1,10 @@
import 'package:collection/collection.dart';
import 'package:seed/seed.dart';
Map<String, String>? rowsToMap(List<NameValueModel>? kvRows,
{bool isHeader = false}) {
Map<String, String>? rowsToMap(
List<NameValueModel>? kvRows, {
bool isHeader = false,
}) {
if (kvRows == null) {
return null;
}
@ -19,7 +21,9 @@ Map<String, String>? rowsToMap(List<NameValueModel>? kvRows,
return finalMap;
}
List<NameValueModel>? mapToRows(Map<String, String>? kvMap) {
List<NameValueModel>? mapToRows(
Map<String, String>? kvMap,
) {
if (kvMap == null) {
return null;
}
@ -50,7 +54,9 @@ List<Map<String, String>>? rowsToFormDataMapList(
return finalMap;
}
List<FormDataModel>? mapListToFormDataModelRows(List<Map>? kvMap) {
List<FormDataModel>? mapListToFormDataModelRows(
List<Map>? kvMap,
) {
if (kvMap == null) {
return null;
}
@ -72,7 +78,9 @@ FormDataType getFormDataType(String? type) {
}
List<NameValueModel>? getEnabledRows(
List<NameValueModel>? rows, List<bool>? isRowEnabledList) {
List<NameValueModel>? rows,
List<bool>? isRowEnabledList,
) {
if (rows == null || isRowEnabledList == null) {
return rows;
}

View File

@ -1,33 +1,10 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:xml/xml.dart';
import '../consts.dart';
String? getContentTypeFromHeaders(Map? headers) {
return headers?[HttpHeaders.contentTypeHeader];
}
MediaType? getMediaTypeFromHeaders(Map? headers) {
var contentType = getContentTypeFromHeaders(headers);
MediaType? mediaType = getMediaTypeFromContentType(contentType);
return mediaType;
}
MediaType? getMediaTypeFromContentType(String? contentType) {
if (contentType != null) {
try {
MediaType mediaType = MediaType.parse(contentType);
return mediaType;
} catch (e) {
return null;
}
}
return null;
}
String? formatBody(String? body, MediaType? mediaType) {
if (mediaType != null && body != null) {
var subtype = mediaType.subtype;

View File

@ -1,3 +1,4 @@
export 'content_type_utils.dart';
export 'http_request_utils.dart';
export 'http_response_utils.dart';
export 'string_utils.dart';

View File

@ -0,0 +1,129 @@
import 'package:apidash_core/apidash_core.dart';
import 'package:test/test.dart';
void main() {
group('Testing MapExtensions', () {
group('Testing hasKeyContentType()', () {
test('Content-Type present should return true', () {
Map<String, String> mapEx = {"Content-Type": "x", "Agent": "Test"};
expect(mapEx.hasKeyContentType(), true);
});
test('content-Type present should return true', () {
Map<String, String> mapEx = {"content-Type": "x", "Agent": "Test"};
expect(mapEx.hasKeyContentType(), true);
});
test('empty should return false', () {
Map<String, String> mapEx = {};
expect(mapEx.hasKeyContentType(), false);
});
test('No content-type present should return false', () {
Map<String, String> mapEx = {"Agent": "Test"};
expect(mapEx.hasKeyContentType(), false);
});
test('Different datatype should return false', () {
Map mapEx = {1: "Test"};
expect(mapEx.hasKeyContentType(), false);
});
test('Mixed datatype but should return true', () {
Map mapEx = {1: "Test", "content-type": "x"};
expect(mapEx.hasKeyContentType(), true);
});
});
group('Testing getKeyContentType()', () {
test('Content-Type present', () {
Map<String, String> mapEx = {"Agent": "Test", "Content-Type": "x"};
expect(mapEx.getKeyContentType(), "Content-Type");
});
test('content-Type present', () {
Map<String, String> mapEx = {"Agent": "Test", "content-Type": "x"};
expect(mapEx.getKeyContentType(), "content-Type");
});
test('empty should return null', () {
Map<String, String> mapEx = {};
expect(mapEx.getKeyContentType(), null);
});
test('No content-type present should return null', () {
Map<String, String> mapEx = {"Agent": "Test"};
expect(mapEx.getKeyContentType(), null);
});
test('Different datatype should return null', () {
Map mapEx = {1: "Test"};
expect(mapEx.getKeyContentType(), null);
});
test('Mixed datatype but should return content-type', () {
Map mapEx = {1: "Test", "content-type": "x"};
expect(mapEx.getKeyContentType(), "content-type");
});
test('Multiple occurence should return first', () {
Map mapEx = {1: "Test", "content-Type": "y", "content-type": "x"};
expect(mapEx.getKeyContentType(), "content-Type");
});
});
});
group('Testing getValueContentType()', () {
test('Content-Type present', () {
Map<String, String> mapEx = {"Agent": "Test", "Content-Type": "x"};
expect(mapEx.getValueContentType(), "x");
});
test('content-Type present', () {
Map<String, String> mapEx = {"Agent": "Test", "content-Type": "x"};
expect(mapEx.getValueContentType(), "x");
});
test('empty should return null', () {
Map<String, String> mapEx = {};
expect(mapEx.getValueContentType(), null);
});
test('No content-type present should return null', () {
Map<String, String> mapEx = {"Agent": "Test"};
expect(mapEx.getValueContentType(), null);
});
test('Different datatype should return null', () {
Map mapEx = {1: "Test"};
expect(mapEx.getValueContentType(), null);
});
test('Mixed datatype but should return x', () {
Map mapEx = {1: "Test", "content-type": "x"};
expect(mapEx.getValueContentType(), "x");
});
test('Multiple occurence should return first', () {
Map mapEx = {1: "Test", "content-Type": "y", "content-type": "x"};
expect(mapEx.getValueContentType(), "y");
});
});
group("Testing ?.getValueContentType() function", () {
test('Testing ?.getValueContentType() for header1', () {
Map<String, String> header1 = {
"content-type": "application/json",
};
String contentType1Expected = "application/json";
expect(header1.getValueContentType(), contentType1Expected);
});
test('Testing ?.getValueContentType() when header keys are in header case',
() {
Map<String, String> header2 = {
"Content-Type": "application/json",
};
expect(header2.getValueContentType(), "application/json");
});
});
}

View File

@ -1,30 +1,8 @@
import 'package:apidash_core/utils/http_response_utils.dart';
import 'package:apidash_core/utils/string_utils.dart';
import 'package:apidash_core/utils/utils.dart';
import 'package:http_parser/http_parser.dart';
import 'package:test/test.dart';
void main() {
group("Testing getContentTypeFromHeaders function", () {
test('Testing getContentTypeFromHeaders for header1', () {
Map<String, String> header1 = {
"content-type": "application/json",
};
String contentType1Expected = "application/json";
expect(getContentTypeFromHeaders(header1), contentType1Expected);
});
test('Testing getContentTypeFromHeaders for null headers', () {
expect(getContentTypeFromHeaders(null), null);
});
test(
'Testing getContentTypeFromHeaders when header keys are in header case',
() {
Map<String, String> header2 = {
"Content-Type": "application/json",
};
expect(getContentTypeFromHeaders(header2), null);
});
});
group('Testing getMediaTypeFromContentType function', () {
test('Testing getMediaTypeFromContentType for json type', () {
String contentType1 = "application/json";

View File

@ -1,7 +1,31 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# Avoid committing pubspec.lock for library packages; see
# https://dart.dev/guides/libraries/private-files#pubspeclock.
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
pubspec.lock
**/doc/api/
.dart_tool/
.packages
build/
**/golden/**/failures/
coverage/

View File

@ -1,9 +1,30 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# Avoid committing pubspec.lock for library packages; see
# https://dart.dev/guides/libraries/private-files#pubspeclock.
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
pubspec.lock
**/doc/api/
.dart_tool/
.packages
build/
coverage/

View File

@ -0,0 +1,94 @@
import 'package:apidash/importer/curl/curl.dart';
import 'package:test/test.dart';
import 'package:apidash_core/apidash_core.dart';
void main() {
group('CurlFileImport Tests', () {
late CurlFileImport curlImport;
setUp(() {
curlImport = CurlFileImport();
});
test('should parse simple GET request', () {
const curl = 'curl https://api.apidash.dev/users';
final result = curlImport.getHttpRequestModel(curl);
expect(
result,
const HttpRequestModel(
method: HTTPVerb.get,
url: 'https://api.apidash.dev/users',
headers: null,
params: [],
body: null,
bodyContentType: ContentType.text,
formData: null));
});
test('should parse POST request with JSON body and headers', () {
const curl = '''
curl -X POST https://api.apidash.dev/users
-H "Content-Type: application/json"
-H "Authorization: Bearer token123"
-d '{"name": "John", "age": 30}'
''';
final result = curlImport.getHttpRequestModel(curl);
expect(
result,
const HttpRequestModel(
method: HTTPVerb.post,
url: 'https://api.apidash.dev/users',
headers: [
NameValueModel(name: 'Content-Type', value: 'application/json'),
NameValueModel(name: 'Authorization', value: 'Bearer token123'),
],
params: [],
body: '{"name": "John", "age": 30}',
bodyContentType: ContentType.json,
formData: null,
),
);
});
test('should parse form data request', () {
const curl = '''
curl -X POST https://api.apidash.dev/upload
-F "file=@photo.jpg"
-F "description=My Photo"
''';
final result = curlImport.getHttpRequestModel(curl);
expect(
result,
const HttpRequestModel(
method: HTTPVerb.post,
url: 'https://api.apidash.dev/upload',
headers: [
NameValueModel(name: "Content-Type", value: "multipart/form-data")
],
params: [],
body: null,
bodyContentType: ContentType.formdata,
formData: [
FormDataModel(
name: 'file', value: 'photo.jpg', type: FormDataType.file),
FormDataModel(
name: 'description',
value: 'My Photo',
type: FormDataType.text),
],
));
});
test('should return null for invalid curl command', () {
const curl = 'invalid curl command';
final result = curlImport.getHttpRequestModel(curl);
expect(result, isNull);
});
});
}