Allow response parsing to be customized (#20)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a72fc4..4a26b86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+# 1.2.0
+
+* Add a `getParameter()` parameter to `new AuthorizationCodeGrant()`, `new
+  Credentials()`, and `resourceOwnerPasswordGrant()`. This controls how the
+  authorization server's response is parsed for servers that don't provide the
+  standard JSON response.
+
 # 1.1.1
 
 * `resourceOwnerPasswordGrant()` now properly uses its HTTP client for requests
diff --git a/lib/src/authorization_code_grant.dart b/lib/src/authorization_code_grant.dart
index faec8c9..bacb680 100644
--- a/lib/src/authorization_code_grant.dart
+++ b/lib/src/authorization_code_grant.dart
@@ -5,10 +5,12 @@
 import 'dart:async';
 
 import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
 
 import 'client.dart';
 import 'authorization_exception.dart';
 import 'handle_access_token_response.dart';
+import 'parameters.dart';
 import 'utils.dart';
 
 /// A class for obtaining credentials via an [authorization code grant][].
@@ -26,6 +28,9 @@
 ///
 /// [authorization code grant]: http://tools.ietf.org/html/draft-ietf-oauth-v2-31#section-4.1
 class AuthorizationCodeGrant {
+  /// The function used to parse parameters from a host's response.
+  final GetParameters _getParameters;
+
   /// The client identifier for this client.
   ///
   /// The authorization server will issue each client a separate client
@@ -105,15 +110,27 @@
   /// The scope strings will be separated by the provided [delimiter]. This
   /// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's)
   /// use non-standard delimiters.
+  ///
+  /// By default, this follows the OAuth2 spec and requires the server's
+  /// responses to be in JSON format. However, some servers return non-standard
+  /// response formats, which can be parsed using the [getParameters] function.
+  ///
+  /// This function is passed the `Content-Type` header of the response as well
+  /// as its body as a UTF-8-decoded string. It should return a map in the same
+  /// format as the [standard JSON response][].
+  ///
+  /// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1
   AuthorizationCodeGrant(
       this.identifier, this.authorizationEndpoint, this.tokenEndpoint,
       {this.secret,
       String delimiter,
       bool basicAuth: true,
-      http.Client httpClient})
+      http.Client httpClient,
+      Map<String, dynamic> getParameters(MediaType contentType, String body)})
       : _basicAuth = basicAuth,
         _httpClient = httpClient == null ? new http.Client() : httpClient,
-        _delimiter = delimiter ?? ' ';
+        _delimiter = delimiter ?? ' ',
+        _getParameters = getParameters ?? parseJsonParameters;
 
   /// Returns the URL to which the resource owner should be redirected to
   /// authorize this client.
@@ -266,7 +283,8 @@
         headers: headers, body: body);
 
     var credentials = handleAccessTokenResponse(
-        response, tokenEndpoint, startTime, _scopes, _delimiter);
+        response, tokenEndpoint, startTime, _scopes, _delimiter,
+        getParameters: _getParameters);
     return new Client(credentials,
         identifier: this.identifier,
         secret: this.secret,
diff --git a/lib/src/credentials.dart b/lib/src/credentials.dart
index 06a89d0..4bd22a1 100644
--- a/lib/src/credentials.dart
+++ b/lib/src/credentials.dart
@@ -7,8 +7,10 @@
 import 'dart:convert';
 
 import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
 
 import 'handle_access_token_response.dart';
+import 'parameters.dart';
 import 'utils.dart';
 
 /// Credentials that prove that a client is allowed to access a resource on the
@@ -57,6 +59,9 @@
   /// expiration date.
   final DateTime expiration;
 
+  /// The function used to parse parameters from a host's response.
+  final GetParameters _getParameters;
+
   /// Whether or not these credentials have expired.
   ///
   /// Note that it's possible the credentials will expire shortly after this is
@@ -78,17 +83,29 @@
   /// The scope strings will be separated by the provided [delimiter]. This
   /// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's)
   /// use non-standard delimiters.
+  ///
+  /// By default, this follows the OAuth2 spec and requires the server's
+  /// responses to be in JSON format. However, some servers return non-standard
+  /// response formats, which can be parsed using the [getParameters] function.
+  ///
+  /// This function is passed the `Content-Type` header of the response as well
+  /// as its body as a UTF-8-decoded string. It should return a map in the same
+  /// format as the [standard JSON response][].
+  ///
+  /// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1
   Credentials(this.accessToken,
       {this.refreshToken,
       this.tokenEndpoint,
       Iterable<String> scopes,
       this.expiration,
-      String delimiter})
+      String delimiter,
+      Map<String, dynamic> getParameters(MediaType mediaType, String body)})
       : scopes = new UnmodifiableListView(
             // Explicitly type-annotate the list literal to work around
             // sdk#24202.
             scopes == null ? <String>[] : scopes.toList()),
