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.api;
18  
19  import static com.google.common.base.MoreObjects.firstNonNull;
20  import static com.google.common.collect.ImmutableList.toImmutableList;
21  import static com.linecorp.centraldogma.server.internal.api.HttpApiUtil.checkUnremoveArgument;
22  import static com.linecorp.centraldogma.server.internal.api.HttpApiUtil.returnOrThrow;
23  import static java.util.Objects.requireNonNull;
24  
25  import java.util.List;
26  import java.util.Map;
27  import java.util.concurrent.CompletableFuture;
28  
29  import javax.annotation.Nullable;
30  
31  import com.fasterxml.jackson.databind.JsonNode;
32  import com.google.common.collect.ImmutableList;
33  import com.google.common.collect.ImmutableMap;
34  
35  import com.linecorp.armeria.common.HttpStatus;
36  import com.linecorp.armeria.common.logging.RequestOnlyLog;
37  import com.linecorp.armeria.server.ServiceRequestContext;
38  import com.linecorp.armeria.server.annotation.Consumes;
39  import com.linecorp.armeria.server.annotation.Delete;
40  import com.linecorp.armeria.server.annotation.ExceptionHandler;
41  import com.linecorp.armeria.server.annotation.Get;
42  import com.linecorp.armeria.server.annotation.Param;
43  import com.linecorp.armeria.server.annotation.Patch;
44  import com.linecorp.armeria.server.annotation.Post;
45  import com.linecorp.armeria.server.annotation.ProducesJson;
46  import com.linecorp.armeria.server.annotation.ResponseConverter;
47  import com.linecorp.armeria.server.annotation.StatusCode;
48  import com.linecorp.centraldogma.common.Author;
49  import com.linecorp.centraldogma.common.Revision;
50  import com.linecorp.centraldogma.internal.api.v1.CreateRepositoryRequest;
51  import com.linecorp.centraldogma.internal.api.v1.RepositoryDto;
52  import com.linecorp.centraldogma.server.command.Command;
53  import com.linecorp.centraldogma.server.command.CommandExecutor;
54  import com.linecorp.centraldogma.server.internal.api.auth.RequiresReadPermission;
55  import com.linecorp.centraldogma.server.internal.api.auth.RequiresRole;
56  import com.linecorp.centraldogma.server.internal.api.converter.CreateApiResponseConverter;
57  import com.linecorp.centraldogma.server.metadata.MetadataService;
58  import com.linecorp.centraldogma.server.metadata.ProjectRole;
59  import com.linecorp.centraldogma.server.metadata.User;
60  import com.linecorp.centraldogma.server.storage.project.Project;
61  import com.linecorp.centraldogma.server.storage.repository.Repository;
62  
63  import io.micrometer.core.instrument.Tag;
64  
65  /**
66   * Annotated service object for managing repositories.
67   */
68  @ProducesJson
69  @ExceptionHandler(HttpApiExceptionHandler.class)
70  public class RepositoryServiceV1 extends AbstractService {
71  
72      private final MetadataService mds;
73  
74      public RepositoryServiceV1(CommandExecutor executor, MetadataService mds) {
75          super(executor);
76          this.mds = requireNonNull(mds, "mds");
77      }
78  
79      /**
80       * GET /projects/{projectName}/repos?status={status}
81       *
82       * <p>Returns the list of the repositories or removed repositories.
83       */
84      @Get("/projects/{projectName}/repos")
85      public CompletableFuture<List<RepositoryDto>> listRepositories(ServiceRequestContext ctx, Project project,
86                                                                     @Param @Nullable String status, User user) {
87          if (status != null) {
88              HttpApiUtil.checkStatusArgument(status);
89          }
90  
91          return mds.findRole(project.name(), user).handle((role, throwable) -> {
92              final boolean hasOwnerRole = role == ProjectRole.OWNER;
93              if (status != null) {
94                  if (hasOwnerRole) {
95                      return project.repos().listRemoved().keySet().stream().map(RepositoryDto::new)
96                                    .collect(toImmutableList());
97                  }
98                  return HttpApiUtil.throwResponse(
99                          ctx, HttpStatus.FORBIDDEN,
100                         "You must be an owner of project '%s' to retrieve removed repositories.",
101                         project.name());
102             }
103 
104             return project.repos().list().values().stream()
105                           .filter(r -> user.isAdmin() || !Project.REPO_DOGMA.equals(r.name()))
106                           .filter(r -> hasOwnerRole || !Project.REPO_META.equals(r.name()))
107                           .map(DtoConverter::convert)
108                           .collect(toImmutableList());
109         });
110     }
111 
112     /**
113      * POST /projects/{projectName}/repos
114      *
115      * <p>Creates a new repository.
116      */
117     @Post("/projects/{projectName}/repos")
118     @StatusCode(201)
119     @ResponseConverter(CreateApiResponseConverter.class)
120     @RequiresRole(roles = ProjectRole.OWNER)
121     public CompletableFuture<RepositoryDto> createRepository(ServiceRequestContext ctx, Project project,
122                                                              CreateRepositoryRequest request,
123                                                              Author author) {
124         if (Project.isReservedRepoName(request.name())) {
125             return HttpApiUtil.throwResponse(ctx, HttpStatus.FORBIDDEN,
126                                              "A reserved repository cannot be created.");
127         }
128         return execute(Command.createRepository(author, project.name(), request.name()))
129                 .thenCompose(unused -> mds.addRepo(author, project.name(), request.name()))
130                 .handle(returnOrThrow(() -> DtoConverter.convert(project.repos().get(request.name()))));
131     }
132 
133     /**
134      * DELETE /projects/{projectName}/repos/{repoName}
135      *
136      * <p>Removes a repository.
137      */
138     @Delete("/projects/{projectName}/repos/{repoName}")
139     @RequiresRole(roles = ProjectRole.OWNER)
140     public CompletableFuture<Void> removeRepository(ServiceRequestContext ctx,
141                                                     @Param String repoName,
142                                                     Repository repository,
143                                                     Author author) {
144         if (Project.isReservedRepoName(repoName)) {
145             return HttpApiUtil.throwResponse(ctx, HttpStatus.FORBIDDEN,
146                                              "A reserved repository cannot be removed.");
147         }
148         return execute(Command.removeRepository(author, repository.parent().name(), repository.name()))
149                 .thenCompose(unused -> mds.removeRepo(author, repository.parent().name(), repository.name()))
150                 .handle(HttpApiUtil::throwUnsafelyIfNonNull);
151     }
152 
153     /**
154      * DELETE /projects/{projectName}/repos/{repoName}/removed
155      *
156      * <p>Purges a repository that was removed before.
157      */
158     @Delete("/projects/{projectName}/repos/{repoName}/removed")
159     @RequiresRole(roles = ProjectRole.OWNER)
160     public CompletableFuture<Void> purgeRepository(@Param String repoName,
161                                                    Project project, Author author) {
162         return execute(Command.purgeRepository(author, project.name(), repoName))
163                 .thenCompose(unused -> mds.purgeRepo(author, project.name(), repoName)
164                                           .handle(HttpApiUtil::throwUnsafelyIfNonNull));
165     }
166 
167     /**
168      * PATCH /projects/{projectName}/repos/{repoName}
169      *
170      * <p>Patches a repository with the JSON_PATCH. Currently, only unremove repository operation is supported.
171      */
172     @Consumes("application/json-patch+json")
173     @Patch("/projects/{projectName}/repos/{repoName}")
174     @RequiresRole(roles = ProjectRole.OWNER)
175     public CompletableFuture<RepositoryDto> patchRepository(@Param String repoName,
176                                                             Project project,
177                                                             JsonNode node,
178                                                             Author author) {
179         checkUnremoveArgument(node);
180         return execute(Command.unremoveRepository(author, project.name(), repoName))
181                 .thenCompose(unused -> mds.restoreRepo(author, project.name(), repoName))
182                 .handle(returnOrThrow(() -> DtoConverter.convert(project.repos().get(repoName))));
183     }
184 
185     /**
186      * GET /projects/{projectName}/repos/{repoName}/revision/{revision}
187      *
188      * <p>Normalizes the revision into an absolute revision.
189      */
190     @Get("/projects/{projectName}/repos/{repoName}/revision/{revision}")
191     @RequiresReadPermission
192     public Map<String, Integer> normalizeRevision(ServiceRequestContext ctx,
193                                                   Repository repository, @Param String revision) {
194         final Revision normalizedRevision = repository.normalizeNow(new Revision(revision));
195         final Revision head = repository.normalizeNow(Revision.HEAD);
196         increaseCounterIfOldRevisionUsed(ctx, repository, normalizedRevision, head);
197         return ImmutableMap.of("revision", normalizedRevision.major());
198     }
199 
200     static void increaseCounterIfOldRevisionUsed(ServiceRequestContext ctx, Repository repository,
201                                                  Revision revision) {
202         final Revision normalized = repository.normalizeNow(revision);
203         final Revision head = repository.normalizeNow(Revision.HEAD);
204         increaseCounterIfOldRevisionUsed(ctx, repository, normalized, head);
205     }
206 
207     public static void increaseCounterIfOldRevisionUsed(
208             ServiceRequestContext ctx, Repository repository, Revision normalized, Revision head) {
209         final String projectName = repository.parent().name();
210         final String repoName = repository.name();
211         if (normalized.major() == 1) {
212             ctx.log().whenRequestComplete().thenAccept(
213                     log -> ctx.meterRegistry()
214                               .counter("revisions.init", generateTags(projectName, repoName, log).build())
215                               .increment());
216         }
217         if (head.major() - normalized.major() >= 5000) {
218             ctx.log().whenRequestComplete().thenAccept(
219                     log -> ctx.meterRegistry()
220                               .summary("revisions.old",
221                                        generateTags(projectName, repoName, log)
222                                                .add(Tag.of("init", Boolean.toString(normalized.major() == 1)))
223                                                .build())
224                               .record(head.major() - normalized.major()));
225         }
226     }
227 
228     private static ImmutableList.Builder<Tag> generateTags(
229             String projectName, String repoName, RequestOnlyLog log) {
230         final ImmutableList.Builder<Tag> builder = ImmutableList.builder();
231         return builder.add(Tag.of("project", projectName),
232                            Tag.of("repo", repoName),
233                            Tag.of("service", firstNonNull(log.serviceName(), "none")),
234                            Tag.of("method", log.name()));
235     }
236 }