PATCH Handling Across CDA Endpoints

Summary

This ADR defines a standardized approach for implementing HTTP PATCH across CDA endpoints. PATCH operations SHALL identify the target resource using path parameters and include only the fields to be modified in the request body. Existing DTOs will be reused by retrieving the current resource representation and applying the incoming JSON using Jackson’s ObjectMapper.readerForUpdating().

Opinions

Opinion 1

@brysonspilman

Summary

PATCH requests should represent both partial and total updates. Resource identifiers belong in the request path, while the request body contains only the properties to be modified. To preserve the distinction between omitted properties and properties explicitly set to null, PATCH operations will retrieve the existing resource and apply the incoming JSON onto that object using Jackson’s update capabilities.

That mechanism covers scalar fields well: Jackson’s tree merge (ObjectMapper.readerForUpdating()) naturally leaves an omitted scalar field unchanged and overwrites one that’s present, because it can compare the existing value and the incoming value directly. A collection-typed field (the rows of a time series, for example) doesn’t have that luxury on its own – Jackson’s merge has no built-in way to match an element of the existing array against an element of the incoming array by identity, so its only two well-defined behaviors for an array field are to replace it wholesale or to append the incoming elements onto the existing ones. The second of those isn’t safe to expose as its own strategy: a plain append can leave two items sharing the same identity sitting side by side in the collection, which is exactly what these DTOs’ identity fields (see MERGE below) exist to rule out. Rather than an endpoint silently committing to either native behavior, PATCH endpoints whose body may include a collection field SHALL expose a collection-merge-strategy query parameter so the caller picks between wholesale replacement and an identity-aware merge – see OVERWRITE and MERGE below.

For endpoints scoped by a time window (begin / end, as the time-series endpoints are), that window bounds the entire operation: it’s what determines which existing rows are retrieved prior to the merge, and consequently the only rows that can ever be read or affected by the PATCH at all. Data outside the window is never touched, regardless of merge strategy.

Key points

Topic

Decision

Justification

Resource identification

Resource identifiers SHALL be provided as path parameters

Follows REST conventions and avoids duplication of resource identity between the URI and request body.

Request body

Include only the fields to be modified

PATCH semantics represent partial updates rather than full resource replacement.

Omitted fields

Omitted properties SHALL remain unchanged

Clients should only send the fields they intend to modify.

Explicit null values

Properties explicitly provided with a value of null SHALL clear the corresponding field when permitted by the resource

Distinguishes “remove this value” from “leave this value unchanged.”

DTO reuse

Existing DTOs SHALL be reused for PATCH operations

Avoids introducing PATCH-specific DTOs or wrapper types across the API.

Update implementation

Retrieve the existing resource, populate the existing DTO, and apply the incoming JSON using ObjectMapper.readerForUpdating()

Jackson updates only properties present in the payload while leaving omitted properties untouched, naturally preserving PATCH semantics without requiring DTO changes.

Validation

Validate the resulting object after the update has been applied

Validation should be performed against the final resource state.

PUT semantics

PUT remains the mechanism for complete resource replacement

Preserves the standard semantic distinction between PUT and PATCH.

Implementation tradeoff

PATCH operations require retrieval of the existing resource prior to applying updates

The additional read enables reuse of existing DTOs while correctly distinguishing omitted properties from explicit null values.

Backwards compatibility

Existing DTOs and serialization formats remain unchanged

Minimizes implementation effort and avoids widespread API changes.

Collection merge control

PATCH endpoints with a collection field SHALL accept a collection-merge-strategy query parameter with values OVERWRITE (default) and MERGE

Names the operation precisely rather than reusing a bare “replace” that could be misread as describing HTTP semantics rather than this specific per-row behavior.

PATCH Handling Across CDA Endpoints

Summary

This ADR defines a standardized approach for implementing HTTP PATCH across CDA endpoints. PATCH operations SHALL identify the target resource using path parameters and include only the fields to be modified in the request body. Existing DTOs will be reused by retrieving the current resource representation and applying the incoming JSON using Jackson’s ObjectMapper.readerForUpdating().

Opinions

Opinion 1

@brysonspilman

Summary

