diff --git a/README.md b/README.md
index d5793309e8..7e3ac32c5c 100644
--- a/README.md
+++ b/README.md
@@ -50,9 +50,13 @@ Note that this project uses Java 17, please ensure that you have the target JDK
You can run your application in dev mode that enables live coding using:
```shell script
+docker run -d --rm -e CLUSTER_NAME=dse-cluster -e CLUSTER_VERSION=6.8 -e ENABLE_AUTH=true -e DEVELOPER_MODE=true -e DS_LICENSE=accept -e DSE=true -p 8081:8081 -p 8091:8091 -p 9042:9042 stargateio/coordinator-dse-68:v2
+
./mvnw compile quarkus:dev
```
+The command above will first spin the single Stargate DSE coordinator in dev that the API would communicate to.
+
> **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at http://localhost:8080/stargate/dev/.
#### Debugging
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/StargateJsonApi.java b/src/main/java/io/stargate/sgv2/jsonapi/StargateJsonApi.java
index 174ce2bb36..da9aa9a9e8 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/StargateJsonApi.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/StargateJsonApi.java
@@ -15,10 +15,18 @@
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme;
+import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@OpenAPIDefinition(
// note that info is defined via the properties
info = @Info(title = "", version = ""),
+ tags = {
+ @Tag(name = "General", description = "Executes general commands."),
+ @Tag(name = "Databases", description = "Executes database commands."),
+ @Tag(
+ name = "Documents",
+ description = "Executes document commands against a single collection."),
+ },
components =
@Components(
@@ -153,6 +161,34 @@
}
}
"""),
+ @ExampleObject(
+ name = "createDatabase",
+ summary = "`CreateDatabase` command",
+ value =
+ """
+ {
+ "createDatabase": {
+ "name": "cycling"
+ }
+ }
+ """),
+ @ExampleObject(
+ name = "createDatabaseWithReplication",
+ summary = "`CreateDatabase` command with replication",
+ value =
+ """
+ {
+ "createDatabase": {
+ "name": "cycling",
+ "options": {
+ "replication": {
+ "class": "SimpleStrategy",
+ "replication_factor": 3
+ }
+ }
+ }
+ }
+ """),
@ExampleObject(
name = "createCollection",
summary = "`CreateCollection` command",
@@ -266,7 +302,7 @@
}
"""),
@ExampleObject(
- name = "resultCreateCollection",
+ name = "resultCreate",
summary = "Create result",
value =
"""
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CollectionCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CollectionCommand.java
new file mode 100644
index 0000000000..21fa4cd264
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CollectionCommand.java
@@ -0,0 +1,4 @@
+package io.stargate.sgv2.jsonapi.api.model.command;
+
+/** Interface for all commands executed against a collection in a namespace (database). */
+public interface CollectionCommand extends Command {}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/Command.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/Command.java
index c6c73c67f4..345882f59a 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/Command.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/Command.java
@@ -3,6 +3,7 @@
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateCollectionCommand;
+import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateDatabaseCommand;
import io.stargate.sgv2.jsonapi.api.model.command.impl.DeleteOneCommand;
import io.stargate.sgv2.jsonapi.api.model.command.impl.FindCommand;
import io.stargate.sgv2.jsonapi.api.model.command.impl.FindOneAndUpdateCommand;
@@ -33,6 +34,7 @@
include = JsonTypeInfo.As.WRAPPER_OBJECT,
property = "commandName")
@JsonSubTypes({
+ @JsonSubTypes.Type(value = CreateDatabaseCommand.class),
@JsonSubTypes.Type(value = CreateCollectionCommand.class),
@JsonSubTypes.Type(value = DeleteOneCommand.class),
@JsonSubTypes.Type(value = FindCommand.class),
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandContext.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandContext.java
index 0ef8b3ff51..139aae0baf 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandContext.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandContext.java
@@ -6,4 +6,15 @@
* @param database The name of the database.
* @param collection The name of the collection.
*/
-public record CommandContext(String database, String collection) {}
+public record CommandContext(String database, String collection) {
+
+ private static final CommandContext EMPTY = new CommandContext(null, null);
+
+ /**
+ * @return Returns empty command context, having both {@link #database} and {@link #collection} as
+ * null
.
+ */
+ public static CommandContext empty() {
+ return EMPTY;
+ }
+}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/DatabaseCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/DatabaseCommand.java
new file mode 100644
index 0000000000..7dcfc6d4a8
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/DatabaseCommand.java
@@ -0,0 +1,4 @@
+package io.stargate.sgv2.jsonapi.api.model.command;
+
+/** Interface for all commands executed against a namespace (database). */
+public interface DatabaseCommand extends Command {}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/GeneralCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/GeneralCommand.java
new file mode 100644
index 0000000000..1c0d152816
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/GeneralCommand.java
@@ -0,0 +1,7 @@
+package io.stargate.sgv2.jsonapi.api.model.command;
+
+/**
+ * Interface for all general commands, that are not executed against a namespace (database) nor a
+ * collection .
+ */
+public interface GeneralCommand extends Command {}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/ModifyCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/ModifyCommand.java
index d15021a963..2a02b1f8cb 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/ModifyCommand.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/ModifyCommand.java
@@ -1,4 +1,4 @@
package io.stargate.sgv2.jsonapi.api.model.command;
/** Base for any commands that modify, such as insert, delete, update, etc. */
-public interface ModifyCommand extends Command {}
+public interface ModifyCommand extends CollectionCommand {}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/ReadCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/ReadCommand.java
index d5c6ee5e00..39a2205355 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/ReadCommand.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/ReadCommand.java
@@ -1,4 +1,4 @@
package io.stargate.sgv2.jsonapi.api.model.command;
/** Basic interface for all read commands. */
-public interface ReadCommand extends Command {}
+public interface ReadCommand extends CollectionCommand {}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/SchemaChangeCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/SchemaChangeCommand.java
deleted file mode 100644
index 2f2b72aaa5..0000000000
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/SchemaChangeCommand.java
+++ /dev/null
@@ -1,3 +0,0 @@
-package io.stargate.sgv2.jsonapi.api.model.command;
-
-public interface SchemaChangeCommand extends Command {}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateCollectionCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateCollectionCommand.java
index 97127fe8af..8c47b50f7c 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateCollectionCommand.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateCollectionCommand.java
@@ -1,7 +1,7 @@
package io.stargate.sgv2.jsonapi.api.model.command.impl;
import com.fasterxml.jackson.annotation.JsonTypeName;
-import io.stargate.sgv2.jsonapi.api.model.command.SchemaChangeCommand;
+import io.stargate.sgv2.jsonapi.api.model.command.DatabaseCommand;
import javax.annotation.Nullable;
import javax.validation.constraints.NotNull;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
@@ -17,6 +17,6 @@ public record CreateCollectionCommand(
type = SchemaType.OBJECT)
String name,
@Nullable Options options)
- implements SchemaChangeCommand {
+ implements DatabaseCommand {
public record Options() {}
}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateDatabaseCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateDatabaseCommand.java
new file mode 100644
index 0000000000..e36d92eb87
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateDatabaseCommand.java
@@ -0,0 +1,58 @@
+package io.stargate.sgv2.jsonapi.api.model.command.impl;
+
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import io.stargate.sgv2.jsonapi.api.model.command.GeneralCommand;
+import java.util.Map;
+import javax.annotation.Nullable;
+import javax.validation.Valid;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Pattern;
+import javax.validation.constraints.Size;
+import org.eclipse.microprofile.openapi.annotations.media.Schema;
+
+@Schema(description = "Command that creates a namespace (database).")
+@JsonTypeName("createDatabase")
+public record CreateDatabaseCommand(
+ @NotBlank @Size(min = 1, max = 48) @Schema(description = "Name of the database") String name,
+ @Nullable @Valid CreateDatabaseCommand.Options options)
+ implements GeneralCommand {
+
+ @Schema(
+ name = "CreateDatabaseCommand.Options",
+ description = "Options for creating a new database.")
+ public record Options(@Nullable @Valid Replication replication) {}
+
+ /**
+ * Replication options for the create namespace.
+ *
+ * @param strategy Cassandra keyspace strategy class name to use (SimpleStrategy or
+ * NetworkTopologyStrategy).
+ * @param strategyOptions Options for each strategy. For SimpleStrategy
,
+ * `replication_factor` is optional. For the NetworkTopologyStrategy
each data
+ * center with replication.
+ */
+ @Schema(description = "Cassandra based replication settings.")
+ // no record due to the @JsonAnySetter, see
+ // https://github.com/FasterXML/jackson-databind/issues/562
+ public static class Replication {
+ @NotNull()
+ @Pattern(regexp = "SimpleStrategy|NetworkTopologyStrategy")
+ @JsonProperty("class")
+ private String strategy;
+
+ @JsonAnySetter
+ @Schema(hidden = true)
+ private Map strategyOptions;
+
+ public String strategy() {
+ return strategy;
+ }
+
+ public Map strategyOptions() {
+ return strategyOptions;
+ }
+ }
+}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/CollectionResource.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/CollectionResource.java
index 3de88604de..ac93f271ae 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/CollectionResource.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/CollectionResource.java
@@ -1,7 +1,7 @@
package io.stargate.sgv2.jsonapi.api.v1;
import io.smallrye.mutiny.Uni;
-import io.stargate.sgv2.jsonapi.api.model.command.Command;
+import io.stargate.sgv2.jsonapi.api.model.command.CollectionCommand;
import io.stargate.sgv2.jsonapi.api.model.command.CommandContext;
import io.stargate.sgv2.jsonapi.api.model.command.CommandResult;
import io.stargate.sgv2.jsonapi.api.model.command.impl.DeleteOneCommand;
@@ -39,7 +39,7 @@
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@SecurityRequirement(name = OpenApiConstants.SecuritySchemes.TOKEN)
-@Tag(name = "Documents", description = "Executes document commands against a single collection.")
+@Tag(ref = "Documents")
public class CollectionResource {
public static final String BASE_PATH = "/v1/{database}/{collection}";
@@ -102,7 +102,7 @@ public CollectionResource(CommandProcessor commandProcessor) {
})))
@POST
public Uni> postCommand(
- @NotNull @Valid Command command,
+ @NotNull @Valid CollectionCommand command,
@PathParam("database") String database,
@PathParam("collection") String collection) {
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/DatabaseResource.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/DatabaseResource.java
index db30ef8add..12cfd4e672 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/DatabaseResource.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/DatabaseResource.java
@@ -3,7 +3,7 @@
import io.smallrye.mutiny.Uni;
import io.stargate.sgv2.jsonapi.api.model.command.CommandContext;
import io.stargate.sgv2.jsonapi.api.model.command.CommandResult;
-import io.stargate.sgv2.jsonapi.api.model.command.SchemaChangeCommand;
+import io.stargate.sgv2.jsonapi.api.model.command.DatabaseCommand;
import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateCollectionCommand;
import io.stargate.sgv2.jsonapi.config.constants.OpenApiConstants;
import io.stargate.sgv2.jsonapi.service.processor.CommandProcessor;
@@ -33,7 +33,7 @@
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@SecurityRequirement(name = OpenApiConstants.SecuritySchemes.TOKEN)
-@Tag(name = "Databases", description = "Executes database commands.")
+@Tag(ref = "Databases")
public class DatabaseResource {
public static final String BASE_PATH = "/v1/{database}";
@@ -67,12 +67,12 @@ public DatabaseResource(CommandProcessor commandProcessor) {
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = CommandResult.class),
examples = {
- @ExampleObject(ref = "resultCreateCollection"),
+ @ExampleObject(ref = "resultCreate"),
@ExampleObject(ref = "resultError"),
})))
@POST
public Uni> postCommand(
- @NotNull @Valid SchemaChangeCommand command, @PathParam("database") String database) {
+ @NotNull @Valid DatabaseCommand command, @PathParam("database") String database) {
// create context
CommandContext commandContext = new CommandContext(database, null);
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/GeneralResource.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/GeneralResource.java
new file mode 100644
index 0000000000..2ff865a82c
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/GeneralResource.java
@@ -0,0 +1,77 @@
+package io.stargate.sgv2.jsonapi.api.v1;
+
+import io.smallrye.mutiny.Uni;
+import io.stargate.sgv2.jsonapi.api.model.command.CommandContext;
+import io.stargate.sgv2.jsonapi.api.model.command.CommandResult;
+import io.stargate.sgv2.jsonapi.api.model.command.GeneralCommand;
+import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateDatabaseCommand;
+import io.stargate.sgv2.jsonapi.config.constants.OpenApiConstants;
+import io.stargate.sgv2.jsonapi.service.processor.CommandProcessor;
+import javax.inject.Inject;
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import org.eclipse.microprofile.openapi.annotations.Operation;
+import org.eclipse.microprofile.openapi.annotations.media.Content;
+import org.eclipse.microprofile.openapi.annotations.media.ExampleObject;
+import org.eclipse.microprofile.openapi.annotations.media.Schema;
+import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody;
+import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
+import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
+import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
+import org.eclipse.microprofile.openapi.annotations.tags.Tag;
+import org.jboss.resteasy.reactive.RestResponse;
+
+@Path(GeneralResource.BASE_PATH)
+@Produces(MediaType.APPLICATION_JSON)
+@Consumes(MediaType.APPLICATION_JSON)
+@SecurityRequirement(name = OpenApiConstants.SecuritySchemes.TOKEN)
+@Tag(ref = "General")
+public class GeneralResource {
+
+ public static final String BASE_PATH = "/v1";
+
+ private final CommandProcessor commandProcessor;
+
+ @Inject
+ public GeneralResource(CommandProcessor commandProcessor) {
+ this.commandProcessor = commandProcessor;
+ }
+
+ @Operation(summary = "Execute command", description = "Executes a single general command.")
+ @RequestBody(
+ content =
+ @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ schema = @Schema(anyOf = {CreateDatabaseCommand.class}),
+ examples = {
+ @ExampleObject(ref = "createDatabase"),
+ @ExampleObject(ref = "createDatabaseWithReplication"),
+ }))
+ @APIResponses(
+ @APIResponse(
+ responseCode = "200",
+ description =
+ "Call successful. Returns result of the command execution. Note that in case of errors, response code remains `HTTP 200`.",
+ content =
+ @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ schema = @Schema(implementation = CommandResult.class),
+ examples = {
+ @ExampleObject(ref = "resultCreate"),
+ @ExampleObject(ref = "resultError"),
+ })))
+ @POST
+ public Uni> postCommand(@NotNull @Valid GeneralCommand command) {
+
+ // call processor
+ return commandProcessor
+ .processCommand(CommandContext.empty(), command)
+ // map to 2xx always
+ .map(RestResponse::ok);
+ }
+}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/CreateDatabaseOperation.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/CreateDatabaseOperation.java
new file mode 100644
index 0000000000..c0f6048b51
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/model/impl/CreateDatabaseOperation.java
@@ -0,0 +1,39 @@
+package io.stargate.sgv2.jsonapi.service.operation.model.impl;
+
+import io.smallrye.mutiny.Uni;
+import io.stargate.bridge.proto.QueryOuterClass;
+import io.stargate.sgv2.jsonapi.api.model.command.CommandResult;
+import io.stargate.sgv2.jsonapi.service.bridge.executor.QueryExecutor;
+import io.stargate.sgv2.jsonapi.service.operation.model.Operation;
+import java.util.function.Supplier;
+
+/**
+ * Operation that creates a new Cassandra keyspace that serves as a namespace (database) for the
+ * JSON API.
+ *
+ * @param name Name of the namespace to create.
+ * @param replicationMap A replication json, see
+ * https://docs.datastax.com/en/cql-oss/3.3/cql/cql_reference/cqlCreateKeyspace.html#Table2.Replicationstrategyclassandfactorsettings.
+ */
+public record CreateDatabaseOperation(String name, String replicationMap) implements Operation {
+
+ // simple pattern for the cql
+ private static final String CREATE_KEYSPACE_CQL =
+ "CREATE KEYSPACE IF NOT EXISTS \"%s\" WITH REPLICATION = %s;\n";
+
+ /** {@inheritDoc} */
+ @Override
+ public Uni> execute(QueryExecutor queryExecutor) {
+ QueryOuterClass.Query query =
+ QueryOuterClass.Query.newBuilder()
+ .setCql(String.format(CREATE_KEYSPACE_CQL, name, replicationMap))
+ .build();
+
+ // execute
+ return queryExecutor
+ .executeSchemaChange(query)
+
+ // if we have a result always respond positively
+ .map(any -> new SchemaChangeResult(true));
+ }
+}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/model/impl/CreateDatabaseResolver.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/model/impl/CreateDatabaseResolver.java
new file mode 100644
index 0000000000..c0582cec34
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/model/impl/CreateDatabaseResolver.java
@@ -0,0 +1,70 @@
+package io.stargate.sgv2.jsonapi.service.resolver.model.impl;
+
+import io.stargate.sgv2.jsonapi.api.model.command.CommandContext;
+import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateDatabaseCommand;
+import io.stargate.sgv2.jsonapi.service.operation.model.Operation;
+import io.stargate.sgv2.jsonapi.service.operation.model.impl.CreateDatabaseOperation;
+import io.stargate.sgv2.jsonapi.service.resolver.model.CommandResolver;
+import java.util.Map;
+import javax.enterprise.context.ApplicationScoped;
+
+/**
+ * Command resolver for {@link CreateDatabaseCommand}. Responsible for creating the replication map.
+ */
+@ApplicationScoped
+public class CreateDatabaseResolver implements CommandResolver {
+
+ // default if omitted
+ private static final String DEFAULT_REPLICATION_MAP =
+ "{'class': 'SimpleStrategy', 'replication_factor': 1}";
+
+ /** {@inheritDoc} */
+ @Override
+ public Class getCommandClass() {
+ return CreateDatabaseCommand.class;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Operation resolveCommand(CommandContext ctx, CreateDatabaseCommand command) {
+ String replicationMap = getReplicationMap(command.options());
+ return new CreateDatabaseOperation(command.name(), replicationMap);
+ }
+
+ // resolve the replication map
+ private String getReplicationMap(CreateDatabaseCommand.Options options) {
+ if (null == options) {
+ return DEFAULT_REPLICATION_MAP;
+ }
+
+ CreateDatabaseCommand.Replication replication = options.replication();
+ if ("NetworkTopologyStrategy".equals(replication.strategy())) {
+ return networkTopologyStrategyMap(replication);
+ } else {
+ return simpleStrategyMap(replication);
+ }
+ }
+
+ private static String networkTopologyStrategyMap(CreateDatabaseCommand.Replication replication) {
+ Map options = replication.strategyOptions();
+
+ StringBuilder map = new StringBuilder("{'class': 'NetworkTopologyStrategy'");
+ if (null != options) {
+ for (Map.Entry dcEntry : options.entrySet()) {
+ map.append(", '%s': %d".formatted(dcEntry.getKey(), dcEntry.getValue()));
+ }
+ }
+ map.append("}");
+ return map.toString();
+ }
+
+ private static String simpleStrategyMap(CreateDatabaseCommand.Replication replication) {
+ Map options = replication.strategyOptions();
+ if (null == options || options.isEmpty()) {
+ return DEFAULT_REPLICATION_MAP;
+ }
+
+ Integer replicationFactor = options.getOrDefault("replication_factor", 1);
+ return "{'class': 'SimpleStrategy', 'replication_factor': " + replicationFactor + "}";
+ }
+}
diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateDatabaseCommandTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateDatabaseCommandTest.java
new file mode 100644
index 0000000000..a1323ff836
--- /dev/null
+++ b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/impl/CreateDatabaseCommandTest.java
@@ -0,0 +1,117 @@
+package io.stargate.sgv2.jsonapi.api.model.command.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.TestProfile;
+import io.stargate.sgv2.common.testprofiles.NoGlobalResourcesTestProfile;
+import java.util.Set;
+import javax.inject.Inject;
+import javax.validation.ConstraintViolation;
+import javax.validation.Validator;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
+
+@QuarkusTest
+@TestProfile(NoGlobalResourcesTestProfile.Impl.class)
+class CreateDatabaseCommandTest {
+
+ @Inject ObjectMapper objectMapper;
+
+ @Inject Validator validator;
+
+ @Nested
+ class Validation {
+
+ @Test
+ public void noName() throws Exception {
+ String json =
+ """
+ {
+ "createDatabase": {
+ }
+ }
+ """;
+
+ CreateDatabaseCommand command = objectMapper.readValue(json, CreateDatabaseCommand.class);
+ Set> result = validator.validate(command);
+
+ assertThat(result)
+ .isNotEmpty()
+ .extracting(ConstraintViolation::getMessage)
+ .contains("must not be blank");
+ }
+
+ @Test
+ public void nameTooLong() throws Exception {
+ String json =
+ """
+ {
+ "createDatabase": {
+ "name": "%s"
+ }
+ }
+ """
+ .formatted(RandomStringUtils.randomAlphabetic(49));
+
+ CreateDatabaseCommand command = objectMapper.readValue(json, CreateDatabaseCommand.class);
+ Set> result = validator.validate(command);
+
+ assertThat(result)
+ .isNotEmpty()
+ .extracting(ConstraintViolation::getMessage)
+ .contains("size must be between 1 and 48");
+ }
+
+ @Test
+ public void strategyNull() throws Exception {
+ String json =
+ """
+ {
+ "createDatabase": {
+ "name": "red_star_belgrade",
+ "options": {
+ "replication": {
+ }
+ }
+ }
+ }
+ """;
+
+ CreateDatabaseCommand command = objectMapper.readValue(json, CreateDatabaseCommand.class);
+ Set> result = validator.validate(command);
+
+ assertThat(result)
+ .isNotEmpty()
+ .extracting(ConstraintViolation::getMessage)
+ .contains("must not be null");
+ }
+
+ @Test
+ public void strategyWrong() throws Exception {
+ String json =
+ """
+ {
+ "createDatabase": {
+ "name": "red_star_belgrade",
+ "options": {
+ "replication": {
+ "class": "MyClass"
+ }
+ }
+ }
+ }
+ """;
+
+ CreateDatabaseCommand command = objectMapper.readValue(json, CreateDatabaseCommand.class);
+ Set> result = validator.validate(command);
+
+ assertThat(result)
+ .isNotEmpty()
+ .extracting(ConstraintViolation::getMessage)
+ .contains("must match \"SimpleStrategy|NetworkTopologyStrategy\"");
+ }
+ }
+}
diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/GeneralResourceIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/GeneralResourceIntegrationTest.java
new file mode 100644
index 0000000000..cde95cb3af
--- /dev/null
+++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/GeneralResourceIntegrationTest.java
@@ -0,0 +1,187 @@
+package io.stargate.sgv2.jsonapi.api.v1;
+
+import static io.restassured.RestAssured.given;
+import static io.stargate.sgv2.common.IntegrationTestUtils.getAuthToken;
+import static org.hamcrest.Matchers.blankString;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+
+import io.quarkus.test.common.QuarkusTestResource;
+import io.quarkus.test.junit.QuarkusIntegrationTest;
+import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
+import io.stargate.sgv2.api.common.config.constants.HttpConstants;
+import io.stargate.sgv2.common.CqlEnabledIntegrationTestBase;
+import io.stargate.sgv2.common.testresource.StargateTestResource;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+@QuarkusIntegrationTest
+@QuarkusTestResource(StargateTestResource.class)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class GeneralResourceIntegrationTest extends CqlEnabledIntegrationTestBase {
+
+ static String DB_NAME = "stargate";
+
+ @BeforeAll
+ public static void enableLog() {
+ RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
+ }
+
+ @AfterEach
+ public void deleteKeyspace() {
+ this.session.execute("DROP KEYSPACE IF EXISTS %s".formatted(DB_NAME));
+ }
+
+ @Nested
+ class CreateDatabase {
+
+ @Test
+ public final void happyPath() {
+ String json =
+ """
+ {
+ "createDatabase": {
+ "name": "%s"
+ }
+ }
+ """
+ .formatted(DB_NAME);
+
+ given()
+ .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken())
+ .contentType(ContentType.JSON)
+ .body(json)
+ .when()
+ .post(GeneralResource.BASE_PATH)
+ .then()
+ .statusCode(200)
+ .body("status.ok", is(1));
+ }
+
+ @Test
+ public final void alreadyExists() {
+ String json =
+ """
+ {
+ "createDatabase": {
+ "name": "%s"
+ }
+ }
+ """
+ .formatted(keyspaceId.asInternal());
+
+ given()
+ .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken())
+ .contentType(ContentType.JSON)
+ .body(json)
+ .when()
+ .post(GeneralResource.BASE_PATH)
+ .then()
+ .statusCode(200)
+ .body("status.ok", is(1));
+ }
+
+ @Test
+ public final void withReplicationFactor() {
+ String json =
+ """
+ {
+ "createDatabase": {
+ "name": "%s",
+ "options": {
+ "replication": {
+ "class": "SimpleStrategy",
+ "replication_factor": 2
+ }
+ }
+ }
+ }
+ """
+ .formatted(DB_NAME);
+
+ given()
+ .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken())
+ .contentType(ContentType.JSON)
+ .body(json)
+ .when()
+ .post(GeneralResource.BASE_PATH)
+ .then()
+ .statusCode(200)
+ .body("status.ok", is(1));
+ }
+
+ @Test
+ public void invalidCommand() {
+ String json =
+ """
+ {
+ "createDatabase": {
+ }
+ }
+ """;
+
+ given()
+ .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken())
+ .contentType(ContentType.JSON)
+ .body(json)
+ .when()
+ .post(GeneralResource.BASE_PATH)
+ .then()
+ .statusCode(200)
+ .body("errors[0].message", is(not(blankString())))
+ .body("errors[0].exceptionClass", is("ConstraintViolationException"));
+ }
+ }
+
+ @Nested
+ class ClientErrors {
+
+ @Test
+ public void tokenMissing() {
+ given()
+ .contentType(ContentType.JSON)
+ .body("{}")
+ .when()
+ .post(GeneralResource.BASE_PATH)
+ .then()
+ .statusCode(200)
+ .body(
+ "errors[0].message",
+ is(
+ "Role unauthorized for operation: Missing token, expecting one in the X-Cassandra-Token header."));
+ }
+
+ @Test
+ public void malformedBody() {
+ given()
+ .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken())
+ .contentType(ContentType.JSON)
+ .body("{wrong}")
+ .when()
+ .post(GeneralResource.BASE_PATH)
+ .then()
+ .statusCode(200)
+ .body("errors[0].message", is(not(blankString())))
+ .body("errors[0].exceptionClass", is("WebApplicationException"))
+ .body("errors[1].message", is(not(blankString())))
+ .body("errors[1].exceptionClass", is("JsonParseException"));
+ }
+
+ @Test
+ public void emptyBody() {
+ given()
+ .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken())
+ .contentType(ContentType.JSON)
+ .when()
+ .post(GeneralResource.BASE_PATH)
+ .then()
+ .statusCode(200)
+ .body("errors[0].message", is(not(blankString())))
+ .body("errors[0].exceptionClass", is("ConstraintViolationException"));
+ }
+ }
+}
diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/model/impl/CreateDatabaseResolverTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/model/impl/CreateDatabaseResolverTest.java
new file mode 100644
index 0000000000..834648e29e
--- /dev/null
+++ b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/model/impl/CreateDatabaseResolverTest.java
@@ -0,0 +1,171 @@
+package io.stargate.sgv2.jsonapi.service.resolver.model.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.TestProfile;
+import io.stargate.sgv2.common.testprofiles.NoGlobalResourcesTestProfile;
+import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateDatabaseCommand;
+import io.stargate.sgv2.jsonapi.service.operation.model.Operation;
+import io.stargate.sgv2.jsonapi.service.operation.model.impl.CreateDatabaseOperation;
+import javax.inject.Inject;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+@QuarkusTest
+@TestProfile(NoGlobalResourcesTestProfile.Impl.class)
+class CreateDatabaseResolverTest {
+
+ @Inject ObjectMapper objectMapper;
+ @Inject CreateDatabaseResolver resolver;
+
+ @Nested
+ class ResolveCommand {
+
+ @Test
+ public void noOptions() throws Exception {
+ String json =
+ """
+ {
+ "createDatabase": {
+ "name" : "red_star_belgrade"
+ }
+ }
+ """;
+
+ CreateDatabaseCommand command = objectMapper.readValue(json, CreateDatabaseCommand.class);
+ Operation result = resolver.resolveCommand(null, command);
+
+ assertThat(result)
+ .isInstanceOfSatisfying(
+ CreateDatabaseOperation.class,
+ op -> {
+ assertThat(op.name()).isEqualTo("red_star_belgrade");
+ assertThat(op.replicationMap())
+ .isEqualTo("{'class': 'SimpleStrategy', 'replication_factor': 1}");
+ });
+ }
+
+ @Test
+ public void simpleStrategy() throws Exception {
+ String json =
+ """
+ {
+ "createDatabase": {
+ "name" : "red_star_belgrade",
+ "options": {
+ "replication": {
+ "class": "SimpleStrategy"
+ }
+ }
+ }
+ }
+ """;
+
+ CreateDatabaseCommand command = objectMapper.readValue(json, CreateDatabaseCommand.class);
+ Operation result = resolver.resolveCommand(null, command);
+
+ assertThat(result)
+ .isInstanceOfSatisfying(
+ CreateDatabaseOperation.class,
+ op -> {
+ assertThat(op.name()).isEqualTo("red_star_belgrade");
+ assertThat(op.replicationMap())
+ .isEqualTo("{'class': 'SimpleStrategy', 'replication_factor': 1}");
+ });
+ }
+
+ @Test
+ public void simpleStrategyWithReplication() throws Exception {
+ String json =
+ """
+ {
+ "createDatabase": {
+ "name" : "red_star_belgrade",
+ "options": {
+ "replication": {
+ "class": "SimpleStrategy",
+ "replication_factor": 2
+ }
+ }
+ }
+ }
+ """;
+
+ CreateDatabaseCommand command = objectMapper.readValue(json, CreateDatabaseCommand.class);
+ Operation result = resolver.resolveCommand(null, command);
+
+ assertThat(result)
+ .isInstanceOfSatisfying(
+ CreateDatabaseOperation.class,
+ op -> {
+ assertThat(op.name()).isEqualTo("red_star_belgrade");
+ assertThat(op.replicationMap())
+ .isEqualTo("{'class': 'SimpleStrategy', 'replication_factor': 2}");
+ });
+ }
+
+ @Test
+ public void networkTopologyStrategy() throws Exception {
+ String json =
+ """
+ {
+ "createDatabase": {
+ "name" : "red_star_belgrade",
+ "options": {
+ "replication": {
+ "class": "NetworkTopologyStrategy",
+ "Boston": 2,
+ "Berlin": 3
+ }
+ }
+ }
+ }
+ """;
+
+ CreateDatabaseCommand command = objectMapper.readValue(json, CreateDatabaseCommand.class);
+ Operation result = resolver.resolveCommand(null, command);
+
+ assertThat(result)
+ .isInstanceOfSatisfying(
+ CreateDatabaseOperation.class,
+ op -> {
+ assertThat(op.name()).isEqualTo("red_star_belgrade");
+ assertThat(op.replicationMap())
+ .isIn(
+ "{'class': 'NetworkTopologyStrategy', 'Boston': 2, 'Berlin': 3}",
+ "{'class': 'NetworkTopologyStrategy', 'Berlin': 3, 'Boston': 2}");
+ });
+ }
+
+ @Test
+ public void networkTopologyStrategyNoDataCenter() throws Exception {
+ // allow, fail on the coordinator
+ String json =
+ """
+ {
+ "createDatabase": {
+ "name" : "red_star_belgrade",
+ "options": {
+ "replication": {
+ "class": "NetworkTopologyStrategy"
+ }
+ }
+ }
+ }
+ """;
+
+ CreateDatabaseCommand command = objectMapper.readValue(json, CreateDatabaseCommand.class);
+ Operation result = resolver.resolveCommand(null, command);
+
+ assertThat(result)
+ .isInstanceOfSatisfying(
+ CreateDatabaseOperation.class,
+ op -> {
+ assertThat(op.name()).isEqualTo("red_star_belgrade");
+ assertThat(op.replicationMap()).isEqualTo("{'class': 'NetworkTopologyStrategy'}");
+ });
+ }
+ }
+}