Chuỗi: cli-to-agent-native · Phần 4
Năng suất & công cụ dev
Từ API spec đến structured metadata: nguồn sự thật cho code generation
Khi bạn có 439 API endpoints, viết tay adapter mất tuần. Extract API spec thành structured JSON metadata trước — nguồn sự thật cho toàn bộ code generation pipeline.
2026-03-1710 phút đọcVI
- 1.Sự tiến hóa giao diện: GUI → CLI → API → Agent
- 2.Giải phẫu CLI cho agent: 7 nguyên tắc thiết kế
- 3.API trước, CLI sau: Adapter Pattern — tầng trung gian tạo nên CLI chuyên nghiệp
- 4.Từ API spec đến structured metadata: nguồn sự thật cho code generation(bài này)
- 5.Code Generation Pipeline: Từ metadata JSON đến 16,000 dòng code trong vài giây
- 6.Test generated code: 4 chiến lược cho code mà không ai viết tay
- 7.Biến CLI thành MCP Server: agent tự discover và invoke tool của bạn
- 8.Agent Skills: giảm 99.6% token cost so với MCP
- 9.Progressive Enhancement: 5 levels từ raw API đến agent-native — khi nào dùng level nào
- 10.End-to-End Build: Từ 439 API endpoints đến agent-ready CLI — case study: một procurement CLI
English title: From API Spec to Structured Metadata
Mở đầu
Năm 2023, khi Stripe ra mắt Stripe CLI v2, họ tiết lộ một chi tiết thú vị trong engineering blog: toàn bộ CLI không được viết tay. Stripe API có hơn 200 resources, mỗi resource có 4-6 operations. Thay vì viết từng command, team họ build một pipeline: OpenAPI spec → metadata JSON → generate commands.
Thay đổi trong API? Chạy lại pipeline. 200 commands cập nhật trong vài giây.
Bài này đi vào bước đầu tiên của pipeline đó: extract API spec thành structured metadata. Đây là bước quan trọng nhất vì metadata là nguồn sự thật cho mọi thứ còn lại — adapter, CLI command, test, documentation.
1. Ba nguồn của API spec
Không phải API nào cũng có OpenAPI spec. Trong thực tế, bạn gặp ba trường hợp:
Trường hợp 1: OpenAPI/Swagger spec có sẵn (tốt nhất)
# Download spec
curl https://api.example.com/openapi.json -o openapi.json
# Hoặc từ GitHub
curl https://raw.githubusercontent.com/example/api/main/openapi.yaml -o openapi.yamlTrường hợp 2: Python SDK có sẵn (phổ biến với internal tools)
SDK source code là structured — class names, method names, docstrings, type annotations. Parser Python AST đọc được.
Trường hợp 3: Chỉ có HTML documentation (khó nhất)
HTML docs scraped, parsed, normalized. Tốn công hơn nhưng khả thi.
Bài này tập trung vào Trường hợp 1 (OpenAPI) và Trường hợp 2 (Python SDK), vì đây là hai trường hợp phổ biến nhất trong enterprise context.
2. Schema của metadata document
Trước khi extract, define schema. Metadata của mỗi API method trông như thế này:
{
"methods": [
{
"id": "user.list",
"resource": "user",
"action": "list",
"cli_command": "list-users",
"http_method": "GET",
"path": "/v1/users",
"summary": "List all users",
"description": "Returns a paginated list of users. Supports filtering by role.",
"parameters": [
{
"name": "role",
"in": "query",
"type": "string",
"required": false,
"enum": ["admin", "member", "viewer"],
"description": "Filter by user role"
},
{
"name": "limit",
"in": "query",
"type": "integer",
"required": false,
"default": 100,
"description": "Maximum number of results"
}
],
"returns": {
"type": "array",
"items": {
"$ref": "#/definitions/User"
}
},
"paginated": true,
"idempotent": true,
"requires_auth": true,
"tags": ["users", "read"]
}
],
"definitions": {
"User": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"email": {"type": "string", "format": "email"},
"role": {"type": "string", "enum": ["admin", "member", "viewer"]},
"created_at": {"type": "string", "format": "date-time"}
},
"required": ["id", "name", "email"]
}
},
"meta": {
"api_version": "v1",
"base_url": "https://api.example.com",
"auth_type": "bearer",
"extracted_at": "2026-03-17T10:00:00Z",
"total_methods": 439
}
}Fields quan trọng nhất:
id: Unique identifier, format{resource}.{action}— dùng để generate adapter method namecli_command: Kebab-case command name cho CLIpaginated: Boolean — generator sẽ thêm pagination logic vào adapter nếutrueidempotent: Boolean — generator sẽ generateensure_*variant nếutrue
3. Extract từ OpenAPI spec
OpenAPI 3.x là structured YAML/JSON — parse thẳng:
# tools/extract_openapi.py
import json
import yaml
import re
from pathlib import Path
from datetime import datetime, timezone
def load_spec(path: str) -> dict:
"""Load OpenAPI spec từ YAML hoặc JSON."""
content = Path(path).read_text()
if path.endswith(".yaml") or path.endswith(".yml"):
return yaml.safe_load(content)
return json.loads(content)
def path_to_resource_action(path: str, method: str) -> tuple[str, str]:
"""
Convert OpenAPI path + HTTP method → (resource, action).
/users GET → (user, list)
/users POST → (user, create)
/users/{id} GET → (user, get)
/users/{id} PUT → (user, update)
/users/{id} DELETE → (user, delete)
"""
# Remove path params for analysis
clean = re.sub(r"/\{[^}]+\}", "", path).strip("/")
parts = clean.split("/")
resource = parts[-1].rstrip("s") # Singularize: users → user
method_map = {
("get", False): "list", # GET /users → list
("get", True): "get", # GET /users/{id} → get
("post", False): "create",
("put", True): "update",
("patch", True): "update",
("delete", True): "delete",
}
has_id = bool(re.search(r"/\{[^}]+\}$", path))
action = method_map.get((method.lower(), has_id), method.lower())
return resource, action
def extract_parameters(operation: dict, path_params: list) -> list[dict]:
"""Extract parameters từ operation."""
params = []
# Path parameters
for p in path_params:
params.append({
"name": p.get("name"),
"in": "path",
"type": p.get("schema", {}).get("type", "string"),
"required": True,
"description": p.get("description", ""),
})
# Query/header parameters
for p in operation.get("parameters", []):
schema = p.get("schema", {})
param = {
"name": p.get("name"),
"in": p.get("in", "query"),
"type": schema.get("type", "string"),
"required": p.get("required", False),
"description": p.get("description", ""),
}
if "enum" in schema:
param["enum"] = schema["enum"]
if "default" in schema:
param["default"] = schema["default"]
params.append(param)
# Request body parameters (POST/PUT)
body = operation.get("requestBody", {})
if body:
content = body.get("content", {}).get("application/json", {})
body_schema = content.get("schema", {})
for prop_name, prop_schema in body_schema.get("properties", {}).items():
required_props = body_schema.get("required", [])
param = {
"name": prop_name,
"in": "body",
"type": prop_schema.get("type", "string"),
"required": prop_name in required_props,
"description": prop_schema.get("description", ""),
}
if "enum" in prop_schema:
param["enum"] = prop_schema["enum"]
params.append(param)
return params
def action_to_cli_command(resource: str, action: str) -> str:
"""(user, list) → list-users"""
if action == "list":
return f"list-{resource}s"
if action == "create":
return f"create-{resource}"
if action == "get":
return f"get-{resource}"
if action == "update":
return f"update-{resource}"
if action == "delete":
return f"delete-{resource}"
return f"{action}-{resource}"
def extract_from_openapi(spec_path: str, output_path: str):
"""Main extractor: OpenAPI spec → metadata JSON."""
spec = load_spec(spec_path)
methods = []
definitions = {}
# Extract component schemas
components = spec.get("components", {})
for name, schema in components.get("schemas", {}).items():
definitions[name] = schema
# Extract paths
for path, path_item in spec.get("paths", {}).items():
path_params = path_item.get("parameters", [])
for http_method, operation in path_item.items():
if http_method in ("parameters", "summary", "description"):
continue
if not isinstance(operation, dict):
continue
resource, action = path_to_resource_action(path, http_method)
method_id = f"{resource}.{action}"
cli_command = action_to_cli_command(resource, action)
# Detect pagination
response_200 = operation.get("responses", {}).get("200", {})
response_content = response_200.get("content", {}).get("application/json", {})
response_schema = response_content.get("schema", {})
paginated = "has_next_page" in str(response_schema) or "next_cursor" in str(response_schema)
# Detect idempotency
idempotent = http_method.lower() in ("get", "put") or action in ("list", "get", "update")
method = {
"id": method_id,
"resource": resource,
"action": action,
"cli_command": cli_command,
"http_method": http_method.upper(),
"path": path,
"summary": operation.get("summary", ""),
"description": operation.get("description", ""),
"parameters": extract_parameters(operation, path_params),
"paginated": paginated,
"idempotent": idempotent,
"requires_auth": bool(operation.get("security") or spec.get("security")),
"tags": operation.get("tags", [resource]),
}
methods.append(method)
metadata = {
"methods": methods,
"definitions": definitions,
"meta": {
"api_version": spec.get("info", {}).get("version", "v1"),
"base_url": next(
(s.get("url", "") for s in spec.get("servers", [])), ""
),
"auth_type": "bearer",
"extracted_at": datetime.now(timezone.utc).isoformat(),
"total_methods": len(methods),
}
}
Path(output_path).write_text(json.dumps(metadata, indent=2))
print(f"✓ Extracted {len(methods)} methods → {output_path}")
if __name__ == "__main__":
import sys
extract_from_openapi(sys.argv[1], sys.argv[2])Chạy:
python tools/extract_openapi.py openapi.yaml metadata/api-metadata.json
# ✓ Extracted 439 methods → metadata/api-metadata.json4. Extract từ Python SDK
Khi không có OpenAPI spec, Python SDK source là nguồn tốt nhất. Python AST (Abstract Syntax Tree) đọc code như structured data:
# tools/extract_sdk.py
import ast
import json
import inspect
import importlib
from pathlib import Path
from datetime import datetime, timezone
def extract_type_annotation(annotation) -> str:
"""Convert Python type annotation → JSON type string."""
if annotation is None:
return "string"
ann_str = str(annotation)
type_map = {
"int": "integer", "float": "number", "bool": "boolean",
"str": "string", "list": "array", "dict": "object",
"None": "null",
}
for py_type, json_type in type_map.items():
if py_type in ann_str:
return json_type
return "string"
def extract_methods_from_class(cls) -> list[dict]:
"""Extract public methods từ một class dùng inspect."""
methods = []
for name, method in inspect.getmembers(cls, predicate=inspect.isfunction):
if name.startswith("_"):
continue
sig = inspect.signature(method)
docstring = inspect.getdoc(method) or ""
# Parse parameters từ signature
params = []
for param_name, param in sig.parameters.items():
if param_name in ("self", "cls"):
continue
param_info = {
"name": param_name,
"in": "body",
"type": extract_type_annotation(param.annotation),
"required": param.default is inspect.Parameter.empty,
"description": "",
}
params.append(param_info)
# Infer resource và action từ method name
# list_users → resource=user, action=list
parts = name.split("_", 1)
if len(parts) == 2:
action, resource = parts[0], parts[1].rstrip("s")
else:
action, resource = name, "resource"
# Infer CLI command
cli_command = f"{action}-{resource}s" if action == "list" else f"{action}-{resource}"
method_info = {
"id": f"{resource}.{action}",
"resource": resource,
"action": action,
"cli_command": cli_command.replace("_", "-"),
"http_method": "GET" if action in ("list", "get") else "POST",
"path": f"/{resource}s",
"summary": docstring.split("\n")[0] if docstring else name,
"description": docstring,
"parameters": params,
"paginated": action == "list",
"idempotent": action in ("list", "get"),
"requires_auth": True,
"tags": [resource],
}
methods.append(method_info)
return methods
def extract_from_sdk(module_name: str, class_name: str, output_path: str):
"""Main extractor: Python SDK class → metadata JSON."""
module = importlib.import_module(module_name)
cls = getattr(module, class_name)
methods = extract_methods_from_class(cls)
metadata = {
"methods": methods,
"definitions": {},
"meta": {
"api_version": "v1",
"base_url": "",
"auth_type": "bearer",
"source": f"{module_name}.{class_name}",
"extracted_at": datetime.now(timezone.utc).isoformat(),
"total_methods": len(methods),
}
}
Path(output_path).write_text(json.dumps(metadata, indent=2))
print(f"✓ Extracted {len(methods)} methods from {module_name}.{class_name} → {output_path}")5. Normalize: chuẩn hóa metadata
Sau khi extract từ bất kỳ nguồn nào, chạy normalization pass để đảm bảo consistency:
# tools/normalize_metadata.py
import json
import re
from pathlib import Path
def normalize_cli_command(command: str) -> str:
"""Đảm bảo kebab-case và không có ký tự đặc biệt."""
command = command.lower().replace("_", "-")
command = re.sub(r"[^a-z0-9-]", "", command)
return command.strip("-")
def infer_missing_fields(method: dict) -> dict:
"""Fill in fields còn thiếu dựa trên convention."""
if not method.get("cli_command"):
action = method.get("action", "")
resource = method.get("resource", "")
if action == "list":
method["cli_command"] = f"list-{resource}s"
else:
method["cli_command"] = f"{action}-{resource}"
if "idempotent" not in method:
method["idempotent"] = method.get("action") in ("list", "get", "update")
if "paginated" not in method:
method["paginated"] = method.get("action") == "list"
if "requires_auth" not in method:
method["requires_auth"] = True
return method
def validate_method(method: dict) -> list[str]:
"""Validate method và trả về list lỗi."""
errors = []
required_fields = ["id", "resource", "action", "cli_command", "http_method", "path"]
for field in required_fields:
if not method.get(field):
errors.append(f"Missing required field: {field}")
if "." not in method.get("id", ""):
errors.append("id must be in format 'resource.action'")
return errors
def normalize(input_path: str, output_path: str):
"""Normalize metadata JSON."""
metadata = json.loads(Path(input_path).read_text())
normalized_methods = []
errors_found = 0
for method in metadata.get("methods", []):
# Normalize fields
method["cli_command"] = normalize_cli_command(method.get("cli_command", ""))
method = infer_missing_fields(method)
# Validate
errors = validate_method(method)
if errors:
print(f"⚠️ {method.get('id', '?')}: {', '.join(errors)}")
errors_found += 1
normalized_methods.append(method)
metadata["methods"] = normalized_methods
metadata["meta"]["normalized"] = True
metadata["meta"]["total_methods"] = len(normalized_methods)
Path(output_path).write_text(json.dumps(metadata, indent=2, ensure_ascii=False))
print(f"✓ Normalized {len(normalized_methods)} methods ({errors_found} warnings) → {output_path}")6. Query metadata
Metadata JSON là human-readable và machine-queryable. Với jq:
# Đếm tổng số methods
jq '.meta.total_methods' metadata/api-metadata.json
# List tất cả CLI commands
jq -r '.methods[].cli_command' metadata/api-metadata.json | sort
# Tìm methods có pagination
jq '[.methods[] | select(.paginated == true) | .id]' metadata/api-metadata.json
# Methods theo resource
jq '[.methods[] | select(.resource == "user")]' metadata/api-metadata.json
# Methods không idempotent (cần --force flag khi generate)
jq '[.methods[] | select(.idempotent == false) | .id]' metadata/api-metadata.jsonVà từ Python:
import json
metadata = json.loads(open("metadata/api-metadata.json").read())
# Filter methods cho code generation
paginated_methods = [m for m in metadata["methods"] if m["paginated"]]
write_methods = [m for m in metadata["methods"] if m["http_method"] in ("POST", "PUT", "DELETE")]
print(f"Total: {metadata['meta']['total_methods']} methods")
print(f"Paginated: {len(paginated_methods)}")
print(f"Write operations: {len(write_methods)}")7. Ứng dụng trong AI-centric engineering
Metadata JSON là single source of truth cho toàn bộ pipeline:
openapi.yaml / SDK source / HTML docs
│
▼ extract_openapi.py / extract_sdk.py
│
metadata/api-metadata.json (SSOT)
│
├── generate_adapters.py → adapters/user_adapter.py (439 methods)
├── generate_commands.py → cli/users.py (439 commands)
├── generate_tests.py → tests/test_users.py (439 × 3 test cases)
└── generate_skills.py → SKILL.md (agent skills doc)
Khi API thay đổi — thêm endpoint, đổi parameter name — bạn chỉ cần:
- Re-run extractor:
python tools/extract_openapi.py openapi.yaml metadata/api-metadata.json - Re-run normalizer:
python tools/normalize_metadata.py metadata/api-metadata.json metadata/api-metadata.json - Re-run generators:
make codegen
Ba bước. Vài giây. 439 adapters, commands, và tests được cập nhật.
Đây là lý do Stripe CLI, GitHub CLI, và kubectl đều có pattern tương tự: API evolves — CLI keeps up automatically.
Bài tiếp sẽ đi vào bước tiếp theo: code generation pipeline — template engine đọc metadata JSON, generate adapter, command, và test cùng lúc. Đây là nơi một dòng thay đổi trong template tạo ra 16,000 dòng code.
Bài tiếp
Bài 5: Code Generation Pipeline — Adapter, Command, Test — Template engine + Jinja2 đọc metadata JSON, generate adapter, CLI command, và test file cùng lúc. Xử lý edge cases: optional params, pagination, idempotency, --force flag. Khi 1 dòng template thay đổi → 439 files cập nhật.
