GraphQL API Development: Panduan Lengkap dari Basic hingga Advanced

Pelajari cara membangun GraphQL API yang efisien dan scalable, dari konsep dasar hingga implementasi advanced dengan best practices.

20 Februari 2024
Novin Ardian Yulianto
GraphQLAPIBackendApolloSchema Design
GraphQL API Development: Panduan Lengkap dari Basic hingga Advanced

GraphQL API Development: Panduan Lengkap dari Basic hingga Advanced

GraphQL telah mengubah cara kita membangun dan mengonsumsi API. Artikel ini akan membahas secara mendalam bagaimana membangun GraphQL API yang efisien, scalable, dan maintainable.

Apa itu GraphQL?

GraphQL adalah query language dan runtime untuk API yang dikembangkan oleh Facebook. Berbeda dengan REST API, GraphQL memungkinkan client untuk meminta data yang spesifik sesuai kebutuhan.

GraphQL vs REST API

| Aspect | GraphQL | REST | | ------------------- | ----------------------------- | ---------------------------------- | | Data Fetching | Single request, specific data | Multiple requests, fixed structure | | Over/Under-fetching | Eliminated | Common problem | | Versioning | Schema evolution | URL versioning | | Caching | Complex | Simple (HTTP caching) | | Learning Curve | Steep | Gentle | | Tooling | Rich ecosystem | Mature ecosystem |

Keuntungan GraphQL

  1. Efficient Data Loading: Hanya fetch data yang dibutuhkan
  2. Strong Type System: Schema-first development
  3. Single Endpoint: Satu URL untuk semua operations
  4. Real-time Subscriptions: Built-in support untuk real-time data
  5. Introspection: Self-documenting API
  6. Developer Experience: Excellent tooling dan debugging

Core Concepts

1. Schema Definition Language (SDL)

# User type definition
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
  createdAt: DateTime!
}

# Post type definition
type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  tags: [String!]!
  published: Boolean!
  createdAt: DateTime!
  updatedAt: DateTime!
}

# Custom scalar
scalar DateTime

# Input types
input CreateUserInput {
  name: String!
  email: String!
}

input UpdatePostInput {
  title: String
  content: String
  tags: [String!]
  published: Boolean
}

# Root types
type Query {
  # Get single user
  user(id: ID!): User

  # Get users with pagination
  users(first: Int, after: String): UserConnection!

  # Get posts with filtering
  posts(
    first: Int
    after: String
    filter: PostFilter
    orderBy: PostOrderBy
  ): PostConnection!

  # Search posts
  searchPosts(query: String!): [Post!]!
}

type Mutation {
  # User mutations
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!

  # Post mutations
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!

  # Bulk operations
  publishPosts(ids: [ID!]!): [Post!]!
}

type Subscription {
  # Real-time updates
  postAdded: Post!
  postUpdated(id: ID!): Post!
  userOnline: User!
}

# Pagination types
type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

# Filter and ordering
input PostFilter {
  published: Boolean
  authorId: ID
  tags: [String!]
  createdAfter: DateTime
  createdBefore: DateTime
}

enum PostOrderBy {
  CREATED_AT_ASC
  CREATED_AT_DESC
  TITLE_ASC
  TITLE_DESC
  UPDATED_AT_ASC
  UPDATED_AT_DESC
}

2. Resolvers

Resolvers adalah functions yang mengambil data untuk setiap field dalam schema.

// resolvers.js
const { PubSub } = require("graphql-subscriptions");
const pubsub = new PubSub();

