This is an automated email from the ASF dual-hosted git repository.

martin_s pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/archiva.git

commit b97724c6a70b18f5667b45a20375ff550bd9015c
Author: Martin Stockhammer <[email protected]>
AuthorDate: Tue Jan 19 09:36:23 2021 +0100

    Refactoring exceptions and adding REST V2 service
---
 .../archiva/admin/model/EntityExistsException.java |  78 +++++++
 .../admin/model/EntityNotFoundException.java       |  79 +++++++
 .../admin/model/RepositoryAdminException.java      |  88 +++++++
 .../archiva/admin/model/beans/RepositoryGroup.java |  12 +
 .../admin/model/group/RepositoryGroupAdmin.java    |  12 +-
 .../admin/model/error/AdminErrors.properties       |  27 +++
 .../group/DefaultRepositoryGroupAdmin.java         |  38 +--
 .../rest/api/model/v2/MergeConfiguration.java      | 110 +++++++++
 .../archiva/rest/api/model/v2/RepositoryGroup.java | 149 ++++++++++++
 .../api/services/v2/RepositoryGroupService.java    | 257 +++++++++++++++++++++
 .../archiva/rest/api/services/v2/package-info.java | 102 ++++++++
 .../services/v2/DefaultRepositoryGroupService.java | 214 +++++++++++++++++
 .../apache/archiva/rest/services/v2/ErrorKeys.java |  11 +
 13 files changed, 1161 insertions(+), 16 deletions(-)

diff --git 
a/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/EntityExistsException.java
 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/EntityExistsException.java
new file mode 100644
index 0000000..9868427
--- /dev/null
+++ 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/EntityExistsException.java
@@ -0,0 +1,78 @@
+package org.apache.archiva.admin.model;/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * This exception is thrown, if a entity that should be created, exists 
already.
+ * @author Martin Stockhammer <[email protected]>
+ * @since 3.0
+ */
+public class EntityExistsException extends RepositoryAdminException
+{
+    private static final String KEY = "entity.exists";
+
+    public static EntityExistsException of(String... parameters) {
+        String message = getMessage( KEY, parameters );
+        return new EntityExistsException( message, parameters );
+    }
+
+    public EntityExistsException( String s, String... parameters )
+    {
+        super( s );
+        setKey( KEY );
+        setParameters( parameters );
+    }
+
+    public EntityExistsException( String s, String fieldName, String... 
parameters )
+    {
+        super( s, fieldName );
+        setKey( KEY );
+        setParameters( parameters );
+    }
+
+    public EntityExistsException( String message, Throwable cause, String... 
parameters )
+    {
+        super( message, cause );
+        setKey( KEY );
+        setParameters( parameters );
+    }
+
+    public EntityExistsException( String message, Throwable cause, String 
fieldName, String... parameters )
+    {
+        super( message, cause, fieldName );
+        setKey( KEY );
+        setParameters( parameters );
+    }
+}
diff --git 
a/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/EntityNotFoundException.java
 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/EntityNotFoundException.java
new file mode 100644
index 0000000..90bf82d
--- /dev/null
+++ 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/EntityNotFoundException.java
@@ -0,0 +1,79 @@
+package org.apache.archiva.admin.model;/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * This exception is thrown, if a requested entity does not exist.
+ *
+ * @author Martin Stockhammer <[email protected]>
+ * @since 3.0
+ */
+public class EntityNotFoundException extends RepositoryAdminException
+{
+    public static final String KEY = "entity.not_found";
+
+    public static EntityNotFoundException of(String... parameters) {
+        String message = getMessage( KEY, parameters );
+        return new EntityNotFoundException( message, parameters );
+    }
+
+    public EntityNotFoundException( String s, String... parameters )
+    {
+        super( s );
+        setKey( KEY );
+        setParameters( parameters );
+    }
+
+    public EntityNotFoundException( String s, String fieldName, String... 
parameters )
+    {
+        super( s, fieldName );
+        setKey( KEY );
+        setParameters( parameters );
+    }
+
+    public EntityNotFoundException( String message, Throwable cause, String... 
parameters )
+    {
+        super( message, cause );
+        setKey( KEY );
+        setParameters( parameters );
+    }
+
+    public EntityNotFoundException( String message, Throwable cause, String 
fieldName, String... parameters )
+    {
+        super( message, cause, fieldName );
+        setKey( KEY );
+        setParameters( parameters );
+    }
+}
diff --git 
a/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/RepositoryAdminException.java
 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/RepositoryAdminException.java
index 80f7770..c684c0e 100644
--- 
a/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/RepositoryAdminException.java
+++ 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/RepositoryAdminException.java
@@ -19,6 +19,12 @@ package org.apache.archiva.admin.model;
  */
 
 
+import org.apache.commons.lang3.StringUtils;
+
+import java.text.MessageFormat;
+import java.util.Locale;
+import java.util.ResourceBundle;
+
 /**
  * @author Olivier Lamy
  * @since 1.4-M1
@@ -27,6 +33,8 @@ public class RepositoryAdminException
     extends Exception
 {
 
+    private static final ResourceBundle bundle = ResourceBundle.getBundle( 
"org.apache.archiva.admin.model.error.AdminErrors", Locale.ROOT );
+
     /**
      * can return the field name of bean with issue
      * can be <code>null</code>
@@ -34,6 +42,58 @@ public class RepositoryAdminException
      */
     private String fieldName;
 
+    /**
+     * A unique identifier of this error
+     * @since 3.0
+     */
+    private String key;
+    private boolean keyExists = false;
+
+    /**
+     * Message parameters
+     */
+    String[] parameters = new String[0];
+
+
+    public static RepositoryAdminException ofKey(String key, String... params) 
{
+        String message = getMessage( key, params );
+        RepositoryAdminException ex = new RepositoryAdminException( message );
+        ex.setKey( key );
+        ex.setParameters( params );
+        return ex;
+    }
+
+    protected static String getMessage( String key, String[] params )
+    {
+        return MessageFormat.format( bundle.getString( key ), params );
+    }
+
+    public static RepositoryAdminException ofKey(String key, Throwable cause, 
String... params) {
+        String message = getMessage( key, params );
+        RepositoryAdminException ex = new RepositoryAdminException( message, 
cause );
+        ex.setKey( key );
+        ex.setParameters( params );
+        return ex;
+    }
+
+
+    public static RepositoryAdminException ofKeyAndField(String key, String 
fieldName, String... params) {
+        String message = getMessage( key, params );
+        RepositoryAdminException ex = new RepositoryAdminException( message, 
fieldName );
+        ex.setKey( key );
+        ex.setParameters( params );
+        return ex;
+    }
+
+    public static RepositoryAdminException ofKeyAndField(String key, Throwable 
cause, String fieldName, String... params) {
+        String message = getMessage( key, params );
+        RepositoryAdminException ex = new RepositoryAdminException( message, 
cause, fieldName );
+        ex.setKey( key );
+        ex.setFieldName( fieldName );
+        ex.setParameters( params );
+        return ex;
+    }
+
     public RepositoryAdminException( String s )
     {
         super( s );
@@ -65,4 +125,32 @@ public class RepositoryAdminException
     {
         this.fieldName = fieldName;
     }
+
+    public String getKey( )
+    {
+        return key;
+    }
+
+    public void setKey( String key )
+    {
+        this.keyExists=!StringUtils.isEmpty( key );
+        this.key = key;
+    }
+
+    public boolean keyExists() {
+        return this.keyExists;
+    }
+
+    public String[] getParameters( )
+    {
+        return parameters;
+    }
+
+    public void setParameters( String[] parameters )
+    {
+        if (parameters==null) {
+            this.parameters = new String[0];
+        }
+        this.parameters = parameters;
+    }
 }
