diff --git a/python/cuopt_server/cuopt_server/proxy_webserver.py b/python/cuopt_server/cuopt_server/proxy_webserver.py index fd0ab33a71..1efdae9e2b 100644 --- a/python/cuopt_server/cuopt_server/proxy_webserver.py +++ b/python/cuopt_server/cuopt_server/proxy_webserver.py @@ -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: @@ -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: @@ -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) @@ -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) @@ -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) @@ -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), @@ -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"): @@ -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) @@ -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), ): diff --git a/python/cuopt_server/cuopt_server/tests/test_grpc_http_proxy.py b/python/cuopt_server/cuopt_server/tests/test_grpc_http_proxy.py index 9a2fe7659f..7e06a69ac4 100644 --- a/python/cuopt_server/cuopt_server/tests/test_grpc_http_proxy.py +++ b/python/cuopt_server/cuopt_server/tests/test_grpc_http_proxy.py @@ -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)) @@ -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 @@ -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}] @@ -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"] @@ -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 @@ -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 @@ -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 @@ -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 @@ -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"] @@ -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