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}