JSON #
JSON adalah format pertukaran data yang hampir tidak bisa dihindari di dunia pemrograman modern — API, konfigurasi, log terstruktur, dan komunikasi antar service hampir semuanya menggunakan JSON. Ruby menyediakan library JSON sebagai bagian dari standard library, tanpa perlu menginstall gem tambahan. Library ini menyediakan dua operasi fundamental: parsing JSON string menjadi objek Ruby, dan generating JSON string dari objek Ruby. Tapi di balik kesederhanaannya, ada nuansa penting tentang bagaimana Ruby dan JSON saling memetakan tipe data satu sama lain, bagaimana menangani error dengan tepat, dan bagaimana mengintegrasikan JSON dengan kelas custom di aplikasimu.
Parsing: JSON ke Ruby #
JSON.parse mengubah string JSON menjadi objek Ruby. Ini adalah operasi yang paling sering dilakukan saat bekerja dengan respons API atau membaca file konfigurasi.
require "json"
# Parsing dasar
json_string = '{"nama": "Alice", "umur": 30, "aktif": true}'
data = JSON.parse(json_string)
# => {"nama"=>"Alice", "umur"=>30, "aktif"=>true}
data["nama"] # => "Alice"
data["umur"] # => 30
data["aktif"] # => true
# Array JSON
json_array = '[1, 2, 3, "empat", null, true]'
JSON.parse(json_array)
# => [1, 2, 3, "empat", nil, true]
# JSON bersarang
json_nested = '{
"pengguna": {
"id": 1,
"nama": "Bob",
"alamat": {
"kota": "Jakarta",
"kode_pos": "10110"
},
"tag": ["ruby", "developer"]
}
}'
data = JSON.parse(json_nested)
data["pengguna"]["alamat"]["kota"] # => "Jakarta"
data["pengguna"]["tag"] # => ["ruby", "developer"]
Pemetaan Tipe Data JSON ↔ Ruby #
# Tabel konversi otomatis saat parsing
json = '{
"string": "hello",
"integer": 42,
"float": 3.14,
"boolean_true": true,
"boolean_false": false,
"null_value": null,
"array": [1, 2, 3],
"object": {"key": "value"}
}'
data = JSON.parse(json)
data["string"].class # => String
data["integer"].class # => Integer
data["float"].class # => Float
data["boolean_true"].class # => TrueClass
data["boolean_false"].class # => FalseClass
data["null_value"].class # => NilClass
data["array"].class # => Array
data["object"].class # => Hash
symbolize_names — String Key vs Symbol Key #
Secara default, JSON.parse menggunakan string sebagai key Hash. Opsi symbolize_names: true mengubahnya menjadi symbol.
json = '{"nama": "Alice", "umur": 30}'
# Default: key berupa String
data_string = JSON.parse(json)
data_string["nama"] # => "Alice"
data_string[:nama] # => nil (symbol tidak cocok!)
# Dengan symbolize_names: true — key berupa Symbol
data_symbol = JSON.parse(json, symbolize_names: true)
data_symbol[:nama] # => "Alice"
data_symbol["nama"] # => nil (string tidak cocok!)
# Berlaku rekursif untuk nested hash
json_nested = '{"user": {"name": "Bob", "role": "admin"}}'
data = JSON.parse(json_nested, symbolize_names: true)
data[:user][:name] # => "Bob"
data[:user][:role] # => "admin"
# Kapan pakai symbolize_names?
# ✓ Ketika bekerja dengan data internal yang dikontrol kamu
# ✓ Ketika key diakses berkali-kali (symbol lebih hemat memori)
# ✗ Ketika key datang dari input eksternal yang tidak terprediksi
# ✗ Ketika key mengandung karakter yang tidak valid untuk symbol (jarang tapi ada)
Generating: Ruby ke JSON #
JSON.generate (atau to_json) mengubah objek Ruby menjadi string JSON.
require "json"
# Hash ke JSON
data = { nama: "Alice", umur: 30, aktif: true }
JSON.generate(data)
# => '{"nama":"Alice","umur":30,"aktif":true}'
# Atau menggunakan to_json
data.to_json
# => '{"nama":"Alice","umur":30,"aktif":true}'
# Array ke JSON
[1, 2, 3, "empat", nil, false].to_json
# => '[1,2,3,"empat",null,false]'
# Nested structure
{
pengguna: {
id: 1,
nama: "Bob",
tag: ["ruby", "developer"],
alamat: nil
}
}.to_json
# => '{"pengguna":{"id":1,"nama":"Bob","tag":["ruby","developer"],"alamat":null}}'
JSON.pretty_generate — Output yang Terbaca #
data = {
status: "success",
data: {
pengguna: [
{ id: 1, nama: "Alice" },
{ id: 2, nama: "Bob" }
]
}
}
# generate: satu baris, compact
JSON.generate(data)
# => '{"status":"success","data":{"pengguna":[{"id":1,"nama":"Alice"},{"id":2,"nama":"Bob"}]}}'
# pretty_generate: multi-baris, indentasi 2 spasi
puts JSON.pretty_generate(data)
# {
# "status": "success",
# "data": {
# "pengguna": [
# {
# "id": 1,
# "nama": "Alice"
# },
# {
# "id": 2,
# "nama": "Bob"
# }
# ]
# }
# }
# Gunakan pretty_generate untuk file konfigurasi atau output yang dibaca manusia
# Gunakan generate untuk response API, data transfer (lebih kecil ukurannya)
Pemetaan Tipe Data Ruby → JSON #
# Tipe yang didukung langsung
{ kunci: "nilai" }.to_json # => '{"kunci":"nilai"}' Hash
[1, 2, 3].to_json # => '[1,2,3]' Array
"string".to_json # => '"string"' String
42.to_json # => '42' Integer
3.14.to_json # => '3.14' Float
true.to_json # => 'true' TrueClass
false.to_json # => 'false' FalseClass
nil.to_json # => 'null' NilClass
# Tipe yang perlu perhatian khusus
:symbol.to_json # => '"symbol"' Symbol jadi String!
Time.now.to_json # => '"2024-01-15 10:30:00 +0700"' Time jadi String
Date.today.to_json # => '"2024-01-15"' Date jadi String
# Integer besar aman di Ruby, tapi perhatikan kompatibilitas JSON parser lain
9_007_199_254_740_993.to_json # Melebihi Number.MAX_SAFE_INTEGER di JavaScript!
Symbol Ruby dikonversi menjadi String saat di-serialize ke JSON. Ketika kamu parse JSON tersebut kembali, hasilnya adalah String, bukan Symbol — kecuali menggunakan
symbolize_names: true. Ini bisa menyebabkan mismatch yang membingungkan jika kamu tidak sadar akan konversi ini.data = { status: :aktif, id: 1 } json = data.to_json # => '{"status":"aktif","id":1}' parsed = JSON.parse(json) parsed["status"] # => "aktif" (String, bukan Symbol!) parsed["status"] == :aktif # => false! # Jika butuh Symbol tetap konsisten, selalu canonicalize setelah parse parsed = JSON.parse(json, symbolize_names: true) parsed[:status].to_sym # => :aktif
Penanganan Error #
JSON yang datang dari sumber eksternal tidak selalu valid. Selalu tangani error parsing dengan tepat.
require "json"
# JSON.parse melempar JSON::ParserError untuk input tidak valid
begin
JSON.parse("ini bukan json")
rescue JSON::ParserError => e
puts "JSON tidak valid: #{e.message}"
end
begin
JSON.parse('{"key": "value"') # JSON tidak lengkap
rescue JSON::ParserError => e
puts "JSON tidak valid: #{e.message}"
end
# String kosong
begin
JSON.parse("")
rescue JSON::ParserError => e
puts "JSON kosong"
end
# Pola aman: parse dengan default jika gagal
def parse_json_aman(string, default: nil)
JSON.parse(string)
rescue JSON::ParserError
default
end
hasil = parse_json_aman('{"valid": true}') # => {"valid"=>true}
hasil = parse_json_aman("tidak valid") # => nil
hasil = parse_json_aman("", default: {}) # => {}
# Pola dengan logging
def parse_json_dengan_log(string, konteks: "unknown")
JSON.parse(string)
rescue JSON::ParserError => e
logger.error("JSON parse error di #{konteks}: #{e.message}")
logger.debug("Input: #{string.truncate(200)}")
nil
end
# Validasi JSON tanpa parsing penuh
def valid_json?(string)
JSON.parse(string)
true
rescue JSON::ParserError
false
end
valid_json?('{"key": "value"}') # => true
valid_json?("tidak valid") # => false
valid_json?("null") # => true (null adalah JSON valid!)
valid_json?("42") # => true (number adalah JSON valid!)
Serialisasi Kelas Custom #
Ketika kamu memiliki kelas sendiri dan ingin mengkonversinya ke/dari JSON, ada beberapa pendekatan yang bisa dipilih.
Pendekatan 1: to_json #
require "json"
class Produk
attr_reader :id, :nama, :harga, :stok
def initialize(id, nama, harga, stok = 0)
@id = id
@nama = nama
@harga = harga
@stok = stok
end
# Konversi ke Hash dulu, lalu Hash.to_json
def as_json
{
id: @id,
nama: @nama,
harga: @harga,
stok: @stok
}
end
def to_json(*args)
as_json.to_json(*args)
end
# Factory method untuk parse dari JSON
def self.from_json(json_string)
data = JSON.parse(json_string, symbolize_names: true)
new(data[:id], data[:nama], data[:harga], data[:stok])
end
def self.from_hash(hash)
new(hash[:id] || hash["id"],
hash[:nama] || hash["nama"],
hash[:harga] || hash["harga"],
hash[:stok] || hash["stok"] || 0)
end
end
produk = Produk.new(1, "Laptop Ruby", 15_000_000, 5)
json = produk.to_json
# => '{"id":1,"nama":"Laptop Ruby","harga":15000000,"stok":5}'
# Parse kembali ke objek
produk_restored = Produk.from_json(json)
produk_restored.nama # => "Laptop Ruby"
Pendekatan 2: JSON.load dengan custom parser #
require "json"
class Pengguna
attr_accessor :id, :nama, :email, :dibuat_pada
def initialize(attrs = {})
@id = attrs["id"] || attrs[:id]
@nama = attrs["nama"] || attrs[:nama]
@email = attrs["email"] || attrs[:email]
@dibuat_pada = attrs["dibuat_pada"] || attrs[:dibuat_pada]
end
def to_json(*args)
{
"id" => @id,
"nama" => @nama,
"email" => @email,
"dibuat_pada" => @dibuat_pada&.iso8601
}.to_json(*args)
end
end
# Dengan JSON.load dan custom create_id
class PenggunaSerializer
def self.dump(pengguna)
JSON.generate({
id: pengguna.id,
nama: pengguna.nama,
email: pengguna.email
})
end
def self.load(json_string)
data = JSON.parse(json_string)
Pengguna.new(data)
end
end
Bekerja dengan File JSON #
Skenario umum: membaca konfigurasi dari file JSON atau menyimpan data ke file JSON.
require "json"
require "pathname"
# Membaca dari file
def baca_config(path)
file = Pathname.new(path)
raise "File tidak ditemukan: #{path}" unless file.exist?
JSON.parse(file.read, symbolize_names: true)
rescue JSON::ParserError => e
raise "File config tidak valid: #{e.message}"
end
config = baca_config("config/settings.json")
config[:database][:host] # => "localhost"
# Menulis ke file
def simpan_data(data, path)
file = Pathname.new(path)
file.dirname.mkpath # pastikan direktori ada
file.open("w") do |f|
f.write(JSON.pretty_generate(data))
end
end
simpan_data({ versi: "1.0", timestamp: Time.now.iso8601 }, "output/result.json")
# Pola: baca-modifikasi-tulis dengan atomic write
def update_config(path, &blok)
file = Pathname.new(path)
data = JSON.parse(file.read, symbolize_names: true)
blok.call(data)
tmp = Pathname.new("#{path}.tmp.#{Process.pid}")
tmp.write(JSON.pretty_generate(data))
tmp.rename(file)
end
update_config("config/settings.json") do |config|
config[:versi] = "2.0"
config[:diperbarui_pada] = Time.now.iso8601
end
Opsi Lanjutan #
require "json"
# max_nesting — batasi kedalaman parsing (default: 100)
# Mencegah stack overflow dari JSON yang sangat bersarang
begin
sangat_bersarang = "[" * 200 + "]" * 200
JSON.parse(sangat_bersarang, max_nesting: 10)
rescue JSON::NestingError => e
puts "Terlalu dalam: #{e.message}"
end
# allow_nan — izinkan NaN dan Infinity (tidak standar JSON)
JSON.generate(Float::NAN) # => JSON::GeneratorError!
JSON.generate(Float::NAN, allow_nan: true) # => 'NaN'
JSON.parse("NaN", allow_nan: true) # => Float::NAN
# Serialisasi dengan custom state
json = JSON.generate(data,
indent: " ", # 2 spasi untuk indentasi
space: " ", # spasi setelah : dan ,
object_nl: "\n", # newline setelah setiap key-value
array_nl: "\n" # newline setelah setiap elemen array
)
# JSON.dump vs JSON.generate
# JSON.dump: lebih permisif, gunakan to_json jika tersedia
# JSON.generate: lebih ketat, butuh tipe yang dikenal
Pola Penggunaan di Aplikasi Nyata #
Response API yang Konsisten #
require "json"
class ApiResponse
def self.sukses(data, pesan: "OK")
{
status: "success",
pesan: pesan,
data: data,
timestamp: Time.now.utc.iso8601
}.to_json
end
def self.error(pesan, kode: 400, detail: nil)
response = {
status: "error",
pesan: pesan,
kode: kode,
timestamp: Time.now.utc.iso8601
}
response[:detail] = detail if detail
response.to_json
end
end
# Penggunaan
ApiResponse.sukses({ pengguna: { id: 1, nama: "Alice" } })
# => '{"status":"success","pesan":"OK","data":{"pengguna":{"id":1,"nama":"Alice"}},...}'
ApiResponse.error("Pengguna tidak ditemukan", kode: 404)
# => '{"status":"error","pesan":"Pengguna tidak ditemukan","kode":404,...}'
Cache JSON dengan Validasi #
require "json"
class JsonCache
def initialize(path)
@path = Pathname.new(path)
@data = load_dari_disk
end
def [](key)
@data[key.to_s]
end
def []=(key, value)
@data[key.to_s] = value
simpan_ke_disk
end
def delete(key)
@data.delete(key.to_s)
simpan_ke_disk
end
private
def load_dari_disk
return {} unless @path.exist?
JSON.parse(@path.read)
rescue JSON::ParserError
{} # Reset jika file corrupt
end
def simpan_ke_disk
@path.dirname.mkpath
@path.write(JSON.pretty_generate(@data))
end
end
cache = JsonCache.new("tmp/cache.json")
cache["token"] = "abc123"
cache["expires"] = Time.now.to_i + 3600
puts cache["token"] # => "abc123"
Ringkasan #
require "json"dulu — tidak seperti Array dan Hash, JSON perlu di-require secara eksplisit sebelum bisa digunakan.JSON.parseuntuk parsing,to_jsonuntuk generating — dua operasi paling fundamental; gunakanJSON.pretty_generateuntuk output yang terbaca manusia.symbolize_names: trueuntuk API internal — mengubah key dari String menjadi Symbol saat parsing; berguna untuk akses dengan notasi symbol tapi hindari untuk key dari input eksternal yang tidak terprediksi.- Selalu rescue
JSON::ParserError— JSON dari sumber eksternal tidak selalu valid; tangani error ini secara eksplisit daripada membiarkan aplikasi crash.- Symbol Ruby menjadi String di JSON — saat serialize ke JSON, Symbol otomatis dikonversi ke String; saat parse kembali, hasilnya String bukan Symbol.
- Implementasikan
as_jsondanto_jsonuntuk kelas custom —as_jsonmengembalikan Hash representasi,to_jsonmendelegasikan keas_json; ini pola yang diikuti Rails dan banyak library.- Gunakan atomic write untuk file JSON — tulis ke file temporary dulu lalu rename ke target; ini mencegah file corrupt jika proses tiba-tiba berhenti di tengah penulisan.
max_nestinguntuk proteksi dari input jahat — batasi kedalaman JSON yang diparse untuk mencegah stack overflow dari input yang sengaja dibuat sangat bersarang.