Skip to content

converted graphql middleware blog post #276

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
353 changes: 353 additions & 0 deletions modules/ROOT/pages/security/securing-a-graphql-api.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
= Using GraphQL middleware with Neo4j GraphQL

You can wrap your auto-generated Neo4j GraphQL resolver with custom logic that intercepts specific GraphQL operations.
This approach allows you to retain all the benefits of auto-generation while adding the custom behavior you need.


== GraphQL middleware

This page makes use of https://github.com/dimatill/graphql-middleware[`graphql-middleware`].
GraphQL middleware is a library that provides a way to wrap and extend the behavior of your GraphQL resolvers.
It acts as a layer that allows you to apply reusable logic, such as logging, validation, or authentication, across multiple resolvers in a consistent and modular
way.


=== Logging every request

Consider this Neo4j GraphQL setup:

[source,typescript]
----
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { applyMiddleware } from "graphql-middleware";
import * as neo4j from "neo4j-driver";
import { Neo4jGraphQL } from "@neo4j/graphql";

const typeDefs = /* GraphQL */ `
type User @node {
id: ID! @id
name: String!
email: String!
posts: [Post!]! @relationship(type: "AUTHORED", direction: OUT)
}

type Post @node {
id: ID!
title: String!
content: String!
author: [User!]! @relationship(type: "AUTHORED", direction: IN)
}

type Query {
me: User @cypher(statement: "MATCH (u:User {id: $userId}) RETURN u", columnName: "u")
}
`;

const driver = neo4j.driver("bolt://localhost:7687", neo4j.auth.basic("neo4j", "password"));

const neoSchema = new Neo4jGraphQL({
typeDefs,
driver,
});

const server = new ApolloServer({
schema: await neoSchema.getSchema(),
});

const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});
console.log(`🚀 Server ready at ${url}`);
----

Add logging to every single operation without touching the generated schema:

[source,typescript]
----
import { applyMiddleware } from "graphql-middleware";

/* ...existing code... */

const logMiddleware = async (resolve, root, args, context, info) => {
const start = Date.now();
console.log(`🚀 ${info.fieldName} started`);

try {
const result = await resolve(root, args, context, info);
console.log(`✅ ${info.fieldName} completed in ${Date.now() - start}ms`);
return result;
} catch (error) {
console.log(`💥 ${info.fieldName} failed`);
throw error;
}
};

// Wrap your executable schema
const schemaWithLogging = applyMiddleware(await neoSchema.getSchema(), {
Query: logMiddleware,
Mutation: logMiddleware,
});

const server = new ApolloServer({ schema: schemaWithLogging });
----

*That’s it. Every query and mutation is now logged.* Your auto-generated
resolver is unchanged, but you’ve added custom behavior.

Query the users:

[source,graphql]
----
{
users {
name
}
}
----

You should see in your server:

....
🚀 users started
✅ users completed in 23ms
....


=== Email validation before database writes

You can use middleware to enforce specific business rules before data is written to the database.
For example, you can ensure that email addresses provided during user creation are valid.
By using middleware, you can intercept and validate the input before it reaches the Neo4j GraphQL resolver.

Add a middleware that validates the email input in the `createUsers` operation.
A validation error will be thrown before it reaches the Neo4j GraphQL resolver, and the GraphQL client will receive the error message "Invalid email addresses detected".

[source,typescript]
----
/* ...existing code... */

const validateEmails = async (resolve, root, args, context, info) => {
// Only check createUsers mutations
if (info.fieldName === "createUsers") {
// Note: This is a simplistic and intentionally flawed email validation example, but good for demonstration purposes.
const invalidEmails = args.input.filter((user) => !user.email.includes("@"));
if (invalidEmails.length > 0) {
throw new Error("Invalid email addresses detected");
}
}

return resolve(root, args, context, info);
};

const schema = applyMiddleware(
await neoSchema.getSchema(),
{
Query: logMiddleware,
Mutation: logMiddleware,
},
{
Mutation: validateEmails,
}
);
----

Try to create a user with the email "not-an-email":

[source,graphql]
----
mutation createUsers {
createUsers(input: [{ email: "not-an-email.com", name: "firstname" }]) {
users {
email
}
}
}
----


== Working with Neo4j GraphQL

