[DOTNET] GraphQL บน NET8 ด้วย HotChocolate Library #02 (Mutation) + TiDB

Blog จะเขียนต่อจากตอนที่แล้ว [DOTNET] GraphQL บน NET8 ด้วย HotChocolate Library #01 (Query) ที่จะเน้นไปส่วนของการดึงข้อมูล (Query) โดยใช้ GraphQL HotChocolate เหมือนเดิมครับ แต่ไปเน้นในส่วยของ Create Update Delete แทนครับ ซึ่งใน GraphQL เค้าจะเรียกว่า mutation และมีการลองเจ้า TiDB ด้วยครับ (รู้จากงาน CodeMania ได้เอามาลองพอดี)

เนื่องจาก Blog ตอนที่แล้ว ผมมีแค่ Code อย่างเดียว ไม่มีได้มี Dependency อะไรเพิ่ม แต่เนื่องจากลองในส่วน mutation (Create / Update / Delete) มันต้องมี Database และ โดยใน Blog นี้ ผมใช้ TiDB เค้ามี Free Tier และลอง Table เดียว เล็กๆ ครับ

ภาพรวม

สำหรับภาพรวมของระบบใน Blog นี้หลักจะมี 2 ส่วน ตัว

  1. WebAPI ที่เปิดรับทั้งตัว GraphQL / REST API เดิมด้วย
  2. Database อันนี้เรียกว่าเป็นการลองของ ใช้ TiDB Cloud ลองใน Blog นี้ครับ

การทดสอบ ผมใช้ไฟล์ .http ลองเป็น Client ทำ Sample Request เพื่อทดสอบครับ

โครงสร้าง Project

ผมมีปรับจาก Blog ตอนที่แล้วนิดนึง มีลักษณะดังนี้

GraphQLAPI
-> Controllers
-> DTOs
---> AppMapperProfile.cs
---> SuppilerCreateDTO.cs
---> SupplierDTO.cs
SuppilerDTO.cs
-> GraphQL
---> Query
---> Type
---> Mutation
------> SupplierGraphQLMutation.cs
-> Services
-> Infra
---> Models
------> SupplierModel.cs
---> Repositories
---> DataDBContext.cs

TiDB

อันนี้ง่ายเลยครับ สมัครก่อนตาม Link ของ TiDB Cloud ผมลองใช้ Google Account สมัครเข้าไป สร้าง Cluster + Database ของผมชื่อ test

หน้าจอมันจะมี Connection Generate มาให้ด้วยครับ และ Gen Password ด้วยนะ มันจะมีรูปแบบ Connection String หลายแบบ ตอนที่ผมลอง C# ไม่มีครับ เอาแบบ .env มาแทน เก็บข้อมูลตรงนี้เอาไว้นะครับ

Code ส่วนที่เกี่ยวกับ DB

ตัว Project ผมเลือกใช้ Lib ดังนี้ครับ

  • ORM ในที่นี้ใช่ตัว EntityFrameworkCore ครับ
  • ตัว TiDB ใช้ Driver MySQL ในการเชื่อมต่อครับ กลับมาใช้ MySQL Driver ในรอบหลายปีเลย เจอแปลกเหมือนมี 2 ค่ายที่ทำครับ ได้แก่ MySql.EntityFrameworkCore / Pomelo.EntityFrameworkCore.MySql ผมเลือกตัว Pomelo ครับ
  • AutoMapper เอาช่วง Map Field DTO กับ Model ครับ

สำหรับ Command จะได้ ดังนี้ครับ

dotnet add package Microsoft.EntityFrameworkCore --version 8.0.8
dotnet add package Microsoft.EntityFrameworkCore.Tools --version 8.0.8
dotnet add package Pomelo.EntityFrameworkCore.MySql --version 8.0.2
dotnet add package AutoMapper --version 13.0.0

