HTTP/API Automation, JSON Validation, and Service-Level Testing: Guided Hands-On Workflow
Build and exercise a disposable loopback CRUD-like JSON API with RequestsLibrary, explicit transport assertions, nested JSON checks, correlation IDs, fixture ownership, and cleanup evidence.
Learning objectives
- Create a deterministic local HTTP fixture with synthetic in-memory records.
- Install/pin Robot Framework and RequestsLibrary and record the resolved Requests version.
- Build a domain resource over sessionless GET/POST/DELETE operations with bounded timeouts.
- Validate status, headers, nested JSON, record identity, and cleanup independently.
- Use before/after evidence to prove which layer changed and which did not.
Current compatibility baseline — verified 2026-08-31.
Robot Framework 7.4.2 is the stable course
baseline. The mandatory HTTP layer uses
robotframework-requests 0.9.7, the latest stable
RequestsLibrary release; the 1.0a series remains prerelease. The
underlying Python Requests project is independently versioned; at
generation time its stable release is 2.34.2. The
lab uses Python 3.10+ for a single consistent environment even
though Robot Framework itself supports Python 3.8+. All required
targets are 127.0.0.1 loopback and all
records/credentials are synthetic. No public API, paid service,
browser, database, SSH target, Pabot, CI provider, or container
runtime is required.
1. Create the disposable workspace
rf-api-lab/
├── fixture_api.py
├── resources/
│ └── api.resource
├── tests/
│ └── api_contract.robot
└── results/
The directory is intentionally separate from any real service
repository. fixture_api.py is the system under test for
this lab; resources/api.resource is the Robot
transport/domain boundary;
tests/api_contract.robot contains business-facing
scenarios; and results/ contains evidence only.
2. Preflight and pinned environment
# PowerShell
py -3.12 -m venv .venv
.\.venv\Scripts\python -m pip install --upgrade pip
.\.venv\Scripts\python -m pip install robotframework==7.4.2 robotframework-requests==0.9.7
.\.venv\Scripts\python -m pip show robotframework robotframework-requests requests
# Bash / POSIX
python3 -m venv .venv
./.venv/bin/python -m pip install --upgrade pip
./.venv/bin/python -m pip install robotframework==7.4.2 robotframework-requests==0.9.7
./.venv/bin/python -m pip show robotframework robotframework-requests requests
The direct teaching pins are Robot Framework 7.4.2 and
RequestsLibrary 0.9.7. The requests package is a
transitive dependency; record the resolved version in the evidence
packet. If your package index resolves a different version than the
generation-time 2.34.2 baseline, record it and re-check
compatibility before treating the environment as equivalent.
3. Build the loopback-only HTTP fixture
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
from urllib.parse import urlparse
HOST = "127.0.0.1"
PORT = 8766
STORE = {}
NEXT_ID = 1
def envelope(record):
return {"data": {"id": record["id"], "attributes": {"name": record["name"], "kind": record["kind"]}}}
class Handler(BaseHTTPRequestHandler):
server_version = "RFLocalFixture/1.0"
def log_message(self, fmt, *args):
correlation = self.headers.get("X-Correlation-ID", "none")
print(f"method={self.command} path={self.path} correlation={correlation}")
def send_json(self, status, payload, correlation=None):
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
if correlation:
self.send_header("X-Correlation-ID", correlation)
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
path = urlparse(self.path).path
correlation = self.headers.get("X-Correlation-ID", "rf-local-none")
if path == "/health":
self.send_json(200, {"status": "ok", "service": "rf-local-fixture"}, correlation)
return
if path == "/malformed":
body = b'{"data": invalid-json}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("X-Correlation-ID", correlation)
self.end_headers()
self.wfile.write(body)
return
if path.startswith("/items/"):
try:
item_id = int(path.rsplit("/", 1)[1])
except ValueError:
self.send_json(400, {"error": {"code": "bad-id"}}, correlation)
return
record = STORE.get(item_id)
if record is None:
self.send_json(404, {"error": {"code": "not-found", "id": item_id}}, correlation)
else:
self.send_json(200, envelope(record), correlation)
return
self.send_json(404, {"error": {"code": "route-not-found"}}, correlation)
def do_POST(self):
global NEXT_ID
path = urlparse(self.path).path
correlation = self.headers.get("X-Correlation-ID", "rf-local-none")
if path == "/__reset":
STORE.clear()
NEXT_ID = 1
self.send_json(200, {"status": "reset", "count": 0}, correlation)
return
if path != "/items":
self.send_json(404, {"error": {"code": "route-not-found"}}, correlation)
return
try:
size = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(size) or b"{}")
except (ValueError, json.JSONDecodeError):
self.send_json(400, {"error": {"code": "invalid-json"}}, correlation)
return
name = payload.get("name")
kind = payload.get("kind")
if not isinstance(name, str) or not name.strip() or kind not in {"demo", "boundary"}:
self.send_json(422, {"error": {"code": "validation", "fields": ["name", "kind"]}}, correlation)
return
item_id = NEXT_ID
NEXT_ID += 1
record = {"id": item_id, "name": name.strip(), "kind": kind}
STORE[item_id] = record
self.send_json(201, envelope(record), correlation)
def do_DELETE(self):
path = urlparse(self.path).path
correlation = self.headers.get("X-Correlation-ID", "rf-local-none")
if not path.startswith("/items/"):
self.send_json(404, {"error": {"code": "route-not-found"}}, correlation)
return
try:
item_id = int(path.rsplit("/", 1)[1])
except ValueError:
self.send_json(400, {"error": {"code": "bad-id"}}, correlation)
return
if STORE.pop(item_id, None) is None:
self.send_json(404, {"error": {"code": "not-found", "id": item_id}}, correlation)
return
self.send_response(204)
self.send_header("X-Correlation-ID", correlation)
self.end_headers()
if __name__ == "__main__":
print(f"RF local fixture listening on http://{HOST}:{PORT}")
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
The server owns only in-memory synthetic records. It binds to
127.0.0.1:8766, not every network interface. It
supports health inspection, record create/read/delete, a reset
endpoint, and one deliberately malformed JSON endpoint used later
for diagnosis. Its log intentionally omits request bodies and
headers except a synthetic correlation ID.
4. Start and inspect the fixture before Robot mutates it
# Terminal A — PowerShell
.\.venv\Scripts\python fixture_api.py
# Terminal A — Bash
./.venv/bin/python fixture_api.py
# Terminal B — read-only health probe
python -c "import urllib.request; r=urllib.request.urlopen('http://127.0.0.1:8766/health', timeout=2); print(r.status, r.headers.get('Content-Type'), r.read().decode())"
Expected observation: status 200 and a small JSON health body. No
item has been created. If port 8766 is occupied, do not kill an
unknown process. Choose another free loopback port and update both
the fixture and ${API_BASE} consistently.
5. Build the Robot API resource
*** Settings ***
Library RequestsLibrary
Library Collections
*** Variables ***
${API_BASE} http://127.0.0.1:8766
${HTTP_TIMEOUT} 3
*** Keywords ***
Build Safe Headers
[Arguments] ${correlation}
&{headers}= Create Dictionary Accept=application/json X-Correlation-ID=${correlation}
RETURN ${headers}
Reset Local Fixture
${headers}= Build Safe Headers rf-reset
${response}= POST ${API_BASE}/__reset headers=${headers} timeout=${HTTP_TIMEOUT} expected_status=200
RETURN ${response}
Create Demo Item
[Arguments] ${name} ${kind}=demo ${correlation}=rf-create-001
${headers}= Build Safe Headers ${correlation}
&{payload}= Create Dictionary name=${name} kind=${kind}
${response}= POST ${API_BASE}/items json=${payload} headers=${headers} timeout=${HTTP_TIMEOUT} expected_status=201
${body}= Set Variable ${response.json()}
${data}= Get From Dictionary ${body} data
${item_id}= Get From Dictionary ${data} id
RETURN ${item_id} ${response}
Get Demo Item
[Arguments] ${item_id} ${correlation}=rf-get-001 ${expected}=200
${headers}= Build Safe Headers ${correlation}
${response}= GET ${API_BASE}/items/${item_id} headers=${headers} timeout=${HTTP_TIMEOUT} expected_status=${expected}
RETURN ${response}
Delete Demo Item
[Arguments] ${item_id} ${correlation}=rf-delete-001 ${expected}=204
${headers}= Build Safe Headers ${correlation}
${response}= DELETE ${API_BASE}/items/${item_id} headers=${headers} timeout=${HTTP_TIMEOUT} expected_status=${expected}
RETURN ${response}
Response Correlation Should Be
[Arguments] ${response} ${expected}
Should Be Equal ${response.headers}[X-Correlation-ID] ${expected}
Item Business Fields Should Be
[Arguments] ${response} ${expected_name} ${expected_kind}
${body}= Set Variable ${response.json()}
${data}= Get From Dictionary ${body} data
${attributes}= Get From Dictionary ${data} attributes
Dictionary Should Contain Item ${attributes} name ${expected_name}
Dictionary Should Contain Item ${attributes} kind ${expected_kind}
This resource is deliberately thin. It owns request construction, safe headers, status expectations, response extraction, and reusable business-field assertions. It does not own test selection or server lifecycle. Every network operation has a bounded timeout. Every URL is derived from a loopback base. No Authorization header or real secret exists in source.
6. Build the first two tests
*** Settings ***
Resource ../resources/api.resource
Suite Setup Reset Local Fixture
Test Setup Initialize Ownership
Test Teardown Cleanup Owned Item
*** Test Cases ***
Create And Read One Synthetic Item
${item_id} ${created}= Create Demo Item alpha demo rf-create-alpha
VAR ${OWNED_ITEM_ID} ${item_id} scope=TEST
Response Correlation Should Be ${created} rf-create-alpha
${fetched}= Get Demo Item ${item_id} rf-get-alpha
Response Correlation Should Be ${fetched} rf-get-alpha
Item Business Fields Should Be ${fetched} alpha demo
Missing Item Has Explicit Error Contract
${response}= Get Demo Item 9999 rf-get-missing expected=any
Status Should Be 404 ${response}
${body}= Set Variable ${response.json()}
${error}= Get From Dictionary ${body} error
Dictionary Should Contain Item ${error} code not-found
*** Keywords ***
Parse Response JSON
[Arguments] ${response}
${body}= Set Variable ${response.json()}
RETURN ${body}
Initialize Ownership
VAR ${OWNED_ITEM_ID} ${EMPTY} scope=TEST
Cleanup Owned Item
IF $OWNED_ITEM_ID
Delete Demo Item ${OWNED_ITEM_ID} rf-cleanup-${OWNED_ITEM_ID}
END
The positive case proves both transport and business behavior. The missing-record case disables only the request keyword's implicit status assertion so the test can assert the intended 404 contract. The teardown deletes only the record ID created and owned by that test.
Scope note: The checkpoint version initializes
${OWNED_ITEM_ID} to an empty safe value before use.
Do not rely on an undefined variable in teardown. A teardown must
be safe even when creation fails before an ID is returned.
7. Execute into a new evidence directory
# PowerShell
.\.venv\Scripts\python -m robot --outputdir results\first tests\api_contract.robot
# Bash
./.venv/bin/python -m robot --outputdir results/first tests/api_contract.robot
Inspect the console status, then open
results/first/log.html. The hierarchy should show
business keyword → RequestsLibrary keyword → assertions. Preserve
output.xml even when all tests pass; Lesson 14
established it as the machine-readable source for result
post-processing.
8. Before/after evidence matrix
| Moment | Robot state | HTTP/client state | Server state | Evidence |
|---|---|---|---|---|
| Before suite | no response/owned ID | no persistent session required | empty store after reset | health probe + versions |
| After POST | test owns returned ID | one completed request/response object | one synthetic record | 201, correlation header, JSON ID |
| After GET | response variable updated locally | new completed request | same record | 200 + nested field assertions |
| After teardown | test scope ends | no browser/db/SSH state exists | owned record deleted | 204 cleanup call in log |
| Negative 404 | response retained because expected_status=any | normal request completed | still no record 9999 | 404 + error.code assertion |
9. Compare one session-based flow
When several calls intentionally share a base URL and headers, a session can reduce repetition. Make the ownership visible and keep TLS verification explicit for HTTPS.
*** Keywords ***
Open Local API Session
&{headers}= Create Dictionary Accept=application/json X-Correlation-ID=rf-session
Create Session local_api http://127.0.0.1:8766 headers=${headers} timeout=3 max_retries=0
Read Health Through Session
${response}= GET On Session local_api /health expected_status=200
RETURN ${response}
The local fixture is HTTP, so certificate verification is not
involved. For HTTPS, set verify=${True} or a trusted CA
bundle explicitly. Also note that session-level retry configuration
is HTTP-client behavior, not Robot Framework retry semantics. This
course does not use retries to hide flaky service behavior.
10. Challenge: choose the correct layer
You need to assert that a created item always has a non-empty numeric ID, but the exact ID differs every run. Where should the check live?
Place it in the domain resource or a focused test assertion near the
business contract, not in the Python fixture and not in a giant
raw-JSON string comparison. Use the parsed
data.id value and assert its type/value constraints.
11. Cleanup / rollback
- Run only the lab suite and confirm each owned record is deleted.
- Stop the specific fixture server with Ctrl+C in Terminal A.
- Keep
results/first/until review is complete. -
Delete only the disposable
rf-api-labdirectory if desired. - Never redirect the reset/delete keywords to another environment.
Knowledge check
Why is a correlation ID useful in a local lab?
It provides a stable link among Robot request evidence, the response header, and the fixture log without exposing credentials or depending on body logging.
Should the test use a session if it only makes one independent GET?
Not necessarily. A sessionless GET makes ownership simpler when no cookies/common headers/connection policy need to persist.
What does timeout=3 own?
It is a client-side HTTP timeout passed through RequestsLibrary to Python Requests. It is not a Robot test timeout and not a server-side timeout.
Why must teardown delete by the exact owned ID rather than resetting everything?
Deleting the owned record proves precise cleanup and avoids destroying unrelated state. A full reset is acceptable only at an explicit disposable-suite boundary.
12. Summary and bridge
You now have a complete local service-testing path: deterministic fixture, bounded HTTP client calls, transport assertions, nested JSON business assertions, correlation evidence, and owned cleanup. Lesson 3 turns those mechanics into architecture decisions.
References and version anchors
- Robot Framework 7.4.2 User Guide — execution, variables, lifecycle, status, result, and library boundaries.
- RequestsLibrary keyword documentation, RequestsLibrary 0.9.7 on PyPI, and maintainer repository — HTTP keyword signatures, session behavior, status handling, and release status.
- Python Requests documentation and Requests 2.34.2 on PyPI — underlying HTTP client semantics and current transport-library release anchor.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.