diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/api/spec/v0/request/CreateUpdateViewRequestBody.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/spec/v0/request/CreateUpdateViewRequestBody.java new file mode 100644 index 000000000..0ee7b669a --- /dev/null +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/spec/v0/request/CreateUpdateViewRequestBody.java @@ -0,0 +1,113 @@ +package com.linkedin.openhouse.tables.api.spec.v0.request; + +import static com.linkedin.openhouse.common.api.validator.ValidatorConstants.*; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.google.gson.Gson; +import com.linkedin.openhouse.tables.api.spec.v0.request.components.ViewRepresentation; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.Map; +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Request body for POST and PUT on /v1/databases/{databaseId}/views. Nullable fields are omitted + * from the serialized payload rather than emitted as JSON null, so an omitted {@code + * baseViewVersion} on create stays absent on the wire. + */ +@Builder(toBuilder = true) +@EqualsAndHashCode +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CreateUpdateViewRequestBody { + + @Schema( + description = "Unique Resource identifier for a view within a Database", + example = "my_view") + @NotEmpty(message = "viewId cannot be empty") + @Size(max = 128) + @Pattern(regexp = ALPHA_NUM_UNDERSCORE_REGEX, message = ALPHA_NUM_UNDERSCORE_ERROR_MSG) + private String viewId; + + @Schema( + description = "Unique Resource identifier for the Database containing the View", + example = "my_database") + @NotEmpty(message = "databaseId cannot be empty") + @Size(max = 128) + @Pattern(regexp = ALPHA_NUM_UNDERSCORE_REGEX, message = ALPHA_NUM_UNDERSCORE_ERROR_MSG) + private String databaseId; + + @Schema( + description = "Unique Resource identifier for the Cluster containing the Database", + example = "my_cluster") + @NotEmpty(message = "clusterId cannot be empty") + @Pattern( + regexp = ALPHA_NUM_UNDERSCORE_REGEX_HYPHEN_ALLOW, + message = ALPHA_NUM_UNDERSCORE_ERROR_MSG_HYPHEN_ALLOW) + private String clusterId; + + @Schema( + description = "Schema of the view. OpenHouse views use Iceberg schema specification", + example = + "{\"type\": \"struct\", " + + "\"fields\": [{\"id\": 1,\"required\": true,\"name\": \"id\",\"type\": \"string\"}, " + + "{\"id\": 2,\"required\": true,\"name\": \"name\",\"type\": \"string\"}]}") + @NotEmpty(message = "schema cannot be empty") + private String schema; + + @Schema( + description = "Engine-specific representations of the view definition", + example = "[{\"type\": \"sql\", \"sql\": \"SELECT 1\", \"dialect\": \"spark\"}]") + @NotEmpty(message = "representations cannot be empty") + @Valid + private List representations; + + @Schema(description = "Dialect of the representation the view was authored in", example = "spark") + @NotEmpty(message = "sourceDialect cannot be empty") + private String sourceDialect; + + @Schema( + nullable = true, + description = "Catalog used to resolve unqualified identifiers in the view SQL", + example = "openhouse") + private String defaultCatalog; + + @Schema( + nullable = true, + description = "Namespace used to resolve unqualified identifiers in the view SQL", + example = "[\"my_database\"]") + private List defaultNamespace; + + @Schema(nullable = true, description = "View properties", example = "{\"key\": \"value\"}") + private Map viewProperties; + + /** + * Route-sensitive: absent or {@code INITIAL_VERSION} on create, and the current metadata pointer + * on replace. Intentionally carries no bean constraint because the rule differs per HTTP verb and + * is owned by the verb-aware view validator. + */ + @Schema( + nullable = true, + description = "The version of the view that the current update is based upon") + private String baseViewVersion; + + /** + * Uses default Gson null handling rather than {@code serializeNulls()} so this stays consistent + * with the class-level {@link JsonInclude.Include#NON_NULL}: an omitted nullable field is absent + * from the payload, not present as JSON null. + */ + public String toJson() { + return new Gson().toJson(this); + } +} diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/api/spec/v0/request/components/ViewRepresentation.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/spec/v0/request/components/ViewRepresentation.java new file mode 100644 index 000000000..fdd451aba --- /dev/null +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/spec/v0/request/components/ViewRepresentation.java @@ -0,0 +1,40 @@ +package com.linkedin.openhouse.tables.api.spec.v0.request.components; + +import io.swagger.v3.oas.annotations.media.Schema; +import javax.validation.constraints.NotEmpty; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * ViewRepresentation is the entity holding a single engine-specific representation of a view in the + * /views API request body. SQL text is carried as opaque text; this class holds no parsing, + * translation or dialect-support logic. Byte-size, representation-type and dialect-support rules + * are owned by the manual view validator. + */ +@Builder(toBuilder = true) +@EqualsAndHashCode +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ViewRepresentation { + + @Schema(description = "Type of the view representation", example = "sql") + @NotEmpty(message = "type cannot be empty") + private String type; + + @Schema( + description = + "SQL text of the view representation. This endpoint accepts it as opaque text: it is" + + " stored as sent and is not parsed or rewritten here.", + example = "SELECT id, name FROM my_database.my_table") + @NotEmpty(message = "sql cannot be empty") + private String sql; + + @Schema(description = "SQL dialect the representation is written in", example = "spark") + @NotEmpty(message = "dialect cannot be empty") + private String dialect; +} diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/api/spec/v0/response/GetAllViewsResponseBody.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/spec/v0/response/GetAllViewsResponseBody.java new file mode 100644 index 000000000..f8b608309 --- /dev/null +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/spec/v0/response/GetAllViewsResponseBody.java @@ -0,0 +1,25 @@ +package com.linkedin.openhouse.tables.api.spec.v0.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.Gson; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Value; +import org.springframework.data.domain.Page; + +/** + * List contract for views. Paginated from the first release, so there is no unpaginated legacy + * {@code results} field to deprecate later. + */ +@Builder +@Value +public class GetAllViewsResponseBody { + + @Schema(description = "Page of View objects in a database", example = "") + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private Page pageResults; + + public String toJson() { + return new Gson().toJson(this); + } +} diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/api/spec/v0/response/GetViewResponseBody.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/spec/v0/response/GetViewResponseBody.java new file mode 100644 index 000000000..910d5b910 --- /dev/null +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/spec/v0/response/GetViewResponseBody.java @@ -0,0 +1,62 @@ +package com.linkedin.openhouse.tables.api.spec.v0.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.Gson; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Value; + +/** + * Read contract for a view. Pointer-only today: this response omits the definition fields. The SQL, + * schema, representations, version history, UUID, properties and resolution context live in the + * view metadata file and are not returned by the item or list response in this milestone. + */ +@Builder(toBuilder = true) +@Value +public class GetViewResponseBody { + + @Schema( + description = "Unique Resource identifier for a view within a Database", + example = "my_view") + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private String viewId; + + @Schema( + description = "Unique Resource identifier for the Database containing the View", + example = "my_database") + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private String databaseId; + + @Schema( + description = "Unique Resource identifier for the Cluster containing the Database", + example = "my_cluster") + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private String clusterId; + + @Schema( + description = "Fully Qualified Resource URI for the view", + example = "my_cluster.my_database.my_view") + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private String viewUri; + + @Schema( + description = "Location of the view metadata in File System / Blob Store", + example = + "://////metadata/.metadata.json") + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private String metadataLocation; + + @Schema(description = "Current Version of the View.", example = "") + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private String viewVersion; + + @Schema( + description = "View creation epoch time measured in UTC in milliseconds of a view.", + example = "1651002318265") + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private long creationTime; + + public String toJson() { + return new Gson().toJson(this); + } +} diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/exception/ViewErrorCode.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/exception/ViewErrorCode.java new file mode 100644 index 000000000..c44648e44 --- /dev/null +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/exception/ViewErrorCode.java @@ -0,0 +1,33 @@ +package com.linkedin.openhouse.tables.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * Internal taxonomy of view failure modes. This enum is never serialized to the wire: it exists + * only to select the HTTP status of the response, and the error body shape stays unchanged. + * + *