เพิ่ม DTO จริงๆคล้ายๆ Entity นะ แต่อยากบังคับ Structure ให้แยกกันส่วน DB / Data ของ API ส่วน DTO แยกสำหรับ Create / อื่นๆด้วย ถ้าสงสัยว่าทำไมต้องแยกลองดู Blog นี้ได้ครับ

  • SupplierModel.cs ส่วนที่แทน Table ของ Database
  • SupplierDTO.cs สำหรับส่งข้อมูล ในแต่ละ Layer Service / Repository
  • SuppilerCreateDTO.cs สำหรับ Create ข้อมูล

จากนั้นมาเพิ่ม DBContext ของ EF ครับ อย่างผมเอา Default + เพิ่มกำหนดให้มัน Auto Create Table ให้เลย / Seed Data

using GraphQLAPI.Infra.Models;
using Microsoft.EntityFrameworkCore;

namespace GraphQLAPI.Infra
{
    public class DataDBContext : DbContext
    {
        public DataDBContext(DbContextOptions<DataDBContext> options) : base(options)
        {
        }

        public DbSet<SupplierModel> Suppliers { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            //อันนี้ ถ้าไม่อยากให้มันสร้า่งComment ออก
            base.OnModelCreating(modelBuilder);
            //Seed Data
            modelBuilder.Entity<SupplierModel>().HasData(
                new SupplierModel
                {
                    Id = 1,
                    FirstName = "John",
                    LastName = "Doe",
                    Address = "123 Main St",
                    Email = "John@exam,ple.com",
                    Phone = "123-456-7890"
                },
                new SupplierModel
                {
                    Id = 2,
                    FirstName = "Jane",
                    LastName = "Doe",
                    Address = "123 Main St",
                    Email = "Jane@exam,ple.com",
                    Phone = "123-456-7890"
                });
        }
    }
}

ขยับขึ้นมาปรับส่วน Repository ตรงนี้ใช้ Automapper มาช่วย Map Enitity / DTO Code Mapping ผมเอาง่ายๆ ดังนี้

namespace GraphQLAPI.Infra.Repositories;

using AutoMapper;
using GraphQLAPI.DTOs;
using GraphQLAPI.Infra.Models;
using System.Collections.Generic;

public class SupplierRepository: ISupplierRepository
{
    
    private readonly DataDBContext _context;
    private readonly IMapper _mapper;

    public SupplierRepository(DataDBContext context, IMapper mapper)
    {
        _context = context;
        _mapper = mapper;
    }

    public async Task <IEnumerable <SupplierDTO>> GetSuppliers()
    {
        IList<SupplierModel> supplierModels = _context.Suppliers.ToList();

        return await Task.FromResult(_mapper.Map<IEnumerable<SupplierDTO>>(supplierModels));
    }

    public async Task <SupplierDTO> GetSupplier(int Id)
    {
        SupplierModel supplierModel = _context.Suppliers.FirstOrDefault(x => x.Id == Id);

        return await Task.FromResult(_mapper.Map<SupplierDTO>(supplierModel));
    }

    public async Task<SupplierDTO> AddSupplier(SupplierDTO supplier)
    {
        SupplierModel supplierModel = _mapper.Map<SupplierModel>(supplier);

        _context.Suppliers.Add(supplierModel);
        _context.SaveChanges();

        return await Task.FromResult(_mapper.Map<SupplierDTO>(supplierModel));
    }
    //Other Logic
}
using AutoMapper;
using GraphQLAPI.Infra.Models;

namespace GraphQLAPI.DTOs;
public class AppMapperProfile : Profile
{
    public AppMapperProfile()
    {
        CreateMap<SupplierModel, SupplierDTO>().ReverseMap();
        CreateMap<SuppilerCreateDTO, SupplierDTO>()
            .ForMember(dest => dest.Id, opt => opt.Ignore()) // Ignore the Id property
            .ForAllMembers(opt => opt.Condition((src, dest, srcMember) => srcMember != null)); 
             // Apply the global condition
    }
}

