diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchCollectionToDtoMapper.java index 51ce9e6f5a..b394c4215f 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchCollectionToDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchCollectionToDtoMapper.java @@ -3,9 +3,12 @@ package sonia.scm.api.v2.resources; import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Link; import de.otto.edison.hal.Links; import sonia.scm.repository.Branch; import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermissions; import java.util.Collection; import java.util.List; @@ -25,22 +28,36 @@ public class BranchCollectionToDtoMapper { this.branchToDtoMapper = branchToDtoMapper; } - public HalRepresentation map(String namespace, String name, Collection branches) { - return new HalRepresentation(createLinks(namespace, name), embedDtos(getBranchDtoList(namespace, name, branches))); + public HalRepresentation map(Repository repository, Collection branches) { + return new HalRepresentation( + createLinks(repository), + embedDtos(getBranchDtoList(repository.getNamespace(), repository.getName(), branches))); } public List getBranchDtoList(String namespace, String name, Collection branches) { return branches.stream().map(branch -> branchToDtoMapper.map(branch, new NamespaceAndName(namespace, name))).collect(toList()); } - private Links createLinks(String namespace, String name) { + private Links createLinks(Repository repository) { + String namespace = repository.getNamespace(); + String name = repository.getName(); String baseUrl = resourceLinks.branchCollection().self(namespace, name); - Links.Builder linksBuilder = linkingTo() - .with(Links.linkingTo().self(baseUrl).build()); + Links.Builder linksBuilder = linkingTo().with(createSelfLink(baseUrl)); + if (RepositoryPermissions.push(repository).isPermitted()) { + linksBuilder.single(createCreateLink(namespace, name)); + } return linksBuilder.build(); } + private Links createSelfLink(String baseUrl) { + return Links.linkingTo().self(baseUrl).build(); + } + + private Link createCreateLink(String namespace, String name) { + return Link.link("create", resourceLinks.branch().create(namespace, name)); + } + private Embedded embedDtos(List dtos) { return embeddedBuilder() .with("branches", dtos) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java index c27baf3666..c66428697d 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchDto.java @@ -6,10 +6,19 @@ import de.otto.edison.hal.Links; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.NotEmpty; + +import javax.validation.constraints.Pattern; @Getter @Setter @NoArgsConstructor public class BranchDto extends HalRepresentation { + private static final String VALID_CHARACTERS_AT_START_AND_END = "\\w-,;\\]{}@&+=$#`|<>"; + private static final String VALID_CHARACTERS = VALID_CHARACTERS_AT_START_AND_END + "/."; + static final String VALID_BRANCH_NAMES = "[" + VALID_CHARACTERS_AT_START_AND_END + "]([" + VALID_CHARACTERS + "]*[" + VALID_CHARACTERS_AT_START_AND_END + "])?"; + + @NotEmpty @Length(min = 1, max=100) @Pattern(regexp = VALID_BRANCH_NAMES) private String name; private String revision; private boolean defaultBranch; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java index d19c1a9e03..b35b59beca 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java @@ -1,9 +1,10 @@ package sonia.scm.api.v2.resources; import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.ResponseHeader; +import com.webcohesion.enunciate.metadata.rs.ResponseHeaders; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.TypeHint; -import sonia.scm.NotFoundException; import sonia.scm.PageResult; import sonia.scm.repository.Branch; import sonia.scm.repository.Branches; @@ -18,15 +19,20 @@ import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.validation.Valid; +import javax.ws.rs.Consumes; import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; +import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Response; import java.io.IOException; +import java.net.URI; +import static sonia.scm.AlreadyExistsException.alreadyExists; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; @@ -38,12 +44,15 @@ public class BranchRootResource { private final BranchChangesetCollectionToDtoMapper branchChangesetCollectionToDtoMapper; + private final ResourceLinks resourceLinks; + @Inject - public BranchRootResource(RepositoryServiceFactory serviceFactory, BranchToBranchDtoMapper branchToDtoMapper, BranchCollectionToDtoMapper branchCollectionToDtoMapper, BranchChangesetCollectionToDtoMapper changesetCollectionToDtoMapper) { + public BranchRootResource(RepositoryServiceFactory serviceFactory, BranchToBranchDtoMapper branchToDtoMapper, BranchCollectionToDtoMapper branchCollectionToDtoMapper, BranchChangesetCollectionToDtoMapper changesetCollectionToDtoMapper, ResourceLinks resourceLinks) { this.serviceFactory = serviceFactory; this.branchToDtoMapper = branchToDtoMapper; this.branchCollectionToDtoMapper = branchCollectionToDtoMapper; this.branchChangesetCollectionToDtoMapper = changesetCollectionToDtoMapper; + this.resourceLinks = resourceLinks; } /** @@ -100,12 +109,7 @@ public class BranchRootResource { @DefaultValue("0") @QueryParam("page") int page, @DefaultValue("10") @QueryParam("pageSize") int pageSize) throws IOException { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { - boolean branchExists = repositoryService.getBranchesCommand() - .getBranches() - .getBranches() - .stream() - .anyMatch(branch -> branchName.equals(branch.getName())); - if (!branchExists){ + if (!branchExists(branchName, repositoryService)){ throw notFound(entity(Branch.class, branchName).in(Repository.class, namespace + "/" + name)); } Repository repository = repositoryService.getRepository(); @@ -125,6 +129,50 @@ public class BranchRootResource { } } + /** + * Creates a new branch. + * + * @param namespace the namespace of the repository + * @param name the name of the repository + * @param branchName The branch to be created. + * @return A response with the link to the new branch (if created successfully). + */ + @POST + @Path("") + @Consumes(VndMediaType.BRANCH) + @StatusCodes({ + @ResponseCode(code = 201, condition = "create success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"push\" privilege"), + @ResponseCode(code = 409, condition = "conflict, a user with this name already exists"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @TypeHint(TypeHint.NO_CONTENT.class) + @ResponseHeaders(@ResponseHeader(name = "Location", description = "uri to the created branch")) + public Response create(@PathParam("namespace") String namespace, + @PathParam("name") String name, + @Valid BranchDto branchToCreate) throws IOException { + NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); + String branchName = branchToCreate.getName(); + try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { + if (branchExists(branchName, repositoryService)) { + throw alreadyExists(entity(Branch.class, branchName).in(Repository.class, namespace + "/" + name)); + } + Repository repository = repositoryService.getRepository(); + RepositoryPermissions.push(repository).check(); + Branch newBranch = repositoryService.getBranchCommand().branch(branchName); + return Response.created(URI.create(resourceLinks.branch().self(namespaceAndName, newBranch.getName()))).build(); + } + } + + private boolean branchExists(String branchName, RepositoryService repositoryService) throws IOException { + return repositoryService.getBranchesCommand() + .getBranches() + .getBranches() + .stream() + .anyMatch(branch -> branchName.equals(branch.getName())); + } + /** * Returns the branches for a repository. * @@ -141,14 +189,14 @@ public class BranchRootResource { @ResponseCode(code = 200, condition = "success"), @ResponseCode(code = 400, condition = "branches not supported for given repository"), @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), - @ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"group\" privilege"), + @ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"read repository\" privilege"), @ResponseCode(code = 404, condition = "not found, no repository found for the given namespace and name"), @ResponseCode(code = 500, condition = "internal server error") }) public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name) throws IOException { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { Branches branches = repositoryService.getBranchesCommand().getBranches(); - return Response.ok(branchCollectionToDtoMapper.map(namespace, name, branches.getBranches())).build(); + return Response.ok(branchCollectionToDtoMapper.map(repositoryService.getRepository(), branches.getBranches())).build(); } catch (CommandNotSupportedException ex) { return Response.status(Response.Status.BAD_REQUEST).build(); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 1fc6b2a442..ff1013bb76 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -386,6 +386,9 @@ class ResourceLinks { return branchLinkBuilder.method("getRepositoryResource").parameters(namespaceAndName.getNamespace(), namespaceAndName.getName()).method("branches").parameters().method("history").parameters(branch).href(); } + public String create(String namespace, String name) { + return branchLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("branches").parameters().method("create").parameters().href(); + } } public IncomingLinks incoming() { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchDtoTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchDtoTest.java new file mode 100644 index 0000000000..b9072e3167 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchDtoTest.java @@ -0,0 +1,51 @@ +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BranchDtoTest { + + @ParameterizedTest + @ValueSource(strings = { + "v", + "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", + "val#x", + "val&x", + "val+", + "val,kill", + "val.kill", + "val;kill", + "valkill", + "val@", + "val]id", + "val`id", + "valid#", + "valid.t", + "val{", + "val{d", + "val{}d", + "val|kill", + "val}" + }) + void shouldAcceptValidBranchName(String branchName) { + assertTrue(branchName.matches(BranchDto.VALID_BRANCH_NAMES)); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + ".val", + "val.", + "/val", + "val/", + "val id" + }) + void shouldRejectInvalidBranchName(String branchName) { + assertFalse(branchName.matches(BranchDto.VALID_BRANCH_NAMES)); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java index d432dee18f..eae9d36082 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchRootResourceTest.java @@ -25,10 +25,12 @@ import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Person; import sonia.scm.repository.Repository; +import sonia.scm.repository.api.BranchCommandBuilder; import sonia.scm.repository.api.BranchesCommandBuilder; import sonia.scm.repository.api.LogCommandBuilder; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.web.VndMediaType; import java.net.URI; import java.time.Instant; @@ -49,6 +51,7 @@ public class BranchRootResourceTest extends RepositoryTestBase { public static final String BRANCH_PATH = "space/repo/branches/master"; public static final String BRANCH_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + BRANCH_PATH; + public static final String REVISION = "revision"; private final Dispatcher dispatcher = MockDispatcherFactory.createDispatcher(); private final URI baseUri = URI.create("/"); @@ -60,6 +63,8 @@ public class BranchRootResourceTest extends RepositoryTestBase { private RepositoryService service; @Mock private BranchesCommandBuilder branchesCommandBuilder; + @Mock + private BranchCommandBuilder branchCommandBuilder; @Mock private LogCommandBuilder logCommandBuilder; @@ -89,10 +94,10 @@ public class BranchRootResourceTest extends RepositoryTestBase { @Before - public void prepareEnvironment() throws Exception { + public void prepareEnvironment() { changesetCollectionToDtoMapper = new BranchChangesetCollectionToDtoMapper(changesetToChangesetDtoMapper, resourceLinks); BranchCollectionToDtoMapper branchCollectionToDtoMapper = new BranchCollectionToDtoMapper(branchToDtoMapper, resourceLinks); - branchRootResource = new BranchRootResource(serviceFactory, branchToDtoMapper, branchCollectionToDtoMapper, changesetCollectionToDtoMapper); + branchRootResource = new BranchRootResource(serviceFactory, branchToDtoMapper, branchCollectionToDtoMapper, changesetCollectionToDtoMapper, resourceLinks); super.branchRootResource = Providers.of(branchRootResource); dispatcher.getRegistry().addSingletonResource(getRepositoryRootResource()); when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(service); @@ -100,6 +105,7 @@ public class BranchRootResourceTest extends RepositoryTestBase { when(service.getRepository()).thenReturn(new Repository("repoId", "git", "space", "repo")); when(service.getBranchesCommand()).thenReturn(branchesCommandBuilder); + when(service.getBranchCommand()).thenReturn(branchCommandBuilder); when(service.getLogCommand()).thenReturn(logCommandBuilder); subjectThreadState.bind(); ThreadContext.bind(subject); @@ -125,7 +131,7 @@ public class BranchRootResourceTest extends RepositoryTestBase { @Test public void shouldFindExistingBranch() throws Exception { - when(branchesCommandBuilder.getBranches()).thenReturn(new Branches(Branch.normalBranch("master", "revision"))); + when(branchesCommandBuilder.getBranches()).thenReturn(new Branches(createBranch("master"))); MockHttpRequest request = MockHttpRequest.get(BRANCH_URL); MockHttpResponse response = new MockHttpResponse(); @@ -139,13 +145,12 @@ public class BranchRootResourceTest extends RepositoryTestBase { @Test public void shouldFindHistory() throws Exception { - String id = "revision_123"; Instant creationDate = Instant.now(); String authorName = "name"; String authorEmail = "em@i.l"; String commit = "my branch commit"; ChangesetPagingResult changesetPagingResult = mock(ChangesetPagingResult.class); - List changesetList = Lists.newArrayList(new Changeset(id, Date.from(creationDate).getTime(), new Person(authorName, authorEmail), commit)); + List changesetList = Lists.newArrayList(new Changeset(REVISION, Date.from(creationDate).getTime(), new Person(authorName, authorEmail), commit)); when(changesetPagingResult.getChangesets()).thenReturn(changesetList); when(changesetPagingResult.getTotal()).thenReturn(1); when(logCommandBuilder.setPagingStart(anyInt())).thenReturn(logCommandBuilder); @@ -153,7 +158,7 @@ public class BranchRootResourceTest extends RepositoryTestBase { when(logCommandBuilder.setBranch(anyString())).thenReturn(logCommandBuilder); when(logCommandBuilder.getChangesets()).thenReturn(changesetPagingResult); Branches branches = mock(Branches.class); - List branchList = Lists.newArrayList(Branch.normalBranch("master",id)); + List branchList = Lists.newArrayList(createBranch("master")); when(branches.getBranches()).thenReturn(branchList); when(branchesCommandBuilder.getBranches()).thenReturn(branches); MockHttpRequest request = MockHttpRequest.get(BRANCH_URL + "/changesets/"); @@ -161,9 +166,51 @@ public class BranchRootResourceTest extends RepositoryTestBase { dispatcher.invoke(request, response); assertEquals(200, response.getStatus()); log.info("Response :{}", response.getContentAsString()); - assertTrue(response.getContentAsString().contains(String.format("\"id\":\"%s\"", id))); + assertTrue(response.getContentAsString().contains(String.format("\"id\":\"%s\"", REVISION))); assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", authorName))); assertTrue(response.getContentAsString().contains(String.format("\"mail\":\"%s\"", authorEmail))); assertTrue(response.getContentAsString().contains(String.format("\"description\":\"%s\"", commit))); } + + @Test + public void shouldCreateNewBranch() throws Exception { + when(branchesCommandBuilder.getBranches()).thenReturn(new Branches()); + when(branchCommandBuilder.branch("new_branch")).thenReturn(createBranch("new_branch")); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/branches/") + .content("{\"name\": \"new_branch\"}".getBytes()) + .contentType(VndMediaType.BRANCH); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(201, response.getStatus()); + assertEquals( + URI.create("/v2/repositories/space/repo/branches/new_branch"), + response.getOutputHeaders().getFirst("Location")); + } + + @Test + public void shouldNotCreateExistingBranchAgain() throws Exception { + when(branchesCommandBuilder.getBranches()).thenReturn(new Branches(createBranch("existing_branch"))); + when(branchCommandBuilder.branch("new_branch")).thenReturn(createBranch("new_branch")); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/branches/") + .content("{\"name\": \"new_branch\"}".getBytes()) + .contentType(VndMediaType.BRANCH); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(201, response.getStatus()); + assertEquals( + URI.create("/v2/repositories/space/repo/branches/new_branch"), + response.getOutputHeaders().getFirst("Location")); + } + + private Branch createBranch(String existing_branch) { + return Branch.normalBranch(existing_branch, REVISION); + } }