-        _delimiter = delimiter ?? ' ';
+        _delimiter = delimiter ?? ' ',
+        _getParameters = getParameters ?? parseJsonParameters;
 
   /// Loads a set of credentials from a JSON-serialized form.
   ///
@@ -208,7 +225,8 @@
     var response =
         await httpClient.post(tokenEndpoint, headers: headers, body: body);
     var credentials = await handleAccessTokenResponse(
-        response, tokenEndpoint, startTime, scopes, _delimiter);
+        response, tokenEndpoint, startTime, scopes, _delimiter,
+        getParameters: _getParameters);
 
     // The authorization server may issue a new refresh token. If it doesn't,
     // we should re-use the one we already have.
diff --git a/lib/src/handle_access_token_response.dart b/lib/src/handle_access_token_response.dart
index 7f03f1f..84fac67 100644
--- a/lib/src/handle_access_token_response.dart
+++ b/lib/src/handle_access_token_response.dart
@@ -2,14 +2,12 @@
 // 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:convert';
-
-import 'package:collection/collection.dart';
 import 'package:http/http.dart' as http;
 import 'package:http_parser/http_parser.dart';
 
 import 'credentials.dart';
 import 'authorization_exception.dart';
+import 'parameters.dart';
 
 /// The amount of time to add as a "grace period" for credential expiration.
 ///
@@ -23,80 +21,86 @@
 /// This response format is common across several different components of the
 /// OAuth2 flow.
 ///