const resolvers = {
  // Scalar resolvers
  DateTime: {
    serialize: (date) => date.toISOString(),
    parseValue: (value) => new Date(value),
    parseLiteral: (ast) => new Date(ast.value),
  },

  // Query resolvers
  Query: {
    user: async (parent, { id }, context) => {
      return await context.dataSources.userAPI.getUserById(id);
    },

    users: async (parent, { first = 10, after }, context) => {
      const result = await context.dataSources.userAPI.getUsers({
        first,
        after,
      });

      return {
        edges: result.users.map((user) => ({
          node: user,
          cursor: Buffer.from(user.id).toString("base64"),
        })),
        pageInfo: {
          hasNextPage: result.hasNextPage,
          hasPreviousPage: result.hasPreviousPage,
          startCursor:
            result.users.length > 0
              ? Buffer.from(result.users[0].id).toString("base64")
              : null,
          endCursor:
            result.users.length > 0
              ? Buffer.from(result.users[result.users.length - 1].id).toString(
                  "base64"
                )
              : null,
        },
        totalCount: result.totalCount,
      };
    },

    posts: async (parent, { first = 10, after, filter, orderBy }, context) => {
      return await context.dataSources.postAPI.getPosts({
        first,
        after,
        filter,
        orderBy,
      });
    },

    searchPosts: async (parent, { query }, context) => {
      return await context.dataSources.searchAPI.searchPosts(query);
    },
  },

  // Mutation resolvers
  Mutation: {
    createUser: async (parent, { input }, context) => {
      // Validate input
      await context.validators.validateCreateUser(input);

      // Check permissions
      context.auth.requirePermission("CREATE_USER");

      // Create user
      const user = await context.dataSources.userAPI.createUser(input);

      // Publish subscription
      pubsub.publish("USER_CREATED", { userCreated: user });

      return user;
    },

    updateUser: async (parent, { id, input }, context) => {
      // Check ownership or admin permission
      const currentUser = context.auth.getCurrentUser();
      if (currentUser.id !== id && !currentUser.isAdmin) {
        throw new Error("Unauthorized");
      }

      const user = await context.dataSources.userAPI.updateUser(id, input);

      pubsub.publish("USER_UPDATED", { userUpdated: user });

      return user;
    },

    createPost: async (parent, { input }, context) => {
      const currentUser = context.auth.getCurrentUser();

      const post = await context.dataSources.postAPI.createPost({
        ...input,
        authorId: currentUser.id,
      });

      pubsub.publish("POST_ADDED", { postAdded: post });

      return post;
    },

    publishPosts: async (parent, { ids }, context) => {
      context.auth.requirePermission("PUBLISH_POSTS");

      const posts = await context.dataSources.postAPI.publishPosts(ids);

      // Publish individual updates
      posts.forEach((post) => {
        pubsub.publish("POST_UPDATED", { postUpdated: post });
      });

      return posts;
    },
  },

  // Subscription resolvers
  Subscription: {
    postAdded: {
      subscribe: () => pubsub.asyncIterator(["POST_ADDED"]),
    },

    postUpdated: {
      subscribe: (parent, { id }) => {
        return pubsub.asyncIterator([`POST_UPDATED_${id}`]);
      },
    },

    userOnline: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(["USER_ONLINE"]),
        (payload, variables, context) => {
          // Filter based on user permissions
          return context.auth.canSeeUserStatus(payload.userOnline.id);
        }
      ),
    },
  },

  // Type resolvers
  User: {
    posts: async (parent, args, context) => {
      // DataLoader untuk menghindari N+1 problem
      return await context.loaders.postsByUserId.load(parent.id);
    },
  },

  Post: {
    author: async (parent, args, context) => {
      return await context.loaders.userById.load(parent.authorId);
    },
  },
};

module.exports = resolvers;

Server Setup dengan Apollo Server

1. Basic Setup

// server.js
const { ApolloServer } = require("apollo-server-express");
const { makeExecutableSchema } = require("@graphql-tools/schema");
const express = require("express");
const { createServer } = require("http");
const { SubscriptionServer } = require("subscriptions-transport-ws");
const { execute, subscribe } = require("graphql");

const typeDefs = require("./schema");
const resolvers = require("./resolvers");
const { createDataSources } = require("./dataSources");
const { createLoaders } = require("./loaders");
const { authMiddleware } = require("./middleware/auth");

