Seluruh kontrak yang kita bangun sejauh ini — path, parameter, status, bentuk respons, struktur galat — hanya hidup di kepala penulis dan di dalam kode. Klien pihak lain harus membaca kode atau bertanya untuk tahu cara memakai API kita. OpenAPI memindahkan kontrak itu ke satu dokumen terstruktur yang bisa dibaca manusia sekaligus diproses mesin: alat dokumentasi, generator klien, dan validator semuanya bertumpu pada dokumen yang sama. Pada chapter ini kita menulis dokumen minimal, lalu membangun validator mini yang membuktikan respons server masih cocok dengan skema yang dijanjikan dokumen.

Apa Itu OpenAPI #

OpenAPI adalah spesifikasi format dokumen untuk mendeskripsikan REST API. Dokumen OpenAPI ditulis dalam YAML atau JSON, memuat daftar path beserta operasinya, skema data yang dikirim dan diterima, serta respons yang mungkin muncul. Nilainya bukan sekadar dokumentasi: dokumen ini menjadi kontrak yang bisa diverifikasi otomatis, bahan baku pembangkit kode klien untuk berbagai bahasa, dan sumber tampilan dokumentasi interaktif seperti Swagger UI.

Struktur Dokumen Minimal #

Dokumen terkecil yang berguna memuat empat bagian: openapi (versi spesifikasi), info (nama dan versi API), paths (operasi per endpoint), serta components.schemas (definisi bentuk data yang bisa dirujuk ulang). Berikut dokumen untuk API produk yang kita pakai sepanjang section ini:

yaml
openapi: 3.1.0
info:
  title: API Katalog Produk
  version: 1.0.0
paths:
  /produk:
    get:
      operationId: daftarProduk
      responses:
        "200":
          description: Daftar seluruh produk
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Produk"
  /produk/{id}:
    get:
      operationId: detailProduk
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Satu produk
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Produk"
        "404":
          description: Produk tidak ditemukan
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Galat"
components:
  schemas:
    Produk:
      type: object
      required: [id, nama, harga, stok]
      properties:
        id:
          type: integer
        nama:
          type: string
        harga:
          type: integer
        stok:
          type: integer
    Galat:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string

Potongan components.schemas.Produk menjanjikan empat field bertipe integer dan string dengan required yang eksplisit — daftar inilah yang dibandingkan program kita terhadap respons nyata. Rujukan $ref menghubungkan paths dengan components sehingga satu definisi dipakai ulang.

Menjaga Respons Cocok dengan Skema #

Dokumen yang melenceng dari perilaku server lebih berbahaya daripada tanpa dokumen, karena klien percaya pada janji yang salah — kondisi ini disebut documentation drift. Pemeriksaan sederhana bisa menjaganya: jalankan handler sungguhan, decode body respons, lalu bandingkan tiap field terhadap skema. Program kita menyalin components.schemas.Produk menjadi map skemaProduk, dan memakai json.Number (lewat Decoder.UseNumber) agar tipe angka tidak lenyap menjadi float64 saat decode.

Ekosistem Tooling #

Karena dokumennya terstandar, ekosistemnya kaya. Swagger UI merender dokumen menjadi halaman interaktif tempat pemanggil mencoba endpoint langsung dari browser. Generator seperti oapi-codegen membangkitkan kode Go — tipe, handler stub, hingga server mux — dari dokumen, membalik arah kerja: skema ditulis dulu, kode mengikuti.

Program: Validator Mini Skema #

Program openapi-demo.go menjalankan dua server: server baru yang responsnya sesuai skema, dan server lama yang masih mengirim harga sebagai string tanpa field stok. Validator memeriksa setiap respons terhadap skemaProduk dan melaporkan selisihnya.

go
package main

import (
	"encoding/json"
	"fmt"
	"maps"
	"net/http"
	"net/http/httptest"
	"slices"
	"strconv"
	"strings"
)

