Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions python/cuopt_server/cuopt_server/proxy_webserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,10 @@ def _require_uuid(id):
)


def _resolve_accept(accept, fallback=mime_json):
def _resolve_accept(accept, fallback=mime_msgpack):
# Wildcards match encode(): */* and application/* are msgpack unless
# a caller supplies a different fallback (POST /cuopt/request uses
# Content-Type; GET solution uses the stored request accept).
if not accept:
return fallback
if accept not in [mime_json, mime_msgpack, mime_zlib] + mime_wild:
Expand Down Expand Up @@ -712,7 +715,7 @@ def _require_grpc_healthy():
)
def getsolverlogs(
id: str,
accept: str = Header(default="application/json"),
accept: Optional[str] = Header(default=None),
frombyte: Optional[int] = Query(default=0),
):
try:
Expand Down Expand Up @@ -758,7 +761,7 @@ def getsolverlogs(
@app.delete("/cuopt/log/{id}", responses=DeleteResponse)
def deletesolverlogs(
id: str,
accept: str = Header(default="application/json"),
accept: Optional[str] = Header(default=None),
):
try:
accept = _resolve_accept(accept)
Expand All @@ -778,7 +781,7 @@ def deletesolverlogs(
)
def getincumbent(
id: str,
accept: str = Header(default="application/json"),
accept: Optional[str] = Header(default=None),
):
try:
accept = _resolve_accept(accept)
Expand Down Expand Up @@ -833,7 +836,7 @@ async def postsolution():
@app.delete("/cuopt/solution/{id}", responses=DeleteResponse)
def deletesolution(
id: str = Path(...),
accept: str = Header(default="application/json"),
accept: Optional[str] = Header(default=None),
):
try:
accept = _resolve_accept(accept)
Expand Down Expand Up @@ -868,7 +871,7 @@ def deletesolution(
)
def deleterequest(
id: str = Path(...),
accept: str = Header(default="application/json"),
accept: Optional[str] = Header(default=None),
running: Optional[bool] = Query(default=None),
queued: Optional[bool] = Query(default=None),
cached: Optional[bool] = Query(default=None),
Expand Down Expand Up @@ -933,13 +936,13 @@ def getwarmstart(id: str):
)
def getsolution(
id: str,
accept: str = Header(default="application/json"),
accept: Optional[str] = Header(default=None),
):
try:
fallback = mime_json
fallback = mime_msgpack
meta = _get_job(id)
if meta is not None:
fallback = meta.get("accept", mime_json)
fallback = meta.get("accept", mime_msgpack)
accept = _resolve_accept(accept, fallback)
_require_uuid(id)
if meta is not None and meta.get("validation_only"):
Expand Down Expand Up @@ -1020,7 +1023,7 @@ def getsolution(
)
def getrequest(
id: str,
accept: str = Header(default="application/json"),
accept: Optional[str] = Header(default=None),
):
try:
accept = _resolve_accept(accept)
Expand Down Expand Up @@ -1094,7 +1097,7 @@ async def postrequest(
cuopt_data_file: str = Header(default=None),
cuopt_result_file: str = Header(default=None),
client_version: str = Header(default=None),
accept: str = Header(default="application/json"),
accept: Optional[str] = Header(default=None),
content_type: str = Header(default="application/json"),
content_length: int = Header(default=0),
):
Expand Down
207 changes: 194 additions & 13 deletions python/cuopt_server/cuopt_server/tests/test_grpc_http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,31 @@
set_grpc_routing_client,
set_max_request_size,
)
from cuopt_server.utils.http_codec import mime_json, mime_msgpack, mime_zlib
from cuopt_server.utils.http_codec import (
mime_json,
mime_msgpack,
mime_wild,
mime_zlib,
)
from cuopt_server.utils.http_envelope import make_response
from cuopt_server.utils.linear_programming import conversion as lp_conversion
from cuopt_server.utils.routing import conversion as routing_conversion

_JSON_ACCEPT = {"Accept": mime_json}


class _Uvicorn(uvicorn.Server):
def install_signal_handlers(self):
pass


def _request_without_accept(method, url, **kwargs):
session = requests.Session()
prepared = session.prepare_request(requests.Request(method, url, **kwargs))
prepared.headers.pop("Accept", None)
return session.send(prepared)


def _free_port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
Expand Down Expand Up @@ -480,11 +494,12 @@ def test_submit_status_result_delete(proxy):
uuid.UUID(req_id)
assert fake.submitted[0]["enable_incumbents"] is False

st = requests.get(url + f"/cuopt/request/{req_id}")
st = requests.get(url + f"/cuopt/request/{req_id}", headers=_JSON_ACCEPT)
assert st.status_code == 200
assert st.headers["content-type"].startswith(mime_json)
assert st.json() == "completed"

