Module 4: Consuming Public APIs

Integrating JSONPlaceholder and Dog CEO

Capsule overview

The architecture is defined, the CLI skeleton works. Now it's time to plug in the first real APIs. You start with the two simplest ones: JSONPlaceholder and Dog CEO. Neither requires authentication, both respond fast, and neither will ever block you with a rate limit. They're the perfect training ground to validate that your BaseAPIClient works before you add the complexity of tokens and API keys.

In this capsule you'll implement complete classes for both APIs, wire them into the CLI with subcommands, and run the CLI end to end. By the end, python cli.py jsonplaceholder posts and python cli.py dogs random will work with real data.


JSONPlaceholder: the complete integration

The JSONPlaceholderClient class

import requests
import json


class BaseAPIClient:
    def __init__(self, base_url, timeout=10):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({
            "Accept": "application/json",
            "User-Agent": "RESTClientCLI/1.0"
        })

    def _build_url(self, path):
        if path.startswith("http"):
            return path
        return f"{self.base_url}/{path.lstrip('/')}"

    def get(self, path, params=None, **kwargs):
        return self._request("GET", path, params=params, **kwargs)

    def post(self, path, **kwargs):
        return self._request("POST", path, **kwargs)

    def _request(self, method, path, **kwargs):
        url = self._build_url(path)
        kwargs.setdefault("timeout", self.timeout)
        try:
            response = self.session.request(method, url, **kwargs)
        except requests.exceptions.ConnectionError:
            return {"success": False, "status": None, "data": None,
                    "error": f"Could not connect to {self.base_url}",
                    "error_type": "CONNECTION"}
        except requests.exceptions.Timeout:
            return {"success": False, "status": None, "data": None,
                    "error": f"Timed out after {kwargs['timeout']}s",
                    "error_type": "TIMEOUT"}
        except requests.exceptions.RequestException as e:
            return {"success": False, "status": None, "data": None,
                    "error": str(e), "error_type": "NETWORK"}
        data = None
        ct = response.headers.get("Content-Type", "")
        if "json" in ct and response.text:
            try:
                data = response.json()
            except Exception:
                return {"success": False, "status": response.status_code,
                        "data": None, "error": "Malformed JSON",
                        "error_type": "JSON_PARSE"}
        if not response.ok:
            msg = f"HTTP {response.status_code} {response.reason}"
            if isinstance(data, dict) and "message" in data:
                msg += f": {data['message']}"
            return {"success": False, "status": response.status_code,
                    "data": data, "error": msg, "error_type": "HTTP_ERROR"}
        return {"success": True, "status": response.status_code,
                "data": data, "error": None, "error_type": None}

    def close(self):
        self.session.close()


class JSONPlaceholderClient(BaseAPIClient):
    """Client for the JSONPlaceholder API — posts, users, comments, todos."""

    def __init__(self):
        super().__init__("https://jsonplaceholder.typicode.com")

    def list_posts(self, limit=10):
        """Lists posts with an optional limit."""
        return self.get("/posts", params={"_limit": limit})

    def get_post(self, post_id):
        """Fetches a post by ID."""
        return self.get(f"/posts/{post_id}")

    def get_post_comments(self, post_id):
        """Fetches a post's comments."""
        return self.get(f"/posts/{post_id}/comments")

    def list_user_posts(self, user_id, limit=5):
        """Lists the posts of a specific user."""
        return self.get(f"/users/{user_id}/posts", params={"_limit": limit})

    def get_user(self, user_id):
        """Fetches a user's info by ID."""
        return self.get(f"/users/{user_id}")

    def list_users(self):
        """Lists all users."""
        return self.get("/users")

    def create_post(self, title, body, user_id=1):
        """Creates a post (simulated — JSONPlaceholder doesn't persist)."""
        return self.post("/posts", json={
            "title": title,
            "body": body,
            "userId": user_id
        })

    def list_todos(self, user_id=None, limit=10):
        """Lists todos, optionally filtered by user."""
        params = {"_limit": limit}
        if user_id:
            params["userId"] = user_id
        return self.get("/todos", params=params)


client = JSONPlaceholderClient()

print("=== JSONPlaceholderClient ===\n")

result = client.list_posts(limit=3)
if result["success"]:
    print("Posts (top 3):")
    for post in result["data"]:
        print(f"  #{post['id']:>3} | {post['title'][:45]}")

print()
result = client.get_post(42)
if result["success"]:
    post = result["data"]
    print(f"Post #{post['id']}:")
    print(f"  Title:  {post['title']}")
    print(f"  Author: userId={post['userId']}")

print()
result = client.get_post_comments(42)
if result["success"]:
    print(f"Comments on post #42: {len(result['data'])}")
    for c in result["data"][:2]:
        print(f"  📧 {c['email']}{c['name'][:35]}")

print()
result = client.create_post("My post from the CLI", "Test content")
if result["success"]:
    print(f"Post created: ID={result['data']['id']} (simulated)")

client.close()

Expected output:

=== JSONPlaceholderClient ===

Posts (top 3):
  #  1 | sunt aut facere repellat provident occaecati
  #  2 | qui est esse
  #  3 | ea molestias quasi exercitationem repellat qu

Post #42:
  Title:  commodi ullam sint et excepturi error explicabo praesentium voluptas
  Author: userId=5

Comments on post #42: 5
  📧 Sophie@antoinette.ca — deserunt eveniet quam vitae velit
  📧 Jessika@crystel.ca — asperiores sed voluptate est

Post created: ID=101 (simulated)

The CLI subcommands for JSONPlaceholder

import argparse
import requests
import json
import sys