PATCH requests should represent partial updates only. Resource identifiers belong in the request path, while the request body contains only the properties to be modified. To preserve the distinction between omitted properties and properties explicitly set to null, PATCH operations will retrieve the existing resource and apply the incoming JSON onto that object using Jackson’s update capabilities.

Key points

Topic

Decision

Justification

Resource identification

Resource identifiers SHALL be provided as path parameters

Follows REST conventions and avoids duplication of resource identity between the URI and request body.

Request body

Include only the fields to be modified

PATCH semantics represent partial updates rather than full resource replacement.

Omitted fields

Omitted properties SHALL remain unchanged

Clients should only send the fields they intend to modify.

Explicit null values

Properties explicitly provided with a value of null SHALL clear the corresponding field when permitted by the resource

Distinguishes “remove this value” from “leave this value unchanged.”

DTO reuse

Existing DTOs SHALL be reused for PATCH operations

Avoids introducing PATCH-specific DTOs or wrapper types across the API.

Update implementation

Retrieve the existing resource, populate the existing DTO, and apply the incoming JSON using ObjectMapper.readerForUpdating()

Jackson updates only properties present in the payload while leaving omitted properties untouched, naturally preserving PATCH semantics without requiring DTO changes.

Validation

Validate the resulting object after the update has been applied

Validation should be performed against the final resource state.

PUT semantics

PUT remains the mechanism for complete resource replacement

Preserves the standard semantic distinction between PUT and PATCH.

Implementation tradeoff

PATCH operations require retrieval of the existing resource prior to applying updates

The additional read enables reuse of existing DTOs while correctly distinguishing omitted properties from explicit null values.

Backwards compatibility

Existing DTOs and serialization formats remain unchanged

Minimizes implementation effort and avoids widespread API changes.

Example

Example endpoint:

PATCH /entity/{entity-id}

Request:

PATCH /entity/MyEntity

Request body:

{
  "long-name": "Updated Entity Long Name",
  "parent-entity-id": "NewParent"
}

Implementation flow:

  1. Retrieve the existing Entity identified by entity-id.

  2. Populate the existing DTO.

  3. Apply the incoming JSON using ObjectMapper.readerForUpdating(existingDto).

  4. Validate the resulting object.

  5. Persist the updated resource.

For example, given the existing resource:

{
  "id": {
    "office-id": "SWT",
    "name": "MyEntity"
  },
  "parent-entity-id": "ParentA",
  "category-id": "Reservoir",
  "long-name": "Original Long Name"
}

and the PATCH request:

{
  "category-id": "Dam",
  "parent-entity-id": null
}

the resulting object after applying the PATCH becomes:

{
  "id": {
    "office-id": "SWT",
    "name": "MyEntity"
  },
  "parent-entity-id": null,
  "category-id": "Dam",
  "long-name": "Original Long Name"
}

Only the properties present in the request body are modified. The id remains unchanged because it is derived from the request path, and long-name remains unchanged because it was omitted from the PATCH payload.

Existing endpoints that support PATCH

Endpoint Path

Controller

Support Level

Notes

/entity/{entity-id}

EntityController

Full-patch

Reuses existing DTO and updates fields.

/locations/{location-id}

LocationController

Full-patch

Supports partial updates and renaming if the name in the body differs.

/timeseries/{timeseries}

TimeSeriesController

Full-patch

Used to store/update time series data.

/levels/{level-id}

LevelsController

Full-patch

Supports partial updates and renaming.

/clobs/{clob-id}

ClobController

Full-patch

Supports updating clob value/description; allows ignore-nulls.

/location/{location-id}/vertical-datum

VerticalDatumController

Full-patch

Updates vertical datum information for a location.

/ratings/{rating-id}

RatingController

Full-patch

Updates/stores RatingSet data.

/timeseries/text/{name}

TextTimeSeriesController

Full-patch

Updates text time series values.

/timeseries/binary/{name}

BinaryTimeSeriesController

Full-patch

Updates binary time series values.

/forecast-instance/{name}

ForecastInstanceController

Full-patch

Updates notes, max age, and files for a forecast instance.

/forecast-spec/{name}

ForecastSpecController

Full-patch

Updates forecast specification values.

