Merge with default

This commit is contained in:
Rene Pfeuffer
2020-02-25 16:37:17 +01:00
87 changed files with 3348 additions and 1557 deletions

View File

@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added footer extension points for links and avatar
- Create OpenAPI specification during build
- Extension point entries with supplied extensionName are sorted ascending
### Changed
- New footer design
### Fixed
- Modification for mercurial repositories with enabled XSRF protection
### Removed
- Enunciate rest documentation
## 2.0.0-rc4 - 2020-02-14 ## 2.0.0-rc4 - 2020-02-14
### Added ### Added
- Support for Java versions > 8 - Support for Java versions > 8

3
Jenkinsfile vendored
View File

@@ -29,7 +29,7 @@ node('docker') {
} }
stage('Build') { stage('Build') {
mvn 'clean install -Pdoc -DskipTests' mvn 'clean install -DskipTests'
} }
stage('Unit Test') { stage('Unit Test') {
@@ -67,7 +67,6 @@ node('docker') {
stage('Archive') { stage('Archive') {
archiveArtifacts 'scm-webapp/target/scm-webapp.war' archiveArtifacts 'scm-webapp/target/scm-webapp.war'
archiveArtifacts 'scm-server/target/scm-server-app.*' archiveArtifacts 'scm-server/target/scm-server-app.*'
archiveArtifacts 'scm-webapp/target/scm-webapp-restdocs.zip'
} }
stage('Docker') { stage('Docker') {

31
pom.xml
View File

@@ -184,12 +184,6 @@
<optional>true</optional> <optional>true</optional>
</dependency> </dependency>
<dependency>
<groupId>com.webcohesion.enunciate</groupId>
<artifactId>enunciate-core-annotations</artifactId>
<version>${enunciate.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.mapstruct</groupId> <groupId>org.mapstruct</groupId>
<artifactId>mapstruct-jdk8</artifactId> <artifactId>mapstruct-jdk8</artifactId>
@@ -266,6 +260,12 @@
<version>${jaxrs.version}</version> <version>${jaxrs.version}</version>
</dependency> </dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations</artifactId>
<version>2.1.1</version>
</dependency>
<dependency> <dependency>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId> <artifactId>jackson-core</artifactId>
@@ -447,16 +447,10 @@
<version>2.3</version> <version>2.3</version>
</plugin> </plugin>
<plugin>
<groupId>com.webcohesion.enunciate</groupId>
<artifactId>enunciate-maven-plugin</artifactId>
<version>${enunciate.version}</version>
</plugin>
<plugin> <plugin>
<groupId>sonia.scm.maven</groupId> <groupId>sonia.scm.maven</groupId>
<artifactId>smp-maven-plugin</artifactId> <artifactId>smp-maven-plugin</artifactId>
<version>1.0.0-rc3</version> <version>1.0.0-rc4</version>
</plugin> </plugin>
<plugin> <plugin>
@@ -465,6 +459,12 @@
<version>2.8.2</version> <version>2.8.2</version>
</plugin> </plugin>
<plugin>
<groupId>io.openapitools.swagger</groupId>
<artifactId>swagger-maven-plugin</artifactId>
<version>2.1.2</version>
</plugin>
</plugins> </plugins>
</pluginManagement> </pluginManagement>
@@ -831,7 +831,6 @@
<jaxrs.version>2.1.1</jaxrs.version> <jaxrs.version>2.1.1</jaxrs.version>
<resteasy.version>4.4.1.Final</resteasy.version> <resteasy.version>4.4.1.Final</resteasy.version>
<jersey-client.version>1.19.4</jersey-client.version> <jersey-client.version>1.19.4</jersey-client.version>
<enunciate.version>2.11.1</enunciate.version>
<jackson.version>2.10.0</jackson.version> <jackson.version>2.10.0</jackson.version>
<guice.version>4.0</guice.version> <guice.version>4.0</guice.version>
<jaxb.version>2.3.0</jaxb.version> <jaxb.version>2.3.0</jaxb.version>
@@ -856,8 +855,8 @@
<guava.version>26.0-jre</guava.version> <guava.version>26.0-jre</guava.version>
<!-- frontend --> <!-- frontend -->
<nodejs.version>10.16.0</nodejs.version> <nodejs.version>12.16.1</nodejs.version>
<yarn.version>1.16.0</yarn.version> <yarn.version>1.22.0</yarn.version>
<!-- build properties --> <!-- build properties -->
<project.build.javaLevel>8</project.build.javaLevel> <project.build.javaLevel>8</project.build.javaLevel>

View File

@@ -137,12 +137,6 @@
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<!-- rest documentation -->
<dependency>
<groupId>com.webcohesion.enunciate</groupId>
<artifactId>enunciate-core-annotations</artifactId>
</dependency>
<!-- event bus --> <!-- event bus -->
<dependency> <dependency>

View File

@@ -32,15 +32,15 @@ package sonia.scm.security;
/** /**
* Shared constants for Xsrf related classes. * Shared constants for Xsrf related classes.
* *
* @author Sebastian Sdorra * @author Sebastian Sdorra
* @since 2.0.0 * @since 2.0.0
*/ */
public final class Xsrf { public final class Xsrf {
static final String HEADER_KEY = "X-XSRF-Token"; public static final String HEADER_KEY = "X-XSRF-Token";
static final String TOKEN_KEY = "xsrf"; public static final String TOKEN_KEY = "xsrf";
private Xsrf() { private Xsrf() {
} }

View File

@@ -61,6 +61,13 @@
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<!-- openapi documentation -->
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations</artifactId>
<scope>provided</scope>
</dependency>
<!-- test scope --> <!-- test scope -->
<dependency> <dependency>
@@ -136,100 +143,37 @@
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>io.openapitools.swagger</groupId>
<artifactId>swagger-maven-plugin</artifactId>
<configuration>
<resourcePackages>
<resourcePackage>sonia.scm.api.v2.resources</resourcePackage>
</resourcePackages>
<outputDirectory>${basedir}/target/classes/META-INF/scm</outputDirectory>
<outputFilename>openapi</outputFilename>
<outputFormats>JSON,YAML</outputFormats>
<prettyPrint>true</prettyPrint>
<swaggerConfig>
<info>
<title>SCM-Manager Plugin REST-API</title>
<version>${project.version}</version>
<license>
<url>http://www.opensource.org/licenses/bsd-license.php</url>
<name>BSD</name>
</license>
</info>
</swaggerConfig>
</configuration>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins> </plugins>
</build> </build>
<profiles>
<profile>
<id>plugin-doc</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-enunciate-configuration</id>
<phase>compile</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}</outputDirectory>
<resources>
<resource>
<directory>src/main/doc</directory>
<filtering>true</filtering>
<includes>
<include>**/enunciate.xml</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.webcohesion.enunciate</groupId>
<artifactId>enunciate-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>docs</goal>
</goals>
<phase>compile</phase>
</execution>
</executions>
<configuration>
<configFile>${project.build.directory}/enunciate.xml</configFile>
<docsDir>${project.build.directory}</docsDir>
<docsSubdir>restdocs</docsSubdir>
</configuration>
<dependencies>
<dependency>
<groupId>com.webcohesion.enunciate</groupId>
<artifactId>enunciate-top</artifactId>
<version>${enunciate.version}</version>
<exclusions>
<exclusion>
<groupId>com.webcohesion.enunciate</groupId>
<artifactId>enunciate-swagger</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.webcohesion.enunciate</groupId>
<artifactId>enunciate-lombok</artifactId>
<version>${enunciate.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<descriptors>
<descriptor>src/main/doc/assembly.xml</descriptor>
</descriptors>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project> </project>

View File

@@ -1,12 +1,16 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import sonia.scm.config.ConfigurationPermissions; import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.repository.GitConfig; import sonia.scm.repository.GitConfig;
import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.web.GitVndMediaType; import sonia.scm.web.GitVndMediaType;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Provider; import javax.inject.Provider;
@@ -14,13 +18,15 @@ import javax.ws.rs.Consumes;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.PUT; import javax.ws.rs.PUT;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
/** /**
* RESTful Web Service Resource to manage the configuration of the git plugin. * RESTful Web Service Resource to manage the configuration of the git plugin.
*/ */
@OpenAPIDefinition(tags = {
@Tag(name = "Git", description = "Configuration for the git repository type")
})
@Path(GitConfigResource.GIT_CONFIG_PATH_V2) @Path(GitConfigResource.GIT_CONFIG_PATH_V2)
public class GitConfigResource { public class GitConfigResource {
@@ -45,13 +51,24 @@ public class GitConfigResource {
@GET @GET
@Path("") @Path("")
@Produces(GitVndMediaType.GIT_CONFIG) @Produces(GitVndMediaType.GIT_CONFIG)
@TypeHint(GitConfigDto.class) @Operation(summary = "Git configuration", description = "Returns the global git configuration.", tags = "Git")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:read:git\" privilege"), content = @Content(
@ResponseCode(code = 500, condition = "internal server error") mediaType = GitVndMediaType.GIT_CONFIG,
}) schema = @Schema(implementation = GitConfigDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:read:git\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response get() { public Response get() {
GitConfig config = repositoryHandler.getConfig(); GitConfig config = repositoryHandler.getConfig();
@@ -74,13 +91,20 @@ public class GitConfigResource {
@PUT @PUT
@Path("") @Path("")
@Consumes(GitVndMediaType.GIT_CONFIG) @Consumes(GitVndMediaType.GIT_CONFIG)
@StatusCodes({ @Operation(summary = "Modify git configuration", description = "Modifies the global git configuration.", tags = "Git")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "204",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:write:git\" privilege"), description = "update success"
@ResponseCode(code = 500, condition = "internal server error") )
}) @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@TypeHint(TypeHint.NO_CONTENT.class) @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:write:git\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response update(GitConfigDto configDto) { public Response update(GitConfigDto configDto) {
GitConfig config = dtoToConfigMapper.map(configDto); GitConfig config = dtoToConfigMapper.map(configDto);
@@ -94,7 +118,7 @@ public class GitConfigResource {
} }
@Path("{namespace}/{name}") @Path("{namespace}/{name}")
public GitRepositoryConfigResource getRepositoryConfig(@PathParam("namespace") String namespace, @PathParam("name") String name) { public GitRepositoryConfigResource getRepositoryConfig() {
return gitRepositoryConfigResource.get(); return gitRepositoryConfigResource.get();
} }
} }

View File

@@ -1,7 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.repository.GitRepositoryConfig; import sonia.scm.repository.GitRepositoryConfig;
@@ -11,6 +13,7 @@ import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryPermissions; import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.store.ConfigurationStore; import sonia.scm.store.ConfigurationStore;
import sonia.scm.web.GitVndMediaType; import sonia.scm.web.GitVndMediaType;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject; import javax.inject.Inject;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
@@ -42,13 +45,31 @@ public class GitRepositoryConfigResource {
@GET @GET
@Path("/") @Path("/")
@Produces(GitVndMediaType.GIT_REPOSITORY_CONFIG) @Produces(GitVndMediaType.GIT_REPOSITORY_CONFIG)
@StatusCodes({ @Operation(summary = "Git repository configuration", description = "Returns the repository related git configuration.", tags = "Git")
@ResponseCode(code = 200, condition = "success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "200",
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the repository config"), description = "success",
@ResponseCode(code = 404, condition = "not found, no repository with the specified namespace and name available"), content = @Content(
@ResponseCode(code = 500, condition = "internal server error") mediaType = GitVndMediaType.GIT_REPOSITORY_CONFIG,
}) schema = @Schema(implementation = GitRepositoryConfigDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the repository config")
@ApiResponse(
responseCode = "404",
description = "not found, no repository with the specified namespace and name available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getRepositoryConfig(@PathParam("namespace") String namespace, @PathParam("name") String name) { public Response getRepositoryConfig(@PathParam("namespace") String namespace, @PathParam("name") String name) {
Repository repository = getRepository(namespace, name); Repository repository = getRepository(namespace, name);
RepositoryPermissions.read(repository).check(); RepositoryPermissions.read(repository).check();
@@ -61,13 +82,27 @@ public class GitRepositoryConfigResource {
@PUT @PUT
@Path("/") @Path("/")
@Consumes(GitVndMediaType.GIT_REPOSITORY_CONFIG) @Consumes(GitVndMediaType.GIT_REPOSITORY_CONFIG)
@StatusCodes({ @Operation(summary = "Modifies git repository configuration", description = "Modifies the repository related git configuration.", tags = "Git")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "204",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the privilege to change this repositories config"), description = "update success"
@ResponseCode(code = 404, condition = "not found, no repository with the specified namespace and name available/name available"), )
@ResponseCode(code = 500, condition = "internal server error") @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
}) @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the privilege to change this repositories config")
@ApiResponse(
responseCode = "404",
description = "not found, no repository with the specified namespace and name available/name available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response setRepositoryConfig(@PathParam("namespace") String namespace, @PathParam("name") String name, GitRepositoryConfigDto dto) { public Response setRepositoryConfig(@PathParam("namespace") String namespace, @PathParam("name") String name, GitRepositoryConfigDto dto) {
Repository repository = getRepository(namespace, name); Repository repository = getRepository(namespace, name);
RepositoryPermissions.custom("git", repository).check(); RepositoryPermissions.custom("git", repository).check();

View File

@@ -27,6 +27,7 @@
</exclusion> </exclusion>
</exclusions> </exclusions>
</dependency> </dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -1,13 +1,15 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.config.ConfigurationPermissions; import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.repository.HgConfig; import sonia.scm.repository.HgConfig;
import sonia.scm.repository.HgRepositoryHandler; import sonia.scm.repository.HgRepositoryHandler;
import sonia.scm.web.HgVndMediaType; import sonia.scm.web.HgVndMediaType;
import sonia.scm.web.VndMediaType;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.PUT; import javax.ws.rs.PUT;
@@ -31,13 +33,20 @@ public class HgConfigAutoConfigurationResource {
*/ */
@PUT @PUT
@Path("") @Path("")
@StatusCodes({ @Operation(summary = "Sets hg configuration and installs hg binary", description = "Sets the default mercurial config and installs the mercurial binary.", tags = "Mercurial")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "204",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:write:hg\" privilege"), description = "update success"
@ResponseCode(code = 500, condition = "internal server error") )
}) @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@TypeHint(TypeHint.NO_CONTENT.class) @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:write:hg\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response autoConfiguration() { public Response autoConfiguration() {
return autoConfiguration(null); return autoConfiguration(null);
} }
@@ -50,13 +59,20 @@ public class HgConfigAutoConfigurationResource {
@PUT @PUT
@Path("") @Path("")
@Consumes(HgVndMediaType.CONFIG) @Consumes(HgVndMediaType.CONFIG)
@StatusCodes({ @Operation(summary = "Modifies hg configuration and installs hg binary", description = "Modifies the mercurial config and installs the mercurial binary.", tags = "Mercurial")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "204",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:write:hg\" privilege"), description = "update success"
@ResponseCode(code = 500, condition = "internal server error") )
}) @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@TypeHint(TypeHint.NO_CONTENT.class) @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:write:hg\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response autoConfiguration(HgConfigDto configDto) { public Response autoConfiguration(HgConfigDto configDto) {
HgConfig config; HgConfig config;

View File

@@ -1,13 +1,15 @@
package sonia.scm.api.v2.resources; 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 de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.HalRepresentation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.config.ConfigurationPermissions; import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.installer.HgInstallerFactory; import sonia.scm.installer.HgInstallerFactory;
import sonia.scm.repository.HgConfig; import sonia.scm.repository.HgConfig;
import sonia.scm.web.HgVndMediaType; import sonia.scm.web.HgVndMediaType;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject; import javax.inject.Inject;
import javax.ws.rs.GET; import javax.ws.rs.GET;
@@ -31,13 +33,24 @@ public class HgConfigInstallationsResource {
@GET @GET
@Path(PATH_HG) @Path(PATH_HG)
@Produces(HgVndMediaType.INSTALLATIONS) @Produces(HgVndMediaType.INSTALLATIONS)
@TypeHint(HalRepresentation.class) @Operation(summary = "Hg installations", description = "Returns the mercurial installations.", tags = "Mercurial")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:read:hg\" privilege"), content = @Content(
@ResponseCode(code = 500, condition = "internal server error") mediaType = HgVndMediaType.INSTALLATIONS,
}) schema = @Schema(implementation = HgConfigInstallationsDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:read:hg\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public HalRepresentation getHgInstallations() { public HalRepresentation getHgInstallations() {
ConfigurationPermissions.read(HgConfig.PERMISSION).check(); ConfigurationPermissions.read(HgConfig.PERMISSION).check();
@@ -52,13 +65,24 @@ public class HgConfigInstallationsResource {
@GET @GET
@Path(PATH_PYTHON) @Path(PATH_PYTHON)
@Produces(HgVndMediaType.INSTALLATIONS) @Produces(HgVndMediaType.INSTALLATIONS)
@TypeHint(HalRepresentation.class) @Operation(summary = "Python installations", description = "Returns the python installations.", tags = "Mercurial")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:read:hg\" privilege"), content = @Content(
@ResponseCode(code = 500, condition = "internal server error") mediaType = HgVndMediaType.INSTALLATIONS,
}) schema = @Schema(implementation = HgConfigInstallationsDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:read:hg\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public HalRepresentation getPythonInstallations() { public HalRepresentation getPythonInstallations() {
ConfigurationPermissions.read(HgConfig.PERMISSION).check(); ConfigurationPermissions.read(HgConfig.PERMISSION).check();

View File

@@ -1,9 +1,10 @@
package sonia.scm.api.v2.resources; 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 de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.HalRepresentation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.SCMContext; import sonia.scm.SCMContext;
import sonia.scm.config.ConfigurationPermissions; import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.installer.HgInstallerFactory; import sonia.scm.installer.HgInstallerFactory;
@@ -13,6 +14,7 @@ import sonia.scm.net.ahc.AdvancedHttpClient;
import sonia.scm.repository.HgConfig; import sonia.scm.repository.HgConfig;
import sonia.scm.repository.HgRepositoryHandler; import sonia.scm.repository.HgRepositoryHandler;
import sonia.scm.web.HgVndMediaType; import sonia.scm.web.HgVndMediaType;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject; import javax.inject.Inject;
import javax.ws.rs.GET; import javax.ws.rs.GET;
@@ -44,13 +46,20 @@ public class HgConfigPackageResource {
@GET @GET
@Path("") @Path("")
@Produces(HgVndMediaType.PACKAGES) @Produces(HgVndMediaType.PACKAGES)
@StatusCodes({ @Operation(summary = "Hg configuration packages", description = "Returns all mercurial packages.", tags = "Mercurial")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "204",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:read:hg\" privilege"), description = "update success"
@ResponseCode(code = 500, condition = "internal server error") )
}) @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@TypeHint(HalRepresentation.class) @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:read:hg\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public HalRepresentation getPackages() { public HalRepresentation getPackages() {
ConfigurationPermissions.read(HgConfig.PERMISSION).check(); ConfigurationPermissions.read(HgConfig.PERMISSION).check();
@@ -65,14 +74,27 @@ public class HgConfigPackageResource {
*/ */
@PUT @PUT
@Path("{pkgId}") @Path("{pkgId}")
@StatusCodes({ @Operation(summary = "Modifies hg configuration package", description = "Installs a mercurial package.", tags = "Mercurial")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "204",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:write:hg\" privilege"), description = "update success"
@ResponseCode(code = 404, condition = "no package found for id"), )
@ResponseCode(code = 500, condition = "internal server error") @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
}) @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:write:hg\" privilege")
@TypeHint(TypeHint.NO_CONTENT.class) @ApiResponse(
responseCode = "404",
description = "no package found for id",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response installPackage(@PathParam("pkgId") String pkgId) { public Response installPackage(@PathParam("pkgId") String pkgId) {
Response response; Response response;
@@ -82,7 +104,7 @@ public class HgConfigPackageResource {
if (pkg != null) { if (pkg != null) {
if (HgInstallerFactory.createInstaller() if (HgInstallerFactory.createInstaller()
.installPackage(client, handler, SCMContext.getContext().getBaseDirectory(), pkg)) { .installPackage(client, handler, SCMContext.getContext().getBaseDirectory(), pkg)) {
response = Response.noContent().build(); response = Response.noContent().build();
} else { } else {
response = Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); response = Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();

View File

@@ -1,12 +1,16 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import sonia.scm.config.ConfigurationPermissions; import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.repository.HgConfig; import sonia.scm.repository.HgConfig;
import sonia.scm.repository.HgRepositoryHandler; import sonia.scm.repository.HgRepositoryHandler;
import sonia.scm.web.HgVndMediaType; import sonia.scm.web.HgVndMediaType;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Provider; import javax.inject.Provider;
@@ -20,11 +24,13 @@ import javax.ws.rs.core.Response;
/** /**
* RESTful Web Service Resource to manage the configuration of the hg plugin. * RESTful Web Service Resource to manage the configuration of the hg plugin.
*/ */
@OpenAPIDefinition(tags = {
@Tag(name = "Mercurial", description = "Configuration for the mercurial repository type")
})
@Path(HgConfigResource.HG_CONFIG_PATH_V2) @Path(HgConfigResource.HG_CONFIG_PATH_V2)
public class HgConfigResource { public class HgConfigResource {
static final String HG_CONFIG_PATH_V2 = "v2/config/hg"; static final String HG_CONFIG_PATH_V2 = "v2/config/hg";
private final HgConfigDtoToHgConfigMapper dtoToConfigMapper; private final HgConfigDtoToHgConfigMapper dtoToConfigMapper;
private final HgConfigToHgConfigDtoMapper configToDtoMapper; private final HgConfigToHgConfigDtoMapper configToDtoMapper;
private final HgRepositoryHandler repositoryHandler; private final HgRepositoryHandler repositoryHandler;
@@ -51,13 +57,24 @@ public class HgConfigResource {
@GET @GET
@Path("") @Path("")
@Produces(HgVndMediaType.CONFIG) @Produces(HgVndMediaType.CONFIG)
@TypeHint(HgConfigDto.class) @Operation(summary = "Hg configuration", description = "Returns the global mercurial configuration.", tags = "Mercurial")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:read:hg\" privilege"), content = @Content(
@ResponseCode(code = 500, condition = "internal server error") mediaType = HgVndMediaType.CONFIG,
}) schema = @Schema(implementation = HgConfigDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:read:hg\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response get() { public Response get() {
ConfigurationPermissions.read(HgConfig.PERMISSION).check(); ConfigurationPermissions.read(HgConfig.PERMISSION).check();
@@ -80,13 +97,20 @@ public class HgConfigResource {
@PUT @PUT
@Path("") @Path("")
@Consumes(HgVndMediaType.CONFIG) @Consumes(HgVndMediaType.CONFIG)
@StatusCodes({ @Operation(summary = "Modify hg configuration", description = "Modifies the global mercurial configuration.", tags = "Mercurial")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "204",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:write:hg\" privilege"), description = "update success"
@ResponseCode(code = 500, condition = "internal server error") )
}) @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@TypeHint(TypeHint.NO_CONTENT.class) @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:write:hg\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response update(HgConfigDto configDto) { public Response update(HgConfigDto configDto) {
HgConfig config = dtoToConfigMapper.map(configDto); HgConfig config = dtoToConfigMapper.map(configDto);

View File

@@ -38,6 +38,9 @@ package sonia.scm.repository;
import com.google.inject.ProvisionException; import com.google.inject.ProvisionException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.security.AccessToken;
import sonia.scm.security.CipherUtil;
import sonia.scm.security.Xsrf;
import sonia.scm.web.HgUtil; import sonia.scm.web.HgUtil;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
@@ -65,6 +68,8 @@ public final class HgEnvironment
private static final String SCM_BEARER_TOKEN = "SCM_BEARER_TOKEN"; private static final String SCM_BEARER_TOKEN = "SCM_BEARER_TOKEN";
private static final String SCM_XSRF = "SCM_XSRF";
//~--- constructors --------------------------------------------------------- //~--- constructors ---------------------------------------------------------
/** /**
@@ -114,8 +119,9 @@ public final class HgEnvironment
} }
try { try {
String credentials = hookManager.getCredentials(); AccessToken accessToken = hookManager.getAccessToken();
environment.put(SCM_BEARER_TOKEN, credentials); environment.put(SCM_BEARER_TOKEN, CipherUtil.getInstance().encode(accessToken.compact()));
extractXsrfKey(environment, accessToken);
} catch (ProvisionException e) { } catch (ProvisionException e) {
LOG.debug("could not create bearer token; looks like currently we are not in a request; probably you can ignore the following exception:", e); LOG.debug("could not create bearer token; looks like currently we are not in a request; probably you can ignore the following exception:", e);
} }
@@ -123,4 +129,8 @@ public final class HgEnvironment
environment.put(ENV_URL, hookUrl); environment.put(ENV_URL, hookUrl);
environment.put(ENV_CHALLENGE, hookManager.getChallenge()); environment.put(ENV_CHALLENGE, hookManager.getChallenge());
} }
private static void extractXsrfKey(Map<String, String> environment, AccessToken accessToken) {
environment.put(SCM_XSRF, accessToken.<String>getCustom(Xsrf.TOKEN_KEY).orElse("-"));
}
} }

View File

@@ -49,7 +49,6 @@ import sonia.scm.config.ScmConfigurationChangedEvent;
import sonia.scm.net.ahc.AdvancedHttpClient; import sonia.scm.net.ahc.AdvancedHttpClient;
import sonia.scm.security.AccessToken; import sonia.scm.security.AccessToken;
import sonia.scm.security.AccessTokenBuilderFactory; import sonia.scm.security.AccessTokenBuilderFactory;
import sonia.scm.security.CipherUtil;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
import sonia.scm.util.Util; import sonia.scm.util.Util;
@@ -196,11 +195,9 @@ public class HgHookManager
return this.challenge.equals(challenge); return this.challenge.equals(challenge);
} }
public String getCredentials() public AccessToken getAccessToken()
{ {
AccessToken accessToken = accessTokenBuilderFactory.create().build(); return accessTokenBuilderFactory.create().build();
return CipherUtil.getInstance().encode(accessToken.compact());
} }
//~--- methods -------------------------------------------------------------- //~--- methods --------------------------------------------------------------
@@ -279,7 +276,7 @@ public class HgHookManager
//J- //J-
return HttpUtil.getUriWithoutEndSeperator( return HttpUtil.getUriWithoutEndSeperator(
MoreObjects.firstNonNull( MoreObjects.firstNonNull(
configuration.getBaseUrl(), configuration.getBaseUrl(),
"http://localhost:8080/scm" "http://localhost:8080/scm"
) )
).concat("/hook/hg/"); ).concat("/hook/hg/");

View File

@@ -41,6 +41,7 @@ import os, urllib, urllib2
baseUrl = os.environ['SCM_URL'] baseUrl = os.environ['SCM_URL']
challenge = os.environ['SCM_CHALLENGE'] challenge = os.environ['SCM_CHALLENGE']
token = os.environ['SCM_BEARER_TOKEN'] token = os.environ['SCM_BEARER_TOKEN']
xsrf = os.environ['SCM_XSRF']
repositoryId = os.environ['SCM_REPOSITORY_ID'] repositoryId = os.environ['SCM_REPOSITORY_ID']
def printMessages(ui, msgs): def printMessages(ui, msgs):
@@ -59,6 +60,7 @@ def callHookUrl(ui, repo, hooktype, node):
proxy_handler = urllib2.ProxyHandler({}) proxy_handler = urllib2.ProxyHandler({})
opener = urllib2.build_opener(proxy_handler) opener = urllib2.build_opener(proxy_handler)
req = urllib2.Request(url, data) req = urllib2.Request(url, data)
req.add_header("X-XSRF-Token", xsrf)
conn = opener.open(req) conn = opener.open(req)
if 200 <= conn.code < 300: if 200 <= conn.code < 300:
ui.debug( "scm-hook " + hooktype + " success with status code " + str(conn.code) + "\n" ) ui.debug( "scm-hook " + hooktype + " success with status code " + str(conn.code) + "\n" )
@@ -101,7 +103,7 @@ def preHook(ui, repo, hooktype, node=None, source=None, pending=None, **kwargs):
# older mercurial versions # older mercurial versions
if pending != None: if pending != None:
pending() pending()
# newer mercurial version # newer mercurial version
# we have to make in-memory changes visible to external process # we have to make in-memory changes visible to external process
# this does not happen automatically, because mercurial treat our hooks as internal hooks # this does not happen automatically, because mercurial treat our hooks as internal hooks

View File

@@ -0,0 +1,54 @@
package sonia.scm.repository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.security.AccessToken;
import sonia.scm.security.Xsrf;
import java.util.HashMap;
import java.util.Map;
import static java.util.Optional.empty;
import static java.util.Optional.of;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class HgEnvironmentTest {
@Mock
HgRepositoryHandler handler;
@Mock
HgHookManager hookManager;
@Test
void shouldExtractXsrfTokenWhenSet() {
AccessToken accessToken = mock(AccessToken.class);
when(accessToken.compact()).thenReturn("");
when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(of("XSRF Token"));
when(hookManager.getAccessToken()).thenReturn(accessToken);
Map<String, String> environment = new HashMap<>();
HgEnvironment.prepareEnvironment(environment, handler, hookManager);
assertThat(environment).contains(entry("SCM_XSRF", "XSRF Token"));
}
@Test
void shouldIgnoreXsrfWhenNotSetButStillContainDummy() {
AccessToken accessToken = mock(AccessToken.class);
when(accessToken.compact()).thenReturn("");
when(accessToken.getCustom(Xsrf.TOKEN_KEY)).thenReturn(empty());
when(hookManager.getAccessToken()).thenReturn(accessToken);
Map<String, String> environment = new HashMap<>();
HgEnvironment.prepareEnvironment(environment, handler, hookManager);
assertThat(environment).containsKeys("SCM_XSRF");
}
}

View File

@@ -38,6 +38,7 @@ package sonia.scm.repository;
import org.junit.Assume; import org.junit.Assume;
import sonia.scm.SCMContext; import sonia.scm.SCMContext;
import sonia.scm.TempDirRepositoryLocationResolver; import sonia.scm.TempDirRepositoryLocationResolver;
import sonia.scm.security.AccessToken;
import sonia.scm.store.InMemoryConfigurationStoreFactory; import sonia.scm.store.InMemoryConfigurationStoreFactory;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
@@ -107,7 +108,6 @@ public final class HgTestUtil
RepositoryLocationResolver repositoryLocationResolver = new TempDirRepositoryLocationResolver(directory); RepositoryLocationResolver repositoryLocationResolver = new TempDirRepositoryLocationResolver(directory);
HgRepositoryHandler handler = HgRepositoryHandler handler =
new HgRepositoryHandler(new InMemoryConfigurationStoreFactory(), new HgContextProvider(), repositoryLocationResolver, null, null); new HgRepositoryHandler(new InMemoryConfigurationStoreFactory(), new HgContextProvider(), repositoryLocationResolver, null, null);
Path repoDir = directory.toPath();
handler.init(context); handler.init(context);
return handler; return handler;
@@ -128,7 +128,9 @@ public final class HgTestUtil
"http://localhost:8081/scm/hook/hg/"); "http://localhost:8081/scm/hook/hg/");
when(hookManager.createUrl(any(HttpServletRequest.class))).thenReturn( when(hookManager.createUrl(any(HttpServletRequest.class))).thenReturn(
"http://localhost:8081/scm/hook/hg/"); "http://localhost:8081/scm/hook/hg/");
when(hookManager.getCredentials()).thenReturn(""); AccessToken accessToken = mock(AccessToken.class);
when(accessToken.compact()).thenReturn("");
when(hookManager.getAccessToken()).thenReturn(accessToken);
return hookManager; return hookManager;
} }

View File

@@ -1,8 +1,6 @@
package sonia.scm.legacy; package sonia.scm.legacy;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryManager;
@@ -26,12 +24,6 @@ public class LegacyRepositoryService {
@GET @GET
@Path("{id}") @Path("{id}")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@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:read:global\" privilege"),
@ResponseCode(code = 500, condition = "internal server error")
})
public NamespaceAndNameDto getNameAndNamespaceForRepositoryId(@PathParam("id") String repositoryId) { public NamespaceAndNameDto getNameAndNamespaceForRepositoryId(@PathParam("id") String repositoryId) {
Repository repo = repositoryManager.get(repositoryId); Repository repo = repositoryManager.get(repositoryId);
if (repo == null) { if (repo == null) {

View File

@@ -1,12 +1,16 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import sonia.scm.config.ConfigurationPermissions; import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.repository.SvnConfig; import sonia.scm.repository.SvnConfig;
import sonia.scm.repository.SvnRepositoryHandler; import sonia.scm.repository.SvnRepositoryHandler;
import sonia.scm.web.SvnVndMediaType; import sonia.scm.web.SvnVndMediaType;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject; import javax.inject.Inject;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
@@ -19,6 +23,9 @@ import javax.ws.rs.core.Response;
/** /**
* RESTful Web Service Resource to manage the configuration of the svn plugin. * RESTful Web Service Resource to manage the configuration of the svn plugin.
*/ */
@OpenAPIDefinition(tags = {
@Tag(name = "Subversion", description = "Configuration for the subversion repository type")
})
@Path(SvnConfigResource.SVN_CONFIG_PATH_V2) @Path(SvnConfigResource.SVN_CONFIG_PATH_V2)
public class SvnConfigResource { public class SvnConfigResource {
@@ -41,13 +48,24 @@ public class SvnConfigResource {
@GET @GET
@Path("") @Path("")
@Produces(SvnVndMediaType.SVN_CONFIG) @Produces(SvnVndMediaType.SVN_CONFIG)
@TypeHint(SvnConfigDto.class) @Operation(summary = "Svn configuration", description = "Returns the global subversion configuration.", tags = "Subversion")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:read:svn\" privilege"), content = @Content(
@ResponseCode(code = 500, condition = "internal server error") mediaType = SvnVndMediaType.SVN_CONFIG,
}) schema = @Schema(implementation = SvnConfigDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:read:svn\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response get() { public Response get() {
SvnConfig config = repositoryHandler.getConfig(); SvnConfig config = repositoryHandler.getConfig();
@@ -70,13 +88,20 @@ public class SvnConfigResource {
@PUT @PUT
@Path("") @Path("")
@Consumes(SvnVndMediaType.SVN_CONFIG) @Consumes(SvnVndMediaType.SVN_CONFIG)
@StatusCodes({ @Operation(summary = "Modify svn configuration", description = "Modifies the global subversion configuration.", tags = "Subversion")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "204",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:write:svn\" privilege"), description = "update success"
@ResponseCode(code = 500, condition = "internal server error") )
}) @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@TypeHint(TypeHint.NO_CONTENT.class) @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:write:svn\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response update(SvnConfigDto configDto) { public Response update(SvnConfigDto configDto) {
SvnConfig config = dtoToConfigMapper.map(configDto); SvnConfig config = dtoToConfigMapper.map(configDto);

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -484,7 +484,7 @@ exports[`Storyshots DateFromNow Default 1`] = `
exports[`Storyshots Diff Binaries 1`] = ` exports[`Storyshots Diff Binaries 1`] = `
<div <div
className="sc-TOsTZ flmUBf" className="sc-hmzhuo TypKC"
> >
<div <div
className="sc-gZMcBi iABzaT panel is-size-6" className="sc-gZMcBi iABzaT panel is-size-6"
@@ -753,7 +753,7 @@ exports[`Storyshots Diff Binaries 1`] = `
exports[`Storyshots Diff Collapsed 1`] = ` exports[`Storyshots Diff Collapsed 1`] = `
<div <div
className="sc-TOsTZ flmUBf" className="sc-hmzhuo TypKC"
> >
<div <div
className="sc-gZMcBi ckdmuY panel is-size-6" className="sc-gZMcBi ckdmuY panel is-size-6"
@@ -1102,7 +1102,7 @@ exports[`Storyshots Diff Collapsed 1`] = `
exports[`Storyshots Diff CollapsingWithFunction 1`] = ` exports[`Storyshots Diff CollapsingWithFunction 1`] = `
<div <div
className="sc-TOsTZ flmUBf" className="sc-hmzhuo TypKC"
> >
<div <div
className="sc-gZMcBi ckdmuY panel is-size-6" className="sc-gZMcBi ckdmuY panel is-size-6"
@@ -3028,7 +3028,7 @@ exports[`Storyshots Diff CollapsingWithFunction 1`] = `
exports[`Storyshots Diff Default 1`] = ` exports[`Storyshots Diff Default 1`] = `
<div <div
className="sc-TOsTZ flmUBf" className="sc-hmzhuo TypKC"
> >
<div <div
className="sc-gZMcBi iABzaT panel is-size-6" className="sc-gZMcBi iABzaT panel is-size-6"
@@ -6936,7 +6936,7 @@ exports[`Storyshots Diff Default 1`] = `
exports[`Storyshots Diff File Annotation 1`] = ` exports[`Storyshots Diff File Annotation 1`] = `
<div <div
className="sc-TOsTZ flmUBf" className="sc-hmzhuo TypKC"
> >
<div <div
className="sc-gZMcBi iABzaT panel is-size-6" className="sc-gZMcBi iABzaT panel is-size-6"
@@ -10868,7 +10868,7 @@ exports[`Storyshots Diff File Annotation 1`] = `
exports[`Storyshots Diff File Controls 1`] = ` exports[`Storyshots Diff File Controls 1`] = `
<div <div
className="sc-TOsTZ flmUBf" className="sc-hmzhuo TypKC"
> >
<div <div
className="sc-gZMcBi iABzaT panel is-size-6" className="sc-gZMcBi iABzaT panel is-size-6"
@@ -14884,7 +14884,7 @@ exports[`Storyshots Diff File Controls 1`] = `
exports[`Storyshots Diff Hunks 1`] = ` exports[`Storyshots Diff Hunks 1`] = `
<div <div
className="sc-TOsTZ flmUBf" className="sc-hmzhuo TypKC"
> >
<div <div
className="sc-gZMcBi iABzaT panel is-size-6" className="sc-gZMcBi iABzaT panel is-size-6"
@@ -15721,7 +15721,7 @@ exports[`Storyshots Diff Hunks 1`] = `
exports[`Storyshots Diff Line Annotation 1`] = ` exports[`Storyshots Diff Line Annotation 1`] = `
<div <div
className="sc-TOsTZ flmUBf" className="sc-hmzhuo TypKC"
> >
<div <div
className="sc-gZMcBi iABzaT panel is-size-6" className="sc-gZMcBi iABzaT panel is-size-6"
@@ -19665,7 +19665,7 @@ exports[`Storyshots Diff Line Annotation 1`] = `
exports[`Storyshots Diff OnClick 1`] = ` exports[`Storyshots Diff OnClick 1`] = `
<div <div
className="sc-TOsTZ flmUBf" className="sc-hmzhuo TypKC"
> >
<div <div
className="sc-gZMcBi iABzaT panel is-size-6" className="sc-gZMcBi iABzaT panel is-size-6"
@@ -23847,7 +23847,7 @@ exports[`Storyshots Diff OnClick 1`] = `
exports[`Storyshots Diff Side-By-Side 1`] = ` exports[`Storyshots Diff Side-By-Side 1`] = `
<div <div
className="sc-TOsTZ flmUBf" className="sc-hmzhuo TypKC"
> >
<div <div
className="sc-gZMcBi iABzaT panel is-size-6" className="sc-gZMcBi iABzaT panel is-size-6"
@@ -28284,7 +28284,7 @@ exports[`Storyshots Diff Side-By-Side 1`] = `
exports[`Storyshots Diff SyntaxHighlighting 1`] = ` exports[`Storyshots Diff SyntaxHighlighting 1`] = `
<div <div
className="sc-TOsTZ flmUBf" className="sc-hmzhuo TypKC"
> >
<div <div
className="sc-gZMcBi iABzaT panel is-size-6" className="sc-gZMcBi iABzaT panel is-size-6"
@@ -32192,7 +32192,7 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = `
exports[`Storyshots Forms|Checkbox Default 1`] = ` exports[`Storyshots Forms|Checkbox Default 1`] = `
<div <div
className="sc-gisBJw jHakbY" className="sc-kgAjT khfRmZ"
> >
<div <div
className="field" className="field"
@@ -32237,7 +32237,7 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
exports[`Storyshots Forms|Checkbox Disabled 1`] = ` exports[`Storyshots Forms|Checkbox Disabled 1`] = `
<div <div
className="sc-gisBJw jHakbY" className="sc-kgAjT khfRmZ"
> >
<div <div
className="field" className="field"
@@ -32265,7 +32265,7 @@ exports[`Storyshots Forms|Checkbox Disabled 1`] = `
exports[`Storyshots Forms|Radio Default 1`] = ` exports[`Storyshots Forms|Radio Default 1`] = `
<div <div
className="sc-kjoXOD hVPZau" className="sc-cJSrbW hLoADP"
> >
<label <label
className="sc-cMljjf kOqpHe radio" className="sc-cMljjf kOqpHe radio"
@@ -32294,7 +32294,7 @@ exports[`Storyshots Forms|Radio Default 1`] = `
exports[`Storyshots Forms|Radio Disabled 1`] = ` exports[`Storyshots Forms|Radio Disabled 1`] = `
<div <div
className="sc-kjoXOD hVPZau" className="sc-cJSrbW hLoADP"
> >
<label <label
className="sc-cMljjf kOqpHe radio" className="sc-cMljjf kOqpHe radio"
@@ -32314,7 +32314,7 @@ exports[`Storyshots Forms|Radio Disabled 1`] = `
exports[`Storyshots Forms|Textarea OnCancel 1`] = ` exports[`Storyshots Forms|Textarea OnCancel 1`] = `
<div <div
className="sc-cHGsZl klfJMr" className="sc-ksYbfQ ePXdiL"
> >
<div <div
className="field" className="field"
@@ -32337,7 +32337,7 @@ exports[`Storyshots Forms|Textarea OnCancel 1`] = `
exports[`Storyshots Forms|Textarea OnChange 1`] = ` exports[`Storyshots Forms|Textarea OnChange 1`] = `
<div <div
className="sc-cHGsZl klfJMr" className="sc-ksYbfQ ePXdiL"
> >
<div <div
className="field" className="field"
@@ -32364,7 +32364,7 @@ exports[`Storyshots Forms|Textarea OnChange 1`] = `
exports[`Storyshots Forms|Textarea OnSubmit 1`] = ` exports[`Storyshots Forms|Textarea OnSubmit 1`] = `
<div <div
className="sc-cHGsZl klfJMr" className="sc-ksYbfQ ePXdiL"
> >
<div <div
className="field" className="field"
@@ -32389,6 +32389,514 @@ exports[`Storyshots Forms|Textarea OnSubmit 1`] = `
</div> </div>
`; `;
exports[`Storyshots Layout|Footer Default 1`] = `
<footer
className="footer"
>
<section
className="section container"
>
<div
className="columns is-size-7"
>
<section
className="column is-one-third"
>
<div
className="sc-hzDkRC jeksqW"
>
<i
className="fas fa-user-circle fa-fw"
/>
Trillian McMillian
</div>
<ul
className="sc-jhAzac eoWThz"
>
<li>
<a
className=""
href="/me"
onClick={[Function]}
>
footer.user.profile
</a>
</li>
<li>
<a
className=""
href="/me/settings/password"
onClick={[Function]}
>
profile.changePasswordNavLink
</a>
</li>
</ul>
</section>
<section
className="column is-one-third"
>
<div
className="sc-hzDkRC jeksqW"
>
<i
className="fas fa-info-circle fa-fw"
/>
footer.information.title
</div>
<ul
className="sc-jhAzac eoWThz"
>
<li>
<a
href="https://www.scm-manager.org/"
target="_blank"
>
SCM-Manager 2.0.0
</a>
</li>
</ul>
</section>
<section
className="column is-one-third"
>
<div
className="sc-hzDkRC jeksqW"
>
<i
className="fas fa-life-ring fa-fw"
/>
footer.support.title
</div>
<ul
className="sc-jhAzac eoWThz"
>
<li>
<a
href="https://www.scm-manager.org/support/"
target="_blank"
>
footer.support.community
</a>
</li>
<li>
<a
href="https://cloudogu.com/en/scm-manager-enterprise/"
target="_blank"
>
footer.support.enterprise
</a>
</li>
</ul>
</section>
</div>
</section>
</footer>
`;
exports[`Storyshots Layout|Footer Full 1`] = `
<footer
className="footer"
>
<section
className="section container"
>
<div
className="columns is-size-7"
>
<section
className="column is-one-third"
>
<div
className="sc-hzDkRC jeksqW"
>
<span
className="sc-fMiknA fyPpQQ image is-rounded"
>
<img
alt="trillian"
className="is-rounded sc-fBuWsC djJrAv"
src="test-file-stub"
/>
</span>
Trillian McMillian
</div>
<ul
className="sc-jhAzac eoWThz"
>
<li>
<a
className=""
href="/me"
onClick={[Function]}
>
footer.user.profile
</a>
</li>
<li>
<a
className=""
href="/me/settings/password"
onClick={[Function]}
>
profile.changePasswordNavLink
</a>
</li>
<li>
<a
className=""
href="/"
onClick={[Function]}
>
Authorized Keys
</a>
</li>
</ul>
</section>
<section
className="column is-one-third"
>
<div
className="sc-hzDkRC jeksqW"
>
<i
className="fas fa-info-circle fa-fw"
/>
footer.information.title
</div>
<ul
className="sc-jhAzac eoWThz"
>
<li>
<a
href="https://www.scm-manager.org/"
target="_blank"
>
SCM-Manager 2.0.0
</a>
</li>
<li>
<a
href="#"
target="_blank"
>
REST API
</a>
</li>
<li>
<a
href="#"
target="_blank"
>
CLI
</a>
</li>
</ul>
</section>
<section
className="column is-one-third"
>
<div
className="sc-hzDkRC jeksqW"
>
<i
className="fas fa-life-ring fa-fw"
/>
footer.support.title
</div>
<ul
className="sc-jhAzac eoWThz"
>
<li>
<a
href="https://www.scm-manager.org/support/"
target="_blank"
>
footer.support.community
</a>
</li>
<li>
<a
href="https://cloudogu.com/en/scm-manager-enterprise/"
target="_blank"
>
footer.support.enterprise
</a>
</li>
<li>
<a
href="#"
target="_blank"
>
FAQ
</a>
</li>
</ul>
</section>
</div>
</section>
</footer>
`;
exports[`Storyshots Layout|Footer With Avatar 1`] = `
<footer
className="footer"
>
<section
className="section container"
>
<div
className="columns is-size-7"
>
<section
className="column is-one-third"
>
<div
className="sc-hzDkRC jeksqW"
>
<span
className="sc-fMiknA fyPpQQ image is-rounded"
>
<img
alt="trillian"
className="is-rounded sc-fBuWsC djJrAv"
src="test-file-stub"
/>
</span>
Trillian McMillian
</div>
<ul
className="sc-jhAzac eoWThz"
>
<li>
<a
className=""
href="/me"
onClick={[Function]}
>
footer.user.profile
</a>
</li>
<li>
<a
className=""
href="/me/settings/password"
onClick={[Function]}
>
profile.changePasswordNavLink
</a>
</li>
</ul>
</section>
<section
className="column is-one-third"
>
<div
className="sc-hzDkRC jeksqW"
>
<i
className="fas fa-info-circle fa-fw"
/>
footer.information.title
</div>
<ul
className="sc-jhAzac eoWThz"
>
<li>
<a
href="https://www.scm-manager.org/"
target="_blank"
>
SCM-Manager 2.0.0
</a>
</li>
</ul>
</section>
<section
className="column is-one-third"
>
<div
className="sc-hzDkRC jeksqW"
>
<i
className="fas fa-life-ring fa-fw"
/>
footer.support.title
</div>
<ul
className="sc-jhAzac eoWThz"
>
<li>
<a
href="https://www.scm-manager.org/support/"
target="_blank"
>
footer.support.community
</a>
</li>
<li>
<a
href="https://cloudogu.com/en/scm-manager-enterprise/"
target="_blank"
>
footer.support.enterprise
</a>
</li>
</ul>
</section>
</div>
</section>
</footer>
`;
exports[`Storyshots Layout|Footer With Plugin Links 1`] = `
<footer
className="footer"
>
<section
className="section container"
>
<div
className="columns is-size-7"
>
<section
className="column is-one-third"
>
<div
className="sc-hzDkRC jeksqW"
>
<i
className="fas fa-user-circle fa-fw"
/>
Trillian McMillian
</div>
<ul
className="sc-jhAzac eoWThz"
>
<li>
<a
className=""
href="/me"
onClick={[Function]}
>
footer.user.profile
</a>
</li>
<li>
<a
className=""
href="/me/settings/password"
onClick={[Function]}
>
profile.changePasswordNavLink
</a>
</li>
<li>
<a
className=""
href="/"
onClick={[Function]}
>
Authorized Keys
</a>
</li>
</ul>
</section>
<section
className="column is-one-third"
>
<div
className="sc-hzDkRC jeksqW"
>
<i
className="fas fa-info-circle fa-fw"
/>
footer.information.title
</div>
<ul
className="sc-jhAzac eoWThz"
>
<li>
<a
href="https://www.scm-manager.org/"
target="_blank"
>
SCM-Manager 2.0.0
</a>
</li>
<li>
<a
href="#"
target="_blank"
>
REST API
</a>
</li>
<li>
<a
href="#"
target="_blank"
>
CLI
</a>
</li>
</ul>
</section>
<section
className="column is-one-third"
>
<div
className="sc-hzDkRC jeksqW"
>
<i
className="fas fa-life-ring fa-fw"
/>
footer.support.title
</div>
<ul
className="sc-jhAzac eoWThz"
>
<li>
<a
href="https://www.scm-manager.org/support/"
target="_blank"
>
footer.support.community
</a>
</li>
<li>
<a
href="https://cloudogu.com/en/scm-manager-enterprise/"
target="_blank"
>
footer.support.enterprise
</a>
</li>
<li>
<a
href="#"
target="_blank"
>
FAQ
</a>
</li>
</ul>
</section>
</div>
</section>
</footer>
`;
exports[`Storyshots Loading Default 1`] = ` exports[`Storyshots Loading Default 1`] = `
<div> <div>
<div <div
@@ -34243,7 +34751,7 @@ PORT_NUMBER =
exports[`Storyshots Table|Table Default 1`] = ` exports[`Storyshots Table|Table Default 1`] = `
<table <table
className="sc-fBuWsC eeihxG table content is-hoverable" className="sc-fAjcbJ byigni table content is-hoverable"
> >
<thead> <thead>
<tr> <tr>
@@ -34261,7 +34769,7 @@ exports[`Storyshots Table|Table Default 1`] = `
> >
Last Name Last Name
<i <i
className="fas fa-sort-amount-down has-text-grey-light sc-jhAzac gDbcZp" className="fas fa-sort-amount-down has-text-grey-light sc-eqIVtm jxAoDg"
/> />
</th> </th>
<th <th
@@ -34334,7 +34842,7 @@ exports[`Storyshots Table|Table Empty 1`] = `
exports[`Storyshots Table|Table TextColumn 1`] = ` exports[`Storyshots Table|Table TextColumn 1`] = `
<table <table
className="sc-fBuWsC eeihxG table content is-hoverable" className="sc-fAjcbJ byigni table content is-hoverable"
> >
<thead> <thead>
<tr> <tr>
@@ -34346,7 +34854,7 @@ exports[`Storyshots Table|Table TextColumn 1`] = `
> >
Id Id
<i <i
className="fas fa-sort-alpha-down has-text-grey-light sc-jhAzac gDbcZp" className="fas fa-sort-alpha-down has-text-grey-light sc-eqIVtm jxAoDg"
/> />
</th> </th>
<th <th
@@ -34357,7 +34865,7 @@ exports[`Storyshots Table|Table TextColumn 1`] = `
> >
Name Name
<i <i
className="fas fa-sort-alpha-down has-text-grey-light sc-jhAzac gDbcZp" className="fas fa-sort-alpha-down has-text-grey-light sc-eqIVtm jxAoDg"
/> />
</th> </th>
<th <th
@@ -34368,7 +34876,7 @@ exports[`Storyshots Table|Table TextColumn 1`] = `
> >
Description Description
<i <i
className="fas fa-sort-alpha-down has-text-grey-light sc-jhAzac gDbcZp" className="fas fa-sort-alpha-down has-text-grey-light sc-eqIVtm jxAoDg"
/> />
</th> </th>
</tr> </tr>

View File

@@ -1,26 +1,30 @@
import React from "react"; import React, { FC } from "react";
import { binder } from "@scm-manager/ui-extensions";
import { Image } from ".."; import { Image } from "..";
import { Person } from "./Avatar"; import { Person } from "./Avatar";
import { EXTENSION_POINT } from "./Avatar"; import { EXTENSION_POINT } from "./Avatar";
import { useBinder } from "@scm-manager/ui-extensions";
type Props = { type Props = {
person: Person; person: Person;
representation?: "rounded" | "rounded-border";
className?: string;
}; };
class AvatarImage extends React.Component<Props> { const AvatarImage: FC<Props> = ({ person, representation = "rounded-border", className }) => {
render() { const binder = useBinder();
const { person } = this.props; const avatarFactory = binder.getExtension(EXTENSION_POINT);
if (avatarFactory) {
const avatar = avatarFactory(person);
const avatarFactory = binder.getExtension(EXTENSION_POINT); let classes = representation === "rounded" ? "is-rounded" : "has-rounded-border";
if (avatarFactory) { if (className) {
const avatar = avatarFactory(person); classes += " " + className;
return <Image className="has-rounded-border" src={avatar} alt={person.name} />;
} }
return null; return <Image className={classes} src={avatar} alt={person.name} />;
} }
}
return null;
};
export default AvatarImage; export default AvatarImage;

View File

@@ -1,18 +1,13 @@
import React, { Component, ReactNode } from "react"; import React, { FC } from "react";
import { binder } from "@scm-manager/ui-extensions"; import { useBinder } from "@scm-manager/ui-extensions";
import { EXTENSION_POINT } from "./Avatar"; import { EXTENSION_POINT } from "./Avatar";
type Props = { const AvatarWrapper: FC = ({ children }) => {
children: ReactNode; const binder = useBinder();
if (binder.hasExtension(EXTENSION_POINT)) {
return <>{children}</>;
}
return null;
}; };
class AvatarWrapper extends Component<Props> {
render() {
if (binder.hasExtension(EXTENSION_POINT)) {
return <>{this.props.children}</>;
}
return null;
}
}
export default AvatarWrapper; export default AvatarWrapper;

View File

@@ -15,7 +15,7 @@ export const byKey = (key: string) => {
} }
if (isUndefined(b, key)) { if (isUndefined(b, key)) {
return 0; return -1;
} }
if (a[key] < b[key]) { if (a[key] < b[key]) {
@@ -35,7 +35,7 @@ export const byValueLength = (key: string) => {
} }
if (isUndefined(b, key)) { if (isUndefined(b, key)) {
return 0; return -1;
} }
if (a[key].length < b[key].length) { if (a[key].length < b[key].length) {
@@ -55,7 +55,7 @@ export const byNestedKeys = (key: string, nestedKey: string) => {
} }
if (isUndefined(b, key, nestedKey)) { if (isUndefined(b, key, nestedKey)) {
return 0; return -1;
} }
if (a[key][nestedKey] < b[key][nestedKey]) { if (a[key][nestedKey] < b[key][nestedKey]) {

View File

@@ -41,7 +41,7 @@ class ConfigurationBinder {
}); });
// bind navigation link to extension point // bind navigation link to extension point
binder.bind("admin.setting", ConfigNavLink, configPredicate); binder.bind("admin.setting", ConfigNavLink, configPredicate, labelI18nKey);
// route for global configuration, passes the link from the index resource to component // route for global configuration, passes the link from the index resource to component
const ConfigRoute = ({ url, links, ...additionalProps }: GlobalRouteProps) => { const ConfigRoute = ({ url, links, ...additionalProps }: GlobalRouteProps) => {

View File

@@ -0,0 +1,62 @@
import React from "react";
import { storiesOf } from "@storybook/react";
import Footer from "./Footer";
import { Binder, BinderContext } from "@scm-manager/ui-extensions";
import { Me } from "@scm-manager/ui-types";
import { EXTENSION_POINT } from "../avatar/Avatar";
// @ts-ignore ignore unknown png
import hitchhiker from "../__resources__/hitchhiker.png";
// @ts-ignore ignore unknown jpg
import marvin from "../__resources__/marvin.jpg";
import NavLink from "../navigation/NavLink";
import ExternalLink from "../navigation/ExternalLink";
const trillian: Me = {
name: "trillian",
displayName: "Trillian McMillian",
mail: "tricia@hitchhiker.com",
groups: ["crew"],
_links: {}
};
const bindAvatar = (binder: Binder, avatar: string) => {
binder.bind(EXTENSION_POINT, () => {
return avatar;
});
};
const bindLinks = (binder: Binder) => {
binder.bind("footer.information", () => <ExternalLink to="#" label="REST API" />);
binder.bind("footer.information", () => <ExternalLink to="#" label="CLI" />);
binder.bind("footer.support", () => <ExternalLink to="#" label="FAQ" />);
binder.bind("profile.setting", () => <NavLink label="Authorized Keys" to="#" />);
};
const withBinder = (binder: Binder) => {
return (
<BinderContext.Provider value={binder}>
<Footer me={trillian} version="2.0.0" links={{}} />
</BinderContext.Provider>
);
};
storiesOf("Layout|Footer", module)
.add("Default", () => {
return <Footer me={trillian} version="2.0.0" links={{}} />;
})
.add("With Avatar", () => {
const binder = new Binder("avatar-story");
bindAvatar(binder, hitchhiker);
return withBinder(binder);
})
.add("With Plugin Links", () => {
const binder = new Binder("link-story");
bindLinks(binder);
return withBinder(binder);
})
.add("Full", () => {
const binder = new Binder("link-story");
bindAvatar(binder, marvin);
bindLinks(binder);
return withBinder(binder);
});

View File

@@ -1,27 +1,93 @@
import React from "react"; import React, { FC } from "react";
import { Me } from "@scm-manager/ui-types"; import { Me, Links } from "@scm-manager/ui-types";
import { Link } from "react-router-dom"; import { useBinder, ExtensionPoint } from "@scm-manager/ui-extensions";
import { AvatarImage } from "../avatar";
import NavLink from "../navigation/NavLink";
import FooterSection from "./FooterSection";
import styled from "styled-components";
import { EXTENSION_POINT } from "../avatar/Avatar";
import ExternalLink from "../navigation/ExternalLink";
import { useTranslation } from "react-i18next";
type Props = { type Props = {
me?: Me; me?: Me;
version: string;
links: Links;
}; };
class Footer extends React.Component<Props> { type TitleWithIconsProps = {
render() { title: string;
const { me } = this.props; icon: string;
if (!me) { };
return "";
} const TitleWithIcon: FC<TitleWithIconsProps> = ({ icon, title }) => (
return ( <>
<footer className="footer"> <i className={`fas fa-${icon} fa-fw`} /> {title}
<div className="container is-centered"> </>
<p className="has-text-centered"> );
<Link to={"/me"}>{me.displayName}</Link>
</p> type TitleWithAvatarProps = {
</div> me: Me;
</footer> };
);
const VCenteredAvatar = styled(AvatarImage)`
vertical-align: middle;
`;
const AvatarContainer = styled.span`
float: left;
margin-right: 0.3em;
padding-top: 0.2em;
width: 1em;
height: 1em;
`;
const TitleWithAvatar: FC<TitleWithAvatarProps> = ({ me }) => (
<>
<AvatarContainer className="image is-rounded">
<VCenteredAvatar person={me} representation="rounded" />
</AvatarContainer>
{me.displayName}
</>
);
const Footer: FC<Props> = ({ me, version, links }) => {
const [t] = useTranslation("commons");
const binder = useBinder();
if (!me) {
return null;
} }
}
const extensionProps = { me, url: "/me", links };
let meSectionTile;
if (binder.hasExtension(EXTENSION_POINT)) {
meSectionTile = <TitleWithAvatar me={me} />;
} else {
meSectionTile = <TitleWithIcon title={me.displayName} icon="user-circle" />;
}
return (
<footer className="footer">
<section className="section container">
<div className="columns is-size-7">
<FooterSection title={meSectionTile}>
<NavLink to="/me" label={t("footer.user.profile")} />
<NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</FooterSection>
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>
<ExternalLink to="https://www.scm-manager.org/" label={`SCM-Manager ${version}`} />
<ExtensionPoint name="footer.information" props={extensionProps} renderAll={true} />
</FooterSection>
<FooterSection title={<TitleWithIcon title={t("footer.support.title")} icon="life-ring" />}>
<ExternalLink to="https://www.scm-manager.org/support/" label={t("footer.support.community")} />
<ExternalLink to="https://cloudogu.com/en/scm-manager-enterprise/" label={t("footer.support.enterprise")} />
<ExtensionPoint name="footer.support" props={extensionProps} renderAll={true} />
</FooterSection>
</div>
</section>
</footer>
);
};
export default Footer; export default Footer;

View File

@@ -0,0 +1,26 @@
import React, { FC, ReactNode } from "react";
import styled from "styled-components";
type Props = {
title: ReactNode;
};
const Title = styled.div`
font-weight: bold;
margin-bottom: 0.5rem;
`;
const Menu = styled.ul`
padding-left: 1.1rem;
`;
const FooterSection: FC<Props> = ({ title, children }) => {
return (
<section className="column is-one-third">
<Title>{title}</Title>
<Menu>{children}</Menu>
</section>
);
};
export default FooterSection;

View File

@@ -0,0 +1,30 @@
import React, { FC } from "react";
import classNames from "classnames";
type Props = {
to: string;
icon?: string;
label: string;
};
const ExternalLink: FC<Props> = ({ to, icon, label }) => {
let showIcon;
if (icon) {
showIcon = (
<>
<i className={classNames(icon, "fa-fw")} />{" "}
</>
);
}
return (
<li>
<a target="_blank" href={to}>
{showIcon}
{label}
</a>
</li>
);
};
export default ExternalLink;

View File

@@ -33,12 +33,6 @@ describe("ExtensionPoint test", () => {
expect(rendered.text()).toBe("Extension One"); expect(rendered.text()).toBe("Extension One");
}); });
// We use this wrapper since Enzyme cannot handle React Fragments (see https://github.com/airbnb/enzyme/issues/1213)
class ExtensionPointEnzymeFix extends ExtensionPoint {
render() {
return <div>{super.render()}</div>;
}
}
it("should render the given components", () => { it("should render the given components", () => {
const labelOne = () => { const labelOne = () => {
return <label>Extension One</label>; return <label>Extension One</label>;
@@ -50,7 +44,7 @@ describe("ExtensionPoint test", () => {
mockedBinder.hasExtension.mockReturnValue(true); mockedBinder.hasExtension.mockReturnValue(true);
mockedBinder.getExtensions.mockReturnValue([labelOne, labelTwo]); mockedBinder.getExtensions.mockReturnValue([labelOne, labelTwo]);
const rendered = mount(<ExtensionPointEnzymeFix name="something.special" renderAll={true} />); const rendered = mount(<ExtensionPoint name="something.special" renderAll={true} />);
const text = rendered.text(); const text = rendered.text();
expect(text).toContain("Extension One"); expect(text).toContain("Extension One");
expect(text).toContain("Extension Two"); expect(text).toContain("Extension Two");
@@ -143,4 +137,12 @@ describe("ExtensionPoint test", () => {
const text = rendered.text(); const text = rendered.text();
expect(text).toBe("Hello Trillian"); expect(text).toBe("Hello Trillian");
}); });
it("should not render nothing without extension and without default", () => {
mockedBinder.hasExtension.mockReturnValue(false);
const rendered = mount(<ExtensionPoint name="something.special" />);
const text = rendered.text();
expect(text).toBe("");
});
}); });

View File

@@ -1,53 +1,51 @@
import * as React from "react"; import * as React from "react";
import binder from "./binder"; import { Binder } from "./binder";
import { FC, ReactNode } from "react";
import useBinder from "./useBinder";
type Props = { type Props = {
name: string; name: string;
renderAll?: boolean; renderAll?: boolean;
props?: object; props?: object;
children?: React.ReactNode; };
const renderAllExtensions = (binder: Binder, name: string, props?: object) => {
const extensions = binder.getExtensions(name, props);
return (
<>
{extensions.map((Component, index) => {
return <Component key={index} {...props} />;
})}
</>
);
};
const renderSingleExtension = (binder: Binder, name: string, props?: object) => {
const Component = binder.getExtension(name, props);
if (!Component) {
return null;
}
return <Component {...props} />;
};
const renderDefault = (children: ReactNode) => {
if (children) {
return <>{children}</>;
}
return null;
}; };
/** /**
* ExtensionPoint renders components which are bound to an extension point. * ExtensionPoint renders components which are bound to an extension point.
*/ */
class ExtensionPoint extends React.Component<Props> { const ExtensionPoint: FC<Props> = ({ name, renderAll, props, children }) => {
renderAll(name: string, props?: object) { const binder = useBinder();
const extensions = binder.getExtensions(name, props); if (!binder.hasExtension(name, props)) {
return ( return renderDefault(children);
<> } else if (renderAll) {
{extensions.map((Component, index) => { return renderAllExtensions(binder, name, props);
return <Component key={index} {...props} />;
})}
</>
);
} }
return renderSingleExtension(binder, name, props);
renderSingle(name: string, props?: object) { };
const Component = binder.getExtension(name, props);
if (!Component) {
return null;
}
return <Component {...props} />;
}
renderDefault() {
const { children } = this.props;
if (children) {
return <>{children}</>;
}
return null;
}
render() {
const { name, renderAll, props } = this.props;
if (!binder.hasExtension(name, props)) {
return this.renderDefault();
} else if (renderAll) {
return this.renderAll(name, props);
}
return this.renderSingle(name, props);
}
}
export default ExtensionPoint; export default ExtensionPoint;

View File

@@ -4,7 +4,7 @@ describe("binder tests", () => {
let binder: Binder; let binder: Binder;
beforeEach(() => { beforeEach(() => {
binder = new Binder(); binder = new Binder("testing");
}); });
it("should return an empty array for non existing extension points", () => { it("should return an empty array for non existing extension points", () => {
@@ -13,31 +13,31 @@ describe("binder tests", () => {
}); });
it("should return the binded extensions", () => { it("should return the binded extensions", () => {
binder.bind("hitchhicker.trillian", "heartOfGold"); binder.bind("hitchhiker.trillian", "heartOfGold");
binder.bind("hitchhicker.trillian", "earth"); binder.bind("hitchhiker.trillian", "earth");
const extensions = binder.getExtensions("hitchhicker.trillian"); const extensions = binder.getExtensions("hitchhiker.trillian");
expect(extensions).toEqual(["heartOfGold", "earth"]); expect(extensions).toEqual(["heartOfGold", "earth"]);
}); });
it("should return the first bound extension", () => { it("should return the first bound extension", () => {
binder.bind("hitchhicker.trillian", "heartOfGold"); binder.bind("hitchhiker.trillian", "heartOfGold");
binder.bind("hitchhicker.trillian", "earth"); binder.bind("hitchhiker.trillian", "earth");
expect(binder.getExtension("hitchhicker.trillian")).toBe("heartOfGold"); expect(binder.getExtension("hitchhiker.trillian")).toBe("heartOfGold");
}); });
it("should return null if no extension was bound", () => { it("should return null if no extension was bound", () => {
expect(binder.getExtension("hitchhicker.trillian")).toBe(null); expect(binder.getExtension("hitchhiker.trillian")).toBe(null);
}); });
it("should return true, if an extension is bound", () => { it("should return true, if an extension is bound", () => {
binder.bind("hitchhicker.trillian", "heartOfGold"); binder.bind("hitchhiker.trillian", "heartOfGold");
expect(binder.hasExtension("hitchhicker.trillian")).toBe(true); expect(binder.hasExtension("hitchhiker.trillian")).toBe(true);
}); });
it("should return false, if no extension is bound", () => { it("should return false, if no extension is bound", () => {
expect(binder.hasExtension("hitchhicker.trillian")).toBe(false); expect(binder.hasExtension("hitchhiker.trillian")).toBe(false);
}); });
type Props = { type Props = {
@@ -45,13 +45,34 @@ describe("binder tests", () => {
}; };
it("should return only extensions which predicates matches", () => { it("should return only extensions which predicates matches", () => {
binder.bind("hitchhicker.trillian", "heartOfGold", (props: Props) => props.category === "a"); binder.bind("hitchhiker.trillian", "heartOfGold", (props: Props) => props.category === "a");
binder.bind("hitchhicker.trillian", "earth", (props: Props) => props.category === "b"); binder.bind("hitchhiker.trillian", "earth", (props: Props) => props.category === "b");
binder.bind("hitchhicker.trillian", "earth2", (props: Props) => props.category === "a"); binder.bind("hitchhiker.trillian", "earth2", (props: Props) => props.category === "a");
const extensions = binder.getExtensions("hitchhicker.trillian", { const extensions = binder.getExtensions("hitchhiker.trillian", {
category: "b" category: "b"
}); });
expect(extensions).toEqual(["earth"]); expect(extensions).toEqual(["earth"]);
}); });
it("should return extensions in ascending order", () => {
binder.bind("hitchhiker.trillian", "planetA", () => true, "zeroWaste");
binder.bind("hitchhiker.trillian", "planetB", () => true, "EPSILON");
binder.bind("hitchhiker.trillian", "planetC", () => true, "emptyBin");
binder.bind("hitchhiker.trillian", "planetD", () => true, "absolute");
const extensions = binder.getExtensions("hitchhiker.trillian");
expect(extensions).toEqual(["planetD", "planetC", "planetB", "planetA"]);
});
it("should return extensions starting with entries with specified extensionName", () => {
binder.bind("hitchhiker.trillian", "planetA", () => true);
binder.bind("hitchhiker.trillian", "planetB", () => true, "zeroWaste");
binder.bind("hitchhiker.trillian", "planetC", () => true);
binder.bind("hitchhiker.trillian", "planetD", () => true, "emptyBin");
const extensions = binder.getExtensions("hitchhiker.trillian");
expect(extensions[0]).toEqual("planetD");
expect(extensions[1]).toEqual("planetB");
});
}); });

View File

@@ -3,6 +3,7 @@ type Predicate = (props: any) => boolean;
type ExtensionRegistration = { type ExtensionRegistration = {
predicate: Predicate; predicate: Predicate;
extension: any; extension: any;
extensionName: string;
}; };
/** /**
@@ -10,11 +11,13 @@ type ExtensionRegistration = {
* The Binder class is mainly exported for testing, plugins should only use the default export. * The Binder class is mainly exported for testing, plugins should only use the default export.
*/ */
export class Binder { export class Binder {
name: string;
extensionPoints: { extensionPoints: {
[key: string]: Array<ExtensionRegistration>; [key: string]: Array<ExtensionRegistration>;
}; };
constructor() { constructor(name: string) {
this.name = name;
this.extensionPoints = {}; this.extensionPoints = {};
} }
@@ -25,13 +28,14 @@ export class Binder {
* @param extension provided extension * @param extension provided extension
* @param predicate to decide if the extension gets rendered for the given props * @param predicate to decide if the extension gets rendered for the given props
*/ */
bind(extensionPoint: string, extension: any, predicate?: Predicate) { bind(extensionPoint: string, extension: any, predicate?: Predicate, extensionName?: string) {
if (!this.extensionPoints[extensionPoint]) { if (!this.extensionPoints[extensionPoint]) {
this.extensionPoints[extensionPoint] = []; this.extensionPoints[extensionPoint] = [];
} }
const registration = { const registration = {
predicate: predicate ? predicate : () => true, predicate: predicate ? predicate : () => true,
extension extension,
extensionName: extensionName ? extensionName : ""
}; };
this.extensionPoints[extensionPoint].push(registration); this.extensionPoints[extensionPoint].push(registration);
} }
@@ -61,6 +65,7 @@ export class Binder {
if (props) { if (props) {
registrations = registrations.filter(reg => reg.predicate(props || {})); registrations = registrations.filter(reg => reg.predicate(props || {}));
} }
registrations.sort(this.sortExtensions);
return registrations.map(reg => reg.extension); return registrations.map(reg => reg.extension);
} }
@@ -70,9 +75,28 @@ export class Binder {
hasExtension(extensionPoint: string, props?: object): boolean { hasExtension(extensionPoint: string, props?: object): boolean {
return this.getExtensions(extensionPoint, props).length > 0; return this.getExtensions(extensionPoint, props).length > 0;
} }
/**
* Sort extensions in ascending order, starting with entries with specified extensionName.
*/
sortExtensions = (a: ExtensionRegistration, b: ExtensionRegistration) => {
const regA = a.extensionName ? a.extensionName.toUpperCase() : "";
const regB = b.extensionName ? b.extensionName.toUpperCase() : "";
if (regA === "" && regB !== "") {
return 1;
} else if (regA !== "" && regB === "") {
return -1;
} else if (regA > regB) {
return 1;
} else if (regA < regB) {
return -1;
}
return 0;
};
} }
// singleton binder // singleton binder
const binder = new Binder(); const binder = new Binder("default");
export default binder; export default binder;

View File

@@ -1,2 +1,3 @@
export { default as binder } from "./binder"; export { default as binder, Binder } from "./binder";
export * from "./useBinder";
export { default as ExtensionPoint } from "./ExtensionPoint"; export { default as ExtensionPoint } from "./ExtensionPoint";

View File

@@ -0,0 +1,29 @@
import useBinder, { BinderContext } from "./useBinder";
import { Binder } from "./binder";
import { mount } from "enzyme";
import "@scm-manager/ui-tests/enzyme";
import React from "react";
describe("useBinder tests", () => {
const BinderName = () => {
const binder = useBinder();
return <>{binder.name}</>;
};
it("should return default binder", () => {
const rendered = mount(<BinderName />);
expect(rendered.text()).toBe("default");
});
it("should return binder from context", () => {
const binder = new Binder("from-context");
const app = (
<BinderContext.Provider value={binder}>
<BinderName />
</BinderContext.Provider>
);
const rendered = mount(app);
expect(rendered.text()).toBe("from-context");
});
});

View File

@@ -0,0 +1,16 @@
import { createContext, useContext } from "react";
import defaultBinder from "./binder";
/**
* The BinderContext should only be used to override the default binder for testing purposes.
*/
export const BinderContext = createContext(defaultBinder);
/**
* Hook to get the binder from context.
*/
export const useBinder = () => {
return useContext(BinderContext);
};
export default useBinder;

View File

@@ -67,8 +67,14 @@ hr.header-with-actions {
} }
} }
.footer { footer.footer {
height: 50px; //height: 100px;
background-color: $white-ter;
padding: inherit;
a {
color: darken($blue, 15%);
}
} }
// 6. Import the rest of Bulma // 6. Import the rest of Bulma
@@ -691,11 +697,6 @@ form .field:not(.is-grouped) {
} }
} }
// footer
.footer {
background-color: whitesmoke;
}
// aside // aside
.aside-background { .aside-background {
bottom: 0; bottom: 0;

View File

@@ -4,6 +4,6 @@ export type Me = {
name: string; name: string;
displayName: string; displayName: string;
mail: string; mail: string;
groups: []; groups: string[];
_links: Links; _links: Links;
}; };

View File

@@ -86,5 +86,18 @@
"passwordConfirmFailed": "Passwörter müssen identisch sein!", "passwordConfirmFailed": "Passwörter müssen identisch sein!",
"submit": "Speichern", "submit": "Speichern",
"changedSuccessfully": "Passwort erfolgreich geändert!" "changedSuccessfully": "Passwort erfolgreich geändert!"
},
"footer": {
"user": {
"profile": "Profil"
},
"information": {
"title": "Information"
},
"support": {
"title": "Support",
"community": "Community",
"enterprise": "Enterprise"
}
} }
} }

View File

@@ -87,5 +87,18 @@
"passwordConfirmFailed": "Passwords have to be identical", "passwordConfirmFailed": "Passwords have to be identical",
"submit": "Submit", "submit": "Submit",
"changedSuccessfully": "Password changed successfully" "changedSuccessfully": "Password changed successfully"
},
"footer": {
"user": {
"profile": "Profile"
},
"information": {
"title": "Information"
},
"support": {
"title": "Support",
"community": "Community",
"enterprise": "Enterprise"
}
} }
} }

View File

@@ -8,6 +8,7 @@ import { fetchMe, getFetchMeFailure, getMe, isAuthenticated, isFetchMePending }
import { ErrorPage, Footer, Header, Loading, PrimaryNavigation } from "@scm-manager/ui-components"; import { ErrorPage, Footer, Header, Loading, PrimaryNavigation } from "@scm-manager/ui-components";
import { Links, Me } from "@scm-manager/ui-types"; import { Links, Me } from "@scm-manager/ui-types";
import { import {
getAppVersion,
getFetchIndexResourcesFailure, getFetchIndexResourcesFailure,
getLinks, getLinks,
getMeLink, getMeLink,
@@ -21,6 +22,7 @@ type Props = WithTranslation & {
loading: boolean; loading: boolean;
links: Links; links: Links;
meLink: string; meLink: string;
version: string;
// dispatcher functions // dispatcher functions
fetchMe: (link: string) => void; fetchMe: (link: string) => void;
@@ -34,7 +36,7 @@ class App extends Component<Props> {
} }
render() { render() {
const { me, loading, error, authenticated, links, t } = this.props; const { me, loading, error, authenticated, links, version, t } = this.props;
let content; let content;
const navigation = authenticated ? <PrimaryNavigation links={links} /> : ""; const navigation = authenticated ? <PrimaryNavigation links={links} /> : "";
@@ -50,7 +52,7 @@ class App extends Component<Props> {
<div className="App"> <div className="App">
<Header>{navigation}</Header> <Header>{navigation}</Header>
{content} {content}
{authenticated && <Footer me={me} />} {authenticated && <Footer me={me} version={version} links={links} />}
</div> </div>
); );
} }
@@ -69,13 +71,15 @@ const mapStateToProps = (state: any) => {
const error = getFetchMeFailure(state) || getFetchIndexResourcesFailure(state); const error = getFetchMeFailure(state) || getFetchIndexResourcesFailure(state);
const links = getLinks(state); const links = getLinks(state);
const meLink = getMeLink(state); const meLink = getMeLink(state);
const version = getAppVersion(state);
return { return {
authenticated, authenticated,
me, me,
loading, loading,
error, error,
links, links,
meLink meLink,
version
}; };
}; };

View File

@@ -426,6 +426,13 @@
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations</artifactId>
<version>2.1.1</version>
<scope>provided</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -471,6 +478,49 @@
</executions> </executions>
</plugin> </plugin>
<plugin>
<groupId>io.openapitools.swagger</groupId>
<artifactId>swagger-maven-plugin</artifactId>
<configuration>
<resourcePackages>
<resourcePackage>sonia.scm.api.v2.resources</resourcePackage>
</resourcePackages>
<outputDirectory>${basedir}/target/openapi/META-INF/scm</outputDirectory>
<outputFilename>openapi</outputFilename>
<outputFormats>JSON,YAML</outputFormats>
<prettyPrint>true</prettyPrint>
<swaggerConfig>
<servers>
<server>
<url>http://localhost:8081/scm/api</url>
<description>local endpoint url</description>
</server>
</servers>
<info>
<title>SCM-Manager REST-API</title>
<version>${project.version}</version>
<contact>
<email>scmmanager@googlegroups.com</email>
<name>SCM-Manager</name>
<url>https://scm-manager.org</url>
</contact>
<license>
<url>http://www.opensource.org/licenses/bsd-license.php</url>
<name>BSD</name>
</license>
</info>
<descriptionFile>src/main/doc/openapi.md</descriptionFile>
</swaggerConfig>
</configuration>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin> <plugin>
<groupId>sonia.scm.maven</groupId> <groupId>sonia.scm.maven</groupId>
<artifactId>smp-maven-plugin</artifactId> <artifactId>smp-maven-plugin</artifactId>
@@ -511,9 +561,15 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId> <artifactId>maven-war-plugin</artifactId>
<version>2.2</version> <version>3.1.0</version>
<configuration> <configuration>
<filteringDeploymentDescriptors>true</filteringDeploymentDescriptors> <filteringDeploymentDescriptors>true</filteringDeploymentDescriptors>
<webResources>
<resource>
<directory>target/openapi</directory>
<targetPath>WEB-INF/classes</targetPath>
</resource>
</webResources>
</configuration> </configuration>
</plugin> </plugin>
@@ -860,107 +916,9 @@
</execution> </execution>
</executions> </executions>
</plugin> </plugin>
</plugins>
</build>
</profile>
<profile>
<id>doc</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-enunciate-configuration</id>
<phase>compile</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}</outputDirectory>
<resources>
<resource>
<directory>src/main/doc</directory>
<filtering>true</filtering>
<includes>
<include>**/enunciate.xml</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.webcohesion.enunciate</groupId>
<artifactId>enunciate-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>docs</goal>
</goals>
<phase>compile</phase>
</execution>
</executions>
<configuration>
<configFile>${project.build.directory}/enunciate.xml</configFile>
<docsDir>${project.build.directory}</docsDir>
<docsSubdir>restdocs</docsSubdir>
</configuration>
<dependencies>
<dependency>
<groupId>com.webcohesion.enunciate</groupId>
<artifactId>enunciate-top</artifactId>
<version>${enunciate.version}</version>
<exclusions>
<exclusion>
<groupId>com.webcohesion.enunciate</groupId>
<artifactId>enunciate-swagger</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.webcohesion.enunciate</groupId>
<artifactId>enunciate-lombok</artifactId>
<version>${enunciate.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<descriptors>
<descriptor>src/main/doc/assembly.xml</descriptor>
</descriptors>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins> </plugins>
</build> </build>
</profile> </profile>
</profiles> </profiles>
</project> </project>

View File

@@ -1,67 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright (c) 2010, Sebastian Sdorra
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of SCM-Manager; nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
http://bitbucket.org/sdorra/scm-manager
-->
<!--
Document : enunciate.xml
Created on : October 2, 2011, 12:02 PM
Author : Sebastian Sdorra
Description: Enunciate configuration
-->
<enunciate xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://enunciate.webcohesion.com/schemas/enunciate-2.9.0.xsd"
slug="scm-manager" version="${project.version}">
<title>SCM-Manager API</title>
<description>
<![CDATA[
<h1>SCM-Manager API</h1>
<p>This page describes the RESTful Web Service API of <a href="https://www.scm-manager.org">SCM-Manager</a> ${project.version}.</p>
]]>
</description>
<api-classes/>
<modules>
<jaxrs datatype-detection="local">
<application path="/api" />
</jaxrs>
<docs disableResourceLinks="true" includeApplicationPath="true" />
</modules>
</enunciate>

View File

@@ -0,0 +1,15 @@
The following REST documentation describes all public endpoints of your SCM-Manager instance.
You can try the endpoints with or without authentication right on the swagger surface provided by the OpenAPI-Plugin.
For authenticated requests please use the "Authorize" button and insert your preferred authentication method.
For basic authentication simply use your SCM-Manager credentials. If you want to use the bearer token authentication, you can generate an
valid token using the authentication endpoint and copy the response body.
SCM-Manager defines a modern ["Level 3"-REST API](https://martinfowler.com/articles/richardsonMaturityModel.html).
Using the HATEOAS architecture for REST allows us to provide discoverable and self explanatory endpoint definitions.
The responses are build using the [HAL JSON format](http://stateless.co/hal_specification.html).
HAL makes the API human-friendly and simplifies the communication between the frontend and the server using links and embedded resources.
We highly suggest using HAL links when creating new functions for SCM-Manager since they are consistent and can be
permission checked before being append to the response. The links and embedded resources can also be used by plugins, which can
define new resources or enrich existing ones.

View File

@@ -1,19 +1,19 @@
/** /**
* Copyright (c) 2010, Sebastian Sdorra * Copyright (c) 2010, Sebastian Sdorra
* All rights reserved. * All rights reserved.
* * <p>
* Redistribution and use in source and binary forms, with or without * Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met: * modification, are permitted provided that the following conditions are met:
* * <p>
* 1. Redistributions of source code must retain the above copyright notice, * 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer. * this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice, * 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation * this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution. * and/or other materials provided with the distribution.
* 3. Neither the name of SCM-Manager; nor the names of its * 3. Neither the name of SCM-Manager; nor the names of its
* contributors may be used to endorse or promote products derived from this * contributors may be used to endorse or promote products derived from this
* software without specific prior written permission. * software without specific prior written permission.
* * <p>
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
@@ -24,13 +24,11 @@
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
* * <p>
* http://bitbucket.org/sdorra/scm-manager * http://bitbucket.org/sdorra/scm-manager
*
*/ */
package sonia.scm.api.rest.resources; package sonia.scm.api.rest.resources;
import com.google.common.base.MoreObjects; import com.google.common.base.MoreObjects;
@@ -38,10 +36,6 @@ import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.io.Files; import com.google.common.io.Files;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.ResponseHeader;
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import com.webcohesion.enunciate.metadata.rs.TypeHint;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.FeatureNotSupportedException; import sonia.scm.FeatureNotSupportedException;
@@ -100,8 +94,7 @@ import static com.google.common.base.Preconditions.checkNotNull;
* @author Sebastian Sdorra * @author Sebastian Sdorra
*/ */
// @Path("import/repositories") // @Path("import/repositories")
public class RepositoryImportResource public class RepositoryImportResource {
{
/** /**
* the logger for RepositoryImportResource * the logger for RepositoryImportResource
@@ -114,13 +107,12 @@ public class RepositoryImportResource
/** /**
* Constructs a new repository import resource. * Constructs a new repository import resource.
* *
* @param manager repository manager * @param manager repository manager
* @param serviceFactory * @param serviceFactory
*/ */
@Inject @Inject
public RepositoryImportResource(RepositoryManager manager, public RepositoryImportResource(RepositoryManager manager,
RepositoryServiceFactory serviceFactory) RepositoryServiceFactory serviceFactory) {
{
this.manager = manager; this.manager = manager;
this.serviceFactory = serviceFactory; this.serviceFactory = serviceFactory;
} }
@@ -133,37 +125,23 @@ public class RepositoryImportResource
* bundle file is passed to the {@link UnbundleCommandBuilder}. <strong>Note:</strong> This method * bundle file is passed to the {@link UnbundleCommandBuilder}. <strong>Note:</strong> This method
* requires admin privileges. * requires admin privileges.
* *
* @param uriInfo uri info * @param uriInfo uri info
* @param type repository type * @param type repository type
* @param name name of the repository * @param name name of the repository
* @param inputStream input bundle * @param inputStream input bundle
* @param compressed true if the bundle is gzip compressed * @param compressed true if the bundle is gzip compressed
*
* @return empty response with location header which points to the imported repository * @return empty response with location header which points to the imported repository
* @since 1.43 * @since 1.43
*/ */
@POST @POST
@Path("{type}/bundle") @Path("{type}/bundle")
@StatusCodes({
@ResponseCode(code = 201, condition = "created", additionalHeaders = {
@ResponseHeader(name = "Location", description = "uri to the imported repository")
}),
@ResponseCode(
code = 400,
condition = "bad request, the import bundle feature is not supported by this type of repositories or the parameters are not valid"
),
@ResponseCode(code = 409, condition = "conflict, a repository with the name already exists"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(TypeHint.NO_CONTENT.class)
@Consumes(MediaType.MULTIPART_FORM_DATA) @Consumes(MediaType.MULTIPART_FORM_DATA)
public Response importFromBundle(@Context UriInfo uriInfo, public Response importFromBundle(@Context UriInfo uriInfo,
@PathParam("type") String type, @FormParam("name") String name, @PathParam("type") String type, @FormParam("name") String name,
@FormParam("bundle") InputStream inputStream, @QueryParam("compressed") @FormParam("bundle") InputStream inputStream, @QueryParam("compressed")
@DefaultValue("false") boolean compressed) @DefaultValue("false") boolean compressed) {
{
Repository repository = doImportFromBundle(type, name, inputStream, Repository repository = doImportFromBundle(type, name, inputStream,
compressed); compressed);
return buildResponse(uriInfo, repository); return buildResponse(uriInfo, repository);
} }
@@ -175,43 +153,28 @@ public class RepositoryImportResource
* workaround of the javascript ui extjs. <strong>Note:</strong> This method requires admin * workaround of the javascript ui extjs. <strong>Note:</strong> This method requires admin
* privileges. * privileges.
* *
* @param type repository type * @param type repository type
* @param name name of the repository * @param name name of the repository
* @param inputStream input bundle * @param inputStream input bundle
* @param compressed true if the bundle is gzip compressed * @param compressed true if the bundle is gzip compressed
*
* @return empty response with location header which points to the imported * @return empty response with location header which points to the imported
* repository * repository
* @since 1.43 * @since 1.43
*/ */
@POST @POST
@Path("{type}/bundle.html") @Path("{type}/bundle.html")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(
code = 400,
condition = "bad request, the import bundle feature is not supported by this type of repositories or the parameters are not valid"
),
@ResponseCode(code = 409, condition = "conflict, a repository with the name already exists"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(RestActionUploadResult.class)
@Consumes(MediaType.MULTIPART_FORM_DATA) @Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.TEXT_HTML) @Produces(MediaType.TEXT_HTML)
public Response importFromBundleUI(@PathParam("type") String type, public Response importFromBundleUI(@PathParam("type") String type,
@FormParam("name") String name, @FormParam("name") String name,
@FormParam("bundle") InputStream inputStream, @QueryParam("compressed") @FormParam("bundle") InputStream inputStream, @QueryParam("compressed")
@DefaultValue("false") boolean compressed) @DefaultValue("false") boolean compressed) {
{
Response response; Response response;
try try {
{
doImportFromBundle(type, name, inputStream, compressed); doImportFromBundle(type, name, inputStream, compressed);
response = Response.ok(new RestActionUploadResult(true)).build(); response = Response.ok(new RestActionUploadResult(true)).build();
} } catch (WebApplicationException ex) {
catch (WebApplicationException ex)
{
logger.warn("error durring bundle import", ex); logger.warn("error durring bundle import", ex);
response = Response.fromResponse(ex.getResponse()).entity( response = Response.fromResponse(ex.getResponse()).entity(
new RestActionUploadResult(false)).build(); new RestActionUploadResult(false)).build();
@@ -227,31 +190,17 @@ public class RepositoryImportResource
* repository. <strong>Note:</strong> This method requires admin privileges. * repository. <strong>Note:</strong> This method requires admin privileges.
* *
* @param uriInfo uri info * @param uriInfo uri info
* @param type repository type * @param type repository type
* @param request request object * @param request request object
*
* @return empty response with location header which points to the imported * @return empty response with location header which points to the imported
* repository * repository
* @since 1.43 * @since 1.43
*/ */
@POST @POST
@Path("{type}/url") @Path("{type}/url")
@StatusCodes({ @Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
@ResponseCode(code = 201, condition = "created", additionalHeaders = {
@ResponseHeader(name = "Location", description = "uri to the imported repository")
}),
@ResponseCode(
code = 400,
condition = "bad request, the import feature is not supported by this type of repositories or the parameters are not valid"
),
@ResponseCode(code = 409, condition = "conflict, a repository with the name already exists"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(TypeHint.NO_CONTENT.class)
@Consumes({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML })
public Response importFromUrl(@Context UriInfo uriInfo, public Response importFromUrl(@Context UriInfo uriInfo,
@PathParam("type") String type, UrlImportRequest request) @PathParam("type") String type, UrlImportRequest request) {
{
RepositoryPermissions.create().check(); RepositoryPermissions.create().check();
checkNotNull(request, "request is required"); checkNotNull(request, "request is required");
checkArgument(!Strings.isNullOrEmpty(request.getName()), checkArgument(!Strings.isNullOrEmpty(request.getName()),
@@ -268,17 +217,12 @@ public class RepositoryImportResource
Repository repository = create(type, request.getName()); Repository repository = create(type, request.getName());
RepositoryService service = null; RepositoryService service = null;
try try {
{
service = serviceFactory.create(repository); service = serviceFactory.create(repository);
service.getPullCommand().pull(request.getUrl()); service.getPullCommand().pull(request.getUrl());
} } catch (IOException ex) {
catch (IOException ex)
{
handleImportFailure(ex, repository); handleImportFailure(ex, repository);
} } finally {
finally
{
IOUtil.close(service); IOUtil.close(service);
} }
@@ -290,23 +234,12 @@ public class RepositoryImportResource
* directory. <strong>Note:</strong> This method requires admin privileges. * directory. <strong>Note:</strong> This method requires admin privileges.
* *
* @param type repository type * @param type repository type
*
* @return imported repositories * @return imported repositories
*/ */
@POST @POST
@Path("{type}") @Path("{type}")
@StatusCodes({ @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
@ResponseCode(code = 200, condition = "success"), public Response importRepositories(@PathParam("type") String type) {
@ResponseCode(
code = 400,
condition = "bad request, the import feature is not supported by this type of repositories"
),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(Repository[].class)
@Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML })
public Response importRepositories(@PathParam("type") String type)
{
RepositoryPermissions.create().check(); RepositoryPermissions.create().check();
List<Repository> repositories = new ArrayList<Repository>(); List<Repository> repositories = new ArrayList<Repository>();
@@ -315,7 +248,8 @@ public class RepositoryImportResource
//J- //J-
return Response.ok( return Response.ok(
new GenericEntity<List<Repository>>(repositories) {} new GenericEntity<List<Repository>>(repositories) {
}
).build(); ).build();
//J+ //J+
} }
@@ -327,32 +261,22 @@ public class RepositoryImportResource
* @return imported repositories * @return imported repositories
*/ */
@POST @POST
@StatusCodes({ @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
@ResponseCode(code = 200, condition = "success"), public Response importRepositories() {
@ResponseCode(
code = 400,
condition = "bad request, the import feature is not supported by this type of repositories"
),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(Repository[].class)
@Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML })
public Response importRepositories()
{
RepositoryPermissions.create().check(); RepositoryPermissions.create().check();
logger.info("start directory import for all supported repository types"); logger.info("start directory import for all supported repository types");
List<Repository> repositories = new ArrayList<Repository>(); List<Repository> repositories = new ArrayList<Repository>();
for (Type t : findImportableTypes()) for (Type t : findImportableTypes()) {
{
importFromDirectory(repositories, t.getName()); importFromDirectory(repositories, t.getName());
} }
//J- //J-
return Response.ok( return Response.ok(
new GenericEntity<List<Repository>>(repositories) {} new GenericEntity<List<Repository>>(repositories) {
}
).build(); ).build();
//J+ //J+
} }
@@ -363,72 +287,50 @@ public class RepositoryImportResource
* of failed directories. <strong>Note:</strong> This method requires admin privileges. * of failed directories. <strong>Note:</strong> This method requires admin privileges.
* *
* @param type repository type * @param type repository type
*
* @return imported repositories * @return imported repositories
* @since 1.43 * @since 1.43
*/ */
@POST @POST
@Path("{type}/directory") @Path("{type}/directory")
@StatusCodes({ @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(
code = 400,
condition = "bad request, the import feature is not supported by this type of repositories"
),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(ImportResult.class)
@Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML })
public Response importRepositoriesFromDirectory( public Response importRepositoriesFromDirectory(
@PathParam("type") String type) @PathParam("type") String type) {
{
RepositoryPermissions.create().check(); RepositoryPermissions.create().check();
Response response; Response response;
RepositoryHandler handler = manager.getHandler(type); RepositoryHandler handler = manager.getHandler(type);
if (handler != null) if (handler != null) {
{
logger.info("start directory import for repository type {}", type); logger.info("start directory import for repository type {}", type);
try try {
{
ImportResult result; ImportResult result;
ImportHandler importHandler = handler.getImportHandler(); ImportHandler importHandler = handler.getImportHandler();
if (importHandler instanceof AdvancedImportHandler) if (importHandler instanceof AdvancedImportHandler) {
{
logger.debug("start directory import, using advanced import handler"); logger.debug("start directory import, using advanced import handler");
result = result =
((AdvancedImportHandler) importHandler) ((AdvancedImportHandler) importHandler)
.importRepositoriesFromDirectory(manager); .importRepositoriesFromDirectory(manager);
} } else {
else
{
logger.debug("start directory import, using normal import handler"); logger.debug("start directory import, using normal import handler");
result = new ImportResult(importHandler.importRepositories(manager), result = new ImportResult(importHandler.importRepositories(manager),
ImmutableList.<String>of()); ImmutableList.<String>of());
} }
response = Response.ok(result).build(); response = Response.ok(result).build();
} } catch (FeatureNotSupportedException ex) {
catch (FeatureNotSupportedException ex)
{
logger logger
.warn( .warn(
"import feature is not supported by repository handler for type " "import feature is not supported by repository handler for type "
.concat(type), ex); .concat(type), ex);
response = Response.status(Response.Status.BAD_REQUEST).build(); response = Response.status(Response.Status.BAD_REQUEST).build();
} } catch (IOException ex) {
catch (IOException ex)
{
logger.warn("exception occured durring directory import", ex); logger.warn("exception occured durring directory import", ex);
response = Response.serverError().build(); response = Response.serverError().build();
} }
} } else {
else
{
logger.warn("could not find reposiotry handler for type {}", type); logger.warn("could not find reposiotry handler for type {}", type);
response = Response.status(Response.Status.BAD_REQUEST).build(); response = Response.status(Response.Status.BAD_REQUEST).build();
} }
@@ -445,25 +347,16 @@ public class RepositoryImportResource
* @return list of repository types * @return list of repository types
*/ */
@GET @GET
@TypeHint(Type[].class) @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
@StatusCodes({ public Response getImportableTypes() {
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(
code = 400,
condition = "bad request, the import feature is not supported by this type of repositories"
),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML })
public Response getImportableTypes()
{
RepositoryPermissions.create().check(); RepositoryPermissions.create().check();
List<Type> types = findImportableTypes(); List<Type> types = findImportableTypes();
//J- //J-
return Response.ok( return Response.ok(
new GenericEntity<List<Type>>(types) {} new GenericEntity<List<Type>>(types) {
}
).build(); ).build();
//J+ //J+
} }
@@ -473,16 +366,13 @@ public class RepositoryImportResource
/** /**
* Build rest response for repository. * Build rest response for repository.
* *
* * @param uriInfo uri info
* @param uriInfo uri info
* @param repository imported repository * @param repository imported repository
*
* @return rest response * @return rest response
*/ */
private Response buildResponse(UriInfo uriInfo, Repository repository) private Response buildResponse(UriInfo uriInfo, Repository repository) {
{
URI location = uriInfo.getBaseUriBuilder().path( URI location = uriInfo.getBaseUriBuilder().path(
RepositoryResource.class).path(repository.getId()).build(); RepositoryResource.class).path(repository.getId()).build();
return Response.created(location).build(); return Response.created(location).build();
} }
@@ -490,15 +380,12 @@ public class RepositoryImportResource
/** /**
* Check repository type for support for the given command. * Check repository type for support for the given command.
* *
* * @param type repository type
* @param type repository type * @param cmd command
* @param cmd command
* @param request request object * @param request request object
*/ */
private void checkSupport(Type type, Command cmd, Object request) private void checkSupport(Type type, Command cmd, Object request) {
{ if (!(type instanceof RepositoryType)) {
if (!(type instanceof RepositoryType))
{
logger.warn("type {} is not a repository type", type.getName()); logger.warn("type {} is not a repository type", type.getName());
throw new WebApplicationException(Response.Status.BAD_REQUEST); throw new WebApplicationException(Response.Status.BAD_REQUEST);
@@ -506,8 +393,7 @@ public class RepositoryImportResource
Set<Command> cmds = ((RepositoryType) type).getSupportedCommands(); Set<Command> cmds = ((RepositoryType) type).getSupportedCommands();
if (!cmds.contains(cmd)) if (!cmds.contains(cmd)) {
{
logger.warn("type {} does not support this type of import: {}", logger.warn("type {} does not support this type of import: {}",
type.getName(), request); type.getName(), request);
@@ -518,24 +404,18 @@ public class RepositoryImportResource
/** /**
* Creates a new repository with the given name and type. * Creates a new repository with the given name and type.
* *
*
* @param type repository type * @param type repository type
* @param name repository name * @param name repository name
*
* @return newly created repository * @return newly created repository
*/ */
private Repository create(String type, String name) private Repository create(String type, String name) {
{
Repository repository = null; Repository repository = null;
try try {
{
// TODO #8783 // TODO #8783
// repository = new Repository(null, type, name); // repository = new Repository(null, type, name);
manager.create(repository); manager.create(repository);
} } catch (InternalRepositoryException ex) {
catch (InternalRepositoryException ex)
{
handleGenericCreationFailure(ex, type, name); handleGenericCreationFailure(ex, type, name);
} }
@@ -545,17 +425,14 @@ public class RepositoryImportResource
/** /**
* Start bundle import. * Start bundle import.
* *
* * @param type repository type
* @param type repository type * @param name name of the repository
* @param name name of the repository
* @param inputStream bundle stream * @param inputStream bundle stream
* @param compressed true if the bundle is gzip compressed * @param compressed true if the bundle is gzip compressed
*
* @return imported repository * @return imported repository
*/ */
private Repository doImportFromBundle(String type, String name, private Repository doImportFromBundle(String type, String name,
InputStream inputStream, boolean compressed) InputStream inputStream, boolean compressed) {
{
RepositoryPermissions.create().check(); RepositoryPermissions.create().check();
checkArgument(!Strings.isNullOrEmpty(name), checkArgument(!Strings.isNullOrEmpty(name),
@@ -564,8 +441,7 @@ public class RepositoryImportResource
Repository repository; Repository repository;
try try {
{
Type t = type(type); Type t = type(type);
checkSupport(t, Command.UNBUNDLE, "bundle"); checkSupport(t, Command.UNBUNDLE, "bundle");
@@ -576,26 +452,19 @@ public class RepositoryImportResource
File file = File.createTempFile("scm-import-", ".bundle"); File file = File.createTempFile("scm-import-", ".bundle");
try try {
{
long length = Files.asByteSink(file).writeFrom(inputStream); long length = Files.asByteSink(file).writeFrom(inputStream);
logger.info("copied {} bytes to temp, start bundle import", length); logger.info("copied {} bytes to temp, start bundle import", length);
service = serviceFactory.create(repository); service = serviceFactory.create(repository);
service.getUnbundleCommand().setCompressed(compressed).unbundle(file); service.getUnbundleCommand().setCompressed(compressed).unbundle(file);
} } catch (InternalRepositoryException ex) {
catch (InternalRepositoryException ex)
{
handleImportFailure(ex, repository); handleImportFailure(ex, repository);
} } finally {
finally
{
IOUtil.close(service); IOUtil.close(service);
IOUtil.delete(file); IOUtil.delete(file);
} }
} } catch (IOException ex) {
catch (IOException ex)
{
logger.warn("could not create temporary file", ex); logger.warn("could not create temporary file", ex);
throw new WebApplicationException(ex); throw new WebApplicationException(ex);
@@ -607,42 +476,29 @@ public class RepositoryImportResource
/** /**
* Method description * Method description
* *
*
* @return * @return
*/ */
private List<Type> findImportableTypes() private List<Type> findImportableTypes() {
{
List<Type> types = new ArrayList<Type>(); List<Type> types = new ArrayList<Type>();
Collection<Type> handlerTypes = manager.getTypes(); Collection<Type> handlerTypes = manager.getTypes();
for (Type t : handlerTypes) for (Type t : handlerTypes) {
{
RepositoryHandler handler = manager.getHandler(t.getName()); RepositoryHandler handler = manager.getHandler(t.getName());
if (handler != null) if (handler != null) {
{ try {
try if (handler.getImportHandler() != null) {
{
if (handler.getImportHandler() != null)
{
types.add(t); types.add(t);
} }
} } catch (FeatureNotSupportedException ex) {
catch (FeatureNotSupportedException ex) if (logger.isTraceEnabled()) {
{
if (logger.isTraceEnabled())
{
logger.trace("import handler is not supported", ex); logger.trace("import handler is not supported", ex);
} } else if (logger.isInfoEnabled()) {
else if (logger.isInfoEnabled())
{
logger.info("{} handler does not support import of repositories", logger.info("{} handler does not support import of repositories",
t.getName()); t.getName());
} }
} }
} } else if (logger.isWarnEnabled()) {
else if (logger.isWarnEnabled())
{
logger.warn("could not find handler for type {}", t.getName()); logger.warn("could not find handler for type {}", t.getName());
} }
} }
@@ -653,14 +509,12 @@ public class RepositoryImportResource
/** /**
* Handle creation failures. * Handle creation failures.
* *
* * @param ex exception
* @param ex exception
* @param type repository type * @param type repository type
* @param name name of the repository * @param name name of the repository
*/ */
private void handleGenericCreationFailure(Exception ex, String type, private void handleGenericCreationFailure(Exception ex, String type,
String name) String name) {
{
logger.error(String.format("could not create repository %s with type %s", logger.error(String.format("could not create repository %s with type %s",
type, name), ex); type, name), ex);
@@ -670,20 +524,15 @@ public class RepositoryImportResource
/** /**
* Handle import failures. * Handle import failures.
* *
* * @param ex exception
* @param ex exception
* @param repository repository * @param repository repository
*/ */
private void handleImportFailure(Exception ex, Repository repository) private void handleImportFailure(Exception ex, Repository repository) {
{
logger.error("import for repository failed, delete repository", ex); logger.error("import for repository failed, delete repository", ex);
try try {
{
manager.delete(repository); manager.delete(repository);
} } catch (InternalRepositoryException | NotFoundException e) {
catch (InternalRepositoryException | NotFoundException e)
{
logger.error("can not delete repository after import failure", e); logger.error("can not delete repository after import failure", e);
} }
@@ -694,27 +543,21 @@ public class RepositoryImportResource
/** /**
* Import repositories from a specific type. * Import repositories from a specific type.
* *
*
* @param repositories repository list * @param repositories repository list
* @param type type of repository * @param type type of repository
*/ */
private void importFromDirectory(List<Repository> repositories, String type) private void importFromDirectory(List<Repository> repositories, String type) {
{
RepositoryHandler handler = manager.getHandler(type); RepositoryHandler handler = manager.getHandler(type);
if (handler != null) if (handler != null) {
{
logger.info("start directory import for repository type {}", type); logger.info("start directory import for repository type {}", type);
try try {
{
List<String> repositoryNames = List<String> repositoryNames =
handler.getImportHandler().importRepositories(manager); handler.getImportHandler().importRepositories(manager);
if (repositoryNames != null) if (repositoryNames != null) {
{ for (String repositoryName : repositoryNames) {
for (String repositoryName : repositoryNames)
{
// TODO #8783 // TODO #8783
/*Repository repository = null; //manager.get(type, repositoryName); /*Repository repository = null; //manager.get(type, repositoryName);
@@ -729,22 +572,14 @@ public class RepositoryImportResource
}*/ }*/
} }
} }
} } catch (FeatureNotSupportedException ex) {
catch (FeatureNotSupportedException ex)
{
throw new WebApplicationException(ex, Response.Status.BAD_REQUEST); throw new WebApplicationException(ex, Response.Status.BAD_REQUEST);
} } catch (IOException ex) {
catch (IOException ex) throw new WebApplicationException(ex);
{ } catch (InternalRepositoryException ex) {
throw new WebApplicationException(ex); throw new WebApplicationException(ex);
} }
catch (InternalRepositoryException ex) } else {
{
throw new WebApplicationException(ex);
}
}
else
{
throw new WebApplicationException(Response.Status.BAD_REQUEST); throw new WebApplicationException(Response.Status.BAD_REQUEST);
} }
} }
@@ -752,17 +587,13 @@ public class RepositoryImportResource
/** /**
* Method description * Method description
* *
*
* @param type * @param type
*
* @return * @return
*/ */
private Type type(String type) private Type type(String type) {
{
RepositoryHandler handler = manager.getHandler(type); RepositoryHandler handler = manager.getHandler(type);
if (handler == null) if (handler == null) {
{
logger.warn("no handler for type {} found", type); logger.warn("no handler for type {} found", type);
throw new WebApplicationException(Response.Status.NOT_FOUND); throw new WebApplicationException(Response.Status.NOT_FOUND);
@@ -778,24 +609,21 @@ public class RepositoryImportResource
*/ */
@XmlRootElement(name = "import") @XmlRootElement(name = "import")
@XmlAccessorType(XmlAccessType.FIELD) @XmlAccessorType(XmlAccessType.FIELD)
public static class UrlImportRequest public static class UrlImportRequest {
{
/** /**
* Constructs ... * Constructs ...
*
*/ */
public UrlImportRequest() {} public UrlImportRequest() {
}
/** /**
* Constructs a new {@link UrlImportRequest} * Constructs a new {@link UrlImportRequest}
* *
*
* @param name name of the repository * @param name name of the repository
* @param url external url of the repository * @param url external url of the repository
*/ */
public UrlImportRequest(String name, String url) public UrlImportRequest(String name, String url) {
{
this.name = name; this.name = name;
this.url = url; this.url = url;
} }
@@ -806,13 +634,12 @@ public class RepositoryImportResource
* {@inheritDoc} * {@inheritDoc}
*/ */
@Override @Override
public String toString() public String toString() {
{
//J- //J-
return MoreObjects.toStringHelper(this) return MoreObjects.toStringHelper(this)
.add("name", name) .add("name", name)
.add("url", url) .add("url", url)
.toString(); .toString();
//J+ //J+
} }
@@ -821,40 +648,44 @@ public class RepositoryImportResource
/** /**
* Returns name of the repository. * Returns name of the repository.
* *
*
* @return name of the repository * @return name of the repository
*/ */
public String getName() public String getName() {
{
return name; return name;
} }
/** /**
* Returns external url of the repository. * Returns external url of the repository.
* *
*
* @return external url of the repository * @return external url of the repository
*/ */
public String getUrl() public String getUrl() {
{
return url; return url;
} }
//~--- fields ------------------------------------------------------------- //~--- fields -------------------------------------------------------------
/** name of the repository */ /**
* name of the repository
*/
private String name; private String name;
/** external url of the repository */ /**
* external url of the repository
*/
private String url; private String url;
} }
//~--- fields --------------------------------------------------------------- //~--- fields ---------------------------------------------------------------
/** repository manager */ /**
* repository manager
*/
private final RepositoryManager manager; private final RepositoryManager manager;
/** repository service factory */ /**
* repository service factory
*/
private final RepositoryServiceFactory serviceFactory; private final RepositoryServiceFactory serviceFactory;
} }

View File

@@ -1,24 +1,63 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.annotations.security.SecuritySchemes;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.apache.shiro.SecurityUtils; import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.Subject;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.security.*; import sonia.scm.security.AccessToken;
import sonia.scm.security.AccessTokenBuilder;
import sonia.scm.security.AccessTokenBuilderFactory;
import sonia.scm.security.AccessTokenCookieIssuer;
import sonia.scm.security.AllowAnonymousAccess;
import sonia.scm.security.Scope;
import sonia.scm.security.Tokens;
import sonia.scm.web.VndMediaType;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.*; import javax.ws.rs.BeanParam;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context; import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.net.URI; import java.net.URI;
import java.util.Optional; import java.util.Optional;
@SecuritySchemes({
@SecurityScheme(
name = "Basic Authentication",
description = "HTTP Basic authentication with username and password",
scheme = "basic",
type = SecuritySchemeType.HTTP
),
@SecurityScheme(
name = "Bearer Token Authentication",
description = "Authentication with a jwt bearer token",
scheme = "bearer",
bearerFormat = "JWT",
type = SecuritySchemeType.HTTP
)
})
@OpenAPIDefinition(tags = {
@Tag(name = "Authentication", description = "Authentication related endpoints")
})
@Path(AuthenticationResource.PATH) @Path(AuthenticationResource.PATH)
@AllowAnonymousAccess @AllowAnonymousAccess
public class AuthenticationResource { public class AuthenticationResource {
@@ -34,21 +73,33 @@ public class AuthenticationResource {
private LogoutRedirection logoutRedirection; private LogoutRedirection logoutRedirection;
@Inject @Inject
public AuthenticationResource(AccessTokenBuilderFactory tokenBuilderFactory, AccessTokenCookieIssuer cookieIssuer) public AuthenticationResource(AccessTokenBuilderFactory tokenBuilderFactory, AccessTokenCookieIssuer cookieIssuer) {
{
this.tokenBuilderFactory = tokenBuilderFactory; this.tokenBuilderFactory = tokenBuilderFactory;
this.cookieIssuer = cookieIssuer; this.cookieIssuer = cookieIssuer;
} }
@POST @POST
@Path("access_token") @Path("access_token")
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@StatusCodes({ @Operation(
@ResponseCode(code = 200, condition = "success"), summary = "Login via Form",
@ResponseCode(code = 400, condition = "bad request, required parameter is missing"), description = "Form-based authentication.",
@ResponseCode(code = 401, condition = "unauthorized, the specified username or password is wrong"), tags = "Authentication",
@ResponseCode(code = 500, condition = "internal server error") hidden = true
}) )
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "204", description = "success without content")
@ApiResponse(responseCode = "400", description = "bad request, required parameter is missing")
@ApiResponse(responseCode = "401", description = "unauthorized, the specified username or password is wrong")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response authenticateViaForm( public Response authenticateViaForm(
@Context HttpServletRequest request, @Context HttpServletRequest request,
@Context HttpServletResponse response, @Context HttpServletResponse response,
@@ -59,18 +110,41 @@ public class AuthenticationResource {
@POST @POST
@Path("access_token") @Path("access_token")
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@StatusCodes({ @Operation(
@ResponseCode(code = 200, condition = "success"), summary = "Login via JSON",
@ResponseCode(code = 400, condition = "bad request, required parameter is missing"), description = "JSON-based authentication.",
@ResponseCode(code = 401, condition = "unauthorized, the specified username or password is wrong"), tags = "Authentication",
@ResponseCode(code = 500, condition = "internal server error") requestBody = @RequestBody(
}) content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = AuthenticationRequestDto.class),
examples = @ExampleObject(
name = "Simple login",
value = "{\n \"username\":\"scmadmin\",\n \"password\":\"scmadmin\",\n \"cookie\":false,\n \"grant_type\":\"password\"\n}",
summary = "Authenticate with username and password"
)
)
)
)
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "204", description = "success without content")
@ApiResponse(responseCode = "400", description = "bad request, required parameter is missing")
@ApiResponse(responseCode = "401", description = "unauthorized, the specified username or password is wrong")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response authenticateViaJSONBody( public Response authenticateViaJSONBody(
@Context HttpServletRequest request, @Context HttpServletRequest request,
@Context HttpServletResponse response, @Context HttpServletResponse response,
AuthenticationRequestDto authentication AuthenticationRequestDto authentication
) { ) {
return authenticate(request, response, authentication); return authenticate(request, response, authentication);
} }
@@ -86,12 +160,11 @@ public class AuthenticationResource {
Response res; Response res;
Subject subject = SecurityUtils.getSubject(); Subject subject = SecurityUtils.getSubject();
try try {
{
subject.login(Tokens.createAuthenticationToken(request, authentication.getUsername(), authentication.getPassword())); subject.login(Tokens.createAuthenticationToken(request, authentication.getUsername(), authentication.getPassword()));
AccessTokenBuilder tokenBuilder = tokenBuilderFactory.create(); AccessTokenBuilder tokenBuilder = tokenBuilderFactory.create();
if ( authentication.getScope() != null ) { if (authentication.getScope() != null) {
tokenBuilder.scope(Scope.valueOf(authentication.getScope())); tokenBuilder.scope(Scope.valueOf(authentication.getScope()));
} }
@@ -101,17 +174,12 @@ public class AuthenticationResource {
cookieIssuer.authenticate(request, response, token); cookieIssuer.authenticate(request, response, token);
res = Response.noContent().build(); res = Response.noContent().build();
} else { } else {
res = Response.ok( token.compact() ).build(); res = Response.ok(token.compact()).build();
} }
} } catch (AuthenticationException ex) {
catch (AuthenticationException ex) if (LOG.isTraceEnabled()) {
{
if (LOG.isTraceEnabled())
{
LOG.trace("authentication failed for user ".concat(authentication.getUsername()), ex); LOG.trace("authentication failed for user ".concat(authentication.getUsername()), ex);
} } else {
else
{
LOG.warn("authentication failed for user {}", authentication.getUsername()); LOG.warn("authentication failed for user {}", authentication.getUsername());
} }
@@ -126,12 +194,10 @@ public class AuthenticationResource {
@DELETE @DELETE
@Path("access_token") @Path("access_token")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@StatusCodes({ @Operation(summary = "Logout", description = "Removes the access token.", tags = "Authentication")
@ResponseCode(code = 204, condition = "success"), @ApiResponse(responseCode = "204", description = "success")
@ResponseCode(code = 500, condition = "internal server error") @ApiResponse(responseCode = "500", description = "internal server error")
}) public Response logout(@Context HttpServletRequest request, @Context HttpServletResponse response) {
public Response logout(@Context HttpServletRequest request, @Context HttpServletResponse response)
{
Subject subject = SecurityUtils.getSubject(); Subject subject = SecurityUtils.getSubject();
subject.logout(); subject.logout();
@@ -139,7 +205,6 @@ public class AuthenticationResource {
// remove authentication cookie // remove authentication cookie
cookieIssuer.invalidate(request, response); cookieIssuer.invalidate(request, response);
// TODO anonymous access ??
if (logoutRedirection == null) { if (logoutRedirection == null) {
return Response.noContent().build(); return Response.noContent().build();
} else { } else {

View File

@@ -1,14 +1,18 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.Operation;
import javax.validation.constraints.NotEmpty; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import sonia.scm.ReducedModelObject; import sonia.scm.ReducedModelObject;
import sonia.scm.group.GroupDisplayManager; import sonia.scm.group.GroupDisplayManager;
import sonia.scm.user.UserDisplayManager; import sonia.scm.user.UserDisplayManager;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
import javax.inject.Inject; import javax.inject.Inject;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size; import javax.validation.constraints.Size;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.Path; import javax.ws.rs.Path;
@@ -18,7 +22,9 @@ import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@OpenAPIDefinition(tags = {
@Tag(name = "Autocomplete", description = "Autocomplete related endpoints")
})
@Path(AutoCompleteResource.PATH) @Path(AutoCompleteResource.PATH)
public class AutoCompleteResource { public class AutoCompleteResource {
public static final String PATH = "v2/autocomplete/"; public static final String PATH = "v2/autocomplete/";
@@ -43,13 +49,26 @@ public class AutoCompleteResource {
@GET @GET
@Path("users") @Path("users")
@Produces(VndMediaType.AUTOCOMPLETE) @Produces(VndMediaType.AUTOCOMPLETE)
@StatusCodes({ @Operation(summary = "Search user", description = "Returns matching users.", tags = "Autocomplete")
@ResponseCode(code = 200, condition = "success"), @ApiResponse(
@ResponseCode(code = 400, condition = "if the searched string contains less than 2 characters"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"user:autocomplete\" privilege"), content = @Content(
@ResponseCode(code = 500, condition = "internal server error") mediaType = VndMediaType.AUTOCOMPLETE,
}) schema = @Schema(implementation = ReducedObjectModelDto.class)
)
)
@ApiResponse(responseCode = "400", description = "if the searched string contains less than 2 characters")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"user:autocomplete\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public List<ReducedObjectModelDto> searchUser(@NotEmpty(message = PARAMETER_IS_REQUIRED) @Size(min = MIN_SEARCHED_CHARS, message = INVALID_PARAMETER_LENGTH) @QueryParam("q") String filter) { public List<ReducedObjectModelDto> searchUser(@NotEmpty(message = PARAMETER_IS_REQUIRED) @Size(min = MIN_SEARCHED_CHARS, message = INVALID_PARAMETER_LENGTH) @QueryParam("q") String filter) {
return map(userDisplayManager.autocomplete(filter)); return map(userDisplayManager.autocomplete(filter));
} }
@@ -57,13 +76,25 @@ public class AutoCompleteResource {
@GET @GET
@Path("groups") @Path("groups")
@Produces(VndMediaType.AUTOCOMPLETE) @Produces(VndMediaType.AUTOCOMPLETE)
@StatusCodes({ @Operation(summary = "Search groups", description = "Returns matching groups.", tags = "Autocomplete")
@ResponseCode(code = 200, condition = "success"), @ApiResponse(
@ResponseCode(code = 400, condition = "if the searched string contains less than 2 characters"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"group:autocomplete\" privilege"), content = @Content(
@ResponseCode(code = 500, condition = "internal server error") mediaType = VndMediaType.AUTOCOMPLETE,
}) schema = @Schema(implementation = ReducedObjectModelDto.class)
))
@ApiResponse(responseCode = "400", description = "if the searched string contains less than 2 characters")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"group:autocomplete\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public List<ReducedObjectModelDto> searchGroup(@NotEmpty(message = PARAMETER_IS_REQUIRED) @Size(min = MIN_SEARCHED_CHARS, message = INVALID_PARAMETER_LENGTH) @QueryParam("q") String filter) { public List<ReducedObjectModelDto> searchGroup(@NotEmpty(message = PARAMETER_IS_REQUIRED) @Size(min = MIN_SEARCHED_CHARS, message = INVALID_PARAMETER_LENGTH) @QueryParam("q") String filter) {
return map(groupDisplayManager.autocomplete(filter)); return map(groupDisplayManager.autocomplete(filter));
} }

View File

@@ -1,8 +1,10 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import de.otto.edison.hal.HalRepresentation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.AvailablePlugin;
import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.InstalledPlugin;
import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginManager;
@@ -44,11 +46,29 @@ public class AvailablePluginResource {
*/ */
@GET @GET
@Path("") @Path("")
@StatusCodes({ @Operation(
@ResponseCode(code = 200, condition = "success"), summary = "Find all available plugins",
@ResponseCode(code = 500, condition = "internal server error") description = "Returns a collection of available plugins.",
}) tags = "Plugin Management"
@TypeHint(CollectionDto.class) )
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.PLUGIN_COLLECTION,
schema = @Schema(implementation = CollectionDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:read\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
@Produces(VndMediaType.PLUGIN_COLLECTION) @Produces(VndMediaType.PLUGIN_COLLECTION)
public Response getAvailablePlugins() { public Response getAvailablePlugins() {
PluginPermissions.read().check(); PluginPermissions.read().check();
@@ -68,13 +88,37 @@ public class AvailablePluginResource {
*/ */
@GET @GET
@Path("/{name}") @Path("/{name}")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 404, condition = "not found"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(PluginDto.class)
@Produces(VndMediaType.PLUGIN) @Produces(VndMediaType.PLUGIN)
@Operation(
summary = "Find single available plugin",
description = "Returns an available plugins.",
tags = "Plugin Management"
)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.PLUGIN,
schema = @Schema(implementation = PluginDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:read\" privilege")
@ApiResponse(
responseCode = "404",
description = "not found, no plugin with the specified name found",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response getAvailablePlugin(@PathParam("name") String name) { public Response getAvailablePlugin(@PathParam("name") String name) {
PluginPermissions.read().check(); PluginPermissions.read().check();
Optional<AvailablePlugin> plugin = pluginManager.getAvailable(name); Optional<AvailablePlugin> plugin = pluginManager.getAvailable(name);
@@ -87,15 +131,28 @@ public class AvailablePluginResource {
/** /**
* Triggers plugin installation. * Triggers plugin installation.
*
* @param name plugin name * @param name plugin name
* @return HTTP Status. * @return HTTP Status.
*/ */
@POST @POST
@Path("/{name}/install") @Path("/{name}/install")
@StatusCodes({ @Operation(
@ResponseCode(code = 200, condition = "success"), summary = "Triggers plugin installation",
@ResponseCode(code = 500, condition = "internal server error") description = "Put single plugin in installation queue. Plugin will be installed after restart.",
}) tags = "Plugin Management"
)
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response installPlugin(@PathParam("name") String name, @QueryParam("restart") boolean restartAfterInstallation) { public Response installPlugin(@PathParam("name") String name, @QueryParam("restart") boolean restartAfterInstallation) {
PluginPermissions.manage().check(); PluginPermissions.manage().check();
pluginManager.install(name, restartAfterInstallation); pluginManager.install(name, restartAfterInstallation);

View File

@@ -1,11 +1,11 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.ResponseHeader; import io.swagger.v3.oas.annotations.headers.Header;
import com.webcohesion.enunciate.metadata.rs.ResponseHeaders; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Schema;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.PageResult; import sonia.scm.PageResult;
import sonia.scm.repository.Branch; import sonia.scm.repository.Branch;
import sonia.scm.repository.Branches; import sonia.scm.repository.Branches;
@@ -69,15 +69,33 @@ public class BranchRootResource {
@GET @GET
@Path("{branch}") @Path("{branch}")
@Produces(VndMediaType.BRANCH) @Produces(VndMediaType.BRANCH)
@TypeHint(BranchDto.class) @Operation(summary = "Get single branch", description = "Returns a branch for a repository.", tags = "Repository")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 400, condition = "branches not supported for given repository"), description = "success",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), content = @Content(
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the branch"), mediaType = VndMediaType.BRANCH,
@ResponseCode(code = 404, condition = "not found, no branch with the specified name for the repository available or repository not found"), schema = @Schema(implementation = BranchDto.class)
@ResponseCode(code = 500, condition = "internal server error") )
}) )
@ApiResponse(responseCode = "400", description = "branches not supported for given repository")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the branch")
@ApiResponse(
responseCode = "404",
description = "not found, no branch with the specified name for the repository available or repository found",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("branch") String branchName) throws IOException { public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("branch") String branchName) throws IOException {
NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name);
try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) {
@@ -95,24 +113,42 @@ public class BranchRootResource {
} }
} }
@Path("{branch}/changesets/")
@GET @GET
@StatusCodes({ @Path("{branch}/changesets/")
@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 changeset"),
@ResponseCode(code = 404, condition = "not found, no changesets available in the repository"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.CHANGESET_COLLECTION) @Produces(VndMediaType.CHANGESET_COLLECTION)
@TypeHint(CollectionDto.class) @Operation(summary = "Collection of changesets", description = "Returns a collection of changesets for specific branch.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.CHANGESET_COLLECTION,
schema = @Schema(implementation = CollectionDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the changeset")
@ApiResponse(
responseCode = "404",
description = "not found, no changesets available in the repository",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response history(@PathParam("namespace") String namespace, public Response history(@PathParam("namespace") String namespace,
@PathParam("name") String name, @PathParam("name") String name,
@PathParam("branch") String branchName, @PathParam("branch") String branchName,
@DefaultValue("0") @QueryParam("page") int page, @DefaultValue("0") @QueryParam("page") int page,
@DefaultValue("10") @QueryParam("pageSize") int pageSize) throws IOException { @DefaultValue("10") @QueryParam("pageSize") int pageSize) throws IOException {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
if (!branchExists(branchName, repositoryService)){ if (!branchExists(branchName, repositoryService)) {
throw notFound(entity(Branch.class, branchName).in(Repository.class, namespace + "/" + name)); throw notFound(entity(Branch.class, branchName).in(Repository.class, namespace + "/" + name));
} }
Repository repository = repositoryService.getRepository(); Repository repository = repositoryService.getRepository();
@@ -143,15 +179,25 @@ public class BranchRootResource {
@POST @POST
@Path("") @Path("")
@Consumes(VndMediaType.BRANCH_REQUEST) @Consumes(VndMediaType.BRANCH_REQUEST)
@StatusCodes({ @Operation(summary = "Create branch", description = "Creates a new branch.", tags = "Repository")
@ResponseCode(code = 201, condition = "create success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "201",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"push\" privilege"), description = "create success",
@ResponseCode(code = 409, condition = "conflict, a user with this name already exists"), headers = @Header(
@ResponseCode(code = 500, condition = "internal server error") name = "Location",
}) description = "uri to the created branch"
@TypeHint(TypeHint.NO_CONTENT.class) )
@ResponseHeaders(@ResponseHeader(name = "Location", description = "uri to the created branch")) )
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"push\" privilege")
@ApiResponse(responseCode = "409", description = "conflict, a branch with this name already exists")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response create(@PathParam("namespace") String namespace, public Response create(@PathParam("namespace") String namespace,
@PathParam("name") String name, @PathParam("name") String name,
@Valid BranchRequestDto branchRequest) throws IOException { @Valid BranchRequestDto branchRequest) throws IOException {
@@ -195,15 +241,33 @@ public class BranchRootResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.BRANCH_COLLECTION) @Produces(VndMediaType.BRANCH_COLLECTION)
@TypeHint(CollectionDto.class) @Operation(summary = "List of branches", description = "Returns all branches for a repository.", tags = "Repository")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 400, condition = "branches not supported for given repository"), description = "success",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), content = @Content(
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"read repository\" privilege"), mediaType = VndMediaType.BRANCH_COLLECTION,
@ResponseCode(code = 404, condition = "not found, no repository found for the given namespace and name"), schema = @Schema(implementation = CollectionDto.class)
@ResponseCode(code = 500, condition = "internal server error") )
}) )
@ApiResponse(responseCode = "400", description = "branches not supported for given repository")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"read repository\" privilege")
@ApiResponse(
responseCode = "404",
description = "not found, no repository found for the given namespace and name",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name) throws IOException { public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name) throws IOException {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
Branches branches = repositoryService.getBranchesCommand().getBranches(); Branches branches = repositoryService.getBranchesCommand().getBranches();

View File

@@ -1,8 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import sonia.scm.PageResult; import sonia.scm.PageResult;
import sonia.scm.repository.Changeset; import sonia.scm.repository.Changeset;
@@ -44,15 +45,32 @@ public class ChangesetRootResource {
@GET @GET
@Path("") @Path("")
@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 changeset"),
@ResponseCode(code = 404, condition = "not found, no changesets available in the repository"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.CHANGESET_COLLECTION) @Produces(VndMediaType.CHANGESET_COLLECTION)
@TypeHint(CollectionDto.class) @Operation(summary = "Collection of changesets", description = "Returns a collection of changesets.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.CHANGESET_COLLECTION,
schema = @Schema(implementation = CollectionDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the changeset")
@ApiResponse(
responseCode = "404",
description = "not found, no changesets available in the repository",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name, @DefaultValue("0") @QueryParam("page") int page, public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name, @DefaultValue("0") @QueryParam("page") int page,
@DefaultValue("10") @QueryParam("pageSize") int pageSize) throws IOException { @DefaultValue("10") @QueryParam("pageSize") int pageSize) throws IOException {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
@@ -77,16 +95,33 @@ public class ChangesetRootResource {
} }
@GET @GET
@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 changeset"),
@ResponseCode(code = 404, condition = "not found, no changeset with the specified id is available in the repository"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.CHANGESET)
@TypeHint(ChangesetDto.class)
@Path("{id}") @Path("{id}")
@Produces(VndMediaType.CHANGESET)
@Operation(summary = "Specific changeset", description = "Returns a specific changeset.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.CHANGESET,
schema = @Schema(implementation = ChangesetDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the changeset")
@ApiResponse(
responseCode = "404",
description = "not found, no changeset with the specified id is available in the repository",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("id") String id) throws IOException { public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("id") String id) throws IOException {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
Repository repository = repositoryService.getRepository(); Repository repository = repositoryService.getRepository();

View File

@@ -1,8 +1,12 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import sonia.scm.config.ConfigurationPermissions; import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.repository.NamespaceStrategyValidator; import sonia.scm.repository.NamespaceStrategyValidator;
@@ -21,6 +25,9 @@ import javax.ws.rs.core.Response;
/** /**
* RESTful Web Service Resource to manage the configuration. * RESTful Web Service Resource to manage the configuration.
*/ */
@OpenAPIDefinition(tags = {
@Tag(name = "Instance configuration", description = "Global SCM-Manager instance configuration")
})
@Path(ConfigResource.CONFIG_PATH_V2) @Path(ConfigResource.CONFIG_PATH_V2)
public class ConfigResource { public class ConfigResource {
@@ -46,13 +53,25 @@ public class ConfigResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.CONFIG) @Produces(VndMediaType.CONFIG)
@TypeHint(UserDto.class) @Operation(summary = "Instance configuration", description = "Returns the instance configuration.", tags = "Instance configuration")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:read:global\" privilege"), content = @Content(
@ResponseCode(code = 500, condition = "internal server error") mediaType = VndMediaType.CONFIG,
}) schema = @Schema(implementation = ConfigDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:read\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response get() { public Response get() {
// We do this permission check in Resource and not in ScmConfiguration, because it must be available for reading // We do this permission check in Resource and not in ScmConfiguration, because it must be available for reading
@@ -70,13 +89,18 @@ public class ConfigResource {
@PUT @PUT
@Path("") @Path("")
@Consumes(VndMediaType.CONFIG) @Consumes(VndMediaType.CONFIG)
@StatusCodes({ @Operation(summary = "Update instance configuration", description = "Modifies the instance configuration.", tags = "Instance configuration")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(responseCode = "204", description = "update success")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"configuration:write:global\" privilege"), @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"configuration:write\" privilege")
@ResponseCode(code = 500, condition = "internal server error") @ApiResponse(
}) responseCode = "500",
@TypeHint(TypeHint.NO_CONTENT.class) description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response update(@Valid ConfigDto configDto) { public Response update(@Valid ConfigDto configDto) {
// This *could* be moved to ScmConfiguration or ScmConfigurationUtil classes. // This *could* be moved to ScmConfiguration or ScmConfigurationUtil classes.

View File

@@ -2,8 +2,10 @@ package sonia.scm.api.v2.resources;
import com.github.sdorra.spotter.ContentType; import com.github.sdorra.spotter.ContentType;
import com.github.sdorra.spotter.ContentTypes; import com.github.sdorra.spotter.ContentTypes;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
@@ -11,6 +13,7 @@ import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.util.IOUtil; import sonia.scm.util.IOUtil;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject; import javax.inject.Inject;
import javax.ws.rs.GET; import javax.ws.rs.GET;
@@ -43,20 +46,30 @@ public class ContentResource {
* recognized, this will be given in the header <code>Language</code>. * recognized, this will be given in the header <code>Language</code>.
* *
* @param namespace the namespace of the repository * @param namespace the namespace of the repository
* @param name the name of the repository * @param name the name of the repository
* @param revision the revision * @param revision the revision
* @param path The path of the file * @param path The path of the file
*
*/ */
@GET @GET
@Path("{revision}/{path: .*}") @Path("{revision}/{path: .*}")
@StatusCodes({ @Operation(summary = "File content by revision", description = "Returns the content of a file for the given revision in the repository.", tags = "Repository")
@ResponseCode(code = 200, condition = "success"), @ApiResponse(responseCode = "200", description = "success")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the repository"), @ApiResponse(responseCode = "403", description = "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"), @ApiResponse(
@ResponseCode(code = 500, condition = "internal server error") responseCode = "404",
}) description = "not found, no repository with the specified name available in the namespace",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @PathParam("path") String path) { public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @PathParam("path") String path) {
StreamingOutput stream = createStreamingOutput(namespace, name, revision, path); StreamingOutput stream = createStreamingOutput(namespace, name, revision, path);
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
@@ -85,20 +98,34 @@ public class ContentResource {
* the repository. The programming language will be given in the header <code>Language</code>. * the repository. The programming language will be given in the header <code>Language</code>.
* *
* @param namespace the namespace of the repository * @param namespace the namespace of the repository
* @param name the name of the repository * @param name the name of the repository
* @param revision the revision * @param revision the revision
* @param path The path of the file * @param path The path of the file
*
*/ */
@HEAD @HEAD
@Path("{revision}/{path: .*}") @Path("{revision}/{path: .*}")
@StatusCodes({ @Operation(
@ResponseCode(code = 200, condition = "success"), summary = "File metadata by revision",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "Returns the content type and the programming language (if it can be detected) of a file for the given revision in the repository.",
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the repository"), tags = "Repository"
@ResponseCode(code = 404, condition = "not found, no repository with the specified name available in the namespace"), )
@ResponseCode(code = 500, condition = "internal server error") @ApiResponse(responseCode = "200", description = "success")
}) @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the repository")
@ApiResponse(
responseCode = "404",
description = "not found, no repository with the specified name available in the namespace",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response metadata(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @PathParam("path") String path) { public Response metadata(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @PathParam("path") String path) {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
Response.ResponseBuilder responseBuilder = Response.ok(); Response.ResponseBuilder responseBuilder = Response.ok();

View File

@@ -1,7 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.api.DiffCommandBuilder; import sonia.scm.repository.api.DiffCommandBuilder;
@@ -44,23 +46,35 @@ public class DiffRootResource {
* Get the repository diff of a revision * Get the repository diff of a revision
* *
* @param namespace repository namespace * @param namespace repository namespace
* @param name repository name * @param name repository name
* @param revision the revision * @param revision the revision
* @return the dif of the revision * @return the dif of the revision
* @throws NotFoundException if the repository is not found * @throws NotFoundException if the repository is not found
*/ */
@GET @GET
@Path("{revision}") @Path("{revision}")
@Produces(VndMediaType.DIFF) @Produces(VndMediaType.DIFF)
@StatusCodes({ @Operation(summary = "Diff by revision", description = "Get the repository diff of a revision.", tags = "Repository")
@ResponseCode(code = 200, condition = "success"), @ApiResponse(responseCode = "200", description = "success")
@ResponseCode(code = 400, condition = "Bad Request"), @ApiResponse(responseCode = "400", description = "bad request")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the diff"), @ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the diff")
@ResponseCode(code = 404, condition = "not found, no revision with the specified param for the repository available or repository not found"), @ApiResponse(
@ResponseCode(code = 500, condition = "internal server error") responseCode = "404",
}) description = "not found, no revision with the specified param for the repository available or repository found",
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision , @Pattern(regexp = DIFF_FORMAT_VALUES_REGEX) @DefaultValue("NATIVE") @QueryParam("format") String format ) throws IOException { content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @Pattern(regexp = DIFF_FORMAT_VALUES_REGEX) @DefaultValue("NATIVE") @QueryParam("format") String format) throws IOException {
HttpUtil.checkForCRLFInjection(revision); HttpUtil.checkForCRLFInjection(revision);
DiffFormat diffFormat = DiffFormat.valueOf(format); DiffFormat diffFormat = DiffFormat.valueOf(format);
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
@@ -77,14 +91,33 @@ public class DiffRootResource {
@GET @GET
@Path("{revision}/parsed") @Path("{revision}/parsed")
@Produces(VndMediaType.DIFF_PARSED) @Produces(VndMediaType.DIFF_PARSED)
@StatusCodes({ @Operation(summary = "Parsed diff by revision", description = "Get the parsed repository diff of a revision.", tags = "Repository")
@ResponseCode(code = 200, condition = "success"), @ApiResponse(
@ResponseCode(code = 400, condition = "Bad Request"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the diff"), content = @Content(
@ResponseCode(code = 404, condition = "not found, no revision with the specified param for the repository available or repository not found"), mediaType = VndMediaType.DIFF_PARSED,
@ResponseCode(code = 500, condition = "internal server error") schema = @Schema(implementation = DiffResultDto.class)
}) )
)
@ApiResponse(responseCode = "400", description = "bad request")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the diff")
@ApiResponse(
responseCode = "404",
description = "not found, no revision with the specified param for the repository available or repository not found",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public DiffResultDto getParsed(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision) throws IOException { public DiffResultDto getParsed(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision) throws IOException {
HttpUtil.checkForCRLFInjection(revision); HttpUtil.checkForCRLFInjection(revision);
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {

View File

@@ -1,8 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import sonia.scm.PageResult; import sonia.scm.PageResult;
import sonia.scm.repository.Changeset; import sonia.scm.repository.Changeset;
@@ -54,15 +55,32 @@ public class FileHistoryRootResource {
*/ */
@GET @GET
@Path("{revision}/{path: .*}") @Path("{revision}/{path: .*}")
@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 changeset"),
@ResponseCode(code = 404, condition = "not found, no changesets available in the repository"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.CHANGESET_COLLECTION) @Produces(VndMediaType.CHANGESET_COLLECTION)
@TypeHint(CollectionDto.class) @Operation(summary = "Changesets to given file", description = "Get all changesets related to the given file starting with the given revision.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.CHANGESET_COLLECTION,
schema = @Schema(implementation = CollectionDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the changeset")
@ApiResponse(
responseCode = "404",
description = "not found, no changesets available in the repository",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name, public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name,
@PathParam("revision") String revision, @PathParam("revision") String revision,
@PathParam("path") String path, @PathParam("path") String path,

View File

@@ -1,5 +1,11 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import sonia.scm.security.PermissionAssigner; import sonia.scm.security.PermissionAssigner;
import sonia.scm.security.PermissionDescriptor; import sonia.scm.security.PermissionDescriptor;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -10,6 +16,9 @@ import javax.ws.rs.Path;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
@OpenAPIDefinition(tags = {
@Tag(name = "Permissions", description = "Permission related endpoints")
})
@Path("v2/permissions") @Path("v2/permissions")
public class GlobalPermissionResource { public class GlobalPermissionResource {
@@ -22,6 +31,25 @@ public class GlobalPermissionResource {
@GET @GET
@Produces(VndMediaType.PERMISSION_COLLECTION) @Produces(VndMediaType.PERMISSION_COLLECTION)
@Operation(summary = "List of permissions", description = "Returns all available permissions.", tags = "Permissions")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.PERMISSION_COLLECTION,
schema = @Schema(implementation = PermissionListDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the permissions")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
@Path("") @Path("")
public Response getAll() { public Response getAll() {
String[] permissions = permissionAssigner.getAvailablePermissions().stream().map(PermissionDescriptor::getValue).toArray(String[]::new); String[] permissions = permissionAssigner.getAvailablePermissions().stream().map(PermissionDescriptor::getValue).toArray(String[]::new);

View File

@@ -1,10 +1,10 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.ResponseHeader; import io.swagger.v3.oas.annotations.headers.Header;
import com.webcohesion.enunciate.metadata.rs.ResponseHeaders; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Schema;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.group.Group; import sonia.scm.group.Group;
import sonia.scm.group.GroupManager; import sonia.scm.group.GroupManager;
import sonia.scm.search.SearchRequest; import sonia.scm.search.SearchRequest;
@@ -25,9 +25,8 @@ import java.util.function.Predicate;
import static com.google.common.base.Strings.isNullOrEmpty; import static com.google.common.base.Strings.isNullOrEmpty;
public class GroupCollectionResource { public class GroupCollectionResource {
private static final int DEFAULT_PAGE_SIZE = 10; private static final int DEFAULT_PAGE_SIZE = 10;
private final GroupDtoToGroupMapper dtoToGroupMapper; private final GroupDtoToGroupMapper dtoToGroupMapper;
private final GroupCollectionToDtoMapper groupCollectionToDtoMapper; private final GroupCollectionToDtoMapper groupCollectionToDtoMapper;
@@ -56,46 +55,70 @@ public class GroupCollectionResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.GROUP_COLLECTION) @Produces(VndMediaType.GROUP_COLLECTION)
@TypeHint(CollectionDto.class) @Operation(summary = "List of groups", description = "Returns all groups for a given page number with a given page size.", tags = "Group")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 400, condition = "\"sortBy\" field unknown"), description = "success",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), content = @Content(
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"group\" privilege"), mediaType = VndMediaType.GROUP_COLLECTION,
@ResponseCode(code = 500, condition = "internal server error") schema = @Schema(implementation = CollectionDto.class)
}) )
)
@ApiResponse(responseCode = "400", description = "\"sortBy\" field unknown")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"group\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response getAll(@DefaultValue("0") @QueryParam("page") int page, public Response getAll(@DefaultValue("0") @QueryParam("page") int page,
@DefaultValue("" + DEFAULT_PAGE_SIZE) @QueryParam("pageSize") int pageSize, @DefaultValue("" + DEFAULT_PAGE_SIZE) @QueryParam("pageSize") int pageSize,
@QueryParam("sortBy") String sortBy, @QueryParam("sortBy") String sortBy,
@DefaultValue("false") @DefaultValue("false")
@QueryParam("desc") boolean desc, @QueryParam("desc") boolean desc,
@DefaultValue("") @QueryParam("q") String search @DefaultValue("") @QueryParam("q") String search
) { ) {
return adapter.getAll(page, pageSize, createSearchPredicate(search), sortBy, desc, return adapter.getAll(page, pageSize, createSearchPredicate(search), sortBy, desc,
pageResult -> groupCollectionToDtoMapper.map(page, pageSize, pageResult)); pageResult -> groupCollectionToDtoMapper.map(page, pageSize, pageResult));
} }
/** /**
* Creates a new group. * Creates a new group.
*
* @param group The group to be created. * @param group The group to be created.
* @return A response with the link to the new group (if created successfully). * @return A response with the link to the new group (if created successfully).
*/ */
@POST @POST
@Path("") @Path("")
@Consumes(VndMediaType.GROUP) @Consumes(VndMediaType.GROUP)
@StatusCodes({ @Operation(summary = "Create group", description = "Creates a new group.", tags = "Group")
@ResponseCode(code = 201, condition = "create success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "201",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"group\" privilege"), description = "create success",
@ResponseCode(code = 409, condition = "conflict, a group with this name already exists"), headers = @Header(
@ResponseCode(code = 500, condition = "internal server error") name = "Location",
}) description = "uri to the created group"
@TypeHint(TypeHint.NO_CONTENT.class) )
@ResponseHeaders(@ResponseHeader(name = "Location", description = "uri to the created group")) )
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"group\" privilege")
@ApiResponse(responseCode = "409", description = "conflict, a group with this name already exists")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response create(@Valid GroupDto group) { public Response create(@Valid GroupDto group) {
return adapter.create(group, return adapter.create(group,
() -> dtoToGroupMapper.map(group), () -> dtoToGroupMapper.map(group),
g -> resourceLinks.group().self(g.getName())); g -> resourceLinks.group().self(g.getName()));
} }
private Predicate<Group> createSearchPredicate(String search) { private Predicate<Group> createSearchPredicate(String search) {

View File

@@ -1,8 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.security.PermissionAssigner; import sonia.scm.security.PermissionAssigner;
import sonia.scm.security.PermissionDescriptor; import sonia.scm.security.PermissionDescriptor;
import sonia.scm.security.PermissionPermissions; import sonia.scm.security.PermissionPermissions;
@@ -39,14 +40,31 @@ public class GroupPermissionResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.PERMISSION_COLLECTION) @Produces(VndMediaType.PERMISSION_COLLECTION)
@TypeHint(PermissionListDto.class) @Operation(summary = "Group permission", description = "Returns permissions for a group.", tags = {"Group", "Permissions"})
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the group"), content = @Content(
@ResponseCode(code = 404, condition = "not found, no group with the specified id/name available"), mediaType = VndMediaType.PERMISSION_COLLECTION,
@ResponseCode(code = 500, condition = "internal server error") schema = @Schema(implementation = PermissionListDto.class)
}) ))
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the group")
@ApiResponse(
responseCode = "404",
description = "not found, no group with the specified id/name available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response getPermissions(@PathParam("id") String id) { public Response getPermissions(@PathParam("id") String id) {
PermissionPermissions.read().check(); PermissionPermissions.read().check();
Collection<PermissionDescriptor> permissions = permissionAssigner.readPermissionsForGroup(id); Collection<PermissionDescriptor> permissions = permissionAssigner.readPermissionsForGroup(id);
@@ -62,15 +80,26 @@ public class GroupPermissionResource {
@PUT @PUT
@Path("") @Path("")
@Consumes(VndMediaType.PERMISSION_COLLECTION) @Consumes(VndMediaType.PERMISSION_COLLECTION)
@StatusCodes({ @Operation(summary = "Update Group permissions", description = "Sets permissions for a group. Overwrites all existing permissions.", tags = {"Group", "Permissions"})
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(responseCode = "204", description = "update success")
@ResponseCode(code = 400, condition = "Invalid body"), @ApiResponse(responseCode = "400", description = "invalid body")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current group does not have the correct privilege"), @ApiResponse(responseCode = "403", description = "not authorized, the current group does not have the correct privilege")
@ResponseCode(code = 404, condition = "not found, no group with the specified id/name available"), @ApiResponse(
@ResponseCode(code = 500, condition = "internal server error") responseCode = "404",
}) description = "not found, no group with the specified id/name available",
@TypeHint(TypeHint.NO_CONTENT.class) content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response overwritePermissions(@PathParam("id") String id, PermissionListDto newPermissions) { public Response overwritePermissions(@PathParam("id") String id, PermissionListDto newPermissions) {
Collection<PermissionDescriptor> permissionDescriptors = Arrays.stream(newPermissions.getPermissions()) Collection<PermissionDescriptor> permissionDescriptors = Arrays.stream(newPermissions.getPermissions())
.map(PermissionDescriptor::new) .map(PermissionDescriptor::new)

View File

@@ -1,8 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.group.Group; import sonia.scm.group.Group;
import sonia.scm.group.GroupManager; import sonia.scm.group.GroupManager;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -40,20 +41,37 @@ public class GroupResource {
* <strong>Note:</strong> This method requires "group" privilege. * <strong>Note:</strong> This method requires "group" privilege.
* *
* @param id the id/name of the group * @param id the id/name of the group
*
*/ */
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.GROUP) @Produces(VndMediaType.GROUP)
@TypeHint(GroupDto.class) @Operation(summary = "Get single group", description = "Returns a group.", tags = "Group")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the group"), content = @Content(
@ResponseCode(code = 404, condition = "not found, no group with the specified id/name available"), mediaType = VndMediaType.GROUP,
@ResponseCode(code = 500, condition = "internal server error") schema = @Schema(implementation = GroupDto.class)
}) )
public Response get(@PathParam("id") String id){ )
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the group")
@ApiResponse(
responseCode = "404",
description = "not found, no group with the specified id/name available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response get(@PathParam("id") String id) {
return adapter.get(id, groupToGroupDtoMapper::map); return adapter.get(id, groupToGroupDtoMapper::map);
} }
@@ -63,17 +81,21 @@ public class GroupResource {
* <strong>Note:</strong> This method requires "group" privilege. * <strong>Note:</strong> This method requires "group" privilege.
* *
* @param name the name of the group to delete. * @param name the name of the group to delete.
*
*/ */
@DELETE @DELETE
@Path("") @Path("")
@StatusCodes({ @Operation(summary = "Delete group", description = "Deletes the group with the given id.", tags = "Group")
@ResponseCode(code = 204, condition = "delete success or nothing to delete"), @ApiResponse(responseCode = "204", description = "delete success or nothing to delete")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"group\" privilege"), @ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the group")
@ResponseCode(code = 500, condition = "internal server error") @ApiResponse(
}) responseCode = "500",
@TypeHint(TypeHint.NO_CONTENT.class) description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response delete(@PathParam("id") String name) { public Response delete(@PathParam("id") String name) {
return adapter.delete(name); return adapter.delete(name);
} }
@@ -83,21 +105,32 @@ public class GroupResource {
* *
* <strong>Note:</strong> This method requires "group" privilege. * <strong>Note:</strong> This method requires "group" privilege.
* *
* @param name name of the group to be modified * @param name name of the group to be modified
* @param group group object to modify * @param group group object to modify
*/ */
@PUT @PUT
@Path("") @Path("")
@Consumes(VndMediaType.GROUP) @Consumes(VndMediaType.GROUP)
@StatusCodes({ @Operation(summary = "Update group", description = "Modifies a group.", tags = "Group")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(responseCode = "204", description = "update success")
@ResponseCode(code = 400, condition = "Invalid body, e.g. illegal change of id/group name"), @ApiResponse(responseCode = "400", description = "invalid body, e.g. illegal change of id/group name")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"group\" privilege"), @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"group\" privilege")
@ResponseCode(code = 404, condition = "not found, no group with the specified id/name available"), @ApiResponse(
@ResponseCode(code = 500, condition = "internal server error") responseCode = "404",
}) description = "not found, no group with the specified id/name available",
@TypeHint(TypeHint.NO_CONTENT.class) content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response update(@PathParam("id") String name, @Valid GroupDto group) { public Response update(@PathParam("id") String name, @Valid GroupDto group) {
return adapter.update(name, existing -> dtoToGroupMapper.map(group)); return adapter.update(name, existing -> dtoToGroupMapper.map(group));
} }

View File

@@ -1,5 +1,8 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.tags.Tag;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Provider; import javax.inject.Provider;
import javax.ws.rs.Path; import javax.ws.rs.Path;
@@ -7,6 +10,9 @@ import javax.ws.rs.Path;
/** /**
* RESTful Web Service Resource to manage groups and their members. * RESTful Web Service Resource to manage groups and their members.
*/ */
@OpenAPIDefinition(tags = {
@Tag(name = "Group", description = "Group related endpoints")
})
@Path(GroupRootResource.GROUPS_PATH_V2) @Path(GroupRootResource.GROUPS_PATH_V2)
public class GroupRootResource { public class GroupRootResource {
@@ -17,7 +23,7 @@ public class GroupRootResource {
@Inject @Inject
public GroupRootResource(Provider<GroupCollectionResource> groupCollectionResource, public GroupRootResource(Provider<GroupCollectionResource> groupCollectionResource,
Provider<GroupResource> groupResource) { Provider<GroupResource> groupResource) {
this.groupCollectionResource = groupCollectionResource; this.groupCollectionResource = groupCollectionResource;
this.groupResource = groupResource; this.groupResource = groupResource;
} }

View File

@@ -1,9 +1,10 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.PageResult; import sonia.scm.PageResult;
import sonia.scm.repository.Changeset; import sonia.scm.repository.Changeset;
import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.ChangesetPagingResult;
@@ -81,17 +82,34 @@ public class IncomingRootResource {
* @return * @return
* @throws Exception * @throws Exception
*/ */
@Path("{source}/{target}/changesets")
@GET @GET
@StatusCodes({ @Path("{source}/{target}/changesets")
@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 changeset"),
@ResponseCode(code = 404, condition = "not found, no changesets available in the repository"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.CHANGESET_COLLECTION) @Produces(VndMediaType.CHANGESET_COLLECTION)
@TypeHint(CollectionDto.class) @Operation(summary = "Incoming changesets", description = "Get the incoming changesets from source to target.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.CHANGESET_COLLECTION,
schema = @Schema(implementation = CollectionDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the changeset")
@ApiResponse(
responseCode = "404",
description = "not found, no changesets available in the repository",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response incomingChangesets(@PathParam("namespace") String namespace, public Response incomingChangesets(@PathParam("namespace") String namespace,
@PathParam("name") String name, @PathParam("name") String name,
@PathParam("source") String source, @PathParam("source") String source,
@@ -117,18 +135,34 @@ public class IncomingRootResource {
} }
} }
@Path("{source}/{target}/diff")
@GET @GET
@StatusCodes({ @Path("{source}/{target}/diff")
@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 changeset"),
@ResponseCode(code = 404, condition = "not found, no changesets available in the repository"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.DIFF) @Produces(VndMediaType.DIFF)
@TypeHint(CollectionDto.class) @Operation(summary = "Incoming diff", description = "Get the incoming diff from source to target.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.DIFF,
schema = @Schema(implementation = CollectionDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the changeset")
@ApiResponse(
responseCode = "404",
description = "not found, no changesets available in the repository",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response incomingDiff(@PathParam("namespace") String namespace, public Response incomingDiff(@PathParam("namespace") String namespace,
@PathParam("name") String name, @PathParam("name") String name,
@PathParam("source") String source, @PathParam("source") String source,
@@ -155,14 +189,32 @@ public class IncomingRootResource {
@GET @GET
@Path("{source}/{target}/diff/parsed") @Path("{source}/{target}/diff/parsed")
@Produces(VndMediaType.DIFF_PARSED) @Produces(VndMediaType.DIFF_PARSED)
@StatusCodes({ @Operation(summary = "Incoming parsed diff", description = "Get the incoming parsed diff from source to target.", tags = "Repository")
@ResponseCode(code = 200, condition = "success"), @ApiResponse(
@ResponseCode(code = 400, condition = "Bad Request"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the diff"), content = @Content(
@ResponseCode(code = 404, condition = "not found, source or target branch for the repository not available or repository not found"), mediaType = VndMediaType.DIFF_PARSED,
@ResponseCode(code = 500, condition = "internal server error") schema = @Schema(implementation = CollectionDto.class)
}) )
)
@ApiResponse(responseCode = "400", description = "bad request")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the diff")
@ApiResponse(
responseCode = "404",
description = "not found, source or target branch for the repository not available or repository not found",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response incomingDiffParsed(@PathParam("namespace") String namespace, public Response incomingDiffParsed(@PathParam("namespace") String namespace,
@PathParam("name") String name, @PathParam("name") String name,
@PathParam("source") String source, @PathParam("source") String source,

View File

@@ -1,6 +1,12 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import sonia.scm.security.AllowAnonymousAccess; import sonia.scm.security.AllowAnonymousAccess;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -9,6 +15,15 @@ import javax.ws.rs.GET;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
@OpenAPIDefinition(
security = {
@SecurityRequirement(name = "Basic Authentication"),
@SecurityRequirement(name = "Bearer Token Authentication")
},
tags = {
@Tag(name = "Index", description = "SCM-Manager Index")
}
)
@Path(IndexResource.INDEX_PATH_V2) @Path(IndexResource.INDEX_PATH_V2)
@AllowAnonymousAccess @AllowAnonymousAccess
public class IndexResource { public class IndexResource {
@@ -24,7 +39,23 @@ public class IndexResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.INDEX) @Produces(VndMediaType.INDEX)
@TypeHint(IndexDto.class) @Operation(summary = "Get index", description = "Returns the index for the scm-manager instance.", tags = "Index")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.INDEX,
schema = @Schema(implementation = IndexDto.class)
)
)
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public IndexDto getIndex() { public IndexDto getIndex() {
return indexDtoGenerator.generate(); return indexDtoGenerator.generate();
} }

View File

@@ -1,8 +1,10 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import de.otto.edison.hal.HalRepresentation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.AvailablePlugin;
import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.InstalledPlugin;
import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginManager;
@@ -43,12 +45,30 @@ public class InstalledPluginResource {
*/ */
@GET @GET
@Path("") @Path("")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(CollectionDto.class)
@Produces(VndMediaType.PLUGIN_COLLECTION) @Produces(VndMediaType.PLUGIN_COLLECTION)
@Operation(
summary = "Find all installed plugins",
description = "Returns a collection of installed plugins.",
tags = "Plugin Management"
)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.PLUGIN_COLLECTION,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:read\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response getInstalledPlugins() { public Response getInstalledPlugins() {
PluginPermissions.read().check(); PluginPermissions.read().check();
List<InstalledPlugin> plugins = pluginManager.getInstalled(); List<InstalledPlugin> plugins = pluginManager.getInstalled();
@@ -61,11 +81,22 @@ public class InstalledPluginResource {
*/ */
@POST @POST
@Path("/update") @Path("/update")
@StatusCodes({ @Operation(
@ResponseCode(code = 200, condition = "success"), summary = "Update all installed plugins",
@ResponseCode(code = 500, condition = "internal server error") description = "Updates all installed plugins to the latest compatible version.",
}) tags = "Plugin Management"
@TypeHint(CollectionDto.class) )
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response updateAll() { public Response updateAll() {
pluginManager.updateAll(); pluginManager.updateAll();
return Response.ok().build(); return Response.ok().build();
@@ -75,18 +106,41 @@ public class InstalledPluginResource {
* Returns the installed plugin with the given id. * Returns the installed plugin with the given id.
* *
* @param name name of plugin * @param name name of plugin
*
* @return installed plugin with specified id * @return installed plugin with specified id
*/ */
@GET @GET
@Path("/{name}") @Path("/{name}")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 404, condition = "not found"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(PluginDto.class)
@Produces(VndMediaType.PLUGIN) @Produces(VndMediaType.PLUGIN)
@Operation(
summary = "Get installed plugin by name",
description = "Returns the installed plugin with the given id",
tags = "Plugin Management"
)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.PLUGIN,
schema = @Schema(implementation = PluginDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:read\" privilege")
@ApiResponse(
responseCode = "404",
description = "not found, plugin by given id could not be found",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response getInstalledPlugin(@PathParam("name") String name) { public Response getInstalledPlugin(@PathParam("name") String name) {
PluginPermissions.read().check(); PluginPermissions.read().check();
Optional<InstalledPlugin> pluginDto = pluginManager.getInstalled(name); Optional<InstalledPlugin> pluginDto = pluginManager.getInstalled(name);
@@ -100,17 +154,29 @@ public class InstalledPluginResource {
/** /**
* Triggers plugin uninstall. * Triggers plugin uninstall.
*
* @param name plugin name * @param name plugin name
* @return HTTP Status. * @return HTTP Status.
*/ */
@POST @POST
@Path("/{name}/uninstall") @Path("/{name}/uninstall")
@StatusCodes({ @Operation(
@ResponseCode(code = 200, condition = "success"), summary = "Uninstall plugin",
@ResponseCode(code = 500, condition = "internal server error") description = "Add plugin uninstall to pending queue. The plugin will be removed on restart.",
}) tags = "Plugin Management"
)
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response uninstallPlugin(@PathParam("name") String name, @QueryParam("restart") boolean restartAfterInstallation) { public Response uninstallPlugin(@PathParam("name") String name, @QueryParam("restart") boolean restartAfterInstallation) {
PluginPermissions.manage().check();
pluginManager.uninstall(name, restartAfterInstallation); pluginManager.uninstall(name, restartAfterInstallation);
return Response.ok().build(); return Response.ok().build();
} }

View File

@@ -1,8 +1,11 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.apache.shiro.authc.credential.PasswordService; import org.apache.shiro.authc.credential.PasswordService;
import sonia.scm.user.UserManager; import sonia.scm.user.UserManager;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -23,11 +26,13 @@ import javax.ws.rs.core.UriInfo;
/** /**
* RESTful Web Service Resource to get currently logged in users. * RESTful Web Service Resource to get currently logged in users.
*/ */
@OpenAPIDefinition(tags = {
@Tag(name = "Current user", description = "Current user related endpoints")
})
@Path(MeResource.ME_PATH_V2) @Path(MeResource.ME_PATH_V2)
public class MeResource { public class MeResource {
static final String ME_PATH_V2 = "v2/me/"; static final String ME_PATH_V2 = "v2/me/";
private final MeDtoFactory meDtoFactory; private final MeDtoFactory meDtoFactory;
private final UserManager userManager; private final UserManager userManager;
private final PasswordService passwordService; private final PasswordService passwordService;
@@ -45,12 +50,23 @@ public class MeResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.ME) @Produces(VndMediaType.ME)
@TypeHint(MeDto.class) @Operation(summary = "Current user", description = "Returns the currently logged in user or a 401 if user is not logged in.", tags = "Current user")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 500, condition = "internal server error") content = @Content(
}) mediaType = VndMediaType.ME,
schema = @Schema(implementation = MeDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response get(@Context Request request, @Context UriInfo uriInfo) { public Response get(@Context Request request, @Context UriInfo uriInfo) {
return Response.ok(meDtoFactory.create()).build(); return Response.ok(meDtoFactory.create()).build();
} }
@@ -60,13 +76,17 @@ public class MeResource {
*/ */
@PUT @PUT
@Path("password") @Path("password")
@StatusCodes({
@ResponseCode(code = 204, condition = "update success"),
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(TypeHint.NO_CONTENT.class)
@Consumes(VndMediaType.PASSWORD_CHANGE) @Consumes(VndMediaType.PASSWORD_CHANGE)
@Operation(summary = "Change password", description = "Change password of the current user.", tags = "Current user")
@ApiResponse(responseCode = "204", description = "update success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response changePassword(@Valid PasswordChangeDto passwordChange) { public Response changePassword(@Valid PasswordChangeDto passwordChange) {
userManager.changePasswordForLoggedInUser( userManager.changePasswordForLoggedInUser(
passwordService.encryptPassword(passwordChange.getOldPassword()), passwordService.encryptPassword(passwordChange.getOldPassword()),

View File

@@ -1,8 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.repository.Modifications; import sonia.scm.repository.Modifications;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryService;
@@ -33,16 +34,27 @@ public class ModificationsRootResource {
* file modifications are for example: Modified, Added or Removed. * file modifications are for example: Modified, Added or Removed.
*/ */
@GET @GET
@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 modifications"),
@ResponseCode(code = 404, condition = "not found, no changeset with the specified id is available in the repository"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.MODIFICATIONS)
@TypeHint(ModificationsDto.class)
@Path("{revision}") @Path("{revision}")
@Produces(VndMediaType.MODIFICATIONS)
@Operation(summary = "File modifications", description = "Get the file modifications related to a revision.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.MODIFICATIONS,
schema = @Schema(implementation = ModificationsDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the modifications")
@ApiResponse(
responseCode = "404",
description = "not found, no changeset with the specified id is available in the repository",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(responseCode = "500", description = "internal server error")
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision) throws IOException { public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision) throws IOException {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
Modifications modifications = repositoryService.getModificationsCommand() Modifications modifications = repositoryService.getModificationsCommand()

View File

@@ -1,6 +1,7 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Links; import de.otto.edison.hal.Links;
import io.swagger.v3.oas.annotations.Operation;
import sonia.scm.repository.NamespaceStrategy; import sonia.scm.repository.NamespaceStrategy;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -42,6 +43,7 @@ public class NamespaceStrategyResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.NAMESPACE_STRATEGIES) @Produces(VndMediaType.NAMESPACE_STRATEGIES)
@Operation(summary = "List of namespace strategies", description = "Returns all available namespace strategies and the current selected.", tags = "Repository")
public NamespaceStrategiesDto get(@Context UriInfo uriInfo) { public NamespaceStrategiesDto get(@Context UriInfo uriInfo) {
String currentStrategy = strategyAsString(namespaceStrategyProvider.get()); String currentStrategy = strategyAsString(namespaceStrategyProvider.get());
List<String> availableStrategies = collectStrategyNames(); List<String> availableStrategies = collectStrategyNames();

View File

@@ -1,10 +1,12 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links; import de.otto.edison.hal.Links;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.AvailablePlugin;
import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.InstalledPlugin;
import sonia.scm.plugin.PluginManager; import sonia.scm.plugin.PluginManager;
@@ -40,11 +42,30 @@ public class PendingPluginResource {
@GET @GET
@Path("") @Path("")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.PLUGIN_COLLECTION) @Produces(VndMediaType.PLUGIN_COLLECTION)
@Operation(
summary = "Find all pending plugins",
description = "Returns a collection of pending plugins.",
tags = "Plugin Management"
)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.PLUGIN_COLLECTION,
schema = @Schema(implementation = CollectionDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:read\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response getPending() { public Response getPending() {
List<AvailablePlugin> pending = pluginManager List<AvailablePlugin> pending = pluginManager
.getAvailable() .getAvailable()
@@ -71,7 +92,7 @@ public class PendingPluginResource {
if ( if (
PluginPermissions.manage().isPermitted() && PluginPermissions.manage().isPermitted() &&
(!installDtos.isEmpty() || !updateDtos.isEmpty() || !uninstallDtos.isEmpty()) (!installDtos.isEmpty() || !updateDtos.isEmpty() || !uninstallDtos.isEmpty())
) { ) {
linksBuilder.single(link("execute", resourceLinks.pendingPluginCollection().executePending())); linksBuilder.single(link("execute", resourceLinks.pendingPluginCollection().executePending()));
linksBuilder.single(link("cancel", resourceLinks.pendingPluginCollection().cancelPending())); linksBuilder.single(link("cancel", resourceLinks.pendingPluginCollection().cancelPending()));
@@ -103,10 +124,22 @@ public class PendingPluginResource {
@POST @POST
@Path("/execute") @Path("/execute")
@StatusCodes({ @Operation(
@ResponseCode(code = 200, condition = "success"), summary = "Execute pending",
@ResponseCode(code = 500, condition = "internal server error") description = "Executes all pending plugin changes. The server will be restarted on this action.",
}) tags = "Plugin Management"
)
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response executePending() { public Response executePending() {
pluginManager.executePendingAndRestart(); pluginManager.executePendingAndRestart();
return Response.ok().build(); return Response.ok().build();
@@ -114,10 +147,22 @@ public class PendingPluginResource {
@POST @POST
@Path("/cancel") @Path("/cancel")
@StatusCodes({ @Operation(
@ResponseCode(code = 200, condition = "success"), summary = "Cancel pending",
@ResponseCode(code = 500, condition = "internal server error") description = "Cancels all pending plugin changes and clear the pending queue.",
}) tags = "Plugin Management"
)
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response cancelPending() { public Response cancelPending() {
pluginManager.cancelPending(); pluginManager.cancelPending();
return Response.ok().build(); return Response.ok().build();

View File

@@ -1,9 +1,15 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.tags.Tag;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Provider; import javax.inject.Provider;
import javax.ws.rs.Path; import javax.ws.rs.Path;
@OpenAPIDefinition(tags = {
@Tag(name = "Plugin Management", description = "Plugin management related endpoints")
})
@Path("v2/plugins") @Path("v2/plugins")
public class PluginRootResource { public class PluginRootResource {

View File

@@ -1,10 +1,10 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.ResponseHeader; import io.swagger.v3.oas.annotations.headers.Header;
import com.webcohesion.enunciate.metadata.rs.ResponseHeaders; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Schema;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.apache.shiro.SecurityUtils; import org.apache.shiro.SecurityUtils;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryInitializer; import sonia.scm.repository.RepositoryInitializer;
@@ -63,13 +63,24 @@ public class RepositoryCollectionResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.REPOSITORY_COLLECTION) @Produces(VndMediaType.REPOSITORY_COLLECTION)
@TypeHint(CollectionDto.class) @Operation(summary = "List of repositories", description = "Returns all repositories for a given page number with a given page size.", tags = "Repository")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repository\" privilege"), content = @Content(
@ResponseCode(code = 500, condition = "internal server error") mediaType = VndMediaType.REPOSITORY_COLLECTION,
}) schema = @Schema(implementation = CollectionDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"repository\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getAll(@DefaultValue("0") @QueryParam("page") int page, public Response getAll(@DefaultValue("0") @QueryParam("page") int page,
@DefaultValue("" + DEFAULT_PAGE_SIZE) @QueryParam("pageSize") int pageSize, @DefaultValue("" + DEFAULT_PAGE_SIZE) @QueryParam("pageSize") int pageSize,
@QueryParam("sortBy") String sortBy, @QueryParam("sortBy") String sortBy,
@@ -92,15 +103,25 @@ public class RepositoryCollectionResource {
@POST @POST
@Path("") @Path("")
@Consumes(VndMediaType.REPOSITORY) @Consumes(VndMediaType.REPOSITORY)
@StatusCodes({ @Operation(summary = "Create repository", description = "Creates a new repository.", tags = "Repository")
@ResponseCode(code = 201, condition = "create success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "201",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repository\" privilege"), description = "create success",
@ResponseCode(code = 409, condition = "conflict, a repository with this name already exists"), headers = @Header(
@ResponseCode(code = 500, condition = "internal server error") name = "Location",
}) description = "uri to the created repository"
@TypeHint(TypeHint.NO_CONTENT.class) )
@ResponseHeaders(@ResponseHeader(name = "Location", description = "uri to the created repository")) )
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"repository\" privilege")
@ApiResponse(responseCode = "409", description = "conflict, a repository with this name already exists")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response create(@Valid RepositoryDto repository, @QueryParam("initialize") boolean initialize) { public Response create(@Valid RepositoryDto repository, @QueryParam("initialize") boolean initialize) {
AtomicReference<Repository> reference = new AtomicReference<>(); AtomicReference<Repository> reference = new AtomicReference<>();
Response response = adapter.create(repository, Response response = adapter.create(repository,

View File

@@ -1,9 +1,10 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.ResponseHeader; import io.swagger.v3.oas.annotations.headers.Header;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import sonia.scm.AlreadyExistsException; import sonia.scm.AlreadyExistsException;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
@@ -64,17 +65,30 @@ public class RepositoryPermissionRootResource {
* @return a web response with the status code 201 and the url to GET the added permission * @return a web response with the status code 201 and the url to GET the added permission
*/ */
@POST @POST
@StatusCodes({
@ResponseCode(code = 201, condition = "creates", additionalHeaders = {
@ResponseHeader(name = "Location", description = "uri of the created permission")
}),
@ResponseCode(code = 500, condition = "internal server error"),
@ResponseCode(code = 404, condition = "not found"),
@ResponseCode(code = 409, condition = "conflict")
})
@TypeHint(TypeHint.NO_CONTENT.class)
@Consumes(VndMediaType.REPOSITORY_PERMISSION)
@Path("") @Path("")
@Consumes(VndMediaType.REPOSITORY_PERMISSION)
@Operation(summary = "Create repository-specific permission", description = "Adds a new permission to the user or group managed by the repository.", tags = {"Repository", "Permissions"})
@ApiResponse(
responseCode = "201",
description = "creates",
headers = @Header(name = "Location", description = "uri of the created permission")
)
@ApiResponse(
responseCode = "404",
description = "not found",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(responseCode = "409", description = "conflict")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response create(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid RepositoryPermissionDto permission) { public Response create(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid RepositoryPermissionDto permission) {
log.info("try to add new permission: {}", permission); log.info("try to add new permission: {}", permission);
Repository repository = load(namespace, name); Repository repository = load(namespace, name);
@@ -95,14 +109,32 @@ public class RepositoryPermissionRootResource {
* @throws NotFoundException if the repository does not exists * @throws NotFoundException if the repository does not exists
*/ */
@GET @GET
@StatusCodes({
@ResponseCode(code = 200, condition = "ok"),
@ResponseCode(code = 404, condition = "not found"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.REPOSITORY_PERMISSION)
@TypeHint(RepositoryPermissionDto.class)
@Path("{permission-name}") @Path("{permission-name}")
@Produces(VndMediaType.REPOSITORY_PERMISSION)
@Operation(summary = "Get single repository-specific permission", description = "Get the searched permission with permission name related to a repository.", tags = {"Repository", "Permissions"})
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.REPOSITORY_PERMISSION,
schema = @Schema(implementation = RepositoryPermissionDto.class)
)
)
@ApiResponse(
responseCode = "404",
description = "not found",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("permission-name") String permissionName) { public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("permission-name") String permissionName) {
Repository repository = load(namespace, name); Repository repository = load(namespace, name);
RepositoryPermissions.permissionRead(repository).check(); RepositoryPermissions.permissionRead(repository).check();
@@ -125,14 +157,32 @@ public class RepositoryPermissionRootResource {
* @throws NotFoundException if the repository does not exists * @throws NotFoundException if the repository does not exists
*/ */
@GET @GET
@StatusCodes({
@ResponseCode(code = 200, condition = "ok"),
@ResponseCode(code = 404, condition = "not found"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.REPOSITORY_PERMISSION)
@TypeHint(RepositoryPermissionDto.class)
@Path("") @Path("")
@Produces(VndMediaType.REPOSITORY_PERMISSION)
@Operation(summary = "List of repository-specific permissions", description = "Get all permissions related to a repository.", tags = {"Repository", "Permissions"})
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.REPOSITORY_PERMISSION,
schema = @Schema(implementation = RepositoryPermissionDto.class)
)
)
@ApiResponse(
responseCode = "404",
description = "not found",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name) { public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name) {
Repository repository = load(namespace, name); Repository repository = load(namespace, name);
RepositoryPermissions.permissionRead(repository).check(); RepositoryPermissions.permissionRead(repository).check();
@@ -148,14 +198,19 @@ public class RepositoryPermissionRootResource {
* @return a web response with the status code 204 * @return a web response with the status code 204
*/ */
@PUT @PUT
@StatusCodes({
@ResponseCode(code = 204, condition = "update success"),
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(TypeHint.NO_CONTENT.class)
@Consumes(VndMediaType.REPOSITORY_PERMISSION)
@Path("{permission-name}") @Path("{permission-name}")
@Consumes(VndMediaType.REPOSITORY_PERMISSION)
@Operation(summary = "Update repository-specific permission", description = "Update a permission to the user or group managed by the repository.", tags = {"Repository", "Permissions"})
@ApiResponse(responseCode = "204", description = "update success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response update(@PathParam("namespace") String namespace, public Response update(@PathParam("namespace") String namespace,
@PathParam("name") String name, @PathParam("name") String name,
@PathParam("permission-name") String permissionName, @PathParam("permission-name") String permissionName,
@@ -194,14 +249,19 @@ public class RepositoryPermissionRootResource {
* @return a web response with the status code 204 * @return a web response with the status code 204
*/ */
@DELETE @DELETE
@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"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(TypeHint.NO_CONTENT.class)
@Path("{permission-name}") @Path("{permission-name}")
@Operation(summary = "Delete repository-specific permission", description = "Delete a permission with the given name.", tags = {"Repository", "Permissions"})
@ApiResponse(responseCode = "204", description = "delete success or nothing to delete")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response delete(@PathParam("namespace") String namespace, public Response delete(@PathParam("namespace") String namespace,
@PathParam("name") String name, @PathParam("name") String name,
@PathParam("permission-name") String permissionName) { @PathParam("permission-name") String permissionName) {

View File

@@ -1,8 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryManager;
@@ -87,14 +88,39 @@ public class RepositoryResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.REPOSITORY) @Produces(VndMediaType.REPOSITORY)
@TypeHint(RepositoryDto.class) @Operation(summary = "Get single repository", description = "Returns the repository for the given namespace and name.", tags = "Repository")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the repository"), content = @Content(
@ResponseCode(code = 404, condition = "not found, no repository with the specified name available in the namespace"), mediaType = VndMediaType.REPOSITORY,
@ResponseCode(code = 500, condition = "internal server error") schema = @Schema(implementation = RepositoryDto.class)
}) )
)
@ApiResponse(
responseCode = "401",
description = "not authenticated / invalid credentials"
)
@ApiResponse(
responseCode = "403",
description = "not authorized, the current user has no privileges to read the repository"
)
@ApiResponse(
responseCode = "404",
description = "not found, no repository with the specified name available in the namespace",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name){ public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name){
return adapter.get(loadBy(namespace, name), repositoryToDtoMapper::map); return adapter.get(loadBy(namespace, name), repositoryToDtoMapper::map);
} }
@@ -110,13 +136,11 @@ public class RepositoryResource {
*/ */
@DELETE @DELETE
@Path("") @Path("")
@StatusCodes({ @Operation(summary = "Delete repository", description = "Deletes the repository with the given namespace and name.", tags = "Repository")
@ResponseCode(code = 204, condition = "delete success or nothing to delete"), @ApiResponse(responseCode = "204", description = "delete success or nothing to delete")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repository\" privilege"), @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"repository\" privilege")
@ResponseCode(code = 500, condition = "internal server error") @ApiResponse(responseCode = "500", description = "internal server error")
})
@TypeHint(TypeHint.NO_CONTENT.class)
public Response delete(@PathParam("namespace") String namespace, @PathParam("name") String name) { public Response delete(@PathParam("namespace") String namespace, @PathParam("name") String name) {
return adapter.delete(loadBy(namespace, name)); return adapter.delete(loadBy(namespace, name));
} }
@@ -133,15 +157,19 @@ public class RepositoryResource {
@PUT @PUT
@Path("") @Path("")
@Consumes(VndMediaType.REPOSITORY) @Consumes(VndMediaType.REPOSITORY)
@StatusCodes({ @Operation(summary = "Update repository", description = "Modifies the repository for the given namespace and name.", tags = "Repository")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(responseCode = "204", description = "update success")
@ResponseCode(code = 400, condition = "Invalid body, e.g. illegal change of namespace or name"), @ApiResponse(responseCode = "400", description = "invalid body, e.g. illegal change of namespace or name")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repository\" privilege"), @ApiResponse(responseCode = "403", description = "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"), @ApiResponse(
@ResponseCode(code = 500, condition = "internal server error") responseCode = "404",
}) description = "not found, no repository with the specified namespace and name available",
@TypeHint(TypeHint.NO_CONTENT.class) content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(responseCode = "500", description = "internal server error")
public Response update(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid RepositoryDto repository) { public Response update(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid RepositoryDto repository) {
return adapter.update( return adapter.update(
loadBy(namespace, name), loadBy(namespace, name),
@@ -168,7 +196,7 @@ public class RepositoryResource {
} }
@Path("branches/") @Path("branches/")
public BranchRootResource branches(@PathParam("namespace") String namespace, @PathParam("name") String name) { public BranchRootResource branches() {
return branchRootResource.get(); return branchRootResource.get();
} }

View File

@@ -1,10 +1,10 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.ResponseHeader; import io.swagger.v3.oas.annotations.headers.Header;
import com.webcohesion.enunciate.metadata.rs.ResponseHeaders; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Schema;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.repository.RepositoryRole; import sonia.scm.repository.RepositoryRole;
import sonia.scm.repository.RepositoryRoleManager; import sonia.scm.repository.RepositoryRoleManager;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -51,21 +51,32 @@ public class RepositoryRoleCollectionResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.REPOSITORY_ROLE_COLLECTION) @Produces(VndMediaType.REPOSITORY_ROLE_COLLECTION)
@TypeHint(CollectionDto.class) @Operation(summary = "List of repository roles", description = "Returns all repository roles for a given page number with a given page size.", tags = "Repository role")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 400, condition = "\"sortBy\" field unknown"), description = "success",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), content = @Content(
@ResponseCode(code = 403, condition = "not authorized, the current repositoryRole does not have the \"repositoryRole\" privilege"), mediaType = VndMediaType.REPOSITORY_ROLE_COLLECTION,
@ResponseCode(code = 500, condition = "internal server error") schema = @Schema(implementation = CollectionDto.class)
}) )
)
@ApiResponse(responseCode = "400", description = "\"sortBy\" field unknown")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current repositoryRole does not have the \"repositoryRole\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getAll(@DefaultValue("0") @QueryParam("page") int page, public Response getAll(@DefaultValue("0") @QueryParam("page") int page,
@DefaultValue("" + DEFAULT_PAGE_SIZE) @QueryParam("pageSize") int pageSize, @DefaultValue("" + DEFAULT_PAGE_SIZE) @QueryParam("pageSize") int pageSize,
@QueryParam("sortBy") String sortBy, @QueryParam("sortBy") String sortBy,
@DefaultValue("false") @QueryParam("desc") boolean desc @DefaultValue("false") @QueryParam("desc") boolean desc
) { ) {
return adapter.getAll(page, pageSize, x -> true, sortBy, desc, return adapter.getAll(page, pageSize, x -> true, sortBy, desc,
pageResult -> repositoryRoleCollectionToDtoMapper.map(page, pageSize, pageResult)); pageResult -> repositoryRoleCollectionToDtoMapper.map(page, pageSize, pageResult));
} }
/** /**
@@ -79,15 +90,25 @@ public class RepositoryRoleCollectionResource {
@POST @POST
@Path("") @Path("")
@Consumes(VndMediaType.REPOSITORY_ROLE) @Consumes(VndMediaType.REPOSITORY_ROLE)
@StatusCodes({ @Operation(summary = "Create repository role", description = "Creates a new repository role.", tags = "Repository role")
@ResponseCode(code = 201, condition = "create success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "201",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repositoryRole\" privilege"), description = "create success",
@ResponseCode(code = 409, condition = "conflict, a repository role with this name already exists"), headers = @Header(
@ResponseCode(code = 500, condition = "internal server error") name = "Location",
}) description = "uri to the created repository role"
@TypeHint(TypeHint.NO_CONTENT.class) )
@ResponseHeaders(@ResponseHeader(name = "Location", description = "uri to the created repositoryRole")) )
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"repositoryRole\" privilege")
@ApiResponse(responseCode = "409", description = "conflict, a repository role with this name already exists")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response create(@Valid RepositoryRoleDto repositoryRole) { public Response create(@Valid RepositoryRoleDto repositoryRole) {
return adapter.create(repositoryRole, () -> dtoToRepositoryRoleMapper.map(repositoryRole), u -> resourceLinks.repositoryRole().self(u.getName())); return adapter.create(repositoryRole, () -> dtoToRepositoryRoleMapper.map(repositoryRole), u -> resourceLinks.repositoryRole().self(u.getName()));
} }

View File

@@ -1,8 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.repository.RepositoryRole; import sonia.scm.repository.RepositoryRole;
import sonia.scm.repository.RepositoryRoleManager; import sonia.scm.repository.RepositoryRoleManager;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -45,14 +46,31 @@ public class RepositoryRoleResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.REPOSITORY_ROLE) @Produces(VndMediaType.REPOSITORY_ROLE)
@TypeHint(RepositoryRoleDto.class) @Operation(summary = "Get single repository role", description = "Returns the repository role for the given name.", tags = "Repository role")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the repository role"), content = @Content(
@ResponseCode(code = 404, condition = "not found, no repository role with the specified name available"), mediaType = VndMediaType.REPOSITORY_ROLE,
@ResponseCode(code = 500, condition = "internal server error") schema = @Schema(implementation = RepositoryRoleDto.class)
}) )
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the repository role")
@ApiResponse(
responseCode = "404",
description = "not found, no repository role with the specified name available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response get(@PathParam("name") String name) { public Response get(@PathParam("name") String name) {
return adapter.get(name, repositoryRoleToDtoMapper::map); return adapter.get(name, repositoryRoleToDtoMapper::map);
} }
@@ -66,13 +84,11 @@ public class RepositoryRoleResource {
*/ */
@DELETE @DELETE
@Path("") @Path("")
@StatusCodes({ @Operation(summary = "Delete repository role", description = "Deletes the repository role with the given name.", tags = "Repository role")
@ResponseCode(code = 204, condition = "delete success or nothing to delete"), @ApiResponse(responseCode = "204", description = "delete success or nothing to delete")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repositoryRole\" privilege"), @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"repositoryRole\" privilege")
@ResponseCode(code = 500, condition = "internal server error") @ApiResponse(responseCode = "500", description = "internal server error")
})
@TypeHint(TypeHint.NO_CONTENT.class)
public Response delete(@PathParam("name") String name) { public Response delete(@PathParam("name") String name) {
return adapter.delete(name); return adapter.delete(name);
} }
@@ -88,15 +104,19 @@ public class RepositoryRoleResource {
@PUT @PUT
@Path("") @Path("")
@Consumes(VndMediaType.REPOSITORY_ROLE) @Consumes(VndMediaType.REPOSITORY_ROLE)
@StatusCodes({ @Operation(summary = "Update repository role", description = "Modifies the repository role for the given name.", tags = "Repository role")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(responseCode = "204", description = "update success")
@ResponseCode(code = 400, condition = "Invalid body, e.g. illegal change of repository role name"), @ApiResponse(responseCode = "400", description = "invalid body, e.g. illegal change of repository role name")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"repositoryRole\" privilege"), @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"repositoryRole\" privilege")
@ResponseCode(code = 404, condition = "not found, no repository role with the specified name available"), @ApiResponse(
@ResponseCode(code = 500, condition = "internal server error") responseCode = "404",
}) description = "not found, no repository role with the specified name available",
@TypeHint(TypeHint.NO_CONTENT.class) content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(responseCode = "500", description = "internal server error")
public Response update(@PathParam("name") String name, @Valid RepositoryRoleDto repositoryRole) { public Response update(@PathParam("name") String name, @Valid RepositoryRoleDto repositoryRole) {
return adapter.update(name, existing -> dtoToRepositoryRoleMapper.map(repositoryRole)); return adapter.update(name, existing -> dtoToRepositoryRoleMapper.map(repositoryRole));
} }

View File

@@ -1,5 +1,8 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.tags.Tag;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Provider; import javax.inject.Provider;
import javax.ws.rs.Path; import javax.ws.rs.Path;
@@ -7,6 +10,9 @@ import javax.ws.rs.Path;
/** /**
* RESTful web service resource to manage repository roles. * RESTful web service resource to manage repository roles.
*/ */
@OpenAPIDefinition(tags = {
@Tag(name = "Repository role", description = "Repository role related endpoints")
})
@Path(RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2) @Path(RepositoryRoleRootResource.REPOSITORY_ROLES_PATH_V2)
public class RepositoryRoleRootResource { public class RepositoryRoleRootResource {

View File

@@ -1,12 +1,20 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.tags.Tag;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Provider; import javax.inject.Provider;
import javax.ws.rs.Path; import javax.ws.rs.Path;
/** /**
* RESTful Web Service Resource to manage repositories. * RESTful Web Service Resource to manage repositories.
*/ */
@OpenAPIDefinition(
tags = {
@Tag(name = "Repository", description = "Repository related endpoints")
}
)
@Path(RepositoryRootResource.REPOSITORIES_PATH_V2) @Path(RepositoryRootResource.REPOSITORIES_PATH_V2)
public class RepositoryRootResource { public class RepositoryRootResource {
static final String REPOSITORIES_PATH_V2 = "v2/repositories/"; static final String REPOSITORIES_PATH_V2 = "v2/repositories/";

View File

@@ -1,8 +1,10 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.HalRepresentation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryManager;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -24,11 +26,25 @@ public class RepositoryTypeCollectionResource {
@GET @GET
@Path("") @Path("")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.REPOSITORY_TYPE_COLLECTION) @Produces(VndMediaType.REPOSITORY_TYPE_COLLECTION)
@Operation(summary = "List of repository types", description = "Returns all repository types.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.REPOSITORY_TYPE_COLLECTION,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(responseCode = "400", description = "\"sortBy\" field unknown")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public HalRepresentation getAll() { public HalRepresentation getAll() {
return mapper.map(repositoryManager.getConfiguredTypes()); return mapper.map(repositoryManager.getConfiguredTypes());
} }

View File

@@ -1,8 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryType; import sonia.scm.repository.RepositoryType;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -35,12 +36,30 @@ public class RepositoryTypeResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.REPOSITORY_TYPE) @Produces(VndMediaType.REPOSITORY_TYPE)
@TypeHint(RepositoryTypeDto.class) @Operation(summary = "Get single repository type", description = "Returns the specified repository type for the given name.", tags = "Repository")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 404, condition = "not found, no repository type with the specified name available"), description = "success",
@ResponseCode(code = 500, condition = "internal server error") content = @Content(
}) mediaType = VndMediaType.REPOSITORY_TYPE,
schema = @Schema(implementation = RepositoryTypeDto.class)
)
)
@ApiResponse(
responseCode = "404",
description = "not found, no repository type with the specified name available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response get(@PathParam("name") String name) { public Response get(@PathParam("name") String name) {
for (RepositoryType type : repositoryManager.getConfiguredTypes()) { for (RepositoryType type : repositoryManager.getConfiguredTypes()) {
if (name.equalsIgnoreCase(type.getName())) { if (name.equalsIgnoreCase(type.getName())) {

View File

@@ -1,8 +1,10 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import de.otto.edison.hal.Links; import de.otto.edison.hal.Links;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.security.RepositoryPermissionProvider; import sonia.scm.security.RepositoryPermissionProvider;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -30,11 +32,23 @@ public class RepositoryVerbResource {
@GET @GET
@Path("") @Path("")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.REPOSITORY_VERB_COLLECTION) @Produces(VndMediaType.REPOSITORY_VERB_COLLECTION)
@Operation(summary = "List of repository verbs", description = "Returns all repository-specific permissions.", hidden = true)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.REPOSITORY_VERB_COLLECTION,
schema = @Schema(implementation = RepositoryVerbsDto.class)
)
)
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public RepositoryVerbsDto getAll() { public RepositoryVerbsDto getAll() {
return new RepositoryVerbsDto( return new RepositoryVerbsDto(
Links.linkingTo().self(resourceLinks.repositoryVerbs().self()).build(), Links.linkingTo().self(resourceLinks.repositoryVerbs().self()).build(),

View File

@@ -1,5 +1,6 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.Operation;
import sonia.scm.repository.BrowserResult; import sonia.scm.repository.BrowserResult;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.api.BrowseCommandBuilder; import sonia.scm.repository.api.BrowseCommandBuilder;
@@ -33,22 +34,25 @@ public class SourceRootResource {
} }
@GET @GET
@Produces(VndMediaType.SOURCE)
@Path("") @Path("")
@Produces(VndMediaType.SOURCE)
@Operation(summary = "List of sources", description = "Returns all sources for repository head.", tags = "Repository")
public FileObjectDto getAllWithoutRevision(@PathParam("namespace") String namespace, @PathParam("name") String name, @DefaultValue("0") @QueryParam("offset") int offset) throws IOException { public FileObjectDto getAllWithoutRevision(@PathParam("namespace") String namespace, @PathParam("name") String name, @DefaultValue("0") @QueryParam("offset") int offset) throws IOException {
return getSource(namespace, name, "/", null, offset); return getSource(namespace, name, "/", null, offset);
} }
@GET @GET
@Produces(VndMediaType.SOURCE)
@Path("{revision}") @Path("{revision}")
@Produces(VndMediaType.SOURCE)
@Operation(summary = "List of sources by revision", description = "Returns all sources for the given revision.", tags = "Repository")
public FileObjectDto getAll(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @DefaultValue("0") @QueryParam("offset") int offset) throws IOException { public FileObjectDto getAll(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @DefaultValue("0") @QueryParam("offset") int offset) throws IOException {
return getSource(namespace, name, "/", revision, offset); return getSource(namespace, name, "/", revision, offset);
} }
@GET @GET
@Produces(VndMediaType.SOURCE)
@Path("{revision}/{path: .*}") @Path("{revision}/{path: .*}")
@Produces(VndMediaType.SOURCE)
@Operation(summary = "List of sources by revision in path", description = "Returns all sources for the given revision in a specific path.", tags = "Repository")
public FileObjectDto get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @PathParam("path") String path, @DefaultValue("0") @QueryParam("offset") int offset) throws IOException { public FileObjectDto get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @PathParam("path") String path, @DefaultValue("0") @QueryParam("offset") int offset) throws IOException {
return getSource(namespace, name, path, revision, offset); return getSource(namespace, name, path, revision, offset);
} }

View File

@@ -1,8 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
@@ -39,17 +40,27 @@ public class TagRootResource {
this.tagToTagDtoMapper = tagToTagDtoMapper; this.tagToTagDtoMapper = tagToTagDtoMapper;
} }
@GET @GET
@Path("") @Path("")
@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 tags"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.TAG_COLLECTION) @Produces(VndMediaType.TAG_COLLECTION)
@TypeHint(CollectionDto.class) @Operation(summary = "List of tags", description = "Returns the tags for the given namespace and name.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.TAG_COLLECTION,
schema = @Schema(implementation = CollectionDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the tags")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name) throws IOException { public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name) throws IOException {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
Tags tags = getTags(repositoryService); Tags tags = getTags(repositoryService);
@@ -65,16 +76,33 @@ public class TagRootResource {
@GET @GET
@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 tags"),
@ResponseCode(code = 404, condition = "not found, no tag available in the repository"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.TAG)
@TypeHint(TagDto.class)
@Path("{tagName}") @Path("{tagName}")
@Produces(VndMediaType.TAG)
@Operation(summary = "Get tag", description = "Returns the tag for the given name.", tags = "Repository")
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.TAG,
schema = @Schema(implementation = TagDto.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the tags")
@ApiResponse(
responseCode = "404",
description = "not found, no tag with the specified name available in the repository",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("tagName") String tagName) throws IOException { public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("tagName") String tagName) throws IOException {
NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name);
try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) {

View File

@@ -1,8 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginLoader;
import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.InstalledPlugin;
import sonia.scm.security.AllowAnonymousAccess; import sonia.scm.security.AllowAnonymousAccess;
@@ -39,12 +40,23 @@ public class UIPluginResource {
*/ */
@GET @GET
@Path("") @Path("")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(CollectionDto.class)
@Produces(VndMediaType.UI_PLUGIN_COLLECTION) @Produces(VndMediaType.UI_PLUGIN_COLLECTION)
@Operation(summary = "Collection of ui plugin bundles", description = "Returns a collection of installed plugins and their ui bundles.", hidden = true)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.UI_PLUGIN_COLLECTION,
schema = @Schema(implementation = CollectionDto.class)
)
)
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getInstalledPlugins() { public Response getInstalledPlugins() {
List<InstalledPlugin> plugins = pluginLoader.getInstalledPlugins() List<InstalledPlugin> plugins = pluginLoader.getInstalledPlugins()
.stream() .stream()
@@ -63,13 +75,30 @@ public class UIPluginResource {
*/ */
@GET @GET
@Path("{id}") @Path("{id}")
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 404, condition = "not found"),
@ResponseCode(code = 500, condition = "internal server error")
})
@TypeHint(UIPluginDto.class)
@Produces(VndMediaType.UI_PLUGIN) @Produces(VndMediaType.UI_PLUGIN)
@Operation(summary = "Get single ui plugin bundle", description = "Returns the installed plugin with the given id.", hidden = true)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.UI_PLUGIN,
schema = @Schema(implementation = UIPluginDto.class)
)
)
@ApiResponse(
responseCode = "404",
description = "not found",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getInstalledPlugin(@PathParam("id") String id) { public Response getInstalledPlugin(@PathParam("id") String id) {
Optional<UIPluginDto> uiPluginDto = pluginLoader.getInstalledPlugins() Optional<UIPluginDto> uiPluginDto = pluginLoader.getInstalledPlugins()
.stream() .stream()

View File

@@ -1,10 +1,10 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.ResponseHeader; import io.swagger.v3.oas.annotations.headers.Header;
import com.webcohesion.enunciate.metadata.rs.ResponseHeaders; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Schema;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.apache.shiro.authc.credential.PasswordService; import org.apache.shiro.authc.credential.PasswordService;
import sonia.scm.search.SearchRequest; import sonia.scm.search.SearchRequest;
import sonia.scm.search.SearchUtil; import sonia.scm.search.SearchUtil;
@@ -59,22 +59,33 @@ public class UserCollectionResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.USER_COLLECTION) @Produces(VndMediaType.USER_COLLECTION)
@TypeHint(CollectionDto.class) @Operation(summary = "List of users", description = "Returns all users for a given page number with a given page size.", tags = "User")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 400, condition = "\"sortBy\" field unknown"), description = "success",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), content = @Content(
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"user\" privilege"), mediaType = VndMediaType.USER_COLLECTION,
@ResponseCode(code = 500, condition = "internal server error") schema = @Schema(implementation = CollectionDto.class)
}) )
)
@ApiResponse(responseCode = "400", description = "\"sortBy\" field unknown")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"user\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getAll(@DefaultValue("0") @QueryParam("page") int page, public Response getAll(@DefaultValue("0") @QueryParam("page") int page,
@DefaultValue("" + DEFAULT_PAGE_SIZE) @QueryParam("pageSize") int pageSize, @DefaultValue("" + DEFAULT_PAGE_SIZE) @QueryParam("pageSize") int pageSize,
@QueryParam("sortBy") String sortBy, @QueryParam("sortBy") String sortBy,
@DefaultValue("false") @QueryParam("desc") boolean desc, @DefaultValue("false") @QueryParam("desc") boolean desc,
@DefaultValue("") @QueryParam("q") String search @DefaultValue("") @QueryParam("q") String search
) { ) {
return adapter.getAll(page, pageSize, createSearchPredicate(search), sortBy, desc, return adapter.getAll(page, pageSize, createSearchPredicate(search), sortBy, desc,
pageResult -> userCollectionToDtoMapper.map(page, pageSize, pageResult)); pageResult -> userCollectionToDtoMapper.map(page, pageSize, pageResult));
} }
/** /**
@@ -88,15 +99,25 @@ public class UserCollectionResource {
@POST @POST
@Path("") @Path("")
@Consumes(VndMediaType.USER) @Consumes(VndMediaType.USER)
@StatusCodes({ @Operation(summary = "Create user", description = "Creates a new user.", tags = "User")
@ResponseCode(code = 201, condition = "create success"), @ApiResponse(
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), responseCode = "201",
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"user\" privilege"), description = "create success",
@ResponseCode(code = 409, condition = "conflict, a user with this name already exists"), headers = @Header(
@ResponseCode(code = 500, condition = "internal server error") name = "Location",
}) description = "uri to the created user"
@TypeHint(TypeHint.NO_CONTENT.class) )
@ResponseHeaders(@ResponseHeader(name = "Location", description = "uri to the created user")) )
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"user\" privilege")
@ApiResponse(responseCode = "409", description = "conflict, a user with this name already exists")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response create(@Valid UserDto user) { public Response create(@Valid UserDto user) {
return adapter.create(user, () -> dtoToUserMapper.map(user, passwordService.encryptPassword(user.getPassword())), u -> resourceLinks.user().self(u.getName())); return adapter.create(user, () -> dtoToUserMapper.map(user, passwordService.encryptPassword(user.getPassword())), u -> resourceLinks.user().self(u.getName()));
} }

View File

@@ -1,8 +1,9 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.security.PermissionAssigner; import sonia.scm.security.PermissionAssigner;
import sonia.scm.security.PermissionDescriptor; import sonia.scm.security.PermissionDescriptor;
import sonia.scm.security.PermissionPermissions; import sonia.scm.security.PermissionPermissions;
@@ -40,14 +41,32 @@ public class UserPermissionResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.PERMISSION_COLLECTION) @Produces(VndMediaType.PERMISSION_COLLECTION)
@TypeHint(PermissionListDto.class) @Operation(summary = "User permission", description = "Returns the global git configuration.", tags = {"User", "Permissions"})
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the user"), content = @Content(
@ResponseCode(code = 404, condition = "not found, no user with the specified id/name available"), mediaType = VndMediaType.PERMISSION_COLLECTION,
@ResponseCode(code = 500, condition = "internal server error") schema = @Schema(implementation = PermissionListDto.class)
}) )
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the user")
@ApiResponse(
responseCode = "404",
description = "not found, no user with the specified id/name available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response getPermissions(@PathParam("id") String id) { public Response getPermissions(@PathParam("id") String id) {
PermissionPermissions.read().check(); PermissionPermissions.read().check();
Collection<PermissionDescriptor> permissions = permissionAssigner.readPermissionsForUser(id); Collection<PermissionDescriptor> permissions = permissionAssigner.readPermissionsForUser(id);
@@ -63,15 +82,26 @@ public class UserPermissionResource {
@PUT @PUT
@Path("") @Path("")
@Consumes(VndMediaType.PERMISSION_COLLECTION) @Consumes(VndMediaType.PERMISSION_COLLECTION)
@StatusCodes({ @Operation(summary = "Update user permissions", description = "Sets permissions for a user. Overwrites all existing permissions.", tags = {"User", "Permissions"})
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(responseCode = "204", description = "update success")
@ResponseCode(code = 400, condition = "Invalid body"), @ApiResponse(responseCode = "400", description = "invalid body")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the correct privilege"), @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the correct privilege")
@ResponseCode(code = 404, condition = "not found, no user with the specified id/name available"), @ApiResponse(
@ResponseCode(code = 500, condition = "internal server error") responseCode = "404",
}) description = "not found, no user with the specified id/name available",
@TypeHint(TypeHint.NO_CONTENT.class) content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response overwritePermissions(@PathParam("id") String id, @Valid PermissionListDto newPermissions) { public Response overwritePermissions(@PathParam("id") String id, @Valid PermissionListDto newPermissions) {
Collection<PermissionDescriptor> permissionDescriptors = Arrays.stream(newPermissions.getPermissions()) Collection<PermissionDescriptor> permissionDescriptors = Arrays.stream(newPermissions.getPermissions())
.map(PermissionDescriptor::new) .map(PermissionDescriptor::new)

View File

@@ -1,12 +1,12 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import io.swagger.v3.oas.annotations.Operation;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import io.swagger.v3.oas.annotations.media.Content;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.apache.shiro.authc.credential.PasswordService; import org.apache.shiro.authc.credential.PasswordService;
import sonia.scm.user.User; import sonia.scm.user.User;
import sonia.scm.user.UserManager; import sonia.scm.user.UserManager;
import sonia.scm.user.UserPermissions;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
import javax.inject.Inject; import javax.inject.Inject;
@@ -54,14 +54,31 @@ public class UserResource {
@GET @GET
@Path("") @Path("")
@Produces(VndMediaType.USER) @Produces(VndMediaType.USER)
@TypeHint(UserDto.class) @Operation(summary = "Get single user", description = "Returns the user for the given id.", tags = "User")
@StatusCodes({ @ApiResponse(
@ResponseCode(code = 200, condition = "success"), responseCode = "200",
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), description = "success",
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the user"), content = @Content(
@ResponseCode(code = 404, condition = "not found, no user with the specified id/name available"), mediaType = VndMediaType.USER,
@ResponseCode(code = 500, condition = "internal server error") schema = @Schema(implementation = UserDto.class)
}) )
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the user")
@ApiResponse(
responseCode = "404",
description = "not found, no user with the specified id/name available",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response get(@PathParam("id") String id) { public Response get(@PathParam("id") String id) {
return adapter.get(id, userToDtoMapper::map); return adapter.get(id, userToDtoMapper::map);
} }
@@ -75,13 +92,11 @@ public class UserResource {
*/ */
@DELETE @DELETE
@Path("") @Path("")
@StatusCodes({ @Operation(summary = "Delete user", description = "Deletes the user with the given id.", tags = "User")
@ResponseCode(code = 204, condition = "delete success or nothing to delete"), @ApiResponse(responseCode = "204", description = "delete success or nothing to delete")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"user\" privilege"), @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"user\" privilege")
@ResponseCode(code = 500, condition = "internal server error") @ApiResponse(responseCode = "500", description = "internal server error")
})
@TypeHint(TypeHint.NO_CONTENT.class)
public Response delete(@PathParam("id") String name) { public Response delete(@PathParam("id") String name) {
return adapter.delete(name); return adapter.delete(name);
} }
@@ -98,15 +113,19 @@ public class UserResource {
@PUT @PUT
@Path("") @Path("")
@Consumes(VndMediaType.USER) @Consumes(VndMediaType.USER)
@StatusCodes({ @Operation(summary = "Update user", description = "Modifies the user for the given id.", tags = "User")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(responseCode = "204", description = "update success")
@ResponseCode(code = 400, condition = "Invalid body, e.g. illegal change of id/user name"), @ApiResponse(responseCode = "400", description = "invalid body, e.g. illegal change of id/user name")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"user\" privilege"), @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"user\" privilege")
@ResponseCode(code = 404, condition = "not found, no user with the specified id/name available"), @ApiResponse(
@ResponseCode(code = 500, condition = "internal server error") responseCode = "404",
}) description = "not found, no user with the specified id/name available",
@TypeHint(TypeHint.NO_CONTENT.class) content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(responseCode = "500", description = "internal server error")
public Response update(@PathParam("id") String name, @Valid UserDto user) { public Response update(@PathParam("id") String name, @Valid UserDto user) {
return adapter.update(name, existing -> dtoToUserMapper.map(user, existing.getPassword())); return adapter.update(name, existing -> dtoToUserMapper.map(user, existing.getPassword()));
} }
@@ -125,15 +144,19 @@ public class UserResource {
@PUT @PUT
@Path("password") @Path("password")
@Consumes(VndMediaType.PASSWORD_OVERWRITE) @Consumes(VndMediaType.PASSWORD_OVERWRITE)
@StatusCodes({ @Operation(summary = "Modifies a user password", description = "Lets admins modifies the user password for the given id.", tags = "User")
@ResponseCode(code = 204, condition = "update success"), @ApiResponse(responseCode = "204", description = "update success")
@ResponseCode(code = 400, condition = "Invalid body, e.g. the user type is not xml or the given oldPassword do not match the stored one"), @ApiResponse(responseCode = "400", description = "invalid body, e.g. the user type is not xml or the given oldPassword do not match the stored one")
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ResponseCode(code = 403, condition = "not authorized, the current user does not have the \"user\" privilege"), @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"user\" privilege")
@ResponseCode(code = 404, condition = "not found, no user with the specified id/name available"), @ApiResponse(
@ResponseCode(code = 500, condition = "internal server error") responseCode = "404",
}) description = "not found, no user with the specified id/name available",
@TypeHint(TypeHint.NO_CONTENT.class) content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
@ApiResponse(responseCode = "500", description = "internal server error")
public Response overwritePassword(@PathParam("id") String name, @Valid PasswordOverwriteDto passwordOverwrite) { public Response overwritePassword(@PathParam("id") String name, @Valid PasswordOverwriteDto passwordOverwrite) {
userManager.overwritePassword(name, passwordService.encryptPassword(passwordOverwrite.getNewPassword())); userManager.overwritePassword(name, passwordService.encryptPassword(passwordOverwrite.getNewPassword()));
return Response.noContent().build(); return Response.noContent().build();

View File

@@ -1,5 +1,8 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.tags.Tag;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Provider; import javax.inject.Provider;
import javax.ws.rs.Path; import javax.ws.rs.Path;
@@ -7,6 +10,9 @@ import javax.ws.rs.Path;
/** /**
* RESTful Web Service Resource to manage users. * RESTful Web Service Resource to manage users.
*/ */
@OpenAPIDefinition(tags = {
@Tag(name = "User", description = "User related endpoints")
})
@Path(UserRootResource.USERS_PATH_V2) @Path(UserRootResource.USERS_PATH_V2)
public class UserRootResource { public class UserRootResource {