001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.NotThreadSafe;
023import javax.annotation.concurrent.ThreadSafe;
024import java.time.Duration;
025import java.util.List;
026import java.util.Objects;
027import java.util.Optional;
028
029import static java.util.Objects.requireNonNull;
030import static com.soklet.internal.mcp.protocol.McpApplicationMetadata.requireApplicationMetadata;
031
032/**
033 * One immutable complete {@code resources/list} page.
034 *
035 * <p>The endpoint's resources-list cache policy owns the fixed cache scope and
036 * default time to live. A page may override only the time to live. The page is
037 * returned directly and does not require a {@link McpCompleteResult} wrapper.
038 * A localization-enabled server conservatively publishes a private,
039 * zero-time-to-live policy regardless of a page override.
040 *
041 * @author <a href="https://www.revetkn.com">Mark Allen</a>
042 */
043@ThreadSafe
044public final class McpResourcePage implements McpOperationResult {
045        @NonNull
046        private final List<@NonNull McpResourceDescriptor> resourceDescriptors;
047        @NonNull
048        private final McpJsonObject metadata;
049        @Nullable
050        private final String nextCursor;
051        @Nullable
052        private final Duration cacheTimeToLiveOverride;
053
054        /** @return an empty resource-page builder */
055        @NonNull
056        public static Builder builder() {
057                return new Builder();
058        }
059
060        private McpResourcePage(@NonNull Builder builder) {
061                this.resourceDescriptors = List.copyOf(builder.resourceDescriptors);
062                this.metadata = requireApplicationMetadata(builder.metadata);
063                this.nextCursor = builder.nextCursor;
064                this.cacheTimeToLiveOverride = builder.cacheTimeToLiveOverride;
065        }
066
067        /** @return immutable resource descriptors in insertion order */
068        @NonNull
069        public List<@NonNull McpResourceDescriptor> getResourceDescriptors() {
070                return this.resourceDescriptors;
071        }
072
073        /** @return immutable result-level protocol extension metadata */
074        @NonNull
075        public McpJsonObject getMetadata() {
076                return this.metadata;
077        }
078
079        /**
080         * Returns the opaque cursor clients should supply for the next page.
081         *
082         * <p>The application is responsible for binding a cursor to the intended
083         * page position, retained snapshot and catalog revision, authorization
084         * context, and expiry as required by its deployment. Soklet does not sign,
085         * encrypt, store, or verify the cursor.
086         *
087         * @return next cursor, or empty when this is the final page
088         */
089        @NonNull
090        public Optional<@NonNull String> getNextCursor() {
091                return Optional.ofNullable(this.nextCursor);
092        }
093
094        /**
095         * Returns this page's time-to-live override.
096         *
097         * @return override, or empty to use the endpoint default
098         */
099        @NonNull
100        public Optional<@NonNull Duration> getCacheTimeToLiveOverride() {
101                return Optional.ofNullable(this.cacheTimeToLiveOverride);
102        }
103
104        /** @return whether every page property is structurally equal */
105        @Override
106        public boolean equals(@Nullable Object other) {
107                if (this == other)
108                        return true;
109                if (!(other instanceof McpResourcePage page))
110                        return false;
111                return this.resourceDescriptors.equals(page.resourceDescriptors)
112                                && this.metadata.equals(page.metadata)
113                                && Objects.equals(this.nextCursor, page.nextCursor)
114                                && Objects.equals(this.cacheTimeToLiveOverride,
115                                                page.cacheTimeToLiveOverride);
116        }
117
118        /** @return structural page hash code */
119        @Override
120        public int hashCode() {
121                return Objects.hash(this.resourceDescriptors, this.metadata, this.nextCursor,
122                                this.cacheTimeToLiveOverride);
123        }
124
125        /**
126         * Mutable builder for an immutable resource page.
127         *
128         * @author <a href="https://www.revetkn.com">Mark Allen</a>
129         */
130        @NotThreadSafe
131        public static final class Builder {
132                @NonNull
133                private List<@NonNull McpResourceDescriptor> resourceDescriptors =
134                                List.of();
135                @NonNull
136                private McpJsonObject metadata = McpJsonObject.emptyInstance();
137                @Nullable
138                private String nextCursor;
139                @Nullable
140                private Duration cacheTimeToLiveOverride;
141
142                private Builder() {
143                }
144
145                /**
146                 * Replaces resource descriptors in supplied order.
147                 * Null or empty clears the property. The complete list is validated and
148                 * snapshotted before replacing the prior value.
149                 *
150                 * @param resourceDescriptors resource descriptors, or null to clear
151                 * @return this builder
152                 * @throws NullPointerException if a list element is null
153                 */
154                @NonNull
155                public Builder resourceDescriptors(
156                                @Nullable List<@NonNull McpResourceDescriptor> resourceDescriptors) {
157                        this.resourceDescriptors = resourceDescriptors == null ? List.of()
158                                        : List.copyOf(resourceDescriptors);
159                        return this;
160                }
161
162                /** @param metadata result-level protocol extension metadata
163                 * @return this builder */
164                @NonNull
165                public Builder metadata(@NonNull McpJsonObject metadata) {
166                        this.metadata = requireNonNull(metadata);
167                        return this;
168                }
169
170                /**
171                 * Supplies the opaque cursor clients should use for the next page.
172                 *
173                 * <p>The application owns the cursor's integrity, confidentiality,
174                 * authorization binding, expiry, page position, retained snapshot,
175                 * catalog revision, and cross-instance portability. An empty string
176                 * remains a present protocol value. Soklet enforces its configured
177                 * outgoing UTF-8 bound but does not mint or protect the value.
178                 *
179                 * @param nextCursor next cursor
180                 * @return this builder
181                 * @throws IllegalArgumentException if the cursor contains malformed UTF-16
182                 */
183                @NonNull
184                public Builder nextCursor(@NonNull String nextCursor) {
185                        this.nextCursor = McpContentValueSupport.requireWellFormedString(requireNonNull(nextCursor));
186                        return this;
187                }
188
189                /**
190                 * Overrides only the endpoint's default time to live for this page.
191                 *
192                 * @param cacheTimeToLiveOverride nonnegative whole-millisecond duration
193                 * @return this builder
194                 * @throws IllegalArgumentException if the duration is negative or has
195                 * sub-millisecond precision
196                 */
197                @NonNull
198                public Builder cacheTimeToLiveOverride(
199                                @NonNull Duration cacheTimeToLiveOverride) {
200                        this.cacheTimeToLiveOverride =
201                                        McpCachePolicy.requireTimeToLive(cacheTimeToLiveOverride);
202                        return this;
203                }
204
205                /** @return immutable resource page */
206                @NonNull
207                public McpResourcePage build() {
208                        return new McpResourcePage(this);
209                }
210        }
211}