-/// The scope strings will be separated by the provided [delimiter].
+/// By default, this follows the OAuth2 spec and requires the server's responses
+/// to be in JSON format. However, some servers return non-standard response
+/// formats, which can be parsed using the [getParameters] function.
+///
+/// This function is passed the `Content-Type` header of the response as well as
+/// its body as a UTF-8-decoded string. It should return a map in the same
+/// format as the [standard JSON response][].
+///
+/// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1
 Credentials handleAccessTokenResponse(http.Response response, Uri tokenEndpoint,
-    DateTime startTime, List<String> scopes, String delimiter) {
-  if (response.statusCode != 200) _handleErrorResponse(response, tokenEndpoint);
+    DateTime startTime, List<String> scopes, String delimiter,
+    {Map<String, dynamic> getParameters(MediaType contentType, String body)}) {
+  getParameters ??= parseJsonParameters;
 
-  validate(condition, message) =>
-      _validate(response, tokenEndpoint, condition, message);
-
-  var contentTypeString = response.headers['content-type'];
-  var contentType =
-      contentTypeString == null ? null : new MediaType.parse(contentTypeString);
-
-  // The spec requires a content-type of application/json, but some endpoints
-  // (e.g. Dropbox) serve it as text/javascript instead.
-  validate(
-      contentType != null &&
-          (contentType.mimeType == "application/json" ||
-              contentType.mimeType == "text/javascript"),
-      'content-type was "$contentType", expected "application/json"');
-
-  Map<String, dynamic> parameters;
   try {
-    var untypedParameters = JSON.decode(response.body);
-    validate(untypedParameters is Map,
-        'parameters must be a map, was "$parameters"');
-    parameters = DelegatingMap.typed(untypedParameters);
-  } on FormatException {
-    validate(false, 'invalid JSON');
+    if (response.statusCode != 200) {
+      _handleErrorResponse(response, tokenEndpoint, getParameters);
+    }
+
+    var contentTypeString = response.headers['content-type'];
+    if (contentTypeString == null) {
+      throw new FormatException('Missing Content-Type string.');
+    }
+
+    var parameters =
+        getParameters(new MediaType.parse(contentTypeString), response.body);
+
+    for (var requiredParameter in ['access_token', 'token_type']) {
+      if (!parameters.containsKey(requiredParameter)) {
+        throw new FormatException(
+            'did not contain required parameter "$requiredParameter"');
+      } else if (parameters[requiredParameter] is! String) {
+        throw new FormatException(
+            'required parameter "$requiredParameter" was not a string, was '
+            '"${parameters[requiredParameter]}"');
+      }
+    }
+
+    // TODO(nweiz): support the "mac" token type
+    // (http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01)
+    if (parameters['token_type'].toLowerCase() != 'bearer') {
+      throw new FormatException(
+          '"$tokenEndpoint": unknown token type "${parameters['token_type']}"');
+    }
+
+    var expiresIn = parameters['expires_in'];
+    if (expiresIn != null && expiresIn is! int) {
+      throw new FormatException(
+          'parameter "expires_in" was not an int, was "$expiresIn"');
+    }
+
+    for (var name in ['refresh_token', 'scope']) {
+      var value = parameters[name];
+      if (value != null && value is! String)
+        throw new FormatException(
+            'parameter "$name" was not a string, was "$value"');
+    }
+
+    var scope = parameters['scope'] as String;
+    if (scope != null) scopes = scope.split(delimiter);
+
+    var expiration = expiresIn == null
+        ? null
+        : startTime.add(new Duration(seconds: expiresIn) - _expirationGrace);
+
+    return new Credentials(parameters['access_token'],
+        refreshToken: parameters['refresh_token'],
+        tokenEndpoint: tokenEndpoint,
+        scopes: scopes,
+        expiration: expiration);
+  } on FormatException catch (e) {
+    throw new FormatException('Invalid OAuth response for "$tokenEndpoint": '
+        '${e.message}.\n\n${response.body}');
   }
-
-  for (var requiredParameter in ['access_token', 'token_type']) {
-    validate(parameters.containsKey(requiredParameter),
-        'did not contain required parameter "$requiredParameter"');
-    validate(
-        parameters[requiredParameter] is String,
-        'required parameter "$requiredParameter" was not a string, was '
-        '"${parameters[requiredParameter]}"');
-  }
-
-  // TODO(nweiz): support the "mac" token type
-  // (http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01)
-  validate(parameters['token_type'].toLowerCase() == 'bearer',
-      '"$tokenEndpoint": unknown token type "${parameters['token_type']}"');
-
-  var expiresIn = parameters['expires_in'];
-  validate(expiresIn == null || expiresIn is int,
-      'parameter "expires_in" was not an int, was "$expiresIn"');
-
-  for (var name in ['refresh_token', 'scope']) {
-    var value = parameters[name];
-    validate(value == null || value is String,
-        'parameter "$name" was not a string, was "$value"');
-  }
-
-  var scope = parameters['scope'] as String;
-  if (scope != null) scopes = scope.split(delimiter);
-
-  var expiration = expiresIn == null
-      ? null
-      : startTime.add(new Duration(seconds: expiresIn) - _expirationGrace);
-
-  return new Credentials(parameters['access_token'],
-      refreshToken: parameters['refresh_token'],
-      tokenEndpoint: tokenEndpoint,
-      scopes: scopes,
-      expiration: expiration);
 }
 
 /// Throws the appropriate exception for an error response from the
 /// authorization server.
