karawaci.kode

2026-08-31 · 10 min

OpenAPI 3.1 First: Generate TypeScript Client dari Spec dan Kontrak yang Tidak Drift

API yang ditulis backend dan dikonsumsi frontend seringkali tumbuh dengan cara yang tidak terkontrol. Backend menambah field baru, frontend baru tahu saat undefined muncul di production. Saya menghadapi situasi ini berulang kali di klien Jakarta — spec OpenAPI ada, tapi tidak ada yang benar-benar enforce-nya, jadi ia pelan-pelan jadi dokumen dekoratif yang tidak mencerminkan realitas kode. Solusinya bukan disiplin manual, tapi pipeline yang membuat drift itu secara struktural tidak mungkin terjadi.

Kenapa contract drift terjadi

Cara kerja mayoritas tim yang saya temui: backend menulis endpoint, lalu secara manual mengupdate Swagger UI, atau bahkan tidak update sama sekali karena “nanti dulu”. Frontend melakukan console.log(response) untuk tahu bentuk data yang dikembalikan, lalu mengetik interface TypeScript secara manual berdasarkan apa yang dilihat di Network tab.

Hasilnya adalah dua sumber kebenaran yang divergen — kode backend dan interface TypeScript yang ditulis frontend — dan tidak ada yang memverifikasi keduanya konsisten. Contract drift bukan kegagalan disiplin individu, tapi kegagalan arsitektur.

OpenAPI 3.1 first development membalik urutannya: spec adalah sumber kebenaran tunggal, dan semua artefak lain (TypeScript types, Zod validators, mock server, dokumentasi) di-generate dari spec tersebut. Perubahan API harus lewat perubahan spec dulu.

Setup: spec-first dari hari pertama

Buat file spec di root repository, bukan di dalam direktori service:

project/
├── openapi/
│   ├── openapi.yaml          # spec utama
│   └── schemas/              # $ref ke schema terpisah
│       ├── user.yaml
│       └── payment.yaml
├── apps/
│   ├── api/                  # backend
│   └── web/                  # frontend

Contoh spec minimal yang valid untuk OpenAPI 3.1:

# openapi/openapi.yaml
openapi: "3.1.0"
info:
  title: Payment API
  version: "1.0.0"

paths:
  /payments:
    post:
      operationId: createPayment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePaymentRequest"
      responses:
        "201":
          description: Payment created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Payment"
        "422":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"

components:
  schemas:
    CreatePaymentRequest:
      type: object
      required: [amount, currency, method]
      properties:
        amount:
          type: integer
          minimum: 1000
          description: Amount in IDR (smallest unit, sen)
        currency:
          type: string
          enum: [IDR]
        method:
          type: string
          enum: [QRIS, VA_BNI, VA_BRI, VA_MANDIRI]
        metadata:
          type: object
          additionalProperties:
            type: string

    Payment:
      type: object
      required: [id, status, amount, currency, createdAt]
      properties:
        id:
          type: string
          format: uuid
        status:
          type: ["string", "null"]   # OpenAPI 3.1: null via JSON Schema
          enum: [PENDING, SUCCESS, FAILED, null]
        amount:
          type: integer
        currency:
          type: string
        createdAt:
          type: string
          format: date-time

Perhatikan type: ["string", "null"] — ini sintaks OpenAPI 3.1 (JSON Schema). Di 3.0, Anda perlu nullable: true yang tidak valid di 3.1. Generator yang hanya support 3.0 akan gagal parse ini dengan benar.

Generate TypeScript client dengan hey-api

Install dependensi di monorepo root:

npm install -D @hey-api/openapi-ts @hey-api/client-fetch

Buat konfigurasi generator:

// openapi-ts.config.ts
import { defineConfig } from "@hey-api/openapi-ts";

export default defineConfig({
  input: "./openapi/openapi.yaml",
  output: {
    path: "./apps/web/src/lib/api-client",
    format: "prettier",
    lint: "eslint",
  },
  plugins: [
    "@hey-api/client-fetch",
    {
      name: "@hey-api/schemas",
      type: "json",        // hasilkan JSON schema juga untuk validasi runtime
    },
    {
      name: "@hey-api/transformers",
      dates: true,         // konversi string ISO ke Date object otomatis
    },
  ],
});

Tambahkan script ke package.json:

{
  "scripts": {
    "generate:api": "openapi-ts",
    "prebuild": "npm run generate:api"
  }
}

Jalankan sekali untuk melihat output:

npm run generate:api

Output di apps/web/src/lib/api-client/:

api-client/
├── index.ts
├── schemas.gen.ts      # JSON schema dari spec
├── services.gen.ts     # fungsi per operationId
└── types.gen.ts        # TypeScript types dari spec

Penggunaan di frontend:

import { createPayment } from "@/lib/api-client";

const result = await createPayment({
  body: {
    amount: 50000,
    currency: "IDR",
    method: "QRIS",
  },
});

if (result.error) {
  // result.error bertipe ValidationError — dari spec
  console.error(result.error);
  return;
}

// result.data bertipe Payment — type-safe dari spec
console.log(result.data.id);

Tipe Payment dan ValidationError bukan yang Anda tulis manual — semuanya di-generate dari spec. Kalau backend mengubah shape response tanpa update spec, tipe tidak berubah. Kalau backend mengubah spec, tipe di-regenerate dan TypeScript akan menangkap inkonsistensi di frontend saat compile.

Enforce spec di backend: validasi request/response

Generate types saja tidak cukup. Backend juga harus divalidasi terhadap spec yang sama agar drift tidak terjadi dari arah sebaliknya. Pakai express-openapi-validator:

