diff --git a/cwms/api.py b/cwms/api.py index 20d2c2c5..bac1dae1 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -44,7 +44,7 @@ from cwms.cwms_types import JSON, RequestParams -# Specify the default API root URL and version. +# Default service URL and data-format selector (not a CDA release version). API_ROOT = "https://cwms-data.usace.army.mil/cwms-data/" API_VERSION = 2 @@ -282,19 +282,21 @@ def get_use_new_lrts_ids() -> bool: def api_version_text(api_version: int) -> str: - """Initialize CDA request headers. + """Return the media type for a CDA data representation. - The CDA supports multiple versions. To request a specific version, the version number - must be included in the request headers. + CDA versions data formats per operation, not the API as a whole. The historical + selector 1 requests unversioned JSON (the server's default representation), 2 + requests JSON version 2, and 102 requests XML version 2. Select the format from + the operation's documented request or response media types. Args: - api_version: The CDA version to use for the request. + api_version: The data-format selector; this is not a CDA release version. Returns: - A dict containing the request headers. + A media-type string for an Accept or Content-Type header. Raises: - InvalidVersion: If an unsupported API version is specified. + InvalidVersion: If an unsupported data-format selector is specified. """ if api_version == 1: @@ -322,7 +324,7 @@ def get_xml( params (optional): Query parameters for the request. Keyword Args: - api_version (optional): The CDA version to use for the request. If not specified, + api_version (optional): The data-format selector for the operation. If not specified, the default API_VERSION will be used. Returns: @@ -375,7 +377,7 @@ def get( params (optional): Query parameters for the request. Keyword Args: - api_version (optional): The CDA version to use for the request. If not specified, + api_version (optional): The data-format selector for the operation. If not specified, the default API_VERSION will be used. Returns: @@ -414,7 +416,7 @@ def get_with_paging( params (optional): Query parameters for the request. Keyword Args: - api_version (optional): The CDA version to use for the request. If not specified, + api_version (optional): The data-format selector for the operation. If not specified, the default API_VERSION will be used. Returns: @@ -482,7 +484,7 @@ def post( params (optional): Query parameters for the request. Keyword Args: - api_version (optional): The CDA version to use for the request. If not specified, + api_version (optional): The data-format selector for the operation. If not specified, the default API_VERSION will be used. Returns: @@ -509,7 +511,7 @@ def post_with_returned_data( params (optional): Query parameters for the request. Keyword Args: - api_version (optional): The CDA version to use for the request. If not specified, + api_version (optional): The data-format selector for the operation. If not specified, the default API_VERSION will be used. Returns: @@ -540,7 +542,7 @@ def patch( params (optional): Query parameters for the request. Keyword Args: - api_version (optional): The CDA version to use for the request. If not specified, + api_version (optional): The data-format selector for the operation. If not specified, the default API_VERSION will be used. Returns: @@ -582,7 +584,7 @@ def delete( params (optional): Query parameters for the request. Keyword Args: - api_version (optional): The CDA version to use for the request. If not specified, + api_version (optional): The data-format selector for the operation. If not specified, the default API_VERSION will be used. Raises: diff --git a/cwms/catalog/blobs.py b/cwms/catalog/blobs.py index 656d8052..16be3a30 100644 --- a/cwms/catalog/blobs.py +++ b/cwms/catalog/blobs.py @@ -96,7 +96,7 @@ def store_blobs(data: JSON, fail_if_exists: Optional[bool] = True) -> None: endpoint = "blobs" params = {"fail-if-exists": fail_if_exists} - return api.post(endpoint, data, params, api_version=1) + return api.post(endpoint, data, params, api_version=2) def delete_blob(blob_id: str, office_id: str) -> None: diff --git a/cwms/catalog/clobs.py b/cwms/catalog/clobs.py index 617c108b..69ccafa3 100644 --- a/cwms/catalog/clobs.py +++ b/cwms/catalog/clobs.py @@ -146,7 +146,7 @@ def update_clob( endpoint = f"clobs/{clob_id}" params["ignore-nulls"] = ignore_nulls - return api.patch(endpoint, data, params, api_version=1) + return api.patch(endpoint, data, params, api_version=2) def store_clobs(data: JSON, fail_if_exists: Optional[bool] = True) -> None: @@ -176,4 +176,4 @@ def store_clobs(data: JSON, fail_if_exists: Optional[bool] = True) -> None: endpoint = "clobs" params = {"fail-if-exists": fail_if_exists} - return api.post(endpoint, data, params, api_version=1) + return api.post(endpoint, data, params, api_version=2) diff --git a/cwms/levels/location_levels.py b/cwms/levels/location_levels.py index 5f3f7554..23a35588 100644 --- a/cwms/levels/location_levels.py +++ b/cwms/levels/location_levels.py @@ -132,7 +132,7 @@ def store_location_level(data: JSON) -> None: raise ValueError("Cannot store a location level without a JSON data dictionary") endpoint = "levels" - return api.post(endpoint, data, params=None) + return api.post(endpoint, data, params=None, api_version=1) def delete_location_level( @@ -195,7 +195,7 @@ def update_location_level( params = { "effective-date": (effective_date.isoformat() if effective_date else None), } - return api.patch(endpoint, data, params) + return api.patch(endpoint, data, params, api_version=1) def get_level_as_timeseries( diff --git a/cwms/locations/physical_locations.py b/cwms/locations/physical_locations.py index d703dad6..464828d5 100644 --- a/cwms/locations/physical_locations.py +++ b/cwms/locations/physical_locations.py @@ -153,7 +153,7 @@ def store_location(data: JSON, fail_if_exists: bool = True) -> None: endpoint = "locations" params = {"fail-if-exists": fail_if_exists} - return api.post(endpoint, data, params=params) + return api.post(endpoint, data, params=params, api_version=1) def update_location(location_id: str, data: JSON) -> None: @@ -178,4 +178,4 @@ def update_location(location_id: str, data: JSON) -> None: endpoint = f"locations/{location_id}" - return api.patch(endpoint=endpoint, data=data) + return api.patch(endpoint=endpoint, data=data, api_version=1) diff --git a/cwms/outlets/outlets.py b/cwms/outlets/outlets.py index aa9870b2..6b202ab8 100644 --- a/cwms/outlets/outlets.py +++ b/cwms/outlets/outlets.py @@ -78,7 +78,7 @@ def get_outlets(office_id: str, project_id: str) -> Data: endpoint = "projects/outlets" params = {"office": office_id, "project-id": project_id} - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=1) return Data(response) diff --git a/cwms/outlets/virtual_outlets.py b/cwms/outlets/virtual_outlets.py index e65d7e87..e4309f8a 100644 --- a/cwms/outlets/virtual_outlets.py +++ b/cwms/outlets/virtual_outlets.py @@ -44,7 +44,7 @@ def get_virtual_outlet(office_id: str, project_id: str, name: str) -> Data: raise ValueError("Retrieve virtual outlet requires an office") endpoint = f"projects/{office_id}/{project_id}/virtual-outlets/{name}" - response = api.get(endpoint) + response = api.get(endpoint, api_version=1) return Data(response) @@ -80,7 +80,7 @@ def get_virtual_outlets(office_id: str, project_id: str) -> Data: raise ValueError("Retrieve virtual outlets requires an office") endpoint = f"projects/{office_id}/{project_id}/virtual-outlets" - response = api.get(endpoint) + response = api.get(endpoint, api_version=1) return Data(response) @@ -161,4 +161,4 @@ def store_virtual_outlet(data: JSON, fail_if_exists: Optional[bool] = True) -> N endpoint = "projects/virtual-outlets" params = {"fail-if-exists": fail_if_exists} - api.post(endpoint, data, params) + api.post(endpoint, data, params, api_version=1) diff --git a/cwms/projects/project_lock_rights.py b/cwms/projects/project_lock_rights.py index 9802f15b..c7460612 100644 --- a/cwms/projects/project_lock_rights.py +++ b/cwms/projects/project_lock_rights.py @@ -49,7 +49,7 @@ def get_project_lock_rights( "project-mask": project_mask, "application-mask": application_mask, } - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=1) return Data(response) diff --git a/cwms/projects/project_locks.py b/cwms/projects/project_locks.py index 4e429814..8a9c7a37 100644 --- a/cwms/projects/project_locks.py +++ b/cwms/projects/project_locks.py @@ -45,7 +45,7 @@ def get_project_lock(office_id: str, name: str, application_id: str) -> Data: endpoint = f"project-locks/{name}" params = {"office": office_id, "application-id": application_id} - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=1) return Data(response) @@ -87,7 +87,7 @@ def get_project_locks( "project-mask": project_mask, "application-mask": application_mask, } - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=1) return Data(response) @@ -170,7 +170,7 @@ def request_project_lock( "revoke-existing": revoke_existing, "revoke-timeout": revoke_timeout_seconds, } - api.post(endpoint, data, params) + api.post(endpoint, data, params, api_version=1) def deny_project_lock_request(lock_id: str) -> None: diff --git a/cwms/projects/water_supply/accounting.py b/cwms/projects/water_supply/accounting.py index 659ecace..89ded484 100644 --- a/cwms/projects/water_supply/accounting.py +++ b/cwms/projects/water_supply/accounting.py @@ -144,4 +144,4 @@ def store_pump_accounting( "water-user": water_user, "contract-name": contract_name, } - api.post(endpoint, data, params) + api.post(endpoint, data, params, api_version=1) diff --git a/cwms/timeseries/timeseries_profile.py b/cwms/timeseries/timeseries_profile.py index 87e0ee73..04e67d97 100644 --- a/cwms/timeseries/timeseries_profile.py +++ b/cwms/timeseries/timeseries_profile.py @@ -37,7 +37,7 @@ def get_timeseries_profile(office_id: str, location_id: str, parameter_id: str) "office": office_id, } - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=1) return Data(response) @@ -82,7 +82,7 @@ def get_timeseries_profiles( "page-size": page_size, } - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=1) return Data(response) @@ -163,4 +163,4 @@ def store_timeseries_profile(data: str, fail_if_exists: Optional[bool] = True) - "fail-if-exists": fail_if_exists, } - return api.post(endpoint, data, params) + return api.post(endpoint, data, params, api_version=1) diff --git a/cwms/timeseries/timeseries_profile_instance.py b/cwms/timeseries/timeseries_profile_instance.py index d696d2ff..c0138db4 100644 --- a/cwms/timeseries/timeseries_profile_instance.py +++ b/cwms/timeseries/timeseries_profile_instance.py @@ -92,7 +92,7 @@ def get_timeseries_profile_instance( "page-size": page_size, } - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=1) return Data(response) @@ -138,7 +138,7 @@ def get_timeseries_profile_instances( "version-mask": version_mask, } - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=1) return Data(response) @@ -234,4 +234,4 @@ def store_timeseries_profile_instance( "override-protection": override_protection, } - return api.post(endpoint, None, params) + return api.post(endpoint, None, params, api_version=1) diff --git a/cwms/timeseries/timeseries_profile_parser.py b/cwms/timeseries/timeseries_profile_parser.py index 1d844378..184d03a1 100644 --- a/cwms/timeseries/timeseries_profile_parser.py +++ b/cwms/timeseries/timeseries_profile_parser.py @@ -39,7 +39,7 @@ def get_timeseries_profile_parser( "office": office_id, } - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=1) return Data(response) @@ -80,7 +80,7 @@ def get_timeseries_profile_parsers( "parameter-id-mask": parameter_id_mask, } - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=1) return Data(response) @@ -207,4 +207,4 @@ def store_timeseries_profile_parser( "fail-if-exists": fail_if_exists, } - return api.post(endpoint, data, params) + return api.post(endpoint, data, params, api_version=1) diff --git a/cwms/users/users.py b/cwms/users/users.py index a69dc635..fc4a7548 100644 --- a/cwms/users/users.py +++ b/cwms/users/users.py @@ -135,7 +135,7 @@ def store_user(user_name: str, office_id: str, roles: List[str]) -> None: endpoint = f"user/{user_name}/roles/{office_id}" try: - api.post(endpoint, roles) + api.post(endpoint, roles, api_version=1) except api.ApiError as error: _raise_user_management_error( error, f"User '{user_name}' role assignment update" @@ -153,7 +153,7 @@ def delete_user_roles(user_name: str, office_id: str, roles: List[str]) -> None: raise ValueError("Delete user roles requires a roles list") endpoint = f"user/{user_name}/roles/{office_id}" - headers = {"accept": "*/*", "Content-Type": api.api_version_text(api.API_VERSION)} + headers = {"accept": "*/*", "Content-Type": api.api_version_text(1)} # TODO: Delete does not currently support a body in the api module. Use SESSION directly with api.SESSION.delete( endpoint, headers=headers, data=json.dumps(roles) @@ -198,6 +198,6 @@ def update_user(user_name: str, office_id: str, roles: List[str]) -> None: delete_user_roles(user_name, office_id, roles_to_remove) if roles_to_add: try: - api.post(endpoint, roles_to_add) + api.post(endpoint, roles_to_add, api_version=1) except api.ApiError as error: _raise_user_management_error(error, f"User '{user_name}' role replacement") diff --git a/tests/mock/endpoint_media_types_test.py b/tests/mock/endpoint_media_types_test.py new file mode 100644 index 00000000..57da130a --- /dev/null +++ b/tests/mock/endpoint_media_types_test.py @@ -0,0 +1,169 @@ +"""Check wire headers against the production Swagger contract, not mock defaults. + +Source: https://cwms-data.usace.army.mil/cwms-data/swagger-docs +Specification version: 2026.05.12-i (retrieved 2026-09-10). +""" + +from datetime import datetime, timezone +from importlib import import_module + +import pytest + +import cwms.api as api + +JSON = "application/json" +JSON_V2 = "application/json;version=2" +DATA = {"id": "TEST", "office-id": "SPK"} +DATE = datetime(2026, 1, 1, tzinfo=timezone.utc) + +# module, function, arguments, HTTP method, expected request/response media type +CASES = [ + ("catalog.blobs", "store_blobs", (DATA,), "POST", JSON_V2), + ("catalog.clobs", "store_clobs", (DATA,), "POST", JSON_V2), + ("catalog.clobs", "update_clob", (DATA,), "PATCH", JSON_V2), + ("levels.location_levels", "store_location_level", (DATA,), "POST", JSON), + ("levels.location_levels", "update_location_level", (DATA, "TEST"), "PATCH", JSON), + ("locations.physical_locations", "store_location", (DATA,), "POST", JSON), + ("locations.physical_locations", "update_location", ("TEST", DATA), "PATCH", JSON), + ("outlets.outlets", "get_outlets", ("SPK", "TEST"), "GET", JSON), + ( + "outlets.virtual_outlets", + "get_virtual_outlet", + ("SPK", "TEST", "OUTLET"), + "GET", + JSON, + ), + ("outlets.virtual_outlets", "get_virtual_outlets", ("SPK", "TEST"), "GET", JSON), + ("outlets.virtual_outlets", "store_virtual_outlet", (DATA,), "POST", JSON), + ("projects.project_lock_rights", "get_project_lock_rights", ("SPK",), "GET", JSON), + ( + "projects.project_locks", + "get_project_lock", + ("SPK", "TEST", "TEST_APP"), + "GET", + JSON, + ), + ("projects.project_locks", "get_project_locks", ("SPK",), "GET", JSON), + ("projects.project_locks", "request_project_lock", (DATA,), "POST", JSON), + ( + "projects.water_supply.accounting", + "store_pump_accounting", + ("SPK", "TEST", "TEST_USER", "TEST_CONTRACT", DATA), + "POST", + JSON, + ), + ( + "timeseries.timeseries_profile", + "get_timeseries_profile", + ("SPK", "TEST", "Elev"), + "GET", + JSON, + ), + ( + "timeseries.timeseries_profile", + "get_timeseries_profiles", + ("SPK", "TEST", "Elev"), + "GET", + JSON, + ), + ( + "timeseries.timeseries_profile", + "store_timeseries_profile", + ("{}",), + "POST", + JSON, + ), + ( + "timeseries.timeseries_profile_instance", + "get_timeseries_profile_instance", + ("SPK", "TEST", "Elev", "TEST", "ft", None, None, None), + "GET", + JSON, + ), + ( + "timeseries.timeseries_profile_instance", + "get_timeseries_profile_instances", + ("SPK", "TEST", "Elev", "TEST"), + "GET", + JSON, + ), + ( + "timeseries.timeseries_profile_instance", + "store_timeseries_profile_instance", + ("{}", "TEST", DATE), + "POST", + JSON, + ), + ( + "timeseries.timeseries_profile_parser", + "get_timeseries_profile_parser", + ("SPK", "TEST", "Elev"), + "GET", + JSON, + ), + ( + "timeseries.timeseries_profile_parser", + "get_timeseries_profile_parsers", + ("SPK", "TEST", "Elev"), + "GET", + JSON, + ), + ( + "timeseries.timeseries_profile_parser", + "store_timeseries_profile_parser", + ("{}",), + "POST", + JSON, + ), + ("users.users", "store_user", ("test_user", "SPK", ["CWMS User"]), "POST", JSON), + ("users.users", "update_user", ("test_user", "SPK", ["CWMS User"]), "POST", JSON), + ( + "users.users", + "delete_user_roles", + ("test_user", "SPK", ["CWMS User"]), + "DELETE", + JSON, + ), + # Keep mixed-format resources covered on both sides of their CRUD contract. + ( + "levels.location_levels", + "get_location_level", + ("TEST", "SPK", DATE), + "GET", + JSON_V2, + ), + ("locations.physical_locations", "get_location", ("TEST", "SPK"), "GET", JSON_V2), + ("catalog.clobs", "get_clob", ("TEST", "SPK"), "GET", JSON_V2), + ("catalog.blobs", "get_blobs", (), "GET", JSON_V2), + ("catalog.blobs", "update_blob", (DATA,), "PATCH", JSON), + ("projects.projects", "get_project", ("SPK", "TEST"), "GET", JSON), +] + + +class RequestCaptured(Exception): + """Stop after requests prepares the wire request, before any network I/O.""" + + +@pytest.mark.parametrize( + "module_name,function_name,args,method,media_type", + CASES, + ids=[case[1] for case in CASES], +) +def test_endpoint_media_type( + monkeypatch, module_name, function_name, args, method, media_type +): + module = import_module(f"cwms.{module_name}") + # update_user reads the existing roles before posting additional roles. + if function_name == "update_user": + monkeypatch.setattr(module, "get_user", lambda _: {"roles": {"SPK": []}}) + + def capture(request, **kwargs): + assert request.method == method + header = "Accept" if method == "GET" else "Content-Type" + assert request.headers[header] == media_type + raise RequestCaptured + + monkeypatch.setattr(api.SESSION, "send", capture) + endpoint_function = getattr(module, function_name) + with pytest.raises(RequestCaptured): + endpoint_function(*args)