DEV Community

Uya
Uya

Posted on • Originally published at zenn.dev

Building an HTTP Client CLI for Your Own API with TypeScript — FastAPI, POST, and Error Handling

Introduction

This is my seventh article as a Java engineer learning TypeScript from scratch.

In my previous article, I built a CLI that calls an external weather API (Open-Meteo) with fetch, and learned about async/await, response type definitions, and error handling with response.ok. But that was "read-only (GET)" against "someone else's API." This time I go a step further: I build the API server myself and then build an HTTP client that calls it.

  • Backend: a REST API server built with FastAPI (Python)
  • Client: a TypeScript CLI that calls that API

What I focused on this time:

  • Implementing GET and POST with fetch (previously GET only)
  • The Content-Type: application/json header on POST (a 422 error I fixed myself)
  • How where you place try-catch changes behavior (continue vs. exit)
  • The difference between return and throw error (close to Java's throws)
  • Narrowing the unknown type with error instanceof Error
  • FastAPI basics (Spring-like decorators, BaseModel, HTTPException)

Same as always — I write honestly about where I got stuck, what I thought through, and what I asked AI for.

📝 Scope of this article

This article centers on the TypeScript client. The Python (FastAPI) side is introduced compactly, as "the thing the client calls."


My Learning Style (AI Transparency)

💡 Learning companions

I use Claude Pro (design discussions and Q&A) and Cursor Pro (coding support) as learning companions.

My rules:

  • I write all the code myself — I never ask AI to write code for me
  • AI helps with hints, spec clarification, and bug spotting
  • I make sure I understand why something works before moving on

In this article, I clearly separate "what I implemented myself" from "what I asked AI for."


What I Built

Pick a menu option in the CLI, and it gets a list of items, gets an item by ID, or adds an item — all against my own API server.

Custom API Call CLI tool
================================
1. Get all items
2. Get item by ID
3. Add an item
4. Exit
================================
Choose: 1
[
  { id: 1, name: 'apple', price: 100 },
  { id: 2, name: 'banana', price: 200 },
  { id: 3, name: 'cherry', price: 300 }
]
Enter fullscreen mode Exit fullscreen mode

Data is held in memory on the server, so it resets when the server restarts (a deliberate simplification for local learning).

📦 Repositories


System Overview

This project is split across two repositories. The roles are simply "server and client."

[ TypeScript CLI ]  --- HTTP (fetch) --->  [ FastAPI server ]
  simple_api_ts                              simple_api_py
  - GET  /items                              - holds items in memory
  - GET  /items/{id}                         - accepts GET / POST
  - POST /items                              - returns 404 via HTTPException
Enter fullscreen mode Exit fullscreen mode

The endpoint spec:

Method Path Description
GET /items Returns all items
GET /items/{id} Returns one item (404 if not found)
POST /items Adds an item (id auto-assigned by the server)

Backend: FastAPI Server (Python)

First I need something to call. This was my first time touching FastAPI, but to an ex-Java engineer's eyes it looked remarkably close to a Spring Boot controller.

Setup

python -m venv venv               # project-local isolated environment
.\venv\Scripts\Activate.ps1       # activate (Windows PowerShell)
pip install fastapi uvicorn       # install packages
pip freeze > requirements.txt     # record dependencies
uvicorn main:app --reload         # start the dev server
Enter fullscreen mode Exit fullscreen mode

I understood venv as "per-project isolation," close to a Maven local repository or Node's node_modules, and uvicorn main:app --reload as the dev server equivalent of TypeScript's ts-node.

Implementing the endpoints

Routes are defined in main.py. The decorator feel is just like Spring.

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

# internal / response use (has id)
class Item(BaseModel):
    id: int
    name: str
    price: int

# request-receiving use (no id — auto-assigned by server)
class ItemCreate(BaseModel):
    name: str
    price: int

items = [
    Item(id=1, name="apple", price=100),
    Item(id=2, name="banana", price=200),
    Item(id=3, name="cherry", price=300),
]

@app.get("/items")
def get_items():
    return items

@app.get("/items/{item_id}")
def get_item(item_id: int):
    for item in items:
        if item.id == item_id:
            return item
    raise HTTPException(status_code=404, detail="Item not found")

@app.post("/items")
def create_item(item: ItemCreate):
    new_id = len(items) + 1
    new_item = Item(id=new_id, name=item.name, price=item.price)
    items.append(new_item)
    return new_item
Enter fullscreen mode Exit fullscreen mode

Splitting RequestDTO and ResponseDTO

The main design decision was separating the receiving type (ItemCreate) from the internal/return type (Item).

  • ItemCreate: only name and price. No id (the client shouldn't send it)
  • Item: includes id. The server assigns it

Avoiding "id can go in either one" and making each type's responsibility clear — I realized this is the same idea as separating RequestDTO and ResponseDTO in Java.

Java mapping (FastAPI to Spring)

FastAPI / Python Equivalent Java concept
@app.get("/items") @GetMapping("/items")
@app.post("/items") @PostMapping("/items")
{item_id} path parameter @PathVariable
BaseModel DTO class
item: ItemCreate (typed argument) @RequestBody
HTTPException ResponseStatusException
raise throw
len(items) .size()
uvicorn main:app --reload ts-node
/docs (auto Swagger UI) manual setup like SpringFox

The biggest surprise was that just visiting /docs auto-generates a Swagger UI — coming from setting that up by hand, that felt like magic.

That's the server done. Now for the main event: the TypeScript client.


Client: TypeScript CLI

types.ts — Mirror the server's types

The client types mirror the server's DTOs. Dropping id from ItemCreate follows the same reasoning.

export interface Item {
  id: number;
  name: string;
  price: number;
}

export interface ItemCreate {
  // no id — the server auto-assigns it
  name: string;
  price: number;
}
Enter fullscreen mode Exit fullscreen mode

📝 An interface closes with }, not };