// skemaProduk menyalin components.schemas.Produk dari dokumen OpenAPI:
// peta field ke tipe JSON yang dijanjikan kepada seluruh klien.
var skemaProduk = map[string]string{
	"id":    "integer",
	"nama":  "string",
	"harga": "integer",
	"stok":  "integer",
}

// Produk adalah data yang disimpan server.
type Produk struct {
	ID    int
	Nama  string
	Harga int
	Stok  int
}

// produkTampil adalah representasi JSON satu produk.
type produkTampil struct {
	ID    int    `json:"id"`
	Nama  string `json:"nama"`
	Harga int    `json:"harga"`
	Stok  int    `json:"stok"`
}

// Handler melayani daftar dan detail produk dari data di memori.
type Handler struct {
	data []Produk
}

func (h *Handler) routes() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /produk", h.daftar)
	mux.HandleFunc("GET /produk/{id}", h.detail)
	return mux
}

// daftar mengirim seluruh produk sebagai array datar.
func (h *Handler) daftar(w http.ResponseWriter, r *http.Request) {
	daftar := make([]produkTampil, 0, len(h.data))
	for _, p := range h.data {
		daftar = append(daftar, produkTampil{ID: p.ID, Nama: p.Nama, Harga: p.Harga, Stok: p.Stok})
	}
	tulisJSON(w, http.StatusOK, daftar)
}

// detail mengirim satu produk sesuai skema terdokumentasi.
func (h *Handler) detail(w http.ResponseWriter, r *http.Request) {
	nomor, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		tulisGalat(w, http.StatusBadRequest, "INVALID_ID", "id harus angka")
		return
	}
	for _, p := range h.data {
		if p.ID == nomor {
			tulisJSON(w, http.StatusOK,
				produkTampil{ID: p.ID, Nama: p.Nama, Harga: p.Harga, Stok: p.Stok})
			return
		}
	}
	tulisGalat(w, http.StatusNotFound, "NOT_FOUND",
		fmt.Sprintf("produk %d tidak ditemukan", nomor))
}

// detailLama mensimulasikan server versi lama yang masih beredar:
// harga dikirim sebagai string dan field stok belum ada.
func detailLama(w http.ResponseWriter, r *http.Request) {
	tulisJSON(w, http.StatusOK, map[string]any{
		"id":    1,
		"nama":  "Buku Tulis",
		"harga": "4500",
	})
}

// cocokSkema memeriksa satu objek respons terhadap skema dokumen.
// Field diperiksa dalam urutan abjad agar laporan selalu sama urutannya.
func cocokSkema(objek map[string]any, skema map[string]string) []string {
	var selisih []string
	for _, nama := range slices.Sorted(maps.Keys(skema)) {
		nilai, ada := objek[nama]
		if !ada {
			selisih = append(selisih, fmt.Sprintf("field %s: hilang dari respons", nama))
			continue
		}
		if tipeJSON(nilai) != skema[nama] {
			selisih = append(selisih, fmt.Sprintf("field %s: skema %s, respons %s",
				nama, skema[nama], tipeJSON(nilai)))
		}
	}
	return selisih
}

// tipeJSON menyimpulkan nama tipe dari nilai hasil decode memakai json.Number
// agar tipe angka tidak lenyap menjadi float64.
func tipeJSON(nilai any) string {
	switch v := nilai.(type) {
	case json.Number:
		if _, err := v.Int64(); err == nil {
			return "integer"
		}
		return "number"
	case string:
		return "string"
	case bool:
		return "boolean"
	case []any:
		return "array"
	case map[string]any:
		return "object"
	case nil:
		return "null"
	}
	return "unknown"
}