-void _handleErrorResponse(http.Response response, Uri tokenEndpoint) {
-  validate(condition, message) =>
-      _validate(response, tokenEndpoint, condition, message);
-
+void _handleErrorResponse(
+    http.Response response, Uri tokenEndpoint, GetParameters getParameters) {
   // OAuth2 mandates a 400 or 401 response code for access token error
   // responses. If it's not a 400 reponse, the server is either broken or
   // off-spec.
@@ -113,27 +117,22 @@
   var contentType =
       contentTypeString == null ? null : new MediaType.parse(contentTypeString);
 
-  validate(contentType != null && contentType.mimeType == "application/json",
-      'content-type was "$contentType", expected "application/json"');
+  var parameters = getParameters(contentType, response.body);
 
-  var parameters;
-  try {
-    parameters = JSON.decode(response.body);
-  } on FormatException {
-    validate(false, 'invalid JSON');
+  if (!parameters.containsKey('error')) {
+    throw new FormatException('did not contain required parameter "error"');
+  } else if (parameters['error'] is! String) {
+    throw new FormatException(
+        'required parameter "error" was not a string, was '
+        '"${parameters["error"]}"');
   }
 
-  validate(parameters.containsKey('error'),
-      'did not contain required parameter "error"');
-  validate(
-      parameters["error"] is String,
-      'required parameter "error" was not a string, was '
-      '"${parameters["error"]}"');
-
   for (var name in ['error_description', 'error_uri']) {
     var value = parameters[name];
-    validate(value == null || value is String,
-        'parameter "$name" was not a string, was "$value"');
+
+    if (value != null && value is! String)
+      throw new FormatException(
+          'parameter "$name" was not a string, was "$value"');
   }
 
   var description = parameters['error_description'];
@@ -141,10 +140,3 @@
   var uri = uriString == null ? null : Uri.parse(uriString);
   throw new AuthorizationException(parameters['error'], description, uri);
 }
-
-void _validate(
-    http.Response response, Uri tokenEndpoint, bool condition, String message) {
-  if (condition) return;
-  throw new FormatException('Invalid OAuth response for "$tokenEndpoint": '
-      '$message.\n\n${response.body}');
-}
diff --git a/lib/src/parameters.dart b/lib/src/parameters.dart
new file mode 100644
index 0000000..ccbf23e
--- /dev/null
+++ b/lib/src/parameters.dart
@@ -0,0 +1,34 @@
+// Copyright (c) 2018, the Dart 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:convert';
+
+import 'package:collection/collection.dart';
+import 'package:http_parser/http_parser.dart';
+
+/// The type of a callback that parses parameters from an HTTP response.
+typedef Map<String, dynamic> GetParameters(MediaType contentType, String body);
+
+/// Parses parameters from a response with a JSON body, as per the [OAuth2
+/// spec][].
+///
+/// [OAuth2 spec]: https://tools.ietf.org/html/rfc6749#section-5.1
+Map<String, dynamic> parseJsonParameters(MediaType contentType, String body) {
+  // The spec requires a content-type of application/json, but some endpoints
+  // (e.g. Dropbox) serve it as text/javascript instead.
+  if (contentType == null ||
+      (contentType.mimeType != "application/json" &&
+          contentType.mimeType != "text/javascript")) {
+    throw new FormatException(
+        'Content-Type was "$contentType", expected "application/json"');
+  }
+
+  var untypedParameters = JSON.decode(body);
+  if (untypedParameters is! Map) {
+    throw new FormatException(
+        'Parameters must be a map, was "$untypedParameters"');
+  }
+
+  return DelegatingMap.typed(untypedParameters);
+}
diff --git a/lib/src/resource_owner_password_grant.dart b/lib/src/resource_owner_password_grant.dart
index 90c7f6c..ebd1d12 100644
--- a/lib/src/resource_owner_password_grant.dart
+++ b/lib/src/resource_owner_password_grant.dart
@@ -5,6 +5,7 @@
 import 'dart:async';
 
 import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
 
 import 'client.dart';
 import 'handle_access_token_response.dart';
@@ -32,6 +33,16 @@
 /// The scope strings will be separated by the provided [delimiter]. This
 /// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's)
 /// use non-standard delimiters.
+///
+/// By default, this follows the OAuth2 spec and requires the server's responses
+/// to be in JSON format. However, some servers return non-standard response
+/// formats, which can be parsed using the [getParameters] function.
+///
+/// This function is passed the `Content-Type` header of the response as well as
+/// its body as a UTF-8-decoded string. It should return a map in the same
+/// format as the [standard JSON response][].
+///
+/// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1
 Future<Client> resourceOwnerPasswordGrant(
     Uri authorizationEndpoint, String username, String password,
     {String identifier,
@@ -39,7 +50,9 @@
     Iterable<String> scopes,
     bool basicAuth: true,
     http.Client httpClient,
-    String delimiter}) async {
+    String delimiter,
+    Map<String, dynamic> getParameters(
+        MediaType contentType, String body)}) async {
   delimiter ??= ' ';
   var startTime = new DateTime.now();
 
@@ -67,7 +80,8 @@
       headers: headers, body: body);
 
   var credentials = await handleAccessTokenResponse(
-      response, authorizationEndpoint, startTime, scopes, delimiter);
+      response, authorizationEndpoint, startTime, scopes, delimiter,
+      getParameters: getParameters);
   return new Client(credentials,
       identifier: identifier, secret: secret, httpClient: httpClient);
 }
