diff --git a/scm-core/src/main/java/sonia/scm/HandlerBase.java b/scm-core/src/main/java/sonia/scm/HandlerBase.java index 8e6b12ba79..6ce7d9a6a6 100644 --- a/scm-core/src/main/java/sonia/scm/HandlerBase.java +++ b/scm-core/src/main/java/sonia/scm/HandlerBase.java @@ -53,13 +53,9 @@ public interface HandlerBase /** * Persists a new object. * - * - * @param object to store - * - * @throws E - * @throws IOException + * @return The persisted object. */ - public void create(T object) throws E; + public T create(T object) throws E; /** * Removes a persistent object. diff --git a/scm-core/src/main/java/sonia/scm/ManagerDecorator.java b/scm-core/src/main/java/sonia/scm/ManagerDecorator.java index 99707c4c4d..3b90002d13 100644 --- a/scm-core/src/main/java/sonia/scm/ManagerDecorator.java +++ b/scm-core/src/main/java/sonia/scm/ManagerDecorator.java @@ -77,9 +77,9 @@ public class ManagerDecorator * {@inheritDoc} */ @Override - public void create(T object) throws E + public T create(T object) throws E { - decorated.create(object); + return decorated.create(object); } /** diff --git a/scm-core/src/main/java/sonia/scm/repository/AbstractSimpleRepositoryHandler.java b/scm-core/src/main/java/sonia/scm/repository/AbstractSimpleRepositoryHandler.java index 90015f0f31..2809069913 100644 --- a/scm-core/src/main/java/sonia/scm/repository/AbstractSimpleRepositoryHandler.java +++ b/scm-core/src/main/java/sonia/scm/repository/AbstractSimpleRepositoryHandler.java @@ -80,7 +80,7 @@ public abstract class AbstractSimpleRepositoryHandler getByNamespace(String namespace, String name) { + return getAll() + .stream() + .filter(r -> r.getName().equals(name) && r.getNamespace().equals(namespace)) + .findFirst(); + } } diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index 3ec121f9a4..fb80450bc8 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -14,8 +14,10 @@ public class VndMediaType { public static final String USER = PREFIX + "user" + SUFFIX; public static final String GROUP = PREFIX + "group" + SUFFIX; + public static final String REPOSITORY = PREFIX + "repository" + SUFFIX; public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX; public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX; + public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX; private VndMediaType() { } diff --git a/scm-core/src/test/java/sonia/scm/ManagerTest.java b/scm-core/src/test/java/sonia/scm/ManagerTest.java index 749e579386..06c8eb3ea6 100644 --- a/scm-core/src/test/java/sonia/scm/ManagerTest.java +++ b/scm-core/src/test/java/sonia/scm/ManagerTest.java @@ -88,7 +88,7 @@ public class ManagerTest { public Collection getAll(Comparator comparator, int start, int limit) { return null; } @Override - public void create(TypedObject object) {} + public TypedObject create(TypedObject object) { return null; } @Override public void delete(TypedObject object) {} diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/RepositoryResource.java b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/RepositoryResource.java index 5c9618860b..459a094914 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/RepositoryResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/RepositoryResource.java @@ -46,7 +46,6 @@ import org.apache.shiro.SecurityUtils; import org.apache.shiro.authz.AuthorizationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.config.ScmConfiguration; import sonia.scm.repository.BlameResult; import sonia.scm.repository.Branches; import sonia.scm.repository.BrowserResult; @@ -120,19 +119,15 @@ public class RepositoryResource extends AbstractManagerResource { @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes - public abstract D map(T user); + public abstract D map(T modelObject); Instant mapTime(Long epochMilli) { return epochMilli == null? null: Instant.ofEpochMilli(epochMilli); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchCollectionResource.java new file mode 100644 index 0000000000..d64016b475 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchCollectionResource.java @@ -0,0 +1,18 @@ +package sonia.scm.api.v2.resources; + +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Response; + +public class BranchCollectionResource { + @GET + @Path("") + public Response getAll(@DefaultValue("0") @QueryParam("page") int page, + @DefaultValue("10") @QueryParam("pageSize") int pageSize, + @QueryParam("sortBy") String sortBy, + @DefaultValue("false") @QueryParam("desc") boolean desc) { + throw new UnsupportedOperationException(); + } +} 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 new file mode 100644 index 0000000000..ee50fdcd1e --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java @@ -0,0 +1,20 @@ +package sonia.scm.api.v2.resources; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.ws.rs.Path; + +public class BranchRootResource { + + private final Provider branchCollectionResource; + + @Inject + public BranchRootResource(Provider branchCollectionResource) { + this.branchCollectionResource = branchCollectionResource; + } + + @Path("") + public BranchCollectionResource getBranchCollectionResource() { + return branchCollectionResource.get(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetCollectionResource.java new file mode 100644 index 0000000000..d42494a270 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetCollectionResource.java @@ -0,0 +1,18 @@ +package sonia.scm.api.v2.resources; + +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Response; + +public class ChangesetCollectionResource { + @GET + @Path("") + public Response getAll(@DefaultValue("0") @QueryParam("page") int page, + @DefaultValue("10") @QueryParam("pageSize") int pageSize, + @QueryParam("sortBy") String sortBy, + @DefaultValue("false") @QueryParam("desc") boolean desc) { + throw new UnsupportedOperationException(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetRootResource.java new file mode 100644 index 0000000000..1681a27cd4 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ChangesetRootResource.java @@ -0,0 +1,20 @@ +package sonia.scm.api.v2.resources; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.ws.rs.Path; + +public class ChangesetRootResource { + + private final Provider changesetCollectionResource; + + @Inject + public ChangesetRootResource(Provider changesetCollectionResource) { + this.changesetCollectionResource = changesetCollectionResource; + } + + @Path("") + public ChangesetCollectionResource getChangesetCollectionResource() { + return changesetCollectionResource.get(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceManagerAdapter.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/CollectionResourceManagerAdapter.java similarity index 59% rename from scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceManagerAdapter.java rename to scm-webapp/src/main/java/sonia/scm/api/v2/resources/CollectionResourceManagerAdapter.java index bc35942900..3169088331 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceManagerAdapter.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/CollectionResourceManagerAdapter.java @@ -8,7 +8,6 @@ import sonia.scm.api.rest.resources.AbstractManagerResource; import javax.ws.rs.core.GenericEntity; import javax.ws.rs.core.Response; -import java.io.IOException; import java.net.URI; import java.util.Collection; import java.util.function.Function; @@ -17,52 +16,25 @@ import java.util.function.Supplier; import static javax.ws.rs.core.Response.Status.BAD_REQUEST; /** - * Adapter from resource http endpoints to managers. + * Adapter from resource http endpoints to managers, for Collection resources (e.g. {@code /users}). * * Provides common CRUD operations and DTO to Model Object mapping to keep Resources more DRY. * * @param The type of the model object, eg. {@link sonia.scm.user.User}. * @param The corresponding transport object, eg. {@link UserDto}. * @param The exception type for the model object, eg. {@link sonia.scm.user.UserException}. + * + * @see SingleResourceManagerAdapter */ @SuppressWarnings("squid:S00119") // "MODEL_OBJECT" is much more meaningful than "M", right? -class ResourceManagerAdapter extends AbstractManagerResource { - ResourceManagerAdapter(Manager manager, Class type) { + CollectionResourceManagerAdapter(Manager manager, Class type) { super(manager, type); } - /** - * Reads the model object for the given id, transforms it to a dto and returns a corresponding http response. - * This handles all corner cases, eg. no matching object for the id or missing privileges. - */ - Response get(String id, Function mapToDto) { - MODEL_OBJECT modelObject = manager.get(id); - if (modelObject == null) { - return Response.status(Response.Status.NOT_FOUND).build(); - } - DTO dto = mapToDto.apply(modelObject); - return Response.ok(dto).build(); - } - - /** - * Update the model object for the given id according to the given function and returns a corresponding http response. - * This handles all corner cases, eg. no matching object for the id or missing privileges. - */ - public Response update(String id, Function applyChanges) { - MODEL_OBJECT existingModelObject = manager.get(id); - if (existingModelObject == null) { - return Response.status(Response.Status.NOT_FOUND).build(); - } - MODEL_OBJECT changedModelObject = applyChanges.apply(existingModelObject); - if (!id.equals(changedModelObject.getId())) { - return Response.status(BAD_REQUEST).entity("illegal change of id").build(); - } - return update(id, changedModelObject); - } - /** * Reads all model objects in a paged way, maps them using the given function and returns a corresponding http response. * This handles all corner cases, eg. missing privileges. @@ -76,13 +48,13 @@ class ResourceManagerAdapter modelObjectSupplier, Function uriCreator) throws IOException, EXCEPTION { + public Response create(DTO dto, Supplier modelObjectSupplier, Function uriCreator) throws EXCEPTION { if (dto == null) { return Response.status(BAD_REQUEST).build(); } MODEL_OBJECT modelObject = modelObjectSupplier.get(); - manager.create(modelObject); - return Response.created(URI.create(uriCreator.apply(modelObject))).build(); + MODEL_OBJECT created = manager.create(modelObject); + return Response.created(URI.create(uriCreator.apply(created))).build(); } @Override diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java index 912d890fe8..d806d3cee7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java @@ -29,14 +29,14 @@ public class GroupCollectionResource { private final GroupCollectionToDtoMapper groupCollectionToDtoMapper; private final ResourceLinks resourceLinks; - private final ResourceManagerAdapter adapter; + private final IdResourceManagerAdapter adapter; @Inject public GroupCollectionResource(GroupManager manager, GroupDtoToGroupMapper dtoToGroupMapper, GroupCollectionToDtoMapper groupCollectionToDtoMapper, ResourceLinks resourceLinks) { this.dtoToGroupMapper = dtoToGroupMapper; this.groupCollectionToDtoMapper = groupCollectionToDtoMapper; this.resourceLinks = resourceLinks; - this.adapter = new ResourceManagerAdapter<>(manager, Group.class); + this.adapter = new IdResourceManagerAdapter<>(manager, Group.class); } /** diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupResource.java index 62bdaad38f..c9f066790b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupResource.java @@ -22,14 +22,14 @@ public class GroupResource { private final GroupToGroupDtoMapper groupToGroupDtoMapper; private final GroupDtoToGroupMapper dtoToGroupMapper; - private final ResourceManagerAdapter adapter; + private final IdResourceManagerAdapter adapter; @Inject public GroupResource(GroupManager manager, GroupToGroupDtoMapper groupToGroupDtoMapper, GroupDtoToGroupMapper groupDtoToGroupMapper) { this.groupToGroupDtoMapper = groupToGroupDtoMapper; this.dtoToGroupMapper = groupDtoToGroupMapper; - this.adapter = new ResourceManagerAdapter<>(manager, Group.class); + this.adapter = new IdResourceManagerAdapter<>(manager, Group.class); } /** diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/HealthCheckFailureDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/HealthCheckFailureDto.java new file mode 100644 index 0000000000..21d98c257c --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/HealthCheckFailureDto.java @@ -0,0 +1,11 @@ +package sonia.scm.api.v2.resources; + +import lombok.Getter; +import lombok.Setter; + +@Getter @Setter +public class HealthCheckFailureDto { + private String description; + private String summary; + private String url; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IdResourceManagerAdapter.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IdResourceManagerAdapter.java new file mode 100644 index 0000000000..ded4bff309 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IdResourceManagerAdapter.java @@ -0,0 +1,66 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import sonia.scm.Manager; +import sonia.scm.ModelObject; +import sonia.scm.PageResult; + +import javax.ws.rs.core.Response; +import java.io.IOException; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** + * Facade for {@link SingleResourceManagerAdapter} and {@link CollectionResourceManagerAdapter} + * for model objects handled by a single id. + */ +@SuppressWarnings("squid:S00119") // "MODEL_OBJECT" is much more meaningful than "M", right? +class IdResourceManagerAdapter { + + private final Manager manager; + + private final SingleResourceManagerAdapter singleAdapter; + private final CollectionResourceManagerAdapter collectionAdapter; + + IdResourceManagerAdapter(Manager manager, Class type) { + this.manager = manager; + singleAdapter = new SingleResourceManagerAdapter<>(manager, type); + collectionAdapter = new CollectionResourceManagerAdapter<>(manager, type); + } + + Response get(String id, Function mapToDto) { + return singleAdapter.get(loadBy(id), mapToDto); + } + + public Response update(String id, Function applyChanges) { + return singleAdapter.update( + loadBy(id), + applyChanges, + idStaysTheSame(id) + ); + } + + public Response getAll(int page, int pageSize, String sortBy, boolean desc, Function, CollectionDto> mapToDto) { + return collectionAdapter.getAll(page, pageSize, sortBy, desc, mapToDto); + } + + public Response create(DTO dto, Supplier modelObjectSupplier, Function uriCreator) throws IOException, EXCEPTION { + return collectionAdapter.create(dto, modelObjectSupplier, uriCreator); + } + + public Response delete(String id) { + return singleAdapter.delete(id); + } + + private Supplier> loadBy(String id) { + return () -> Optional.ofNullable(manager.get(id)); + } + + private Predicate idStaysTheSame(String id) { + return changed -> changed.getId().equals(id); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java index fec2a677b5..8e22755fe0 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java @@ -15,6 +15,9 @@ public class MapperModule extends AbstractModule { bind(GroupToGroupDtoMapper.class).to(Mappers.getMapper(GroupToGroupDtoMapper.class).getClass()); bind(GroupCollectionToDtoMapper.class); + bind(RepositoryToRepositoryDtoMapper.class).to(Mappers.getMapper(RepositoryToRepositoryDtoMapper.class).getClass()); + bind(RepositoryDtoToRepositoryMapper.class).to(Mappers.getMapper(RepositoryDtoToRepositoryMapper.class).getClass()); + bind(UriInfoStore.class).in(ServletScopes.REQUEST); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionCollectionResource.java new file mode 100644 index 0000000000..6c4b52c16d --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionCollectionResource.java @@ -0,0 +1,18 @@ +package sonia.scm.api.v2.resources; + +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Response; + +public class PermissionCollectionResource { + @GET + @Path("") + public Response getAll(@DefaultValue("0") @QueryParam("page") int page, + @DefaultValue("10") @QueryParam("pageSize") int pageSize, + @QueryParam("sortBy") String sortBy, + @DefaultValue("false") @QueryParam("desc") boolean desc) { + throw new UnsupportedOperationException(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionRootResource.java new file mode 100644 index 0000000000..cd1e970e43 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionRootResource.java @@ -0,0 +1,20 @@ +package sonia.scm.api.v2.resources; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.ws.rs.Path; + +public class PermissionRootResource { + + private final Provider permissionCollectionResource; + + @Inject + public PermissionRootResource(Provider permissionCollectionResource) { + this.permissionCollectionResource = permissionCollectionResource; + } + + @Path("") + public PermissionCollectionResource getPermissionCollectionResource() { + return permissionCollectionResource.get(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java new file mode 100644 index 0000000000..7112e7113b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java @@ -0,0 +1,94 @@ +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.repository.Repository; +import sonia.scm.repository.RepositoryException; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +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.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Response; + +public class RepositoryCollectionResource { + + private static final int DEFAULT_PAGE_SIZE = 10; + + private final CollectionResourceManagerAdapter adapter; + private final RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper; + private final RepositoryDtoToRepositoryMapper dtoToRepositoryMapper; + private final ResourceLinks resourceLinks; + + @Inject + public RepositoryCollectionResource(RepositoryManager manager, RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper, RepositoryDtoToRepositoryMapper dtoToRepositoryMapper, ResourceLinks resourceLinks) { + this.adapter = new CollectionResourceManagerAdapter<>(manager, Repository.class); + this.repositoryCollectionToDtoMapper = repositoryCollectionToDtoMapper; + this.dtoToRepositoryMapper = dtoToRepositoryMapper; + this.resourceLinks = resourceLinks; + } + + /** + * Returns all repositories for a given page number with a given page size (default page size is {@value DEFAULT_PAGE_SIZE}). + * + * Note: This method requires "repository" privilege. + * + * @param page the number of the requested page + * @param pageSize the page size (default page size is {@value DEFAULT_PAGE_SIZE}) + * @param sortBy sort parameter (if empty - undefined sorting) + * @param desc sort direction desc or asc + */ + @GET + @Path("") + @Produces(VndMediaType.REPOSITORY_COLLECTION) + @TypeHint(RepositoryDto[].class) + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repository\" privilege"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response getAll(@DefaultValue("0") @QueryParam("page") int page, + @DefaultValue("" + DEFAULT_PAGE_SIZE) @QueryParam("pageSize") int pageSize, + @QueryParam("sortBy") String sortBy, + @DefaultValue("false") @QueryParam("desc") boolean desc) { + return adapter.getAll(page, pageSize, sortBy, desc, + pageResult -> repositoryCollectionToDtoMapper.map(page, pageSize, pageResult)); + } + + /** + * Creates a new repository. + * + * Note: This method requires "repository" privilege. The namespace of the given repository will + * be ignored and set by the configured namespace strategy. + * + * @param repositoryDto The repository to be created. + * @return A response with the link to the new repository (if created successfully). + */ + @POST + @Path("") + @Consumes(VndMediaType.REPOSITORY) + @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 \"repository\" privilege"), + @ResponseCode(code = 409, condition = "conflict, a repository 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 repository")) + public Response create(RepositoryDto repositoryDto) throws RepositoryException { + return adapter.create(repositoryDto, + () -> dtoToRepositoryMapper.map(repositoryDto, null), + repository -> resourceLinks.repository().self(repository.getNamespace(), repository.getName())); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionToDtoMapper.java new file mode 100644 index 0000000000..a1cf0218e4 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionToDtoMapper.java @@ -0,0 +1,34 @@ +package sonia.scm.api.v2.resources; + +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermissions; + +import javax.inject.Inject; + +// Mapstruct does not support parameterized (i.e. non-default) constructors. Thus, we need to use field injection. +@SuppressWarnings("squid:S3306") +public class RepositoryCollectionToDtoMapper extends BasicCollectionToDtoMapper { + + private final ResourceLinks resourceLinks; + + @Inject + public RepositoryCollectionToDtoMapper(RepositoryToRepositoryDtoMapper repositoryToDtoMapper, ResourceLinks resourceLinks) { + super("repositories", repositoryToDtoMapper); + this.resourceLinks = resourceLinks; + } + + @Override + String createCreateLink() { + return resourceLinks.repositoryCollection().create(); + } + + @Override + String createSelfLink() { + return resourceLinks.repositoryCollection().self(); + } + + @Override + boolean isCreatePermitted() { + return RepositoryPermissions.create().isPermitted(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDto.java new file mode 100644 index 0000000000..bcc8e16ebb --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDto.java @@ -0,0 +1,33 @@ +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.annotation.JsonInclude; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@Getter @Setter +public class RepositoryDto extends HalRepresentation { + + private String contact; + private Instant creationDate; + private String description; + private List healthCheckFailures; + @JsonInclude(JsonInclude.Include.NON_NULL) + private Instant lastModified; + private String namespace; + private String name; + private boolean archived = false; + private String type; + protected Map properties; + + @Override + @SuppressWarnings("squid:S1185") // We want to have this method available in this package + protected HalRepresentation add(Links links) { + return super.add(links); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDtoToRepositoryMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDtoToRepositoryMapper.java new file mode 100644 index 0000000000..2c02bb8180 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDtoToRepositoryMapper.java @@ -0,0 +1,25 @@ +package sonia.scm.api.v2.resources; + +import org.mapstruct.AfterMapping; +import org.mapstruct.Context; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import sonia.scm.repository.Repository; + +@Mapper +public abstract class RepositoryDtoToRepositoryMapper { + + @Mapping(target = "creationDate", ignore = true) + @Mapping(target = "lastModified", ignore = true) + @Mapping(target = "id", ignore = true) + @Mapping(target = "publicReadable", ignore = true) + @Mapping(target = "healthCheckFailures", ignore = true) + @Mapping(target = "permissions", ignore = true) + public abstract Repository map(RepositoryDto repositoryDto, @Context String id); + + @AfterMapping + void updateId(@MappingTarget Repository repository, @Context String id) { + repository.setId(id); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java new file mode 100644 index 0000000000..5972508c78 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java @@ -0,0 +1,164 @@ +package sonia.scm.api.v2.resources; + +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import com.webcohesion.enunciate.metadata.rs.TypeHint; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryException; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.function.Supplier; + +public class RepositoryResource { + + private final RepositoryToRepositoryDtoMapper repositoryToDtoMapper; + private final RepositoryDtoToRepositoryMapper dtoToRepositoryMapper; + + private final RepositoryManager manager; + private final SingleResourceManagerAdapter adapter; + private final Provider tagRootResource; + private final Provider branchRootResource; + private final Provider changesetRootResource; + private final Provider sourceRootResource; + private final Provider permissionRootResource; + + @Inject + public RepositoryResource( + RepositoryToRepositoryDtoMapper repositoryToDtoMapper, + RepositoryDtoToRepositoryMapper dtoToRepositoryMapper, RepositoryManager manager, + Provider tagRootResource, + Provider branchRootResource, + Provider changesetRootResource, + Provider sourceRootResource, Provider permissionRootResource) { + this.dtoToRepositoryMapper = dtoToRepositoryMapper; + this.manager = manager; + this.repositoryToDtoMapper = repositoryToDtoMapper; + this.adapter = new SingleResourceManagerAdapter<>(manager, Repository.class); + this.tagRootResource = tagRootResource; + this.branchRootResource = branchRootResource; + this.changesetRootResource = changesetRootResource; + this.sourceRootResource = sourceRootResource; + this.permissionRootResource = permissionRootResource; + } + + /** + * Returns a repository. + * + * Note: This method requires "repository" privilege. + * + * @param namespace the namespace of the repository + * @param name the name of the repository + * + */ + @GET + @Path("") + @Produces(VndMediaType.REPOSITORY) + @TypeHint(RepositoryDto.class) + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the repository"), + @ResponseCode(code = 404, condition = "not found, no repository with the specified name available in the namespace"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name) { + return adapter.get(loadBy(namespace, name), repositoryToDtoMapper::map); + } + + /** + * Deletes a repository. + * + * Note: This method requires "repository" privilege. + * + * @param namespace the namespace of the repository to delete + * @param name the name of the repository to delete + * + */ + @DELETE + @Path("") + @StatusCodes({ + @ResponseCode(code = 204, condition = "delete success or nothing to delete"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repository\" privilege"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @TypeHint(TypeHint.NO_CONTENT.class) + public Response delete(@PathParam("namespace") String namespace, @PathParam("name") String name) { + return adapter.delete(loadBy(namespace, name)); + } + + /** + * Modifies the given repository. + * + * Note: This method requires "repository" privilege. + * + * @param namespace the namespace of the repository to be modified + * @param name the name of the repository to be modified + * @param repositoryDto repository object to modify + */ + @PUT + @Path("") + @Consumes(VndMediaType.REPOSITORY) + @StatusCodes({ + @ResponseCode(code = 204, condition = "update success"), + @ResponseCode(code = 400, condition = "Invalid body, e.g. illegal change of namespace or name"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repository\" privilege"), + @ResponseCode(code = 404, condition = "not found, no repository with the specified namespace and name available"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @TypeHint(TypeHint.NO_CONTENT.class) + public Response update(@PathParam("namespace") String namespace, @PathParam("name") String name, RepositoryDto repositoryDto) { + return adapter.update( + loadBy(namespace, name), + existing -> dtoToRepositoryMapper.map(repositoryDto, existing.getId()), + nameAndNamespaceStaysTheSame(namespace, name) + ); + } + + @Path("tags/") + public TagRootResource tags() { + return tagRootResource.get(); + } + + @Path("branches/") + public BranchRootResource branches() { + return branchRootResource.get(); + } + + @Path("changesets/") + public ChangesetRootResource changesets() { + return changesetRootResource.get(); + } + + @Path("sources/") + public SourceRootResource sources() { + return sourceRootResource.get(); + } + + @Path("permissions/") + public PermissionRootResource permissions() { + return permissionRootResource.get(); + } + + private Supplier> loadBy(String namespace, String name) { + return () -> manager.getByNamespace(namespace, name); + } + + private Predicate nameAndNamespaceStaysTheSame(String namespace, String name) { + return changed -> changed.getName().equals(name) && changed.getNamespace().equals(namespace); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRootResource.java new file mode 100644 index 0000000000..a7a6365c37 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryRootResource.java @@ -0,0 +1,32 @@ +package sonia.scm.api.v2.resources; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.ws.rs.Path; + +/** + * RESTful Web Service Resource to manage repositories. + */ +@Path(RepositoryRootResource.REPOSITORIES_PATH_V2) +public class RepositoryRootResource { + static final String REPOSITORIES_PATH_V2 = "v2/repositories/"; + + private final Provider repositoryResource; + private final Provider repositoryCollectionResource; + + @Inject + public RepositoryRootResource(Provider repositoryResource, Provider repositoryCollectionResource) { + this.repositoryResource = repositoryResource; + this.repositoryCollectionResource = repositoryCollectionResource; + } + + @Path("{namespace}/{name}") + public RepositoryResource getRepositoryResource() { + return repositoryResource.get(); + } + + @Path("") + public RepositoryCollectionResource getRepositoryCollectionResource() { + return repositoryCollectionResource.get(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java new file mode 100644 index 0000000000..2f13723d39 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java @@ -0,0 +1,41 @@ +package sonia.scm.api.v2.resources; + +import com.google.inject.Inject; +import de.otto.edison.hal.Links; +import org.mapstruct.AfterMapping; +import org.mapstruct.Mapper; +import org.mapstruct.MappingTarget; +import sonia.scm.repository.HealthCheckFailure; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermissions; + +import static de.otto.edison.hal.Link.link; +import static de.otto.edison.hal.Links.linkingTo; + +// Mapstruct does not support parameterized (i.e. non-default) constructors. Thus, we need to use field injection. +@SuppressWarnings("squid:S3306") +@Mapper +public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper { + + @Inject + private ResourceLinks resourceLinks; + + abstract HealthCheckFailureDto toDto(HealthCheckFailure failure); + + @AfterMapping + void appendLinks(Repository repository, @MappingTarget RepositoryDto target) { + Links.Builder linksBuilder = linkingTo().self(resourceLinks.repository().self(target.getNamespace(), target.getName())); + if (RepositoryPermissions.delete(repository).isPermitted()) { + linksBuilder.single(link("delete", resourceLinks.repository().delete(target.getNamespace(), target.getName()))); + } + if (RepositoryPermissions.modify(repository).isPermitted()) { + linksBuilder.single(link("update", resourceLinks.repository().update(target.getNamespace(), target.getName()))); + linksBuilder.single(link("permissions", resourceLinks.permissionCollection().self(target.getNamespace(), target.getName()))); + } + linksBuilder.single(link("tags", resourceLinks.tagCollection().self(target.getNamespace(), target.getName()))); + linksBuilder.single(link("branches", resourceLinks.branchCollection().self(target.getNamespace(), target.getName()))); + linksBuilder.single(link("changesets", resourceLinks.changesetCollection().self(target.getNamespace(), target.getName()))); + linksBuilder.single(link("sources", resourceLinks.sourceCollection().self(target.getNamespace(), target.getName()))); + target.add(linksBuilder.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 13691da0f6..8f6f1eaae3 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 @@ -20,7 +20,7 @@ class ResourceLinks { static class GroupLinks { private final LinkBuilder groupLinkBuilder; - private GroupLinks(UriInfo uriInfo) { + GroupLinks(UriInfo uriInfo) { groupLinkBuilder = new LinkBuilder(uriInfo, GroupRootResource.class, GroupResource.class); } @@ -44,7 +44,7 @@ class ResourceLinks { static class GroupCollectionLinks { private final LinkBuilder collectionLinkBuilder; - private GroupCollectionLinks(UriInfo uriInfo) { + GroupCollectionLinks(UriInfo uriInfo) { collectionLinkBuilder = new LinkBuilder(uriInfo, GroupRootResource.class, GroupCollectionResource.class); } @@ -64,7 +64,7 @@ class ResourceLinks { static class UserLinks { private final LinkBuilder userLinkBuilder; - private UserLinks(UriInfo uriInfo) { + UserLinks(UriInfo uriInfo) { userLinkBuilder = new LinkBuilder(uriInfo, UserRootResource.class, UserResource.class); } @@ -88,7 +88,7 @@ class ResourceLinks { static class UserCollectionLinks { private final LinkBuilder collectionLinkBuilder; - private UserCollectionLinks(UriInfo uriInfo) { + UserCollectionLinks(UriInfo uriInfo) { collectionLinkBuilder = new LinkBuilder(uriInfo, UserRootResource.class, UserCollectionResource.class); } @@ -100,4 +100,128 @@ class ResourceLinks { return collectionLinkBuilder.method("getUserCollectionResource").parameters().method("create").parameters().href(); } } + + public RepositoryLinks repository() { + return new RepositoryLinks(uriInfoStore.get()); + } + + static class RepositoryLinks { + private final LinkBuilder repositoryLinkBuilder; + + RepositoryLinks(UriInfo uriInfo) { + repositoryLinkBuilder = new LinkBuilder(uriInfo, RepositoryRootResource.class, RepositoryResource.class); + } + + String self(String namespace, String name) { + return repositoryLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("get").parameters().href(); + } + + String delete(String namespace, String name) { + return repositoryLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("delete").parameters().href(); + } + + String update(String namespace, String name) { + return repositoryLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("update").parameters().href(); + } + } + + RepositoryCollectionLinks repositoryCollection() { + return new RepositoryCollectionLinks(uriInfoStore.get()); + } + + static class RepositoryCollectionLinks { + private final LinkBuilder collectionLinkBuilder; + + RepositoryCollectionLinks(UriInfo uriInfo) { + collectionLinkBuilder = new LinkBuilder(uriInfo, RepositoryRootResource.class, RepositoryCollectionResource.class); + } + + String self() { + return collectionLinkBuilder.method("getRepositoryCollectionResource").parameters().method("getAll").parameters().href(); + } + + String create() { + return collectionLinkBuilder.method("getRepositoryCollectionResource").parameters().method("create").parameters().href(); + } + } + + public TagCollectionLinks tagCollection() { + return new TagCollectionLinks(uriInfoStore.get()); + } + + static class TagCollectionLinks { + private final LinkBuilder tagLinkBuilder; + + TagCollectionLinks(UriInfo uriInfo) { + tagLinkBuilder = new LinkBuilder(uriInfo, RepositoryRootResource.class, RepositoryResource.class, TagRootResource.class, TagCollectionResource.class); + } + + String self(String namespace, String name) { + return tagLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("tags").parameters().method("getTagCollectionResource").parameters().method("getAll").parameters().href(); + } + } + + public BranchCollectionLinks branchCollection() { + return new BranchCollectionLinks(uriInfoStore.get()); + } + + static class BranchCollectionLinks { + private final LinkBuilder branchLinkBuilder; + + BranchCollectionLinks(UriInfo uriInfo) { + branchLinkBuilder = new LinkBuilder(uriInfo, RepositoryRootResource.class, RepositoryResource.class, BranchRootResource.class, BranchCollectionResource.class); + } + + String self(String namespace, String name) { + return branchLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("branches").parameters().method("getBranchCollectionResource").parameters().method("getAll").parameters().href(); + } + } + + public ChangesetCollectionLinks changesetCollection() { + return new ChangesetCollectionLinks(uriInfoStore.get()); + } + + static class ChangesetCollectionLinks { + private final LinkBuilder changesetLinkBuilder; + + ChangesetCollectionLinks(UriInfo uriInfo) { + changesetLinkBuilder = new LinkBuilder(uriInfo, RepositoryRootResource.class, RepositoryResource.class, ChangesetRootResource.class, ChangesetCollectionResource.class); + } + + String self(String namespace, String name) { + return changesetLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("changesets").parameters().method("getChangesetCollectionResource").parameters().method("getAll").parameters().href(); + } + } + + public SourceCollectionLinks sourceCollection() { + return new SourceCollectionLinks(uriInfoStore.get()); + } + + static class SourceCollectionLinks { + private final LinkBuilder sourceLinkBuilder; + + SourceCollectionLinks(UriInfo uriInfo) { + sourceLinkBuilder = new LinkBuilder(uriInfo, RepositoryRootResource.class, RepositoryResource.class, SourceRootResource.class, SourceCollectionResource.class); + } + + String self(String namespace, String name) { + return sourceLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("sources").parameters().method("getSourceCollectionResource").parameters().method("getAll").parameters().href(); + } + } + + public PermissionCollectionLinks permissionCollection() { + return new PermissionCollectionLinks(uriInfoStore.get()); + } + + static class PermissionCollectionLinks { + private final LinkBuilder permissionLinkBuilder; + + PermissionCollectionLinks(UriInfo uriInfo) { + permissionLinkBuilder = new LinkBuilder(uriInfo, RepositoryRootResource.class, RepositoryResource.class, PermissionRootResource.class, PermissionCollectionResource.class); + } + + String self(String namespace, String name) { + return permissionLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("permissions").parameters().method("getPermissionCollectionResource").parameters().method("getAll").parameters().href(); + } + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java new file mode 100644 index 0000000000..7f8b115dee --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java @@ -0,0 +1,87 @@ +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.HalRepresentation; +import sonia.scm.Manager; +import sonia.scm.ModelObject; +import sonia.scm.api.rest.resources.AbstractManagerResource; + +import javax.ws.rs.core.GenericEntity; +import javax.ws.rs.core.Response; +import java.util.Collection; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import static javax.ws.rs.core.Response.Status.BAD_REQUEST; + +/** + * Adapter from resource http endpoints to managers, for Single resources (e.g. {@code /user/name}). + * + * Provides common CRUD operations and DTO to Model Object mapping to keep Resources more DRY. + * + * @param The type of the model object, eg. {@link sonia.scm.user.User}. + * @param The corresponding transport object, eg. {@link UserDto}. + * @param The exception type for the model object, eg. {@link sonia.scm.user.UserException}. + * + * @see CollectionResourceManagerAdapter + */ +@SuppressWarnings("squid:S00119") // "MODEL_OBJECT" is much more meaningful than "M", right? +class SingleResourceManagerAdapter extends AbstractManagerResource { + + SingleResourceManagerAdapter(Manager manager, Class type) { + super(manager, type); + } + + /** + * Reads the model object for the given id, transforms it to a dto and returns a corresponding http response. + * This handles all corner cases, eg. no matching object for the id or missing privileges. + */ + Response get(Supplier> reader, Function mapToDto) { + return reader.get() + .map(mapToDto) + .map(Response::ok) + .map(Response.ResponseBuilder::build) + .orElse(Response.status(Response.Status.NOT_FOUND).build()); + } + + /** + * Update the model object for the given id according to the given function and returns a corresponding http response. + * This handles all corner cases, eg. no matching object for the id or missing privileges. + */ + public Response update(Supplier> reader, Function applyChanges, Predicate hasSameKey) { + Optional existingModelObject = reader.get(); + if (!existingModelObject.isPresent()) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + MODEL_OBJECT changedModelObject = applyChanges.apply(existingModelObject.get()); + if (!hasSameKey.test(changedModelObject)) { + return Response.status(BAD_REQUEST).entity("illegal change of id").build(); + } + return update(getId(existingModelObject.get()), changedModelObject); + } + + public Response delete(Supplier> reader) { + return reader.get() + .map(MODEL_OBJECT::getId) + .map(this::delete) + .orElse(null); + } + + @Override + protected GenericEntity> createGenericEntity(Collection modelObjects) { + throw new UnsupportedOperationException(); + } + + @Override + protected String getId(MODEL_OBJECT item) { + return item.getId(); + } + + @Override + protected String getPathPart() { + throw new UnsupportedOperationException(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceCollectionResource.java new file mode 100644 index 0000000000..d3f00a0b51 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceCollectionResource.java @@ -0,0 +1,18 @@ +package sonia.scm.api.v2.resources; + +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Response; + +public class SourceCollectionResource { + @GET + @Path("") + public Response getAll(@DefaultValue("0") @QueryParam("page") int page, + @DefaultValue("10") @QueryParam("pageSize") int pageSize, + @QueryParam("sortBy") String sortBy, + @DefaultValue("false") @QueryParam("desc") boolean desc) { + throw new UnsupportedOperationException(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java new file mode 100644 index 0000000000..f58cbb6dff --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java @@ -0,0 +1,20 @@ +package sonia.scm.api.v2.resources; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.ws.rs.Path; + +public class SourceRootResource { + + private final Provider sourceCollectionResource; + + @Inject + public SourceRootResource(Provider sourceCollectionResource) { + this.sourceCollectionResource = sourceCollectionResource; + } + + @Path("") + public SourceCollectionResource getSourceCollectionResource() { + return sourceCollectionResource.get(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagCollectionResource.java new file mode 100644 index 0000000000..3dbba0cb0e --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagCollectionResource.java @@ -0,0 +1,18 @@ +package sonia.scm.api.v2.resources; + +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Response; + +public class TagCollectionResource { + @GET + @Path("") + public Response getAll(@DefaultValue("0") @QueryParam("page") int page, + @DefaultValue("10") @QueryParam("pageSize") int pageSize, + @QueryParam("sortBy") String sortBy, + @DefaultValue("false") @QueryParam("desc") boolean desc) { + throw new UnsupportedOperationException(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRootResource.java new file mode 100644 index 0000000000..29a2a922ca --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/TagRootResource.java @@ -0,0 +1,20 @@ +package sonia.scm.api.v2.resources; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.ws.rs.Path; + +public class TagRootResource { + + private final Provider tagCollectionResource; + + @Inject + public TagRootResource(Provider tagCollectionResource) { + this.tagCollectionResource = tagCollectionResource; + } + + @Path("") + public TagCollectionResource getTagCollectionResource() { + return tagCollectionResource.get(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java index c269cd9f90..578361424c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java @@ -28,14 +28,14 @@ public class UserCollectionResource { private final UserCollectionToDtoMapper userCollectionToDtoMapper; private final ResourceLinks resourceLinks; - private final ResourceManagerAdapter adapter; + private final IdResourceManagerAdapter adapter; @Inject public UserCollectionResource(UserManager manager, UserDtoToUserMapper dtoToUserMapper, UserCollectionToDtoMapper userCollectionToDtoMapper, ResourceLinks resourceLinks) { this.dtoToUserMapper = dtoToUserMapper; this.userCollectionToDtoMapper = userCollectionToDtoMapper; - this.adapter = new ResourceManagerAdapter<>(manager, User.class); + this.adapter = new IdResourceManagerAdapter<>(manager, User.class); this.resourceLinks = resourceLinks; } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java index 7805ba3440..2dc070d815 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java @@ -23,13 +23,13 @@ public class UserResource { private final UserDtoToUserMapper dtoToUserMapper; private final UserToUserDtoMapper userToDtoMapper; - private final ResourceManagerAdapter adapter; + private final IdResourceManagerAdapter adapter; @Inject public UserResource(UserDtoToUserMapper dtoToUserMapper, UserToUserDtoMapper userToDtoMapper, UserManager manager) { this.dtoToUserMapper = dtoToUserMapper; this.userToDtoMapper = userToDtoMapper; - this.adapter = new ResourceManagerAdapter<>(manager, User.class); + this.adapter = new IdResourceManagerAdapter<>(manager, User.class); } /** diff --git a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupManager.java b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupManager.java index 0646b109d8..ed1d7723b2 100644 --- a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupManager.java +++ b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupManager.java @@ -111,7 +111,7 @@ public class DefaultGroupManager extends AbstractGroupManager * @throws IOException */ @Override - public void create(Group group) throws GroupException + public Group create(Group group) throws GroupException { String type = group.getType(); @@ -140,6 +140,7 @@ public class DefaultGroupManager extends AbstractGroupManager fireEvent(HandlerEventType.BEFORE_CREATE, group); groupDAO.add(group); fireEvent(HandlerEventType.CREATE, group); + return group; } /** diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java index c644034df9..47b0e247c1 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java @@ -128,7 +128,7 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { } } - public void create(Repository repository, boolean initRepository) + public Repository create(Repository repository, boolean initRepository) throws RepositoryException { logger.info("create repository {} of type {}", repository.getName(), repository.getType()); @@ -153,12 +153,13 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { fireEvent(HandlerEventType.BEFORE_CREATE, repository); repositoryDAO.add(repository); fireEvent(HandlerEventType.CREATE, repository); + return repository; } @Override - public void create(Repository repository) + public Repository create(Repository repository) throws RepositoryException { - create(repository, true); + return create(repository, true); } @Override diff --git a/scm-webapp/src/main/java/sonia/scm/user/DefaultUserManager.java b/scm-webapp/src/main/java/sonia/scm/user/DefaultUserManager.java index 7d6a45b790..5a13a7fef8 100644 --- a/scm-webapp/src/main/java/sonia/scm/user/DefaultUserManager.java +++ b/scm-webapp/src/main/java/sonia/scm/user/DefaultUserManager.java @@ -137,7 +137,7 @@ public class DefaultUserManager extends AbstractUserManager * @throws UserException */ @Override - public void create(User user) throws UserException + public User create(User user) throws UserException { String type = user.getType(); @@ -163,6 +163,7 @@ public class DefaultUserManager extends AbstractUserManager fireEvent(HandlerEventType.BEFORE_CREATE, user); userDAO.add(user); fireEvent(HandlerEventType.CREATE, user); + return user; } /** diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupRootResourceTest.java index 7f88ceb50f..3302d6ef6e 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupRootResourceTest.java @@ -10,7 +10,6 @@ import org.jboss.resteasy.mock.MockHttpResponse; import org.junit.Before; import org.junit.Rule; import org.junit.Test; -import org.mockito.Answers; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -49,8 +48,7 @@ public class GroupRootResourceTest { private Dispatcher dispatcher = MockDispatcherFactory.createDispatcher(); - @Mock(answer = Answers.RETURNS_DEEP_STUBS) - private ResourceLinks resourceLinks; + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/")); @Mock private GroupManager groupManager; @@ -64,15 +62,13 @@ public class GroupRootResourceTest { @Before public void prepareEnvironment() throws IOException, GroupException { initMocks(this); - doNothing().when(groupManager).create(groupCaptor.capture()); + when(groupManager.create(groupCaptor.capture())).thenAnswer(invocation -> invocation.getArguments()[0]); doNothing().when(groupManager).modify(groupCaptor.capture()); Group group = createDummyGroup(); when(groupManager.getPage(any(), eq(0), eq(10))).thenReturn(new PageResult<>(singletonList(group), 1)); when(groupManager.get("admin")).thenReturn(group); - ResourceLinksMock.initMock(resourceLinks, URI.create("/")); - GroupCollectionToDtoMapper groupCollectionToDtoMapper = new GroupCollectionToDtoMapper(groupToDtoMapper, resourceLinks); GroupCollectionResource groupCollectionResource = new GroupCollectionResource(groupManager, dtoToGroupMapper, groupCollectionToDtoMapper, resourceLinks); GroupResource groupResource = new GroupResource(groupManager, groupToDtoMapper, dtoToGroupMapper); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapperTest.java index 1db089cd0b..e519a9c3e5 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupToGroupDtoMapperTest.java @@ -7,9 +7,7 @@ import org.apache.shiro.util.ThreadState; import org.junit.After; import org.junit.Before; import org.junit.Test; -import org.mockito.Answers; import org.mockito.InjectMocks; -import org.mockito.Mock; import sonia.scm.group.Group; import java.net.URI; @@ -24,8 +22,9 @@ import static org.mockito.MockitoAnnotations.initMocks; public class GroupToGroupDtoMapperTest { - @Mock(answer = Answers.RETURNS_DEEP_STUBS) - private ResourceLinks resourceLinks; + private final URI baseUri = URI.create("http://example.com/base/"); + @SuppressWarnings("unused") // Is injected + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); @InjectMocks private GroupToGroupDtoMapperImpl mapper; @@ -38,12 +37,8 @@ public class GroupToGroupDtoMapperTest { @Before public void init() throws URISyntaxException { initMocks(this); - URI baseUri = new URI("http://example.com/base/"); expectedBaseUri = baseUri.resolve(GroupRootResource.GROUPS_PATH_V2 + "/"); subjectThreadState.bind(); - - ResourceLinksMock.initMock(resourceLinks, baseUri); - ThreadContext.bind(subject); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MapperModuleTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MapperModuleTest.java new file mode 100644 index 0000000000..a792d40e76 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MapperModuleTest.java @@ -0,0 +1,33 @@ +package sonia.scm.api.v2.resources; + +import com.google.inject.binder.AnnotatedBindingBuilder; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MapperModuleTest { + + @Test + public void shouldBindToClassesWithDefaultConstructorOnly() { + AnnotatedBindingBuilder binding = mock(AnnotatedBindingBuilder.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(Class.class); + when(binding.to(captor.capture())).thenReturn(null); + new MapperModule() { + @Override + protected AnnotatedBindingBuilder bind(Class clazz) { + return binding; + } + }.configure(); + captor.getAllValues().forEach(this::verifyClassCanBeInstantiated); + } + + private T verifyClassCanBeInstantiated(Class c) { + try { + return c.getConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java new file mode 100644 index 0000000000..24a11fbf09 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java @@ -0,0 +1,237 @@ +package sonia.scm.api.v2.resources; + +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import com.google.common.io.Resources; +import org.jboss.resteasy.core.Dispatcher; +import org.jboss.resteasy.mock.MockDispatcherFactory; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import sonia.scm.PageResult; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryException; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.web.VndMediaType; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; + +import static java.util.Collections.singletonList; +import static java.util.Optional.empty; +import static java.util.Optional.of; +import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; +import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT; +import static javax.servlet.http.HttpServletResponse.SC_OK; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyObject; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +@SubjectAware( + username = "trillian", + password = "secret", + configuration = "classpath:sonia/scm/repository/shiro.ini" +) +public class RepositoryRootResourceTest { + + private final Dispatcher dispatcher = MockDispatcherFactory.createDispatcher(); + + @Rule + public ShiroRule shiro = new ShiroRule(); + + @Mock + private RepositoryManager repositoryManager; + + private final URI baseUri = URI.create("/"); + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + + @InjectMocks + private RepositoryToRepositoryDtoMapperImpl repositoryToDtoMapper; + @InjectMocks + private RepositoryDtoToRepositoryMapperImpl dtoToRepositoryMapper; + + @Before + public void prepareEnvironment() { + initMocks(this); + RepositoryResource repositoryResource = new RepositoryResource(repositoryToDtoMapper, dtoToRepositoryMapper, repositoryManager, null, null, null, null, null); + RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper = new RepositoryCollectionToDtoMapper(repositoryToDtoMapper, resourceLinks); + RepositoryCollectionResource repositoryCollectionResource = new RepositoryCollectionResource(repositoryManager, repositoryCollectionToDtoMapper, dtoToRepositoryMapper, resourceLinks); + RepositoryRootResource repositoryRootResource = new RepositoryRootResource(MockProvider.of(repositoryResource), MockProvider.of(repositoryCollectionResource)); + dispatcher.getRegistry().addSingletonResource(repositoryRootResource); + } + + @Test + public void shouldFailForNotExistingRepository() throws URISyntaxException { + when(repositoryManager.getByNamespace(anyString(), anyString())).thenReturn(empty()); + mockRepository("space", "repo"); + + MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/other"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_NOT_FOUND, response.getStatus()); + } + + @Test + public void shouldFindExistingRepository() throws URISyntaxException { + mockRepository("space", "repo"); + + MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_OK, response.getStatus()); + assertTrue(response.getContentAsString().contains("\"name\":\"repo\"")); + } + + @Test + public void shouldMapProperties() throws URISyntaxException { + Repository repository = mockRepository("space", "repo"); + repository.setProperty("testKey", "testValue"); + + MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertTrue(response.getContentAsString().contains("\"testKey\":\"testValue\"")); + } + + @Test + public void shouldGetAll() throws URISyntaxException { + PageResult singletonPageResult = createSingletonPageResult(mockRepository("space", "repo")); + when(repositoryManager.getPage(any(), eq(0), eq(10))).thenReturn(singletonPageResult); + + MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_OK, response.getStatus()); + assertTrue(response.getContentAsString().contains("\"name\":\"repo\"")); + } + + @Test + public void shouldHandleUpdateForNotExistingRepository() throws URISyntaxException, IOException { + URL url = Resources.getResource("sonia/scm/api/v2/repository-test-update.json"); + byte[] repository = Resources.toByteArray(url); + when(repositoryManager.getByNamespace(anyString(), anyString())).thenReturn(empty()); + + MockHttpRequest request = MockHttpRequest + .put("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo") + .contentType(VndMediaType.REPOSITORY) + .content(repository); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_NOT_FOUND, response.getStatus()); + } + + @Test + public void shouldHandleUpdateForExistingRepository() throws Exception { + mockRepository("space", "repo"); + + URL url = Resources.getResource("sonia/scm/api/v2/repository-test-update.json"); + byte[] repository = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .put("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo") + .contentType(VndMediaType.REPOSITORY) + .content(repository); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_NO_CONTENT, response.getStatus()); + verify(repositoryManager).modify(anyObject()); + } + + @Test + public void shouldHandleUpdateForExistingRepositoryForChangedNamespace() throws Exception { + mockRepository("wrong", "repo"); + + URL url = Resources.getResource("sonia/scm/api/v2/repository-test-update.json"); + byte[] repository = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .put("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "wrong/repo") + .contentType(VndMediaType.REPOSITORY) + .content(repository); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_BAD_REQUEST, response.getStatus()); + verify(repositoryManager, never()).modify(anyObject()); + } + + @Test + public void shouldHandleDeleteForExistingRepository() throws Exception { + mockRepository("space", "repo"); + + MockHttpRequest request = MockHttpRequest.delete("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_NO_CONTENT, response.getStatus()); + verify(repositoryManager).delete(anyObject()); + } + + @Test + public void shouldCreateNewRepositoryInCorrectNamespace() throws URISyntaxException, IOException, RepositoryException { + when(repositoryManager.create(any())).thenAnswer(invocation -> { + Repository repository = (Repository) invocation.getArguments()[0]; + repository.setNamespace("otherspace"); + return repository; + }); + + URL url = Resources.getResource("sonia/scm/api/v2/repository-test-update.json"); + byte[] repositoryJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2) + .contentType(VndMediaType.REPOSITORY) + .content(repositoryJson); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_CREATED, response.getStatus()); + assertEquals("/v2/repositories/otherspace/repo", response.getOutputHeaders().get("Location").get(0).toString()); + verify(repositoryManager).create(any(Repository.class)); + } + + private PageResult createSingletonPageResult(Repository repository) { + return new PageResult<>(singletonList(repository), 0); + } + + private Repository mockRepository(String namespace, String name) { + Repository repository = new Repository(); + repository.setNamespace(namespace); + repository.setName(name); + String id = namespace + "-" + name; + repository.setId(id); + when(repositoryManager.getByNamespace(namespace, name)).thenReturn(of(repository)); + when(repositoryManager.get(id)).thenReturn(repository); + return repository; + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java new file mode 100644 index 0000000000..ad6ff3cae7 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java @@ -0,0 +1,159 @@ +package sonia.scm.api.v2.resources; + +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import org.apache.shiro.util.ThreadContext; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.InjectMocks; +import sonia.scm.repository.HealthCheckFailure; +import sonia.scm.repository.Permission; +import sonia.scm.repository.PermissionType; +import sonia.scm.repository.Repository; + +import java.net.URI; + +import static java.util.Collections.singletonList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.mockito.MockitoAnnotations.initMocks; + +@SubjectAware( + username = "trillian", + password = "secret", + configuration = "classpath:sonia/scm/repository/shiro.ini" +) +public class RepositoryToRepositoryDtoMapperTest { + + @Rule + public final ShiroRule rule = new ShiroRule(); + + private final URI baseUri = URI.create("http://example.com/base/"); + @SuppressWarnings("unused") // Is injected + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + + @InjectMocks + private RepositoryToRepositoryDtoMapperImpl mapper; + + @Before + public void init() { + initMocks(this); + } + + @After + public void cleanup() { + ThreadContext.unbindSubject(); + } + + @Test + public void shouldMapSimpleProperties() { + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals("testspace", dto.getNamespace()); + assertEquals("test", dto.getName()); + assertEquals("description", dto.getDescription()); + assertEquals("git", dto.getType()); + assertEquals("none@example.com", dto.getContact()); + } + + @Test + public void shouldMapPropertiesProperty() { + Repository repository = createTestRepository(); + repository.setProperty("testKey", "testValue"); + + RepositoryDto dto = mapper.map(repository); + + assertEquals("testValue", dto.getProperties().get("testKey")); + } + + @Test + @SubjectAware(username = "unpriv") + public void shouldCreateLinksForUnprivilegedUser() { + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals( + "http://example.com/base/v2/repositories/testspace/test", + dto.getLinks().getLinkBy("self").get().getHref()); + assertFalse(dto.getLinks().getLinkBy("update").isPresent()); + assertFalse(dto.getLinks().getLinkBy("delete").isPresent()); + assertFalse(dto.getLinks().getLinkBy("permissions").isPresent()); + } + + @Test + public void shouldCreateDeleteLink() { + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals( + "http://example.com/base/v2/repositories/testspace/test", + dto.getLinks().getLinkBy("delete").get().getHref()); + } + + @Test + public void shouldCreateUpdateLink() { + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals( + "http://example.com/base/v2/repositories/testspace/test", + dto.getLinks().getLinkBy("update").get().getHref()); + } + + @Test + public void shouldMapHealthCheck() { + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals(1, dto.getHealthCheckFailures().size()); + assertEquals("summary", dto.getHealthCheckFailures().get(0).getSummary()); + } + + @Test + public void shouldCreateTagsLink() { + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals( + "http://example.com/base/v2/repositories/testspace/test/tags/", + dto.getLinks().getLinkBy("tags").get().getHref()); + } + + @Test + public void shouldCreateBranchesLink() { + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals( + "http://example.com/base/v2/repositories/testspace/test/branches/", + dto.getLinks().getLinkBy("branches").get().getHref()); + } + + @Test + public void shouldCreateChangesetsLink() { + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals( + "http://example.com/base/v2/repositories/testspace/test/changesets/", + dto.getLinks().getLinkBy("changesets").get().getHref()); + } + + @Test + public void shouldCreateSourcesLink() { + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals( + "http://example.com/base/v2/repositories/testspace/test/sources/", + dto.getLinks().getLinkBy("sources").get().getHref()); + } + + @Test + public void shouldCreatePermissionsLink() { + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals( + "http://example.com/base/v2/repositories/testspace/test/permissions/", + dto.getLinks().getLinkBy("permissions").get().getHref()); + } + + private Repository createTestRepository() { + Repository repository = new Repository(); + repository.setNamespace("testspace"); + repository.setName("test"); + repository.setDescription("description"); + repository.setType("git"); + repository.setContact("none@example.com"); + repository.setId("1"); + repository.setCreationDate(System.currentTimeMillis()); + repository.setHealthCheckFailures(singletonList(new HealthCheckFailure("1", "summary", "url", "failure"))); + repository.setPermissions(singletonList(new Permission("permission", PermissionType.READ))); + + return repository; + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index 119ced5397..b35bc3820c 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -1,26 +1,29 @@ package sonia.scm.api.v2.resources; +import javax.ws.rs.core.UriInfo; import java.net.URI; -import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import static sonia.scm.api.v2.resources.GroupRootResource.GROUPS_PATH_V2; -import static sonia.scm.api.v2.resources.UserRootResource.USERS_PATH_V2; public class ResourceLinksMock { - public static void initMock(ResourceLinks resourceLinks, URI baseUri) { - when(resourceLinks.user().self(anyString())).thenAnswer(invocation -> baseUri + USERS_PATH_V2 + invocation.getArguments()[0]); - when(resourceLinks.user().update(anyString())).thenAnswer(invocation -> baseUri + USERS_PATH_V2 + invocation.getArguments()[0]); - when(resourceLinks.user().delete(anyString())).thenAnswer(invocation -> baseUri + USERS_PATH_V2 + invocation.getArguments()[0]); + public static ResourceLinks createMock(URI baseUri) { + ResourceLinks resourceLinks = mock(ResourceLinks.class); - when(resourceLinks.userCollection().self()).thenAnswer(invocation -> baseUri + USERS_PATH_V2); - when(resourceLinks.userCollection().create()).thenAnswer(invocation -> baseUri + USERS_PATH_V2); + UriInfo uriInfo = mock(UriInfo.class); + when(uriInfo.getBaseUri()).thenReturn(baseUri); - when(resourceLinks.group().self(anyString())).thenAnswer(invocation -> baseUri + GROUPS_PATH_V2 + invocation.getArguments()[0]); - when(resourceLinks.group().update(anyString())).thenAnswer(invocation -> baseUri + GROUPS_PATH_V2 + invocation.getArguments()[0]); - when(resourceLinks.group().delete(anyString())).thenAnswer(invocation -> baseUri + GROUPS_PATH_V2 + invocation.getArguments()[0]); - - when(resourceLinks.groupCollection().self()).thenAnswer(invocation -> baseUri + GROUPS_PATH_V2); - when(resourceLinks.groupCollection().create()).thenAnswer(invocation -> baseUri + GROUPS_PATH_V2); + when(resourceLinks.user()).thenReturn(new ResourceLinks.UserLinks(uriInfo)); + when(resourceLinks.userCollection()).thenReturn(new ResourceLinks.UserCollectionLinks(uriInfo)); + when(resourceLinks.group()).thenReturn(new ResourceLinks.GroupLinks(uriInfo)); + when(resourceLinks.groupCollection()).thenReturn(new ResourceLinks.GroupCollectionLinks(uriInfo)); + when(resourceLinks.repository()).thenReturn(new ResourceLinks.RepositoryLinks(uriInfo)); + when(resourceLinks.repositoryCollection()).thenReturn(new ResourceLinks.RepositoryCollectionLinks(uriInfo)); + when(resourceLinks.tagCollection()).thenReturn(new ResourceLinks.TagCollectionLinks(uriInfo)); + when(resourceLinks.branchCollection()).thenReturn(new ResourceLinks.BranchCollectionLinks(uriInfo)); + when(resourceLinks.changesetCollection()).thenReturn(new ResourceLinks.ChangesetCollectionLinks(uriInfo)); + when(resourceLinks.sourceCollection()).thenReturn(new ResourceLinks.SourceCollectionLinks(uriInfo)); + when(resourceLinks.permissionCollection()).thenReturn(new ResourceLinks.PermissionCollectionLinks(uriInfo)); + return resourceLinks; } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java index 103eebd411..c4bc5c73cd 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java @@ -84,6 +84,54 @@ public class ResourceLinksTest { assertEquals(BASE_URL + GroupRootResource.GROUPS_PATH_V2, url); } + @Test + public void shouldCreateCorrectRepositorySelfUrl() { + String url = resourceLinks.repository().self("space", "repo"); + assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo", url); + } + + @Test + public void shouldCreateCorrectRepositoryDeleteUrl() { + String url = resourceLinks.repository().delete("space", "repo"); + assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo", url); + } + + @Test + public void shouldCreateCorrectRepositoryUpdateUrl() { + String url = resourceLinks.repository().update("space", "repo"); + assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo", url); + } + + @Test + public void shouldCreateCorrectTagCollectionUrl() { + String url = resourceLinks.tagCollection().self("space", "repo"); + assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/tags/", url); + } + + @Test + public void shouldCreateCorrectBranchCollectionUrl() { + String url = resourceLinks.branchCollection().self("space", "repo"); + assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/branches/", url); + } + + @Test + public void shouldCreateCorrectChangesetCollectionUrl() { + String url = resourceLinks.changesetCollection().self("space", "repo"); + assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/changesets/", url); + } + + @Test + public void shouldCreateCorrectSourceCollectionUrl() { + String url = resourceLinks.sourceCollection().self("space", "repo"); + assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/sources/", url); + } + + @Test + public void shouldCreateCorrectPermissionCollectionUrl() { + String url = resourceLinks.sourceCollection().self("space", "repo"); + assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/sources/", url); + } + @Before public void initUriInfo() { initMocks(this); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserCollectionToDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserCollectionToDtoMapperTest.java index b9ed8558d4..4d2bf7c62e 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserCollectionToDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserCollectionToDtoMapperTest.java @@ -7,7 +7,6 @@ import org.apache.shiro.util.ThreadContext; import org.junit.After; import org.junit.Before; import org.junit.Test; -import org.mockito.Answers; import org.mockito.InjectMocks; import org.mockito.Mock; import sonia.scm.PageResult; @@ -28,8 +27,8 @@ import static sonia.scm.PageResult.createPage; public class UserCollectionToDtoMapperTest { - @Mock(answer = Answers.RETURNS_DEEP_STUBS) - private ResourceLinks resourceLinks; + private final URI baseUri = URI.create("http://example.com/base/"); + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); @Mock private UserToUserDtoMapper userToDtoMapper; @Mock @@ -45,9 +44,7 @@ public class UserCollectionToDtoMapperTest { @Before public void init() throws URISyntaxException { initMocks(this); - URI baseUri = new URI("http://example.com/base/"); expectedBaseUri = baseUri.resolve(UserRootResource.USERS_PATH_V2 + "/"); - ResourceLinksMock.initMock(resourceLinks, baseUri); subjectThreadState.bind(); ThreadContext.bind(subject); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java index 077cbef9b2..049d8ab1c8 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java @@ -11,7 +11,6 @@ import org.jboss.resteasy.mock.MockHttpResponse; import org.junit.Before; import org.junit.Rule; import org.junit.Test; -import org.mockito.Answers; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -51,8 +50,7 @@ public class UserRootResourceTest { private Dispatcher dispatcher = MockDispatcherFactory.createDispatcher(); - @Mock(answer = Answers.RETURNS_DEEP_STUBS) - private ResourceLinks resourceLinks; + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/")); @Mock private PasswordService passwordService; @@ -66,15 +64,13 @@ public class UserRootResourceTest { private ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); @Before - public void prepareEnvironment() throws IOException, UserException { + public void prepareEnvironment() throws UserException { initMocks(this); User dummyUser = createDummyUser("Neo"); - doNothing().when(userManager).create(userCaptor.capture()); + when(userManager.create(userCaptor.capture())).thenAnswer(invocation -> invocation.getArguments()[0]); doNothing().when(userManager).modify(userCaptor.capture()); doNothing().when(userManager).delete(userCaptor.capture()); - ResourceLinksMock.initMock(resourceLinks, URI.create("/")); - UserCollectionToDtoMapper userCollectionToDtoMapper = new UserCollectionToDtoMapper(userToDtoMapper, resourceLinks); UserCollectionResource userCollectionResource = new UserCollectionResource(userManager, dtoToUserMapper, userCollectionToDtoMapper, resourceLinks); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java index ae3168cc07..330dd2a89e 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java @@ -7,14 +7,11 @@ import org.apache.shiro.util.ThreadState; import org.junit.After; import org.junit.Before; import org.junit.Test; -import org.mockito.Answers; import org.mockito.InjectMocks; -import org.mockito.Mock; import sonia.scm.api.rest.resources.UserResource; import sonia.scm.user.User; import java.net.URI; -import java.net.URISyntaxException; import java.time.Instant; import static org.junit.Assert.assertEquals; @@ -25,8 +22,9 @@ import static org.mockito.MockitoAnnotations.initMocks; public class UserToUserDtoMapperTest { - @Mock(answer = Answers.RETURNS_DEEP_STUBS) - private ResourceLinks resourceLinks; + private final URI baseUri = URI.create("http://example.com/base/"); + @SuppressWarnings("unused") // Is injected + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); @InjectMocks private UserToUserDtoMapperImpl mapper; @@ -37,12 +35,10 @@ public class UserToUserDtoMapperTest { private URI expectedBaseUri; @Before - public void init() throws URISyntaxException { + public void init() { initMocks(this); - URI baseUri = new URI("http://example.com/base/"); expectedBaseUri = baseUri.resolve(UserRootResource.USERS_PATH_V2 + "/"); subjectThreadState.bind(); - ResourceLinksMock.initMock(resourceLinks, baseUri); ThreadContext.bind(subject); } diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java index e76d2af7b3..f8fb0857c1 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java @@ -37,6 +37,7 @@ import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; import com.google.common.collect.ImmutableSet; import org.apache.shiro.authz.UnauthorizedException; +import org.apache.shiro.util.ThreadContext; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/repository-test-update.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/repository-test-update.json new file mode 100644 index 0000000000..660fa256bf --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/repository-test-update.json @@ -0,0 +1,8 @@ +{ + "contact": "none@example.com", + "description": "Test repository", + "namespace": "space", + "name": "repo", + "archived": false, + "type": "git" +}