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