diff --git a/pom.xml b/pom.xml
index 9aa2ece650..23d869b0d1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -135,6 +135,10 @@
io.quarkusquarkus-hibernate-validator
+
+ io.quarkus
+ quarkus-logging-json
+ jakarta.validationjakarta.validation-api
@@ -143,6 +147,11 @@
org.apache.commonscommons-lang3
+
+ org.apache.commons
+ commons-text
+ 1.12.0
+ com.bpodgurskyjbool_expressions
@@ -189,10 +198,18 @@
java-driver-query-builder${driver.version}
+
- io.quarkus
- quarkus-logging-json
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-yaml
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jdk8
+
+
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 49c2b086e0..5b1f5dc896 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
@@ -137,7 +137,7 @@ public record VectorizeConfig(
@Nullable
@Schema(
description =
- "Optional parameters that match the template provided for the provider",
+ "Optional parameters that match the messageTemplate provided for the provider",
type = SchemaType.OBJECT)
@JsonProperty("parameters")
@JsonInclude(JsonInclude.Include.NON_NULL)
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/security/HeaderBasedAuthenticationMechanism.java b/src/main/java/io/stargate/sgv2/jsonapi/api/security/HeaderBasedAuthenticationMechanism.java
index 31adf80de9..8d90af5118 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/security/HeaderBasedAuthenticationMechanism.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/security/HeaderBasedAuthenticationMechanism.java
@@ -20,7 +20,6 @@
import static io.stargate.sgv2.jsonapi.config.constants.HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME;
import static io.stargate.sgv2.jsonapi.config.constants.HttpConstants.DEPRECATED_AUTHENTICATION_TOKEN_HEADER_NAME;
-import io.netty.handler.codec.http.HttpResponseStatus;
import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.AuthenticationRequest;
@@ -32,6 +31,7 @@
import io.stargate.sgv2.jsonapi.api.security.challenge.impl.ErrorChallengeSender;
import io.vertx.ext.web.RoutingContext;
import jakarta.enterprise.inject.Instance;
+import jakarta.ws.rs.core.Response;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
@@ -84,7 +84,7 @@ public Uni authenticate(
@Override
public Uni getChallenge(RoutingContext context) {
return Uni.createFrom()
- .item(new ChallengeData(HttpResponseStatus.UNAUTHORIZED.code(), null, null));
+ .item(new ChallengeData(Response.Status.UNAUTHORIZED.getStatusCode(), null, null));
}
/**
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/APIException.java b/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/APIException.java
new file mode 100644
index 0000000000..47fc6cc9c2
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/APIException.java
@@ -0,0 +1,130 @@
+package io.stargate.sgv2.jsonapi.exception.playing;
+
+import jakarta.ws.rs.core.Response;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Supplier;
+
+/**
+ * Base for all exceptions returned from the API for external use (as opposed to ones only used
+ * internally)
+ *
+ *
All errors are of a {@link ErrorFamily}, this class should not be used directly, one of the
+ * subclasses should be used. There are further categorised to be errors have an optional {@link
+ * ErrorScope}, that groups errors of a similar source together. Finally, the error has an {@link
+ * ErrorCode} that is unique within the family and scope.
+ *
+ *
To facilitate better error messages we template the messages in a {@link ErrorTemplate} that
+ * is loaded from a properties file. The body for the error may change with each instance of the
+ * exception, for example to include the number of filters that were included in a request.
+ *
+ *
The process of adding a new Error Code is:
+ *
+ *
+ *
+ *
+ *
Decide what {@link ErrorFamily} the code belongs to.
+ *
Decide if the error has a {@link ErrorScope}, such as errors with Embedding Providers, if
+ * it does not then use {@link ErrorScope#NONE}.
+ *
Decide on the {@link ErrorCode}, it should be unique within the Family and Scope
+ * combination.
+ *
Add the error to file read by {@link ErrorTemplate} to define the title and templated body
+ * body.
+ *
Add the error code to the Code enum for the Exception class, such as {@link
+ * FilterException.Code} or {@link RequestException.Code} with the same name. When the enum is
+ * instantiated at JVM start the error template is loaded.
+ *
Create instances using the methods on {@link ErrorCode}.
+ *
+ *
+ * To get the Error to be returned in the {@link
+ * io.stargate.sgv2.jsonapi.api.model.command.CommandResult} call the {@link #get()} method to get a
+ * {@link CommandResponseError} that contains the subset of information we want to return.
+ */
+public abstract class APIException extends RuntimeException
+ implements Supplier {
+
+ // All errors default to 200 HTTP status code, because we have partial failure modes.
+ // There are some overrides, e.g. a server timeout may be a 500, this is managed in the
+ // error config. See ErrorTemplate.
+ public static final int DEFAULT_HTTP_RESPONSE = Response.Status.OK.getStatusCode();
+
+ /**
+ * HTTP Response code for this error. NOTE: Not using enum from quarkus because do not want
+ * references to the HTTP framework this deep into the command processing
+ */
+ public final int httpResponse;
+
+ /** Unique identifier for this error instance. */
+ public final UUID errorId;
+
+ /** The family of the error. */
+ public final ErrorFamily family;
+
+ /**
+ * Optional scope of the error, inside the family.
+ *
+ *
Never {@code null}, uses "" for no scope. See {@link ErrorScope}
+ */
+ public final String scope;
+
+ /** Unique code for this error, codes should be unique within the Family and Scope combination. */
+ public final String code;
+
+ /** Title of this exception, the same title is used for all instances of the error code. */
+ public final String title;
+
+ /**
+ * Message body for this instance of the error.
+ *
+ *
Messages may be unique for each instance of the error code, they are typically driven from
+ * the {@link ErrorTemplate}.
+ *
+ *
This is the processed body to be returned to the client. NOT called body to avoid confusion
+ * with getMessage() on the RuntimeException
+ */
+ public final String body;
+
+ public APIException(ErrorInstance errorInstance) {
+ Objects.requireNonNull(errorInstance, "ErrorInstance cannot be null");
+
+ this.errorId = errorInstance.errorId();
+ this.family = errorInstance.family();
+ this.scope = errorInstance.scope().scope();
+ this.code = errorInstance.code();
+ this.title = errorInstance.title();
+ this.body = errorInstance.body();
+ Objects.requireNonNull(
+ errorInstance.httpResponseOverride(), "httpResponseOverride cannot be null");
+ this.httpResponse = errorInstance.httpResponseOverride().orElse(DEFAULT_HTTP_RESPONSE);
+ }
+
+ public APIException(
+ ErrorFamily family, ErrorScope scope, String code, String title, String body) {
+ this(new ErrorInstance(UUID.randomUUID(), family, scope, code, title, body, Optional.empty()));
+ }
+
+ @Override
+ public CommandResponseError get() {
+ return null;
+ }
+
+ /**
+ * Overrides to return the {@link #body} of the error. Using the body as this is effectively the
+ * message, the structure we want to return to the in the API is the {@link CommandResponseError}
+ * from {@link #get()}
+ *
+ * @return
+ */
+ @Override
+ public String getMessage() {
+ return body;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "%s{errorId=%s, family=%s, scope='%s', code='%s', title='%s', body='%s'}",
+ getClass().getSimpleName(), errorId, family, scope, code, title, body);
+ }
+}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/CommandResponseError.java b/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/CommandResponseError.java
new file mode 100644
index 0000000000..88db9aa384
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/CommandResponseError.java
@@ -0,0 +1,6 @@
+package io.stargate.sgv2.jsonapi.exception.playing;
+
+// TODO has all the fields we want to return in the error object in the request
+// TODO: where do we implement error coalescing , here or in in the APIException
+public record CommandResponseError(
+ String family, String scope, String code, String title, String message) {}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/DatabaseException.java b/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/DatabaseException.java
new file mode 100644
index 0000000000..0983bec672
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/DatabaseException.java
@@ -0,0 +1,23 @@
+package io.stargate.sgv2.jsonapi.exception.playing;
+
+public class DatabaseException extends ServerException {
+ public DatabaseException(ErrorInstance errorInstance) {
+ super(errorInstance);
+ }
+
+ public enum Code implements ErrorCode {
+ FAKE;
+
+ private final ErrorTemplate template;
+
+ Code() {
+ template =
+ ErrorTemplate.load(DatabaseException.class, ErrorFamily.SERVER, Scope.DATABASE, name());
+ }
+
+ @Override
+ public ErrorTemplate template() {
+ return template;
+ }
+ }
+}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/EmbeddingProviderException.java b/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/EmbeddingProviderException.java
new file mode 100644
index 0000000000..805220a2ca
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/EmbeddingProviderException.java
@@ -0,0 +1,28 @@
+package io.stargate.sgv2.jsonapi.exception.playing;
+
+public class EmbeddingProviderException extends ServerException {
+ public EmbeddingProviderException(ErrorInstance errorInstance) {
+ super(errorInstance);
+ }
+
+ public enum Code implements ErrorCode {
+ CLIENT_ERROR,
+ SERVER_ERROR;
+
+ private final ErrorTemplate template;
+
+ Code() {
+ template =
+ ErrorTemplate.load(
+ EmbeddingProviderException.class,
+ ErrorFamily.SERVER,
+ Scope.EMBEDDING_PROVIDER,
+ name());
+ }
+
+ @Override
+ public ErrorTemplate template() {
+ return template;
+ }
+ }
+}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/ErrorCode.java b/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/ErrorCode.java
new file mode 100644
index 0000000000..946b20ffbd
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/ErrorCode.java
@@ -0,0 +1,75 @@
+package io.stargate.sgv2.jsonapi.exception.playing;
+
+import com.google.common.base.Preconditions;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Interface for any enum that represents an error code to implement.
+ *
+ *
This interface is used because multiple ENUM's define the scopes, the interface creates a way
+ * to treat the values from the different ENUM's in a consistent way.
+ *
+ *
The interface makes it easy for code to get an instance of the exception the code represents,
+ * built using the templates error information in {@link ErrorTemplate} that is loaded at startup.
+ *
+ *
With this interface code creates an instance of the exception by calling any of the {@link
+ * #get()} overloads for example:
+ *
+ *
+ *
+ * @param Type of the {@link APIException} the error code creates.
+ */
+public interface ErrorCode {
+
+ /**
+ * Gets an instance of the {@link APIException} the error code represents without providing any
+ * substitution values for the error body.
+ *
+ * @return Instance of {@link APIException} the error code represents.
+ */
+ default T get() {
+ return get(Map.of());
+ }
+
+ /**
+ * Gets an instance of the {@link APIException} the error code represents, providing substitution
+ * values for the error body as a param array.
+ *
+ * @param values Substitution values for the error body. The array length must be a multiple of 2,
+ * each pair of strings is treated as a key-value pair for example ["key-1", "value-1",
+ * "key-2", "value-2"]
+ * @return Instance of {@link APIException} the error code represents.
+ */
+ default T get(String... values) {
+ Preconditions.checkArgument(
+ values.length % 2 == 0, "Length of the values must be a multiple of 2");
+ Map valuesMap = new HashMap<>(values.length / 2);
+ for (int i = 0; i < values.length; i += 2) {
+ valuesMap.put(values[i], values[i + 1]);
+ }
+ return get(valuesMap);
+ }
+
+ /**
+ * Gets an instance of the {@link APIException} the error code represents, providing substitution
+ * values for the error body as a param array.
+ *
+ * @param values May of substitution values for the error body.
+ * @return Instance of {@link APIException} the error code represents.
+ */
+ default T get(Map values) {
+ return template().toException(values);
+ }
+
+ /**
+ * ENUM Implementers must return a non-null {@link ErrorTemplate} that is used to build an
+ * instance of the Exception the code represents.
+ *
+ * @return {@link ErrorTemplate} for the error code.
+ */
+ ErrorTemplate template();
+}
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/ErrorConfig.java b/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/ErrorConfig.java
new file mode 100644
index 0000000000..1a9a4fd909
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/exception/playing/ErrorConfig.java
@@ -0,0 +1,301 @@
+package io.stargate.sgv2.jsonapi.exception.playing;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JacksonException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
+import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.LoadingCache;
+import com.google.common.annotations.VisibleForTesting;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * Java objects to hold the config from the errors config yaml file for use by {@link ErrorTemplate}
+ *
+ *
See the {@link #DEFAULT_ERROR_CONFIG_FILE} for description of what the yaml should look like.
+ *
+ *
To use this, call {@link #getInstance()} in code, this will cause the yaml file to be loaded
+ * if needed. The file will be loaded from {@link #DEFAULT_ERROR_CONFIG_FILE} resource in the JAR.
+ * Then use {@link #getErrorDetail(ErrorFamily, String, String)} if you want the template, or the
+ * use {@link #getSnippetVars()} when running templates.
+ *
+ *
If you need more control use the {@link #initializeFromYamlResource(String)} before any code
+ * causes the config to be read or {@link #unsafeInitializeFromYamlResource(String)} .
+ *
+ *
A {@link IllegalStateException} will be raised if an attempt is made to re-read the config.
+ *
+ *
Using a class rather than a record so we can cache converting the snippets to a map for use in
+ * the templates.
+ */
+public class ErrorConfig {
+
+ /**
+ * Method to get the configured ErrorConfig instance that has the data from the file
+ *
+ * @return Configured ErrorConfig instance
+ */
+ public static ErrorConfig getInstance() {
+ return CACHE.get(CACHE_KEY);
+ }
+
+ private final List snippets;
+ private final List requestErrors;
+ private final List serverErrors;
+
+ // Lazy loaded map of the snippets for use in the templates
+ private Map snippetVars;
+
+ // Prefix used when adding snippets to the variables for a template.
+ private static final String SNIPPET_VAR_PREFIX = "SNIPPET.";
+
+ // TIDY: move this to the config sections
+ public static final String DEFAULT_ERROR_CONFIG_FILE = "errors.yaml";
+
+ @JsonCreator
+ public ErrorConfig(
+ @JsonProperty("snippets") List snippets,
+ @JsonProperty("request-errors") List requestErrors,
+ @JsonProperty("server-errors") List serverErrors) {
+
+ // defensive immutable copies because this config can be shared
+ this.snippets = snippets == null ? List.of() : List.copyOf(snippets);
+ this.requestErrors = requestErrors == null ? List.of() : List.copyOf(requestErrors);
+ this.serverErrors = serverErrors == null ? List.of() : List.copyOf(serverErrors);
+ }
+
+ /**
+ * ErrorDetail is a record to hold the template for a particular error.
+ *
+ *
Does not have {@link ErrorFamily} because we have that as the only hierarchy in the config
+ * file, it is two different lists in the parent class.
+ *
+ * @param scope
+ * @param code
+ * @param title
+ * @param body
+ * @param httpResponseOverride Optional override for the HTTP response code for this error, only
+ * needs to be set if different from {@link APIException#DEFAULT_HTTP_RESPONSE}. NOTE:
+ * there is no checking that this is a well known HTTP status code, as we do not want to
+ * depend on classes like {@link jakarta.ws.rs.core.Response.Status} in this class and if we
+ * want to return a weird status this class should not limit that. It would be handled higher
+ * up the stack and tracked with Integration Tests.
+ */
+ public record ErrorDetail(
+ String scope,
+ String code,
+ String title,
+ String body,
+ Optional httpResponseOverride) {
+
+ public ErrorDetail {
+ if (scope == null) {
+ scope = ErrorScope.NONE.scope();
+ }
+ // scope can be empty, if not empty must be snake case
+ if (!scope.isBlank()) {
+ requireSnakeCase(scope, "scope");
+ }
+
+ Objects.requireNonNull(code, "code cannot be null");
+ requireSnakeCase(code, "code");
+
+ Objects.requireNonNull(title, "title cannot be null");
+ if (title.isBlank()) {
+ throw new IllegalArgumentException("title cannot be blank");
+ }
+
+ Objects.requireNonNull(body, "body cannot be null");
+ if (body.isBlank()) {
+ throw new IllegalArgumentException("body cannot be blank");
+ }
+
+ Objects.requireNonNull(httpResponseOverride, "httpResponseOverride cannot be null");
+ }
+ }
+
+ /**
+ * Snippet is a record to hold the template for a particular snippet.
+ *
+ *
+ *
+ * @param name
+ * @param body
+ */
+ public record Snippet(String name, String body) {
+
+ public Snippet {
+ Objects.requireNonNull(name, "name cannot be null");
+ requireSnakeCase(name, "name");
+
+ Objects.requireNonNull(body, "body cannot be null");
+ if (body.isBlank()) {
+ throw new IllegalArgumentException("body cannot be blank");
+ }
+ }
+
+ /**
+ * Name to use for this snippet when substituting into templates.
+ *
+ * @return
+ */
+ public String variableName() {
+ return SNIPPET_VAR_PREFIX + name;
+ }
+ }
+
+ /**
+ * See {@link #getSnippetVars()} for the cached map of snippets vars.
+ *
+ * @return
+ */
+ @VisibleForTesting
+ List snippets() {
+ return snippets;
+ }
+
+ /**
+ * Helper to optionally get a {@link ErrorDetail} for use by a {@link ErrorTemplate}
+ *
+ * @param family
+ * @param scope
+ * @param code
+ * @return
+ */
+ public Optional getErrorDetail(ErrorFamily family, String scope, String code) {
+
+ var errors =
+ switch (family) {
+ case REQUEST -> requestErrors;
+ case SERVER -> serverErrors;
+ };
+ return errors.stream()
+ .filter(e -> e.code().equals(code) && e.scope().equals(scope))
+ .findFirst();
+ }
+
+ /**
+ * Returns a map of the snippets for use in the templates.
+ *
+ *
The map is cached, recommend use this rather than call {@link #snippets()} for every error
+ *
+ * @return Map of snippets for use in templates
+ */
+ protected Map getSnippetVars() {
+
+ if (snippetVars == null) {
+ // NOTE: Potential race condition, should be OK because the data won't change and we are only
+ // writing.
+ // want the map to be immutable because we hand it out
+ snippetVars =
+ Map.copyOf(
+ snippets.stream().collect(Collectors.toMap(s -> s.variableName(), Snippet::body)));
+ }
+ return snippetVars;
+ }
+
+ // Reusable Pattern for UPPER_SNAKE_CASE_2 - allows alpha and digits
+ private static final Pattern UPPER_SNAKE_CASE_PATTERN =
+ Pattern.compile("^[A-Z0-9]+(_[A-Z0-9]+)*$");
+
+ private static void requireSnakeCase(String value, String name) {
+ if (!UPPER_SNAKE_CASE_PATTERN.matcher(value).matches()) {
+ throw new IllegalArgumentException(
+ name + " must be in UPPER_SNAKE_CASE_1 format, got: " + value);
+ }
+ }
+
+ // there is a single item in the cache
+ private static final Object CACHE_KEY = new Object();
+
+ // Using a Caffeine cache even though there is a single instance of the ErrorConfig read from disk
+ // so we can either lazy load when we first need it using default file or load from a
+ // different file or yaml string for tests etc.
+ private static final LoadingCache