You will learn,
- How to get the schema from the backend?
- How to use a code generation tool to make things easier?
- How to make a GraphQL API request?
- How to renew the access token?
I will jump right in!
Code Links:
Get the source code from here.
alishgiri / flutter-graphql-authentication
Flutter and GraphQL with Authentication Tutorial.
Flutter & GraphQL with Authentication Tutorial.
Place your Base URL
Global search on the project on VSCode or any IDE and replace the following with your base url.
Steps to download your graphql schema from the backend.
- Install packages from package.json file.
yarn install
# or
npm install
- Give permission to run the script.
chmod +x ./scripts/gen-schema.sh
- Run the script to download your_app.schema.graphql
./scripts/gen-schema.sh
- Run build_runner to convert graphql files from lib/graphql/queries to dart types.
dart run build_runner build
Tools we will be using:
-
get-graphql-schema
This npm package will allow us to download GraphQL schema from our backend.
prisma-labs / get-graphql-schema
Fetch and print the GraphQL schema from a GraphQL HTTP endpoint. (Can be used for Relay Modern.)
Fetch and print the GraphQL schema from a GraphQL HTTP endpoint. (Can be used for Relay Modern.)
Note: Consider using
graphql-cli
instead for improved workflows.Install
npm install -g get-graphql-schema
Usage
Usage: get-graphql-schema [OPTIONS] ENDPOINT_URL > schema.graphql Fetch and print the GraphQL schema from a GraphQL HTTP endpoint (Outputs schema in IDL syntax by default) Options: --header, -h Add a custom header (ex. 'X-API-KEY=ABC123'), can be used multiple times --json, -j Output in JSON format (based on introspection query) --version, -v Print version of get-graphql-schema
Join our Slack community if you run into issues or have questions. We love talking to you!
-
graphql_flutter
We will make GraphQL API requests using this package.
graphql_codegen
This code-generation tool will convert our schema (.graphql) to dart types (.dart).
There is another code-generation tool called Artemis as well but I found this to be better.
Additionally, we will be using,
- Provider — For state management.
- flutter_secure_storage — to store user auth data locally.
- get_it — to locate our registered services and view-models files.
- build_runner — to generate files. We will configure graphql_codegen with this to make code generation possible.
Alternative package to work with GraphQL:
Ferry
I found this package very complicated but feel free to try this out.
This package will help in making GraphQL API requests. This will also be a code-generation tool to convert schema files (.graphql) to dart types (.dart).
Files & Folder structure
lib
- core
- models
- services
- view_models
- graphql
- __generated__
- your_app.schema.graphql // We will download this using get-graphql-schema
- queries
- __generated__
- auth.graphql // this is equivalent to auth end-points in REST API
- ui
- widgets
- views
- locator.dart
- main.dart
pubspec.yml
build.yaml
Check the comments on top of every file below to place the files in their respective folders.
Make the following changes to your pubspec.yml file,
dependencies:
flutter_secure_storage: ^9.0.0
jwt_decode: ^0.3.1
provider: ^6.1.1
graphql_flutter: ^5.2.0-beta.6
get_it: ^7.6.7
dev_dependencies:
build_runner: ^2.4.8
flutter_gen: ^5.4.0
flutter_lints: ^4.0.0
graphql_codegen: ^0.14.0
flutter:
generate: true
Add the following content to your build.yaml file,
targets:
$default:
builders:
graphql_codegen:
options:
assetsPath: lib/graphql/**
outputDirectory: __generated__
clients:
- graphql_flutter
Here, in the options section we have,
assetsPath: All the GraphQL-related code will be placed inside lib/graphql/ so we are pointing it to that folder.
outputDirectory: is where we want our generated code to reside. So create the following folders.
- lib/graphql/generated/
- lib/graphql/queries/generated/
Getting the schema file
Install get-graphql-schema globally using npm or yarn and run it from your project root directory.
# Install using yarn
yarn global add get-graphql-schema
# Install using npm
npm install -g get-graphql-schema
npx get-graphql-schema http://localhost:8000/graphql > lib/graphql/your_app.schema.graphql
We are providing our graphql API link and asking get-graphql-schema to store it on the file your_app.schema.graphql
Modify the above as required!
Adding the endpoints to auth.graphql file
The queries and mutations below are defined by the backend so please get the correct GraphQL schema (also called the end-points).
# lib/graphql/queries/auth.graphql
mutation RegisterUser($input: UserInput!) {
auth {
register(input: $input) {
...RegisterSuccess
}
}
}
query Login($input: LoginInput!) {
auth {
login(input: $input) {
...LoginSuccess
}
}
}
query RenewAccessToken($input: RenewTokenInput!) {
auth {
renewToken(input: $input) {
...RenewTokenSuccess
}
}
}
fragment RegisterSuccess on RegisterSuccess {
userId
}
fragment LoginSuccess on LoginSuccess {
accessToken
refreshToken
}
fragment RenewTokenSuccess on RenewTokenSuccess {
newAccessToken
}
The Implementation!
Run the following command to generate all dart types for our .graphql files.
dart run build_runner build
Now, setting up the graphql, get_it and initialising hive (used for caching) in our main.dart file,
Create a file lib/locator.dart and add the following content.
// locator.dart
import 'package:get_it/get_it.dart';
import 'package:auth_app/core/view_models/login.vm.dart';
import 'package:auth_app/core/services/base.service.dart';
import 'package:auth_app/core/services/auth.service.dart';
import 'package:auth_app/core/services/secure_storage.service.dart';
final locator = GetIt.instance;
void setupLocator() async {
locator.registerSingleton(BaseService());
locator.registerLazySingleton(() => AuthService());
locator.registerLazySingleton(() => SecureStorageService());
locator.registerFactory(() => LoginViewModel());
}
In the lib/main.dart we will call setupLocator() in the main() function as shown below.
// main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:auth_app/locator.dart';
import 'package:auth_app/ui/views/login.view.dart';
import 'package:auth_app/core/services/base.service.dart';
import 'package:auth_app/core/services/auth.service.dart';
void main() async {
// If you want to use HiveStore() for GraphQL caching.
// await initHiveForFlutter();
setupLocator();
runApp(const App());
}
class App extends StatelessWidget {
const App({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return GraphQLProvider(
client: locator<BaseService>().clientNotifier,
child: ChangeNotifierProvider.value(
value: locator<AuthService>(),
child: const MaterialApp(
title: 'your_app',
debugShowCheckedModeBanner: false,
home: LoginView(),
),
),
);
}
Now we will create the remaining files.
Our data models:
// core/models/auth_data.model.dart
class AuthData {
final String? accessToken;
final String? refreshToken;
const AuthData({
required this.accessToken,
required this.refreshToken,
});
}
// core/models/auth.model.dart
import 'package:jwt_decode/jwt_decode.dart';
import 'package:auth_app/core/models/auth_data.model.dart';
class Auth {
final String name;
final String userId;
final String accessToken;
final String refreshToken;
const Auth({
required this.name,
required this.userId,
required this.accessToken,
required this.refreshToken,
});
factory Auth.fromJson(Map<String, dynamic> data) {
final jwt = Jwt.parseJwt(data["accessToken"]);
return Auth(
name: jwt["name"],
userId: jwt["iss"],
accessToken: data["accessToken"],
refreshToken: data["refreshToken"],
);
}
factory Auth.fromAuthData(AuthData data) {
final jwt = Jwt.parseJwt(data.accessToken!);
return Auth(
name: jwt["name"],
userId: jwt["iss"],
accessToken: data.accessToken!,
refreshToken: data.refreshToken!,
);
}
}
Our secure storage service file saves authentication information:
// core/services/secure_storage.service.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:auth_app/core/models/auth.model.dart';
import 'package:auth_app/core/models/auth_data.model.dart';
const accessToken = "access_token";
const refreshToken = "refresh_token";
class SecureStorageService {
final _storage = FlutterSecureStorage(
iOptions: _getIOSOptions(),
aOptions: _getAndroidOptions(),
);
static IOSOptions _getIOSOptions() => const IOSOptions();
static AndroidOptions _getAndroidOptions() => const AndroidOptions(
encryptedSharedPreferences: true,
);
Future<void> storeAuthData(Auth auth) async {
await _storage.write(key: accessToken, value: auth.accessToken);
await _storage.write(key: refreshToken, value: auth.refreshToken);
}
Future<AuthData> getAuthData() async {
final map = await _storage.readAll();
return AuthData(accessToken: map[accessToken], refreshToken: map[refreshToken]);
}
Future<void> updateAccessToken(String token) async {
await _storage.delete(key: accessToken);
await _storage.write(key: accessToken, value: token);
}
Future<void> updateRefreshToken(String token) async {
await _storage.write(key: refreshToken, value: token);
}
Future<void> clearAuthData() async {
await _storage.deleteAll();
}
}
Our base service file contains a configured graphql client which will be used to make the API requests to the server:
// core/services/base.service.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:jwt_decode/jwt_decode.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:auth_app/locator.dart';
import 'package:auth_app/core/services/auth.service.dart';
import 'package:auth_app/core/services/secure_storage.service.dart';
import 'package:auth_app/graphql/queries/__generated__/auth.graphql.dart';
import 'package:auth_app/graphql/__generated__/your_app.schema.graphql.dart';
class BaseService {
late GraphQLClient _client;
late ValueNotifier<GraphQLClient> _clientNotifier;
bool _renewingToken = false;
GraphQLClient get client => _client;
ValueNotifier<GraphQLClient> get clientNotifier => _clientNotifier;
BaseService() {
final authLink = AuthLink(getToken: _getToken);
final httpLink = HttpLink("http://localhost:8000/graphql");
/// The order of the links in the array matters!
final link = Link.from([authLink, httpLink]);
_client = GraphQLClient(
link: link,
cache: GraphQLCache(),
//
// You have two other caching options.
// But for my example I won't be using caching.
//
// cache: GraphQLCache(store: HiveStore()),
// cache: GraphQLCache(store: InMemoryStore()),
//
defaultPolicies: DefaultPolicies(query: Policies(fetch: FetchPolicy.networkOnly)),
);
_clientNotifier = ValueNotifier(_client);
}
Future<String?> _getToken() async {
if (_renewingToken) return null;
final storageService = locator<SecureStorageService>();
final authData = await storageService.getAuthData();
final aT = authData.accessToken;
final rT = authData.refreshToken;
if (aT == null || rT == null) return null;
if (Jwt.isExpired(aT)) {
final renewedToken = await _renewToken(rT);
if (renewedToken == null) return null;
await storageService.updateAccessToken(renewedToken);
return 'Bearer $renewedToken';
}
return 'Bearer $aT';
}
Future<String?> _renewToken(String refreshToken) async {
try {
_renewingToken = true;
final result = await _client.mutate$RenewAccessToken(Options$Mutation$RenewAccessToken(
fetchPolicy: FetchPolicy.networkOnly,
variables: Variables$Mutation$RenewAccessToken(
input: Input$RenewTokenInput(refreshToken: refreshToken),
),
));
final resp = result.parsedData?.auth.renewToken;
if (resp is Fragment$RenewTokenSuccess) {
return resp.newAccessToken;
} else {
if (result.exception != null && result.exception!.graphqlErrors.isNotEmpty) {
locator<AuthService>().logout();
}
}
} catch (e) {
rethrow;
} finally {
_renewingToken = false;
}
return null;
}
}
We will use _client in the file above to make the GraphQL API requests. We will also check if our access-token has expired before making an API request and renew it if necessary.
File auth.service.dart contains all Auth APIs service functions:
// core/services/auth.service.dart
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:auth_app/locator.dart';
import 'package:auth_app/core/models/auth.model.dart';
import 'package:auth_app/core/services/base.service.dart';
import 'package:auth_app/core/services/secure_storage.service.dart';
import 'package:auth_app/graphql/queries/__generated__/auth.graphql.dart';
import 'package:auth_app/graphql/__generated__/your_app.schema.graphql.dart';
class AuthService extends ChangeNotifier {
Auth? _auth;
final client = locator<BaseService>().client;
final storageService = locator<SecureStorageService>();
Auth? get auth => _auth;
Future<void> initAuthIfPreviouslyLoggedIn() async {
final auth = await storageService.getAuthData();
if (auth.accessToken != null) {
_auth = Auth.fromAuthData(auth);
notifyListeners();
}
}
Future<void> login(Input$LoginInput input) async {
final result = await client.query$Login(Options$Query$Login(
variables: Variables$Query$Login(input: input),
));
final resp = result.parsedData?.auth.login;
if (resp is Fragment$LoginSuccess) {
_auth = Auth.fromJson(resp.toJson());
storageService.storeAuthData(_auth!);
notifyListeners();
} else {
throw gqlErrorHandler(result.exception);
}
}
Future<void> registerUser(Input$UserInput input) async {
final result = await client.mutate$RegisterUser(Options$Mutation$RegisterUser(
variables: Variables$Mutation$RegisterUser(input: input),
));
final resp = result.parsedData?.auth.register;
if (resp is! Fragment$RegisterSuccess) {
throw gqlErrorHandler(result.exception);
}
}
Future<void> logout() async {
await locator<SecureStorageService>().clearAuthData();
_auth = null;
notifyListeners();
}
// You can put this in a common utility functions so
// that you can reuse it in other services file too.
//
String gqlErrorHandler(OperationException? exception) {
if (exception != null && exception.graphqlErrors.isNotEmpty) {
return exception.graphqlErrors.first.message;
}
return "Something went wrong.";
}
}
Our base view and base view model:
// ui/shared/base.view.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:auth_app/locator.dart';
import 'package:auth_app/core/view_models/base.vm.dart';
class BaseView<T extends BaseViewModel> extends StatefulWidget {
final Function(T)? dispose;
final Function(T)? initState;
final Widget Function(BuildContext context, T model, Widget? child) builder;
const BaseView({
super.key,
this.dispose,
this.initState,
required this.builder,
});
@override
BaseViewState<T> createState() => BaseViewState<T>();
}
class BaseViewState<T extends BaseViewModel> extends State<BaseView<T>> {
final T model = locator<T>();
@override
void initState() {
if (widget.initState != null) widget.initState!(model);
super.initState();
}
@override
void dispose() {
if (widget.dispose != null) widget.dispose!(model);
super.dispose();
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<T>.value(
value: model,
child: Consumer<T>(builder: widget.builder),
);
}
}
// core/view_models/base.vm.dart
import 'package:flutter/material.dart';
class BaseViewModel extends ChangeNotifier {
bool _isLoading = false;
final scaffoldKey = GlobalKey<ScaffoldState>();
bool get isLoading => _isLoading;
setIsLoading([bool busy = true]) {
_isLoading = busy;
notifyListeners();
}
void displaySnackBar(String message) {
final scaffoldMessenger = ScaffoldMessenger.of(
scaffoldKey.currentContext!,
);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.warning, color: Colors.white),
const SizedBox(width: 10),
Flexible(child: Text(message)),
],
),
),
);
}
}
In the above base view, we use the Provider as a state management tool. The base view model extends ChangeNotifier which notifies its view when the notifyListeners() function is called in the View Model.
Now, We will be using the base view and base view model for our login view and login view model:
// ui/views/login.view.dart
import 'package:flutter/material.dart';
import 'package:auth_app/ui/shared/base.view.dart';
import 'package:auth_app/core/view_models/login.vm.dart';
class LoginView extends StatelessWidget {
const LoginView({super.key});
@override
Widget build(BuildContext context) {
return BaseView<LoginViewModel>(
builder: (context, loginVm, child) {
return Scaffold(
key: loginVm.scaffoldKey,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Form(
// Attach form key for validations. I won't be adding validations.
// key: loginVm.formKey,
child: Column(
children: [
Text("Auth App", style: Theme.of(context).textTheme.displayMedium),
const SizedBox(height: 30),
TextFormField(
onChanged: loginVm.onChangedEmail,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(hintText: "Email"),
),
const Divider(height: 2),
TextFormField(
obscureText: true,
onChanged: loginVm.onChangedPassword,
decoration: const InputDecoration(hintText: "Password"),
),
const SizedBox(height: 20),
TextButton(
onPressed: loginVm.onLogin,
child: loginVm.isLoading ? const CircularProgressIndicator() : const Text("Login"),
),
],
),
),
],
),
),
),
);
},
);
}
}
The final file in our tutorial and we are all done 🎉:
// core/view_models/login.vm.dart
import 'package:auth_app/locator.dart';
import 'package:auth_app/core/view_models/base.vm.dart';
import 'package:auth_app/core/services/auth.service.dart';
import 'package:auth_app/graphql/__generated__/your_app.schema.graphql.dart';
class LoginViewModel extends BaseViewModel {
String? _email;
String? _password;
// Used for validation or any other purpose like clearing form and more...
// final formKey = GlobalKey<FormState>();
final _authService = locator<AuthService>();
void onChangedPassword(String value) => _password = value;
void onChangedEmail(String value) => _email = value;
Future<void> onLogin() async {
// Validate login details using [formKey]
// if (!formKey.currentState!.validate()) return;
try {
setIsLoading(true);
final input = Input$LoginInput(identifier: _email!, password: _password!);
await _authService.login(input);
displaySnackBar("Successfully logged in!");
} catch (error) {
displaySnackBar(error.toString());
} finally {
setIsLoading(false);
}
}
}
And always use Provider to access auth from the AuthService, this will make sure that your UI gets updated when you call notifyListeners() in AuthService.
// Always access auth using Provider.of
Widget build(BuildContext context) {
final auth = Provider.of<AuthService>(context).auth;
// Set listen to false if you don't want to re-render the widget.
//
// final auth = Provider.of<AuthService>(context, listen: false).auth;
// DO NOT DO THIS!
// If you do this then your UI won't be updated,
// when you call notifyListeners() in AuthService.
//
// final auth = locator<AuthService>().auth;
return Scaffold(...)
}
I hope this gives you a complete idea about working with GraphQL in Flutter. If you have any questions feel free to comment.
Awesome! See you next time.
Top comments (0)