// kirim mengeksekusi permintaan, mencetak respons, lalu membandingkan
// respons dengan skema — validator mini antara dokumen dan perilaku nyata.
func kirim(server http.Handler, metode, target string) {
	req := httptest.NewRequest(metode, target, nil)
	rec := httptest.NewRecorder()
	server.ServeHTTP(rec, req)
	fmt.Printf("%-5s %-12s -> %d\n", metode, target, rec.Code)
	fmt.Printf("   %s", rec.Body.String())
	switch v := decodeJSON(rec.Body.String()).(type) {
	case []any:
		for i, item := range v {
			objek, ok := item.(map[string]any)
			if !ok {
				fmt.Printf("   item %d: bukan objek\n", i+1)
				continue
			}
			laporkan(fmt.Sprintf("item %d", i+1), cocokSkema(objek, skemaProduk))
		}
	case map[string]any:
		laporkan("objek", cocokSkema(v, skemaProduk))
	}
}

// decodeJSON mengubah body respons menjadi nilai Go memakai json.Number.
func decodeJSON(body string) any {
	dec := json.NewDecoder(strings.NewReader(body))
	dec.UseNumber()
	var v any
	if err := dec.Decode(&v); err != nil {
		return nil
	}
	return v
}

// laporkan mencetak hasil pemeriksaan satu objek atau item.
func laporkan(nama string, selisih []string) {
	if len(selisih) == 0 {
		fmt.Printf("   %s: cocok dengan skema\n", nama)
		return
	}
	fmt.Printf("   %s: %d selisih dengan skema\n", nama, len(selisih))
	for _, s := range selisih {
		fmt.Printf("   - %s\n", s)
	}
}

type galatBody struct {
	Error galatRincian `json:"error"`
}

type galatRincian struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

func tulisGalat(w http.ResponseWriter, status int, kode, pesan string) {
	tulisJSON(w, status, galatBody{Error: galatRincian{Code: kode, Message: pesan}})
}

func tulisJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func main() {
	h := &Handler{data: []Produk{
		{ID: 1, Nama: "Buku Tulis", Harga: 4500, Stok: 12},
		{ID: 2, Nama: "Pensil", Harga: 2500, Stok: 40},
		{ID: 3, Nama: "Penghapus", Harga: 1500, Stok: 25},
	}}
	muxBaru := h.routes()

	// Server lama: deployment yang responsnya melenceng dari dokumentasi.
	muxLama := http.NewServeMux()
	muxLama.HandleFunc("GET /produk/{id}", detailLama)

	fmt.Println("== server baru: respons dibandingkan dengan skema ==")
	kirim(muxBaru, "GET", "/produk")
	kirim(muxBaru, "GET", "/produk/1")

	fmt.Println("== server lama: dokumentasi dan perilaku tidak lagi cocok ==")
	kirim(muxLama, "GET", "/produk/1")
}

Output yang Dihasilkan:

text
== server baru: respons dibandingkan dengan skema ==
GET   /produk      -> 200
   [{"id":1,"nama":"Buku Tulis","harga":4500,"stok":12},{"id":2,"nama":"Pensil","harga":2500,"stok":40},{"id":3,"nama":"Penghapus","harga":1500,"stok":25}]
   item 1: cocok dengan skema
   item 2: cocok dengan skema
   item 3: cocok dengan skema
GET   /produk/1    -> 200
   {"id":1,"nama":"Buku Tulis","harga":4500,"stok":12}
   objek: cocok dengan skema
== server lama: dokumentasi dan perilaku tidak lagi cocok ==
GET   /produk/1    -> 200
   {"harga":"4500","id":1,"nama":"Buku Tulis"}
   objek: 2 selisih dengan skema
   - field harga: skema integer, respons string
   - field stok: hilang dari respons

