Skip to content

Commit 3f53a53

Browse files
authored
Expose JSON Patch operations as public API (#1089)
Motivation: JSON Patch syntax is not easy to write in string. An API will prevent users from writing raw operations in string and help to build JSON Patch operations type-safely. Modifications: - Move JSON Patch operations to `common.jsonpatch` from `internal.jsonpatch`. - Fix `Change.ofJsonPatch()` to create JSON patch the `JsonPatchOperation`s. - Add factory methods to `JsonPatchOperation`. - Create `JsonPatchConflictException` and `TextPatchConflitException` to distinguish exceptions easily and provide a detailed message. - Fix `GitRepository` to allow an empty message JSON patch because `test` or `testAbsent` do not have changes. - In addition, JSON patch operations have their own validation mechanism. Result: - Closes #1088 - You can now easily create a JSON patch with `JsonPatchOperation`. ```java // Add AddOperation add = JsonPatchOperation.add("/b", new IntNode(2)); // Copy CopyOperation copy = JsonPatchOperation.copy("/a", "/b"); // Move MoveOperation move = JsonPatchOperation.move("/a","/b"); // Remove RemoveOperation remove = JsonPatchOperation.remove("/a"); // Remove if exists RemoveIfExistsOperation removeIfExists = JsonPatchOperation.removeIfExists("/a"); // Replace ReplaceOperation replace = JsonPatchOperation.replace("/a", new IntNode(2)); // Safe replace (aka. compare and set) SafeReplaceOperation safeReplace = JsonPatchOperation.safeReplace("/a", new IntNode(1), new IntNode(2)); // Test if a value exists in a node TestOperation test = JsonPatchOperation.test("/a", new IntNode(1)); // Test absent TestAbsenceOperation testAbsence = JsonPatchOperation.testAbsence("/b"); ``` The operations above can be used to create a change and push to a Central Dogma server. ```java // Create a change with JSON patch operations Change<JsonNode> change = Change.ofJsonPatch("/a.json", List.of(add, move, remove, safeReplace, ...)); repository.commit("json patch operations", change) .push() .join(); ```
1 parent c465263 commit 3f53a53

38 files changed

+1263
-356
lines changed

client/java-armeria/src/main/java/com/linecorp/centraldogma/internal/client/armeria/ArmeriaCentralDogma.java

+4
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@
106106
import com.linecorp.centraldogma.common.Revision;
107107
import com.linecorp.centraldogma.common.RevisionNotFoundException;
108108
import com.linecorp.centraldogma.common.ShuttingDownException;
109+
import com.linecorp.centraldogma.common.TextPatchConflictException;
110+
import com.linecorp.centraldogma.common.jsonpatch.JsonPatchConflictException;
109111
import com.linecorp.centraldogma.internal.HistoryConstants;
110112
import com.linecorp.centraldogma.internal.Jackson;
111113
import com.linecorp.centraldogma.internal.Util;
@@ -140,6 +142,8 @@ public final class ArmeriaCentralDogma extends AbstractCentralDogma {
140142
.put(ReadOnlyException.class.getName(), ReadOnlyException::new)
141143
.put(MirrorException.class.getName(), MirrorException::new)
142144
.put(PermissionException.class.getName(), PermissionException::new)
145+
.put(JsonPatchConflictException.class.getName(), JsonPatchConflictException::new)
146+
.put(TextPatchConflictException.class.getName(), TextPatchConflictException::new)
143147
.build();
144148

145149
private final WebClient client;

client/java/src/main/java/com/linecorp/centraldogma/client/CentralDogmaRepository.java

+8-2
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,17 @@ CentralDogma centralDogma() {
6363
return centralDogma;
6464
}
6565

66-
String projectName() {
66+
/**
67+
* Returns the name of the project.
68+
*/
69+
public String projectName() {
6770
return projectName;
6871
}
6972

70-
String repositoryName() {
73+
/**
74+
* Returns the name of the repository.
75+
*/
76+
public String repositoryName() {
7177
return repositoryName;
7278
}
7379

common/src/main/java/com/linecorp/centraldogma/common/Change.java

+41
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.linecorp.centraldogma.common;
1818

19+
import static com.google.common.base.Preconditions.checkArgument;
1920
import static com.linecorp.centraldogma.internal.Util.validateDirPath;
2021
import static com.linecorp.centraldogma.internal.Util.validateFilePath;
2122
import static java.util.Objects.requireNonNull;
@@ -36,7 +37,10 @@
3637
import com.fasterxml.jackson.annotation.JsonProperty;
3738
import com.fasterxml.jackson.databind.JsonNode;
3839
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
40+
import com.google.common.collect.ImmutableList;
41+
import com.google.common.collect.Iterables;
3942

43+
import com.linecorp.centraldogma.common.jsonpatch.JsonPatchOperation;
4044
import com.linecorp.centraldogma.internal.Jackson;
4145
import com.linecorp.centraldogma.internal.Util;
4246
import com.linecorp.centraldogma.internal.jsonpatch.JsonPatch;
@@ -223,6 +227,43 @@ static Change<JsonNode> ofJsonPatch(String path, @Nullable JsonNode oldJsonNode,
223227
JsonPatch.generate(oldJsonNode, newJsonNode, ReplaceMode.SAFE).toJson());
224228
}
225229

230+
/**
231+
* Returns a newly-created {@link Change} whose type is {@link ChangeType#APPLY_JSON_PATCH}.
232+
*
233+
* @param path the path of the file
234+
* @param jsonPatch the patch in <a href="https://tools.ietf.org/html/rfc6902">JSON patch format</a>
235+
*/
236+
static Change<JsonNode> ofJsonPatch(String path, JsonPatchOperation jsonPatch) {
237+
requireNonNull(path, "path");
238+
requireNonNull(jsonPatch, "jsonPatch");
239+
return new DefaultChange<>(path, ChangeType.APPLY_JSON_PATCH, jsonPatch.toJsonNode());
240+
}
241+
242+
/**
243+
* Returns a newly-created {@link Change} whose type is {@link ChangeType#APPLY_JSON_PATCH}.
244+
*
245+
* @param path the path of the file
246+
* @param jsonPatches the list of patches in <a href="https://tools.ietf.org/html/rfc6902">JSON patch format</a>
247+
*/
248+
static Change<JsonNode> ofJsonPatch(String path, JsonPatchOperation... jsonPatches) {
249+
requireNonNull(jsonPatches, "jsonPatches");
250+
return ofJsonPatch(path, ImmutableList.copyOf(jsonPatches));
251+
}
252+
253+
/**
254+
* Returns a newly-created {@link Change} whose type is {@link ChangeType#APPLY_JSON_PATCH}.
255+
*
256+
* @param path the path of the file
257+
* @param jsonPatches the list of patches in <a href="https://tools.ietf.org/html/rfc6902">JSON patch format</a>
258+
*/
259+
static Change<JsonNode> ofJsonPatch(String path, Iterable<? extends JsonPatchOperation> jsonPatches) {
260+
requireNonNull(path, "path");
261+
requireNonNull(jsonPatches, "jsonPatches");
262+
checkArgument(!Iterables.isEmpty(jsonPatches), "jsonPatches cannot be empty");
263+
return new DefaultChange<>(path, ChangeType.APPLY_JSON_PATCH,
264+
JsonPatchOperation.asJsonArray(jsonPatches));
265+
}
266+
226267
/**
227268
* Returns a newly-created {@link Change} whose type is {@link ChangeType#APPLY_JSON_PATCH}.
228269
*
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2025 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*
16+
*/
17+
18+
package com.linecorp.centraldogma.common;
19+
20+
/**
21+
* A {@link CentralDogmaException} that is raised when attempted to apply a text patch which cannot be applied
22+
* without a conflict.
23+
*/
24+
public final class TextPatchConflictException extends ChangeConflictException {
25+
private static final long serialVersionUID = -6150468151945332532L;
26+
27+
/**
28+
* Creates a new instance.
29+
*/
30+
public TextPatchConflictException(String message) {
31+
super(message);
32+
}
33+
34+
/**
35+
* Creates a new instance with the specified {@code cause}.
36+
*/
37+
public TextPatchConflictException(String message, Throwable cause) {
38+
super(message, cause);
39+
}
40+
}

common/src/main/java/com/linecorp/centraldogma/internal/jsonpatch/AddOperation.java common/src/main/java/com/linecorp/centraldogma/common/jsonpatch/AddOperation.java

+15-7
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
* - ASL 2.0: https://www.apache.org/licenses/LICENSE-2.0.txt
3333
*/
3434

35-
package com.linecorp.centraldogma.internal.jsonpatch;
35+
package com.linecorp.centraldogma.common.jsonpatch;
36+
37+
import static java.util.Objects.requireNonNull;
3638

3739
import com.fasterxml.jackson.annotation.JsonCreator;
3840
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -79,14 +81,19 @@ public final class AddOperation extends PathValueOperation {
7981

8082
private static final String LAST_ARRAY_ELEMENT = "-";
8183

84+
/**
85+
* Creates a new instance with the specified {@code path} and {@code value}.
86+
*/
8287
@JsonCreator
83-
public AddOperation(@JsonProperty("path") final JsonPointer path,
84-
@JsonProperty("value") final JsonNode value) {
88+
AddOperation(@JsonProperty("path") final JsonPointer path,
89+
@JsonProperty("value") final JsonNode value) {
8590
super("add", path, value);
8691
}
8792

8893
@Override
89-
JsonNode apply(final JsonNode node) {
94+
public JsonNode apply(final JsonNode node) {
95+
requireNonNull(node, "node");
96+
final JsonPointer path = path();
9097
if (path.toString().isEmpty()) {
9198
return valueCopy();
9299
}
@@ -110,12 +117,13 @@ static JsonNode addToArray(final JsonPointer path, final JsonNode node, final Js
110117
try {
111118
index = Integer.parseInt(rawToken);
112119
} catch (NumberFormatException ignored) {
113-
throw new JsonPatchException("not an index: " + rawToken + " (expected: a non-negative integer)");
120+
throw new JsonPatchConflictException(
121+
"not an index: " + rawToken + " (expected: a non-negative integer)");
114122
}
115123

116124
if (index < 0 || index > size) {
117-
throw new JsonPatchException("index out of bounds: " + index +
118-
" (expected: >= 0 && <= " + size + ')');
125+
throw new JsonPatchConflictException("index out of bounds: " + index +
126+
" (expected: >= 0 && <= " + size + ')');
119127
}
120128

121129
target.insert(index, value);

common/src/main/java/com/linecorp/centraldogma/internal/jsonpatch/CopyOperation.java common/src/main/java/com/linecorp/centraldogma/common/jsonpatch/CopyOperation.java

+11-3
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
* - ASL 2.0: https://www.apache.org/licenses/LICENSE-2.0.txt
3333
*/
3434

35-
package com.linecorp.centraldogma.internal.jsonpatch;
35+
package com.linecorp.centraldogma.common.jsonpatch;
36+
37+
import static java.util.Objects.requireNonNull;
3638

3739
import com.fasterxml.jackson.annotation.JsonCreator;
3840
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -49,19 +51,25 @@
4951
*/
5052
public final class CopyOperation extends DualPathOperation {
5153

54+
/**
55+
* Creates a new instance.
56+
*/
5257
@JsonCreator
5358
CopyOperation(@JsonProperty("from") final JsonPointer from,
5459
@JsonProperty("path") final JsonPointer path) {
5560
super("copy", from, path);
5661
}
5762

5863
@Override
59-
JsonNode apply(final JsonNode node) {
64+
public JsonNode apply(final JsonNode node) {
65+
requireNonNull(node, "node");
66+
final JsonPointer from = from();
6067
JsonNode source = node.at(from);
6168
if (source.isMissingNode()) {
62-
throw new JsonPatchException("non-existent source path: " + from);
69+
throw new JsonPatchConflictException("non-existent source path: " + from);
6370
}
6471

72+
final JsonPointer path = path();
6573
if (path.toString().isEmpty()) {
6674
return source;
6775
}

common/src/main/java/com/linecorp/centraldogma/internal/jsonpatch/DualPathOperation.java common/src/main/java/com/linecorp/centraldogma/common/jsonpatch/DualPathOperation.java

+34-6
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@
3232
* - ASL 2.0: https://www.apache.org/licenses/LICENSE-2.0.txt
3333
*/
3434

35-
package com.linecorp.centraldogma.internal.jsonpatch;
35+
package com.linecorp.centraldogma.common.jsonpatch;
36+
37+
import static java.util.Objects.requireNonNull;
3638

3739
import java.io.IOException;
40+
import java.util.Objects;
3841

3942
import com.fasterxml.jackson.core.JsonGenerator;
4043
import com.fasterxml.jackson.core.JsonPointer;
@@ -49,7 +52,7 @@
4952
abstract class DualPathOperation extends JsonPatchOperation {
5053

5154
@JsonSerialize(using = ToStringSerializer.class)
52-
final JsonPointer from;
55+
private final JsonPointer from;
5356

5457
/**
5558
* Creates a new instance.
@@ -60,15 +63,23 @@ abstract class DualPathOperation extends JsonPatchOperation {
6063
*/
6164
DualPathOperation(final String op, final JsonPointer from, final JsonPointer path) {
6265
super(op, path);
63-
this.from = from;
66+
this.from = requireNonNull(from, "from");
67+
}
68+
69+
/**
70+
* Returns the source path.
71+
*/
72+
public JsonPointer from() {
73+
return from;
6474
}
6575

6676
@Override
6777
public final void serialize(final JsonGenerator jgen,
6878
final SerializerProvider provider) throws IOException {
79+
requireNonNull(jgen, "jgen");
6980
jgen.writeStartObject();
70-
jgen.writeStringField("op", op);
71-
jgen.writeStringField("path", path.toString());
81+
jgen.writeStringField("op", op());
82+
jgen.writeStringField("path", path().toString());
7283
jgen.writeStringField("from", from.toString());
7384
jgen.writeEndObject();
7485
}
@@ -80,8 +91,25 @@ public final void serializeWithType(final JsonGenerator jgen,
8091
serialize(jgen, provider);
8192
}
8293

94+
@Override
95+
public boolean equals(Object o) {
96+
if (!(o instanceof DualPathOperation)) {
97+
return false;
98+
}
99+
if (!super.equals(o)) {
100+
return false;
101+
}
102+
final DualPathOperation that = (DualPathOperation) o;
103+
return from.equals(that.from);
104+
}
105+
106+
@Override
107+
public int hashCode() {
108+
return Objects.hash(super.hashCode(), from);
109+
}
110+
83111
@Override
84112
public final String toString() {
85-
return "op: " + op + "; from: \"" + from + "\"; path: \"" + path + '"';
113+
return "op: " + op() + "; from: \"" + from + "\"; path: \"" + path() + '"';
86114
}
87115
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2025 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.centraldogma.common.jsonpatch;
18+
19+
import com.linecorp.centraldogma.common.CentralDogmaException;
20+
import com.linecorp.centraldogma.common.ChangeConflictException;
21+
22+
/**
23+
* A {@link CentralDogmaException} raised when a JSON Patch operation fails.
24+
*/
25+
public final class JsonPatchConflictException extends ChangeConflictException {
26+
27+
private static final long serialVersionUID = 4746173383862473527L;
28+
29+
/**
30+
* Creates a new instance.
31+
*/
32+
public JsonPatchConflictException(String message) {
33+
super(message);
34+
}
35+
36+
/**
37+
* Creates a new instance with the specified {@code cause}.
38+
*/
39+
public JsonPatchConflictException(String message, Throwable cause) {
40+
super(message, cause);
41+
}
42+
}

0 commit comments

Comments
 (0)