diff --git 
a/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/beans/RepositoryGroup.java
 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/beans/RepositoryGroup.java
index 74bafcd..d4c0041 100644
--- 
a/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/beans/RepositoryGroup.java
+++ 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/beans/RepositoryGroup.java
@@ -59,6 +59,8 @@ public class RepositoryGroup
      */
     private String cronExpression;
 
+    private String location;
+
     public RepositoryGroup()
     {
         // no op
@@ -184,6 +186,16 @@ public class RepositoryGroup
         return this;
     }
 
+    public String getLocation( )
+    {
+        return location;
+    }
+
+    public void setLocation( String location )
+    {
+        this.location = location;
+    }
+
     @Override
     public boolean equals( Object other )
     {
diff --git 
a/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/group/RepositoryGroupAdmin.java
 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/group/RepositoryGroupAdmin.java
index e98e832..e5411fe 100644
--- 
a/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/group/RepositoryGroupAdmin.java
+++ 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/java/org/apache/archiva/admin/model/group/RepositoryGroupAdmin.java
@@ -19,6 +19,7 @@ package org.apache.archiva.admin.model.group;
  */
 
 import org.apache.archiva.admin.model.AuditInformation;
+import org.apache.archiva.admin.model.EntityNotFoundException;
 import org.apache.archiva.admin.model.RepositoryAdminException;
 import org.apache.archiva.admin.model.beans.RepositoryGroup;
 import org.apache.archiva.repository.storage.StorageAsset;
@@ -35,8 +36,17 @@ public interface RepositoryGroupAdmin
     List<RepositoryGroup> getRepositoriesGroups()
         throws RepositoryAdminException;
 
+    /**
+     * Returns the repository group. If it is not found a {@link 
org.apache.archiva.admin.model.EntityNotFoundException}
+     * will be thrown.
+     *
+     * @param repositoryGroupId the identifier of the repository group
+     * @return the repository group object
+     * @throws RepositoryAdminException
+     * @throws EntityNotFoundException
+     */
     RepositoryGroup getRepositoryGroup( String repositoryGroupId )
-        throws RepositoryAdminException;
+        throws RepositoryAdminException, EntityNotFoundException;
 
     Boolean addRepositoryGroup( RepositoryGroup repositoryGroup, 
AuditInformation auditInformation )
         throws RepositoryAdminException;
diff --git 
a/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/resources/org/apache/archiva/admin/model/error/AdminErrors.properties
 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/resources/org/apache/archiva/admin/model/error/AdminErrors.properties
new file mode 100644
index 0000000..7a9ddf8
--- /dev/null
+++ 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-api/src/main/resources/org/apache/archiva/admin/model/error/AdminErrors.properties
@@ -0,0 +1,27 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+entity.exists=The entity {0} exists already
+entity.not_found=The entity {0} was not found 
+repository_group.id.empty=The repository group id was empty
+repository_group.id.max_length=The id "{0}" of the repository group exceeds 
{1} characters
+repository_group.id.invalid_chars=The repository group id "{0}" contains 
invalid characters. Only the following are allowed: {1}.
+repository_group.merged_index_ttl.min=Merged Index TTL must be greater than 
{0}.
+repository_group.repository.not_found=The member repository with id "{0}" does 
not exist. Cannot be used in a repository group.
+repository_group.registry.add_error=The registry could not add the repository 
"{0}": {1}
+repository_group.registry.update_error=The registry could not update the 
repository "{0}": {1}
diff --git 
a/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-default/src/main/java/org/apache/archiva/admin/repository/group/DefaultRepositoryGroupAdmin.java
 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-default/src/main/java/org/apache/archiva/admin/repository/group/DefaultRepositoryGroupAdmin.java
index 1986c42..3b810c7 100644
--- 
a/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-default/src/main/java/org/apache/archiva/admin/repository/group/DefaultRepositoryGroupAdmin.java
+++ 
b/archiva-modules/archiva-base/archiva-repository-admin/archiva-repository-admin-default/src/main/java/org/apache/archiva/admin/repository/group/DefaultRepositoryGroupAdmin.java
@@ -19,6 +19,8 @@ package org.apache.archiva.admin.repository.group;
  */
 
 import org.apache.archiva.admin.model.AuditInformation;
+import org.apache.archiva.admin.model.EntityExistsException;
+import org.apache.archiva.admin.model.EntityNotFoundException;
 import org.apache.archiva.admin.model.RepositoryAdminException;
 import org.apache.archiva.admin.model.beans.ManagedRepository;
 import org.apache.archiva.admin.model.beans.RepositoryGroup;
@@ -125,8 +127,14 @@ public class DefaultRepositoryGroupAdmin
     }
 
     @Override
