GraphQL is a language specification published by Facebook for constructing graph APIs. The specification provides great flexibility in API expression, but also little direction for best practices for common mutation operations. For example, it is silent on how to:
Elide offers an opinionated GraphQL API that addresses exactly how to do these things in a uniform way across your entire data model graph.
Elide accepts GraphQL queries embedded in HTTP POST requests. It follows the convention defined by GraphQL for serving over HTTP. Namely, ever GraphQL query is wrapped in a JSON envelope object with one required attribute and two optional attributes:
{
"query": "mutation myMutation($bookName: String $authorName: String) {book(op: UPSERT data: {id:2,title:$bookName}) {edges {node {id title authors(op: UPSERT data: {id:2,name:$authorName}) {edges {node {id name}}}}}}}",
"variables": {
"authorName": "John Steinbeck",
"bookName": "Grapes of Wrath"
}
}
The response is also a JSON payload:
{
"data": { ... },
"errors": [ ... ]
}
The ‘data’ field contains the graphQL response object, and the ‘errors’ field (only present when they exist) contains one or more errors encountered when executing the query. Note that it is possible to receive a 200 HTTP OK from Elide but also have errors in the query.
GraphQL splits its schema into two kinds of objects:
The schema for both kinds of objects are derived from the entity relationship graph (defined by the JPA data model). Both contain a set of attributes and relationships. Attributes are properties of the entity. Relationships are links to other entities in the graph.
Input objects just contain attributes and relationship with names directly matching the property names in the JPA annotated model:
Query Objects are more complex than Input Objects since they do more than simply describe data; they must support filtering, sorting, and pagination. Elide’s GraphQL structure for queries and mutations is depicted below:
Every GraphQL schema must define a root document which represents the root of the graph. In Elide, entities can be marked if they are directly navigable from the root of the graph. Elide’s GraphQL root documents consist of relationships to these rootable entities. Each root relationship is named by its pluralized type name in the GraphQL root document.
All other non-rootable entities in our schema must be referenced through traversal of the relationships in the entity relationship graph.
Elide models relationships following Relay’s Connection pattern. Relationships are a collection of graph edges. Each edge contains a graph node. The node is an instance of a data model which in turn contains its own attributes and set of relationships.
In GraphQL, any property in the schema can take arguments. Relationships in Elide have a standard set of arguments that either constrain the edges fetched from a relationship or supply data to a mutation:
Entity attributes generally do not take arguments.
Elide GraphQL relationships support six operations which can be broken into two groups: data operations and id operations. The operations are separated into those that accept a data argument and those that accept an ids argument. Operations that edit or manipulate data are restricted to GraphQL Mutation queries:
Operation | Data | Ids | Mutation Support | Query Support |
---|---|---|---|---|
Upsert | ✓ | X | ✓ | X |
Update | ✓ | X | ✓ | X |
Fetch | X | ✓ | ✓ | ✓ |
Replace | ✓ | X | ✓ | X |
Remove | X | ✓ | ✓ | X |
Delete | X | ✓ | ✓ | X |
GraphQL has no native support for a map data type. If a JPA data model includes a map, Elide translates this to a list of key/value pairs in the GraphQL schema.
All calls must be HTTP POST
requests made to the root endpoint. This specific endpoint will depend on where you mount the provided servlet.
For example, if the servlet is mounted at /graphql
, all requests should be sent as:
POST https://yourdomain.com/graphql
All subsequent query examples are based on the following data model including Book
, Author
, and Publisher
:
@Entity
@Table(name = "book")
@Include(rootLevel = true)
public class Book {
@Id private long id;
private String title;
private String genre;
private String language;
@ManyToMany
private Set<Author> authors;
@ManyToOne
private Publisher publisher;
}
@Entity
@Table(name = "author")
@Include(rootLevel = false)
public class Author {
@Id private long id;
private String name;
@ManyToMany
private Set<Book> books;
}
@Entity
@Table(name = "publisher")
@Include(rootLevel = false)
public class Publisher {
@Id private long id;
private String name;
@OneToMany
private Set<Book> books;
}
Elide supports filtering relationships for any FETCH operation by passing a RSQL expression in the filter parameter for the relationship. RSQL is a query language that allows conjunction (and), disjunction (or), and parenthetic grouping of boolean expressions. It is a superset of the FIQL language. FIQL defines all String comparison operators to be case insensitive. Elide overrides this behavior making all operators case sensitive by default. For case insensitive queries, Elide introduces new operators.
RSQL predicates can filter attributes:
To join across relationships or drill into nested objects, the attribute name is prefixed by one or more relationship or field names separated by period (‘.’). For example, ‘author.books.price.total’ references all of the author’s books with a price having a particular total value.
The following RSQL operators are supported:
=in=
: Evaluates to true if the attribute exactly matches any of the values in the list. (Case Sensitive)=ini=
: Evaluates to true if the attribute exactly matches any of the values in the list. (Case Insensitive)=out=
: Evaluates to true if the attribute does not match any of the values in the list. (Case Sensitive)=outi=
: Evaluates to true if the attribute does not match any of the values in the list. (Case Insensitive)==ABC*
: Similar to SQL like 'ABC%
. (Case Sensitive)==*ABC
: Similar to SQL like '%ABC
. (Case Sensitive)==*ABC*
: Similar to SQL like '%ABC%
. (Case Sensitive)=ini=ABC*
: Similar to SQL like 'ABC%
. (Case Insensitive)=ini=*ABC
: Similar to SQL like '%ABC
. (Case Insensitive)=ini=*ABC*
: Similar to SQL like '%ABC%
. (Case Insensitive)=isnull=true
: Evaluates to true if the attribute is null=isnull=false
: Evaluates to true if the attribute is not null=lt=
: Evaluates to true if the attribute is less than the value.=gt=
: Evaluates to true if the attribute is greater than the value.=le=
: Evaluates to true if the attribute is less than or equal to the value.=ge=
: Evaluates to true if the attribute is greater than or equal to the value.=isempty=
: Determines if a collection is empty or not.=between=
: Determines if a model attribute is >= and <= the two provided arguments.=notbetween=
: Negates the between operator.=hasmember=
: Determines if a collection contains a particular element.=hasnomember=
: Determines if a collection does not contain a particular element.The operators ‘hasmember’ and ‘hasnomember’ can be applied to collections (book.awards) or across to-many relationships (book.authors.name).
By default, the FIQL operators =in=,=out=,== are case sensitive. This can be reverted to case insensitive by changing the case sensitive strategy:
@Configuration
public class ElideConfiguration {
@Bean
GraphQLSettingsBuilderCustomizer graphQLSettingsBuilderCustomizer(EntityDictionary entityDictionary) {
return builder -> builder.filterDialect(RSQLFilterDialect.builder()
.dictionary(entityDictionary)
.caseSensitivityStrategy(new CaseSensitivityStrategy.FIQLCompliant())
.addDefaultArguments(true)
.build());
}
}
Some data stores like the Aggregation Store support parameterized model attributes. Parameters can be included in a filter predicate with the following syntax:
field[arg1:value1][arg2:value2]
Argument values must be URL encoded. There is no limit to the number of arguments provided in this manner.
"title=='abc';genre=='Science*';price.total>100.0
publishDate>1454638927411,genre=out=('Literary Fiction','Science Fiction')
publisher.name==*XYZ*
Any relationship can be paginated by providing one or both of the following parameters:
Every relationship includes information about the collection (in addition to a list of edges) that can be requested on demand:
These properties are contained within the pageInfo structure:
{
pageInfo {
endCursor
startCursor
hasNextPage
totalRecords
}
}
Cursor pagination is not enabled for the models by default and needs to be enabled by using annotating the model with the @Paginate
annotation.
@Include
@Entity
@Paginate(modes = { PaginationMode.OFFSET, PaginationMode.CURSOR })
public class Book {
}
Cursor pagination is supported by the following data stores:
The JPA Data Store implements keyset pagination which assumes that appropriate indexes are created and that the sortable columns are non nullable.
Where cursor pagination is supported, any relationship can be paginated by providing one or both of the following parameters:
Scrolling forwards
Scrolling backwards
Note that scrolling backwards using last and before does not change the sort order.
Every relationship includes information about the collection (in addition to a list of edges) that can be requested on demand:
These properties are contained within the pageInfo structure:
{
pageInfo {
startCursor
endCursor
hasPreviousPage
hasNextPage
totalRecords
}
}
Any relationship can be sorted by attributes in:
To join across relationships, the attribute name is prefixed by one or more relationship names separated by period (‘.’)
It is also possible to sort in either ascending or descending order by prepending the attribute expression with a ‘+’ or ‘-‘ character. If no order character is provided, sort order defaults to ascending.
A relationship can be sorted by multiple attributes by separating the attribute expressions by commas: ‘,’.
Elide supports three mechanisms by which a newly created entity is assigned an ID:
Elide looks for the JPA GeneratedValue
annotation to disambiguate whether or not
the data store generates an ID for a given data model. If the client also generated
an ID during the object creation request, the data store ID overrides the client value.
When using UPSERT, Elide returns object entity bodies (containing newly assigned IDs) in the order in which they were created - assuming all the entities were newly created (and not mixed with entity updates in the request). The client can use this order to map the object created to its server assigned ID.
Include the id, title, genre, & language in the result.
{
book {
edges {
node {
id
title
genre
language
}
}
}
}
{
"book": {
"edges": [
{
"node": {
"id": "1",
"title": "Libro Uno",
"genre": null,
"language": null
}
},
{
"node": {
"id": "2",
"title": "Libro Dos",
"genre": null,
"language": null
}
},
{
"node": {
"id": "3",
"title": "Doctor Zhivago",
"genre": null,
"language": null
}
}
]
}
}
Fetches book 1. The response includes the id, title, and authors.
For each author, the response includes its id & name.
{
book(ids: ["1"]) {
edges {
node {
id
title
authors {
edges {
node {
id
name
}
}
}
}
}
}
}
{
"book": {
"edges": [
{
"node": {
"id": "1",
"title": "Libro Uno",
"authors": {
"edges": [
{
"node": {
"id": "1",
"name": "Mark Twain"
}
}
]
}
}
}
]
}
}
Fetches the set of books that start with ‘Libro U’.
{
book(filter: "title==\"Libro U*\"") {
edges {
node {
id
title
}
}
}
}
{
"book": {
"edges": [
{
"node": {
"id": "1",
"title": "Libro Uno"
}
}
]
}
}
Fetches a single page of books (1 book per page), starting at the 2nd page.
Also requests the relationship metadata.
{
book(first: "1", after: "1") {
edges {
node {
id
title
}
}
pageInfo {
totalRecords
startCursor
endCursor
hasNextPage
}
}
}
{
"book": {
"edges": [
{
"node": {
"id": "2",
"title": "Libro Dos"
}
}
],
"pageInfo": {
"totalRecords": 3,
"startCursor": "1",
"endCursor": "2",
"hasNextPage": true
}
}
}
Sorts the collection of books first by their publisher id (descending) and then by the book id (ascending).
{
book(sort: "-publisher.id,id") {
edges {
node {
id
title
publisher {
edges {
node {
id
}
}
}
}
}
}
}
{
"book": {
"edges": [
{
"node": {
"id": "3",
"title": "Doctor Zhivago",
"publisher": {
"edges": [
{
"node": {
"id": "2"
}
}
]
}
}
},
{
"node": {
"id": "1",
"title": "Libro Uno",
"publisher": {
"edges": [
{
"node": {
"id": "1"
}
}
]
}
}
},
{
"node": {
"id": "2",
"title": "Libro Dos",
"publisher": {
"edges": [
{
"node": {
"id": "1"
}
}
]
}
}
}
]
}
}
Fetches the entire list of data types in the GraphQL schema.
{
__schema {
types {
name
}
}
}
{
"__schema": {
"types": [
{
"name": "root"
},
{
"name": "noshare"
},
{
"name": "__edges__noshare"
},
{
"name": "__node__noshare"
},
{
"name": "id"
},
{
"name": "__pageInfoObject"
},
{
"name": "Boolean"
},
{
"name": "String"
},
{
"name": "Long"
},
{
"name": "com.yahoo.elide.graphql.RelationshipOp"
},
{
"name": "noshareInput"
},
{
"name": "ID"
},
{
"name": "book"
},
{
"name": "__edges__book"
},
{
"name": "__node__book"
},
{
"name": "authorInput"
},
{
"name": "example.AddressInputInput"
},
{
"name": "example.Author$AuthorType"
},
{
"name": "bookInput"
},
{
"name": "publisherInput"
},
{
"name": "pseudonymInput"
},
{
"name": "author"
},
{
"name": "__edges__author"
},
{
"name": "__node__author"
},
{
"name": "example.Address"
},
{
"name": "publisher"
},
{
"name": "__edges__publisher"
},
{
"name": "__node__publisher"
},
{
"name": "pseudonym"
},
{
"name": "__edges__pseudonym"
},
{
"name": "__node__pseudonym"
},
{
"name": "__Schema"
},
{
"name": "__Type"
},
{
"name": "__TypeKind"
},
{
"name": "__Field"
},
{
"name": "__InputValue"
},
{
"name": "__EnumValue"
},
{
"name": "__Directive"
},
{
"name": "__DirectiveLocation"
}
]
}
}
Creates a new book and adds it to Author 1. The author’s id and list of newly created books is returned in the response. For each newly created book, only the title is returned.
mutation {
author(ids: ["1"]) {
edges {
node {
id
books(op: UPSERT, data: {title: "Book Numero Dos"}) {
edges {
node {
title
}
}
}
}
}
}
}
{
"author": {
"edges": [
{
"node": {
"id": "1",
"books": {
"edges": [
{
"node": {
"title": "Book Numero Dos"
}
}
]
}
}
}
]
}
}
Updates the title of book 1 belonging to author 1. The author’s id and list of updated books is returned in the response. For each updated book, only the title is returned.
mutation {
author(ids: ["1"]) {
edges {
node {
id
books(op:UPSERT, data: {id: "1", title: "abc"}) {
edges {
node {
id
title
}
}
}
}
}
}
}
{
"author": {
"edges": [
{
"node": {
"id": "1",
"books": {
"edges": [
{
"node": {
"id": "1",
"title": "abc"
}
}
]
}
}
}
]
}
}
Updates author 1’s name and simultaneously updates the titles of books 2 and 3.
mutation {
author(op:UPDATE, data: {id: "1", name: "John Snow", books: [{id: "3", title: "updated again"}, {id: "2", title: "newish title"}]}) {
edges {
node {
id
name
books(ids: ["3"]) {
edges {
node {
title
}
}
}
}
}
}
}
{
"author": {
"edges": [
{
"node": {
"id": "1",
"name": "John Snow",
"books": {
"edges": [
{
"node": {
"title": "updated again"
}
}
]
}
}
}
]
}
}
Deletes books 1 and 2. The id and title of the remaining books are returned in the response.
mutation {
book(op:DELETE, ids: ["1", "2"]) {
edges {
node {
id
title
}
}
}
}
{
"book": {
"edges": [
]
}
}
Removes books 1 and 2 from author 1. Author 1 is returned with the remaining books.
mutation {
author(ids: ["1"]) {
edges {
node {
books(op:REMOVE, ids: ["1", "2"]) {
edges {
node {
id
title
}
}
}
}
}
}
}
{
"author": {
"edges": [
{
"node": {
"books": {
"edges": [
]
}
}
}
]
}
}
Replaces the set of authors for every book with the set consisting of:
The response includes the complete set of books (id & title) and their new authors (id & name).
mutation {
book {
edges {
node {
id
title
authors(op: REPLACE, data:[{name:"My New Author"},{id:"1"}]) {
edges {
node {
id
name
}
}
}
}
}
}
}
{
"book": {
"edges": [
{
"node": {
"id": "1",
"title": "Libro Uno",
"authors": {
"edges": [
{
"node": {
"id": "3",
"name": "My New Author"
}
},
{
"node": {
"id": "1",
"name": "Mark Twain"
}
}
]
}
}
},
{
"node": {
"id": "2",
"title": "Libro Dos",
"authors": {
"edges": [
{
"node": {
"id": "4",
"name": "My New Author"
}
},
{
"node": {
"id": "1",
"name": "Mark Twain"
}
}
]
}
}
},
{
"node": {
"id": "3",
"title": "Doctor Zhivago",
"authors": {
"edges": [
{
"node": {
"id": "5",
"name": "My New Author"
}
},
{
"node": {
"id": "1",
"name": "Mark Twain"
}
}
]
}
}
}
]
}
}
Type coercion between the API and underlying data model has common support across JSON-API and GraphQL and is covered here.
Configuring custom error responses is documented here.
The GraphQLFieldDefinition
can be customized by setting a GraphQLFieldDefinitionCustomizer
to the GraphQLSettingsBuilder
.
@Configuration
public class ElideConfiguration {
@Bean
GraphQLSettingsBuilderCustomizer graphqlSettingsBuilderCustomizer() {
return graphqlSettings -> graphqlSettings.graphqlFieldDefinitionCustomizer(
((fieldDefinition, parentClass, attributeClass, attribute, fetcher, entityDictionary) -> {
Description description = entityDictionary.getAttributeOrRelationAnnotation(parentClass,
Description.class, attribute);
if (description != null) {
fieldDefinition.description(description.value());
}
}));
}
}