Support creation of accounts via REST

PUT on '/accounts/<username>' creates a new account.
The code from the 'create-account' SSH command was moved to the REST
layer.

Change-Id: Ic8fd9daf9fb64f7918b7f7eb461f8e8e957099a9
Signed-off-by: Edwin Kempin <edwin.kempin@sap.com>
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index f17a741..5addd16 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -35,6 +35,50 @@
   }
 ----
 
+[[create-account]]
+Create Account
+~~~~~~~~~~~~~~
+[verse]
+'PUT /accounts/link:#username[\{username\}]'
+
+Creates a new account.
+
+In the request body additional data for the account can be provided as
+link:#account-input[AccountInput].
+
+.Request
+----
+  PUT /accounts/john HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "name": "John Doe",
+    "email": "john.doe@example.com",
+    "ssh_key": "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA0T...YImydZAw==",
+    "http_password": "19D9aIn7zePb",
+    "groups": [
+      "MyProject-Owners"
+    ]
+  }
+----
+
+As response a detailed link:#account-info[AccountInfo] entity is
+returned that describes the created account.
+
+.Response
+----
+  HTTP/1.1 201 Created
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  {
+    "_account_id": 1000195,
+    "name": "John Doe",
+    "email": "john.doe@example.com"
+  }
+----
+
 [[list-account-capabilities]]
 List Account Capabilities
 ~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -391,6 +435,11 @@
 Identifier of a global capability. Valid values are all field names of
 the link:#capability-info[CapabilityInfo] entity.
 
+[[username]]
+\{username\}
+~~~~~~~~~~~~
+The user name.
+
 
 [[json-entities]]
 JSON Entities
@@ -412,6 +461,26 @@
 Only set if detailed account information is requested.
 |===========================
 
+[[account-input]]
+AccountInput
+~~~~~~~~~~~~
+The `AccountInput` entity contains information for the creation of
+a new account.
+
+[options="header",width="50%",cols="1,^2,4"]
+|============================
+|Field Name     ||Description
+|`username`     |optional|
+The user name. If provided, must match the user name from the URL.
+|`name`         |optional|The full name of the user.
+|`email`        |optional|The email address of the user.
+|`ssh_key`      |optional|The public SSH key of the user.
+|`http_password`|optional|The HTTP password of the user.
+|`groups`       |optional|
+A list of link:rest-api-groups.html#group-id[group IDs] that identify
+the groups to which the user should be added.
+|============================
+
 [[capability-info]]
 CapabilityInfo
 ~~~~~~~~~~~~~~
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
index 674046c..cff3a35 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -34,24 +35,28 @@
 import java.util.Set;
 
 public class AccountsCollection implements
