diff --git a/CHANGELOG.md b/CHANGELOG.md index 79afa1ed..dcea244c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - `ofExponentialBackOff` - `ofExponentialBackOffAndJitter` - For endpoints that can use both OAuth 2.0 and OAuth 1.0a authentication methods, the resolution of both has been improved. Starting with this version, OAuth 2.0 is the highest priority authentication method. For example, if OAuth 2.0 and OAuth 1.0a tokens are specified at the same time, the OAuth 2.0 client is always used for v2 endpoints. ([#596](https://github.com/twitter-dart/twitter-api-v2/issues/596)) +- A utility has been added to this package to allow access token refresh. `OAuthUtils.refreshAccessToken` can be used to refresh access tokens issued by OAuth 2.0 PKCE. ([#604](https://github.com/twitter-dart/twitter-api-v2/issues/604)) ## v4.6.1 diff --git a/README.md b/README.md index 434dc2c6..9127a4da 100644 --- a/README.md +++ b/README.md @@ -90,20 +90,21 @@ - [1.4.1. Method Names](#141-method-names) - [1.4.2. Automatic REST Client Resolution](#142-automatic-rest-client-resolution) - [1.4.3. Generate App-Only Bearer Token](#143-generate-app-only-bearer-token) - - [1.4.4. Null Parameter at Request](#144-null-parameter-at-request) - - [1.4.5. Expand Object Fields with `expansions`](#145-expand-object-fields-with-expansions) - - [1.4.6. Expand Object Fields with `fields`](#146-expand-object-fields-with-fields) - - [1.4.7. JSON Serialization and Deserialization](#147-json-serialization-and-deserialization) - - [1.4.8. OAuth 2.0 Authorization Code Flow with PKCE](#148-oauth-20-authorization-code-flow-with-pkce) - - [1.4.9. Change the Timeout Duration](#149-change-the-timeout-duration) - - [1.4.10. Automatic Retry](#1410-automatic-retry) - - [1.4.10.1. Exponential BackOff and Jitter](#14101-exponential-backoff-and-jitter) - - [1.4.10.2. Do Something on Retry](#14102-do-something-on-retry) - - [1.4.11. Meaning of the Returned Boolean](#1411-meaning-of-the-returned-boolean) - - [1.4.12. Thrown Exceptions](#1412-thrown-exceptions) - - [1.4.13. Upload Media](#1413-upload-media) - - [1.4.14. Check the Progress of Media Upload](#1414-check-the-progress-of-media-upload) - - [1.4.15. Generate Filtering Rules Safely and Easily](#1415-generate-filtering-rules-safely-and-easily) + - [1.4.4. Refresh Access Token](#144-refresh-access-token) + - [1.4.5. Null Parameter at Request](#145-null-parameter-at-request) + - [1.4.6. Expand Object Fields with `expansions`](#146-expand-object-fields-with-expansions) + - [1.4.7. Expand Object Fields with `fields`](#147-expand-object-fields-with-fields) + - [1.4.8. JSON Serialization and Deserialization](#148-json-serialization-and-deserialization) + - [1.4.9. OAuth 2.0 Authorization Code Flow with PKCE](#149-oauth-20-authorization-code-flow-with-pkce) + - [1.4.10. Change the Timeout Duration](#1410-change-the-timeout-duration) + - [1.4.11. Automatic Retry](#1411-automatic-retry) + - [1.4.11.1. Exponential BackOff and Jitter](#14111-exponential-backoff-and-jitter) + - [1.4.11.2. Do Something on Retry](#14112-do-something-on-retry) + - [1.4.12. Meaning of the Returned Boolean](#1412-meaning-of-the-returned-boolean) + - [1.4.13. Thrown Exceptions](#1413-thrown-exceptions) + - [1.4.14. Upload Media](#1414-upload-media) + - [1.4.15. Check the Progress of Media Upload](#1415-check-the-progress-of-media-upload) + - [1.4.16. Generate Filtering Rules Safely and Easily](#1416-generate-filtering-rules-safely-and-easily) - [1.5. Contribution 🏆](#15-contribution-) - [1.6. Contributors ✨](#16-contributors-) - [1.7. Support ❤️](#17-support-️) @@ -676,7 +677,34 @@ Future main() async { } ``` -### 1.4.4. Null Parameter at Request +### 1.4.4. Refresh Access Token + +The advantage of access tokens issued by **OAuth 2.0 PKCE** is not only security, but also the ability to determine user access permissions in detail. However, the lifespan of this access token is short, expiring in about 2 hours. + +And then, the `refresh token` is a mechanism to solve this problem. `Refresh token` can be used to reissue your expired access token. + +You can implement it as follows. + +```dart +import 'package:twitter_api_v2/twitter_api_v2.dart' as v2; + +Future main() async { + final response = await v2.OAuthUtils.refreshAccessToken( + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + refreshToken: 'REFRESH_TOKEN_YOU_GOT', + ); + + print(response.accessToken); + print(response.refreshToken); +} +``` + +> **Note**:
+> If you are looking for a way to authenticate with **OAuth 2.0 PKCE**, use **[twitter_oauth2_pkce](https://pub.dev/packages/twitter_oauth2_pkce)**.
+> The `refresh token` is returned together with the access token by specifying the **`offline.access`** scope to the Twitter authentication server. + +### 1.4.5. Null Parameter at Request In this library, parameters that are not required at request time, i.e., optional parameters, are defined as nullable. However, developers do not need to be aware of the null parameter when sending requests when using this library. @@ -700,7 +728,7 @@ Future main() async { } ``` -### 1.4.5. Expand Object Fields with `expansions` +### 1.4.6. Expand Object Fields with `expansions` For example, there may be a situation where data contains only an ID, and you want to retrieve the data object associated with that ID as well. In such cases, the `Twitter API v2.0` specification called `expansions` is useful, and this library supports that specification. @@ -733,7 +761,7 @@ Future main() async { You can see more details about `expansions` from [Official Documentation](https://developer.twitter.com/en/docs/twitter-api/expansions). -### 1.4.6. Expand Object Fields with `fields` +### 1.4.7. Expand Object Fields with `fields` `Twitter API v2.0` supports a very interesting specification, allowing users to control the amount of data contained in the response object for each endpoint depending on the situation. It's called `fields`, and this library supports this specification. @@ -774,7 +802,7 @@ Future main() async { You can see more details about `fields` from [Official Documentation](https://developer.twitter.com/en/docs/twitter-api/fields). -### 1.4.7. JSON Serialization and Deserialization +### 1.4.8. JSON Serialization and Deserialization All Twitter API responses obtained using **twitter_api_v2** are returned stored in a safe type object. However, there may be cases where the raw JSON returned from the Twitter API is needed when creating applications in combination with other libraries. @@ -832,7 +860,7 @@ Future main() async { } ``` -### 1.4.8. OAuth 2.0 Authorization Code Flow with PKCE +### 1.4.9. OAuth 2.0 Authorization Code Flow with PKCE **Twitter API v2.0** supports authentication methods with [OAuth 2.0 PKCE](https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code), and it allows users of apps using **Twitter API v2.0** to request authorization for the minimum necessary [scope](https://developer.twitter.com/en/docs/authentication/guides/v2-authentication-mapping) of operation. @@ -847,7 +875,7 @@ Also, please refer to the next simple sample Flutter application that combines * - [Example Tweet App](https://github.com/twitter-dart/example-tweet-app-with-twitter-api-v2) -### 1.4.9. Change the Timeout Duration +### 1.4.10. Change the Timeout Duration The library specifies a default timeout of **10 seconds** for all API communications. @@ -866,7 +894,7 @@ Future main() { } ``` -### 1.4.10. Automatic Retry +### 1.4.11. Automatic Retry Due to the nature of this library's communication with external systems, timeouts may occur due to inevitable communication failures or temporary crashes of the server to which requests are sent. @@ -878,7 +906,7 @@ The errors subject to retry are as follows. - When the network is temporarily lost and `SocketException` is thrown. - When communication times out temporarily and `TimeoutException` is thrown -#### 1.4.10.1. Exponential BackOff and Jitter +#### 1.4.11.1. Exponential BackOff and Jitter The easiest way to perform an automatic retry is to stop the process at a certain time and rerun it until it succeeds. However, if there is a network outage on Twitter's servers, sending multiple requests to a specific server at the same time may further overload the server to which the request is sent and further reduce the success rate of retry attempts. @@ -905,7 +933,7 @@ In the above implementation, the interval increases exponentially for each retry > **(2 ^ retryCount) + jitter(Random Number between 0 ~ 3)** -#### 1.4.10.2. Do Something on Retry +#### 1.4.11.2. Do Something on Retry It would be useful to output logging on retries and a popup notifying the user that a retry has been executed. So **twitter_api_v2** provides callbacks that can perform arbitrary processing when retries are executed. @@ -930,7 +958,7 @@ Future main() async { The [RetryEvent](https://pub.dev/documentation/twitter_api_v2/latest/twitter_api_v2/RetryEvent-class.html) passed to the callback contains information on retries. -### 1.4.11. Meaning of the Returned Boolean +### 1.4.12. Meaning of the Returned Boolean A boolean value is returned from the endpoint when the communication is primarily POST, DELETE, or PUT. @@ -950,7 +978,7 @@ Note that this specification differs from the official [Twitter API v2.0](https: However, as mentioned earlier in **twitter_api_v2**, for example if you use the [createRetweet](https://pub.dev/documentation/twitter_api_v2/latest/twitter_api_v2/TweetsService/createRetweet.html) method, it will return a **flag indicating whether the process was successful or not**. This principle applies not only to the [createRetweet](https://pub.dev/documentation/twitter_api_v2/latest/twitter_api_v2/TweetsService/createRetweet.html) method, but to all methods that return flags as a result of processing. -### 1.4.12. Thrown Exceptions +### 1.4.13. Thrown Exceptions **twitter_api_v2** provides a convenient exception object for easy handling of exceptional responses and errors returned from [Twitter API v2.0](https://developer.twitter.com/en/docs/twitter-api/data-dictionary/introduction). @@ -995,7 +1023,7 @@ Future main() async { } ``` -### 1.4.13. Upload Media +### 1.4.14. Upload Media Uploading media on Twitter and sharing it with various people is a very interesting activity. Also, from a business perspective, accompanying tweets with media will attract even more interest from people. @@ -1046,7 +1074,7 @@ This upload process works very safely, but note that [TwitterUploadException](ht > **Note**
> Also note that the v1.1 endpoint is used to achieve this specification in twitter_api_v2. This is because the official Twitter API v2.0 does not yet support media uploads. While I'm trying to keep the implementation as non-disruptive as possible in the future, there may be disruptive changes when media uploads are supported by the official Twitter API v2.0. -### 1.4.14. Check the Progress of Media Upload +### 1.4.15. Check the Progress of Media Upload Uploading small images to Twitter does not take long, but uploading large videos takes longer to complete. At that time, it would be very useful if you could show users how far along we are in the uploading process. @@ -1118,7 +1146,7 @@ And the trigger that calls the `onProgress` callback is as follows. But if the m Note that media uploads may also fail for reasons such as broken media. In such cases, [TwitterUploadException](https://pub.dev/documentation/twitter_api_core/latest/twitter_api_core/TwitterUploadException-class.html) is always thrown. -### 1.4.15. Generate Filtering Rules Safely and Easily +### 1.4.16. Generate Filtering Rules Safely and Easily Some endpoints in [Twitter API v2.0](https://developer.twitter.com/en/docs/twitter-api/data-dictionary/introduction) supports a number of operators for advanced searches, not just `keywords` and `hashtags`. diff --git a/lib/src/core/oauth_response.dart b/lib/src/core/oauth_response.dart new file mode 100644 index 00000000..74c201de --- /dev/null +++ b/lib/src/core/oauth_response.dart @@ -0,0 +1,62 @@ +// Copyright 2022 Kato Shinya. All rights reserved. +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided the conditions. + +// ignore_for_file: invalid_annotation_target + +// 📦 Package imports: +import 'package:freezed_annotation/freezed_annotation.dart'; + +// 🌎 Project imports: +import 'scope.dart'; + +part 'oauth_response.freezed.dart'; +part 'oauth_response.g.dart'; + +@freezed +class OAuthResponse with _$OAuthResponse { + // ignore: unused_element + const OAuthResponse._(); + + const factory OAuthResponse({ + required String accessToken, + required String refreshToken, + @JsonKey(name: 'scope') @ScopeConverter() required List scopes, + @JsonKey(name: 'expires_in') + @DateTimeConverter() + required DateTime expiresAt, + }) = _OAuthResponse; + + factory OAuthResponse.fromJson(Map json) => + _$OAuthResponseFromJson(json); + + /// Returns true if the access token is expired, otherwise false. + bool get isExpired => DateTime.now().isAfter(expiresAt); + + /// Returns true if the access token is valid, otherwise false. + bool get isNotExpired => !isExpired; +} + +class ScopeConverter implements JsonConverter, String> { + const ScopeConverter(); + + @override + List fromJson(final String scope) => + scope.split(' ').map((e) => Scope.valueOf(e)).toList(); + + @override + String toJson(final List scopes) => + scopes.map((e) => e.value).toList().join(' '); +} + +class DateTimeConverter implements JsonConverter { + const DateTimeConverter(); + + @override + DateTime fromJson(final int json) => DateTime.now().add( + Duration(seconds: json), + ); + + @override + int toJson(final DateTime json) => 7200; +} diff --git a/lib/src/core/oauth_response.freezed.dart b/lib/src/core/oauth_response.freezed.dart new file mode 100644 index 00000000..fda35fd7 --- /dev/null +++ b/lib/src/core/oauth_response.freezed.dart @@ -0,0 +1,247 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target + +part of 'oauth_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +OAuthResponse _$OAuthResponseFromJson(Map json) { + return _OAuthResponse.fromJson(json); +} + +/// @nodoc +mixin _$OAuthResponse { + String get accessToken => throw _privateConstructorUsedError; + String get refreshToken => throw _privateConstructorUsedError; + @JsonKey(name: 'scope') + @ScopeConverter() + List get scopes => throw _privateConstructorUsedError; + @JsonKey(name: 'expires_in') + @DateTimeConverter() + DateTime get expiresAt => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $OAuthResponseCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $OAuthResponseCopyWith<$Res> { + factory $OAuthResponseCopyWith( + OAuthResponse value, $Res Function(OAuthResponse) then) = + _$OAuthResponseCopyWithImpl<$Res>; + $Res call( + {String accessToken, + String refreshToken, + @JsonKey(name: 'scope') @ScopeConverter() List scopes, + @JsonKey(name: 'expires_in') @DateTimeConverter() DateTime expiresAt}); +} + +/// @nodoc +class _$OAuthResponseCopyWithImpl<$Res> + implements $OAuthResponseCopyWith<$Res> { + _$OAuthResponseCopyWithImpl(this._value, this._then); + + final OAuthResponse _value; + // ignore: unused_field + final $Res Function(OAuthResponse) _then; + + @override + $Res call({ + Object? accessToken = freezed, + Object? refreshToken = freezed, + Object? scopes = freezed, + Object? expiresAt = freezed, + }) { + return _then(_value.copyWith( + accessToken: accessToken == freezed + ? _value.accessToken + : accessToken // ignore: cast_nullable_to_non_nullable + as String, + refreshToken: refreshToken == freezed + ? _value.refreshToken + : refreshToken // ignore: cast_nullable_to_non_nullable + as String, + scopes: scopes == freezed + ? _value.scopes + : scopes // ignore: cast_nullable_to_non_nullable + as List, + expiresAt: expiresAt == freezed + ? _value.expiresAt + : expiresAt // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// @nodoc +abstract class _$$_OAuthResponseCopyWith<$Res> + implements $OAuthResponseCopyWith<$Res> { + factory _$$_OAuthResponseCopyWith( + _$_OAuthResponse value, $Res Function(_$_OAuthResponse) then) = + __$$_OAuthResponseCopyWithImpl<$Res>; + @override + $Res call( + {String accessToken, + String refreshToken, + @JsonKey(name: 'scope') @ScopeConverter() List scopes, + @JsonKey(name: 'expires_in') @DateTimeConverter() DateTime expiresAt}); +} + +/// @nodoc +class __$$_OAuthResponseCopyWithImpl<$Res> + extends _$OAuthResponseCopyWithImpl<$Res> + implements _$$_OAuthResponseCopyWith<$Res> { + __$$_OAuthResponseCopyWithImpl( + _$_OAuthResponse _value, $Res Function(_$_OAuthResponse) _then) + : super(_value, (v) => _then(v as _$_OAuthResponse)); + + @override + _$_OAuthResponse get _value => super._value as _$_OAuthResponse; + + @override + $Res call({ + Object? accessToken = freezed, + Object? refreshToken = freezed, + Object? scopes = freezed, + Object? expiresAt = freezed, + }) { + return _then(_$_OAuthResponse( + accessToken: accessToken == freezed + ? _value.accessToken + : accessToken // ignore: cast_nullable_to_non_nullable + as String, + refreshToken: refreshToken == freezed + ? _value.refreshToken + : refreshToken // ignore: cast_nullable_to_non_nullable + as String, + scopes: scopes == freezed + ? _value._scopes + : scopes // ignore: cast_nullable_to_non_nullable + as List, + expiresAt: expiresAt == freezed + ? _value.expiresAt + : expiresAt // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$_OAuthResponse extends _OAuthResponse { + const _$_OAuthResponse( + {required this.accessToken, + required this.refreshToken, + @JsonKey(name: 'scope') + @ScopeConverter() + required final List scopes, + @JsonKey(name: 'expires_in') + @DateTimeConverter() + required this.expiresAt}) + : _scopes = scopes, + super._(); + + factory _$_OAuthResponse.fromJson(Map json) => + _$$_OAuthResponseFromJson(json); + + @override + final String accessToken; + @override + final String refreshToken; + final List _scopes; + @override + @JsonKey(name: 'scope') + @ScopeConverter() + List get scopes { + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_scopes); + } + + @override + @JsonKey(name: 'expires_in') + @DateTimeConverter() + final DateTime expiresAt; + + @override + String toString() { + return 'OAuthResponse(accessToken: $accessToken, refreshToken: $refreshToken, scopes: $scopes, expiresAt: $expiresAt)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$_OAuthResponse && + const DeepCollectionEquality() + .equals(other.accessToken, accessToken) && + const DeepCollectionEquality() + .equals(other.refreshToken, refreshToken) && + const DeepCollectionEquality().equals(other._scopes, _scopes) && + const DeepCollectionEquality().equals(other.expiresAt, expiresAt)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(accessToken), + const DeepCollectionEquality().hash(refreshToken), + const DeepCollectionEquality().hash(_scopes), + const DeepCollectionEquality().hash(expiresAt)); + + @JsonKey(ignore: true) + @override + _$$_OAuthResponseCopyWith<_$_OAuthResponse> get copyWith => + __$$_OAuthResponseCopyWithImpl<_$_OAuthResponse>(this, _$identity); + + @override + Map toJson() { + return _$$_OAuthResponseToJson( + this, + ); + } +} + +abstract class _OAuthResponse extends OAuthResponse { + const factory _OAuthResponse( + {required final String accessToken, + required final String refreshToken, + @JsonKey(name: 'scope') + @ScopeConverter() + required final List scopes, + @JsonKey(name: 'expires_in') + @DateTimeConverter() + required final DateTime expiresAt}) = _$_OAuthResponse; + const _OAuthResponse._() : super._(); + + factory _OAuthResponse.fromJson(Map json) = + _$_OAuthResponse.fromJson; + + @override + String get accessToken; + @override + String get refreshToken; + @override + @JsonKey(name: 'scope') + @ScopeConverter() + List get scopes; + @override + @JsonKey(name: 'expires_in') + @DateTimeConverter() + DateTime get expiresAt; + @override + @JsonKey(ignore: true) + _$$_OAuthResponseCopyWith<_$_OAuthResponse> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/src/core/oauth_response.g.dart b/lib/src/core/oauth_response.g.dart new file mode 100644 index 00000000..d93b5d6f --- /dev/null +++ b/lib/src/core/oauth_response.g.dart @@ -0,0 +1,39 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: non_constant_identifier_names + +part of 'oauth_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$_OAuthResponse _$$_OAuthResponseFromJson(Map json) => $checkedCreate( + r'_$_OAuthResponse', + json, + ($checkedConvert) { + final val = _$_OAuthResponse( + accessToken: $checkedConvert('access_token', (v) => v as String), + refreshToken: $checkedConvert('refresh_token', (v) => v as String), + scopes: $checkedConvert( + 'scope', (v) => const ScopeConverter().fromJson(v as String)), + expiresAt: $checkedConvert('expires_in', + (v) => const DateTimeConverter().fromJson(v as int)), + ); + return val; + }, + fieldKeyMap: const { + 'accessToken': 'access_token', + 'refreshToken': 'refresh_token', + 'scopes': 'scope', + 'expiresAt': 'expires_in' + }, + ); + +Map _$$_OAuthResponseToJson(_$_OAuthResponse instance) => + { + 'access_token': instance.accessToken, + 'refresh_token': instance.refreshToken, + 'scope': const ScopeConverter().toJson(instance.scopes), + 'expires_in': const DateTimeConverter().toJson(instance.expiresAt), + }; diff --git a/lib/src/core/scope.dart b/lib/src/core/scope.dart new file mode 100644 index 00000000..58d1f431 --- /dev/null +++ b/lib/src/core/scope.dart @@ -0,0 +1,85 @@ +// Copyright 2022 Kato Shinya. All rights reserved. +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided the conditions. + +/// Scopes allow you to set granular access for your App so that your App only +/// has the permissions that it needs. To learn more about what scopes map to +/// what endpoints, see [authentication mapping guide](https://developer.twitter.com/en/docs/authentication/guides/v2-authentication-mapping). +enum Scope { + /// All the Tweets you can view, including Tweets from protected accounts. + tweetRead('tweet.read'), + + /// Tweet and Retweet for you. + tweetWrite('tweet.write'), + + /// Hide and unhide replies to your Tweets. + tweetModerateWrite('tweet.moderate.write'), + + /// Any account you can view, including protected accounts. + usersRead('users.read'), + + /// People who follow you and people who you follow. + followsRead('follows.read'), + + /// Follow and unfollow people for you. + followsWrite('follows.write'), + + /// Stay connected to your account until you revoke access. + offlineAccess('offline.access'), + + /// All the Spaces you can view. + spaceRead('space.read'), + + /// Accounts you’ve muted. + muteRead('mute.read'), + + /// Mute and unmute accounts for you. + muteWrite('mute.write'), + + /// Tweets you’ve liked and likes you can view. + likeRead('like.read'), + + /// Like and un-like Tweets for you. + likeWrite('like.write'), + + /// Lists, list members, and list followers of lists you’ve created or are a + /// member of, including private lists. + listRead('list.read'), + + /// Create and manage Lists for you. + listWrite('list.write'), + + /// Accounts you’ve blocked. + blockRead('block.read'), + + /// Block and unblock accounts for you. + blockWrite('block.write'), + + /// Get Bookmarked Tweets from an authenticated user. + bookmarkRead('bookmark.read'), + + /// Bookmark and remove Bookmarks from Tweets. + bookmarkWrite('bookmark.write'), + + /// Get direct messages from an authenticated user. + directMessageRead('dm.read'), + + /// Write and remove direct messages. + directMessageWrite('dm.write'); + + /// The scope value + final String value; + + const Scope(this.value); + + /// Returns the scope associated with the given [value]. + static Scope valueOf(final String value) { + for (final scope in values) { + if (scope.value == value) { + return scope; + } + } + + throw ArgumentError('Invalid scope value: $value'); + } +} diff --git a/lib/src/core/util/oauth_utils.dart b/lib/src/core/util/oauth_utils.dart index 54269423..5703d6f5 100644 --- a/lib/src/core/util/oauth_utils.dart +++ b/lib/src/core/util/oauth_utils.dart @@ -10,10 +10,11 @@ import 'package:http/http.dart' as http; // 🌎 Project imports: import '../exception/twitter_exception.dart'; +import '../oauth_response.dart'; /// Provides the convenience utils for OAuth. class OAuthUtils { - OAuthUtils._(); + const OAuthUtils._(); /// Returns the App-Only bearer token associated with /// [consumerKey] and [consumerSecret]. @@ -40,4 +41,32 @@ class OAuthUtils { return jsonDecode(response.body)['access_token']; } + + /// Reissue the access token using the refresh token. + static Future refreshAccessToken({ + required String clientId, + required String clientSecret, + required String refreshToken, + }) async { + final credentials = base64.encode(utf8.encode('$clientId:$clientSecret')); + + final response = await http.post( + Uri.https('api.twitter.com', '/2/oauth2/token'), + headers: { + 'Authorization': 'Basic $credentials', + }, + body: { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + }, + ); + + if (response.statusCode != 200) { + throw TwitterException('Failed to refresh an access token.', response); + } + + return OAuthResponse.fromJson( + jsonDecode(response.body), + ); + } } diff --git a/lib/twitter_api_v2.dart b/lib/twitter_api_v2.dart index 7a319564..3182bffe 100644 --- a/lib/twitter_api_v2.dart +++ b/lib/twitter_api_v2.dart @@ -12,6 +12,8 @@ export 'package:twitter_api_v2/src/core/exception/twitter_exception.dart'; export 'package:twitter_api_v2/src/core/exception/twitter_upload_exception.dart'; export 'package:twitter_api_v2/src/core/exception/unauthorized_exception.dart'; export 'package:twitter_api_v2/src/core/language.dart'; +export 'package:twitter_api_v2/src/core/oauth_response.dart' show OAuthResponse; +export 'package:twitter_api_v2/src/core/scope.dart'; export 'package:twitter_api_v2/src/core/util/oauth_utils.dart'; export 'package:twitter_api_v2/src/service/common/includes.dart'; export 'package:twitter_api_v2/src/service/common/locale.dart'; diff --git a/test/src/core/auth/oauth_utils_test.dart b/test/src/core/auth/oauth_utils_test.dart index bc778fb5..7db49aa8 100644 --- a/test/src/core/auth/oauth_utils_test.dart +++ b/test/src/core/auth/oauth_utils_test.dart @@ -13,13 +13,31 @@ void main() { //! Since it is difficult to add the case where the access token can be //! obtained as a unit test, only the case where an exception occurs should be //! tested. - test('throws TwitterException', () { - expect( - () async => await OAuthUtils.generateAppOnlyBearerToken( - consumerKey: '', - consumerSecret: '', - ), - throwsA(isA()), - ); + group('.generateAppOnlyBearerToken', () { + test('throws TwitterException', () { + expect( + () async => await OAuthUtils.generateAppOnlyBearerToken( + consumerKey: '', + consumerSecret: '', + ), + throwsA(isA()), + ); + }); + }); + + //! Since it is difficult to add the case where the access token can be + //! obtained as a unit test, only the case where an exception occurs should be + //! tested. + group('.refreshAccessToken', () { + test('throws TwitterException', () { + expect( + () async => await OAuthUtils.refreshAccessToken( + clientId: 'aaaa', + clientSecret: 'bbbb', + refreshToken: 'test', + ), + throwsA(isA()), + ); + }); }); } diff --git a/test/src/core/client/scope_test.dart b/test/src/core/client/scope_test.dart new file mode 100644 index 00000000..0fcfa6fe --- /dev/null +++ b/test/src/core/client/scope_test.dart @@ -0,0 +1,67 @@ +// Copyright 2022 Kato Shinya. All rights reserved. +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided the conditions. + +// 📦 Package imports: +import 'package:test/test.dart'; + +// 🌎 Project imports: +import 'package:twitter_api_v2/src/core/scope.dart'; + +void main() { + test('.name', () { + expect(Scope.tweetRead.name, 'tweetRead'); + expect(Scope.tweetWrite.name, 'tweetWrite'); + expect(Scope.tweetModerateWrite.name, 'tweetModerateWrite'); + expect(Scope.usersRead.name, 'usersRead'); + expect(Scope.followsRead.name, 'followsRead'); + expect(Scope.followsWrite.name, 'followsWrite'); + expect(Scope.offlineAccess.name, 'offlineAccess'); + expect(Scope.spaceRead.name, 'spaceRead'); + expect(Scope.muteRead.name, 'muteRead'); + expect(Scope.muteWrite.name, 'muteWrite'); + expect(Scope.likeRead.name, 'likeRead'); + expect(Scope.likeWrite.name, 'likeWrite'); + expect(Scope.listRead.name, 'listRead'); + expect(Scope.listWrite.name, 'listWrite'); + expect(Scope.blockRead.name, 'blockRead'); + expect(Scope.blockWrite.name, 'blockWrite'); + expect(Scope.bookmarkRead.name, 'bookmarkRead'); + expect(Scope.bookmarkWrite.name, 'bookmarkWrite'); + expect(Scope.directMessageRead.name, 'directMessageRead'); + expect(Scope.directMessageWrite.name, 'directMessageWrite'); + }); + + test('.value', () { + expect(Scope.tweetRead.value, 'tweet.read'); + expect(Scope.tweetWrite.value, 'tweet.write'); + expect(Scope.tweetModerateWrite.value, 'tweet.moderate.write'); + expect(Scope.usersRead.value, 'users.read'); + expect(Scope.followsRead.value, 'follows.read'); + expect(Scope.followsWrite.value, 'follows.write'); + expect(Scope.offlineAccess.value, 'offline.access'); + expect(Scope.spaceRead.value, 'space.read'); + expect(Scope.muteRead.value, 'mute.read'); + expect(Scope.muteWrite.value, 'mute.write'); + expect(Scope.likeRead.value, 'like.read'); + expect(Scope.likeWrite.value, 'like.write'); + expect(Scope.listRead.value, 'list.read'); + expect(Scope.listWrite.value, 'list.write'); + expect(Scope.blockRead.value, 'block.read'); + expect(Scope.blockWrite.value, 'block.write'); + expect(Scope.bookmarkRead.value, 'bookmark.read'); + expect(Scope.bookmarkWrite.value, 'bookmark.write'); + expect(Scope.directMessageRead.value, 'dm.read'); + expect(Scope.directMessageWrite.value, 'dm.write'); + }); + + group('.valueOf', () { + test('when normal case', () { + expect(Scope.valueOf('users.read'), Scope.usersRead); + }); + + test('with invalid scope name', () { + expect(() => Scope.valueOf('test'), throwsA(isA())); + }); + }); +} diff --git a/test/src/core/client/user_context_test.dart b/test/src/core/client/user_context_test.dart index afd0724a..6d87067c 100644 --- a/test/src/core/client/user_context_test.dart +++ b/test/src/core/client/user_context_test.dart @@ -2,7 +2,10 @@ // Redistribution and use in source and binary forms, with or without // modification, are permitted provided the conditions. +// 📦 Package imports: import 'package:test/test.dart'; + +// 🌎 Project imports: import 'package:twitter_api_v2/src/core/client/user_context.dart'; void main() {