ขยับชึ้นมาในส่วนของ Service ที่เตรียมไว้ครับ จะมี 2 ไฟล์ ISuppilerService.cs / SuppilerService.cs โดยมี Code สำหรับส่วน Create ตามด้านล่าง

namespace GraphQLAPI.Services;

using AutoMapper;
using GraphQLAPI.DTOs;
using GraphQLAPI.Infra.Repositories;

public class SupplierService : ISupplierService
{
    private readonly ISupplierRepository _supplierRepository;
    private readonly IMapper _mapper;
    public SupplierService(ISupplierRepository supplierRepository, IMapper mapper)
    {
        _supplierRepository = supplierRepository;
        _mapper = mapper;
    }

    public async Task<IEnumerable<SupplierDTO>> GetSuppliers()
    {
        return await _supplierRepository.GetSuppliers();
    }

    public async Task<SupplierDTO> GetSupplier(int id)
    {
        return await _supplierRepository.GetSupplier(id);
    }

    public async Task<SupplierDTO> AddSupplier(SuppilerCreateDTO supplierCreate)
    {
        //map SuppilerCreateDTO to SupplierDTO
        SupplierDTO supplier = _mapper.Map<SupplierDTO>(supplierCreate);
        supplier = await _supplierRepository.AddSupplier(supplier);
        return supplier;
    }
    //Other Operation
}

จากนั้นมาที่ส่วนของ program.cs โดยมี Step การเพิ่มของลงไปดังนี้

  • เรียกใช้ Automapper
builder.Services.AddAutoMapper(typeof(Program));
  • เพิ่มการเชื่อมต่อ DB
String connectionString = builder.Configuration.GetConnectionString("TiDBConnn");
builder.Services.AddDbContext<DataDBContext>(opt => opt.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)));

สุดท้ายในส่วน appsettings.json เพิ่ม Connnection String แก้ Server / Port / User และ Password ให้ครับ

  "ConnectionStrings": {
    "TiDBConnn": "Server=gateway01.ap-southeast-1.prod.aws.tidbcloud.com;port=4000;database=<YOUR-DB>;user=<YOUR_USER_WITH.ROOT>;password=<YOUR_PASSWORD>;"
  }

หลังจากทำเสร็จแล้ว ลอง Build สักรอบด้วยคำสั่ง dotnet build

สร้าง Migration File

เมื่อตรวจสอบว่า Build Success แล้ว ให้ลองสร้าง Migration File (Base Path project root)

dotnet ef migrations add intial 

List และ Apply

--List out pending migrations
dotnet ef migrations list --project .\GraphQLAPI.csproj

--Update database with pending migrations
dotnet ef database update --project .\GraphQLAPI.csproj

ลองไปดู TiDB มี Table และ

จากนั้น dotnet watch run ลองทดสอบครับ

เพิ่มในส่วน Mutation (Create Update Delete)

สำหรับส่วนนี้ Pattern คล้ายๆกันครับ เพิ่ม Logic ในส่วน repository และไล่มาส่วน service สุดท้ายที่ส่วน Mutation

  • repository map ข้อมูลระหว่าง dto กับ entity
  • service ตอนนี้ไม่มี Logic พิเศษ เอาไว้ให้มัน Call repository
  • mutation ผมแยกไฟล์ SupplierGraphQLMutation.cs และเพิ่ม Logic เข้าไป สังเกคุว่า ผมจะมีดัก Try Catch และ มา Rewrite Exception ด้วย ตรง GraphQLException
using GraphQLAPI.DTOs;
using GraphQLAPI.Services;