-    RestCollection<TopLevelResource, AccountResource> {
+    RestCollection<TopLevelResource, AccountResource>,
+    AcceptsCreate<TopLevelResource>{
   private final Provider<CurrentUser> self;
   private final AccountResolver resolver;
   private final AccountControl.Factory accountControlFactory;
   private final IdentifiedUser.GenericFactory userFactory;
   private final DynamicMap<RestView<AccountResource>> views;
+  private final CreateAccount.Factory createAccountFactory;
 
   @Inject
   AccountsCollection(Provider<CurrentUser> self,
       AccountResolver resolver,
       AccountControl.Factory accountControlFactory,
       IdentifiedUser.GenericFactory userFactory,
-      DynamicMap<RestView<AccountResource>> views) {
+      DynamicMap<RestView<AccountResource>> views,
+      CreateAccount.Factory createAccountFactory) {
     this.self = self;
     this.resolver = resolver;
     this.accountControlFactory = accountControlFactory;
     this.userFactory = userFactory;
     this.views = views;
+    this.createAccountFactory = createAccountFactory;
   }
 
   @Override
@@ -116,4 +121,10 @@
   public DynamicMap<RestView<AccountResource>> views() {
     return views;
   }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public CreateAccount create(TopLevelResource parent, IdString username) {
+    return createAccountFactory.create(username.get());
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
new file mode 100644
index 0000000..4a1f4db
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
@@ -0,0 +1,200 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed 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.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupDescriptions;
+import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.CreateAccount.Input;
+import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+@RequiresCapability(GlobalCapability.CREATE_ACCOUNT)
+public class CreateAccount implements RestModifyView<TopLevelResource, Input> {
+  public static class Input {
+    @DefaultInput
+    public String username;
+    public String name;
+    public String email;
+    public String sshKey;
+    public String httpPassword;
+    public List<String> groups;
+  }
+
+  public static interface Factory {
+    CreateAccount create(String username);
+  }
+
+  private final ReviewDb db;
+  private final IdentifiedUser currentUser;
+  private final GroupsCollection groupsCollection;
+  private final SshKeyCache sshKeyCache;
+  private final AccountCache accountCache;
+  private final AccountByEmailCache byEmailCache;
+  private final String username;
+
+  @Inject
+  CreateAccount(ReviewDb db, IdentifiedUser currentUser,
+      GroupsCollection groupsCollection, SshKeyCache sshKeyCache,
+      AccountCache accountCache, AccountByEmailCache byEmailCache,
+      @Assisted String username) {
+    this.db = db;
+    this.currentUser = currentUser;
+    this.groupsCollection = groupsCollection;
+    this.sshKeyCache = sshKeyCache;
+    this.accountCache = accountCache;
+    this.byEmailCache = byEmailCache;
+    this.username = username;
+  }
+
+  @Override
+  public Object apply(TopLevelResource rsrc, Input input)
+      throws BadRequestException, ResourceConflictException,
+      UnprocessableEntityException, OrmException {
+    if (input == null) {
+      input = new Input();
+    }
+    if (input.username != null && !username.equals(input.username)) {
+      throw new BadRequestException("username must match URL");
+    }
+
+    if (!username.matches(Account.USER_NAME_PATTERN)) {
+      throw new BadRequestException("Username '" + username + "'"
+          + " must contain only letters, numbers, _, - or .");
+    }
+
+    Set<AccountGroup.Id> groups = parseGroups(input.groups);
+
+    Account.Id id = new Account.Id(db.nextAccountId());
+    AccountSshKey key = createSshKey(id, input.sshKey);
+
+    AccountExternalId extUser =
+        new AccountExternalId(id, new AccountExternalId.Key(
+            AccountExternalId.SCHEME_USERNAME, username));
+
+    if (input.httpPassword != null) {
+      extUser.setPassword(input.httpPassword);
+    }
+
+    if (db.accountExternalIds().get(extUser.getKey()) != null) {
+      throw new ResourceConflictException(
+          "username '" + username + "' already exists");
+    }
+    if (input.email != null
+        && db.accountExternalIds().get(getEmailKey(input.email)) != null) {
+      throw new UnprocessableEntityException(
+          "email '" + input.email + "' already exists");
+    }
+
+    try {
+      db.accountExternalIds().insert(Collections.singleton(extUser));
+    } catch (OrmDuplicateKeyException duplicateKey) {
+      throw new ResourceConflictException(
+          "username '" + username + "' already exists");
+    }
+
+    if (input.email != null) {
+      AccountExternalId extMailto =
+          new AccountExternalId(id, getEmailKey(input.email));
+      extMailto.setEmailAddress(input.email);
+      try {
+        db.accountExternalIds().insert(Collections.singleton(extMailto));
+      } catch (OrmDuplicateKeyException duplicateKey) {
+        try {
+          db.accountExternalIds().delete(Collections.singleton(extUser));
+        } catch (OrmException cleanupError) {
+        }
+        throw new UnprocessableEntityException(
+            "email '" + input.email + "' already exists");
+      }
+    }
+
+    Account a = new Account(id);
+    a.setFullName(input.name);
+    a.setPreferredEmail(input.email);
+    db.accounts().insert(Collections.singleton(a));
+
+    if (key != null) {
+      db.accountSshKeys().insert(Collections.singleton(key));
+    }
+
+    for (AccountGroup.Id groupId : groups) {
+      AccountGroupMember m =
+          new AccountGroupMember(new AccountGroupMember.Key(id, groupId));
+      db.accountGroupMembersAudit().insert(Collections.singleton(
+          new AccountGroupMemberAudit(m, currentUser.getAccountId())));
+      db.accountGroupMembers().insert(Collections.singleton(m));
+    }
+
+    sshKeyCache.evict(username);
+    accountCache.evictByUsername(username);
+    byEmailCache.evict(input.email);
+
+    return Response.created(AccountInfo.parse(a, true));
+  }
+
+  private Set<AccountGroup.Id> parseGroups(List<String> groups)
+      throws UnprocessableEntityException {
+    Set<AccountGroup.Id> groupIds = Sets.newHashSet();
+    if (groups != null) {
+      for (String g : groups) {
+        groupIds.add(GroupDescriptions.toAccountGroup(
+            groupsCollection.parseInternal(g)).getId());
+      }
+    }
+    return groupIds;
+  }
+
+  private AccountSshKey createSshKey(Account.Id id, String sshKey)
+      throws BadRequestException {
+    if (sshKey == null) {
+      return null;
+    }
+    try {
+      return sshKeyCache.create(new AccountSshKey.Id(id, 1), sshKey.trim());
+    } catch (InvalidSshKeyException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+
+  private AccountExternalId.Key getEmailKey(String email) {
+    return new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO, email);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
index 57a4a22..dedcc03 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
@@ -19,6 +19,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
 
 public class Module extends RestApiModule {
   @Override
@@ -29,6 +30,7 @@
     DynamicMap.mapOf(binder(), ACCOUNT_KIND);
     DynamicMap.mapOf(binder(), CAPABILITY_KIND);
 
+    put(ACCOUNT_KIND).to(PutAccount.class);
     get(ACCOUNT_KIND).to(GetAccount.class);
     get(ACCOUNT_KIND, "avatar").to(GetAvatar.class);
     get(ACCOUNT_KIND, "avatar.change.url").to(GetAvatarChangeUrl.class);
@@ -37,5 +39,7 @@
     get(ACCOUNT_KIND, "preferences.diff").to(GetDiffPreferences.class);
     put(ACCOUNT_KIND, "preferences.diff").to(SetDiffPreferences.class);
     get(CAPABILITY_KIND).to(GetCapabilities.CheckOne.class);
+
+    install(new FactoryModuleBuilder().build(CreateAccount.Factory.class));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
new file mode 100644
index 0000000..34e9143
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAccount.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed 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.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.CreateAccount.Input;
+
+public class PutAccount implements RestModifyView<AccountResource, Input> {
+  @Override
+  public Object apply(AccountResource resource, Input input)
+      throws ResourceConflictException {
+    throw new ResourceConflictException("Account \"" + resource.getUser().getNameEmail()
+        + "\" already exists");
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index c209249..6c4b41c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -14,23 +14,16 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.common.base.Function;
+import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.errors.InvalidSshKeyException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
-import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountByEmailCache;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.gerrit.server.account.CreateAccount;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
@@ -42,8 +35,6 @@
 import java.io.InputStreamReader;
 import java.io.UnsupportedEncodingException;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashSet;
 import java.util.List;
 
 /** Create a new user account. **/
@@ -69,94 +60,29 @@
   private String username;
 
   @Inject
-  private IdentifiedUser currentUser;
-
-  @Inject
-  private ReviewDb db;
-
-  @Inject
-  private SshKeyCache sshKeyCache;
-
-  @Inject
-  private AccountCache accountCache;
-
-  @Inject
-  private AccountByEmailCache byEmailCache;
+  private CreateAccount.Factory createAccountFactory;
 
   @Override
-  protected void run() throws OrmException, IOException,
-      InvalidSshKeyException, UnloggedFailure {
-    if (!username.matches(Account.USER_NAME_PATTERN)) {
-      throw die("Username '" + username + "'"
-          + " must contain only letters, numbers, _, - or .");
-    }
-
-    final Account.Id id = new Account.Id(db.nextAccountId());
-    final AccountSshKey key = readSshKey(id);
-
-    AccountExternalId extUser =
-        new AccountExternalId(id, new AccountExternalId.Key(
-            AccountExternalId.SCHEME_USERNAME, username));
-
-    if (httpPassword != null) {
-      extUser.setPassword(httpPassword);
-    }
-
-    if (db.accountExternalIds().get(extUser.getKey()) != null) {
-      throw die("username '" + username + "' already exists");
-    }
-    if (email != null && db.accountExternalIds().get(getEmailKey()) != null) {
-      throw die("email '" + email + "' already exists");
-    }
-
+  protected void run() throws OrmException, IOException, UnloggedFailure {
+    CreateAccount.Input input = new CreateAccount.Input();
+    input.username = username;
+    input.email = email;
+    input.name = fullName;
+    input.sshKey = readSshKey();
+    input.httpPassword = httpPassword;
+    input.groups = Lists.transform(groups, new Function<AccountGroup.Id, String>() {
+      @Override
+      public String apply(AccountGroup.Id id) {
+        return id.toString();
+      }});
     try {
-      db.accountExternalIds().insert(Collections.singleton(extUser));
-    } catch (OrmDuplicateKeyException duplicateKey) {
-      throw die("username '" + username + "' already exists");
+      createAccountFactory.create(username).apply(TopLevelResource.INSTANCE, input);
+    } catch (RestApiException e) {
+      throw die(e.getMessage());
     }
-
-    if (email != null) {
-      AccountExternalId extMailto = new AccountExternalId(id, getEmailKey());
-      extMailto.setEmailAddress(email);
-      try {
-        db.accountExternalIds().insert(Collections.singleton(extMailto));
-      } catch (OrmDuplicateKeyException duplicateKey) {
-        try {
-          db.accountExternalIds().delete(Collections.singleton(extUser));
-        } catch (OrmException cleanupError) {
-        }
-        throw die("email '" + email + "' already exists");
-      }
-    }
-
-    Account a = new Account(id);
-    a.setFullName(fullName);
-    a.setPreferredEmail(email);
-    db.accounts().insert(Collections.singleton(a));
-
-    if (key != null) {
-      db.accountSshKeys().insert(Collections.singleton(key));
-    }
-
-    for (AccountGroup.Id groupId : new HashSet<AccountGroup.Id>(groups)) {
-      AccountGroupMember m =
-          new AccountGroupMember(new AccountGroupMember.Key(id, groupId));
-      db.accountGroupMembersAudit().insert(Collections.singleton( //
-          new AccountGroupMemberAudit(m, currentUser.getAccountId())));
-      db.accountGroupMembers().insert(Collections.singleton(m));
-    }
-
-    sshKeyCache.evict(username);
-    accountCache.evictByUsername(username);
-    byEmailCache.evict(email);
   }
 
-  private AccountExternalId.Key getEmailKey() {
-    return new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO, email);
-  }
-
-  private AccountSshKey readSshKey(final Account.Id id)
-      throws UnsupportedEncodingException, IOException, InvalidSshKeyException {
+  private String readSshKey() throws UnsupportedEncodingException, IOException {
     if (sshKey == null) {
       return null;
     }
@@ -169,6 +95,6 @@
         sshKey += line + "\n";
       }
     }
-    return sshKeyCache.create(new AccountSshKey.Id(id, 1), sshKey.trim());
+    return sshKey;
   }
 }