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 5ef12bbbb1 TODO-281: resolve PARITY cluster (INI round-trip, openapi3
drift, Tomcat launcher)
5ef12bbbb1 is described below
commit 5ef12bbbb13e10e9b1d2515449c8938b880347a9
Author: James Bognar <[email protected]>
AuthorDate: Tue Jul 21 20:22:49 2026 -0400
TODO-281: resolve PARITY cluster (INI round-trip, openapi3 drift, Tomcat
launcher)
PARITY-01: add IniParser.Builder.kvSeparator(char) threaded through
context/session
(+ @IniConfig wiring) so a custom key/value separator round-trips; legacy
=/: behavior
preserved when left at the default. PARITY-02: add getSiteName/setSiteName
(x-siteName)
and addProperty(String,SchemaInfo) to openapi-v3 for parity with
swagger-v2, and document
the required(List)/requiredProperties(Set) divergence as intentional (no
rename).
PARITY-03: build TomcatMicroservice zero-config launcher +
HealthProbeConfiguration
mirroring the Jetty facade. Adds round-trip and smoke tests; new public API
(IniParser.kvSeparator, TomcatMicroservice) noted for the 10.0.0 release
notes.
Co-authored-by: Cursor <[email protected]>
---
.../java/org/apache/juneau/bean/openapi3/Info.java | 32 +++++
.../apache/juneau/bean/openapi3/SchemaInfo.java | 26 ++++
.../org/apache/juneau/bean/openapi3/Info_Test.java | 42 +++---
.../juneau/bean/openapi3/SchemaInfo_Test.java | 13 +-
.../org/apache/juneau/bean/swagger/SchemaInfo.java | 8 ++
.../juneau/marshall/ini/IniConfigAnnotation.java | 3 +-
.../org/apache/juneau/marshall/ini/IniParser.java | 37 ++++++
.../juneau/marshall/ini/IniParserSession.java | 24 +++-
.../juneau/marshall/ini/IniRoundTrip_Test.java | 37 ++++++
.../tomcat/HealthProbeConfiguration.java | 53 ++++++++
.../microservice/tomcat/TomcatMicroservice.java | 147 +++++++++++++++++++++
.../tomcat/TomcatMicroservice_Test.java | 98 ++++++++++++++
12 files changed, 490 insertions(+), 30 deletions(-)
diff --git
a/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/Info.java
b/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/Info.java
index ceeddac2c3..6ff14c4bf6 100644
---
a/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/Info.java
+++
b/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/Info.java
@@ -110,10 +110,12 @@ public class Info extends OpenApiElement {
private static final String PROP_contact = "contact";
private static final String PROP_description = "description";
private static final String PROP_license = "license";
+ private static final String PROP_siteName = "siteName";
private static final String PROP_termsOfService = "termsOfService";
private static final String PROP_title = "title";
private static final String PROP_version = "version";
+ private String siteName;
private String title;
private String description;
private String termsOfService;
@@ -136,6 +138,7 @@ public class Info extends OpenApiElement {
this.title = copyFrom.title;
this.description = copyFrom.description;
+ this.siteName = copyFrom.siteName;
this.termsOfService = copyFrom.termsOfService;
this.version = copyFrom.version;
this.contact = copyOf(copyFrom.contact);
@@ -160,6 +163,7 @@ public class Info extends OpenApiElement {
case PROP_termsOfService -> toType(getTermsOfService(),
type);
case PROP_contact -> toType(getContact(), type);
case PROP_license -> toType(getLicense(), type);
+ case PROP_siteName -> toType(getSiteName(), type);
case PROP_version -> toType(getVersion(), type);
default -> super.get(property, type);
};
@@ -195,6 +199,16 @@ public class Info extends OpenApiElement {
*/
public License getLicense() { return license; }
+ /**
+ * Bean property getter: <property>siteName</property>.
+ *
+ * <p>
+ * The site name of the application.
+ *
+ * @return The property value, or <jk>null</jk> if it is not set.
+ */
+ public String getSiteName() { return siteName; }
+
/**
* Bean property getter: <property>termsOfService</property>.
*
@@ -232,6 +246,7 @@ public class Info extends OpenApiElement {
.addIf(nn(contact), PROP_contact)
.addIf(nn(description), PROP_description)
.addIf(nn(license), PROP_license)
+ .addIf(nn(siteName), PROP_siteName)
.addIf(nn(termsOfService), PROP_termsOfService)
.addIf(nn(title), PROP_title)
.addIf(nn(version), PROP_version)
@@ -247,6 +262,7 @@ public class Info extends OpenApiElement {
case PROP_contact -> setContact(toType(value,
Contact.class));
case PROP_description -> setDescription(s(value));
case PROP_license -> setLicense(toType(value,
License.class));
+ case PROP_siteName -> setSiteName(s(value));
case PROP_termsOfService -> setTermsOfService(s(value));
case PROP_title -> setTitle(s(value));
case PROP_version -> setVersion(s(value));
@@ -305,6 +321,22 @@ public class Info extends OpenApiElement {
return this;
}
+ /**
+ * Bean property setter: <property>siteName</property>.
+ *
+ * <p>
+ * The site name of the application.
+ *
+ * @param value
+ * The new value for this property.
+ * <br>Can be <jk>null</jk> to unset the property.
+ * @return This object
+ */
+ public Info setSiteName(String value) {
+ siteName = value;
+ return this;
+ }
+
/**
* Bean property setter: <property>termsOfService</property>.
*
diff --git
a/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/SchemaInfo.java
b/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/SchemaInfo.java
index a617a7738a..88fd7e6860 100644
---
a/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/SchemaInfo.java
+++
b/juneau-bean/juneau-bean-openapi-v3/src/main/java/org/apache/juneau/bean/openapi3/SchemaInfo.java
@@ -104,7 +104,9 @@ import org.apache.juneau.commons.collections.*;
public class SchemaInfo extends OpenApiElement {
// Argument name constants for assertArgNotNull
+ private static final String ARG_key = "key";
private static final String ARG_property = "property";
+ private static final String ARG_value = "value";
// Property name constants
private static final String PROP_additionalProperties =
"additionalProperties";
@@ -356,6 +358,22 @@ public class SchemaInfo extends OpenApiElement {
return this;
}
+ /**
+ * Bean property appender: <property>properties</property>.
+ *
+ * @param key The property key. Must not be <jk>null</jk>.
+ * @param value The property value. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public SchemaInfo addProperty(String key, SchemaInfo value) {
+ assertArgNotNull(ARG_key, key);
+ assertArgNotNull(ARG_value, value);
+ if (properties == null)
+ properties = map();
+ properties.put(key, value);
+ return this;
+ }
+
/**
* Same as {@link #addRequired(String...)}.
*
@@ -661,6 +679,14 @@ public class SchemaInfo extends OpenApiElement {
* <p>
* The list of required properties.
*
+ * <p>
+ * <b>Note:</b> Named/typed <c>required</c> (<c>List<String></c>)
here per the OpenAPI 3.x JSON Schema
+ * subset, vs. <c>requiredProperties</c> (<c>Set<String></c>) in
swagger-v2's sibling
+ * {@code org.apache.juneau.bean.swagger.SchemaInfo}. This is an
intentional, maintainer-confirmed divergence
+ * (PARITY-02): each module tracks its own spec version's canonical
field name/type; swagger-v2 additionally
+ * carries a separate boolean {@code getRequired()}/{@code
setRequired(Boolean)} pair that this module has no
+ * equivalent for. No rename/retype planned.
+ *
* @return The property value, or <jk>null</jk> if it is not set.
*/
public List<String> getRequired() { return nie(required); }
diff --git
a/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/Info_Test.java
b/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/Info_Test.java
index 2103336205..719f77565b 100644
---
a/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/Info_Test.java
+++
b/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/Info_Test.java
@@ -39,14 +39,15 @@ class Info_Test extends TestBase {
.setContact(contact().setEmail("a1").setName("a2").setUrl(URI.create("a3")))
.setDescription("b")
.setLicense(license().setName("c1").setUrl(URI.create("c2")))
- .setTermsOfService("d")
- .setTitle("e")
- .setVersion("f")
+ .setSiteName("d")
+ .setTermsOfService("e")
+ .setTitle("f")
+ .setVersion("g")
)
-
.props("contact{email,name,url},description,license{name,url},termsOfService,title,version")
- .vals("{a1,a2,a3},b,{c1,c2},d,e,f")
-
.json("{contact:{email:'a1',name:'a2',url:'a3'},description:'b',license:{name:'c1',url:'c2'},termsOfService:'d',title:'e',version:'f'}")
-
.string("{'contact':{'email':'a1','name':'a2','url':'a3'},'description':'b','license':{'name':'c1','url':'c2'},'termsOfService':'d','title':'e','version':'f'}".replace('\'','"'))
+
.props("contact{email,name,url},description,license{name,url},siteName,termsOfService,title,version")
+ .vals("{a1,a2,a3},b,{c1,c2},d,e,f,g")
+
.json("{contact:{email:'a1',name:'a2',url:'a3'},description:'b',license:{name:'c1',url:'c2'},siteName:'d',termsOfService:'e',title:'f',version:'g'}")
+
.string("{'contact':{'email':'a1','name':'a2','url':'a3'},'description':'b','license':{'name':'c1','url':'c2'},'siteName':'d','termsOfService':'e','title':'f','version':'g'}".replace('\'','"'))
;
@Test void a01_gettersAndSetters() {
@@ -74,7 +75,7 @@ class Info_Test extends TestBase {
}
@Test void a07_keySet() {
- assertList(TESTER.bean().keySet(), "contact",
"description", "license", "termsOfService", "title", "version");
+ assertList(TESTER.bean().keySet(), "contact",
"description", "license", "siteName", "termsOfService", "title", "version");
}
@Test void a08_nullParameters() {
@@ -157,16 +158,17 @@ class Info_Test extends TestBase {
.set("contact", contact().setName("a"))
.set("description", "b")
.set("license", license().setName("c"))
- .set("termsOfService", "d")
- .set("title", "e")
- .set("version", "f")
+ .set("siteName", "d")
+ .set("termsOfService", "e")
+ .set("title", "f")
+ .set("version", "g")
.set("x1", "x1a")
.set("x2", null)
)
-
.props("contact{name},description,license{name},termsOfService,title,version,x1,x2")
- .vals("{a},b,{c},d,e,f,x1a,<null>")
-
.json("{contact:{name:'a'},description:'b',license:{name:'c'},termsOfService:'d',title:'e',version:'f',x1:'x1a'}")
-
.string("{'contact':{'name':'a'},'description':'b','license':{'name':'c'},'termsOfService':'d','title':'e','version':'f','x1':'x1a'}".replace('\'',
'"'))
+
.props("contact{name},description,license{name},siteName,termsOfService,title,version,x1,x2")
+ .vals("{a},b,{c},d,e,f,g,x1a,<null>")
+
.json("{contact:{name:'a'},description:'b',license:{name:'c'},siteName:'d',termsOfService:'e',title:'f',version:'g',x1:'x1a'}")
+
.string("{'contact':{'name':'a'},'description':'b','license':{'name':'c'},'siteName':'d','termsOfService':'e','title':'f','version':'g','x1':'x1a'}".replace('\'',
'"'))
;
@Test void c01_gettersAndSetters() {
@@ -194,22 +196,22 @@ class Info_Test extends TestBase {
}
@Test void c07_keySet() {
- assertList(TESTER.bean().keySet(), "contact",
"description", "license", "termsOfService", "title", "version", "x1", "x2");
+ assertList(TESTER.bean().keySet(), "contact",
"description", "license", "siteName", "termsOfService", "title", "version",
"x1", "x2");
}
@Test void c08_get() {
assertMapped(
TESTER.bean(), (obj,prop) -> obj.get(prop,
Object.class),
-
"contact{name},description,license{name},termsOfService,title,version,x1,x2",
- "{a},b,{c},d,e,f,x1a,<null>"
+
"contact{name},description,license{name},siteName,termsOfService,title,version,x1,x2",
+ "{a},b,{c},d,e,f,g,x1a,<null>"
);
}
@Test void c09_getTypes() {
assertMapped(
TESTER.bean(), (obj,prop) -> cns(obj.get(prop,
Object.class)),
-
"contact,description,license,termsOfService,title,version,x1,x2",
-
"Contact,String,License,String,String,String,String,<null>"
+
"contact,description,license,siteName,termsOfService,title,version,x1,x2",
+
"Contact,String,License,String,String,String,String,String,<null>"
);
}
diff --git
a/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/SchemaInfo_Test.java
b/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/SchemaInfo_Test.java
index 4e500d6211..1ddc77bd1d 100644
---
a/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/SchemaInfo_Test.java
+++
b/juneau-bean/juneau-bean-openapi-v3/src/test/java/org/apache/juneau/bean/openapi3/SchemaInfo_Test.java
@@ -121,12 +121,19 @@ class SchemaInfo_Test extends TestBase {
.addRequired("b1", "b2")
.addAllOf(schemaInfo("c1"),
schemaInfo("c2"))
.addAnyOf(schemaInfo("d1"),
schemaInfo("d2"))
- .addOneOf(schemaInfo("e1"),
schemaInfo("e2")),
-
"enum,required,allOf{#{type}},anyOf{#{type}},oneOf{#{type}}",
-
"[a1,a2],[b1,b2],{[{c1},{c2}]},{[{d1},{d2}]},{[{e1},{e2}]}"
+ .addOneOf(schemaInfo("e1"),
schemaInfo("e2"))
+ .addProperty("f1", schemaInfo("f2")),
+
"enum,required,allOf{#{type}},anyOf{#{type}},oneOf{#{type}},properties{f1{type}}",
+
"[a1,a2],[b1,b2],{[{c1},{c2}]},{[{d1},{d2}]},{[{e1},{e2}]},{{f2}}"
);
}
+ @Test void a09b_addProperty_nullChecks() {
+ var x = bean();
+ assertThrows(IllegalArgumentException.class, () ->
x.addProperty(null, schemaInfo("a")));
+ assertThrows(IllegalArgumentException.class, () ->
x.addProperty("a", null));
+ }
+
@Test void a10_asMap() {
assertBean(
bean()
diff --git
a/juneau-bean/juneau-bean-swagger-v2/src/main/java/org/apache/juneau/bean/swagger/SchemaInfo.java
b/juneau-bean/juneau-bean-swagger-v2/src/main/java/org/apache/juneau/bean/swagger/SchemaInfo.java
index 3f8db061e4..37607e6b16 100644
---
a/juneau-bean/juneau-bean-swagger-v2/src/main/java/org/apache/juneau/bean/swagger/SchemaInfo.java
+++
b/juneau-bean/juneau-bean-swagger-v2/src/main/java/org/apache/juneau/bean/swagger/SchemaInfo.java
@@ -580,6 +580,14 @@ public class SchemaInfo extends SwaggerElement {
* <p>
* The list of required properties.
*
+ * <p>
+ * <b>Note:</b> Named/typed <c>requiredProperties</c>
(<c>Set<String></c>) here per the JSON Schema Draft
+ * this module targets, vs. <c>required</c> (<c>List<String></c>)
in openapi3's sibling
+ * {@code org.apache.juneau.bean.openapi3.SchemaInfo}. This is an
intentional, maintainer-confirmed divergence
+ * (PARITY-02): each module tracks its own spec version's canonical
field name/type; the separate boolean
+ * {@link #getRequired()}/{@link #setRequired(Boolean)} pair on this
class (unrelated to this property) has no
+ * openapi3-side equivalent. No rename/retype planned.
+ *
* @return The property value, or <jk>null</jk> if it is not set.
*/
public Set<String> getRequiredProperties() { return
nie(requiredProperties); }
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniConfigAnnotation.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniConfigAnnotation.java
index 0da48c7c67..5153faa3b3 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniConfigAnnotation.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniConfigAnnotation.java
@@ -44,7 +44,8 @@ public class IniConfigAnnotation {
@Override
public void apply(AnnotationInfo<IniConfig> ai,
IniParser.Builder b) {
- // No-op: Parser accepts both = and :; no
format-specific settings needed.
+ IniConfig a = ai.inner();
+ string(a.kvSeparator()).filter(s ->
!s.isEmpty()).ifPresent(s -> b.kvSeparator(s.charAt(0)));
}
}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParser.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParser.java
index dcffe56ffb..5c637e4e1f 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParser.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParser.java
@@ -97,6 +97,7 @@ public class IniParser extends ReaderParser implements
IniMetaProvider, RecordRe
private final java.util.concurrent.ConcurrentHashMap<ClassMeta<?>,
IniClassMeta> iniClassMetas = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<BeanPropertyMeta,
IniBeanPropertyMeta> iniBeanPropertyMetas = new
java.util.concurrent.ConcurrentHashMap<>();
+ private static final String PROP_kvSeparator = "kvSeparator";
private static final String ARG_copyFrom = "copyFrom";
/**
@@ -106,16 +107,37 @@ public class IniParser extends ReaderParser implements
IniMetaProvider, RecordRe
private static final Cache<HashKey,IniParser> CACHE =
Cache.of(HashKey.class, IniParser.class).build();
+ private char kvSeparator = '=';
+
protected Builder() {
consumes("text/ini,text/x-ini");
}
protected Builder(Builder copyFrom) {
super(assertArgNotNull(ARG_copyFrom, copyFrom));
+ kvSeparator = copyFrom.kvSeparator;
}
protected Builder(IniParser copyFrom) {
super(assertArgNotNull(ARG_copyFrom, copyFrom));
+ kvSeparator = copyFrom.kvSeparator;
+ }
+
+ /**
+ * Key-value separator character.
+ *
+ * <p>
+ * By default, the parser recognizes both <c>=</c> and <c>:</c>
as key-value separators (matching
+ * {@link IniSerializer.Builder#kvSeparator(char)}'s default of
<c>=</c>). Set this to match a
+ * serializer configured with a non-default {@code kvSeparator}
so the resulting INI can be
+ * round-tripped.
+ *
+ * @param value The key-value separator character to recognize
(in addition to the default <c>=</c>/<c>:</c> pair).
+ * @return This object.
+ */
+ public Builder kvSeparator(char value) {
+ kvSeparator = value;
+ return this;
}
@Override
@@ -127,6 +149,11 @@ public class IniParser extends ReaderParser implements
IniMetaProvider, RecordRe
public Builder copy() {
return new Builder(this);
}
+
+ @Override /* Overridden from Context.Builder<?> */
+ public HashKey hashKey() {
+ return HashKey.of(super.hashKey(), kvSeparator);
+ }
}
/** Default parser instance. */
@@ -141,6 +168,9 @@ public class IniParser extends ReaderParser implements
IniMetaProvider, RecordRe
return new Builder();
}
+ /** Key-value separator. */
+ protected final char kvSeparator;
+
/**
* Constructor.
*
@@ -148,6 +178,7 @@ public class IniParser extends ReaderParser implements
IniMetaProvider, RecordRe
*/
public IniParser(Builder builder) {
super(builder);
+ kvSeparator = builder.kvSeparator;
}
@Override
@@ -172,6 +203,12 @@ public class IniParser extends ReaderParser implements
IniMetaProvider, RecordRe
return new Builder(this);
}
+ @Override
+ protected FluentMap<String,Object> properties() {
+ return super.properties()
+ .a(PROP_kvSeparator, String.valueOf(kvSeparator));
+ }
+
/**
* Convenience delegator that opens a {@link RecordReader} over the
input using
* <b>default session arguments</b> (mirrors {@link #read(Object,
Class)}).
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParserSession.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParserSession.java
index 490e803bcb..d208bd81c7 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParserSession.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniParserSession.java
@@ -59,8 +59,11 @@ public class IniParserSession extends ReaderParserSession
implements RecordReada
*/
public static class Builder extends
ReaderParserSession.Builder<Builder> {
+ private final IniParser ctx;
+
protected Builder(IniParser ctx) {
super(assertArgNotNull(ARG_ctx, ctx));
+ this.ctx = ctx;
}
@Override
@@ -80,8 +83,11 @@ public class IniParserSession extends ReaderParserSession
implements RecordReada
return new Builder(assertArgNotNull(ARG_ctx, ctx));
}
+ private final IniParser ctx;
+
protected IniParserSession(Builder builder) {
super(builder);
+ ctx = builder.ctx;
}
@Override /* RecordReadable */
@@ -151,12 +157,17 @@ public class IniParserSession extends ReaderParserSession
implements RecordReada
}
/**
- * Splits an INI {@code key=value} / {@code key:value} line into its
key and raw-value parts.
+ * Splits an INI {@code key=value} / {@code key:value} (or
configured-separator) line into its key and
+ * raw-value parts.
*
* <p>
* Behavior mirrors the former {@code ^([^=#\s][^=]*)\s*[=:]\s*(.*)$}
regex but without its super-linear
- * backtracking: an {@code '='} delimiter (if present) binds to the
first {@code '='}; otherwise the last
- * {@code ':'} is used. Surrounding whitespace is left in place
(callers trim).
+ * backtracking: when this session's {@link
IniParser.Builder#kvSeparator(char) kvSeparator} is left at its
+ * default (<c>=</c>), an {@code '='} delimiter (if present) binds to
the first {@code '='}; otherwise the
+ * last {@code ':'} is used (unchanged legacy behavior). When a
non-default {@code kvSeparator} has been
+ * configured (e.g. to round-trip an {@link IniSerializer} that was
configured with a matching
+ * {@code kvSeparator}), only that character is recognized as the
delimiter. Surrounding whitespace is left
+ * in place (callers trim).
*
* @param line The (already-trimmed) candidate line.
* @return A two-element array of {raw-key, raw-value}, or
<jk>null</jk> if the line is not a key/value pair.
@@ -164,14 +175,15 @@ public class IniParserSession extends ReaderParserSession
implements RecordReada
@SuppressWarnings({
"java:S1168" // null is a distinct "not a key/value pair"
sentinel; the caller guards on != null before indexing, so an empty array would
change parse semantics.
})
- private static String[] splitKeyValue(String line) {
+ private String[] splitKeyValue(String line) {
if (line.isEmpty())
return null;
var first = line.charAt(0);
if (first == '=' || first == '#' ||
Character.isWhitespace(first))
return null;
- var idx = line.indexOf('=');
- if (idx < 0)
+ var sep = ctx.kvSeparator;
+ var idx = line.indexOf(sep);
+ if (idx < 0 && sep == '=')
idx = line.lastIndexOf(':');
if (idx < 1)
return null;
diff --git
a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/ini/IniRoundTrip_Test.java
b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/ini/IniRoundTrip_Test.java
index 29654c4acb..a28d08fe90 100644
---
a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/ini/IniRoundTrip_Test.java
+++
b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/ini/IniRoundTrip_Test.java
@@ -183,4 +183,41 @@ class IniRoundTrip_Test extends TestBase {
var b = (Map<String, Object>) IniParser.DEFAULT.read(ini,
Map.class, String.class, Object.class);
assertBean(b, "name,emoji", "José,Hello \uD83D\uDE00");
}
+
+
//====================================================================================================
+ // b - Custom kvSeparator round-trip (PARITY-01)
+
//====================================================================================================
+
+ @Test
+ void b01_customKvSeparatorRoundTrip_map() throws Exception {
+ var a = new LinkedHashMap<String, Object>();
+ a.put("name", "Alice");
+ a.put("age", 30);
+ var s = IniSerializer.create().kvSeparator('|').build();
+ var ini = s.write(a);
+ var p = IniParser.create().kvSeparator('|').build();
+ var b = (Map<String, Object>) p.read(ini, Map.class,
String.class, Object.class);
+ assertBean(b, "name,age", "Alice,30");
+ }
+
+ @Test
+ void b02_customKvSeparatorRoundTrip_bean() throws Exception {
+ var a = new ComplexPerson("Alice", new Address("123 Main",
"Boston"), list("a", "b", "c"));
+ var s = IniSerializer.create().kvSeparator('|').build();
+ var ini = s.write(a);
+ var p = IniParser.create().kvSeparator('|').build();
+ var b = p.read(ini, ComplexPerson.class);
+ assertBean(b, "name,address{street,city},tags", "Alice,{123
Main,Boston},[a,b,c]");
+ }
+
+ @Test
+ void b03_defaultKvSeparatorBehaviorUnchanged() throws Exception {
+ var a = new LinkedHashMap<String, Object>();
+ a.put("name", "Alice");
+ a.put("age", 30);
+ var ini = IniSerializer.DEFAULT.write(a);
+ var p = IniParser.create().build();
+ var b = (Map<String, Object>) p.read(ini, Map.class,
String.class, Object.class);
+ assertBean(b, "name,age", "Alice,30");
+ }
}
diff --git
a/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/HealthProbeConfiguration.java
b/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/HealthProbeConfiguration.java
new file mode 100644
index 0000000000..c2fc0d089d
--- /dev/null
+++
b/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/HealthProbeConfiguration.java
@@ -0,0 +1,53 @@
+/*
+ * 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.microservice.tomcat;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.server.health.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Opt-in probe configuration that auto-mounts {@link HealthServlet}.
+ *
+ * @since 10.0.0
+ */
+@Configuration
+public class HealthProbeConfiguration {
+
+ /**
+ * Default probe settings bean.
+ *
+ * @return Default settings.
+ */
+ @Bean
+ @ConditionalOnMissingBean(HealthProbeSettings.class)
+ public HealthProbeSettings healthProbeSettings() {
+ return HealthProbeSettings.create().build();
+ }
+
+ /**
+ * Probe servlet bean discovered by {@link TomcatServerComponent}.
+ *
+ * @return Probe servlet.
+ */
+ @Bean(name="healthProbeServlet")
+ @ConditionalOnMissingBean(name="healthProbeServlet")
+ public Servlet healthProbeServlet() {
+ return new HealthServlet();
+ }
+}
diff --git
a/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatMicroservice.java
b/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatMicroservice.java
new file mode 100644
index 0000000000..d3ae5d811e
--- /dev/null
+++
b/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatMicroservice.java
@@ -0,0 +1,147 @@
+/*
+ * 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.microservice.tomcat;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.microservice.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Zero-config convenience launcher for an embedded-Tomcat-backed Juneau
microservice.
+ *
+ * <p>
+ * Wraps the {@link Microservice} + {@link TomcatConfiguration} bootstrap in a
single static call so
+ * a consumer can stand up a Tomcat microservice with one Maven dependency and
one line of code:
+ *
+ * <p class='bjava'>
+ * <jc>// Boot Tomcat + Juneau on the default port (8000) with the
interactive console enabled.</jc>
+ * <jk>public static void</jk> main(String[] args) <jk>throws</jk>
Exception {
+ * TomcatMicroservice.<jsm>run</jsm>(args, <jk>new</jk>
RootResources());
+ * }
+ * </p>
+ *
+ * <p>
+ * Bundled defaults (all overridable):
+ * <ul>
+ * <li>Port <c>8000</c> (surfaced as <c>Tomcat/port</c> in a
consumer-supplied <c>juneau.cfg</c>).
+ * <li>Interactive console enabled (use {@link #run(String[], Servlet,
boolean)} with
+ * {@code startConsole=false} to suppress).
+ * <li>An auto-created, auto-deleted temp Catalina base directory —
a consumer-supplied
+ * {@code @Bean TomcatSettings} with {@link
TomcatSettings.Builder#baseDir(String)} wins via the
+ * existing {@link TomcatServerComponent} resolution chain.
+ * </ul>
+ *
+ * <p>
+ * The supplied root servlet is registered as a {@code @Bean Servlet} in an
external bean store and
+ * auto-mounted by {@link TomcatServerComponent TomcatServerComponent}
+ * at the path declared by its {@link org.apache.juneau.rest.server.Rest
@Rest} annotation. Consumers
+ * who want full control over the bean store / configuration classes /
listener wiring should call
+ * {@link Microservice#create()} directly — this facade is a thin
convenience over that builder.
+ *
+ * @since 10.0.0
+ */
+@SuppressWarnings({
+ "resource" // beanStore lifetime is managed by the returned
Microservice; not an independent resource.
+})
+public final class TomcatMicroservice {
+
+ private TomcatMicroservice() {}
+
+ /**
+ * Boots a Tomcat microservice with the supplied root servlet and the
bundled defaults,
+ * with the interactive console enabled.
+ *
+ * <p>
+ * Equivalent to calling {@link #run(String[], Servlet, boolean)
run(args, rootServlet, true)}.
+ *
+ * @param args The {@code main(String[])} arguments. Forwarded
to {@link org.apache.juneau.microservice.Microservice.Builder#args(String...)}.
+ * @param rootServlet The root REST servlet (typically annotated with
+ * {@link org.apache.juneau.rest.server.Rest
@Rest}).
+ * @return The started {@link Microservice} instance. Callers
typically chain {@link Microservice#join()}.
+ * @throws Exception Error occurred during bootstrap.
+ */
+ @SuppressWarnings({
+ "java:S112" // throws Exception intentional - mirrors
Microservice.start() lifecycle contract.
+ })
+ public static Microservice run(String[] args, Servlet rootServlet)
throws Exception {
+ return run(args, rootServlet, true);
+ }
+
+ /**
+ * Boots a Tomcat microservice with the supplied root servlet and the
bundled defaults.
+ *
+ * <p>
+ * Constructs the {@link Microservice}, registers the supplied servlet
as a {@code @Bean Servlet} so
+ * {@link TomcatServerComponent TomcatServerComponent} auto-mounts
+ * it, applies {@link TomcatConfiguration} so embedded Tomcat itself is
wired, and starts the lifecycle. The
+ * returned microservice has not yet been {@link Microservice#join()
joined}; callers wanting the
+ * standard "start and block forever" loop should chain {@code .join()}.
+ *
+ * @param args The {@code main(String[])} arguments.
Forwarded to {@link
org.apache.juneau.microservice.Microservice.Builder#args(String...)}.
+ * @param rootServlet The root REST servlet (typically annotated with
+ * {@link org.apache.juneau.rest.server.Rest
@Rest}).
+ * @param startConsole If <jk>true</jk>, also starts the interactive
console thread via
+ * {@link Microservice#startConsole()}.
+ * @return The started {@link Microservice} instance.
+ * @throws Exception Error occurred during bootstrap.
+ */
+ @SuppressWarnings({
+ "java:S112" // throws Exception intentional - mirrors
Microservice.start() lifecycle contract.
+ })
+ public static Microservice run(String[] args, Servlet rootServlet,
boolean startConsole) throws Exception {
+ var beanStore = new BasicBeanStore();
+ beanStore.addBean(Servlet.class, rootServlet);
+ return run(args, beanStore, startConsole,
TomcatConfiguration.class);
+ }
+
+ /**
+ * Power-user form: boots a Tomcat microservice with a caller-supplied
bean store and configuration
+ * class list.
+ *
+ * <p>
+ * Equivalent to writing the {@link Microservice} builder chain by
hand, but with the boilerplate
+ * collapsed. Use this when the simpler {@link #run(String[],
Servlet)} overloads are too
+ * restrictive — e.g. multiple {@code @Bean Servlet}
contributions, custom
+ * {@code MicroserviceListener}s, or a parent bean store.
+ *
+ * @param args The {@code main(String[])} arguments.
Forwarded to {@link
org.apache.juneau.microservice.Microservice.Builder#args(String...)}.
+ * @param beanStore The bean store to use for dependency
injection. Pre-populated bean entries
+ * ({@code @Bean Servlet}, {@code
MicroserviceListener}, etc.) are picked up by
+ * {@link TomcatConfiguration} and the
microservice runtime.
+ * @param startConsole If <jk>true</jk>, also starts the interactive
console thread.
+ * @param configurations The {@code @Configuration}-annotated classes
to register. Always includes
+ * {@link TomcatConfiguration}; pass an empty
list to use only that.
+ * @return The started {@link Microservice} instance.
+ * @throws Exception Error occurred during bootstrap.
+ */
+ @SuppressWarnings({
+ "java:S112" // throws Exception intentional - mirrors
Microservice.start() lifecycle contract.
+ })
+ public static Microservice run(String[] args, WritableBeanStore
beanStore, boolean startConsole, Class<?>... configurations) throws Exception {
+ var ms = Microservice
+ .create()
+ .args(args)
+ .beanStore(beanStore)
+ .configurations(configurations)
+ .build()
+ .start();
+ if (startConsole)
+ ms.startConsole();
+ return ms;
+ }
+}
diff --git
a/juneau-microservice/juneau-microservice-tomcat/src/test/java/org/apache/juneau/microservice/tomcat/TomcatMicroservice_Test.java
b/juneau-microservice/juneau-microservice-tomcat/src/test/java/org/apache/juneau/microservice/tomcat/TomcatMicroservice_Test.java
new file mode 100644
index 0000000000..ed0536894d
--- /dev/null
+++
b/juneau-microservice/juneau-microservice-tomcat/src/test/java/org/apache/juneau/microservice/tomcat/TomcatMicroservice_Test.java
@@ -0,0 +1,98 @@
+/*
+ * 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.microservice.tomcat;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.util.stream.*;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Boot-and-shutdown smoke test for {@link TomcatMicroservice}.
+ *
+ * <p>
+ * Verifies that a bean-store-supplied ephemeral {@link TomcatSettings} port
plus a single
+ * {@code @Bean Servlet} are sufficient to stand up a Tomcat-backed Juneau
microservice and serve a 200
+ * from a {@link Rest @Rest}-annotated resource. Asserts both the facade
entry-point and a clean shutdown
+ * via {@link Microservice#stop()}.
+ *
+ * <p>
+ * Unlike the Jetty facade (which resolves its bind port from a bundled {@code
jetty.xml} via a
+ * pre-published {@code availablePort} system property), the embedded-Tomcat
path binds programmatically,
+ * so the ephemeral port ({@code TomcatSettings.ports(0)}) is supplied
directly via the power-user
+ * {@link TomcatMicroservice#run(String[], WritableBeanStore, boolean,
Class[])} overload and read back
+ * from {@link TomcatServerComponent#getPort()} after start.
+ *
+ * @since 10.0.0
+ */
[email protected]
+class TomcatMicroservice_Test {
+
+ @Rest(paths="/*")
+ public static class Root extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/hello")
+ public String hello() {
+ return "OK";
+ }
+ }
+
+ @Test
+ @SuppressWarnings({
+ "resource" // ms is a server-lifetime resource stopped in the
finally block; try-with-resources is not applicable.
+ })
+ void runStartsServerServesRequestAndStops() throws Exception {
+ var beanStore = new BasicBeanStore();
+ beanStore.addBean(Servlet.class, new Root());
+ beanStore.addBean(TomcatSettings.class,
TomcatSettings.create().ports(0).build());
+ var ms = TomcatMicroservice.run(new String[0], beanStore,
false, TomcatConfiguration.class);
+ try {
+ var tsc =
ms.getBeanStore().getBean(TomcatServerComponent.class).orElseThrow();
+ var localPort = tsc.getPort();
+ assertTrue(localPort > 0, "Tomcat should bind to an
ephemeral port");
+
+ var url = URI.create("http://localhost:" + localPort +
"/hello").toURL();
+ var conn = (HttpURLConnection)url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.setRequestProperty("Accept", "text/plain");
+ conn.setConnectTimeout(5000);
+ conn.setReadTimeout(5000);
+ try {
+ assertEquals(200, conn.getResponseCode());
+ try (var r = new BufferedReader(new
InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
+ var body =
r.lines().collect(Collectors.joining());
+ assertTrue(body.contains("OK"),
"Expected body to contain 'OK' but got: " + body);
+ }
+ } finally {
+ conn.disconnect();
+ }
+ } finally {
+ ms.stop();
+ ms.stopConsole();
+ }
+ }
+}