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