sol = requests.get(url + f"/cuopt/solution/{req_id}")
sol = requests.get(url + f"/cuopt/solution/{req_id}", headers=_JSON_ACCEPT)
assert sol.status_code == 200
body = sol.json()
assert body["reqId"] == req_id
Expand Down Expand Up @@ -674,13 +689,17 @@ def test_incumbents_cursor_and_sentinel(proxy):
{"index": 0, "objective": 2.0, "assignment": [1.0, 1.0]},
{"index": 1, "objective": 1.0, "assignment": [0.0, 1.0]},
]
first = requests.get(url + f"/cuopt/solution/{req_id}/incumbents")
first = requests.get(
url + f"/cuopt/solution/{req_id}/incumbents", headers=_JSON_ACCEPT
)
assert first.status_code == 200
assert first.json() == [
{"solution": [1.0, 1.0], "cost": 2.0, "bound": None},
{"solution": [0.0, 1.0], "cost": 1.0, "bound": None},
]
second = requests.get(url + f"/cuopt/solution/{req_id}/incumbents")
second = requests.get(
url + f"/cuopt/solution/{req_id}/incumbents", headers=_JSON_ACCEPT
)
assert second.json() == [{"solution": [], "cost": None, "bound": None}]


Expand All @@ -694,7 +713,7 @@ def test_logs_and_log_delete_noop(proxy):
json=lp,
)
req_id = res.json()["reqId"]
logs = requests.get(url + f"/cuopt/log/{req_id}")
logs = requests.get(url + f"/cuopt/log/{req_id}", headers=_JSON_ACCEPT)
assert logs.status_code == 200
body = logs.json()
assert body["log"] == ["line1", "line2"]
Expand All @@ -711,7 +730,9 @@ def test_cancel_request(proxy):
json=lp,
).json()["reqId"]
fake.jobs[req_id] = FakeJobStatus.PROCESSING
res = requests.delete(url + f"/cuopt/request/{req_id}")
res = requests.delete(
url + f"/cuopt/request/{req_id}", headers=_JSON_ACCEPT
)
assert res.status_code == 200
assert res.json() == {"queued": 0, "running": 1, "cached": 0}
assert req_id in fake.cancelled
Expand All @@ -725,7 +746,9 @@ def test_cancel_completed_is_noop(proxy):
headers={"CLIENT-VERSION": "custom"},
json=lp,
).json()["reqId"]
res = requests.delete(url + f"/cuopt/request/{req_id}")
res = requests.delete(
url + f"/cuopt/request/{req_id}", headers=_JSON_ACCEPT
)
assert res.status_code == 200
assert res.json() == {"queued": 0, "running": 0, "cached": 0}
assert req_id not in fake.cancelled
Expand All @@ -743,9 +766,11 @@ def test_validation_only_skips_submit(proxy):
assert res.status_code == 200
req_id = res.json()["reqId"]
assert fake.submitted == []
st = requests.get(url + f"/cuopt/request/{req_id}")
st = requests.get(url + f"/cuopt/request/{req_id}", headers=_JSON_ACCEPT)
assert st.json() == "completed"
sol = requests.get(url + f"/cuopt/solution/{req_id}").json()
sol = requests.get(
url + f"/cuopt/solution/{req_id}", headers=_JSON_ACCEPT
).json()
assert sol["notes"] == ["Input is valid"]
assert sol["response"]["solver_response"]["status"] == 0

Expand Down Expand Up @@ -793,10 +818,10 @@ def test_vrp_submit_status_and_solution(proxy):
req_id = res.json()["reqId"]
assert fake.submitted == []
assert len(fake.routing.submitted) == 1
st = requests.get(url + f"/cuopt/request/{req_id}")
st = requests.get(url + f"/cuopt/request/{req_id}", headers=_JSON_ACCEPT)
assert st.status_code == 200
assert st.json() == "completed"
sol = requests.get(url + f"/cuopt/solution/{req_id}")
sol = requests.get(url + f"/cuopt/solution/{req_id}", headers=_JSON_ACCEPT)
assert sol.status_code == 200, sol.text
body = sol.json()["response"]["solver_response"]
assert body["status"] == 0
Expand Down Expand Up @@ -853,7 +878,7 @@ def test_vrp_solution_after_sidecar_lost(proxy):
).json()["reqId"]
with pw._jobs_lock:
pw._jobs.pop(req_id, None)
sol = requests.get(url + f"/cuopt/solution/{req_id}")
sol = requests.get(url + f"/cuopt/solution/{req_id}", headers=_JSON_ACCEPT)
assert sol.status_code == 200, sol.text
assert "vehicle_data" in sol.json()["response"]["solver_response"]

Expand Down Expand Up @@ -953,6 +978,162 @@ def test_lp_does_not_enable_incumbents(proxy):
assert fake.submitted[0]["enable_incumbents"] is False


@pytest.mark.parametrize("accept", mime_wild)
def test_wildcard_accept_status_is_msgpack(proxy, accept):
import msgpack