diff --git a/pubspec.yaml b/pubspec.yaml
index 44e41fa..8c49603 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: oauth2
-version: 1.1.1-dev
+version: 1.2.0
 author: Dart Team <misc@dartlang.org>
 homepage: https://github.com/dart-lang/oauth2
 description: >
diff --git a/test/handle_access_token_response_test.dart b/test/handle_access_token_response_test.dart
index 6edfbcb..e32e13d 100644
--- a/test/handle_access_token_response_test.dart
+++ b/test/handle_access_token_response_test.dart
@@ -5,9 +5,12 @@
 import 'dart:convert';
 
 import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
+import 'package:test/test.dart';
+
 import 'package:oauth2/oauth2.dart' as oauth2;
 import 'package:oauth2/src/handle_access_token_response.dart';
-import 'package:test/test.dart';
+import 'package:oauth2/src/parameters.dart';
 
 import 'utils.dart';
 
@@ -15,8 +18,11 @@
 
 final DateTime startTime = new DateTime.now();
 
-oauth2.Credentials handle(http.Response response) => handleAccessTokenResponse(
-    response, tokenEndpoint, startTime, ["scope"], ' ');
+oauth2.Credentials handle(http.Response response,
+        {GetParameters getParameters}) =>
+    handleAccessTokenResponse(
+        response, tokenEndpoint, startTime, ["scope"], ' ',
+        getParameters: getParameters);
 
 void main() {
   group('an error response', () {
@@ -49,6 +55,12 @@
           throwsFormatException);
     });
 
+    test('with a non-JSON, non-plain content-type causes a FormatException',
+        () {
+      expect(() => handleError(headers: {'content-type': 'image/png'}),
+          throwsFormatException);
+    });
+
     test(
         'with a JSON content-type and charset causes an '
         'AuthorizationException', () {
@@ -151,6 +163,37 @@
       expect(credentials.accessToken, equals('access token'));
     });
 
+    test('with custom getParameters() returns the correct credentials', () {
+      var body = '_' +
+          JSON.encode({'token_type': 'bearer', 'access_token': 'access token'});
+      var credentials = handle(
+          new http.Response(body, 200, headers: {'content-type': 'text/plain'}),
+          getParameters: (contentType, body) => JSON.decode(body.substring(1)));
+      expect(credentials.accessToken, equals('access token'));
+      expect(credentials.tokenEndpoint.toString(),
+          equals(tokenEndpoint.toString()));
+    });
+
+    test('throws a FormatException if custom getParameters rejects response',
+        () {
+      var response = new http.Response(
+          JSON.encode({
+            'access_token': 'access token',
+            'token_type': 'bearer',
+            'expires_in': 24,
+            'refresh_token': 'refresh token',
+            'scope': 'scope',
+          }),
+          200,
+          headers: {'content-type': 'foo/bar'});
+
+      expect(
+          () => handle(response,
+              getParameters: (contentType, body) => throw new FormatException(
+                  'unsupported content-type: $contentType')),
+          throwsFormatException);
+    });
+
     test('with a null access token throws a FormatException', () {
       expect(() => handleSuccess(accessToken: null), throwsFormatException);
     });
diff --git a/test/utils.dart b/test/utils.dart
index d6b1d62..92835ea 100644
--- a/test/utils.dart
+++ b/test/utils.dart
@@ -46,8 +46,7 @@
 const isAuthorizationException = const _AuthorizationException();
 
 /// A matcher for functions that throw AuthorizationException.
-const Matcher throwsAuthorizationException =
-    const Throws(isAuthorizationException);
+final Matcher throwsAuthorizationException = throwsA(isAuthorizationException);
 
 class _AuthorizationException extends TypeMatcher {
   const _AuthorizationException() : super("AuthorizationException");
@@ -58,7 +57,7 @@
 const isExpirationException = const _ExpirationException();
 
 /// A matcher for functions that throw ExpirationException.
-const Matcher throwsExpirationException = const Throws(isExpirationException);
+final Matcher throwsExpirationException = throwsA(isExpirationException);
 
 class _ExpirationException extends TypeMatcher {
   const _ExpirationException() : super("ExpirationException");