async function startServer() {
  const app = express();
  const httpServer = createServer(app);

  // Create executable schema
  const schema = makeExecutableSchema({
    typeDefs,
    resolvers,
  });

  // Create Apollo Server
  const server = new ApolloServer({
    schema,
    context: async ({ req, connection }) => {
      // WebSocket connection (subscriptions)
      if (connection) {
        return {
          ...connection.context,
          dataSources: createDataSources(),
          loaders: createLoaders(),
        };
      }

      // HTTP request
      const auth = await authMiddleware(req);

      return {
        auth,
        dataSources: createDataSources(),
        loaders: createLoaders(),
        req,
      };
    },

    // Error formatting
    formatError: (error) => {
      console.error("GraphQL Error:", error);

      // Don't expose internal errors in production
      if (process.env.NODE_ENV === "production") {
        if (error.message.startsWith("Database")) {
          return new Error("Internal server error");
        }
      }

      return error;
    },

    // Enable GraphQL Playground in development
    introspection: process.env.NODE_ENV !== "production",
    playground: process.env.NODE_ENV !== "production",
  });

  await server.start();
  server.applyMiddleware({ app, path: "/graphql" });

  // Setup subscription server
  const subscriptionServer = SubscriptionServer.create(
    {
      schema,
      execute,
      subscribe,
      onConnect: async (connectionParams, webSocket) => {
        // Authenticate WebSocket connection
        const auth = await authMiddleware({
          headers: connectionParams,
        });

        return { auth };
      },
      onDisconnect: (webSocket, context) => {
        console.log("Client disconnected");
      },
    },
    {
      server: httpServer,
      path: "/graphql",
    }
  );

  const PORT = process.env.PORT || 4000;

  httpServer.listen(PORT, () => {
    console.log(
      `🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`
    );
    console.log(
      `🚀 Subscriptions ready at ws://localhost:${PORT}${server.graphqlPath}`
    );
  });
}

startServer().catch((error) => {
  console.error("Error starting server:", error);
});

2. Data Sources

// dataSources/userAPI.js
const { DataSource } = require("apollo-datasource");
const { UserInputError } = require("apollo-server-express");

class UserAPI extends DataSource {
  constructor() {
    super();
  }

  initialize(config) {
    this.context = config.context;
    this.db = config.context.db;
  }

  async getUserById(id) {
    const user = await this.db.user.findUnique({
      where: { id },
      include: {
        posts: true,
      },
    });

    if (!user) {
      throw new UserInputError("User not found");
    }

    return user;
  }

  async getUsers({ first, after }) {
    const cursor = after
      ? { id: Buffer.from(after, "base64").toString() }
      : undefined;

    const users = await this.db.user.findMany({
      take: first + 1, // +1 to check if there's next page
      cursor: cursor ? { id: cursor.id } : undefined,
      skip: cursor ? 1 : 0,
      orderBy: { createdAt: "desc" },
    });

    const hasNextPage = users.length > first;
    const nodes = hasNextPage ? users.slice(0, -1) : users;

    const totalCount = await this.db.user.count();

    return {
      users: nodes,
      hasNextPage,
      hasPreviousPage: !!cursor,
      totalCount,
    };
  }

  async createUser(input) {
    // Validate email uniqueness
    const existingUser = await this.db.user.findUnique({
      where: { email: input.email },
    });

    if (existingUser) {
      throw new UserInputError("Email already exists");
    }

    return await this.db.user.create({
      data: input,
    });
  }

  async updateUser(id, input) {
    return await this.db.user.update({
      where: { id },
      data: input,
    });
  }
}

module.exports = UserAPI;

3. DataLoaders untuk N+1 Problem

// loaders/index.js
const DataLoader = require("dataloader");
const { db } = require("../database");

function createLoaders() {
  return {
    // User loader
    userById: new DataLoader(async (ids) => {
      const users = await db.user.findMany({
        where: { id: { in: ids } },
      });

      // Return users in same order as requested IDs
      return ids.map((id) => users.find((user) => user.id === id));
    }),

    // Posts by user ID
    postsByUserId: new DataLoader(async (userIds) => {
      const posts = await db.post.findMany({
        where: { authorId: { in: userIds } },
        orderBy: { createdAt: "desc" },
      });

      // Group posts by user ID
      const postsByUser = userIds.map((userId) =>
        posts.filter((post) => post.authorId === userId)
      );

      return postsByUser;
    }),

    // Comments by post ID
    commentsByPostId: new DataLoader(async (postIds) => {
      const comments = await db.comment.findMany({
        where: { postId: { in: postIds } },
        include: { author: true },
        orderBy: { createdAt: "asc" },
      });

      return postIds.map((postId) =>
        comments.filter((comment) => comment.postId === postId)
      );
    }),
  };
}

module.exports = { createLoaders };

Advanced Features

1. Custom Directives