At first I wrote the end of the interface as };. The correct form is } (watch the difference from a class or a function expression). The trailing ; on properties works with or without it, but by TypeScript convention it's common to include it.

api.ts — GET and POST with fetch

Previously I only did GET; this time I add POST too.

import { Item, ItemCreate } from "./types";

const API_ENDPOINT = "http://localhost:8000";

export async function getItems(): Promise<Item[]> {
  const response = await fetch(`${API_ENDPOINT}/items`);
  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }
  return await response.json();
}

export async function getItemById(id: number): Promise<Item> {
  const response = await fetch(`${API_ENDPOINT}/items/${id}`);
  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }
  return await response.json();
}

export async function createItem(inputItem: ItemCreate): Promise<Item> {
  const response = await fetch(`${API_ENDPOINT}/items`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(inputItem),
  });
  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }
  return await response.json();
}
Enter fullscreen mode Exit fullscreen mode

Two things I improved from AI feedback.

1. Collect the base URL into a constant

At first I hardcoded "http://localhost:8000" in all three functions. I pulled it into a single const API_ENDPOINT to make changes easier.

2. The POST body is fine as JSON.stringify(inputItem)

At first I expanded the fields: JSON.stringify({ name: inputItem.name, price: inputItem.price }). But since inputItem is the ItemCreate type and is guaranteed to hold only name and price, JSON.stringify(inputItem) is enough. The type makes the code shorter — a nice example.

index.ts — Interactive menu and input validation

I wrap readline in a Promise and run a menu loop with while (true) + switch (an application of what I built up in previous projects).

const id = await askQuestion("Enter ID: ");
if (isNaN(parseInt(id))) {
  console.log("Please enter a number");
  continue;
}
Enter fullscreen mode Exit fullscreen mode

The important part here is where to place try-catch (more below). In "get item by ID" (case "2"), the CLI was exiting entirely when the server returned 404, so I added try-catch inside the case to keep the loop going.

case "2": {
  const id = await askQuestion("Enter ID: ");
  if (isNaN(parseInt(id))) {
    console.log("Please enter a number");
    break;
  }
  try {
    const item = await getItemById(parseInt(id));
    console.log(item);
  } catch (error) {
    if (error instanceof Error && error.message.includes("404")) {
      console.log("Item not found");
    } else {
      console.error(error);
      throw error; // delegate unexpected errors to main().catch()
    }
  }
  break;
}
Enter fullscreen mode Exit fullscreen mode

api.test.ts — Mocking fetch (success and failure)

Seven tests total. I reuse jest.spyOn(global, "fetch") from last time. This time, for the failure case, I used mockRejectedValue to reproduce fetch itself failing.

test("throws when the API returns an error", async () => {
  jest.spyOn(global, "fetch").mockRejectedValue(new Error("network error"));
  await expect(getItems()).rejects.toThrow();
});

afterEach(() => {
  jest.restoreAllMocks();
});
Enter fullscreen mode Exit fullscreen mode

For the add (POST) test, I compared the count (length) before and after to confirm exactly one item was added.

 PASS  src/__tests__/api.test.ts

Test Suites: 1 passed, 1 total
Tests:       7 passed, 7 total
Time:        0.248 s
Enter fullscreen mode Exit fullscreen mode

💡 Unit vs. integration tests

Tests that complete with mocks are "unit tests"; tests that need the actual FastAPI server running are "integration tests." Right now they share a single describe block, but as things grow, splitting them into unit/ and integration/ folders makes them easier to manage.


What I Asked AI For

Topic What AI helped with
Spec clarification Surfacing missing and unclear requirements
Design suggestion (Python) Explaining why to split ItemCreate and Item
Concept explanation (Python) Explaining venv, pip, uvicorn, HTTPException vs Java/TS
404 exit bug Caught that case "2" had no try-catch, so errors reached main().catch() and exited
Error message branching Suggested error.message.includes("404") to show "not found"
return vs throw Suggested re-throwing with throw error instead of return on unexpected errors
Base URL collection Suggested pulling the three hardcoded URLs into an API_ENDPOINT constant
Body simplification Suggested simplifying to JSON.stringify(inputItem)
interface syntax Caught the trailing }; that should be }

Note: I found and fixed the 422 error (below) myself.


