001/*
002 * Copyright 2024-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.servlet.jakarta;
018
019import com.soklet.Request;
020import com.soklet.Utilities;
021import jakarta.servlet.AsyncContext;
022import jakarta.servlet.DispatcherType;
023import jakarta.servlet.RequestDispatcher;
024import jakarta.servlet.ServletConnection;
025import jakarta.servlet.ServletContext;
026import jakarta.servlet.ServletException;
027import jakarta.servlet.ServletInputStream;
028import jakarta.servlet.ServletRequest;
029import jakarta.servlet.ServletResponse;
030import jakarta.servlet.http.Cookie;
031import jakarta.servlet.http.HttpServletMapping;
032import jakarta.servlet.http.HttpServletRequest;
033import jakarta.servlet.http.HttpServletResponse;
034import jakarta.servlet.http.HttpSession;
035import jakarta.servlet.http.HttpUpgradeHandler;
036import jakarta.servlet.http.MappingMatch;
037import jakarta.servlet.http.Part;
038
039import javax.annotation.Nonnull;
040import javax.annotation.Nullable;
041import javax.annotation.concurrent.NotThreadSafe;
042import java.io.BufferedReader;
043import java.io.ByteArrayInputStream;
044import java.io.IOException;
045import java.io.InputStream;
046import java.io.InputStreamReader;
047import java.io.UnsupportedEncodingException;
048import java.net.InetAddress;
049import java.net.URI;
050import java.nio.charset.Charset;
051import java.nio.charset.IllegalCharsetNameException;
052import java.nio.charset.StandardCharsets;
053import java.nio.charset.UnsupportedCharsetException;
054import java.security.Principal;
055import java.time.Instant;
056import java.time.ZoneOffset;
057import java.time.format.DateTimeFormatter;
058import java.time.format.DateTimeFormatterBuilder;
059import java.time.format.SignStyle;
060import java.time.temporal.ChronoField;
061import java.util.ArrayList;
062import java.util.Collection;
063import java.util.Collections;
064import java.util.Enumeration;
065import java.util.HashMap;
066import java.util.HashSet;
067import java.util.List;
068import java.util.Locale;
069import java.util.Map;
070import java.util.Map.Entry;
071import java.util.Optional;
072import java.util.Set;
073import java.util.TreeMap;
074import java.util.UUID;
075
076import static java.lang.String.format;
077import static java.util.Locale.ROOT;
078import static java.util.Locale.US;
079import static java.util.Locale.getDefault;
080import static java.util.Objects.requireNonNull;
081
082/**
083 * Soklet integration implementation of {@link HttpServletRequest}.
084 *
085 * @author <a href="https://www.revetkn.com">Mark Allen</a>
086 */
087@NotThreadSafe
088public final class SokletHttpServletRequest implements HttpServletRequest {
089        @Nonnull
090        private static final Charset DEFAULT_CHARSET;
091        @Nonnull
092        private static final DateTimeFormatter RFC_1123_PARSER;
093        @Nonnull
094        private static final DateTimeFormatter RFC_1036_PARSER;
095        @Nonnull
096        private static final DateTimeFormatter ASCTIME_PARSER;
097
098        static {
099                DEFAULT_CHARSET = StandardCharsets.ISO_8859_1; // Per Servlet spec
100                RFC_1123_PARSER = DateTimeFormatter.RFC_1123_DATE_TIME;
101                // RFC 1036: spaces between day/month/year + 2-digit year reduced to 19xx baseline.
102                RFC_1036_PARSER = new DateTimeFormatterBuilder()
103                                .parseCaseInsensitive()
104                                .appendPattern("EEE, dd MMM ")
105                                .appendValueReduced(ChronoField.YEAR, 2, 2, 1900) // 94 -> 1994
106                                .appendPattern(" HH:mm:ss zzz")
107                                .toFormatter(US)
108                                .withZone(ZoneOffset.UTC);
109
110                // asctime: "EEE MMM  d HH:mm:ss yyyy" — allow 1 or 2 spaces before day, no zone in text → default GMT.
111                ASCTIME_PARSER = new DateTimeFormatterBuilder()
112                                .parseCaseInsensitive()
113                                .appendPattern("EEE MMM")
114                                .appendLiteral(' ')
115                                .optionalStart().appendLiteral(' ').optionalEnd() // tolerate double space before single-digit day
116                                .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NOT_NEGATIVE)
117                                .appendPattern(" HH:mm:ss yyyy")
118                                .toFormatter(US)
119                                .withZone(ZoneOffset.UTC);
120        }
121
122        @Nonnull
123        private final Request request;
124        @Nullable
125        private final String host;
126        @Nullable
127        private final Integer port;
128        @Nonnull
129        private final ServletContext servletContext;
130        @Nullable
131        private HttpSession httpSession;
132        @Nonnull
133        private final Map<String, Object> attributes;
134        @Nonnull
135        private final List<Cookie> cookies;
136        @Nullable
137        private Charset charset;
138        @Nullable
139        private String contentType;
140
141        @Nonnull
142        public static Builder withRequest(@Nonnull Request request) {
143                return new Builder(request);
144        }
145
146        private SokletHttpServletRequest(@Nonnull Builder builder) {
147                requireNonNull(builder);
148                requireNonNull(builder.request);
149
150                this.request = builder.request;
151                this.attributes = new HashMap<>();
152                this.cookies = parseCookies(request);
153                this.charset = parseCharacterEncoding(request).orElse(null);
154                this.contentType = parseContentType(request).orElse(null);
155                this.host = builder.host;
156                this.port = builder.port;
157                this.servletContext = builder.servletContext == null ? SokletServletContext.withDefaults() : builder.servletContext;
158                this.httpSession = builder.httpSession;
159        }
160
161        @Nonnull
162        protected Request getRequest() {
163                return this.request;
164        }
165
166        @Nonnull
167        protected Map<String, Object> getAttributes() {
168                return this.attributes;
169        }
170
171        @Nonnull
172        protected List<Cookie> parseCookies(@Nonnull Request request) {
173                requireNonNull(request);
174
175                Map<String, Set<String>> cookies = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
176                cookies.putAll(request.getCookies());
177
178                List<Cookie> convertedCookies = new ArrayList<>(cookies.size());
179
180                for (Entry<String, Set<String>> entry : cookies.entrySet()) {
181                        String name = entry.getKey();
182                        Set<String> values = entry.getValue();
183
184                        // Should never occur...
185                        if (name == null)
186                                continue;
187
188                        for (String value : values)
189                                convertedCookies.add(new Cookie(name, value));
190                }
191
192                return convertedCookies;
193        }
194
195        @Nonnull
196        protected Optional<Charset> parseCharacterEncoding(@Nonnull Request request) {
197                requireNonNull(request);
198                return Utilities.extractCharsetFromHeaders(request.getHeaders());
199        }
200
201        @Nonnull
202        protected Optional<String> parseContentType(@Nonnull Request request) {
203                requireNonNull(request);
204                return Utilities.extractContentTypeFromHeaders(request.getHeaders());
205        }
206
207        @Nonnull
208        protected Optional<HttpSession> getHttpSession() {
209                return Optional.ofNullable(this.httpSession);
210        }
211
212        protected void setHttpSession(@Nullable HttpSession httpSession) {
213                this.httpSession = httpSession;
214        }
215
216        @Nonnull
217        protected Optional<Charset> getCharset() {
218                return Optional.ofNullable(this.charset);
219        }
220
221        protected void setCharset(@Nullable Charset charset) {
222                this.charset = charset;
223        }
224
225        @Nonnull
226        protected Optional<String> getHost() {
227                return Optional.ofNullable(this.host);
228        }
229
230        @Nonnull
231        protected Optional<Integer> getPort() {
232                return Optional.ofNullable(this.port);
233        }
234
235        /**
236         * Builder used to construct instances of {@link SokletHttpServletRequest}.
237         * <p>
238         * This class is intended for use by a single thread.
239         *
240         * @author <a href="https://www.revetkn.com">Mark Allen</a>
241         */
242        @NotThreadSafe
243        public static class Builder {
244                @Nonnull
245                private Request request;
246                @Nullable
247                private Integer port;
248                @Nullable
249                private String host;
250                @Nullable
251                private ServletContext servletContext;
252                @Nullable
253                private HttpSession httpSession;
254
255                @Nonnull
256                private Builder(@Nonnull Request request) {
257                        requireNonNull(request);
258                        this.request = request;
259                }
260
261                @Nonnull
262                public Builder request(@Nonnull Request request) {
263                        requireNonNull(request);
264                        this.request = request;
265                        return this;
266                }
267
268                @Nonnull
269                public Builder host(@Nullable String host) {
270                        this.host = host;
271                        return this;
272                }
273
274                @Nonnull
275                public Builder port(@Nullable Integer port) {
276                        this.port = port;
277                        return this;
278                }
279
280                @Nonnull
281                public Builder servletContext(@Nullable ServletContext servletContext) {
282                        this.servletContext = servletContext;
283                        return this;
284                }
285
286                @Nonnull
287                public Builder httpSession(@Nullable HttpSession httpSession) {
288                        this.httpSession = httpSession;
289                        return this;
290                }
291
292                @Nonnull
293                public SokletHttpServletRequest build() {
294                        return new SokletHttpServletRequest(this);
295                }
296        }
297
298        // Implementation of HttpServletRequest methods below:
299
300        // Helpful reference at https://stackoverflow.com/a/21046620 by Victor Stafusa - BozoNaCadeia
301        //
302        // Method              URL-Decoded Result
303        // ----------------------------------------------------
304        // getContextPath()        no      /app
305        // getLocalAddr()                  127.0.0.1
306        // getLocalName()                  30thh.loc
307        // getLocalPort()                  8480
308        // getMethod()                     GET
309        // getPathInfo()           yes     /a?+b
310        // getProtocol()                   HTTP/1.1
311        // getQueryString()        no      p+1=c+d&p+2=e+f
312        // getRequestedSessionId() no      S%3F+ID
313        // getRequestURI()         no      /app/test%3F/a%3F+b;jsessionid=S+ID
314        // getRequestURL()         no      http://30thh.loc:8480/app/test%3F/a%3F+b;jsessionid=S+ID
315        // getScheme()                     http
316        // getServerName()                 30thh.loc
317        // getServerPort()                 8480
318        // getServletPath()        yes     /test?
319        // getParameterNames()     yes     [p 2, p 1]
320        // getParameter("p 1")     yes     c d
321
322        @Override
323        @Nullable
324        public String getAuthType() {
325                // This is legal according to spec
326                return null;
327        }
328
329        @Override
330        @Nonnull
331        public Cookie[] getCookies() {
332                return this.cookies.toArray(new Cookie[0]);
333        }
334
335        @Override
336        public long getDateHeader(@Nullable String name) {
337                if (name == null)
338                        return -1;
339
340                String value = getHeader(name);
341
342                if (value == null)
343                        return -1;
344
345                // Try HTTP-date formats (RFC 1123 → RFC 1036 → asctime)
346                for (DateTimeFormatter fmt : List.of(RFC_1123_PARSER, RFC_1036_PARSER, ASCTIME_PARSER)) {
347                        try {
348                                return Instant.from(fmt.parse(value)).toEpochMilli();
349                        } catch (Exception ignored) {
350                                // try next
351                        }
352                }
353
354                // Fallback: epoch millis
355                try {
356                        return Long.parseLong(value);
357                } catch (NumberFormatException e) {
358                        throw new IllegalArgumentException(
359                                        String.format("Header with name '%s' and value '%s' cannot be converted to a date", name, value),
360                                        e
361                        );
362                }
363        }
364
365        @Override
366        @Nullable
367        public String getHeader(@Nullable String name) {
368                if (name == null)
369                        return null;
370
371                return getRequest().getHeader(name).orElse(null);
372        }
373
374        @Override
375        @Nonnull
376        public Enumeration<String> getHeaders(@Nullable String name) {
377                if (name == null)
378                        return Collections.emptyEnumeration();
379
380                Set<String> values = request.getHeaders().get(name);
381                return values == null ? Collections.emptyEnumeration() : Collections.enumeration(values);
382        }
383
384        @Override
385        @Nonnull
386        public Enumeration<String> getHeaderNames() {
387                return Collections.enumeration(getRequest().getHeaders().keySet());
388        }
389
390        @Override
391        public int getIntHeader(@Nullable String name) {
392                if (name == null)
393                        return -1;
394
395                String value = getHeader(name);
396
397                if (value == null)
398                        return -1;
399
400                // Throws NumberFormatException if parsing fails, per spec
401                return Integer.valueOf(value, 10);
402        }
403
404        @Override
405        @Nonnull
406        public String getMethod() {
407                return getRequest().getHttpMethod().name();
408        }
409
410        @Override
411        @Nullable
412        public String getPathInfo() {
413                return getRequest().getPath();
414        }
415
416        @Override
417        @Nullable
418        public String getPathTranslated() {
419                return getRequest().getPath();
420        }
421
422        @Override
423        @Nonnull
424        public String getContextPath() {
425                return "";
426        }
427
428        @Override
429        @Nullable
430        public String getQueryString() {
431                try {
432                        URI uri = new URI(request.getUri());
433                        return uri.getQuery();
434                } catch (Exception ignored) {
435                        return null;
436                }
437        }
438
439        @Override
440        @Nullable
441        public String getRemoteUser() {
442                // This is legal according to spec
443                return null;
444        }
445
446        @Override
447        public boolean isUserInRole(@Nullable String role) {
448                // This is legal according to spec
449                return false;
450        }
451
452        @Override
453        @Nullable
454        public Principal getUserPrincipal() {
455                // This is legal according to spec
456                return null;
457        }
458
459        @Override
460        @Nullable
461        public String getRequestedSessionId() {
462                // This is legal according to spec
463                return null;
464        }
465
466        @Override
467        @Nonnull
468        public String getRequestURI() {
469                return getRequest().getPath();
470        }
471
472        @Override
473        @Nonnull
474        public StringBuffer getRequestURL() {
475                // Try forwarded/synthesized absolute prefix first
476                String clientUrlPrefix = Utilities.extractClientUrlPrefixFromHeaders(getRequest().getHeaders()).orElse(null);
477
478                if (clientUrlPrefix != null)
479                        return new StringBuffer(format("%s%s", clientUrlPrefix, getRequest().getPath()));
480
481                // Fall back to builder-provided host/port when available
482                String scheme = getScheme(); // Soklet returns "http" by design
483                String host = getServerName();
484                int port = getServerPort(); // may throw if not initialized by builder
485                boolean defaultPort = ("https".equalsIgnoreCase(scheme) && port == 443) || ("http".equalsIgnoreCase(scheme) && port == 80);
486                String authority = defaultPort ? host : format("%s:%d", host, port);
487                return new StringBuffer(format("%s://%s%s", scheme, authority, getRequest().getPath()));
488        }
489
490        @Override
491        @Nonnull
492        public String getServletPath() {
493                // This is legal according to spec
494                return "";
495        }
496
497        @Override
498        @Nullable
499        public HttpSession getSession(boolean create) {
500                HttpSession currentHttpSession = getHttpSession().orElse(null);
501
502                if (create && currentHttpSession == null) {
503                        currentHttpSession = SokletHttpSession.withServletContext(getServletContext());
504                        setHttpSession(currentHttpSession);
505                }
506
507                return currentHttpSession;
508        }
509
510        @Override
511        @Nonnull
512        public HttpSession getSession() {
513                HttpSession currentHttpSession = getHttpSession().orElse(null);
514
515                if (currentHttpSession == null) {
516                        currentHttpSession = SokletHttpSession.withServletContext(getServletContext());
517                        setHttpSession(currentHttpSession);
518                }
519
520                return currentHttpSession;
521        }
522
523        @Override
524        @Nonnull
525        public String changeSessionId() {
526                HttpSession currentHttpSession = getHttpSession().orElse(null);
527
528                if (currentHttpSession == null)
529                        throw new IllegalStateException("No session is present");
530
531                if (!(currentHttpSession instanceof SokletHttpSession))
532                        throw new IllegalStateException(format("Cannot change session IDs. Session must be of type %s; instead it is of type %s",
533                                        SokletHttpSession.class.getSimpleName(), currentHttpSession.getClass().getSimpleName()));
534
535                UUID newSessionId = UUID.randomUUID();
536                ((SokletHttpSession) currentHttpSession).setSessionId(newSessionId);
537                return String.valueOf(newSessionId);
538        }
539
540        @Override
541        public boolean isRequestedSessionIdValid() {
542                // This is legal according to spec
543                return false;
544        }
545
546        @Override
547        public boolean isRequestedSessionIdFromCookie() {
548                // This is legal according to spec
549                return false;
550        }
551
552        @Override
553        public boolean isRequestedSessionIdFromURL() {
554                // This is legal according to spec
555                return false;
556        }
557
558        @Override
559        public boolean authenticate(@Nonnull HttpServletResponse httpServletResponse) throws IOException, ServletException {
560                requireNonNull(httpServletResponse);
561                // TODO: perhaps revisit this in the future
562                throw new ServletException("Authentication is not supported");
563        }
564
565        @Override
566        public void login(@Nullable String username,
567                                                                                @Nullable String password) throws ServletException {
568                // This is legal according to spec
569                throw new ServletException("Authentication login is not supported");
570        }
571
572        @Override
573        public void logout() throws ServletException {
574                // This is legal according to spec
575                throw new ServletException("Authentication logout is not supported");
576        }
577
578        @Override
579        @Nonnull
580        public Collection<Part> getParts() throws IOException, ServletException {
581                // Legal if the request body is larger than maxRequestSize, or any Part in the request is larger than maxFileSize,
582                // or there is no @MultipartConfig or multipart-config in deployment descriptors
583                throw new IllegalStateException("Servlet multipart configuration is not supported");
584        }
585
586        @Override
587        @Nullable
588        public Part getPart(@Nullable String name) throws IOException, ServletException {
589                // Legal if the request body is larger than maxRequestSize, or any Part in the request is larger than maxFileSize,
590                // or there is no @MultipartConfig or multipart-config in deployment descriptors
591                throw new IllegalStateException("Servlet multipart configuration is not supported");
592        }
593
594        @Override
595        @Nonnull
596        public <T extends HttpUpgradeHandler> T upgrade(@Nullable Class<T> handlerClass) throws IOException, ServletException {
597                // Legal if the given handlerClass fails to be instantiated
598                throw new ServletException("HTTP upgrade is not supported");
599        }
600
601        @Override
602        @Nullable
603        public Object getAttribute(@Nullable String name) {
604                if (name == null)
605                        return null;
606
607                return getAttributes().get(name);
608        }
609
610        @Override
611        @Nonnull
612        public Enumeration<String> getAttributeNames() {
613                return Collections.enumeration(getAttributes().keySet());
614        }
615
616        @Override
617        @Nonnull
618        public String getCharacterEncoding() {
619                Charset charset = getCharset().orElse(null);
620                return charset == null ? null : charset.name();
621        }
622
623        @Override
624        public void setCharacterEncoding(@Nullable String env) throws UnsupportedEncodingException {
625                // Note that spec says: "This method must be called prior to reading request parameters or
626                // reading input using getReader(). Otherwise, it has no effect."
627                // ...but we don't need to care about this because Soklet requests are byte arrays of finite size, not streams
628                if (env == null) {
629                        setCharset(null);
630                } else {
631                        try {
632                                setCharset(Charset.forName(env));
633                        } catch (IllegalCharsetNameException | UnsupportedCharsetException e) {
634                                throw new UnsupportedEncodingException(format("Not sure how to handle character encoding '%s'", env));
635                        }
636                }
637        }
638
639        @Override
640        public int getContentLength() {
641                byte[] body = request.getBody().orElse(null);
642                return body == null ? 0 : body.length;
643        }
644
645        @Override
646        public long getContentLengthLong() {
647                byte[] body = request.getBody().orElse(null);
648                return body == null ? 0 : body.length;
649        }
650
651        @Override
652        @Nullable
653        public String getContentType() {
654                return this.contentType;
655        }
656
657        @Override
658        @Nonnull
659        public ServletInputStream getInputStream() throws IOException {
660                byte[] body = getRequest().getBody().orElse(new byte[]{});
661                return SokletServletInputStream.withInputStream(new ByteArrayInputStream(body));
662        }
663
664        @Override
665        @Nullable
666        public String getParameter(@Nullable String name) {
667                String value = null;
668
669                // First, check query parameters.
670                if (getRequest().getQueryParameters().keySet().contains(name)) {
671                        // If there is a query parameter with the given name, return it
672                        value = getRequest().getQueryParameter(name).orElse(null);
673                } else if (getRequest().getFormParameters().keySet().contains(name)) {
674                        // Otherwise, check form parameters in request body
675                        value = getRequest().getFormParameter(name).orElse(null);
676                }
677
678                return value;
679        }
680
681        @Override
682        @Nonnull
683        public Enumeration<String> getParameterNames() {
684                Set<String> queryParameterNames = getRequest().getQueryParameters().keySet();
685                Set<String> formParameterNames = getRequest().getFormParameters().keySet();
686
687                Set<String> parameterNames = new HashSet<>(queryParameterNames.size() + formParameterNames.size());
688                parameterNames.addAll(queryParameterNames);
689                parameterNames.addAll(formParameterNames);
690
691                return Collections.enumeration(parameterNames);
692        }
693
694        @Override
695        @Nullable
696        public String[] getParameterValues(@Nullable String name) {
697                if (name == null)
698                        return null;
699
700                List<String> parameterValues = new ArrayList<>();
701
702                Set<String> queryValues = getRequest().getQueryParameters().get(name);
703
704                if (queryValues != null)
705                        parameterValues.addAll(queryValues);
706
707                Set<String> formValues = getRequest().getFormParameters().get(name);
708
709                if (formValues != null)
710                        parameterValues.addAll(formValues);
711
712                return parameterValues.isEmpty() ? null : parameterValues.toArray(new String[0]);
713        }
714
715        @Override
716        @Nonnull
717        public Map<String, String[]> getParameterMap() {
718                Map<String, Set<String>> parameterMap = new HashMap<>();
719
720                // Mutable copy of entries
721                for (Entry<String, Set<String>> entry : getRequest().getQueryParameters().entrySet())
722                        parameterMap.put(entry.getKey(), new HashSet<>(entry.getValue()));
723
724                // Add form parameters to entries
725                for (Entry<String, Set<String>> entry : getRequest().getFormParameters().entrySet()) {
726                        Set<String> existingEntries = parameterMap.get(entry.getKey());
727
728                        if (existingEntries != null)
729                                existingEntries.addAll(entry.getValue());
730                        else
731                                parameterMap.put(entry.getKey(), entry.getValue());
732                }
733
734                Map<String, String[]> finalParameterMap = new HashMap<>();
735
736                for (Entry<String, Set<String>> entry : parameterMap.entrySet())
737                        finalParameterMap.put(entry.getKey(), entry.getValue().toArray(new String[0]));
738
739                return Collections.unmodifiableMap(finalParameterMap);
740        }
741
742        @Override
743        @Nonnull
744        public String getProtocol() {
745                return "HTTP/1.1";
746        }
747
748        @Override
749        @Nonnull
750        public String getScheme() {
751                // Honor common reverse-proxy header; fall back to http
752                String proto = getRequest().getHeader("X-Forwarded-Proto").orElse(null);
753
754                if (proto != null) {
755                        proto = proto.trim().toLowerCase(ROOT);
756                        if (proto.equals("https") || proto.equals("http"))
757                                return proto;
758                }
759
760                return "http";
761        }
762
763        @Override
764        @Nonnull
765        public String getServerName() {
766                // Path only (no query parameters) preceded by remote protocol, host, and port (if available)
767                // e.g. https://www.soklet.com/test/abc
768                String clientUrlPrefix = Utilities.extractClientUrlPrefixFromHeaders(getRequest().getHeaders()).orElse(null);
769
770                if (clientUrlPrefix == null)
771                        return getLocalName();
772
773                clientUrlPrefix = clientUrlPrefix.toLowerCase(ROOT);
774
775                // Remove protocol prefix
776                if (clientUrlPrefix.startsWith("https://"))
777                        clientUrlPrefix = clientUrlPrefix.replace("https://", "");
778                else if (clientUrlPrefix.startsWith("http://"))
779                        clientUrlPrefix = clientUrlPrefix.replace("http://", "");
780
781                // Remove "/" and anything after it
782                int indexOfFirstSlash = clientUrlPrefix.indexOf("/");
783
784                if (indexOfFirstSlash != -1)
785                        clientUrlPrefix = clientUrlPrefix.substring(0, indexOfFirstSlash);
786
787                // Remove ":" and anything after it (port)
788                int indexOfColon = clientUrlPrefix.indexOf(":");
789
790                if (indexOfColon != -1)
791                        clientUrlPrefix = clientUrlPrefix.substring(0, indexOfColon);
792
793                return clientUrlPrefix;
794        }
795
796        @Override
797        public int getServerPort() {
798                // Path only (no query parameters) preceded by remote protocol, host, and port (if available)
799                // e.g. https://www.soklet.com/test/abc
800                String clientUrlPrefix = Utilities.extractClientUrlPrefixFromHeaders(getRequest().getHeaders()).orElse(null);
801
802                if (clientUrlPrefix == null)
803                        return getLocalPort();
804
805                clientUrlPrefix = clientUrlPrefix.toLowerCase(ROOT);
806
807                boolean https = false;
808
809                // Remove protocol prefix
810                if (clientUrlPrefix.startsWith("https://")) {
811                        clientUrlPrefix = clientUrlPrefix.replace("https://", "");
812                        https = true;
813                } else if (clientUrlPrefix.startsWith("http://")) {
814                        clientUrlPrefix = clientUrlPrefix.replace("http://", "");
815                }
816
817                // Remove "/" and anything after it
818                int indexOfFirstSlash = clientUrlPrefix.indexOf("/");
819
820                if (indexOfFirstSlash != -1)
821                        clientUrlPrefix = clientUrlPrefix.substring(0, indexOfFirstSlash);
822
823                String[] hostAndPortComponents = clientUrlPrefix.split(":");
824
825                // No explicit port?  Look at protocol for guidance
826                if (hostAndPortComponents.length == 1)
827                        return https ? 443 : 80;
828
829                try {
830                        return Integer.parseInt(hostAndPortComponents[1], 10);
831                } catch (Exception ignored) {
832                        return getLocalPort();
833                }
834        }
835
836        @Override
837        @Nonnull
838        public BufferedReader getReader() throws IOException {
839                Charset charset = getCharset().orElse(DEFAULT_CHARSET);
840                InputStream inputStream = new ByteArrayInputStream(getRequest().getBody().orElse(new byte[0]));
841                return new BufferedReader(new InputStreamReader(inputStream, charset));
842        }
843
844        @Override
845        @Nullable
846        public String getRemoteAddr() {
847                String xForwardedForHeader = getRequest().getHeader("X-Forwarded-For").orElse(null);
848
849                if (xForwardedForHeader == null)
850                        return null;
851
852                // Example value: 203.0.113.195,2001:db8:85a3:8d3:1319:8a2e:370:7348,198.51.100.178
853                String[] components = xForwardedForHeader.split(",");
854
855                if (components.length == 0 || components[0] == null)
856                        return null;
857
858                String value = components[0].trim();
859                return value.length() > 0 ? value : "127.0.0.1";
860        }
861
862        @Override
863        @Nullable
864        public String getRemoteHost() {
865                // This is X-Forwarded-For and is generally what we want (if present)
866                String remoteAddr = getRemoteAddr();
867
868                if (remoteAddr != null)
869                        return remoteAddr;
870
871                // Path only (no query parameters) preceded by remote protocol, host, and port (if available)
872                // e.g. https://www.soklet.com/test/abc
873                String clientUrlPrefix = Utilities.extractClientUrlPrefixFromHeaders(getRequest().getHeaders()).orElse(null);
874
875                if (clientUrlPrefix != null) {
876                        clientUrlPrefix = clientUrlPrefix.toLowerCase(ROOT);
877
878                        // Remove protocol prefix
879                        if (clientUrlPrefix.startsWith("https://"))
880                                clientUrlPrefix = clientUrlPrefix.replace("https://", "");
881                        else if (clientUrlPrefix.startsWith("http://"))
882                                clientUrlPrefix = clientUrlPrefix.replace("http://", "");
883
884                        // Remove "/" and anything after it
885                        int indexOfFirstSlash = clientUrlPrefix.indexOf("/");
886
887                        if (indexOfFirstSlash != -1)
888                                clientUrlPrefix = clientUrlPrefix.substring(0, indexOfFirstSlash);
889
890                        String[] hostAndPortComponents = clientUrlPrefix.split(":");
891
892                        String host = null;
893
894                        if (hostAndPortComponents != null && hostAndPortComponents.length > 0 && hostAndPortComponents[0] != null)
895                                host = hostAndPortComponents[0].trim();
896
897                        if (host != null && host.length() > 0)
898                                return host;
899                }
900
901                // "If the engine cannot or chooses not to resolve the hostname (to improve performance),
902                // this method returns the dotted-string form of the IP address."
903                return getRemoteAddr();
904        }
905
906        @Override
907        public void setAttribute(@Nullable String name,
908                                                                                                         @Nullable Object o) {
909                if (name == null)
910                        return;
911
912                if (o == null)
913                        removeAttribute(name);
914                else
915                        getAttributes().put(name, o);
916        }
917
918        @Override
919        public void removeAttribute(@Nullable String name) {
920                if (name == null)
921                        return;
922
923                getAttributes().remove(name);
924        }
925
926        @Override
927        @Nonnull
928        public Locale getLocale() {
929                List<Locale> locales = getRequest().getLocales();
930                return locales.size() == 0 ? getDefault() : locales.get(0);
931        }
932
933        @Override
934        @Nonnull
935        public Enumeration<Locale> getLocales() {
936                List<Locale> locales = getRequest().getLocales();
937                return Collections.enumeration(locales.size() == 0 ? List.of(getDefault()) : locales);
938        }
939
940        @Override
941        public boolean isSecure() {
942                return getScheme().equals("https");
943        }
944
945        @Override
946        @Nullable
947        public RequestDispatcher getRequestDispatcher(@Nullable String path) {
948                // "This method returns null if the servlet container cannot return a RequestDispatcher."
949                return null;
950        }
951
952        @Override
953        public int getRemotePort() {
954                // Not reliably knowable without a socket; return 0 to indicate "unknown"
955                return 0;
956        }
957
958        @Override
959        @Nonnull
960        public String getLocalName() {
961                if (getHost().isPresent())
962                        return getHost().get();
963
964                try {
965                        String hostName = InetAddress.getLocalHost().getHostName();
966
967                        if (hostName != null) {
968                                hostName = hostName.trim();
969
970                                if (hostName.length() > 0)
971                                        return hostName;
972                        }
973                } catch (Exception e) {
974                        // Ignored
975                }
976
977                return "localhost";
978        }
979
980        @Override
981        @Nonnull
982        public String getLocalAddr() {
983                try {
984                        String hostAddress = InetAddress.getLocalHost().getHostAddress();
985
986                        if (hostAddress != null) {
987                                hostAddress = hostAddress.trim();
988
989                                if (hostAddress.length() > 0)
990                                        return hostAddress;
991                        }
992                } catch (Exception e) {
993                        // Ignored
994                }
995
996                return "127.0.0.1";
997        }
998
999        @Override
1000        public int getLocalPort() {
1001                return getPort().orElseThrow(() -> new IllegalStateException(format("%s must be initialized with a port in order to call this method",
1002                                getClass().getSimpleName())));
1003        }
1004
1005        @Override
1006        @Nonnull
1007        public ServletContext getServletContext() {
1008                return this.servletContext;
1009        }
1010
1011        @Override
1012        @Nonnull
1013        public AsyncContext startAsync() throws IllegalStateException {
1014                throw new IllegalStateException("Soklet does not support async servlet operations");
1015        }
1016
1017        @Override
1018        @Nonnull
1019        public AsyncContext startAsync(@Nonnull ServletRequest servletRequest,
1020                                                                                                                                 @Nonnull ServletResponse servletResponse) throws IllegalStateException {
1021                requireNonNull(servletResponse);
1022                requireNonNull(servletResponse);
1023
1024                throw new IllegalStateException("Soklet does not support async servlet operations");
1025        }
1026
1027        @Override
1028        public boolean isAsyncStarted() {
1029                return false;
1030        }
1031
1032        @Override
1033        public boolean isAsyncSupported() {
1034                return false;
1035        }
1036
1037        @Override
1038        @Nonnull
1039        public AsyncContext getAsyncContext() {
1040                throw new IllegalStateException("Soklet does not support async servlet operations");
1041        }
1042
1043        @Override
1044        @Nonnull
1045        public DispatcherType getDispatcherType() {
1046                // Currently Soklet does not support RequestDispatcher, so this is safe to hardcode
1047                return DispatcherType.REQUEST;
1048        }
1049
1050        // *** Jakarta-specific below
1051
1052        @Nullable
1053        private String requestId;
1054        @Nullable
1055        private ServletConnection servletConnection;
1056
1057        @Override
1058        @Nonnull
1059        public HttpServletMapping getHttpServletMapping() {
1060                // Soklet does not use Servlet mappings. Return a default mapping consistent with the container's default servlet handling ("/").
1061                return new HttpServletMapping() {
1062                        @Override
1063                        @Nonnull
1064                        public String getMatchValue() {
1065                                return ""; // empty for DEFAULT
1066                        }
1067
1068                        @Override
1069                        @Nonnull
1070                        public String getPattern() {
1071                                return "/";
1072                        }
1073
1074                        @Override
1075                        @Nonnull
1076                        public String getServletName() {
1077                                return "Soklet";
1078                        }
1079
1080                        @Override
1081                        @Nonnull
1082                        public MappingMatch getMappingMatch() {
1083                                return MappingMatch.DEFAULT;
1084                        }
1085                };
1086        }
1087
1088        @Override
1089        @Nonnull
1090        public Map<String, String> getTrailerFields() {
1091                // Soklet requests are backed by an in-memory byte array and do not support protocol trailers.
1092                return Map.of();
1093        }
1094
1095        @Override
1096        public boolean isTrailerFieldsReady() {
1097                // There will never be trailers to read for Soklet-backed requests.
1098                return true;
1099        }
1100
1101        @Override
1102        public void setCharacterEncoding(@Nullable Charset encoding) {
1103                // Prefer the new 6.1 overload. Behaves like setCharacterEncoding(String) but without checked exception.
1104                setCharset(encoding);
1105        }
1106
1107        @Override
1108        @Nonnull
1109        public String getRequestId() {
1110                if (this.requestId == null)
1111                        this.requestId = UUID.randomUUID().toString();
1112
1113                return this.requestId;
1114        }
1115
1116        @Override
1117        @Nonnull
1118        public String getProtocolRequestId() {
1119                // Per Servlet 6.1 specification, for HTTP/1.x there is no protocol-defined request ID.
1120                // Return the empty string in that case.
1121                return "";
1122        }
1123
1124        @Override
1125        @Nonnull
1126        public ServletConnection getServletConnection() {
1127                if (this.servletConnection == null) {
1128                        String protocol = getProtocol(); // e.g. "HTTP/1.1"
1129                        boolean secure = "https".equalsIgnoreCase(getScheme());
1130                        String alpn = protocol.toUpperCase(Locale.ROOT).startsWith("HTTP/1") ? "http/1.1" : "unknown";
1131                        String connectionId = UUID.randomUUID().toString();
1132
1133                        this.servletConnection = new ServletConnection() {
1134                                @Override
1135                                @Nonnull
1136                                public String getConnectionId() {
1137                                        return connectionId;
1138                                }
1139
1140                                @Override
1141                                @Nonnull
1142                                public String getProtocol() {
1143                                        return alpn;
1144                                }
1145
1146
1147                                @Override
1148                                @Nonnull
1149                                public String getProtocolConnectionId() {
1150                                        // HTTP/1.x and HTTP/2 do not define a protocol connection ID per spec.
1151                                        return "";
1152                                }
1153
1154                                @Override
1155                                public boolean isSecure() {
1156                                        return secure;
1157                                }
1158                        };
1159                }
1160
1161                return this.servletConnection;
1162        }
1163}