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.Arrays;
024import java.util.LinkedHashSet;
025import java.util.List;
026import java.util.Map;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.Set;
030import java.util.stream.Collectors;
031
032import static com.soklet.Utilities.trimAggressively;
033import static com.soklet.Utilities.trimAggressivelyToEmpty;
034import static com.soklet.Utilities.trimAggressivelyToNull;
035import static java.lang.String.format;
036import static java.util.Objects.requireNonNull;
037
038/**
039 * Encapsulates <a href="https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request">CORS preflight</a>-related HTTP request data.
040 * <p>
041 * Instances can be acquired via these factory methods:
042 * <ul>
043 *   <li>{@link #with(String, HttpMethod)} (uses {@code Origin} and {@code Access-Control-Request-Method} header values)</li>
044 *   <li>{@link #with(String, HttpMethod, Set)} (uses {@code Origin}, {@code Access-Control-Request-Method}, and {@code Access-Control-Request-Headers} header values)</li>
045 *   <li>{@link #fromHeaders(Map)} (parses raw headers)</li>
046 * </ul>
047 * Data for <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS">non-preflight CORS</a> requests is represented by {@link Cors}.
048 * <p>
049 * See <a href="https://www.soklet.com/docs/cors">https://www.soklet.com/docs/cors</a> for detailed documentation.
050 *
051 * @author <a href="https://www.revetkn.com">Mark Allen</a>
052 */
053@ThreadSafe
054public final class CorsPreflight {
055        @NonNull
056        private final String origin;
057        @Nullable
058        private final HttpMethod accessControlRequestMethod;
059        @NonNull
060        private final Set<@NonNull String> accessControlRequestHeaders;
061
062        /**
063         * Acquires a CORS <strong>preflight</strong> request representation for the given HTTP request data.
064         * <p>
065         * CORS preflight requests always have method {@code OPTIONS} and specify their target method via
066         * the <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method">{@code Access-Control-Request-Method}</a> header value.
067         *
068         * @param origin                     HTTP <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin">{@code Origin}</a> request header value
069         * @param accessControlRequestMethod HTTP <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method">{@code Access-Control-Request-Method}</a> request header value
070         * @return a {@link CorsPreflight} instance
071         */
072        @NonNull
073        public static CorsPreflight with(@NonNull String origin,
074                                                                                                                                         @NonNull HttpMethod accessControlRequestMethod) {
075                requireNonNull(origin);
076                requireNonNull(accessControlRequestMethod);
077
078                return new CorsPreflight(origin, accessControlRequestMethod, null);
079        }
080
081        /**
082         * Acquires a CORS <strong>preflight</strong> request representation for the given HTTP request data.
083         * <p>
084         * CORS preflight requests always have method {@code OPTIONS} and specify their target method via
085         * the <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method">{@code Access-Control-Request-Method}</a> request value.
086         *
087         * @param origin                      HTTP <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin">{@code Origin}</a> request header value
088         * @param accessControlRequestMethod  HTTP <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method">{@code Access-Control-Request-Method}</a> request header value
089         * @param accessControlRequestHeaders the optional set of HTTP <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers">{@code Access-Control-Request-Headers}</a> request header values
090         * @return a {@link CorsPreflight} instance
091         */
092        @NonNull
093        public static CorsPreflight with(@NonNull String origin,
094                                                                                                                                         @NonNull HttpMethod accessControlRequestMethod,
095                                                                                                                                         @Nullable Set<@NonNull String> accessControlRequestHeaders) {
096                requireNonNull(origin);
097                requireNonNull(accessControlRequestMethod);
098
099                return new CorsPreflight(origin, accessControlRequestMethod, accessControlRequestHeaders);
100        }
101
102        /**
103         * Extracts a <a href="https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request">CORS preflight request</a> representation from the given HTTP request data.
104         * <p>
105         * Note that only HTTP {@code OPTIONS} requests qualify to be CORS preflight requests.
106         *
107         * @param headers the request headers
108         * @return the CORS preflight data for this request, or {@link Optional#empty()} if insufficient data is present
109         */
110        @NonNull
111        public static Optional<CorsPreflight> fromHeaders(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
112                requireNonNull(headers);
113
114                // Build a lowercase-key view of headers for case-insensitive lookups
115                Map<String, Set<String>> normalizedHeaders = headers.entrySet().stream()
116                                .collect(java.util.stream.Collectors.toMap(
117                                                entry -> entry.getKey().toLowerCase(java.util.Locale.ROOT),
118                                                Map.Entry::getValue,
119                                                (a, b) -> b, // if duplicate differing only by case, keep last
120                                                java.util.LinkedHashMap::new));
121
122                Set<String> originHeaderValues = normalizedHeaders.get("origin");
123
124                if (originHeaderValues == null || originHeaderValues.size() == 0)
125                        return Optional.empty();
126
127                String originHeaderValue = trimAggressivelyToNull(originHeaderValues.stream().findFirst().orElse(null));
128
129                if (originHeaderValue == null)
130                        return Optional.empty();
131
132                Set<String> accessControlRequestMethodHeaderValues = normalizedHeaders.get("access-control-request-method");
133
134                if (accessControlRequestMethodHeaderValues == null)
135                        accessControlRequestMethodHeaderValues = Set.of();
136
137                List<HttpMethod> accessControlRequestMethods = accessControlRequestMethodHeaderValues.stream()
138                                .filter(headerValue -> {
139                                        headerValue = trimAggressivelyToEmpty(headerValue);
140
141                                        try {
142                                                HttpMethod.valueOf(headerValue);
143                                                return true;
144                                        } catch (Exception ignored) {
145                                                return false;
146                                        }
147                                })
148                                .map((headerValue -> HttpMethod.valueOf(trimAggressively(headerValue))))
149                                .toList();
150
151                // Preflights are required to have Access-Control-Request-Method defined
152                if (accessControlRequestMethods.size() == 0)
153                        return Optional.empty();
154
155                Set<String> accessControlRequestHeaderValues = Optional
156                                .ofNullable(normalizedHeaders.get("access-control-request-headers"))
157                                .orElse(Set.of())
158                                .stream()
159                                .flatMap(value -> Arrays.stream(value.split(",")))
160                                .map(value -> trimAggressivelyToEmpty(value))
161                                .filter(value -> !value.isEmpty())
162                                .collect(Collectors.toCollection(LinkedHashSet::new));
163
164                if (accessControlRequestHeaderValues == null)
165                        accessControlRequestHeaderValues = Set.of();
166
167                return Optional.of(new CorsPreflight(originHeaderValue, accessControlRequestMethods.get(0), accessControlRequestHeaderValues));
168        }
169
170        private CorsPreflight(@NonNull String origin,
171                                                                                                @NonNull HttpMethod accessControlRequestMethod,
172                                                                                                @Nullable Set<@NonNull String> accessControlRequestHeaders) {
173                requireNonNull(origin);
174                requireNonNull(accessControlRequestMethod);
175
176                this.origin = origin;
177                this.accessControlRequestMethod = accessControlRequestMethod;
178                this.accessControlRequestHeaders = accessControlRequestHeaders == null ?
179                                Set.of() : Set.copyOf(accessControlRequestHeaders);
180        }
181
182        @Override
183        @NonNull
184        public String toString() {
185                return format("%s{origin=%s, accessControlRequestMethod=%s, accessControlRequestHeaders=%s}",
186                                getClass().getSimpleName(), getOrigin(), getAccessControlRequestMethod(), getAccessControlRequestHeaders());
187        }
188
189        @Override
190        public boolean equals(@Nullable Object object) {
191                if (this == object)
192                        return true;
193
194                if (!(object instanceof CorsPreflight cors))
195                        return false;
196
197                return Objects.equals(getOrigin(), cors.getOrigin())
198                                && Objects.equals(getAccessControlRequestMethod(), cors.getAccessControlRequestMethod())
199                                && Objects.equals(getAccessControlRequestHeaders(), cors.getAccessControlRequestHeaders());
200        }
201
202        @Override
203        public int hashCode() {
204                return Objects.hash(getOrigin(), getAccessControlRequestMethod(), getAccessControlRequestHeaders());
205        }
206
207        /**
208         * Returns the HTTP <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin">{@code Origin}</a> request header value.
209         *
210         * @return the header value
211         */
212        @NonNull
213        public String getOrigin() {
214                return this.origin;
215        }
216
217        /**
218         * The HTTP <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method">{@code Access-Control-Request-Method}</a> request header value.
219         *
220         * @return the header value
221         */
222        @NonNull
223        public HttpMethod getAccessControlRequestMethod() {
224                return this.accessControlRequestMethod;
225        }
226
227        /**
228         * Returns the set of values for the HTTP <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers">{@code Access-Control-Request-Headers}</a> request header.
229         *
230         * @return the set of header values, or the empty set if not present
231         */
232        @NonNull
233        public Set<@NonNull String> getAccessControlRequestHeaders() {
234                return this.accessControlRequestHeaders;
235        }
236}