// directives/auth.js
const { SchemaDirectiveVisitor } = require("apollo-server-express");
const { defaultFieldResolver } = require("graphql");
const {
  AuthenticationError,
  ForbiddenError,
} = require("apollo-server-express");

class AuthDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;
    const requiredRole = this.args.requires;

    field.resolve = async function (...args) {
      const [, , context] = args;

      if (!context.auth.user) {
        throw new AuthenticationError("You must be logged in");
      }

      if (requiredRole && !context.auth.user.roles.includes(requiredRole)) {
        throw new ForbiddenError("Insufficient permissions");
      }

      return resolve.apply(this, args);
    };
  }
}

class RateLimitDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;
    const { max, window } = this.args;

    field.resolve = async function (...args) {
      const [, , context] = args;
      const key = `${context.auth.user?.id || context.req.ip}:${field.name}`;

      const current = await context.redis.incr(key);
      if (current === 1) {
        await context.redis.expire(key, window);
      }

      if (current > max) {
        throw new Error("Rate limit exceeded");
      }

      return resolve.apply(this, args);
    };
  }
}

module.exports = {
  auth: AuthDirective,
  rateLimit: RateLimitDirective,
};
# Schema with directives
directive @auth(requires: Role = USER) on FIELD_DEFINITION
directive @rateLimit(max: Int!, window: Int!) on FIELD_DEFINITION

enum Role {
  USER
  ADMIN
  MODERATOR
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
    @auth(requires: USER)
    @rateLimit(max: 5, window: 60)
  deleteUser(id: ID!): Boolean! @auth(requires: ADMIN)
  banUser(id: ID!): Boolean! @auth(requires: MODERATOR)
}

2. File Upload

// File upload resolver
const { GraphQLUpload } = require("graphql-upload");
const { createWriteStream } = require("fs");
const { join } = require("path");
const { v4: uuid } = require("uuid");

const resolvers = {
  Upload: GraphQLUpload,

  Mutation: {
    uploadFile: async (parent, { file }, context) => {
      const { createReadStream, filename, mimetype, encoding } = await file;

      // Validate file type
      const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
      if (!allowedTypes.includes(mimetype)) {
        throw new Error("Invalid file type");
      }

      // Generate unique filename
      const extension = filename.split(".").pop();
      const uniqueFilename = `${uuid()}.${extension}`;
      const filepath = join(__dirname, "../uploads", uniqueFilename);

      // Save file
      const stream = createReadStream();
      await new Promise((resolve, reject) => {
        stream
          .pipe(createWriteStream(filepath))
          .on("finish", resolve)
          .on("error", reject);
      });

      // Save file info to database
      const fileRecord = await context.db.file.create({
        data: {
          filename: uniqueFilename,
          originalName: filename,
          mimetype,
          size: stream.bytesRead,
          path: filepath,
          uploadedBy: context.auth.user.id,
        },
      });

      return {
        id: fileRecord.id,
        filename: uniqueFilename,
        url: `/uploads/${uniqueFilename}`,
      };
    },
  },
};

3. Real-time Subscriptions

// Advanced subscription with filtering
const { withFilter } = require("graphql-subscriptions");
const { RedisPubSub } = require("graphql-redis-subscriptions");
const Redis = require("ioredis");

const redis = new Redis(process.env.REDIS_URL);
const pubsub = new RedisPubSub({
  publisher: redis,
  subscriber: redis,
});

const resolvers = {
  Subscription: {
    messageAdded: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(["MESSAGE_ADDED"]),
        (payload, variables, context) => {
          // Only send to users in the same room
          return (
            payload.messageAdded.roomId === variables.roomId &&
            context.auth.user.rooms.includes(variables.roomId)
          );
        }
      ),
    },

    notificationAdded: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(["NOTIFICATION_ADDED"]),
        (payload, variables, context) => {
          // Only send to the target user
          return payload.notificationAdded.userId === context.auth.user.id;
        }
      ),
    },

    postUpdated: {
      subscribe: (parent, { id }, context) => {
        // Subscribe to specific post updates
        return pubsub.asyncIterator([`POST_UPDATED_${id}`]);
      },
    },
  },
};

Testing GraphQL APIs

1. Unit Testing Resolvers

