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.ThreadSafe;
023import java.util.Objects;
024import java.util.Optional;
025
026import static java.lang.String.format;
027import static java.util.Objects.requireNonNull;
028
029/**
030 * Immutable representation of one HTTP entity tag.
031 *
032 * @author <a href="https://www.revetkn.com">Mark Allen</a>
033 */
034@ThreadSafe
035public final class EntityTag {
036        @NonNull
037        private final String value;
038        @NonNull
039        private final Boolean weak;
040
041        @NonNull
042        public static EntityTag fromStrongValue(@NonNull String value) {
043                requireNonNull(value);
044                return new EntityTag(value, false);
045        }
046
047        @NonNull
048        public static EntityTag fromWeakValue(@NonNull String value) {
049                requireNonNull(value);
050                return new EntityTag(value, true);
051        }
052
053        @NonNull
054        public static Optional<EntityTag> fromHeaderValue(@Nullable String headerValue) {
055                String trimmed = Utilities.trimAggressivelyToNull(headerValue);
056
057                if (trimmed == null || "*".equals(trimmed))
058                        return Optional.empty();
059
060                Boolean weak = false;
061                String remaining = trimmed;
062
063                if (remaining.regionMatches(true, 0, "W/", 0, 2)) {
064                        weak = true;
065                        remaining = remaining.substring(2);
066                }
067
068                if (remaining.length() < 2 || remaining.charAt(0) != '"' || remaining.charAt(remaining.length() - 1) != '"')
069                        return Optional.empty();
070
071                String value = remaining.substring(1, remaining.length() - 1);
072
073                if (!isValidOpaqueTag(value))
074                        return Optional.empty();
075
076                return Optional.of(new EntityTag(value, weak));
077        }
078
079        private EntityTag(@NonNull String value,
080                                                                                @NonNull Boolean weak) {
081                requireNonNull(value);
082                requireNonNull(weak);
083
084                if (!isValidOpaqueTag(value))
085                        throw new IllegalArgumentException(format("Invalid entity-tag value '%s'.", value));
086
087                this.value = value;
088                this.weak = weak;
089        }
090
091        @NonNull
092        public Boolean isWeak() {
093                return this.weak;
094        }
095
096        @NonNull
097        public String getValue() {
098                return this.value;
099        }
100
101        @NonNull
102        public String toHeaderValue() {
103                return format("%s\"%s\"", isWeak() ? "W/" : "", getValue());
104        }
105
106        @NonNull
107        public Boolean stronglyMatches(@NonNull EntityTag other) {
108                requireNonNull(other);
109                return !isWeak() && !other.isWeak() && getValue().equals(other.getValue());
110        }
111
112        @NonNull
113        public Boolean weaklyMatches(@NonNull EntityTag other) {
114                requireNonNull(other);
115                return getValue().equals(other.getValue());
116        }
117
118        @Override
119        public String toString() {
120                return format("%s{value=%s, weak=%s}", getClass().getSimpleName(), getValue(), isWeak());
121        }
122
123        @Override
124        public boolean equals(@Nullable Object object) {
125                if (this == object)
126                        return true;
127
128                if (!(object instanceof EntityTag entityTag))
129                        return false;
130
131                return Objects.equals(getValue(), entityTag.getValue())
132                                && Objects.equals(isWeak(), entityTag.isWeak());
133        }
134
135        @Override
136        public int hashCode() {
137                return Objects.hash(getValue(), isWeak());
138        }
139
140        private static boolean isValidOpaqueTag(@NonNull String value) {
141                requireNonNull(value);
142
143                for (int i = 0; i < value.length(); i++) {
144                        char c = value.charAt(i);
145
146                        if (c == '"' || c < 0x21 || c == 0x7F)
147                                return false;
148                }
149
150                return true;
151        }
152}