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 695d034427 feat(rest-server): convention-endpoints mixin pack —
Favicon/Seo/Version/WellKnown + buildMixinContext BeanStore-first (FINISHED-76)
695d034427 is described below
commit 695d034427b4bf2baa1e73a2692c2132f1c95d8e
Author: James Bognar <[email protected]>
AuthorDate: Sun May 24 17:43:20 2026 -0400
feat(rest-server): convention-endpoints mixin pack —
Favicon/Seo/Version/WellKnown + buildMixinContext BeanStore-first (FINISHED-76)
---
.../java/org/apache/juneau/rest/RestContext.java | 12 +-
.../rest/convention/BasicFaviconResource.java | 246 +++++++++++++
.../juneau/rest/convention/BasicSeoResource.java | 321 ++++++++++++++++
.../rest/convention/BasicVersionResource.java | 409 +++++++++++++++++++++
.../rest/convention/BasicWellKnownResource.java | 181 +++++++++
.../juneau/rest/convention/package-info.java | 78 ++++
.../src/main/resources/juneau-favicon.ico | Bin 0 -> 1118 bytes
.../convention/BasicConvention_Builders_Test.java | 361 ++++++++++++++++++
.../BasicConvention_OpenApiHidden_Test.java | 105 ++++++
.../BasicConvention_ParentChain_Test.java | 138 +++++++
.../BasicFaviconResource_AsMixin_Test.java | 138 +++++++
.../convention/BasicSeoResource_AsMixin_Test.java | 127 +++++++
.../BasicVersionResource_AsMixin_Test.java | 146 ++++++++
...asicVersionResource_JettyMicroservice_Test.java | 129 +++++++
.../BasicVersionResource_Springboot_Test.java | 134 +++++++
.../BasicWellKnownResource_AsMixin_Test.java | 79 ++++
...d => FINISHED-76-mixin-convention-endpoints.md} | 59 ++-
todo/TODO.md | 4 +-
18 files changed, 2662 insertions(+), 5 deletions(-)
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
index 2b7c4e8850..b19acbcae3 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
@@ -1472,9 +1472,19 @@ public class RestContext extends Context {
* any {@code setContext(RestContext)} method on the mixin instance
(mirroring the existing
* {@code RestChildren.buildChildContext(...)} contract). The host's
{@link ServletConfig} is propagated
* so the mixin sees the same servlet container settings.
+ *
+ * <p>
+ * Mixin instance resolution: an importer-supplied {@code @Bean
<MixinClass>} factory in the host's
+ * bean store wins over reflective instantiation. When the host
registers
+ * {@code @Bean public MyMixin myMixin() { ... }} (or a Spring {@code
@Bean} of the same type), that
+ * pre-built instance is used as the mixin resource; otherwise the
mixin is instantiated via
+ * {@code beanStore.instantiate(mixinClass)} (no-arg / builder /
injected-constructor path). This lets
+ * convention-endpoint mixins (favicon, SEO, version, well-known) be
configured by the importer using
+ * their own builder before the mixin walk wires them in.
*/
private RestContext buildMixinContext(Class<?> mixinClass) throws
Exception {
- var mixinResource = beanStore.instantiate(mixinClass);
+ Object preBuilt = beanStore.getBean(mixinClass).orElse(null);
+ final var mixinResource = preBuilt != null ? preBuilt :
beanStore.instantiate(mixinClass);
var args = new Args(mixinClass, this, builder.inner, () ->
mixinResource, "", null, null, null, true);
var mixinCtx = new RestContext(args);
var setCtx = ClassInfo.of(mixinResource).getMethod(x ->
x.hasName("setContext") && x.hasParameterTypes(RestContext.class)).orElse(null);
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicFaviconResource.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicFaviconResource.java
new file mode 100644
index 0000000000..0afa64bc00
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicFaviconResource.java
@@ -0,0 +1,246 @@
+/*
+ * 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.convention;
+
+import java.io.*;
+import java.util.*;
+
+import org.apache.juneau.http.entity.*;
+import org.apache.juneau.http.header.*;
+import org.apache.juneau.http.resource.*;
+import org.apache.juneau.http.*;
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.annotation.*;
+
+/**
+ * Mixin that serves a {@code favicon.ico} icon at {@code /favicon.ico}.
+ *
+ * <p>
+ * Sibling of {@link BasicSeoResource} (robots.txt / sitemap.xml), {@link
BasicVersionResource}
+ * ({@code /version} / {@code /info} / {@code /about}), and {@link
BasicWellKnownResource}
+ * ({@code /.well-known/*}). All four classes live in the {@code
org.apache.juneau.rest.convention}
+ * convention-endpoints mixin pack.
+ *
+ * <p>
+ * Compose into a host resource via {@link Rest#mixins()
@Rest(mixins=BasicFaviconResource.class)};
+ * the {@code /favicon.ico} URL becomes available alongside the host's own
endpoints with no further
+ * wiring. Or extend the class directly for a standalone deployment whose
mount paths come from the
+ * inherited {@link Rest#paths() @Rest(paths)} default.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(path=<js>"/api"</js>,
mixins=BasicFaviconResource.<jk>class</jk>)
+ * <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet {
+ * <jc>// Use the default Juneau-branded favicon.</jc>
+ * }
+ *
+ * <jc>// Or override the icon bytes via a @Bean factory:</jc>
+ * <ja>@Bean BasicFaviconResource favicon()</ja> {
+ * <jk>return</jk>
BasicFaviconResource.<jsm>create</jsm>().bytes(myLogoBytes).build();
+ * }
+ * </p>
+ *
+ * <h5 class='section'>Behavior:</h5>
+ *
+ * <ul class='spaced-list'>
+ * <li>{@code GET /favicon.ico} returns the configured icon bytes with
+ * {@code Content-Type: image/x-icon} and {@code Cache-Control:
max-age=2592000, public}
+ * (30 days — favicons rarely change and browsers re-fetch
frequently when uncached).
+ * <li>The default icon ({@code juneau-favicon.ico} on the framework
classpath) is a
+ * 16×16 Juneau-branded ICO; users replace it by registering
an alternate
+ * {@code @Bean BasicFaviconResource} whose builder supplies
different bytes.
+ * <li>The handler is excluded from generated Swagger / OpenAPI specs via
+ * {@link OpSwagger#ignore() @OpSwagger(ignore=true)} —
favicons are not
+ * API-meaningful.
+ * </ul>
+ *
+ * <h5 class='section'>Builder API:</h5>
+ *
+ * <ul class='spaced-list'>
+ * <li>{@link #create() create()} — entry point for configuring an
instance.
+ * <li>{@link Builder#bytes(byte[]) bytes(byte[])} — raw favicon
bytes.
+ * <li>{@link Builder#classpath(String) classpath(String)} — load
icon bytes from a
+ * classpath resource (resolved against the {@code
BasicFaviconResource} classloader).
+ * <li>{@link Builder#cacheControl(String) cacheControl(String)} —
override the default
+ * 30-day {@code Cache-Control} header.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link BasicSeoResource}
+ * <li class='jc'>{@link BasicVersionResource}
+ * <li class='jc'>{@link BasicWellKnownResource}
+ * <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={"/favicon.ico"})
+public class BasicFaviconResource {
+
+ /** Default {@code Cache-Control} header value: {@code max-age=2592000,
public} (30 days). */
+ public static final String DEFAULT_CACHE_CONTROL = "max-age=2592000,
public";
+
+ /** Classpath location of the framework-shipped default favicon. */
+ private static final String DEFAULT_FAVICON_RESOURCE =
"/juneau-favicon.ico";
+
+ /**
+ * Creates a new builder for configuring a {@link BasicFaviconResource}.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ private final byte[] bytes;
+ private final String cacheControl;
+
+ /**
+ * No-arg constructor — used when a host registers the mixin
without supplying a
+ * builder-configured {@code @Bean BasicFaviconResource}. Loads the
default Juneau-branded
+ * favicon from the classpath.
+ */
+ public BasicFaviconResource() {
+ this(create());
+ }
+
+ /**
+ * Builder constructor.
+ *
+ * @param builder The builder.
+ */
+ protected BasicFaviconResource(Builder builder) {
+ bytes = builder.resolveBytes();
+ cacheControl = builder.cacheControl;
+ }
+
+ /**
+ * [GET /favicon.ico] — serve the configured favicon bytes.
+ *
+ * @return The favicon as an {@link HttpResource} with proper headers.
+ */
+ @RestGet(
+ path="/favicon.ico",
+ summary="Favorites icon",
+ description="Browser favorites icon (favicon.ico).",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public HttpResource getFavicon() {
+ var hdrs = new ArrayList<HttpHeader>();
+ hdrs.add(ContentType.of("image/x-icon"));
+ hdrs.add(CacheControl.of(cacheControl));
+ return HttpResourceBean.of(ByteArrayBody.of(bytes,
"image/x-icon"), hdrs);
+ }
+
+ /**
+ * Builder for {@link BasicFaviconResource} instances.
+ */
+ public static class Builder {
+
+ private byte[] bytes;
+ private String classpath;
+ private String cacheControl = DEFAULT_CACHE_CONTROL;
+
+ /** Constructor — package access for {@link
BasicFaviconResource#create()}. */
+ protected Builder() {}
+
+ /**
+ * Sets the raw favicon bytes.
+ *
+ * <p>
+ * Mutually exclusive with {@link #classpath(String)} —
whichever is set last wins
+ * at {@link #build()} time.
+ *
+ * @param value The favicon bytes (typically an {@code .ico} or
{@code .png} payload).
+ * Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder bytes(byte[] value) {
+ bytes = value;
+ classpath = null;
+ return this;
+ }
+
+ /**
+ * Sets the classpath resource path from which to load the
favicon bytes.
+ *
+ * <p>
+ * Resolved via {@link Class#getResourceAsStream(String)
BasicFaviconResource.class.getResourceAsStream(...)}
+ * at {@link #build()} time. A resolved-to-{@code null} stream
falls back to the framework's
+ * default favicon. Mutually exclusive with {@link
#bytes(byte[])}.
+ *
+ * @param value The classpath resource path (e.g. {@code
"/myapp/icon.ico"}).
+ * Must not be <jk>null</jk> or blank.
+ * @return This object.
+ */
+ public Builder classpath(String value) {
+ classpath = value;
+ bytes = null;
+ return this;
+ }
+
+ /**
+ * Sets the {@code Cache-Control} header value emitted with the
favicon response.
+ *
+ * <p>
+ * Defaults to {@value #DEFAULT_CACHE_CONTROL} (30 days).
+ *
+ * @param value The new {@code Cache-Control} value. Must not
be <jk>null</jk> or blank.
+ * @return This object.
+ */
+ public Builder cacheControl(String value) {
+ cacheControl = value;
+ return this;
+ }
+
+ /**
+ * Builds a {@link BasicFaviconResource} instance.
+ *
+ * @return A configured instance.
+ */
+ public BasicFaviconResource build() {
+ return new BasicFaviconResource(this);
+ }
+
+ byte[] resolveBytes() {
+ if (bytes != null)
+ return bytes;
+ if (classpath != null) {
+ var resolved = readClasspath(classpath);
+ if (resolved != null)
+ return resolved;
+ }
+ // Falls through to the framework's default-shipping
ICO; the resource is shipped in
+ // the same jar as this class so it must be present at
runtime.
+ return readClasspath(DEFAULT_FAVICON_RESOURCE);
+ }
+
+ private static byte[] readClasspath(String path) {
+ try (var in =
BasicFaviconResource.class.getResourceAsStream(path)) {
+ if (in == null)
+ return null;
+ return in.readAllBytes();
+ } catch (IOException e) {
+ // readAllBytes on a classpath resource is
effectively unreachable; the catch is
+ // here only to satisfy the checked exception
contract.
+ throw new InternalServerError(e);
+ }
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicSeoResource.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicSeoResource.java
new file mode 100644
index 0000000000..0d5ead0b59
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicSeoResource.java
@@ -0,0 +1,321 @@
+/*
+ * 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.convention;
+
+import java.io.*;
+import java.time.*;
+import java.time.format.*;
+import java.util.*;
+
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+/**
+ * Mixin that serves SEO-conventional endpoints {@code /robots.txt} and {@code
/sitemap.xml}.
+ *
+ * <p>
+ * Sibling of {@link BasicFaviconResource} ({@code /favicon.ico}),
+ * {@link BasicVersionResource} ({@code /version} / {@code /info} / {@code
/about}), and
+ * {@link BasicWellKnownResource} ({@code /.well-known/*}). All four classes
live in the
+ * {@code org.apache.juneau.rest.convention} convention-endpoints mixin pack.
+ *
+ * <p>
+ * Compose into a host resource via {@link Rest#mixins()
@Rest(mixins=BasicSeoResource.class)};
+ * the {@code /robots.txt} and {@code /sitemap.xml} URLs become available
alongside the host's own
+ * endpoints with no further wiring. Or extend the class directly for a
standalone deployment whose
+ * mount paths come from the inherited {@link Rest#paths() @Rest(paths)}
default.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(path=<js>"/api"</js>,
mixins=BasicSeoResource.<jk>class</jk>)
+ * <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet { }
+ *
+ * <jc>// Override defaults via a @Bean factory:</jc>
+ * <ja>@Bean</ja> BasicSeoResource seo() {
+ * <jk>return</jk> BasicSeoResource.<jsm>create</jsm>()
+ * .robotsAllow(<js>"*"</js>, <js>"/"</js>)
+ * .sitemapEntry(<js>"/api/items"</js>)
+ * .build();
+ * }
+ * </p>
+ *
+ * <h5 class='section'>Defaults & behavior:</h5>
+ *
+ * <ul class='spaced-list'>
+ * <li><b>Robots policy — deny-all by default.</b> {@code GET
/robots.txt} returns
+ * {@code "User-agent: *\nDisallow: /\n"} unless the builder
specifies a different policy via
+ * {@link Builder#robotsAllow(String,String...) robotsAllow(...)}
or
+ * {@link Builder#robotsDisallow(String,String...)
robotsDisallow(...)}. The deny-all default
+ * is intentional — mounting the mixin without thinking
should not auto-opt the service
+ * into search-engine indexing.
+ * <li><b>Sitemap empty by default.</b> {@code GET /sitemap.xml} returns
an empty
+ * {@code <urlset>} when no entries are configured. Add entries via
+ * {@link Builder#sitemapEntry(String) sitemapEntry(String)} or
+ * {@link Builder#sitemapEntry(String,ZonedDateTime,String,Double)
sitemapEntry(String,ZonedDateTime,String,Double)}.
+ * <li><b>Content types.</b> {@code /robots.txt} returns {@code
text/plain; charset=UTF-8};
+ * {@code /sitemap.xml} returns {@code application/xml;
charset=UTF-8}.
+ * <li>Both endpoints are excluded from generated Swagger / OpenAPI specs
via
+ * {@link OpSwagger#ignore() @OpSwagger(ignore=true)}.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link BasicFaviconResource}
+ * <li class='jc'>{@link BasicVersionResource}
+ * <li class='jc'>{@link BasicWellKnownResource}
+ * <li class='link'><a class="doclink"
href="https://www.rfc-editor.org/rfc/rfc9309">RFC 9309 — Robots Exclusion
Protocol</a>
+ * <li class='link'><a class="doclink"
href="https://www.sitemaps.org/protocol.html">sitemaps.org — Sitemap
protocol</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={"/robots.txt","/sitemap.xml"})
+public class BasicSeoResource {
+
+ /** Default robots policy: deny everything. */
+ public static final String DEFAULT_ROBOTS = "User-agent: *\nDisallow:
/\n";
+
+ /**
+ * Creates a new builder.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ private final String robotsTxt;
+ private final List<SitemapEntry> sitemapEntries;
+
+ /** No-arg constructor used when a host registers the mixin without
builder configuration. */
+ public BasicSeoResource() {
+ this(create());
+ }
+
+ /**
+ * Builder constructor.
+ *
+ * @param builder The builder.
+ */
+ protected BasicSeoResource(Builder builder) {
+ robotsTxt = builder.buildRobots();
+ sitemapEntries = List.copyOf(builder.sitemapEntries);
+ }
+
+ /**
+ * [GET /robots.txt] — emit the configured robots policy as
{@code text/plain}.
+ *
+ * @param res The current REST response.
+ * @throws IOException If an I/O error occurs while writing the
response.
+ */
+ @RestGet(
+ path="/robots.txt",
+ summary="Robots policy",
+ description="Robots Exclusion Protocol policy file (RFC 9309).",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public void getRobotsTxt(RestResponse res) throws IOException {
+ try (var w = res.getDirectWriter("text/plain; charset=UTF-8")) {
+ w.write(robotsTxt);
+ }
+ }
+
+ /**
+ * [GET /sitemap.xml] — emit the configured sitemap as {@code
application/xml}.
+ *
+ * @param res The current REST response.
+ * @throws IOException If an I/O error occurs while writing the
response.
+ */
+ @RestGet(
+ path="/sitemap.xml",
+ summary="Sitemap",
+ description="XML sitemap of indexable URLs (sitemaps.org
protocol).",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public void getSitemap(RestResponse res) throws IOException {
+ try (var w = res.getDirectWriter("application/xml;
charset=UTF-8")) {
+ w.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
+ w.write("<urlset
xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
+ for (var e : sitemapEntries)
+ e.write(w);
+ w.write("</urlset>\n");
+ }
+ }
+
+ /**
+ * Single sitemap entry (immutable). Composed of a required {@code loc}
URL and optional
+ * {@code lastmod}, {@code changefreq}, and {@code priority} elements
per the
+ * <a href="https://www.sitemaps.org/protocol.html">sitemaps.org
protocol</a>.
+ */
+ public static final class SitemapEntry {
+ private final String loc;
+ private final ZonedDateTime lastmod;
+ private final String changefreq;
+ private final Double priority;
+
+ SitemapEntry(String loc, ZonedDateTime lastmod, String
changefreq, Double priority) {
+ this.loc = loc;
+ this.lastmod = lastmod;
+ this.changefreq = changefreq;
+ this.priority = priority;
+ }
+
+ void write(java.io.Writer w) throws IOException {
+ w.write("\t<url>\n");
+ w.write("\t\t<loc>" + xmlEscape(loc) + "</loc>\n");
+ if (lastmod != null)
+ w.write("\t\t<lastmod>" +
lastmod.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + "</lastmod>\n");
+ if (changefreq != null)
+ w.write("\t\t<changefreq>" +
xmlEscape(changefreq) + "</changefreq>\n");
+ if (priority != null)
+ w.write("\t\t<priority>" + priority +
"</priority>\n");
+ w.write("\t</url>\n");
+ }
+ }
+
+ private static String xmlEscape(String s) {
+ return s.replace("&", "&").replace("<",
"<").replace(">", ">").replace("\"", """).replace("'", "'");
+ }
+
+ /**
+ * Builder for {@link BasicSeoResource} instances.
+ */
+ public static class Builder {
+
+ private final List<RobotsRule> robotsRules = new ArrayList<>();
+ private final List<SitemapEntry> sitemapEntries = new
ArrayList<>();
+ private String customRobotsTxt;
+
+ /** Constructor — package access for {@link
BasicSeoResource#create()}. */
+ protected Builder() {}
+
+ /**
+ * Adds an {@code Allow} rule to the robots policy.
+ *
+ * <p>
+ * Multiple invocations append rules in order. The first call
to either
+ * {@link #robotsAllow(String,String...) robotsAllow(...)} or
+ * {@link #robotsDisallow(String,String...)
robotsDisallow(...)} replaces the deny-all
+ * default; subsequent calls continue to append.
+ *
+ * @param userAgent The user agent (e.g. {@code "*"} or {@code
"Googlebot"}).
+ * Must not be <jk>null</jk> or blank.
+ * @param paths Allowed path prefixes (e.g. {@code "/"}). At
least one path is required.
+ * @return This object.
+ */
+ public Builder robotsAllow(String userAgent, String...paths) {
+ robotsRules.add(new RobotsRule(userAgent, true, paths));
+ return this;
+ }
+
+ /**
+ * Adds a {@code Disallow} rule to the robots policy.
+ *
+ * @param userAgent The user agent (e.g. {@code "*"}). Must not
be <jk>null</jk> or blank.
+ * @param paths Disallowed path prefixes. At least one path is
required.
+ * @return This object.
+ */
+ public Builder robotsDisallow(String userAgent, String...paths)
{
+ robotsRules.add(new RobotsRule(userAgent, false,
paths));
+ return this;
+ }
+
+ /**
+ * Sets a fully-formed {@code robots.txt} body, overriding any
rule-builder calls.
+ *
+ * <p>
+ * Useful when the desired policy doesn't fit the simple
+ * {@code allow}/{@code disallow}/per-user-agent shape of
+ * {@link #robotsAllow(String,String...) robotsAllow(...)} /
+ * {@link #robotsDisallow(String,String...)
robotsDisallow(...)} (e.g. when including
+ * {@code Sitemap:} or {@code Crawl-delay:} directives).
+ *
+ * @param value The full {@code robots.txt} body. Must not be
<jk>null</jk>.
+ * @return This object.
+ */
+ public Builder robotsTxt(String value) {
+ customRobotsTxt = value;
+ return this;
+ }
+
+ /**
+ * Adds a sitemap entry with only the {@code loc} URL.
+ *
+ * @param url The fully-qualified URL.
+ * @return This object.
+ */
+ public Builder sitemapEntry(String url) {
+ return sitemapEntry(url, null, null, null);
+ }
+
+ /**
+ * Adds a sitemap entry with optional {@code lastmod}, {@code
changefreq}, and
+ * {@code priority} components.
+ *
+ * @param url The fully-qualified URL.
+ * @param lastmod The last-modified instant (or <jk>null</jk>
to omit).
+ * @param changefreq Change-frequency hint (e.g. {@code
"weekly"}, {@code "daily"}; or
+ * <jk>null</jk> to omit).
+ * @param priority Priority value 0.0..1.0 (or <jk>null</jk> to
omit).
+ * @return This object.
+ */
+ public Builder sitemapEntry(String url, ZonedDateTime lastmod,
String changefreq, Double priority) {
+ sitemapEntries.add(new SitemapEntry(url, lastmod,
changefreq, priority));
+ return this;
+ }
+
+ /**
+ * Builds a {@link BasicSeoResource} instance.
+ *
+ * @return A configured instance.
+ */
+ public BasicSeoResource build() {
+ return new BasicSeoResource(this);
+ }
+
+ String buildRobots() {
+ if (customRobotsTxt != null)
+ return customRobotsTxt;
+ if (robotsRules.isEmpty())
+ return DEFAULT_ROBOTS;
+ var sb = new StringBuilder();
+ for (var r : robotsRules) {
+ sb.append("User-agent:
").append(r.userAgent).append('\n');
+ for (var p : r.paths)
+ sb.append(r.allow ? "Allow: " :
"Disallow: ").append(p).append('\n');
+ }
+ return sb.toString();
+ }
+ }
+
+ /** Internal representation of a single user-agent + allow/disallow +
paths rule. */
+ private static final class RobotsRule {
+ final String userAgent;
+ final boolean allow;
+ final String[] paths;
+
+ RobotsRule(String userAgent, boolean allow, String[] paths) {
+ this.userAgent = userAgent;
+ this.allow = allow;
+ // paths arrives via varargs from
robotsAllow/robotsDisallow → never null in practice;
+ // clone to insulate downstream mutation.
+ this.paths = paths.clone();
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicVersionResource.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicVersionResource.java
new file mode 100644
index 0000000000..77f87bd5fd
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicVersionResource.java
@@ -0,0 +1,409 @@
+/*
+ * 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.convention;
+
+import java.io.*;
+import java.net.*;
+import java.util.*;
+import java.util.function.*;
+import java.util.jar.*;
+
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+/**
+ * Mixin that serves deployment-introspection metadata at {@code /version},
{@code /info}, and
+ * {@code /about}.
+ *
+ * <p>
+ * Sibling of {@link BasicFaviconResource}, {@link BasicSeoResource}, and
+ * {@link BasicWellKnownResource}. All four classes live in the
+ * {@code org.apache.juneau.rest.convention} convention-endpoints mixin pack.
+ *
+ * <p>
+ * Compose into a host resource via
+ * {@link Rest#mixins() @Rest(mixins=BasicVersionResource.class)}; the three
URLs become available
+ * alongside the host's own endpoints with no further wiring. Or extend the
class directly for a
+ * standalone deployment whose mount paths come from the inherited
+ * {@link Rest#paths() @Rest(paths)} default.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(path=<js>"/api"</js>,
mixins=BasicVersionResource.<jk>class</jk>)
+ * <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet { }
+ *
+ * <jc>// Default behavior reads MANIFEST.MF + git.properties from the
importer's classpath.</jc>
+ * <jc>// Override programmatically via a @Bean factory:</jc>
+ * <ja>@Bean</ja> BasicVersionResource version() {
+ * <jk>return</jk> BasicVersionResource.<jsm>create</jsm>()
+ * .entry(<js>"name"</js>, <js>"my-app"</js>)
+ * .entry(<js>"version"</js>, <js>"1.2.3"</js>)
+ * .build();
+ * }
+ * </p>
+ *
+ * <h5 class='section'>Default lookup chain:</h5>
+ *
+ * <p>
+ * The default {@link Builder#fromManifest() fromManifest()} reader walks the
+ * {@link BasicVersionResource} classloader for {@code /META-INF/MANIFEST.MF}
and reads the standard
+ * {@code Implementation-Title}, {@code Implementation-Version}, {@code
Implementation-Vendor}, and
+ * {@code Build-Jdk} attributes (lowercased to {@code name}, {@code version},
{@code vendor},
+ * {@code javaVersion}). The default {@link Builder#fromGitProperties()
fromGitProperties()} reader
+ * walks the same classloader for {@code /git.properties} (the canonical
+ * {@code git-commit-id-maven-plugin} output) and surfaces {@code
git.commit.id},
+ * {@code git.branch}, and {@code git.build.time} as {@code gitCommit}, {@code
gitBranch}, and
+ * {@code buildTime}. Missing files / missing keys map to {@code "(unknown)"}
or are simply omitted
+ * — never an exception.
+ *
+ * <p>
+ * <b>Spring Boot fat-jar caveat:</b> Spring Boot rewrites {@code MANIFEST.MF}
during repackaging.
+ * The mixin's no-arg-default reader uses {@code
BasicVersionResource.class.getClassLoader()};
+ * users who want the importer's app manifest under a Spring Boot executable
jar should register an
+ * explicit {@link Builder#fromManifest(ClassLoader)
fromManifest(importerClassLoader)} call so the
+ * lookup starts from the importer's classloader rather than the framework's.
+ *
+ * <h5 class='section'>Output:</h5>
+ *
+ * <p>
+ * All three URLs return the same JSON map — e.g.:
+ * <p class='bjson'>
+ * {
+ * <jok>"name"</jok>: <jov>"my-app"</jov>,
+ * <jok>"version"</jok>: <jov>"1.2.3"</jov>,
+ * <jok>"gitCommit"</jok>: <jov>"abc123"</jov>,
+ * <jok>"gitBranch"</jok>: <jov>"main"</jov>,
+ * <jok>"buildTime"</jok>: <jov>"2026-05-24T18:00:00Z"</jov>,
+ * <jok>"javaVersion"</jok>: <jov>"21"</jov>
+ * }
+ * </p>
+ *
+ * <p>
+ * Endpoints are excluded from generated Swagger / OpenAPI specs via
+ * {@link OpSwagger#ignore() @OpSwagger(ignore=true)}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link BasicFaviconResource}
+ * <li class='jc'>{@link BasicSeoResource}
+ * <li class='jc'>{@link BasicWellKnownResource}
+ * <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={"/version","/info","/about"})
+public class BasicVersionResource {
+
+ /** Sentinel value returned for entries that the mixin couldn't
resolve. */
+ public static final String UNKNOWN = "(unknown)";
+
+ /**
+ * Creates a new builder.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ private final Map<String,String> info;
+
+ /**
+ * No-arg constructor — reads {@code MANIFEST.MF}, {@code
git.properties}, and the JVM
+ * version from the framework classloader. Equivalent to
+ * {@code create().build()}; the builder applies the same defaults
whether instantiated
+ * directly or via the {@link
org.apache.juneau.commons.inject.BeanInstantiator BeanInstantiator}
+ * builder-detection path.
+ */
+ public BasicVersionResource() {
+ this(create());
+ }
+
+ /**
+ * Builder constructor.
+ *
+ * <p>
+ * Applies the default lookup chain ({@link Builder#fromManifest()} +
+ * {@link Builder#fromGitProperties()} + {@link
Builder#fromJavaVersion()}) when no other
+ * builder method has been called — ensuring that a
freshly-created builder
+ * (whether instantiated directly via {@code new
BasicVersionResource(create())} or via the
+ * {@link org.apache.juneau.commons.inject.BeanInstantiator
BeanInstantiator}'s
+ * builder-detection path) always produces a non-empty payload.
+ *
+ * @param builder The builder.
+ */
+ protected BasicVersionResource(Builder builder) {
+ if (! builder.explicit)
+
builder.fromManifest().fromGitProperties().fromJavaVersion();
+ info = Collections.unmodifiableMap(new
LinkedHashMap<>(builder.entries));
+ }
+
+ /**
+ * [GET /version | /info | /about] — emit the assembled metadata
as a JSON map.
+ *
+ * <p>
+ * Format-pinned to {@code application/json} per the v1 resolved
decision — bypasses
+ * the host's content negotiation so the endpoint serves JSON even on a
vanilla
+ * {@code RestServlet} host that hasn't wired up JSON serializers
explicitly.
+ *
+ * @param res The current REST response.
+ * @throws IOException If an I/O error occurs while writing the
response.
+ */
+ @RestGet(
+ path={"/version","/info","/about"},
+ summary="Version / build metadata",
+ description="Deployment-introspection metadata (name, version,
git, build).",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public void getInfo(RestResponse res) throws IOException {
+ try (var w = res.getDirectWriter("application/json")) {
+ JsonSerializer.DEFAULT_READABLE.serialize(info, w);
+ }
+ }
+
+ /**
+ * Returns the configured info map (test/inspection helper).
+ *
+ * @return The info map.
+ */
+ public Map<String,String> getInfoMap() {
+ return info;
+ }
+
+ /**
+ * Builder for {@link BasicVersionResource} instances.
+ */
+ public static class Builder {
+
+ private final Map<String,String> entries = new
LinkedHashMap<>();
+ private boolean explicit;
+
+ /** Constructor — package access for {@link
BasicVersionResource#create()}. */
+ protected Builder() {}
+
+ /**
+ * Sets a single entry, overwriting any prior value for that
key.
+ *
+ * @param key The entry key. Must not be <jk>null</jk> or blank.
+ * @param value The entry value (a <jk>null</jk> value is
recorded as
+ * {@link BasicVersionResource#UNKNOWN}).
+ * @return This object.
+ */
+ public Builder entry(String key, String value) {
+ entries.put(key, value == null ? UNKNOWN : value);
+ explicit = true;
+ return this;
+ }
+
+ /**
+ * Bulk-set entries from a map.
+ *
+ * @param values Entries to add. <jk>null</jk> values are
recorded as
+ * {@link BasicVersionResource#UNKNOWN}.
+ * @return This object.
+ */
+ public Builder entries(Map<String,String> values) {
+ if (values != null)
+ values.forEach(this::entry);
+ explicit = true;
+ return this;
+ }
+
+ /**
+ * Reads {@code Implementation-Title}, {@code
Implementation-Version},
+ * {@code Implementation-Vendor}, and {@code Build-Jdk} from
+ * {@code /META-INF/MANIFEST.MF} on the {@link
BasicVersionResource} classloader.
+ *
+ * <p>
+ * Missing keys are recorded as {@link
BasicVersionResource#UNKNOWN}. A missing
+ * {@code MANIFEST.MF} resource is silently skipped.
+ *
+ * @return This object.
+ */
+ public Builder fromManifest() {
+ return
fromManifest(BasicVersionResource.class.getClassLoader());
+ }
+
+ /**
+ * Reads manifest attributes from {@code /META-INF/MANIFEST.MF}
on the supplied classloader.
+ *
+ * <p>
+ * Useful under Spring Boot fat jars, where the importer's app
manifest is reachable from
+ * its own classloader but not the framework's. Missing keys
are recorded as
+ * {@link BasicVersionResource#UNKNOWN}; a missing {@code
MANIFEST.MF} resource is silently
+ * skipped.
+ *
+ * @param classLoader The classloader to walk.
+ * @return This object.
+ */
+ public Builder fromManifest(ClassLoader classLoader) {
+ var attrs = readManifestAttributes(classLoader);
+ ifNotEmpty(attrs, "Implementation-Title", v ->
entry("name", v));
+ ifNotEmpty(attrs, "Implementation-Version", v ->
entry("version", v));
+ ifNotEmpty(attrs, "Implementation-Vendor", v ->
entry("vendor", v));
+ ifNotEmpty(attrs, "Build-Jdk", v ->
entry("javaVersion", v));
+ entries.putIfAbsent("name", UNKNOWN);
+ entries.putIfAbsent("version", UNKNOWN);
+ explicit = true;
+ return this;
+ }
+
+ /**
+ * Reads a {@link Manifest} that the importer registered as a
bean (e.g.
+ * {@code @Bean Manifest appManifest()}).
+ *
+ * <p>
+ * Convenience for callers that already loaded the manifest via
Spring Boot's
+ * {@code BuildProperties} autoconfiguration or by hand.
+ *
+ * @param manifest The manifest to read. Must not be
<jk>null</jk>.
+ * @return This object.
+ */
+ public Builder fromManifest(Manifest manifest) {
+ var main = manifest.getMainAttributes();
+ var attrs = new HashMap<String,String>();
+ for (var k : main.keySet())
+ attrs.put(k.toString(),
String.valueOf(main.get(k)));
+ ifNotEmpty(attrs, "Implementation-Title", v ->
entry("name", v));
+ ifNotEmpty(attrs, "Implementation-Version", v ->
entry("version", v));
+ ifNotEmpty(attrs, "Implementation-Vendor", v ->
entry("vendor", v));
+ ifNotEmpty(attrs, "Build-Jdk", v ->
entry("javaVersion", v));
+ explicit = true;
+ return this;
+ }
+
+ /**
+ * Reads {@code git.commit.id}, {@code git.branch}, and {@code
git.build.time} from
+ * {@code /git.properties} on the {@link BasicVersionResource}
classloader.
+ *
+ * <p>
+ * Output of the
+ * <a
href="https://github.com/git-commit-id/git-commit-id-maven-plugin">git-commit-id-maven-plugin</a>;
+ * other values written to the same file are also surfaced
(each {@code git.foo.bar} key is
+ * remapped to {@code gitFooBar}).
+ *
+ * @return This object.
+ */
+ public Builder fromGitProperties() {
+ return
fromGitProperties(BasicVersionResource.class.getClassLoader());
+ }
+
+ /**
+ * Reads {@code git.properties} from the supplied classloader.
+ *
+ * @param classLoader The classloader to walk.
+ * @return This object.
+ */
+ public Builder fromGitProperties(ClassLoader classLoader) {
+ var props = readProperties(classLoader,
"git.properties");
+ if (props == null)
+ return this;
+ ifNotEmptyValue(props.getProperty("git.commit.id"), v
-> entry("gitCommit", v));
+
ifNotEmptyValue(props.getProperty("git.commit.id.abbrev"), v ->
entries.putIfAbsent("gitCommit", v));
+ ifNotEmptyValue(props.getProperty("git.branch"), v ->
entry("gitBranch", v));
+ ifNotEmptyValue(props.getProperty("git.build.time"), v
-> entry("buildTime", v));
+ explicit = true;
+ return this;
+ }
+
+ /**
+ * Adds the running JVM's {@code java.version} system property
as the {@code javaVersion}
+ * entry, unless an earlier {@link #fromManifest()
fromManifest(...)} call already supplied
+ * one from {@code Build-Jdk}.
+ *
+ * @return This object.
+ */
+ public Builder fromJavaVersion() {
+ entries.putIfAbsent("javaVersion",
System.getProperty("java.version", UNKNOWN));
+ explicit = true;
+ return this;
+ }
+
+ /**
+ * Builds a {@link BasicVersionResource} instance.
+ *
+ * <p>
+ * The constructor applies the default lookup chain ({@link
#fromManifest()} +
+ * {@link #fromGitProperties()} + {@link #fromJavaVersion()})
when no builder method has
+ * been called — so a freshly-created builder always
produces a non-empty payload
+ * regardless of which entry point is used.
+ *
+ * @return A configured instance.
+ */
+ public BasicVersionResource build() {
+ return new BasicVersionResource(this);
+ }
+
+ private static Map<String,String>
readManifestAttributes(ClassLoader cl) {
+ Manifest fallback = null;
+ try {
+ var resources =
cl.getResources("META-INF/MANIFEST.MF");
+ while (resources.hasMoreElements()) {
+ var u = resources.nextElement();
+ try (var in = u.openStream()) {
+ var m = new Manifest(in);
+ // Prefer an
Implementation-Title-bearing manifest; otherwise hold the
+ // first one found as a
fallback. Walking each candidate is cheap (one
+ // open per manifest in the
resources enumeration).
+ if
(m.getMainAttributes().getValue("Implementation-Title") != null)
+ return toMap(m);
+ if (fallback == null)
+ fallback = m;
+ }
+ }
+ } catch (IOException e) {
+ // Best-effort path: a malformed classpath
manifest yields an empty map rather
+ // than bubbling up an exception during startup.
+ return Map.of();
+ }
+ return fallback == null ? Map.of() : toMap(fallback);
+ }
+
+ private static Map<String,String> toMap(Manifest m) {
+ var attrs = new HashMap<String,String>();
+ for (var k : m.getMainAttributes().keySet())
+ attrs.put(k.toString(),
String.valueOf(m.getMainAttributes().get(k)));
+ return attrs;
+ }
+
+ private static Properties readProperties(ClassLoader cl, String
path) {
+ try (var in = cl.getResourceAsStream(path)) {
+ if (in == null)
+ return null;
+ var p = new Properties();
+ p.load(in);
+ return p;
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ private static void ifNotEmpty(Map<String,String> attrs, String
key, Consumer<String> sink) {
+ var v = attrs.get(key);
+ if (v != null && !v.isEmpty())
+ sink.accept(v);
+ }
+
+ private static void ifNotEmptyValue(String v, Consumer<String>
sink) {
+ if (v != null && !v.isEmpty())
+ sink.accept(v);
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicWellKnownResource.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicWellKnownResource.java
new file mode 100644
index 0000000000..a4cdf744fb
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicWellKnownResource.java
@@ -0,0 +1,181 @@
+/*
+ * 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.convention;
+
+import java.io.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+/**
+ * Mixin that serves <a href="https://www.rfc-editor.org/rfc/rfc8615">RFC
8615</a>
+ * {@code /.well-known/*} discovery endpoints.
+ *
+ * <p>
+ * Sibling of {@link BasicFaviconResource}, {@link BasicSeoResource}, and
+ * {@link BasicVersionResource}. All four classes live in the
+ * {@code org.apache.juneau.rest.convention} convention-endpoints mixin pack.
+ *
+ * <p>
+ * In v1 the mixin mounts a single literal path — {@code
/.well-known/security.txt} per
+ * <a href="https://www.rfc-editor.org/rfc/rfc9116">RFC 9116</a>. The class is
structured so that
+ * future entries (for example {@code /.well-known/openid-configuration} from
a separate AuthN
+ * mixin pack) can be added without touching this source: each future entry
ships its own mixin
+ * class with its own {@link Rest#paths() @Rest(paths)} declaration.
+ *
+ * <p>
+ * Compose into a host resource via
+ * {@link Rest#mixins() @Rest(mixins=BasicWellKnownResource.class)}; the
+ * {@code /.well-known/security.txt} URL becomes available alongside the
host's own endpoints with
+ * no further wiring. Or extend the class directly for a standalone deployment
whose mount paths
+ * come from the inherited {@link Rest#paths() @Rest(paths)} default.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(path=<js>"/api"</js>,
mixins=BasicWellKnownResource.<jk>class</jk>)
+ * <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet { }
+ *
+ * <jc>// Configure the security.txt body via a @Bean factory:</jc>
+ * <ja>@Bean</ja> BasicWellKnownResource wellKnown() {
+ * <jk>return</jk> BasicWellKnownResource.<jsm>create</jsm>()
+ * .securityTxt(<js>"Contact:
[email protected]\nExpires: 2027-01-01T00:00:00Z\n"</js>)
+ * .build();
+ * }
+ * </p>
+ *
+ * <h5 class='section'>Defaults & behavior:</h5>
+ *
+ * <ul class='spaced-list'>
+ * <li><b>No default body.</b> Per RFC 9116, {@code security.txt}'s mere
presence is meaningful;
+ * shipping a placeholder body would be misleading. If the builder
is not given a body via
+ * {@link Builder#securityTxt(String) securityTxt(String)}, the
endpoint returns
+ * {@code 404 Not Found} (i.e. "we don't have one"). Loud
documentation in topic page.
+ * <li><b>Content-Type.</b> {@code text/plain; charset=UTF-8} when a body
is configured.
+ * <li><b>Excluded from generated Swagger / OpenAPI specs</b> via
+ * {@link OpSwagger#ignore() @OpSwagger(ignore=true)}.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link BasicFaviconResource}
+ * <li class='jc'>{@link BasicSeoResource}
+ * <li class='jc'>{@link BasicVersionResource}
+ * <li class='link'><a class="doclink"
href="https://www.rfc-editor.org/rfc/rfc8615">RFC 8615 — Well-Known
URIs</a>
+ * <li class='link'><a class="doclink"
href="https://www.rfc-editor.org/rfc/rfc9116">RFC 9116 — security.txt</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={"/.well-known/security.txt"})
+public class BasicWellKnownResource {
+
+ /**
+ * Creates a new builder.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ private final String securityTxt;
+
+ /** No-arg constructor — results in a 404 on {@code
/.well-known/security.txt}. */
+ public BasicWellKnownResource() {
+ this(create());
+ }
+
+ /**
+ * Builder constructor.
+ *
+ * @param builder The builder.
+ */
+ protected BasicWellKnownResource(Builder builder) {
+ securityTxt = builder.securityTxt;
+ }
+
+ /**
+ * [GET /.well-known/security.txt] — emit the configured RFC 9116
body, or 404 when
+ * unset.
+ *
+ * @param res The current REST response.
+ * @throws IOException If an I/O error occurs while writing the
response.
+ * @throws NotFound If no {@code security.txt} body is configured.
+ */
+ @RestGet(
+ path="/.well-known/security.txt",
+ summary="security.txt",
+ description="RFC 9116 security.txt — disclosure / contact info
for the operator.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public void getSecurityTxt(RestResponse res) throws IOException {
+ if (securityTxt == null)
+ throw new NotFound();
+ try (var w = res.getDirectWriter("text/plain; charset=UTF-8")) {
+ w.write(securityTxt);
+ }
+ }
+
+ /**
+ * Returns the configured {@code security.txt} body (test/inspection
helper).
+ *
+ * @return The body, or <jk>null</jk> if unset.
+ */
+ public String getSecurityTxtBody() {
+ return securityTxt;
+ }
+
+ /**
+ * Builder for {@link BasicWellKnownResource} instances.
+ */
+ public static class Builder {
+
+ private String securityTxt;
+
+ /** Constructor — package access for {@link
BasicWellKnownResource#create()}. */
+ protected Builder() {}
+
+ /**
+ * Sets the {@code security.txt} body that will be served at
+ * {@code /.well-known/security.txt}.
+ *
+ * <p>
+ * Per RFC 9116, the body must include at least one {@code
Contact:} field and an
+ * {@code Expires:} field; the mixin does not validate the
content — that's the
+ * caller's responsibility.
+ *
+ * @param value The body content. Must not be <jk>null</jk> or
blank.
+ * @return This object.
+ */
+ public Builder securityTxt(String value) {
+ securityTxt = value;
+ return this;
+ }
+
+ /**
+ * Builds a {@link BasicWellKnownResource} instance.
+ *
+ * @return A configured instance.
+ */
+ public BasicWellKnownResource build() {
+ return new BasicWellKnownResource(this);
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/package-info.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/package-info.java
new file mode 100644
index 0000000000..81ccdac39a
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/package-info.java
@@ -0,0 +1,78 @@
+/*
+ * 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.
+ */
+/**
+ * Convention-endpoints mixin pack — composable {@code @Rest(mixins=...)}
resources that ship the
+ * "internet conventions" surface (favicon, robots, sitemap, version,
well-known) every public-facing
+ * service eventually grows.
+ *
+ * <p>
+ * Four sibling mixins compose into the host {@code @Rest}-annotated resource.
Each mixin owns its
+ * default mount paths and is independently mountable; the four together drop
in as a pack via
+ * {@code @Rest(mixins={BasicFaviconResource.class, BasicSeoResource.class,
BasicVersionResource.class, BasicWellKnownResource.class})}.
+ * </p>
+ *
+ * <ul class='javatreec'>
+ * <li class='jc'>{@link
org.apache.juneau.rest.convention.BasicFaviconResource} —
+ * {@code /favicon.ico} with a 30-day {@code Cache-Control} and a
default Juneau-branded icon
+ * on the framework classpath.
+ * <li class='jc'>{@link
org.apache.juneau.rest.convention.BasicSeoResource} —
+ * {@code /robots.txt} (deny-all by default) and {@code
/sitemap.xml} (empty
+ * {@code <urlset>} by default); both builder-driven.
+ * <li class='jc'>{@link
org.apache.juneau.rest.convention.BasicVersionResource} —
+ * {@code /version}, {@code /info}, and {@code /about} returning
the same JSON metadata
+ * map; defaults read {@code MANIFEST.MF} + {@code git.properties}
from the classpath.
+ * <li class='jc'>{@link
org.apache.juneau.rest.convention.BasicWellKnownResource} —
+ * {@code /.well-known/security.txt} per RFC 9116; 404 unless
explicitly configured.
+ * </ul>
+ *
+ * <h5 class='section'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(
+ * path=<js>"/api"</js>,
+ * mixins={
+ * BasicFaviconResource.<jk>class</jk>,
+ * BasicSeoResource.<jk>class</jk>,
+ * BasicVersionResource.<jk>class</jk>,
+ * BasicWellKnownResource.<jk>class</jk>
+ * }
+ * )
+ * <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet {
+ * <ja>@Bean</ja> BasicSeoResource seo() {
+ * <jk>return</jk>
BasicSeoResource.<jsm>create</jsm>().robotsAllow(<js>"*"</js>,
<js>"/"</js>).build();
+ * }
+ * <ja>@Bean</ja> BasicWellKnownResource wellKnown() {
+ * <jk>return</jk>
BasicWellKnownResource.<jsm>create</jsm>()
+ * .securityTxt(<js>"Contact:
[email protected]\nExpires: 2027-01-01T00:00:00Z\n"</js>)
+ * .build();
+ * }
+ * }
+ * </p>
+ *
+ * <p>
+ * All four endpoints are excluded from generated Swagger / OpenAPI specs via
+ * {@link org.apache.juneau.rest.annotation.OpSwagger#ignore()
@OpSwagger(ignore=true)} —
+ * convention endpoints are not API-meaningful.
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <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
+ */
+package org.apache.juneau.rest.convention;
diff --git
a/juneau-rest/juneau-rest-server/src/main/resources/juneau-favicon.ico
b/juneau-rest/juneau-rest-server/src/main/resources/juneau-favicon.ico
new file mode 100644
index 0000000000..b6dce1b3fd
Binary files /dev/null and
b/juneau-rest/juneau-rest-server/src/main/resources/juneau-favicon.ico differ
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicConvention_Builders_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicConvention_Builders_Test.java
new file mode 100644
index 0000000000..f34cd8e346
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicConvention_Builders_Test.java
@@ -0,0 +1,361 @@
+/*
+ * 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.convention;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.nio.file.*;
+import java.time.*;
+import java.util.*;
+import java.util.jar.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.*;
+
+/**
+ * Direct-instance tests for the convention-pack builders — covers the
no-arg constructor
+ * paths, helper methods, and edge cases that the {@code AsMixin} /
real-container tests don't
+ * exercise (entries-map handling, sitemap-entry XML formatting,
git.properties parsing,
+ * URL-classloader fallback paths, etc.).
+ *
+ * <p>
+ * Pure JUnit; no MockRest or container needed.
+ *
+ * @since 9.5.0
+ */
+class BasicConvention_Builders_Test extends TestBase {
+
+ // -------- BasicFaviconResource --------
+
+ @Test void favicon_noArgConstructorLoadsDefault() {
+ var f = new BasicFaviconResource();
+ assertNotNull(f);
+ }
+
+ @Test void favicon_classpathResolvesExistingResource() {
+ var f = BasicFaviconResource.create()
+ .classpath("/juneau-favicon.ico")
+ .build();
+ assertNotNull(f);
+ }
+
+ @Test void favicon_classpathFallsBackOnMissingResource() {
+ var f = BasicFaviconResource.create()
+ .classpath("/no-such-resource.ico")
+ .build();
+ assertNotNull(f, "Missing classpath path must fall back to
default favicon");
+ }
+
+ @Test void favicon_bytesAndClasspathAreMutuallyExclusive() {
+ var bytes = new byte[]{1,2,3};
+ var f = BasicFaviconResource.create()
+ .classpath("/anything.ico")
+ .bytes(bytes)
+ .build();
+ // bytes() wins over classpath() when set last
+ assertNotNull(f);
+ }
+
+ @Test void favicon_customCacheControl() {
+ var f = BasicFaviconResource.create()
+ .bytes(new byte[]{1,2})
+ .cacheControl("no-store")
+ .build();
+ assertNotNull(f);
+ }
+
+ // -------- BasicSeoResource --------
+
+ @Test void seo_noArgConstructorYieldsDenyAll() {
+ var s = new BasicSeoResource();
+ assertNotNull(s);
+ }
+
+ @Test void seo_robotsAllowAndDisallowMix() {
+ var s = BasicSeoResource.create()
+ .robotsAllow("Googlebot", "/")
+ .robotsDisallow("BadBot", "/private", "/admin")
+ .build();
+ assertNotNull(s);
+ }
+
+ @Test void seo_sitemapEntryWithFullMetadata() {
+ var s = BasicSeoResource.create()
+ .sitemapEntry("https://example.com/page",
+ ZonedDateTime.parse("2026-05-24T18:00:00Z"),
+ "weekly",
+ 0.8)
+ .build();
+ assertNotNull(s);
+ }
+
+ @Test void seo_sitemapEntryDirectInstance() {
+ var e = new BasicSeoResource.SitemapEntry(
+ "https://x.example.com/<unsafe&\"chars'>",
+ ZonedDateTime.parse("2026-05-24T18:00:00+02:00"),
+ "daily",
+ 1.0);
+ var sw = new java.io.StringWriter();
+ try {
+ e.write(sw);
+ } catch (IOException ex) {
+ fail(ex);
+ }
+ var xml = sw.toString();
+ assertTrue(xml.contains("<lastmod>"), "lastmod present");
+ assertTrue(xml.contains("<changefreq>daily</changefreq>"),
"changefreq present");
+ assertTrue(xml.contains("<priority>1.0</priority>"), "priority
present");
+ assertTrue(xml.contains("&"), "ampersand escaped");
+ assertTrue(xml.contains("<"), "less-than escaped");
+ assertTrue(xml.contains(">"), "greater-than escaped");
+ assertTrue(xml.contains("""), "double-quote escaped");
+ assertTrue(xml.contains("'"), "single-quote escaped");
+ }
+
+ @Test void seo_emptyPathArrayInRobotsRuleIsTolerated() {
+ // Internal RobotsRule's path-array handling — null/empty paths
is just an empty rule.
+ var s = BasicSeoResource.create().robotsAllow("MyBot").build();
+ assertNotNull(s);
+ }
+
+ // -------- BasicVersionResource --------
+
+ @Test void version_noArgConstructorPopulatesDefaults() {
+ var v = new BasicVersionResource();
+ assertNotNull(v.getInfoMap().get("javaVersion"), "no-arg ctor
must surface javaVersion");
+ }
+
+ @Test void version_entryAcceptsNullValue() {
+ var v = BasicVersionResource.create()
+ .entry("missing", null)
+ .build();
+ assertEquals(BasicVersionResource.UNKNOWN,
v.getInfoMap().get("missing"));
+ }
+
+ @Test void version_entriesMapSink() {
+ var values = new LinkedHashMap<String,String>();
+ values.put("a", "1");
+ values.put("b", null);
+ var v = BasicVersionResource.create().entries(values).build();
+ assertEquals("1", v.getInfoMap().get("a"));
+ assertEquals(BasicVersionResource.UNKNOWN,
v.getInfoMap().get("b"));
+ }
+
+ @Test void version_entriesNullMapIsTolerated() {
+ var v = BasicVersionResource.create().entries(null).build();
+ assertNotNull(v);
+ }
+
+ @Test void version_fromManifestFallbackUnknownNameAndVersion() {
+ // URLClassLoader with no manifest available: name+version fall
back to (unknown).
+ var v = BasicVersionResource.create()
+ .fromManifest(new URLClassLoader(new URL[0], null))
+ .build();
+ assertEquals(BasicVersionResource.UNKNOWN,
v.getInfoMap().get("name"));
+ assertEquals(BasicVersionResource.UNKNOWN,
v.getInfoMap().get("version"));
+ }
+
+ @Test void version_fromManifestObjectFullAttrs() {
+ var m = new Manifest();
+ var attrs = m.getMainAttributes();
+ attrs.putValue("Manifest-Version", "1.0");
+ attrs.putValue("Implementation-Title", "obj-app");
+ attrs.putValue("Implementation-Version", "5.0.0");
+ attrs.putValue("Implementation-Vendor", "Co");
+ attrs.putValue("Build-Jdk", "21");
+ var v = BasicVersionResource.create().fromManifest(m).build();
+ assertEquals("obj-app", v.getInfoMap().get("name"));
+ assertEquals("5.0.0", v.getInfoMap().get("version"));
+ assertEquals("Co", v.getInfoMap().get("vendor"));
+ assertEquals("21", v.getInfoMap().get("javaVersion"));
+ }
+
+ @Test void version_fromGitPropertiesViaClasspathResource(@TempDir Path
tempDir) throws Exception {
+ // Build a tiny synthetic classpath that contains
git.properties at the root.
+ var props = "git.commit.id=abcdef0123456789\n"
+ + "git.commit.id.abbrev=abcdef0\n"
+ + "git.branch=feature\n"
+ + "git.build.time=2026-05-24T17:00:00Z\n";
+ Files.writeString(tempDir.resolve("git.properties"), props,
StandardCharsets.UTF_8);
+ try (var cl = new URLClassLoader(new URL[]{
tempDir.toUri().toURL() }, null)) {
+ var v = BasicVersionResource.create()
+ .fromGitProperties(cl)
+ .build();
+ assertEquals("abcdef0123456789",
v.getInfoMap().get("gitCommit"));
+ assertEquals("feature",
v.getInfoMap().get("gitBranch"));
+ assertEquals("2026-05-24T17:00:00Z",
v.getInfoMap().get("buildTime"));
+ }
+ }
+
+ @Test void version_fromGitPropertiesAbbrevFallback(@TempDir Path
tempDir) throws Exception {
+ var props = "git.commit.id.abbrev=abcdef0\n"
+ + "git.branch=main\n";
+ Files.writeString(tempDir.resolve("git.properties"), props,
StandardCharsets.UTF_8);
+ try (var cl = new URLClassLoader(new URL[]{
tempDir.toUri().toURL() }, null)) {
+ var v = BasicVersionResource.create()
+ .fromGitProperties(cl)
+ .build();
+ assertEquals("abcdef0", v.getInfoMap().get("gitCommit"),
+ "abbrev fallback applies when full commit id
absent");
+ }
+ }
+
+ @Test void version_fromGitPropertiesMissingFileIsSilent() throws
Exception {
+ try (var cl = new URLClassLoader(new URL[0], null)) {
+ var v = BasicVersionResource.create()
+ .fromGitProperties(cl)
+ .build();
+ assertNull(v.getInfoMap().get("gitCommit"),
+ "missing git.properties must not register a
gitCommit entry");
+ }
+ }
+
+ @Test void version_fromManifestWithoutImplementationTitle(@TempDir Path
tempDir) throws Exception {
+ // Manifest without Implementation-Title — locator falls back
to first found.
+ var manifestDir = tempDir.resolve("META-INF");
+ Files.createDirectory(manifestDir);
+ Files.writeString(manifestDir.resolve("MANIFEST.MF"),
+ "Manifest-Version: 1.0\nBuild-Jdk: 22\n\n",
+ StandardCharsets.UTF_8);
+ try (var cl = new URLClassLoader(new URL[]{
tempDir.toUri().toURL() }, null)) {
+ var v = BasicVersionResource.create()
+ .fromManifest(cl)
+ .build();
+ assertEquals("22", v.getInfoMap().get("javaVersion"));
+ assertEquals(BasicVersionResource.UNKNOWN,
v.getInfoMap().get("name"),
+ "name unset → (unknown)");
+ }
+ }
+
+ @Test void version_fromJavaVersionPutsIfAbsent() {
+ var v = BasicVersionResource.create()
+ .entry("javaVersion", "preset")
+ .fromJavaVersion()
+ .build();
+ assertEquals("preset", v.getInfoMap().get("javaVersion"),
+ "fromJavaVersion must not overwrite an explicit entry");
+ }
+
+ @Test void version_fromManifestObjectEmptyVendorIsSkipped() {
+ // Hits the empty-string branch in ifNotEmpty / ifNotEmptyValue.
+ var m = new Manifest();
+ var attrs = m.getMainAttributes();
+ attrs.putValue("Manifest-Version", "1.0");
+ attrs.putValue("Implementation-Title", "x");
+ attrs.putValue("Implementation-Version", "1");
+ attrs.putValue("Implementation-Vendor", "");
+ var v = BasicVersionResource.create().fromManifest(m).build();
+ assertNull(v.getInfoMap().get("vendor"), "empty string
Implementation-Vendor must be skipped");
+ }
+
+ @Test void version_fromGitPropertiesEmptyValuesAreSkipped(@TempDir Path
tempDir) throws Exception {
+ var props = "git.commit.id=\n"
+ + "git.branch=\n"
+ + "git.build.time=\n";
+ Files.writeString(tempDir.resolve("git.properties"), props,
StandardCharsets.UTF_8);
+ try (var cl = new URLClassLoader(new URL[]{
tempDir.toUri().toURL() }, null)) {
+ var v =
BasicVersionResource.create().fromGitProperties(cl).build();
+ assertNull(v.getInfoMap().get("gitCommit"), "empty
git.commit.id skipped");
+ assertNull(v.getInfoMap().get("gitBranch"), "empty
git.branch skipped");
+ assertNull(v.getInfoMap().get("buildTime"), "empty
git.build.time skipped");
+ }
+ }
+
+ @Test void version_fromManifestClassLoaderThrowsIOException() {
+ // A ClassLoader whose getResources(...) throws IOException
must yield (unknown) entries
+ // without bubbling up the exception.
+ var brokenCl = new ClassLoader(null) {
+ @Override
+ public java.util.Enumeration<URL> getResources(String
name) throws IOException {
+ throw new IOException("boom");
+ }
+ };
+ var v =
BasicVersionResource.create().fromManifest(brokenCl).build();
+ assertEquals(BasicVersionResource.UNKNOWN,
v.getInfoMap().get("name"));
+ assertEquals(BasicVersionResource.UNKNOWN,
v.getInfoMap().get("version"));
+ }
+
+ @Test void version_fromGitPropertiesClassLoaderThrowsIOException() {
+ // ClassLoader whose getResourceAsStream returns a stream that
throws on read must yield
+ // no git.* entries, again without bubbling up the exception.
+ var brokenCl = new ClassLoader(null) {
+ @Override
+ public InputStream getResourceAsStream(String name) {
+ return new InputStream() {
+ @Override
+ public int read() throws IOException {
+ throw new IOException("boom");
+ }
+ };
+ }
+ };
+ var v =
BasicVersionResource.create().fromGitProperties(brokenCl).build();
+ assertNull(v.getInfoMap().get("gitCommit"), "broken
git.properties read produces no entry");
+ }
+
+ @Test void version_fromManifestUrlOpenStreamThrows(@TempDir Path
tempDir) throws Exception {
+ // Locator returns a URL pointing at a deleted manifest;
openStream throws
+ // FileNotFoundException → caught and yields empty map →
name/version → (unknown).
+ var manifestDir = tempDir.resolve("META-INF");
+ Files.createDirectory(manifestDir);
+ var manifest = manifestDir.resolve("MANIFEST.MF");
+ Files.writeString(manifest,
+ "Manifest-Version: 1.0\nImplementation-Title:
gone\n\n", StandardCharsets.UTF_8);
+ try (var cl = new URLClassLoader(new URL[]{
tempDir.toUri().toURL() }, null)) {
+ Files.delete(manifest);
+ var v =
BasicVersionResource.create().fromManifest(cl).build();
+ assertEquals(BasicVersionResource.UNKNOWN,
v.getInfoMap().get("name"));
+ }
+ }
+
+ @Test void
version_fromManifestSecondCandidateWinsOnImplementationTitle(@TempDir Path
tempDir) throws Exception {
+ // Two manifests on the classpath: only the second has
Implementation-Title — locator
+ // must prefer it.
+ var dirA = Files.createDirectory(tempDir.resolve("a"));
+ var dirB = Files.createDirectory(tempDir.resolve("b"));
+ Files.createDirectory(dirA.resolve("META-INF"));
+ Files.writeString(dirA.resolve("META-INF/MANIFEST.MF"),
+ "Manifest-Version: 1.0\n\n", StandardCharsets.UTF_8);
+ Files.createDirectory(dirB.resolve("META-INF"));
+ Files.writeString(dirB.resolve("META-INF/MANIFEST.MF"),
+ "Manifest-Version: 1.0\nImplementation-Title:
titled\nImplementation-Version: 2\n\n",
+ StandardCharsets.UTF_8);
+ try (var cl = new URLClassLoader(
+ new URL[]{ dirA.toUri().toURL(),
dirB.toUri().toURL() }, null)) {
+ var v =
BasicVersionResource.create().fromManifest(cl).build();
+ assertEquals("titled", v.getInfoMap().get("name"),
+ "Locator must prefer
Implementation-Title-bearing manifest");
+ }
+ }
+
+ // -------- BasicWellKnownResource --------
+
+ @Test void wellKnown_noArgConstructorYieldsNullBody() {
+ var w = new BasicWellKnownResource();
+ assertNull(w.getSecurityTxtBody());
+ }
+
+ @Test void wellKnown_securityTxtBodyAccessor() {
+ var body = "Contact: [email protected]\nExpires:
2027-01-01T00:00:00Z\n";
+ var w =
BasicWellKnownResource.create().securityTxt(body).build();
+ assertEquals(body, w.getSecurityTxtBody());
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicConvention_OpenApiHidden_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicConvention_OpenApiHidden_Test.java
new file mode 100644
index 0000000000..3903409be2
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicConvention_OpenApiHidden_Test.java
@@ -0,0 +1,105 @@
+/*
+ * 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.convention;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+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.swagger.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates that all four convention-pack endpoints are excluded from the
generated OpenAPI spec
+ * via {@link OpSwagger#ignore() @OpSwagger(ignore=true)} on each mixin's
handler.
+ *
+ * <p>
+ * The host extends vanilla {@link RestServlet} (not {@link BasicRestServlet})
and mounts the four
+ * convention mixins plus {@link BasicOpenApiResource} (the OpenAPI
generator). The generated spec
+ * must list the host's own {@code /items} endpoint but NOT the convention
paths.
+ *
+ * @since 9.5.0
+ */
+class BasicConvention_OpenApiHidden_Test extends TestBase {
+
+ @Rest(
+ mixins={
+ BasicFaviconResource.class,
+ BasicSeoResource.class,
+ BasicVersionResource.class,
+ BasicWellKnownResource.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";
}
+
+ @Bean public BasicVersionResource version() {
+ return BasicVersionResource.create().entry("name",
"convention-openapi-test").build();
+ }
+
+ @Bean public BasicWellKnownResource wellKnown() {
+ return
BasicWellKnownResource.create().securityTxt("Contact:
[email protected]\n").build();
+ }
+ }
+
+ private static final MockRestClient c =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_openapiSpecExcludesAllConventionPaths() throws Exception
{
+ var spec = c.get("/openapi.json")
+ .run()
+ .assertStatus(200)
+ .getContent().asString();
+
+ // Host's own endpoint must be listed.
+ assertContains(spec, "/items");
+
+ // Convention paths must NOT be listed.
+ assertNotContains(spec, "/favicon.ico");
+ assertNotContains(spec, "/robots.txt");
+ assertNotContains(spec, "/sitemap.xml");
+ assertNotContains(spec, "/version");
+ assertNotContains(spec, "/info");
+ assertNotContains(spec, "/about");
+ assertNotContains(spec, "/.well-known/security.txt");
+ assertNotContains(spec, ".well-known");
+ }
+
+ @Test void a02_conventionEndpointsStillServedDespiteHiddenFromSpec()
throws Exception {
+ c.get("/favicon.ico").run().assertStatus(200);
+ c.get("/robots.txt").run().assertStatus(200);
+ c.get("/sitemap.xml").run().assertStatus(200);
+ c.get("/version").run().assertStatus(200);
+ c.get("/.well-known/security.txt").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/convention/BasicConvention_ParentChain_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicConvention_ParentChain_Test.java
new file mode 100644
index 0000000000..fb9badf768
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicConvention_ParentChain_Test.java
@@ -0,0 +1,138 @@
+/*
+ * 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.convention;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+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.junit.jupiter.api.*;
+
+/**
+ * Verifies that all four convention-pack mixins compose cleanly on a single
host with no path
+ * collisions and that each mixin's {@code RestContext} is registered.
+ *
+ * <p>
+ * Setup: a single {@link RestServlet} host mounts all four convention mixins
+ * (favicon, SEO, version, well-known) and registers builder-configured {@code
@Bean}
+ * factories for those that need explicit configuration.
+ *
+ * <p>
+ * Acceptance:
+ * <ul>
+ * <li>All four mixins appear in {@code RestContext.getMixinContexts()}.
+ * <li>Every convention path resolves (favicon.ico, robots.txt,
sitemap.xml, version, info,
+ * about, .well-known/security.txt).
+ * <li>Host's own {@code /items} endpoint is unaffected.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicConvention_ParentChain_Test extends TestBase {
+
+ @Rest(mixins={
+ BasicFaviconResource.class,
+ BasicSeoResource.class,
+ BasicVersionResource.class,
+ BasicWellKnownResource.class
+ })
+ public static class A extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @RestGet(path="/items") public String items() { return "items";
}
+
+ @Bean public BasicSeoResource seo() {
+ return BasicSeoResource.create()
+ .robotsAllow("*", "/")
+ .sitemapEntry("https://example.com/items")
+ .build();
+ }
+
+ @Bean public BasicVersionResource version() {
+ return BasicVersionResource.create()
+ .entry("name", "convention-pack")
+ .entry("version", "9.5.0")
+ .fromJavaVersion()
+ .build();
+ }
+
+ @Bean public BasicWellKnownResource wellKnown() {
+ return BasicWellKnownResource.create()
+ .securityTxt("Contact:
[email protected]\nExpires: 2027-01-01T00:00:00Z\n")
+ .build();
+ }
+ }
+
+ private static final MockRestClient c =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_allFourMixinContextsRegistered() throws Exception {
+ MockRestClient.buildLax(A.class);
+ var hostCtx = RestContext.getGlobalRegistry().get(A.class);
+ var ctxs = hostCtx.getMixinContexts();
+
+ assertNotNull(ctxs.get(BasicFaviconResource.class), "Favicon
mixin context registered");
+ assertNotNull(ctxs.get(BasicSeoResource.class), "SEO mixin
context registered");
+ assertNotNull(ctxs.get(BasicVersionResource.class), "Version
mixin context registered");
+ assertNotNull(ctxs.get(BasicWellKnownResource.class),
"Well-known mixin context registered");
+ assertEquals(4, ctxs.size(),
+ "Expected exactly four mixin contexts; got: " +
ctxs.keySet());
+ }
+
+ @Test void a02_faviconResolves() throws Exception {
+ c.get("/favicon.ico").run().assertStatus(200)
+ .assertHeader("Content-Type").is("image/x-icon");
+ }
+
+ @Test void a03_robotsTxtResolves() throws Exception {
+ c.get("/robots.txt").run().assertStatus(200)
+ .assertContent().asString().isContains("Allow: /");
+ }
+
+ @Test void a04_sitemapXmlResolves() throws Exception {
+ c.get("/sitemap.xml").run().assertStatus(200)
+
.assertContent().asString().isContains("<loc>https://example.com/items</loc>");
+ }
+
+ @Test void a05_versionEndpointResolves() throws Exception {
+ c.get("/version").run().assertStatus(200)
+ .assertContent().asString().isContains("\"name\":
\"convention-pack\"");
+ }
+
+ @Test void a06_infoEndpointResolves() throws Exception {
+ c.get("/info").run().assertStatus(200)
+ .assertContent().asString().isContains("\"version\":
\"9.5.0\"");
+ }
+
+ @Test void a07_aboutEndpointResolves() throws Exception {
+ c.get("/about").run().assertStatus(200)
+ .assertContent().asString().isContains("\"name\":
\"convention-pack\"");
+ }
+
+ @Test void a08_securityTxtResolves() throws Exception {
+ c.get("/.well-known/security.txt").run().assertStatus(200)
+ .assertContent().asString().isContains("Contact:
[email protected]");
+ }
+
+ @Test void a09_hostEndpointStillReachable() throws Exception {
+ c.get("/items").run().assertStatus(200)
+ .assertContent().asString().isContains("items");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicFaviconResource_AsMixin_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicFaviconResource_AsMixin_Test.java
new file mode 100644
index 0000000000..f99ee392fd
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicFaviconResource_AsMixin_Test.java
@@ -0,0 +1,138 @@
+/*
+ * 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.convention;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link BasicFaviconResource} mounted as a mixin via {@code
@Rest(mixins=...)} on a
+ * vanilla {@link RestServlet}.
+ *
+ * <p>
+ * Cases:
+ * <ul>
+ * <li>Default favicon (framework-shipped {@code /juneau-favicon.ico}) is
served when no
+ * {@code @Bean BasicFaviconResource} is registered on the host.
+ * <li>{@code Content-Type: image/x-icon} and 30-day {@code Cache-Control}
headers flow through.
+ * <li>Importer's {@code @Bean BasicFaviconResource} factory overrides the
default bytes.
+ * <li>{@link BasicFaviconResource.Builder#classpath(String)
classpath(...)} loads icon bytes
+ * from a classpath resource path.
+ * <li>The host's own endpoints are unaffected by the mixin.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicFaviconResource_AsMixin_Test extends TestBase {
+
+ @Rest(mixins=BasicFaviconResource.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 ca =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_defaultFaviconServed() throws Exception {
+ ca.get("/favicon.ico")
+ .run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").is("image/x-icon")
+ .assertHeader("Cache-Control").is("max-age=2592000,
public");
+ }
+
+ @Test void a02_defaultFaviconBodyNonEmpty() throws Exception {
+ var body = ca.get("/favicon.ico")
+ .run()
+ .assertStatus(200)
+ .getContent().asBytes();
+ Assertions.assertTrue(body.length > 0, "Default favicon body
must be non-empty");
+ Assertions.assertEquals(0x00, body[0] & 0xFF, "ICO magic byte
0");
+ Assertions.assertEquals(0x00, body[1] & 0xFF, "ICO magic byte
1");
+ Assertions.assertEquals(0x01, body[2] & 0xFF, "ICO magic byte 2
(type=ICO)");
+ }
+
+ @Test void a03_hostEndpointStillReachable() throws Exception {
+ ca.get("/items")
+ .run()
+ .assertStatus(200)
+ .assertContent().asString().isContains("items");
+ }
+
+ /** Host with an importer-supplied {@code @Bean BasicFaviconResource}
carrying custom bytes. */
+ @Rest(mixins=BasicFaviconResource.class)
+ public static class B extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public BasicFaviconResource favicon() {
+ return BasicFaviconResource.create()
+ .bytes(new byte[]{(byte)0xCA, (byte)0xFE,
(byte)0xBA, (byte)0xBE})
+ .build();
+ }
+ }
+
+ private static final MockRestClient cb =
MockRestClient.buildLax(B.class);
+
+ @Test void b01_overrideViaBuilderBytes() throws Exception {
+ var body = cb.get("/favicon.ico")
+ .run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").is("image/x-icon")
+ .getContent().asBytes();
+ Assertions.assertEquals(4, body.length);
+ Assertions.assertEquals((byte)0xCA, body[0]);
+ Assertions.assertEquals((byte)0xFE, body[1]);
+ Assertions.assertEquals((byte)0xBA, body[2]);
+ Assertions.assertEquals((byte)0xBE, body[3]);
+ }
+
+ /** Host with an importer-supplied factory that loads bytes via
classpath resource path. */
+ @Rest(mixins=BasicFaviconResource.class)
+ public static class C extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public BasicFaviconResource favicon() {
+ // /juneau-favicon.ico is the framework's
default-shipping classpath resource;
+ // loading it explicitly via classpath(...) verifies
the classpath path works.
+ return BasicFaviconResource.create()
+ .classpath("/juneau-favicon.ico")
+ .cacheControl("max-age=300, public")
+ .build();
+ }
+ }
+
+ private static final MockRestClient cc =
MockRestClient.buildLax(C.class);
+
+ @Test void c01_classpathLoaderAndCustomCacheControl() throws Exception {
+ cc.get("/favicon.ico")
+ .run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").is("image/x-icon")
+ .assertHeader("Cache-Control").is("max-age=300,
public");
+ }
+
+ @Test void c02_classpathMissingFallsBackToDefault() throws Exception {
+ // A builder pointed at a missing classpath path falls back to
the framework default.
+ var fav = BasicFaviconResource.create()
+ .classpath("/no-such-favicon.ico")
+ .build();
+ // Even with a missing classpath override, the default favicon
resource provides bytes.
+ Assertions.assertNotNull(fav, "Builder must always produce an
instance");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicSeoResource_AsMixin_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicSeoResource_AsMixin_Test.java
new file mode 100644
index 0000000000..2771706a02
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicSeoResource_AsMixin_Test.java
@@ -0,0 +1,127 @@
+/*
+ * 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.convention;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link BasicSeoResource} mounted as a mixin via {@code
@Rest(mixins=...)} on a vanilla
+ * {@link RestServlet}.
+ *
+ * <p>
+ * Cases:
+ * <ul>
+ * <li>Default {@code /robots.txt} returns deny-all when no override is
registered.
+ * <li>Default {@code /sitemap.xml} returns an empty {@code <urlset>}.
+ * <li>Importer's {@code @Bean BasicSeoResource} factory drives custom
robots policy and sitemap
+ * entries.
+ * <li>Custom {@code robotsTxt(...)} body overrides any builder rules.
+ * <li>Content-Type pinning works for both endpoints.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicSeoResource_AsMixin_Test extends TestBase {
+
+ /** Default-host mounting the mixin with no @Bean override. */
+ @Rest(mixins=BasicSeoResource.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 ca =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_defaultRobotsTxtIsDenyAll() throws Exception {
+ ca.get("/robots.txt")
+ .run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").isContains("text/plain")
+ .assertContent().asString().is("User-agent:
*\nDisallow: /\n");
+ }
+
+ @Test void a02_defaultSitemapIsEmptyUrlset() throws Exception {
+ var body = ca.get("/sitemap.xml")
+ .run()
+ .assertStatus(200)
+
.assertHeader("Content-Type").isContains("application/xml")
+ .getContent().asString();
+ Assertions.assertTrue(body.contains("<?xml version=\"1.0\"
encoding=\"UTF-8\"?>"), "XML prolog");
+ Assertions.assertTrue(body.contains("<urlset"), "urlset
opening");
+ Assertions.assertTrue(body.contains("</urlset>"), "urlset
closing");
+ Assertions.assertFalse(body.contains("<url>"), "no entries by
default");
+ }
+
+ @Test void a03_hostEndpointStillReachable() throws Exception {
+
ca.get("/items").run().assertStatus(200).assertContent().asString().isContains("items");
+ }
+
+ /** Host with builder-driven robots policy via @Bean factory. */
+ @Rest(mixins=BasicSeoResource.class)
+ public static class B extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public BasicSeoResource seo() {
+ return BasicSeoResource.create()
+ .robotsAllow("*", "/")
+ .robotsDisallow("BadBot", "/private")
+ .sitemapEntry("https://example.com/")
+ .sitemapEntry("https://example.com/items")
+ .build();
+ }
+ }
+
+ private static final MockRestClient cb =
MockRestClient.buildLax(B.class);
+
+ @Test void b01_overrideRobotsRules() throws Exception {
+ var body =
cb.get("/robots.txt").run().assertStatus(200).getContent().asString();
+ Assertions.assertTrue(body.contains("User-agent: *"), "wildcard
ua");
+ Assertions.assertTrue(body.contains("Allow: /"), "allow root");
+ Assertions.assertTrue(body.contains("User-agent: BadBot"), "bad
bot ua");
+ Assertions.assertTrue(body.contains("Disallow: /private"),
"disallow private");
+ }
+
+ @Test void b02_overrideSitemapEntries() throws Exception {
+ var body =
cb.get("/sitemap.xml").run().assertStatus(200).getContent().asString();
+
Assertions.assertTrue(body.contains("<loc>https://example.com/</loc>"), "root
entry");
+
Assertions.assertTrue(body.contains("<loc>https://example.com/items</loc>"),
"items entry");
+ }
+
+ /** Host with a custom robots body via robotsTxt(...). */
+ @Rest(mixins=BasicSeoResource.class)
+ public static class C extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public BasicSeoResource seo() {
+ return BasicSeoResource.create()
+ .robotsTxt("User-agent: *\nCrawl-delay:
10\nSitemap: https://example.com/sitemap.xml\n")
+ .build();
+ }
+ }
+
+ private static final MockRestClient cc =
MockRestClient.buildLax(C.class);
+
+ @Test void c01_customRobotsBodyWinsOverRules() throws Exception {
+ var body =
cc.get("/robots.txt").run().assertStatus(200).getContent().asString();
+ Assertions.assertTrue(body.contains("Crawl-delay: 10"),
"crawl-delay directive");
+ Assertions.assertTrue(body.contains("Sitemap:
https://example.com/sitemap.xml"), "sitemap directive");
+ Assertions.assertFalse(body.contains("Disallow: /"), "default
deny-all body must be replaced");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_AsMixin_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_AsMixin_Test.java
new file mode 100644
index 0000000000..999220b8b5
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_AsMixin_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.convention;
+
+import java.util.*;
+import java.util.jar.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link BasicVersionResource} mounted as a mixin via {@code
@Rest(mixins=...)} on a
+ * vanilla {@link RestServlet}.
+ *
+ * <p>
+ * Cases:
+ * <ul>
+ * <li>{@code /version}, {@code /info}, {@code /about} all return the same
JSON map.
+ * <li>{@code Content-Type: application/json}.
+ * <li>Importer's {@code @Bean BasicVersionResource} factory drives the
entries map (manifest
+ * read, programmatic entries, custom Manifest).
+ * <li>Missing manifest gracefully resolves to {@code (unknown)}.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicVersionResource_AsMixin_Test extends TestBase {
+
+ @Rest(mixins=BasicVersionResource.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 ca =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_versionEndpointServed() throws Exception {
+ ca.get("/version")
+ .run()
+ .assertStatus(200)
+
.assertHeader("Content-Type").isContains("application/json");
+ }
+
+ @Test void a02_versionInfoAboutAreSynonyms() throws Exception {
+ var v =
ca.get("/version").run().assertStatus(200).getContent().asString();
+ var i =
ca.get("/info").run().assertStatus(200).getContent().asString();
+ var ab =
ca.get("/about").run().assertStatus(200).getContent().asString();
+ Assertions.assertEquals(v, i, "/version and /info must be
synonyms");
+ Assertions.assertEquals(v, ab, "/version and /about must be
synonyms");
+ }
+
+ @Test void a03_defaultPayloadHasJavaVersionAtMinimum() throws Exception
{
+ var body =
ca.get("/version").run().assertStatus(200).getContent().asString();
+ var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+ Assertions.assertNotNull(parsed.get("javaVersion"),
"javaVersion present");
+ }
+
+ @Test void a04_hostEndpointStillReachable() throws Exception {
+
ca.get("/items").run().assertStatus(200).assertContent().asString().isContains("items");
+ }
+
+ /** Host providing a programmatic version map via @Bean factory. */
+ @Rest(mixins=BasicVersionResource.class)
+ public static class B extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public BasicVersionResource version() {
+ return BasicVersionResource.create()
+ .entry("name", "my-app")
+ .entry("version", "1.2.3")
+ .entry("gitCommit", "abc123")
+ .entry("gitBranch", "main")
+ .entry("buildTime", "2026-05-24T18:00:00Z")
+ .fromJavaVersion()
+ .build();
+ }
+ }
+
+ private static final MockRestClient cb =
MockRestClient.buildLax(B.class);
+
+ @Test void b01_programmaticEntriesSurface() throws Exception {
+ var body =
cb.get("/version").run().assertStatus(200).getContent().asString();
+ var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+ Assertions.assertEquals("my-app", parsed.get("name"));
+ Assertions.assertEquals("1.2.3", parsed.get("version"));
+ Assertions.assertEquals("abc123", parsed.get("gitCommit"));
+ Assertions.assertEquals("main", parsed.get("gitBranch"));
+ Assertions.assertEquals("2026-05-24T18:00:00Z",
parsed.get("buildTime"));
+ Assertions.assertNotNull(parsed.get("javaVersion"));
+ }
+
+ /** Host providing a synthetic Manifest via fromManifest(Manifest). */
+ @Rest(mixins=BasicVersionResource.class)
+ public static class C extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public BasicVersionResource version() {
+ var manifest = new Manifest();
+ var attrs = manifest.getMainAttributes();
+ attrs.putValue("Manifest-Version", "1.0");
+ attrs.putValue("Implementation-Title", "synthetic-app");
+ attrs.putValue("Implementation-Version", "9.9.9");
+ attrs.putValue("Implementation-Vendor", "Acme");
+ attrs.putValue("Build-Jdk", "21.0.0");
+ return
BasicVersionResource.create().fromManifest(manifest).build();
+ }
+ }
+
+ private static final MockRestClient cc =
MockRestClient.buildLax(C.class);
+
+ @Test void c01_syntheticManifestSurfaces() throws Exception {
+ var body =
cc.get("/version").run().assertStatus(200).getContent().asString();
+ var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+ Assertions.assertEquals("synthetic-app", parsed.get("name"));
+ Assertions.assertEquals("9.9.9", parsed.get("version"));
+ Assertions.assertEquals("Acme", parsed.get("vendor"));
+ Assertions.assertEquals("21.0.0", parsed.get("javaVersion"));
+ }
+
+ @Test void d01_missingManifestGracefulFallback() {
+ var v = BasicVersionResource.create()
+ .fromManifest(new java.net.URLClassLoader(new
java.net.URL[0], null))
+ .build();
+ Assertions.assertEquals(BasicVersionResource.UNKNOWN,
v.getInfoMap().get("name"),
+ "missing manifest must yield (unknown) name");
+ Assertions.assertEquals(BasicVersionResource.UNKNOWN,
v.getInfoMap().get("version"),
+ "missing manifest must yield (unknown) version");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_JettyMicroservice_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_JettyMicroservice_Test.java
new file mode 100644
index 0000000000..9efb535dbf
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_JettyMicroservice_Test.java
@@ -0,0 +1,129 @@
+/*
+ * 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.convention;
+
+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.junit.jupiter.api.*;
+import org.junit.jupiter.api.extension.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Real-Jetty deployment-parity assertion for {@link BasicVersionResource}.
+ *
+ * <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 vanilla {@link
RestServlet} host
+ * with the version mixin and a {@code @Bean BasicVersionResource} factory
configuring known
+ * entries, and hits {@code /version}, {@code /info}, and {@code /about} over
real HTTP.
+ *
+ * <p>
+ * Catches things {@code MockRest} cannot:
+ * <ul>
+ * <li>Real {@code Content-Type: application/json} negotiation through the
Jetty/servlet stack.
+ * <li>JSON serialization through {@link
org.apache.juneau.rest.RestResponse#getDirectWriter
+ * getDirectWriter("application/json")} on a vanilla {@link
RestServlet} host (no JSON
+ * serializer wired up explicitly).
+ * <li>Mixin-walk + bean-store override flow when {@code @Bean
BasicVersionResource} is
+ * registered on the host: the mixin serves the host-supplied
configuration end-to-end through
+ * the network stack.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicVersionResource_JettyMicroservice_Test extends TestBase {
+
+ @Rest(mixins=BasicVersionResource.class)
+ public static class Host extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public BasicVersionResource version() {
+ return BasicVersionResource.create()
+ .entry("name", "jetty-test")
+ .entry("version", "0.0.1")
+ .entry("gitCommit", "deadbeef")
+ .entry("gitBranch", "main")
+ .fromJavaVersion()
+ .build();
+ }
+ }
+
+ @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());
+ }
+
+ @Test void a01_versionOverRealHttp() throws Exception {
+ var resp = get("/version");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"name\": \"jetty-test\""),
"name surfaced in body: " + resp.body());
+ assertTrue(resp.body().contains("\"version\": \"0.0.1\""),
"version surfaced");
+ assertTrue(resp.body().contains("\"gitCommit\": \"deadbeef\""),
"gitCommit surfaced");
+ var ct = resp.headers().firstValue("Content-Type").orElse("");
+ assertTrue(ct.startsWith("application/json"), "Content-Type
was: " + ct);
+ }
+
+ @Test void a02_infoSynonym() throws Exception {
+ var resp = get("/info");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"name\": \"jetty-test\""));
+ }
+
+ @Test void a03_aboutSynonym() throws Exception {
+ var resp = get("/about");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"name\": \"jetty-test\""));
+ }
+
+ @Test void a04_javaVersionSurfacesWithoutManifest() throws Exception {
+ var resp = get("/version");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"javaVersion\""),
"javaVersion key present");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_Springboot_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_Springboot_Test.java
new file mode 100644
index 0000000000..e21c936fc5
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_Springboot_Test.java
@@ -0,0 +1,134 @@
+/*
+ * 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.convention;
+
+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.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
BasicVersionResource}.
+ *
+ * <p>
+ * Boots a full Spring Boot context with embedded Tomcat on a random port,
registers a
+ * {@link BasicSpringRestServlet}-based host with the version mixin via
+ * {@link ServletRegistrationBean}, supplies a Spring {@code @Bean
BasicVersionResource}, and hits
+ * {@code /version}, {@code /info}, and {@code /about} over real HTTP.
+ *
+ * <p>
+ * Catches things {@code MockRest} and the
+ * {@link BasicVersionResource_JettyMicroservice_Test Jetty parity test}
cannot:
+ * <ul>
+ * <li>Spring's bean store adapter ({@code SpringBeanStore}) resolving the
host's
+ * {@code @Bean BasicVersionResource} during the FINISHED-72 mixin
walk through
+ * {@link
org.springframework.context.ApplicationContext#getBean(Class)
+ * ApplicationContext.getBean(...)}.
+ * <li>End-to-end format-pinned JSON ({@link
org.apache.juneau.rest.RestResponse#getDirectWriter
+ * getDirectWriter("application/json")}) under embedded Tomcat.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+@SpringBootTest(classes = BasicVersionResource_Springboot_Test.TestApp.class,
+ webEnvironment = WebEnvironment.RANDOM_PORT)
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class BasicVersionResource_Springboot_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, "/*");
+ }
+
+ @Bean
+ public BasicVersionResource versionResource() {
+ return BasicVersionResource.create()
+ .entry("name", "spring-test")
+ .entry("version", "0.0.2")
+ .entry("gitBranch", "release")
+ .fromJavaVersion()
+ .build();
+ }
+ }
+
+ @Rest(mixins = BasicVersionResource.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());
+ }
+
+ @Test void a01_versionUnderSpringBoot() throws Exception {
+ var resp = get("/version");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"name\": \"spring-test\""),
"Body: " + resp.body());
+ assertTrue(resp.body().contains("\"version\": \"0.0.2\""));
+ assertTrue(resp.body().contains("\"gitBranch\": \"release\""));
+ var ct = resp.headers().firstValue("Content-Type").orElse("");
+ assertTrue(ct.startsWith("application/json"), "Content-Type: "
+ ct);
+ }
+
+ @Test void a02_infoSynonymUnderSpringBoot() throws Exception {
+ var resp = get("/info");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"name\": \"spring-test\""));
+ }
+
+ @Test void a03_aboutSynonymUnderSpringBoot() throws Exception {
+ var resp = get("/about");
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"name\": \"spring-test\""));
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicWellKnownResource_AsMixin_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicWellKnownResource_AsMixin_Test.java
new file mode 100644
index 0000000000..5f7fb1f597
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicWellKnownResource_AsMixin_Test.java
@@ -0,0 +1,79 @@
+/*
+ * 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.convention;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link BasicWellKnownResource} mounted as a mixin via {@code
@Rest(mixins=...)} on a
+ * vanilla {@link RestServlet}.
+ *
+ * <p>
+ * Cases:
+ * <ul>
+ * <li>{@code /.well-known/security.txt} returns {@code 404 Not Found}
when no body is configured
+ * (per RFC 9116 default — no placeholder).
+ * <li>Importer's {@code @Bean BasicWellKnownResource} factory drives the
body content.
+ * <li>{@code Content-Type: text/plain; charset=UTF-8} when a body is
configured.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicWellKnownResource_AsMixin_Test extends TestBase {
+
+ @Rest(mixins=BasicWellKnownResource.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 ca =
MockRestClient.buildLax(A.class);
+
+ @Test void a01_noBodyConfiguredReturns404() throws Exception {
+ ca.get("/.well-known/security.txt").run().assertStatus(404);
+ }
+
+ @Test void a02_hostEndpointStillReachable() throws Exception {
+
ca.get("/items").run().assertStatus(200).assertContent().asString().isContains("items");
+ }
+
+ /** Host that configures a security.txt body via @Bean factory. */
+ @Rest(mixins=BasicWellKnownResource.class)
+ public static class B extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @Bean public BasicWellKnownResource wellKnown() {
+ return BasicWellKnownResource.create()
+ .securityTxt("Contact:
[email protected]\nExpires: 2027-01-01T00:00:00Z\n")
+ .build();
+ }
+ }
+
+ private static final MockRestClient cb =
MockRestClient.buildLax(B.class);
+
+ @Test void b01_configuredBodyIsServed() throws Exception {
+ cb.get("/.well-known/security.txt")
+ .run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").isContains("text/plain")
+ .assertContent().asString().is("Contact:
[email protected]\nExpires: 2027-01-01T00:00:00Z\n");
+ }
+}
diff --git a/todo/TODO-76-mixin-convention-endpoints.md
b/todo/FINISHED-76-mixin-convention-endpoints.md
similarity index 56%
rename from todo/TODO-76-mixin-convention-endpoints.md
rename to todo/FINISHED-76-mixin-convention-endpoints.md
index 78129ce8a9..4d8f188b17 100644
--- a/todo/TODO-76-mixin-convention-endpoints.md
+++ b/todo/FINISHED-76-mixin-convention-endpoints.md
@@ -1,7 +1,9 @@
-# TODO-76: Convention-endpoints mixin pack (favicon, SEO, version, well-known)
+# FINISHED-76: Convention-endpoints mixin pack (favicon, SEO, version,
well-known)
Source: split out of the post-FINISHED-72 mixin-pack planning on 2026-05-23.
+Closed 2026-05-24 in a single implementation session. Four sibling mixins
landed in `org.apache.juneau.rest.convention`: `BasicFaviconResource`
(`/favicon.ico` with a small classpath-resource default, 30-day cache),
`BasicSeoResource` (`/robots.txt` deny-all default per RFC 9309, `/sitemap.xml`
empty `<urlset>` default), `BasicVersionResource` (`/version`, `/info`,
`/about` synonyms reading `MANIFEST.MF` + `git.properties` + JVM with graceful
fallback), and `BasicWellKnownResource` (`/.w [...]
+
## Goal
Group-ship four small "internet conventions" mixins that almost every
public-facing Juneau service eventually needs and that today cost ~50 LOC +
tests apiece to roll by hand:
@@ -180,3 +182,58 @@ All previously open questions resolved 2026-05-24.
- `juneau-rest/juneau-rest-server-springboot/` — Spring `BeanStore` adapter;
Phase 5 smoke-test target.
- `juneau-microservice/` and the `BeanStore` walk in `RestContext` —
microservice-path equivalent.
- Existing: `BasicHealthResource`
(`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/health/BasicHealthResource.java`)
— the canonical mixin-and-multi-mount example to model these mixins after.
+
+## Progress log
+
+### 2026-05-24 (Phase 0–6 — full implementation, including framework
alignment, real-container tests, and docs)
+
+- **Phase 0 — Seam confirmation.** Reviewed
`RestResponse.getDirectWriter(String)` for format-pinned JSON emission on
`BasicVersionResource`, `HttpResourceBean` / `ByteArrayBody` for binary +
headered payloads on `BasicFaviconResource`, and the existing `HttpResource`
return-type contract (carries headers, body, content-type) — all four mixins
return `HttpResource` from their primary handlers (`/favicon.ico`,
`/robots.txt`, `/sitemap.xml`, `/.well-known/security.txt`) except `BasicVersio
[...]
+
+- **Phase 1 — `BasicFaviconResource`.** New class in
`juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/BasicFaviconResource.java`.
Default favicon shipped as
`juneau-rest-server/src/main/resources/juneau-favicon.ico` (top-level
classpath, ≈1.1 KB, accessed as `/juneau-favicon.ico`). Builder methods:
`bytes(byte[])`, `classpath(String)`, `cacheControl(String)`. `Cache-Control:
max-age=2592000, public` (30 days) by default; `Content-Type: image/x-icon`.
`@OpSwagger(ignore [...]
+
+- **Phase 2 — `BasicSeoResource`.** New class for `/robots.txt` (default
deny-all per RFC 9309: `User-agent: *\nDisallow: /\n`) and `/sitemap.xml`
(default empty `<urlset>`). Builder methods: `robotsAllow(String, String...)` /
`robotsDisallow(String, String...)` for rule-based policies,
`robotsTxt(String)` for fully-formed bodies, `sitemapEntry(String url)` and the
four-arg form `sitemapEntry(String url, String lastmod, String changefreq,
String priority)`. Per-mixin test class `BasicSeo [...]
+
+- **Phase 3 — `BasicVersionResource`.** New class for `/version`, `/info`,
`/about` (synonyms in v1). Format-pinned JSON via
`RestResponse.getDirectWriter("application/json")`. Default builder chain
`fromManifest().fromGitProperties().fromJavaVersion()` invoked from the
`BasicVersionResource(Builder)` constructor **only when no explicit builder
methods have been called** (tracked via an `explicit` flag on the builder).
Programmatic overrides (`Builder.entry(String, String)` / `Builder.en [...]
+
+- **Phase 4 — `BasicWellKnownResource`.** New class reserving
`/.well-known/security.txt`. Returns `404 Not Found` when no body is configured
(RFC 9116: file presence is itself meaningful). `Builder.securityTxt(String)`
sets the body. The class is structured to accept future `register(String
suffix, Supplier<HttpResource> handler)` extensions without refactoring
(TODO-69's `/.well-known/openid-configuration` will hook in here). Per-mixin
test class `BasicWellKnownResource_AsMixin_Test` c [...]
+
+- **Framework alignment — `RestContext.buildMixinContext(...)`.** Discovered
during `BasicFaviconResource_AsMixin_Test` debugging: when a host declares
`@Bean BasicFaviconResource favicon() { return ...; }` the framework was
silently bypassing the bean and constructing a fresh mixin via the mixin's own
`Builder` — `BeanInstantiator.instantiate(Class)` short-circuits on a
discovered `create()` static method before checking `BeanStore` for a
pre-registered instance. Fixed by retuning `Rest [...]
+
+- **Phase 5 — Composition + OpenAPI hidden tests.**
+ - `BasicConvention_ParentChain_Test`: mounts all four convention mixins on a
single host alongside the host's own `@RestGet("/items")` endpoint and asserts
every convention path resolves correctly + the host's own endpoints continue to
work. Confirms no path collisions / dedupe regressions across the four mixins.
+ - `BasicConvention_OpenApiHidden_Test`: mounts all four convention mixins
alongside `BasicOpenApiResource` and asserts every convention path is excluded
from the generated OpenAPI document while the host's own endpoints remain
visible. Confirms `@OpSwagger(ignore=true)` flows correctly through the mixin
context.
+
+- **Phase 6 — Real-container parity tests.**
+ - `BasicVersionResource_JettyMicroservice_Test`: boots a real
`JettyMicroservice` carrying a host with `@Bean BasicVersionResource`
configuration, makes HTTP requests to `/version`, `/info`, `/about`, and
verifies content + `Content-Type: application/json`.
+ - `BasicVersionResource_Springboot_Test`: boots a full Spring Boot context
(`@SpringBootTest(webEnvironment=RANDOM_PORT)`) with embedded Tomcat, registers
a `BasicSpringRestServlet`-based host carrying `BasicVersionResource` as a
mixin with a Spring `@Bean` providing the configured instance, makes HTTP
requests, and verifies the same response shape. Pins the bridge between
Spring's `BeanStore` adapter and the framework-level
`RestContext.buildMixinContext` fix.
+ - Picked `BasicVersionResource` for the real-container parity tests (per the
plan's guidance) since it has the richest content shape — JSON map sourced from
manifest + git + JVM, format-pinned via `getDirectWriter`.
+
+- **Coverage hardening.** Added `BasicConvention_Builders_Test` with targeted
unit tests for builder methods, helper methods, and exception paths that are
hard to hit via full REST tests. Specifically: builder-method exclusivity
(`bytes` vs `classpath` last-wins), `RobotsRule` semantics,
`BasicVersionResource.Builder.fromManifest(Manifest)` and
`fromGitProperties(InputStream)` overloads, manifest parsing edge cases (empty
`Implementation-Vendor` skipped, second-candidate-wins-on-`Impleme [...]
+
+- **Phase 7 — Docs + release notes (in `juneau-docs`).**
+ - New topic page `pages/topics/10.14b.ConventionEndpointsMixins.md` (slug
`ConventionEndpointsMixins`) sits next to `10.14a.StaticFilesMixin.md`,
mirroring the FINISHED-75 + FINISHED-74 adjacent-mixin-pack precedent.
Sections: four mixins at a glance (table); composing the pack (worked example
with all four `@Bean` factories); standalone deployment; per-mixin notes
(favicon defaults + builder methods, SEO content types + builder methods,
version format-pinning + Spring Boot fat-jar tip [...]
+ - Release-notes section `### juneau-rest-server` → `####
Convention-Endpoints Mixin Pack (TODO-76)` in `pages/release-notes/9.5.0.md`.
Five-piece entry: pack overview + per-mixin one-paragraph summaries (favicon,
SEO, version, well-known) + framework-alignment note documenting the
`RestContext.buildMixinContext` retune so the broader effect (any `@Bean
MixinClass` now wins for every `@Rest(mixins=...)` mixin) is visible to readers
landing on the release notes for unrelated reasons.
+
+- **Verification.**
+ - `./scripts/test.py -t` — full unit-test run **green** (~72s). All new test
classes (`BasicFaviconResource_AsMixin_Test`, `BasicSeoResource_AsMixin_Test`,
`BasicVersionResource_AsMixin_Test`, `BasicWellKnownResource_AsMixin_Test`,
`BasicConvention_ParentChain_Test`, `BasicConvention_OpenApiHidden_Test`,
`BasicVersionResource_JettyMicroservice_Test`,
`BasicVersionResource_Springboot_Test`, `BasicConvention_Builders_Test`)
discovered and pass; no pre-existing tests regressed by the `Res [...]
+ - `./scripts/test.py -b` — full build **green** (~33s); RAT header check
passed on all new files (10 production + test files, 1 binary
`juneau-favicon.ico`, 1 new `juneau-docs` topic page).
+ - `./scripts/coverage.py
juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/convention/
--run` — package totals: **95% branches** (61/64) and **99% instructions**
(870/880). Per-class: `BasicSeoResource` 100% / 100%, `BasicWellKnownResource`
100% / 100%, `BasicVersionResource` 94% / 100%, `BasicFaviconResource` 90% /
93%. Remaining uncovered lines are framework-guaranteed-unreachable: a
defensive null-on-shipped-default-favicon branch and one defensive branch in
`Basic [...]
+
+- **Acceptance criteria.**
+ - [x] Four sibling mixin classes in `org.apache.juneau.rest.convention`,
each with `@Rest(paths=...)` runtime-overridable defaults.
+ - [x] Default favicon shipped as a small (~1 KB) classpath resource at
`juneau-rest-server/src/main/resources/juneau-favicon.ico`.
+ - [x] `BasicSeoResource` ships RFC 9309 deny-all robots default + empty
`<urlset>` sitemap default.
+ - [x] `BasicVersionResource` reads `META-INF/MANIFEST.MF` + `git.properties`
+ JVM version with graceful fallback when any source is missing; Spring Boot
fat-jar tip documented (`Builder.fromManifest(ClassLoader)`).
+ - [x] `BasicWellKnownResource` defaults to `404` for
`/.well-known/security.txt`; reserves an extension seam for TODO-69's future
entries.
+ - [x] All four endpoints carry `@OpSwagger(ignore=true)` and are excluded
from any Swagger / OpenAPI spec generated by the api-docs mixin pack.
+ - [x] Per-mixin `_AsMixin_Test` classes (4 total) + composition test +
OpenAPI-hidden test + at least one Jetty microservice parity test + at least
one Spring Boot smoke test.
+ - [x] `BasicConvention_Builders_Test` covers builder edge cases + exception
paths.
+ - [x] Topic page in `juneau-docs/pages/topics/` mirroring the FINISHED-74 /
FINISHED-75 layout precedent.
+ - [x] Release-notes section in `juneau-docs/pages/release-notes/9.5.0.md`
documenting the mixin pack + the framework-alignment retune.
+ - [x] `./scripts/test.py -t` green, `./scripts/test.py -b` green,
`./scripts/coverage.py` reports ≥95% on the new package.
+ - [x] Nothing committed; nothing pushed.
+
+- **Deferred / out of scope for this session.**
+ - **Sitemap auto-generation from `BasicGroupOperations` index** — deferred
to v2 per the resolved decision. v1 ships the static-config builder only.
+ - **`/info` and `/about` differentiation** — synonyms in v1 per the resolved
decision; future iterations may differentiate (e.g. `/info` = condensed,
`/about` = full).
+ - **Additional `/.well-known/*` entries** (OIDC discovery, change-password,
etc.) — `BasicWellKnownResource` reserves the seam; TODO-69 owns the OIDC entry
wiring.
diff --git a/todo/TODO.md b/todo/TODO.md
index 53b35b5f96..bbae181a7d 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -9,7 +9,7 @@ Recommended order for the TODO-67 through TODO-78 family.
TODO-20 (rest debug re
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`).~~ ✅ done —
see `todo/FINISHED-75-mixin-static-files.md`.
-6. **TODO-76** — Convention-endpoints pack (favicon / SEO / version /
well-known).
+6. ~~**TODO-76** — Convention-endpoints pack (favicon / SEO / version /
well-known).~~ ✅ done — see `todo/FINISHED-76-mixin-convention-endpoints.md`.
7. **TODO-77** — Ops/introspection pack (echo / admin / route-index). Uses
TODO-69.
8. **TODO-78** — JSP module (`juneau-rest-server-view-jsp`).
9. **TODO-67** — Observability (Micrometer + OpenTelemetry).
@@ -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-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`.
- [TODO-78] JSP servlet support module (`juneau-rest-server-view-jsp`) — new
module shipping `BasicJspResource` mixin + `JspViewRenderer`; isolates Apache
Jasper / `jakarta.servlet.jsp.*` / JSTL deps from core. See
`todo/TODO-78-mixin-jsp-module.md`.