// tests/resolvers/user.test.js
const { createTestClient } = require("apollo-server-testing");
const { ApolloServer } = require("apollo-server-express");
const typeDefs = require("../../schema");
const resolvers = require("../../resolvers");

describe("User Resolvers", () => {
  let server, query, mutate;

  beforeEach(() => {
    server = new ApolloServer({
      typeDefs,
      resolvers,
      context: () => ({
        auth: {
          user: { id: "1", email: "test@example.com" },
          requirePermission: jest.fn(),
        },
        dataSources: {
          userAPI: {
            getUserById: jest.fn(),
            createUser: jest.fn(),
            updateUser: jest.fn(),
          },
        },
        loaders: {
          userById: {
            load: jest.fn(),
          },
        },
      }),
    });

    const testClient = createTestClient(server);
    query = testClient.query;
    mutate = testClient.mutate;
  });

  describe("Query.user", () => {
    it("should return user by ID", async () => {
      const mockUser = {
        id: "1",
        name: "John Doe",
        email: "john@example.com",
      };

      server.requestOptions.context.dataSources.userAPI.getUserById.mockResolvedValue(
        mockUser
      );

      const GET_USER = `
        query GetUser($id: ID!) {
          user(id: $id) {
            id
            name
            email
          }
        }
      `;

      const { data } = await query({
        query: GET_USER,
        variables: { id: "1" },
      });

      expect(data.user).toEqual(mockUser);
    });
  });

  describe("Mutation.createUser", () => {
    it("should create new user", async () => {
      const input = {
        name: "Jane Doe",
        email: "jane@example.com",
      };

      const mockUser = {
        id: "2",
        ...input,
      };

      server.requestOptions.context.dataSources.userAPI.createUser.mockResolvedValue(
        mockUser
      );

      const CREATE_USER = `
        mutation CreateUser($input: CreateUserInput!) {
          createUser(input: $input) {
            id
            name
            email
          }
        }
      `;

      const { data } = await mutate({
        mutation: CREATE_USER,
        variables: { input },
      });

      expect(data.createUser).toEqual(mockUser);
    });
  });
});

2. Integration Testing

// tests/integration/api.test.js
const request = require("supertest");
const { createTestServer } = require("../helpers/testServer");
const { seedDatabase, cleanDatabase } = require("../helpers/database");

describe("GraphQL API Integration", () => {
  let app, server;

  beforeAll(async () => {
    ({ app, server } = await createTestServer());
    await seedDatabase();
  });

  afterAll(async () => {
    await cleanDatabase();
    await server.close();
  });

  describe("User Operations", () => {
    it("should create and retrieve user", async () => {
      // Create user
      const createMutation = `
        mutation {
          createUser(input: {
            name: "Test User"
            email: "test@example.com"
          }) {
            id
            name
            email
          }
        }
      `;

      const createResponse = await request(app)
        .post("/graphql")
        .send({ query: createMutation })
        .expect(200);

      const userId = createResponse.body.data.createUser.id;

      // Retrieve user
      const getQuery = `
        query {
          user(id: "${userId}") {
            id
            name
            email
          }
        }
      `;

      const getResponse = await request(app)
        .post("/graphql")
        .send({ query: getQuery })
        .expect(200);

      expect(getResponse.body.data.user).toMatchObject({
        id: userId,
        name: "Test User",
        email: "test@example.com",
      });
    });
  });
});

Performance Optimization

1. Query Complexity Analysis

// middleware/queryComplexity.js
const { createComplexityLimitRule } = require("graphql-query-complexity");
const { ValidationContext } = require("graphql");

const complexityLimitRule = createComplexityLimitRule(1000, {
  maximumComplexity: 1000,
  variables: {},
  createError: (max, actual) => {
    return new Error(
      `Query complexity ${actual} exceeds maximum complexity ${max}`
    );
  },
  onComplete: (complexity) => {
    console.log("Query complexity:", complexity);
  },
});

// In Apollo Server
const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [complexityLimitRule],
});

2. Query Depth Limiting

// middleware/depthLimit.js
const depthLimit = require("graphql-depth-limit");

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(10)], // Max depth 10
});

3. Caching Strategies

// caching/redis.js
const Redis = require("ioredis");
const redis = new Redis(process.env.REDIS_URL);

class CacheAPI {
  async get(key) {
    const cached = await redis.get(key);
    return cached ? JSON.parse(cached) : null;
  }

