Dokumentasi adalah wajah API Anda bagi dunia luar — dan selama dokumentasi itu berbentuk prosa bebas, ia akan berbohong begitu kode berubah. OpenAPI mengatasinya dengan satu dokumen terstruktur yang mendeskripsikan seluruh endpoint, parameter, respons, dan skema data dalam format yang bisa dibaca mesin. Dari dokumen ini lahir dokumentasi interaktif, SDK klien, hingga test sinkronisasi. Pada Chapter ini kita bedah anatomi berkas OpenAPI 3, membandingkan spec tulisan tangan dengan yang dibangkitkan otomatis, lalu menutup dengan test sederhana yang memastikan router dan dokumentasi tidak pernah saling meninggalkan.

Anatomi Berkas OpenAPI #

Dokumen OpenAPI dapat ditulis dalam YAML atau JSON — keduanya sah dan setara. Bagian atas berkas selalu memuat identitas spec dan API yang dideskripsikan:

yaml
openapi: 3.1.0
info:
  title: TaskApp API
  version: 1.0.0
  description: API manajemen task untuk aplikasi TaskApp.
servers:
  - url: https://api.taskapp.example.com
    description: Server produksi
paths:
  /tasks:
    get:
      summary: Daftar task
      responses:
        "200":
          description: Daftar task beserta meta paginasi

Empat bagian teratas punya peran yang tegas:

  1. openapi: versi spesifikasi OpenAPI yang dipakai dokumen — 3.1.0 di sini. Versi spec menentukan fitur bahasa yang boleh dipakai, jadi jangan tertukar dengan versi API Anda sendiri.
  2. info: metadata API — judul, deskripsi, dan version. Nilai version inilah tempat yang tepat untuk menuliskan versi API dari Chapter API Versioning , sehingga kontrak tiap versi terdokumentasi terpisah.
  3. servers: daftar base URL — produksi, staging, atau localhost — tempat path-path di bawahnya ditempelkan.
  4. paths: peta endpoint; setiap path memuat operasi per HTTP method lengkap dengan parameter, body, dan responsnya.

Perhatikan bahwa status code ditulis sebagai string ("200") — dalam JSON, kunci objek memang selalu string, dan YAML mengikuti konvensi yang sama.

Mendokumentasikan Endpoint, Respons, dan Skema #

Operasi yang lebih kaya membutuhkan tiga bagian tambahan: parameter untuk input di URL, requestBody untuk input di body, dan components untuk definisi skema yang bisa dipakai ulang:

yaml
paths:
  /tasks:
    post:
      summary: Buat task baru
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TaskInput"
      responses:
        "201":
          description: Task berhasil dibuat
        "422":
          description: Request tidak lolos validasi
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
  /tasks/{id}:
    get:
      summary: Ambil detail satu task
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Detail task
        "404":
          description: Task tidak ditemukan
components:
  schemas:
    TaskInput:
      type: object
      required: [title]
      properties:
        title:
          type: string
          maxLength: 100
        assignee:
          type: string
          format: email
    ValidationError:
      type: object
      required: [code, message, details]
      properties:
        code:
          type: string
          example: VALIDATION_ERROR
        message:
          type: string
        details:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              rule:
                type: string
              message:
                type: string

Kunci $ref adalah mekanisme pakai-ulang OpenAPI: #/components/schemas/TaskInput menunjuk definisi di dalam dokumen yang sama, sehingga satu skema bisa dipakai di banyak operasi tanpa duplikasi — pola DRY yang sama seperti constant di kode. Parameter path dideklarasikan dengan in: path, sedangkan parameter query dari Chapter Pagination, Filtering, dan Sorting ditandai in: query. Skema ValidationError sengaja menerjemahkan bentuk respons 422 dari Chapter Request Validation — field, rule, message — sehingga konsumen API tahu persis bentuk error yang akan diterimanya.

Spec Tulisan Tangan atau Otomatis #

Ada dua kubu alur kerja: menulis spec lebih dulu dengan tangan, atau membangkitkannya dari kode yang sudah ada. Keduanya sah, dengan konsekuensi berbeda:

AspekTulisan tangan (spec-first)Dibangkitkan dari kode
Kendali bentuk akhirPenuh — spec menjadi kontrak resmi sejak awalMengikuti implementasi apa adanya
Cocok untukAPI publik, tim terpisah frontend-backendPrototype dan proyek internal cepat
Risiko utamaBasi ketika lupa diperbaruiDetail sulit dikontrol, anotasi menyebar di seluruh kode
Alat bantuEditor dan linter specGenerator dari anotasi, mis. swagger-php

Kubu generator memakai alat seperti swagger-php: anotasi pada class dan method dipindai, lalu berkas spec dihasilkan otomatis. Kubu spec-first justru memperlakukan dokumen sebagai sumber kebenaran: spec ditinjau lebih dulu, baru kode mengikutinya. Untuk API yang dikonsumsi pihak luar, spec-first hampir selalu lebih sehat karena kontrak bisa disepakati sebelum satu baris handler ditulis.

