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'}"); + }); + } + } +}