Build a Simulated Healthcare Data API
Build a local FastAPI service to validate and store fictional device readings.
Introduction
30 Second Summary
Device readings can look complete while hiding important gaps. Those gaps can make an entire timeline hard to trust.
In this project, you will build PulseBridge, a local FastAPI service for fictional device readings. Its validated JSON pipeline stores complete readings in SQLite for later retrieval.
What You'll Build
You'll open a browser-based API console where a fictional reading receives a database ID before appearing in a stored timeline.
By the end of this project, you'll have:
- A browser-based submission flow that sends fictional device data through POST /readings and returns a generated database ID.
- Clear field-level validation that identifies missing values before an incomplete reading reaches storage.
- A persistent timeline that returns saved readings newest first through GET /readings even after an API restart.
- Secret Mission: Filter the stored timeline to one simulated device with an optional device_id query parameter.
Are there any prerequisites?
Basic Python familiarity is enough. You'll use Visual Studio Code on a Mac with a setup path if your Python version needs an upgrade.
You do not need a GitHub or cloud account because PulseBridge runs entirely on your computer.
Before We Start
Before any hands-on work, commit to PulseBridge's purpose. This keeps the local API focused on fictional patient-device readings for a safe, non-diagnostic simulation.
Set Up PulseBridge on Your Mac
Package conflicts from previous Python work can stop PulseBridge before the API starts.
A clean virtual environment gives this project its own package set. This step verifies the interpreter before creating that isolated workspace.
You will also install the exact package versions recorded in requirements.txt. A final compatibility check proves the environment is ready.
In this step, get ready to:
- Confirm Python 3.10 or later is available.
- Prepare an isolated pulsebridge workspace.
- Validate the pinned dependency installation.
Check your Python version
PulseBridge needs Python 3.10 or later because every pinned package supports that range. The version check tells you whether the current interpreter is ready.
- Press Cmd+Space to open the macOS search bar.
- Type Terminal and press Enter to open it.
- Check the installed Python version by running this command:
python3 --version
What does this command do?
- The python3 command selects the Python interpreter available on your Mac.
- The --version option prints its version number.
✔️ I see a supported version
A result of Python 3.10 or later meets every package requirement in this project.
ⓧ I see an older version
Your current interpreter is below the minimum supported version. Install Python 3.14.7 before creating the project environment.
- Visit the official Python macOS releases page.
- Download the macOS installer for Python 3.14.7.
- Run the downloaded installer.
- Follow the installer prompts using the default options.
- Return to Terminal after the installation finishes.
- Check the upgraded interpreter by running this command:
python3 --version
What should change?
You should now see Python 3.14.7. That result confirms the upgraded interpreter is available.
ⓧ No version appears
Python is unavailable from your Terminal session. The official macOS installer adds the required interpreter.
- Visit the official Python macOS releases page.
- Download the macOS installer for Python 3.14.7.
- Run the downloaded installer.
- Follow the installer prompts using the default options.
- Return to Terminal after the installation finishes.
- Check the installed interpreter by running this command:
python3 --version
What should you see?
You should now see Python 3.14.7. Terminal can now find the interpreter.
Create an isolated project environment
The .venv directory keeps PulseBridge packages separate from every other Python project on your Mac. Activating it directs later package commands into that isolated environment.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
The ~ path represents your home folder. This command makes its Desktop directory the location for the PulseBridge project.
- Create the project folder by running these commands:
mkdir pulsebridge
cd pulsebridge
What do these commands do?
- The mkdir pulsebridge command creates the project directory on your Desktop.
- The cd pulsebridge command moves the Terminal session into that directory.
You should now see pulsebridge named as the current folder in your Terminal prompt.
- Create the virtual environment by running these commands:
python3 -m venv .venv
source .venv/bin/activate
How does the environment work?
- The first command creates an isolated Python environment inside .venv.
- The second command activates that environment for the current Terminal session.
- Later package installations stay inside PulseBridge.
That isolation is in place. Your Terminal prompt now includes the environment name to show it is active.
Environment not active?
Check that Terminal is inside the pulsebridge folder. Rerun the activation command shown above.
If the prompt still does not change, help me activate the PulseBridge virtual environment.
Pin and verify the dependencies
A requirements file records the exact package versions PulseBridge expects. Those pins make the environment repeatable.
Visual Studio Code gives you an editor for creating the file inside the project directory.
- Press Cmd+Space to open the macOS search bar.
- Type Visual Studio Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the pulsebridge folder on your Desktop.
- Click Open.
- Click the new file icon in the Explorer sidebar.
- Enter requirements.txt as the file name.
The editor now shows an empty requirements.txt file inside the PulseBridge workspace.
- Add the pinned dependencies by pasting this content into requirements.txt:
fastapi==0.141.1
pydantic==2.13.5
uvicorn==0.52.4
What do these packages provide?
- FastAPI 0.141.1 provides the API framework.
- Pydantic 2.13.5 validates structured reading data later in the project.
- Uvicorn 0.52.4 runs the local service.
- Save requirements.txt.
- Confirm the Explorer sidebar lists requirements.txt inside pulsebridge.
File missing from the workspace?
Make sure requirements.txt sits directly inside pulsebridge. It should not be inside .venv.
If the file appears elsewhere, help me place requirements.txt in the correct PulseBridge folder.
✔️ Awesome, I've got everything!
Your requirements.txt file is saved inside pulsebridge with all three pinned packages.
ⓧ I'd like to double check the full code
Your complete requirements.txt file should match this content exactly.
fastapi==0.141.1
pydantic==2.13.5
uvicorn==0.52.4
The activated environment from earlier directs pip to install these packages inside .venv.
- Switch back to the Terminal session from earlier.
- Install the pinned dependencies by running this command:
python -m pip install -r requirements.txt
What does this command do?
- The python -m pip portion uses the package installer from the active environment.
- The -r requirements.txt portion installs every pinned entry from the file.
When the command finishes, you should see successful installation output for the three pinned packages.
Package installation failed?
Confirm the Terminal prompt still includes the environment name. Check that your Python version is 3.10 or later.
If the installation still fails, help me troubleshoot the PulseBridge dependency installation.
Before you run the final check, do you expect this fresh environment to contain any broken requirements?
- Verify package compatibility by running this command:
python -m pip check
What should you see?
You should see No broken requirements found. This confirms the installed packages have compatible dependencies.
That is the setup locked in. PulseBridge now has an isolated environment with a compatible package set.
Seeing a dependency conflict?
Confirm requirements.txt matches the full-code tab. Rerun the installation command while .venv is active.
If the check still reports a conflict, help me resolve my pip check result.
Your PulseBridge workspace is ready. Next up, you will run the first API route and expose the ingestion problem that validation needs to solve.
Build a Naive Ingestion Endpoint
Your pinned packages are compatible inside the virtual environment. PulseBridge can now become a running local API.
The first version uses FastAPI to accept any JSON object through an HTTP POST operation. This loose input exposes the data-quality problem inside an unguarded ingestion endpoint.
In this step, get ready to:
- Build the running PulseBridge application in main.py.
- Add a loose POST /readings operation.
- Submit an incomplete fictional reading through the interactive documentation.
Create the app and status route
A FastAPI application object holds the API configuration. A root route gives you a quick way to prove that PulseBridge is responding.
- Press Cmd+Space to open Spotlight Search.
- Type Visual Studio Code in Spotlight Search.
- Press Enter to open Visual Studio Code.
- Click File in the top menu bar.
- Click Open Folder.
- Select the pulsebridge folder.
- Click Open.
Where are you now?
The Explorer sidebar should show requirements.txt inside pulsebridge. This confirms that your editor is working in the folder created during setup.
- Click the new file icon in the Explorer sidebar.
- Enter main.py as the file name.
- Create the application object by pasting this code into main.py:
from fastapi import FastAPI
app = FastAPI(
title="PulseBridge",
description="A local API for fictional patient-device readings.",
)
What does this code do?
- The import makes the FastAPI application class available inside main.py.
- The app object becomes the application served by Uvicorn.
- The title value names the project in its generated documentation.
- The description value explains that PulseBridge handles fictional readings.
- Save main.py.
- Start the FastAPI development server with Uvicorn by running this command in the activated terminal from setup:
uvicorn main:app --reload
What does this command do?
- The main:app value tells Uvicorn to load app from main.py.
- The --reload option watches your files for saved changes.
- The command keeps control of this terminal while the server is running.
- Keep the server terminal running.
- Enter http://127.0.0.1:8000/docs in your browser address bar.
You should see the interactive documentation with PulseBridge at the top. Your local FastAPI application is now responding through Uvicorn.
Documentation page not loading?
Confirm that the activated terminal still shows the server running. Keep that process active while you use the API.
If Uvicorn cannot import the application, confirm that main.py is directly inside the pulsebridge folder.
Check your file against the first code block. Pay close attention to the indentation inside FastAPI().
Help me troubleshoot the PulseBridge server.
The application now loads successfully. The root route adds a small status response that confirms requests can reach your Python function.
- Return to main.py in VS Code.
- Place your cursor below the app = FastAPI(...) block.
- Add the status route by pasting this code:
@app.get("/")
def read_root() -> dict[str, str]:
return {"message": "PulseBridge is running"}
How does the status route work?
- The @app.get("/") decorator connects an HTTP GET request at the root path to the function below it.
- The read_root() function returns a small status message.
- FastAPI converts the returned dictionary into a JSON response.
- Save main.py.
- Enter http://127.0.0.1:8000/ in a new browser tab.
You should see {"message":"PulseBridge is running"} in the browser. That response proves your root route is live.
Status message missing?
Confirm that the browser address is http://127.0.0.1:8000/.
Check that @app.get("/") begins at the left edge of main.py. The return line should be indented inside read_root().
Help me fix the root route.
Add the loose ingestion route
An ingestion route receives data from another system. This first version types the request body as a plain dict, which allows any JSON object to reach the function.
- Return to main.py in VS Code.
- Place your cursor below the read_root() function.
- Add the ingestion route by pasting this code:
@app.post("/readings")
def create_reading(reading: dict) -> dict[str, object]:
return {"accepted": True, "reading": reading}
What does this route do?
- The @app.post("/readings") decorator creates the ingestion path operation.
- The reading: dict parameter accepts an arbitrary JSON object as the request body.
- The returned dictionary marks the object as accepted.
- The response includes the same submitted reading so you can inspect what reached the function.
- Save main.py.
- Return to the interactive documentation from earlier.
- Refresh the browser page.
You should now see POST /readings listed with the root operation. The saved route has reached the running application.
POST operation not listed?
Confirm that you saved main.py after adding create_reading().
Check that the server terminal is still running. Restart the command from earlier if the process stopped.
Help me find the missing POST route.
✔️ Awesome, I've got everything!
Great. Double check that main.py is saved while the Uvicorn server remains running.
ⓧ I'd like to double check the full code
Compare your saved main.py with this complete version:
from fastapi import FastAPI
app = FastAPI(
title="PulseBridge",
description="A local API for fictional patient-device readings.",
)
@app.get("/")
def read_root() -> dict[str, str]:
return {"message": "PulseBridge is running"}
@app.post("/readings")
def create_reading(reading: dict) -> dict[str, object]:
return {"accepted": True, "reading": reading}
What should this file provide?
This file creates the PulseBridge application. It exposes the root status route plus the loose ingestion route.
Test the designed shortfall
The interactive documentation can send a request directly to your running API. This test uses a fictional device identifier to keep the simulation free of real patient information.
- Return to http://127.0.0.1:8000/docs in your browser.
- Select POST /readings.
- Click Try it out.
- Replace the request body with {"device_id":"sensor-001"}.
Before you send the request, do you think this one-field object contains enough information to represent a useful device reading?
- Click Execute.
You will see a response with accepted set to true. The response also contains the submitted device_id value.
Why was the incomplete reading accepted?
The dict annotation allows an arbitrary JSON object. The route returns that object without requiring any other fields.
This is the intended shortfall. PulseBridge currently accepts data that cannot form a useful device-reading timeline.
You have a running ingestion endpoint plus proof of its data-quality gap. Next, you will define the required reading shape so incomplete objects are rejected.
Reject Incomplete Device Readings
The Uvicorn server is still running the FastAPI endpoint that accepted an incomplete JSON object. That result exposed a data-quality gap before storage.
A Pydantic model gives the request body a required shape before create_reading() runs. You will apply that model so incomplete requests stop at the API boundary.
In this step, get ready to:
- Define the required fields for a fictional device reading.
- Apply the validated model to the ingestion endpoint.
- Compare rejected and accepted request bodies.
Define the reading schema
The DeviceReading class becomes the request schema. Its type annotations describe the data every accepted reading must contain.
- Switch back to main.py in Visual Studio Code.
- Replace the existing import line with this import group:
from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel
What Do These Imports Add?
- The datetime type lets the model validate when a reading was recorded.
- The BaseModel class provides the foundation for a validated request model.
- Save main.py.
- Return to the server terminal from earlier.
You'll see Uvicorn reload the app without reporting an import failure.
Import Fails After Reload?
- Check that datetime is lowercase in the import statement.
- Check that BaseModel uses both capital letters.
Ask for help with the import if the reload still fails: Help me fix these Python imports.
Each field in DeviceReading is required because the class does not provide default values. The endpoint receives one predictable structure every time validation succeeds.
- Locate the closing ) beneath the app = FastAPI( block.
- Add the model below that block by pasting this code:
class DeviceReading(BaseModel):
patient_id: str
device_id: str
metric: str
value: float
recorded_at: datetime
What Does This Model Validate?
- The patient_id field identifies the fictional patient in the simulation.
- The device_id field identifies the simulated device that produced the reading.
- The metric field names the fictional measurement.
- The value field requires a numeric measurement.
- The recorded_at field requires a valid date-time value.
- Save main.py.
- Return to the server terminal from earlier.
You'll see the application reload cleanly with the new model defined.
Model Causes a Reload Failure?
- Check that every field is indented with four spaces beneath class DeviceReading(BaseModel):.
- Check that recorded_at uses the imported datetime type.
Ask for help if the model still prevents a reload: Help me fix my DeviceReading model.
Replace the loose endpoint type
FastAPI uses the endpoint parameter type to decide how to process the request body. Changing the parameter from dict to DeviceReading activates validation before the function runs.
- Scroll to @app.post("/readings") in main.py.
- Replace the existing create_reading() endpoint with this code:
@app.post("/readings")
def create_reading(reading: DeviceReading) -> DeviceReading:
return reading
What Changed at the Endpoint?
- The reading: DeviceReading parameter tells FastAPI to validate the request body against the model.
- The -> DeviceReading annotation describes the response type.
- The function returns the validated reading so you can inspect the accepted values.
- Save main.py.
- Return to the interactive API documentation from earlier.
- Refresh the browser page.
You'll see five required properties in the request-body schema for POST /readings.
Required Fields Missing From the Documentation?
- Confirm that the endpoint parameter says reading: DeviceReading.
- Confirm that Uvicorn reloaded after you saved main.py.
Ask for help if the old request shape remains: Help me update the request schema.
✔️ Awesome, I've got everything!
Great. Double check that main.py is saved before you test both payloads.
ⓧ I'd like to double check the full code
Compare your main.py file with this complete version.
from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(
title="PulseBridge",
description="A local API for fictional patient-device readings.",
)
class DeviceReading(BaseModel):
patient_id: str
device_id: str
metric: str
value: float
recorded_at: datetime
@app.get("/")
def read_root() -> dict[str, str]:
return {"message": "PulseBridge is running"}
@app.post("/readings")
def create_reading(reading: DeviceReading) -> DeviceReading:
return reading
Test rejected and accepted payloads
The interactive documentation lets you compare two requests against the same model. The first request proves the incomplete object can no longer reach create_reading().
- Select POST /readings in the interactive documentation.
- Click Try it out.
- Replace the request body with this incomplete object:
{"device_id":"sensor-001"}
Why Should This Payload Fail?
This object supplies only device_id. The model still requires four other fields before the endpoint can accept the reading.
Before you send this request, which missing fields do you expect the validation response to name?
- Click Execute.
You'll see a field-level validation response naming patient_id, metric, value, and recorded_at as missing.
You have closed the gap. Incomplete readings now stop before the endpoint can use them.
A complete fictional reading satisfies every field in the model. This successful request confirms that useful input still passes through the validation boundary.
- Replace the request body with this complete fictional reading:
{
"patient_id": "patient-001",
"device_id": "sensor-001",
"metric": "signal_score",
"value": 42.5,
"recorded_at": "2026-09-09T10:00:00+00:00"
}
Why Does This Payload Pass?
Every required field has a value of the expected type. The date-time string also includes its UTC offset.
Before you send the complete request, do you expect the endpoint to reject it or return the validated reading?
- Click Execute.
You'll see the complete fictional reading returned with all five validated fields.
Payload Produces the Wrong Result?
- Refresh the documentation if the incomplete payload is still accepted.
- Compare every complete-payload field name with the attributes in DeviceReading.
- Keep the quotation marks around the recorded_at value.
Ask for help if either request behaves differently: Help me debug these validation results.
Your endpoint now blocks incomplete readings while preserving valid input. Next, you'll store accepted readings in a local SQLite database so they survive beyond one request.
Persist Valid Readings in SQLite
Your Pydantic model now rejects incomplete readings before they enter the pipeline. Accepted readings still disappear once each request finishes.
Each successful response proves validation worked. It leaves no durable timeline for the next request.
In this step, you'll connect FastAPI to SQLite. The local database file keeps accepted fictional readings available after the API restarts.
In this step, get ready to:
- Create the local database file and its readings table.
- Insert each validated reading with a parameterized query.
- Restart the API and confirm that a generated row ID is returned.
Create the database and table
Python's sqlite3 module can create an on-disk database from a file path. The DATABASE_PATH constant gives the rest of your code one place to reference that file.
- In main.py, select everything from from datetime import datetime through the closing parenthesis of app = FastAPI(...).
- Replace the selected section with this database configuration:
import sqlite3
from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel
DATABASE_PATH = "pulsebridge.db"
app = FastAPI(
title="PulseBridge",
description="A local API for fictional patient-device readings.",
)
What Does This Configuration Do?
- The sqlite3 import gives your API access to Python's local database tools.
- The DATABASE_PATH constant points every database connection to pulsebridge.db.
- The existing application configuration continues to provide the PulseBridge title and description.
A database file needs a table before it can hold readings. The initialization function creates that table whenever the application module loads.
- In main.py, place this code below the DeviceReading class:
def initialize_database() -> None:
connection = sqlite3.connect(DATABASE_PATH)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
patient_id TEXT NOT NULL,
device_id TEXT NOT NULL,
metric TEXT NOT NULL,
value REAL NOT NULL,
recorded_at TEXT NOT NULL
)
"""
)
connection.commit()
connection.close()
initialize_database()
How Does Initialization Work?
- The connection opens pulsebridge.db. SQLite creates the file when it does not already exist.
- The table definition creates columns for the generated ID and every validated reading field.
- The commit saves the table definition to disk.
- The final function call prepares the database as soon as main.py loads.
- Save main.py.
- Check the PulseBridge file list in Visual Studio Code for pulsebridge.db.
That is your first storage milestone complete. The database file now provides a durable home for fictional readings.
Database File Missing?
Check that initialize_database() sits below the function definition. Make sure DATABASE_PATH matches the constant near the top of main.py.
Look at the Uvicorn terminal for a traceback that points to a missing parenthesis or incorrect indentation.
Help me fix the database initialization.
Store each validated reading
The table now exists. Your create_reading() function needs to write each accepted model into it.
A parameterized SQL query binds each reading value to a ? placeholder. This keeps incoming values separate from the SQL statement.
- In main.py, locate the current create_reading() function.
- Replace that function with this database-write section:
@app.post("/readings")
def create_reading(reading: DeviceReading) -> dict[str, object]:
connection = sqlite3.connect(DATABASE_PATH)
cursor = connection.execute(
"""
INSERT INTO readings (
patient_id,
device_id,
metric,
value,
recorded_at
) VALUES (?, ?, ?, ?, ?)
""",
(
reading.patient_id,
reading.device_id,
reading.metric,
reading.value,
reading.recorded_at.isoformat(),
),
)
connection.commit()
reading_id = cursor.lastrowid
connection.close()
How Does the Insert Work?
- The function opens a connection to the same pulsebridge.db file.
- The five placeholders receive values from the validated DeviceReading model.
- The isoformat() call converts the validated timestamp into text for storage.
- The commit makes the inserted row persistent.
- The lastrowid value captures the numeric ID generated for the new row.
The database write is complete once the connection closes. The response can now reveal which row was created.
- Directly below connection.close(), complete create_reading() with this response:
return {
"id": reading_id,
"patient_id": reading.patient_id,
"device_id": reading.device_id,
"metric": reading.metric,
"value": reading.value,
"recorded_at": reading.recorded_at.isoformat(),
}
What Does the Response Prove?
The response includes the generated id from SQLite. It also returns the validated fields that were stored in that row.
- Save main.py.
The Uvicorn terminal should complete its reload without a Python traceback. This confirms that the updated function is importable.
Seeing a Reload Failure?
Check that the response dictionary is indented inside create_reading(). Confirm that each value in the insert tuple has a matching ? placeholder.
Help me fix the insert function.
Restart and verify persistence
A clean restart proves that module loading can prepare the database before requests arrive. The next successful request should return the row ID generated by SQLite.
- Stop the running server by pressing Control-C in its terminal.
- Restart the development server by running this command:
uvicorn main:app --reload
What Does This Command Do?
Uvicorn loads the app object from main.py. Reload mode restarts the development server when you save code changes.
- Switch back to the interactive documentation from earlier.
- Refresh the documentation page.
- Select POST /readings.
- Click Try it out.
- Paste the complete fictional reading you used in the previous step into the request editor.
Before you send the request, what new field do you expect the stored response to include?
- Click Execute.
You should see a successful response containing a generated numeric id. The response should also contain every validated reading field.
- Confirm that pulsebridge.db remains visible in the PulseBridge file list.
You now have a working storage path. Every accepted fictional reading can receive a persistent database ID.
Missing the Generated ID?
Refresh the documentation after restarting the server. Check that create_reading() returns the dictionary containing "id": reading_id.
If the request fails, compare the submitted reading with the complete fictional payload from the previous step.
Help me debug the stored response.
✔️ Awesome, I've got everything!
Great. Double check that main.py is saved and that pulsebridge.db appears in your PulseBridge file list.
ⓧ I'd like to double check the full code
Here is what your complete main.py file should look like:
import sqlite3
from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel
DATABASE_PATH = "pulsebridge.db"
app = FastAPI(
title="PulseBridge",
description="A local API for fictional patient-device readings.",
)
class DeviceReading(BaseModel):
patient_id: str
device_id: str
metric: str
value: float
recorded_at: datetime
def initialize_database() -> None:
connection = sqlite3.connect(DATABASE_PATH)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
patient_id TEXT NOT NULL,
device_id TEXT NOT NULL,
metric TEXT NOT NULL,
value REAL NOT NULL,
recorded_at TEXT NOT NULL
)
"""
)
connection.commit()
connection.close()
initialize_database()
@app.get("/")
def read_root() -> dict[str, str]:
return {"message": "PulseBridge is running"}
@app.post("/readings")
def create_reading(reading: DeviceReading) -> dict[str, object]:
connection = sqlite3.connect(DATABASE_PATH)
cursor = connection.execute(
"""
INSERT INTO readings (
patient_id,
device_id,
metric,
value,
recorded_at
) VALUES (?, ?, ?, ?, ?)
""",
(
reading.patient_id,
reading.device_id,
reading.metric,
reading.value,
reading.recorded_at.isoformat(),
),
)
connection.commit()
reading_id = cursor.lastrowid
connection.close()
return {
"id": reading_id,
"patient_id": reading.patient_id,
"device_id": reading.device_id,
"metric": reading.metric,
"value": reading.value,
"recorded_at": reading.recorded_at.isoformat(),
}
Your API now validates fictional readings and preserves them in a local database. Next up, you'll retrieve the stored timeline through a new API operation.
Retrieve the Stored Timeline
Your ingestion endpoint now stores validated fictional readings in SQLite. Those records survive beyond a single request.
Persistence becomes useful when PulseBridge can read the timeline back. In this step, a FastAPI HTTP GET operation will retrieve the saved rows as JSON.
In this step, get ready to:
- Add an operation that retrieves saved readings.
- Store two readings for the timeline.
- Verify that the newest reading appears first.
Read saved rows newest first
The sqlite3.Row row factory lets each query result expose values by column name. This keeps the returned dictionaries tied to the database columns.
- In main.py, place the following function below create_reading():
@app.get("/readings")
def list_readings() -> list[dict[str, object]]:
connection = sqlite3.connect(DATABASE_PATH)
connection.row_factory = sqlite3.Row
rows = connection.execute(
"""
SELECT id, patient_id, device_id, metric, value, recorded_at
FROM readings
ORDER BY id DESC
"""
).fetchall()
connection.close()
return [
{
"id": row["id"],
"patient_id": row["patient_id"],
"device_id": row["device_id"],
"metric": row["metric"],
"value": row["value"],
"recorded_at": row["recorded_at"],
}
for row in rows
]
What does this code do?
- The GET /readings operation opens a fresh connection to pulsebridge.db.
- The connection.row_factory = sqlite3.Row setting enables access through column names such as row["device_id"].
- The ORDER BY id DESC clause controls the sequence through generated IDs.
- The list comprehension converts every row into a dictionary that FastAPI can return as JSON.
- Save main.py.
- Refresh the interactive documentation from earlier.
You'll see GET /readings listed in the documentation.
Don't see the retrieval operation?
- Check that list_readings() starts at the left edge of main.py.
- Confirm that you saved main.py after adding the function.
Help me find why my GET operation is missing from the FastAPI documentation.
Create two saved readings
A timeline needs more than one record to make its sequence visible. Two successful requests give the retrieval operation a clear result to return.
- Return to the interactive documentation from earlier.
- Expand POST /readings.
- Click Try it out.
- Replace the example request with the complete fictional reading from earlier.
- Click Execute.
You'll see a successful response containing a numeric id.
- Change recorded_at to 2026-09-09T10:05:00+00:00.
- Click Execute again.
You'll see another successful response with a different numeric id.
Is the second reading rejected?
- Check that every required field from the complete fictional reading remains present.
- Confirm that recorded_at contains the full fictional date and time value.
Help me fix a rejected fictional device reading.
Retrieve the timeline
The retrieval operation can now prove that both accepted readings remain in the local database. Their generated IDs reveal how the query arranged the result.
- Before you execute the request, predict whether the newest saved reading will appear at the top or bottom.
- Expand GET /readings.
- Click Try it out.
- Click Execute.
You'll see both saved records in the response. The record with the larger id appears first.
Missing one of the readings?
- Confirm that both earlier POST /readings requests returned numeric IDs.
- Execute GET /readings again after both requests succeed.
Help me trace why GET /readings is missing a saved record.
✔️ Awesome, I've got everything!
Your main.py now contains the ingestion and retrieval operations.
ⓧ I'd like to double check the full code
Your full main.py should look like this:
import sqlite3
from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel
DATABASE_PATH = "pulsebridge.db"
app = FastAPI(
title="PulseBridge",
description="A local API for fictional patient-device readings.",
)
class DeviceReading(BaseModel):
patient_id: str
device_id: str
metric: str
value: float
recorded_at: datetime
def initialize_database() -> None:
connection = sqlite3.connect(DATABASE_PATH)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
patient_id TEXT NOT NULL,
device_id TEXT NOT NULL,
metric TEXT NOT NULL,
value REAL NOT NULL,
recorded_at TEXT NOT NULL
)
"""
)
connection.commit()
connection.close()
initialize_database()
@app.get("/")
def read_root() -> dict[str, str]:
return {"message": "PulseBridge is running"}
@app.post("/readings")
def create_reading(reading: DeviceReading) -> dict[str, object]:
connection = sqlite3.connect(DATABASE_PATH)
cursor = connection.execute(
"""
INSERT INTO readings (
patient_id,
device_id,
metric,
value,
recorded_at
) VALUES (?, ?, ?, ?, ?)
""",
(
reading.patient_id,
reading.device_id,
reading.metric,
reading.value,
reading.recorded_at.isoformat(),
),
)
connection.commit()
reading_id = cursor.lastrowid
connection.close()
return {
"id": reading_id,
"patient_id": reading.patient_id,
"device_id": reading.device_id,
"metric": reading.metric,
"value": reading.value,
"recorded_at": reading.recorded_at.isoformat(),
}
@app.get("/readings")
def list_readings() -> list[dict[str, object]]:
connection = sqlite3.connect(DATABASE_PATH)
connection.row_factory = sqlite3.Row
rows = connection.execute(
"""
SELECT id, patient_id, device_id, metric, value, recorded_at
FROM readings
ORDER BY id DESC
"""
).fetchall()
connection.close()
return [
{
"id": row["id"],
"patient_id": row["patient_id"],
"device_id": row["device_id"],
"metric": row["metric"],
"value": row["value"],
"recorded_at": row["recorded_at"],
}
for row in rows
]
You have closed the full storage loop. PulseBridge now returns persisted fictional readings through one local API.
Secret mission
Filter Readings by Device
Filter the growing timeline with an optional device_id input. Keep the full timeline available while giving callers a focused view of one simulated device.
Clean Up Your Resources
Clean Up Your Resources
PulseBridge runs entirely on your Mac, so there are no ongoing costs. Choose whether to keep the project running, pause it for later, or delete it entirely.
Resources you used:
- The running Uvicorn development server for PulseBridge (no cost).
- The local pulsebridge project folder containing requirements.txt, main.py, .venv, and pulsebridge.db (no cost).
Keep everything running
No action needed. Choose this if you want to continue testing PulseBridge through the interactive documentation.
- Keep the development server running while you submit more fictional readings.
- Keep the pulsebridge folder to preserve your code.
- Keep pulsebridge.db to preserve the simulated timeline.
Pause - I'll come back to this later
Shut down the running process to free up memory. Your code and fictional readings remain on your Mac.
- Stop the development server by pressing Control-C in the server terminal.
You'll see the terminal return to its command prompt.
- Leave the active environment by running this command:
deactivate
What does deactivate do?
The deactivate command leaves the current virtual environment. Your project files remain unchanged.
- Leave the pulsebridge folder in place until you return.
Delete - I don't want to use this again
Remove all PulseBridge resources to start fresh.
Deleting the folder can feel final. Moving the pulsebridge folder affects only that folder.
- Stop the development server by pressing Control-C in the server terminal.
You'll see the terminal return to its command prompt.
- Leave the active environment by running this command:
deactivate
What does deactivate do?
The deactivate command releases the environment from the current terminal session. The .venv folder remains ready for deletion with the rest of the project.
- Locate the pulsebridge project folder on your Mac.
- Move the pulsebridge project folder to Trash.
- Review the contents of Trash before removing anything permanently.
- Empty Trash when you are ready to remove the project permanently.
- Search your Mac for pulsebridge to confirm the project folder is gone.
The server is stopped. Your code, environment, package list, and fictional database are now removed.
Nice Work!
Nice Work!
You did it! PulseBridge now turns fictional device readings into a validated timeline that stays available between server restarts.
You've learned how to:
- Built a FastAPI service that receives fictional device readings through POST /readings.
- Used Pydantic validation to reject incomplete JSON request bodies before they reach storage.
- Stored accepted readings in SQLite using parameterized SQL queries. Retrieved the persistent timeline through GET /readings with the newest records first.
- Secret Mission: Added an optional device_id filter that returns readings from one simulated device.
Ready to quiz yourself?