From c9468592c7400b89bdf6b8584c277c4029d06988 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Thu, 10 Sep 2026 23:22:27 -0500 Subject: [PATCH 1/3] fix: align endpoint data formats with production Swagger --- cwms/api.py | 30 +- cwms/catalog/blobs.py | 2 +- cwms/catalog/clobs.py | 4 +- cwms/levels/location_levels.py | 4 +- cwms/locations/physical_locations.py | 4 +- cwms/outlets/outlets.py | 2 +- cwms/outlets/virtual_outlets.py | 6 +- cwms/projects/project_lock_rights.py | 2 +- cwms/projects/project_locks.py | 6 +- cwms/projects/water_supply/accounting.py | 2 +- cwms/timeseries/timeseries_profile.py | 6 +- .../timeseries/timeseries_profile_instance.py | 6 +- cwms/timeseries/timeseries_profile_parser.py | 6 +- cwms/users/users.py | 6 +- docs/endpoint-media-types.md | 371 ++++++++++++++++++ tests/mock/endpoint_media_types_test.py | 168 ++++++++ 16 files changed, 583 insertions(+), 42 deletions(-) create mode 100644 docs/endpoint-media-types.md create mode 100644 tests/mock/endpoint_media_types_test.py 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/docs/endpoint-media-types.md b/docs/endpoint-media-types.md new file mode 100644 index 00000000..ee3f5bc7 --- /dev/null +++ b/docs/endpoint-media-types.md @@ -0,0 +1,371 @@ +# Endpoint data-format audit + +Source: [production Swagger](https://cwms-data.usace.army.mil/cwms-data/swagger-docs), +retrieved 2026-09-10, specification release `2026.05.12-i`. + +This audit covers every existing endpoint wrapper, including paged/chunked +time-series requests, XML rating writes, and the direct session call in +`delete_user_roles`. It checks data-format selection, not full payload/schema +compatibility or successful authenticated CRUD against a database. + +`api_version` is a historical data-format selector, not a service release: + +- `1`: `application/json`, the server's unversioned default representation. +- `2`: `application/json;version=2`. +- `102`: `application/xml;version=2`. + +GET uses the successful response content types in Swagger. POST/PATCH and the +body-bearing user-role DELETE use request-body content types. Error responses +are excluded. Operations with no request body or no successful response content +do not impose a data-version constraint; their existing selector is retained. +There are no existing PUT wrappers. + +Special cases: + +- BLOB retrieval streams the stored media type; its controller does not negotiate + a versioned JSON representation from Accept. The existing retrieval behavior + is retained. BLOB creation requires JSON v2, while BLOB PATCH also documents + unversioned JSON. +- The spec lists explicit JSON v1 for individual projects, measurement extents, + turbine changes, and pump accounting writes. These use the existing selector 1 + (unversioned JSON), which CDA resolves to its default representation. This is + not an explicit v1 pin. The live measurement-extents response was HTTP 200 with + `Content-Type: application/json;version=1` for unversioned Accept. The CDA + DTO formatter annotations explicitly alias unversioned JSON to v1; see + [Project](https://github.com/USACE/cwms-data-api/blob/9f1ffc59732da46d5543e2e9c737f05846fd4aa3/cwms-data-api/src/main/java/cwms/cda/data/dto/project/Project.java#L44), + [TurbineChange](https://github.com/USACE/cwms-data-api/blob/9f1ffc59732da46d5543e2e9c737f05846fd4aa3/cwms-data-api/src/main/java/cwms/cda/data/dto/location/kind/TurbineChange.java#L41), + and [WaterSupplyAccounting](https://github.com/USACE/cwms-data-api/blob/9f1ffc59732da46d5543e2e9c737f05846fd4aa3/cwms-data-api/src/main/java/cwms/cda/data/dto/watersupply/WaterSupplyAccounting.java#L42). +- Catalog dataset names and special-character BLOB/CLOB IDs resolve to the + corresponding templated Swagger paths. Both `get_timeseries` branches and + `get_timeseries_chunk` use v2. Both synchronous and threaded `store_timeseries` + calls also use v2 through the same POST helper. + +Validation: + +- 28 header regression cases fail on unchanged main and pass with the fix. +- 34 prepared-request header checks cover every changed wrapper plus unchanged + reads/writes on mixed-format resources and the prior project retrieval fix. +- The full mock/doctest suite passes: 132 tests. +- Live GET `/timeseries/profile?office-mask=SPK&location-mask=TEST` returns HTTP + 406 for `Accept: application/json;version=2` and HTTP 200 for + `Accept: application/json`. No live write requests were made. + +## Complete wrapper inventory + +The format column is the selected wire media type, abbreviated as JSON, JSON v2, +or XML v2. A dash means Swagger defines no versioned payload for that operation. +"Default JSON" marks the explicit-v1 Swagger cases discussed above. + +Swagger SHA-256: `964fc78c22b8af7721ef88f5cb23700c41f812f1e83047a7f17622161847bd03`. + + +### `cwms/catalog/blobs.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_blob` | GET | `blobs/{blob_id}` | Stored media type | +| `get_blobs` | GET | `blobs` | JSON v2 | +| `store_blobs` | POST | `blobs` | JSON v2 | +| `delete_blob` | DELETE | `blobs/{blob_id}` | — | +| `update_blob` | PATCH | `blobs/{blob_id}` | JSON | + +### `cwms/catalog/catalog.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_locations_catalog` | GET | `catalog/LOCATIONS` | JSON v2 | +| `get_timeseries_catalog` | GET | `catalog/TIMESERIES` | JSON v2 | + +### `cwms/catalog/clobs.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_clob` | GET | `clobs/{clob_id}` | JSON v2 | +| `get_clobs` | GET | `clobs` | JSON v2 | +| `delete_clob` | DELETE | `clobs/{clob_id}` | — | +| `update_clob` | PATCH | `clobs/{clob_id}` | JSON v2 | +| `store_clobs` | POST | `clobs` | JSON v2 | + +### `cwms/forecast/forecast_instance.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_forecast_instances` | GET | `forecast-instance` | JSON v2 | +| `get_forecast_instance` | GET | `forecast-instance/{spec_id}` | JSON v2 | +| `store_forecast_instance` | POST | `forecast-instance` | JSON v2 | +| `delete_forecast_instance` | DELETE | `forecast-instance/{spec_id}` | — | + +### `cwms/forecast/forecast_spec.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_forecast_specs` | GET | `forecast-spec` | JSON v2 | +| `get_forecast_spec` | GET | `forecast-spec/{spec_id}` | JSON v2 | +| `store_forecast_spec` | POST | `forecast-spec` | JSON v2 | +| `delete_forecast_spec` | DELETE | `forecast-spec/{spec_id}` | — | + +### `cwms/levels/location_levels.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_location_levels` | GET | `levels` | JSON v2 | +| `get_location_level` | GET | `levels/{level_id}` | JSON v2 | +| `store_location_level` | POST | `levels` | JSON | +| `delete_location_level` | DELETE | `levels/{location_level_id}` | — | +| `update_location_level` | PATCH | `levels/{level_id}` | JSON | +| `get_level_as_timeseries` | GET | `levels/{location_level_id}/timeseries` | JSON v2 | + +### `cwms/levels/specified_levels.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_specified_levels` | GET | `specified-levels` | JSON v2 | +| `store_specified_level` | POST | `specified-levels` | JSON v2 | +| `delete_specified_level` | DELETE | `specified-levels/{specified_level_id}` | — | +| `update_specified_level` | PATCH | `specified-levels/{old_specified_level_id}` | — | + +### `cwms/locations/gate_changes.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_all_gate_changes` | GET | `projects/{office_id}/{project_id}/gate-changes` | JSON | +| `store_gate_change` | POST | `projects/gate-changes` | JSON | +| `delete_gate_change` | DELETE | `projects/{office_id}/{project_id}/gate-changes` | — | + +### `cwms/locations/location_groups.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_location_group` | GET | `location/group/{loc_group_id}` | JSON | +| `get_location_groups` | GET | `location/group` | JSON | +| `store_location_groups` | POST | `location/group` | JSON | +| `update_location_group` | PATCH | `location/group/{group_id}` | JSON | +| `delete_location_group` | DELETE | `location/group/{group_id}` | — | + +### `cwms/locations/lookups.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_all_lookups` | GET | `lookup-types` | JSON | +| `create_lookup` | POST | `lookup-types` | JSON | +| `update_lookup` | PATCH | `lookup-types/{category}` | JSON | +| `delete_lookup` | DELETE | `lookup-types/{display_value}` | — | + +### `cwms/locations/physical_locations.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_location` | GET | `locations/{location_id}` | JSON v2 | +| `get_locations` | GET | `locations` | JSON v2 | +| `delete_location` | DELETE | `locations/{location_id}` | — | +| `store_location` | POST | `locations` | JSON | +| `update_location` | PATCH | `locations/{location_id}` | JSON | + +### `cwms/measurements/measurements.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_measurements` | GET | `measurements` | JSON | +| `store_measurements` | POST | `measurements` | JSON | +| `delete_measurements` | DELETE | `measurements/{location_id}` | — | +| `get_measurements_extents` | GET | `measurements/time-extents` | Default JSON | + +### `cwms/outlets/outlets.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_outlet` | GET | `projects/outlets/{name}` | JSON | +| `get_outlets` | GET | `projects/outlets` | JSON | +| `delete_outlet` | DELETE | `projects/outlets/{name}` | — | +| `rename_outlet` | PATCH | `projects/outlets/{old_name}` | — | +| `store_outlet` | POST | `projects/outlets` | JSON | + +### `cwms/outlets/virtual_outlets.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_virtual_outlet` | GET | `projects/{office_id}/{project_id}/virtual-outlets/{name}` | JSON | +| `get_virtual_outlets` | GET | `projects/{office_id}/{project_id}/virtual-outlets` | JSON | +| `delete_virtual_outlet` | DELETE | `projects/{office_id}/{project_id}/virtual-outlets/{name}` | — | +| `store_virtual_outlet` | POST | `projects/virtual-outlets` | JSON | + +### `cwms/projects/project_lock_rights.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_project_lock_rights` | GET | `project-lock-rights` | JSON | +| `remove_all_project_lock_rights` | POST | `project-lock-rights/remove-all` | — | +| `update_project_lock_rights` | POST | `project-lock-rights/update` | — | + +### `cwms/projects/project_locks.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_project_lock` | GET | `project-locks/{name}` | JSON | +| `get_project_locks` | GET | `project-locks` | JSON | +| `revoke_project_lock` | DELETE | `project-locks/{name}` | — | +| `request_project_lock` | POST | `project-locks` | JSON | +| `deny_project_lock_request` | POST | `project-locks/deny` | — | +| `release_project_lock` | POST | `project-locks/release` | — | + +### `cwms/projects/projects.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_project` | GET | `projects/{name}` | Default JSON | +| `get_projects` | GET | `projects` | JSON | +| `get_project_locations` | GET | `projects/locations` | JSON | +| `delete_project` | DELETE | `projects/{name}` | — | +| `rename_project` | PATCH | `projects/{old_name}` | JSON | +| `store_project` | POST | `projects` | JSON | +| `status_update` | POST | `projects/status-update/{project_id}` | — | + +### `cwms/projects/water_supply/accounting.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_pump_accounting` | GET | `projects/{office_id}/{project_id}/water-user/{water_user}/contracts/{contract_name}/accounting` | JSON | +| `store_pump_accounting` | POST | `projects/{office}/{project_id}/water-user/{water_user}/contracts/{contract_name}/accounting` | Default JSON | + +### `cwms/properties/properties.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_properties` | GET | `properties` | JSON | +| `get_property` | GET | `properties/{name}` | JSON | +| `create_property` | POST | `properties` | JSON | +| `update_property` | PATCH | `properties/{name}` | JSON | +| `delete_property` | DELETE | `properties/{name}` | — | + +### `cwms/ratings/ratings.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_ratings_xml` | GET | `ratings/{rating_id}` | XML v2 | +| `get_ratings` | GET | `ratings/{rating_id}` | JSON v2 | +| `update_ratings` | PATCH | `ratings/{rating_id}` | JSON v2 | +| `delete_ratings` | DELETE | `ratings/{rating_id}` | — | +| `store_rating` | POST | `ratings` | JSON v2 | +| `_perform_value_rating` | POST | `ratings/rate-values/{office_id}/{rating_id}` | JSON | + +### `cwms/ratings/ratings_spec.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_rating_spec` | GET | `ratings/spec/{rating_id}` | JSON v2 | +| `get_rating_specs` | GET | `ratings/spec` | JSON v2 | +| `delete_rating_spec` | DELETE | `ratings/spec/{rating_id}` | — | +| `store_rating_spec` | POST | `ratings/spec/` | XML v2 | + +### `cwms/ratings/ratings_template.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_rating_template` | GET | `ratings/template/{template_id}` | JSON v2 | +| `get_rating_templates` | GET | `ratings/template` | JSON v2 | +| `delete_rating_template` | DELETE | `ratings/template/{template_id}` | — | +| `store_rating_template` | POST | `ratings/template/` | XML v2 | + +### `cwms/standard_text/standard_text.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_standard_text_catalog` | GET | `standard-text-id` | JSON v2 | +| `get_standard_text` | GET | `standard-text-id/{text_id}` | JSON v2 | +| `delete_standard_text` | DELETE | `standard-text-id/{text_id}` | — | +| `store_standard_text` | POST | `standard-text-id` | JSON v2 | + +### `cwms/timeseries/timeseries.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_timeseries_chunk` | GET | `timeseries` | JSON v2 | +| `get_timeseries` | GET | `timeseries` | JSON v2 | +| `store_timeseries` | POST | `timeseries` | JSON v2 | +| `delete_timeseries` | DELETE | `timeseries/{ts_id}` | — | + +### `cwms/timeseries/timeseries_bin.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_binary_timeseries` | GET | `timeseries/binary` | JSON v2 | +| `store_binary_timeseries` | POST | `timeseries/binary` | JSON v2 | +| `delete_binary_timeseries` | DELETE | `timeseries/binary/{timeseries_id}` | — | + +### `cwms/timeseries/timeseries_group.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_timeseries_group` | GET | `timeseries/group/{group_id}` | JSON | +| `get_timeseries_groups` | GET | `timeseries/group` | JSON | +| `store_timeseries_groups` | POST | `timeseries/group` | JSON | +| `update_timeseries_groups` | PATCH | `timeseries/group/{group_id}` | JSON | +| `delete_timeseries_group` | DELETE | `timeseries/group/{group_id}` | — | + +### `cwms/timeseries/timeseries_identifier.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_timeseries_identifier` | GET | `timeseries/identifier-descriptor/{ts_id}` | JSON v2 | +| `get_timeseries_identifiers` | GET | `timeseries/identifier-descriptor/` | JSON v2 | +| `delete_timeseries_identifier` | DELETE | `timeseries/identifier-descriptor/{ts_id}` | — | +| `store_timeseries_identifier` | POST | `timeseries/identifier-descriptor/` | JSON v2 | + +### `cwms/timeseries/timeseries_profile.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_timeseries_profile` | GET | `timeseries/profile/{location_id}/{parameter_id}` | JSON | +| `get_timeseries_profiles` | GET | `timeseries/profile` | JSON | +| `delete_timeseries_profile` | DELETE | `timeseries/profile/{location_id}/{parameter_id}` | — | +| `store_timeseries_profile` | POST | `timeseries/profile` | JSON | + +### `cwms/timeseries/timeseries_profile_instance.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_timeseries_profile_instance` | GET | `timeseries/profile-instance/{location_id}/{parameter_id}/{version}` | JSON | +| `get_timeseries_profile_instances` | GET | `timeseries/profile-instance` | JSON | +| `delete_timeseries_profile_instance` | DELETE | `timeseries/profile-instance/{location_id}/{parameter_id}/{version}` | — | +| `store_timeseries_profile_instance` | POST | `timeseries/profile-instance` | JSON | + +### `cwms/timeseries/timeseries_profile_parser.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_timeseries_profile_parser` | GET | `timeseries/profile-parser/{location_id}/{parameter_id}` | JSON | +| `get_timeseries_profile_parsers` | GET | `timeseries/profile-parser` | JSON | +| `delete_timeseries_profile_parser` | DELETE | `timeseries/profile-parser/{location_id}/{parameter_id}` | — | +| `store_timeseries_profile_parser` | POST | `timeseries/profile-parser` | JSON | + +### `cwms/timeseries/timeseries_txt.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_text_timeseries` | GET | `timeseries/text` | JSON v2 | +| `store_text_timeseries` | POST | `timeseries/text` | JSON v2 | +| `delete_text_timeseries` | DELETE | `timeseries/text/{timeseries_id}` | — | + +### `cwms/turbines/turbines.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_project_turbines` | GET | `projects/turbines` | JSON | +| `get_project_turbine` | GET | `projects/turbines/{name}` | JSON | +| `get_project_turbine_changes` | GET | `projects/{office}/{name}/turbine-changes` | Default JSON | +| `store_project_turbine` | POST | `projects/turbines` | JSON | +| `store_project_turbine_changes` | POST | `projects/{office}/{name}/turbine-changes` | JSON | +| `delete_project_turbine` | DELETE | `projects/turbines/{name}` | — | +| `delete_project_turbine_changes` | DELETE | `projects/{office}/{name}/turbine-changes` | — | + +### `cwms/users/users.py` + +| Function | HTTP | Path | Format | +| --- | --- | --- | --- | +| `get_roles` | GET | `roles` | JSON | +| `get_user_profile` | GET | `user/profile` | JSON | +| `get_users` | GET | `users` | JSON | +| `get_user` | GET | `users/{user_name}` | JSON | +| `store_user` | POST | `user/{user_name}/roles/{office_id}` | JSON | +| `update_user` | POST | `user/{user_name}/roles/{office_id}` | JSON | +| `delete_user_roles` | DELETE | `user/{user_name}/roles/{office_id}` | JSON | diff --git a/tests/mock/endpoint_media_types_test.py b/tests/mock/endpoint_media_types_test.py new file mode 100644 index 00000000..cd2ad9b2 --- /dev/null +++ b/tests/mock/endpoint_media_types_test.py @@ -0,0 +1,168 @@ +"""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) + with pytest.raises(RequestCaptured): + getattr(module, function_name)(*args) From 1c85385bda9172281eb8bdf95287fab5867ca5fb Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Thu, 10 Sep 2026 23:28:11 -0500 Subject: [PATCH 2/3] Remove endpoint media type audit recap --- docs/endpoint-media-types.md | 371 ----------------------------------- 1 file changed, 371 deletions(-) delete mode 100644 docs/endpoint-media-types.md diff --git a/docs/endpoint-media-types.md b/docs/endpoint-media-types.md deleted file mode 100644 index ee3f5bc7..00000000 --- a/docs/endpoint-media-types.md +++ /dev/null @@ -1,371 +0,0 @@ -# Endpoint data-format audit - -Source: [production Swagger](https://cwms-data.usace.army.mil/cwms-data/swagger-docs), -retrieved 2026-09-10, specification release `2026.05.12-i`. - -This audit covers every existing endpoint wrapper, including paged/chunked -time-series requests, XML rating writes, and the direct session call in -`delete_user_roles`. It checks data-format selection, not full payload/schema -compatibility or successful authenticated CRUD against a database. - -`api_version` is a historical data-format selector, not a service release: - -- `1`: `application/json`, the server's unversioned default representation. -- `2`: `application/json;version=2`. -- `102`: `application/xml;version=2`. - -GET uses the successful response content types in Swagger. POST/PATCH and the -body-bearing user-role DELETE use request-body content types. Error responses -are excluded. Operations with no request body or no successful response content -do not impose a data-version constraint; their existing selector is retained. -There are no existing PUT wrappers. - -Special cases: - -- BLOB retrieval streams the stored media type; its controller does not negotiate - a versioned JSON representation from Accept. The existing retrieval behavior - is retained. BLOB creation requires JSON v2, while BLOB PATCH also documents - unversioned JSON. -- The spec lists explicit JSON v1 for individual projects, measurement extents, - turbine changes, and pump accounting writes. These use the existing selector 1 - (unversioned JSON), which CDA resolves to its default representation. This is - not an explicit v1 pin. The live measurement-extents response was HTTP 200 with - `Content-Type: application/json;version=1` for unversioned Accept. The CDA - DTO formatter annotations explicitly alias unversioned JSON to v1; see - [Project](https://github.com/USACE/cwms-data-api/blob/9f1ffc59732da46d5543e2e9c737f05846fd4aa3/cwms-data-api/src/main/java/cwms/cda/data/dto/project/Project.java#L44), - [TurbineChange](https://github.com/USACE/cwms-data-api/blob/9f1ffc59732da46d5543e2e9c737f05846fd4aa3/cwms-data-api/src/main/java/cwms/cda/data/dto/location/kind/TurbineChange.java#L41), - and [WaterSupplyAccounting](https://github.com/USACE/cwms-data-api/blob/9f1ffc59732da46d5543e2e9c737f05846fd4aa3/cwms-data-api/src/main/java/cwms/cda/data/dto/watersupply/WaterSupplyAccounting.java#L42). -- Catalog dataset names and special-character BLOB/CLOB IDs resolve to the - corresponding templated Swagger paths. Both `get_timeseries` branches and - `get_timeseries_chunk` use v2. Both synchronous and threaded `store_timeseries` - calls also use v2 through the same POST helper. - -Validation: - -- 28 header regression cases fail on unchanged main and pass with the fix. -- 34 prepared-request header checks cover every changed wrapper plus unchanged - reads/writes on mixed-format resources and the prior project retrieval fix. -- The full mock/doctest suite passes: 132 tests. -- Live GET `/timeseries/profile?office-mask=SPK&location-mask=TEST` returns HTTP - 406 for `Accept: application/json;version=2` and HTTP 200 for - `Accept: application/json`. No live write requests were made. - -## Complete wrapper inventory - -The format column is the selected wire media type, abbreviated as JSON, JSON v2, -or XML v2. A dash means Swagger defines no versioned payload for that operation. -"Default JSON" marks the explicit-v1 Swagger cases discussed above. - -Swagger SHA-256: `964fc78c22b8af7721ef88f5cb23700c41f812f1e83047a7f17622161847bd03`. - - -### `cwms/catalog/blobs.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_blob` | GET | `blobs/{blob_id}` | Stored media type | -| `get_blobs` | GET | `blobs` | JSON v2 | -| `store_blobs` | POST | `blobs` | JSON v2 | -| `delete_blob` | DELETE | `blobs/{blob_id}` | — | -| `update_blob` | PATCH | `blobs/{blob_id}` | JSON | - -### `cwms/catalog/catalog.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_locations_catalog` | GET | `catalog/LOCATIONS` | JSON v2 | -| `get_timeseries_catalog` | GET | `catalog/TIMESERIES` | JSON v2 | - -### `cwms/catalog/clobs.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_clob` | GET | `clobs/{clob_id}` | JSON v2 | -| `get_clobs` | GET | `clobs` | JSON v2 | -| `delete_clob` | DELETE | `clobs/{clob_id}` | — | -| `update_clob` | PATCH | `clobs/{clob_id}` | JSON v2 | -| `store_clobs` | POST | `clobs` | JSON v2 | - -### `cwms/forecast/forecast_instance.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_forecast_instances` | GET | `forecast-instance` | JSON v2 | -| `get_forecast_instance` | GET | `forecast-instance/{spec_id}` | JSON v2 | -| `store_forecast_instance` | POST | `forecast-instance` | JSON v2 | -| `delete_forecast_instance` | DELETE | `forecast-instance/{spec_id}` | — | - -### `cwms/forecast/forecast_spec.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_forecast_specs` | GET | `forecast-spec` | JSON v2 | -| `get_forecast_spec` | GET | `forecast-spec/{spec_id}` | JSON v2 | -| `store_forecast_spec` | POST | `forecast-spec` | JSON v2 | -| `delete_forecast_spec` | DELETE | `forecast-spec/{spec_id}` | — | - -### `cwms/levels/location_levels.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_location_levels` | GET | `levels` | JSON v2 | -| `get_location_level` | GET | `levels/{level_id}` | JSON v2 | -| `store_location_level` | POST | `levels` | JSON | -| `delete_location_level` | DELETE | `levels/{location_level_id}` | — | -| `update_location_level` | PATCH | `levels/{level_id}` | JSON | -| `get_level_as_timeseries` | GET | `levels/{location_level_id}/timeseries` | JSON v2 | - -### `cwms/levels/specified_levels.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_specified_levels` | GET | `specified-levels` | JSON v2 | -| `store_specified_level` | POST | `specified-levels` | JSON v2 | -| `delete_specified_level` | DELETE | `specified-levels/{specified_level_id}` | — | -| `update_specified_level` | PATCH | `specified-levels/{old_specified_level_id}` | — | - -### `cwms/locations/gate_changes.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_all_gate_changes` | GET | `projects/{office_id}/{project_id}/gate-changes` | JSON | -| `store_gate_change` | POST | `projects/gate-changes` | JSON | -| `delete_gate_change` | DELETE | `projects/{office_id}/{project_id}/gate-changes` | — | - -### `cwms/locations/location_groups.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_location_group` | GET | `location/group/{loc_group_id}` | JSON | -| `get_location_groups` | GET | `location/group` | JSON | -| `store_location_groups` | POST | `location/group` | JSON | -| `update_location_group` | PATCH | `location/group/{group_id}` | JSON | -| `delete_location_group` | DELETE | `location/group/{group_id}` | — | - -### `cwms/locations/lookups.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_all_lookups` | GET | `lookup-types` | JSON | -| `create_lookup` | POST | `lookup-types` | JSON | -| `update_lookup` | PATCH | `lookup-types/{category}` | JSON | -| `delete_lookup` | DELETE | `lookup-types/{display_value}` | — | - -### `cwms/locations/physical_locations.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_location` | GET | `locations/{location_id}` | JSON v2 | -| `get_locations` | GET | `locations` | JSON v2 | -| `delete_location` | DELETE | `locations/{location_id}` | — | -| `store_location` | POST | `locations` | JSON | -| `update_location` | PATCH | `locations/{location_id}` | JSON | - -### `cwms/measurements/measurements.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_measurements` | GET | `measurements` | JSON | -| `store_measurements` | POST | `measurements` | JSON | -| `delete_measurements` | DELETE | `measurements/{location_id}` | — | -| `get_measurements_extents` | GET | `measurements/time-extents` | Default JSON | - -### `cwms/outlets/outlets.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_outlet` | GET | `projects/outlets/{name}` | JSON | -| `get_outlets` | GET | `projects/outlets` | JSON | -| `delete_outlet` | DELETE | `projects/outlets/{name}` | — | -| `rename_outlet` | PATCH | `projects/outlets/{old_name}` | — | -| `store_outlet` | POST | `projects/outlets` | JSON | - -### `cwms/outlets/virtual_outlets.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_virtual_outlet` | GET | `projects/{office_id}/{project_id}/virtual-outlets/{name}` | JSON | -| `get_virtual_outlets` | GET | `projects/{office_id}/{project_id}/virtual-outlets` | JSON | -| `delete_virtual_outlet` | DELETE | `projects/{office_id}/{project_id}/virtual-outlets/{name}` | — | -| `store_virtual_outlet` | POST | `projects/virtual-outlets` | JSON | - -### `cwms/projects/project_lock_rights.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_project_lock_rights` | GET | `project-lock-rights` | JSON | -| `remove_all_project_lock_rights` | POST | `project-lock-rights/remove-all` | — | -| `update_project_lock_rights` | POST | `project-lock-rights/update` | — | - -### `cwms/projects/project_locks.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_project_lock` | GET | `project-locks/{name}` | JSON | -| `get_project_locks` | GET | `project-locks` | JSON | -| `revoke_project_lock` | DELETE | `project-locks/{name}` | — | -| `request_project_lock` | POST | `project-locks` | JSON | -| `deny_project_lock_request` | POST | `project-locks/deny` | — | -| `release_project_lock` | POST | `project-locks/release` | — | - -### `cwms/projects/projects.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_project` | GET | `projects/{name}` | Default JSON | -| `get_projects` | GET | `projects` | JSON | -| `get_project_locations` | GET | `projects/locations` | JSON | -| `delete_project` | DELETE | `projects/{name}` | — | -| `rename_project` | PATCH | `projects/{old_name}` | JSON | -| `store_project` | POST | `projects` | JSON | -| `status_update` | POST | `projects/status-update/{project_id}` | — | - -### `cwms/projects/water_supply/accounting.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_pump_accounting` | GET | `projects/{office_id}/{project_id}/water-user/{water_user}/contracts/{contract_name}/accounting` | JSON | -| `store_pump_accounting` | POST | `projects/{office}/{project_id}/water-user/{water_user}/contracts/{contract_name}/accounting` | Default JSON | - -### `cwms/properties/properties.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_properties` | GET | `properties` | JSON | -| `get_property` | GET | `properties/{name}` | JSON | -| `create_property` | POST | `properties` | JSON | -| `update_property` | PATCH | `properties/{name}` | JSON | -| `delete_property` | DELETE | `properties/{name}` | — | - -### `cwms/ratings/ratings.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_ratings_xml` | GET | `ratings/{rating_id}` | XML v2 | -| `get_ratings` | GET | `ratings/{rating_id}` | JSON v2 | -| `update_ratings` | PATCH | `ratings/{rating_id}` | JSON v2 | -| `delete_ratings` | DELETE | `ratings/{rating_id}` | — | -| `store_rating` | POST | `ratings` | JSON v2 | -| `_perform_value_rating` | POST | `ratings/rate-values/{office_id}/{rating_id}` | JSON | - -### `cwms/ratings/ratings_spec.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_rating_spec` | GET | `ratings/spec/{rating_id}` | JSON v2 | -| `get_rating_specs` | GET | `ratings/spec` | JSON v2 | -| `delete_rating_spec` | DELETE | `ratings/spec/{rating_id}` | — | -| `store_rating_spec` | POST | `ratings/spec/` | XML v2 | - -### `cwms/ratings/ratings_template.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_rating_template` | GET | `ratings/template/{template_id}` | JSON v2 | -| `get_rating_templates` | GET | `ratings/template` | JSON v2 | -| `delete_rating_template` | DELETE | `ratings/template/{template_id}` | — | -| `store_rating_template` | POST | `ratings/template/` | XML v2 | - -### `cwms/standard_text/standard_text.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_standard_text_catalog` | GET | `standard-text-id` | JSON v2 | -| `get_standard_text` | GET | `standard-text-id/{text_id}` | JSON v2 | -| `delete_standard_text` | DELETE | `standard-text-id/{text_id}` | — | -| `store_standard_text` | POST | `standard-text-id` | JSON v2 | - -### `cwms/timeseries/timeseries.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_timeseries_chunk` | GET | `timeseries` | JSON v2 | -| `get_timeseries` | GET | `timeseries` | JSON v2 | -| `store_timeseries` | POST | `timeseries` | JSON v2 | -| `delete_timeseries` | DELETE | `timeseries/{ts_id}` | — | - -### `cwms/timeseries/timeseries_bin.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_binary_timeseries` | GET | `timeseries/binary` | JSON v2 | -| `store_binary_timeseries` | POST | `timeseries/binary` | JSON v2 | -| `delete_binary_timeseries` | DELETE | `timeseries/binary/{timeseries_id}` | — | - -### `cwms/timeseries/timeseries_group.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_timeseries_group` | GET | `timeseries/group/{group_id}` | JSON | -| `get_timeseries_groups` | GET | `timeseries/group` | JSON | -| `store_timeseries_groups` | POST | `timeseries/group` | JSON | -| `update_timeseries_groups` | PATCH | `timeseries/group/{group_id}` | JSON | -| `delete_timeseries_group` | DELETE | `timeseries/group/{group_id}` | — | - -### `cwms/timeseries/timeseries_identifier.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_timeseries_identifier` | GET | `timeseries/identifier-descriptor/{ts_id}` | JSON v2 | -| `get_timeseries_identifiers` | GET | `timeseries/identifier-descriptor/` | JSON v2 | -| `delete_timeseries_identifier` | DELETE | `timeseries/identifier-descriptor/{ts_id}` | — | -| `store_timeseries_identifier` | POST | `timeseries/identifier-descriptor/` | JSON v2 | - -### `cwms/timeseries/timeseries_profile.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_timeseries_profile` | GET | `timeseries/profile/{location_id}/{parameter_id}` | JSON | -| `get_timeseries_profiles` | GET | `timeseries/profile` | JSON | -| `delete_timeseries_profile` | DELETE | `timeseries/profile/{location_id}/{parameter_id}` | — | -| `store_timeseries_profile` | POST | `timeseries/profile` | JSON | - -### `cwms/timeseries/timeseries_profile_instance.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_timeseries_profile_instance` | GET | `timeseries/profile-instance/{location_id}/{parameter_id}/{version}` | JSON | -| `get_timeseries_profile_instances` | GET | `timeseries/profile-instance` | JSON | -| `delete_timeseries_profile_instance` | DELETE | `timeseries/profile-instance/{location_id}/{parameter_id}/{version}` | — | -| `store_timeseries_profile_instance` | POST | `timeseries/profile-instance` | JSON | - -### `cwms/timeseries/timeseries_profile_parser.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_timeseries_profile_parser` | GET | `timeseries/profile-parser/{location_id}/{parameter_id}` | JSON | -| `get_timeseries_profile_parsers` | GET | `timeseries/profile-parser` | JSON | -| `delete_timeseries_profile_parser` | DELETE | `timeseries/profile-parser/{location_id}/{parameter_id}` | — | -| `store_timeseries_profile_parser` | POST | `timeseries/profile-parser` | JSON | - -### `cwms/timeseries/timeseries_txt.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_text_timeseries` | GET | `timeseries/text` | JSON v2 | -| `store_text_timeseries` | POST | `timeseries/text` | JSON v2 | -| `delete_text_timeseries` | DELETE | `timeseries/text/{timeseries_id}` | — | - -### `cwms/turbines/turbines.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_project_turbines` | GET | `projects/turbines` | JSON | -| `get_project_turbine` | GET | `projects/turbines/{name}` | JSON | -| `get_project_turbine_changes` | GET | `projects/{office}/{name}/turbine-changes` | Default JSON | -| `store_project_turbine` | POST | `projects/turbines` | JSON | -| `store_project_turbine_changes` | POST | `projects/{office}/{name}/turbine-changes` | JSON | -| `delete_project_turbine` | DELETE | `projects/turbines/{name}` | — | -| `delete_project_turbine_changes` | DELETE | `projects/{office}/{name}/turbine-changes` | — | - -### `cwms/users/users.py` - -| Function | HTTP | Path | Format | -| --- | --- | --- | --- | -| `get_roles` | GET | `roles` | JSON | -| `get_user_profile` | GET | `user/profile` | JSON | -| `get_users` | GET | `users` | JSON | -| `get_user` | GET | `users/{user_name}` | JSON | -| `store_user` | POST | `user/{user_name}/roles/{office_id}` | JSON | -| `update_user` | POST | `user/{user_name}/roles/{office_id}` | JSON | -| `delete_user_roles` | DELETE | `user/{user_name}/roles/{office_id}` | JSON | From 87fbb12ea08402b2883d493c8c13198881706369 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Thu, 10 Sep 2026 23:30:42 -0500 Subject: [PATCH 3/3] Isolate endpoint invocation in exception assertion --- tests/mock/endpoint_media_types_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/mock/endpoint_media_types_test.py b/tests/mock/endpoint_media_types_test.py index cd2ad9b2..57da130a 100644 --- a/tests/mock/endpoint_media_types_test.py +++ b/tests/mock/endpoint_media_types_test.py @@ -164,5 +164,6 @@ def capture(request, **kwargs): raise RequestCaptured monkeypatch.setattr(api.SESSION, "send", capture) + endpoint_function = getattr(module, function_name) with pytest.raises(RequestCaptured): - getattr(module, function_name)(*args) + endpoint_function(*args)