Capstone: Building a Small GraphQL API

GraphQL — Capstone: Building a Small GraphQL API
GraphQL
Chapter 9 · Capstone: Building a Small GraphQL API

🏁 Capstone: Building a Small GraphQL API

Every previous chapter built one piece in isolation. This capstone assembles them into one small blog-style API — User, Post, Comment — with authentication, batched resolvers, and structured errors. Nothing new is introduced here, only assembly.

What's Being Built

  • Read posts and their authors/comments in one nested query
  • Create and delete posts, restricted to authenticated owners
  • Batched, N+1-safe resolvers for every relationship
  • Categorized errors the client can act on, not just generic failures

📐 The Schema

type User { id: ID! name: String! email: String // Ch.8 — resolver hides this unless owner/admin posts: [Post!]! } type Post { id: ID! title: String! author: User! comments: [Comment!]! } type Comment { id: ID! text: String! author: User! } input CreatePostInput { title: String! } type UserError { field: String!, message: String! } type CreatePostPayload { post: Post, errors: [UserError!]! } type Query { posts: [Post!]! post(id: ID!): Post } type Mutation { createPost(input: CreatePostInput!): CreatePostPayload! deletePost(id: ID!): Boolean! }

🖥️ Server: Everything Combined

async context({ req }) { // Ch.6 + Ch.8 const token = (req.headers.authorization ?? "").replace("Bearer ", ""); return { currentUser: verifyToken(token), userLoader: createUserLoader(pool), // Ch.5 commentsByPostLoader: createCommentsByPostLoader(pool), // Ch.5 }; }
const resolvers = { Query: { posts: () => db.posts.findAll(), post: (parent, args) => db.posts.findById(args.id), }, Mutation: { createPost: (parent, args, context) => { // Ch.4 + Ch.7 + Ch.8 const user = requireAuth(context); if (args.input.title.trim().length === 0) { return { post: null, errors: [{ field: "title", message: "Title cannot be empty" }] }; } const post = db.posts.create({ title: args.input.title, authorId: user.id }); return { post, errors: [] }; }, deletePost: (parent, args, context) => { const post = db.posts.findById(args.id); requireOwnerOrAdmin(context, post.authorId); // Ch.8 return db.posts.delete(args.id); }, }, User: { email: (parent, args, context) => // Ch.8 — field-level auth (context.currentUser?.id === parent.id) ? parent.email : null, posts: (parent) => db.posts.findByAuthorId(parent.id), }, Post: { author: (parent, args, context) => context.userLoader.load(parent.authorId), // Ch.5 comments: (parent, args, context) => context.commentsByPostLoader.load(parent.id), // Ch.5 }, };

Every Field Auth-Protected Individually

User.email is checked at the FIELD, not at whatever query happened to reach it — exactly the fix for Chapter 8's "unprotected path" gotcha, since this same resolver runs whether the User came from a direct query or via Post.author.

What's Simplified Here

No real database, no pagination, no rate limiting — this capstone stays honest about scope, focusing on how the pieces from Chapters 2–8 fit together, not on production hardening.

Was GraphQL Worth It for This API?

Honestly — Yes, Here

Posts/authors/comments is a genuinely nested, multi-shaped read pattern — exactly Chapter 1's under-fetching problem.

But Not Automatically

A flatter API with one client and no nested reads (Chapter 1's webhook example) would gain nothing from this — the decision was never "GraphQL is better," only "it fits this shape."

💻 Coding Challenges

Challenge 1: Add a createComment Mutation

Add a CreateCommentInput { postId: ID!, text: String! }, a createComment mutation returning Comment!, and a resolver requiring authentication before creating the comment.

Goal: Practice extending the capstone's schema and resolvers with a new, consistent operation.

→ Solution

Challenge 2: Verify the Auth Fix Actually Works

Trace the query { posts { author { email } } } against this chapter's resolvers, for a caller who isn't the author, and confirm email comes back null rather than leaking the value — citing exactly which resolver prevents it.

Goal: Practice verifying Chapter 8's gotcha is genuinely closed, not just described.

→ Solution

Challenge 3: Trace a Full Mutation Lifecycle

Narrate, step by step, everything that happens from an authenticated client calling createPost(input: { title: "" }) to the client receiving its response — naming which chapter's mechanism handles each step.

Goal: Practice holding the entire course's material together as one coherent system.

→ Solution

⚠️ Gotcha: Testing Has to Cover Query Shape, Not Just Endpoints

A REST API's tests map cleanly onto its endpoints — test GET /posts, test POST /posts, done. A GraphQL API has exactly one endpoint but effectively unlimited query shapes, and Chapter 8's capstone made this concrete: the SAME User.email resolver behaves differently depending on whether the caller is the owner, an admin, or neither — and it's reachable through multiple different query paths (Query.user, Post.author, Comment.author). Testing this API properly means testing representative query shapes and permission combinations, not just "does the endpoint respond" — a single resolver can be correct in isolation and still leak data through a query shape nobody thought to test.

🎓 Course Complete

This closes out the GraphQL course — from the specific REST problems it solves, through the schema and type system, resolvers and mutations, the N+1 problem and DataLoader, a real Node.js server, structured error handling, authentication and authorization, and finally this capstone tying every piece into one working API.