Most of the above is applicable even outside Neo4j GraphQL, but there is an important concept when writing middleware for Neo4j GraphQL resolvers.

Here's the key difference from how traditional GraphQL resolvers are
usually built:

In *traditional GraphQL*, each field resolver executes independently, potentially causing multiple database calls.
By contrast, in *Neo4j GraphQL* the root field resolver (like `users` or `createUsers`) analyzes the entire query tree and executes one optimized Cypher query.

The N+1 problem is solved in Neo4j GraphQL by analyzing the entire GraphQL operation (via the `info` object) and generating optimized Cypher queries that fetch all requested data in a single database round-trip.

Consider this query:

[source,graphql]
----
{
users {
name
email
posts {
title
content
}
}
}
----

Neo4j GraphQL doesn't execute separate resolvers for `name`, `email`, `posts`, `title`, and `content`.
Instead, the `users` field resolver generates and executes a single Cypher query that returns all the data at once.
The nested field resolvers simply return the already fetched data from memory.


=== Timing matters

Timing matters for middleware - by the time the individual field resolvers execute, the database has already been queried and the data is available in the resolver's result.

Consider the `logMiddleware` from above:

[source,typescript]
----
const logMiddleware = async (resolve, root, args, context, info) => {
const start = Date.now();
console.log(`🚀 ${info.fieldName} started`);

try {
const result = await resolve(root, args, context, info);
console.log(`✅ ${info.fieldName} completed in ${Date.now() - start}ms`);
return result;
} catch (error) {
console.log(`💥 ${info.fieldName} failed: ${error.message}`);
throw error;
}
};
----

Apply the `logMiddleware` to queries and the user's name:

[source,typescript]
----
const schema = applyMiddleware(
schema,
{
Query: logMiddleware, // wraps all the Queries and it's executed before the database round-trip
},
{
User: {
name: logMiddleware, // wraps only the User's name field resolver and it's executed after the database roundtrip
},
}
);
----

Run this query:

[source,graphql]
----
query {
users {
name
}
}
----

You should see:

....
🚀 users started
... Neo4j resolver generates and executes Cypher ...
✅ users completed in 48ms
🚀 name started
✅ name completed in 0ms
....

Note how the name resolution happens after the round-trip to the database.

Note the following difference:

* Query and mutation level middleware runs before and after the Neo4j GraphQL autogenerated resolvers.
* Type and field level middleware runs only after the Neo4j GraphQL autogenerated resolvers.


== Stack multiple middleware

It's possible to apply multiple pieces of middleware for the same field.
For instance, you can apply diverse middleware to the same `users` resolver:

[source,typescript]
----
const schema = applyMiddleware(
schema,
{
Query: {
users: async (resolve, root, args, context, info) => {
console.log("A started");
await resolve(root, args, context, info);
console.log("A completed");
},
},
},
{
Query: {
users: async (resolve, root, args, context, info) => {
console.log("B started");
await resolve(root, args, context, info);
console.log("B completed");
},
},
},
{
Query: {
users: async (resolve, root, args, context, info) => {
console.log("C started");
await resolve(root, args, context, info);
console.log("C completed");
},
},
}
);
----

The order in which middleware is applied is important, as they execute one after the other.
Each middleware wraps the next one, creating a chain of execution from outermost to innermost.

Run this query:

[source,graphql]
----
query {
users {
name
}
}
----

Schematic output:

[source,bash]
----
....
A started
B started
C started
... Neo4j GraphQL user resolver ...
C completed
B completed
A completed
....
----

The user's resolver is wrapped in three layers of middleware.


== Conclusion

GraphQL middleware with Neo4j GraphQL gives you the best of both worlds: the power of auto-generated schemas and the flexibility to inject custom logic exactly where you need it.

When you need custom logic, graphql-middleware lets you keep the rapid development benefits of Neo4j GraphQL while adding the custom behavior you need.

The GraphQL ecosystem evolves rapidly.
https://the-guild.dev/[The Guild] has developed https://envelop.dev/[Envelop] with its own https://www.npmjs.com/package/@envelop/graphql-middleware[graphql-middleware
plugin].

This guide uses `graphql-middleware` because it's server-agnostic and delivers the clearest path to understanding middleware with Neo4j GraphQL.
If you need a more comprehensive plugin ecosystem, we recommend exploring envelop.