class BaseAPIClient:
    def __init__(self, base_url, timeout=10):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({"Accept": "application/json",
                                     "User-Agent": "RESTClientCLI/1.0"})

    def get(self, path, params=None, **kwargs):
        return self._request("GET", path, params=params, **kwargs)

    def post(self, path, **kwargs):
        return self._request("POST", path, **kwargs)

    def _request(self, method, path, **kwargs):
        url = f"{self.base_url}/{path.lstrip('/')}"
        kwargs.setdefault("timeout", self.timeout)
        try:
            response = self.session.request(method, url, **kwargs)
        except requests.exceptions.RequestException as e:
            return {"success": False, "data": None, "error": str(e)}
        data = None
        ct = response.headers.get("Content-Type", "")
        if "json" in ct and response.text:
            try:
                data = response.json()
            except Exception:
                pass
        if not response.ok:
            return {"success": False, "data": data,
                    "error": f"HTTP {response.status_code}"}
        return {"success": True, "data": data, "error": None}

    def close(self):
        self.session.close()


class JSONPlaceholderClient(BaseAPIClient):
    def __init__(self):
        super().__init__("https://jsonplaceholder.typicode.com")

    def list_posts(self, limit=10):
        return self.get("/posts", params={"_limit": limit})

    def get_post(self, post_id):
        return self.get(f"/posts/{post_id}")

    def get_post_comments(self, post_id):
        return self.get(f"/posts/{post_id}/comments")

    def create_post(self, title, body, user_id=1):
        return self.post("/posts", json={"title": title, "body": body,
                                          "userId": user_id})

    def list_todos(self, limit=10):
        return self.get("/todos", params={"_limit": limit})


def cmd_jp_posts(args):
    """Handler: list posts."""
    client = JSONPlaceholderClient()
    result = client.list_posts(limit=args.limit)
    client.close()

    if not result["success"]:
        print(f"❌ Error: {result['error']}")
        return

    print(f"\n📝 Posts (showing {len(result['data'])}):\n")
    for post in result["data"]:
        print(f"  #{post['id']:>3} | userId={post['userId']} | "
              f"{post['title'][:45]}")


def cmd_jp_post(args):
    """Handler: view a post with its comments."""
    client = JSONPlaceholderClient()
    post_result = client.get_post(args.id)

    if not post_result["success"]:
        client.close()
        print(f"❌ Error: {post_result['error']}")
        return

    post = post_result["data"]
    if not post:
        client.close()
        print(f"❌ Post #{args.id} not found")
        return

    comments_result = client.get_post_comments(args.id)
    client.close()

    print(f"\n📝 Post #{post['id']}")
    print(f"{'─' * 50}")
    print(f"  Title:  {post['title']}")
    print(f"  Author: userId={post['userId']}")
    print(f"\n  {post['body']}")

    if comments_result["success"]:
        comments = comments_result["data"]
        print(f"\n  💬 {len(comments)} comments:")
        for c in comments:
            print(f"     {c['email']}")
            print(f"     {c['body'][:60]}...")
            print()


def cmd_jp_todos(args):
    """Handler: list todos."""
    client = JSONPlaceholderClient()
    result = client.list_todos(limit=args.limit)
    client.close()

    if not result["success"]:
        print(f"❌ Error: {result['error']}")
        return

    print(f"\n✅ Todos (showing {len(result['data'])}):\n")
    for todo in result["data"]:
        status = "✅" if todo["completed"] else "⬜"
        print(f"  {status} #{todo['id']:>3} | {todo['title'][:45]}")


print("=== CLI: jsonplaceholder posts --limit 5 ===")

args = argparse.Namespace(api="jsonplaceholder", action="posts", limit=5)
cmd_jp_posts(args)

print("\n\n=== CLI: jsonplaceholder post 1 ===")

args = argparse.Namespace(api="jsonplaceholder", action="post", id=1)
cmd_jp_post(args)

print("\n\n=== CLI: jsonplaceholder todos --limit 5 ===")

args = argparse.Namespace(api="jsonplaceholder", action="todos", limit=5)
cmd_jp_todos(args)

Expected output:

=== CLI: jsonplaceholder posts --limit 5 ===

📝 Posts (showing 5):

  #  1 | userId=1 | sunt aut facere repellat provident occaecati
  #  2 | userId=1 | qui est esse
  #  3 | userId=1 | ea molestias quasi exercitationem repellat qu
  #  4 | userId=1 | eum et est occaecati
  #  5 | userId=1 | nesciunt quas odio


=== CLI: jsonplaceholder post 1 ===

📝 Post #1
──────────────────────────────────────────────────
  Title:  sunt aut facere repellat provident occaecati excepturi optio reprehenderit
  Author: userId=1

  quia et suscipit
suscipit recusandae consequuntur expedita...

  💬 5 comments:
     id labore ex et quam laborum
     laudantium enim quasi est quidem magnam voluptate...


=== CLI: jsonplaceholder todos --limit 5 ===

✅ Todos (showing 5):

  ⬜ #  1 | delectus aut autem
  ✅ #  2 | quis ut nam facilis et officia qui
  ⬜ #  3 | fugiat veniam minus
  ⬜ #  4 | et porro tempora
  ✅ #  5 | laboriosam mollitia et enim quasi

Dog CEO: the complete integration

The DogCEOClient class

import requests
import json


class BaseAPIClient:
    def __init__(self, base_url, timeout=10):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({"Accept": "application/json",
                                     "User-Agent": "RESTClientCLI/1.0"})

    def get(self, path, params=None, **kwargs):
        return self._request("GET", path, params=params, **kwargs)

    def _request(self, method, path, **kwargs):
        url = f"{self.base_url}/{path.lstrip('/')}"
        kwargs.setdefault("timeout", self.timeout)
        try:
            response = self.session.request(method, url, **kwargs)
        except requests.exceptions.RequestException as e:
            return {"success": False, "data": None, "error": str(e)}
        data = None
        ct = response.headers.get("Content-Type", "")
        if "json" in ct and response.text:
            try:
                data = response.json()
            except Exception:
                pass
        if not response.ok:
            return {"success": False, "data": data,
                    "error": f"HTTP {response.status_code}"}
        return {"success": True, "data": data, "error": None}

    def close(self):
        self.session.close()


