-
Notifications
You must be signed in to change notification settings - Fork 118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add JSON5 support #655
base: main
Are you sure you want to change the base?
Add JSON5 support #655
Changes from 22 commits
0a070ab
96900f3
5ba66fe
e384b9d
b780570
ade904f
3ef8a6a
7ed6751
e6d76a7
6a5218f
ee64818
cc71c25
2463422
cd45a77
2384e54
e0f6fbb
a3c3e64
d7f23f6
a8d6adb
bcaef45
dc4e076
cb9ae45
849bdb2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,55 +15,140 @@ | |
*/ | ||
package com.linecorp.centraldogma.client.armeria; | ||
|
||
import static java.util.concurrent.Executors.newSingleThreadScheduledExecutor; | ||
import static org.assertj.core.api.Assertions.assertThat; | ||
import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
|
||
import java.net.UnknownHostException; | ||
import java.util.concurrent.CompletionException; | ||
import static org.mockito.Mockito.when; | ||
|
||
import org.junit.jupiter.api.BeforeEach; | ||
import org.junit.jupiter.api.Test; | ||
import org.junit.jupiter.api.extension.RegisterExtension; | ||
import org.mockito.ArgumentMatchers; | ||
import org.mockito.Mock; | ||
|
||
import com.fasterxml.jackson.annotation.JsonProperty; | ||
import com.fasterxml.jackson.core.JsonParseException; | ||
import com.fasterxml.jackson.databind.JsonNode; | ||
|
||
import com.linecorp.armeria.client.WebClient; | ||
import com.linecorp.armeria.common.HttpResponse; | ||
import com.linecorp.armeria.common.RequestHeaders; | ||
import com.linecorp.centraldogma.client.CentralDogma; | ||
import com.linecorp.centraldogma.common.Change; | ||
import com.linecorp.centraldogma.common.InvalidPushException; | ||
import com.linecorp.centraldogma.common.PushResult; | ||
import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; | ||
import com.linecorp.centraldogma.common.Entry; | ||
import com.linecorp.centraldogma.common.Query; | ||
import com.linecorp.centraldogma.common.Revision; | ||
import com.linecorp.centraldogma.internal.Jackson; | ||
import com.linecorp.centraldogma.internal.Json5; | ||
|
||
class ArmeriaCentralDogmaTest { | ||
|
||
@RegisterExtension | ||
static CentralDogmaExtension dogma = new CentralDogmaExtension() { | ||
@Override | ||
protected void scaffold(CentralDogma client) { | ||
client.createProject("foo").join(); | ||
} | ||
}; | ||
private static final String JSON5_STRING = | ||
"{\n" + | ||
" // comments\n" + | ||
" unquoted: 'and you can quote me on that',\n" + | ||
" singleQuotes: 'I can use \"double quotes\" here',\n" + | ||
" leadingDecimalPoint: .8675309," + | ||
" trailingComma: 'in objects', andIn: ['arrays',]," + | ||
" \"backwardsCompatible\": \"with JSON\",\n" + | ||
"}\n"; | ||
|
||
@Mock | ||
private WebClient webClient; | ||
|
||
private CentralDogma client; | ||
|
||
static <T> Entry<T> getFile(CentralDogma client, Query<T> query) { | ||
return client.getFile("foo", "bar", Revision.INIT, query).join(); | ||
} | ||
|
||
static <T> Entry<T> watchFile(CentralDogma client, Query<T> query) { | ||
return client.watchFile("foo", "bar", Revision.INIT, query).join(); | ||
} | ||
|
||
static void validateJson5Entry(Entry<?> entry) throws JsonParseException { | ||
assertThat(entry.path()).isEqualTo("/foo.json5"); | ||
assertThat(entry.content()).isEqualTo(Json5.readTree(JSON5_STRING)); | ||
assertThat(entry.contentAsText()).isEqualTo(JSON5_STRING); | ||
} | ||
|
||
@BeforeEach | ||
void setUp() { | ||
client = new ArmeriaCentralDogma(newSingleThreadScheduledExecutor(), webClient, "access_token"); | ||
} | ||
|
||
@Test | ||
void testGetJson5File() throws Exception { | ||
when(webClient.execute(ArgumentMatchers.<RequestHeaders>any())).thenReturn( | ||
HttpResponse.ofJson(new MockEntryDto("/foo.json5", "JSON", JSON5_STRING))); | ||
|
||
final Entry<?> entry = getFile(client, Query.ofJson("/foo.json5")); | ||
validateJson5Entry(entry); | ||
} | ||
|
||
@Test | ||
void pushFileToMetaRepositoryShouldFail() throws UnknownHostException { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved to it/MetadataPushTest.java. |
||
final CentralDogma client = new ArmeriaCentralDogmaBuilder() | ||
.host(dogma.serverAddress().getHostString(), dogma.serverAddress().getPort()) | ||
.build(); | ||
|
||
assertThatThrownBy(() -> client.forRepo("foo", "meta") | ||
.commit("summary", Change.ofJsonUpsert("/bar.json", "{ \"a\": \"b\" }")) | ||
.push() | ||
.join()) | ||
.isInstanceOf(CompletionException.class) | ||
.hasCauseInstanceOf(InvalidPushException.class); | ||
void testGetJson5FileWithJsonPath() throws Exception { | ||
final JsonNode node = Jackson.readTree("{\"a\": \"bar\"}"); | ||
when(webClient.execute(ArgumentMatchers.<RequestHeaders>any())).thenReturn( | ||
HttpResponse.ofJson(new MockEntryDto("/foo.json5", "JSON", node))); | ||
|
||
final Entry<?> entry = getFile(client, Query.ofJsonPath("/foo.json5", "$.a")); | ||
assertThat(entry.content()).isEqualTo(node); | ||
} | ||
|
||
@Test | ||
void pushMirrorsJsonFileToMetaRepository() throws UnknownHostException { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved to |
||
final CentralDogma client = new ArmeriaCentralDogmaBuilder() | ||
.host(dogma.serverAddress().getHostString(), dogma.serverAddress().getPort()) | ||
.build(); | ||
|
||
final PushResult result = client.forRepo("foo", "meta") | ||
.commit("summary", Change.ofJsonUpsert("/mirrors.json", "[]")) | ||
.push() | ||
.join(); | ||
assertThat(result.revision().major()).isPositive(); | ||
void testWatchJson5File() throws Exception { | ||
final MockEntryDto entryDto = new MockEntryDto("/foo.json5", "JSON", JSON5_STRING); | ||
when(webClient.execute(ArgumentMatchers.<RequestHeaders>any())).thenReturn( | ||
HttpResponse.ofJson(new MockWatchResultDto(1, entryDto))); | ||
|
||
final Entry<?> entry = watchFile(client, Query.ofJson("/foo.json5")); | ||
validateJson5Entry(entry); | ||
} | ||
|
||
static class MockEntryDto { | ||
|
||
private final String path; | ||
private final String type; | ||
private final Object content; | ||
|
||
MockEntryDto(String path, String type, Object content) { | ||
this.path = path; | ||
this.type = type; | ||
this.content = content; | ||
} | ||
|
||
@JsonProperty("path") | ||
String path() { | ||
return path; | ||
} | ||
|
||
@JsonProperty("type") | ||
String type() { | ||
return type; | ||
} | ||
|
||
@JsonProperty("content") | ||
Object content() { | ||
return content; | ||
} | ||
} | ||
|
||
static class MockWatchResultDto { | ||
|
||
private final int revision; | ||
private final MockEntryDto entry; | ||
|
||
MockWatchResultDto(int revision, MockEntryDto entry) { | ||
this.revision = revision; | ||
this.entry = entry; | ||
} | ||
|
||
@JsonProperty("revision") | ||
int revision() { | ||
return revision; | ||
} | ||
|
||
@JsonProperty("entry") | ||
MockEntryDto entry() { | ||
return entry; | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,6 +16,7 @@ | |
|
||
package com.linecorp.centraldogma.common; | ||
|
||
import static com.linecorp.centraldogma.internal.Util.isJson5; | ||
import static com.linecorp.centraldogma.internal.Util.validateDirPath; | ||
import static com.linecorp.centraldogma.internal.Util.validateFilePath; | ||
import static java.util.Objects.requireNonNull; | ||
|
@@ -34,10 +35,12 @@ | |
import javax.annotation.Nullable; | ||
|
||
import com.fasterxml.jackson.annotation.JsonProperty; | ||
import com.fasterxml.jackson.core.JsonParseException; | ||
import com.fasterxml.jackson.databind.JsonNode; | ||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize; | ||
|
||
import com.linecorp.centraldogma.internal.Jackson; | ||
import com.linecorp.centraldogma.internal.Json5; | ||
import com.linecorp.centraldogma.internal.Util; | ||
import com.linecorp.centraldogma.internal.jsonpatch.JsonPatch; | ||
import com.linecorp.centraldogma.internal.jsonpatch.ReplaceMode; | ||
|
@@ -51,6 +54,22 @@ | |
@JsonDeserialize(as = DefaultChange.class) | ||
public interface Change<T> { | ||
|
||
/** | ||
* Returns a newly-created {@link Change} whose type is {@link ChangeType#UPSERT_TEXT}. | ||
* | ||
* <p>Note that you should use {@link #ofJsonUpsert(String, String)} if the specified {@code path} ends with | ||
* {@code ".json"}. The {@link #ofJsonUpsert(String, String)} will check that the given {@code text} is a | ||
* valid JSON. | ||
* | ||
* @param path the path of the file | ||
* @param content UTF-8 encoded text file content | ||
* @throws ChangeFormatException if the path ends with {@code ".json"} | ||
*/ | ||
static Change<String> ofTextUpsert(String path, byte[] content) { | ||
requireNonNull(content, "content"); | ||
return ofTextUpsert(path, new String(content, StandardCharsets.UTF_8)); | ||
} | ||
|
||
/** | ||
* Returns a newly-created {@link Change} whose type is {@link ChangeType#UPSERT_TEXT}. | ||
* | ||
|
@@ -65,13 +84,26 @@ public interface Change<T> { | |
static Change<String> ofTextUpsert(String path, String text) { | ||
requireNonNull(text, "text"); | ||
validateFilePath(path, "path"); | ||
if (EntryType.guessFromPath(path) == EntryType.JSON) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems not to make sense to use static Change<JsonNode> ofJsonUpsert(String path, String jsonText) {
final JsonNode jsonNode = ...;
return new DefaultChange<>(path, ChangeType.UPSERT_JSON, jsonNode, jsonText);
} I think we can give There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just wanted to point out we may need to use ArmeriaCentralDogma.java
|
||
if (EntryType.guessFromPath(path) == EntryType.JSON && !isJson5(path)) { | ||
throw new ChangeFormatException("invalid file type: " + path + | ||
" (expected: a non-JSON file). Use Change.ofJsonUpsert() instead"); | ||
} | ||
return new DefaultChange<>(path, ChangeType.UPSERT_TEXT, text); | ||
} | ||
|
||
/** | ||
* Returns a newly-created {@link Change} whose type is {@link ChangeType#UPSERT_JSON}. | ||
* | ||
* @param path the path of the file | ||
* @param jsonContent the content of the file | ||
* | ||
* @throws ChangeFormatException if the specified {@code jsonText} is not a valid JSON | ||
*/ | ||
static Change<JsonNode> ofJsonUpsert(String path, byte[] jsonContent) { | ||
requireNonNull(jsonContent, "jsonContent"); | ||
return ofJsonUpsert(path, new String(jsonContent, StandardCharsets.UTF_8)); | ||
} | ||
|
||
/** | ||
* Returns a newly-created {@link Change} whose type is {@link ChangeType#UPSERT_JSON}. | ||
* | ||
|
@@ -85,12 +117,15 @@ static Change<JsonNode> ofJsonUpsert(String path, String jsonText) { | |
|
||
final JsonNode jsonNode; | ||
try { | ||
if (isJson5(path)) { | ||
jsonNode = Json5.readTree(jsonText); | ||
return new DefaultChange<>(path, ChangeType.UPSERT_JSON, jsonNode, jsonText); | ||
} | ||
jsonNode = Jackson.readTree(jsonText); | ||
} catch (IOException e) { | ||
return new DefaultChange<>(path, ChangeType.UPSERT_JSON, jsonNode); | ||
Comment on lines
118
to
+125
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Could move |
||
} catch (JsonParseException e) { | ||
minwoox marked this conversation as resolved.
Show resolved
Hide resolved
|
||
throw new ChangeFormatException("failed to read a value as a JSON tree", e); | ||
} | ||
|
||
return new DefaultChange<>(path, ChangeType.UPSERT_JSON, jsonNode); | ||
} | ||
|
||
/** | ||
|
@@ -140,10 +175,6 @@ static Change<String> ofRename(String oldPath, String newPath) { | |
static Change<String> ofTextPatch(String path, @Nullable String oldText, String newText) { | ||
validateFilePath(path, "path"); | ||
requireNonNull(newText, "newText"); | ||
if (EntryType.guessFromPath(path) == EntryType.JSON) { | ||
throw new ChangeFormatException("invalid file type: " + path + | ||
" (expected: a non-JSON file). Use Change.ofJsonPatch() instead"); | ||
} | ||
|
||
final List<String> oldLineList = oldText == null ? Collections.emptyList() | ||
: Util.stringToLines(oldText); | ||
|
@@ -152,7 +183,7 @@ static Change<String> ofTextPatch(String path, @Nullable String oldText, String | |
final Patch<String> patch = DiffUtils.diff(oldLineList, newLineList); | ||
final List<String> unifiedDiff = DiffUtils.generateUnifiedDiff(path, path, oldLineList, patch, 3); | ||
|
||
return new DefaultChange<>(path, ChangeType.APPLY_TEXT_PATCH, String.join("\n", unifiedDiff)); | ||
return ofTextPatch(path, String.join("\n", unifiedDiff)); | ||
} | ||
|
||
/** | ||
|
@@ -170,7 +201,7 @@ static Change<String> ofTextPatch(String path, @Nullable String oldText, String | |
static Change<String> ofTextPatch(String path, String textPatch) { | ||
validateFilePath(path, "path"); | ||
requireNonNull(textPatch, "textPatch"); | ||
if (EntryType.guessFromPath(path) == EntryType.JSON) { | ||
if (EntryType.guessFromPath(path) == EntryType.JSON && !isJson5(path)) { | ||
throw new ChangeFormatException("invalid file type: " + path + | ||
" (expected: a non-JSON file). Use Change.ofJsonPatch() instead"); | ||
} | ||
|
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -96,6 +96,10 @@ private static DefaultChange<?> rejectIncompatibleContent(@Nullable JsonNode con | |||||||||
private String contentAsText; | ||||||||||
|
||||||||||
DefaultChange(String path, ChangeType type, @Nullable T content) { | ||||||||||
this(path, type, content, null); | ||||||||||
} | ||||||||||
|
||||||||||
DefaultChange(String path, ChangeType type, @Nullable T content, @Nullable String contentAsText) { | ||||||||||
this.type = requireNonNull(type, "type"); | ||||||||||
|
||||||||||
if (type.contentType() == JsonNode.class) { | ||||||||||
|
@@ -106,6 +110,7 @@ private static DefaultChange<?> rejectIncompatibleContent(@Nullable JsonNode con | |||||||||
|
||||||||||
this.path = path; | ||||||||||
this.content = content; | ||||||||||
this.contentAsText = contentAsText; | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if we should also add centraldogma/common/src/main/java/com/linecorp/centraldogma/common/DefaultChange.java Lines 38 to 40 in 3642633
which may be done when replicating logs Line 1034 in 3642633
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm.. thanks for pointing this out. I think we should handle the case that No need to care about I think we should have There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry about the late reply 😅 I wonder if it's difficult to tweak behavior so that serde happens in the same way as we've been doing so far (using
I personally think we don't want Let me know if you feel differently though 😅 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The type of Maybe we can do the tweak that I agree that we should avoid unnecessary complexity, but either way(tweaking serde vs tweak `Change) is not very appealing 😅.. The 3rd(and the most simple) option would be converting There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Had a chat with @minwoox and @jrhee17 about this and I was advised to put denormalized JSON5 I tried this approach only to find there's no easy way to pass the original JSON5 content to replication log even with this. Meanwhile, it turned out that 3rd option that I mentioned above works just fine. |
||||||||||
} | ||||||||||
|
||||||||||
@Override | ||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about adding the
FileType
mentioned toChange
so that we check file type more statically?