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:
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: stringPotongan 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.
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:
== 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 responsMari kita telaah baris demi baris program di atas:
- Baris 14-21 (
skemaProduk): potongancomponents.schemas.Produkdisalin sebagai peta field ke tipe — bagian dokumen yang paling sering berubah dan paling penting dijaga. - Baris 44-58 (handler):
daftardandetailmemakai satu structprodukTampilyang field-nya sengaja sama dengan skema. - Baris 78-86 (
detailLama): handler ini mensimulasikan server versi lama —hargastring danstokbelum ada — drift yang nyata di lapangan. - Baris 88-104 (
cocokSkema): field diperiksa dalam urutan abjad lewatslices.Sorted(maps.Keys(skema))agar laporan selalu terurut sama; field hilang dan tipe salah dicatat sebagai selisih. - Baris 106-127 (
tipeJSON): type switch menyimpulkan tipe JSON; cabangjson.NumbermemanggilInt64untuk membedakan bilangan bulat dari pecahan. - Baris 152-161 (
decodeJSON):Decoder.UseNumbermenjaga representasi angka — tanpa ini,4500dan4500.5sama-sama menjadifloat64dan pemeriksaan integer menjadi mustahil. - Baris 131-150 (
kirim): setelah mencetak respons, helper langsung memvalidasi body terhadap skema dalam satu fungsi. - Baris 163-173 (
laporkan): laporan tanpa selisih mencetak baris “cocok dengan skema”; sebaliknya setiap selisih dicetak sebagai baris khusus. - 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 #
- Spesifikasi OpenAPI 3.1 — rujukan resmi struktur dokumen.
- Dokumentasi package encoding/json
—
Decoder.UseNumberdanjson.Number. - Dokumentasi package httptest — mengeksekusi handler untuk validasi.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
openapi-demo.go
Unduh berkaspackage 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")
}