class DogCEOClient(BaseAPIClient):
    """Client for the Dog CEO API — dog images by breed."""

    def __init__(self):
        super().__init__("https://dog.ceo/api")

    def _extract(self, result):
        """Extracts data from Dog CEO's {message, status} structure."""
        if not result["success"]:
            return result
        data = result["data"]
        if data.get("status") != "success":
            return {"success": False, "data": None,
                    "error": data.get("message", "Unknown Dog CEO error")}
        return {"success": True, "data": data["message"], "error": None}

    def random_image(self, count=1):
        """Fetches 1 or more random images."""
        if count == 1:
            return self._extract(self.get("/breeds/image/random"))
        return self._extract(self.get(f"/breeds/image/random/{count}"))

    def breed_image(self, breed, count=1):
        """Fetches image(s) of a specific breed."""
        if count == 1:
            return self._extract(
                self.get(f"/breed/{breed}/images/random"))
        return self._extract(
            self.get(f"/breed/{breed}/images/random/{count}"))

    def list_breeds(self):
        """Lists all available breeds."""
        return self._extract(self.get("/breeds/list/all"))

    def list_sub_breeds(self, breed):
        """Lists a breed's sub-breeds."""
        return self._extract(self.get(f"/breed/{breed}/list"))

    def breed_images_all(self, breed):
        """All the images of a breed."""
        return self._extract(self.get(f"/breed/{breed}/images"))


client = DogCEOClient()

print("=== DogCEOClient ===\n")

result = client.random_image()
if result["success"]:
    print(f"Random image:")
    print(f"  🐕 {result['data']}")

print()
result = client.random_image(count=3)
if result["success"]:
    print(f"3 random images:")
    for i, url in enumerate(result["data"], 1):
        print(f"  {i}. {url}")

print()
result = client.breed_image("labrador")
if result["success"]:
    print(f"Labrador:")
    print(f"  🐕 {result['data']}")

print()
result = client.list_breeds()
if result["success"]:
    breeds = result["data"]
    total = len(breeds)
    with_subs = sum(1 for v in breeds.values() if v)
    print(f"Breeds: {total} total, {with_subs} with sub-breeds")

    sample = list(breeds.items())[:5]
    for breed, subs in sample:
        if subs:
            print(f"  🐕 {breed}{', '.join(subs)}")
        else:
            print(f"  🐕 {breed}")

print()
result = client.breed_image("nonexistent-breed")
if not result["success"]:
    print(f"Expected error: {result['error']}")

client.close()

Expected output:

=== DogCEOClient ===

Random image:
  🐕 https://images.dog.ceo/breeds/terrier-norwich/n02094258_1003.jpg

3 random images:
  1. https://images.dog.ceo/breeds/mastiff-bull/n02108422_2760.jpg
  2. https://images.dog.ceo/breeds/hound-plott/hhh_plott002.jpg
  3. https://images.dog.ceo/breeds/dingo/n02115641_5765.jpg

Labrador:
  🐕 https://images.dog.ceo/breeds/labrador/n02099712_3456.jpg

Breeds: 98 total, 20 with sub-breeds
  🐕 affenpinscher
  🐕 african
  🐕 airedale
  🐕 akita
  🐕 appenzeller

Expected error: Breed not found (master breed does not exist)

Look at the _extract() method: it adapts Dog CEO's {message, status} structure to the standard {success, data, error} format the rest of the CLI uses. That adaptation happens once, in the class — not in every handler.

The CLI subcommands for Dog CEO

import argparse
import requests
import json


class BaseAPIClient:
    def __init__(self, base_url, timeout=10):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({"Accept": "application/json",
                                     "User-Agent": "RESTClientCLI/1.0"})

    def get(self, path, params=None, **kwargs):
        return self._request("GET", path, params=params, **kwargs)

    def _request(self, method, path, **kwargs):
        url = f"{self.base_url}/{path.lstrip('/')}"
        kwargs.setdefault("timeout", self.timeout)
        try:
            response = self.session.request(method, url, **kwargs)
        except requests.exceptions.RequestException as e:
            return {"success": False, "data": None, "error": str(e)}
        data = None
        ct = response.headers.get("Content-Type", "")
        if "json" in ct and response.text:
            try:
                data = response.json()
            except Exception:
                pass
        if not response.ok:
            return {"success": False, "data": data,
                    "error": f"HTTP {response.status_code}"}
        return {"success": True, "data": data, "error": None}

    def close(self):
        self.session.close()


class DogCEOClient(BaseAPIClient):
    def __init__(self):
        super().__init__("https://dog.ceo/api")

    def _extract(self, result):
        if not result["success"]:
            return result
        data = result["data"]
        if data.get("status") != "success":
            return {"success": False, "data": None,
                    "error": data.get("message", "Dog CEO error")}
        return {"success": True, "data": data["message"], "error": None}

    def random_image(self, count=1):
        if count == 1:
            return self._extract(self.get("/breeds/image/random"))
        return self._extract(self.get(f"/breeds/image/random/{count}"))

    def breed_image(self, breed, count=1):
        if count == 1:
            return self._extract(self.get(f"/breed/{breed}/images/random"))
        return self._extract(
            self.get(f"/breed/{breed}/images/random/{count}"))

    def list_breeds(self):
        return self._extract(self.get("/breeds/list/all"))


