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 jakarta.servlet.Filter;
020import jakarta.servlet.FilterRegistration;
021import jakarta.servlet.RequestDispatcher;
022import jakarta.servlet.Servlet;
023import jakarta.servlet.ServletContext;
024import jakarta.servlet.ServletException;
025import jakarta.servlet.ServletRegistration;
026import jakarta.servlet.ServletRequest;
027import jakarta.servlet.ServletResponse;
028import jakarta.servlet.SessionCookieConfig;
029import jakarta.servlet.SessionTrackingMode;
030import jakarta.servlet.descriptor.JspConfigDescriptor;
031
032import javax.annotation.Nonnull;
033import javax.annotation.Nullable;
034import javax.annotation.concurrent.NotThreadSafe;
035import javax.annotation.concurrent.ThreadSafe;
036import java.io.File;
037import java.io.IOException;
038import java.io.InputStream;
039import java.io.PrintWriter;
040import java.io.StringWriter;
041import java.io.UncheckedIOException;
042import java.io.Writer;
043import java.net.MalformedURLException;
044import java.net.URL;
045import java.net.URLConnection;
046import java.nio.charset.Charset;
047import java.nio.charset.StandardCharsets;
048import java.nio.file.Files;
049import java.nio.file.Path;
050import java.nio.file.Paths;
051import java.util.ArrayList;
052import java.util.Collections;
053import java.util.Enumeration;
054import java.util.EventListener;
055import java.util.HashMap;
056import java.util.List;
057import java.util.Map;
058import java.util.Set;
059import java.util.jar.JarEntry;
060import java.util.jar.JarFile;
061import java.util.stream.Collectors;
062import java.util.stream.Stream;
063
064import static java.lang.String.format;
065import static java.util.Objects.requireNonNull;
066
067/**
068 * Soklet integration implementation of {@link ServletContext}.
069 *
070 * @author <a href="https://www.revetkn.com">Mark Allen</a>
071 */
072@NotThreadSafe
073public final class SokletServletContext implements ServletContext {
074        @Nonnull
075        private final Writer logWriter;
076        @Nonnull
077        private final Map<String, Object> attributes;
078        @Nonnull
079        private int sessionTimeout;
080        @Nullable
081        private Charset requestCharset;
082        @Nullable
083        private Charset responseCharset;
084
085        @Nonnull
086        public static SokletServletContext of() {
087                return new SokletServletContext(null);
088        }
089
090        @Nonnull
091        public static SokletServletContext withLogWriter(@Nullable Writer logWriter) {
092                return new SokletServletContext(logWriter);
093        }
094
095        private SokletServletContext(@Nullable Writer logWriter) {
096                this.logWriter = logWriter == null ? new NoOpWriter() : logWriter;
097                this.attributes = new HashMap<>();
098                this.sessionTimeout = -1;
099                this.requestCharset = StandardCharsets.UTF_8;
100                this.responseCharset = StandardCharsets.UTF_8;
101        }
102
103        @Nonnull
104        protected Writer getLogWriter() {
105                return this.logWriter;
106        }
107
108        @Nonnull
109        protected Map<String, Object> getAttributes() {
110                return this.attributes;
111        }
112
113        @ThreadSafe
114        protected static class NoOpWriter extends Writer {
115                @Override
116                public void write(@Nonnull char[] cbuf,
117                                                                                        int off,
118                                                                                        int len) throws IOException {
119                        requireNonNull(cbuf);
120                        // No-op
121                }
122
123                @Override
124                public void flush() throws IOException {
125                        // No-op
126                }
127
128                @Override
129                public void close() throws IOException {
130                        // No-op
131                }
132        }
133
134        // Implementation of ServletContext methods below:
135
136        @Override
137        @Nullable
138        public String getContextPath() {
139                return "";
140        }
141
142        @Override
143        @Nullable
144        public ServletContext getContext(@Nullable String uripath) {
145                return this;
146        }
147
148        @Override
149        public int getMajorVersion() {
150                return 4;
151        }
152
153        @Override
154        public int getMinorVersion() {
155                return 0;
156        }
157
158        @Override
159        public int getEffectiveMajorVersion() {
160                return 4;
161        }
162
163        @Override
164        public int getEffectiveMinorVersion() {
165                return 0;
166        }
167
168        @Override
169        @Nullable
170        public String getMimeType(@Nullable String file) {
171                if (file == null)
172                        return null;
173
174                return URLConnection.guessContentTypeFromName(file);
175        }
176
177        @Override
178        @Nonnull
179        public Set<String> getResourcePaths(@Nullable String path) {
180                // TODO: revisit https://javaee.github.io/javaee-spec/javadocs/javax/servlet/ServletContext.html#getResourcePaths-java.lang.String-
181                if (path == null || !path.startsWith("/"))
182                        return java.util.Set.of();
183
184                try {
185                        String normalized = path.equals("/") ? "" : path.substring(1);
186
187                        if (!normalized.endsWith("/") && !normalized.isEmpty())
188                                normalized += "/";
189
190                        Enumeration<URL> roots =
191                                        Thread.currentThread().getContextClassLoader().getResources(normalized);
192
193                        Set<String> out = new java.util.TreeSet<>();
194
195                        while (roots.hasMoreElements()) {
196                                URL url = roots.nextElement();
197                                String protocol = url.getProtocol();
198
199                                if ("file".equals(protocol)) {
200                                        Path p = Paths.get(url.toURI());
201
202                                        try (Stream<Path> s = Files.list(p)) {
203                                                s.forEach(child -> {
204                                                        String name = child.getFileName().toString();
205                                                        boolean dir = java.nio.file.Files.isDirectory(child);
206                                                        out.add((path.endsWith("/") ? path : path + "/") + name + (dir ? "/" : ""));
207                                                });
208                                        }
209                                } else if ("jar".equals(protocol)) {
210                                        String spec = url.getFile();           // e.g. file:/app.jar!/static/
211                                        int bang = spec.indexOf("!");
212                                        String jarPath = spec.substring(0, bang);
213                                        java.net.URL jarUrl = new java.net.URL(jarPath);
214                                        try (JarFile jar = new JarFile(new File(jarUrl.toURI()))) {
215                                                String prefix = normalized;
216                                                jar.stream()
217                                                                .map(JarEntry::getName)
218                                                                .filter(n -> n.startsWith(prefix) && !n.equals(prefix))
219                                                                .map(n -> {
220                                                                        String remainder = n.substring(prefix.length());
221                                                                        int slash = remainder.indexOf('/');
222                                                                        if (slash == -1)
223                                                                                return (path.endsWith("/") ? path : path + "/") + remainder;
224
225                                                                        return (path.endsWith("/") ? path : path + "/") + remainder.substring(0, slash + 1);
226                                                                })
227                                                                .forEach(out::add);
228                                        }
229                                }
230                        }
231                        return out;
232                } catch (Exception ignored) {
233                        return Set.of();
234                }
235        }
236
237        @Override
238        @Nullable
239        public URL getResource(@Nullable String path) throws MalformedURLException {
240                // TODO: revisit https://javaee.github.io/javaee-spec/javadocs/javax/servlet/ServletContext.html#getResource-java.lang.String-
241                if (path == null || !path.startsWith("/"))
242                        return null;
243
244                return getClass().getResource(path); // may be null
245        }
246
247        @Override
248        @Nullable
249        public InputStream getResourceAsStream(@Nullable String path) {
250                // TODO: revisit https://javaee.github.io/javaee-spec/javadocs/javax/servlet/ServletContext.html#getResourceAsStream-java.lang.String-
251                if (path == null || !path.startsWith("/"))
252                        return null;
253
254                return getClass().getResourceAsStream(path); // may be null
255        }
256
257        @Override
258        @Nullable
259        public RequestDispatcher getRequestDispatcher(@Nullable String path) {
260                // TODO: revisit https://javaee.github.io/javaee-spec/javadocs/javax/servlet/ServletContext.html#getRequestDispatcher-java.lang.String-
261                if (path == null || path.isBlank())
262                        return null;
263
264                return new RequestDispatcher() {
265                        @Override
266                        public void forward(ServletRequest servletRequest, ServletResponse servletResponse) {
267                                throw new IllegalStateException("RequestDispatcher.forward is not supported by Soklet.");
268                        }
269
270                        @Override
271                        public void include(ServletRequest servletRequest, ServletResponse servletResponse) {
272                                throw new IllegalStateException("RequestDispatcher.include is not supported by Soklet.");
273                        }
274                };
275        }
276
277        @Override
278        @Nullable
279        public RequestDispatcher getNamedDispatcher(@Nullable String name) {
280                // TODO: revisit https://javaee.github.io/javaee-spec/javadocs/javax/servlet/ServletContext.html#getNamedDispatcher-java.lang.String-
281                // This is legal according to spec, but we likely want a real instance returned
282                return null;
283        }
284
285        @Override
286        public void log(@Nullable String msg) {
287                if (msg == null)
288                        return;
289
290                try {
291                        getLogWriter().write(msg);
292                } catch (IOException e) {
293                        throw new UncheckedIOException(e);
294                }
295        }
296
297        @Override
298        public void log(@Nullable String message,
299                                                                        @Nullable Throwable throwable) {
300                List<String> components = new ArrayList<>(2);
301
302                if (message != null)
303                        components.add(message);
304
305                if (throwable != null) {
306                        StringWriter stringWriter = new StringWriter();
307                        PrintWriter printWriter = new PrintWriter(stringWriter);
308                        throwable.printStackTrace(printWriter);
309                        components.add(stringWriter.toString());
310                }
311
312                String combinedMessage = components.stream().collect(Collectors.joining("\n"));
313
314                try {
315                        getLogWriter().write(combinedMessage);
316                } catch (IOException e) {
317                        throw new UncheckedIOException(e);
318                }
319        }
320
321        @Override
322        @Nullable
323        public String getRealPath(@Nullable String path) {
324                // Soklet has no concept of a physical path on the filesystem for a URL path
325                return null;
326        }
327
328        @Override
329        @Nonnull
330        public String getServerInfo() {
331                return "Soklet/Undefined";
332        }
333
334        @Override
335        @Nullable
336        public String getInitParameter(String name) {
337                // Soklet has no concept of init parameters
338                return null;
339        }
340
341        @Override
342        @Nonnull
343        public Enumeration<String> getInitParameterNames() {
344                // Soklet has no concept of init parameters
345                return Collections.emptyEnumeration();
346        }
347
348        @Override
349        public boolean setInitParameter(@Nullable String name,
350                                                                                                                                        @Nullable String value) {
351                throw new IllegalStateException(format("Soklet does not support %s init parameters.",
352                                ServletContext.class.getSimpleName()));
353        }
354
355        @Override
356        @Nullable
357        public Object getAttribute(@Nullable String name) {
358                return name == null ? null : getAttributes().get(name);
359        }
360
361        @Override
362        @Nonnull
363        public Enumeration<String> getAttributeNames() {
364                return Collections.enumeration(getAttributes().keySet());
365        }
366
367        @Override
368        public void setAttribute(@Nullable String name,
369                                                                                                         @Nullable Object object) {
370                if (name == null)
371                        return;
372
373                if (object == null)
374                        removeAttribute(name);
375                else
376                        getAttributes().put(name, object);
377        }
378
379        @Override
380        public void removeAttribute(@Nullable String name) {
381                getAttributes().remove(name);
382        }
383
384        @Override
385        @Nullable
386        public String getServletContextName() {
387                // This is legal according to spec
388                return null;
389        }
390
391        @Override
392        @Nullable
393        public ServletRegistration.Dynamic addServlet(@Nullable String servletName,
394                                                                                                                                                                                                @Nullable String className) {
395                throw new IllegalStateException("Soklet does not support adding Servlets");
396        }
397
398        @Override
399        @Nullable
400        public ServletRegistration.Dynamic addServlet(@Nullable String servletName,
401                                                                                                                                                                                                @Nullable Servlet servlet) {
402                throw new IllegalStateException("Soklet does not support adding Servlets");
403        }
404
405        @Override
406        @Nullable
407        public ServletRegistration.Dynamic addServlet(@Nullable String servletName,
408                                                                                                                                                                                                @Nullable Class<? extends Servlet> servletClass) {
409                throw new IllegalStateException("Soklet does not support adding Servlets");
410        }
411
412        @Override
413        @Nullable
414        public ServletRegistration.Dynamic addJspFile(@Nullable String servletName,
415                                                                                                                                                                                                @Nullable String jspFile) {
416                throw new IllegalStateException("Soklet does not support adding JSP files");
417        }
418
419        @Override
420        @Nullable
421        public <T extends Servlet> T createServlet(@Nullable Class<T> clazz) throws ServletException {
422                throw new ServletException("Soklet does not support creating Servlets");
423        }
424
425        @Override
426        @Nullable
427        public ServletRegistration getServletRegistration(@Nullable String servletName) {
428                // This is legal according to spec
429                return null;
430        }
431
432        @Override
433        @Nonnull
434        public Map<String, ? extends ServletRegistration> getServletRegistrations() {
435                return Map.of();
436        }
437
438        @Override
439        @Nullable
440        public FilterRegistration.Dynamic addFilter(@Nullable String filterName,
441                                                                                                                                                                                        @Nullable String className) {
442                throw new IllegalStateException("Soklet does not support adding Filters");
443        }
444
445        @Override
446        @Nullable
447        public FilterRegistration.Dynamic addFilter(@Nullable String filterName,
448                                                                                                                                                                                        @Nullable Filter filter) {
449                throw new IllegalStateException("Soklet does not support adding Filters");
450        }
451
452        @Override
453        @Nullable
454        public FilterRegistration.Dynamic addFilter(@Nullable String filterName,
455                                                                                                                                                                                        @Nullable Class<? extends Filter> filterClass) {
456                throw new IllegalStateException("Soklet does not support adding Filters");
457        }
458
459        @Override
460        @Nullable
461        public <T extends Filter> T createFilter(@Nullable Class<T> clazz) throws ServletException {
462                throw new ServletException("Soklet does not support creating Filters");
463        }
464
465        @Override
466        @Nullable
467        public FilterRegistration getFilterRegistration(@Nullable String filterName) {
468                // This is legal according to spec
469                return null;
470        }
471
472        @Override
473        @Nonnull
474        public Map<String, ? extends FilterRegistration> getFilterRegistrations() {
475                return Map.of();
476        }
477
478        @Override
479        @Nullable
480        public SessionCookieConfig getSessionCookieConfig() {
481                // Diverges from spec here; Soklet has no concept of "session cookie"
482                throw new IllegalStateException("Soklet does not support session cookies");
483        }
484
485        @Override
486        public void setSessionTrackingModes(@Nullable Set<SessionTrackingMode> sessionTrackingModes) {
487                throw new IllegalStateException("Soklet does not support session tracking");
488        }
489
490        @Override
491        @Nonnull
492        public Set<SessionTrackingMode> getDefaultSessionTrackingModes() {
493                return Set.of();
494        }
495
496        @Override
497        @Nonnull
498        public Set<SessionTrackingMode> getEffectiveSessionTrackingModes() {
499                return Set.of();
500        }
501
502        @Override
503        public void addListener(@Nullable String className) {
504                throw new IllegalStateException("Soklet does not support listeners");
505        }
506
507        @Override
508        @Nullable
509        public <T extends EventListener> void addListener(@Nullable T t) {
510                throw new IllegalStateException("Soklet does not support listeners");
511        }
512
513        @Override
514        public void addListener(@Nullable Class<? extends EventListener> listenerClass) {
515                throw new IllegalStateException("Soklet does not support listeners");
516        }
517
518        @Override
519        @Nullable
520        public <T extends EventListener> T createListener(@Nullable Class<T> clazz) throws ServletException {
521                throw new ServletException("Soklet does not support listeners");
522        }
523
524        @Override
525        @Nullable
526        public JspConfigDescriptor getJspConfigDescriptor() {
527                // This is legal according to spec
528                return null;
529        }
530
531        @Override
532        @Nonnull
533        public ClassLoader getClassLoader() {
534                return this.getClass().getClassLoader();
535        }
536
537        @Override
538        public void declareRoles(@Nullable String... strings) {
539                throw new IllegalStateException("Soklet does not support Servlet roles");
540        }
541
542        @Override
543        @Nonnull
544        public String getVirtualServerName() {
545                return "soklet";
546        }
547
548        @Override
549        public int getSessionTimeout() {
550                return this.sessionTimeout;
551        }
552
553        @Override
554        public void setSessionTimeout(int sessionTimeout) {
555                this.sessionTimeout = sessionTimeout;
556        }
557
558        @Override
559        @Nullable
560        public String getRequestCharacterEncoding() {
561                return this.requestCharset == null ? null : this.requestCharset.name();
562        }
563
564        @Override
565        public void setRequestCharacterEncoding(@Nullable String encoding) {
566                this.requestCharset = encoding == null ? null : Charset.forName(encoding);
567        }
568
569        @Override
570        @Nullable
571        public String getResponseCharacterEncoding() {
572                return this.responseCharset == null ? null : this.responseCharset.name();
573        }
574
575        @Override
576        public void setResponseCharacterEncoding(@Nullable String encoding) {
577                this.responseCharset = encoding == null ? null : Charset.forName(encoding);
578        }
579}