Subversion repository export

Add the repository export function for Subversion repositories. The repository will be exported as dump file which can be downloaded directly or inside a gzip compressed archive.
This commit is contained in:
Eduard Heimbuch
2021-01-08 09:19:33 +01:00
committed by GitHub
parent badcf3ecb4
commit adf7bac665
21 changed files with 451 additions and 49 deletions

View File

@@ -39,6 +39,7 @@ public class RepositoryBasedResourceProvider {
private final Provider<FileHistoryRootResource> fileHistoryRootResource;
private final Provider<IncomingRootResource> incomingRootResource;
private final Provider<AnnotateResource> annotateResource;
private final Provider<RepositoryExportResource> repositoryExportResource;
@Inject
public RepositoryBasedResourceProvider(
@@ -51,7 +52,9 @@ public class RepositoryBasedResourceProvider {
Provider<DiffRootResource> diffRootResource,
Provider<ModificationsRootResource> modificationsRootResource,
Provider<FileHistoryRootResource> fileHistoryRootResource,
Provider<IncomingRootResource> incomingRootResource, Provider<AnnotateResource> annotateResource) {
Provider<IncomingRootResource> incomingRootResource,
Provider<AnnotateResource> annotateResource,
Provider<RepositoryExportResource> repositoryExportResource) {
this.tagRootResource = tagRootResource;
this.branchRootResource = branchRootResource;
this.changesetRootResource = changesetRootResource;
@@ -63,6 +66,7 @@ public class RepositoryBasedResourceProvider {
this.fileHistoryRootResource = fileHistoryRootResource;
this.incomingRootResource = incomingRootResource;
this.annotateResource = annotateResource;
this.repositoryExportResource = repositoryExportResource;
}
public TagRootResource getTagRootResource() {
@@ -108,4 +112,8 @@ public class RepositoryBasedResourceProvider {
public AnnotateResource getAnnotateResource() {
return annotateResource.get();
}
public RepositoryExportResource getRepositoryExportResource() {
return repositoryExportResource.get();
}
}

View File

@@ -0,0 +1,163 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.google.inject.Inject;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.Type;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.api.Command;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.web.VndMediaType;
import javax.ws.rs.Consumes;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.StreamingOutput;
import javax.ws.rs.core.UriInfo;
import java.io.FileOutputStream;
import java.io.IOException;
import java.time.Instant;
import static sonia.scm.api.v2.resources.RepositoryTypeSupportChecker.checkSupport;
import static sonia.scm.api.v2.resources.RepositoryTypeSupportChecker.type;
public class RepositoryExportResource {
private static final Logger logger = LoggerFactory.getLogger(RepositoryExportResource.class);
private final RepositoryManager manager;
private final RepositoryServiceFactory serviceFactory;
@Inject
public RepositoryExportResource(RepositoryManager manager,
RepositoryServiceFactory serviceFactory) {
this.manager = manager;
this.serviceFactory = serviceFactory;
}
/**
* Exports an existing repository without additional metadata. The method can
* only be used, if the repository type supports the {@link Command#BUNDLE}.
*
* @param uriInfo uri info
* @param namespace of the repository
* @param name of the repository
* @param type of the repository
* @return response with readable stream of repository dump
* @since 2.13.0
*/
@GET
@Path("{type}")
@Consumes(VndMediaType.REPOSITORY)
@Operation(summary = "Exports the repository", description = "Exports the repository.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "Repository export was successful"
)
@ApiResponse(
responseCode = "401",
description = "not authenticated / invalid credentials"
)
@ApiResponse(
responseCode = "403",
description = "not authorized, the current user has no privileges to read the repository"
)
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response exportRepository(@Context UriInfo uriInfo,
@PathParam("namespace") String namespace,
@PathParam("name") String name,
@PathParam("type") String type,
@DefaultValue("false") @QueryParam("compressed") boolean compressed
) {
Repository repository = manager.get(new NamespaceAndName(namespace, name));
RepositoryPermissions.read().check(repository);
Type repositoryType = type(manager, type);
checkSupport(repositoryType, Command.BUNDLE);
return exportRepository(repository, compressed);
}
private Response exportRepository(Repository repository, boolean compressed) {
StreamingOutput output = os -> {
try (RepositoryService service = serviceFactory.create(repository)) {
if (compressed) {
GzipCompressorOutputStream gzipCompressorOutputStream = new GzipCompressorOutputStream(os);
service.getBundleCommand().bundle(gzipCompressorOutputStream);
gzipCompressorOutputStream.finish();
} else {
service.getBundleCommand().bundle(os);
}
} catch (IOException e) {
throw new InternalRepositoryException(repository, "repository export failed", e);
}
};
return Response
.ok(output, compressed ? "application/x-gzip" : MediaType.APPLICATION_OCTET_STREAM)
.header("content-disposition", createContentDispositionHeaderValue(repository, compressed))
.build();
}
private String createContentDispositionHeaderValue(Repository repository, boolean compressed) {
String timestamp = createFormattedTimestamp();
return String.format(
"attachment; filename = %s-%s-%s.%s",
repository.getNamespace(),
repository.getName(),
timestamp,
compressed ? "dump.gz" : "dump"
);
}
private String createFormattedTimestamp() {
return Instant.now().toString().replace(":", "-").split("\\.")[0];
}
}

View File

@@ -52,12 +52,10 @@ import sonia.scm.Type;
import sonia.scm.event.ScmEventBus;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryHandler;
import sonia.scm.repository.RepositoryImportEvent;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryPermission;
import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.RepositoryType;
import sonia.scm.repository.api.Command;
import sonia.scm.repository.api.PullCommandBuilder;
import sonia.scm.repository.api.RepositoryService;
@@ -87,13 +85,14 @@ import java.io.InputStream;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Collections.singletonList;
import static sonia.scm.api.v2.resources.RepositoryTypeSupportChecker.checkSupport;
import static sonia.scm.api.v2.resources.RepositoryTypeSupportChecker.type;
public class RepositoryImportResource {
@@ -163,12 +162,11 @@ public class RepositoryImportResource {
@PathParam("type") String type, @Valid RepositoryImportDto request) {
RepositoryPermissions.create().check();
Type t = type(type);
Type t = type(manager, type);
if (!t.getName().equals(request.getType())) {
throw new WebApplicationException("type of import url and repository does not match", Response.Status.BAD_REQUEST);
}
checkSupport(t, Command.PULL, request);
checkSupport(t, Command.PULL);
logger.info("start {} import for external url {}", type, request.getImportUrl());
@@ -272,9 +270,8 @@ public class RepositoryImportResource {
checkNotNull(inputStream, "bundle inputStream is required");
checkArgument(!Strings.isNullOrEmpty(repositoryDto.getName()), "request does not contain name of the repository");
Type t = type(type);
checkSupport(t, Command.UNBUNDLE, "bundle");
Type t = type(manager, type);
checkSupport(t, Command.UNBUNDLE);
Repository repository = mapper.map(repositoryDto);
repository.setPermissions(singletonList(
@@ -340,42 +337,6 @@ public class RepositoryImportResource {
return null;
}
/**
* Check repository type for support for the given command.
*
* @param type repository type
* @param cmd command
* @param request request object
*/
private void checkSupport(Type type, Command cmd, Object request) {
if (!(type instanceof RepositoryType)) {
logger.warn("type {} is not a repository type", type.getName());
throw new WebApplicationException(Response.Status.BAD_REQUEST);
}
Set<Command> cmds = ((RepositoryType) type).getSupportedCommands();
if (!cmds.contains(cmd)) {
logger.warn("type {} does not support this type of import: {}",
type.getName(), request);
throw new WebApplicationException(Response.Status.BAD_REQUEST);
}
}
private Type type(String type) {
RepositoryHandler handler = manager.getHandler(type);
if (handler == null) {
logger.warn("no handler for type {} found", type);
throw new WebApplicationException(Response.Status.NOT_FOUND);
}
return handler.getType();
}
@Getter
@Setter
@NoArgsConstructor

View File

@@ -332,6 +332,11 @@ public class RepositoryResource {
return resourceProvider.getAnnotateResource();
}
@Path("export/")
public RepositoryExportResource export() {
return resourceProvider.getRepositoryExportResource();
}
private Supplier<Repository> loadBy(String namespace, String name) {
NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name);
return () -> Optional.ofNullable(manager.get(namespaceAndName)).orElseThrow(() -> notFound(entity(namespaceAndName)));

View File

@@ -103,6 +103,11 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
.collect(toList());
linksBuilder.array(protocolLinks);
}
if (repositoryService.isSupported(Command.BUNDLE)) {
linksBuilder.single(link("export", resourceLinks.repository().export(repository.getNamespace(), repository.getName(), repository.getType())));
}
if (repositoryService.isSupported(Command.TAGS)) {
linksBuilder.single(link("tags", resourceLinks.tag().all(repository.getNamespace(), repository.getName())));
}

View File

@@ -0,0 +1,75 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.Type;
import sonia.scm.repository.RepositoryHandler;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryType;
import sonia.scm.repository.api.Command;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import java.util.Set;
class RepositoryTypeSupportChecker {
private RepositoryTypeSupportChecker() {
}
private static final Logger logger = LoggerFactory.getLogger(RepositoryTypeSupportChecker.class);
/**
* Check repository type for support for the given command.
*
* @param type repository type
* @param cmd command
*/
static void checkSupport(Type type, Command cmd) {
if (!(type instanceof RepositoryType)) {
logger.warn("type {} is not a repository type", type.getName());
throw new WebApplicationException(Response.Status.BAD_REQUEST);
}
Set<Command> cmds = ((RepositoryType) type).getSupportedCommands();
if (!cmds.contains(cmd)) {
logger.warn("type {} does not support this command {}",
type.getName(),
cmd.name());
throw new WebApplicationException(Response.Status.BAD_REQUEST);
}
}
static Type type(RepositoryManager manager, String type) {
RepositoryHandler handler = manager.getHandler(type);
if (handler == null) {
logger.warn("no handler for type {} found", type);
throw new WebApplicationException(Response.Status.NOT_FOUND);
}
return handler.getType();
}
}

View File

@@ -341,10 +341,12 @@ class ResourceLinks {
static class RepositoryLinks {
private final LinkBuilder repositoryLinkBuilder;
private final LinkBuilder repositoryImportLinkBuilder;
private final LinkBuilder repositoryExportLinkBuilder;
RepositoryLinks(ScmPathInfo pathInfo) {
repositoryLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class);
repositoryImportLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryImportResource.class);
repositoryExportLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, RepositoryExportResource.class);
}
String self(String namespace, String name) {
@@ -377,6 +379,10 @@ class ResourceLinks {
String unarchive(String namespace, String name) {
return repositoryLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("unarchive").parameters().href();
}
String export(String namespace, String name, String type) {
return repositoryExportLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("export").parameters().method("exportRepository").parameters(type).href();
}
}
RepositoryCollectionLinks repositoryCollection() {