def cmd_dogs_random(args):
    """Handler: random dog image."""
    client = DogCEOClient()
    count = getattr(args, "count", 1)
    result = client.random_image(count=count)
    client.close()

    if not result["success"]:
        print(f"❌ Error: {result['error']}")
        return

    print(f"\n🐕 Random dog image:\n")
    if isinstance(result["data"], list):
        for i, url in enumerate(result["data"], 1):
            breed = url.split("/breeds/")[1].split("/")[0] if "/breeds/" in url else "?"
            print(f"  {i}. [{breed}] {url}")
    else:
        url = result["data"]
        breed = url.split("/breeds/")[1].split("/")[0] if "/breeds/" in url else "?"
        print(f"  [{breed}] {url}")


def cmd_dogs_breed(args):
    """Handler: image of a specific breed."""
    client = DogCEOClient()
    result = client.breed_image(args.name)
    client.close()

    if not result["success"]:
        print(f"\n❌ Breed '{args.name}' not found")
        print(f"   Run 'cli.py dogs breeds' to see the available breeds")
        return

    print(f"\n🐕 {args.name}:\n")
    print(f"  {result['data']}")


def cmd_dogs_breeds(args):
    """Handler: list all breeds."""
    client = DogCEOClient()
    result = client.list_breeds()
    client.close()

    if not result["success"]:
        print(f"❌ Error: {result['error']}")
        return

    breeds = result["data"]
    print(f"\n🐕 Available breeds ({len(breeds)} total):\n")

    for breed, subs in sorted(breeds.items()):
        if subs:
            print(f"  {breed}")
            for sub in subs:
                print(f"    └─ {sub}")
        else:
            print(f"  {breed}")


print("=== CLI: dogs random ===")
args = argparse.Namespace(api="dogs", action="random", count=1)
cmd_dogs_random(args)

print("\n\n=== CLI: dogs breed labrador ===")
args = argparse.Namespace(api="dogs", action="breed", name="labrador")
cmd_dogs_breed(args)

print("\n\n=== CLI: dogs breed doesnotexist ===")
args = argparse.Namespace(api="dogs", action="breed", name="doesnotexist")
cmd_dogs_breed(args)

print("\n\n=== CLI: dogs breeds (first 10) ===")
args = argparse.Namespace(api="dogs", action="breeds")
cmd_dogs_breeds(args)

Expected output:

=== CLI: dogs random ===

🐕 Random dog image:

  [retriever-golden] https://images.dog.ceo/breeds/retriever-golden/n02099601_1234.jpg


=== CLI: dogs breed labrador ===

🐕 labrador:

  https://images.dog.ceo/breeds/labrador/n02099712_5678.jpg


=== CLI: dogs breed doesnotexist ===

❌ Breed 'doesnotexist' not found
   Run 'cli.py dogs breeds' to see the available breeds


=== CLI: dogs breeds (first 10) ===

🐕 Available breeds (98 total):

  affenpinscher
  african
  airedale
  akita
  appenzeller
  australian
    └─ kelpie
    └─ shepherd
  ...

Wiring both APIs into the dispatcher

With both clients implemented, the dispatch table gets updated to replace the placeholders:

import argparse
import requests
import json
import sys


class BaseAPIClient:
    def __init__(self, base_url, timeout=10):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({"Accept": "application/json",
                                     "User-Agent": "RESTClientCLI/1.0"})

    def get(self, path, params=None, **kwargs):
        return self._request("GET", path, params=params, **kwargs)

    def post(self, path, **kwargs):
        return self._request("POST", path, **kwargs)

    def _request(self, method, path, **kwargs):
        url = f"{self.base_url}/{path.lstrip('/')}"
        kwargs.setdefault("timeout", self.timeout)
        try:
            response = self.session.request(method, url, **kwargs)
        except requests.exceptions.RequestException as e:
            return {"success": False, "data": None, "error": str(e)}
        data = None
        ct = response.headers.get("Content-Type", "")
        if "json" in ct and response.text:
            try:
                data = response.json()
            except Exception:
                pass
        if not response.ok:
            return {"success": False, "data": data,
                    "error": f"HTTP {response.status_code}"}
        return {"success": True, "data": data, "error": None}

    def close(self):
        self.session.close()


class JSONPlaceholderClient(BaseAPIClient):
    def __init__(self):
        super().__init__("https://jsonplaceholder.typicode.com")

    def list_posts(self, limit=10):
        return self.get("/posts", params={"_limit": limit})

    def get_post(self, post_id):
        return self.get(f"/posts/{post_id}")

    def get_post_comments(self, post_id):
        return self.get(f"/posts/{post_id}/comments")


class DogCEOClient(BaseAPIClient):
    def __init__(self):
        super().__init__("https://dog.ceo/api")

    def _extract(self, result):
        if not result["success"]:
            return result
        data = result["data"]
        if data.get("status") != "success":
            return {"success": False, "data": None,
                    "error": data.get("message", "Dog CEO error")}
        return {"success": True, "data": data["message"], "error": None}

    def random_image(self):
        return self._extract(self.get("/breeds/image/random"))

    def breed_image(self, breed):
        return self._extract(self.get(f"/breed/{breed}/images/random"))

    def list_breeds(self):
        return self._extract(self.get("/breeds/list/all"))


def cmd_jp_posts(args):
    client = JSONPlaceholderClient()
    result = client.list_posts(limit=args.limit)
    client.close()
    if not result["success"]:
        print(f"❌ {result['error']}")
        return
    print(f"\n📝 Posts ({len(result['data'])}):\n")
    for p in result["data"]:
        print(f"  #{p['id']:>3} | {p['title'][:45]}")

def cmd_jp_post(args):
    client = JSONPlaceholderClient()
    result = client.get_post(args.id)
    client.close()
    if not result["success"]:
        print(f"❌ {result['error']}")
        return
    p = result["data"]
    print(f"\n📝 Post #{p['id']}: {p['title']}")
    print(f"   {p['body'][:80]}...")

