Computing FoundationsAPIs and the Web › Day 28

Hands-on lab — Day 28: Consuming a Public API from the Command Line

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-the when 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 curl and capture the JSON reply.
  • Parse nested JSON fields (current.temperature_2m, current.wind_speed_10m) with python3 (with the jq equivalent 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 curl and python3 (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 (curl and python3 preinstalled).
  • Windowscurl ships with Windows 10+ and python3 is 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 with curl -s --max-time 15, parses the current object with python3, 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 committed sample-response.json with 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

  1. bash examples/weather.sh 52.52 13.41 prints a numeric temperature and wind.
  2. Passing your own latitude and longitude changes the location in the output.
  3. Complete all five exercises in starter/weather.sh; it produces the same shape of report.
  4. With the network off, the client prints a clear error and exits non-zero.
  5. bash tests/run_tests.sh prints 0 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

  1. Add a third measurement (relative_humidity_2m) to the query string, the parse, and the report.
  2. Make the client resilient to a partial response: if current is present but a field is missing, print unavailable for that line (the reference already does this — confirm it, then extend it to your new field).
  3. Rewrite the fetch-and-parse in a few lines of python3 using only the standard library (urllib.request to fetch, json to parse), and add a comment marking where an Authorization header would go for a key-based API.
  • 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}&current=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}&current=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&current=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&current=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_KEY and 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 15 so 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.