Working with the Google API in Serverpod: Authentication — Part 2.5
Authentication Series
Part 1 — Email and Password Authentication
Part 2 — Google Authentication
Part 2.5 — Google API
Part 3 — Apple Authentication
Working with the Google API
The default setup for Google Sign-In integration in your Flutter app allows you access to basic user information such as email, profile image, and name. However, you might require additional functionality, like accessing a user’s calendar, contacts, or files. We will showcase how to work with the google API by implementing an integration with YouTube to fetch the users liked videos. This feature goes beyond simple authentication, if you only want to let users sign in you can skip this tutorial.
Prerequisite
- Serverpod Project: An existing Serverpod project with the serverpod_auth module installed is necessary for this tutorial. If you haven’t done this already, our Part 1: Email and Password Authentication provides a step-by-step guide.
- Google Sign in integration: You need to integrate Google Sign in to be able to complete this tutorial. Take a look at Part 2: Google Authentication if you have not completed this step already.
Requesting Additional Access Scopes
- Update the OAuth Consent Screen: To gain access to more than just the basic user information, you need to add the required scopes to the OAuth consent screen.
- Request Access at Sign-In: After updating the consent screen, request access to the additional scopes at the time of signing in. You can do this by setting the
additionalScopes
parameter of thesignInWithGoogle
method or theSignInWithGoogleButton
widget.
SignInWithGoogleButton(
...
additionalScopes: const ['https://www.googleapis.com/auth/youtube'],
)
A full list of available scopes can be found here.
Accessing Google APIs from the Server
With additional access scopes, you can access a range of Google APIs on the server side. Here is how you do it:
- Install googleapis: Use the
googleapis
package to access the different google apis. Rundart pub add googleapis
in your server project. - Enable the API: Go to the google cloud console and enable the specific api you want to use. In this example we are using the YouTube API, you can enable it here.
- Get an Auth Client: Use the
authClientForUser
method from theserverpod_auth_server
package to request anAutoRefreshingAuthClient
. This client can be used to access Google's APIs on behalf of the user.
import 'package:serverpod_auth_server/module.dart';
// Retrieve the google client
final googleClient = await GoogleAuth.authClientForUser(session, userId!);
Use the google client to retrieve data from some api.
var youTubeApi = YouTubeApi(googleClient);
var favorites = await youTubeApi.playlistItems.list(
['snippet'],
playlistId: 'LL', // Liked List
);
In essence this is all you need to do to work with the google API. But let’s see how we can combine this info a fully functional Serverpod endpoint complete with error handling.
Setting Up Protocol Classes and Generating Dart Models
First, we will define two protocol classes in their respective YAML files. The first, NotSignedInWithGoogle
, is an exception class used when the user has not signed in with Google. The second, VideoPreview
, is a data class used to hold video information retrieved from the YouTube API.
Create the protocol/not_signed_in_with_google.yaml
file with the following content:
exception: NotSignedInWithGoogle
fields:
message: String
Then, create the protocol/video_preview.yaml
file with this content:
class: VideoPreview
fields:
videoId: String
title: String
thumbnailUrl: String
publishedAt: DateTime
videoUrl: String
After creating these files, we need to generate the corresponding Dart models. Serverpod comes with a handy tool for this, which we’ll use by running the following command in the terminal:
serverpod generate
This command should generate the not_signed_in_with_google.dart
and video_preview.dart
files in the generated
directory of your server project, and these classes are ready to use in your server code.
Creating the YouTube Endpoint
Now, let’s build an endpoint to interact with the YouTube API. To do this, create a new file named youtube_endpoint.dart
inside the endpoints
directory and start by importing the necessary modules.
import 'package:googleapis/youtube/v3.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_server/module.dart';
import '../generated/not_signed_in_with_google.dart';
import '../generated/video_preview.dart';
Next, define a new class YoutubeEndpoint
that extends Endpoint
and requires user login.
class YoutubeEndpoint extends Endpoint {
@override
bool get requireLogin => true;
// ...
}
Inside this class, let’s implement a readFavoriteVideos
method. This method retrieves a user's favorite videos from YouTube. It returns a Future
that completes with a List
of VideoPreview
objects.
Future<List<VideoPreview>> readFavoriteVideos(Session session) async {
// ...
}
Fetching the User’s ID and Google Client
Firstly, retrieve the ID of the currently authenticated user and the Google client associated with this user.
final userId = await session.auth.authenticatedUserId;
final googleClient = await GoogleAuth.authClientForUser(session, userId!);
Checking if User is Authenticated with Google
If the googleClient
is null
, it implies that the user isn't authenticated with Google. In such a case, throw a NotSignedInWithGoogle
exception. This is the exception we defined in the protocol file earlier!
if (googleClient == null) {
// User is not authenticated with Google
throw NotSignedInWithGoogle(message: 'Sign in with Google to access this feature.');
}
Retrieving Liked Videos from YouTube
Establish a connection to the YouTube API and fetch the user’s favorite videos. The googleClient
is an object containing the user credentials to access the different google resources.
final youTubeApi = YouTubeApi(googleClient);
final favorites = await youTubeApi.playlistItems.list(
['snippet'],
playlistId: 'LL', // Liked List
);
Transforming the Response
Finally, transform the API response into a list of VideoPreview
objects.
final videoPreviews = favorites.items
?.where(_isValidPlaylistItem)
.map(_converToVideoPreview)
.toList();
return videoPreviews ?? [];
What happens here? Let’s break it down!
favorites.items
: This is the list of playlist items we received from the YouTube API. Each item in this list corresponds to a video in the user's favorites.?.where(_isValidPlaylistItem)
: We're using thewhere
method to filter the list of items. Thewhere
method loops through each item in the list and keeps only those items that satisfy the condition specified by_isValidPlaylistItem
. The condition in_isValidPlaylistItem
checks that all required fields (title, publishedAt, thumbnail url, and videoId) are present in the item. The?
before.where
is a null-aware operator, which means iffavorites.items
is null, the code after it won't be executed andnull
will be returned..map(_converToVideoPreview)
: After filtering the list, we use themap
method to transform each item in the list from aPlaylistItem
object to aVideoPreview
object. The transformation is performed by the_converToVideoPreview
function..toList()
: Themap
method returns an iterable, so we convert it back into a list.- Lastly we return the
videoPreviews
list. However, ifvideoPreviews
isnull
(which can happen iffavorites.items
wasnull
), the null-coalescing operator??
ensures that an empty list is returned instead. This is a best practice to avoid null pointer exceptions elsewhere in your code.
Defining Helper Methods
You might have noticed the two helper methods: _isValidPlaylistItem
and _converToVideoPreview
is missing an implementation. Let's define them now.
- _isValidPlaylistItem: This method takes a
PlaylistItem
as input and checks if it's valid i.e., it has all the required fields:title
,publishedAt
,url
, andvideoId
. - _converToVideoPreview: This method takes a
PlaylistItem
and transforms it into aVideoPreview
object.
bool _isValidPlaylistItem(PlaylistItem item) {
final snippet = item.snippet;
if (snippet?.title == null) return false;
if (snippet?.publishedAt == null) return false;
if (snippet?.thumbnails?.default_?.url == null) return false;
if (snippet?.resourceId?.videoId == null) return false;
return true;
}
VideoPreview _converToVideoPreview(PlaylistItem item) {
return VideoPreview(
videoId: item.snippet!.resourceId!.videoId!,
title: item.snippet!.title!,
thumbnailUrl: item.snippet!.thumbnails!.default_!.url!,
publishedAt: item.snippet!.publishedAt!,
videoUrl:
'https://www.youtube.com/watch?v=${item.snippet!.resourceId!.videoId!}',
);
}
Putting it all together you should end up with a file inside endpoints/youtube_endpoint.dart
that looks like this:
import 'package:googleapis/youtube/v3.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_server/module.dart';
import '../generated/not_signed_in_with_google.dart';
import '../generated/video_preview.dart';
class YoutubeEndpoint extends Endpoint {
@override
bool get requireLogin => true;
Future<List<VideoPreview>> readFavoriteVideos(Session session) async {
final userId = await session.auth.authenticatedUserId;
final googleClient = await GoogleAuth.authClientForUser(session, userId!);
if (googleClient == null) {
// User is not authenticated with Google
throw NotSignedInWithGoogle(message: 'Sign in with Google to access this feature.');
}
try {
final youTubeApi = YouTubeApi(googleClient);
final favorites = await youTubeApi.playlistItems.list(
['snippet'],
playlistId: 'LL', // Liked List
);
final videoPreviews = favorites.items
?.where(_isValidPlaylistItem)
.map(_converToVideoPreview)
.toList();
return videoPreviews ?? [];
} catch (error) {
print('Failed to read favorites videos from the google api.');
// There are a number of things that can go wrong here, for example if
// we didn't enable the youtube api in the google cloud console.
rethrow;
}
}
bool _isValidPlaylistItem(PlaylistItem item) {
final snippet = item.snippet;
if (snippet?.title == null) return false;
if (snippet?.publishedAt == null) return false;
if (snippet?.thumbnails?.default_?.url == null) return false;
if (snippet?.resourceId?.videoId == null) return false;
return true;
}
VideoPreview _converToVideoPreview(PlaylistItem item) {
return VideoPreview(
videoId: item.snippet!.resourceId!.videoId!,
title: item.snippet!.title!,
thumbnailUrl: item.snippet!.thumbnails!.default_!.url!,
publishedAt: item.snippet!.publishedAt!,
videoUrl:
'https://www.youtube.com/watch?v=${item.snippet!.resourceId!.videoId!}',
);
}
}
With this, you have created an endpoint that allows you to fetch a user’s favorite videos from YouTube. Great job! Run serverpod generate
to generate all the client code we need to integrate this in our flutter app.
Creating the Flutter widget
Let’s connect our server code to our Flutter app and render a list of our favorite YouTube videos. In this part we will make use of the url_launcher
package to open the YouTube videos to play them. You can add the dependency by running:
flutter pub add url_launcher
In our Flutter project create a new file inside src/widgets/youtube_favorits.dart
. This is where we'll create a widget to display a user's favorite YouTube videos.
Setting up the Widget
Begin by importing the necessary packages at the start of the file and declare the YoutubeFavorits
StatefulWidget.
import 'package:flutter/material.dart';
import 'package:your_app_name/src/client/protocol.dart'; // Import your generated protocol file
class YoutubeFavorits extends StatefulWidget {
const YoutubeFavorits({Key? key}) : super(key: key);
@override
_YoutubeFavoritsState createState() => _YoutubeFavoritsState();
}
We’re creating a StatefulWidget
because the list of favorite videos can change, and we want to be able to rebuild our widget when that happens.
Defining the State
Next, define the _YoutubeFavoritsState
class. This class will hold the mutable state for our YoutubeFavorits
widget.
class _YoutubeFavoritsState extends State<YoutubeFavorits> {
List<VideoPreview> _favorites = [];
String? _errorMessage;
}
Here we’ve declared a list _favorites
to hold the videos, and a String _errorMessage
to hold any error messages.
Fetching YouTube Favorites
Now we’ll define a method to fetch the user’s favorite videos from the server.
void readYouTubeFavorites() async {
try {
final favorites = await client.youtube.readFavoriteVideos();
setState(() {
_favorites = favorites;
_errorMessage = null;
});
} catch (e) {
var message = 'Something went wrong';
if (e is NotSignedInWithGoogle) {
message = e.message;
}
setState(() {
_errorMessage = message;
});
}
}
The readYouTubeFavorites
method makes a call to the server using the readFavoriteVideos
method we defined in the server code. If successful, it updates the state with the new list of favorite videos. If an error occurs, it updates the state with an appropriate error message.
Building the UI
Now we’ll build the UI for our widget in the build
method.
We can easily convert our VideoPreview list into renderable widgets using the ListTile
widget.
_favorites.map((v) => ListTile(
onTap: () => launchUrl(Uri.parse(v.videoUrl)),
leading: Image.network(v.thumbnailUrl),
title: Text(v.title),
subtitle: Text(
v.publishedAt.toString(),
),
))
onTap: () => launchUrl(Uri.parse(v.videoUrl))
: This line of code specifies what happens when a user taps on theListTile
. ThelaunchUrl
function is invoked, which would open the provided URL in a web browser. The URL is thevideoUrl
of the current video in our_favorites
list.leading: Image.network(v.thumbnailUrl)
: Theleading
attribute typically holds a widget that appears at the start of theListTile
. In this case, it's anImage
widget that displays an image from the provided URL, which is the thumbnail of the current video in our_favorites
list.title: Text(v.title)
: Thetitle
attribute is the primary content of theListTile
. In our case, it's aText
widget that displays the title of the current video.subtitle: Text(v.publishedAt.toString())
: Thesubtitle
attribute is secondary content that is displayed below the title. Here, it's aText
widget displaying the publication date of the current video.
Note: On Flutter web loading the image may fail with a CORS error. This is because we are using the URL directly from google and by default Flutter uses canvaskit which loads images with http requests. To get around this you can run Flutter with the html renderer. (Read more)
flutter run -d chrome --web-renderer html
Putting all this together and adding in a button to fetch the videos we end up with a widget that looks like this:
class YoutubeFavorits extends StatefulWidget {
const YoutubeFavorits({Key? key}) : super(key: key);
@override
_YoutubeFavoritsState createState() => _YoutubeFavoritsState();
}
class _YoutubeFavoritsState extends State<YoutubeFavorits> {
List<VideoPreview> _favorites = [];
String? _errorMessage;
void readYouTubeFavorites() async {
try {
final favorites = await client.youtube.readFavorites();
setState(() {
_favorites = favorites;
_errorMessage = null;
});
} catch (e) {
var message = 'Something went wrong';
if (e is NotSignedInWithGoogle) {
message = e.message;
}
setState(() {
_errorMessage = message;
});
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton(
onPressed: () {
readYouTubeFavorites();
},
child: const Text('Refresh videos'),
),
Text("Videos: ${_favorites.length}"),
],
),
),
_errorMessage != null
? Text(_errorMessage!)
: Column(
children: _favorites
.map(
(v) => ListTile(
onTap: () => launchUrl(Uri.parse(v.videoUrl)),
leading: Image.network(v.thumbnailUrl),
title: Text(v.title),
subtitle: Text(
v.publishedAt.toString(),
),
),
)
.toList(),
),
],
);
}
}
Last step we have to do is including this new widget in the AccountPage
we created in the previous tutorial. The build method would now look like this for src/widgets/account_page.dart
@override
Widget build(BuildContext context) {
return ListView(
children: [
ListTile(
contentPadding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
leading: CircularUserImage(
userInfo: sessionManager.signedInUser,
size: 42,
),
title: Text(sessionManager.signedInUser!.userName),
subtitle: Text(sessionManager.signedInUser!.email ?? ''),
),
Padding(
padding: const EdgeInsets.all(16),
child: ElevatedButton(
onPressed: () {
sessionManager.signOut();
},
child: const Text('Sign out'),
),
),
const YoutubeFavorits(),
],
);
}
Running our app
Now that we’ve completed both the server-side and client-side code, it’s time to run our application to see our favorite YouTube videos in action.
Start the Serverpod Server: Navigate to the directory where your server-side code resides and run the following command to start your Serverpod server:
dart bin/main.dart
Run the Flutter App: Open a new terminal, navigate to your Flutter project’s directory and run:
flutter run
Conclusion
Congratulations on building this integration with the YouTube API using Serverpod and Flutter! We’ve covered a lot in this tutorial, from defining server-side endpoints and exceptions, calling the YouTube API, to creating a Flutter UI to display our data. Hopefully, you’ve found it useful for understanding how to work with external APIs in Serverpod and Flutter.