url, _ = proxy
req_id = requests.post(
url + "/cuopt/request",
headers={"CLIENT-VERSION": "custom", **_JSON_ACCEPT},
json=_lp(),
).json()["reqId"]
st = requests.get(
url + f"/cuopt/request/{req_id}",
headers={"Accept": accept},
)
assert st.status_code == 200
assert st.headers["content-type"].startswith(mime_msgpack)
assert msgpack.loads(st.content, strict_map_key=False) == "completed"


@pytest.mark.parametrize("accept", mime_wild)
def test_wildcard_accept_solution_without_stored_accept_is_msgpack(
proxy, accept
):
import msgpack
import cuopt_server.proxy_webserver as pw

url, _ = proxy
req_id = requests.post(
url + "/cuopt/request",
headers={"CLIENT-VERSION": "custom", **_JSON_ACCEPT},
json=_lp(),
).json()["reqId"]
with pw._jobs_lock:
pw._jobs.pop(req_id, None)
sol = requests.get(
url + f"/cuopt/solution/{req_id}",
headers={"Accept": accept},
)
assert sol.status_code == 200, sol.text
assert sol.headers["content-type"].startswith(mime_msgpack)
body = msgpack.loads(sol.content, strict_map_key=False)
assert body["response"]["solver_response"]["status"] == "Optimal"


@pytest.mark.parametrize("accept", mime_wild)
def test_wildcard_accept_solution_uses_stored_request_accept(proxy, accept):
url, _ = proxy
req_id = requests.post(
url + "/cuopt/request",
headers={"CLIENT-VERSION": "custom", "Accept": mime_json},
json=_lp(),
).json()["reqId"]
sol = requests.get(
url + f"/cuopt/solution/{req_id}",
headers={"Accept": accept},
)
assert sol.status_code == 200
assert sol.headers["content-type"].startswith(mime_json)
assert sol.json()["response"]["solver_response"]["status"] == "Optimal"


@pytest.mark.parametrize("accept", mime_wild)
def test_wildcard_accept_post_request_follows_content_type(proxy, accept):
import msgpack

url, _ = proxy
res = requests.post(
url + "/cuopt/request",
headers={
"CLIENT-VERSION": "custom",
"Content-Type": mime_json,
"Accept": accept,
},
json=_lp(),
)
assert res.status_code == 200
assert res.headers["content-type"].startswith(mime_json)
assert "reqId" in res.json()

packed = msgpack.dumps(_lp())
res = requests.post(
url + "/cuopt/request",
headers={
"CLIENT-VERSION": "custom",
"Content-Type": mime_msgpack,
"Accept": accept,
},
data=packed,
)
assert res.status_code == 200
assert res.headers["content-type"].startswith(mime_msgpack)
body = msgpack.loads(res.content, strict_map_key=False)
assert "reqId" in body


def test_omitted_accept_status_is_msgpack(proxy):
import msgpack

url, _ = proxy
req_id = requests.post(
url + "/cuopt/request",
headers={"CLIENT-VERSION": "custom", **_JSON_ACCEPT},
json=_lp(),
).json()["reqId"]
st = _request_without_accept("GET", url + f"/cuopt/request/{req_id}")
assert st.status_code == 200
assert st.headers["content-type"].startswith(mime_msgpack)
assert msgpack.loads(st.content, strict_map_key=False) == "completed"


def test_omitted_accept_solution_uses_stored_request_accept(proxy):
url, _ = proxy
req_id = requests.post(
url + "/cuopt/request",
headers={"CLIENT-VERSION": "custom", "Accept": mime_json},
json=_lp(),
).json()["reqId"]
sol = _request_without_accept("GET", url + f"/cuopt/solution/{req_id}")
assert sol.status_code == 200
assert sol.headers["content-type"].startswith(mime_json)
assert sol.json()["response"]["solver_response"]["status"] == "Optimal"


def test_omitted_accept_post_request_follows_content_type(proxy):
import msgpack

url, _ = proxy
res = _request_without_accept(
"POST",
url + "/cuopt/request",
headers={
"CLIENT-VERSION": "custom",
"Content-Type": mime_json,
},
json=_lp(),
)
assert res.status_code == 200
assert res.headers["content-type"].startswith(mime_json)
assert "reqId" in res.json()

packed = msgpack.dumps(_lp())
res = _request_without_accept(
"POST",
url + "/cuopt/request",
headers={
"CLIENT-VERSION": "custom",
"Content-Type": mime_msgpack,
},
data=packed,
)
assert res.status_code == 200
assert res.headers["content-type"].startswith(mime_msgpack)
body = msgpack.loads(res.content, strict_map_key=False)
assert "reqId" in body


def test_log_delete_error_is_encoded(proxy, monkeypatch):
import cuopt_server.proxy_webserver as pw

Expand Down
Loading