/properties/{name}

PropertyController

Full-patch

Updates property values.

/stream-locations/{name}

StreamLocationController

Full-patch

Updates stream location attributes.

/timeseries/category/{category-id}

TimeSeriesCategoryController

Full-patch

Supports renaming and updating descriptions.

/lookup-types/{name}

LookupTypeController

Full-patch

Updates lookup type display values and tooltips.

/basins/{name}

BasinController

Rename-only

Primarily used for renaming the basin via the name query parameter.

/projects/{name}

ProjectController

Rename-only

Renames a project using the name query parameter.

/projects/embankments/{name}

EmbankmentController

Rename-only

Renames an embankment.

/projects/turbines/{name}

TurbineController

Rename-only

Renames a turbine.

/projects/locks/{name}

LockController

Rename-only

Renames a lock.

/projects/outlets/{name}

OutletController

Rename-only

Renames an outlet.

/streams/{name}

StreamController

Rename-only

Renames a stream.

/stream-reaches/{name}

StreamReachController

Rename-only

Renames a stream reach.

/specified-levels/{specified-level-id}

SpecifiedLevelController

Rename-only

Renames a specified level ID.

/timeseries/group/{group-id}

TimeSeriesGroupController

Rename/ Specific fields

Supports renaming and assigning/unassigning time series.

/location/group/{group-id}

LocationGroupController

Rename / Specific fields

Supports renaming and assigning/unassigning locations.

/timeseries/identifier-descriptor/{name}

TimeSeriesIdentifierDescriptorController

Rename / Specific fields

Supports renaming and updating snap tolerances.

/projects/{office}/{project-id}/water-user/{water-user}

WaterUserUpdateController

Rename-only

Renames a water user.

/projects/{office}/{project-id}/water-users/{water-user}/contracts/{contract-name}

WaterContractUpdateController

Rename-only

Renames a water contract.

Decision Status

(Status: accepted)

References

Related Pattern: HTTP PATCH

Jackson ObjectMapper.readerForUpdating()

RFC 5789 - PATCH Method for HTTP

RFC 7396 -

Operation

Description

Usage

JSON Merge Patch action becomes exactly the set of items named in the request body. Any existing item that falls within the request's time window but isn’t named in the body is removed.

Matches "replace" as most callers mean it for a bounded window: the window's collection now looks exactly like what was sent, not like what was sent plus leftovers.

MERGE

Items named in the request body are matched against existing items by that collection’s identity field(s), then updated in place – preserving that one item’s own fields the body omits, the same way a top-level PATCH preserves an omitted scalar field. An unmatched identity is added as new. Every other existing item, in or out of the request’s time window, is left exactly as it was.

The precise, surgical option: change just the named item(s) without disturbing anything else in the collection, including items in-window that OVERWRITE would otherwise remove.

MERGE’s identity

An item’s identity is whichever field(s) of its class are annotated @Identifier, taken together as one composite key – e.g. both date-time and data-entry-date on a text-timeseries row, since two rows can share the same date-time and are only distinguished by data-entry-date. When an element type has no @Identifier field at all, identity falls back to whichever field(s) are marked @JsonProperty(required = true) instead – e.g. just date-time, for a type with no need of a composite key. Formats.parsePatchContent finds either via ordinary Jackson bean introspection on the collection’s declared element type, generically for any CwmsDTOBase. A null or absent identity field on the incoming item never matches anything, even an existing item whose own value for that field is also null.

A single field isn’t always enough to say two items are “the same” one – text-timeseries rows are the concrete case: date-time alone can’t tell two rows at the same time apart, but the pair can. @Identifier lets a DTO opt into a composite key for exactly that case while every other DTO keeps the simpler, existing @JsonProperty(required = true) behavior unchanged. Treating a null identity field as “never matches” rather than “ignore this field” matters specifically for a field like data-entry-date that a client wouldn’t normally supply at all (the database assigns it) – omitting it means “add this as a new row,” not “match whatever’s at this date-time.” A collection whose element type has neither kind of field can’t use MERGE (the request fails outright rather than guessing at a key).

Time window scope

