Compare commits

...

4 Commits

Author SHA1 Message Date
Nikolas Rimikis 77727e7ab0
test(dynamite): add openapi petstore as dynamite integration test 1 year ago
Nikolas Rimikis 0ded61d9ce
feat(dynamite): support all specs based on openapi 3.0.0 and later 1 year ago
Nikolas Rimikis 3f95251476
fix(dynamite): fix documentation with empty strings 1 year ago
Nikolas Rimikis ec6e7cafd0
feat(dynamite): fully support application/octet-stream and more gracefully handle multiple media types 1 year ago
  1. 139
      packages/dynamite/dynamite/lib/src/builder/client.dart
  2. 148
      packages/dynamite/dynamite/lib/src/builder/resolve_mime_type.dart
  3. 2
      packages/dynamite/dynamite/lib/src/models/operation.dart
  4. 6
      packages/dynamite/dynamite/lib/src/openapi_builder.dart
  5. 18
      packages/dynamite/dynamite/lib/src/type_result/base.dart
  6. 5
      packages/dynamite/dynamite/lib/src/type_result/type_result.dart
  7. 1
      packages/dynamite/dynamite/pubspec.yaml
  8. 7
      packages/dynamite/dynamite_end_to_end_test/.gitignore
  9. 5
      packages/dynamite/dynamite_end_to_end_test/analysis_options.yaml
  10. 1
      packages/dynamite/dynamite_end_to_end_test/lib/petstore.dart
  11. 1200
      packages/dynamite/dynamite_end_to_end_test/lib/src/petstore.openapi.dart
  12. 1933
      packages/dynamite/dynamite_end_to_end_test/lib/src/petstore.openapi.g.dart
  13. 1
      packages/dynamite/dynamite_end_to_end_test/lib/src/petstore.openapi.json
  14. 30
      packages/dynamite/dynamite_end_to_end_test/pubspec.yaml
  15. 8
      packages/dynamite/dynamite_end_to_end_test/pubspec_overrides.yaml
  16. 15
      tool/generate-dynamite-test.sh

139
packages/dynamite/dynamite/lib/src/builder/client.dart