  async set(key, value, ttl = 3600) {
    await redis.setex(key, ttl, JSON.stringify(value));
  }

  async del(key) {
    await redis.del(key);
  }

  async invalidatePattern(pattern) {
    const keys = await redis.keys(pattern);
    if (keys.length > 0) {
      await redis.del(...keys);
    }
  }
}

// In resolvers
const resolvers = {
  Query: {
    user: async (parent, { id }, context) => {
      const cacheKey = `user:${id}`;

      // Try cache first
      let user = await context.cache.get(cacheKey);

      if (!user) {
        // Fetch from database
        user = await context.dataSources.userAPI.getUserById(id);

        // Cache for 1 hour
        await context.cache.set(cacheKey, user, 3600);
      }

      return user;
    },
  },

  Mutation: {
    updateUser: async (parent, { id, input }, context) => {
      const user = await context.dataSources.userAPI.updateUser(id, input);

      // Invalidate cache
      await context.cache.del(`user:${id}`);
      await context.cache.invalidatePattern(`users:*`);

      return user;
    },
  },
};

Security Best Practices

1. Authentication & Authorization

// middleware/auth.js
const jwt = require("jsonwebtoken");
const { AuthenticationError } = require("apollo-server-express");

class AuthService {
  constructor(user, permissions = []) {
    this.user = user;
    this.permissions = permissions;
  }

  requireAuth() {
    if (!this.user) {
      throw new AuthenticationError("Authentication required");
    }
  }

  requirePermission(permission) {
    this.requireAuth();

    if (!this.permissions.includes(permission)) {
      throw new ForbiddenError(`Permission ${permission} required`);
    }
  }

  canAccess(resource) {
    this.requireAuth();

    // Resource-based access control
    if (resource.ownerId === this.user.id) {
      return true;
    }

    if (this.user.role === "ADMIN") {
      return true;
    }

    return false;
  }
}

async function authMiddleware(req) {
  const token = req.headers.authorization?.replace("Bearer ", "");

  if (!token) {
    return new AuthService(null);
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    const user = await getUserById(decoded.userId);
    const permissions = await getUserPermissions(user.id);

    return new AuthService(user, permissions);
  } catch (error) {
    throw new AuthenticationError("Invalid token");
  }
}

2. Input Validation

// validation/schemas.js
const Joi = require("joi");

const schemas = {
  createUser: Joi.object({
    name: Joi.string().min(2).max(50).required(),
    email: Joi.string().email().required(),
    password: Joi.string()
      .min(8)
      .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
      .required(),
  }),

  updatePost: Joi.object({
    title: Joi.string().min(5).max(200),
    content: Joi.string().min(10),
    tags: Joi.array().items(Joi.string().max(20)).max(10),
  }),
};

class ValidationService {
  async validateCreateUser(input) {
    const { error } = schemas.createUser.validate(input);
    if (error) {
      throw new UserInputError(error.details[0].message);
    }
  }

  async validateUpdatePost(input) {
    const { error } = schemas.updatePost.validate(input);
    if (error) {
      throw new UserInputError(error.details[0].message);
    }
  }
}

3. Rate Limiting

// middleware/rateLimiting.js
const { RateLimiterRedis } = require("rate-limiter-flexible");
const Redis = require("ioredis");

const redis = new Redis(process.env.REDIS_URL);

const rateLimiters = {
  // General API rate limiting
  general: new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: "rl_general",
    points: 100, // requests
    duration: 60, // per 60 seconds
  }),

  // Mutation rate limiting
  mutation: new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: "rl_mutation",
    points: 10,
    duration: 60,
  }),

  // Login attempts
  login: new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: "rl_login",
    points: 5,
    duration: 900, // 15 minutes
    blockDuration: 900,
  }),
};

function createRateLimitMiddleware(limiterName) {
  return async (resolve, parent, args, context, info) => {
    const key = context.auth.user?.id || context.req.ip;

    try {
      await rateLimiters[limiterName].consume(key);
      return resolve(parent, args, context, info);
    } catch (rejRes) {
      throw new Error(
        `Rate limit exceeded. Try again in ${rejRes.msBeforeNext}ms`
      );
    }
  };
}

Monitoring dan Observability

