How to use GraphQL with Ext JS—A Tutorial
TL;DR GraphQL schema and ExtJS data model work together very well. Apollo Client provides a core library that can be included in ExtJS projects to easily manage GraphQL requests, adding powerful features such as in-memory caching.
Here I explain a proxy implementation that fully wraps the GraphQL integration, generating requests from ExtJS models (with associations) and their values. The proxy, together with a working example, can be found here.
What is GraphQL?
GraphQL is a great tool for developers to control the data they need from an API: by introducing schemas, it provides a standard structure for your APIs. It requests you to define object types and fields, unlike the REST APIs that are based on a style convention.
The GraphQL structured approach in remote communication allows you to use a lot of productivity tools both in server-side runtime and front-end applications. These include testing, auto-generated documentation, editing and multi-language SDKs.
From the client side, the main difference with REST is the ability to send queries to the server, specifying exactly what you need rather than relying on an “unpredictable” route implementation.
More about Ext JS
ExtJS is a framework for Web and mobile Single Page Applications and it is quite popular for development of reach-data interfaces. It is also a first-class citizen in the front-end technologies in terms of “productivity” and ” schema” and this is the reason why GraphQL is a promising tool for ExtJS data management.
At the time of writing, there is no built-in integration for GraphQL queries in the framework, but in this article we’ll see how to integrate GraphQL in an ExtJS application in order to enable remote communication with a GraphQL back-end and benefit from of all the GraphQL features.
Note: an alternative approach can be found using ExtReact taking advantage of Apollo for React integration. Here is the tech talk repo.
GraphQL client library
When I first introducedGraphQL, I said that one of the advantages are the productivity tools. A production-ready JS library has to be there, and the solutions out there are certainly more than a client SDK. The two options are:
I chose Apollo Client because it provides a core library that is framework agnostic rather than Relay which is focused on React use-case. In addition, Apollo is a very popular platform for development of both GraphQL clients and server APIs.
GraphQL server
Since the implementation of GraphQL server is not in the scope of this how-to article, I assume you have a working back-end, or you can start a new Apollo Server project from the official tutorial.
This is the example schema used for this article.
type Query { getUsers( limit: Int offset: Int orderBy: String filter: String ): Users user(id: Int!): User } type Mutation { createUser(createUserInput: CreateUserInput!): Int updateUser( id: Int! updateUserInput: UpdateUserInput! ): Int deleteUser(id: Int!): Int } type User { id: Int! username: String! firstName: String lastName: String role: String! email: String! areas: [Area!] } type Area { id: Int! name: String! } type Users { count: Int! users: [User!]! } input CreateUserInput { username: String! firstName: String! lastName: String! role: String! email: String! areas: [Int!]! } input UpdateUserInput { username: String firstName: String lastName: String role: String email: String areas: [Int!] password: String }
ExtJS application setup
The test case analysed here is based on the ExtJS framework version 7.3.1 using the modern toolkit. The same code and principles can be applied to classic toolkit projects.
The following instructions are specifically for the new tooling based on NPM, but to obtain the same result using Sencha CMD you will need to download and include the two lib js files as external resources.
Generate the project.
ext-gen app -a -t moderndesktopminimal -n GraphQLApp
Then, from the project dir execute.
npm install --save @apollo/client npm install --save graphql
Edit the index.js file.
require('graphql'); GraphQLApp.xApolloClient = require('@apollo/client/core');
Initialize the client
A single instance of Apollo client must be created during application launch where we can specify our preferred configuration.
A singleton class GraphQL
can be useful to create the client ensuring that it will be accessible from other components.
Ext.define('GraphQLApp.GraphQL', { alternateClassName: ['GraphQL'], singleton: true, client: null, initClient(baseUrl) { this.client = new GraphQLApp.xApolloClient.ApolloClient({ cache: new GraphQLApp.xApolloClient.InMemoryCache({}), uri: baseUrl, defaultOptions: { query: { fetchPolicy: 'network-only', errorPolicy: 'all', }, mutate: { errorPolicy: 'all', }, }, }); }, });
The init function can be called during the launch process (in Application.js
).
launch() { GraphQL.initClient('http://localhost:3000/graphql'); Ext.Viewport.add([{ xtype: 'mainview' }]); }
Caching options
One of the main benefits of Apollo is the outstanding client caching system provided with the library. The InMemoryCache
class is provided by the package and enables your client to respond to future queries for the same data without sending unnecessary network requests.
Many options are available to configure Apollo caching, in many cases you will just need to select your preferred fetch policy (ex. cache-first
or network-only
). Here is the official documentation.
Send a GraphQL request
The client is now ready to be used in your controllers to query GraphQL data from the back-end. The response is automatically processed and response data, both from success and error, will be available as js objects.
GraphQL.client .query({ query: GraphQLApp.xApolloClient.gql` query GetUsers { getUsers { count users { id username } } } ` }) .then(result => console.log(result));
This query will retrieve just the total count of system users with the list of their ids and usernames, without loading additional fields and relations.
ExtJS data model with GraphQL schema
GraphQL queries rely on a schema that is shared between sender and receiver in order to allow the client to request exactly the data it needs.
ExtJS provides a data package to manage the structure and records of data in the application, and the UI components (grids, select fields, trees, forms, …etc) are able to visualize this data. For this reason, ExtJS applications should be able to communicate with GraphQL APIs representing the schema with their data models, with absolutely no modification on UI components and controllers.
To integrate GraphQL through the data package we will need to:
- Define the GraphQL schema using Models, with fields and associations. There is no need for any customization.
- Implement a custom Proxy. It should extend the ServerProxy and provide the logic of query generation. The inputs for query generation are the data models, pagination params, sort params and filter params.
- Implement a custom Reader. It must be a very simple implementation because Apollo client already parses json responses, we just need to create new Models from Apollo results.
GraphQL proxy and reader implementation
The GraphQL proxy can extend the built-in Ext.data.proxy.Server
class. That is the base class for Soap and Ajax proxies which are widely used in ExtJS applications. The GraphQL integration is based on ajax requests with JSON payloads, this is why most of the logic from ServerProxy is still valid for GraphQL. The custom implementation is limited to the requests (GraphQL queries) generation and parsing responses.
Here is the full implementation with details explained in the following sections.
Ext.define('GraphQLApp.proxy.GraphQLProxy', { extend: 'Ext.data.proxy.Server', alias: 'proxy.graphql', config: { pageParam: '', startParam: 'offset', limitParam: 'limit', sortParam: 'orderBy', filterParam: 'filter', query: { list: undefined, get: undefined }, mutation: { create: undefined, update: undefined, destroy: undefined, }, readListTpl: [ 'query {', '{name}(', '', '{$}: {.} ', ') {', '{totalProperty},', '{rootProperty} {', '{fields}', '}}}' ], readSingleTpl: [ 'query {', '{name}(', '"{.}" ', '', '{$}: {.} ', ') {', '{fields}', '}}' ], saveTpl: [ 'mutation {', '{name} (', '"{.}" ', '', '{idParam}: {id},', ' ', '{name}Input: {', '{values}', '})}', ], deleteTpl: [ 'mutation {', '{name} (', '{idParam}: {id}', ')}', ], }, applyReadListTpl(tpl) { return this.createTpl(tpl); }, applyReadSingleTpl(tpl) { return this.createTpl(tpl); }, applySaveTpl(tpl) { return this.createTpl(tpl); }, applyDeleteTpl(tpl) { return this.createTpl(tpl); }, createTpl(tpl) { if (tpl && !tpl.isTpl) { tpl = new Ext.XTemplate(tpl); } return tpl; }, encodeSorters(sorters, preventArray) { const encoded = this.callParent([sorters, preventArray]); // Escape double quotes to pass in GQL string return encoded.replace(/"/g, '\\"'); }, encodeFilters(filters) { const encoded = this.callParent([filters]); // Escape double quotes to pass in GQL string return encoded.replace(/"/g, '\\"'); }, doRequest(operation) { const me = this, action = operation.getAction(), requestPromise = action === 'read' ? me.sendQuery(operation) : me.sendMutation(operation); requestPromise .then((result) => { if (!me.destroying && !me.destroyed) { me.processResponse(!result.errors, operation, null, Ext.merge(result, { status: result.errors ? 500 : 200 })); } }) .catch((error) => { if (!me.destroying && !me.destroyed) { me.processResponse(true, operation, null, { status: 500 }); } }); }, sendQuery(operation) { const me = this, initialParams = Ext.apply({}, operation.getParams()), params = Ext.applyIf(initialParams, me.getExtraParams() || {}), operationId = operation.getId(), idParam = me.getIdParam(); let query; Ext.applyIf(params, me.getParams(operation)); if (operationId === undefined) { // Load list query = me.getReadListTpl().apply({ name: me.getQuery()['list'], params, fields: me.getFields(me.getModel()), totalProperty: me.getReader().getTotalProperty(), rootProperty: me.getReader().getRootProperty() }); } else { // Load recod by id if (params[idParam] === undefined) { params[idParam] = operationId; } query = me.getReadSingleTpl().apply({ name: me.getQuery()['get'], params, fields: me.getFields(me.getModel()), recordId: operationId }); } return GraphQL.client .query({ query: GraphQLApp.xApolloClient.gql(query) }); }, sendMutation(operation) { const me = this, action = operation.getAction(), records = operation.getRecords(); let query; if (action === 'destroy') { // Delete record query = me.getDeleteTpl().apply({ name: me.getMutation()[action], idParam: me.getIdParam(), id: records[0].getId(), }); } else { // Save record query = me.getSaveTpl().apply({ name: me.getMutation()[action], values: me.getValues(records[0]), action, idParam: me.getIdParam(), id: records[0].getId(), }); } return GraphQL.client .mutate({ mutation: GraphQLApp.xApolloClient.gql(query) }); }, privates: { getFields(model) { const me = this; const fields = model.prototype.fields; return fields .filter(field => !field.calculated) .map(field => { if (!field.reference) { return field.name; } else { return `${field.name} {${me.getFields(Ext.data.schema.Schema.lookupEntity(field.reference.type))}}`; } }) .join(','); }, getValues(record) { const values = record.getData({ associated: true, changes: !this.getWriter().getWriteAllFields() }); const valuesArray = []; delete values[this.getIdParam()]; Ext.Object.each(values, (key, value) => valuesArray.push(`${key}: ${Ext.encode(value)}`)); return valuesArray.join(','); }, } });
Requests composition
Proxy actions must be divided into queries and mutations. Read
actions will produce a GraphQL query while create
, update
and destroy
must generate a mutation. For each action a template is defined to generate the right GraphQL json payload.
Queries templates (readListTpl
and readSingleTpl
) are applied on model fields, sort and filter parameters in order to retrieve the exact structure described in the model.
Mutation templates (saveTpl
, deleteTpl
) are applied on record values to save data based on record state (it sends only modified values by default).
Queries for lists with pagination, sorting and filtering
This proxy applies the Apollo offset-based pagination. This is provided by the built-in Server proxy and configured with Apollo standard parameters. In order to get both total count and results from server response, the totalProperty
and rootProperty
taken from the Reader are included in the query.
Sorters and filters are sent in a query using the default ExtJS serializers that produce a JSON string representing the list of sorters/filters. This is a simple approach that offers a full integration with ExtJs elements. If the GraphQL server needs a structured schema (instead of a string) for sorters and filters, different encodeSorters and encodeFilters can be implemented.
The fields to select in the query are taken directly from the model in the getFields
private method. In this implementation associations are supported by retrieving fields recursively from referenced models. By default, all calculated fields are ignored.
Mutations for create, update and destroy
The values to send for create and update mutations are retrieved in the getValues private method. Different options can be sent to record.getData
in order to customize what values to send.
Reading responses
All the responses from GraphQL APIs can be read using the built-in proxy logic using the processResponse
method. This ensures to be completely compatible with ExtJS stores and then with UI components.
In order to parse the data, a simple Reader implementation is needed because Apollo Client parse the response by itself therefore there is no need to redo the task.
Ext.define('GraphQLApp.proxy.GraphQLReader', { extend: 'Ext.data.reader.Json', alias: 'reader.graphql', read: function (response, readOptions) { if (response && response.data) { // NOTE Clone is needed to prevent conflicts with Apollo client const responseObj = Ext.clone(Ext.Object.getValues(response.data)[0]); if (this.getTotalProperty() && responseObj.hasOwnProperty(this.getTotalProperty())) { // List response return this.readRecords(responseObj, readOptions); } else { // Single record response return this.readRecords([responseObj], readOptions); } } else { return new Ext.data.ResultSet({ total: 0, count: 0, records: [], success: false, message: response.errors }); } }, });
Define a Model
In the interest of defining the models, we just need to use the GraphQL proxy and describe the fields reproducing the GraphQL schema we want to connect with. In this article we define the User model with a one-to-many association with Area.
In order to generate GraphQL requests we just need to specify in the proxy configuration the root level names for queries and mutations.
Ext.define('GraphQLApp.model.Area', { extend: 'Ext.data.Model', fields: [{ name: 'id', type: 'int' }, { name: 'name', type: 'string' }], proxy: { type: 'graphql', query: { list: 'getAreas', get: 'area' }, reader: { type: 'graphql', rootProperty: 'areas', totalProperty: 'count' } }, }); Ext.define('GraphQLApp.model.User', { extend: 'Ext.data.Model', fields: [{ name: 'id', type: 'int' }, { name: 'username', type: 'string' }, { name: 'email', type: 'string' }, { name: 'firstName', type: 'string' }, { name: 'lastName', type: 'string' }, { name: 'role', type: 'string' }, { name: 'areas', reference: { type: 'GraphQLApp.model.Area' } }], proxy: { type: 'graphql', query: { list: 'getUsers', get: 'user' }, mutation: { create: 'createUser', update: 'updateUser', destroy: 'deleteUser', }, reader: { type: 'graphql', rootProperty: 'users', totalProperty: 'count' } }, });
UI binding
The UI integration is totally transparent as the stores and models that use the GraphQL proxy do not change their behaviour. These are some examples that trigger queries and mutations.
Grid with pagination, sorting and filtering
Ext.define('GraphQLApp.view.main.MainView', { extend: 'Ext.Panel', xtype: 'mainview', requires: [ 'Ext.grid.plugin.PagingToolbar' ], layout: 'fit', items: [{ xtype: 'grid', store: { model: 'GraphQLApp.model.User', autoLoad: true, remoteSort: true, }, plugins: { pagingtoolbar: true }, columns: [ { text: 'Id', dataIndex: 'id', width: 100 }, { text: 'Username', dataIndex: 'username', flex: 1 } ] }] })
Load single record and update
GraphQLApp.model.User.load(1, { success: function (record, operation) { record.set('firstName', 'Foo'); record.save(); }, });
GitHub repository
The full working example can be found here.