Where I Got Stuck

1. A 422 error on POST (the Content-Type header)

Situation: When adding an item (POST), the server returned 422 (Unprocessable Entity).

Root cause: My fetch POST didn't set the Content-Type: application/json header. Without it, FastAPI can't interpret the request body as JSON and returns 422.

// ❌ no header — the body can't be read as JSON → 422
await fetch(`${API_ENDPOINT}/items`, {
  method: "POST",
  body: JSON.stringify(inputItem),
});

// ✅ declare the Content-Type
await fetch(`${API_ENDPOINT}/items`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(inputItem),
});
Enter fullscreen mode Exit fullscreen mode

Takeaway: I reached the cause on my own. A server and a client only agree to "exchange JSON" once that's declared via the header. I never thought about this while writing GET-only code.


2. The CLI exits entirely on a 404

Situation: Entering a non-existent ID exited the CLI with no error message at all.

Root cause: The exception thrown by getItemById wasn't caught inside the switch and propagated all the way to main().catch(). Since main().catch() is the last line of defense that ends the program, the loop couldn't resume and the CLI fell over.

// ❌ no catch in the case → the exception reaches main().catch() and exits
case "2": {
  const item = await getItemById(parseInt(id)); // 404 throws → loop breaks → exit
  console.log(item);
  break;
}
Enter fullscreen mode Exit fullscreen mode

I added try-catch inside case "2", showing "Item not found" for 404 and continuing the loop (code shown earlier).

Takeaway: Where you catch a thrown error decides whether the program continues or exits. The same exception leads to a different user experience depending on where you place the catch.


3. The difference between return and throw error

Situation: On unexpected errors, I first exited main with return. That quietly ended the program normally, and I couldn't tell what had happened.

Root cause and fix: return just ends main normally. Logging with console.error(error) and then re-throwing with throw error lets it reach main().catch() and end with process.exit(1) (abnormal exit).

// normal exit. no error detail remains
return;

// abnormal exit. delegate to main().catch() → process.exit(1)
console.error(error);
throw error;
Enter fullscreen mode Exit fullscreen mode

Takeaway: This felt close to delegating an exception upward with Java's throws. Choosing deliberately between "swallow it" and "throw it up" matters.


What I Learned

async/await and fetch (GET / POST)

  • POST specifies method, headers, and body. The body is stringified with JSON.stringify(...)
  • Sending JSON to FastAPI requires Content-Type: application/json (without it, 422)
  • For both GET and POST, you detect errors yourself with !response.ok and throw

Error handling

Topic Key Takeaway
try-catch placement Where you catch decides whether the program continues or exits
error instanceof Error A caught error is unknown. Narrow the type before using .message
return vs throw error return exits normally. throw error delegates to main().catch() for an abnormal exit. Close to Java's throws

Letting types simplify code

  • Since the ItemCreate type is guaranteed to hold only name and price, JSON.stringify(inputItem) sends it as-is. No need to expand the fields by hand

FastAPI / Python (first contact)

Topic Key Takeaway
Decorators @app.get / @app.post correspond to Spring's @GetMapping / @PostMapping
BaseModel Defines request/response data structures. Equivalent to a Java DTO
HTTPException An exception with a status code. Equivalent to ResponseStatusException
RequestDTO/ResponseDTO split ItemCreate (no id) and Item (with id) separate the responsibilities
/docs Just visiting it auto-generates a Swagger UI

Test design

  • Tests that complete with mocks are unit tests; those needing a running server are integration tests
  • A POST test can confirm the add by comparing the count (length) before and after

Reflection

I built this the same way as before: write the code myself and have AI review it. The big difference from last time was that I built the "other side" of the API myself.

Three things stood out:

A server and a client are connected by a "contract": The 422 error came down to declaring "this is JSON" via the Content-Type header before FastAPI would understand it. Building both sides myself made that boundary agreement tangible.

Exceptions are a design choice about "where to catch": The 404-exit incident showed me that try-catch placement directly shapes the user experience. The return vs throw error distinction also clicked, connecting back to my memory of Java's throws.

FastAPI looked like Spring: Decorators, DTOs, exception classes, auto-docs. It was rewarding to feel my past Java experience working as a foundation even in a new language.


Wrapping Up

This was my record of building a self-made FastAPI server and a TypeScript HTTP client CLI that calls it.

Progress since the previous projects:

  1. Implemented not just GET but POST with fetch (Content-Type, body)
  2. Found and fixed the cause of a 422 error myself
  3. Understood that try-catch placement changes behavior, and made a CLI that doesn't fall over on 404
  4. Used return and throw error deliberately
  5. Built the server side with FastAPI too, understanding both ends of the API

Building both ends of an API once gave me both perspectives — "the server as seen from the client" and "the client as seen from the server." Next, I want to build on this setup and move toward more practical data operations and persistence.

Full learning logs are in each repository:


This article is part of my public learning journey using AI tools (Claude Pro / Cursor Pro). All code is written by me — AI is used for design discussions, bug hints, and spec clarification only.

Top comments (0)