@ -1,6 +1,7 @@
import 'package:built_collection/built_collection.dart'; import 'package:built_collection/built_collection.dart';
import 'package:code_builder/code_builder.dart'; import 'package:code_builder/code_builder.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dynamite/src/builder/resolve_mime_type.dart';
import 'package:dynamite/src/builder/resolve_object.dart'; import 'package:dynamite/src/builder/resolve_object.dart';
import 'package:dynamite/src/builder/resolve_type.dart'; import 'package:dynamite/src/builder/resolve_type.dart';
import 'package:dynamite/src/builder/state.dart'; import 'package:dynamite/src/builder/state.dart';
@ -302,7 +303,11 @@ Iterable<Method> buildTags(
var responses = <Response, List<int>>{}; var responses = <Response, List<int>>{};
if (operation.responses != null) { if (operation.responses != null) {
for (final responseEntry in operation.responses!.entries) { for (final responseEntry in operation.responses!.entries) {
final statusCode = int.parse(responseEntry.key); final statusCode = int.tryParse(responseEntry.key);
if (statusCode == null) {
print('Default responses are not supported right now. Skipping it for $operationId');
continue;
}
final response = responseEntry.value; final response = responseEntry.value;
responses[response] ??= []; responses[response] ??= [];
@ -448,64 +453,18 @@ if (${toDartName(parameter.name)}.length > ${parameter.schema!.maxLength!}) {
} }
} }
if (operation.requestBody != null) { resolveMimeTypeEncode(
if (operation.requestBody!.content!.length > 1) { operation,
throw Exception('Can not work with multiple mime types right now'); spec,
} state,
for (final content in operation.requestBody!.content!.entries) { operationId,
final mimeType = content.key; b,
final mediaType = content.value; code,
);
code.write("_headers['Content-Type'] = '$mimeType';");
final dartParameterNullable = isDartParameterNullable(
operation.requestBody!.required,
mediaType.schema,
);
final result = resolveType(
spec,
state,
toDartName('$operationId-request-$mimeType', uppercaseFirstCharacter: true),
mediaType.schema!,
nullable: dartParameterNullable,
);
final parameterName = toDartName(result.name.replaceFirst(state.classPrefix, ''));
switch (mimeType) {
case 'application/json':
case 'application/x-www-form-urlencoded':
final dartParameterRequired = isRequired(
operation.requestBody!.required,
mediaType.schema?.$default,
);
b.optionalParameters.add(
Parameter(
(final b) => b
..name = parameterName
..type = refer(result.nullableName)
..named = true
..required = dartParameterRequired,
),
);
if (dartParameterNullable) {
code.write('if ($parameterName != null) {');
}
code.write(
'_body = utf8.encode(${result.encode(parameterName, mimeType: mimeType)}) as Uint8List;',
);
if (dartParameterNullable) {
code.write('}');
}
default:
throw Exception('Can not parse mime type "$mimeType"');
}
}
}
code.write( code.write(
''' '''
final _response = await ${isRootClient ? 'this' : '_rootClient'}.doRequest( ${responses.values.isNotEmpty ? 'final _response =' : ''} await ${isRootClient ? 'this' : '_rootClient'}.doRequest(
'$httpMethod', '$httpMethod',
Uri(path: _path, queryParameters: _queryParameters.isNotEmpty ? _queryParameters : null), Uri(path: _path, queryParameters: _queryParameters.isNotEmpty ? _queryParameters : null),
_headers, _headers,
@ -547,54 +506,15 @@ final _response = await ${isRootClient ? 'this' : '_rootClient'}.doRequest(
headersValue = result.deserialize('_response.responseHeaders'); headersValue = result.deserialize('_response.responseHeaders');
} }
String? dataType; final (dataType, dataValue, dataNeedsAwait) = resolveMimeTypeDecode(
String? dataValue; response,
bool? dataNeedsAwait; spec,
if (response.content != null) { state,
if (response.content!.length > 1) { toDartName(
throw Exception('Can not work with multiple mime types right now'); '$operationId-response${responses.entries.length > 1 ? '-${responses.entries.toList().indexOf(responseEntry)}' : ''}',
} uppercaseFirstCharacter: true,
for (final content in response.content!.entries) { ),
final mimeType = content.key; );
final mediaType = content.value;
final result = resolveType(
spec,
state,
toDartName(
'$operationId-response${responses.entries.length > 1 ? '-${responses.entries.toList().indexOf(responseEntry)}' : ''}-$mimeType',
uppercaseFirstCharacter: true,
),
mediaType.schema!,
);
if (mimeType == '*/*' || mimeType == 'application/octet-stream' || mimeType.startsWith('image/')) {
dataType = 'Uint8List';
dataValue = '_response.bodyBytes';
dataNeedsAwait = true;
} else if (mimeType.startsWith('text/') || mimeType == 'application/javascript') {
dataType = 'String';
dataValue = '_response.body';
dataNeedsAwait = true;
} else if (mimeType == 'application/json') {
dataType = result.name;
if (result.name == 'dynamic') {
dataValue = '';
} else if (result.name == 'String') {
dataValue = '_response.body';
dataNeedsAwait = true;
} else if (result is TypeResultEnum || result is TypeResultBase) {
dataValue = result.deserialize(result.decode('await _response.body'));
dataNeedsAwait = false;
} else {
dataValue = result.deserialize('await _response.jsonBody');
dataNeedsAwait = false;
}
} else {
throw Exception('Can not parse mime type "$mimeType"');
}
}
}
if (headersType != null && dataType != null) { if (headersType != null && dataType != null) {
b.returns = refer('Future<${state.classPrefix}Response<$dataType, $headersType>>'); b.returns = refer('Future<${state.classPrefix}Response<$dataType, $headersType>>');
@ -614,9 +534,14 @@ final _response = await ${isRootClient ? 'this' : '_rootClient'}.doRequest(
code.write('}'); code.write('}');
} }
code.write(
'throw await ${state.classPrefix}ApiException.fromResponse(_response); // coverage:ignore-line\n', if (responses.values.isNotEmpty) {
); code.write(
'throw await ${state.classPrefix}ApiException.fromResponse(_response); // coverage:ignore-line\n',
);
} else {
b.returns = refer('Future<void>');
}
b.body = Code(code.toString()); b.body = Code(code.toString());
}, },

148
packages/dynamite/dynamite/lib/src/builder/resolve_mime_type.dart

@ -0,0 +1,148 @@
import 'package:code_builder/code_builder.dart';
import 'package:dynamite/src/builder/resolve_type.dart';
import 'package:dynamite/src/builder/state.dart';
import 'package:dynamite/src/helpers/dart_helpers.dart';
import 'package:dynamite/src/helpers/dynamite.dart';
import 'package:dynamite/src/models/open_api.dart';
import 'package:dynamite/src/models/operation.dart';
import 'package:dynamite/src/models/response.dart';
import 'package:dynamite/src/type_result/type_result.dart';
(String? dataType, String? dataValue, bool? dataNeedsAwait) resolveMimeTypeDecode(
final Response response,
final OpenAPI spec,
final State state,
final String identifier,
) {
if (response.content != null) {
if (response.content!.length > 1) {
print('Can not work with multiple mime types right now. Using the first supported.');
}
for (final content in response.content!.entries) {
final mimeType = content.key;
final mediaType = content.value;
final result = resolveType(
spec,
state,
'identifier-$mimeType',
mediaType.schema!,
);
if (mimeType == '*/*' || mimeType == 'application/octet-stream' || mimeType.startsWith('image/')) {
return ('Uint8List', '_response.bodyBytes', true);
} else if (mimeType.startsWith('text/') || mimeType == 'application/javascript') {
return ('String', '_response.body', true);
} else if (mimeType == 'application/json') {
String? dataValue;
bool? dataNeedsAwait;
if (result.name == 'dynamic') {
dataValue = '';
} else if (result.name == 'String') {
dataValue = '_response.body';
dataNeedsAwait = true;
} else if (result is TypeResultEnum || result is TypeResultBase) {
dataValue = result.deserialize(result.decode('await _response.body'));
dataNeedsAwait = false;
} else {
dataValue = result.deserialize('await _response.jsonBody');
dataNeedsAwait = false;
}
return (result.name, dataValue, dataNeedsAwait);
}
}
throw Exception('Can not parse any mime type of Operation:"$identifier"');
}
return (null, null, null);
}
void resolveMimeTypeEncode(
final Operation operation,
final OpenAPI spec,
final State state,
final String identifier,
final MethodBuilder b,
final StringBuffer code,
) {
if (operation.requestBody != null) {
if (operation.requestBody!.content!.length > 1) {
print('Can not work with multiple mime types right now. Using the first supported.');
}
for (final content in operation.requestBody!.content!.entries) {
final mimeType = content.key;
final mediaType = content.value;
code.write("_headers['Content-Type'] = '$mimeType';");
final dartParameterNullable = isDartParameterNullable(
operation.requestBody!.required,
mediaType.schema,
);
final result = resolveType(
spec,
state,
toDartName('$identifier-request-$mimeType', uppercaseFirstCharacter: true),
mediaType.schema!,
nullable: dartParameterNullable,
);
final parameterName = toDartName(result.name.replaceFirst(state.classPrefix, ''));
switch (mimeType) {
case 'application/json':
case 'application/x-www-form-urlencoded':
final dartParameterRequired = isRequired(
operation.requestBody!.required,
mediaType.schema?.$default,
);
b.optionalParameters.add(
Parameter(
(final b) => b
..name = parameterName
..type = refer(result.nullableName)
..named = true
..required = dartParameterRequired,
),
);
if (dartParameterNullable) {
code.write('if ($parameterName != null) {');
}
code.write(
'_body = utf8.encode(${result.encode(parameterName, mimeType: mimeType)}) as Uint8List;',
);
if (dartParameterNullable) {
code.write('}');
}
return;
case 'application/octet-stream':
final dartParameterRequired = isRequired(
operation.requestBody!.required,
mediaType.schema?.$default,
);
b.optionalParameters.add(
Parameter(
(final b) => b
..name = parameterName
..type = refer(result.nullableName)
..named = true
..required = dartParameterRequired,
),
);
if (dartParameterNullable) {
code.write('if ($parameterName != null) {');
}
code.write(
'_body = ${result.encode(parameterName, mimeType: mimeType)};',
);
if (dartParameterNullable) {
code.write('}');
}
return;
}
}
throw Exception('Can not parse any mime type of Operation:"$identifier"');
}
}

2
packages/dynamite/dynamite/lib/src/models/operation.dart

@ -38,7 +38,7 @@ abstract class Operation implements Built<Operation, OperationBuilder> {
Iterable<String> get formattedDescription sync* { Iterable<String> get formattedDescription sync* {
yield* descriptionToDocs(summary); yield* descriptionToDocs(summary);
if (summary != null && description != null) { if (summary != null && summary!.isNotEmpty && description != null && description!.isNotEmpty) {
yield docsSeparator; yield docsSeparator;
} }

6
packages/dynamite/dynamite/lib/src/openapi_builder.dart

@ -13,6 +13,7 @@ import 'package:dynamite/src/helpers/dart_helpers.dart';
import 'package:dynamite/src/models/open_api.dart'; import 'package:dynamite/src/models/open_api.dart';
import 'package:dynamite/src/models/serializers.dart'; import 'package:dynamite/src/models/serializers.dart';
import 'package:dynamite/src/type_result/type_result.dart'; import 'package:dynamite/src/type_result/type_result.dart';
import 'package:pub_semver/pub_semver.dart';
class OpenAPIBuilder implements Builder { class OpenAPIBuilder implements Builder {
@override @override
@ -36,9 +37,8 @@ class OpenAPIBuilder implements Builder {
json.decode(await buildStep.readAsString(inputId)), json.decode(await buildStep.readAsString(inputId)),
)!; )!;
final supportedVersions = ['3.0.3', '3.1.0']; if (Version.parse(spec.version).major != 3) {
if (!supportedVersions.contains(spec.version)) { throw Exception('Only OpenAPI 3.0.0 and later are supported');
throw Exception('Only OpenAPI ${supportedVersions.join(', ')} are supported');
} }
final state = State(spec.info.title); final state = State(spec.info.title);

18
packages/dynamite/dynamite/lib/src/type_result/base.dart

@ -21,8 +21,22 @@ class TypeResultBase extends TypeResult {
final String object, { final String object, {
final bool onlyChildren = false, final bool onlyChildren = false,
final String? mimeType, final String? mimeType,
}) => }) {
name == 'String' ? object : '$object.toString()'; switch (mimeType) {
case null:
case 'application/json':
case 'application/x-www-form-urlencoded':
if (className == 'String') {
return object;
} else {
return '$object.toString()';
}
case 'application/octet-stream':
return 'utf8.encode($object) as Uint8List';
default:
throw Exception('Can not encode mime type "$mimeType"');
}
}
@override @override
String deserialize(final String object, {final bool toBuilder = false}) => '($object as $nullableName)'; String deserialize(final String object, {final bool toBuilder = false}) => '($object as $nullableName)';

5
packages/dynamite/dynamite/lib/src/type_result/type_result.dart

@ -87,6 +87,11 @@ abstract class TypeResult {
return 'json.encode($serialized)'; return 'json.encode($serialized)';
case 'application/x-www-form-urlencoded': case 'application/x-www-form-urlencoded':
return 'Uri(queryParameters: $serialized! as Map<String, dynamic>).query'; return 'Uri(queryParameters: $serialized! as Map<String, dynamic>).query';
case 'application/octet-stream':
if (className != 'Uint8List') {
throw Exception('octet-stream can only be applied to binary data. Expected Uint8List but got $className');
}
return '$object as Uint8List';
default: default:
throw Exception('Can not encode mime type "$mimeType"'); throw Exception('Can not encode mime type "$mimeType"');
} }

1
packages/dynamite/dynamite/pubspec.yaml

@ -14,6 +14,7 @@ dependencies:
intersperse: ^2.0.0 intersperse: ^2.0.0
meta: ^1.9.1 meta: ^1.9.1
path: ^1.8.3 path: ^1.8.3
pub_semver: ^2.1.4
dev_dependencies: dev_dependencies:
build_runner: ^2.4.6 build_runner: ^2.4.6

7
packages/dynamite/dynamite_end_to_end_test/.gitignore vendored

@ -0,0 +1,7 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/
# Avoid committing pubspec.lock for library packages; see
# https://dart.dev/guides/libraries/private-files#pubspeclock.
pubspec.lock

5
packages/dynamite/dynamite_end_to_end_test/analysis_options.yaml

@ -0,0 +1,5 @@
include: package:neon_lints/dart.yaml
analyzer:
exclude:
- '**.g.dart'

1
packages/dynamite/dynamite_end_to_end_test/lib/petstore.dart

@ -0,0 +1 @@
export 'src/petstore.openapi.dart';

1200
packages/dynamite/dynamite_end_to_end_test/lib/src/petstore.openapi.dart

File diff suppressed because it is too large Load Diff

1933
packages/dynamite/dynamite_end_to_end_test/lib/src/petstore.openapi.g.dart

File diff suppressed because it is too large Load Diff

1
packages/dynamite/dynamite_end_to_end_test/lib/src/petstore.openapi.json

File diff suppressed because one or more lines are too long

30
packages/dynamite/dynamite_end_to_end_test/pubspec.yaml

@ -0,0 +1,30 @@
name: dynamite_end_to_end_test
publish_to: none
description: Tests for dynamite. Not meant for publishing.
version: 1.0.0
environment:
sdk: '>=3.1.1 <4.0.0'
dependencies:
built_collection: ^5.1.1
built_value: ^8.6.2
dynamite_runtime:
git:
url: https://github.com/nextcloud/neon
path: packages/dynamite/dynamite_runtime
universal_io: ^2.2.2
dev_dependencies:
build_runner: ^2.4.6
built_value_generator: ^8.6.2
dynamite:
git:
url: https://github.com/nextcloud/neon
path: packages/dynamite/dynamite
lints: ^2.0.0
neon_lints:
git:
url: https://github.com/nextcloud/neon
path: packages/neon_lints
test: ^1.21.0

8
packages/dynamite/dynamite_end_to_end_test/pubspec_overrides.yaml

@ -0,0 +1,8 @@
# melos_managed_dependency_overrides: dynamite,dynamite_runtime,neon_lints
dependency_overrides:
dynamite:
path: ../dynamite
dynamite_runtime:
path: ../dynamite_runtime
neon_lints:
path: ../../neon_lints

15
tool/generate-dynamite-test.sh

@ -0,0 +1,15 @@
#!/bin/bash
set -euxo pipefail
cd "$(dirname "$0")/.."
(
cd packages/dynamite/dynamite_end_to_end_test
wget https://petstore3.swagger.io/api/v3/openapi.json -O lib/src/petstore.openapi.json
rm -rf .dart_tool/build
fvm dart pub run build_runner build --delete-conflicting-outputs
# For some reason we need to fix and format twice, otherwise not everything gets fixed
fvm dart fix --apply lib/src/
melos run format
fvm dart fix --apply lib/src/
melos run format
)
Loading…
Cancel
Save