Pratinjau dengan Swagger UI dan Redoc #

Spec yang tersimpan sebagai berkas statis bisa langsung ditampilkan menjadi dokumentasi yang rapi. Swagger UI membacanya dari sisi browser lalu merender daftar endpoint yang bisa dikembangkan; Redoc menawarkan tampilan baca-saja yang lebih tenang untuk publikasi. Keduanya hanya butuh satu halaman HTML yang memuat pustaka dari CDN dan menunjuk berkas spec Anda:

html
<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="utf-8">
    <title>Dokumentasi API TaskApp</title>
    <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head>
<body>
    <div id="swagger-ui"></div>
    <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
    <script>
        window.onload = () => SwaggerUIBundle({
            url: "openapi.json",
            dom_id: "#swagger-ui",
        });
    </script>
</body>
</html>

Karena seluruhnya berkas statis, halaman semacam ini bisa dihosting di mana saja tanpa backend PHP tambahan — cukup pastikan openapi.json terbarui setiap kali spec berubah. Kombinasi yang umum: Redoc untuk dokumentasi publik yang rapi, Swagger UI untuk lingkungan development yang butuh eksplorasi cepat.

Validasi Spec dan Alur Contract-First #

Sebelum spec dibaca orang lain, kesalahan strukturnya perlu ditangkap lebih dulu. Swagger Editor menyoroti kesalahan sambil Anda menulis; untuk pemeriksaan berulang di CI, linter seperti Redocly CLI memeriksa spec dari baris perintah dan menolak merge yang melanggar aturan — misalnya operasi tanpa responses, atau penamaan path yang tidak konsisten. Aturan mainnya sama seperti PHP: kesalahan ketik harus ketahuan oleh mesin, bukan oleh pembaca.

Alur contract-first yang sehat berbentuk lingkaran:

  1. Tulis (atau ubah) spec dan tinjau bersama konsumen API — kontrak disepakati sebelum kode.
  2. Validasi spec dengan linter sampai bersih.
  3. Implementasikan router dan handler mengikuti spec.
  4. Jalankan test sinkronisasi spec–router (contoh di bawah) sebagai bagian dari CI.
  5. Terbitkan spec dan halaman pratinjau sebagai dokumentasi resmi.

Menjaga Spec Sinkron dengan Test #

Musuh utama dokumentasi adalah drift: endpoint baru ditambahkan di router, spec tidak ikut diperbarui, dan dokumentasi mulai berbohong pelan-pelan. Pertahanannya adalah test yang membandingkan kedua dunia — daftar route dari router versus daftar operasi dari spec. Berikut versi ringkasnya; spec JSON ditanam sebagai string agar contoh berdiri sendiri (di proyek nyata ia dibaca dari berkas openapi.json di repositori):

php
<?php
$specJson = <<<'JSON'
{
    "openapi": "3.1.0",
    "info": {
        "title": "TaskApp API",
        "version": "1.0.0",
        "description": "API manajemen task untuk aplikasi TaskApp."
    },
    "paths": {
        "/tasks": {
            "get": {
                "summary": "Daftar task",
                "parameters": [
                    {
                        "name": "status",
                        "in": "query",
                        "schema": {"type": "string", "enum": ["todo", "doing", "done"]}
                    },
                    {
                        "name": "page",
                        "in": "query",
                        "schema": {"type": "integer", "minimum": 1, "default": 1}
                    }
                ],
                "responses": {
                    "200": {"description": "Daftar task beserta meta paginasi"}
                }
            },
            "post": {
                "summary": "Buat task baru",
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {"$ref": "#/components/schemas/TaskInput"}
                        }
                    }
                },
                "responses": {
                    "201": {"description": "Task berhasil dibuat"},
                    "422": {"description": "Request tidak lolos validasi"}
                }
            }
        },
        "/tasks/{id}": {
            "get": {
                "summary": "Ambil detail satu task",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "required": true,
                        "schema": {"type": "integer"}
                    }
                ],
                "responses": {
                    "200": {"description": "Detail task"},
                    "404": {"description": "Task tidak ditemukan"}
                }
            }
        }
    },
    "components": {
        "schemas": {
            "TaskInput": {
                "type": "object",
                "required": ["title"],
                "properties": {
                    "title": {"type": "string", "maxLength": 100},
                    "assignee": {"type": "string", "format": "email"}
                }
            }
        }
    }
}
JSON;

$routes = [
    "GET /tasks",
    "POST /tasks",
    "GET /tasks/{id}",
    "DELETE /tasks/{id}",
];

