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