feat: add imported PVE contract and core endpoints
This commit is contained in:
+3
-1
@@ -8,8 +8,10 @@ DB_COMMAND_TIMEOUT_SECONDS=30
|
|||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
REQUEST_ID_HEADER=X-Request-ID
|
REQUEST_ID_HEADER=X-Request-ID
|
||||||
PVE_API_VERSION=9.2.3
|
PVE_API_VERSION=9.2.3
|
||||||
|
CONTRACT_SNAPSHOT=/app/contracts/pve-9.2.3.json
|
||||||
|
CONTRACT_FALLBACK=error
|
||||||
|
TICKET_SIGNING_KEY=development-only-signing-key-change-me
|
||||||
SIMULATION_SEED=42
|
SIMULATION_SEED=42
|
||||||
SIMULATION_TIME_SCALE=10
|
SIMULATION_TIME_SCALE=10
|
||||||
SIMULATOR_ADMIN_ENABLED=false
|
SIMULATOR_ADMIN_ENABLED=false
|
||||||
SIMULATOR_ADMIN_TOKEN=replace-with-a-long-random-secret
|
SIMULATOR_ADMIN_TOKEN=replace-with-a-long-random-secret
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ ENV PATH="/opt/venv/bin:$PATH" \
|
|||||||
RUN groupadd --system --gid 10001 simulator \
|
RUN groupadd --system --gid 10001 simulator \
|
||||||
&& useradd --system --uid 10001 --gid simulator --home-dir /app --no-create-home simulator
|
&& useradd --system --uid 10001 --gid simulator --home-dir /app --no-create-home simulator
|
||||||
COPY --from=builder /opt/venv /opt/venv
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
COPY contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json /app/contracts/pve-9.2.3.json
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
USER 10001:10001
|
USER 10001:10001
|
||||||
EXPOSE 8006
|
EXPOSE 8006
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ Imported methods can be registered dynamically, but no stateful Proxmox method
|
|||||||
is claimed as compatible yet; the vertical slice is tracked in
|
is claimed as compatible yet; the vertical slice is tracked in
|
||||||
[the implementation plan](docs/implementation-plan.md).
|
[the implementation plan](docs/implementation-plan.md).
|
||||||
|
|
||||||
|
The bundled PVE 9.2.3 declared contract contains 444 paths and 675 methods.
|
||||||
|
Implemented semantics currently include version, ticket login, node listing and
|
||||||
|
status, and cluster resources; all other declared methods return an explicit
|
||||||
|
unsupported error.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Python 3.13 is required.
|
Python 3.13 is required.
|
||||||
@@ -26,6 +31,9 @@ curl http://localhost:8006/health/live
|
|||||||
curl http://localhost:8006/health/ready
|
curl http://localhost:8006/health/ready
|
||||||
make db-migrate
|
make db-migrate
|
||||||
make seed
|
make seed
|
||||||
|
curl http://localhost:8006/api2/json/version
|
||||||
|
curl -X POST -d 'username=root@pam&password=secret' \
|
||||||
|
http://localhost:8006/api2/json/access/ticket
|
||||||
```
|
```
|
||||||
|
|
||||||
Database migrations are ordered SQL files applied transactionally and recorded
|
Database migrations are ordered SQL files applied transactionally and recorded
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class Settings(BaseSettings):
|
|||||||
request_id_header: str = "X-Request-ID"
|
request_id_header: str = "X-Request-ID"
|
||||||
contract_snapshot: Path | None = None
|
contract_snapshot: Path | None = None
|
||||||
contract_fallback: Literal["error", "schema-default", "fixture"] = "error"
|
contract_fallback: Literal["error", "schema-default", "fixture"] = "error"
|
||||||
|
ticket_signing_key: SecretStr = SecretStr("development-only-signing-key-change-me")
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ def _json(value: Any) -> JsonValue:
|
|||||||
|
|
||||||
def normalize_schema(raw: Mapping[str, Any] | None) -> Schema:
|
def normalize_schema(raw: Mapping[str, Any] | None) -> Schema:
|
||||||
source = raw or {}
|
source = raw or {}
|
||||||
properties = source.get("properties", {})
|
properties = source.get("properties") or {}
|
||||||
normalized_properties = {
|
normalized_properties = {
|
||||||
str(name): normalize_schema(cast(Mapping[str, Any], schema))
|
str(name): normalize_schema(cast(Mapping[str, Any], schema))
|
||||||
for name, schema in cast(Mapping[str, Any], properties).items()
|
for name, schema in cast(Mapping[str, Any], properties).items()
|
||||||
@@ -67,7 +67,7 @@ def normalize_schema(raw: Mapping[str, Any] | None) -> Schema:
|
|||||||
items=normalize_schema(cast(Mapping[str, Any], items))
|
items=normalize_schema(cast(Mapping[str, Any], items))
|
||||||
if isinstance(items, Mapping)
|
if isinstance(items, Mapping)
|
||||||
else None,
|
else None,
|
||||||
enum=tuple(_json(value) for value in source.get("enum", ())),
|
enum=tuple(_json(value) for value in (source.get("enum") or ())),
|
||||||
optional=bool(source["optional"]) if "optional" in source else None,
|
optional=bool(source["optional"]) if "optional" in source else None,
|
||||||
default=_json(source.get("default")),
|
default=_json(source.get("default")),
|
||||||
minimum=source.get("minimum"),
|
minimum=source.get("minimum"),
|
||||||
@@ -96,7 +96,7 @@ def normalize_permissions(raw: Mapping[str, Any] | None) -> Permissions | None:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_method(verb: str, raw: Mapping[str, Any]) -> Method:
|
def normalize_method(verb: str, raw: Mapping[str, Any]) -> Method:
|
||||||
parameters_raw = cast(Mapping[str, Any], raw.get("parameters", {})).get("properties", {})
|
parameters_raw = cast(Mapping[str, Any], raw.get("parameters") or {}).get("properties") or {}
|
||||||
parameters = tuple(
|
parameters = tuple(
|
||||||
Parameter(name=str(name), definition=normalize_schema(cast(Mapping[str, Any], schema)))
|
Parameter(name=str(name), definition=normalize_schema(cast(Mapping[str, Any], schema)))
|
||||||
for name, schema in sorted(cast(Mapping[str, Any], parameters_raw).items())
|
for name, schema in sorted(cast(Mapping[str, Any], parameters_raw).items())
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Semantic handlers for implemented Proxmox methods."""
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""First read/login semantic service handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.config import Settings
|
||||||
|
from app.db.pool import AsyncpgDatabase
|
||||||
|
from app.security.auth import csrf_token, issue_ticket, verify_secret
|
||||||
|
|
||||||
|
|
||||||
|
def _database(request: Request) -> AsyncpgDatabase:
|
||||||
|
return cast(AsyncpgDatabase, request.app.state.database)
|
||||||
|
|
||||||
|
|
||||||
|
def build_core_handlers(settings: Settings) -> HandlerRegistry:
|
||||||
|
registry = HandlerRegistry()
|
||||||
|
|
||||||
|
async def version(_request: Request, _inputs: dict[str, Any]) -> dict[str, str]:
|
||||||
|
return {"version": "9.2.3", "release": "9.2", "repoid": "simulator"}
|
||||||
|
|
||||||
|
async def login(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
values = cast(dict[str, Any], inputs["values"])
|
||||||
|
username = str(values["username"])
|
||||||
|
password = str(values["password"])
|
||||||
|
row = await _database(request).pool.fetchrow(
|
||||||
|
"SELECT name, password_hash FROM principals WHERE name=$1", username
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
row is None
|
||||||
|
or row["password_hash"] is None
|
||||||
|
or not verify_secret(password, str(row["password_hash"]))
|
||||||
|
):
|
||||||
|
raise ApiError(401, "authentication failure")
|
||||||
|
key = settings.ticket_signing_key.get_secret_value().encode()
|
||||||
|
ticket = issue_ticket(username, key)
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"ticket": ticket,
|
||||||
|
"CSRFPreventionToken": csrf_token(ticket, key),
|
||||||
|
"cap": {"vms": {"VM.Audit": 1, "VM.PowerMgmt": 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def nodes(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await _database(request).pool.fetch(
|
||||||
|
"SELECT name AS node, status FROM nodes ORDER BY name"
|
||||||
|
)
|
||||||
|
return [{"node": str(row["node"]), "status": str(row["status"])} for row in rows]
|
||||||
|
|
||||||
|
async def node_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(cast(dict[str, Any], inputs["values"])["node"])
|
||||||
|
row = await _database(request).pool.fetchrow(
|
||||||
|
"SELECT name, status FROM nodes WHERE name=$1", node
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "node does not exist")
|
||||||
|
return {
|
||||||
|
"status": str(row["status"]),
|
||||||
|
"node": str(row["name"]),
|
||||||
|
"uptime": 0,
|
||||||
|
"cpu": 0.0,
|
||||||
|
"memory": {"used": 0, "total": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def resources(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await _database(request).pool.fetch(
|
||||||
|
"""SELECT r.kind AS type, r.external_id, r.state, n.name AS node
|
||||||
|
FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
ORDER BY r.kind, r.external_id"""
|
||||||
|
)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
raw_state = row["state"]
|
||||||
|
state = json.loads(raw_state) if isinstance(raw_state, str) else dict(raw_state)
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"type": str(row["type"]),
|
||||||
|
"id": f"{row['type']}/{row['external_id']}",
|
||||||
|
"node": str(row["node"]),
|
||||||
|
**state,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
registry.register("/version", "GET", version)
|
||||||
|
registry.register("/access/ticket", "POST", login)
|
||||||
|
registry.register("/nodes", "GET", nodes)
|
||||||
|
registry.register("/nodes/{node}/status", "GET", node_status)
|
||||||
|
registry.register("/cluster/resources", "GET", resources)
|
||||||
|
return registry
|
||||||
+2
-1
@@ -10,6 +10,7 @@ from app.api.registry import HandlerRegistry, register_contract_routes
|
|||||||
from app.compatibility import build_report
|
from app.compatibility import build_report
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.contracts.model import Snapshot
|
from app.contracts.model import Snapshot
|
||||||
|
from app.handlers.core import build_core_handlers
|
||||||
from app.lifespan import DatabaseFactory, WorkerFactory, create_lifespan, default_database_factory
|
from app.lifespan import DatabaseFactory, WorkerFactory, create_lifespan, default_database_factory
|
||||||
from app.logging import configure_logging
|
from app.logging import configure_logging
|
||||||
from app.observability.health import router as health_router
|
from app.observability.health import router as health_router
|
||||||
@@ -36,7 +37,7 @@ def create_app(
|
|||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
if resolved.contract_snapshot is not None:
|
if resolved.contract_snapshot is not None:
|
||||||
snapshot = Snapshot.model_validate_json(resolved.contract_snapshot.read_bytes())
|
snapshot = Snapshot.model_validate_json(resolved.contract_snapshot.read_bytes())
|
||||||
resolved_handlers = handlers or HandlerRegistry()
|
resolved_handlers = handlers or build_core_handlers(resolved)
|
||||||
register_contract_routes(
|
register_contract_routes(
|
||||||
app,
|
app,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from dataclasses import dataclass
|
|||||||
import asyncpg # type: ignore[import-untyped]
|
import asyncpg # type: ignore[import-untyped]
|
||||||
from asyncpg import Connection
|
from asyncpg import Connection
|
||||||
|
|
||||||
|
from app.security.auth import hash_secret
|
||||||
|
|
||||||
NAMESPACE = uuid.UUID("c9040a72-b391-4a7e-9864-3ae46291a531")
|
NAMESPACE = uuid.UUID("c9040a72-b391-4a7e-9864-3ae46291a531")
|
||||||
|
|
||||||
|
|
||||||
@@ -89,6 +91,14 @@ async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
|
|||||||
for resource in profile.resources
|
for resource in profile.resources
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
await connection.execute(
|
||||||
|
"""INSERT INTO principals(id, name, password_hash, realm_name)
|
||||||
|
VALUES($1, 'root@pam', $2, 'pam')
|
||||||
|
ON CONFLICT (name) DO UPDATE SET password_hash=EXCLUDED.password_hash,
|
||||||
|
realm_name=EXCLUDED.realm_name""",
|
||||||
|
stable_id("principal:root@pam"),
|
||||||
|
hash_secret("secret", salt=b"pve-simulator-v1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def seed_url(database_url: str) -> dict[str, object]:
|
async def seed_url(database_url: str) -> dict[str, object]:
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"method_count":675,"path_count":444,"raw_sha256":"f2b77b57c71f3781a0993cc5062940ef31e0843fd9a6bcfdb4de4dd2001d6d9e","snapshot_sha256":"e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1","source_version":"9.2.3"}
|
||||||
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
@@ -24,6 +24,7 @@ services:
|
|||||||
required: false
|
required: false
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
|
DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
|
||||||
|
CONTRACT_SNAPSHOT: /app/contracts/pve-9.2.3.json
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -68,6 +68,23 @@ def test_unknown_schema_fields_are_retained() -> None:
|
|||||||
assert snapshot.paths[0].methods[0].returns.extra["futureKeyword"] == {"x": 1}
|
assert snapshot.paths[0].methods[0].returns.extra["futureKeyword"] == {"x": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_nullable_source_collections_normalize_as_empty() -> None:
|
||||||
|
raw = (
|
||||||
|
b'[{"path":"/nullable","info":{"GET":{"parameters":{"properties":null},'
|
||||||
|
b'"returns":{"type":"string","enum":null}}}}]'
|
||||||
|
)
|
||||||
|
snapshot, _ = normalize_snapshot(
|
||||||
|
ApiViewerParser().parse(raw),
|
||||||
|
raw=raw,
|
||||||
|
source_version="test",
|
||||||
|
retrieved_at=RETRIEVED_AT,
|
||||||
|
)
|
||||||
|
|
||||||
|
method = snapshot.paths[0].methods[0]
|
||||||
|
assert method.parameters == ()
|
||||||
|
assert method.returns.enum == ()
|
||||||
|
|
||||||
|
|
||||||
@given(st.dictionaries(st.text(min_size=1), st.integers(), max_size=10))
|
@given(st.dictionaries(st.text(min_size=1), st.integers(), max_size=10))
|
||||||
def test_canonical_json_is_independent_of_mapping_order(values: dict[str, int]) -> None:
|
def test_canonical_json_is_independent_of_mapping_order(values: dict[str, int]) -> None:
|
||||||
reversed_values = dict(reversed(tuple(values.items())))
|
reversed_values = dict(reversed(tuple(values.items())))
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""First vertical read/login handler tests."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.contracts.model import Method, Parameter, PathContract, Schema, Snapshot
|
||||||
|
from app.main import create_app
|
||||||
|
from app.security.auth import hash_secret
|
||||||
|
|
||||||
|
|
||||||
|
class FakePool:
|
||||||
|
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||||
|
if "principals" in sql and args[0] == "root@pam":
|
||||||
|
return {
|
||||||
|
"name": "root@pam",
|
||||||
|
"password_hash": hash_secret("secret", salt=b"pve-simulator-v1"),
|
||||||
|
}
|
||||||
|
if "FROM nodes" in sql and args[0] == "pve1":
|
||||||
|
return {"name": "pve1", "status": "online"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
|
||||||
|
del args
|
||||||
|
if "FROM nodes" in sql:
|
||||||
|
return [{"node": "pve1", "status": "online"}]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "qemu",
|
||||||
|
"external_id": "100",
|
||||||
|
"state": '{"status":"stopped"}',
|
||||||
|
"node": "pve1",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDatabase:
|
||||||
|
pool = FakePool()
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def is_ready(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def method(verb: str, name: str, parameters: tuple[Parameter, ...] = ()) -> Method:
|
||||||
|
return Method(
|
||||||
|
verb=verb,
|
||||||
|
name=name,
|
||||||
|
parameters=parameters,
|
||||||
|
returns=Schema(type="object"),
|
||||||
|
checksum=(name[0] * 64),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_snapshot(path: Path) -> None:
|
||||||
|
string = Schema(type="string")
|
||||||
|
paths = (
|
||||||
|
PathContract(path="/version", methods=(method("GET", "version"),)),
|
||||||
|
PathContract(
|
||||||
|
path="/access/ticket",
|
||||||
|
methods=(
|
||||||
|
method(
|
||||||
|
"POST",
|
||||||
|
"ticket",
|
||||||
|
(
|
||||||
|
Parameter(name="username", definition=string),
|
||||||
|
Parameter(name="password", definition=string),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PathContract(path="/nodes", methods=(method("GET", "nodes"),)),
|
||||||
|
PathContract(
|
||||||
|
path="/nodes/{node}/status",
|
||||||
|
methods=(method("GET", "status", (Parameter(name="node", definition=string),)),),
|
||||||
|
),
|
||||||
|
PathContract(path="/cluster/resources", methods=(method("GET", "resources"),)),
|
||||||
|
)
|
||||||
|
snapshot = Snapshot(
|
||||||
|
source_version="test",
|
||||||
|
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||||
|
raw_sha256="0" * 64,
|
||||||
|
paths=paths,
|
||||||
|
path_count=len(paths),
|
||||||
|
method_count=5,
|
||||||
|
)
|
||||||
|
path.write_bytes(snapshot.canonical_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_core_login_and_read_endpoints(tmp_path: Path) -> None:
|
||||||
|
snapshot_path = tmp_path / "snapshot.json"
|
||||||
|
write_snapshot(snapshot_path)
|
||||||
|
database = FakeDatabase()
|
||||||
|
app = create_app(Settings(contract_snapshot=snapshot_path), lambda _settings: database)
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
login = await client.post(
|
||||||
|
"/api2/json/access/ticket",
|
||||||
|
content="username=root%40pam&password=secret",
|
||||||
|
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
version = await client.get("/api2/json/version")
|
||||||
|
nodes = await client.get("/api2/json/nodes")
|
||||||
|
status = await client.get("/api2/json/nodes/pve1/status")
|
||||||
|
resources = await client.get("/api2/json/cluster/resources")
|
||||||
|
|
||||||
|
assert login.status_code == 200
|
||||||
|
assert login.json()["data"]["username"] == "root@pam"
|
||||||
|
assert "ticket" in login.json()["data"]
|
||||||
|
assert version.json()["data"]["release"] == "9.2"
|
||||||
|
assert nodes.json()["data"][0]["node"] == "pve1"
|
||||||
|
assert status.json()["data"]["status"] == "online"
|
||||||
|
assert resources.json()["data"][0]["type"] == "qemu"
|
||||||
@@ -44,7 +44,11 @@ async def request_app(
|
|||||||
snapshot_path.write_bytes(contract_snapshot(get_method()).canonical_bytes())
|
snapshot_path.write_bytes(contract_snapshot(get_method()).canonical_bytes())
|
||||||
settings = Settings(contract_snapshot=snapshot_path, contract_fallback=fallback)
|
settings = Settings(contract_snapshot=snapshot_path, contract_fallback=fallback)
|
||||||
database = FakeDatabase(True)
|
database = FakeDatabase(True)
|
||||||
app = create_app(settings, lambda _settings: database, handlers)
|
app = create_app(
|
||||||
|
settings,
|
||||||
|
lambda _settings: database,
|
||||||
|
handlers if handlers is not None else HandlerRegistry(),
|
||||||
|
)
|
||||||
async with app.router.lifespan_context(app):
|
async with app.router.lifespan_context(app):
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
json_response = await client.get("/api2/json/version")
|
json_response = await client.get("/api2/json/version")
|
||||||
|
|||||||
Reference in New Issue
Block a user