Mari kita telaah baris demi baris program di atas:

  1. Baris 14-21 (skemaProduk): potongan components.schemas.Produk disalin sebagai peta field ke tipe — bagian dokumen yang paling sering berubah dan paling penting dijaga.
  2. Baris 44-58 (handler): daftar dan detail memakai satu struct produkTampil yang field-nya sengaja sama dengan skema.
  3. Baris 78-86 (detailLama): handler ini mensimulasikan server versi lama — harga string dan stok belum ada — drift yang nyata di lapangan.
  4. Baris 88-104 (cocokSkema): field diperiksa dalam urutan abjad lewat slices.Sorted(maps.Keys(skema)) agar laporan selalu terurut sama; field hilang dan tipe salah dicatat sebagai selisih.
  5. Baris 106-127 (tipeJSON): type switch menyimpulkan tipe JSON; cabang json.Number memanggil Int64 untuk membedakan bilangan bulat dari pecahan.
  6. Baris 152-161 (decodeJSON): Decoder.UseNumber menjaga representasi angka — tanpa ini, 4500 dan 4500.5 sama-sama menjadi float64 dan pemeriksaan integer menjadi mustahil.
  7. Baris 131-150 (kirim): setelah mencetak respons, helper langsung memvalidasi body terhadap skema dalam satu fungsi.
  8. Baris 163-173 (laporkan): laporan tanpa selisih mencetak baris “cocok dengan skema”; sebaliknya setiap selisih dicetak sebagai baris khusus.
  9. Baris 194-212 (main): dua mux mewakili dua deployment — server baru lulus semua pemeriksaan, server lama dilaporkan dua selisih.

Output bagian pertama menegaskan kontrak terpenuhi; bagian kedua menangkap drift persis seperti yang dijanjikan peringatan di atas — respons 200 yang tampak sehat, namun melanggar dokumen. Kontrak yang terjaga membuat API mudah dipakai pihak lain; sisa terakhir dari pengalaman pemanggil adalah cara kita gagal secara santun — desain error menjadi chapter penutup section ini.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

openapi-demo.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"fmt"
	"maps"
	"net/http"
	"net/http/httptest"
	"slices"
	"strconv"
	"strings"
)

// skemaProduk menyalin components.schemas.Produk dari dokumen OpenAPI:
// peta field ke tipe JSON yang dijanjikan kepada seluruh klien.
var skemaProduk = map[string]string{
	"id":    "integer",
	"nama":  "string",
	"harga": "integer",
	"stok":  "integer",
}

// Produk adalah data yang disimpan server.
type Produk struct {
	ID    int
	Nama  string
	Harga int
	Stok  int
}

// produkTampil adalah representasi JSON satu produk.
type produkTampil struct {
	ID    int    `json:"id"`
	Nama  string `json:"nama"`
	Harga int    `json:"harga"`
	Stok  int    `json:"stok"`
}

// Handler melayani daftar dan detail produk dari data di memori.
type Handler struct {
	data []Produk
}

func (h *Handler) routes() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /produk", h.daftar)
	mux.HandleFunc("GET /produk/{id}", h.detail)
	return mux
}

// daftar mengirim seluruh produk sebagai array datar.
func (h *Handler) daftar(w http.ResponseWriter, r *http.Request) {
	daftar := make([]produkTampil, 0, len(h.data))
	for _, p := range h.data {
		daftar = append(daftar, produkTampil{ID: p.ID, Nama: p.Nama, Harga: p.Harga, Stok: p.Stok})
	}
	tulisJSON(w, http.StatusOK, daftar)
}

// detail mengirim satu produk sesuai skema terdokumentasi.
func (h *Handler) detail(w http.ResponseWriter, r *http.Request) {
	nomor, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		tulisGalat(w, http.StatusBadRequest, "INVALID_ID", "id harus angka")
		return
	}
	for _, p := range h.data {
		if p.ID == nomor {
			tulisJSON(w, http.StatusOK,
				produkTampil{ID: p.ID, Nama: p.Nama, Harga: p.Harga, Stok: p.Stok})
			return
		}
	}
	tulisGalat(w, http.StatusNotFound, "NOT_FOUND",
		fmt.Sprintf("produk %d tidak ditemukan", nomor))
}

// detailLama mensimulasikan server versi lama yang masih beredar:
// harga dikirim sebagai string dan field stok belum ada.
func detailLama(w http.ResponseWriter, r *http.Request) {
	tulisJSON(w, http.StatusOK, map[string]any{
		"id":    1,
		"nama":  "Buku Tulis",
		"harga": "4500",
	})
}

