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