def cmd_dogs_random(args):
    client = DogCEOClient()
    result = client.random_image()
    client.close()
    if not result["success"]:
        print(f"❌ {result['error']}")
        return
    print(f"\n🐕 {result['data']}")

def cmd_dogs_breed(args):
    client = DogCEOClient()
    result = client.breed_image(args.name)
    client.close()
    if not result["success"]:
        print(f"❌ Breed '{args.name}' not found")
        return
    print(f"\n🐕 {args.name}: {result['data']}")

def cmd_dogs_breeds(args):
    client = DogCEOClient()
    result = client.list_breeds()
    client.close()
    if not result["success"]:
        print(f"❌ {result['error']}")
        return
    breeds = result["data"]
    print(f"\n🐕 Breeds ({len(breeds)}):")
    for b in sorted(breeds.keys())[:10]:
        print(f"  - {b}")
    print(f"  ... and {len(breeds) - 10} more")


DISPATCH = {
    ("jsonplaceholder", "posts"): cmd_jp_posts,
    ("jsonplaceholder", "post"):  cmd_jp_post,
    ("dogs", "random"):           cmd_dogs_random,
    ("dogs", "breed"):            cmd_dogs_breed,
    ("dogs", "breeds"):           cmd_dogs_breeds,
}


def dispatch(args):
    key = (args.api, args.action)
    handler = DISPATCH.get(key)
    if handler:
        handler(args)
    else:
        print(f"🚧 {args.api}/{args.action} not implemented")


print("=== Integrated CLI: 2 APIs working ===\n")

commands = [
    argparse.Namespace(api="jsonplaceholder", action="posts", limit=3),
    argparse.Namespace(api="dogs", action="random"),
    argparse.Namespace(api="dogs", action="breeds"),
    argparse.Namespace(api="github", action="user", username="octocat"),
]

for args in commands:
    print(f"--- cli.py {args.api} {args.action} ---")
    dispatch(args)
    print()

Expected output:

=== Integrated CLI: 2 APIs working ===

--- cli.py jsonplaceholder posts ---

📝 Posts (3):

  #  1 | sunt aut facere repellat provident occaecati
  #  2 | qui est esse
  #  3 | ea molestias quasi exercitationem repellat qu

--- cli.py dogs random ---

🐕 https://images.dog.ceo/breeds/hound-afghan/n02088094_1234.jpg

--- cli.py dogs breeds ---

🐕 Breeds (98):
  - affenpinscher
  - african
  - airedale
  - akita
  - appenzeller
  - australian
  - basenji
  - beagle
  - bluetick
  - borzoi
  ... and 88 more

--- cli.py github user octocat ---
🚧 github/user not implemented

The first 2 APIs work. GitHub, Weather, and Countries show "not implemented" — those get added in the next capsules.


Comparison: JSONPlaceholder vs Dog CEO

Aspect               │ JSONPlaceholder              │ Dog CEO
─────────────────────┼──────────────────────────────┼───────────────────────────
JSON structure       │ Standard: {id, title, body}  │ Non-standard: {message, status}
Root response        │ Array or direct object       │ Always a wrapper object
Useful data          │ In the response itself       │ Inside data["message"]
HTTP methods         │ GET, POST, PUT, PATCH, DELETE│ GET only
Auth                 │ None                         │ None
Adaptation needed    │ Minimal                      │ _extract() to normalize
Data types           │ Strings, ints                │ Image URLs, dicts
Errors               │ HTTP 404                     │ {status: "error", message: ...}

The key difference: JSONPlaceholder follows REST conventions, Dog CEO doesn't. The _extract() method in DogCEOClient is the fix — it normalizes the response so the rest of the CLI never needs to know that Dog CEO uses message instead of returning data directly.


Connection to the project

With these 2 APIs integrated, your CLI already consumes real data. The pattern is proven:

BaseAPIClient       → Inheritance
    ↓
Child APIClient     → Business methods + response adaptation
    ↓
CLI handler         → Formats and shows it to the user
    ↓
Dispatch table      → Connects (api, action) → handler

The next APIs follow exactly the same pattern. Capsule 05 adds GitHub (with a token and pagination). Capsule 06 adds OpenWeather (with an API key) and REST Countries (with nested JSON).


Troubleshooting

Problem 1: JSONPlaceholder returns an empty dict for a post that doesn't exist

Cause: JSONPlaceholder returns {} (an empty object) with status 200 for nonexistent IDs. It doesn't return 404.

Solution: Check that the response actually has data:

result = client.get_post(99999)
if result["success"] and not result["data"]:
    print("Post not found")
elif result["success"]:
    print(f"Post: {result['data']['title']}")

Problem 2: Dog CEO errors out on a breed with spaces

Cause: Breeds in Dog CEO are lowercase, without spaces, and sub-breeds are separated with /.

Solution:

# ❌ Wrong
client.breed_image("golden retriever")

# ✅ Correct — it's a sub-breed of "retriever"
client.breed_image("retriever/golden")

# ✅ Or if you aren't sure, look it up first
breeds = client.list_breeds()
# Search for "retriever" in the keys

Problem 3: The handler doesn't get the arguments you expected

Cause: The argparse Namespace doesn't have the attribute you expect, probably because the parser never defined it.

Solution: Use getattr() with a default:

limit = getattr(args, "limit", 10)  # 10 if args has no .limit

Problem 4: Multiple client instances stay open

Cause: You create the client in the handler but don't close it if an error happens before the close().

Solution: Use try/finally or a context manager:

def cmd_jp_posts(args):
    client = JSONPlaceholderClient()
    try:
        result = client.list_posts(limit=args.limit)
        if result["success"]:
            for p in result["data"]:
                print(f"  #{p['id']} {p['title'][:45]}")
    finally:
        client.close()

