1   /*
2    * Copyright 2017 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;
18  
19  import static com.google.common.base.Preconditions.checkArgument;
20  import static com.linecorp.centraldogma.internal.Util.validateDirPath;
21  import static com.linecorp.centraldogma.internal.Util.validateFilePath;
22  import static java.util.Objects.requireNonNull;
23  
24  import java.io.File;
25  import java.io.IOError;
26  import java.io.IOException;
27  import java.nio.charset.StandardCharsets;
28  import java.nio.file.Files;
29  import java.nio.file.Path;
30  import java.util.Collections;
31  import java.util.List;
32  import java.util.stream.Collectors;
33  import java.util.stream.Stream;
34  
35  import javax.annotation.Nullable;
36  
37  import com.fasterxml.jackson.annotation.JsonProperty;
38  import com.fasterxml.jackson.databind.JsonNode;
39  import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
40  import com.google.common.collect.ImmutableList;
41  import com.google.common.collect.Iterables;
42  
43  import com.linecorp.centraldogma.common.jsonpatch.JsonPatchOperation;
44  import com.linecorp.centraldogma.internal.Jackson;
45  import com.linecorp.centraldogma.internal.Util;
46  import com.linecorp.centraldogma.internal.jsonpatch.JsonPatch;
47  import com.linecorp.centraldogma.internal.jsonpatch.ReplaceMode;
48  
49  import difflib.DiffUtils;
50  import difflib.Patch;
51  
52  /**
53   * A modification of an individual {@link Entry}.
54   */
55  @JsonDeserialize(as = DefaultChange.class)
56  public interface Change<T> {
57  
58      /**
59       * Returns a newly-created {@link Change} whose type is {@link ChangeType#UPSERT_TEXT}.
60       *
61       * <p>Note that you should use {@link #ofJsonUpsert(String, String)} if the specified {@code path} ends with
62       * {@code ".json"}. The {@link #ofJsonUpsert(String, String)} will check that the given {@code text} is a
63       * valid JSON.
64       *
65       * @param path the path of the file
66       * @param text the content of the file
67       * @throws ChangeFormatException if the path ends with {@code ".json"}
68       */
69      static Change<String> ofTextUpsert(String path, String text) {
70          requireNonNull(text, "text");
71          validateFilePath(path, "path");
72          if (EntryType.guessFromPath(path) == EntryType.JSON) {
73              throw new ChangeFormatException("invalid file type: " + path +
74                                              " (expected: a non-JSON file). Use Change.ofJsonUpsert() instead");
75          }
76          return new DefaultChange<>(path, ChangeType.UPSERT_TEXT, text);
77      }
78  
79      /**
80       * Returns a newly-created {@link Change} whose type is {@link ChangeType#UPSERT_JSON}.
81       *
82       * @param path the path of the file
83       * @param jsonText the content of the file
84       *
85       * @throws ChangeFormatException if the specified {@code jsonText} is not a valid JSON
86       */
87      static Change<JsonNode> ofJsonUpsert(String path, String jsonText) {
88          requireNonNull(jsonText, "jsonText");
89  
90          final JsonNode jsonNode;
91          try {
92              jsonNode = Jackson.readTree(jsonText);
93          } catch (IOException e) {
94              throw new ChangeFormatException("failed to read a value as a JSON tree", e);
95          }
96  
97          return new DefaultChange<>(path, ChangeType.UPSERT_JSON, jsonNode);
98      }
99  
100     /**
101      * Returns a newly-created {@link Change} whose type is {@link ChangeType#UPSERT_JSON}.
102      *
103      * @param path the path of the file
104      * @param jsonNode the content of the file
105      */
106     static Change<JsonNode> ofJsonUpsert(String path, JsonNode jsonNode) {
107         requireNonNull(jsonNode, "jsonNode");
108         return new DefaultChange<>(path, ChangeType.UPSERT_JSON, jsonNode);
109     }
110 
111     /**
112      * Returns a newly-created {@link Change} whose type is {@link ChangeType#REMOVE}.
113      *
114      * @param path the path of the file to remove
115      */
116     static Change<Void> ofRemoval(String path) {
117         return new DefaultChange<>(path, ChangeType.REMOVE, null);
118     }
119 
120     /**
121      * Returns a newly-created {@link Change} whose type is {@link ChangeType#RENAME}.
122      *
123      * @param oldPath the old path of the file
124      * @param newPath the new path of the file
125      */
126     static Change<String> ofRename(String oldPath, String newPath) {
127         validateFilePath(oldPath, "oldPath");
128         validateFilePath(newPath, "newPath");
129         return new DefaultChange<>(oldPath, ChangeType.RENAME, newPath);
130     }
131 
132     /**
133      * Returns a newly-created {@link Change} whose type is {@link ChangeType#APPLY_TEXT_PATCH}.
134      *
135      * <p>Note that you should use {@link #ofJsonPatch(String, String, String)} if the specified {@code path}
136      * ends with {@code ".json"}. The {@link #ofJsonUpsert(String, String)} will check that
137      * the given {@code oldText} and {@code newText} are valid JSONs.
138      *
139      * @param path the path of the file
140      * @param oldText the old content of the file
141      * @param newText the new content of the file
142      * @throws ChangeFormatException if the path ends with {@code ".json"}
143      */
144     static Change<String> ofTextPatch(String path, @Nullable String oldText, String newText) {
145         validateFilePath(path, "path");
146         requireNonNull(newText, "newText");
147         if (EntryType.guessFromPath(path) == EntryType.JSON) {
148             throw new ChangeFormatException("invalid file type: " + path +
149                                             " (expected: a non-JSON file). Use Change.ofJsonPatch() instead");
150         }
151 
152         final List<String> oldLineList = oldText == null ? Collections.emptyList()
153                                                          : Util.stringToLines(oldText);
154         final List<String> newLineList = Util.stringToLines(newText);
155 
156         final Patch<String> patch = DiffUtils.diff(oldLineList, newLineList);
157         final List<String> unifiedDiff = DiffUtils.generateUnifiedDiff(path, path, oldLineList, patch, 3);
158 
159         return new DefaultChange<>(path, ChangeType.APPLY_TEXT_PATCH, String.join("\n", unifiedDiff));
160     }
161 
162     /**
163      * Returns a newly-created {@link Change} whose type is {@link ChangeType#APPLY_TEXT_PATCH}.
164      *
165      * <p>Note that you should use {@link #ofJsonPatch(String, String)} if the specified {@code path}
166      * ends with {@code ".json"}. The {@link #ofJsonUpsert(String, String)} will check that
167      * the given {@code textPatch} is a valid JSON.
168      *
169      * @param path the path of the file
170      * @param textPatch the patch in
171      *                  <a href="https://en.wikipedia.org/wiki/Diff_utility#Unified_format">unified format</a>
172      * @throws ChangeFormatException if the path ends with {@code ".json"}
173      */
174     static Change<String> ofTextPatch(String path, String textPatch) {
175         validateFilePath(path, "path");
176         requireNonNull(textPatch, "textPatch");
177         if (EntryType.guessFromPath(path) == EntryType.JSON) {
178             throw new ChangeFormatException("invalid file type: " + path +
179                                             " (expected: a non-JSON file). Use Change.ofJsonPatch() instead");
180         }
181 
182         return new DefaultChange<>(path, ChangeType.APPLY_TEXT_PATCH, textPatch);
183     }
184 
185     /**
186      * Returns a newly-created {@link Change} whose type is {@link ChangeType#APPLY_JSON_PATCH}.
187      *
188      * @param path the path of the file
189      * @param oldJsonText the old content of the file
190      * @param newJsonText the new content of the file
191      *
192      * @throws ChangeFormatException if the specified {@code oldJsonText} or {@code newJsonText} is
193      *                               not a valid JSON
194      */
195     static Change<JsonNode> ofJsonPatch(String path, @Nullable String oldJsonText, String newJsonText) {
196         requireNonNull(newJsonText, "newJsonText");
197 
198         final JsonNode oldJsonNode;
199         final JsonNode newJsonNode;
200         try {
201             oldJsonNode = oldJsonText == null ? Jackson.nullNode
202                                               : Jackson.readTree(oldJsonText);
203             newJsonNode = Jackson.readTree(newJsonText);
204         } catch (IOException e) {
205             throw new ChangeFormatException("failed to read a value as a JSON tree", e);
206         }
207 
208         return new DefaultChange<>(path, ChangeType.APPLY_JSON_PATCH,
209                                    JsonPatch.generate(oldJsonNode, newJsonNode, ReplaceMode.SAFE).toJson());
210     }
211 
212     /**
213      * Returns a newly-created {@link Change} whose type is {@link ChangeType#APPLY_JSON_PATCH}.
214      *
215      * @param path the path of the file
216      * @param oldJsonNode the old content of the file
217      * @param newJsonNode the new content of the file
218      */
219     static Change<JsonNode> ofJsonPatch(String path, @Nullable JsonNode oldJsonNode, JsonNode newJsonNode) {
220         requireNonNull(newJsonNode, "newJsonNode");
221 
222         if (oldJsonNode == null) {
223             oldJsonNode = Jackson.nullNode;
224         }
225 
226         return new DefaultChange<>(path, ChangeType.APPLY_JSON_PATCH,
227                                    JsonPatch.generate(oldJsonNode, newJsonNode, ReplaceMode.SAFE).toJson());
228     }
229 
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 
267     /**
268      * Returns a newly-created {@link Change} whose type is {@link ChangeType#APPLY_JSON_PATCH}.
269      *
270      * @param path the path of the file
271      * @param jsonPatchText the patch in <a href="https://tools.ietf.org/html/rfc6902">JSON patch format</a>
272      *
273      * @throws ChangeFormatException if the specified {@code jsonPatchText} is not a valid JSON
274      */
275     static Change<JsonNode> ofJsonPatch(String path, String jsonPatchText) {
276         requireNonNull(jsonPatchText, "jsonPatchText");
277 
278         final JsonNode jsonPatchNode;
279         try {
280             jsonPatchNode = Jackson.readTree(jsonPatchText);
281         } catch (IOException e) {
282             throw new ChangeFormatException("failed to read a value as a JSON tree", e);
283         }
284 
285         return ofJsonPatch(path, jsonPatchNode);
286     }
287 
288     /**
289      * Returns a newly-created {@link Change} whose type is {@link ChangeType#APPLY_JSON_PATCH}.
290      *
291      * @param path the path of the file
292      * @param jsonPatchNode the patch in <a href="https://tools.ietf.org/html/rfc6902">JSON patch format</a>
293      */
294     static Change<JsonNode> ofJsonPatch(String path, JsonNode jsonPatchNode) {
295         requireNonNull(jsonPatchNode, "jsonPatchNode");
296 
297         return new DefaultChange<>(path, ChangeType.APPLY_JSON_PATCH, jsonPatchNode);
298     }
299 
300     /**
301      * Creates a {@link List} of upsert {@link Change}s from all files under the specified directory
302      * recursively.
303      *
304      * @param sourcePath the path to the import directory
305      * @param targetPath the target directory path of the imported {@link Change}s
306      *
307      * @throws IOError if I/O error occurs
308      */
309     static List<Change<?>> fromDirectory(Path sourcePath, String targetPath) {
310         requireNonNull(sourcePath, "sourcePath");
311         validateDirPath(targetPath, "targetPath");
312 
313         if (!Files.isDirectory(sourcePath)) {
314             throw new IllegalArgumentException("sourcePath: " + sourcePath + " (must be a directory)");
315         }
316 
317         final String finalTargetPath;
318         if (!targetPath.endsWith("/")) {
319             finalTargetPath = targetPath + '/';
320         } else {
321             finalTargetPath = targetPath;
322         }
323 
324         try (Stream<Path> s = Files.find(sourcePath, Integer.MAX_VALUE, (p, a) -> a.isRegularFile())) {
325             final int baseLength = sourcePath.toString().length() + 1;
326             return s.map(sourceFilePath -> {
327                 final String targetFilePath =
328                         finalTargetPath +
329                         sourceFilePath.toString().substring(baseLength).replace(File.separatorChar, '/');
330 
331                 return fromFile(sourceFilePath, targetFilePath);
332             }).collect(Collectors.toList());
333         } catch (IOException e) {
334             throw new IOError(e);
335         }
336     }
337 
338     /**
339      * Creates a new {@link Change} from the file at the specified location.
340      *
341      * @param sourcePath the path to the regular file to import
342      * @param targetPath the target path of the imported {@link Change}
343      */
344     static Change<?> fromFile(Path sourcePath, String targetPath) {
345         requireNonNull(sourcePath, "sourcePath");
346         validateFilePath(targetPath, "targetPath");
347 
348         if (!Files.isRegularFile(sourcePath)) {
349             throw new IllegalArgumentException("sourcePath: " + sourcePath + " (must be a regular file)");
350         }
351 
352         if (targetPath.endsWith("/")) {
353             throw new IllegalArgumentException("targetPath: " + targetPath + " (must be a regular file path)");
354         }
355 
356         final EntryType entryType = EntryType.guessFromPath(targetPath);
357         final String content;
358         try {
359             content = new String(Files.readAllBytes(sourcePath), StandardCharsets.UTF_8);
360         } catch (IOException e) {
361             throw new IOError(e);
362         }
363 
364         switch (entryType) {
365             case JSON:
366                 return ofJsonUpsert(targetPath, content);
367             case TEXT:
368                 return ofTextUpsert(targetPath, content);
369             default:
370                 throw new Error("unexpected entry type: " + entryType);
371         }
372     }
373 
374     /**
375      * Returns the type of the {@link Change}.
376      */
377     @JsonProperty
378     ChangeType type();
379 
380     /**
381      * Returns the path of the {@link Change}.
382      */
383     @JsonProperty
384     String path();
385 
386     /**
387      * Returns the content of the {@link Change}, which depends on the {@link #type()}.
388      */
389     @Nullable
390     @JsonProperty
391     T content();
392 
393     /**
394      * Returns the textual representation of {@link #content()}.
395      */
396     @Nullable
397     String contentAsText();
398 }