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