--- title: Đồ chơi để thấy GraphQL đỡ phiền date: 2025-04-23 13:41:04.609617 UTC --- [GraphQL](https://graphql.org/) là một dạng thiết kế HTTP API được dùng rộng rãi. Mặt lợi của nó thì không cần bàn. Tuy nhiên mình và có thể các bạn cũng thấy hơi phiền vì nó dài dòng. Bài này sẽ giới thiệu một số phương tiện phần mềm để làm việc với nó thoải mái hơn, ở phía client. ## Python: Cách dùng Pydantic để kiểm tra hợp lệ Lấy ví dụ API của [BirdWeather](https://app.birdweather.com/api/). Đây là kho dữ liệu của các trạm thu thập tiếng chim hót, phục vụ nghiên cứu nhận dạng chim từ tiếng hót. Ta sẽ gọi vào truy vấn [`stations`](https://app.birdweather.com/api/index.html#query-stations) để lấy danh sách các trạm. Một response mẫu sẽ trông như sau: ```json { "data": { "stations": { "nodes": [ { "id": "4106", "name": " PUC-4106 ", "type": "puc", "country": "Netherlands", "latestDetectionAt": "2025-04-09T17:08:47+02:00" }, { "id": "11011", "name": " Lee’s puc ", "type": "puc", "country": "Australia", "latestDetectionAt": "2025-04-17T06:07:53+10:00" } ], "pageInfo": { "endCursor": "Mg", "hasNextPage": true, "hasPreviousPage": false, "startCursor": "MQ" }, "totalCount": 9969 } } } ``` Ta thấy response của GraphQL API có đặc điểm là phải đi sâu hai lớp mới tới dữ liệu thực sự ta quan tâm. Cấu trúc của nó luôn có field `data`, rồi tới field cùng tên với query, rồi mới tới dữ liệu ta cần. Khi nhận dữ liệu từ bên ngoài, như từ API, ta luôn phải làm bước validate (kiểm tra hợp lệ). Để phục vụ cho việc này, ta sẽ dựa vào tài liệu [API BirdWeather](https://app.birdweather.com/api/), và định nghĩa các model sau với [Pydantic](https://docs.pydantic.dev/latest/), mô tả dạng dữ liệu ta mong muốn: ```py from datetime import datetime from enum import StrEnum from typing import Annotated from pydantic import BaseModel, Field, ConfigDict from pydantic.dataclasses import dataclass from pydantic.alias_generators import to_camel class StationType(StrEnum): BIRDNETPI = 'birdnetpi' PUC = 'puc' MOBILE = 'mobile' YOUTUBE = 'stream_youtube' AUDIO = 'stream_audio' @dataclass(frozen=True, config=ConfigDict(alias_generator=to_camel)) class PageInfo: has_next_page: bool has_previous_page: bool start_cursor: str end_cursor: str class Station(BaseModel): id: int name: str type: StationType country: str | None latest_detection_at: Annotated[datetime, Field(alias='latestDetectionAt')] class StationsResponse(BaseModel): model_config = ConfigDict(alias_generator=to_camel) nodes: tuple[Station, ...] page_info: PageInfo total_count: int ``` Tới đây thì `StationsResponse` chỉ mới định nghĩa phần bên trong `['data']['stations']` thôi, chẳng lẽ giờ phải định nghĩa thêm model cho hai lớp bên ngoài nữa sao, nếu vậy, nhân lên với số câu queries thì nhiều quá. Đừng lo, để ý rằng response của GraphQL luôn có field với tên cố định `data`, ta sẽ tận dụng `Generic` trong Python và sự [hỗ trợ từ Pydantic](https://docs.pydantic.dev/latest/concepts/models/#generic-models) để định nghĩa một model khái quát chung: ```py from typing import Generic, TypeVar from pydantic import BaseModel MT = TypeVar('MT') class GenericResponse(BaseModel, Generic[MT]): data: MT ``` Để sử dụng, ta sẽ truyền vào model cụ thể rồi gọi các hàm `model_validate_xxx` như thường: ```py import niquests def get_stations() -> None: post_data = { 'query': 'query stations ...', 'variables': {'first': 2}, } try: raw = niquests.post(API_URL, json=post_data) raw.raise_for_status() except niquests.HTTPError as e: log.info('Failed to call API. {}', e) return GenericResponse[StationsResponse].model_validate_json(resp.content) ``` Tuy nhiên, thực ra model `GenericResponse` chưa đủ, vì mới giải quyết lớp `data`. Còn lớp `stations` hay gì gì nữa. Lớp này hơi lắt léo tí vì có tên field không cố định, không xài "generic" như trước được. Ta sẽ dùng phương án khác là tạo một model trung gian, on-the-fly, dùng xong rồi vứt, trong cùng một hàm mà ta đang gọi query, vì khi đó ta biết tên field là gì. Để có cách định nghĩa nhanh trong một dòng, ta sẽ tận dụng [`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict): ```py from typing import TypedDic from devtools import debug # Lớp tạm, tạo xong rồi vứt Wrapper = TypedDict('Wrapper', {'stations': StationsResponse}) resp = GenericResponse[Wrapper].model_validate_json(raw.content or b'') debug(resp.data['stations']) ``` Kết quả debug: ```py resp.data['stations']: StationsResponse( nodes=( Station( id=4106, name=' PUC-4106 ', type=, country='Netherlands', latest_detection_at=datetime.datetime(2025, 4, 9, 17, 8, 47, tzinfo=TzInfo(+02:00)), ), Station( id=11011, name=' Lee’s puc ', type=, country='Australia', latest_detection_at=datetime.datetime(2025, 4, 17, 6, 7, 53, tzinfo=TzInfo(+10:00)), ), ), page_info=PageInfo( has_next_page=True, has_previous_page=False, start_cursor='MQ', end_cursor='Mg', ), total_count=9971, ) (StationsResponse) ``` Trong ví dụ này mình còn dùng một thủ thuật khác: Đổi tên field từ dạng `camelCase` sang `snake_case` cho hòa hợp với coding style của Python. Sau khi validate bằng Pydantic thì trình soạn thảo code sẽ xác định được kiểu biến và sẽ đưa ra gợi ý chính xác: ![Zed](https://quan-images.b-cdn.net/blogs/imgur/2026/LnE7w95.png) Ở backend với Python thì như vậy, còn ở frontend, với TypeScript/JavaScript thì ta làm thế nào. Sau đây là ví dụ với [Valibot](https://valibot.dev). Ta cũng định nghĩa các schema mô tả dữ liệu ta mong muốn: ```ts // models.ts import * as v from 'valibot' export enum StationType { BirdNetPi = 'birdnetpi', Puc = 'puc', Mobile = 'mobile', Youtube = 'stream_youtube', Audio = 'stream_audio', } export const PageInfoSchema = v.object({ hasNextPage: v.boolean(), hasPreviousPage: v.boolean(), startCursor: v.string(), endCursor: v.string(), }) const StringToDateSchema = v.pipe(v.string(), v.isoTimestamp(), v.transform(s => new Date(s))) export const StationSchema = v.object({ id: v.string(), name: v.string(), type: v.enum(StationType), country: v.nullable(v.string()), latestDetectionAt: StringToDateSchema, }) export const StationsResponseSchema = v.object({ nodes: v.array(StationSchema), pageInfo: PageInfoSchema, totalCount: v.number(), }) export type Station = v.InferOutput export type StationsResponse = v.InferOutput ``` Và đây là phần lắt léo với "generics". Mặc dù TypeScript hỗ trợ "generics", nhưng vì code TypeScript khi chạy cũng phải bị tước bỏ hết kí hiệu kiểu để trở thành JavaScript, nên code TypeScript không thể truyền tên kiểu như Python, mà phải truyền nguyên một đối tượng schema đã định nghĩa ở trên, và phải dùng hàm để biểu diễn "generics". Ta tạo một hàm chung để xử lý lớp "data" trong response: ```ts // repositories.ts import * as v from 'valibot' type _TE = v.ObjectEntries type _TM = v.ErrorMessage | undefined type ElementT = v.InferOutput> type GenericResponse = { data: T } export function parseGraphQLResponse(elementSchema: v.ObjectSchema, data: unknown): GenericResponse> { const WrapperSchema = v.object({ data: elementSchema }) return v.parse(WrapperSchema, data) } ``` Tiếp đến, với mỗi hàm gọi API, ta sẽ tạo thêm một schema trung gian để xử lý lớp giữa: ```ts // repositories.ts import ky from 'ky' import * as v from 'valibot' import { API_URL } from './consts' import type { Station } from './models' import { StationsResponseSchema } from './models' import stationsQuery from './queries/stations.gql?raw' export async function getStations(): Promise { const postData = { query: stationsQuery, variables: { first: 2 } } const raw = await ky.post(API_URL, { json: postData }).json() const WrapperSchema = v.object({ 'stations': StationsResponseSchema, }) const resp = parseGraphQLResponse(WrapperSchema, raw) const stations = resp.data.stations.nodes console.log('Stations:', stations) return stations } ``` Ta thấy rằng code TypeScript khá rườm rà so với Python nhỉ, nhưng biết sao được 😸, khi nào các trình duyệt đồng ý với nhau bỏ JavaScript đi thì may ra tình hình tốt hơn. ## Công cụ thử nghiệm API: Nushell Trước khi viết code ứng dụng, ta thường cần một công cụ để "nghịch" với API, xem nó trả về nội dung thế nào. Thường mọi người dùng Postman cho việc này. Mình thì ưa môi trường dòng lệnh hơn nên dùng công cụ khác. Trước đây mình hay dùng [HTTPie](https://httpie.io/cli) nhưng nay thì chuyển qua xài [Nushell](https://www.nushell.sh/) vì những ưu điểm sẽ kể ra sau đây. Đây là hình ảnh khi dùng Nushell: ```nu { query: (open --raw queries/stations.gql), variables: $vars } | http post -t application/json $API_URL ``` ![Nu HTTP](https://quan-images.b-cdn.net/blogs/imgur/2026/Gqglf32.png) Nushell có những tính năng sau, khiến nó rất tiện lợi để khám phá API: - Sử dụng dữ liệu có cấu trúc, với các kiểu dữ liệu như `record`, `list`, `table`, nhờ vậy có thể tạo nội dung JSON nhiều tầng dễ dàng bằng cú pháp của Nushell. - Response của API được parse thành [`table`](https://www.nushell.sh/lang-guide/chapters/types/basic_types/table.html#table) và hiển thị thành bảng cho dễ nhìn. Trong ví dụ trên, mình đã dùng kiểu [`record`](https://www.nushell.sh/book/working_with_records.html) của Nushell để tạo nội dung JSON cho request: ```nu { query: 'query stations ...', variables: { first: 2 } } ``` Do câu truy vấn GraphQL quá dài nên mình dùng mẹo sau để ra được câu lệnh thật ngắn: - Lưu câu truy vấn vào file _stations.gql_ rồi đọc lên (`open --raw queries/stations.gql`). - Phần cung cấp `variables` cho GraphQL cũng có thể dài dòng, nên mình lưu vào biến `vars` rồi sử dụng: ```nu let vars = { first: 2 } # hoặc let vars = { first: 2, countries: [Canada Sweden] } ``` Dữ liệu truyền qua ống `|` đến lệnh [`http post`](https://www.nushell.sh/commands/docs/http_post.html) thì sẽ được lệnh này dùng làm body cho request. Mình thêm `-t application/json` để khiến lệnh `http` chuỗi hóa `record` kia thành JSON cho hợp lệ. Nếu thấy `-t application/json` dài dòng thì còn cách khác là chuỗi hóa trước khi truyền cho `http post`: ```nu ❯ { query: (open --raw queries/stations.gql), variables: $vars } | to json | http post $API_URL ``` Nếu API đòi hỏi authentication thì sao? Giả sử nó đòi hỏi authentication ở dạng Bearer Token, ta có thể tạo header cho request như sau: ```nu http post -H [Authorization 'Bearer mytoken'] $API_URL ``` Vì access token trong thực tế khá dài, lại là thông tin mật, chẳng lẽ lúc nào cũng cho hiện chình ình trên màn hình, ta nên lưu lại trong một file *.nu (giống như khi các bạn lưu trong file .env vậy) rồi khi cần làm việc thì nạp lên. Ví dụ file _dev.env.nu_ của mình sẽ như sau: ```nu export const API_URL = 'https://app.birdweather.com/graphql' export const ACCESS_TOKEN = 'abcdef' export const AUTH_HEADER = [Authorization $'Bearer ($ACCESS_TOKEN)'] ``` Mỗi khi bắt đầu làm việc thì nạp nó lên: ```nu use dev.env.nu * ``` ![Load from saved .env.nu](https://quan-images.b-cdn.net/blogs/imgur/2026/EULxiDT.png) Nếu bạn thắc mắc, xài dòng lệnh vậy rồi làm nhiều lần thì cứ phải gõ gõ bàn phím à? Không đâu, Nushell có tính năng tìm kiếm từ lịch sử và auto-complete mà? ![History & autocomplete](https://quan-images.b-cdn.net/blogs/imgur/2026/dgGnUZY.gif) Nushell còn có thể được tận dụng để xử lý hậu kỳ response cho dễ nhìn hơn. Ví dụ ta thấy rằng field `latestDetectionAt` đang trả về thời gian ở múi giờ không giống ta, khiến khó hình dung, ta có thể xử lý thêm để chuyển đổi múi giờ. Đầu tiên, để đỡ phải gọi đi gọi lại API khiến server mỏi mệt, ta lưu response vào một biến `resp`: ![Save response](https://quan-images.b-cdn.net/blogs/imgur/2026/kp9dfQx.png) Xử lý bằng lệnh sau: ```nu $resp | update nodes { update latestDetectionAt { into datetime | date to-timezone local | format date } } ``` ![Convert timezone](https://quan-images.b-cdn.net/blogs/imgur/2026/f0KSD3D.png) Ở đây có chỗ lắt léo là phải dùng hai lần lệnh [`update`](https://www.nushell.sh/commands/docs/update.html), do giá trị của field `nodes` là một danh sách. Lệnh `update` thứ nhất, `update nodes {}`, dùng để tính toán giá trị mới cho `nodes`. Lệnh `update` nếu áp vào một danh sách, nó sẽ tự động áp closure xử lý lên từng phần tử, và đó là lệnh `update` thứ hai, `update latestDetectionAt`. Giá trị ban đầu của field `latestDetectionAt` mà API trả về là kiểu `string`, ta đưa qua `into datetime` để biến thành kiểu [`datetime`](https://www.nushell.sh/lang-guide/chapters/types/basic_types/datetime.html), tiếp đến đưa qua [`date to-timezone`](https://www.nushell.sh/commands/docs/date_to-timezone.html) để chuyển đổi múi giờ, và cuối cùng đưa qua [`format date`](https://www.nushell.sh/commands/docs/format_date.html) để format cho dễ nhìn. Nếu không format thì sẽ ra thế này: ![Without format date](https://quan-images.b-cdn.net/blogs/imgur/2026/jvJDTLL.png) Nếu ta muốn nhìn thấy chuỗi JSON ban đầu của response, không hiện thành bảng thì sao, thêm `--raw` vào lệnh `http`: ```nu ❯ { query: (open --raw queries/stations.gql), variables: { first: 4 } } | http post --raw -t application/json $API_URL {"data":{"stations":{"nodes":[{"id":"4106","name":" PUC-4106 ","type":"puc","country":"Netherlands","latestDetectionAt":"2025-04-09T17:08:47+02:00"},{"id":"11011","name":" Lee’s puc ","type":"puc","country":"Australia","latestDetectionAt":"2025-04-17T06:07:53+10:00"},{"id":"10558","name":" Vancouver Cambie","type":"puc","country":"Canada","latestDetectionAt":"2025-03-30T16:11:11-07:00"},{"id":"10284","name":" .PUC-10284","type":"puc","country":"United States","latestDetectionAt":"2025-04-16T16:07:12-04:00"}],"pageInfo":{"endCursor":"NA","hasNextPage":true,"hasPreviousPage":false,"startCursor":"MQ"},"totalCount":9991}}} ``` Chuỗi JSON bị ép lại, khó nhìn quá, muốn format ngay hàng thẳng lối và tô màu như kết quả của *HTTPie* thì sao? Để hiển thị response ở dạng JSON theo hàng lối thì ta không dùng flag `--raw` mà chỉ cần chuyển đổi từ `table`, sau đó lại truyền qua cho [Pygment](https://pygments.org/styles/) (công cụ bên ngoài, viết bằng Python) để tô màu: ![Highlight JSON response](https://quan-images.b-cdn.net/blogs/imgur/2026/BZdAD2u.png) Ta cũng có thể tô màu bằng [Shiki](https://shiki.style/packages/cli) (cũng là thư viện tô màu mà blog của mình đang sử dụng), công cụ này phân tích cú pháp chi li hơn Pygment, nhưng cách dùng cũng dài hơn cho chưa hỗ trợ đọc file từ _standard input_: ![Highlight with Shiki](https://quan-images.b-cdn.net/blogs/imgur/2026/IYQcb7G.png) Xong. Mình đã giới thiệu vài món đồ chơi. Bây giờ hãy áp dụng thử nhé! Lấy Nushell về tại https://www.nushell.sh/book/installation.html.