Exercises

Exercise 1: JSONPlaceholder users (Easy)

Add a cmd_jp_users handler that lists JSONPlaceholder's 10 users showing: ID, name, email, and city. Use the JSONPlaceholderClient.

See solution
import requests


class BaseAPIClient:
    def __init__(self, base_url, timeout=10):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({"Accept": "application/json"})

    def get(self, path, params=None, **kwargs):
        url = f"{self.base_url}/{path.lstrip('/')}"
        kwargs.setdefault("timeout", self.timeout)
        try:
            r = self.session.request("GET", url, params=params, **kwargs)
        except requests.exceptions.RequestException as e:
            return {"success": False, "data": None, "error": str(e)}
        data = None
        if r.text and "json" in r.headers.get("Content-Type", ""):
            try:
                data = r.json()
            except Exception:
                pass
        if not r.ok:
            return {"success": False, "data": data,
                    "error": f"HTTP {r.status_code}"}
        return {"success": True, "data": data, "error": None}

    def close(self):
        self.session.close()


class JSONPlaceholderClient(BaseAPIClient):
    def __init__(self):
        super().__init__("https://jsonplaceholder.typicode.com")

    def list_users(self):
        return self.get("/users")


def cmd_jp_users():
    client = JSONPlaceholderClient()
    result = client.list_users()
    client.close()

    if not result["success"]:
        print(f"❌ {result['error']}")
        return

    print(f"\n👥 Users ({len(result['data'])}):\n")
    print(f"  {'ID':>3}  {'Name':<25} {'Email':<30} {'City':<15}")
    print(f"  {'─'*3}  {'─'*25} {'─'*30} {'─'*15}")

    for user in result["data"]:
        city = user["address"]["city"]
        print(f"  {user['id']:>3}  {user['name']:<25} "
              f"{user['email']:<30} {city:<15}")


cmd_jp_users()

Explanation: The /users endpoint returns the 10 users. The city is nested in user["address"]["city"] — it's the first example of extracting nested data in JSONPlaceholder.

Exercise 2: Dog CEO with a count (Easy)

Add a --count parameter to the dogs random subcommand that lets you fetch multiple images. Default: 1. Maximum: 10.

See solution
import argparse
import requests


class BaseAPIClient:
    def __init__(self, base_url, timeout=10):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({"Accept": "application/json"})

    def get(self, path, **kwargs):
        url = f"{self.base_url}/{path.lstrip('/')}"
        kwargs.setdefault("timeout", self.timeout)
        try:
            r = self.session.request("GET", url, **kwargs)
        except requests.exceptions.RequestException as e:
            return {"success": False, "data": None, "error": str(e)}
        data = None
        if r.text and "json" in r.headers.get("Content-Type", ""):
            try:
                data = r.json()
            except Exception:
                pass
        if not r.ok:
            return {"success": False, "data": data,
                    "error": f"HTTP {r.status_code}"}
        return {"success": True, "data": data, "error": None}

    def close(self):
        self.session.close()


class DogCEOClient(BaseAPIClient):
    def __init__(self):
        super().__init__("https://dog.ceo/api")

    def random_image(self, count=1):
        if count == 1:
            r = self.get("/breeds/image/random")
        else:
            r = self.get(f"/breeds/image/random/{count}")
        if r["success"] and r["data"].get("status") == "success":
            return {"success": True, "data": r["data"]["message"],
                    "error": None}
        return {"success": False, "data": None,
                "error": "Dog CEO error"}


def cmd_dogs_random(args):
    count = min(args.count, 10)
    client = DogCEOClient()
    result = client.random_image(count=count)
    client.close()

    if not result["success"]:
        print(f"❌ {result['error']}")
        return

    print(f"\n🐕 Random images ({count}):\n")
    if isinstance(result["data"], list):
        for i, url in enumerate(result["data"], 1):
            breed = url.split("/breeds/")[1].split("/")[0]
            print(f"  {i}. [{breed}] {url}")
    else:
        breed = result["data"].split("/breeds/")[1].split("/")[0]
        print(f"  [{breed}] {result['data']}")


print("=== 1 image ===")
args = argparse.Namespace(count=1)
cmd_dogs_random(args)

print("\n=== 4 images ===")
args = argparse.Namespace(count=4)
cmd_dogs_random(args)

Explanation: When count=1, Dog CEO returns a string (one URL). When count>1, it returns an array of strings. The handler detects whether it's a list or a string with isinstance(). The min(args.count, 10) caps the maximum at 10 so you don't abuse the API.

Exercise 3: Complete JSONPlaceholder CRUD (Medium)

Implement the 4 CRUD handlers for posts: create, read, update (PATCH), and delete. The create handler should accept --title and --body. The update one should accept the ID and --title. The delete one just the ID.

See solution
import argparse
import requests


class BaseAPIClient:
    def __init__(self, base_url, timeout=10):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({"Accept": "application/json"})

    def _request(self, method, path, **kwargs):
        url = f"{self.base_url}/{path.lstrip('/')}"
        kwargs.setdefault("timeout", self.timeout)
        try:
            r = self.session.request(method, url, **kwargs)
        except requests.exceptions.RequestException as e:
            return {"success": False, "data": None, "error": str(e)}
        data = None
        if r.text and "json" in r.headers.get("Content-Type", ""):
            try:
                data = r.json()
            except Exception:
                pass
        if not r.ok:
            return {"success": False, "data": data,
                    "error": f"HTTP {r.status_code}"}
        return {"success": True, "data": data, "error": None,
                "status": r.status_code}

    def get(self, path, **kw):    return self._request("GET", path, **kw)
    def post(self, path, **kw):   return self._request("POST", path, **kw)
    def patch(self, path, **kw):  return self._request("PATCH", path, **kw)
    def delete(self, path, **kw): return self._request("DELETE", path, **kw)
    def close(self):              self.session.close()


