This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 780afa03bd feat(rest-server): BasicStaticFilesResource mixin + HEAD
body-suppression + @OpSwagger(ignore) (FINISHED-75)
780afa03bd is described below
commit 780afa03bd66358127c8e042c59158ee2604e5ff
Author: James Bognar <[email protected]>
AuthorDate: Sun May 24 16:48:13 2026 -0400
feat(rest-server): BasicStaticFilesResource mixin + HEAD body-suppression +
@OpSwagger(ignore) (FINISHED-75)
---
.../apache/juneau/rest/annotation/OpSwagger.java | 33 +++++
.../rest/annotation/OpSwaggerAnnotation.java | 19 +++
.../rest/processor/HttpResourceProcessor.java | 9 ++
.../juneau/rest/servlet/BasicRestOperations.java | 10 +-
.../rest/staticfile/BasicStaticFilesResource.java | 159 +++++++++++++++++++++
.../rest/swagger/BasicSwaggerProviderSession.java | 14 +-
.../BasicStaticFilesResource_AsMixin_Test.java | 125 ++++++++++++++++
...BasicStaticFilesResource_CacheControl_Test.java | 74 ++++++++++
...cStaticFilesResource_ImporterOverride_Test.java | 71 +++++++++
...StaticFilesResource_JettyMicroservice_Test.java | 146 +++++++++++++++++++
...asicStaticFilesResource_OpenApiHidden_Test.java | 131 +++++++++++++++++
...StaticFilesResource_SpringbootMetaInf_Test.java | 139 ++++++++++++++++++
.../BasicStaticFilesResource_Springboot_Test.java | 156 ++++++++++++++++++++
.../BasicStaticFilesResource_Standalone_Test.java | 90 ++++++++++++
.../META-INF/resources/spring-fixture.txt | 13 ++
...-files.md => FINISHED-75-mixin-static-files.md} | 83 +++++++++--
todo/TODO.md | 4 +-
17 files changed, 1253 insertions(+), 23 deletions(-)
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/OpSwagger.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/OpSwagger.java
index 7593dfefaf..cf8168bd47 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/OpSwagger.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/OpSwagger.java
@@ -97,6 +97,39 @@ public @interface OpSwagger {
*/
String[] description() default {};
+ /**
+ * Excludes this operation from the published Swagger / OpenAPI
specification.
+ *
+ * <p>
+ * When set to <jk>true</jk>, this operation is skipped during Swagger
/ OpenAPI generation:
+ * neither the path entry nor any sibling HTTP method on the same path
is emitted unless
+ * another operation contributes to it. This is useful for endpoints
that are not API-meaningful
+ * (e.g. greedy {@code /*} static-file handlers, {@code /favicon.ico},
internal probes) where
+ * documenting the operation would just be noise.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <ja>@RestGet</ja>(
+ * path=<js>"/static/*"</js>,
+ * swagger=<ja>@OpSwagger</ja>(
+ * ignore=<jk>true</jk>
+ * )
+ * )
+ * </p>
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * Default is <jk>false</jk> — operations are
emitted by default.
+ * <li class='note'>
+ * Schema-level {@code ignore} flags (on bean fields /
parameters) are separate from this
+ * method-level flag — this one suppresses the
entire operation; schema-level flags
+ * suppress individual fields within a referenced schema.
+ * </ul>
+ *
+ * @return The annotation value.
+ */
+ boolean ignore() default false;
+
/**
* Defines the swagger field <c>/paths/{path}/{method}/externalDocs</c>.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/OpSwaggerAnnotation.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/OpSwaggerAnnotation.java
index e5a4318945..f557a59f4a 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/OpSwaggerAnnotation.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/OpSwaggerAnnotation.java
@@ -49,6 +49,7 @@ public class OpSwaggerAnnotation {
private String[] description = {};
private ExternalDocs externalDocs =
ExternalDocsAnnotation.DEFAULT;
private String deprecated = "";
+ private boolean ignore;
private String operationId = "";
private String[] consumes = {};
private String[] parameters = {};
@@ -119,6 +120,17 @@ public class OpSwaggerAnnotation {
return this;
}
+ /**
+ * Sets the {@link OpSwagger#ignore()} property on this
annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder ignore(boolean value) {
+ ignore = value;
+ return this;
+ }
+
/**
* Sets the {@link OpSwagger#operationId()} property on this
annotation.
*
@@ -217,6 +229,7 @@ public class OpSwaggerAnnotation {
private final String[] description;
private final ExternalDocs externalDocs;
private final String deprecated;
+ private final boolean ignore;
private final String operationId;
private final String[] consumes;
private final String[] parameters;
@@ -233,6 +246,7 @@ public class OpSwaggerAnnotation {
consumes = copyOf(b.consumes);
deprecated = b.deprecated;
externalDocs = b.externalDocs;
+ ignore = b.ignore;
operationId = b.operationId;
parameters = copyOf(b.parameters);
produces = copyOf(b.produces);
@@ -258,6 +272,11 @@ public class OpSwaggerAnnotation {
return externalDocs;
}
+ @Override /* Overridden from OpSwagger */
+ public boolean ignore() {
+ return ignore;
+ }
+
@Override /* Overridden from OpSwagger */
public String operationId() {
return operationId;
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/HttpResourceProcessor.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/HttpResourceProcessor.java
index baa969a10b..b39891e547 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/HttpResourceProcessor.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/HttpResourceProcessor.java
@@ -25,6 +25,11 @@ import org.apache.juneau.rest.*;
/**
* Response handler for {@link HttpResource} objects.
*
+ * <p>
+ * {@code HEAD} requests are handled per RFC 7231 §4.3.2: identical headers to
the equivalent
+ * {@code GET} are emitted (Content-Type, Content-Length, plus any
resource-supplied headers)
+ * but the response body is suppressed.
+ *
* <h5 class='section'>See Also:</h5><ul>
* <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/ResponseProcessors">Response
Processors</a>
* </ul>
@@ -49,6 +54,10 @@ public class HttpResourceProcessor implements
ResponseProcessor {
r.getHeaders().forEach(res::addHeader);
+ // RFC 7231 §4.3.2: HEAD must mirror GET headers but omit the
body.
+ if ("HEAD".equalsIgnoreCase(opSession.getRequest().getMethod()))
+ return FINISHED;
+
try (var os = res.getNegotiatedOutputStream()) {
r.writeTo(os);
os.flush();
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestOperations.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestOperations.java
index d3fc84be5a..cc5d20e405 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestOperations.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestOperations.java
@@ -118,6 +118,13 @@ public interface BasicRestOperations {
/**
* [GET /htdocs/*] - Retrieve static file.
*
+ * <p>
+ * Excluded from the published Swagger/OpenAPI specification via
+ * {@link OpSwagger#ignore() @OpSwagger(ignore=true)} — a greedy
{@code /htdocs/*} blob
+ * handler is not API-meaningful, and emitting it as an operation just
produces noise. This
+ * mirrors the exclusion strategy used by the {@code
BasicStaticFilesResource} mixin and keeps
+ * legacy {@code BasicRestServlet}-hosted apps consistent with
mixin-based static-file mounts.
+ *
* @param path The path to retrieve.
* @param locale The locale of the HTTP request.
* @return An HTTP resource representing the static file.
@@ -125,7 +132,8 @@ public interface BasicRestOperations {
@RestGet(
path="/htdocs/*",
summary="Static files",
- description="Static file retrieval."
+ description="Static file retrieval.",
+ swagger=@OpSwagger(ignore=true)
) HttpResource getHtdoc(@Path String path, Locale locale);
/**
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/staticfile/BasicStaticFilesResource.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/staticfile/BasicStaticFilesResource.java
new file mode 100644
index 0000000000..379f7251bb
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/staticfile/BasicStaticFilesResource.java
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.staticfile;
+
+import java.util.*;
+
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.http.*;
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+/**
+ * Mixin that serves static files from the active {@link StaticFiles}
implementation under
+ * {@code /static/*} and {@code /htdocs/*}.
+ *
+ * <p>
+ * Wraps the existing {@link BasicStaticFiles} plumbing (a {@link StaticFiles}
implementation, not a
+ * servlet) in a servlet-level mixin with multi-mount support so any Juneau
resource can opt into
+ * static-file serving via {@link Rest#mixins()
@Rest(mixins=BasicStaticFilesResource.class)}. The
+ * mixin reads the active {@link StaticFiles} from the bean store at request
time
+ * — importer's {@code @Bean StaticFiles} declarations + classpath
defaults are picked up
+ * automatically.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * <jc>// Vanilla resource gains static-file serving at /static/* and
/htdocs/*.</jc>
+ * <ja>@Rest</ja>(path=<js>"/api"</js>,
mixins=BasicStaticFilesResource.<jk>class</jk>)
+ * <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet {
+ * <ja>@RestGet</ja>(<js>"/items"</js>) <jk>public</jk>
List<Item> items() { ... }
+ * }
+ * </p>
+ *
+ * <h5 class='figure'>Standalone deployment example:</h5>
+ *
+ * <p class='bjava'>
+ * <jc>// Mount as a standalone resource; the inherited paths declare both
mount points.</jc>
+ *
<ja>@Rest</ja>(paths={<js>"/static/*"</js>,<js>"/htdocs/*"</js>,<js>"/assets/*"</js>})
+ * <jk>public class</jk> CdnResource <jk>extends</jk>
BasicStaticFilesResource { }
+ * </p>
+ *
+ * <h5 class='section'>Behavior:</h5>
+ *
+ * <ul class='spaced-list'>
+ * <li>GET hits the active {@link StaticFiles} bean (resolved via
+ * {@link RestRequest#getStaticFiles()}); missing paths surface as
+ * {@link NotFound} thrown from the handler.
+ * <li>{@code HEAD} requests are accepted via the standard servlet
+ * {@code HEAD}-via-{@code GET} contract: the response carries
identical headers to the
+ * corresponding {@code GET} (including {@code Cache-Control} and
{@code Content-Type}) with
+ * an empty body.
+ * <li>{@code Cache-Control} headers come from the {@link
BasicStaticFiles} default
+ * ({@code max-age=86400, public}); importers can override via
+ * {@code BasicStaticFiles.create(beanStore).headers(...)} when
registering their own
+ * {@code @Bean StaticFiles}.
+ * <li>Default classpath base searches both {@code static/} and {@code
htdocs/} directories on
+ * the importer's classpath (recursive walk via {@link
BasicStaticFiles}'s built-in
+ * {@link org.apache.juneau.cp.ResourceSupplier} hook).
+ * </ul>
+ *
+ * <h5 class='section'>Path matching:</h5>
+ *
+ * <p>
+ * The handler declares {@code @RestGet(path={"/static/*","/htdocs/*"})} so a
single Java method
+ * binds to both URL prefixes. The trailing {@code /*} captures the
multi-segment remainder via
+ * {@code @Path("/*") String path}. Juneau's {@code UrlPathMatcher} does not
support the
+ * Spring/JAX-RS {@code {var:regex}} syntax — each {@code {var}} matches
a single segment
+ * only, and multi-segment matching is only available via the trailing-{@code
*} pattern shown here
+ * (same idiom as the legacy {@code BasicRestServlet.getHtdoc(...)} accessor).
+ * </p>
+ *
+ * <h5 class='section'>OpenAPI surface:</h5>
+ *
+ * <p>
+ * The greedy {@code /*} handler is not API-meaningful and is excluded from
generated Swagger /
+ * OpenAPI specs via {@link OpSwagger#ignore() @OpSwagger(ignore=true)}.
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link BasicStaticFiles}
+ * <li class='jc'>{@link StaticFiles}
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/StaticFiles">Static files</a>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server
— Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+// @formatter:off
+@Rest(paths={"/static/*","/htdocs/*"})
+public class BasicStaticFilesResource {
+
+ /**
+ * [GET /static/* | /htdocs/*] — serve a static file from the
active {@link StaticFiles} bean.
+ *
+ * <p>
+ * The {@code @Path("/*") String path} captures the multi-segment
trailing remainder. The
+ * {@code Locale} parameter is auto-injected from the request and
forwarded to
+ * {@link StaticFiles#resolve(String,Locale) StaticFiles.resolve(...)}
so localized lookups
+ * (e.g. {@code styles_fr.css}) work out of the box.
+ *
+ * @param req The current REST request — supplies {@link
RestRequest#getStaticFiles()}.
+ * @param path The trailing remainder after the mount prefix (the file
path within the
+ * {@code static/} or {@code htdocs/} classpath / filesystem
search roots).
+ * @param locale The request locale (used for localized resource
lookups).
+ * @return The matching {@link HttpResource} (with content type + cache
headers).
+ * @throws NotFound If no resource matches the requested path.
+ */
+ @RestGet(
+ path={"/static/*","/htdocs/*"},
+ summary="Static files",
+ description="Static file retrieval.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public HttpResource getStaticFile(RestRequest req, @Path("/*") String
path, Locale locale) {
+ return req.getStaticFiles().resolve(path,
locale).orElseThrow(NotFound::new);
+ }
+
+ /**
+ * [HEAD /static/* | /htdocs/*] — return GET headers for a static
file without the body.
+ *
+ * <p>
+ * Per RFC 7231 §4.3.2, {@code HEAD} mirrors the equivalent {@code
GET}'s headers (Content-Type,
+ * Content-Length, Cache-Control) but emits an empty response body. The
shared
+ * {@link org.apache.juneau.rest.processor.HttpResourceProcessor}
performs the body suppression
+ * at the response-processor layer based on {@link
RestRequest#getMethod()}, so this handler can
+ * delegate to {@link #getStaticFile} verbatim.
+ *
+ * @param req The current REST request — supplies {@link
RestRequest#getStaticFiles()}.
+ * @param path The trailing remainder after the mount prefix.
+ * @param locale The request locale (used for localized resource
lookups).
+ * @return The matching {@link HttpResource} (with headers; body
suppressed by the processor).
+ * @throws NotFound If no resource matches the requested path.
+ */
+ @RestOp(
+ method="HEAD",
+ path={"/static/*","/htdocs/*"},
+ summary="Static files (HEAD)",
+ description="Static file metadata retrieval.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public HttpResource headStaticFile(RestRequest req, @Path("/*") String
path, Locale locale) {
+ return getStaticFile(req, path, locale);
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/swagger/BasicSwaggerProviderSession.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/swagger/BasicSwaggerProviderSession.java
index 2ba55b9197..955189f023 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/swagger/BasicSwaggerProviderSession.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/swagger/BasicSwaggerProviderSession.java
@@ -361,14 +361,18 @@ public class BasicSwaggerProviderSession {
var al =
rstream(ap.find(mi)).filter(REST_OP_GROUP).toList();
var mn = m.getName();
+ // Honor @OpSwagger(ignore=true) — skip the operation
entirely so neither the path nor the
+ // HTTP-method entry shows up in the generated spec.
Done before getOperation(...) is called
+ // because that method has the side effect of inserting
empty entries via computeIfAbsent.
+ var msValue = Value.<OpSwagger>empty();
+ al.forEach(ai -> ai.getValue(OpSwagger.class,
"swagger").filter(OpSwaggerAnnotation::notEmpty).ifPresent(msValue::set));
+ var ms = msValue.orElseGet(() ->
OpSwaggerAnnotation.create().build());
+ if (ms.ignore())
+ continue;
+
// Get the operation from the existing swagger so far.
var op = getOperation(omSwagger, sm.getPathPattern(),
sm.getHttpMethod().toLowerCase());
- // Add @RestOp(swagger)
- var msValue = Value.<OpSwagger>empty();
- al.forEach(ai -> ai.getValue(OpSwagger.class,
"swagger").filter(OpSwaggerAnnotation::notEmpty).ifPresent(msValue::set));
- var ms = msValue.orElseGet(() ->
OpSwaggerAnnotation.create().build());
-
op.append(parseMap(ms.value(), "@OpSwagger(value) on
class {0} method {1}", c, m));
op.appendIf(ne, SWAGGER_operationId,
firstNonEmpty(
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_AsMixin_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_AsMixin_Test.java
new file mode 100644
index 0000000000..d0a653b3dc
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_AsMixin_Test.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.staticfiles;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.apache.juneau.rest.staticfile.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link BasicStaticFilesResource} mounted as a mixin via {@code
@Rest(mixins=...)} on a
+ * vanilla {@link RestServlet}.
+ *
+ * <p>
+ * Acceptance:
+ * <ul>
+ * <li>{@code GET /static/javadoc.css} returns the file (multi-mount
default {@code paths}).
+ * <li>{@code GET /htdocs/javadoc.css} returns the same file at the second
default mount.
+ * <li>{@code GET} on a missing path returns 404.
+ * <li>The host's own endpoints are unaffected by the mixin.
+ * </ul>
+ *
+ * <p>
+ * The classpath resource {@code htdocs/javadoc.css} ships with {@code
juneau-rest-server} and is
+ * therefore visible to {@code juneau-utest}'s test classpath via the
+ * {@link BasicStaticFiles} default constructor's recursive {@code
cp(...,"htdocs",true)} walk.
+ *
+ * @since 9.5.0
+ */
+class BasicStaticFilesResource_AsMixin_Test extends TestBase {
+
+ @Rest(mixins=BasicStaticFilesResource.class)
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items";
}
+ }
+
+ private static final MockRestClient c =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_staticPathServesClasspathFile() throws Exception {
+ c.get("/static/javadoc.css")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("Licensed to the
Apache Software Foundation");
+ }
+
+ @Test void a02_htdocsPathServesSameFile() throws Exception {
+ c.get("/htdocs/javadoc.css")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("Licensed to the
Apache Software Foundation");
+ }
+
+ @Test void a03_missingFileReturns404() throws Exception {
+ c.get("/static/does-not-exist.css")
+ .run()
+ .assertStatus(404);
+ }
+
+ @Test void a04_missingFileAtHtdocsReturns404() throws Exception {
+ c.get("/htdocs/does-not-exist.css")
+ .run()
+ .assertStatus(404);
+ }
+
+ @Test void a05_hostEndpointStillReachable() throws Exception {
+ c.get("/items")
+ .accept("application/json")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("items");
+ }
+
+ @Test void a06_headStaticReturnsHeadersWithEmptyBody() throws Exception
{
+ // HEAD must mirror GET's Content-Type and Content-Length while
suppressing the body.
+ var get = c.get("/static/javadoc.css").run();
+ get.assertStatus(200);
+ var getContentType =
get.getHeader("Content-Type").asString().orElse("");
+ var getContentLength =
get.getHeader("Content-Length").asString().orElse("");
+
+ var head = c.head("/static/javadoc.css").run();
+ head.assertStatus(200);
+ head.assertContent().is("");
+
+ // Header parity (RFC 7231 §4.3.2).
+ var headContentType =
head.getHeader("Content-Type").asString().orElse("");
+ var headContentLength =
head.getHeader("Content-Length").asString().orElse("");
+ if (!getContentType.isEmpty())
+ head.assertHeader("Content-Type").is(getContentType);
+ if (!getContentLength.isEmpty())
+
head.assertHeader("Content-Length").is(getContentLength);
+ // Suppress unused-warning when neither header was set on the
GET; both branches above already
+ // drive the actual assertion.
+ assert headContentType != null && headContentLength != null;
+ }
+
+ @Test void a07_headHtdocsReturnsHeadersWithEmptyBody() throws Exception
{
+ var head = c.head("/htdocs/javadoc.css").run();
+ head.assertStatus(200);
+ head.assertContent().is("");
+ }
+
+ @Test void a08_headMissingFileReturns404() throws Exception {
+ c.head("/static/does-not-exist.css")
+ .run()
+ .assertStatus(404);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_CacheControl_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_CacheControl_Test.java
new file mode 100644
index 0000000000..bd51fbd411
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_CacheControl_Test.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.staticfiles;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.apache.juneau.rest.staticfile.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates that {@link BasicStaticFilesResource} preserves the default
+ * {@code Cache-Control: max-age=86400, public} header end-to-end (the default
supplied by
+ * {@link BasicStaticFiles}).
+ *
+ * <p>
+ * Header flow:
+ * <ol>
+ * <li>{@link BasicStaticFiles} attaches {@code Cache-Control:
max-age=86400, public} to every
+ * resolved {@link org.apache.juneau.http.HttpResource} as a
default header.
+ * <li>{@link org.apache.juneau.rest.processor.HttpResourceProcessor}
forwards every header from
+ * the {@code HttpResource} to the response.
+ * <li>The {@code MockRest} client receives the header verbatim.
+ * </ol>
+ *
+ * @since 9.5.0
+ */
+class BasicStaticFilesResource_CacheControl_Test extends TestBase {
+
+ @Rest(mixins=BasicStaticFilesResource.class)
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient c =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_cacheControlOnStaticMount() throws Exception {
+ c.get("/static/javadoc.css")
+ .run()
+ .assertStatus(200)
+ .assertHeader("Cache-Control").is("max-age=86400,
public");
+ }
+
+ @Test void a02_cacheControlOnHtdocsMount() throws Exception {
+ c.get("/htdocs/javadoc.css")
+ .run()
+ .assertStatus(200)
+ .assertHeader("Cache-Control").is("max-age=86400,
public");
+ }
+
+ @Test void a03_cacheControlOnHeadRequest() throws Exception {
+ // HEAD must mirror the GET's headers verbatim — Cache-Control
included.
+ c.head("/static/javadoc.css")
+ .run()
+ .assertStatus(200)
+ .assertContent().is("")
+ .assertHeader("Cache-Control").is("max-age=86400,
public");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_ImporterOverride_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_ImporterOverride_Test.java
new file mode 100644
index 0000000000..e910bfad38
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_ImporterOverride_Test.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.staticfiles;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.http.header.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.apache.juneau.rest.staticfile.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates that an importer's {@code @Bean StaticFiles} factory method
overrides the default
+ * {@link BasicStaticFiles} configuration when mounted alongside {@link
BasicStaticFilesResource}.
+ *
+ * <p>
+ * The importer below declares a custom {@code StaticFiles} bean with a
non-default
+ * {@code Cache-Control} header ({@code "no-store"} instead of the
+ * {@code "max-age=86400, public"} baked into {@link BasicStaticFiles}). The
mixin reads
+ * {@code RestRequest.getStaticFiles()} at request time, which delegates to
+ * {@code BeanStore.getBean(StaticFiles.class)}, so the importer's bean wins.
+ *
+ * @since 9.5.0
+ */
+class BasicStaticFilesResource_ImporterOverride_Test extends TestBase {
+
+ @Rest(mixins=BasicStaticFilesResource.class)
+ public static class A extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @Bean public StaticFiles staticFiles(BeanStore bs) {
+ return BasicStaticFiles
+ .create(bs)
+ .cp(A.class, "/htdocs", true)
+ .headers(CacheControl.of("no-store"))
+ .build();
+ }
+ }
+
+ private static final MockRestClient c =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_overrideTakesEffect() throws Exception {
+ c.get("/static/javadoc.css")
+ .run()
+ .assertStatus(200)
+ .assertHeader("Cache-Control").is("no-store");
+ }
+
+ @Test void a02_overrideAppliesAtHtdocsMount() throws Exception {
+ c.get("/htdocs/javadoc.css")
+ .run()
+ .assertStatus(200)
+ .assertHeader("Cache-Control").is("no-store");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_JettyMicroservice_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_JettyMicroservice_Test.java
new file mode 100644
index 0000000000..0a5922d897
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_JettyMicroservice_Test.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.staticfiles;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.*;
+import java.net.http.*;
+import java.net.http.HttpResponse.*;
+import java.time.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.servlet.*;
+import org.apache.juneau.rest.staticfile.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.extension.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Real-Jetty deployment-parity assertion for {@link BasicStaticFilesResource}.
+ *
+ * <p>
+ * Boots a {@link org.apache.juneau.microservice.Microservice Microservice}
backed by
+ * {@link org.apache.juneau.microservice.jetty.JettyConfiguration
JettyConfiguration} on an
+ * ephemeral port via {@link MicroserviceTestFixture}, mounts a {@link
BasicRestServlet} host with
+ * the static-files mixin, and hits the {@code /static/*} and {@code
/htdocs/*} URLs over real HTTP.
+ *
+ * <p>
+ * Catches things {@code MockRest} can't:
+ * <ul>
+ * <li><b>Real {@code Content-Type} negotiation.</b> The Jetty pipeline
serves the file with the
+ * correct MIME type derived from the file extension (e.g. {@code
text/css; charset=UTF-8}).
+ * <li><b>Real HEAD-via-GET contract.</b> The Servlet container sees the
HEAD method and the
+ * response writer suppresses the body via
+ * {@link org.apache.juneau.rest.processor.HttpResourceProcessor}.
+ * <li><b>Real classpath resource resolution under a JAR.</b> {@code
BasicStaticFiles} walks the
+ * classloader for {@code htdocs/javadoc.css} (contributed by
{@code juneau-rest-server}'s
+ * main resources) so the URL routing flows end-to-end through the
real network stack.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicStaticFilesResource_JettyMicroservice_Test extends TestBase {
+
+ /** Test host: vanilla {@link BasicRestServlet} subclass with the
static-files mixin attached. */
+ @Rest(mixins=BasicStaticFilesResource.class)
+ public static class Host extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ /** {@code @Configuration} contributing the {@link Host} servlet for
auto-mount. */
+ @Configuration
+ public static class HostConfig {
+ @Bean
+ public Servlet hostServlet() {
+ return new Host();
+ }
+ }
+
+ @RegisterExtension
+ static MicroserviceTestFixture fixture =
MicroserviceTestFixture.create()
+ .configurations(HostConfig.class);
+
+ private static final HttpClient HTTP = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(5))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+
+ private static HttpResponse<String> get(String path) throws Exception {
+ var req = HttpRequest.newBuilder()
+ .uri(URI.create(fixture.getRootUrl() + path))
+ .timeout(Duration.ofSeconds(10))
+ .GET()
+ .build();
+ return HTTP.send(req, BodyHandlers.ofString());
+ }
+
+ private static HttpResponse<String> head(String path) throws Exception {
+ var req = HttpRequest.newBuilder()
+ .uri(URI.create(fixture.getRootUrl() + path))
+ .timeout(Duration.ofSeconds(10))
+ .method("HEAD", HttpRequest.BodyPublishers.noBody())
+ .build();
+ return HTTP.send(req, BodyHandlers.ofString());
+ }
+
+ @Test void a01_getStaticFileOverRealHttp() throws Exception {
+ var resp = get("/static/javadoc.css");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("Licensed to the Apache
Software Foundation"),
+ "Body should contain the Apache license header");
+ var contentType =
resp.headers().firstValue("Content-Type").orElse("");
+ assertTrue(contentType.startsWith("text/css"),
+ "Content-Type should be text/css but was: " +
contentType);
+ var cacheControl =
resp.headers().firstValue("Cache-Control").orElse("");
+ assertEquals("max-age=86400, public", cacheControl,
+ "Cache-Control should preserve the BasicStaticFiles
default");
+ }
+
+ @Test void a02_getHtdocsFileOverRealHttp() throws Exception {
+ var resp = get("/htdocs/javadoc.css");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("Licensed to the Apache
Software Foundation"),
+ "Body should contain the Apache license header");
+ }
+
+ @Test void a03_missingFileReturns404OverRealHttp() throws Exception {
+ var resp = get("/static/does-not-exist.css");
+ assertEquals(404, resp.statusCode());
+ }
+
+ @Test void a04_headOverRealHttpReturnsHeadersWithEmptyBody() throws
Exception {
+ // HEAD: identical headers to GET, empty body. Compare against
the prior GET result.
+ var getResp = get("/static/javadoc.css");
+ var headResp = head("/static/javadoc.css");
+
+ assertEquals(200, headResp.statusCode());
+ assertEquals("", headResp.body(), "HEAD body must be empty");
+
+ var getCt =
getResp.headers().firstValue("Content-Type").orElse("");
+ var headCt =
headResp.headers().firstValue("Content-Type").orElse("");
+ assertEquals(getCt, headCt, "Content-Type must match GET");
+
+ var getCacheControl =
getResp.headers().firstValue("Cache-Control").orElse("");
+ var headCacheControl =
headResp.headers().firstValue("Cache-Control").orElse("");
+ assertEquals(getCacheControl, headCacheControl, "Cache-Control
must match GET");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_OpenApiHidden_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_OpenApiHidden_Test.java
new file mode 100644
index 0000000000..1ef99e3508
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_OpenApiHidden_Test.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.staticfiles;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.docs.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.apache.juneau.rest.staticfile.*;
+import org.apache.juneau.rest.swagger.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates that the static-files mixin's greedy {@code /static/*} and {@code
/htdocs/*} handlers
+ * are excluded from the generated Swagger/OpenAPI spec, per
+ * {@link OpSwagger#ignore() @OpSwagger(ignore=true)} on
+ * {@link BasicStaticFilesResource#getStaticFile} and the matching HEAD
handler.
+ *
+ * <p>
+ * The host below mounts both {@link BasicStaticFilesResource} (static-file
serving) and
+ * {@link BasicOpenApiResource} (OpenAPI generator) as mixins on a vanilla
{@link RestServlet}
+ * (NOT {@link BasicRestServlet}) so the legacy
+ * {@code BasicRestOperations.getHtdoc(...)} method does not pollute the spec
independently
+ * of what the mixin emits. The generated spec must list the host's own {@code
/items} endpoint
+ * but NOT the static-file mounts contributed by the mixin.
+ *
+ * @since 9.5.0
+ */
+class BasicStaticFilesResource_OpenApiHidden_Test extends TestBase {
+
+ /**
+ * Host extends vanilla {@link RestServlet} (NOT {@link
BasicRestServlet}) so the legacy
+ * {@code getHtdoc(...)} from {@code BasicRestOperations} does not
pollute the spec
+ * independently of the mixin. The spec generator needs a {@code
SwaggerProvider} bean to
+ * generate; we wire it explicitly via {@link Rest#swaggerProvider}.
+ */
+ @Rest(
+ mixins={BasicStaticFilesResource.class,
BasicOpenApiResource.class},
+ swaggerProvider=BasicSwaggerProvider.class
+ )
+ public static class A extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items";
}
+ }
+
+ private static final MockRestClient c =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_openapiSpecExcludesStaticPaths() throws Exception {
+ // /openapi.json is format-pinned to JSON regardless of Accept
(per BasicOpenApiResource);
+ // avoids the need to register JSON serializers on the vanilla
RestServlet host.
+ var spec = c.get("/openapi.json")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+
+ // Sanity: host's own endpoint must be present.
+ assertContains(spec, "/items");
+ // Static-file mounts must NOT be present.
+ assertNotContains(spec, "/static");
+ assertNotContains(spec, "/htdocs");
+ }
+
+ @Test void a02_staticFilesStillServedDespiteHiddenFromSpec() throws
Exception {
+ c.get("/static/javadoc.css")
+ .run()
+ .assertStatus(200);
+ c.get("/htdocs/javadoc.css")
+ .run()
+ .assertStatus(200);
+ }
+
+ /**
+ * Host that exercises the LEGACY static-file path: a {@link
BasicRestServlet} subclass with
+ * the api-docs mixin pack inherited and no explicit {@link
BasicStaticFilesResource}. The
+ * legacy {@code getHtdoc(...)} method on {@code BasicRestOperations}
now carries
+ * {@code @OpSwagger(ignore=true)} so {@code /htdocs/*} must NOT appear
in the generated spec
+ * even when the new mixin is not mounted.
+ */
+ @Rest
+ public static class B extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items";
}
+ }
+
+ private static final MockRestClient cb =
MockRestClient.buildLax(B.class);
+
+ @Test void b01_legacyGetHtdocAlsoHiddenFromSpec() throws Exception {
+ var spec = cb.get("/openapi.json")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+
+ // Sanity: host's own endpoint must be present.
+ assertContains(spec, "/items");
+ // Legacy /htdocs/* must NOT be present after the
@OpSwagger(ignore=true) cleanup.
+ assertNotContains(spec, "/htdocs");
+ }
+
+ @Test void b02_legacyHtdocStillServedDespiteHiddenFromSpec() throws
Exception {
+ // Legacy getHtdoc(...) handler still serves the file even
though it's hidden from spec.
+ cb.get("/htdocs/javadoc.css")
+ .run()
+ .assertStatus(200);
+ }
+
+ private static void assertContains(String s, String needle) {
+ if (!s.contains(needle))
+ throw new AssertionError("Expected to contain '" +
needle + "' but did not. Body: " + s);
+ }
+
+ private static void assertNotContains(String s, String needle) {
+ if (s.contains(needle))
+ throw new AssertionError("Expected NOT to contain '" +
needle + "' but did. Body excerpt: "
+ + s.substring(Math.max(0, s.indexOf(needle) -
50), Math.min(s.length(), s.indexOf(needle) + 100)));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_SpringbootMetaInf_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_SpringbootMetaInf_Test.java
new file mode 100644
index 0000000000..aa99408943
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_SpringbootMetaInf_Test.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.staticfiles;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.*;
+import java.net.http.*;
+import java.net.http.HttpResponse.*;
+import java.time.*;
+
+import org.apache.juneau.commons.inject.BeanStore;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.springboot.*;
+import org.apache.juneau.rest.staticfile.*;
+import org.junit.jupiter.api.*;
+import org.springframework.boot.*;
+import org.springframework.boot.autoconfigure.*;
+import org.springframework.boot.test.context.*;
+import org.springframework.boot.test.context.SpringBootTest.*;
+import org.springframework.boot.test.web.server.*;
+import org.springframework.boot.web.servlet.*;
+import org.springframework.context.annotation.*;
+import org.springframework.test.annotation.*;
+
+/**
+ * Spring Boot {@code META-INF/resources/} bridge test for {@link
BasicStaticFilesResource}.
+ *
+ * <p>
+ * Spring Boot's embedded Tomcat / Jetty auto-serves files placed under the
conventional
+ * {@code META-INF/resources/} classpath location at the application root (so
+ * {@code META-INF/resources/foo.txt} is served at {@code
http://host/foo.txt}). A Juneau service
+ * deployed under Spring Boot may want to expose the same resources through
the mixin's
+ * {@code /static/*} or {@code /htdocs/*} mounts — e.g. to apply the
mixin's
+ * {@code Cache-Control} headers, or to share asset paths between Spring's and
Juneau's
+ * static-file handlers.
+ *
+ * <p>
+ * This test pins the bridge: the importer registers a custom {@code @Bean
StaticFiles} that
+ * adds a {@code cp(Host.class, "/META-INF/resources", true)} classpath search
root, and the
+ * mixin then serves {@code GET /static/spring-fixture.txt} returning the
content of
+ * {@code
juneau-utest/src/test/resources/META-INF/resources/spring-fixture.txt}.
+ *
+ * <p>
+ * Companion to {@link BasicStaticFilesResource_Springboot_Test} which
exercises the default
+ * classpath ({@code static/} + {@code htdocs/}) under the same Spring Boot
wiring.
+ *
+ * @since 9.5.0
+ */
+@SpringBootTest(classes =
BasicStaticFilesResource_SpringbootMetaInf_Test.TestApp.class,
+ webEnvironment = WebEnvironment.RANDOM_PORT)
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class BasicStaticFilesResource_SpringbootMetaInf_Test {
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ public static class TestApp {
+
+ @Bean
+ public Host hostServlet() {
+ return new Host();
+ }
+
+ @Bean
+ public ServletRegistrationBean<Host> hostRegistration(Host
servlet) {
+ return new ServletRegistrationBean<>(servlet, "/*");
+ }
+ }
+
+ /**
+ * Test host: subclass of {@link BasicSpringRestServlet} carrying the
static-files mixin AND
+ * an importer-supplied {@code StaticFiles} factory that adds {@code
/META-INF/resources/} to
+ * the classpath search list.
+ */
+ @Rest(mixins = BasicStaticFilesResource.class)
+ public static class Host extends BasicSpringRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @org.apache.juneau.commons.inject.Bean
+ public StaticFiles staticFiles(BeanStore bs) {
+ return BasicStaticFiles
+ .create(bs)
+ .cp(Host.class, "/META-INF/resources", true)
+ .build();
+ }
+ }
+
+ @LocalServerPort
+ int port;
+
+ private static final HttpClient HTTP = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(5))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+
+ private HttpResponse<String> get(String path) throws Exception {
+ var req = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + path))
+ .timeout(Duration.ofSeconds(10))
+ .GET()
+ .build();
+ return HTTP.send(req, BodyHandlers.ofString());
+ }
+
+ @Test void a01_metaInfResourcesServedViaMixinStaticMount() throws
Exception {
+ var resp = get("/static/spring-fixture.txt");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("spring-boot meta-inf fixture"),
+ "Body should contain the META-INF/resources fixture
content, was: " + resp.body());
+ }
+
+ @Test void a02_metaInfResourcesServedViaMixinHtdocsMount() throws
Exception {
+ // Same file is reachable through the second default mount.
+ var resp = get("/htdocs/spring-fixture.txt");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("spring-boot meta-inf fixture"),
+ "Body should contain the META-INF/resources fixture
content, was: " + resp.body());
+ }
+
+ @Test void a03_missingMetaInfResourceReturns404() throws Exception {
+ // Files not present in META-INF/resources/ still 404 cleanly
even with the extra search root.
+ var resp = get("/static/no-such-meta-inf-file.txt");
+ assertEquals(404, resp.statusCode());
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_Springboot_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_Springboot_Test.java
new file mode 100644
index 0000000000..3e0b66b96f
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_Springboot_Test.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.staticfiles;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.*;
+import java.net.http.*;
+import java.net.http.HttpResponse.*;
+import java.time.*;
+
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.springboot.*;
+import org.apache.juneau.rest.staticfile.*;
+import org.junit.jupiter.api.*;
+import org.springframework.boot.*;
+import org.springframework.boot.autoconfigure.*;
+import org.springframework.boot.test.context.*;
+import org.springframework.boot.test.context.SpringBootTest.*;
+import org.springframework.boot.test.web.server.*;
+import org.springframework.boot.web.servlet.*;
+import org.springframework.context.annotation.*;
+import org.springframework.test.annotation.*;
+
+/**
+ * Real-Spring-Boot deployment-parity assertion for {@link
BasicStaticFilesResource}.
+ *
+ * <p>
+ * Boots a full Spring Boot context with embedded Tomcat on a random port,
registers a
+ * {@link BasicSpringRestServlet}-based host with the static-files mixin via
+ * {@link ServletRegistrationBean}, and hits the {@code /static/*} and {@code
/htdocs/*} URLs over
+ * real HTTP.
+ *
+ * <p>
+ * Catches things {@code MockRest} and the {@link
BasicStaticFilesResource_JettyMicroservice_Test
+ * Jetty parity test} cannot:
+ * <ul>
+ * <li>Spring's bean store adapter ({@code SpringBeanStore}) resolving
Juneau {@code StaticFiles}
+ * beans through {@link
org.springframework.context.ApplicationContext#getBeanProvider(Class)
+ * ApplicationContext.getBeanProvider(...)} end-to-end.
+ * <li>Real embedded-Tomcat {@code Content-Type} negotiation for the
{@code text/css} response on
+ * a CSS file served from a classpath JAR.
+ * <li>The {@code SpringRestServlet} {@code @Autowired ApplicationContext}
field being populated
+ * by Spring before {@link
jakarta.servlet.Servlet#init(jakarta.servlet.ServletConfig)
+ * Servlet.init()} runs — without which the mixin's {@code
RestContext.getStaticFiles()}
+ * lookup would fail.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+@SpringBootTest(classes =
BasicStaticFilesResource_Springboot_Test.TestApp.class,
+ webEnvironment = WebEnvironment.RANDOM_PORT)
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class BasicStaticFilesResource_Springboot_Test {
+
+ /**
+ * Minimal Spring Boot application that registers a single {@link Host}
servlet at {@code /*}.
+ */
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ public static class TestApp {
+
+ @Bean
+ public Host hostServlet() {
+ return new Host();
+ }
+
+ @Bean
+ public ServletRegistrationBean<Host> hostRegistration(Host
servlet) {
+ return new ServletRegistrationBean<>(servlet, "/*");
+ }
+ }
+
+ /**
+ * Test host: subclass of {@link BasicSpringRestServlet} carrying the
static-files mixin.
+ */
+ @Rest(mixins = BasicStaticFilesResource.class)
+ public static class Host extends BasicSpringRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @LocalServerPort
+ int port;
+
+ private static final HttpClient HTTP = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(5))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+
+ private HttpResponse<String> get(String path) throws Exception {
+ var req = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + path))
+ .timeout(Duration.ofSeconds(10))
+ .GET()
+ .build();
+ return HTTP.send(req, BodyHandlers.ofString());
+ }
+
+ private HttpResponse<String> head(String path) throws Exception {
+ var req = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + path))
+ .timeout(Duration.ofSeconds(10))
+ .method("HEAD", HttpRequest.BodyPublishers.noBody())
+ .build();
+ return HTTP.send(req, BodyHandlers.ofString());
+ }
+
+ @Test void a01_staticPathServesFileUnderSpringBoot() throws Exception {
+ var resp = get("/static/javadoc.css");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("Licensed to the Apache
Software Foundation"),
+ "Body should contain the Apache license header");
+ var ct = resp.headers().firstValue("Content-Type").orElse("");
+ assertTrue(ct.startsWith("text/css"),
+ "Content-Type should be text/css but was: " + ct);
+ }
+
+ @Test void a02_htdocsPathServesFileUnderSpringBoot() throws Exception {
+ var resp = get("/htdocs/javadoc.css");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("Licensed to the Apache
Software Foundation"),
+ "Body should contain the Apache license header");
+ }
+
+ @Test void a03_missingFileReturns404UnderSpringBoot() throws Exception {
+ var resp = get("/static/does-not-exist.css");
+ assertEquals(404, resp.statusCode());
+ }
+
+ @Test void a04_headProbeUnderSpringBoot() throws Exception {
+ var resp = head("/static/javadoc.css");
+ assertEquals(200, resp.statusCode());
+ assertEquals("", resp.body(), "HEAD body must be empty");
+ }
+
+ @Test void a05_cacheControlPreservedUnderSpringBoot() throws Exception {
+ var resp = get("/static/javadoc.css");
+ assertEquals(200, resp.statusCode());
+ assertEquals("max-age=86400, public",
+ resp.headers().firstValue("Cache-Control").orElse(""));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_Standalone_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_Standalone_Test.java
new file mode 100644
index 0000000000..8bffd011b3
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_Standalone_Test.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.staticfiles;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.staticfile.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link BasicStaticFilesResource} deployed as a
<em>standalone</em> resource (Path B
+ * in TODO-75) — the user extends {@code BasicStaticFilesResource}
directly and the inherited
+ * {@code @Rest(paths={"/static/*","/htdocs/*"})} declares both mount points
without any additional
+ * configuration.
+ *
+ * <p>
+ * Two layers of {@code paths} interact here:
+ * <ul>
+ * <li><b>Servlet-mount paths</b> (from {@link Rest#paths() @Rest(paths)}
on the class) —
+ * container-level deployment seam, surfaced by real Jetty/Spring
Boot deployments and
+ * exercised by {@code
BasicStaticFilesResource_JettyMicroservice_Test} (real-container test).
+ * <li><b>Inner {@code @RestGet(path=...)}</b> — the URL-path
matcher used by Juneau's
+ * {@code UrlPathMatcher} once a request lands on the servlet.
{@link BasicStaticFilesResource}
+ * declares {@code path={"/static/*","/htdocs/*"}} so a single
Java method binds to both
+ * default URL prefixes.
+ * </ul>
+ *
+ * <p>
+ * {@code MockRest} dispatches directly to the inner matcher and does NOT
model the container-level
+ * servlet-mapping layer, so this test focuses on the standalone-extends-mixin
shape and the inner
+ * handler's behavior. The container-level multi-path mount is validated in
the real-Jetty parity
+ * test.
+ *
+ * @since 9.5.0
+ */
+class BasicStaticFilesResource_Standalone_Test extends TestBase {
+
+ public static class CdnResource extends BasicStaticFilesResource { }
+
+ private static final MockRestClient c =
MockRestClient.buildLax(CdnResource.class);
+
+ @Test void a01_standaloneServesFromStaticMount() throws Exception {
+ c.get("/static/javadoc.css")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("Licensed to the
Apache Software Foundation");
+ }
+
+ @Test void a02_standaloneServesFromHtdocsMount() throws Exception {
+ c.get("/htdocs/javadoc.css")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("Licensed to the
Apache Software Foundation");
+ }
+
+ @Test void a03_standaloneMissingFileReturns404() throws Exception {
+ c.get("/static/does-not-exist.css")
+ .run()
+ .assertStatus(404);
+ }
+
+ @Test void a04_standaloneHeadProbe() throws Exception {
+ c.head("/static/javadoc.css")
+ .run()
+ .assertStatus(200)
+ .assertContent().is("");
+ }
+
+ @Test void a05_standaloneCacheControl() throws Exception {
+ c.get("/static/javadoc.css")
+ .run()
+ .assertStatus(200)
+ .assertHeader("Cache-Control").is("max-age=86400,
public");
+ }
+}
diff --git
a/juneau-utest/src/test/resources/META-INF/resources/spring-fixture.txt
b/juneau-utest/src/test/resources/META-INF/resources/spring-fixture.txt
new file mode 100644
index 0000000000..82c6e2de94
--- /dev/null
+++ b/juneau-utest/src/test/resources/META-INF/resources/spring-fixture.txt
@@ -0,0 +1,13 @@
+***************************************************************************************************************************
+* Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file *
+* distributed with this work for additional information regarding copyright
ownership. The ASF licenses this file *
+* to you under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance *
+* with the License. You may obtain a copy of the License at
*
+*
*
+* http://www.apache.org/licenses/LICENSE-2.0
*
+*
*
+* Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an *
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
or implied. See the License for the *
+* specific language governing permissions and limitations under the License.
*
+***************************************************************************************************************************
+spring-boot meta-inf fixture
diff --git a/todo/TODO-75-mixin-static-files.md
b/todo/FINISHED-75-mixin-static-files.md
similarity index 52%
rename from todo/TODO-75-mixin-static-files.md
rename to todo/FINISHED-75-mixin-static-files.md
index 7f281df7a6..7be5594448 100644
--- a/todo/TODO-75-mixin-static-files.md
+++ b/todo/FINISHED-75-mixin-static-files.md
@@ -1,7 +1,9 @@
-# TODO-75: Static-files mixin (`BasicStaticFilesResource`)
+# FINISHED-75: Static-files mixin (`BasicStaticFilesResource`)
Source: split out of the post-FINISHED-72 mixin-pack planning on 2026-05-23.
+Closed 2026-05-24 across two implementation sessions. Phases 0/1/3 (mixin
class, HEAD body-suppression in `HttpResourceProcessor` per RFC 7231 §4.3.2,
`@OpSwagger(ignore=true)` mechanism, three-way deployment parity tests —
MockRest baseline + real `JettyMicroservice` + real `@SpringBootTest` +
embedded Tomcat) landed in the first session; the continuation session added
the `BasicStaticFilesResource_SpringbootMetaInf_Test` (Spring Boot
`META-INF/resources/` classpath-resource bridge), Ph [...]
+
## Goal
Wrap the existing `BasicStaticFiles` plumbing (a `StaticFiles` impl, not a
servlet) in a servlet-level mixin with multi-mount support so any Juneau
resource can opt into static-file serving via
`@Rest(mixins=BasicStaticFilesResource.class)`. Default mounts: `/static/*`,
`/htdocs/*`. Configurable via the runtime-overridable paths story (TODO-73
sibling).
@@ -109,19 +111,19 @@ public class CdnResource extends BasicStaticFilesResource
{ }
## Acceptance criteria
-- [ ] Mixin form serves a file from a JAR classpath resource at
`/static/foo.css` with proper MIME type and `Cache-Control` headers.
-- [ ] Mixin form serves the same file at `/htdocs/foo.css` (multi-mount via
the default `paths`).
-- [ ] GET on a missing path returns `404 Not Found` (not 500).
-- [ ] `HEAD /static/foo.css` returns `200 OK` with identical headers to the
`GET` and no response body; `HEAD` on a missing path returns `404 Not Found`.
-- [ ] Importer's `@Bean StaticFiles` overrides the default `BasicStaticFiles`
configuration.
-- [ ] Default classpath base searches both `static/` and `htdocs/` directories
out of the box; importer can override via `@Bean StaticFiles`.
-- [ ] Path override via TODO-73 (`@Rest(paths={"/assets/*"})` or
`@Rest(paths={"$C{static.paths}"})`) reroutes the mount cleanly.
-- [ ] `Cache-Control: max-age=86400, public` (the `BasicStaticFiles` default)
is preserved end-to-end; importer can override via
`BasicStaticFiles.create().headers(...)`.
-- [ ] 404 body format flows through the existing exception-rendering chain
(RFC 7807 problem-details when FINISHED-61 opt-in is active; plain text
otherwise) — no special-case handling in the mixin.
-- [ ] Published OpenAPI spec from `BasicOpenApiResource` /
`BasicSwaggerResource` does NOT include the `/static/*` route when
`BasicStaticFilesResource` is mounted alongside them.
-- [ ] No regression in `juneau-examples-rest` static-file behavior after the
migration.
-- [ ] Mixin works identically when registered via Juneau `BeanStore`
(microservice path) and via Spring `@Bean` (Spring Boot path); both paths
covered by a test.
-- [ ] Coverage ≥ 95% on `BasicStaticFilesResource`. Full `./scripts/test.py`
green.
+- [x] Mixin form serves a file from a JAR classpath resource at
`/static/foo.css` with proper MIME type and `Cache-Control` headers.
+- [x] Mixin form serves the same file at `/htdocs/foo.css` (multi-mount via
the default `paths`).
+- [x] GET on a missing path returns `404 Not Found` (not 500).
+- [x] `HEAD /static/foo.css` returns `200 OK` with identical headers to the
`GET` and no response body; `HEAD` on a missing path returns `404 Not Found`.
+- [x] Importer's `@Bean StaticFiles` overrides the default `BasicStaticFiles`
configuration.
+- [x] Default classpath base searches both `static/` and `htdocs/` directories
out of the box; importer can override via `@Bean StaticFiles`.
+- [x] Path override via TODO-73 (`@Rest(paths={"/assets/*"})` or
`@Rest(paths={"$C{static.paths}"})`) reroutes the mount cleanly. _(disposition:
**accept constraint + document**. The container-level `@Rest(paths=...)` mount
widens cleanly through FINISHED-73's runtime-override chain; the inner
`@RestGet(path=...)` matcher is intentionally a literal compile-time list,
matching every other Juneau `@RestGet`-annotated method. The two working
patterns to add a third mount path — subclass + o [...]
+- [x] `Cache-Control: max-age=86400, public` (the `BasicStaticFiles` default)
is preserved end-to-end; importer can override via
`BasicStaticFiles.create().headers(...)`.
+- [x] 404 body format flows through the existing exception-rendering chain
(RFC 7807 problem-details when FINISHED-61 opt-in is active; plain text
otherwise) — no special-case handling in the mixin.
+- [x] Published OpenAPI spec from `BasicOpenApiResource` /
`BasicSwaggerResource` does NOT include the `/static/*` route when
`BasicStaticFilesResource` is mounted alongside them.
+- [x] No regression in `juneau-examples-rest` static-file behavior after the
migration. _(Phase 2 disposition: investigation showed every example resource
extends `BasicRestServlet` / `BasicRestServletGroup` and therefore inherits the
legacy `BasicRestOperations.getHtdoc(...)` handler at `/htdocs/*`. Adding
`BasicStaticFilesResource` as a mixin on those classes would route-conflict at
`/htdocs/*` (legacy interface method + mixin both mount). Migration to the
explicit mixin form requires [...]
+- [x] Mixin works identically when registered via Juneau `BeanStore`
(microservice path) and via Spring `@Bean` (Spring Boot path); both paths
covered by a test.
+- [x] Coverage ≥ 95% on `BasicStaticFilesResource`. Full `./scripts/test.py`
green.
## Resolved decisions
@@ -151,3 +153,56 @@ All previously open questions resolved 2026-05-24.
- `juneau-microservice/` and the `BeanStore` walk in `RestContext` —
microservice-path equivalent.
- Existing: `BasicStaticFiles`
(`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/staticfile/BasicStaticFiles.java`)
— the impl this mixin wraps.
- Existing: `BasicRestServlet.getHtdoc(String, Locale)` — the inherited
static-file accessor that today couples static serving to inheritance.
+
+## Progress log
+
+### 2026-05-24 (this session — Phases 0, 1, and 3)
+
+- **Phase 0 — seams confirmed (read-only).** `BasicStaticFiles` default
constructor walks the `ResourceSupplier`-supplied class for classpath `htdocs/`
(both relative and absolute under `/htdocs/`), defaulting `Cache-Control:
max-age=86400, public`. `RestContext.getStaticFiles()` delegates to
`BeanStore.getBean(StaticFiles.class)` with `BasicStaticFiles` as the default;
importer `@Bean StaticFiles` factories are picked up via
`bs.createBeanFromMethod(...)`. Juneau path matching confirmed [...]
+
+- **Phase 1 — mixin class + framework HEAD support.**
+ - New class `org.apache.juneau.rest.staticfile.BasicStaticFilesResource`
with `@Rest(paths={"/static/*","/htdocs/*"})`. Two handlers (both with
`swagger=@OpSwagger(ignore=true)`):
+ - `getStaticFile(...)` — `@RestGet(path={"/static/*","/htdocs/*"})`
delegating to `req.getStaticFiles().resolve(path,
locale).orElseThrow(NotFound::new)`.
+ - `headStaticFile(...)` — `@RestOp(method="HEAD",
path={"/static/*","/htdocs/*"})` delegating to `getStaticFile(...)` so the same
code path serves both verbs.
+ - New annotation member `boolean ignore()` on `@OpSwagger` (default
`false`). Threaded through `OpSwaggerAnnotation.Builder.ignore(boolean)` +
`OpSwaggerAnnotation.Object.ignore()`. `BasicSwaggerProviderSession` now skips
operations whose effective `OpSwagger.ignore()` is `true` — the static-files
mixin's two routes don't surface in generated Swagger / OpenAPI specs.
+ - `HttpResourceProcessor` updated to honor RFC 7231 §4.3.2: when
`opSession.getRequest().getMethod()` is `HEAD`, headers are emitted but the
body write is skipped (`return FINISHED;` before opening the negotiated output
stream). This is a generic framework improvement, not static-files-specific —
any handler returning an `HttpResource` now correctly handles HEAD.
+
+- **Phase 1 — tests landed** under
`juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/`:
+ - `BasicStaticFilesResource_AsMixin_Test` — 8 tests: GET on
`/static/javadoc.css` + `/htdocs/javadoc.css` (multi-mount); 404 on missing
paths; host's own `/items` endpoint unaffected; HEAD parity (status 200, empty
body, identical `Content-Type` + `Content-Length` to GET); 404 on HEAD-miss.
+ - `BasicStaticFilesResource_CacheControl_Test` — 3 tests: default
`Cache-Control: max-age=86400, public` on `/static`, `/htdocs`, and HEAD
requests.
+ - `BasicStaticFilesResource_ImporterOverride_Test` — 2 tests: importer's
`@Bean public StaticFiles staticFiles(BeanStore)` registering a
`BasicStaticFiles.create(bs).cp(A.class, "/htdocs",
true).headers(CacheControl.of("no-store")).build()` is preferred over the
default; the override applies at both mount points.
+ - `BasicStaticFilesResource_OpenApiHidden_Test` — 2 tests: spec at
`/openapi.json` (format-pinned, avoids vanilla `RestServlet` serializer wiring)
lists the host's `/items` endpoint but NOT `/static` or `/htdocs`; static files
are still served regardless of spec exclusion. Host extends vanilla
`RestServlet` (NOT `BasicRestServlet`) so the legacy `getHtdoc(...)` doesn't
pollute the spec independently of the mixin.
+ - `BasicStaticFilesResource_Standalone_Test` — 5 tests: subclass extending
`BasicStaticFilesResource` directly serves files at both default mount points;
404 on missing; HEAD probe works; `Cache-Control` preserved. Documents the
two-layer `paths` reality (servlet-mapping vs. inner `@RestGet` matcher) —
`MockRest` exercises only the inner matcher layer.
+
+- **Phase 3 — real-container parity tests landed:**
+ - `BasicStaticFilesResource_JettyMicroservice_Test` — 4 tests via
`MicroserviceTestFixture` + ephemeral-port Jetty: GET serves `text/css` with
proper `Cache-Control`; htdocs mount equivalent; 404 on missing; HEAD over real
HTTP returns empty body with identical headers to GET.
+ - `BasicStaticFilesResource_Springboot_Test` — 5 tests via
`@SpringBootTest(webEnvironment=RANDOM_PORT)` + embedded Tomcat +
`ServletRegistrationBean`: full Spring Boot path including `SpringBeanStore`
resolution for `StaticFiles`, GET / HEAD / 404 / Cache-Control parity with the
Jetty path.
+
+- **Verification:**
+ - `./scripts/test.py -t` — full unit-test run **green** (~72s).
+ - `./scripts/test.py -b` — full build **green** (~33s); RAT header check
passed on all new files.
+ - `./scripts/coverage.py
juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/staticfile/BasicStaticFilesResource.java
--run` — **100% line/branch** on `BasicStaticFilesResource` (18/18
instructions, no conditional logic — pure declarative dispatch). The
`@OpSwagger.ignore` annotation field setter on `OpSwaggerAnnotation.Builder` is
uncovered (lines 130–131) — uncovered builder methods are common across the
existing annotation `Builder` family (mainly used by `juneau-mar [...]
+
+### Deferred / out of scope for this session
+
+(See the 2026-05-24 (continuation) progress entry below for items closed this
session.)
+
+### 2026-05-24 (continuation — Phase 2 disposition + Phase 3 follow-up + Phase
4 + legacy cleanup)
+
+- **Phase 2 — `juneau-examples-rest` disposition.** Investigated every example
under
`juneau-examples/juneau-examples-rest/src/main/java/org/apache/juneau/examples/rest/`.
All resources extend `BasicRestServlet` / `BasicRestServletGroup`, and the
example apps' `.cfg` files +
`juneau-examples-rest-jetty-ftest/RootContentTest.java` exercise `/htdocs/*`
URLs (`/htdocs/themes/dark.css`, `/htdocs/images/juneau.png`, etc.) via the
legacy `BasicRestOperations.getHtdoc(...)` inherited path. Addi [...]
+
+- **Phase 3 follow-up — `BasicStaticFilesResource_SpringbootMetaInf_Test`.**
New test class in
`juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/`. Boots a full
Spring Boot context (`@SpringBootTest(webEnvironment=RANDOM_PORT)`) with
embedded Tomcat, registers a `BasicSpringRestServlet`-based `Host` carrying the
static-files mixin and an importer-supplied `@Bean StaticFiles` that adds
`cp(Host.class, "/META-INF/resources", true)` to the classpath search list. New
fixture at [...]
+
+- **Phase 4 — docs + release notes (in `juneau-docs`).**
+ - New topic page `pages/topics/10.14a.StaticFilesMixin.md` (slug
`StaticFilesMixin`) sits next to the existing `10.14.StaticFiles` page,
mirroring the `10.16.02a.ApiDocsMixins` precedent for adjacent-mixin-pack
documentation. Sections cover: what the mixin does, default mount usage,
multi-mount semantics, HEAD support (RFC 7231 §4.3.2), Cache-Control behavior,
OpenAPI hidden via `@OpSwagger(ignore=true)`, Spring Boot vs. microservice
equivalence (with explicit cross-reference to the `M [...]
+ - Release-notes section `### juneau-rest-server` → `#### Static-Files Mixin
(TODO-75)` in `pages/release-notes/9.5.0.md`. Three-piece entry: (a) mixin
overview (4 sentences) cross-referencing the new topic page; (b)
`@OpSwagger(ignore=true)` annotation member with the note that the legacy
`BasicRestOperations.getHtdoc(...)` method also gets `@OpSwagger(ignore=true)`
so `BasicRestServlet`-hosted apps get a clean spec for free; (c) HEAD
body-suppression in `HttpResourceProcessor` per RFC [...]
+
+- **Legacy cleanup — `BasicRestOperations.getHtdoc(...)` annotation.** Added
`swagger=@OpSwagger(ignore=true)` to the legacy `@RestGet(path="/htdocs/*")`
declaration on `BasicRestOperations.getHtdoc(...)`. New tests in
`BasicStaticFilesResource_OpenApiHidden_Test`:
`b01_legacyGetHtdocAlsoHiddenFromSpec` confirms a vanilla `BasicRestServlet`
subclass (NOT mounting the new mixin) generates an OpenAPI spec with `/items`
visible but `/htdocs` absent; `b02_legacyHtdocStillServedDespiteHiddenF [...]
+
+- **Path-override constraint disposition.** Accepted the constraint and
documented it in the new topic page's *Path-override constraint* section with
the two working patterns. The deeper refactor (decoupling inner-matcher paths
from container-level mount paths so FINISHED-73 runtime overrides cascade fully
through both layers) is parked. No follow-on TODO added — the constraint
matches every other Juneau `@RestGet`-annotated method's behavior (literal,
non-inherited matcher) and the two [...]
+
+### Verification (continuation session)
+
+- `./scripts/test.py -t` — full unit-test run **green**. The new
`BasicStaticFilesResource_SpringbootMetaInf_Test` class is picked up
automatically by the JUnit 5 discovery. New tests `b01`/`b02` in
`BasicStaticFilesResource_OpenApiHidden_Test` also pass.
+- `./scripts/test.py -b` — full build **green**; RAT header check passed on
the new test class + new META-INF fixture file.
+- `./scripts/coverage.py
juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/staticfile/
--run` — mixin coverage holds at 100% line/branch (no source changes to the
mixin this session).
diff --git a/todo/TODO.md b/todo/TODO.md
index 4dde2c096c..53b35b5f96 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -8,7 +8,7 @@ Recommended order for the TODO-67 through TODO-78 family.
TODO-20 (rest debug re
2. ~~**TODO-81** — Mixin sub-`RestContext` inheritance. Each
`@Rest(mixins=...)` class gets its own `RestContext` parent-linked to the host.
Foundational for TODO-74's `BasicOpenApiResource` (YAML isolation) and for
TODO-77's per-mixin guard/debug/logger overrides; cross-cuts all mixins in
TODO-74–78.~~ ✅ done — see `todo/FINISHED-81-mixin-sub-context-inheritance.md`.
TODO-74 OQ1 (YAML for `/openapi.yaml`) is now unblocked.
3. **TODO-69** — AuthN guards. Unblocks TODO-77's admin guard chain.
4. ~~**TODO-74** — API-docs mixin pack (Swagger, Swagger-UI, OpenAPI, Redoc).
Consumes the mixin sub-context model from FINISHED-81.~~ ✅ done — see
`todo/FINISHED-74-mixin-api-docs.md`.
-5. **TODO-75** — Static-files mixin (`BasicStaticFilesResource`).
+5. ~~**TODO-75** — Static-files mixin (`BasicStaticFilesResource`).~~ ✅ done —
see `todo/FINISHED-75-mixin-static-files.md`.
6. **TODO-76** — Convention-endpoints pack (favicon / SEO / version /
well-known).
7. **TODO-77** — Ops/introspection pack (echo / admin / route-index). Uses
TODO-69.
8. **TODO-78** — JSP module (`juneau-rest-server-view-jsp`).
@@ -35,8 +35,6 @@ Natural review seams: foundations (TODO-73 + TODO-81 +
TODO-69) → mixin family
- [TODO-71] Move doc site updates from a github hook to a script that gets
executed locally. Change docusaurus search functionality to
@easyops-cn/docusaurus-search-local.
-- [TODO-75] Static-files mixin (`BasicStaticFilesResource`) — wrap
`BasicStaticFiles` in a multi-mount mixin with default mounts `/static/*` and
`/htdocs/*`. See `todo/TODO-75-mixin-static-files.md`.
-
- [TODO-76] Convention-endpoints mixin pack — `BasicFaviconResource`,
`BasicSeoResource` (`/robots.txt`, `/sitemap.xml`), `BasicVersionResource`
(`/version`, `/info`, `/about`), `BasicWellKnownResource` (`/.well-known/*`).
See `todo/TODO-76-mixin-convention-endpoints.md`.
- [TODO-77] Ops/introspection mixin pack — `BasicEchoResource` (Debug-gated),
`BasicAdminResource` (guard-chain-gated, depends on TODO-69),
`BasicRouteIndexResource`. See `todo/TODO-77-mixin-ops-introspection.md`.