Introduce an OpenAPI-first HTTP client driven by the same specification
used for server-side route binding, and refactor documentation and templates to treat the client as a first-class contract consumer. Key changes: - Add OpenAPI-first client module based on httpx - Document client usage alongside server-side binder usage - Update mkdocs navigation to include client documentation - Refactor CRUD and model app templates to call APIs via operationId instead of hardcoded paths - Align package documentation and public API surface with client support - Clarify server/client dependency split (fastapi vs httpx) This establishes strict symmetry between OpenAPI-driven server binding and OpenAPI-driven client invocation, reinforcing OpenAPI as the single source of truth on both sides of the contract.
This commit is contained in:
3
docs/openapi_first/client.md
Normal file
3
docs/openapi_first/client.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Client
|
||||||
|
|
||||||
|
::: openapi_first.client
|
||||||
@@ -42,6 +42,7 @@ nav:
|
|||||||
- OpenAPI-First App: openapi_first/app.md
|
- OpenAPI-First App: openapi_first/app.md
|
||||||
- Route Binder: openapi_first/binder.md
|
- Route Binder: openapi_first/binder.md
|
||||||
- Spec Loaders: openapi_first/loader.md
|
- Spec Loaders: openapi_first/loader.md
|
||||||
|
- Client: openapi_first/client.md
|
||||||
|
|
||||||
- CLI:
|
- CLI:
|
||||||
- Home: openapi_first/cli.md
|
- Home: openapi_first/cli.md
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ convenience facades.
|
|||||||
Architecture Overview
|
Architecture Overview
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
The library is structured around three core responsibilities:
|
The library is structured around four core responsibilities:
|
||||||
|
|
||||||
- loader: load and validate OpenAPI 3.x specifications (JSON/YAML)
|
- loader: load and validate OpenAPI 3.x specifications (JSON/YAML)
|
||||||
- binder: bind OpenAPI operations to FastAPI routes via operationId
|
- binder: bind OpenAPI operations to FastAPI routes via operationId
|
||||||
- app: OpenAPI-first FastAPI application bootstrap
|
- app: OpenAPI-first FastAPI application bootstrap
|
||||||
|
- client: OpenAPI-first HTTP client driven by the same specification
|
||||||
- errors: explicit error hierarchy for contract violations
|
- errors: explicit error hierarchy for contract violations
|
||||||
|
|
||||||
The package root acts as a **namespace**, not a facade. Consumers are
|
The package root acts as a **namespace**, not a facade. Consumers are
|
||||||
@@ -40,7 +41,8 @@ Or with Poetry:
|
|||||||
poetry add openapi-first
|
poetry add openapi-first
|
||||||
|
|
||||||
Runtime dependencies are intentionally minimal:
|
Runtime dependencies are intentionally minimal:
|
||||||
- fastapi
|
- fastapi (server-side)
|
||||||
|
- httpx (client-side)
|
||||||
- openapi-spec-validator
|
- openapi-spec-validator
|
||||||
- pyyaml (optional, for YAML specs)
|
- pyyaml (optional, for YAML specs)
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ The ASGI server (e.g., uvicorn) is an application-level dependency and is
|
|||||||
not bundled with this library.
|
not bundled with this library.
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
Basic Usage
|
Server-Side Usage (OpenAPI → FastAPI)
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
Minimal OpenAPI-first FastAPI application:
|
Minimal OpenAPI-first FastAPI application:
|
||||||
@@ -57,7 +59,7 @@ Minimal OpenAPI-first FastAPI application:
|
|||||||
import my_service.routes as routes
|
import my_service.routes as routes
|
||||||
|
|
||||||
api = app.OpenAPIFirstApp(
|
api = app.OpenAPIFirstApp(
|
||||||
openapi_path="openapi.json",
|
openapi_path="openapi.yaml",
|
||||||
routes_module=routes,
|
routes_module=routes,
|
||||||
title="My Service",
|
title="My Service",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
@@ -81,6 +83,56 @@ OpenAPI snippet:
|
|||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
|
|
||||||
|
The binder guarantees:
|
||||||
|
- Every OpenAPI operationId has exactly one handler
|
||||||
|
- No undocumented routes exist
|
||||||
|
- All mismatches fail at application startup
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Client-Side Usage (OpenAPI → HTTP Client)
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
The same OpenAPI specification can be used to construct a strict,
|
||||||
|
operationId-driven HTTP client.
|
||||||
|
|
||||||
|
Client construction:
|
||||||
|
|
||||||
|
from openapi_first.loader import load_openapi
|
||||||
|
from openapi_first.client import OpenAPIClient
|
||||||
|
|
||||||
|
spec = load_openapi("openapi.yaml")
|
||||||
|
|
||||||
|
client = OpenAPIClient(spec)
|
||||||
|
|
||||||
|
Calling operations (operationId is the API):
|
||||||
|
|
||||||
|
response = client.get_health()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
Path parameters must match the OpenAPI specification exactly:
|
||||||
|
|
||||||
|
response = client.get_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
Request bodies are passed explicitly:
|
||||||
|
|
||||||
|
response = client.create_item(
|
||||||
|
body={"name": "Orange", "price": 0.8}
|
||||||
|
)
|
||||||
|
|
||||||
|
Client guarantees:
|
||||||
|
- One callable per OpenAPI operationId
|
||||||
|
- No hardcoded URLs or HTTP methods in user code
|
||||||
|
- Path and body parameters must match the spec exactly
|
||||||
|
- Invalid or incomplete OpenAPI specs fail at client construction time
|
||||||
|
- No schema inference or mutation is performed
|
||||||
|
|
||||||
|
The client is transport-level only and returns `httpx.Response`
|
||||||
|
objects directly. Response interpretation and validation are left to
|
||||||
|
the consumer or higher-level layers.
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
Extensibility Model
|
Extensibility Model
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
@@ -107,6 +159,7 @@ The supported public API consists of the following top-level modules:
|
|||||||
- openapi_first.app
|
- openapi_first.app
|
||||||
- openapi_first.binder
|
- openapi_first.binder
|
||||||
- openapi_first.loader
|
- openapi_first.loader
|
||||||
|
- openapi_first.client
|
||||||
- openapi_first.errors
|
- openapi_first.errors
|
||||||
|
|
||||||
Classes and functions should be imported explicitly from these modules.
|
Classes and functions should be imported explicitly from these modules.
|
||||||
@@ -118,8 +171,8 @@ Design Guarantees
|
|||||||
|
|
||||||
- OpenAPI is the single source of truth
|
- OpenAPI is the single source of truth
|
||||||
- No undocumented routes can exist
|
- No undocumented routes can exist
|
||||||
- No OpenAPI operation can exist without a handler
|
- No OpenAPI operation can exist without a handler or client callable
|
||||||
- All contract violations fail at application startup
|
- All contract violations fail at application startup or client creation
|
||||||
- No hidden FastAPI magic or implicit behavior
|
- No hidden FastAPI magic or implicit behavior
|
||||||
- Deterministic, testable application assembly
|
- Deterministic, testable application assembly
|
||||||
- CI-friendly failure modes
|
- CI-friendly failure modes
|
||||||
@@ -131,11 +184,13 @@ enforcement over convenience shortcuts.
|
|||||||
from . import app
|
from . import app
|
||||||
from . import binder
|
from . import binder
|
||||||
from . import loader
|
from . import loader
|
||||||
|
from . import client
|
||||||
from . import errors
|
from . import errors
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"app",
|
"app",
|
||||||
"binder",
|
"binder",
|
||||||
"loader",
|
"loader",
|
||||||
|
"client",
|
||||||
"errors",
|
"errors",
|
||||||
]
|
]
|
||||||
|
|||||||
176
openapi_first/client.py
Normal file
176
openapi_first/client.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .errors import OpenAPIFirstError
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAPIClientError(OpenAPIFirstError):
|
||||||
|
"""Raised when an OpenAPI client operation fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAPIClient:
|
||||||
|
"""
|
||||||
|
OpenAPI-first HTTP client (httpx-based).
|
||||||
|
|
||||||
|
- One callable per operationId
|
||||||
|
- Explicit parameters (path, query, headers, body)
|
||||||
|
- No implicit schema inference or mutation
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
spec: dict[str, Any],
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
client: Optional[httpx.Client] = None,
|
||||||
|
) -> None:
|
||||||
|
self.spec = spec
|
||||||
|
self.base_url = base_url or self._resolve_base_url(spec)
|
||||||
|
self.client = client or httpx.Client(base_url=self.base_url)
|
||||||
|
|
||||||
|
self._operations: Dict[str, Callable[..., httpx.Response]] = {}
|
||||||
|
self._build_operations()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Callable[..., httpx.Response]:
|
||||||
|
try:
|
||||||
|
return self._operations[name]
|
||||||
|
except KeyError:
|
||||||
|
raise AttributeError(f"No such operationId: {name}") from None
|
||||||
|
|
||||||
|
def operations(self) -> Dict[str, Callable[..., httpx.Response]]:
|
||||||
|
return dict(self._operations)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Internal mechanics
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _resolve_base_url(self, spec: dict[str, Any]) -> str:
|
||||||
|
servers = spec.get("servers")
|
||||||
|
if not servers:
|
||||||
|
raise OpenAPIClientError("No servers defined in OpenAPI spec")
|
||||||
|
|
||||||
|
url = servers[0].get("url")
|
||||||
|
if not url:
|
||||||
|
raise OpenAPIClientError("Server entry missing 'url'")
|
||||||
|
|
||||||
|
return url.rstrip("/") + "/"
|
||||||
|
|
||||||
|
def _build_operations(self) -> None:
|
||||||
|
paths = self.spec.get("paths", {})
|
||||||
|
if not paths:
|
||||||
|
raise OpenAPIClientError("OpenAPI spec contains no paths")
|
||||||
|
|
||||||
|
for path, path_item in paths.items():
|
||||||
|
for method, operation in path_item.items():
|
||||||
|
if method.lower() not in {
|
||||||
|
"get", "post", "put", "patch", "delete", "head", "options"
|
||||||
|
}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
operation_id = operation.get("operationId")
|
||||||
|
if not operation_id:
|
||||||
|
raise OpenAPIClientError(
|
||||||
|
f"Missing operationId for {method.upper()} {path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if operation_id in self._operations:
|
||||||
|
raise OpenAPIClientError(
|
||||||
|
f"Duplicate operationId detected: {operation_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._operations[operation_id] = self._make_operation(
|
||||||
|
method=method.upper(),
|
||||||
|
path=path,
|
||||||
|
operation=operation,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_operation(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
operation: dict[str, Any],
|
||||||
|
) -> Callable[..., httpx.Response]:
|
||||||
|
request_body = operation.get("requestBody")
|
||||||
|
|
||||||
|
def call(
|
||||||
|
*,
|
||||||
|
path_params: Optional[dict[str, Any]] = None,
|
||||||
|
query: Optional[dict[str, Any]] = None,
|
||||||
|
headers: Optional[dict[str, str]] = None,
|
||||||
|
body: Optional[Any] = None,
|
||||||
|
timeout: Optional[float] = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
url = self._build_url(path, path_params or {})
|
||||||
|
|
||||||
|
req_headers = headers.copy() if headers else {}
|
||||||
|
|
||||||
|
json_data = None
|
||||||
|
content = None
|
||||||
|
|
||||||
|
if request_body is not None:
|
||||||
|
if body is None:
|
||||||
|
raise OpenAPIClientError(
|
||||||
|
f"Request body required for operation {operation['operationId']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
media_types = request_body.get("content", {})
|
||||||
|
if "application/json" in media_types:
|
||||||
|
json_data = body
|
||||||
|
req_headers.setdefault("Content-Type", "application/json")
|
||||||
|
else:
|
||||||
|
content = body
|
||||||
|
|
||||||
|
response = self.client.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
params=query,
|
||||||
|
headers=req_headers,
|
||||||
|
json=json_data,
|
||||||
|
content=content,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
call.__name__ = operation["operationId"]
|
||||||
|
call.__doc__ = self._build_docstring(method, path, operation)
|
||||||
|
|
||||||
|
return call
|
||||||
|
|
||||||
|
def _build_url(self, path: str, path_params: dict[str, Any]) -> str:
|
||||||
|
try:
|
||||||
|
formatted_path = path.format(**path_params)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise OpenAPIClientError(
|
||||||
|
f"Missing path parameter: {exc.args[0]}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return urljoin(self.base_url, formatted_path.lstrip("/"))
|
||||||
|
|
||||||
|
def _build_docstring(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
operation: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
lines = [
|
||||||
|
f"{method} {path}",
|
||||||
|
"",
|
||||||
|
operation.get("summary", ""),
|
||||||
|
"",
|
||||||
|
"Parameters:",
|
||||||
|
" path_params: dict | None",
|
||||||
|
" query: dict | None",
|
||||||
|
" headers: dict | None",
|
||||||
|
" body: Any | None",
|
||||||
|
"",
|
||||||
|
"Returns:",
|
||||||
|
" httpx.Response",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -25,8 +25,6 @@ This module intentionally does NOT:
|
|||||||
- Perform request/response validation at runtime
|
- Perform request/response validation at runtime
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|||||||
@@ -13,14 +13,22 @@ The tests assume:
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from main import app
|
from main import app
|
||||||
|
from openapi_first.loader import load_openapi
|
||||||
|
from openapi_first.client import OpenAPIClient
|
||||||
|
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
spec = load_openapi("openapi.yaml")
|
||||||
|
client = OpenAPIClient(
|
||||||
|
spec=spec,
|
||||||
|
base_url="http://testserver",
|
||||||
|
client=client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_list_items_initial():
|
def test_list_items_initial():
|
||||||
"""Initial items should be present."""
|
"""Initial items should be present."""
|
||||||
response = client.get("/items")
|
response = client.list_items()
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -34,7 +42,9 @@ def test_list_items_initial():
|
|||||||
|
|
||||||
def test_get_item():
|
def test_get_item():
|
||||||
"""Existing item should be retrievable by ID."""
|
"""Existing item should be retrievable by ID."""
|
||||||
response = client.get("/items/1")
|
response = client.get_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
item = response.json()
|
item = response.json()
|
||||||
@@ -50,7 +60,9 @@ def test_create_item():
|
|||||||
"price": 0.8,
|
"price": 0.8,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = client.post("/items", json=payload)
|
response = client.create_item(
|
||||||
|
body=payload
|
||||||
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
item = response.json()
|
item = response.json()
|
||||||
@@ -59,7 +71,7 @@ def test_create_item():
|
|||||||
assert item["price"] == payload["price"]
|
assert item["price"] == payload["price"]
|
||||||
|
|
||||||
# Verify it appears in list
|
# Verify it appears in list
|
||||||
list_response = client.get("/items")
|
list_response = client.list_items()
|
||||||
ids = {i["id"] for i in list_response.json()}
|
ids = {i["id"] for i in list_response.json()}
|
||||||
assert item["id"] in ids
|
assert item["id"] in ids
|
||||||
|
|
||||||
@@ -71,7 +83,10 @@ def test_update_item():
|
|||||||
"price": 0.6,
|
"price": 0.6,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = client.put("/items/1", json=payload)
|
response = client.update_item(
|
||||||
|
path_params={"item_id": 1},
|
||||||
|
body=payload,
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
item = response.json()
|
item = response.json()
|
||||||
@@ -80,7 +95,9 @@ def test_update_item():
|
|||||||
assert item["price"] == payload["price"]
|
assert item["price"] == payload["price"]
|
||||||
|
|
||||||
# Verify persisted update
|
# Verify persisted update
|
||||||
get_response = client.get("/items/1")
|
get_response = client.get_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
updated = get_response.json()
|
updated = get_response.json()
|
||||||
assert updated["name"] == payload["name"]
|
assert updated["name"] == payload["name"]
|
||||||
assert updated["price"] == payload["price"]
|
assert updated["price"] == payload["price"]
|
||||||
@@ -88,10 +105,12 @@ def test_update_item():
|
|||||||
|
|
||||||
def test_delete_item():
|
def test_delete_item():
|
||||||
"""Deleting an item should remove it from the store."""
|
"""Deleting an item should remove it from the store."""
|
||||||
response = client.delete("/items/2")
|
response = client.delete_item(
|
||||||
|
path_params={"item_id": 2}
|
||||||
|
)
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
# Verify deletion
|
# Verify deletion
|
||||||
list_response = client.get("/items")
|
list_response = client.list_items()
|
||||||
ids = {item["id"] for item in list_response.json()}
|
ids = {item["id"] for item in list_response.json()}
|
||||||
assert 2 not in ids
|
assert 2 not in ids
|
||||||
|
|||||||
@@ -14,14 +14,22 @@ The tests assume:
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from main import app
|
from main import app
|
||||||
|
from openapi_first.loader import load_openapi
|
||||||
|
from openapi_first.client import OpenAPIClient
|
||||||
|
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
spec = load_openapi("openapi.yaml")
|
||||||
|
client = OpenAPIClient(
|
||||||
|
spec=spec,
|
||||||
|
base_url="http://testserver",
|
||||||
|
client=client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_list_items_initial():
|
def test_list_items_initial():
|
||||||
"""Initial items should be present."""
|
"""Initial items should be present."""
|
||||||
response = client.get("/items")
|
response = client.list_items()
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -35,7 +43,9 @@ def test_list_items_initial():
|
|||||||
|
|
||||||
def test_get_item():
|
def test_get_item():
|
||||||
"""Existing item should be retrievable by ID."""
|
"""Existing item should be retrievable by ID."""
|
||||||
response = client.get("/items/1")
|
response = client.get_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
item = response.json()
|
item = response.json()
|
||||||
@@ -51,7 +61,9 @@ def test_create_item():
|
|||||||
"price": 0.8,
|
"price": 0.8,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = client.post("/items", json=payload)
|
response = client.create_item(
|
||||||
|
body=payload
|
||||||
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
item = response.json()
|
item = response.json()
|
||||||
@@ -60,7 +72,7 @@ def test_create_item():
|
|||||||
assert item["price"] == payload["price"]
|
assert item["price"] == payload["price"]
|
||||||
|
|
||||||
# Verify it appears in list
|
# Verify it appears in list
|
||||||
list_response = client.get("/items")
|
list_response = client.list_items()
|
||||||
ids = {i["id"] for i in list_response.json()}
|
ids = {i["id"] for i in list_response.json()}
|
||||||
assert item["id"] in ids
|
assert item["id"] in ids
|
||||||
|
|
||||||
@@ -72,7 +84,10 @@ def test_update_item():
|
|||||||
"price": 0.6,
|
"price": 0.6,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = client.put("/items/1", json=payload)
|
response = client.update_item(
|
||||||
|
path_params={"item_id": 1},
|
||||||
|
body=payload,
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
item = response.json()
|
item = response.json()
|
||||||
@@ -81,7 +96,9 @@ def test_update_item():
|
|||||||
assert item["price"] == payload["price"]
|
assert item["price"] == payload["price"]
|
||||||
|
|
||||||
# Verify persisted update
|
# Verify persisted update
|
||||||
get_response = client.get("/items/1")
|
get_response = client.get_item(
|
||||||
|
path_params={"item_id": 1}
|
||||||
|
)
|
||||||
updated = get_response.json()
|
updated = get_response.json()
|
||||||
assert updated["name"] == payload["name"]
|
assert updated["name"] == payload["name"]
|
||||||
assert updated["price"] == payload["price"]
|
assert updated["price"] == payload["price"]
|
||||||
@@ -89,10 +106,12 @@ def test_update_item():
|
|||||||
|
|
||||||
def test_delete_item():
|
def test_delete_item():
|
||||||
"""Deleting an item should remove it from the store."""
|
"""Deleting an item should remove it from the store."""
|
||||||
response = client.delete("/items/2")
|
response = client.delete_item(
|
||||||
|
path_params={"item_id": 2}
|
||||||
|
)
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
# Verify deletion
|
# Verify deletion
|
||||||
list_response = client.get("/items")
|
list_response = client.list_items()
|
||||||
ids = {item["id"] for item in list_response.json()}
|
ids = {item["id"] for item in list_response.json()}
|
||||||
assert 2 not in ids
|
assert 2 not in ids
|
||||||
|
|||||||
Reference in New Issue
Block a user