namespace GraphQLAPI.GraphQL.Mutation
{
    public class SupplierGraphQLMutation
    {
        public async Task<SupplierDTO> AddSupplier([Service] ISupplierService supplierService, SuppilerCreateDTO newSupplier)
        {
            try
            {
                return await supplierService.AddSupplier(newSupplier);
            }
            catch (Exception ex)
            {
                throw new GraphQLException(new ErrorBuilder()
                    .SetMessage(ex.Message)
                    .SetCode("SUPPLIER_ADD_ERROR")
                    .Build());
            }
        }
    }
}
  • program.cs เพิ่มให้ GraphQL รู้จักกับส่วน Mutation ตอน Start มันจะได้เอาไป Generate GraphQL Schema ใหม่ครับ
builder.Services.AddGraphQLServer()
                .AddType<SupplierType>()
                .AddQueryType<SupplierGraphQLQuery>()
                .AddMutationType<SupplierGraphQLMutation>(); // Register the mutation type

ถ้า Run ขึ้นมา พบว่าตัว Create Register ใน Schema ในแล้วครับ

Test Create

หลังจากสร้างเสร็จแล้ว มาลองกันครับ ของผมเอาง่ายๆ เขียนไฟล์ .http ไว้แล้ว ลองยิง Create แล้วดู result ที่ได้ครับ

### Create Supplier ###
POST {{GraphQLAPI_HostAddress}}/graphQL
Content-Type: application/json
X-REQUEST-TYPE: GraphQL

mutation {
  addSupplier(newSupplier: {
    firstName: "John"
    lastName: "Sunma"
    address: "1234 Main St"
    phone: "123-456-7890"
    email: "faii@adc.com"
  }) {
    id
    firstName
    lastName
    address
    phone
    email
  }
}

ส่วน Update / Delete คล้ายกับ Create โดย Code ตัวเต็ม GraphQL/Mutation/SupplierGraphQLMutation.cs

อ๋อ ส่วน Service / Repository จะเคสพิเศษตอน Update มาตรวจว่า Id ที่ส่งเข้ามา มีอะไรบ้างลองไปแกะจาก Code ใน Repo ได้ครับ

Test Update / Delete

ไฟล์ .http ไว้แล้ว ลองยิง Update / Delete ตัวเต็มจะอยู่ที่นี่ครับ GraphQLAPI.http

### Update Supplier ###
POST {{GraphQLAPI_HostAddress}}/graphQL
Content-Type: application/json
X-REQUEST-TYPE: GraphQL

mutation {
  updateSupplier(
   id: 30002,
   supplier: {
    id: 30002
    firstName: "John"
    lastName: "John"
    address: "1234 Main St"
    phone: "123-456-7890"
    email: "faii@adc.com"
  }) {
    id
    firstName
    lastName
    address
    phone
    email
  }
}

### Update Supplier Id Not Exist ###
POST {{GraphQLAPI_HostAddress}}/graphQL
Content-Type: application/json
X-REQUEST-TYPE: GraphQL

mutation {
  updateSupplier(
   id: 99999,
   supplier: {
    id: 99999
    firstName: "John"
    lastName: "John"
    address: "1234 Main St"
    phone: "123-456-7890"
    email: "faii@adc.com"
  }) {
    id
    firstName
    lastName
    address
    phone
    email
  }
}

### Delete Supplier (true-deleted / false-not found)###
POST {{GraphQLAPI_HostAddress}}/graphQL
Content-Type: application/json
X-REQUEST-TYPE: GraphQL

mutation {
  deleteSupplier(id: 30003)
}

อันนี้เป็นอย่างของ GraphQL หลักผมจะไปใช้ในมุม Query มากกว่า ที่เหลือยังเป็น REST API เหมือนเดิมครับ

ว่าจะเดี๋ยว Blog หน้าอาจจะลองแยก Project นี้มาเป็นหลายๆ ตาม Pattern Clean Architecture ดูครับ สำหรับ Code เต็มๆ อยู่ที่นี้ครับ GitHub - pingkunga/net8_graphql_HotChocolate_sample at feature/mutation_sample


Discover more from naiwaen@DebuggingSoft

Subscribe to get the latest posts sent to your email.