-    public RepositoryGroup getRepositoryGroup( String repositoryGroupId ) {
-        return convertRepositoryGroupObject( 
repositoryRegistry.getRepositoryGroup( repositoryGroupId ) );
+    public RepositoryGroup getRepositoryGroup( String repositoryGroupId ) 
throws EntityNotFoundException
+    {
+        org.apache.archiva.repository.RepositoryGroup group = 
repositoryRegistry.getRepositoryGroup( repositoryGroupId );
+        if (group==null) {
+            throw new EntityNotFoundException( "Repository group does not 
exist" );
+        } else {
+            return convertRepositoryGroupObject( group );
+        }
     }
 
     @Override
@@ -146,7 +154,8 @@ public class DefaultRepositoryGroupAdmin
         try {
             
repositoryRegistry.putRepositoryGroup(repositoryGroupConfiguration);
         } catch (RepositoryException e) {
-            e.printStackTrace();
+            log.error( "Could not add the repository group to the registry: 
{}", e.getMessage( ), e );
+            throw RepositoryAdminException.ofKey( 
"repository_group.registry.add_error", e, repositoryGroup.getId(), 
e.getMessage() );
         }
 
         triggerAuditEvent( repositoryGroup.getId(), null, 
AuditEvent.ADD_REPO_GROUP, auditInformation );
@@ -200,7 +209,8 @@ public class DefaultRepositoryGroupAdmin
         try {
             
repositoryRegistry.putRepositoryGroup(repositoryGroupConfiguration);
         } catch (RepositoryException e) {
-            e.printStackTrace();
+            log.error( "Could not update the repository group in the registry: 
{}", e.getMessage( ), e );
+            throw RepositoryAdminException.ofKey( 
"repository_group.registry.update_error", e, repositoryGroup.getId(), 
e.getMessage() );
         }
 
         org.apache.archiva.repository.RepositoryGroup rg = 
repositoryRegistry.getRepositoryGroup( repositoryGroup.getId( ) );
@@ -349,26 +359,24 @@ public class DefaultRepositoryGroupAdmin
         String repoGroupId = repositoryGroup.getId();
         if ( StringUtils.isBlank( repoGroupId ) )
         {
-            throw new RepositoryAdminException( "repositoryGroup id cannot be 
empty" );
+            throw RepositoryAdminException.ofKey("repository_group.id.empty" );
         }
 
         if ( repoGroupId.length() > 100 )
         {
-            throw new RepositoryAdminException(
-                "Identifier [" + repoGroupId + "] is over the maximum limit of 
100 characters" );
+            throw 
RepositoryAdminException.ofKey("repository_group.id.max_length",repoGroupId, 
Integer.toString( 100 ));
 
         }
 
         Matcher matcher = REPO_GROUP_ID_PATTERN.matcher( repoGroupId );
         if ( !matcher.matches() )
         {
-            throw new RepositoryAdminException(
-                "Invalid character(s) found in identifier. Only the following 
characters are allowed: alphanumeric, '.', '-' and '_'" );
+            throw 
RepositoryAdminException.ofKey("repository_group.id.invalid_chars","alphanumeric,
 '.', '-','_'" );
         }
 
         if ( repositoryGroup.getMergedIndexTtl() <= 0 )
         {
-            throw new RepositoryAdminException( "Merged Index TTL must be 
greater than 0." );
+            throw 
RepositoryAdminException.ofKey("repository_group.merged_index_ttl.min","0" );
         }
 
         Configuration configuration = 
getArchivaConfiguration().getConfiguration();
@@ -377,18 +385,18 @@ public class DefaultRepositoryGroupAdmin
         {
             if ( !updateMode )
             {
-                throw new RepositoryAdminException( "Unable to add new 
repository group with id [" + repoGroupId
+                throw new EntityExistsException( "Unable to add new repository 
group with id [" + repoGroupId
                                                         + "], that id already 
exists as a repository group." );
             }
         }
         else if ( configuration.getManagedRepositoriesAsMap().containsKey( 
repoGroupId ) )
         {
-            throw new RepositoryAdminException( "Unable to add new repository 
group with id [" + repoGroupId
+            throw new EntityExistsException( "Unable to add new repository 
group with id [" + repoGroupId
                                                     + "], that id already 
exists as a managed repository." );
         }
         else if ( configuration.getRemoteRepositoriesAsMap().containsKey( 
repoGroupId ) )
         {
-            throw new RepositoryAdminException( "Unable to add new repository 
group with id [" + repoGroupId
+            throw new EntityExistsException( "Unable to add new repository 
group with id [" + repoGroupId
                                                     + "], that id already 
exists as a remote repository." );
         }
 
@@ -402,8 +410,7 @@ public class DefaultRepositoryGroupAdmin
         {
             if ( getManagedRepositoryAdmin().getManagedRepository( id ) == 
null )
             {
-                throw new RepositoryAdminException(
-                    "managedRepository with id " + id + " not exists so cannot 
be used in a repositoryGroup" );
+                throw 
RepositoryAdminException.ofKey("repository_group.repository.not_found",id );
             }
         }
     }
@@ -427,6 +434,7 @@ public class DefaultRepositoryGroupAdmin
         }
         rg.setCronExpression( group.getSchedulingDefinition() );
         rg.setMergedIndexTtl( group.getMergedIndexTTL() );
+        rg.setLocation( group.getLocation().toString() );
         return rg;
     }
 }
diff --git 
a/archiva-modules/archiva-web/archiva-rest/archiva-rest-api/src/main/java/org/apache/archiva/rest/api/model/v2/MergeConfiguration.java
 
b/archiva-modules/archiva-web/archiva-rest/archiva-rest-api/src/main/java/org/apache/archiva/rest/api/model/v2/MergeConfiguration.java
new file mode 100644
index 0000000..c5e60da
--- /dev/null
+++ 
b/archiva-modules/archiva-web/archiva-rest/archiva-rest-api/src/main/java/org/apache/archiva/rest/api/model/v2/MergeConfiguration.java
@@ -0,0 +1,110 @@
+package org.apache.archiva.rest.api.model.v2;/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import javax.xml.bind.annotation.XmlRootElement;
+import java.io.Serializable;
+import java.util.Objects;
+
+import static 
org.apache.archiva.indexer.ArchivaIndexManager.DEFAULT_INDEX_PATH;
+
+/**
+ * Index merge configuration.
+ *
+ * @author Martin Stockhammer <[email protected]>
+ * @since 3.0
+ */
+@XmlRootElement(name="mergeConfiguration")
+@Schema(name="MergeConfiguration", description = "Configuration settings for 
index merge of remote repositories.")
+public class MergeConfiguration implements Serializable
+{
+    private static final long serialVersionUID = -3629274059574459133L;
+
+    private String mergedIndexPath = DEFAULT_INDEX_PATH;
+    private int mergedIndexTtlMinutes = 30;
+    private String indexMergeSchedule = "";
+
+    @Schema(name="merged_index_path", description = "The path where the merged 
index is stored. The path is relative to the repository directory of the 
group.")
+    public String getMergedIndexPath( )
+    {
+        return mergedIndexPath;
+    }
+
+    public void setMergedIndexPath( String mergedIndexPath )
+    {
+        this.mergedIndexPath = mergedIndexPath;
+    }
+
+    @Schema(name="merged_index_ttl_minutes", description = "The Time to Life 
of the merged index in minutes.")
+    public int getMergedIndexTtlMinutes( )
+    {
+        return mergedIndexTtlMinutes;
+    }
+
+    public void setMergedIndexTtlMinutes( int mergedIndexTtlMinutes )
+    {
+        this.mergedIndexTtlMinutes = mergedIndexTtlMinutes;
+    }
+
+    @Schema(name="index_merge_schedule", description = "Cron expression that 
defines the times/intervals for index merging.")
+    public String getIndexMergeSchedule( )
+    {
+        return indexMergeSchedule;
+    }
+
+    public void setIndexMergeSchedule( String indexMergeSchedule )
+    {
+        this.indexMergeSchedule = indexMergeSchedule;
+    }
+
+    @Override
+    public boolean equals( Object o )
+    {
+        if ( this == o ) return true;
+        if ( o == null || getClass( ) != o.getClass( ) ) return false;
+
+        MergeConfiguration that = (MergeConfiguration) o;
+
+        if ( mergedIndexTtlMinutes != that.mergedIndexTtlMinutes ) return 
false;
+        if ( !Objects.equals( mergedIndexPath, that.mergedIndexPath ) )
+            return false;
+        return Objects.equals( indexMergeSchedule, that.indexMergeSchedule );
+    }
+
+    @Override
+    public int hashCode( )
+    {
+        int result = mergedIndexPath != null ? mergedIndexPath.hashCode( ) : 0;
+        result = 31 * result + mergedIndexTtlMinutes;
+        result = 31 * result + ( indexMergeSchedule != null ? 
indexMergeSchedule.hashCode( ) : 0 );
+        return result;
+    }
+
+    @SuppressWarnings( "StringBufferReplaceableByString" )
+    @Override
+    public String toString( )
+    {
+        final StringBuilder sb = new StringBuilder( "MergeConfiguration{" );
+        sb.append( "mergedIndexPath='" ).append( mergedIndexPath ).append( 
'\'' );
+        sb.append( ", mergedIndexTtlMinutes=" ).append( mergedIndexTtlMinutes 
);
+        sb.append( ", indexMergeSchedule='" ).append( indexMergeSchedule 
).append( '\'' );
+        sb.append( '}' );
+        return sb.toString( );
+    }
+}
diff --git 
a/archiva-modules/archiva-web/archiva-rest/archiva-rest-api/src/main/java/org/apache/archiva/rest/api/model/v2/RepositoryGroup.java
 
b/archiva-modules/archiva-web/archiva-rest/archiva-rest-api/src/main/java/org/apache/archiva/rest/api/model/v2/RepositoryGroup.java
new file mode 100644
index 0000000..d550f27
--- /dev/null
+++ 
b/archiva-modules/archiva-web/archiva-rest/archiva-rest-api/src/main/java/org/apache/archiva/rest/api/model/v2/RepositoryGroup.java
@@ -0,0 +1,149 @@
+package org.apache.archiva.rest.api.model.v2;/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import javax.xml.bind.annotation.XmlRootElement;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * @author Martin Stockhammer <[email protected]>
+ */
+@XmlRootElement(name="repositoryGroup")
+@Schema(name="RepositoryGroup", description = "Information about a repository 
group, which combines multiple repositories as one virtual repository.")
+public class RepositoryGroup implements Serializable
+{
+    private static final long serialVersionUID = -7319687481737616081L;
+    private String id;
+    private final List<String> repositories = new ArrayList<>(  );
+    private String location;
+    MergeConfiguration mergeConfiguration;
+
+    public RepositoryGroup( )
+    {
+    }
+
+    public RepositoryGroup(String id) {
+        this.id = id;
+    }
+
+    public static RepositoryGroup of( 
org.apache.archiva.admin.model.beans.RepositoryGroup modelObj ) {
+        RepositoryGroup result = new RepositoryGroup( );
+        MergeConfiguration mergeConfig = new MergeConfiguration( );
+        result.setMergeConfiguration( mergeConfig );
+        result.setId( modelObj.getId() );
+        result.setLocation( modelObj.getLocation() );
+        result.setRepositories( modelObj.getRepositories() );
+        mergeConfig.setMergedIndexPath( modelObj.getMergedIndexPath() );
+        mergeConfig.setMergedIndexTtlMinutes( modelObj.getMergedIndexTtl( ) );
+        mergeConfig.setIndexMergeSchedule( modelObj.getCronExpression( ) );
+        return result;
+    }
+
+    @Schema(description = "The unique id of the repository group.")
+    public String getId( )
+    {
+        return id;
+    }
+
+    public void setId( String id )
+    {
+        this.id = id;
+    }
+
+    @Schema(description = "The list of ids of repositories which are member of 
the repository group.")
+    public List<String> getRepositories( )
+    {
+        return repositories;
+    }
+
+    public void setRepositories( List<String> repositories )
+    {
+        this.repositories.clear();
+        this.repositories.addAll( repositories );
+    }
+
+    public void addRepository(String repositoryId) {
+        if (!this.repositories.contains( repositoryId )) {
+            this.repositories.add( repositoryId );
+        }
+    }
+
+    @Schema(name="merge_configuration",description = "The configuration for 
index merge.")
+    public MergeConfiguration getMergeConfiguration( )
+    {
+        return mergeConfiguration;
+    }
+
+    public void setMergeConfiguration( MergeConfiguration mergeConfiguration )
+    {
+        this.mergeConfiguration = mergeConfiguration;
+    }
+
+    @Schema(description = "The storage location of the repository. The merged 
index is stored relative to this location.")
+    public String getLocation( )
+    {
+        return location;
+    }
+
+    public void setLocation( String location )
+    {
+        this.location = location;
+    }
+
+    @Override
+    public boolean equals( Object o )
+    {
+        if ( this == o ) return true;
+        if ( o == null || getClass( ) != o.getClass( ) ) return false;
+
+        RepositoryGroup that = (RepositoryGroup) o;
+
+        if ( !Objects.equals( id, that.id ) ) return false;
+        if ( !repositories.equals( that.repositories ) )
+            return false;
+        if ( !Objects.equals( location, that.location ) ) return false;
+        return Objects.equals( mergeConfiguration, that.mergeConfiguration );
+    }
+
+    @Override
+    public int hashCode( )
+    {
+        int result = id != null ? id.hashCode( ) : 0;
+        result = 31 * result + repositories.hashCode( );
+        result = 31 * result + ( location != null ? location.hashCode( ) : 0 );
+        result = 31 * result + ( mergeConfiguration != null ? 
mergeConfiguration.hashCode( ) : 0 );
+        return result;
+    }
+
+    @SuppressWarnings( "StringBufferReplaceableByString" )
+    @Override
+    public String toString( )
+    {
+        final StringBuilder sb = new StringBuilder( "RepositoryGroup{" );
+        sb.append( "id='" ).append( id ).append( '\'' );
+        sb.append( ", repositories=" ).append( repositories );
+        sb.append( ", location='" ).append( location ).append( '\'' );
+        sb.append( ", mergeConfiguration=" ).append( mergeConfiguration );
+        sb.append( '}' );
+        return sb.toString( );
+    }
+}
diff --git 
a/archiva-modules/archiva-web/archiva-rest/archiva-rest-api/src/main/java/org/apache/archiva/rest/api/services/v2/RepositoryGroupService.java
 
b/archiva-modules/archiva-web/archiva-rest/archiva-rest-api/src/main/java/org/apache/archiva/rest/api/services/v2/RepositoryGroupService.java
new file mode 100644
index 0000000..7796d1c
--- /dev/null
+++ 
b/archiva-modules/archiva-web/archiva-rest/archiva-rest-api/src/main/java/org/apache/archiva/rest/api/services/v2/RepositoryGroupService.java
@@ -0,0 +1,257 @@
+package org.apache.archiva.rest.api.services.v2;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.headers.Header;
+import io.swagger.v3.oas.annotations.media.Content;
+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.SecurityRequirement;
+import org.apache.archiva.components.rest.model.PagedResult;
+import org.apache.archiva.redback.authorization.RedbackAuthorization;
+import org.apache.archiva.rest.api.model.v2.RepositoryGroup;
+import org.apache.archiva.security.common.ArchivaRoleConstants;
+
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.DefaultValue;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Response;
+import java.util.List;
+
+import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
+import static 
org.apache.archiva.rest.api.services.v2.Configuration.DEFAULT_PAGE_LIMIT;
+
+/**
+ * Endpoint for repository groups that combine multiple repositories into a 
single virtual repository.
+ *
+ * @author Olivier Lamy
+ * @author Martin Stockhammer
+ * @since 3.0
+ */
+@Path( "/repository_groups" )
+@Schema( name="RepositoryGroups", description = "Managing of repository groups 
or virtual repositories")
+public interface RepositoryGroupService
+{
+    @Path( "" )
+    @GET
+    @Produces( { APPLICATION_JSON } )
+    @RedbackAuthorization( permissions = 
ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION )
+    @Operation( summary = "Returns all repository group entries.",
+        parameters = {
+            @Parameter(name = "q", description = "Search term"),
+            @Parameter(name = "offset", description = "The offset of the first 
element returned"),
+            @Parameter(name = "limit", description = "Maximum number of items 
to return in the response"),
+            @Parameter(name = "orderBy", description = "List of attribute used 
for sorting (key, value)"),
+            @Parameter(name = "order", description = "The sort order. Either 
ascending (asc) or descending (desc)")
+        },
+        security = {
+            @SecurityRequirement(
+                name = ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION
+            )
+        },
+        responses = {
+            @ApiResponse( responseCode = "200",
+                description = "If the list could be returned",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = PagedResult.class))
+            ),
+            @ApiResponse( responseCode = "403", description = "Authenticated 
user is not permitted to gather the information",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) )
+        }
+    )
+    PagedResult<RepositoryGroup> getRepositoriesGroups(@QueryParam("q") 
@DefaultValue( "" ) String searchTerm,
+                                                       @QueryParam( "offset" ) 
@DefaultValue( "0" ) Integer offset,
+                                                       @QueryParam( "limit" ) 
@DefaultValue( value = DEFAULT_PAGE_LIMIT ) Integer limit,
+                                                       @QueryParam( "orderBy") 
@DefaultValue( "key" ) List<String> orderBy,
+                                                       @QueryParam("order") 
@DefaultValue( "asc" ) String order)
+        throws ArchivaRestServiceException;
+
+    @Path( "{repositoryGroupId}" )
+    @GET
+    @Produces( { APPLICATION_JSON } )
+    @RedbackAuthorization( permissions = 
ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION )
+    @Operation( summary = "Returns a single repository group configuration.",
+        security = {
+            @SecurityRequirement(
+                name = ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION
+            )
+        },
+        responses = {
+            @ApiResponse( responseCode = "200",
+                description = "If the configuration is returned",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = RepositoryGroup.class))
+            ),
+            @ApiResponse( responseCode = "403", description = "Authenticated 
user is not permitted to gather the information",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) ),
+            @ApiResponse( responseCode = "404", description = "The repository 
group with the given id does not exist",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) )
+        }
+    )
+    RepositoryGroup getRepositoryGroup( @PathParam( "repositoryGroupId" ) 
String repositoryGroupId )
+        throws ArchivaRestServiceException;
+
+    @Path( "" )
+    @POST
+    @Consumes( { APPLICATION_JSON } )
+    @Produces( { APPLICATION_JSON } )
+    @RedbackAuthorization( permissions = 
ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION )
+    @Operation( summary = "Creates a new group entry.",
+        requestBody =
+            @RequestBody(required = true, description = "The configuration of 
the repository group.",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = RepositoryGroup.class))
+            )
+        ,
+        security = {
+            @SecurityRequirement(
+                name = ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION
+            )
+        },
+        responses = {
+            @ApiResponse( responseCode = "201",
+                description = "If the list could be returned",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = RepositoryGroup.class))
+            ),
+            @ApiResponse( responseCode = "303", description = "The repository 
group exists already",
+                headers = {
+                    @Header( name="Location", description = "The URL of 
existing group", schema = @Schema(type="string"))
+                }
+            ),
+            @ApiResponse( responseCode = "403", description = "Authenticated 
user is not permitted to gather the information",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) ),
+            @ApiResponse( responseCode = "422", description = "The body data 
is not valid",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) )
+        }
+    )
+    RepositoryGroup addRepositoryGroup( RepositoryGroup repositoryGroup )
+        throws ArchivaRestServiceException;
+
+    @Path( "{repositoryGroupId}" )
+    @PUT
+    @Consumes( { APPLICATION_JSON } )
+    @Produces( { APPLICATION_JSON } )
+    @RedbackAuthorization( permissions = 
ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION )
+    @Operation( summary = "Returns all repository group entries.",
+        requestBody =
+        @RequestBody(required = true, description = "The configuration of the 
repository group.",
+            content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = RepositoryGroup.class))
+        )
+        ,
+        security = {
+            @SecurityRequirement(
+                name = ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION
+            )
+        },
+        responses = {
+            @ApiResponse( responseCode = "200",
+                description = "If the group is returned",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = RepositoryGroup.class))
+            ),
+            @ApiResponse( responseCode = "403", description = "Authenticated 
user is not permitted to gather the information",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) ),
+            @ApiResponse( responseCode = "404", description = "The group with 
the given id does not exist",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) ),
+            @ApiResponse( responseCode = "422", description = "The body data 
is not valid",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) )
+        }
+    )
+    RepositoryGroup updateRepositoryGroup( @PathParam( "repositoryGroupId" ) 
String groupId, RepositoryGroup repositoryGroup )
+        throws ArchivaRestServiceException;
+
+    @Path( "{repositoryGroupId}" )
+    @DELETE
+    @Produces( { APPLICATION_JSON } )
+    @RedbackAuthorization( permissions = 
ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION )
+    @Operation( summary = "Deletes the repository group entry with the given 
id.",
+        security = {
+            @SecurityRequirement(
+                name = ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION
+            )
+        },
+        responses = {
+            @ApiResponse( responseCode = "200",
+                description = "If the group was deleted"
+            ),
+            @ApiResponse( responseCode = "403", description = "Authenticated 
user is not permitted to delete the group",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) ),
+            @ApiResponse( responseCode = "404", description = "The group with 
the given id does not exist",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) ),
+        }
+    )
+    Response deleteRepositoryGroup( @PathParam( "repositoryGroupId" ) String 
repositoryGroupId )
+        throws ArchivaRestServiceException;
+
+    @Path( "{repositoryGroupId}/repositories/{repositoryId}" )
+    @PUT
+    @Produces( { APPLICATION_JSON } )
+    @RedbackAuthorization( permissions = 
ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION )
+    @Operation( summary = "Adds the repository with the given id to the 
repository group.",
+        security = {
+            @SecurityRequirement(
+                name = ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION
+            )
+        },
+        responses = {
+            @ApiResponse( responseCode = "200",
+                description = "If the repository was added or if it was 
already part of the group"
+            ),
+            @ApiResponse( responseCode = "403", description = "Authenticated 
user is not permitted to delete the group",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) ),
+            @ApiResponse( responseCode = "404", description = "The group with 
the given id does not exist",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) ),
+        }
+    )
+    RepositoryGroup addRepositoryToGroup( @PathParam( "repositoryGroupId" ) 
String repositoryGroupId,
+                                  @PathParam( "repositoryId" ) String 
repositoryId )
+        throws ArchivaRestServiceException;
+
+    @Path( "{repositoryGroupId}/repositories/{repositoryId}" )
+    @DELETE
+    @Produces( { APPLICATION_JSON } )
+    @RedbackAuthorization( permissions = 
ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION )
+    @Operation( summary = "Removes the repository with the given id from the 
repository group.",
+        security = {
+            @SecurityRequirement(
+                name = ArchivaRoleConstants.OPERATION_MANAGE_CONFIGURATION
+            )
+        },
+        responses = {
+            @ApiResponse( responseCode = "200",
+                description = "If the repository was removed."
+            ),
+            @ApiResponse( responseCode = "403", description = "Authenticated 
user is not permitted to delete the group",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) ),
+            @ApiResponse( responseCode = "404", description = "The group with 
the given id does not exist, or the repository was not part of the group.",
+                content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = ArchivaRestError.class )) ),
+        }
+    )
+    RepositoryGroup deleteRepositoryFromGroup( @PathParam( "repositoryGroupId" 
) String repositoryGroupId,
+                                       @PathParam( "repositoryId" ) String 
repositoryId )
+        throws ArchivaRestServiceException;
+
+
+}
diff --git 
a/archiva-modules/archiva-web/archiva-rest/archiva-rest-api/src/main/java/org/apache/archiva/rest/api/services/v2/package-info.java
 
b/archiva-modules/archiva-web/archiva-rest/archiva-rest-api/src/main/java/org/apache/archiva/rest/api/services/v2/package-info.java
new file mode 100644
index 0000000..0cd02b0
--- /dev/null
+++ 
b/archiva-modules/archiva-web/archiva-rest/archiva-rest-api/src/main/java/org/apache/archiva/rest/api/services/v2/package-info.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * <p>This is the V2 REST API of Archiva. It uses JAX-RS annotations for 
defining the endpoints.
+ * The API is documented with OpenApi annotations.</p>
+ *
+ * <h3>Some design principles of the API and classes:</h3>
+ * <ul>
+ *     <li>All services use V2 model classes. Internal models are always 
converted to V2 classes.</li>
+ *     <li>Schema attributes use the snake case syntax (lower case with '_' as 
divider)</li>
+ *     <li>Return code <code>200</code> and <code>201</code> (POST) is used 
for successful execution.</li>
+ *     <li>Return code <code>403</code> is used, if the user has not the 
permission for the action.</li>
+ *     <li>Return code <code>422</code> is used for input that has invalid 
data.</li>
+ * </ul>
+ *
+ * <h4>Querying entity lists</h4>
+ * <p>The main entities of a given path are retrieved on the base path.
+ * Further sub entities or entries may be retrieved via subpaths.
+ * A single entity is returned by the "{id}" path. Be careful with technical 
paths that are parallel to the
+ * id path. Avoid naming conflicts with the id and technical paths.
+ * Entity attributes may be retrieved by "{id}/{attribute}" path or if there 
are lists or collections by
+ * "{id}/mycollection/{subentryid}"</p>
+ *
+ * <ul>
+ *  <li><code>GET</code> method is used for retrieving entities on the base 
path ""</li>
+ *  <li>The query for base entities should always return a paged result and be 
filterable and sortable</li>
+ *  <li>Query parameters for filtering, ordering and limits should be optional 
and proper defaults must be set</li>
+ *  <li>Return code <code>200</code> is used for successful retrieval</li>
+ *  <li>This action is idempotent</li>
+ * </ul>
+ *
+ * <h4>Querying single entities</h4>
+ * <p>Single entities are retrieved on the path "{id}"</p>
+ * <ul>
+ *  <li><code>GET</code> method is used for retrieving a single entity. The id 
is always a path parameter.</li>
+ *  <li>Return code <code>200</code> is used for successful retrieval</li>
+ *  <li>Return code <code>404</code> is used if the entity with the given id 
does not exist</li>
+ *  <li>This action is idempotent</li>
+ * </ul>
+ *
+ * <h4>Creating entities</h4>
+ * <p>The main entities are created on the base path "".</p>
+ * <ul>
+ *     <li><code>POST</code> is used for creating new entities</li>
+ *     <li>The <code>POST</code> body must always have a complete definition 
of the entity.</li>
+ *     <li>A unique <code>id</code> or <code>name</code> attribute is required 
for entities. If the id is generated during POST,
+ *     it must be returned by response body.</li>
+ *     <li>A successful <code>POST</code> request should always return the 
entity definition as it would be returned by the GET request.</li>
+ *     <li>Return code <code>201</code> is used for successful creation of the 
new entity.</li>
+ *     <li>A successful response has a <code>Location</code> header with the 
URL for retrieving the single created entity.</li>
+ *     <li>Return code <code>303</code> is used, if the entity exists 
already</li>
+ *     <li>This action is not idempotent</li>
+ * </ul>
+ *
+ * <h4>Updating entities</h4>
+ * <p>The path for entity update must contain the '{id}' of the entity. The 
path should be the same as for the GET operation.</p>
+ * <ul>
+ *     <li><code>PUT</code> is used for updating existing entities</li>
+ *     <li>The body contains a JSON object. Only existing attributes are 
updated.</li>
+ *     <li>A successful PUT request should return the complete entity 
definition as it would be returned by the GET request.</li>
+ *     <li>Return code <code>200</code> is used for successful update of the 
new entity. Even if nothing changed.</li>
+ *     <li>This action is idempotent</li>
+ * </ul>
+ *
+ * <h4>Deleting entities</h4>
+ * <p>The path for entity deletion must contain the '{id}' of the entity. The 
path should be the same as
+ * for the GET operation.</p>
+ * <ul>
+ *     <li><code>DELETE</code> is used for deleting existing entities</li>
+ *     <li>The successful operation has no request and no response body</li>
+ *     <li>Return code <code>200</code> is used for successful deletion of the 
new entity.</li>
+ *     <li>This action is not idempotent</li>
+ * </ul>
+ *
+ * <h4>Errors</h4>
+ * <ul>
+ *     <li>A error uses a return code <code>>=400</code> </li>
+ *     <li>All errors use the same result object ({@link 
org.apache.archiva.rest.api.services.v2.ArchivaRestError}</li>
+ *     <li>Error messages are returned as keys. Translation is part of the 
client application.</li>
+ * </ul>
+ *
+ * @author Martin Stockhammer <[email protected]>
+ * @since 3.0
+ */
+package org.apache.archiva.rest.api.services.v2;
\ No newline at end of file
diff --git 
a/archiva-modules/archiva-web/archiva-rest/archiva-rest-services/src/main/java/org/apache/archiva/rest/services/v2/DefaultRepositoryGroupService.java
 
b/archiva-modules/archiva-web/archiva-rest/archiva-rest-services/src/main/java/org/apache/archiva/rest/services/v2/DefaultRepositoryGroupService.java
new file mode 100644
index 0000000..1855ea8
--- /dev/null
+++ 
b/archiva-modules/archiva-web/archiva-rest/archiva-rest-services/src/main/java/org/apache/archiva/rest/services/v2/DefaultRepositoryGroupService.java
@@ -0,0 +1,214 @@
+package org.apache.archiva.rest.services.v2;/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import org.apache.archiva.admin.model.AuditInformation;
+import org.apache.archiva.admin.model.EntityExistsException;
+import org.apache.archiva.admin.model.EntityNotFoundException;
+import org.apache.archiva.admin.model.RepositoryAdminException;
+import org.apache.archiva.admin.model.group.RepositoryGroupAdmin;
+import org.apache.archiva.components.rest.model.PagedResult;
+import org.apache.archiva.components.rest.util.PagingHelper;
+import org.apache.archiva.components.rest.util.QueryHelper;
+import 
org.apache.archiva.redback.rest.services.RedbackAuthenticationThreadLocal;
+import org.apache.archiva.redback.rest.services.RedbackRequestInformation;
+import org.apache.archiva.redback.users.User;
+import org.apache.archiva.rest.api.model.v2.RepositoryGroup;
+import org.apache.archiva.rest.api.services.v2.ArchivaRestServiceException;
+import org.apache.archiva.rest.api.services.v2.ErrorMessage;
+import org.apache.archiva.rest.api.services.v2.RepositoryGroupService;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.inject.Inject;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+/**
+ * REST V2 Implementation for repository groups.
+ *
+ * @author Martin Stockhammer <[email protected]>
+ * @since 3.0
+ * @see RepositoryGroupService
+ */
+public class DefaultRepositoryGroupService implements RepositoryGroupService
+{
+    @Context
+    HttpServletResponse httpServletResponse;
+
+    @Context
+    UriInfo uriInfo;
+
+    private static final Logger log = LoggerFactory.getLogger( 
DefaultRepositoryGroupService.class );
+
+    private static final 
QueryHelper<org.apache.archiva.admin.model.beans.RepositoryGroup> QUERY_HELPER 
= new QueryHelper( new String[]{"id"} );
+    private static final PagingHelper PROP_PAGING_HELPER = new PagingHelper( );
+
+    @Inject
+    private RepositoryGroupAdmin repositoryGroupAdmin;
+
+
+    static
+    {
+        QUERY_HELPER.addStringFilter( "id", 
org.apache.archiva.admin.model.beans.RepositoryGroup::getId );
+        QUERY_HELPER.addNullsafeFieldComparator( "id", 
org.apache.archiva.admin.model.beans.RepositoryGroup::getId );
+    }
+
+
+    protected AuditInformation getAuditInformation()
+    {
+        RedbackRequestInformation redbackRequestInformation = 
RedbackAuthenticationThreadLocal.get();
+        User user = redbackRequestInformation == null ? null : 
redbackRequestInformation.getUser();
+        String remoteAddr = redbackRequestInformation == null ? null : 
redbackRequestInformation.getRemoteAddr();
+        return new AuditInformation( user, remoteAddr );
+    }
+
+    @Override
+    public PagedResult<RepositoryGroup> getRepositoriesGroups( String 
searchTerm, Integer offset, Integer limit, List<String> orderBy, String order ) 
throws ArchivaRestServiceException
+    {
+        try
+        {
+            Predicate<org.apache.archiva.admin.model.beans.RepositoryGroup> 
filter = QUERY_HELPER.getQueryFilter( searchTerm );
+            Comparator<org.apache.archiva.admin.model.beans.RepositoryGroup> 
ordering = QUERY_HELPER.getComparator( orderBy, QUERY_HELPER.isAscending( order 
) );
+            int totalCount = Math.toIntExact( 
repositoryGroupAdmin.getRepositoriesGroups( ).stream( ).filter( filter ).count( 
) );
+            List<RepositoryGroup> result = 
repositoryGroupAdmin.getRepositoriesGroups( ).stream( ).filter( filter 
).sorted( ordering ).skip( offset ).limit( limit ).map(
+                RepositoryGroup::of
+            ).collect( Collectors.toList( ) );
+            return new PagedResult<>( totalCount, offset, limit, result );
+        }
+        catch ( RepositoryAdminException e )
+        {
+            log.error( "Repository admin error: {}", e.getMessage( ), e );
+            throw new ArchivaRestServiceException( ErrorMessage.of( 
ErrorKeys.REPOSITORY_ADMIN_ERROR, e.getMessage() ) );
+        } catch ( ArithmeticException e ) {
+            log.error( "Could not convert total count: {}", e.getMessage( ) );
+            throw new ArchivaRestServiceException( ErrorMessage.of( 
ErrorKeys.INVALID_RESULT_SET_ERROR ) );
+        }
+
+    }
+
+    @Override
+    public RepositoryGroup getRepositoryGroup( String repositoryGroupId ) 
throws ArchivaRestServiceException
+    {
+        try
+        {
+            org.apache.archiva.admin.model.beans.RepositoryGroup group = 
repositoryGroupAdmin.getRepositoryGroup( repositoryGroupId );
+            return RepositoryGroup.of( group );
+        }
+        catch ( EntityNotFoundException e ) {
+            throw new ArchivaRestServiceException( ErrorMessage.of( 
ErrorKeys.REPOSITORY_GROUP_NOT_EXIST, repositoryGroupId ), 404 );
+        }
+        catch ( RepositoryAdminException e )
+        {
+            throw new ArchivaRestServiceException( ErrorMessage.of( 
ErrorKeys.REPOSITORY_ADMIN_ERROR, e.getMessage() ));
+        }
+    }
+
+    private org.apache.archiva.admin.model.beans.RepositoryGroup toModel( 
RepositoryGroup group) {
+        org.apache.archiva.admin.model.beans.RepositoryGroup result = new 
org.apache.archiva.admin.model.beans.RepositoryGroup( );
+        result.setId( group.getId( ) );
+        result.setLocation( group.getLocation( ) );
+        result.setRepositories( new ArrayList<>( group.getRepositories( ) ) );
+        result.setMergedIndexPath( 
group.getMergeConfiguration().getMergedIndexPath() );
+        result.setMergedIndexTtl( 
group.getMergeConfiguration().getMergedIndexTtlMinutes() );
+        result.setCronExpression( 
group.getMergeConfiguration().getIndexMergeSchedule() );
+        return result;
+    }
+
+    @Override
+    public RepositoryGroup addRepositoryGroup( RepositoryGroup repositoryGroup 
) throws ArchivaRestServiceException
+    {
+        try
+        {
+            Boolean result = repositoryGroupAdmin.addRepositoryGroup( toModel( 
repositoryGroup ), getAuditInformation( ) );
+            if (result) {
+                org.apache.archiva.admin.model.beans.RepositoryGroup newGroup 
= repositoryGroupAdmin.getRepositoryGroup( repositoryGroup.getId( ) );
+                if (newGroup!=null) {
+                    return RepositoryGroup.of( newGroup );
+                } else {
+                    throw new ArchivaRestServiceException( ErrorMessage.of( 
ErrorKeys.REPOSITORY_GROUP_ADD_FAILED ) );
+                }
+            } else {
+                throw new ArchivaRestServiceException( ErrorMessage.of( 
ErrorKeys.REPOSITORY_GROUP_ADD_FAILED ) );
+            }
+        } catch ( EntityExistsException e ) {
+            httpServletResponse.setHeader( "Location", 
uriInfo.getAbsolutePathBuilder( ).path( repositoryGroup.getId() ).build( 
).toString( ) );
+            throw new ArchivaRestServiceException( ErrorMessage.of( 
ErrorKeys.REPOSITORY_GROUP_EXIST, repositoryGroup.getId( )), 303 );
+        }
+        catch ( RepositoryAdminException e )
+        {
+            if (e.keyExists()) {
+                throw new ArchivaRestServiceException( ErrorMessage.of( 
ErrorKeys.PREFIX+e.getKey(), e.getParameters() ) );
+            } else
+            {
+                throw new ArchivaRestServiceException( ErrorMessage.of( 
ErrorKeys.REPOSITORY_ADMIN_ERROR, e.getMessage( ) ) );
+            }
+        }
+    }
+
+    @Override
+    public RepositoryGroup updateRepositoryGroup( String groupId, 
RepositoryGroup repositoryGroup ) throws ArchivaRestServiceException
+    {
+        org.apache.archiva.admin.model.beans.RepositoryGroup updateGroup = 
toModel( repositoryGroup );
+        try
+        {
+            org.apache.archiva.admin.model.beans.RepositoryGroup originGroup = 
repositoryGroupAdmin.getRepositoryGroup( groupId );
+            if ( StringUtils.isEmpty( updateGroup.getId())) {
+                updateGroup.setId( groupId );
+            }
+            if (StringUtils.isEmpty( updateGroup.getLocation() )) {
+                updateGroup.setLocation( originGroup.getLocation() );
+            }
+            if (StringUtils.isEmpty( updateGroup.getMergedIndexPath() )) {
+                updateGroup.setMergedIndexPath( 
originGroup.getMergedIndexPath() );
+            }
+            repositoryGroupAdmin.updateRepositoryGroup( updateGroup, 
getAuditInformation( ) );
+            return RepositoryGroup.of( 
repositoryGroupAdmin.getRepositoryGroup( groupId ) );
+        }
+        catch ( RepositoryAdminException e )
+        {
+            log.error( "Repository admin error: {}", e.getMessage( ), e );
+            throw new ArchivaRestServiceException( ErrorMessage.of( 
ErrorKeys.REPOSITORY_ADMIN_ERROR, e.getMessage( ) ) );
+        }
+    }
+
+    @Override
+    public Response deleteRepositoryGroup( String repositoryGroupId ) throws 
ArchivaRestServiceException
+    {
+        return null;
+    }
+
+    @Override
+    public RepositoryGroup addRepositoryToGroup( String repositoryGroupId, 
String repositoryId ) throws ArchivaRestServiceException
+    {
+        return null;
+    }
+
+    @Override
+    public RepositoryGroup deleteRepositoryFromGroup( String 
repositoryGroupId, String repositoryId ) throws 
org.apache.archiva.rest.api.services.v2.ArchivaRestServiceException
+    {
+        return null;
+    }
+}
diff --git 
a/archiva-modules/archiva-web/archiva-rest/archiva-rest-services/src/main/java/org/apache/archiva/rest/services/v2/ErrorKeys.java
 
b/archiva-modules/archiva-web/archiva-rest/archiva-rest-services/src/main/java/org/apache/archiva/rest/services/v2/ErrorKeys.java
index 83fec93..9f65161 100644
--- 
a/archiva-modules/archiva-web/archiva-rest/archiva-rest-services/src/main/java/org/apache/archiva/rest/services/v2/ErrorKeys.java
+++ 
b/archiva-modules/archiva-web/archiva-rest/archiva-rest-services/src/main/java/org/apache/archiva/rest/services/v2/ErrorKeys.java
@@ -16,12 +16,19 @@ package org.apache.archiva.rest.services.v2;/*
  * under the License.
  */
 
+import org.apache.archiva.rest.api.services.v2.ErrorMessage;
+
+import java.util.List;
+
 /**
  * @author Martin Stockhammer <[email protected]>
  */
 public interface ErrorKeys
 {
 
+    String PREFIX = "archiva.";
+    String REPOSITORY_GROUP_PREFIX = PREFIX + "repository_group.";
+
     String INVALID_RESULT_SET_ERROR = "archiva.result_set.invalid";
     String REPOSITORY_ADMIN_ERROR = "archiva.repositoryadmin.error";
     String LDAP_CF_INIT_FAILED = "archiva.ldap.cf.init.failed";
@@ -38,4 +45,8 @@ public interface ErrorKeys
 
     String MISSING_DATA = "archiva.missing.data";
 
+    String REPOSITORY_GROUP_NOT_EXIST = REPOSITORY_GROUP_PREFIX+"notexist";
+    String REPOSITORY_GROUP_ADD_FAILED = REPOSITORY_GROUP_PREFIX+"add.failed"  
;
+    String REPOSITORY_GROUP_EXIST = REPOSITORY_GROUP_PREFIX+"exists";
+
 }

Reply via email to