// cocokSkema memeriksa satu objek respons terhadap skema dokumen.
// Field diperiksa dalam urutan abjad agar laporan selalu sama urutannya.
func cocokSkema(objek map[string]any, skema map[string]string) []string {
	var selisih []string
	for _, nama := range slices.Sorted(maps.Keys(skema)) {
		nilai, ada := objek[nama]
		if !ada {
			selisih = append(selisih, fmt.Sprintf("field %s: hilang dari respons", nama))
			continue
		}
		if tipeJSON(nilai) != skema[nama] {
			selisih = append(selisih, fmt.Sprintf("field %s: skema %s, respons %s",
				nama, skema[nama], tipeJSON(nilai)))
		}
	}
	return selisih
}

// tipeJSON menyimpulkan nama tipe dari nilai hasil decode memakai json.Number
// agar tipe angka tidak lenyap menjadi float64.
func tipeJSON(nilai any) string {
	switch v := nilai.(type) {
	case json.Number:
		if _, err := v.Int64(); err == nil {
			return "integer"
		}
		return "number"
	case string:
		return "string"
	case bool:
		return "boolean"
	case []any:
		return "array"
	case map[string]any:
		return "object"
	case nil:
		return "null"
	}
	return "unknown"
}

// kirim mengeksekusi permintaan, mencetak respons, lalu membandingkan
// respons dengan skema — validator mini antara dokumen dan perilaku nyata.
func kirim(server http.Handler, metode, target string) {
	req := httptest.NewRequest(metode, target, nil)
	rec := httptest.NewRecorder()
	server.ServeHTTP(rec, req)
	fmt.Printf("%-5s %-12s -> %d\n", metode, target, rec.Code)
	fmt.Printf("   %s", rec.Body.String())
	switch v := decodeJSON(rec.Body.String()).(type) {
	case []any:
		for i, item := range v {
			objek, ok := item.(map[string]any)
			if !ok {
				fmt.Printf("   item %d: bukan objek\n", i+1)
				continue
			}
			laporkan(fmt.Sprintf("item %d", i+1), cocokSkema(objek, skemaProduk))
		}
	case map[string]any:
		laporkan("objek", cocokSkema(v, skemaProduk))
	}
}

// decodeJSON mengubah body respons menjadi nilai Go memakai json.Number.
func decodeJSON(body string) any {
	dec := json.NewDecoder(strings.NewReader(body))
	dec.UseNumber()
	var v any
	if err := dec.Decode(&v); err != nil {
		return nil
	}
	return v
}

// laporkan mencetak hasil pemeriksaan satu objek atau item.
func laporkan(nama string, selisih []string) {
	if len(selisih) == 0 {
		fmt.Printf("   %s: cocok dengan skema\n", nama)
		return
	}
	fmt.Printf("   %s: %d selisih dengan skema\n", nama, len(selisih))
	for _, s := range selisih {
		fmt.Printf("   - %s\n", s)
	}
}

type galatBody struct {
	Error galatRincian `json:"error"`
}

type galatRincian struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

func tulisGalat(w http.ResponseWriter, status int, kode, pesan string) {
	tulisJSON(w, status, galatBody{Error: galatRincian{Code: kode, Message: pesan}})
}

func tulisJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func main() {
	h := &Handler{data: []Produk{
		{ID: 1, Nama: "Buku Tulis", Harga: 4500, Stok: 12},
		{ID: 2, Nama: "Pensil", Harga: 2500, Stok: 40},
		{ID: 3, Nama: "Penghapus", Harga: 1500, Stok: 25},
	}}
	muxBaru := h.routes()

	// Server lama: deployment yang responsnya melenceng dari dokumentasi.
	muxLama := http.NewServeMux()
	muxLama.HandleFunc("GET /produk/{id}", detailLama)

	fmt.Println("== server baru: respons dibandingkan dengan skema ==")
	kirim(muxBaru, "GET", "/produk")
	kirim(muxBaru, "GET", "/produk/1")

	fmt.Println("== server lama: dokumentasi dan perilaku tidak lagi cocok ==")
	kirim(muxLama, "GET", "/produk/1")
}