class JSONPlaceholderClient(BaseAPIClient):
    def __init__(self):
        super().__init__("https://jsonplaceholder.typicode.com")

    def create_post(self, title, body, user_id=1):
        return self.post("/posts", json={"title": title, "body": body,
                                          "userId": user_id})

    def get_post(self, post_id):
        return self.get(f"/posts/{post_id}")

    def update_post(self, post_id, title):
        return self.patch(f"/posts/{post_id}", json={"title": title})

    def delete_post(self, post_id):
        return self.delete(f"/posts/{post_id}")


def cmd_create(args):
    client = JSONPlaceholderClient()
    result = client.create_post(args.title, args.body)
    client.close()
    if result["success"]:
        d = result["data"]
        print(f"\n✅ Post created (simulated)")
        print(f"   ID:    {d['id']}")
        print(f"   Title: {d['title']}")
    else:
        print(f"❌ {result['error']}")

def cmd_read(args):
    client = JSONPlaceholderClient()
    result = client.get_post(args.id)
    client.close()
    if result["success"] and result["data"]:
        p = result["data"]
        print(f"\n📝 Post #{p['id']}")
        print(f"   Title: {p['title']}")
        print(f"   Body:  {p['body'][:80]}...")
    else:
        print(f"❌ Post #{args.id} not found")

def cmd_update(args):
    client = JSONPlaceholderClient()
    result = client.update_post(args.id, args.title)
    client.close()
    if result["success"]:
        print(f"\n✅ Post #{args.id} updated")
        print(f"   New title: {result['data']['title']}")
    else:
        print(f"❌ {result['error']}")

def cmd_delete(args):
    client = JSONPlaceholderClient()
    result = client.delete_post(args.id)
    client.close()
    if result["success"]:
        print(f"\n✅ Post #{args.id} deleted")
    else:
        print(f"❌ {result['error']}")


print("=== Complete CRUD ===")

print("\n--- CREATE ---")
args = argparse.Namespace(title="From the CLI", body="Test content")
cmd_create(args)

print("\n--- READ ---")
args = argparse.Namespace(id=1)
cmd_read(args)

print("\n--- UPDATE ---")
args = argparse.Namespace(id=1, title="Title updated via the CLI")
cmd_update(args)

print("\n--- DELETE ---")
args = argparse.Namespace(id=1)
cmd_delete(args)

Explanation: Every CRUD operation has its handler. JSONPlaceholder simulates the operations — POST returns ID 101, PATCH returns the updated object, DELETE returns {}. In a real API, the changes would persist. The handler → client → API pattern is consistent across all 4 cases.

Exercise 4: Dog CEO breed search (Medium)

Write a handler that searches for breeds containing a given text. For example, dogs search terrier should show every breed containing "terrier" (as a main breed or a sub-breed).

See solution
import argparse
import requests


class BaseAPIClient:
    def __init__(self, base_url, timeout=10):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({"Accept": "application/json"})

    def get(self, path, **kwargs):
        url = f"{self.base_url}/{path.lstrip('/')}"
        kwargs.setdefault("timeout", self.timeout)
        try:
            r = self.session.request("GET", url, **kwargs)
        except requests.exceptions.RequestException as e:
            return {"success": False, "data": None, "error": str(e)}
        data = None
        if r.text and "json" in r.headers.get("Content-Type", ""):
            try:
                data = r.json()
            except Exception:
                pass
        if not r.ok:
            return {"success": False, "data": data,
                    "error": f"HTTP {r.status_code}"}
        return {"success": True, "data": data, "error": None}

    def close(self):
        self.session.close()


class DogCEOClient(BaseAPIClient):
    def __init__(self):
        super().__init__("https://dog.ceo/api")

    def list_breeds(self):
        r = self.get("/breeds/list/all")
        if r["success"] and r["data"].get("status") == "success":
            return {"success": True, "data": r["data"]["message"],
                    "error": None}
        return {"success": False, "data": None, "error": "Error"}


def cmd_dogs_search(args):
    client = DogCEOClient()
    result = client.list_breeds()
    client.close()

    if not result["success"]:
        print(f"❌ {result['error']}")
        return

    query = args.query.lower()
    matches = []

    for breed, subs in result["data"].items():
        if query in breed:
            matches.append({"breed": breed, "type": "main breed",
                            "full_name": breed})
        for sub in subs:
            if query in sub:
                matches.append({"breed": breed, "type": "sub-breed",
                                "full_name": f"{breed}/{sub}"})

    if not matches:
        print(f"\n❌ No breeds found matching '{query}'")
        return

    print(f"\n🔍 Breeds containing '{query}': {len(matches)}\n")
    for m in matches:
        print(f"  🐕 {m['full_name']:<30} ({m['type']})")


print("=== Search 'terrier' ===")
cmd_dogs_search(argparse.Namespace(query="terrier"))

print("\n=== Search 'bull' ===")
cmd_dogs_search(argparse.Namespace(query="bull"))

print("\n=== Search 'xyz' ===")
cmd_dogs_search(argparse.Namespace(query="xyz"))

Explanation: The search happens locally after fetching every breed. It looks in main breeds as well as sub-breeds. The result includes the full name (so you can use it with breed_image()) and whether it's a main breed or a sub-breed.

Exercise 5: Integration testing (Hard)

Write a test_integration() function that automatically verifies both APIs work correctly. For each API, make at least 3 requests and check that: they return success: True, the data isn't None, and the expected fields exist. Print a test-runner style report.

See solution
import requests
import time


