Computing Foundations › APIs and the Web › Day 28
Hands-on lab — Day 28: Consuming a Public API from the Command Line
- ← Back to the Day 28 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/computing-foundations/day-028-consuming-a-public-api-from-the/
Commands
Setup
cd labs/sections/computing-foundations/day-028-consuming-a-public-api-from-the Run
bash examples/weather.sh
bash examples/weather.sh 48.85 2.35
bash starter/weather.sh Test
bash tests/run_tests.sh File tree
examples/sample-response.json examples/weather.sh expected-output/sample-run.txt metadata.yml README.md requirements/README.md security.md starter/weather-worksheet.md starter/weather.sh tests/run_tests.sh troubleshooting.md
Lab README
Day 028 lab — Build a Weather CLI
Lesson
- Lesson title: Consuming a Public API from the Command Line
- Day number: 28 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-028-consuming-a-public-api-from-the
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-028-consuming-a-public-api-from-thewhen the site is running.
Purpose
Day 28's lesson turns a week of API theory into one motion: read the docs, form the request, send it, parse the reply, handle errors, present the result. This lab makes it real. You build a small but genuinely useful weather client from scratch — it calls the free, no-key Open-Meteo API for a latitude and longitude, parses the JSON, and prints a clean current-conditions report. No API key is required, and the client fails gracefully when the network is down.
Learning objectives
- Form an Open-Meteo request URL with a correct query string.
- Send the request with
curland capture the JSON reply. - Parse nested JSON fields (
current.temperature_2m,current.wind_speed_10m) withpython3(with thejqequivalent shown). - Handle a failed request and a missing field so the client never prints garbage or crashes.
- Run a test suite that verifies the parser offline against a saved response and the live fetch when online.
Prerequisites
- The Day 28 lesson, and days 22–27 (the APIs and the Web category).
- A terminal with
curlandpython3(both preinstalled on macOS and Linux). - Network access for a live lookup (the lab degrades gracefully offline).
Supported operating systems
- macOS and Linux — fully supported (
curlandpython3preinstalled). - Windows —
curlships with Windows 10+ andpython3is a free install; or run the scripts unmodified inside WSL.
Hardware requirements
Any computer with an internet connection. The lab only makes ordinary web requests and runs a few lines of Python.
Required software
curl— to send the request.python3— to parse the JSON and print the report.jq— optional, only for the commented one-line parse alternative.
See requirements/README.md.
Free and open-source options
Everything here is free and open source. curl, python3, and jq are all
open source, and Open-Meteo, Open Notify, and JSONPlaceholder are free public
APIs with no account or key required. Nothing in this lab costs money.
Installation
None beyond the preinstalled tools. Change into this directory:
cd labs/sections/computing-foundations/day-028-consuming-a-public-api-from-the
File structure
day-028-consuming-a-public-api-from-the/
├── README.md
├── metadata.yml
├── starter/
│ ├── weather.sh ← YOUR working file (5 exercises)
│ └── weather-worksheet.md ← record your captured values
├── examples/
│ ├── weather.sh ← completed reference client
│ └── sample-response.json ← a real captured Open-Meteo response (fixed data)
├── tests/
│ └── run_tests.sh
├── expected-output/
│ └── sample-run.txt ← real captured run (success + offline error)
├── requirements/README.md
├── troubleshooting.md
└── security.md
How to run
## 1. See the finished client first (needs network)
bash examples/weather.sh # default location (Berlin)
bash examples/weather.sh 48.85 2.35 # any latitude / longitude
## 2. Complete the five exercises in the starter, then run it
bash starter/weather.sh
## 3. Check your work (works online or offline)
bash tests/run_tests.sh
What the commands do
bash examples/weather.sh [LAT] [LON]— the finished client. It builds the Open-Meteo request URL, fetches it withcurl -s --max-time 15, parses thecurrentobject withpython3, and prints time, temperature, and wind. With no arguments it uses a default location; pass a latitude and longitude to look up anywhere. If the request fails it prints a clear error and exits non-zero.bash starter/weather.sh— the same client with five numbered exercises to complete: build the URL, send the request, read the temperature, read the wind (handling a missing field), and present the report.bash tests/run_tests.sh— always runs structure checks and a parse check that drives the real client against the committedsample-response.jsonwith no network, then — when online — does a live fetch and confirms a numeric temperature comes back. Offline, the live check is skipped (never failed). Exits 0 on success.
Expected output
See expected-output/sample-run.txt for a
real captured run. A successful lookup looks like:
Weather for 52.52, 13.41
Time: 2026-07-12T12:30
Temperature: 29.5 °C
Wind: 16.9 km/h
Your time, temperature, and wind will differ with the weather and the day. Offline, the client prints the error shown in the sample file instead.
Validation steps
bash examples/weather.sh 52.52 13.41prints a numeric temperature and wind.- Passing your own latitude and longitude changes the location in the output.
- Complete all five exercises in
starter/weather.sh; it produces the same shape of report. - With the network off, the client prints a clear error and exits non-zero.
bash tests/run_tests.shprints0 failure(s)and exits 0.
Tests
bash tests/run_tests.sh
Expected final line online: 14 checks, 0 failure(s), 0 skip(s). Offline the
live-fetch check is skipped (... 1 skip(s).) and the run still exits 0,
because the parse logic is verified against the committed sample response.
Cleanup
Nothing to clean up — the scripts only make web requests and write no files.
Reset your edited starter with git checkout -- starter/weather.sh.
Troubleshooting
See troubleshooting.md — unquoted URLs, null
temperatures, missing jq, offline behavior, and reading the raw response.
Security notes
See security.md. Short version: Open-Meteo needs no key, so there is no secret to leak here — but if you later switch to a key-based API, keep the key in an environment variable (Day 25), never in the script.
Extension exercises
- Add a third measurement (
relative_humidity_2m) to the query string, the parse, and the report. - Make the client resilient to a partial response: if
currentis present but a field is missing, printunavailablefor that line (the reference already does this — confirm it, then extend it to your new field). - Rewrite the fetch-and-parse in a few lines of
python3using only the standard library (urllib.requestto fetch,jsonto parse), and add a comment marking where anAuthorizationheader would go for a key-based API.
Navigation
- Previous day: Day 27 — Rate Limits, Pagination, and Error Handling.
- Next day: Day 29 — begins the next category (see the course schedule).
Expected output
sample-run.txt
$ bash examples/weather.sh 52.52 13.41
Weather for 52.52, 13.41
Time: 2026-07-12T12:30
Temperature: 29.5 °C
Wind: 16.9 km/h
$ bash examples/weather.sh # no network available
Error: could not reach the weather service (no network or the request timed out).
Check your connection and try again.
Source files
examples/sample-response.json (459 bytes)
{
"latitude": 52.52,
"longitude": 13.419998,
"generationtime_ms": 0.05698204040527344,
"utc_offset_seconds": 0,
"timezone": "GMT",
"timezone_abbreviation": "GMT",
"elevation": 38.0,
"current_units": {
"time": "iso8601",
"interval": "seconds",
"temperature_2m": "°C",
"wind_speed_10m": "km/h"
},
"current": {
"time": "2026-07-12T12:30",
"interval": 900,
"temperature_2m": 29.5,
"wind_speed_10m": 16.9
}
}
examples/weather.sh (3682 bytes)
#!/usr/bin/env bash
# Day 028 lab — Build a Weather CLI (completed reference implementation).
#
# A small but genuinely useful API client. It reads a latitude and longitude
# (with a sensible default), calls the free, no-key Open-Meteo forecast API,
# parses the JSON reply, and prints a clean current-conditions report. A failed
# request and a missing field are both handled gracefully.
#
# Run from the lab directory:
# bash examples/weather.sh # default location (Berlin)
# bash examples/weather.sh 48.85 2.35 # any latitude / longitude
#
# No API key is required — Open-Meteo is free and open. Network access is
# needed for a live lookup; offline, the script prints a clear error and exits
# non-zero. For testing, set WEATHER_SAMPLE_FILE to a saved JSON response and
# the same parser runs against that file instead of the network.
set -u
# --- Stage 1: read the docs → the endpoint and its parameters ---------------
# Docs: https://open-meteo.com/en/docs — GET /v1/forecast with latitude,
# longitude, and a `current` list of measurements. No key needed.
DEFAULT_LAT="52.52" # Berlin
DEFAULT_LON="13.41"
LAT="${1:-$DEFAULT_LAT}"
LON="${2:-$DEFAULT_LON}"
API="https://api.open-meteo.com/v1/forecast"
# --- Stage 2: build the request → the URL with its query string -------------
# A query string starts with ? and joins name=value pairs with &.
URL="${API}?latitude=${LAT}&longitude=${LON}¤t=temperature_2m,wind_speed_10m"
# --- Stage 3: send it → curl fetches the reply ------------------------------
# WEATHER_SAMPLE_FILE lets the tests drive the real parser offline against a
# committed response; normally we fetch live. --max-time fails fast on a hang.
fetch() {
if [ -n "${WEATHER_SAMPLE_FILE:-}" ]; then
cat "${WEATHER_SAMPLE_FILE}"
return 0
fi
curl -s --max-time 15 "${URL}"
}
# --- Stages 4 & 6: parse the JSON and present the result --------------------
# Parsed here with python3 (preinstalled). The jq equivalent for one field is:
# echo "$body" | jq '.current.temperature_2m'
# The python version also handles a missing field by printing "unavailable".
present() {
local body="$1"
WEATHER_JSON="${body}" WEATHER_LAT="${LAT}" WEATHER_LON="${LON}" python3 <<'PY'
import os, sys, json
raw = os.environ.get("WEATHER_JSON", "")
lat = os.environ.get("WEATHER_LAT", "")
lon = os.environ.get("WEATHER_LON", "")
# Parse the JSON; a non-JSON body is an error we report clearly.
try:
data = json.loads(raw)
except ValueError:
print("Error: the service did not return valid JSON.", file=sys.stderr)
sys.exit(2)
# The measurements are nested under "current"; if that is missing, say so.
current = data.get("current")
if not isinstance(current, dict):
print("Error: the response did not include current conditions.", file=sys.stderr)
sys.exit(3)
units = data.get("current_units", {})
def field(name):
"""Return 'value unit', or 'unavailable' if the field is absent."""
value = current.get(name)
if value is None:
return "unavailable"
unit = units.get(name, "")
return f"{value} {unit}".strip()
print(f"Weather for {lat}, {lon}")
print(f" Time: {current.get('time', 'unavailable')}")
print(f" Temperature: {field('temperature_2m')}")
print(f" Wind: {field('wind_speed_10m')}")
PY
}
# --- Stage 5: handle errors → a failed request must not print garbage -------
main() {
local body
body="$(fetch)" || body=""
if [ -z "${body}" ]; then
echo "Error: could not reach the weather service (no network or the request timed out)." >&2
echo "Check your connection and try again." >&2
exit 1
fi
present "${body}"
}
main "$@"
metadata.yml (628 bytes)
lesson_id: D028
day: 28
kind: api-example
languages: [bash]
setup_commands:
- cd labs/sections/computing-foundations/day-028-consuming-a-public-api-from-the
run_commands:
- bash examples/weather.sh
- bash examples/weather.sh 48.85 2.35
- bash starter/weather.sh
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- 'git checkout -- starter/weather.sh # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-12'
executed_on: 'macOS (curl 8.7.1, python3 3.14, jq 1.7.1), online, bash tests/run_tests.sh → 14 checks, 0 failure(s), 0 skip(s)'
requirements/README.md (992 bytes)
# Dependencies — Day 028 lab
**`curl` and `python3`** — both preinstalled on macOS and mainstream Linux
(and available on Windows 10+ / WSL). No package installs, no accounts, and
**no API key**: Open-Meteo is a free, open, no-key weather API.
- `curl` — sends the HTTP request.
- `python3` — parses the JSON response and prints the report (standard library
only; nothing to `pip install`).
- `jq` — **optional**. The client parses with `python3` by default; `jq` is
used only in a commented one-line alternative. Install it if you want to try
that line: `brew install jq` (macOS) or your package manager (Linux).
The lab reaches the free public service `https://api.open-meteo.com`, so it
needs network access for a live lookup. With no network it degrades gracefully:
the client prints a clear error and exits non-zero, and the test suite verifies
the parser against the committed `examples/sample-response.json` and skips (never
fails) the live check. No sudo required.
starter/weather-worksheet.md (1161 bytes)
# Weather CLI worksheet — Day 028
Run your completed `weather.sh` and record what it returns. Values change with
the weather and the day — that is expected.
## 1. Pick a location
- Place name: `____________`
- Latitude: `______` Longitude: `______`
(Search "<place> latitude longitude" if you don't know them.)
## 2. Current conditions (network on)
Command: `bash starter/weather.sh <LAT> <LON>`
- Time reported: `____________`
- Temperature: `______ °C`
- Wind: `______ km/h`
## 3. What the script prints when the network is down
Turn off your network (or point the client at a deliberately unreachable URL),
then run the command again and paste the exact output:
```text
____________________________________________________
____________________________________________________
```
- Did the script crash, or fail with a clear message? `____________`
- What exit code did it return? (`echo $?` right after) `______`
## 4. Trace the pipeline (2–3 sentences)
For your successful run in section 2, say which of the six pipeline stages
produced each line of output — the `curl` fetch, the parse, or the
presentation:
> _your answer here_
starter/weather.sh (2970 bytes)
#!/usr/bin/env bash
# Day 028 starter — Build a Weather CLI.
#
# Complete the five exercises below to build the client piece by piece. Each
# names the exact code to write. The finished reference is in
# ../examples/weather.sh — try it yourself first, then compare.
#
# Run from the lab directory:
# bash starter/weather.sh # default location
# bash starter/weather.sh 48.85 2.35 # any latitude / longitude
set -u
DEFAULT_LAT="52.52" # Berlin
DEFAULT_LON="13.41"
LAT="${1:-$DEFAULT_LAT}"
LON="${2:-$DEFAULT_LON}"
API="https://api.open-meteo.com/v1/forecast"
# Exercise 1: build the request URL (the query string).
# A query string starts with ? and joins name=value pairs with &.
# Replace the placeholder with:
# URL="${API}?latitude=${LAT}&longitude=${LON}¤t=temperature_2m,wind_speed_10m"
URL="REPLACE_ME_EXERCISE_1"
# Exercise 2: send the request with curl and capture the reply.
# Replace the placeholder body with a real fetch (quote the URL!):
# body="$(curl -s --max-time 15 "${URL}")"
fetch() {
# Exercise 2 — your turn: return the JSON reply from curl for "${URL}".
echo ""
}
# Exercise 5 (error handling): if the reply is empty, report a clear error.
# This is written for you so the client fails gracefully while you build it.
main() {
local body
body="$(fetch)" || body=""
if [ -z "${body}" ]; then
echo "Error: could not reach the weather service (no network or the request timed out)." >&2
echo "Check your connection and try again." >&2
exit 1
fi
present "${body}"
}
# Exercises 3 & 4: parse the JSON and present the report.
present() {
local body="$1"
WEATHER_JSON="${body}" WEATHER_LAT="${LAT}" WEATHER_LON="${LON}" python3 <<'PY'
import os, sys, json
raw = os.environ.get("WEATHER_JSON", "")
lat = os.environ.get("WEATHER_LAT", "")
lon = os.environ.get("WEATHER_LON", "")
try:
data = json.loads(raw)
except ValueError:
print("Error: the service did not return valid JSON.", file=sys.stderr)
sys.exit(2)
current = data.get("current")
if not isinstance(current, dict):
print("Error: the response did not include current conditions.", file=sys.stderr)
sys.exit(3)
units = data.get("current_units", {})
# Exercise 3: read the temperature from the JSON.
# Replace None below with: current.get("temperature_2m")
temperature = None # <-- Exercise 3
# Exercise 4: read the wind, and handle a MISSING field gracefully.
# Replace None below with: current.get("wind_speed_10m")
# The helper already prints "unavailable" when a value is None.
wind = None # <-- Exercise 4
def show(value, unit_key):
if value is None:
return "unavailable"
unit = units.get(unit_key, "")
return f"{value} {unit}".strip()
print(f"Weather for {lat}, {lon}")
print(f" Time: {current.get('time', 'unavailable')}")
print(f" Temperature: {show(temperature, 'temperature_2m')}")
print(f" Wind: {show(wind, 'wind_speed_10m')}")
PY
}
main "$@"
tests/run_tests.sh (4333 bytes)
#!/usr/bin/env bash
# Tests for the Day 028 lab — Build a Weather CLI. Run from the lab directory:
# bash tests/run_tests.sh
#
# Two kinds of checks:
# * Structure and parse checks always run and must pass. The parse check
# drives the REAL client against a committed sample response
# (examples/sample-response.json) with WEATHER_SAMPLE_FILE, so it needs no
# network yet exercises the actual parser.
# * The live-fetch check runs only when the Open-Meteo API is reachable.
# Offline, it is SKIPPED with a message (never failed).
#
# The script exits 0 when no check failed, whether online or offline.
set -u
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
API="https://api.open-meteo.com/v1/forecast"
example="${lab_dir}/examples/weather.sh"
starter="${lab_dir}/starter/weather.sh"
sample="${lab_dir}/examples/sample-response.json"
failures=0
checks=0
skips=0
pass() { checks=$((checks + 1)); echo " ok: $1"; }
fail() { checks=$((checks + 1)); failures=$((failures + 1)); echo " FAIL: $1"; }
skip() { skips=$((skips + 1)); echo " skip: $1"; }
# Matches a temperature report line with a real number, e.g. " Temperature: 29.5 °C".
has_numeric_temp() {
printf '%s' "$1" | grep -Eq 'Temperature: -?[0-9]+(\.[0-9]+)?'
}
echo "== Structure checks =="
[ -f "${example}" ] && pass "example client exists" || fail "example client missing"
[ -f "${starter}" ] && pass "starter client exists" || fail "starter client missing"
[ -f "${lab_dir}/starter/weather-worksheet.md" ] && pass "worksheet exists" || fail "worksheet missing"
[ -f "${sample}" ] && pass "committed sample response exists" || fail "sample-response.json missing"
# The example must exercise the whole pipeline: endpoint, curl, and a parse.
for needle in "api.open-meteo.com" "curl -s" "current=temperature_2m,wind_speed_10m" "json.loads"; do
if grep -q -- "${needle}" "${example}"; then pass "example uses '${needle}'"; else fail "example missing '${needle}'"; fi
done
# The starter must name its five numbered exercises.
ex_count="$(grep -cE 'Exercise [1-5]' "${starter}" 2>/dev/null || true)"
if [ "${ex_count:-0}" -ge 5 ]; then pass "starter has 5 numbered exercises"; else fail "starter has 5 numbered exercises (found ${ex_count:-0})"; fi
# Both scripts must be valid bash.
if bash -n "${example}" 2>/dev/null; then pass "example has valid bash syntax"; else fail "example has a syntax error"; fi
if bash -n "${starter}" 2>/dev/null; then pass "starter has valid bash syntax"; else fail "starter has a syntax error"; fi
echo
echo "== Parse check (committed sample, no network) =="
# Drive the real client against the saved response; the parser must extract a
# numeric temperature. This proves the parse logic works even offline.
sample_out="$(WEATHER_SAMPLE_FILE="${sample}" bash "${example}" 52.52 13.41 2>/dev/null)" || sample_out=""
if has_numeric_temp "${sample_out}"; then
pass "client parses a numeric temperature from the committed sample"
else
fail "client should parse a numeric temperature from the sample"
fi
# The parser must also degrade a missing field to 'unavailable', not crash.
partial='{"current_units":{"temperature_2m":"°C"},"current":{"time":"t","temperature_2m":1.0}}'
partial_file="$(mktemp)"; printf '%s' "${partial}" > "${partial_file}"
partial_out="$(WEATHER_SAMPLE_FILE="${partial_file}" bash "${example}" 1 2 2>/dev/null)" || partial_out=""
rm -f "${partial_file}"
if printf '%s' "${partial_out}" | grep -q "Wind: unavailable"; then
pass "client reports a missing field as 'unavailable'"
else
fail "client should report a missing field as 'unavailable'"
fi
echo
echo "== Live-fetch check (network) =="
if ! curl -s -o /dev/null --max-time 12 "${API}?latitude=0&longitude=0¤t=temperature_2m" 2>/dev/null; then
skip "no network access — skipping the live fetch (parse check above already passed)"
else
live_out="$(bash "${example}" 52.52 13.41 2>/dev/null)" || live_out=""
if [ -z "${live_out}" ]; then
skip "live fetch — Open-Meteo transiently unavailable; retry later"
elif has_numeric_temp "${live_out}"; then
pass "live client fetched and printed a numeric temperature"
else
fail "live client should print a numeric temperature"
fi
fi
echo
echo "${checks} checks, ${failures} failure(s), ${skips} skip(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 028 lab
The command prints nothing, or only part of the URL is used
The URL contains & and ?. In the shell a bare & means "run in the
background", so an unquoted URL is cut off after the first parameter. Always
wrap the URL in double quotes, exactly as the scripts do:
curl -s "https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤t=temperature_2m,wind_speed_10m"
If the raw curl also prints nothing, your network may be down — add
--max-time 15 so it fails fast instead of hanging.
The temperature comes back as null or unavailable
You asked for a measurement the response does not contain, or the current=
list is misspelled. Field names are case-sensitive and must match the docs:
current=temperature_2m,wind_speed_10m. Run the raw curl and look at the
current object to see exactly which fields the API returned.
jq: command not found
jq is optional in this lab — the client parses with python3, which is
preinstalled. Install jq only to try the commented one-line alternative:
brew install jq (macOS) or your Linux package manager.
python3: command not found
Install Python 3 (free) from your OS package manager or python.org, or run the lab inside WSL on Windows. The parser uses only the standard library, so no extra packages are needed.
The API returns an error object instead of weather
If you send an invalid latitude/longitude, Open-Meteo returns a JSON object
with an error field and no current. The client detects the missing
current object and prints "Error: the response did not include current
conditions." — check that your coordinates are numbers in the valid range
(latitude −90 to 90, longitude −180 to 180).
Offline
The client prints a clear error and exits non-zero when it cannot reach the
service. The test suite skips the live fetch (never fails it) and still passes,
because it verifies the parser against the committed
examples/sample-response.json. Reconnect to see a live lookup.
Security notes
Security notes — Day 028 lab
- No key, no secret. Open-Meteo is a free, no-key API, so this lab has no credential to protect. That is exactly why it is a safe place to learn the request-and-parse loop.
- If you later use a key-based API, keep the key in an environment variable.
As on Day 25, reference it as
$API_KEYand never paste it into the script or the URL. A key hard-coded in a file gets committed and shared with everyone who reads it; a key typed on the command line lands in your shell history and the process list. Prefer sending a key in a header (-H "Authorization: Bearer $API_KEY") over the query string, because URLs are logged by servers and proxies along the way. - The client only sends a location. Each request tells Open-Meteo the latitude and longitude you asked about and your IP address. Repeated lookups of your home coordinates are a small breadcrumb trail — be deliberate about what you send, and read a service's terms to know what it logs.
- Always set a timeout. The client uses
curl --max-time 15so a hung or slow server cannot freeze your script. Do this for every API call you write. - Read a script before running it. Both scripts here are short and
commented; the course's habit is to read any shell script before executing
it. Nothing in this lab needs
sudo.