The begin/end window bounds what is retrieved as “existing” prior to the merge and what gets deleted-and-restored by the storage step (see Storage implementation below), under every strategy. Data outside the window is never read or written, regardless of strategy.

The window is what the existing resource retrieval is already scoped to; keeping the storage step’s reach to that same scope (and no further) keeps the window’s meaning consistent between GET and PATCH.

Superseding replace-all (query parameter)

The old replace-all boolean query parameter is removed from the text-timeseries PATCH endpoint (it remains as-is for POST). collection-merge-strategy now fully determines, per row, whether a value at an already-populated date-time is overwritten or left in place alongside the new one.

The old replace-all and the new collection-merge-strategy were overlapping, similarly-named knobs answering the same underlying question at different layers (store-call collision handling vs. body-level merge behavior); keeping both invited the two being set inconsistently with each other.

Storage implementation

Storage is uniform across both strategies: the controller deletes everything in the begin/end window and stores exactly the merged collection’s rows with replaceAll=true, both within a single transaction (TimeSeriesTextDao.update, backed by one checked-out connection) – not a per-strategy delete/diff path, and not two independent DAO calls.

The merge already computes the correct final row set per strategy – OVERWRITE’s named items only, MERGE’s matched-and-updated items plus every untouched existing item carried through, plus any unmatched incoming item added as new (possibly sharing a date-time with an existing item it didn’t match, since its identity – date-time and data-entry-date together – differs) – so uniformly clearing and restoring the window reaches the right end state regardless of strategy. The store call can’t do this alone: it only ever touches the date-times it’s given, never removing one it isn’t, which is why the delete is still required. Running both in one transaction also means a failure partway through can’t leave the window deleted but not repopulated, the way two independent calls could. Because the window is fully cleared first, a new row that happens to share a date-time with something already there is stored into an empty slot rather than colliding with anything, so replaceAll=true is safe for every strategy – there’s nothing left in the window for it to overwrite by the time any row is stored.

Absent or empty collection

A PATCH body that omits the collection field entirely, or names it with an empty array, still goes through the same delete-and-restore – but the merge carries the existing rows through unchanged, so the window’s observable content afterward is identical to what it was before.

The merge already resolves this without a separate check: an absent or empty collection field never changes what the merged DTO says the window should contain, so the same uniform storage step reaches the correct (unchanged) result. The tradeoff is that every row in the window is still deleted and re-stored even when nothing about the collection was named in the body, rather than being left alone entirely.

Example

Example endpoint:

PATCH /entity/{entity-id}

Request:

PATCH /entity/MyEntity

Request body:

{
  "long-name": "Updated Entity Long Name",
  "parent-entity-id": "NewParent"
}

Implementation flow:

  1. Retrieve the existing Entity identified by entity-id.

  2. Populate the existing DTO.

  3. Apply the incoming JSON using ObjectMapper.readerForUpdating(existingDto).

  4. Validate the resulting object.

  5. Persist the updated resource.

For example, given the existing resource:

{
  "id": {
    "office-id": "SWT",
    "name": "MyEntity"
  },
  "parent-entity-id": "ParentA",
  "category-id": "Reservoir",
  "long-name": "Original Long Name"
}

and the PATCH request:

{
  "category-id": "Dam",
  "parent-entity-id": null
}

the resulting object after applying the PATCH becomes:

{
  "id": {
    "office-id": "SWT",
    "name": "MyEntity"
  },
  "parent-entity-id": null,
  "category-id": "Dam",
  "long-name": "Original Long Name"
}

Only the properties present in the request body are modified. The id remains unchanged because it is derived from the request path, and long-name remains unchanged because it was omitted from the PATCH payload.

Given a text time series with five existing values at hourly date-times 01:00-05:00, each with its own data-entry-date assigned by the database (say the 03:00 row’s is ...T00:00:05Z), and the PATCH request:

PATCH /timeseries/text/SPK/MyTs?begin=...T01:00:00Z&end=...T05:00:00Z&collection-merge-strategy=merge
{
  "regular-text-values": [
    {"date-time": "...T03:00:00Z", "data-entry-date": "...T00:00:05Z", "text-value": "updated"}
  ]
}