npm install express-openapi-validator
import OpenApiValidator from "express-openapi-validator";
import path from "path";

app.use(
  OpenApiValidator.middleware({
    apiSpec: path.join(__dirname, "../../../openapi/openapi.yaml"),
    validateRequests: true,
    validateResponses: {
      // aktifkan di dev/staging, matikan di production untuk performa
      onError: process.env.NODE_ENV !== "production"
        ? "throw"
        : "log",
    },
    operationHandlers: false,
  })
);

Dengan konfigurasi ini:

  • Request yang tidak sesuai spec otomatis ditolak dengan 400/422 sebelum menyentuh handler Anda
  • Response yang tidak sesuai spec di-log atau di-throw di non-production environment
  • Middleware membaca spec yang sama dengan yang dipakai frontend untuk generate types

Di production kami, validateResponses diset ke log bukan throw karena menthrow response validation error bisa menyebabkan 500 untuk bug minor, tapi log-nya masuk ke Grafana Loki dan di-alert kalau frekuensinya naik.

Contract testing di CI

Validasi runtime mencegah drift di satu environment. Contract testing di CI memastikan setiap perubahan spec diverifikasi terhadap implementasi nyata sebelum merge.

Gunakan schemathesis — tool Python yang menjalankan property-based testing terhadap API Anda berdasarkan spec:

# .github/workflows/contract-test.yml
name: Contract Tests

on: [pull_request]

jobs:
  contract:
    runs-on: ubuntu-latest
    services:
      api:
        image: ghcr.io/yourorg/payment-api:${{ github.sha }}
        ports:
          - 3000:3000

    steps:
      - uses: actions/checkout@v4

      - name: Install schemathesis
        run: pip install schemathesis

      - name: Run contract tests
        run: |
          schemathesis run \
            ./openapi/openapi.yaml \
            --base-url http://localhost:3000 \
            --checks all \
            --stateful=links \
            --report=junit \
            --report-path=contract-results.xml

      - name: Upload results
        uses: actions/upload-artifact@v4
        with:
          name: contract-test-results
          path: contract-results.xml

schemathesis secara otomatis:

  • Generate request yang valid dan edge-case dari spec Anda
  • Memverifikasi status code dan response schema cocok
  • Mencari anomali seperti 500 yang tidak terdokumentasi di spec

Kalau ada endpoint yang mengembalikan field yang tidak ada di spec, atau mengembalikan status code 500 untuk input yang valid, CI gagal dan merge diblokir.

Pipeline lengkap: satu perintah, semua terbarui

Di package.json monorepo:

{
  "scripts": {
    "generate:api": "openapi-ts",
    "validate:spec": "openapi-format --lint openapi/openapi.yaml",
    "test:contract": "schemathesis run openapi/openapi.yaml --base-url $API_URL --checks all",
    "prebuild": "npm run validate:spec && npm run generate:api"
  }
}

Alur yang terbentuk:

  1. Engineer mengubah openapi/openapi.yaml (perubahan API wajib lewat sini)
  2. prebuild jalankan lint spec dan regenerate client otomatis
  3. TypeScript compiler menangkap inkonsistensi di frontend/backend saat compile
  4. CI jalankan schemathesis untuk memverifikasi implementasi backend sesuai spec
  5. Merge hanya bisa kalau semua layer hijau

Trade-off yang perlu diakui

Pendekatan ini bukan tanpa ongkos. Tiga hal yang perlu disadari sebelum commit:

Learning curve pada tim yang terbiasa code-first. Engineer backend yang terbiasa menulis kode lalu “otomatis” expose Swagger dari decorator perlu membiasakan diri menulis spec YAML dulu. Dua minggu pertama, sering terjadi spec ditulis setelah kode — yang mengalahkan tujuan utama pendekatan ini.

Spec bisa jadi bottleneck kalau tidak ada tooling yang baik. Kalau menulis YAML terasa berat, investasikan di editor support (Redocly VS Code extension) dan spec linter. Spec yang sulit ditulis mendorong engineer untuk skip prosesnya.

Generated code tidak boleh diedit manual. Semua file di api-client/ harus dianggap immutable. Ini kadang menimbulkan gesekan saat engineer ingin “sedikit” memodifikasi generated helper. Solusinya adalah buat wrapper di luar direktori generated, bukan edit file generated.

Untuk API internal yang hanya dikonsumsi satu tim, overhead ini mungkin terlalu besar. Spec-first paling valuable kalau API dikonsumsi lebih dari satu tim, atau ada klien eksternal, atau tim frontend dan backend bekerja paralel.

Verdict

Kalau API Anda dikonsumsi oleh lebih dari satu consumer, atau frontend dan backend bergerak dengan velocity berbeda, OpenAPI 3.1 first dengan hey-api untuk generate TypeScript client dan express-openapi-validator untuk enforce spec di runtime adalah kombinasi yang solid di 2026.

Satu kondisi: ini hanya berhasil kalau tim benar-benar commit pada “spec dulu”. Kalau spec ditulis setelah kode atau hanya diupdate kalau sempat, Anda tidak mendapat spec-first — Anda hanya mendapat spec yang selalu terlambat. Dalam kasus itu, lebih jujur pakai zod-to-openapi untuk generate spec dari Zod schema yang sudah Anda tulis, daripada pura-pura melakukan spec-first tapi tidak benar-benar melakukannya.

Contract drift adalah masalah proses yang diselesaikan dengan arsitektur, bukan dengan harapan bahwa semua orang akan selalu ingat untuk update spec manual.

Ditulis oleh Reza Pradipta