The full set is declared up front, including codes M1 never emits, so later milestones (view + * admission, dependency analysis) add behavior without a breaking change to this enum. + */ +@AllArgsConstructor +@Getter +public enum ViewErrorCode { + NO_SUCH_VIEW(HttpStatus.NOT_FOUND), + VIEW_ALREADY_EXISTS(HttpStatus.CONFLICT), + NAME_ALREADY_EXISTS_AS_TABLE(HttpStatus.CONFLICT), + CONCURRENT_VIEW_MODIFICATION(HttpStatus.CONFLICT), + DATABASE_NOT_FOUND(HttpStatus.NOT_FOUND), + VIEWS_DISABLED(HttpStatus.NOT_FOUND), + INVALID_VIEW_DEFINITION(HttpStatus.BAD_REQUEST), + UNSUPPORTED_VIEW_DIALECT(HttpStatus.BAD_REQUEST), + UNSUPPORTED_VIEW_SCHEMA(HttpStatus.BAD_REQUEST), + VIEW_ADMISSION_FAILED(HttpStatus.UNPROCESSABLE_ENTITY), + REQUIRED_REPRESENTATION_MISSING(HttpStatus.UNPROCESSABLE_ENTITY), + DEPENDENCY_CYCLE(HttpStatus.UNPROCESSABLE_ENTITY), + MAX_VIEW_DEPTH_EXCEEDED(HttpStatus.UNPROCESSABLE_ENTITY), + ADMISSION_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE); + + private final HttpStatus httpStatus; +}