1. Metrics Collection

// monitoring/metrics.js
const { ApolloServerPluginUsageReporting } = require("apollo-server-core");
const { createPrometheusMetricsPlugin } = require("apollo-prometheus-plugin");

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    // Apollo Studio reporting
    ApolloServerPluginUsageReporting({
      apiKey: process.env.APOLLO_KEY,
      sendVariableValues: { all: true },
      sendHeaders: { all: true },
    }),

    // Prometheus metrics
    createPrometheusMetricsPlugin({
      registry: require("prom-client").register,
      metrics: {
        graphql_request_duration_seconds: true,
        graphql_request_total: true,
        graphql_resolver_duration_seconds: true,
      },
    }),

    // Custom logging plugin
    {
      requestDidStart() {
        return {
          didResolveOperation(requestContext) {
            console.log("Operation:", requestContext.request.operationName);
          },

          didEncounterErrors(requestContext) {
            console.error("GraphQL errors:", requestContext.errors);
          },

          willSendResponse(requestContext) {
            const duration = Date.now() - requestContext.request.startTime;
            console.log(`Request completed in ${duration}ms`);
          },
        };
      },
    },
  ],
});

2. Error Tracking

// monitoring/errorTracking.js
const Sentry = require("@sentry/node");

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
});

const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (error) => {
    // Log to Sentry
    Sentry.captureException(error);

    // Log to console
    console.error("GraphQL Error:", {
      message: error.message,
      locations: error.locations,
      path: error.path,
      stack: error.stack,
    });

    // Return sanitized error in production
    if (process.env.NODE_ENV === "production") {
      if (
        error.message.includes("Database") ||
        error.message.includes("Internal")
      ) {
        return new Error("Internal server error");
      }
    }

    return error;
  },
});

Deployment

1. Docker Setup

# Dockerfile
FROM node:18-alpine

WORKDIR /app

# Copy package files
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production

# Copy source code
COPY . .

# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S graphql -u 1001

# Change ownership
RUN chown -R graphql:nodejs /app
USER graphql

# Expose port
EXPOSE 4000

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:4000/health || exit 1

# Start application
CMD ["npm", "start"]

2. Production Configuration

// config/production.js
module.exports = {
  server: {
    port: process.env.PORT || 4000,
    cors: {
      origin: process.env.ALLOWED_ORIGINS?.split(",") || false,
      credentials: true,
    },
  },

  database: {
    url: process.env.DATABASE_URL,
    ssl: { rejectUnauthorized: false },
    pool: {
      min: 2,
      max: 10,
    },
  },

  redis: {
    url: process.env.REDIS_URL,
    retryDelayOnFailover: 100,
    maxRetriesPerRequest: 3,
  },

  auth: {
    jwtSecret: process.env.JWT_SECRET,
    jwtExpiration: "7d",
  },

  apollo: {
    introspection: false,
    playground: false,
    debug: false,
  },
};

Kesimpulan

GraphQL menawarkan pendekatan yang powerful dan flexible untuk membangun API modern. Dengan memahami konsep core, implementing best practices, dan menggunakan tools yang tepat, Anda dapat membangun GraphQL API yang:

  1. Efficient: Mengurangi over-fetching dan under-fetching
  2. Type-safe: Strong typing system mencegah runtime errors
  3. Scalable: Proper architecture dan caching strategies
  4. Secure: Comprehensive authentication, authorization, dan validation
  5. Maintainable: Clean code structure dan comprehensive testing
  6. Observable: Proper monitoring dan error tracking

Kunci sukses adalah memulai dengan schema design yang baik, implementing resolvers yang efficient, dan secara bertahap menambahkan advanced features sesuai kebutuhan aplikasi Anda.

Mari Mulai Kolaborasi.

Ide digital Anda siap menjadi produk nyata. Konsultasikan gratis dengan tim kami dan wujudkan solusi yang tepat untuk bisnis Anda.

Kami menggunakan cookie
Kami menggunakan cookie untuk memastikan Anda mendapatkan pengalaman terbaik di website kami. Untuk informasi lebih lanjut tentang penggunaan cookie, silakan lihat kebijakan cookie kami.

Dengan mengklik "Terima", Anda menyetujui penggunaan cookie kami.

Pelajari lebih lanjut