class BaseAPIClient:
    def __init__(self, base_url, timeout=10):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({"Accept": "application/json"})

    def get(self, path, params=None, **kwargs):
        url = f"{self.base_url}/{path.lstrip('/')}"
        kwargs.setdefault("timeout", self.timeout)
        try:
            r = self.session.request("GET", url, params=params, **kwargs)
        except requests.exceptions.RequestException as e:
            return {"success": False, "data": None, "error": str(e)}
        data = None
        if r.text and "json" in r.headers.get("Content-Type", ""):
            try:
                data = r.json()
            except Exception:
                pass
        if not r.ok:
            return {"success": False, "data": data,
                    "error": f"HTTP {r.status_code}"}
        return {"success": True, "data": data, "error": None}

    def close(self):
        self.session.close()


def run_test(name, test_fn):
    """Runs a test and reports the result."""
    start = time.time()
    try:
        passed, detail = test_fn()
        ms = (time.time() - start) * 1000
        icon = "✅" if passed else "❌"
        print(f"  {icon} {name:<45} {ms:>6.0f}ms  {detail}")
        return passed
    except Exception as e:
        ms = (time.time() - start) * 1000
        print(f"  💥 {name:<45} {ms:>6.0f}ms  Exception: {e}")
        return False


def test_integration():
    """Integration test suite for JSONPlaceholder and Dog CEO."""
    jp = BaseAPIClient("https://jsonplaceholder.typicode.com")
    dog = BaseAPIClient("https://dog.ceo/api")

    results = []

    print(f"\n{'=' * 70}")
    print(f"  Integration Tests — JSONPlaceholder & Dog CEO")
    print(f"{'=' * 70}\n")

    print("  JSONPlaceholder:")

    def test_jp_posts():
        r = jp.get("/posts", params={"_limit": 5})
        if not r["success"]:
            return False, r["error"]
        if not isinstance(r["data"], list) or len(r["data"]) != 5:
            return False, f"Expected 5 posts, got {len(r['data'])}"
        if "title" not in r["data"][0]:
            return False, "Post without a 'title' field"
        return True, f"{len(r['data'])} posts with the right fields"
    results.append(run_test("GET /posts?_limit=5", test_jp_posts))

    def test_jp_post_by_id():
        r = jp.get("/posts/1")
        if not r["success"]:
            return False, r["error"]
        if r["data"]["id"] != 1:
            return False, f"Wrong ID: {r['data']['id']}"
        return True, f"Post #{r['data']['id']}: {r['data']['title'][:25]}"
    results.append(run_test("GET /posts/1", test_jp_post_by_id))

    def test_jp_users():
        r = jp.get("/users")
        if not r["success"]:
            return False, r["error"]
        if len(r["data"]) != 10:
            return False, f"Expected 10 users, got {len(r['data'])}"
        if "address" not in r["data"][0]:
            return False, "User without an 'address' field"
        return True, f"{len(r['data'])} users with a nested address"
    results.append(run_test("GET /users", test_jp_users))

    print("\n  Dog CEO:")

    def test_dog_random():
        r = dog.get("/breeds/image/random")
        if not r["success"]:
            return False, r["error"]
        if r["data"].get("status") != "success":
            return False, "API status is not 'success'"
        url = r["data"]["message"]
        if not url.startswith("https://"):
            return False, f"Invalid URL: {url}"
        return True, f"URL: ...{url[-40:]}"
    results.append(run_test("GET /breeds/image/random", test_dog_random))

    def test_dog_breeds():
        r = dog.get("/breeds/list/all")
        if not r["success"]:
            return False, r["error"]
        breeds = r["data"]["message"]
        if not isinstance(breeds, dict) or len(breeds) < 50:
            return False, f"Expected 50+ breeds, got {len(breeds)}"
        return True, f"{len(breeds)} breeds found"
    results.append(run_test("GET /breeds/list/all", test_dog_breeds))

    def test_dog_breed_specific():
        r = dog.get("/breed/labrador/images/random")
        if not r["success"]:
            return False, r["error"]
        if r["data"].get("status") != "success":
            return False, "API status is not 'success'"
        if "labrador" not in r["data"]["message"]:
            return False, "URL doesn't contain 'labrador'"
        return True, "URL contains the right breed"
    results.append(run_test("GET /breed/labrador/images/random",
                            test_dog_breed_specific))

    jp.close()
    dog.close()

    passed = sum(results)
    total = len(results)
    print(f"\n{'=' * 70}")
    print(f"  Result: {passed}/{total} tests passed "
          f"{'✅ ALL OK' if passed == total else '❌ FAILURES'}")
    print(f"{'=' * 70}")


test_integration()

Explanation: Each test checks: 1) that the request succeeded, 2) that the data isn't None, 3) that the expected fields exist with the right types. The runner times each test and prints a summary. This manual testing pattern serves you well before you have automated tests with pytest.


Summary

  • JSONPlaceholderClient inherits from BaseAPIClient and exposes clean methods: list_posts(), get_post(), create_post(), etc.
  • DogCEOClient uses _extract() to normalize Dog CEO's {message, status} structure into the CLI's standard format
  • Every handler follows the pattern: create the client → call the method → close the client → format the output
  • The dispatch table maps (api, action) to handler functions — adding a new API means adding entries to the dict
  • JSONPlaceholder is full CRUD with flat JSON; Dog CEO is read-only with a non-standard structure — both integrated without duplicating HTTP logic
  • The unimplemented handlers show a clear message instead of crashing — your CLI is usable from the very first moment

Additional resources

  1. JSONPlaceholder Guide — Every endpoint with usage examples
  2. Dog CEO API Docs — Endpoints by breed, sub-breed, and images
  3. Python argparse Namespace — Reference for the object parse_args() returns
  4. Python isinstance() — Type checks to tell a list from a string in responses
  5. Requests: Session.close() — Cleaning up HTTP session resources

Next capsule: Integrating the GitHub API — authentication with a Bearer token, pagination with Link headers, rate limiting, and nested JSON objects.