diff --git a/server/src/main/java/org/opensearch/action/ActionModule.java b/server/src/main/java/org/opensearch/action/ActionModule.java index 46775466aa615..e2a738ac959a3 100644 --- a/server/src/main/java/org/opensearch/action/ActionModule.java +++ b/server/src/main/java/org/opensearch/action/ActionModule.java @@ -224,6 +224,7 @@ import org.opensearch.action.admin.indices.upgrade.post.UpgradeSettingsAction; import org.opensearch.action.admin.indices.validate.query.TransportValidateQueryAction; import org.opensearch.action.admin.indices.validate.query.ValidateQueryAction; +import org.opensearch.action.admin.indices.view.CreateViewAction; import org.opensearch.action.bulk.BulkAction; import org.opensearch.action.bulk.TransportBulkAction; import org.opensearch.action.bulk.TransportShardBulkAction; @@ -721,6 +722,9 @@ public void reg actions.register(ResolveIndexAction.INSTANCE, ResolveIndexAction.TransportAction.class); actions.register(DataStreamsStatsAction.INSTANCE, DataStreamsStatsAction.TransportAction.class); + // Views: + actions.register(CreateViewAction.INSTANCE, CreateViewAction.TransportAction.class); + // Persistent tasks: actions.register(StartPersistentTaskAction.INSTANCE, StartPersistentTaskAction.TransportAction.class); actions.register(UpdatePersistentTaskStatusAction.INSTANCE, UpdatePersistentTaskStatusAction.TransportAction.class); diff --git a/server/src/main/java/org/opensearch/action/admin/indices/view/CreateViewAction.java b/server/src/main/java/org/opensearch/action/admin/indices/view/CreateViewAction.java new file mode 100644 index 0000000000000..10d84bd832229 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/admin/indices/view/CreateViewAction.java @@ -0,0 +1,193 @@ +package org.opensearch.action.admin.indices.view; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.action.ActionType; +import org.opensearch.action.ValidateActions; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.clustermanager.ClusterManagerNodeRequest; +import org.opensearch.action.support.clustermanager.TransportClusterManagerNodeAction; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.block.ClusterBlockException; +import org.opensearch.cluster.block.ClusterBlockLevel; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.metadata.ViewService; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.Strings; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportService; + +/** Action to create a view */ +public class CreateViewAction extends ActionType { + + public static final CreateViewAction INSTANCE = new CreateViewAction(); + public static final String NAME = "cluster:views:create"; + + private CreateViewAction() { + super(NAME, CreateViewAction.Response::new); + } + + + /** View target representation for create requests */ + public static class ViewTarget implements Writeable { + public final String indexPattern; + + public ViewTarget(final String indexPattern) { + this.indexPattern = indexPattern; + } + + public ViewTarget(final StreamInput in) throws IOException { + this.indexPattern = in.readString(); + } + + public String getIndexPattern() { + return indexPattern; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(indexPattern); + } + + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = null; + + if (Strings.isNullOrEmpty(indexPattern)) { + validationException = ValidateActions.addValidationError("index pattern cannot be empty or null", validationException); + } + + return validationException; + } + + } + + /** + * Request for Creating View + */ + public static class Request extends ClusterManagerNodeRequest { + private final String name; + private final String description; + private final List targets; + + public Request(final String name, final String description, final List targets) { + this.name = name; + this.description = description; + this.targets = targets; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public List getTargets() { + return new ArrayList<>(targets); + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = null; + if (Strings.isNullOrEmpty(name)) { + validationException = ValidateActions.addValidationError("Name is cannot be empty or null", validationException); + } + if (targets.isEmpty()) { + validationException = ValidateActions.addValidationError("targets cannot be empty", validationException); + } + + for (final ViewTarget target : targets) { + validationException = target.validate(); + } + + return validationException; + } + + public Request(final StreamInput in) throws IOException { + super(in); + this.name = in.readString(); + this.description = in.readString(); + this.targets = in.readList(ViewTarget::new); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(name); + out.writeString(description); + out.writeList(targets); + } + } + + /** Response after view is created */ + public static class Response extends ActionResponse { + + private final org.opensearch.cluster.metadata.View createdView; + + public Response(final org.opensearch.cluster.metadata.View createdView) { + this.createdView = createdView; + } + + public Response(final StreamInput in) throws IOException { + super(in); + this.createdView = new org.opensearch.cluster.metadata.View(in); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + this.createdView.writeTo(out); + } + } + + /** + * Transport Action for creating a View + */ + public static class TransportAction extends TransportClusterManagerNodeAction { + + private final ViewService viewService; + + @Inject + public TransportAction( + final TransportService transportService, + final ClusterService clusterService, + final ThreadPool threadPool, + final ActionFilters actionFilters, + final IndexNameExpressionResolver indexNameExpressionResolver, + final ViewService viewService + ) { + super(NAME, transportService, clusterService, threadPool, actionFilters, Request::new, indexNameExpressionResolver); + this.viewService = viewService; + } + + @Override + protected String executor() { + return ThreadPool.Names.SAME; + } + + @Override + protected Response read(StreamInput in) throws IOException { + return new Response(in); + } + + @Override + protected void clusterManagerOperation(Request request, ClusterState state, ActionListener listener) + throws Exception { + viewService.createView(request, listener); + } + + @Override + protected ClusterBlockException checkBlock(Request request, ClusterState state) { + return state.blocks().globalBlockedException(ClusterBlockLevel.METADATA_WRITE); + } + } +} diff --git a/server/src/main/java/org/opensearch/action/admin/indices/view/package-info.java b/server/src/main/java/org/opensearch/action/admin/indices/view/package-info.java new file mode 100644 index 0000000000000..db0556b1bf334 --- /dev/null +++ b/server/src/main/java/org/opensearch/action/admin/indices/view/package-info.java @@ -0,0 +1,10 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/** View transport handlers. */ +package org.opensearch.action.admin.indices.view; diff --git a/server/src/main/java/org/opensearch/action/search/SearchRequest.java b/server/src/main/java/org/opensearch/action/search/SearchRequest.java index 96cea17ff4972..67b78d84ce35d 100644 --- a/server/src/main/java/org/opensearch/action/search/SearchRequest.java +++ b/server/src/main/java/org/opensearch/action/search/SearchRequest.java @@ -82,13 +82,13 @@ public class SearchRequest extends ActionRequest implements IndicesRequest.Repla private static final long DEFAULT_ABSOLUTE_START_MILLIS = -1; - private final String localClusterAlias; - private final long absoluteStartMillis; - private final boolean finalReduce; + protected final String localClusterAlias; + protected final long absoluteStartMillis; + protected final boolean finalReduce; private SearchType searchType = SearchType.DEFAULT; - private String[] indices = Strings.EMPTY_ARRAY; + protected String[] indices = Strings.EMPTY_ARRAY; @Nullable private String routing; @@ -189,7 +189,7 @@ static SearchRequest subSearchRequest( return new SearchRequest(originalSearchRequest, indices, clusterAlias, absoluteStartMillis, finalReduce); } - private SearchRequest( + protected SearchRequest( SearchRequest searchRequest, String[] indices, String localClusterAlias, diff --git a/server/src/main/java/org/opensearch/action/search/ViewSearchRequest.java b/server/src/main/java/org/opensearch/action/search/ViewSearchRequest.java new file mode 100644 index 0000000000000..0c3215e8e647a --- /dev/null +++ b/server/src/main/java/org/opensearch/action/search/ViewSearchRequest.java @@ -0,0 +1,74 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.action.search; + +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.cluster.metadata.View; +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import java.io.IOException; +import java.util.Objects; +import java.util.function.Function; + +import static org.opensearch.action.ValidateActions.addValidationError; + +/** Wraps the functionality of search requests and tailors for what is available when searching through views + */ +@ExperimentalApi +public class ViewSearchRequest extends SearchRequest { + + public final View view; + + public ViewSearchRequest(final View view) { + super(); + this.view = view; + } + + public ViewSearchRequest(final StreamInput in) throws IOException { + super(in); + view = new View(in); + } + + @Override + public ActionRequestValidationException validate() { + final Function unsupported = (String x) -> x + " is not supported when searching views"; + ActionRequestValidationException validationException = super.validate(); + + if (scroll() != null) { + validationException = addValidationError(unsupported.apply("Scroll"), validationException); + } + + // TODO: Filter out anything additional search features that are not supported + + return validationException; + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + super.writeTo(out); + view.writeTo(out); + } + + @Override + public boolean equals(final Object o) { + // TODO: Maybe this isn't standard practice + return this.hashCode() == o.hashCode(); + } + + @Override + public int hashCode() { + return Objects.hash(view, super.hashCode()); + } + + @Override + public String toString() { + return super.toString().replace("SearchRequest{", "ViewSearchRequest{view=" + view + ","); + } +} diff --git a/server/src/main/java/org/opensearch/cluster/ClusterModule.java b/server/src/main/java/org/opensearch/cluster/ClusterModule.java index bad881f8bda76..d2f4888ae8971 100644 --- a/server/src/main/java/org/opensearch/cluster/ClusterModule.java +++ b/server/src/main/java/org/opensearch/cluster/ClusterModule.java @@ -49,6 +49,7 @@ import org.opensearch.cluster.metadata.MetadataMappingService; import org.opensearch.cluster.metadata.MetadataUpdateSettingsService; import org.opensearch.cluster.metadata.RepositoriesMetadata; +import org.opensearch.cluster.metadata.ViewMetadata; import org.opensearch.cluster.metadata.WeightedRoutingMetadata; import org.opensearch.cluster.routing.DelayedAllocationService; import org.opensearch.cluster.routing.allocation.AllocationService; @@ -195,6 +196,7 @@ public static List getNamedWriteables() { ComposableIndexTemplateMetadata::readDiffFrom ); registerMetadataCustom(entries, DataStreamMetadata.TYPE, DataStreamMetadata::new, DataStreamMetadata::readDiffFrom); + registerMetadataCustom(entries, ViewMetadata.TYPE, ViewMetadata::new, ViewMetadata::readDiffFrom); registerMetadataCustom(entries, WeightedRoutingMetadata.TYPE, WeightedRoutingMetadata::new, WeightedRoutingMetadata::readDiffFrom); registerMetadataCustom( entries, @@ -292,6 +294,7 @@ public static List getNamedXWriteables() { DataStreamMetadata::fromXContent ) ); + entries.add(new NamedXContentRegistry.Entry(Metadata.Custom.class, new ParseField(ViewMetadata.TYPE), ViewMetadata::fromXContent)); entries.add( new NamedXContentRegistry.Entry( Metadata.Custom.class, diff --git a/server/src/main/java/org/opensearch/cluster/metadata/Metadata.java b/server/src/main/java/org/opensearch/cluster/metadata/Metadata.java index 1871ed24973c2..69e49e7aec6eb 100644 --- a/server/src/main/java/org/opensearch/cluster/metadata/Metadata.java +++ b/server/src/main/java/org/opensearch/cluster/metadata/Metadata.java @@ -831,6 +831,10 @@ public Map dataStreams() { .orElse(Collections.emptyMap()); } + public Map views() { + return Optional.ofNullable((ViewMetadata) this.custom(ViewMetadata.TYPE)).map(ViewMetadata::views).orElse(Collections.emptyMap()); + } + public DecommissionAttributeMetadata decommissionAttributeMetadata() { return custom(DecommissionAttributeMetadata.TYPE); } @@ -1325,6 +1329,36 @@ public Builder removeDataStream(String name) { return this; } + private Map getViews() { + return Optional.ofNullable(customs.get(ViewMetadata.TYPE)) + .map(o -> (ViewMetadata) o) + .map(vmd -> vmd.views()) + .orElse(new HashMap<>()); + } + + public View view(final String viewName) { + return getViews().get(viewName); + } + + public Builder views(final Map views) { + this.customs.put(ViewMetadata.TYPE, new ViewMetadata(views)); + return this; + } + + public Builder put(final View view) { + Objects.requireNonNull(view, "view cannot be null"); + final var replacementViews = new HashMap<>(getViews()); + replacementViews.put(view.name, view); + return views(replacementViews); + } + + public Builder removeView(final String viewName) { + Objects.requireNonNull(viewName, "viewName cannot be null"); + final var replacementViews = new HashMap<>(getViews()); + replacementViews.remove(viewName); + return views(replacementViews); + } + public Custom getCustom(String type) { return customs.get(type); } diff --git a/server/src/main/java/org/opensearch/cluster/metadata/View.java b/server/src/main/java/org/opensearch/cluster/metadata/View.java new file mode 100644 index 0000000000000..8db65c6afaebe --- /dev/null +++ b/server/src/main/java/org/opensearch/cluster/metadata/View.java @@ -0,0 +1,139 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.cluster.metadata; + +import org.opensearch.cluster.AbstractDiffable; +import org.opensearch.cluster.Diff; +import org.opensearch.common.annotation.ExperimentalApi; +import org.opensearch.core.ParseField; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.xcontent.ConstructingObjectParser; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +/** TODO */ +@ExperimentalApi +public class View extends AbstractDiffable implements ToXContentObject { + + public final String name; + public final String description; + public final long createdAt; + public final long modifiedAt; + public final List targets; + + public View(final String name, final String description, final Long createdAt, final Long modifiedAt, final List targets) { + this.name = Objects.requireNonNull(name, "Name must be provided"); + this.description = description; + this.createdAt = createdAt != null ? createdAt : -1; + this.modifiedAt = modifiedAt != null ? modifiedAt : -1; + this.targets = Objects.requireNonNull(targets, "Targets are required on a view"); + } + + public View(final StreamInput in) throws IOException { + this(in.readString(), in.readOptionalString(), in.readVLong(), in.readVLong(), in.readList(Target::new)); + } + + public static Diff readDiffFrom(final StreamInput in) throws IOException { + return readDiffFrom(View::new, in); + } + + /** TODO */ + @ExperimentalApi + public static class Target implements Writeable, ToXContentObject { + + public final String indexPattern; + + public Target(final String indexPattern) { + this.indexPattern = Objects.requireNonNull(indexPattern, "IndexPattern is required"); + } + + public Target(final StreamInput in) throws IOException { + this(in.readString()); + } + + private static final ParseField INDEX_PATTERN_FIELD = new ParseField("indexPattern"); + + @Override + public XContentBuilder toXContent(final XContentBuilder builder, final Params params) throws IOException { + builder.startObject(); + builder.field(INDEX_PATTERN_FIELD.getPreferredName(), indexPattern); + builder.endObject(); + return builder; + } + + private static final ConstructingObjectParser T_PARSER = new ConstructingObjectParser<>( + "target", + args -> new Target((String) args[0]) + ); + static { + T_PARSER.declareString(ConstructingObjectParser.constructorArg(), INDEX_PATTERN_FIELD); + } + + public static Target fromXContent(final XContentParser parser) throws IOException { + return T_PARSER.parse(parser, null); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(indexPattern); + } + } + + private static final ParseField NAME_FIELD = new ParseField("name"); + private static final ParseField DESCRIPTION_FIELD = new ParseField("description"); + private static final ParseField CREATED_AT_FIELD = new ParseField("createdAt"); + private static final ParseField MODIFIED_AT_FIELD = new ParseField("modifiedAt"); + private static final ParseField TARGETS_FIELD = new ParseField("targets"); + + @SuppressWarnings("unchecked") + private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "view", + args -> new View((String) args[0], (String) args[1], (Long) args[2], (Long) args[3], (List) args[4]) + ); + + static { + PARSER.declareString(ConstructingObjectParser.constructorArg(), NAME_FIELD); + PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), DESCRIPTION_FIELD); + PARSER.declareLongOrNull(ConstructingObjectParser.optionalConstructorArg(), -1L, CREATED_AT_FIELD); + PARSER.declareLongOrNull(ConstructingObjectParser.optionalConstructorArg(), -1L, MODIFIED_AT_FIELD); + PARSER.declareObjectArray(ConstructingObjectParser.constructorArg(), (p, c) -> Target.fromXContent(p), TARGETS_FIELD); + } + + public static View fromXContent(final XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } + + @Override + public XContentBuilder toXContent(final XContentBuilder builder, final Params params) throws IOException { + builder.startObject(); + builder.field(NAME_FIELD.getPreferredName(), name); + builder.field(DESCRIPTION_FIELD.getPreferredName(), description); + builder.field(CREATED_AT_FIELD.getPreferredName(), createdAt); + builder.field(MODIFIED_AT_FIELD.getPreferredName(), modifiedAt); + builder.field(TARGETS_FIELD.getPreferredName(), targets); + builder.endObject(); + return builder; + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(name); + out.writeOptionalString(description); + out.writeVLong(createdAt); + out.writeVLong(modifiedAt); + out.writeList(targets); + } +} diff --git a/server/src/main/java/org/opensearch/cluster/metadata/ViewMetadata.java b/server/src/main/java/org/opensearch/cluster/metadata/ViewMetadata.java new file mode 100644 index 0000000000000..f308cfdfb55bf --- /dev/null +++ b/server/src/main/java/org/opensearch/cluster/metadata/ViewMetadata.java @@ -0,0 +1,193 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.cluster.metadata; + +import org.opensearch.Version; +import org.opensearch.cluster.Diff; +import org.opensearch.cluster.DiffableUtils; +import org.opensearch.cluster.NamedDiff; +import org.opensearch.core.ParseField; +import org.opensearch.core.common.Strings; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ConstructingObjectParser; +import org.opensearch.core.xcontent.MediaTypeRegistry; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +import java.io.IOException; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static org.opensearch.cluster.metadata.ComposableIndexTemplateMetadata.MINIMMAL_SUPPORTED_VERSION; + +/** + * TODO: Tests with failures? `./gradlew :server:test` + + - org.opensearch.index.store.RemoteSegmentStoreDirectoryTests.testDeleteStaleCommitsActualDeleteIOException + - org.opensearch.index.store.RemoteSegmentStoreDirectoryTests.testDeleteStaleCommitsDeleteDedup + - org.opensearch.index.store.RemoteSegmentStoreDirectoryTests.testDeleteStaleCommitsActualDeleteNoSuchFileException + - org.opensearch.index.store.RemoteSegmentStoreDirectoryTests.testDeleteStaleCommitsActualDelete + * + */ + +public class ViewMetadata implements Metadata.Custom { + + public static final String TYPE = "view"; + private static final ParseField VIEW_FIELD = new ParseField("view"); + @SuppressWarnings("unchecked") + private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + TYPE, + false, + a -> new ViewMetadata((Map) a[0]) + ); + + static { + PARSER.declareObject(ConstructingObjectParser.constructorArg(), (p, c) -> { + Map views = new HashMap<>(); + while (p.nextToken() != XContentParser.Token.END_OBJECT) { + views.put(p.currentName(), View.fromXContent(p)); + } + return views; + }, VIEW_FIELD); + } + + private final Map views; + + public ViewMetadata(final Map views) { + this.views = views; + } + + public ViewMetadata(final StreamInput in) throws IOException { + this.views = in.readMap(StreamInput::readString, View::new); + } + + public Map views() { + return this.views; + } + + @Override + public Diff diff(final Metadata.Custom before) { + return new ViewMetadata.ViewMetadataDiff((ViewMetadata) before, this); + } + + public static NamedDiff readDiffFrom(final StreamInput in) throws IOException { + return new ViewMetadata.ViewMetadataDiff(in); + } + + @Override + public EnumSet context() { + return Metadata.ALL_CONTEXTS; + } + + @Override + public String getWriteableName() { + return TYPE; + } + + @Override + public Version getMinimalSupportedVersion() { + return MINIMMAL_SUPPORTED_VERSION; + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeMap(this.views, StreamOutput::writeString, (stream, val) -> val.writeTo(stream)); + } + + public static ViewMetadata fromXContent(final XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } + + @Override + public XContentBuilder toXContent(final XContentBuilder builder, final Params params) throws IOException { + builder.startObject(VIEW_FIELD.getPreferredName()); + for (Map.Entry entry : views.entrySet()) { + builder.field(entry.getKey(), entry.getValue()); + } + builder.endObject(); + return builder; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public int hashCode() { + return Objects.hash(this.views); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj.getClass() != getClass()) { + return false; + } + ViewMetadata other = (ViewMetadata) obj; + return Objects.equals(this.views, other.views); + } + + @Override + public String toString() { + return Strings.toString(MediaTypeRegistry.JSON, this); + } + + /** + * Builder of view metadata. + */ + public static class Builder { + + private final Map views = new HashMap<>(); + + public Builder putDataStream(final View view) { + views.put(view.name, view); + return this; + } + + public ViewMetadata build() { + return new ViewMetadata(views); + } + } + + /** + * A diff between view metadata. + */ + static class ViewMetadataDiff implements NamedDiff { + + final Diff> dataStreamDiff; + + ViewMetadataDiff(ViewMetadata before, ViewMetadata after) { + this.dataStreamDiff = DiffableUtils.diff(before.views, after.views, DiffableUtils.getStringKeySerializer()); + } + + ViewMetadataDiff(StreamInput in) throws IOException { + this.dataStreamDiff = DiffableUtils.readJdkMapDiff(in, DiffableUtils.getStringKeySerializer(), View::new, View::readDiffFrom); + } + + @Override + public Metadata.Custom apply(Metadata.Custom part) { + return new ViewMetadata(dataStreamDiff.apply(((ViewMetadata) part).views)); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + dataStreamDiff.writeTo(out); + } + + @Override + public String getWriteableName() { + return TYPE; + } + } +} diff --git a/server/src/main/java/org/opensearch/cluster/metadata/ViewService.java b/server/src/main/java/org/opensearch/cluster/metadata/ViewService.java new file mode 100644 index 0000000000000..4056e477f2226 --- /dev/null +++ b/server/src/main/java/org/opensearch/cluster/metadata/ViewService.java @@ -0,0 +1,54 @@ +package org.opensearch.cluster.metadata; + +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.action.admin.indices.view.CreateViewAction; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.ClusterStateUpdateTask; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.core.action.ActionListener; + +/** Service to interact with views, create, retrieve, update, and delete */ +public class ViewService { + + private final static Logger LOG = LogManager.getLogger(ViewService.class); + private final ClusterService clusterService; + + public ViewService(final ClusterService clusterService) { + this.clusterService = clusterService; + } + + public void createView(final CreateViewAction.Request request, final ActionListener listener) { + final long currentTime = System.currentTimeMillis(); + + final List targets = request.getTargets() + .stream() + .map(target -> new View.Target(target.getIndexPattern())) + .collect(Collectors.toList()); + final View view = new View(request.getName(), request.getDescription(), currentTime, currentTime, targets); + + clusterService.submitStateUpdateTask("create_view_task", new ClusterStateUpdateTask() { + @Override + public ClusterState execute(final ClusterState currentState) throws Exception { + return new ClusterState.Builder(clusterService.state()).metadata(Metadata.builder(currentState.metadata()).put(view)) + .build(); + } + + @Override + public void onFailure(final String source, final Exception e) { + LOG.error("Unable to create view, due to {}", source, e); + listener.onFailure(e); + } + + @Override + public void clusterStateProcessed(final String source, final ClusterState oldState, final ClusterState newState) { + final View createdView = newState.getMetadata().views().get(request.getName()); + final CreateViewAction.Response response = new CreateViewAction.Response(createdView); + listener.onResponse(response); + } + }); + } +} diff --git a/server/src/main/java/org/opensearch/index/view/package-info.java b/server/src/main/java/org/opensearch/index/view/package-info.java new file mode 100644 index 0000000000000..bb65723bdd5cd --- /dev/null +++ b/server/src/main/java/org/opensearch/index/view/package-info.java @@ -0,0 +1,10 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/** Core classes responsible for handling all view operations */ +package org.opensearch.index.view; diff --git a/server/src/main/java/org/opensearch/index/view/views-design.md b/server/src/main/java/org/opensearch/index/view/views-design.md new file mode 100644 index 0000000000000..773b359cc1953 --- /dev/null +++ b/server/src/main/java/org/opensearch/index/view/views-design.md @@ -0,0 +1,226 @@ +# Views + +Views define how searches are performed against indices on a cluster, uniform data access that is configured separately from the queries. + + +## Design + +### View data + +Views create a mapping to the resources that hold information to be searched over in a consistent manner. This abstraction allows for indirection with the backing indices, so they might be changed without callers being impacted. This can also be used to simplify the security model - searches over views do not require permissions to the backing indices only permissions to the view itself. + +```mermaid +classDiagram + class View { + +String name + +String description + +long createdAt + +long modifiedAt + +List targets + +toXContent(XContentBuilder, Params) XContentBuilder + +writeTo(StreamOutput) void + } + class Target { + +String indexPattern + +toXContent(XContentBuilder, Params) XContentBuilder + +writeTo(StreamOutput) void + } + class StreamOutput + class XContentBuilder + + View -- Target : contains + View -- StreamOutput : writes to + View -- XContentBuilder : outputs to + Target -- StreamOutput : writes to + Target -- XContentBuilder : outputs to +``` + +### View persistence + +Views are long lived objects in OpenSearch, all operations on them should be fully committed before responding to the caller. Views are intentionally created for user scenarios following a similar creation cadence to indices. + +Committed implies that the updates are synchronized across all nodes in a cluster. The Cluster Metadata Store is already available and allows for acknowledging that changes have been applied to all nodes. While this data could be stored in a new purpose built index, index data replication has delays and ensuring synchronization is non-trivial to implement as is seen in the Security plugins [1]. + +- [1] https://github.com/opensearch-project/security/issues/3275 + +```mermaid +sequenceDiagram + participant Client + participant HTTP_Request as ActionHandler + participant Cluster_Metadata as Cluster Metadata Store + participant Data_Store as Indices + + Client->>HTTP_Request: View List/Get/Update/Create/Delete
/views or /views/{view_id} + HTTP_Request->>Cluster_Metadata: Query Views + alt Update/Create/Delete + Cluster_Metadata->>Cluster_Metadata: Refresh Cluster + end + Cluster_Metadata-->>HTTP_Request: Return + HTTP_Request-->>Client: Return + + Client->>HTTP_Request: Search View
/views/{view_id}/search + HTTP_Request->>Cluster_Metadata: Query Views + Cluster_Metadata-->>HTTP_Request: Return + HTTP_Request->>HTTP_Request: Rewrite Search Request + HTTP_Request->>HTTP_Request: Validate Search Request + HTTP_Request->>Data_Store: Search indices + Data_Store-->>HTTP_Request: Return + HTTP_Request-->>Client: Return +``` + +### Resource Request + +In order to permissions views OpenSearch needs a way to consistently refer to them, this is a generic problem and views will be a first use case. Resource requests require a map of types to identifiers for the request, multiple resources could be part of a single request, but only one of each type. + +Considering the request to search a view, `POST /view/{view_id}/_search`, the path parameter 'view_id' is the type and the value from the request would be the identifier. + +```java +public interface ResourceRequest { + /** Returns the resource types and ids associated with this request */ + Map getResourceTypeAndIds(); + + /** Validates the resource type and id pairs are in an allowed format */ + public static ActionRequestValidationException validResourceIds( + final ResourceRequest resourceRequest, + final ActionRequestValidationException validationException + ) {;} +} +``` + +### Resource Permission Grants +With requests include resource type and identifiers the security plugin will need to allow for grants to these new types. Modify the security role to include this information so it can be checked and then the request can be permitted. + +```yaml +all_access: + reserved: true + hidden: false + static: true + description: "Allow full access to all indices and all cluster APIs" + cluster_permissions: + - "*" + index_permissions: + - index_patterns: + - "*" + allowed_actions: + - "*" + tenant_permissions: + - tenant_patterns: + - "*" + allowed_actions: + - "kibana_all_write" + resource_permissions: + - resource_type: "view" + resource_ids: ["songs", "albums"] +``` + +## Frequently Asked Questions + +### How do views work with fine grain access control of index data? +*To be determined...* + +### What happens with existing DLS and FLS rules and searches on views? +*To be determined...* + +### Additional Question(s) +*To be determined...* + +## Appendix + +### Local Testing + +``` +curl localhost:9200/abc/_doc \ + -XPOST \ + --header "Content-Type: application/json" \ + --data '{"foo":"bar"}' \ + +curl localhost:9200/views \ + -XPOST \ + --header "Content-Type: application/json" \ + --data '{"name":"hi", "createdAt": -1, "modifiedAt": -1, "targets":[]}' \ + -v + +curl localhost:9200/views \ + -XPOST \ + --header "Content-Type: application/json" \ + --data '{"name":"hi", "createdAt": -1, "modifiedAt": -1, "targets":[{"indexPattern":"abc"}]}' \ + -v + + +curl localhost:9200/views/hi/_search +``` + +### v0 View Data Model + +``` +VIEW MODEL +{ + name: STRING, // [Optional] Friendly name resolves to ID + id: STRING, // Non-mutatable identifier + description: STRING, // [Optional] Description of the view + created: DATE, // Creation time of the view + modified: DATE // Last modified time of the view + query: QUERY, // enforced query + filter: QUERY, // P2 enforced query after transformations + targets: [ + { + indexPattern: STRING, // No wildcard/aliases! + // P2 Allow wildcard/aliases query parameter + query: QUERY, // enforced query specific for this target + filter: QUERY, // P2 enforced query specific after transformations + documentTransformer: SCRIPT // P2 Convert the results in some way + } + ], + documentTransformer: SCRIPT // P2 Convert the results in some way +} +``` + +#### View Operations + +| Method | Path | +| - | - | +| POST | /views | +| GET | /views/{view_id} | +| PUT | /views/{view_id} | +| PATCH | /views/{view_id} | +| DELETE | /views/{view_id} | + +#### Enumerate Views + +| Method | Path | +| - | - | +| GET | /views | + +#### Perform a Search on a view +| Method | Path | +| - | - | +| GET | /views/{view_id}/_search | +| POST | /views/{view_id}/_search | + +#### Search Views // P2? +| Method | Path | +| - | - | +| GET | /views/_search | +| POST | /views/_search | + +#### Mapping // P2? Need to understand the utility / impact of not having this +| Method | Path | +| - | - | +| GET | /views/{view_id}/_mappings | +| PUT | /views/{view_id}/_mappings | +| PATCH | /views/{view_id}/_mappings | + + +*Results do not include any fields '_', how to protect leaking data?* + +#### Response on Create/Enumerate/Search + +views: [ + { + name: STRING, + id: STRING, + description: STRING, + created: DATE, + modified: DATE + } +] \ No newline at end of file diff --git a/server/src/main/java/org/opensearch/node/Node.java b/server/src/main/java/org/opensearch/node/Node.java index 4cbf8dc191a9d..d26a7deae9e77 100644 --- a/server/src/main/java/org/opensearch/node/Node.java +++ b/server/src/main/java/org/opensearch/node/Node.java @@ -72,6 +72,7 @@ import org.opensearch.cluster.metadata.MetadataIndexUpgradeService; import org.opensearch.cluster.metadata.SystemIndexMetadataUpgradeService; import org.opensearch.cluster.metadata.TemplateUpgradeService; +import org.opensearch.cluster.metadata.ViewService; import org.opensearch.cluster.node.DiscoveryNode; import org.opensearch.cluster.node.DiscoveryNodeRole; import org.opensearch.cluster.routing.BatchedRerouteService; @@ -203,6 +204,8 @@ import org.opensearch.repositories.RepositoriesModule; import org.opensearch.repositories.RepositoriesService; import org.opensearch.rest.RestController; +import org.opensearch.rest.action.admin.indices.RestViewAction; +import org.opensearch.rest.action.admin.indices.RestViewSearchAction; import org.opensearch.script.ScriptContext; import org.opensearch.script.ScriptEngine; import org.opensearch.script.ScriptModule; @@ -860,6 +863,10 @@ protected Node( metadataCreateIndexService ); + final ViewService viewService = new ViewService( + clusterService + ); + Collection pluginComponents = pluginsService.filterPlugins(Plugin.class) .stream() .flatMap( @@ -1217,6 +1224,7 @@ protected Node( b.bind(MetadataCreateIndexService.class).toInstance(metadataCreateIndexService); b.bind(AwarenessReplicaBalance.class).toInstance(awarenessReplicaBalance); b.bind(MetadataCreateDataStreamService.class).toInstance(metadataCreateDataStreamService); + b.bind(ViewService.class).toInstance(viewService); b.bind(SearchService.class).toInstance(searchService); b.bind(SearchTransportService.class).toInstance(searchTransportService); b.bind(SearchPhaseController.class) diff --git a/server/src/main/java/org/opensearch/rest/NamedRoute.java b/server/src/main/java/org/opensearch/rest/NamedRoute.java index 109f688a4924e..b477cd8571416 100644 --- a/server/src/main/java/org/opensearch/rest/NamedRoute.java +++ b/server/src/main/java/org/opensearch/rest/NamedRoute.java @@ -144,7 +144,8 @@ private NamedRoute(Builder builder) { "Invalid route name specified. The route name may include the following characters" + " 'a-z', 'A-Z', '0-9', ':', '/', '*', '_' and be less than " + MAX_LENGTH_OF_ACTION_NAME - + " characters" + + " characters, " + + builder.uniqueName ); } this.uniqueName = builder.uniqueName; diff --git a/server/src/main/java/org/opensearch/rest/action/admin/indices/RestViewAction.java b/server/src/main/java/org/opensearch/rest/action/admin/indices/RestViewAction.java new file mode 100644 index 0000000000000..44ace1fcb1f6f --- /dev/null +++ b/server/src/main/java/org/opensearch/rest/action/admin/indices/RestViewAction.java @@ -0,0 +1,179 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.rest.action.admin.indices; + +import joptsimple.internal.Strings; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.client.node.NodeClient; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.ClusterStateUpdateTask; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.metadata.View; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.NamedRoute; +import org.opensearch.rest.RestChannel; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.RestResponse; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.opensearch.rest.RestRequest.Method.DELETE; +import static org.opensearch.rest.RestRequest.Method.GET; +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.rest.RestRequest.Method.PUT; + +/** TODO */ +public class RestViewAction extends BaseRestHandler { + + private final static Logger LOG = LogManager.getLogger(RestViewAction.class); + + public static final String VIEW_ID = "view_id"; + + private final ClusterService clusterService; + + @Inject + public RestViewAction(final ClusterService clusterService) { + this.clusterService = clusterService; + } + + @Override + public List routes() { + final String viewIdParameter = "{" + VIEW_ID + "}"; + + return List.of( + new NamedRoute.Builder().path("/views").method(GET).uniqueName("cluster:views:list").build(), + new NamedRoute.Builder().path("/views").method(POST).uniqueName("cluster:views:create").build(), + new NamedRoute.Builder().path("/views/" + viewIdParameter).method(GET).uniqueName("cluster:views:get").build(), + new NamedRoute.Builder().path("/views/" + viewIdParameter).method(DELETE).uniqueName("cluster:views:delete").build(), + new NamedRoute.Builder().path("/views/" + viewIdParameter).method(PUT).uniqueName("cluster:views:update").build() + ); + } + + @Override + public String getName() { + return "view_actions"; + } + + @Override + public RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException { + if (!request.hasParam(VIEW_ID)) { + if (request.method() == RestRequest.Method.GET) { + return channel -> channel.sendResponse(handleGet(request, channel.newBuilder())); + } + + if (request.method() == RestRequest.Method.POST) { + return channel -> handlePost(request, channel); + } + + } else if (request.hasParam(VIEW_ID)) { + if (request.method() == RestRequest.Method.GET) { + return channel -> channel.sendResponse(handleSingleGet(request, channel.newBuilder())); + } + + if (request.method() == RestRequest.Method.PUT) { + return channel -> handleSinglePut(request); + } + + if (request.method() == RestRequest.Method.DELETE) { + return channel -> handleSingleDelete(request); + } + } + + return channel -> channel.sendResponse( + new BytesRestResponse(RestStatus.BAD_REQUEST, "Unable to process " + request.method() + " on this endpoint " + request.path()) + ); + } + + public RestResponse handleGet(final RestRequest r, final XContentBuilder builder) throws IOException { + final List views = Optional.ofNullable(clusterService.state().getMetadata()) + .map(m -> m.views()) + .map(v -> v.values()) + .map(v -> v.stream().collect(Collectors.toList())) + .orElse(List.of()); + + return new BytesRestResponse(RestStatus.OK, builder.startObject().field("views", views).endObject()); + } + + public RestResponse handlePost(final RestRequest r, final RestChannel channel) throws IOException { + final View inputView; + try (final XContentParser parser = r.contentParser()) { + inputView = View.fromXContent(parser); + } + + final long currentTime = System.currentTimeMillis(); + final View view = new View(inputView.name, inputView.description, currentTime, currentTime, inputView.targets); + + clusterService.submitStateUpdateTask("create_view_task", new ClusterStateUpdateTask() { + @Override + public ClusterState execute(final ClusterState currentState) throws Exception { + return new ClusterState.Builder(clusterService.state()).metadata(Metadata.builder(currentState.metadata()).put(view)) + .build(); + } + + @Override + public void onFailure(final String source, final Exception e) { + LOG.error("Unable to create view, due to {}", source, e); + channel.sendResponse( + new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, "Unknown error occurred, see the log for details.") + ); + } + + @Override + public void clusterStateProcessed(final String source, final ClusterState oldState, final ClusterState newState) { + try { + channel.sendResponse( + new BytesRestResponse(RestStatus.CREATED, channel.newBuilder().startObject().field(view.name, view).endObject()) + ); + } catch (final IOException e) { + // TODO? + LOG.error(e); + } + } + }); + // TODO: Handle CREATED vs UPDATED + return null; + } + + public RestResponse handleSingleGet(final RestRequest r, final XContentBuilder builder) throws IOException { + final String viewId = r.param(VIEW_ID); + + if (Strings.isNullOrEmpty(viewId)) { + return new BytesRestResponse(RestStatus.NOT_FOUND, ""); + } + + final Optional view = Optional.ofNullable(clusterService.state().getMetadata()) + .map(m -> m.views()) + .map(views -> views.get(viewId)); + + if (view.isEmpty()) { + return new BytesRestResponse(RestStatus.NOT_FOUND, ""); + } + + return new BytesRestResponse(RestStatus.OK, builder.startObject().value(view).endObject()); + } + + public RestResponse handleSinglePut(final RestRequest r) { + return new BytesRestResponse(RestStatus.NOT_IMPLEMENTED, ""); + } + + public RestResponse handleSingleDelete(final RestRequest r) { + return new BytesRestResponse(RestStatus.NOT_IMPLEMENTED, ""); + } + +} diff --git a/server/src/main/java/org/opensearch/rest/action/admin/indices/RestViewSearchAction.java b/server/src/main/java/org/opensearch/rest/action/admin/indices/RestViewSearchAction.java new file mode 100644 index 0000000000000..bdda2971ac38a --- /dev/null +++ b/server/src/main/java/org/opensearch/rest/action/admin/indices/RestViewSearchAction.java @@ -0,0 +1,112 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.rest.action.admin.indices; + +import joptsimple.internal.Strings; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.action.search.SearchAction; +import org.opensearch.action.search.ViewSearchRequest; +import org.opensearch.client.node.NodeClient; +import org.opensearch.cluster.metadata.View; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.NamedRoute; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestCancellableNodeClient; +import org.opensearch.rest.action.RestStatusToXContentListener; +import org.opensearch.rest.action.search.RestSearchAction; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.function.IntConsumer; +import java.util.stream.Collectors; + +import static org.opensearch.rest.RestRequest.Method.GET; +import static org.opensearch.rest.RestRequest.Method.POST; + +/** TODO */ +public class RestViewSearchAction extends BaseRestHandler { + + private final static Logger LOG = LogManager.getLogger(RestViewSearchAction.class); + + private static final String VIEW_ID = "view_id"; + + private final ClusterService clusterService; + + @Inject + public RestViewSearchAction(final ClusterService clusterService) { + this.clusterService = clusterService; + } + + @Override + public List routes() { + final String viewIdParameter = "{" + VIEW_ID + "}"; + + return List.of( + new NamedRoute.Builder().path("/views/" + viewIdParameter + "/_search").method(GET).uniqueName("cluster:views:search").build(), + new NamedRoute.Builder().path("/views/" + viewIdParameter + "/_search").method(POST).uniqueName("cluster:views:search").build() + ); + } + + @Override + public String getName() { + return "view_search_action"; + } + + @Override + public RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException { + final String viewId = request.param(VIEW_ID); + return channel -> { + + if (Strings.isNullOrEmpty(viewId)) { + channel.sendResponse(new BytesRestResponse(RestStatus.NOT_FOUND, "")); + } + + final Optional optView = Optional.ofNullable(clusterService.state().getMetadata()) + .map(m -> m.views()) + .map(views -> views.get(viewId)); + + if (optView.isEmpty()) { + channel.sendResponse(new BytesRestResponse(RestStatus.NOT_FOUND, "")); + } + final View view = optView.get(); + + final ViewSearchRequest viewSearchRequest = new ViewSearchRequest(view); + final IntConsumer setSize = size -> viewSearchRequest.source().size(size); + + request.withContentOrSourceParamParserOrNull( + parser -> RestSearchAction.parseSearchRequest( + viewSearchRequest, + request, + parser, + client.getNamedWriteableRegistry(), + setSize + ) + ); + + // TODO: Only allow operations that are supported + + final String[] indices = view.targets.stream() + .map(target -> target.indexPattern) + .collect(Collectors.toList()) + .toArray(new String[0]); + viewSearchRequest.indices(indices); + + // TODO: Look into resource leak on cancelClient? Note; is already leaking in + // server/src/main/java/org/opensearch/rest/action/search/RestSearchAction.java + final RestCancellableNodeClient cancelClient = new RestCancellableNodeClient(client, request.getHttpChannel()); + cancelClient.execute(SearchAction.INSTANCE, viewSearchRequest, new RestStatusToXContentListener<>(channel)); + }; + } +} diff --git a/server/src/test/java/org/opensearch/cluster/metadata/ViewTests.java b/server/src/test/java/org/opensearch/cluster/metadata/ViewTests.java new file mode 100644 index 0000000000000..c1184ddeca915 --- /dev/null +++ b/server/src/test/java/org/opensearch/cluster/metadata/ViewTests.java @@ -0,0 +1,94 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.cluster.metadata; + +import org.opensearch.cluster.metadata.View.Target; +import org.opensearch.common.UUIDs; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.index.Index; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.test.AbstractSerializingTestCase; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import static org.opensearch.cluster.DataStreamTestHelper.createTimestampField; +import static org.opensearch.cluster.metadata.DataStream.getDefaultBackingIndexName; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; + +public class ViewTests extends AbstractSerializingTestCase { + + private static List randomTargets() { + int numTargets = randomIntBetween(0, 128); + List targets = new ArrayList<>(numTargets); + for (int i = 0; i < numTargets; i++) { + targets.add(new Target(randomAlphaOfLength(10).toLowerCase(Locale.ROOT)); + } + return targets; + } + + private static View randomInstance() { + final List targets = randomTargets(); + final String viewName = randomAlphaOfLength(10).toLowerCase(Locale.ROOT); + final String description = randomAlphaOfLength(100).toLowerCase(Locale.ROOT); + return new View(viewName, description, Math.abs(randomLong()), Math.abs(randomLong()), targets); + } + + @Override + protected View doParseInstance(XContentParser parser) throws IOException { + return View.fromXContent(parser); + } + + @Override + protected Writeable.Reader instanceReader() { + return View::new; + } + + @Override + protected View createTestInstance() { + return randomInstance(); + } + + public void testNullName() { + final NullPointerException npe = assertThrows(NullPointerException.class, () -> new View(null, null, null, null, null)); + + assertThat(npe.getMessage(), equalTo("Name must be provided")); + } + + public void testNullTargets() { + final NullPointerException npe = assertThrows(NullPointerException.class, () -> new View("name", null, null, null, null)); + + assertThat(npe.getMessage(), equalTo("Targets are required on a view")); + } + + public void testNullTargetIndexPattern() { + final NullPointerException npe = assertThrows(NullPointerException.class, () -> new View.Target(null)); + + assertThat(npe.getMessage(), equalTo("IndexPattern is required")); + } + + public void testDefaultValues() { + final View view = new View("myName", null, null, null, List.of()); + + assertThat(view.name, equalTo("myName")); + assertThat(view.description, equalTo(null)); + assertThat(view.createdAt, equalTo(-1L)); + assertThat(view.modifiedAt, equalTo(-1L)); + assertThat(view.targets, empty()); + } +}