From 47084ccfa2af290081ae0716d52d5172056cb5b9 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 8 Mar 2024 08:55:52 +0000 Subject: [PATCH] :bookmark: release v7.2.0 (#581) * :bookmark: release v7.2.0 # chopper ## 7.2.0 - #579 # chopper_generator ## 7.2.0 - #579 --------- Signed-off-by: dependabot[bot] Co-authored-by: wenchieh --- .github/workflows/dart.yml | 8 +- chopper/CHANGELOG.md | 4 + chopper/lib/src/annotations.dart | 29 +++++- chopper/pubspec.yaml | 2 +- chopper_generator/CHANGELOG.md | 4 + chopper_generator/lib/src/generator.dart | 94 +++++++++++++++--- chopper_generator/pubspec.yaml | 4 +- .../test/test_service.chopper.dart | 95 +++++++++++++++++++ chopper_generator/test/test_service.dart | 27 ++++++ requests.md | 63 +++++++----- 10 files changed, 284 insertions(+), 46 deletions(-) diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 91f883dd..67485f82 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cache Pub hosted dependencies - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 + uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 with: path: "~/.pub-cache/hosted" key: "os:ubuntu-latest;pub-cache-hosted;sdk:stable" @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cache Pub hosted dependencies - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 + uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 with: path: "~/.pub-cache/hosted" key: "os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:chopper-chopper_built_value-chopper_generator;commands:format-analyze" @@ -105,7 +105,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cache Pub hosted dependencies - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 + uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 with: path: "~/.pub-cache/hosted" key: "os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:chopper-chopper_built_value-chopper_generator;commands:test_with_coverage" @@ -176,7 +176,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cache Pub hosted dependencies - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 + uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 with: path: "~/.pub-cache/hosted" key: "os:ubuntu-latest;pub-cache-hosted;sdk:stable;packages:chopper-chopper_built_value;commands:test" diff --git a/chopper/CHANGELOG.md b/chopper/CHANGELOG.md index a1621713..dea66c34 100644 --- a/chopper/CHANGELOG.md +++ b/chopper/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 7.2.0 + +- Add support for `@FormUrlEncoded` annotation ([#579](https://github.com/lejard-h/chopper/pull/579)) + ## 7.1.1+1 - Export `ChopperHttpException` in library exports ([#570](https://github.com/lejard-h/chopper/pull/570)) diff --git a/chopper/lib/src/annotations.dart b/chopper/lib/src/annotations.dart index ffc690e7..b4d84aca 100644 --- a/chopper/lib/src/annotations.dart +++ b/chopper/lib/src/annotations.dart @@ -433,7 +433,7 @@ final class Field { /// /// ```dart /// @Post(path: '/something') -/// Future fetch(@FieldMap List> query); +/// Future fetch(@FieldMap Map query); /// ``` /// {@endtemplate} @immutable @@ -533,6 +533,30 @@ final class PartFileMap { const PartFileMap(); } +/// {@template FormUrlEncoded} +/// +/// +/// Denotes that the request body will use form URL encoding. Fields should be declared as parameters +/// and annotated with [Field]/[FieldMap]. +/// +/// Requests made with this annotation will have application/x-www-form-urlencoded MIME +/// type. Field names and values will be UTF-8 encoded before being URI-encoded in accordance to RFC-3986. +/// +/// +/// ```dart +/// @Post(path: '/something') +/// @FormUrlEncoded +/// Future fetch(@Field("param") String? param); +/// ``` +/// {@endtemplate} +@immutable +@Target({TargetKind.method}) +final class FormUrlEncoded { + /// {@macro FormUrlEncoded} + const FormUrlEncoded(); +} + /// {@macro ChopperApi} const chopperApi = ChopperApi(); @@ -595,3 +619,6 @@ const partFile = PartFile(); /// {@macro PartFileMap} const partFileMap = PartFileMap(); + +/// {@macro FormUrlEncoded} +const formUrlEncoded = FormUrlEncoded(); diff --git a/chopper/pubspec.yaml b/chopper/pubspec.yaml index 9ca02f9d..4eaeaffe 100644 --- a/chopper/pubspec.yaml +++ b/chopper/pubspec.yaml @@ -1,6 +1,6 @@ name: chopper description: Chopper is an http client generator using source_gen, inspired by Retrofit -version: 7.1.1+1 +version: 7.2.0 documentation: https://hadrien-lejard.gitbook.io/chopper repository: https://github.com/lejard-h/chopper diff --git a/chopper_generator/CHANGELOG.md b/chopper_generator/CHANGELOG.md index 08e09416..202a3cce 100644 --- a/chopper_generator/CHANGELOG.md +++ b/chopper_generator/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 7.2.0 + +- Add support for `@FormUrlEncoded` annotation ([#579](https://github.com/lejard-h/chopper/pull/579)) + ## 7.1.1 - Add option to override build_extension via build.yaml ([#562](https://github.com/lejard-h/chopper/pull/562)) diff --git a/chopper_generator/lib/src/generator.dart b/chopper_generator/lib/src/generator.dart index f35a341a..b7946c75 100644 --- a/chopper_generator/lib/src/generator.dart +++ b/chopper_generator/lib/src/generator.dart @@ -150,6 +150,7 @@ final class ChopperGenerator ) { final ConstantReader? method = _getMethodAnnotation(m); final bool multipart = _hasAnnotation(m, chopper.Multipart); + final bool formUrlEncoded = _hasAnnotation(m, chopper.FormUrlEncoded); final ConstantReader? factoryConverter = _getFactoryConverterAnnotation(m); final Map body = _getAnnotation(m, chopper.Body); @@ -172,7 +173,7 @@ final class ChopperGenerator final Map fileFieldMap = _getAnnotation(m, chopper.PartFileMap); - final Code? headers = _generateHeaders(m, method!); + final Code? headers = _generateHeaders(m, method!, formUrlEncoded); final Expression url = _generateUrl( method, paths, @@ -298,15 +299,21 @@ final class ChopperGenerator bool hasBody = body.isNotEmpty || fields.isNotEmpty; if (hasBody) { if (body.isNotEmpty) { + final DartType bodyType = m.parameters + .firstWhere((p) => _typeChecker(chopper.Body).hasAnnotationOf(p)) + .type; + final Expression map = (formUrlEncoded && + _isMap(bodyType) && + !_isMapStringString(bodyType)) + ? _generateMapToStringExpression(refer(body.keys.first)) + : refer(body.keys.first); blocks.add( - declareFinal(Vars.body.toString()) - .assign(refer(body.keys.first)) - .statement, + declareFinal(Vars.body.toString()).assign(map).statement, ); } else { blocks.add( declareFinal(Vars.body.toString()) - .assign(_generateMap(fields)) + .assign(_generateMap(fields, enableToString: formUrlEncoded)) .statement, ); } @@ -314,17 +321,23 @@ final class ChopperGenerator final bool hasFieldMap = fieldMap.isNotEmpty; if (hasFieldMap) { + final DartType fieldMapType = m.parameters + .firstWhere( + (p) => _typeChecker(chopper.FieldMap).hasAnnotationOf(p)) + .type; + final Expression map = + (formUrlEncoded && !_isMapStringString(fieldMapType)) + ? _generateMapToStringExpression(refer(fieldMap.keys.first)) + : refer(fieldMap.keys.first); if (hasBody) { blocks.add( refer(Vars.body.toString()).property('addAll').call( - [refer(fieldMap.keys.first)], + [map], ).statement, ); } else { blocks.add( - declareFinal(Vars.body.toString()) - .assign(refer(fieldMap.keys.first)) - .statement, + declareFinal(Vars.body.toString()).assign(map).statement, ); } } @@ -466,6 +479,26 @@ final class ChopperGenerator }); } + static Expression _generateMapToStringExpression(Reference map) { + return map.property('map').call([ + Method((b) => b + ..requiredParameters.add( + Parameter((b) => b..name = 'key'), + ) + ..requiredParameters.add( + Parameter((b) => b..name = 'value'), + ) + ..returns = refer('MapEntry', 'dart.core') + ..body = refer('MapEntry', 'dart.core') + .newInstance([ + refer('key').property('toString').call([]), + refer('value').property('toString').call([]), + ]) + .returned + .statement).closure + ]); + } + static String _factoryForFunction(FunctionTypedElement function) => // ignore: deprecated_member_use function.enclosingElement is ClassElement @@ -546,6 +579,32 @@ final class ChopperGenerator ? type.typeArguments.first : null; + static bool _isMap(DartType type) { + return _typeChecker(Map).isExactlyType(type) || + _typeChecker(Map).isAssignableFromType(type); + } + + static bool _isMapStringString(DartType type) { + if (!_isMap(type)) { + return false; + } + final firsType = type is InterfaceType && type.typeArguments.isNotEmpty + ? type.typeArguments.first + : null; + final secondType = type is InterfaceType && type.typeArguments.length > 1 + ? type.typeArguments[1] + : null; + if (firsType == null || secondType == null) { + return false; + } + return _isString(firsType) && _isString(secondType); + } + + static bool _isString(DartType type) { + return _typeChecker(String).isExactlyType(type) || + _typeChecker(String).isAssignableFromType(type); + } + static bool _isResponse(DartType type) { final DartType? responseType = _genericOf(type); if (responseType == null) return false; @@ -664,17 +723,20 @@ final class ChopperGenerator ); static Expression _generateMap( - Map queries, - ) => + Map queries, { + bool enableToString = false, + }) => literalMap( { for (final MapEntry query in queries.entries) query.value.peek('name')?.stringValue ?? query.key.displayName: - refer(query.key.displayName), + enableToString + ? refer(query.key.displayName).property('toString').call([]) + : refer(query.key.displayName), }, refer('String'), - refer('dynamic'), + refer(enableToString ? 'String' : 'dynamic'), ); static Expression _generateList( @@ -718,6 +780,7 @@ final class ChopperGenerator static Code? _generateHeaders( MethodElement methodElement, ConstantReader method, + bool formUrlEncoded, ) { final StringBuffer codeBuffer = StringBuffer('')..writeln('{'); @@ -756,6 +819,11 @@ final class ChopperGenerator } }); + if (formUrlEncoded) { + codeBuffer + .writeln("'content-type': 'application/x-www-form-urlencoded',"); + } + codeBuffer.writeln('}'); final String code = codeBuffer.toString(); diff --git a/chopper_generator/pubspec.yaml b/chopper_generator/pubspec.yaml index 43870f85..773f94c8 100644 --- a/chopper_generator/pubspec.yaml +++ b/chopper_generator/pubspec.yaml @@ -1,6 +1,6 @@ name: chopper_generator description: Chopper is an http client generator using source_gen, inspired by Retrofit -version: 7.1.1 +version: 7.2.0 documentation: https://hadrien-lejard.gitbook.io/chopper repository: https://github.com/lejard-h/chopper @@ -11,7 +11,7 @@ dependencies: analyzer: ">=5.13.0 <7.0.0" build: ^2.4.1 built_collection: ^5.1.1 - chopper: ^7.1.0 + chopper: ^7.2.0 code_builder: ^4.5.0 dart_style: ^2.3.2 logging: ^1.2.0 diff --git a/chopper_generator/test/test_service.chopper.dart b/chopper_generator/test/test_service.chopper.dart index 262dfb11..5019d39c 100644 --- a/chopper_generator/test/test_service.chopper.dart +++ b/chopper_generator/test/test_service.chopper.dart @@ -393,6 +393,101 @@ final class _$HttpTestService extends HttpTestService { return client.send($request); } + @override + Future> postFormUrlEncodeBody( + HashMap hashMapBody, + Map map, + ) { + final Uri $url = Uri.parse('/test/formUrlEncoded'); + final Map $headers = { + 'content-type': 'application/x-www-form-urlencoded', + }; + final $body = hashMapBody.map(( + key, + value, + ) { + return MapEntry( + key.toString(), + value.toString(), + ); + }); + $body.addAll(map); + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + body: $body, + headers: $headers, + ); + return client.send($request); + } + + @override + Future> postFormUrlEncodeField( + String a, + String a2, + ) { + final Uri $url = Uri.parse('/test/formUrlEncoded'); + final Map $headers = { + 'content-type': 'application/x-www-form-urlencoded', + }; + final $body = { + 'a': a.toString(), + 'a1': a2.toString(), + }; + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + body: $body, + headers: $headers, + ); + return client.send($request); + } + + @override + Future> postFormUrlEncodeFieldMap(Map c) { + final Uri $url = Uri.parse('/test/formUrlEncoded'); + final Map $headers = { + 'content-type': 'application/x-www-form-urlencoded', + }; + final $body = c; + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + body: $body, + headers: $headers, + ); + return client.send($request); + } + + @override + Future> postFormUrlEncodeFieldDynamicMap( + Map c) { + final Uri $url = Uri.parse('/test/formUrlEncoded'); + final Map $headers = { + 'content-type': 'application/x-www-form-urlencoded', + }; + final $body = c.map(( + key, + value, + ) { + return MapEntry( + key.toString(), + value.toString(), + ); + }); + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + body: $body, + headers: $headers, + ); + return client.send($request); + } + @override Future> postFile(List bytes) { final Uri $url = Uri.parse('/test/file'); diff --git a/chopper_generator/test/test_service.dart b/chopper_generator/test/test_service.dart index 1fae94fc..273e9d73 100644 --- a/chopper_generator/test/test_service.dart +++ b/chopper_generator/test/test_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'dart:convert'; import 'package:chopper/chopper.dart'; @@ -113,6 +114,32 @@ abstract class HttpTestService extends ChopperService { @Part('2') Map b, ); + @Post(path: 'formUrlEncoded') + @FormUrlEncoded() + Future postFormUrlEncodeBody( + @Body() HashMap hashMapBody, + @FieldMap() Map map, + ); + + @Post(path: 'formUrlEncoded') + @FormUrlEncoded() + Future postFormUrlEncodeField( + @Field('a') String a, + @Field('a1') String a2, + ); + + @Post(path: 'formUrlEncoded') + @FormUrlEncoded() + Future postFormUrlEncodeFieldMap( + @FieldMap() Map c, + ); + + @Post(path: 'formUrlEncoded') + @FormUrlEncoded() + Future postFormUrlEncodeFieldDynamicMap( + @FieldMap() Map c, + ); + @Post(path: 'file') @multipart Future postFile( diff --git a/requests.md b/requests.md index 60d7f2f3..4a66090d 100644 --- a/requests.md +++ b/requests.md @@ -2,25 +2,26 @@ ## Available Request annotations -| Annotation | HTTP verb | Description | -|--------------------------------------------|-----------|-----------------------------------------------| -| `@Get()`, `@get` | `GET` | Defines a `GET` request. | -| `@Post()`, `@post` | `POST` | Defines a `POST` request. | -| `@Put()`, `@put` | `PUT` | Defines a `PUT` request. | -| `@Patch()`, `@patch` | `PATCH` | Defines a `PATCH` request. | -| `@Delete()`, `@delete` | `DELETE` | Defines a `DELETE` request. | -| `@Head()`, `@head` | `HEAD` | Defines a `HEAD` request. | -| `@Body()`, `@body` | - | Defines the request's body. | -| `@Multipart()`, `@multipart` | - | Defines a `multipart/form-data` request. | -| `@Query()`, `@query` | - | Defines a query parameter. | -| `@QueryMap()`, `@queryMap` | - | Defines a query parameter map. | -| `@FactoryConverter()`, `@factoryConverter` | - | Defines a request/response converter factory. | -| `@Field()`, `@field` | - | Defines a form field. | -| `@FieldMap()`, `@fieldMap` | - | Defines a form field map. | -| `@Part()`, `@part` | - | Defines a multipart part. | -| `@PartMap()`, `@partMap` | - | Defines a multipart part map. | -| `@PartFile()`, `@partFile` | - | Defines a multipart file part. | -| `@PartFileMap()`, `@partFileMap` | - | Defines a multipart file part map. | +| Annotation | HTTP verb | Description | +|--------------------------------------------|-----------|--------------------------------------------------------| +| `@Get()`, `@get` | `GET` | Defines a `GET` request. | +| `@Post()`, `@post` | `POST` | Defines a `POST` request. | +| `@Put()`, `@put` | `PUT` | Defines a `PUT` request. | +| `@Patch()`, `@patch` | `PATCH` | Defines a `PATCH` request. | +| `@Delete()`, `@delete` | `DELETE` | Defines a `DELETE` request. | +| `@Head()`, `@head` | `HEAD` | Defines a `HEAD` request. | +| `@Body()`, `@body` | - | Defines the request's body. | +| `@FormUrlEncoded`, `@formUrlEncoded` | - | Defines a `application/x-www-form-urlencoded` request. | +| `@Multipart()`, `@multipart` | - | Defines a `multipart/form-data` request. | +| `@Query()`, `@query` | - | Defines a query parameter. | +| `@QueryMap()`, `@queryMap` | - | Defines a query parameter map. | +| `@FactoryConverter()`, `@factoryConverter` | - | Defines a request/response converter factory. | +| `@Field()`, `@field` | - | Defines a form field. | +| `@FieldMap()`, `@fieldMap` | - | Defines a form field map. | +| `@Part()`, `@part` | - | Defines a multipart part. | +| `@PartMap()`, `@partMap` | - | Defines a multipart part map. | +| `@PartFile()`, `@partFile` | - | Defines a multipart file part. | +| `@PartFileMap()`, `@partFileMap` | - | Defines a multipart file part map. | ## Path resolution @@ -170,12 +171,27 @@ Future fetch(@Header("foo") String bar); ## Sending `application/x-www-form-urlencoded` data -If no Converter is specified for a request (neither on a `ChopperClient` nor with the `@FactoryConverter` annotation) +If no Converter (neither on a `ChopperClient` nor with the `@FactoryConverter` annotation) or formUrlEncoded (`@FormUrlEncoded` annotation) is specified for a request and the request body is of type `Map`, the body will be sent as form URL encoded data. > This is the default behavior of the http package. -You can also use `FormUrlEncodedConverter` that will add the correct `content-type` and convert a `Map` +### FormUrlEncoded annotation + +We recommend annotation `@formUrlEncoded` on method that will add the correct `content-type` and convert a `Map` +into `Map` for requests. + +```dart +@Post( + path: "form", +) +@formUrlEncoded +Future postForm(@Body() Map fields); +``` + +### FormUrlEncodedConverter + +you can also use `FormUrlEncodedConverter` that also will add the correct `content-type` and convert a `Map` into `Map` for requests. ```dart @@ -185,7 +201,6 @@ final chopper = ChopperClient( ); ``` -### On a single method To do only a single type of request with form encoding in a service, use the provided `FormUrlEncodedConverter`' s `requestFactory` method with the `@FactoryConverter` annotation. @@ -208,9 +223,7 @@ the parameter's name is used as the field's name. ```dart @Post(path: "form") -@FactoryConverter( - request: FormUrlEncodedConverter.requestFactory, -) +@formUrlEncoded Future post(@Field() String foo, @Field("b") int bar); ```