Adding Custom Logic To Our GraphQL API

Adding custom logic to our GraphQL API is necessary any time our application requires logic beyond simple CRUD operations (which are auto-generated by makeAugmentedSchema).

There are two options for adding custom logic to your API using neo4j-graphql.js:

  1. Using the @cypher GraphQL schema directive to express your custom logic using Cypher, or
  2. By implementing custom resolvers and attaching them to the GraphQL schema

The @cypher GraphQL Schema Directive

We expose Cypher through GraphQL via the @cypher directive. Annotate a field in your schema with the @cypher directive to map the results of that query to the annotated GraphQL field. The @cypher directive takes a single argument statement which is a Cypher statement. Parameters are passed into this query at runtime, including this which is the currently resolved node as well as any field-level arguments defined in the GraphQL type definition.

The @cypher directive feature requires the use of the APOC standard library plugin. Be sure you've followed the steps to install APOC in the Project Setup section of this chapter.

Computed Scalar Fields

We can use the @cypher directive to define a custom scalar field, defining a computed field in our schema. Here we add an averageStars field to the Business type which calculates the average stars of all reviews for the business using the this variable.

type Business {
businessId: ID!
averageStars: Float! @cypher(statement:"MATCH (this)<-[:REVIEWS]-(r:Review) RETURN avg(r.stars)")
name: String!
city: String!
state: String!
address: String!
location: Point!
reviews: [Review] @relation(name: "REVIEWS", direction: "IN")
categories: [Category] @relation(name: "IN_CATEGORY", direction: "OUT")
}

Now we can include the averageStars field in our GraphQL query:

{
Business {
name
averageStars
}
}

And we see in the results that the computed value for averageStars is now included.

{
"data": {
"Business": [
{
"name": "Hanabi",
"averageStars": 5
},
{
"name": "Zootown Brew",
"averageStars": 5
},
{
"name": "Ninja Mike's",
"averageStars": 4.5
}
]
}
}

The generated Cypher query includes the annotated Cypher query as a sub-query, preserving the single database call to resolve the GraphQL request.

Computed Object And Array Fields

We can also use the @cypher schema directive to resolve object and array fields. Let's add a recommended business field to the Business type. We'll use a simple Cypher query to find common businesses that other users reviewed. For example, if a user likes "Market on Front", we could recommend other businesses that users who reviewed "Market on Front" also reviewed.

MATCH (b:Business {name: "Market on Front"})<-[:REVIEWS]-(:Review)<-[:WROTE]-(:User)-[:WROTE]->(:Review)-[:REVIEWS]->(rec:Business)
WITH rec, COUNT(*) AS score
RETURN rec ORDER BY score DESC

We can make use of this Cypher query in our GraphQL schema by including it in a @cypher directive on the recommended field in our Business type definition.

type Business {
businessId: ID!
averageStars: Float! @cypher(statement:"MATCH (this)<-[:REVIEWS]-(r:Review) RETURN avg(r.stars)")
recommended(first: Int = 1): [Business] @cypher(statement: """
MATCH (this)<-[:REVIEWS]-(:Review)<-[:WROTE]-(:User)-[:WROTE]->(:Review)-[:REVIEWS]->(rec:Business)
WITH rec, COUNT(*) AS score
RETURN rec ORDER BY score DESC LIMIT $first
""")
name: String!
city: String!
state: String!
address: String!
location: Point!
reviews: [Review] @relation(name: "REVIEWS", direction: "IN")
categories: [Category] @relation(name: "IN_CATEGORY", direction: "OUT")
}

We also define a first field argument, which is passed to the Cypher query included in the @cypher directive and acts as a limit on the number of recommended businesses returned.

Custom Top-Level Query Fields

Another helpful way to use the @cypher directive is as a custom query or mutation field. For example, let's see how we can add full-text query support to search for businesses. Applications often use full-text search to correct for things like misspellings in user input using fuzzy matching.

In Neo4j we can use full-text search by first creating a full-text index.

CALL db.index.fulltext.createNodeIndex("businessNameIndex", ["Business"],["name"])

Then to query the index, in this case we misspell "coffee" but including the ~ character enables fuzzy matching, ensuring we still find what we're looking for.

CALL db.index.fulltext.queryNodes("businessNameIndex", "cofee~")

Wouldn't it be nice to include this fuzzy matching full-text search in our GraphQL API? To do that let's create a Query field called fuzzyBusinessByName that takes a search string and searches for businesses.

type Query {
fuzzyBusinessByName(searchString: String): [Business] @cypher(statement: """
CALL db.index.fulltext.queryNodes( 'businessNameIndex', $searchString+'~')
YIELD node RETURN node;
""")
}

We can now search for business names using this fuzzy matching.

{
fuzzyBusinessByName(searchString: "libary") {
name
}
}

Since we are using full-text search, even though we spell "library" incorrectly, we still find matching results.

{
"data": {
"fuzzyBusinessByName": [
{
"name": "Missoula Public Library"
}
]
}
}

The @cypher schema directive is a powerful way to add custom logic and advanced functionality to our GraphQL API. We can also use the @cypher directive for authorization features, accessing values such as authorization tokens from the request object, a pattern that is discussed in the GraphQL authorization page.

Implementing Custom Resolvers

While the @cypher directive is one way to add custom logic, in some cases we may need to implement custom resolvers that implement logic not able to be expressed in Cypher. For example, we may need to fetch data from another system, or apply some custom validation rules. In these cases we can implement a custom resolver and attach it to the GraphQL schema so that resolver is called to resolve our custom field instead of relying on the generated Cypher query by neo4j-graphql.js to resolve the field.

In our example let's imagine there is an external system that can be used to determine current wait times at businesses. We want to add an additional waitTime field to the Business type in our schema and implement the resolver logic for this field to use this external system.

To do this, we first add the field to our schema, adding the @neo4j_ignore directive to ensure the field is excluded from the generated Cypher query. This is our way of telling neo4j-graphql.js that a custom resolver will be responsible for resolving this field and we don't expect it to be fetched from the database automatically.

type Business {
businessId: ID!
waitTime: Int! @neo4j_ignore
averageStars: Float!
@cypher(
statement: "MATCH (this)<-[:REVIEWS]-(r:Review) RETURN avg(r.stars)"
)
name: String!
city: String!
state: String!
address: String!
location: Point!
reviews: [Review] @relation(name: "REVIEWS", direction: "IN")
categories: [Category] @relation(name: "IN_CATEGORY", direction: "OUT")
}

Next we create a resolver map with our custom resolver. We didn't have to create this previously because neo4j-graphql.js generated our resolvers for us. Our wait time calculation will be just selecting a value at random, but we could implement any custom logic here to determine the waitTime value, such as making a request to a 3rd party API.

const resolvers = {
Business: {
waitTime: (obj, args, context, info) => {
const options = [0, 5, 10, 15, 30, 45];
return options[Math.floor(Math.random() * options.length)];
}
}
};

Then we add this resolver map to the parameters passed to makeAugmentedSchema.

const schema = makeAugmentedSchema({
typeDefs,
resolvers
});

Now, let's search for restaurants and see what their wait times are by including the waitTime field in the selection set.

{
Business(filter: { categories_some: { name: "Restaurant" } }) {
name
waitTime
}
}

In the results we now see a value for the wait time. Your results will of course vary since the value is randomized.

{
"data": {
"Business": [
{
"name": "Ninja Mike's",
"waitTime": 5
},
{
"name": "Market on Front",
"waitTime": 45
},
{
"name": "Hanabi",
"waitTime": 45
}
]
}
}

Resources