Evolving PATCH Contracts¶
Operation schemas are API surface. Changes to them should preserve compatibility on purpose.
Compatibility Rules¶
The safest rules are simple:
- additive changes are the safest changes
- new fields should preserve existing behavior by default
- a semantic break deserves a new
op - deprecation should happen before removal
- registries are where old and new contracts can coexist deliberately
Contract Evolution by Example¶
A mutation like "replace substring" is hard to get right the first time.
Baseline Contract of ReplaceSubstringOp¶
from typing import Literal
from pydantic import Field
from jsonpatchx import JSONPointer, JSONValue, OperationSchema, ReplaceOp, PatchConflictError
from jsonpatchx.types import JSONString, JSONBoolean
class ReplaceSubstringOp(OperationSchema):
op: Literal["replace_substring"] = "replace_substring"
path: JSONPointer[JSONString]
old: JSONString
new: JSONString
def apply(self, doc: JSONValue) -> JSONValue:
current = self.path.get(doc)
if self.old not in current:
raise PatchConflictError(f"{self.old!r} is not in {current!r}")
replaced = current.replace(self.old, self.new)
return ReplaceOp(path=self.path, value=replaced).apply(doc)
Additive Change¶
If clients now need a non-strict mode, keep the same op and add a field that
preserves the old behavior by default.
from typing import Literal
from pydantic import Field
from jsonpatchx import JSONPointer, JSONValue, OperationSchema, ReplaceOp, PatchConflictError
from jsonpatchx.types import JSONString, JSONBoolean
class ReplaceSubstringOp(OperationSchema):
op: Literal["replace_substring"] = "replace_substring"
path: JSONPointer[JSONString]
old: JSONString
new: JSONString
strict: JSONBoolean = Field(default=True)
def apply(self, doc: JSONValue) -> JSONValue:
current = self.path.get(doc)
if self.strict and self.old not in current:
raise PatchConflictError(
f"strict mode is enabled and {self.old!r} is not in {current!r}"
)
replaced = current.replace(self.old, self.new)
return ReplaceOp(path=self.path, value=replaced).apply(doc)
Because strict=True preserves the original behavior, existing clients keep
working.
Deprecation¶
If that field later needs to go away, deprecate it before removing it. That gives OpenAPI and client developers a transition period instead of a surprise.
from typing import Literal
from pydantic import Field
from jsonpatchx import JSONPointer, JSONValue, OperationSchema, ReplaceOp, PatchConflictError
from jsonpatchx.types import JSONString, JSONBoolean
class ReplaceSubstringOp(OperationSchema):
op: Literal["replace_substring"] = "replace_substring"
path: JSONPointer[JSONString]
old: JSONString
new: JSONString
strict: JSONBoolean = Field(default=True, deprecated=True)
def apply(self, doc: JSONValue) -> JSONValue:
current = self.path.get(doc)
if self.old not in current:
if "strict" not in self.model_fields_set or self.strict:
raise PatchConflictError(
f"strict mode is enabled and {self.old!r} is not in {current!r}"
)
replaced = current.replace(self.old, self.new)
return ReplaceOp(path=self.path, value=replaced).apply(doc)
Pydantic's
model_fields_set
distinguishes omission from explicit use, so
DeprecationWarning
becomes a signal that clients are still sending the deprecated field.
Contract Tightening¶
Python's
str.replace()
is broad. str.replace(old, new, /, count=-1) replaces all occurrences by
default, limits replacements when count is given, and even allows old="".
A PATCH contract does not need to expose every lever. If you decide this operation should only replace a non-empty substring, tighten the field contract:
from typing import Literal
from pydantic import Field
from jsonpatchx import JSONPointer, JSONValue, OperationSchema, ReplaceOp, PatchConflictError
from jsonpatchx.types import JSONString, JSONBoolean
class ReplaceSubstringOp(OperationSchema):
op: Literal["replace_substring"] = "replace_substring"
path: JSONPointer[JSONString]
old: JSONString = Field(min_length=1)
new: JSONString
def apply(self, doc: JSONValue) -> JSONValue:
current = self.path.get(doc)
if self.old not in current:
raise PatchConflictError(f"{self.old!r} is not in {current!r}")
replaced = current.replace(self.old, self.new)
return ReplaceOp(path=self.path, value=replaced).apply(doc)
If you use semantic versioning, this is a breaking change.
Breaking Semantic Change¶
If the meaning of the operation itself changes, do not mutate the old one in place.
For example, if replace_substring originally meant "replace all occurrences",
and you now want an operation that replaces only the first occurrence, that is
not the same operation. If you intend to retire the old behavior, deprecate
replace_substring, introduce replace_first_substring, allow both through
registry policy for a while, then remove the deprecated one.
from pydantic import ConfigDict
class ReplaceSubstringOp(OperationSchema):
model_config = ConfigDict(json_schema_extra={"deprecated": True})
...
Changing semantics in place is harder for clients to reason about than adding a
new op. For the route-level side of that rollout, see
Registries and Routes.