Chapter 17Lesson 02240–320 min

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.

Loopback APIGET/POST/DELETECorrelation IDNested JSONCleanup

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

  1. Run only the lab suite and confirm each owned record is deleted.
  2. Stop the specific fixture server with Ctrl+C in Terminal A.
  3. Keep results/first/ until review is complete.
  4. Delete only the disposable rf-api-lab directory if desired.
  5. Never redirect the reset/delete keywords to another environment.

Knowledge check

Why is a correlation ID useful in a local lab?

Should the test use a session if it only makes one independent GET?

What does timeout=3 own?

Why must teardown delete by the exact owned ID rather than resetting everything?

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.

Next lesson

HTTP/API Automation, JSON Validation, and Service-Level Testing: Configuration, Design Patterns, and Trade-Offs

Continue with HTTP/API Automation, JSON Validation, and Service-Level Testing: Configuration, Design Patterns, and Trade-Offs. It builds directly on the state, evidence, and operating assumptions established here, so carry those constraints forward rather than treating the next page as an isolated topic.

References and version anchors

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.