From 80648b29cfe05384f6aeebc361aebc94a165e886 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Fri, 7 Jul 2017 16:20:01 -0700 Subject: [PATCH 01/15] Added [WebAuthenticator], which allows for user authentication at https://www.reddit.com/api/v1/authorize. Authentication + authorization have been tested, but nothing else at the moment. --- lib/src/auth.dart | 191 +++++++++++++++++++++++++++++++++++--------- lib/src/reddit.dart | 24 +++--- 2 files changed, 167 insertions(+), 48 deletions(-) diff --git a/lib/src/auth.dart b/lib/src/auth.dart index 3d3380e6..22713d4d 100644 --- a/lib/src/auth.dart +++ b/lib/src/auth.dart @@ -35,11 +35,6 @@ abstract class Authenticator { _userAgent = userAgent, _client = null; - void authorize(String code) { - // TODO(bkonyi) implement. - throw new UnimplementedError(); - } - /// Request a new access token from the Reddit API. Throws a /// [DRAWAuthenticationError] if the [Authenticator] is not yet initialized. Future refresh() async { @@ -55,35 +50,55 @@ abstract class Authenticator { if (credentials == null) { return; } - String accessToken = credentials.accessToken; - Map revokeAccess = new Map(); - revokeAccess[kTokenKey] = accessToken; - revokeAccess[kTokenTypeHintKey] = 'access_token'; + List tokens = new List(); + Map accessToken = { + kTokenKey: credentials.accessToken, + kTokenTypeHintKey: 'access_token', + }; + tokens.add(accessToken); + + if (credentials.refreshToken != null) { + Map refreshToken = { + kTokenKey: credentials.refreshToken, + kTokenTypeHintKey: 'refresh_token', + }; + tokens.add(refreshToken); + } - // Retrieve the client ID and secret. - String clientId = _grant.identifier; - String clientSecret = _grant.secret; + for (int i = 0; i < tokens.length; i++) { + String token = tokens[i][kTokenKey]; + String tokenType = tokens[i][kTokenTypeHintKey]; + Map revokeAccess = new Map(); + revokeAccess[kTokenKey] = token; + revokeAccess[kTokenTypeHintKey] = tokenType; - // TODO(bkonyi) handle cases where clientSecret isn't used. - String userInfo = '$clientId:$clientSecret'; + // TODO(bkonyi) we shouldn't have hardcoded urls like this. Move to common + // file with all API related strings. + Uri path = Uri.parse(r'https://www.reddit.com/api/v1/revoke_token'); - Uri path = Uri - .parse(r'https://www.reddit.com/api/v1/revoke_token') - .replace(userInfo: userInfo); + // Retrieve the client ID and secret. + String clientId = _grant.identifier; + String clientSecret = _grant.secret; - Map headers = new Map(); - headers[kUserAgentKey] = _userAgent; + if ((clientId != null) && (clientSecret != null)) { + String userInfo = '$clientId:$clientSecret'; + path = path.replace(userInfo: userInfo); + } - http.Client httpClient = new http.Client(); + Map headers = new Map(); + headers[kUserAgentKey] = _userAgent; - // Request the token from the server. - http.Response response = await httpClient.post( - path.replace(userInfo: userInfo), - headers: headers, - body: revokeAccess); + http.Client httpClient = new http.Client(); + + // Request the token from the server. + http.Response response = + await httpClient.post(path, headers: headers, body: revokeAccess); - if (response.statusCode != 204) { - // TODO(bkonyi) throw an error since we should always get a 204 response. + if (response.statusCode != 204) { + // We should always get a 204 response for this call. + Map parsed = JSON.decode(response.body); + _throwAuthenticationError(parsed); + } } } @@ -105,11 +120,11 @@ abstract class Authenticator { return _request(kPostRequest, path, body); } - // Request data from Reddit using our OAuth2 client. - // - // [type] can be one of `GET`, `POST`, and `PUT`. [path] represents the - // request parameters. [body] is an optional parameter which contains the - // body fields for a POST request. + /// Request data from Reddit using our OAuth2 client. + /// + /// [type] can be one of `GET`, `POST`, and `PUT`. [path] represents the + /// request parameters. [body] is an optional parameter which contains the + /// body fields for a POST request. Future _request(String type, Uri path, [Map body]) async { if (_client == null) { @@ -131,15 +146,18 @@ abstract class Authenticator { return parsed; } - // Requests the authentication token from Reddit based on parameters provided - // in [accountInfo] and [_grant]. + /// Requests the authentication token from Reddit based on parameters provided + /// in [accountInfo] and [_grant]. Future _requestToken(Map accountInfo) async { // Retrieve the client ID and secret. String clientId = _grant.identifier; String clientSecret = _grant.secret; + String userInfo = null; + + if ((clientId != null) && (clientSecret != null)) { + userInfo = '$clientId:$clientSecret'; + } - // TODO(bkonyi) handle cases where clientSecret isn't used. - String userInfo = '$clientId:$clientSecret'; http.Client httpClient = new http.Client(); DateTime start = new DateTime.now(); @@ -189,7 +207,7 @@ abstract class Authenticator { /// A flag representing whether or not this authenticator instance is valid. /// /// Returns `false` if the authentication flow has not yet been completed, if - /// [revoke()] has been called, or the access token has expired. + /// [revoke] has been called, or the access token has expired. bool get isValid { return !(credentials?.isExpired ?? true); } @@ -264,3 +282,102 @@ class ReadOnlyAuthenticator extends Authenticator { await _requestToken(accountInfo); } } + +/// The [WebAuthenticator] class allows for the creation of an [Authenticator] +/// that exposes functionality which allows for the user to authenticate through +/// a browser. The [url] method is used to generate the URL that the user uses +/// to authenticate on www.reddit.com, and the [authorize] method retrieves the +/// access token given the returned `code`. This is to be +/// used with the 'Web' app type credentials. Refer to +/// https://github.com/reddit/reddit/wiki/OAuth2-App-Types for descriptions of +/// valid app types. +class WebAuthenticator extends Authenticator { + static WebAuthenticator Create( + oauth2.AuthorizationCodeGrant grant, String userAgent, Uri redirect) { + WebAuthenticator authenticator = + new WebAuthenticator._(grant, userAgent, redirect); + return authenticator; + } + + WebAuthenticator._( + oauth2.AuthorizationCodeGrant grant, String userAgent, Uri redirect) + : _redirect = redirect, + super(grant, userAgent) { + assert(_redirect != null); + } + + /// Generates the authentication URL used for Reddit user verification in a + /// browser. + /// + /// [scopes] is the list of all scopes that can be requested (see + /// https://www.reddit.com/api/v1/scopes for a list of valid scopes). [state] + /// should be a unique [String] for the current [Authenticator] instance. + /// The value of [state] will be returned via the redirect Uri and should be + /// verified against the original value of [state] to ensure the app access + /// response corresponds to the correct request. [duration] indicates whether + /// or not a permanent token is needed for the client, and can take the value + /// of either 'permanent' (default) or 'temporary'. If [compactLogin] is true, + /// then the Uri will link to a mobile-friendly Reddit authentication screen. + Uri url(List scopes, String state, + {String duration = 'permanent', bool compactLogin = false}) { + // TODO(bkonyi) do we want to add the [implicit] flag to the argument list? + if (scopes == null) { + // scopes cannot be null. + throw new DRAWAuthenticationError('Parameter scopes cannot be null.'); + } + Uri redditAuthUri = + _grant.getAuthorizationUrl(_redirect, scopes: scopes, state: state); + if (redditAuthUri == null) { + // TODO(bkonyi) throw meaningful exception. + assert(false); + } + // getAuthorizationUrl returns a Uri which is missing the duration field, so + // we need to add it here. + Map queryParameters = new Map.from(redditAuthUri.queryParameters); + queryParameters[kDurationKey] = duration; + redditAuthUri = redditAuthUri.replace(queryParameters: queryParameters); + if (compactLogin) { + String path = redditAuthUri.path; + assert(path.endsWith('?'), 'The path should end with "authorize?"'); + path = path.substring(0, path.length - 1) + r'.compact?'; + redditAuthUri = redditAuthUri.replace(path: path); + } + return redditAuthUri; + } + + /// Authorizes the current [Authenticator] instance using the code returned + /// from Reddit after the user has authenticated. + /// + /// [code] is the value passed as a query parameter to `redirect`. This value + /// must be parsed from the request made to `redirect` before being passed to + /// this method. + Future authorize(String code) async { + if (code == null) { + // code cannot be null. + throw new DRAWAuthenticationError('Parameter code cannot be null.'); + } + _client = await _grant.handleAuthorizationCode(code); + } + + /// Initiates the authorization flow. This method should populate a + /// [Map] with information needed for authentication, and then + /// call [_requestToken] to authenticate. + @override + Future _authenticationFlow() async { + throw new UnimplementedError( + '_authenticationFlow is not used in WebAuthenticator.'); + } + + /// Request a new access token from the Reddit API. Throws a + /// [DRAWAuthenticationError] if the [Authenticator] is not yet initialized. + @override + Future refresh() async { + if (_client == null) { + throw new DRAWAuthenticationError( + 'cannot refresh uninitialized Authenticator.'); + } + _client = await _client.refreshCredentials(); + } + + Uri _redirect; +} diff --git a/lib/src/reddit.dart b/lib/src/reddit.dart index 9bd0b946..a1efdd4e 100644 --- a/lib/src/reddit.dart +++ b/lib/src/reddit.dart @@ -21,7 +21,7 @@ class Reddit { /// The default [Uri] used to authenticate an authorization token from Reddit. static final Uri defaultAuthEndpoint = - Uri.parse('https://oauth.reddit.com/api/v1/authorize'); + Uri.parse(r'https://reddit.com/api/v1/authorize'); /// A flag representing the initialization state of the current [Reddit] /// instance. @@ -39,7 +39,7 @@ class Reddit { Authenticator get auth => _auth; Authenticator _auth; - bool _readOnly = false; + bool _readOnly = true; Completer _initializedCompleter = new Completer(); // TODO(bkonyi) update clientId entry to show hyperlink. @@ -72,11 +72,6 @@ class Reddit { Uri redirectUri, Uri tokenEndpoint, Uri authEndpoint}) { - oauth2.AuthorizationCodeGrant grant = new oauth2.AuthorizationCodeGrant( - clientId, - authEndpoint ?? defaultAuthEndpoint, - tokenEndpoint ?? defaultTokenEndpoint, - secret: clientSecret); if (clientId == null) { throw new DRAWAuthenticationError('clientId cannot be null.'); } @@ -86,6 +81,11 @@ class Reddit { if (userAgent == null) { throw new DRAWAuthenticationError('userAgent cannot be null.'); } + oauth2.AuthorizationCodeGrant grant = new oauth2.AuthorizationCodeGrant( + clientId, + authEndpoint ?? defaultAuthEndpoint, + tokenEndpoint ?? defaultTokenEndpoint, + secret: clientSecret); if ((username == null) && (password == null) && (redirectUri == null)) { ReadOnlyAuthenticator .Create(grant, userAgent) @@ -97,10 +97,12 @@ class Reddit { .Create(grant, userAgent, username, password) .then(_initializationCallback); _readOnly = false; - } else if (redirectUri != null) { - // TODO(bkonyi) create web application session. - throw new UnimplementedError( - 'Authentication for web applications is not yet supported.'); + } else if ((username == null) && + (password == null) && + (redirectUri != null)) { + _initializationCallback( + WebAuthenticator.Create(grant, userAgent, redirectUri)); + _readOnly = false; } else { throw new UnimplementedError('Unsupported authentication type.'); } From 975d0b547db34751929ec9723655e90e5bfbd07b Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Wed, 12 Jul 2017 15:08:17 -0700 Subject: [PATCH 02/15] Addressed style comments made by leonsenft and enabled additional flags for the analyzer on Travis. --- .travis.yml | 2 +- lib/src/auth.dart | 81 ++++++++++++++++++++++----------------------- lib/src/reddit.dart | 8 ++--- 3 files changed, 44 insertions(+), 47 deletions(-) diff --git a/.travis.yml b/.travis.yml index 228c40bb..583ba5c1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,4 +12,4 @@ dart: dart_task: - test: -p vm - dartfmt - - dartanalyzer + - dartanalyzer: --strong --fatal-warnings --fatal-lints diff --git a/lib/src/auth.dart b/lib/src/auth.dart index 22713d4d..51f2e171 100644 --- a/lib/src/auth.dart +++ b/lib/src/auth.dart @@ -30,6 +30,10 @@ const String kUsernameKey = 'username'; /// credentials, refreshing and revoking access tokens, and issuing HTTPS /// requests using OAuth2 credentials. abstract class Authenticator { + oauth2.AuthorizationCodeGrant _grant; + oauth2.Client _client; + String _userAgent; + Authenticator(oauth2.AuthorizationCodeGrant grant, String userAgent) : _grant = grant, _userAgent = userAgent, @@ -50,53 +54,50 @@ abstract class Authenticator { if (credentials == null) { return; } - List tokens = new List(); - Map accessToken = { + final tokens = new List(); + final accessToken = { kTokenKey: credentials.accessToken, kTokenTypeHintKey: 'access_token', }; tokens.add(accessToken); if (credentials.refreshToken != null) { - Map refreshToken = { + final refreshToken = { kTokenKey: credentials.refreshToken, kTokenTypeHintKey: 'refresh_token', }; tokens.add(refreshToken); } - - for (int i = 0; i < tokens.length; i++) { - String token = tokens[i][kTokenKey]; - String tokenType = tokens[i][kTokenTypeHintKey]; - Map revokeAccess = new Map(); - revokeAccess[kTokenKey] = token; - revokeAccess[kTokenTypeHintKey] = tokenType; + for (final token in tokens) { + final revokeAccess = new Map(); + revokeAccess[kTokenKey] = token[kTokenKey]; + revokeAccess[kTokenTypeHintKey] = token[kTokenTypeHintKey]; // TODO(bkonyi) we shouldn't have hardcoded urls like this. Move to common // file with all API related strings. - Uri path = Uri.parse(r'https://www.reddit.com/api/v1/revoke_token'); + var path = Uri.parse(r'https://www.reddit.com/api/v1/revoke_token'); // Retrieve the client ID and secret. - String clientId = _grant.identifier; - String clientSecret = _grant.secret; + final clientId = _grant.identifier; + final clientSecret = _grant.secret; if ((clientId != null) && (clientSecret != null)) { - String userInfo = '$clientId:$clientSecret'; + final userInfo = '$clientId:$clientSecret'; path = path.replace(userInfo: userInfo); } - Map headers = new Map(); + final headers = new Map(); headers[kUserAgentKey] = _userAgent; - http.Client httpClient = new http.Client(); + final httpClient = new http.Client(); // Request the token from the server. - http.Response response = + final response = await httpClient.post(path, headers: headers, body: revokeAccess); if (response.statusCode != 204) { // We should always get a 204 response for this call. - Map parsed = JSON.decode(response.body); + final parsed = JSON.decode(response.body); _throwAuthenticationError(parsed); } } @@ -134,12 +135,12 @@ abstract class Authenticator { if (!isValid) { refresh(); } - http.Request request = new http.Request(type, path); + final request = new http.Request(type, path); if (body != null) { request.bodyFields = body; } final http.StreamedResponse response = await _client.send(request); - Map parsed = JSON.decode(await response.stream.bytesToString()); + final parsed = JSON.decode(await response.stream.bytesToString()); if (parsed.containsKey(kErrorKey)) { _throwAuthenticationError(parsed); } @@ -150,34 +151,34 @@ abstract class Authenticator { /// in [accountInfo] and [_grant]. Future _requestToken(Map accountInfo) async { // Retrieve the client ID and secret. - String clientId = _grant.identifier; - String clientSecret = _grant.secret; + final clientId = _grant.identifier; + final clientSecret = _grant.secret; String userInfo = null; if ((clientId != null) && (clientSecret != null)) { userInfo = '$clientId:$clientSecret'; } - http.Client httpClient = new http.Client(); - DateTime start = new DateTime.now(); + final httpClient = new http.Client(); + final start = new DateTime.now(); - Map headers = new Map(); + final headers = new Map(); headers[kUserAgentKey] = _userAgent; // Request the token from the server. - http.Response response = await httpClient.post( + final response = await httpClient.post( _grant.tokenEndpoint.replace(userInfo: userInfo), headers: headers, body: accountInfo); // Check for error response. - Map responseMap = JSON.decode(response.body); + final responseMap = JSON.decode(response.body); if (responseMap.containsKey(kErrorKey)) { _throwAuthenticationError(responseMap); } // Create the Credentials object from the authentication token. - oauth2.Credentials credentials = handleAccessTokenResponse( + final credentials = handleAccessTokenResponse( response, _grant.tokenEndpoint, start, ['*'], ','); // Generate the OAuth2 client that will be used to query Reddit servers. @@ -186,8 +187,8 @@ abstract class Authenticator { } void _throwAuthenticationError(Map response) { - String statusCode = response[kErrorKey]; - String reason = response[kMessageKey]; + final statusCode = response[kErrorKey]; + final reason = response[kMessageKey]; throw new DRAWAuthenticationError( 'Status Code: ${statusCode} Reason: ${reason}'); } @@ -211,10 +212,6 @@ abstract class Authenticator { bool get isValid { return !(credentials?.isExpired ?? true); } - - oauth2.AuthorizationCodeGrant _grant; - oauth2.Client _client; - String _userAgent; } /// The [ScriptAuthenticator] class allows for the creation of an [Authenticator] @@ -223,7 +220,10 @@ abstract class Authenticator { /// https://github.com/reddit/reddit/wiki/OAuth2-App-Types for descriptions of /// valid app types. class ScriptAuthenticator extends Authenticator { - static Future Create(oauth2.AuthorizationCodeGrant grant, + String _username; + String _password; + + static Future create(oauth2.AuthorizationCodeGrant grant, String userAgent, String username, String password) async { ScriptAuthenticator authenticator = new ScriptAuthenticator._(grant, userAgent, username, password); @@ -249,9 +249,6 @@ class ScriptAuthenticator extends Authenticator { accountInfo[kDurationKey] = 'permanent'; await _requestToken(accountInfo); } - - String _username; - String _password; } /// The [ReadOnlyAuthenticator] class allows for the creation of an [Authenticator] @@ -261,7 +258,9 @@ class ScriptAuthenticator extends Authenticator { /// account. Refer to https://github.com/reddit/reddit/wiki/OAuth2-App-Types for /// descriptions of valid app types. class ReadOnlyAuthenticator extends Authenticator { - static Future Create( + Uri _redirect; + + static Future create( oauth2.AuthorizationCodeGrant grant, String userAgent) async { ReadOnlyAuthenticator authenticator = new ReadOnlyAuthenticator._(grant, userAgent); @@ -292,7 +291,7 @@ class ReadOnlyAuthenticator extends Authenticator { /// https://github.com/reddit/reddit/wiki/OAuth2-App-Types for descriptions of /// valid app types. class WebAuthenticator extends Authenticator { - static WebAuthenticator Create( + static WebAuthenticator create( oauth2.AuthorizationCodeGrant grant, String userAgent, Uri redirect) { WebAuthenticator authenticator = new WebAuthenticator._(grant, userAgent, redirect); @@ -378,6 +377,4 @@ class WebAuthenticator extends Authenticator { } _client = await _client.refreshCredentials(); } - - Uri _redirect; } diff --git a/lib/src/reddit.dart b/lib/src/reddit.dart index a1efdd4e..e9fecad9 100644 --- a/lib/src/reddit.dart +++ b/lib/src/reddit.dart @@ -81,27 +81,27 @@ class Reddit { if (userAgent == null) { throw new DRAWAuthenticationError('userAgent cannot be null.'); } - oauth2.AuthorizationCodeGrant grant = new oauth2.AuthorizationCodeGrant( + final grant = new oauth2.AuthorizationCodeGrant( clientId, authEndpoint ?? defaultAuthEndpoint, tokenEndpoint ?? defaultTokenEndpoint, secret: clientSecret); if ((username == null) && (password == null) && (redirectUri == null)) { ReadOnlyAuthenticator - .Create(grant, userAgent) + .create(grant, userAgent) .then(_initializationCallback); _readOnly = true; } else if ((username != null) && (password != null)) { // Check if we are creating an authorized client. ScriptAuthenticator - .Create(grant, userAgent, username, password) + .create(grant, userAgent, username, password) .then(_initializationCallback); _readOnly = false; } else if ((username == null) && (password == null) && (redirectUri != null)) { _initializationCallback( - WebAuthenticator.Create(grant, userAgent, redirectUri)); + WebAuthenticator.create(grant, userAgent, redirectUri)); _readOnly = false; } else { throw new UnimplementedError('Unsupported authentication type.'); From 0737d59919f52fd0a0d8d5be80fa7da15934f8fb Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Wed, 12 Jul 2017 15:17:06 -0700 Subject: [PATCH 03/15] Fixed analyzer flags for Travis. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 583ba5c1..718ffe5c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,4 +12,4 @@ dart: dart_task: - test: -p vm - dartfmt - - dartanalyzer: --strong --fatal-warnings --fatal-lints + - dartanalyzer: --strong --fatal-warnings --fatal-lints . From 0c86914ee8ef0b6a5a5344e5b4e5c917ec46591f Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Wed, 12 Jul 2017 15:24:10 -0700 Subject: [PATCH 04/15] Fixed dartanalyzer on Travis (hopefully for real this time) --- .travis.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 718ffe5c..c29faa1f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,4 +12,6 @@ dart: dart_task: - test: -p vm - dartfmt - - dartanalyzer: --strong --fatal-warnings --fatal-lints . + +script: + - dartanalyzer --strong --fatal-warnings --fatal-lints . From 8eaf0795156e994a1e2ca4f8424a18720d56fdf5 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Thu, 13 Jul 2017 12:23:19 -0700 Subject: [PATCH 05/15] Fixed bad refactor and addressed additional comments made by leonsenft. --- lib/src/auth.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/auth.dart b/lib/src/auth.dart index 51f2e171..d3883262 100644 --- a/lib/src/auth.dart +++ b/lib/src/auth.dart @@ -153,7 +153,7 @@ abstract class Authenticator { // Retrieve the client ID and secret. final clientId = _grant.identifier; final clientSecret = _grant.secret; - String userInfo = null; + String userInfo; if ((clientId != null) && (clientSecret != null)) { userInfo = '$clientId:$clientSecret'; @@ -258,8 +258,6 @@ class ScriptAuthenticator extends Authenticator { /// account. Refer to https://github.com/reddit/reddit/wiki/OAuth2-App-Types for /// descriptions of valid app types. class ReadOnlyAuthenticator extends Authenticator { - Uri _redirect; - static Future create( oauth2.AuthorizationCodeGrant grant, String userAgent) async { ReadOnlyAuthenticator authenticator = @@ -291,6 +289,8 @@ class ReadOnlyAuthenticator extends Authenticator { /// https://github.com/reddit/reddit/wiki/OAuth2-App-Types for descriptions of /// valid app types. class WebAuthenticator extends Authenticator { + Uri _redirect; + static WebAuthenticator create( oauth2.AuthorizationCodeGrant grant, String userAgent, Uri redirect) { WebAuthenticator authenticator = From f3d317d97b53557afb5d774df868eff405c26d0d Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Thu, 13 Jul 2017 13:19:48 -0700 Subject: [PATCH 06/15] Enabled and fixed lint errors. --- .analysis_options | 38 ++++++++++++++++++++++++++++++++++++++ lib/src/auth.dart | 44 ++++++++++++++++++++++---------------------- lib/src/reddit.dart | 2 +- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/.analysis_options b/.analysis_options index f2f885dc..dccb59ae 100644 --- a/.analysis_options +++ b/.analysis_options @@ -1,2 +1,40 @@ analyzer: strong-mode: true +linter: + rules: + - always_declare_return_types + - avoid_empty_else + - avoid_init_to_null + - await_only_futures + - camel_case_types + - cancel_subscriptions + - constant_identifier_names + - control_flow_in_finally + - empty_catches + - empty_constructor_bodies + - empty_statements + - hash_and_equals + - iterable_contains_unrelated_type + - library_names + - library_prefixes + - list_remove_unrelated_type + - non_constant_identifier_names + - only_throw_errors + - overridden_fields + - package_names + - package_prefixed_library_names + - parameter_assignments + - prefer_final_fields + - prefer_final_locals + - prefer_is_not_empty + - slash_for_doc_comments + - sort_unnamed_constructors_first + - super_goes_last + - test_types_in_equals + - type_init_formals + - throw_in_finally + - unawaited_futures + - unnecessary_brace_in_string_interp + - unnecessary_getters_setters + - unrelated_type_equality_checks + - valid_regexps diff --git a/lib/src/auth.dart b/lib/src/auth.dart index d3883262..73479d2d 100644 --- a/lib/src/auth.dart +++ b/lib/src/auth.dart @@ -133,7 +133,7 @@ abstract class Authenticator { 'The authenticator does not have a valid token.'); } if (!isValid) { - refresh(); + await refresh(); } final request = new http.Request(type, path); if (body != null) { @@ -223,26 +223,26 @@ class ScriptAuthenticator extends Authenticator { String _username; String _password; + ScriptAuthenticator._(oauth2.AuthorizationCodeGrant grant, String userAgent, + String username, String password) + : _username = username, + _password = password, + super(grant, userAgent); + static Future create(oauth2.AuthorizationCodeGrant grant, String userAgent, String username, String password) async { - ScriptAuthenticator authenticator = + final ScriptAuthenticator authenticator = new ScriptAuthenticator._(grant, userAgent, username, password); await authenticator._authenticationFlow(); return authenticator; } - ScriptAuthenticator._(oauth2.AuthorizationCodeGrant grant, String userAgent, - String username, String password) - : _username = username, - _password = password, - super(grant, userAgent); - /// Initiates the authorization flow. This method should populate a /// [Map] with information needed for authentication, and then /// call [_requestToken] to authenticate. @override Future _authenticationFlow() async { - Map accountInfo = new Map(); + final accountInfo = new Map(); accountInfo[kUsernameKey] = _username; accountInfo[kPasswordKey] = _password; accountInfo[kGrantTypeKey] = 'password'; @@ -258,23 +258,23 @@ class ScriptAuthenticator extends Authenticator { /// account. Refer to https://github.com/reddit/reddit/wiki/OAuth2-App-Types for /// descriptions of valid app types. class ReadOnlyAuthenticator extends Authenticator { + ReadOnlyAuthenticator._(oauth2.AuthorizationCodeGrant grant, String userAgent) + : super(grant, userAgent); + static Future create( oauth2.AuthorizationCodeGrant grant, String userAgent) async { - ReadOnlyAuthenticator authenticator = + final ReadOnlyAuthenticator authenticator = new ReadOnlyAuthenticator._(grant, userAgent); await authenticator._authenticationFlow(); return authenticator; } - ReadOnlyAuthenticator._(oauth2.AuthorizationCodeGrant grant, String userAgent) - : super(grant, userAgent); - /// Initiates the authorization flow. This method should populate a /// [Map] with information needed for authentication, and then /// call [_requestToken] to authenticate. @override Future _authenticationFlow() async { - Map accountInfo = new Map(); + final accountInfo = new Map(); accountInfo[kGrantTypeKey] = 'client_credentials'; await _requestToken(accountInfo); } @@ -291,13 +291,6 @@ class ReadOnlyAuthenticator extends Authenticator { class WebAuthenticator extends Authenticator { Uri _redirect; - static WebAuthenticator create( - oauth2.AuthorizationCodeGrant grant, String userAgent, Uri redirect) { - WebAuthenticator authenticator = - new WebAuthenticator._(grant, userAgent, redirect); - return authenticator; - } - WebAuthenticator._( oauth2.AuthorizationCodeGrant grant, String userAgent, Uri redirect) : _redirect = redirect, @@ -305,6 +298,13 @@ class WebAuthenticator extends Authenticator { assert(_redirect != null); } + static WebAuthenticator create( + oauth2.AuthorizationCodeGrant grant, String userAgent, Uri redirect) { + final WebAuthenticator authenticator = + new WebAuthenticator._(grant, userAgent, redirect); + return authenticator; + } + /// Generates the authentication URL used for Reddit user verification in a /// browser. /// @@ -332,7 +332,7 @@ class WebAuthenticator extends Authenticator { } // getAuthorizationUrl returns a Uri which is missing the duration field, so // we need to add it here. - Map queryParameters = new Map.from(redditAuthUri.queryParameters); + final queryParameters = new Map.from(redditAuthUri.queryParameters); queryParameters[kDurationKey] = duration; redditAuthUri = redditAuthUri.replace(queryParameters: queryParameters); if (compactLogin) { diff --git a/lib/src/reddit.dart b/lib/src/reddit.dart index e9fecad9..5584841d 100644 --- a/lib/src/reddit.dart +++ b/lib/src/reddit.dart @@ -40,7 +40,7 @@ class Reddit { Authenticator _auth; bool _readOnly = true; - Completer _initializedCompleter = new Completer(); + final Completer _initializedCompleter = new Completer(); // TODO(bkonyi) update clientId entry to show hyperlink. /// Creates a new authenticated [Reddit] instance. From 01ae901d13be64c70cbb41c63bb3e38ac4b8b746 Mon Sep 17 00:00:00 2001 From: Kartik Chopra Date: Sat, 29 Apr 2017 19:53:09 -0400 Subject: [PATCH 07/15] Added folder for reddit object to be exposed to user --- draw/reddit.dart | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 draw/reddit.dart diff --git a/draw/reddit.dart b/draw/reddit.dart new file mode 100644 index 00000000..de5ef133 --- /dev/null +++ b/draw/reddit.dart @@ -0,0 +1,9 @@ +// Provides the reddit class + +class reddit{ + + //Reddit Constructor + reddit(){ + + } +} From f081bf85cadd1a8b97f4b062ce2fb9d048ea7f53 Mon Sep 17 00:00:00 2001 From: Kartik Chopra Date: Sun, 30 Apr 2017 12:53:39 -0400 Subject: [PATCH 08/15] Moved comments to top of function to adhere to DART Style --- draw/reddit.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/draw/reddit.dart b/draw/reddit.dart index de5ef133..94e97c1b 100644 --- a/draw/reddit.dart +++ b/draw/reddit.dart @@ -2,8 +2,8 @@ class reddit{ - //Reddit Constructor reddit(){ + //Reddit Constructor } } From f2d8629baf44fc2924d6ccdf1a354af7a2353fe5 Mon Sep 17 00:00:00 2001 From: Kartik Chopra Date: Sun, 30 Apr 2017 13:10:21 -0400 Subject: [PATCH 09/15] Swithed to full sentences and implmented doc comments instead of regular comments with adherence to dart docs. --- draw/reddit.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/draw/reddit.dart b/draw/reddit.dart index 94e97c1b..2c35a465 100644 --- a/draw/reddit.dart +++ b/draw/reddit.dart @@ -1,9 +1,10 @@ -// Provides the reddit class - +/// An object that with the future ability to provide access to user data and subreddits along with HTTP capablities. +/// +///Future private members include A Config Object, User Data Object, and much more! class reddit{ reddit(){ - //Reddit Constructor + ///This is currently a placeholder for the Reddit Constructor. } } From d79cf039efc851e01fa737b4393fc3d8cb27b38d Mon Sep 17 00:00:00 2001 From: Kartik Chopra Date: Sun, 30 Apr 2017 13:27:02 -0400 Subject: [PATCH 10/15] Ran dartfmt and dart analyser --- draw/reddit.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/draw/reddit.dart b/draw/reddit.dart index 2c35a465..bf461a83 100644 --- a/draw/reddit.dart +++ b/draw/reddit.dart @@ -1,10 +1,8 @@ /// An object that with the future ability to provide access to user data and subreddits along with HTTP capablities. /// ///Future private members include A Config Object, User Data Object, and much more! -class reddit{ - - reddit(){ - ///This is currently a placeholder for the Reddit Constructor. - - } +class reddit { + reddit() { + ///This is currently a placeholder for the Reddit Constructor. + } } From 24a8c3970bcb31680d1131fe1ad13765cdaac3d7 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Fri, 30 Jun 2017 17:32:01 -0700 Subject: [PATCH 11/15] Fixed typo in LICENSE. --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 39155ad7..5f1c82c7 100644 --- a/LICENSE +++ b/LICENSE @@ -11,7 +11,7 @@ modification, are permitted provided that the following conditions are met: this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -* Neither the name of Google Inc. nor the anmes of its contributors may be used +* Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. From 2cd0bf9eb279ae82c8f5451d802d728c06ee2640 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Thu, 13 Jul 2017 12:36:52 -0700 Subject: [PATCH 12/15] Added [WebAuthenticator], which allows for user authentication through a browser (#5) Added [WebAuthenticator], which allows for user authentication at https://www.reddit.com/api/v1/authorize. Authentication + authorization have been tested, but nothing else at the moment. --- lib/src/auth.dart | 1 - lib/src/reddit.dart | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/auth.dart b/lib/src/auth.dart index 73479d2d..d0a84300 100644 --- a/lib/src/auth.dart +++ b/lib/src/auth.dart @@ -161,7 +161,6 @@ abstract class Authenticator { final httpClient = new http.Client(); final start = new DateTime.now(); - final headers = new Map(); headers[kUserAgentKey] = _userAgent; diff --git a/lib/src/reddit.dart b/lib/src/reddit.dart index 5584841d..67d5c8cc 100644 --- a/lib/src/reddit.dart +++ b/lib/src/reddit.dart @@ -40,7 +40,7 @@ class Reddit { Authenticator _auth; bool _readOnly = true; - final Completer _initializedCompleter = new Completer(); + final _initializedCompleter = new Completer(); // TODO(bkonyi) update clientId entry to show hyperlink. /// Creates a new authenticated [Reddit] instance. From 6e8dd065e8929875ec5d870ac4b0253747513b12 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Thu, 13 Jul 2017 12:37:42 -0700 Subject: [PATCH 13/15] Removed junk files. --- draw/reddit.dart | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 draw/reddit.dart diff --git a/draw/reddit.dart b/draw/reddit.dart deleted file mode 100644 index bf461a83..00000000 --- a/draw/reddit.dart +++ /dev/null @@ -1,8 +0,0 @@ -/// An object that with the future ability to provide access to user data and subreddits along with HTTP capablities. -/// -///Future private members include A Config Object, User Data Object, and much more! -class reddit { - reddit() { - ///This is currently a placeholder for the Reddit Constructor. - } -} From 3160c58260b9102382a9c684b9fb3176eaa59403 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Tue, 1 Aug 2017 15:55:37 -0700 Subject: [PATCH 14/15] -Added code to turn Reddit API responses into RedditBase objects. -Added an abstract class, ListingGenerator, which contains a static method which allows for asynchronous iteration over Reddit API listing results. -Implemented a subset of the User class. -Created stubs for Redditor and Subreddit classes. -Updated documentation for various classes. --- lib/src/api_paths.dart | 166 +++++++++++++++++++++++++ lib/src/auth.dart | 11 +- lib/src/base.dart | 44 +++++++ lib/src/exceptions.dart | 19 ++- lib/src/listing/listing_generator.dart | 57 +++++++++ lib/src/models/redditor.dart | 21 ++++ lib/src/models/subreddit.dart | 13 ++ lib/src/objector.dart | 62 +++++++++ lib/src/reddit.dart | 33 ++++- lib/src/user.dart | 82 ++++++++++++ 10 files changed, 498 insertions(+), 10 deletions(-) create mode 100644 lib/src/api_paths.dart create mode 100644 lib/src/base.dart create mode 100644 lib/src/listing/listing_generator.dart create mode 100644 lib/src/models/redditor.dart create mode 100644 lib/src/models/subreddit.dart create mode 100644 lib/src/objector.dart create mode 100644 lib/src/user.dart diff --git a/lib/src/api_paths.dart b/lib/src/api_paths.dart new file mode 100644 index 00000000..7281a514 --- /dev/null +++ b/lib/src/api_paths.dart @@ -0,0 +1,166 @@ +// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. +// Please see the AUTHORS file for details. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +/// A [Map] containing all of the Reddit API paths. +final Map apiPath = { + 'about_edited': 'r/{subreddit}/about/edited/', + 'about_log': 'r/{subreddit}/about/log/', + 'about_modqueue': 'r/{subreddit}/about/modqueue/', + 'about_reports': 'r/{subreddit}/about/reports/', + 'about_spam': 'r/{subreddit}/about/spam/', + 'about_sticky': 'r/{subreddit}/about/sticky/', + 'about_stylesheet': 'r/{subreddit}/about/stylesheet/', + 'about_traffic': 'r/{subreddit}/about/traffic/', + 'about_unmoderated': 'r/{subreddit}/about/unmoderated/', + 'accept_mod_invite': 'r/{subreddit}/api/accept_moderator_invite', + 'approve': 'api/approve/', + 'block': 'api/block', + 'blocked': 'prefs/blocked/', + 'comment': 'api/comment/', + 'comment_replies': 'message/comments/', + 'compose': 'api/compose/', + 'contest_mode': 'api/set_contest_mode/', + 'del': 'api/del/', + 'deleteflair': 'r/{subreddit}/api/deleteflair', + 'delete_sr_banner': 'r/{subreddit}/api/delete_sr_banner', + 'delete_sr_header': 'r/{subreddit}/api/delete_sr_header', + 'delete_sr_icon': 'r/{subreddit}/api/delete_sr_icon', + 'delete_sr_image': 'r/{subreddit}/api/delete_sr_img', + 'distinguish': 'api/distinguish/', + 'domain': 'domain/{domain}/', + 'duplicates': 'duplicates/{submission_id}/', + 'edit': 'api/editusertext/', + 'flair': 'r/{subreddit}/api/flair/', + 'flairconfig': 'r/{subreddit}/api/flairconfig/', + 'flaircsv': 'r/{subreddit}/api/flaircsv/', + 'flairlist': 'r/{subreddit}/api/flairlist/', + 'flairselector': 'r/{subreddit}/api/flairselector/', + 'flairtemplate': 'r/{subreddit}/api/flairtemplate/', + 'flairtemplateclear': 'r/{subreddit}/api/clearflairtemplates/', + 'flairtemplatedelete': 'r/{subreddit}/api/deleteflairtemplate/', + 'friend': 'r/{subreddit}/api/friend/', + 'friend_v1': 'api/v1/me/friends/{user}', + 'friends': 'api/v1/me/friends/', + 'gild_thing': 'api/v1/gold/gild/{fullname}/', + 'gild_user': 'api/v1/gold/give/{username}/', + 'hide': 'api/hide/', + 'ignore_reports': 'api/ignore_reports/', + 'inbox': 'message/inbox/', + 'info': 'api/info/', + 'karma': 'api/v1/me/karma', + 'leavecontributor': 'api/leavecontributor', + 'leavemoderator': 'api/leavemoderator', + 'list_banned': 'r/{subreddit}/about/banned/', + 'list_contributor': 'r/{subreddit}/about/contributors/', + 'list_moderator': 'r/{subreddit}/about/moderators/', + 'list_muted': 'r/{subreddit}/about/muted/', + 'list_wikibanned': 'r/{subreddit}/about/wikibanned/', + 'list_wikicontributor': 'r/{subreddit}/about/wikicontributors/', + 'live_accept_invite': 'api/live/{id}/accept_contributor_invite', + 'live_add_update': 'api/live/{id}/update', + 'live_close': 'api/live/{id}/close_thread', + 'live_contributors': 'live/{id}/contributors', + 'live_discussions': 'live/{id}/discussions', + 'live_info': 'api/live/by_id/{ids}', + 'live_invite': 'api/live/{id}/invite_contributor', + 'live_leave': 'api/live/{id}/leave_contributor', + 'live_now': 'api/live/happening_now', + 'live_remove_update': 'api/live/{id}/delete_update', + 'live_remove_contrib': 'api/live/{id}/rm_contributor', + 'live_remove_invite': 'api/live/{id}/rm_contributor_invite', + 'live_report': 'api/live/{id}/report', + 'live_strike': 'api/live/{id}/strike_update', + 'live_update_perms': 'api/live/{id}/set_contributor_permissions', + 'live_update_thread': 'api/live/{id}/edit', + 'live_updates': 'live/{id}', + 'liveabout': 'api/live/{id}/about/', + 'livecreate': 'api/live/create', + 'lock': 'api/lock/', + 'me': 'api/v1/me', + 'mentions': 'message/mentions', + 'message': 'message/messages/{id}/', + 'messages': 'message/messages/', + 'moderator_messages': 'r/{subreddit}/message/moderator/', + 'moderator_unread': 'r/{subreddit}/message/moderator/unread/', + 'morechildren': 'api/morechildren/', + 'my_contributor': 'subreddits/mine/contributor/', + 'my_moderator': 'subreddits/mine/moderator/', + 'my_multireddits': 'api/multi/mine/', + 'my_subreddits': 'subreddits/mine/subscriber/', + 'marknsfw': 'api/marknsfw/', + 'modmail_archive': 'api/mod/conversations/{id}/archive', + 'modmail_conversation': 'api/mod/conversations/{id}', + 'modmail_conversations': 'api/mod/conversations/', + 'modmail_highlight': 'api/mod/conversations/{id}/highlight', + 'modmail_mute': 'api/mod/conversations/{id}/mute', + 'modmail_read': 'api/mod/conversations/read', + 'modmail_unarchive': 'api/mod/conversations/{id}/unarchive', + 'modmail_unmute': 'api/mod/conversations/{id}/unmute', + 'modmail_unread': 'api/mod/conversations/unread', + 'multireddit': 'user/{user}/m/{multi}/', + 'multireddit_api': 'api/multi/user/{user}/m/{multi}/', + 'multireddit_base': 'api/multi/', + 'multireddit_copy': 'api/multi/copy/', + 'multireddit_rename': 'api/multi/rename/', + 'multireddit_update': 'api/multi/user/{user}/m/{multi}/r/{subreddit}', + 'multireddit_user': 'api/multi/user/{user}/', + 'mute_sender': 'api/mute_message_author/', + 'quarantine_opt_in': 'api/quarantine_optin', + 'quarantine_opt_out': 'api/quarantine_optout', + 'read_message': 'api/read_message/', + 'remove': 'api/remove/', + 'report': 'api/report/', + 'rules': 'r/{subreddit}/about/rules', + 'save': 'api/save/', + 'search': 'r/{subreddit}/search/', + 'select_flair': 'r/{subreddit}/api/selectflair/', + 'sent': 'message/sent/', + 'setpermissions': 'r/{subreddit}/api/setpermissions/', + 'spoiler': 'api/spoiler/', + 'site_admin': 'api/site_admin/', + 'sticky_submission': 'api/set_subreddit_sticky/', + 'sub_recommended': 'api/recommend/sr/{subreddits}', + 'submission': 'comments/{id}/', + 'submission_replies': 'message/selfreply/', + 'submit': 'api/submit/', + 'subreddit': 'r/{subreddit}/', + 'subreddit_about': 'r/{subreddit}/about/', + 'subreddit_filter': ('api/filter/user/{user}/f/{special}/' + 'r/{subreddit}'), + 'subreddit_filter_list': 'api/filter/user/{user}/f/{special}', + 'subreddit_random': 'r/{subreddit}/random/', + 'subreddit_settings': 'r/{subreddit}/about/edit/', + 'subreddit_stylesheet': 'r/{subreddit}/api/subreddit_stylesheet/', + 'subreddits_by_topic': 'api/subreddits_by_topic', + 'subreddits_default': 'subreddits/default/', + 'subreddits_gold': 'subreddits/gold/', + 'subreddits_new': 'subreddits/new/', + 'subreddits_popular': 'subreddits/popular/', + 'subreddits_name_search': 'api/search_reddit_names/', + 'subreddits_search': 'subreddits/search/', + 'subscribe': 'api/subscribe/', + 'suggested_sort': 'api/set_suggested_sort/', + 'unfriend': 'r/{subreddit}/api/unfriend/', + 'unhide': 'api/unhide/', + 'unignore_reports': 'api/unignore_reports/', + 'unlock': 'api/unlock/', + 'unmarknsfw': 'api/unmarknsfw/', + 'unmute_sender': 'api/unmute_message_author/', + 'unread': 'message/unread/', + 'unread_message': 'api/unread_message/', + 'unsave': 'api/unsave/', + 'unspoiler': 'api/unspoiler/', + 'upload_image': 'r/{subreddit}/api/upload_sr_img', + 'user': 'user/{user}/', + 'user_about': 'user/{user}/about/', + 'vote': 'api/vote/', + 'wiki_edit': 'r/{subreddit}/api/wiki/edit/', + 'wiki_page': 'r/{subreddit}/wiki/{page}', + 'wiki_page_editor': 'r/{subreddit}/api/wiki/alloweditor/{method}', + 'wiki_page_revisions': 'r/{subreddit}/wiki/revisions/{page}', + 'wiki_page_settings': 'r/{subreddit}/wiki/settings/{page}', + 'wiki_pages': 'r/{subreddit}/wiki/pages/', + 'wiki_revisions': 'r/{subreddit}/wiki/revisions/' +}; diff --git a/lib/src/auth.dart b/lib/src/auth.dart index d0a84300..d07fe222 100644 --- a/lib/src/auth.dart +++ b/lib/src/auth.dart @@ -111,14 +111,14 @@ abstract class Authenticator { /// Make a simple `GET` request. [path] is the destination URI that the /// request will be made to. - Future get(Uri path) async { - return _request(kGetRequest, path); + Future get(Uri path, {Map params}) async { + return _request(kGetRequest, path, params: params); } /// Make a simple `POST` request. [path] is the destination URI and [body] /// contains the POST parameters that will be sent with the request. Future post(Uri path, Map body) async { - return _request(kPostRequest, path, body); + return _request(kPostRequest, path, body: body); } /// Request data from Reddit using our OAuth2 client. @@ -127,7 +127,7 @@ abstract class Authenticator { /// request parameters. [body] is an optional parameter which contains the /// body fields for a POST request. Future _request(String type, Uri path, - [Map body]) async { + {Map body, Map params}) async { if (_client == null) { throw new DRAWAuthenticationError( 'The authenticator does not have a valid token.'); @@ -135,7 +135,8 @@ abstract class Authenticator { if (!isValid) { await refresh(); } - final request = new http.Request(type, path); + final finalPath = path.replace(queryParameters: params); + final request = new http.Request(type, finalPath); if (body != null) { request.bodyFields = body; } diff --git a/lib/src/base.dart b/lib/src/base.dart new file mode 100644 index 00000000..d1990324 --- /dev/null +++ b/lib/src/base.dart @@ -0,0 +1,44 @@ +// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. +// Please see the AUTHORS file for details. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import 'reddit.dart'; + +class RedditBase { + final Reddit reddit; + final Map _data; + final RegExp _snakecaseRegexp = new RegExp("[A-Z]"); + + RedditBase(this.reddit) : _data = null; + + RedditBase.loadData(this.reddit, Map data) : _data = data; + + String _snakeCase(String name, [separator = '_']) => name.replaceAllMapped( + _snakecaseRegexp, + (Match match) => + (match.start != 0 ? separator : '') + match.group(0).toLowerCase()); + + dynamic noSuchMethod(Invocation invocation) { + // This is a dirty hack to allow for dynamic field population based on the + // API response instead of hardcoding these fields and having to update them + // when the API updates. Invocation.memberName is a Symbol, which + // unfortunately doesn't have a getName method due to code minification + // restrictions in dart2js, so the only way to get the name properly is + // using the dart:mirrors library. Unfortunately, dart:mirrors isn't + // supported in Flutter/Dart AOT, which makes it unacceptable to use in this + // library. However, Symbol.toString() returns a string in the form of + // Symbol("memberName") consistently on the Dart VM. We're abusing this + // behaviour here, and there's no promise that this will work in the future, + // but there's no reason to assume that this behaviour will change any time + // soon. + var name = invocation.memberName.toString(); + name = _snakeCase(name.substring(8, name.length - 2)); + if (!invocation.isGetter || (_data == null) || !_data.containsKey(name)) { + // Check that the accessed field is a getter and the property exists. + throw new NoSuchMethodError(this, invocation.memberName, + invocation.positionalArguments, invocation.namedArguments); + } + return _data[name]; + } +} diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart index e2f9dfd0..d6bf172a 100644 --- a/lib/src/exceptions.dart +++ b/lib/src/exceptions.dart @@ -1,11 +1,26 @@ -// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. +// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. // Please see the AUTHORS file for details. All rights reserved. // Use of this source code is governed by a BSD-style license that // can be found in the LICENSE file. -// TODO(bkonyi) document properly. +/// Thrown when there is an error during the authentication flow. class DRAWAuthenticationError implements Exception { DRAWAuthenticationError(this.message); String message; String toString() => 'DRAWAuthenticationError: $message'; } + +/// Thrown by unfinished code that hasn't yet implemented all the features it +/// needs. +class DRAWUnimplementedError extends UnimplementedError { + DRAWUnimplementedError([String message]) : super(message); +} + +/// Thrown due to a fatal error encountered inside DRAW. If you're not adding +/// functionality to DRAW you should never see this. Otherwise, please file a +/// bug at github.com/draw-dev/DRAW/issues. +class DRAWInternalError implements Exception { + DRAWInternalError(this.message); + String message; + String toString() => 'DRAWInternalError: $message'; +} diff --git a/lib/src/listing/listing_generator.dart b/lib/src/listing/listing_generator.dart new file mode 100644 index 00000000..3dfd303e --- /dev/null +++ b/lib/src/listing/listing_generator.dart @@ -0,0 +1,57 @@ +// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. +// Please see the AUTHORS file for details. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import 'dart:async'; + +import '../reddit.dart'; + +/// An abstract static class used to generate [Stream]s of [RedditBase] objects. +/// This class should not be used directly, as it is used by various methods +/// defined in children of [RedditBase]. +abstract class ListingGenerator { + static const defaultRequestLimit = 100; + + /// An asynchronous iterator method used to make Reddit API calls as defined + /// by [api] in blocks of size [limit]. The default [limit] is specified by + /// [defaultRequestLimit]. Returns a [Stream] which can be iterated over + /// using an asynchronous for-loop. + static Stream generator(final Reddit reddit, final String api, + {int limit, Map params}) async* { + final kLimitKey = 'limit'; + final nullLimit = 1024; + final paramsInternal = params ?? new Map(); + paramsInternal[kLimitKey] = (limit ?? nullLimit).toString(); + int yielded = 0; + int index = 0; + List listing; + + Future> _nextBatch() async { + final response = (await reddit.get(api, params: paramsInternal)) as Map; + final newListing = response['listing']; + paramsInternal['after'] = response['after']; + if (newListing.length == 0) { + return null; + } + index = 0; + return newListing; + } + + while (yielded < limit) { + if ((listing == null) || (index >= listing.length)) { + if (listing != null && + listing.length < int.parse(paramsInternal[kLimitKey])) { + break; + } + listing = await _nextBatch(); + if (listing == null) { + break; + } + } + yield listing[index]; + ++index; + ++yielded; + } + } +} diff --git a/lib/src/models/redditor.dart b/lib/src/models/redditor.dart new file mode 100644 index 00000000..4736ec3c --- /dev/null +++ b/lib/src/models/redditor.dart @@ -0,0 +1,21 @@ +// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. +// Please see the AUTHORS file for details. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import '../base.dart'; +import '../exceptions.dart'; +import '../reddit.dart'; + +class Redditor extends RedditBase { + String _name; + Uri _path; + + Redditor.parse(Reddit reddit, Map data) : super.loadData(reddit, data) { + if (!data.containsKey('name')) { + // TODO(bkonyi) throw invalid object exception + throw new DRAWUnimplementedError(); + } + _name = data['name']; + } +} diff --git a/lib/src/models/subreddit.dart b/lib/src/models/subreddit.dart new file mode 100644 index 00000000..f878c7d1 --- /dev/null +++ b/lib/src/models/subreddit.dart @@ -0,0 +1,13 @@ +// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. +// Please see the AUTHORS file for details. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import '../base.dart'; +import '../reddit.dart'; + +// TODO(bkonyi) implement +class Subreddit extends RedditBase { + Subreddit.parse(Reddit reddit, Map data) + : super.loadData(reddit, data['data']); +} diff --git a/lib/src/objector.dart b/lib/src/objector.dart new file mode 100644 index 00000000..a6fa1eaa --- /dev/null +++ b/lib/src/objector.dart @@ -0,0 +1,62 @@ +// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. +// Please see the AUTHORS file for details. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import 'base.dart'; +import 'exceptions.dart'; +import 'reddit.dart'; +import 'models/redditor.dart'; +import 'models/subreddit.dart'; + +/// Converts responses from the Reddit API into instances of [RedditBase]. +class Objector extends RedditBase { + Objector(Reddit reddit) : super(reddit); + + RedditBase _objectifyDictionary(Map data) { + if (data.containsKey('name')) { + // Redditor type. + return new Redditor.parse(reddit, data); + } else if (data.containsKey('data') && + (data['data'] is Map) && + data['data'].containsKey('subreddit_type')) { + return new Subreddit.parse(reddit, data); + } else { + throw new DRAWUnimplementedError('Cannot objectify unsupported response'); + } + } + + /// Converts a response from the Reddit API into an instance of [RedditBase] + /// or a container of [RedditBase] objects. [data] should be one of [List] or + /// [Map], and the return type is one of [RedditBase], [List], or + /// [Map] depending on the response type. + dynamic objectify(dynamic data) { + if (data is List) { + // TODO(bkonyi) handle list objects, if they occur. + throw new DRAWUnimplementedError('objectify cannot yet parse Lists'); + } else if (data is! Map) { + throw new DRAWInternalError('data must be of type List or Map, got ' + '${data.runtimeType}'); + } else if (data.containsKey('kind')) { + final kind = data['kind']; + if (kind == 'Listing') { + final listing = data['data']['children']; + final before = data['data']['before']; + final after = data['data']['after']; + final objectifiedListing = new List(listing.length); + for (var i = 0; i < listing.length; ++i) { + objectifiedListing[i] = _objectifyDictionary(listing[i]); + } + final result = { + 'listing': objectifiedListing, + 'before': before, + 'after': after + }; + return result; + } + throw new DRAWUnimplementedError('response kind, ${kind}, is not ' + 'currently implemented.'); + } + return _objectifyDictionary(data); + } +} diff --git a/lib/src/reddit.dart b/lib/src/reddit.dart index 67d5c8cc..340db759 100644 --- a/lib/src/reddit.dart +++ b/lib/src/reddit.dart @@ -1,4 +1,4 @@ -// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. +// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. // Please see the AUTHORS file for details. All rights reserved. // Use of this source code is governed by a BSD-style license that // can be found in the LICENSE file. @@ -8,7 +8,10 @@ import 'dart:async'; import 'package:oauth2/oauth2.dart' as oauth2; import 'auth.dart'; +import 'base.dart'; import 'exceptions.dart'; +import 'objector.dart'; +import 'user.dart'; /// The [Reddit] class provides access to Reddit's API and stores session state /// for the current [Reddit] instance. This class contains objects that can be @@ -23,6 +26,9 @@ class Reddit { static final Uri defaultAuthEndpoint = Uri.parse(r'https://reddit.com/api/v1/authorize'); + /// The default path to the Reddit API. + static final String defaultOAuthApiEndpoint = 'oauth.reddit.com'; + /// A flag representing the initialization state of the current [Reddit] /// instance. /// @@ -38,9 +44,14 @@ class Reddit { /// The authorized client used to interact with Reddit APIs. Authenticator get auth => _auth; + /// Provides methods for the currently authenticated user. + User get user => _user; + Authenticator _auth; + User _user; bool _readOnly = true; final _initializedCompleter = new Completer(); + Objector _objector; // TODO(bkonyi) update clientId entry to show hyperlink. /// Creates a new authenticated [Reddit] instance. @@ -59,13 +70,15 @@ class Reddit { /// These fields are required in order to perform any account actions or make /// posts. /// - /// [redirectUri] + /// [redirectUri] is the redirect URI associated with your Reddit application. + /// This field is unused for script and read-only instances. /// /// [tokenEndpoint] is a [Uri] to an alternative token endpoint. If not /// provided, [defaultTokenEndpoint] is used. /// /// [authEndpoint] is a [Uri] to an alternative authentication endpoint. If not /// provided, [defaultAuthTokenEndpoint] is used. + // TODO(bkonyi): inherit from some common base class. Reddit(String clientId, String clientSecret, String userAgent, {String username, String password, @@ -81,6 +94,10 @@ class Reddit { if (userAgent == null) { throw new DRAWAuthenticationError('userAgent cannot be null.'); } + + _objector = new Objector(this); + _user = new User(this); + final grant = new oauth2.AuthorizationCodeGrant( clientId, authEndpoint ?? defaultAuthEndpoint, @@ -104,8 +121,18 @@ class Reddit { WebAuthenticator.create(grant, userAgent, redirectUri)); _readOnly = false; } else { - throw new UnimplementedError('Unsupported authentication type.'); + throw new DRAWUnimplementedError('Unsupported authentication type.'); + } + } + + Future get(String api, {Map params}) async { + if (!(await initialized)) { + throw new DRAWAuthenticationError( + 'Cannot make requests using unauthenticated client.'); } + final path = new Uri.https(defaultOAuthApiEndpoint, api); + final response = await auth.get(path, params: params); + return _objector.objectify(response); } void _initializationCallback(Authenticator auth) { diff --git a/lib/src/user.dart b/lib/src/user.dart new file mode 100644 index 00000000..1313867b --- /dev/null +++ b/lib/src/user.dart @@ -0,0 +1,82 @@ +// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. +// Please see the AUTHORS file for details. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import 'dart:async'; + +import 'api_paths.dart'; +import 'base.dart'; +import 'exceptions.dart'; +import 'reddit.dart'; +import 'listing/listing_generator.dart'; +import 'models/redditor.dart'; +import 'models/subreddit.dart'; + +/// The [Reddit] class provides access to Reddit's API and stores session state +/// for the current [Reddit] instance. This class contains objects that can be +/// used to interact with Reddit posts, comments, subreddits, multireddits, and +/// users. + +/// The [User] class provides methods to access information about the currently +/// authenticated user. +class User extends RedditBase { + User(Reddit reddit) : super(reddit); + + /// Returns a [Future>] of blocked Redditors. + Future> blocked() async { + throw new DRAWUnimplementedError(); + } + + /// Returns a [Stream] of [Subreddit]s the currently authenticated user is a + /// contributor of. [limit] is the number of [Subreddit]s to request, and + /// [params] should contain any additional parameters that should be sent as + /// part of the API request. + Stream contributorSubreddits( + {int limit = ListingGenerator.defaultRequestLimit, Map params}) => + ListingGenerator.generator(reddit, apiPath['my_contributor'], + limit: limit, params: params); + + /// Returns a [Future>] of friends. + Future> friends() async { + throw new DRAWUnimplementedError(); + } + + /// Returns a [Future] mapping subreddits to karma earned on the given + /// subreddit. + Future karma() async { + throw new DRAWUnimplementedError(); + } + + /// Returns a [Future] which represents the current user. + Future me({useCache: true}) async { + throw new DRAWUnimplementedError(); + } + + /// Returns a [Stream] of [Subreddit]s the currently authenticated user is a + /// moderator of. [limit] is the number of [Subreddit]s to request, and + /// [params] should contain any additional parameters that should be sent as + /// part of the API request. + Stream moderatorSubreddits( + {int limit = ListingGenerator.defaultRequestLimit, Map params}) => + ListingGenerator.generator(reddit, apiPath['my_moderator'], + limit: limit, params: params); + + // TODO(bkonyi) create Multireddit class. + /// Returns a [Stream] of [Multireddit]s that belongs to the currently + /// authenticated user. [limit] is the number of [Subreddit]s to request, and + /// [params] should contain any additional parameters that should be sent as + /// part of the API request. + Stream multireddits() { + throw new DRAWUnimplementedError(); + } + + /// Returns a [Stream] of [Subreddit]s the currently authenticated user is a + /// subscriber of. [limit] is the number of [Subreddit]s to request, and + /// [params] should contain any additional parameters that should be sent as + /// part of the API request. + Stream subreddits( + {int limit = ListingGenerator.defaultRequestLimit, Map params}) => + ListingGenerator.generator(reddit, apiPath['my_subreddits'], + limit: limit, params: params); +} From 8b1e016bef3ff051335c936bde354eef2a331c0b Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Fri, 4 Aug 2017 14:20:52 -0700 Subject: [PATCH 15/15] Updated TestAuthenticator.get(...) signature to match that of Authenticator.get(...) --- test/test_authenticator.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_authenticator.dart b/test/test_authenticator.dart index 16b54024..22c6f1ed 100644 --- a/test/test_authenticator.dart +++ b/test/test_authenticator.dart @@ -59,16 +59,16 @@ class TestAuthenticator extends Authenticator { } @override - Future get(Uri path) async { + Future get(Uri path, {Map params}) async { Map result; if (isRecording) { // TODO(bkonyi): grab the response based on query. - return _recording.reply([path.toString()]); + return _recording.reply([path.toString(), params]); } else { print(path.toString()); result = await _recordAuth.get(path); // TODO(bkonyi): do we always want to reply? - _recorder.given([path.toString()]).reply(result).always(); + _recorder.given([path.toString(), params]).reply(result).always(); } return result; }