function lintRoutes(string $specJson, array $routes): array
{
    $spec = json_decode($specJson, true);

    if (json_last_error() !== JSON_ERROR_NONE) {
        return ["Spec bukan JSON valid: " . json_last_error_msg()];
    }

    $problems = [];
    $specRoutes = [];

    foreach ($spec["paths"] ?? [] as $path => $operations) {
        foreach ($operations as $method => $operation) {
            if (!is_array($operation)) {
                continue;
            }

            if (!isset($operation["responses"])) {
                $problems[] = "Operasi " . strtoupper($method)
                    . " $path tidak mendokumentasikan responses";
            }

            $specRoutes[] = strtoupper($method) . " " . $path;
        }
    }

    foreach (array_diff($routes, $specRoutes) as $route) {
        $problems[] = "Route $route tidak terdokumentasi di spec";
    }

    foreach (array_diff($specRoutes, $routes) as $route) {
        $problems[] = "Spec mendokumentasikan $route tetapi router tidak memilikinya";
    }

    return $problems;
}

$problems = lintRoutes($specJson, $routes);

echo "Masalah ditemukan: " . count($problems) . "\n";

foreach ($problems as $problem) {
    echo "- $problem\n";
}

Output yang Dihasilkan:

text
Masalah ditemukan: 1
- Route DELETE /tasks/{id} tidak terdokumentasi di spec

Mari kita bedah cara kerjanya:

  1. json_decode + json_last_error(): spec diurai menjadi array asosiatif lebih dulu; jika JSON-nya rusak, fungsi langsung melaporkan masalah parsing tanpa memproses lebih jauh — persis pola dari Chapter Request Parsing .
  2. Loop ganda paths: setiap path memuat operasi per method (get, post, dan kawan-kawan); setiap operasi diubah menjadi kunci "METHOD /path" yang formatnya sama dengan tabel $routes dari Chapter Routing dan Endpoints .
  3. array_diff() dua arah: selisih router−spec menangkap endpoint yang belum terdokumentasi, selisih spec−router menangkap dokumentasi yang menjanjikan endpoint hantu.
  4. Pemeriksaan responses: operasi tanpa blok responses ditandai — kelalaian paling umum saat menulis spec terburu-buru.

Pada contoh ini, route DELETE /tasks/{id} ada di router tetapi belum terdokumentasi, sehingga test melaporkannya. Perbaikannya sederhana: tambahkan operasi delete di bawah /tasks/{id} pada spec, dan test kembali hijau. Bungkus pemanggilan lintRoutes() dalam test PHPUnit yang membaca berkas spec dari repositori, dan setiap perubahan router yang lupa dokumentasi akan menggagalkan build sebelum sempat mencapai produksi. Berkas openapi.json pada bagian bawah halaman berisi spec lengkap yang dipakai contoh ini — unduh, jalankan lewat linter, lalu buka di Swagger UI untuk melihat hasilnya.

Ringkasan #

OpenAPI mengubah dokumentasi dari prosa menjadi kontrak terstruktur: info dan servers memperkenalkan API, paths mendeskripsikan operasi, dan components merangkum skema yang dipakai ulang lewat $ref. Spec-first memberi kendali penuh atas kontrak, pratinjau Swagger UI dan Redoc menghidupkannya, dan test lintRoutes() menjaganya tetap jujur terhadap router. Dengan kontrak yang terdokumentasi dan teruji, API Anda siap memasuki wilayah operasional — pembatasan laju request dan strategi idempotency, yang membangun langsung di atas fondasi section ini.

Referensi #

Berkas Sumber

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

openapi.json

Unduh berkas
json
{
    "openapi": "3.1.0",
    "info": {
        "title": "TaskApp API",
        "version": "1.0.0",
        "description": "API manajemen task untuk aplikasi TaskApp."
    },
    "paths": {
        "/tasks": {
            "get": {
                "summary": "Daftar task",
                "parameters": [
                    {
                        "name": "status",
                        "in": "query",
                        "schema": {
                            "type": "string",
                            "enum": ["todo", "doing", "done"]
                        }
                    },
                    {
                        "name": "page",
                        "in": "query",
                        "schema": {
                            "type": "integer",
                            "minimum": 1,
                            "default": 1
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "Daftar task beserta meta paginasi"
                    }
                }
            },
            "post": {
                "summary": "Buat task baru",
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/TaskInput"
                            }
                        }
                    }
                },
                "responses": {
                    "201": {
                        "description": "Task berhasil dibuat"
                    },
                    "422": {
                        "description": "Request tidak lolos validasi"
                    }
                }
            }
        },
        "/tasks/{id}": {
            "get": {
                "summary": "Ambil detail satu task",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "integer"
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "Detail task"
                    },
                    "404": {
                        "description": "Task tidak ditemukan"
                    }
                }
            }
        }
    },
    "components": {
        "schemas": {
            "TaskInput": {
                "type": "object",
                "required": ["title"],
                "properties": {
                    "title": {
                        "type": "string",
                        "maxLength": 100
                    },
                    "assignee": {
                        "type": "string",
                        "format": "email"
                    }
                }
            }
        }
    }
}