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.server.internal.admin.service;
18  
19  import static com.linecorp.centraldogma.server.storage.repository.FindOptions.FIND_ALL_WITH_CONTENT;
20  import static java.util.stream.Collectors.toList;
21  
22  import java.io.IOException;
23  import java.util.List;
24  import java.util.Map.Entry;
25  import java.util.concurrent.CompletableFuture;
26  import java.util.concurrent.CompletionStage;
27  
28  import com.fasterxml.jackson.databind.JsonNode;
29  import com.google.common.base.Splitter;
30  import com.google.common.base.Strings;
31  import com.google.common.collect.ImmutableList;
32  import com.google.common.collect.Maps;
33  
34  import com.linecorp.armeria.common.AggregatedHttpRequest;
35  import com.linecorp.armeria.common.HttpResponse;
36  import com.linecorp.armeria.common.HttpStatus;
37  import com.linecorp.armeria.server.ServiceRequestContext;
38  import com.linecorp.armeria.server.annotation.Default;
39  import com.linecorp.armeria.server.annotation.ExceptionHandler;
40  import com.linecorp.armeria.server.annotation.Get;
41  import com.linecorp.armeria.server.annotation.Param;
42  import com.linecorp.armeria.server.annotation.Path;
43  import com.linecorp.armeria.server.annotation.Post;
44  import com.linecorp.armeria.server.annotation.Put;
45  import com.linecorp.centraldogma.common.Author;
46  import com.linecorp.centraldogma.common.Change;
47  import com.linecorp.centraldogma.common.Markup;
48  import com.linecorp.centraldogma.common.Query;
49  import com.linecorp.centraldogma.common.QueryType;
50  import com.linecorp.centraldogma.common.Revision;
51  import com.linecorp.centraldogma.internal.Jackson;
52  import com.linecorp.centraldogma.server.command.Command;
53  import com.linecorp.centraldogma.server.command.CommandExecutor;
54  import com.linecorp.centraldogma.server.internal.admin.auth.AuthUtil;
55  import com.linecorp.centraldogma.server.internal.admin.dto.ChangeDto;
56  import com.linecorp.centraldogma.server.internal.admin.dto.CommitDto;
57  import com.linecorp.centraldogma.server.internal.admin.dto.CommitMessageDto;
58  import com.linecorp.centraldogma.server.internal.admin.dto.EntryDto;
59  import com.linecorp.centraldogma.server.internal.admin.dto.RevisionDto;
60  import com.linecorp.centraldogma.server.internal.api.AbstractService;
61  import com.linecorp.centraldogma.server.internal.api.HttpApiExceptionHandler;
62  import com.linecorp.centraldogma.server.internal.api.auth.RequiresReadPermission;
63  import com.linecorp.centraldogma.server.internal.api.auth.RequiresWritePermission;
64  import com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager;
65  import com.linecorp.centraldogma.server.storage.repository.Repository;
66  
67  /**
68   * Annotated service object for managing repositories.
69   */
70  @RequiresReadPermission
71  @ExceptionHandler(HttpApiExceptionHandler.class)
72  public class RepositoryService extends AbstractService {
73  
74      private static final Object VOID = new Object();
75  
76      private static final Splitter termSplitter = Splitter.on(',').trimResults().omitEmptyStrings();
77  
78      private final ProjectApiManager projectApiManager;
79  
80      public RepositoryService(ProjectApiManager projectApiManager, CommandExecutor executor) {
81          super(executor);
82          this.projectApiManager = projectApiManager;
83      }
84  
85      /**
86       * GET /projects/{projectName}/repositories/{repoName}/revision/{revision}
87       * Normalizes the revision into an absolute revision.
88       */
89      @Get("/projects/{projectName}/repositories/{repoName}/revision/{revision}")
90      public RevisionDto normalizeRevision(@Param String projectName,
91                                           @Param String repoName,
92                                           @Param String revision) {
93          return DtoConverter.convert(projectApiManager.getProject(projectName).repos().get(repoName)
94                                                       .normalizeNow(new Revision(revision)));
95      }
96  
97      /**
98       * GET /projects/{projectName}/repositories/{repoName}/files/revisions/{revision}{path}
99       * Returns the blob in the path.
100      */
101     @Get("regex:/projects/(?<projectName>[^/]+)/repositories/(?<repoName>[^/]+)" +
102          "/files/revisions/(?<revision>[^/]+)(?<path>/.*$)")
103     public CompletionStage<EntryDto> getFile(@Param String projectName,
104                                              @Param String repoName,
105                                              @Param String revision,
106                                              @Param String path,
107                                              @Param @Default("IDENTITY") QueryType queryType,
108                                              @Param @Default("") String expression) {
109 
110         final Query<?> query = Query.of(queryType,path, expression);
111         final Repository repo = projectApiManager.getProject(projectName).repos().get(repoName);
112         return repo.get(repo.normalizeNow(new Revision(revision)), query)
113                    .thenApply(DtoConverter::convert);
114     }
115 
116     /**
117      * POST|PUT /projects/{projectName}/repositories/{repoName}/files/revisions/{revision}
118      * Adds a new file or edits the existing file.
119      */
120     @Post
121     @Put
122     @Path("/projects/{projectName}/repositories/{repoName}/files/revisions/{revision}")
123     @RequiresWritePermission
124     public CompletionStage<Object> addOrEditFile(@Param String projectName,
125                                                  @Param String repoName,
126                                                  @Param String revision,
127                                                  AggregatedHttpRequest request,
128                                                  ServiceRequestContext ctx) {
129         final Entry<CommitMessageDto, Change<?>> p = commitMessageAndChange(request);
130         final CommitMessageDto commitMessage = p.getKey();
131         final Change<?> change = p.getValue();
132         return push(projectName, repoName, new Revision(revision), AuthUtil.currentAuthor(ctx),
133                     commitMessage.getSummary(), commitMessage.getDetail().getContent(),
134                     Markup.valueOf(commitMessage.getDetail().getMarkup()), change)
135                 // This is so weird but there is no way to find a converter for 'null' with the current
136                 // Armeria's converter implementation. We will figure out a better way to improve it.
137                 .thenApply(unused -> VOID);
138     }
139 
140     /**
141      * POST /projects/{projectName}/repositories/{repoName}/delete/revisions/{revision}{path}
142      * Deletes a file.
143      */
144     @Post("regex:/projects/(?<projectName>[^/]+)/repositories/(?<repoName>[^/]+)" +
145           "/delete/revisions/(?<revision>[^/]+)(?<path>/.*$)")
146     @RequiresWritePermission
147     public HttpResponse deleteFile(@Param String projectName,
148                                    @Param String repoName,
149                                    @Param String revision,
150                                    @Param String path,
151                                    AggregatedHttpRequest request,
152                                    ServiceRequestContext ctx) {
153         final CommitMessageDto commitMessage;
154         try {
155             final JsonNode node = Jackson.readTree(request.contentUtf8());
156             commitMessage = Jackson.convertValue(node.get("commitMessage"), CommitMessageDto.class);
157         } catch (IOException e) {
158             throw new IllegalArgumentException("invalid data to be parsed", e);
159         }
160 
161         final CompletableFuture<?> future =
162                 push(projectName, repoName, new Revision(revision), AuthUtil.currentAuthor(ctx),
163                      commitMessage.getSummary(), commitMessage.getDetail().getContent(),
164                      Markup.valueOf(commitMessage.getDetail().getMarkup()), Change.ofRemoval(path));
165 
166         return HttpResponse.from(future.thenApply(unused -> HttpResponse.of(HttpStatus.OK)));
167     }
168 
169     /**
170      * GET /projects/{projectName}/repositories/{repoName}/history{path}?from=x.x&amp;to=x.x
171      * Returns a history between the specified revisions.
172      */
173     @Get("regex:/projects/(?<projectName>[^/]+)/repositories/(?<repoName>[^/]+)" +
174          "/history(?<path>/.*$)")
175     public CompletionStage<List<CommitDto>> getHistory(@Param String projectName,
176                                                        @Param String repoName,
177                                                        @Param String path,
178                                                        @Param @Default("-1") String from,
179                                                        @Param @Default("1") String to) {
180         return projectApiManager.getProject(projectName).repos().get(repoName)
181                                 .history(new Revision(from),
182                                          new Revision(to),
183                                          path + "**")
184                                 .thenApply(commits -> commits.stream()
185                                                              .map(DtoConverter::convert)
186                                                              .collect(toList()));
187     }
188 
189     /**
190      * GET /projects/{projectName}/repositories/{repoName}/search/revisions/{revision}?term={term}
191      * Finds the files matched by {@code term}.
192      */
193     @Get("/projects/{projectName}/repositories/{repoName}/search/revisions/{revision}")
194     public CompletionStage<List<EntryDto>> search(@Param String projectName,
195                                                   @Param String repoName,
196                                                   @Param String revision,
197                                                   @Param String term) {
198         return projectApiManager.getProject(projectName).repos().get(repoName)
199                                 .find(new Revision(revision), normalizeSearchTerm(term), FIND_ALL_WITH_CONTENT)
200                                 .thenApply(entries -> entries.values().stream()
201                                                              .map(DtoConverter::convert)
202                                                              .collect(toList()));
203     }
204 
205     /**
206      * GET /projects/{projectName}/repositories/{repoName}/diff{path}?from={from}&amp;to={to}
207      * Returns a diff of the specified path between the specified revisions.
208      */
209     @Get("regex:/projects/(?<projectName>[^/]+)/repositories/(?<repoName>[^/]+)" +
210          "/diff(?<path>/.*$)")
211     public CompletionStage<List<ChangeDto>> getDiff(@Param String projectName,
212                                                     @Param String repoName,
213                                                     @Param String path,
214                                                     @Param String from,
215                                                     @Param String to) {
216         return projectApiManager.getProject(projectName).repos().get(repoName)
217                                 .diff(new Revision(from), new Revision(to), path)
218                                 .thenApply(changeMap -> changeMap.values().stream()
219                                                                  .map(DtoConverter::convert)
220                                                                  .collect(toList()));
221     }
222 
223     private CompletableFuture<?> push(String projectName, String repoName,
224                                       Revision revision, Author author,
225                                       String commitSummary, String commitDetail, Markup commitMarkup,
226                                       Change<?> change) {
227         final Repository repo = projectApiManager.getProject(projectName).repos().get(repoName);
228         return push0(projectName, repoName, repo.normalizeNow(revision), author,
229                      commitSummary, commitDetail, commitMarkup, change);
230     }
231 
232     private CompletableFuture<?> push0(String projectName, String repoName,
233                                        Revision normalizedRev, Author author,
234                                        String commitSummary, String commitDetail, Markup commitMarkup,
235                                        Change<?> change) {
236         return execute(Command.push(author, projectName, repoName, normalizedRev,
237                                     commitSummary, commitDetail, commitMarkup, ImmutableList.of(change)));
238     }
239 
240     private static Entry<CommitMessageDto, Change<?>> commitMessageAndChange(AggregatedHttpRequest request) {
241         try {
242             final JsonNode node = Jackson.readTree(request.contentUtf8());
243             final CommitMessageDto commitMessage =
244                     Jackson.convertValue(node.get("commitMessage"), CommitMessageDto.class);
245             final EntryDto file = Jackson.convertValue(node.get("file"), EntryDto.class);
246             final Change<?> change;
247             switch (file.getType()) {
248                 case "JSON":
249                     change = Change.ofJsonUpsert(file.getPath(), file.getContent());
250                     break;
251                 case "TEXT":
252                     change = Change.ofTextUpsert(file.getPath(), file.getContent());
253                     break;
254                 default:
255                     throw new IllegalArgumentException("unsupported file type: " + file.getType());
256             }
257 
258             return Maps.immutableEntry(commitMessage, change);
259         } catch (IOException e) {
260             throw new IllegalArgumentException("invalid data to be parsed", e);
261         }
262     }
263 
264     private static String normalizeSearchTerm(final String term) {
265         if (Strings.isNullOrEmpty(term)) {
266             throw new IllegalArgumentException("term should not be empty");
267         }
268 
269         final StringBuilder sb = new StringBuilder();
270 
271         for (final String term0 : termSplitter.split(term)) {
272             if (sb.length() > 0) {
273                 sb.append(',');
274             }
275 
276             if (term0.matches(".*[/*]+.*")) {
277                 sb.append(term0);
278             } else {
279                 sb.append('*').append(term0).append('*');
280             }
281         }
282         return sb.toString();
283     }
284 }