matches the existing 03:00 row by its composite (date-time, data-entry-date) identity – both fields marked @Identifier on the row’s class – and updates just that row’s text-value, leaving the other four rows untouched. Omitting data-entry-date from the body instead of supplying the row’s actual value:

{
  "regular-text-values": [
    {"date-time": "...T03:00:00Z", "text-value": "updated"}
  ]
}

does not match the existing 03:00 row at all – a null or absent identity field never matches, even against an existing row whose own value happens to be null – so MERGE adds this as a sixth, distinct row alongside the original 03:00 value rather than updating it. The identical first request with collection-merge-strategy=overwrite instead removes the other four rows entirely, since OVERWRITE means the window’s collection becomes exactly what the body named.

The same request with regular-text-values omitted from the body entirely, or sent as "regular-text-values": [], changes nothing regardless of collection-merge-strategy – all five original values remain exactly as they were.

Existing endpoints that support PATCH

Endpoint Path

Controller

Support Level

Notes

/entity/{entity-id}

EntityController

Full-patch

Reuses existing DTO and updates fields.

/locations/{location-id}

LocationController

Full-patch

Supports partial updates and renaming if the name in the body differs.

/timeseries/{timeseries}

TimeSeriesController

Full-patch

Used to store/update time series data.

/levels/{level-id}

LevelsController

Full-patch

Supports partial updates and renaming.

/clobs/{clob-id}

ClobController

Full-patch

Supports updating clob value/description; allows ignore-nulls.

/location/{location-id}/vertical-datum

VerticalDatumController

Full-patch

Updates vertical datum information for a location.

/ratings/{rating-id}

RatingController

Full-patch

Updates/stores RatingSet data.

/timeseries/text/{name}

TextTimeSeriesController

Full-patch

Updates text time series values; supports collection-merge-strategy for regular-text-values.

/timeseries/binary/{name}

BinaryTimeSeriesController

Full-patch

Updates binary time series values.

/forecast-instance/{name}

ForecastInstanceController

Full-patch

Updates notes, max age, and files for a forecast instance.

/forecast-spec/{name}

ForecastSpecController

Full-patch

Updates forecast specification values.

/properties/{name}

PropertyController

Full-patch

Updates property values.

/stream-locations/{name}

StreamLocationController

Full-patch

Updates stream location attributes.

/timeseries/category/{category-id}

TimeSeriesCategoryController

Full-patch

Supports renaming and updating descriptions.

/lookup-types/{name}

LookupTypeController

Full-patch

Updates lookup type display values and tooltips.

/basins/{name}

BasinController

Rename-only

Primarily used for renaming the basin via the name query parameter.

/projects/{name}

ProjectController

Rename-only

Renames a project using the name query parameter.

/projects/embankments/{name}

EmbankmentController

Rename-only

Renames an embankment.

/projects/turbines/{name}

TurbineController

Rename-only

Renames a turbine.

/projects/locks/{name}

LockController

Rename-only

Renames a lock.

/projects/outlets/{name}

OutletController

Rename-only

Renames an outlet.

/streams/{name}

StreamController

Rename-only

Renames a stream.

/stream-reaches/{name}

StreamReachController

Rename-only

Renames a stream reach.

/specified-levels/{specified-level-id}

SpecifiedLevelController

Rename-only

Renames a specified level ID.

/timeseries/group/{group-id}

TimeSeriesGroupController

Rename/ Specific fields

Supports renaming and assigning/unassigning time series.

/location/group/{group-id}

LocationGroupController

Rename / Specific fields

Supports renaming and assigning/unassigning locations.

/timeseries/identifier-descriptor/{name}

TimeSeriesIdentifierDescriptorController

Rename / Specific fields

Supports renaming and updating snap tolerances.

/projects/{office}/{project-id}/water-user/{water-user}

WaterUserUpdateController

Rename-only

Renames a water user.

/projects/{office}/{project-id}/water-users/{water-user}/contracts/{contract-name}

WaterContractUpdateController

Rename-only

Renames a water contract.

Decision Status

(Status: accepted)

References

Related Pattern: HTTP PATCH

Jackson ObjectMapper.readerForUpdating()

RFC 5789 - PATCH Method for HTTP

RFC 7396 - JSON Merge Patch