06 · Working with JSON/APIs¶
Most modern services exchange data as JSON over HTTP. Ruby's standard
library ships everything you need to both produce and consume it: the
json library for parsing/generating JSON, and net/http for making HTTP
requests — no extra gems required for the basics.
Parsing JSON¶
require "json"
raw = '{"name": "Ada", "age": 30, "skills": ["Ruby", "Math"]}'
data = JSON.parse(raw)
puts data.inspect
# {"name"=>"Ada", "age"=>30, "skills"=>["Ruby", "Math"]}
puts data["name"] # Ada -- keys are strings by default
symbolize_names — keys as symbols instead of strings¶
data = JSON.parse(raw, symbolize_names: true)
puts data[:name] # Ada -- now keys are symbols
puts data[:skills].inspect # ["Ruby", "Math"]
Symbol keys read more naturally in Ruby code (data[:name] vs
data["name"]), but only symbolize data from sources you trust — parsing
attacker-controlled JSON with symbolize_names: true can, in older Ruby
versions, create unbounded numbers of symbols, which are never
garbage-collected.
Generating JSON¶
require "json"
person = { name: "Grace", age: 34, skills: ["COBOL", "Compilers"] }
puts person.to_json
# {"name":"Grace","age":34,"skills":["COBOL","Compilers"]}
puts JSON.pretty_generate(person)
# {
# "name": "Grace",
# "age": 34,
# "skills": [
# "COBOL",
# "Compilers"
# ]
# }
to_json works on any standard Ruby object (Hash, Array, String, Integer,
nil, true/false) once require "json" has been loaded — it's added as a
method on Object by the library.
Handling malformed JSON¶
begin
JSON.parse("{not valid json")
rescue JSON::ParserError => e
puts "Bad JSON: #{e.message.split("\n").first}"
end
# Bad JSON: unexpected token at '{not valid json'
Always wrap JSON.parse in a rescue JSON::ParserError (see
Exception Handling) when the input comes from
outside your program — a network response, a file, user input — since
you can't guarantee it will always be well-formed.
Making an HTTP GET request with Net::HTTP¶
require "net/http"
require "uri"
require "json"
uri = URI("https://api.github.com/repos/ruby/ruby")
response = Net::HTTP.get_response(uri)
if response.is_a?(Net::HTTPSuccess)
data = JSON.parse(response.body)
puts "#{data['full_name']} has #{data['stargazers_count']} stars"
else
puts "Request failed: #{response.code} #{response.message}"
end
# ruby/ruby has 21000+ stars (exact count changes over time)
Net::HTTP.get_response is the simplest way to issue a one-off GET
request. It returns a response object, not raw text — always check
response.code (or the is_a?(Net::HTTPSuccess) shortcut above) before
trusting response.body, since a 404 or 500 response still has a body,
just not the one you wanted.
A small reusable HTTP client class¶
For anything beyond a single request, wrap the boilerplate in a class:
require "net/http"
require "uri"
require "json"
class ApiClient
class RequestError < StandardError; end
def initialize(base_url)
@base_url = base_url
end
def get(path)
uri = URI("#{@base_url}#{path}")
response = Net::HTTP.get_response(uri)
raise RequestError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
rescue JSON::ParserError => e
raise RequestError, "Invalid JSON in response: #{e.message}"
end
end
client = ApiClient.new("https://api.github.com")
repo = client.get("/repos/ruby/ruby")
puts repo["full_name"] # ruby/ruby
Wrapping errors in your own RequestError (see
Exception Handling for building exception
hierarchies) means callers of ApiClient only need to rescue one exception
type, regardless of whether the underlying failure was a bad status code
or malformed JSON.
Setting a timeout — never make a request that can hang forever¶
require "net/http"
uri = URI("https://api.github.com/repos/ruby/ruby")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 5 # seconds to establish the connection
http.read_timeout = 5 # seconds to wait for a response
begin
response = http.get(uri.request_uri)
puts response.code
rescue Net::OpenTimeout, Net::ReadTimeout => e
puts "Request timed out: #{e.class}"
end
Without an explicit timeout, a hung server can block your program indefinitely — always set one for requests that leave your process, especially in anything long-running like a web server or background job.
POST requests with a JSON body¶
require "net/http"
require "uri"
require "json"
uri = URI("https://httpbin.org/post")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
request.body = { title: "New post", body: "Hello" }.to_json
response = http.request(request)
result = JSON.parse(response.body)
puts result["json"]["title"] # New post
How It Actually Works¶
JSON.parse is a recursive-descent parser: it walks the input string
character by character, and every time it encounters {, [, a quote, or
a digit, it recurses into a sub-parser for that JSON type, building nested
Ruby Hash/Array/String/Numeric objects as it goes — there's no
intermediate schema, so the resulting object graph mirrors the JSON's
nesting exactly. JSON.generate (what to_json calls) walks the opposite
direction, recursively calling #to_json on nested objects, which is why
defining a custom to_json method on your own class lets it serialize
however you like. Underneath Net::HTTP or HTTParty, a request opens a
TCP socket via the OS, and — like file I/O — the actual read/write on
that socket releases MRI's GIL, letting other threads run Ruby while your
thread blocks waiting for the network round trip; this is the mechanism
that makes concurrent HTTP requests via multiple threads genuinely useful
in Ruby despite the GIL.
Cheat sheet¶
| Task | Code |
|---|---|
| Parse a JSON string | JSON.parse(str) |
| Parse with symbol keys | JSON.parse(str, symbolize_names: true) |
| Convert an object to JSON | obj.to_json |
| Pretty-print JSON | JSON.pretty_generate(obj) |
| Simple GET request | Net::HTTP.get_response(uri) |
| Check for a successful response | response.is_a?(Net::HTTPSuccess) |
| POST with a body | Net::HTTP::Post.new(uri) + request.body = ... |
| Set timeouts | http.open_timeout =, http.read_timeout = |
Exercise¶
Write a class WeatherClient (a preview of the level project) with a
method current_temperature(latitude, longitude) that calls
https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..¤t_weather=true
(a free, no-API-key weather API), parses the JSON response, and returns
just the temperature value from current_weather. Handle both a
non-success HTTP status and a JSON::ParserError by raising a single
custom WeatherClient::Error — you'll build this out fully in
Project: Weather CLI.