|
| 1 | += Using GraphQL middleware with Neo4j GraphQL |
| 2 | + |
| 3 | +You can wrap your auto-generated Neo4j GraphQL resolver with custom logic that intercepts specific GraphQL operations. |
| 4 | +This approach allows you to retain all the benefits of auto-generation while adding the custom behavior you need. |
| 5 | + |
| 6 | + |
| 7 | +== GraphQL middleware |
| 8 | + |
| 9 | +This page makes use of https://github.com/dimatill/graphql-middleware[`graphql-middleware`]. |
| 10 | +GraphQL middleware is a library that provides a way to wrap and extend the behavior of your GraphQL resolvers. |
| 11 | +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 |
| 12 | +way. |
| 13 | + |
| 14 | + |
| 15 | +=== Logging every request |
| 16 | + |
| 17 | +Consider this Neo4j GraphQL setup: |
| 18 | + |
| 19 | +[source,typescript] |
| 20 | +---- |
| 21 | +import { ApolloServer } from "@apollo/server"; |
| 22 | +import { startStandaloneServer } from "@apollo/server/standalone"; |
| 23 | +import { applyMiddleware } from "graphql-middleware"; |
| 24 | +import * as neo4j from "neo4j-driver"; |
| 25 | +import { Neo4jGraphQL } from "@neo4j/graphql"; |
| 26 | +
|
| 27 | +const typeDefs = /* GraphQL */ ` |
| 28 | + type User @node { |
| 29 | + id: ID! @id |
| 30 | + name: String! |
| 31 | + email: String! |
| 32 | + posts: [Post!]! @relationship(type: "AUTHORED", direction: OUT) |
| 33 | + } |
| 34 | +
|
| 35 | + type Post @node { |
| 36 | + id: ID! |
| 37 | + title: String! |
| 38 | + content: String! |
| 39 | + author: [User!]! @relationship(type: "AUTHORED", direction: IN) |
| 40 | + } |
| 41 | +
|
| 42 | + type Query { |
| 43 | + me: User @cypher(statement: "MATCH (u:User {id: $userId}) RETURN u", columnName: "u") |
| 44 | + } |
| 45 | +`; |
| 46 | +
|
| 47 | +const driver = neo4j.driver("bolt://localhost:7687", neo4j.auth.basic("neo4j", "password")); |
| 48 | +
|
| 49 | +const neoSchema = new Neo4jGraphQL({ |
| 50 | + typeDefs, |
| 51 | + driver, |
| 52 | +}); |
| 53 | +
|
| 54 | +const server = new ApolloServer({ |
| 55 | + schema: await neoSchema.getSchema(), |
| 56 | +}); |
| 57 | +
|
| 58 | +const { url } = await startStandaloneServer(server, { |
| 59 | + listen: { port: 4000 }, |
| 60 | +}); |
| 61 | +console.log(`🚀 Server ready at ${url}`); |
| 62 | +---- |
| 63 | + |
| 64 | +Add logging to every single operation without touching the generated schema: |
| 65 | + |
| 66 | +[source,typescript] |
| 67 | +---- |
| 68 | +import { applyMiddleware } from "graphql-middleware"; |
| 69 | +
|
| 70 | +/* ...existing code... */ |
| 71 | +
|
| 72 | +const logMiddleware = async (resolve, root, args, context, info) => { |
| 73 | + const start = Date.now(); |
| 74 | + console.log(`🚀 ${info.fieldName} started`); |
| 75 | +
|
| 76 | + try { |
| 77 | + const result = await resolve(root, args, context, info); |
| 78 | + console.log(`✅ ${info.fieldName} completed in ${Date.now() - start}ms`); |
| 79 | + return result; |
| 80 | + } catch (error) { |
| 81 | + console.log(`💥 ${info.fieldName} failed`); |
| 82 | + throw error; |
| 83 | + } |
| 84 | +}; |
| 85 | +
|
| 86 | +// Wrap your executable schema |
| 87 | +const schemaWithLogging = applyMiddleware(await neoSchema.getSchema(), { |
| 88 | + Query: logMiddleware, |
| 89 | + Mutation: logMiddleware, |
| 90 | +}); |
| 91 | +
|
| 92 | +const server = new ApolloServer({ schema: schemaWithLogging }); |
| 93 | +---- |
| 94 | + |
| 95 | +*That’s it. Every query and mutation is now logged.* Your auto-generated |
| 96 | +resolver is unchanged, but you’ve added custom behavior. |
| 97 | + |
| 98 | +Query the users: |
| 99 | + |
| 100 | +[source,graphql] |
| 101 | +---- |
| 102 | +{ |
| 103 | + users { |
| 104 | + name |
| 105 | + } |
| 106 | +} |
| 107 | +---- |
| 108 | + |
| 109 | +You should see in your server: |
| 110 | + |
| 111 | +.... |
| 112 | +🚀 users started |
| 113 | +✅ users completed in 23ms |
| 114 | +.... |
| 115 | + |
| 116 | + |
| 117 | +=== Email validation before database writes |
| 118 | + |
| 119 | +You can use middleware to enforce specific business rules before data is written to the database. |
| 120 | +For example, you can ensure that email addresses provided during user creation are valid. |
| 121 | +By using middleware, you can intercept and validate the input before it reaches the Neo4j GraphQL resolver. |
| 122 | + |
| 123 | +Add a middleware that validates the email input in the `createUsers` operation. |
| 124 | +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". |
| 125 | + |
| 126 | +[source,typescript] |
| 127 | +---- |
| 128 | +/* ...existing code... */ |
| 129 | +
|
| 130 | +const validateEmails = async (resolve, root, args, context, info) => { |
| 131 | + // Only check createUsers mutations |
| 132 | + if (info.fieldName === "createUsers") { |
| 133 | + // Note: This is a simplistic and intentionally flawed email validation example, but good for demonstration purposes. |
| 134 | + const invalidEmails = args.input.filter((user) => !user.email.includes("@")); |
| 135 | + if (invalidEmails.length > 0) { |
| 136 | + throw new Error("Invalid email addresses detected"); |
| 137 | + } |
| 138 | + } |
| 139 | +
|
| 140 | + return resolve(root, args, context, info); |
| 141 | +}; |
| 142 | +
|
| 143 | +const schema = applyMiddleware( |
| 144 | + await neoSchema.getSchema(), |
| 145 | + { |
| 146 | + Query: logMiddleware, |
| 147 | + Mutation: logMiddleware, |
| 148 | + }, |
| 149 | + { |
| 150 | + Mutation: validateEmails, |
| 151 | + } |
| 152 | +); |
| 153 | +---- |
| 154 | + |
| 155 | +Try to create a user with the email "not-an-email": |
| 156 | + |
| 157 | +[source,graphql] |
| 158 | +---- |
| 159 | +mutation createUsers { |
| 160 | + createUsers(input: [{ email: "not-an-email.com", name: "firstname" }]) { |
| 161 | + users { |
| 162 | + email |
| 163 | + } |
| 164 | + } |
| 165 | +} |
| 166 | +---- |
| 167 | + |
| 168 | + |
| 169 | +== Working with Neo4j GraphQL |
| 170 | + |
| 171 | +Most of the above is applicable even outside Neo4j GraphQL, but there is an important concept when writing middleware for Neo4j GraphQL resolvers. |
| 172 | + |
| 173 | +Here's the key difference from how traditional GraphQL resolvers are |
| 174 | +usually built: |
| 175 | + |
| 176 | +In *traditional GraphQL*, each field resolver executes independently, potentially causing multiple database calls. |
| 177 | +By contrast, in *Neo4j GraphQL* the root field resolver (like `users` or `createUsers`) analyzes the entire query tree and executes one optimized Cypher query. |
| 178 | + |
| 179 | +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. |
| 180 | + |
| 181 | +Consider this query: |
| 182 | + |
| 183 | +[source,graphql] |
| 184 | +---- |
| 185 | +{ |
| 186 | + users { |
| 187 | + name |
| 188 | + email |
| 189 | + posts { |
| 190 | + title |
| 191 | + content |
| 192 | + } |
| 193 | + } |
| 194 | +} |
| 195 | +---- |
| 196 | + |
| 197 | +Neo4j GraphQL doesn't execute separate resolvers for `name`, `email`, `posts`, `title`, and `content`. |
| 198 | +Instead, the `users` field resolver generates and executes a single Cypher query that returns all the data at once. |
| 199 | +The nested field resolvers simply return the already fetched data from memory. |
| 200 | + |
| 201 | + |
| 202 | +=== Timing matters |
| 203 | + |
| 204 | +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. |
| 205 | + |
| 206 | +Consider the `logMiddleware` from above: |
| 207 | + |
| 208 | +[source,typescript] |
| 209 | +---- |
| 210 | +const logMiddleware = async (resolve, root, args, context, info) => { |
| 211 | + const start = Date.now(); |
| 212 | + console.log(`🚀 ${info.fieldName} started`); |
| 213 | +
|
| 214 | + try { |
| 215 | + const result = await resolve(root, args, context, info); |
| 216 | + console.log(`✅ ${info.fieldName} completed in ${Date.now() - start}ms`); |
| 217 | + return result; |
| 218 | + } catch (error) { |
| 219 | + console.log(`💥 ${info.fieldName} failed: ${error.message}`); |
| 220 | + throw error; |
| 221 | + } |
| 222 | +}; |
| 223 | +---- |
| 224 | + |
| 225 | +Apply the `logMiddleware` to queries and the user's name: |
| 226 | + |
| 227 | +[source,typescript] |
| 228 | +---- |
| 229 | +const schema = applyMiddleware( |
| 230 | + schema, |
| 231 | + { |
| 232 | + Query: logMiddleware, // wraps all the Queries and it's executed before the database round-trip |
| 233 | + }, |
| 234 | + { |
| 235 | + User: { |
| 236 | + name: logMiddleware, // wraps only the User's name field resolver and it's executed after the database roundtrip |
| 237 | + }, |
| 238 | + } |
| 239 | +); |
| 240 | +---- |
| 241 | + |
| 242 | +Run this query: |
| 243 | + |
| 244 | +[source,graphql] |
| 245 | +---- |
| 246 | +query { |
| 247 | + users { |
| 248 | + name |
| 249 | + } |
| 250 | +} |
| 251 | +---- |
| 252 | + |
| 253 | +You should see: |
| 254 | + |
| 255 | +.... |
| 256 | +🚀 users started |
| 257 | +... Neo4j resolver generates and executes Cypher ... |
| 258 | +✅ users completed in 48ms |
| 259 | +🚀 name started |
| 260 | +✅ name completed in 0ms |
| 261 | +.... |
| 262 | + |
| 263 | +Note how the name resolution happens after the round-trip to the database. |
| 264 | + |
| 265 | +Note the following difference: |
| 266 | + |
| 267 | +* Query and mutation level middleware runs before and after the Neo4j GraphQL autogenerated resolvers. |
| 268 | +* Type and field level middleware runs only after the Neo4j GraphQL autogenerated resolvers. |
| 269 | + |
| 270 | + |
| 271 | +== Stack multiple middleware |
| 272 | + |
| 273 | +It's possible to apply multiple pieces of middleware for the same field. |
| 274 | +For instance, you can apply diverse middleware to the same `users` resolver: |
| 275 | + |
| 276 | +[source,typescript] |
| 277 | +---- |
| 278 | +const schema = applyMiddleware( |
| 279 | + schema, |
| 280 | + { |
| 281 | + Query: { |
| 282 | + users: async (resolve, root, args, context, info) => { |
| 283 | + console.log("A started"); |
| 284 | + await resolve(root, args, context, info); |
| 285 | + console.log("A completed"); |
| 286 | + }, |
| 287 | + }, |
| 288 | + }, |
| 289 | + { |
| 290 | + Query: { |
| 291 | + users: async (resolve, root, args, context, info) => { |
| 292 | + console.log("B started"); |
| 293 | + await resolve(root, args, context, info); |
| 294 | + console.log("B completed"); |
| 295 | + }, |
| 296 | + }, |
| 297 | + }, |
| 298 | + { |
| 299 | + Query: { |
| 300 | + users: async (resolve, root, args, context, info) => { |
| 301 | + console.log("C started"); |
| 302 | + await resolve(root, args, context, info); |
| 303 | + console.log("C completed"); |
| 304 | + }, |
| 305 | + }, |
| 306 | + } |
| 307 | +); |
| 308 | +---- |
| 309 | + |
| 310 | +The order in which middleware is applied is important, as they execute one after the other. |
| 311 | +Each middleware wraps the next one, creating a chain of execution from outermost to innermost. |
| 312 | + |
| 313 | +Run this query: |
| 314 | + |
| 315 | +[source,graphql] |
| 316 | +---- |
| 317 | +query { |
| 318 | + users { |
| 319 | + name |
| 320 | + } |
| 321 | +} |
| 322 | +---- |
| 323 | + |
| 324 | +Schematic output: |
| 325 | + |
| 326 | +[source,bash] |
| 327 | +---- |
| 328 | +.... |
| 329 | +A started |
| 330 | +B started |
| 331 | +C started |
| 332 | +... Neo4j GraphQL user resolver ... |
| 333 | +C completed |
| 334 | +B completed |
| 335 | +A completed |
| 336 | +.... |
| 337 | +---- |
| 338 | + |
| 339 | +The user's resolver is wrapped in three layers of middleware. |
| 340 | + |
| 341 | + |
| 342 | +== Conclusion |
| 343 | + |
| 344 | +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. |
| 345 | + |
| 346 | +When you need custom logic, graphql-middleware lets you keep the rapid development benefits of Neo4j GraphQL while adding the custom behavior you need. |
| 347 | + |
| 348 | +The GraphQL ecosystem evolves rapidly. |
| 349 | +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 |
| 350 | +plugin]. |
| 351 | + |
| 352 | +This guide uses `graphql-middleware` because it's server-agnostic and delivers the clearest path to understanding middleware with Neo4j GraphQL. |
| 353 | +If you need a more comprehensive plugin ecosystem, we recommend exploring envelop. |
0 commit comments