]");
+ System.exit(1);
+ }
+
+ LOG.info("--- Apache Artemis JSON Schema Generator ---");
+ LOG.info("Artemis root: {}", artemisRoot);
+ LOG.info("Output directory: {}", outputDir);
+
+ // Run pipeline
+ Pipeline pipeline = new Pipeline();
+ pipeline.run(artemisRoot, outputDir);
+
+ LOG.info("Pipeline complete!");
+ }
+
+ /**
+ * Run the full extraction and schema generation pipeline.
+ *
+ * Produces a JSON Schema file and documentation in the output directory. Pipeline failures in
+ * required extractors propagate as exceptions; optional extractor failures are logged and
+ * skipped.
+ *
+ * @param artemisRoot Path to Artemis source root (must contain standard module layout)
+ * @param outputDir Output directory for generated schema and docs (created if absent)
+ * @throws Exception if a required extractor fails or IR generation encounters a fatal error
+ */
+ public void run(Path artemisRoot, Path outputDir) throws Exception {
+ configureJavaParser();
+
+ LOG.info("IR Graph Generation");
+
+ IRBuilder irBuilder = new IRBuilder();
+ irBuilder.generateIR();
+ irBuilder.logStats();
+ SchemaIR ir = irBuilder.getIR();
+
+ LOG.info("Factory Variant Discovery");
+
+ for (FactoryVariantBuilder builder : FactoryVariantBuilder.createAll(ir, artemisRoot)) {
+ builder.buildVariants(ir);
+ }
+
+ LOG.info("Map Constant Keys Discovery");
+
+ new MapConstantKeysBuilder(ir, artemisRoot).build();
+
+ LOG.info("Enrichment");
+
+ Enricher enricher =
+ new Enricher(
+ List.of(
+ new SetterGetterJavadocExtractor(),
+ new XsdExtractor(),
+ new MetadataExtractor(),
+ new TypeConverterExtractor()));
+ enricher.extract(artemisRoot);
+ enricher.enrich(ir);
+
+ irBuilder.logDocumentationCoverage();
+
+ LOG.info("Schema Emission");
+
+ SchemaEmitter emitter = new SchemaEmitter(ir);
+ Map baseSchema = emitter.emitSchema();
+
+ irBuilder.logExtractionStats();
+
+ LOG.info("Output Generation");
+
+ Files.createDirectories(outputDir);
+ Path schemaFile = outputDir.resolve("broker-config-schema.json");
+ ObjectMapper mapper = new ObjectMapper();
+ if (SchemaGeneratorConfig.load().isPrettyPrint()) {
+ mapper.enable(SerializationFeature.INDENT_OUTPUT);
+ }
+ mapper.writeValue(schemaFile.toFile(), baseSchema);
+ LOG.info("Schema written to: {}", schemaFile);
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/annotation/ConfigProperty.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/annotation/ConfigProperty.java
new file mode 100644
index 00000000000..9ed450953d4
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/annotation/ConfigProperty.java
@@ -0,0 +1,69 @@
+/*
+ * 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.artemis.jsonschema.annotation;
+
+import java.lang.annotation.*;
+
+/**
+ * Marks a configuration property for JSON Schema extraction.
+ *
+ * When present on a getter/setter or field, the schema generator will:
+ *
+ *
+ * Use the annotation's description instead of relying on JavaDoc parsing
+ * Apply explicit type, constraints, and deprecation metadata
+ * Override any heuristic-based extraction for this property
+ *
+ *
+ * This annotation is optional. Properties are still discovered via reflection even without it.
+ * The annotation provides an explicit, compiler-checked way to document properties that survives
+ * refactoring better than JavaDoc conventions.
+ *
+ *
Usage:
+ *
+ *
+ * @ConfigProperty(description = "Maximum disk usage percentage before the broker blocks producers")
+ * public int getMaxDiskUsage() { ... }
+ *
+ */
+@Target({ElementType.METHOD, ElementType.FIELD})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface ConfigProperty {
+
+ /**
+ * Human-readable description of this configuration property. If empty, falls back to JavaDoc
+ * extraction.
+ */
+ String description() default "";
+
+ /** Whether this property can be changed without broker restart. */
+ boolean hotReloadable() default false;
+
+ /** Whether this property is deprecated. */
+ boolean deprecated() default false;
+
+ /** Replacement property path if deprecated (e.g., "addressSettings.*.maxSizeBytes"). */
+ String replacedBy() default "";
+
+ /** Minimum value constraint (for numeric types). Use Long.MIN_VALUE for "no minimum". */
+ long min() default Long.MIN_VALUE;
+
+ /** Maximum value constraint (for numeric types). Use Long.MAX_VALUE for "no maximum". */
+ long max() default Long.MAX_VALUE;
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/annotation/Heuristic.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/annotation/Heuristic.java
new file mode 100644
index 00000000000..837d36ec4a1
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/annotation/Heuristic.java
@@ -0,0 +1,36 @@
+/*
+ * 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.artemis.jsonschema.annotation;
+
+import java.lang.annotation.*;
+
+/**
+ * Marks a method whose logic relies on naming conventions, string patterns, or other heuristics
+ * rather than type-system guarantees.
+ *
+ * These are the places most likely to need updating when Artemis source conventions change.
+ * Search for usages of this annotation to find all heuristic-based decisions in the codebase.
+ */
+@Target({ElementType.METHOD})
+@Retention(RetentionPolicy.SOURCE)
+@Documented
+public @interface Heuristic {
+
+ /** What convention or pattern this method relies on. */
+ String value();
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/config/SchemaGeneratorConfig.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/config/SchemaGeneratorConfig.java
new file mode 100644
index 00000000000..7cb77a2d661
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/config/SchemaGeneratorConfig.java
@@ -0,0 +1,242 @@
+/*
+ * 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.artemis.jsonschema.config;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.InputStream;
+import java.util.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Singleton configuration loaded from META-INF/schema-generator-config.json. Access via {@link
+ * #load()} which lazily initializes and caches the instance.
+ *
+ *
All configuration that would otherwise be hardcoded in Java source lives here, making it
+ * editable without recompilation. A contributor adding a new XSD type or factory interface updates
+ * the JSON file, not the code.
+ */
+public final class SchemaGeneratorConfig {
+
+ private static final Logger LOG = LoggerFactory.getLogger(SchemaGeneratorConfig.class);
+ private static final String CONFIG_PATH = "/META-INF/schema-generator-config.json";
+ private static volatile SchemaGeneratorConfig instance;
+
+ /** Properties excluded from IR traversal (e.g., "class", "parent"). */
+ private List ignoredProperties = new ArrayList<>();
+
+ /**
+ * Interfaces whose implementations expose configurable parameters (AcceptorFactory, LoginModule).
+ */
+ private List factoryInterfaces = new ArrayList<>();
+
+ /** Packages scanned by Reflections to discover factory implementations. */
+ private List factoryScanPackages = new ArrayList<>();
+
+ /**
+ * Source directories scanned for *Configuration.java files (setter/getter JavaDoc extraction).
+ */
+ private List javadocSourceDirs = new ArrayList<>();
+
+ /** Path to artemis-configuration.xsd (relative to artemisRoot). */
+ private String xsdPath = "";
+
+ /** FileConfigurationParser source (relative to javadocSourceDirs). */
+ private String xmlParserSource = "";
+
+ /** Maps parseXxx method names to property path prefixes for XML attribute extraction. */
+ private Map xmlParserMethodToPath = new LinkedHashMap<>();
+
+ /** Source file containing updateReloadableConfigurationFrom() (relative to javadocSourceDirs). */
+ private String reloadableConfigSource = "";
+
+ /**
+ * Directory containing JAAS LoginModule source files (scanned for options.get("key") patterns).
+ */
+ private String jaasSourceDir = "";
+
+ /** Files containing *_PROP_NAME constants with JavaDoc for factory parameter descriptions. */
+ private List factoryParameterFiles = new ArrayList<>();
+
+ /** Source files scanned for DEFAULT_* constants (relative to artemisRoot). */
+ private List constantSourceFiles = new ArrayList<>();
+
+ /** Maps XSD complexType names to broker.properties path prefixes for enrichment. */
+ private Map xsdComplexTypeToPathPattern = new LinkedHashMap<>();
+
+ /**
+ * Maps constant classes to the schema paths of Map properties whose known keys they define.
+ * Key: fully qualified class name, Value: list of schema paths to the map's .properties field.
+ */
+ private Map> mapConstantKeys = new LinkedHashMap<>();
+
+ /**
+ * Explicit path rewrites for enrichments where XSD structure doesn't match Java nesting.
+ * Key: XSD-derived path prefix, Value: corrected broker.properties path prefix.
+ */
+ private Map enrichmentPathAliases = new LinkedHashMap<>();
+
+ /** Whether JSON output is indented (true) or compact (false). */
+ private boolean prettyPrint = true;
+
+ SchemaGeneratorConfig() {}
+
+ public List getIgnoredProperties() {
+ return ignoredProperties;
+ }
+
+ public void setIgnoredProperties(List ignoredProperties) {
+ this.ignoredProperties = ignoredProperties;
+ }
+
+ public List getFactoryInterfaces() {
+ return factoryInterfaces;
+ }
+
+ public void setFactoryInterfaces(List factoryInterfaces) {
+ this.factoryInterfaces = factoryInterfaces;
+ }
+
+ public List getFactoryScanPackages() {
+ return factoryScanPackages;
+ }
+
+ public void setFactoryScanPackages(List factoryScanPackages) {
+ this.factoryScanPackages = factoryScanPackages;
+ }
+
+ public List getJavadocSourceDirs() {
+ return javadocSourceDirs;
+ }
+
+ public void setJavadocSourceDirs(List javadocSourceDirs) {
+ this.javadocSourceDirs = javadocSourceDirs;
+ }
+
+ public String getXsdPath() {
+ return xsdPath;
+ }
+
+ public void setXsdPath(String xsdPath) {
+ this.xsdPath = xsdPath;
+ }
+
+ public String getXmlParserSource() {
+ return xmlParserSource;
+ }
+
+ public void setXmlParserSource(String xmlParserSource) {
+ this.xmlParserSource = xmlParserSource;
+ }
+
+ public Map getXmlParserMethodToPath() {
+ return xmlParserMethodToPath;
+ }
+
+ public void setXmlParserMethodToPath(Map xmlParserMethodToPath) {
+ this.xmlParserMethodToPath = xmlParserMethodToPath;
+ }
+
+ public String getReloadableConfigSource() {
+ return reloadableConfigSource;
+ }
+
+ public void setReloadableConfigSource(String reloadableConfigSource) {
+ this.reloadableConfigSource = reloadableConfigSource;
+ }
+
+ public String getJaasSourceDir() {
+ return jaasSourceDir;
+ }
+
+ public void setJaasSourceDir(String jaasSourceDir) {
+ this.jaasSourceDir = jaasSourceDir;
+ }
+
+ public List getFactoryParameterFiles() {
+ return factoryParameterFiles;
+ }
+
+ public void setFactoryParameterFiles(List factoryParameterFiles) {
+ this.factoryParameterFiles = factoryParameterFiles;
+ }
+
+ public List getConstantSourceFiles() {
+ return constantSourceFiles;
+ }
+
+ public void setConstantSourceFiles(List constantSourceFiles) {
+ this.constantSourceFiles = constantSourceFiles;
+ }
+
+ public boolean isPrettyPrint() {
+ return prettyPrint;
+ }
+
+ public void setPrettyPrint(boolean prettyPrint) {
+ this.prettyPrint = prettyPrint;
+ }
+
+ public Map getXsdComplexTypeToPathPattern() {
+ return xsdComplexTypeToPathPattern;
+ }
+
+ public void setXsdComplexTypeToPathPattern(Map xsdComplexTypeToPathPattern) {
+ this.xsdComplexTypeToPathPattern = xsdComplexTypeToPathPattern;
+ }
+
+ public Map> getMapConstantKeys() {
+ return mapConstantKeys;
+ }
+
+ public void setMapConstantKeys(Map> mapConstantKeys) {
+ this.mapConstantKeys = mapConstantKeys;
+ }
+
+ public Map getEnrichmentPathAliases() {
+ return enrichmentPathAliases;
+ }
+
+ public void setEnrichmentPathAliases(Map enrichmentPathAliases) {
+ this.enrichmentPathAliases = enrichmentPathAliases;
+ }
+
+ /**
+ * Load the shared configuration instance from classpath. Returns a default empty config if the
+ * file is missing or malformed.
+ */
+ public static synchronized SchemaGeneratorConfig load() {
+ if (instance == null) {
+ try (InputStream is = SchemaGeneratorConfig.class.getResourceAsStream(CONFIG_PATH)) {
+ if (is != null) {
+ ObjectMapper mapper = new ObjectMapper();
+ mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ instance = mapper.readValue(is, SchemaGeneratorConfig.class);
+ } else {
+ LOG.warn("Config file not found: {}", CONFIG_PATH);
+ instance = new SchemaGeneratorConfig();
+ }
+ } catch (Exception e) {
+ LOG.warn("Failed to load {}: {}", CONFIG_PATH, e.getMessage());
+ instance = new SchemaGeneratorConfig();
+ }
+ }
+ return instance;
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/CollectionElementPropertyEmitter.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/CollectionElementPropertyEmitter.java
new file mode 100644
index 00000000000..bd5a8fd2d72
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/CollectionElementPropertyEmitter.java
@@ -0,0 +1,102 @@
+/*
+ * 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.artemis.jsonschema.emitters;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.artemis.jsonschema.ir.Location;
+import org.apache.artemis.jsonschema.ir.SchemaIR;
+
+/**
+ * Emits JSON Schema for Collection element properties.
+ *
+ * Handles PropertyType.COLLECTION_ELEMENT by emitting:
+ *
+ *
+ * {
+ * "type": "object",
+ * "additionalProperties": { ... element schema ... }
+ * }
+ *
+ *
+ * The element schema is ALWAYS inlined (never $ref) because Collections represent
+ * broker.properties format like {@code propName.0.nestedProp=value}.
+ *
+ *
Supports two types of polymorphism:
+ *
+ *
+ * Factory-based (property-level): Uses factory variants to emit oneOf pattern
+ * Class-based (class-level): Uses subclass discovery to emit oneOf with $refs
+ *
+ */
+public class CollectionElementPropertyEmitter extends PropertyEmitter {
+
+ /** {@inheritDoc} */
+ @Override
+ public Map emit(
+ SchemaIR.PropertyNode prop, SchemaIR ir, Location location, EmissionContext context) {
+ Map schema = new LinkedHashMap<>();
+
+ // Copy type, x-access, etc.
+ schema.putAll(prop.getSchema());
+
+ String targetClass = prop.getTargetClassName();
+ if (targetClass != null) {
+ SchemaIR.ClassNode targetNode = ir.getOrCreateNode(targetClass);
+
+ // Check for factory-based polymorphism FIRST (property-level)
+ if (prop.hasFactoryVariants()) {
+ // Emit oneOf with factory variants (inline, not $ref)
+ schema.put("additionalProperties", emitFactoryOneOf(prop));
+ }
+ // Check if polymorphic via subclasses (class-level)
+ else if (targetNode.isPolymorphic()) {
+ // Emit oneOf with $refs to all subtypes (subclasses will be in $defs with allOf)
+ List> oneOfSchemas = new ArrayList<>();
+
+ for (String subclassName : targetNode.getSubclasses()) {
+ SchemaIR.ClassNode subNode = ir.getOrCreateNode(subclassName);
+ Map subRef = new LinkedHashMap<>();
+ subRef.put("$ref", "#/$defs/" + subNode.getSimpleName());
+ oneOfSchemas.add(subRef);
+ }
+
+ Map polySchema = new LinkedHashMap<>();
+ polySchema.put("oneOf", oneOfSchemas);
+ schema.put("additionalProperties", polySchema);
+ } else {
+ Map elementSchema =
+ context.emitClassSchema(targetNode, false, location.wildcard());
+ schema.put("additionalProperties", elementSchema);
+ }
+ }
+
+ // Apply enrichments
+ applyEnrichments(schema, ir, location);
+
+ // Apply fallback descriptions
+ applyFallbackDescriptions(schema, location);
+
+ // Apply enrichments to nested properties (factory params)
+ applyNestedEnrichments(schema, ir, location);
+
+ return schema;
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/EmissionContext.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/EmissionContext.java
new file mode 100644
index 00000000000..ab672252a97
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/EmissionContext.java
@@ -0,0 +1,47 @@
+/*
+ * 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.artemis.jsonschema.emitters;
+
+import java.util.Map;
+import org.apache.artemis.jsonschema.ir.Location;
+import org.apache.artemis.jsonschema.ir.SchemaIR;
+
+/**
+ * Provides context and helper methods for property emitters.
+ *
+ * This interface allows emitters to:
+ *
+ *
+ * Recursively emit class schemas (for nested objects)
+ * Apply enrichments from IR
+ * Add fallback descriptions for common property patterns
+ *
+ */
+public interface EmissionContext {
+
+ /**
+ * Emit schema for a class node (for nested objects).
+ *
+ * @param node ClassNode to emit
+ * @param isDefEmission True if emitting to $defs, false if inline
+ * @param location typed path for enrichment lookup
+ * @return JSON Schema map for this class
+ */
+ Map emitClassSchema(
+ SchemaIR.ClassNode node, boolean isDefEmission, Location location);
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/MapCollectionValuePropertyEmitter.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/MapCollectionValuePropertyEmitter.java
new file mode 100644
index 00000000000..9c1fd069169
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/MapCollectionValuePropertyEmitter.java
@@ -0,0 +1,86 @@
+/*
+ * 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.artemis.jsonschema.emitters;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.apache.artemis.jsonschema.ir.Location;
+import org.apache.artemis.jsonschema.ir.SchemaIR;
+import org.apache.artemis.jsonschema.ir.SchemaType;
+
+/**
+ * Emits JSON Schema for map properties whose value type is one or more nested containers
+ * (Collection, Map) wrapping a complex leaf class.
+ *
+ * Each container layer contributes one level of {@code additionalProperties} nesting,
+ * matching the broker.properties flat-key convention where each container key becomes a
+ * path segment: {@code prop......=value}.
+ *
+ * Example: {@code Map>} with nesting depth 1 emits:
+ *
+ * {
+ * "type": "object",
+ * "additionalProperties": {
+ * "type": "object",
+ * "additionalProperties": {
+ * "type": "object",
+ * "properties": { ...Role fields... }
+ * }
+ * }
+ * }
+ *
+ */
+public class MapCollectionValuePropertyEmitter extends PropertyEmitter {
+
+ @Override
+ public Map emit(
+ SchemaIR.PropertyNode prop, SchemaIR ir, Location location, EmissionContext context) {
+ Map schema = new LinkedHashMap<>();
+
+ schema.putAll(prop.getSchema());
+
+ String targetClass = prop.getTargetClassName();
+ if (targetClass != null) {
+ SchemaIR.ClassNode targetNode = ir.getOrCreateNode(targetClass);
+
+ int depth = prop.getCollectionNestingDepth();
+
+ Location leafLocation = location.wildcard();
+ for (int i = 0; i < depth; i++) {
+ leafLocation = leafLocation.wildcard();
+ }
+ Map leafSchema = context.emitClassSchema(targetNode, false, leafLocation);
+
+ Map wrapped = leafSchema;
+ for (int i = 0; i < depth; i++) {
+ Map wrapper = new LinkedHashMap<>();
+ wrapper.put("type", new SchemaType(SchemaType.Kind.OBJECT).toSchemaValue());
+ wrapper.put("additionalProperties", wrapped);
+ wrapped = wrapper;
+ }
+
+ schema.put("additionalProperties", wrapped);
+ }
+
+ applyEnrichments(schema, ir, location);
+ applyFallbackDescriptions(schema, location);
+ applyNestedEnrichments(schema, ir, location);
+
+ return schema;
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/MapValuePropertyEmitter.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/MapValuePropertyEmitter.java
new file mode 100644
index 00000000000..51f96ff42a2
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/MapValuePropertyEmitter.java
@@ -0,0 +1,85 @@
+/*
+ * 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.artemis.jsonschema.emitters;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.artemis.jsonschema.ir.Location;
+import org.apache.artemis.jsonschema.ir.SchemaIR;
+
+/**
+ * Emits JSON Schema for Map value properties.
+ *
+ * Handles PropertyType.MAP_VALUE by emitting:
+ *
+ *
+ * {
+ * "type": "object",
+ * "additionalProperties": { ... value schema ... }
+ * }
+ *
+ *
+ * The value schema is ALWAYS inlined (never $ref) because Maps represent broker.properties
+ * format like {@code propName.key1.nestedProp=value}.
+ *
+ *
If the value type has factory variants (e.g., TransportConfiguration), emits a oneOf pattern
+ * with factory-specific schemas.
+ */
+public class MapValuePropertyEmitter extends PropertyEmitter {
+
+ /** {@inheritDoc} */
+ @Override
+ public Map emit(
+ SchemaIR.PropertyNode prop, SchemaIR ir, Location location, EmissionContext context) {
+ Map schema = new LinkedHashMap<>();
+
+ schema.putAll(prop.getSchema());
+
+ String targetClass = prop.getTargetClassName();
+ if (targetClass != null) {
+ SchemaIR.ClassNode targetNode = ir.getOrCreateNode(targetClass);
+
+ if (prop.hasFactoryVariants()) {
+ schema.put("additionalProperties", emitFactoryOneOf(prop));
+ } else if (targetNode.isPolymorphic()) {
+ List> oneOfSchemas = new ArrayList<>();
+ for (String subclassName : targetNode.getSubclasses()) {
+ SchemaIR.ClassNode subNode = ir.getOrCreateNode(subclassName);
+ Map subRef = new LinkedHashMap<>();
+ subRef.put("$ref", "#/$defs/" + subNode.getSimpleName());
+ oneOfSchemas.add(subRef);
+ }
+ Map polySchema = new LinkedHashMap<>();
+ polySchema.put("oneOf", oneOfSchemas);
+ schema.put("additionalProperties", polySchema);
+ } else {
+ Map valueSchema =
+ context.emitClassSchema(targetNode, false, location.wildcard());
+ schema.put("additionalProperties", valueSchema);
+ }
+ }
+
+ applyEnrichments(schema, ir, location);
+ applyFallbackDescriptions(schema, location);
+ applyNestedEnrichments(schema, ir, location);
+
+ return schema;
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/NestedObjectPropertyEmitter.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/NestedObjectPropertyEmitter.java
new file mode 100644
index 00000000000..24b69541f9e
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/NestedObjectPropertyEmitter.java
@@ -0,0 +1,77 @@
+/*
+ * 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.artemis.jsonschema.emitters;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.apache.artemis.jsonschema.ir.Location;
+import org.apache.artemis.jsonschema.ir.SchemaIR;
+
+/**
+ * Emits JSON Schema for nested object properties.
+ *
+ * Handles PropertyType.NESTED_OBJECT by either:
+ *
+ *
+ * Emitting a $ref to $defs if the class is used multiple times
+ * Inlining the class schema if it's used only once
+ *
+ */
+public class NestedObjectPropertyEmitter extends PropertyEmitter {
+
+ /** {@inheritDoc} */
+ @Override
+ public Map emit(
+ SchemaIR.PropertyNode prop, SchemaIR ir, Location location, EmissionContext context) {
+ Map schema = new LinkedHashMap<>();
+
+ String targetClass = prop.getTargetClassName();
+ if (targetClass != null && ir.shouldExtract(targetClass)) {
+ // Use $ref for multi-use classes
+ SchemaIR.ClassNode targetNode = ir.getOrCreateNode(targetClass);
+ schema.put("$ref", "#/$defs/" + targetNode.getSimpleName());
+
+ // Copy metadata (x-access, x-sources, etc.) but NOT schema structure
+ for (Map.Entry entry : prop.getSchema().entrySet()) {
+ if (entry.getKey().startsWith("x-")) {
+ schema.put(entry.getKey(), entry.getValue());
+ }
+ }
+ } else if (targetClass != null) {
+ // Inline for single-use classes
+ SchemaIR.ClassNode targetNode = ir.getOrCreateNode(targetClass);
+ Map inlineSchema = context.emitClassSchema(targetNode, false, location);
+ schema.putAll(inlineSchema);
+
+ // Merge property-level metadata
+ for (Map.Entry entry : prop.getSchema().entrySet()) {
+ if (!schema.containsKey(entry.getKey())) {
+ schema.put(entry.getKey(), entry.getValue());
+ }
+ }
+ }
+
+ // Apply enrichments
+ applyEnrichments(schema, ir, location);
+
+ // Apply fallback descriptions
+ applyFallbackDescriptions(schema, location);
+
+ return schema;
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/PrimitivePropertyEmitter.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/PrimitivePropertyEmitter.java
new file mode 100644
index 00000000000..c7bc95b7dc3
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/PrimitivePropertyEmitter.java
@@ -0,0 +1,46 @@
+/*
+ * 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.artemis.jsonschema.emitters;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.apache.artemis.jsonschema.ir.Location;
+import org.apache.artemis.jsonschema.ir.SchemaIR;
+
+/**
+ * Emits JSON Schema for primitive and simple types (string, integer, boolean, etc.).
+ *
+ * Handles PropertyType.PRIMITIVE and PropertyType.SIMPLE by copying the schema from the
+ * PropertyNode (which was populated during IR generation) and applying enrichments.
+ */
+public class PrimitivePropertyEmitter extends PropertyEmitter {
+
+ /** {@inheritDoc} */
+ @Override
+ public Map emit(
+ SchemaIR.PropertyNode prop, SchemaIR ir, Location location, EmissionContext context) {
+ Map schema = new LinkedHashMap<>();
+
+ schema.putAll(prop.getSchema());
+ applyEnrichments(schema, ir, location);
+ applyFallbackDescriptions(schema, location);
+ applyNestedEnrichments(schema, ir, location);
+
+ return schema;
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/PropertyEmitter.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/PropertyEmitter.java
new file mode 100644
index 00000000000..98dc2875ec1
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/emitters/PropertyEmitter.java
@@ -0,0 +1,145 @@
+/*
+ * 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.artemis.jsonschema.emitters;
+
+import java.util.Map;
+import org.apache.artemis.jsonschema.ir.Location;
+import org.apache.artemis.jsonschema.ir.SchemaIR;
+
+/**
+ * Base class for property schema emitters.
+ *
+ * Each subclass handles one {@link org.apache.artemis.jsonschema.SchemaIR.PropertyType},
+ * emitting the appropriate JSON Schema structure and applying enrichments.
+ *
+ *
Shared behavior (like applying enrichments to pre-built nested properties) lives here to avoid
+ * duplication across emitters.
+ */
+public abstract class PropertyEmitter {
+
+ /**
+ * Emit JSON Schema for a property node.
+ *
+ * @param prop PropertyNode containing type information and metadata
+ * @param ir SchemaIR for context (usage counts, class nodes, enrichments)
+ * @param location typed path for enrichment lookup
+ * @param context EmissionContext providing access to recursive emission and helper methods
+ * @return JSON Schema map for this property
+ */
+ public abstract Map emit(
+ SchemaIR.PropertyNode prop, SchemaIR ir, Location location, EmissionContext context);
+
+ /**
+ * Merge enrichment metadata (descriptions, defaults, constraints) from the IR into the emitted
+ * schema. Existing non-null values are preserved — earlier, more specific enrichments are never
+ * overwritten by broader ones.
+ *
+ * @param schema mutable schema map to enrich
+ * @param ir the IR graph containing enrichment data
+ * @param location property location for enrichment lookup
+ */
+ protected void applyEnrichments(Map schema, SchemaIR ir, Location location) {
+ Map enrichment = ir.getEnrichment(location);
+ for (Map.Entry entry : enrichment.entrySet()) {
+ if (!schema.containsKey(entry.getKey()) || entry.getValue() != null) {
+ schema.put(entry.getKey(), entry.getValue());
+ }
+ }
+ }
+
+ /**
+ * Last-resort descriptions for well-known property names (params, properties, extraParams). Only
+ * applied if no enrichment provided a description.
+ *
+ * @param schema mutable schema map (may receive a "description" entry)
+ * @param location property location whose leaf name is checked
+ */
+ protected void applyFallbackDescriptions(Map schema, Location location) {
+ String propName = location.leafName();
+ if ("properties".equals(propName) && !schema.containsKey("description")) {
+ schema.put("description", "Configuration key-value pairs passed to the configured instance");
+ } else if ("params".equals(propName) && !schema.containsKey("description")) {
+ schema.put("description", "Transport-specific parameters (depends on factoryClassName)");
+ } else if ("extraParams".equals(propName) && !schema.containsKey("description")) {
+ schema.put("description", "Additional transport parameters");
+ }
+ }
+
+ /**
+ * Build a oneOf array with $ref pointers to each factory variant in $defs. Each variant has
+ * {@code factoryClassName} as a required property with a {@code const} discriminator, ensuring
+ * exactly one variant matches.
+ *
+ * @param prop property node carrying factory variant registrations
+ * @return schema fragment containing the {@code oneOf} array
+ */
+ protected Map emitFactoryOneOf(SchemaIR.PropertyNode prop) {
+ Map wrapper = new java.util.LinkedHashMap<>();
+ java.util.List> schemas = new java.util.ArrayList<>();
+
+ for (String factoryClassName : prop.getFactoryVariants().keySet()) {
+ int lastDot = factoryClassName.lastIndexOf('.');
+ String simpleFactoryName =
+ lastDot >= 0 ? factoryClassName.substring(lastDot + 1) : factoryClassName;
+
+ Map refSchema = new java.util.LinkedHashMap<>();
+ refSchema.put("$ref", "#/$defs/" + simpleFactoryName);
+ schemas.add(refSchema);
+ }
+
+ wrapper.put("oneOf", schemas);
+ return wrapper;
+ }
+
+ /**
+ * Apply enrichments to pre-built nested properties inside a schema.
+ *
+ * Factory params (host, port, sslEnabled...) are assembled as a pre-built "properties" block
+ * in TransportFactoryVariantBuilder, bypassing the normal per-property emission path. This method
+ * "catches up" by applying enrichments (descriptions, defaults) to those nested properties using
+ * the path convention shared with extractors.
+ *
+ *
Only relevant for properties that have an inline "properties" map (factory params). No-op if
+ * the schema has no "properties" key.
+ *
+ * @param schema mutable schema map potentially containing a "properties" sub-map
+ * @param ir the IR graph containing enrichment data
+ * @param location parent location used to derive child paths for each nested property
+ */
+ @SuppressWarnings("unchecked")
+ protected void applyNestedEnrichments(
+ Map schema, SchemaIR ir, Location location) {
+ if (schema.containsKey("properties") && schema.get("properties") instanceof Map) {
+ Map nestedProps = (Map) schema.get("properties");
+ for (Map.Entry nestedEntry : nestedProps.entrySet()) {
+ Location nestedLocation = location.child(nestedEntry.getKey());
+
+ if (nestedEntry.getValue() instanceof Map) {
+ Map nestedSchema = (Map) nestedEntry.getValue();
+
+ Map nestedEnrichment = ir.getEnrichment(nestedLocation);
+ for (Map.Entry enrichEntry : nestedEnrichment.entrySet()) {
+ if (!nestedSchema.containsKey(enrichEntry.getKey()) || enrichEntry.getValue() != null) {
+ nestedSchema.put(enrichEntry.getKey(), enrichEntry.getValue());
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/Enricher.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/Enricher.java
new file mode 100644
index 00000000000..770fe10a008
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/Enricher.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.artemis.jsonschema.enrichment;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.artemis.jsonschema.config.SchemaGeneratorConfig;
+import org.apache.artemis.jsonschema.ir.Location;
+import org.apache.artemis.jsonschema.ir.PropertyDescriptor;
+import org.apache.artemis.jsonschema.ir.PropertyMetadata;
+import org.apache.artemis.jsonschema.ir.SchemaIR;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Runs all extractors and applies their enrichments to the IR. */
+public class Enricher {
+
+ private static final Logger LOG = LoggerFactory.getLogger(Enricher.class);
+
+ private final List extractors;
+ private final List enrichments = new ArrayList<>();
+ private final Map pathAliases;
+
+ public Enricher(List extractors) {
+ this.extractors = extractors;
+ this.pathAliases = SchemaGeneratorConfig.load().getEnrichmentPathAliases();
+ }
+
+ /**
+ * Run all extractors against the Artemis source tree. Collects property descriptors from each;
+ * optional extractors that fail are skipped.
+ */
+ public void extract(Path artemisRoot) throws ExtractionException {
+ for (Extractor extractor : extractors) {
+ try {
+ List descriptors = extractor.extract(artemisRoot);
+ enrichments.addAll(descriptors);
+ LOG.debug("{}: {} descriptors", extractor.getName(), descriptors.size());
+ } catch (ExtractionException e) {
+ if (extractor.isRequired()) {
+ throw e;
+ }
+ LOG.warn("{} failed: {}", extractor.getName(), e.getMessage());
+ }
+ }
+ LOG.info(
+ "Total enrichments: {} property descriptors from {} extractors",
+ enrichments.size(),
+ extractors.size());
+ }
+
+ /** Apply collected enrichments to the IR. */
+ public void enrich(SchemaIR ir) {
+ for (PropertyDescriptor descriptor : enrichments) {
+ Map metadata = new LinkedHashMap<>();
+ PropertyMetadata propMeta = descriptor.getMetadata();
+
+ if (propMeta.getType() != null) {
+ metadata.put("type", propMeta.getType().toSchemaValue());
+ }
+ if (propMeta.getDescription() != null) metadata.put("description", propMeta.getDescription());
+ if (propMeta.getDefaultValue() != null) metadata.put("default", propMeta.getDefaultValue());
+ if (propMeta.getEnumValues() != null) metadata.put("enum", propMeta.getEnumValues());
+ if (propMeta.getMinimum() != null) metadata.put("minimum", propMeta.getMinimum());
+ if (propMeta.getMaximum() != null) metadata.put("maximum", propMeta.getMaximum());
+ if (propMeta.getAccess() != null) metadata.put("x-access", propMeta.getAccess());
+ if (propMeta.getDeprecated() != null && propMeta.getDeprecated())
+ metadata.put("x-deprecated", true);
+ if (propMeta.getFactorySpecific() != null)
+ metadata.put("x-factory-specific", propMeta.getFactorySpecific());
+ if (propMeta.getHotReloadable() != null && propMeta.getHotReloadable())
+ metadata.put("x-hot-reloadable", true);
+ if (propMeta.getPattern() != null) metadata.put("pattern", propMeta.getPattern());
+ if (propMeta.getConverter() != null) metadata.put("x-converter", propMeta.getConverter());
+
+ if (!metadata.isEmpty()) {
+ Location path = descriptor.getPath();
+ String dotted = path.toDotted();
+ String aliased = pathAliases.get(dotted);
+ if (aliased != null) {
+ path = Location.of(aliased);
+ }
+ ir.enrich(path, metadata);
+ }
+ }
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/ExtractionException.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/ExtractionException.java
new file mode 100644
index 00000000000..5b6b7ce59eb
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/ExtractionException.java
@@ -0,0 +1,60 @@
+/*
+ * 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.artemis.jsonschema.enrichment;
+
+/**
+ * Exception thrown when an extractor fails to extract property metadata.
+ *
+ * This exception provides context about which extractor failed and why, enabling the pipeline to
+ * make intelligent decisions about whether to fail the entire schema generation or continue with
+ * partial data.
+ */
+public class ExtractionException extends Exception {
+
+ private final String extractorName;
+
+ /**
+ * Create an extraction exception with extractor context.
+ *
+ * @param extractorName Name of the extractor that failed
+ * @param message Human-readable description of the failure
+ */
+ public ExtractionException(String extractorName, String message) {
+ super(extractorName + ": " + message);
+ this.extractorName = extractorName;
+ }
+
+ /**
+ * Create an extraction exception with extractor context and root cause.
+ *
+ * @param extractorName Name of the extractor that failed
+ * @param message Human-readable description of the failure
+ * @param cause Underlying exception that caused the failure
+ */
+ public ExtractionException(String extractorName, String message, Throwable cause) {
+ super(extractorName + ": " + message, cause);
+ this.extractorName = extractorName;
+ }
+
+ /**
+ * @return Name of the extractor that threw this exception
+ */
+ public String getExtractorName() {
+ return extractorName;
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/Extractor.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/Extractor.java
new file mode 100644
index 00000000000..39b97007a3f
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/Extractor.java
@@ -0,0 +1,61 @@
+/*
+ * 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.artemis.jsonschema.enrichment;
+
+import java.nio.file.Path;
+import java.util.List;
+import org.apache.artemis.jsonschema.ir.PropertyDescriptor;
+
+/**
+ * Common interface for all property extractors.
+ *
+ *
Each extractor analyzes a different source (reflection, XSD, source code constants, etc.) to
+ * discover broker configuration properties and their metadata. Extractors produce
+ * PropertyDescriptor objects that are later merged into the final JSON schema.
+ *
+ *
This architecture allows for modular, type-safe extraction with in-memory processing.
+ */
+public interface Extractor {
+ /**
+ * Extract properties from Artemis source/artifacts.
+ *
+ * @param artemisRoot Path to Artemis source repository root
+ * @return List of property descriptors extracted by this source (never null, may be empty)
+ * @throws ExtractionException if extraction fails critically
+ */
+ List extract(Path artemisRoot) throws ExtractionException;
+
+ /**
+ * Get extractor name for logging.
+ *
+ * @return Extractor name (e.g., "ReflectionExtractor", "XsdExtractor")
+ */
+ String getName();
+
+ /**
+ * Indicates whether this extractor is required for schema generation.
+ *
+ * Required extractors cause the pipeline to fail if extraction fails. Optional extractors log
+ * warnings but allow the pipeline to continue with partial data.
+ *
+ * @return true if this extractor failure should fail the pipeline, false to log and continue
+ */
+ default boolean isRequired() {
+ return false; // Most extractors are optional enrichments
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/MetadataExtractor.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/MetadataExtractor.java
new file mode 100644
index 00000000000..1ed33b70a7a
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/MetadataExtractor.java
@@ -0,0 +1,154 @@
+/*
+ * 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.artemis.jsonschema.enrichment;
+
+import com.github.javaparser.StaticJavaParser;
+import com.github.javaparser.ast.CompilationUnit;
+import com.github.javaparser.ast.body.MethodDeclaration;
+import com.github.javaparser.ast.expr.MethodCallExpr;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import org.apache.artemis.jsonschema.config.SchemaGeneratorConfig;
+import org.apache.artemis.jsonschema.ir.PropertyDescriptor;
+import org.apache.artemis.jsonschema.ir.PropertySource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Extracts runtime metadata about broker properties by parsing ActiveMQServerImpl.
+ *
+ *
Discovers hot-reloadable properties by finding all {@code configuration.setXxx()} calls inside
+ * {@code updateReloadableConfigurationFrom()} — the method that applies configuration changes
+ * without broker restart.
+ */
+public class MetadataExtractor implements Extractor {
+
+ private static final Logger LOG = LoggerFactory.getLogger(MetadataExtractor.class);
+
+ /** {@inheritDoc} */
+ @Override
+ public List extract(Path artemisRoot) throws ExtractionException {
+ try {
+ return extractReloadableProperties(artemisRoot);
+ } catch (Exception e) {
+ throw new ExtractionException(getName(), "Failed to extract metadata", e);
+ }
+ }
+
+ /**
+ * Parse ActiveMQServerImpl.updateReloadableConfigurationFrom() to find which properties are
+ * hot-reloadable (updated without broker restart).
+ *
+ * @param artemisRoot path to Artemis source root
+ * @return descriptors with hotReloadable=true for each property set in that method
+ */
+ private List extractReloadableProperties(Path artemisRoot) {
+ List descriptors = new ArrayList<>();
+
+ SchemaGeneratorConfig config = SchemaGeneratorConfig.load();
+ String reloadSource = config.getReloadableConfigSource();
+ Path serverImplFile = null;
+
+ for (String dir : config.getJavadocSourceDirs()) {
+ Path candidate = artemisRoot.resolve(dir).resolve(reloadSource);
+ if (Files.exists(candidate)) {
+ serverImplFile = candidate;
+ break;
+ }
+ }
+
+ if (serverImplFile == null) {
+ LOG.warn("ActiveMQServerImpl.java not found — cannot extract reloadable properties");
+ return descriptors;
+ }
+
+ try {
+ CompilationUnit cu = StaticJavaParser.parse(serverImplFile);
+
+ Optional reloadMethod =
+ cu.findAll(MethodDeclaration.class).stream()
+ .filter(m -> m.getNameAsString().equals("updateReloadableConfigurationFrom"))
+ .findFirst();
+
+ if (!reloadMethod.isPresent()) {
+ LOG.warn("updateReloadableConfigurationFrom() not found in ActiveMQServerImpl");
+ return descriptors;
+ }
+
+ // Find all configuration.setXxx(...) calls → property "xxx" is hot-reloadable
+ reloadMethod
+ .get()
+ .findAll(MethodCallExpr.class)
+ .forEach(
+ call -> {
+ String methodName = call.getNameAsString();
+ if (methodName.startsWith("set")
+ && methodName.length() > 3
+ && call.getScope().isPresent()
+ && call.getScope().get().toString().equals("configuration")) {
+
+ String rawName = methodName.substring(3);
+ String propertyName =
+ Character.toLowerCase(rawName.charAt(0)) + rawName.substring(1);
+
+ PropertyDescriptor descriptor =
+ new PropertyDescriptor(propertyName, PropertySource.METADATA);
+ descriptor.getMetadata().setHotReloadable(true);
+ descriptors.add(descriptor);
+
+ // BeanInfo property names may differ from setter names:
+ // - Acronym prefix: setAMQPX → BeanInfo has "AMQPx" not "aMQPx"
+ // - Suffix mismatch: setAMQPConnectionConfigurations → BeanInfo has
+ // "AMQPConnection"
+ // Emit variants to maximize match likelihood.
+ if (Character.isUpperCase(rawName.charAt(0))
+ && rawName.length() > 1
+ && Character.isUpperCase(rawName.charAt(1))) {
+ PropertyDescriptor acronymVariant =
+ new PropertyDescriptor(rawName, PropertySource.METADATA);
+ acronymVariant.getMetadata().setHotReloadable(true);
+ descriptors.add(acronymVariant);
+ }
+ if (rawName.endsWith("Configurations")) {
+ String stripped =
+ rawName.substring(0, rawName.length() - "Configurations".length());
+ PropertyDescriptor strippedVariant =
+ new PropertyDescriptor(stripped, PropertySource.METADATA);
+ strippedVariant.getMetadata().setHotReloadable(true);
+ descriptors.add(strippedVariant);
+ }
+ }
+ });
+
+ if (!descriptors.isEmpty()) {
+ LOG.info("Found {} hot-reloadable properties", descriptors.size());
+ }
+
+ } catch (Exception e) {
+ LOG.warn("Failed to parse ActiveMQServerImpl: {}", e.getMessage());
+ }
+
+ return descriptors;
+ }
+
+ @Override
+ public String getName() {
+ return "MetadataExtractor";
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/SetterGetterJavadocExtractor.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/SetterGetterJavadocExtractor.java
new file mode 100644
index 00000000000..71a955f646c
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/SetterGetterJavadocExtractor.java
@@ -0,0 +1,451 @@
+/*
+ * 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.artemis.jsonschema.enrichment;
+
+import com.github.javaparser.StaticJavaParser;
+import com.github.javaparser.ast.CompilationUnit;
+import com.github.javaparser.ast.body.MethodDeclaration;
+import com.github.javaparser.ast.comments.JavadocComment;
+import com.github.javaparser.javadoc.Javadoc;
+import com.github.javaparser.javadoc.description.JavadocDescription;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.stream.Stream;
+import org.apache.artemis.jsonschema.config.SchemaGeneratorConfig;
+import org.apache.artemis.jsonschema.ir.PropertyDescriptor;
+import org.apache.artemis.jsonschema.ir.PropertySource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Extracts JavaDoc documentation from ALL *Configuration.java classes. Scans artemis source tree
+ * for configuration classes and extracts property documentation.
+ *
+ * Extracts from: - Configuration.java → root-level properties - Nested config classes (e.g.,
+ * FederationConnectionConfiguration) → prefixed properties - Setter methods: setJMXDomain() →
+ * JMXDomain property - Getter methods: getJMXDomain() → JMXDomain property (if no setter doc)
+ *
+ *
Cleanup: - Removes @param, @return, @deprecated tags (extract separately) - Cleans up {@link}
+ * and {@code} tags - Removes "Sets " prefix from setter docs - Removes "Returns " prefix from
+ * getter docs
+ */
+public class SetterGetterJavadocExtractor implements Extractor {
+
+ private static final Logger LOG = LoggerFactory.getLogger(SetterGetterJavadocExtractor.class);
+
+ /** {@inheritDoc} */
+ @Override
+ public List extract(Path artemisRoot) throws ExtractionException {
+ try {
+ List descriptors = new ArrayList<>();
+
+ // Find all *Configuration.java files in artemis source tree
+ List configFiles = findConfigurationFiles(artemisRoot);
+
+ // Extract JavaDoc from each file
+ for (Path configFile : configFiles) {
+ extractFromFile(configFile, artemisRoot, descriptors);
+ }
+
+ return descriptors;
+ } catch (IOException e) {
+ throw new ExtractionException(getName(), "Failed to scan configuration files", e);
+ }
+ }
+
+ /**
+ * Find all *Configuration.java files in artemis source tree.
+ *
+ * @param artemisRoot path to Artemis source root
+ * @return paths to all discovered Configuration source files
+ * @throws IOException if directory walking fails
+ */
+ private List findConfigurationFiles(Path artemisRoot) throws IOException {
+ List configFiles = new ArrayList<>();
+
+ List sourceDirs = SchemaGeneratorConfig.load().getJavadocSourceDirs();
+ for (String dir : sourceDirs) {
+ Path searchRoot = artemisRoot.resolve(dir);
+ if (Files.exists(searchRoot)) {
+ try (Stream paths = Files.walk(searchRoot)) {
+ paths
+ .filter(p -> p.toString().endsWith("Configuration.java"))
+ .filter(Files::isRegularFile)
+ .forEach(configFiles::add);
+ }
+ }
+ }
+
+ return configFiles;
+ }
+
+ /** Extract JavaDoc from a single configuration file. Returns number of descriptors extracted. */
+ /**
+ * Extract JavaDoc from a single configuration file using a fallback chain: 1. Setter method
+ * JavaDoc (most specific, "sets the X" documentation) 2. Getter method JavaDoc (often duplicates
+ * setter, but sometimes only source) 3. Field-level JavaDoc (for classes that document fields
+ * instead of methods) 4. Interface method JavaDoc (Configuration interface has docs for many
+ * properties)
+ *
+ * @param configFile path to the Configuration source file to parse
+ * @param artemisRoot Artemis source root (for locating interface files)
+ * @param descriptors accumulator for extracted descriptors
+ * @return number of new descriptors added
+ */
+ private int extractFromFile(
+ Path configFile, Path artemisRoot, List descriptors) {
+ try {
+ CompilationUnit cu = StaticJavaParser.parse(configFile);
+
+ String className = configFile.getFileName().toString().replace(".java", "");
+ String pathPrefix = getPropertyPathPrefix(className);
+
+ Map resolvedDescriptions = new LinkedHashMap<>();
+
+ // Pass 1: Collect setter JavaDoc (highest priority)
+ cu.findAll(MethodDeclaration.class)
+ .forEach(
+ method -> {
+ String methodName = method.getNameAsString();
+ if (!methodName.startsWith("set") || methodName.length() <= 3) {
+ return;
+ }
+ String propertyName = decapitalize(methodName.substring(3));
+ if (resolvedDescriptions.containsKey(propertyName)) {
+ return;
+ }
+ String desc = extractDescriptionFromMethod(method);
+ if (desc != null) {
+ resolvedDescriptions.put(propertyName, desc);
+ }
+ });
+
+ // Pass 2: Getter JavaDoc (fallback for properties without setter doc)
+ cu.findAll(MethodDeclaration.class)
+ .forEach(
+ method -> {
+ String methodName = method.getNameAsString();
+ String propertyName = null;
+ if (methodName.startsWith("get") && methodName.length() > 3) {
+ propertyName = decapitalize(methodName.substring(3));
+ } else if (methodName.startsWith("is") && methodName.length() > 2) {
+ propertyName = decapitalize(methodName.substring(2));
+ }
+ if (propertyName == null || resolvedDescriptions.containsKey(propertyName)) {
+ return;
+ }
+ String desc = extractDescriptionFromMethod(method);
+ if (desc != null) {
+ resolvedDescriptions.put(propertyName, desc);
+ }
+ });
+
+ // Pass 3: Field-level JavaDoc (for properties with no method docs)
+ cu.findAll(com.github.javaparser.ast.body.FieldDeclaration.class)
+ .forEach(
+ field -> {
+ field
+ .getVariables()
+ .forEach(
+ variable -> {
+ String fieldName = variable.getNameAsString();
+ if (resolvedDescriptions.containsKey(fieldName)) {
+ return;
+ }
+ Optional javadocOpt = field.getJavadocComment();
+ if (javadocOpt.isPresent()) {
+ String desc = extractDescription(javadocOpt.get().parse());
+ if (!desc.isEmpty()) {
+ resolvedDescriptions.put(fieldName, desc);
+ }
+ }
+ });
+ });
+
+ // Pass 4: Interface method JavaDoc (scan implemented interfaces)
+ if (className.endsWith("Impl") || className.equals("ConfigurationImpl")) {
+ String interfaceName = className.replace("Impl", "");
+ Path interfaceFile = findInterfaceFile(configFile, interfaceName, artemisRoot);
+ if (interfaceFile != null) {
+ extractInterfaceDoc(interfaceFile, resolvedDescriptions);
+ }
+ }
+
+ // Emit descriptors for all resolved properties
+ int beforeCount = descriptors.size();
+ for (Map.Entry entry : resolvedDescriptions.entrySet()) {
+ String propertyName = entry.getKey();
+ String description = entry.getValue();
+
+ String propertyPath = pathPrefix.isEmpty() ? propertyName : pathPrefix + "." + propertyName;
+
+ PropertyDescriptor descriptor =
+ new PropertyDescriptor(propertyPath, PropertySource.JAVADOC);
+ descriptor.getMetadata().setDescription(description);
+ descriptors.add(descriptor);
+
+ if (propertyName.length() > 1
+ && Character.isUpperCase(propertyName.charAt(0))
+ && Character.isUpperCase(propertyName.charAt(1))) {
+
+ String lowercaseVariant = createLowercaseAcronymVariant(propertyName);
+ String lowercasePath =
+ pathPrefix.isEmpty() ? lowercaseVariant : pathPrefix + "." + lowercaseVariant;
+
+ PropertyDescriptor lowercaseDesc =
+ new PropertyDescriptor(lowercasePath, PropertySource.JAVADOC);
+ lowercaseDesc.getMetadata().setDescription(description);
+ descriptors.add(lowercaseDesc);
+ }
+ }
+
+ return descriptors.size() - beforeCount;
+ } catch (Exception e) {
+ LOG.warn("Failed to parse {}: {}", configFile.getFileName(), e.getMessage());
+ return 0;
+ }
+ }
+
+ /**
+ * Extract the cleaned-up description text from a method's JavaDoc comment.
+ *
+ * @param method the AST method declaration
+ * @return cleaned description text, or {@code null} if no usable JavaDoc
+ */
+ private String extractDescriptionFromMethod(MethodDeclaration method) {
+ Optional javadocOpt = method.getJavadocComment();
+ if (!javadocOpt.isPresent()) {
+ return null;
+ }
+ String desc = extractDescription(javadocOpt.get().parse());
+ return desc.isEmpty() ? null : desc;
+ }
+
+ /**
+ * Locate the source file for a configuration interface by searching source dirs.
+ *
+ * @param implFile path to the implementation file (unused but available for context)
+ * @param interfaceName simple name of the interface (e.g. "Configuration")
+ * @param artemisRoot Artemis source root
+ * @return path to the interface source file, or {@code null} if not found
+ */
+ private Path findInterfaceFile(Path implFile, String interfaceName, Path artemisRoot) {
+ List sourceDirs = SchemaGeneratorConfig.load().getJavadocSourceDirs();
+ for (String dir : sourceDirs) {
+ Path searchRoot = artemisRoot.resolve(dir);
+ if (!Files.exists(searchRoot)) continue;
+ try (Stream paths = Files.walk(searchRoot)) {
+ Optional found =
+ paths
+ .filter(p -> p.getFileName().toString().equals(interfaceName + ".java"))
+ .findFirst();
+ if (found.isPresent()) {
+ return found.get();
+ }
+ } catch (IOException e) {
+ // continue searching
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Scan an interface source file for method JavaDoc and add missing descriptions.
+ *
+ * @param interfaceFile path to the interface source file
+ * @param resolvedDescriptions mutable map; only properties absent from this map are added
+ */
+ private void extractInterfaceDoc(Path interfaceFile, Map resolvedDescriptions) {
+ try {
+ CompilationUnit cu = StaticJavaParser.parse(interfaceFile);
+ cu.findAll(MethodDeclaration.class)
+ .forEach(
+ method -> {
+ String methodName = method.getNameAsString();
+ String propertyName = methodNameToPropertyPath(methodName);
+ if (propertyName == null || resolvedDescriptions.containsKey(propertyName)) {
+ return;
+ }
+ String desc = extractDescriptionFromMethod(method);
+ if (desc != null) {
+ resolvedDescriptions.put(propertyName, desc);
+ }
+ });
+ } catch (Exception e) {
+ LOG.debug(
+ "Failed to parse interface file {}: {}", interfaceFile.getFileName(), e.getMessage());
+ }
+ }
+
+ /**
+ * Determine property path prefix for a configuration class. Configuration → "" (root level),
+ * FederationConnectionConfiguration → "federationConnectionConfiguration".
+ *
+ * @param className simple class name (without package)
+ * @return dotted path prefix, or empty string for the root Configuration class
+ */
+ private String getPropertyPathPrefix(String className) {
+ // Configuration.java is root level (no prefix)
+ if (className.equals("Configuration")) {
+ return "";
+ }
+
+ // Remove "Configuration" suffix and decapitalize
+ if (className.endsWith("Configuration")) {
+ String baseName = className.substring(0, className.length() - "Configuration".length());
+ return decapitalize(baseName) + "Configuration";
+ }
+
+ // Fallback: just decapitalize the class name
+ return decapitalize(className);
+ }
+
+ /**
+ * Convert method name to property path. setJMXDomain → JMXDomain, getJMXDomain → JMXDomain,
+ * isEnabled → enabled.
+ *
+ * @param methodName Java method name
+ * @return property name, or {@code null} if the method is not a getter/setter/is-accessor
+ */
+ private String methodNameToPropertyPath(String methodName) {
+ if (methodName.startsWith("set") && methodName.length() > 3) {
+ return decapitalize(methodName.substring(3));
+ }
+ if (methodName.startsWith("get") && methodName.length() > 3) {
+ return decapitalize(methodName.substring(3));
+ }
+ if (methodName.startsWith("is") && methodName.length() > 2) {
+ return decapitalize(methodName.substring(2));
+ }
+ return null;
+ }
+
+ /**
+ * Decapitalize first letter, preserving acronyms. If first two chars are uppercase, the name
+ * stays unchanged (JMXDomain stays JMXDomain).
+ *
+ * @param name identifier to decapitalize
+ * @return decapitalized name, or the original if it starts with an acronym
+ */
+ private String decapitalize(String name) {
+ if (name.length() == 0) return name;
+ if (name.length() == 1) return name.toLowerCase();
+
+ // If first two characters are uppercase, it's likely an acronym - keep as-is
+ // Examples: JMXDomain, IDCacheSize, AMQPConnections
+ if (Character.isUpperCase(name.charAt(0)) && Character.isUpperCase(name.charAt(1))) {
+ return name;
+ }
+
+ // Otherwise decapitalize first character
+ return Character.toLowerCase(name.charAt(0)) + name.substring(1);
+ }
+
+ /**
+ * Create lowercase variant for acronym-prefixed properties. AMQPConnectionConfigurations →
+ * amqpConnectionConfigurations, JMXDomain → jmxDomain.
+ *
+ * @param propertyName property name starting with 2+ uppercase letters
+ * @return lowercase-acronym variant
+ */
+ private String createLowercaseAcronymVariant(String propertyName) {
+ if (propertyName.length() > 2 && Character.isUpperCase(propertyName.charAt(2))) {
+ // 3+ uppercase chars: find where uppercase run ends
+ int i = 2;
+ while (i < propertyName.length() && Character.isUpperCase(propertyName.charAt(i))) {
+ i++;
+ }
+ // Now i is at first lowercase or end of string
+ if (i < propertyName.length()) {
+ // AMQPConnectionConfigurations: i=5 at 'o' → "amqp" + "ConnectionConfigurations"
+ return propertyName.substring(0, i - 1).toLowerCase() + propertyName.substring(i - 1);
+ } else {
+ // All uppercase: AMQP → amqp
+ return propertyName.toLowerCase();
+ }
+ } else {
+ // Only 2 uppercase: JMXDomain → jmxDomain
+ return Character.toLowerCase(propertyName.charAt(0)) + propertyName.substring(1);
+ }
+ }
+
+ /**
+ * Extract and clean up JavaDoc description: gets main description (before block tags), cleans up
+ * inline tags ({@link}, {@code}), and strips "Sets"/"Returns"/"Gets" prefixes.
+ *
+ * @param javadoc parsed JavaDoc object
+ * @return cleaned description text, or empty string if none
+ */
+ private String extractDescription(Javadoc javadoc) {
+ // Get main description (before block tags)
+ JavadocDescription description = javadoc.getDescription();
+ String text = description.toText().trim();
+
+ if (text.isEmpty()) {
+ return "";
+ }
+
+ // Clean up JavaDoc tags
+ text = cleanupJavaDocTags(text);
+
+ // Remove common prefixes
+ text = text.replaceFirst("^Sets? ", "");
+ text = text.replaceFirst("^Returns? ", "");
+ text = text.replaceFirst("^Gets? ", "");
+
+ // Capitalize first letter after cleanup
+ if (!text.isEmpty()) {
+ text = Character.toUpperCase(text.charAt(0)) + text.substring(1);
+ }
+
+ return text.trim();
+ }
+
+ /**
+ * Strip JavaDoc inline tags down to their text content. {@code {@link Class}} → Class, {@code
+ * {@code value}} → value.
+ *
+ * @param text raw JavaDoc text potentially containing inline tags
+ * @return text with all inline tags replaced by their content
+ */
+ private String cleanupJavaDocTags(String text) {
+ // Remove {@link ClassName} and {@link ClassName#method}
+ text = text.replaceAll("\\{@link\\s+([^}#]+)(?:#[^}]+)?\\}", "$1");
+
+ // Remove {@code value}
+ text = text.replaceAll("\\{@code\\s+([^}]+)\\}", "$1");
+
+ // Remove {@return ...} - not standard but sometimes used
+ text = text.replaceAll("\\{@return\\s+([^}]+)\\}", "$1");
+
+ // Remove {@literal value}
+ text = text.replaceAll("\\{@literal\\s+([^}]+)\\}", "$1");
+
+ // Clean up leftover braces
+ text = text.replaceAll("[{}]", "");
+
+ return text;
+ }
+
+ @Override
+ public String getName() {
+ return "SetterGetterJavadocExtractor";
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/TypeConverterExtractor.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/TypeConverterExtractor.java
new file mode 100644
index 00000000000..e2be928c7dc
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/TypeConverterExtractor.java
@@ -0,0 +1,196 @@
+/*
+ * 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.artemis.jsonschema.enrichment;
+
+import com.github.javaparser.StaticJavaParser;
+import com.github.javaparser.ast.CompilationUnit;
+import com.github.javaparser.ast.body.MethodDeclaration;
+import com.github.javaparser.ast.expr.Expression;
+import com.github.javaparser.ast.expr.MethodCallExpr;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.stream.Collectors;
+import org.apache.artemis.jsonschema.ir.PropertyDescriptor;
+import org.apache.artemis.jsonschema.ir.PropertyMetadata;
+import org.apache.artemis.jsonschema.ir.PropertySource;
+import org.apache.artemis.jsonschema.ir.SchemaType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Discovers BeanUtils type converter registrations from ConfigurationImpl and produces
+ * PropertyDescriptors that widen numeric types to accept string notation (e.g. "25K", "10M" for
+ * byte sizes via ByteUtil.convertTextBytes).
+ *
+ * Replaces the post-emission TypeConverterEnricher with a standard Extractor that works through
+ * the enrichment pipeline.
+ */
+public class TypeConverterExtractor implements Extractor {
+
+ private static final Logger LOG = LoggerFactory.getLogger(TypeConverterExtractor.class);
+
+ private static final Map JAVA_TO_JSON_TYPE =
+ Map.of(
+ "long", "integer",
+ "int", "integer",
+ "double", "number",
+ "float", "number");
+
+ @Override
+ public List extract(Path artemisRoot) throws ExtractionException {
+ Path configImplFile =
+ artemisRoot.resolve(
+ "artemis-server/src/main/java/org/apache/activemq/artemis/core/config/impl/ConfigurationImpl.java");
+ if (!Files.exists(configImplFile)) {
+ throw new ExtractionException(getName(), "ConfigurationImpl.java not found");
+ }
+
+ Map converters = discoverConverters(configImplFile);
+ if (converters.isEmpty()) {
+ return List.of();
+ }
+ LOG.info("Discovered {} type converters", converters.size());
+
+ List descriptors = new ArrayList<>();
+ addDescriptorsForClass(
+ "org.apache.activemq.artemis.core.config.impl.ConfigurationImpl",
+ "",
+ converters,
+ descriptors);
+ addDescriptorsForClass(
+ "org.apache.activemq.artemis.core.settings.impl.AddressSettings",
+ "addressSettings.*",
+ converters,
+ descriptors);
+
+ LOG.info("Produced {} type converter enrichments", descriptors.size());
+ return descriptors;
+ }
+
+ private void addDescriptorsForClass(
+ String className,
+ String pathPrefix,
+ Map converters,
+ List descriptors) {
+ try {
+ Class> clazz = Class.forName(className);
+ java.beans.BeanInfo beanInfo = java.beans.Introspector.getBeanInfo(clazz);
+
+ for (java.beans.PropertyDescriptor pd : beanInfo.getPropertyDescriptors()) {
+ Class> propType = pd.getPropertyType();
+ if (propType == null) continue;
+
+ String javaType =
+ propType.isPrimitive() ? propType.getName() : propType.getSimpleName().toLowerCase();
+
+ ConverterInfo converter = converters.get(javaType);
+ if (converter == null || !"string".equals(converter.inputType)) continue;
+
+ String jsonType = JAVA_TO_JSON_TYPE.get(javaType);
+ if (jsonType == null) continue;
+
+ String path = pathPrefix.isEmpty() ? pd.getName() : pathPrefix + "." + pd.getName();
+ PropertyDescriptor descriptor = new PropertyDescriptor(path, PropertySource.ENRICHMENT);
+ PropertyMetadata meta = descriptor.getMetadata();
+
+ meta.setType(SchemaType.of(SchemaType.Kind.fromSchema(jsonType), SchemaType.Kind.STRING));
+ meta.setPattern(converter.pattern);
+ meta.setConverter(converter.converterMethod);
+
+ descriptors.add(descriptor);
+ }
+ } catch (Exception e) {
+ LOG.debug("Could not introspect {}: {}", className, e.getMessage());
+ }
+ }
+
+ private Map discoverConverters(Path configImplFile)
+ throws ExtractionException {
+ Map converters = new HashMap<>();
+ try {
+ CompilationUnit cu = StaticJavaParser.parse(configImplFile);
+ Optional populateMethod =
+ cu.findAll(MethodDeclaration.class).stream()
+ .filter(m -> m.getNameAsString().equals("populateWithProperties"))
+ .findFirst();
+
+ if (populateMethod.isEmpty()) {
+ throw new ExtractionException(getName(), "populateWithProperties method not found");
+ }
+
+ List registerCalls =
+ populateMethod.get().findAll(MethodCallExpr.class).stream()
+ .filter(call -> call.getNameAsString().equals("register"))
+ .filter(
+ call ->
+ call.getScope().isPresent()
+ && call.getScope().get().toString().contains("getConvertUtils"))
+ .collect(Collectors.toList());
+
+ for (MethodCallExpr registerCall : registerCalls) {
+ if (registerCall.getArguments().size() < 2) continue;
+ String targetType = extractTargetType(registerCall.getArgument(1));
+ if (targetType == null) continue;
+ ConverterInfo info = analyzeConverter(registerCall.getArgument(0));
+ if (info != null) {
+ converters.put(targetType, info);
+ }
+ }
+ } catch (ExtractionException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ExtractionException(getName(), "Failed to parse ConfigurationImpl", e);
+ }
+ return converters;
+ }
+
+ private String extractTargetType(Expression typeExpr) {
+ String s = typeExpr.toString();
+ if (s.endsWith(".TYPE")) return s.substring(0, s.length() - 5).toLowerCase();
+ if (s.endsWith(".class")) return s.substring(0, s.length() - 6);
+ return null;
+ }
+
+ private ConverterInfo analyzeConverter(Expression converterExpr) {
+ String code = converterExpr.toString();
+ if (code.contains("ByteUtil.convertTextBytes")) {
+ return new ConverterInfo(
+ "string",
+ "^\\d+\\s*([KkMmGg][Ii]?[Bb]?)?$",
+ "Accepts byte notation: plain numbers or with units (K, M, G, KB, MB, GB)",
+ "ByteUtil.convertTextBytes");
+ }
+ if (code.contains("split(\",\")")) {
+ return new ConverterInfo(
+ "string",
+ null,
+ code.contains("Set") ? "Comma-separated values" : "Comma-separated key=value pairs",
+ "CSV split");
+ }
+ return null;
+ }
+
+ @Override
+ public String getName() {
+ return "TypeConverterExtractor";
+ }
+
+ private record ConverterInfo(
+ String inputType, String pattern, String description, String converterMethod) {}
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/XsdExtractor.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/XsdExtractor.java
new file mode 100644
index 00000000000..c948893fa52
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/enrichment/XsdExtractor.java
@@ -0,0 +1,386 @@
+/*
+ * 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.artemis.jsonschema.enrichment;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import org.apache.artemis.jsonschema.config.SchemaGeneratorConfig;
+import org.apache.artemis.jsonschema.ir.PropertyDescriptor;
+import org.apache.artemis.jsonschema.ir.PropertyMetadata;
+import org.apache.artemis.jsonschema.ir.PropertySource;
+import org.apache.artemis.jsonschema.ir.SchemaType;
+import org.w3c.dom.*;
+
+/**
+ * Extracts metadata from artemis-configuration.xsd schema using DOM parsing.
+ *
+ * Extracts the following metadata:
+ *
+ *
+ * Element documentation (xsd:documentation tags)
+ * Type constraints (from default attributes — types only, not values)
+ * Type constraints (xsd:int, xsd:boolean, etc.)
+ * Enumerations (xsd:restriction with xsd:enumeration)
+ * Min/max constraints (xsd:minInclusive, xsd:maxInclusive)
+ * ComplexType nested elements (divertType, federationType, etc.)
+ *
+ */
+public class XsdExtractor implements Extractor {
+
+ private static final String XSD_NS = "http://www.w3.org/2001/XMLSchema";
+ private static final Map COMPLEX_TYPE_TO_PATTERN =
+ new LinkedHashMap<>(SchemaGeneratorConfig.load().getXsdComplexTypeToPathPattern());
+
+ /** {@inheritDoc} */
+ @Override
+ public List extract(Path artemisRoot) throws ExtractionException {
+ try {
+ Path xsdPath = artemisRoot.resolve(SchemaGeneratorConfig.load().getXsdPath());
+ if (!Files.exists(xsdPath)) {
+ throw new ExtractionException(
+ getName(), "artemis-configuration.xsd not found at: " + xsdPath);
+ }
+
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ DocumentBuilder builder = factory.newDocumentBuilder();
+ Document doc = builder.parse(xsdPath.toFile());
+
+ List descriptors = new ArrayList<>();
+
+ // Extract from the root configurationType complexType — these map to top-level
+ // broker properties (parentPath=""). We target this specific complexType rather
+ // than using getElementsByTagNameNS("element") which would also return elements
+ // nested inside OTHER complexTypes (e.g. groupingHandlerType's "type" element),
+ // contaminating root-level paths with wrong enrichments.
+ Element schemaRoot = doc.getDocumentElement();
+ NodeList schemaChildren = schemaRoot.getChildNodes();
+ for (int i = 0; i < schemaChildren.getLength(); i++) {
+ if (schemaChildren.item(i) instanceof Element child
+ && XSD_NS.equals(child.getNamespaceURI())) {
+ if ("complexType".equals(child.getLocalName())
+ && "configurationType".equals(child.getAttribute("name"))) {
+ // Root configurationType — extract its elements with empty prefix
+ extractNestedElements(child, "", descriptors);
+ } else if ("element".equals(child.getLocalName())) {
+ // Direct top-level element (e.g. )
+ extractElement(child, "", descriptors);
+ }
+ }
+ }
+
+ // Extract from named complexTypes (path-qualified via xsdComplexTypeToPathPattern)
+ for (int i = 0; i < schemaChildren.getLength(); i++) {
+ if (schemaChildren.item(i) instanceof Element child
+ && "complexType".equals(child.getLocalName())
+ && XSD_NS.equals(child.getNamespaceURI())) {
+ extractComplexType(child, descriptors);
+ }
+ }
+
+ return descriptors;
+ } catch (ExtractionException e) {
+ throw e; // Re-throw without wrapping
+ } catch (Exception e) {
+ throw new ExtractionException(getName(), "Failed to parse XSD", e);
+ }
+ }
+
+ /**
+ * Extract property metadata from a single XSD element definition.
+ *
+ * @param element the {@code xsd:element} DOM node
+ * @param parentPath dot-separated parent path prefix (empty for top-level)
+ * @param descriptors accumulator for extracted descriptors
+ */
+ private void extractElement(
+ Element element, String parentPath, List descriptors) {
+ String name = element.getAttribute("name");
+ if (name.isEmpty()) {
+ return;
+ }
+
+ // Convert kebab-case to camelCase (maxDiskUsage → maxDiskUsage)
+ String propName = kebabToCamel(name);
+ String currentPath = parentPath.isEmpty() ? propName : parentPath + "." + propName;
+
+ PropertyDescriptor descriptor = new PropertyDescriptor(currentPath, PropertySource.XSD);
+ PropertyMetadata metadata = descriptor.getMetadata();
+
+ // Extract documentation
+ String doc = extractDocumentation(element);
+ if (doc != null) {
+ metadata.setDescription(doc);
+ }
+
+ // Extract enums and constraints from inline simpleType
+ NodeList simpleTypes = element.getElementsByTagNameNS(XSD_NS, "simpleType");
+ if (simpleTypes.getLength() > 0) {
+ extractSimpleTypeConstraints((Element) simpleTypes.item(0), metadata);
+ }
+
+ descriptors.add(descriptor);
+
+ // Recurse into inline complexType
+ NodeList inlineComplexTypes = element.getElementsByTagNameNS(XSD_NS, "complexType");
+ if (inlineComplexTypes.getLength() > 0) {
+ extractNestedElements((Element) inlineComplexTypes.item(0), currentPath, descriptors);
+ }
+ }
+
+ /**
+ * Extract nested element metadata from a named XSD complexType. Only processes types listed in
+ * the config's {@code xsdComplexTypeToPathPattern} map.
+ *
+ * @param complexType the {@code xsd:complexType} DOM node
+ * @param descriptors accumulator for extracted descriptors
+ */
+ private void extractComplexType(Element complexType, List descriptors) {
+ String typeName = complexType.getAttribute("name");
+ if (typeName.isEmpty()) {
+ return;
+ }
+
+ // Map complexType to path pattern
+ String pathPrefix = COMPLEX_TYPE_TO_PATTERN.get(typeName);
+ if (pathPrefix == null) {
+ return; // Not a recognized complexType
+ }
+
+ // Extract nested elements
+ extractNestedElements(complexType, pathPrefix, descriptors);
+ }
+
+ /**
+ * Recurse into XSD container elements (all/sequence/choice) and attributes within a complexType.
+ *
+ * @param complexType the complexType DOM node containing the containers
+ * @param parentPath dot-separated parent path prefix for child elements
+ * @param descriptors accumulator for extracted descriptors
+ */
+ private void extractNestedElements(
+ Element complexType, String parentPath, List descriptors) {
+ // Find direct child container elements (all, sequence, choice).
+ // Only process direct child elements of the container — NOT deep descendants,
+ // which would include elements inside inline complexTypes of nested elements.
+ String[] containers = {"all", "sequence", "choice"};
+ for (String containerName : containers) {
+ NodeList children = complexType.getChildNodes();
+ for (int i = 0; i < children.getLength(); i++) {
+ if (!(children.item(i) instanceof Element child
+ && containerName.equals(child.getLocalName())
+ && XSD_NS.equals(child.getNamespaceURI()))) {
+ continue;
+ }
+ // Iterate direct child elements of this container only
+ NodeList containerChildren = child.getChildNodes();
+ for (int j = 0; j < containerChildren.getLength(); j++) {
+ if (containerChildren.item(j) instanceof Element elem
+ && "element".equals(elem.getLocalName())
+ && XSD_NS.equals(elem.getNamespaceURI())) {
+ extractElement(elem, parentPath, descriptors);
+ }
+ }
+ }
+ }
+
+ // Extract direct child attributes (XML attributes become properties too).
+ // Only direct children — not attributes inside nested inline complexTypes.
+ NodeList attrChildren = complexType.getChildNodes();
+ for (int i = 0; i < attrChildren.getLength(); i++) {
+ if (attrChildren.item(i) instanceof Element attrElem
+ && "attribute".equals(attrElem.getLocalName())
+ && XSD_NS.equals(attrElem.getNamespaceURI())) {
+ extractAttribute(attrElem, parentPath, descriptors);
+ }
+ }
+ }
+
+ /**
+ * Extract metadata from an XSD attribute (XML attributes become properties). Example: {@code
+ * } Maps to:
+ * AMQPConnections.*.autoStart
+ *
+ * @param attribute the {@code xsd:attribute} DOM node
+ * @param parentPath dot-separated parent path prefix
+ * @param descriptors accumulator for extracted descriptors
+ */
+ private void extractAttribute(
+ Element attribute, String parentPath, List descriptors) {
+ String attrName = attribute.getAttribute("name");
+ if (attrName.isEmpty()) {
+ return;
+ }
+
+ // Convert XML attribute name to Java property name (kebab-case to camelCase)
+ String propertyName = kebabToCamel(attrName);
+ String currentPath = parentPath.isEmpty() ? propertyName : parentPath + "." + propertyName;
+
+ PropertyDescriptor descriptor = new PropertyDescriptor(currentPath, PropertySource.XSD);
+ PropertyMetadata metadata = descriptor.getMetadata();
+
+ // Extract documentation
+ String doc = extractDocumentation(attribute);
+ if (doc != null) {
+ metadata.setDescription(doc);
+ }
+
+ // Extract use="required" → mark as required
+ String use = attribute.getAttribute("use");
+ if ("required".equals(use)) {
+ metadata.setRequired(true);
+ }
+
+ descriptors.add(descriptor);
+ }
+
+ /**
+ * Extract enum, min/max constraints from an inline {@code xsd:simpleType} restriction.
+ *
+ * @param simpleType the {@code xsd:simpleType} DOM node
+ * @param metadata target metadata to populate with enum values and numeric bounds
+ */
+ private void extractSimpleTypeConstraints(Element simpleType, PropertyMetadata metadata) {
+ NodeList restrictions = simpleType.getElementsByTagNameNS(XSD_NS, "restriction");
+ if (restrictions.getLength() == 0) {
+ return;
+ }
+
+ Element restriction = (Element) restrictions.item(0);
+
+ // Extract enums
+ NodeList enumElements = restriction.getElementsByTagNameNS(XSD_NS, "enumeration");
+ if (enumElements.getLength() > 0) {
+ List enumValues = new ArrayList<>();
+ for (int i = 0; i < enumElements.getLength(); i++) {
+ Element enumElement = (Element) enumElements.item(i);
+ String value = enumElement.getAttribute("value");
+ if (!value.isEmpty()) {
+ enumValues.add(value);
+ }
+ }
+ metadata.setEnumValues(enumValues);
+ metadata.setType(new SchemaType(SchemaType.Kind.STRING)); // Enums are strings
+ }
+
+ // Extract min/max
+ NodeList minInclusiveNodes = restriction.getElementsByTagNameNS(XSD_NS, "minInclusive");
+ if (minInclusiveNodes.getLength() > 0) {
+ String minValue = ((Element) minInclusiveNodes.item(0)).getAttribute("value");
+ try {
+ metadata.setMinimum(Integer.parseInt(minValue));
+ } catch (NumberFormatException e) {
+ // Ignore
+ }
+ }
+
+ NodeList maxInclusiveNodes = restriction.getElementsByTagNameNS(XSD_NS, "maxInclusive");
+ if (maxInclusiveNodes.getLength() > 0) {
+ String maxValue = ((Element) maxInclusiveNodes.item(0)).getAttribute("value");
+ try {
+ metadata.setMaximum(Integer.parseInt(maxValue));
+ } catch (NumberFormatException e) {
+ // Ignore
+ }
+ }
+ }
+
+ /**
+ * Extract the {@code xsd:documentation} text from an element's annotation, if present.
+ *
+ * @param element the XSD element or attribute that may contain an annotation
+ * @return trimmed documentation text, or {@code null} if absent
+ */
+ private String extractDocumentation(Element element) {
+ NodeList annotations = element.getElementsByTagNameNS(XSD_NS, "annotation");
+ if (annotations.getLength() == 0) {
+ return null;
+ }
+
+ Element annotation = (Element) annotations.item(0);
+ NodeList docs = annotation.getElementsByTagNameNS(XSD_NS, "documentation");
+ if (docs.getLength() == 0) {
+ return null;
+ }
+
+ Element documentation = (Element) docs.item(0);
+ String text = documentation.getTextContent();
+ if (text != null) {
+ // Clean up whitespace
+ text = text.trim().replaceAll("\\s+", " ");
+ }
+ return text;
+ }
+
+ /**
+ * Convert a kebab-case XSD name to camelCase Java property name.
+ *
+ * @param kebab kebab-case string (e.g. "max-disk-usage")
+ * @return camelCase equivalent (e.g. "maxDiskUsage"), or the input unchanged if no hyphens
+ */
+ private String kebabToCamel(String kebab) {
+ if (!kebab.contains("-")) {
+ return kebab;
+ }
+
+ String[] parts = kebab.split("-");
+ StringBuilder sb = new StringBuilder(parts[0]);
+ for (int i = 1; i < parts.length; i++) {
+ sb.append(parts[i].substring(0, 1).toUpperCase());
+ sb.append(parts[i].substring(1));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Map an XSD type name to its JSON Schema equivalent.
+ *
+ * @param xsdType XSD type string, optionally namespace-prefixed (e.g. "xsd:int")
+ * @return JSON Schema type string ("string", "integer", "boolean", or "number")
+ */
+ private String xsdTypeToJsonType(String xsdType) {
+ // Strip namespace prefix if present
+ if (xsdType.contains(":")) {
+ xsdType = xsdType.substring(xsdType.indexOf(":") + 1);
+ }
+
+ switch (xsdType) {
+ case "string":
+ return "string";
+ case "int":
+ case "integer":
+ case "long":
+ return "integer";
+ case "boolean":
+ return "boolean";
+ case "double":
+ case "float":
+ return "number";
+ default:
+ return "string";
+ }
+ }
+
+ @Override
+ public String getName() {
+ return "XsdExtractor";
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/FactoryDiscovery.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/FactoryDiscovery.java
new file mode 100644
index 00000000000..d4aa92d279e
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/FactoryDiscovery.java
@@ -0,0 +1,368 @@
+/*
+ * 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.artemis.jsonschema.factories;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.nio.file.Path;
+import java.util.*;
+import org.apache.artemis.jsonschema.config.SchemaGeneratorConfig;
+import org.reflections.Reflections;
+import org.reflections.scanners.Scanners;
+import org.reflections.util.ConfigurationBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Discovery service for factory/plugin implementations and their parameters.
+ *
+ * Scans the classpath for implementations of configured factory interfaces (AcceptorFactory,
+ * ConnectorFactory, LoginModule) and discovers their parameters from:
+ *
+ *
+ * Public static final String *_PROP_NAME constants on the class itself
+ * ConfigKey/ParamKey inner enums with getName()
+ * Companion constant classes (e.g., TransportConstants in the same package)
+ *
+ *
+ * @see TransportFactoryVariantBuilder
+ * @see FactoryParameterRegistry
+ */
+public class FactoryDiscovery {
+
+ private static final Logger LOG = LoggerFactory.getLogger(FactoryDiscovery.class);
+
+ /**
+ * Interfaces whose implementations expose configurable parameters via *_PROP_NAME constants.
+ * Loaded from META-INF/schema-generator-config.json.
+ *
+ * This list is exhaustive for Artemis's factory polymorphism model:
+ *
+ *
+ * AcceptorFactory/ConnectorFactory — transport layer (Netty, InVM)
+ * LoginModule — JAAS security (LDAP, Properties, Kerberos, etc.)
+ *
+ *
+ * This list should NOT grow unless Artemis introduces an entirely new plugin abstraction where
+ * the implementation class is selected by name in broker.properties and each implementation has
+ * its own parameter set. Standard broker plugins (ActiveMQServer*Plugin) do not belong here —
+ * their parameters are configured via a different mechanism (init Map).
+ */
+ private static final List FACTORY_INTERFACES =
+ SchemaGeneratorConfig.load().getFactoryInterfaces();
+
+ /**
+ * Packages scanned by Reflections to find implementations of the above interfaces. Must cover the
+ * packages where AcceptorFactory/ConnectorFactory impls and LoginModule impls reside. Loaded from
+ * META-INF/schema-generator-config.json.
+ */
+ private static final List FACTORY_SCAN_PACKAGES =
+ SchemaGeneratorConfig.load().getFactoryScanPackages();
+
+ private final FactoryParameterRegistry registry = new FactoryParameterRegistry();
+
+ /**
+ * Discover all factory classes and their parameters via classpath scanning.
+ *
+ * @param artemisRoot path to Artemis source root (currently unused; factories are found on
+ * classpath)
+ * @return populated registry mapping factory classes to their parameter names
+ * @throws Exception if classpath scanning or reflection fails
+ */
+ public FactoryParameterRegistry extractRegistry(Path artemisRoot) throws Exception {
+ LOG.info("Running FactoryDiscovery");
+ LOG.info("Scanning classpath for factory implementations");
+
+ Reflections reflections =
+ new Reflections(
+ new ConfigurationBuilder()
+ .forPackages(FACTORY_SCAN_PACKAGES.toArray(new String[0]))
+ .setScanners(Scanners.SubTypes));
+
+ List> factoryClasses = new ArrayList<>();
+
+ // Discover implementations - track which interface each implements
+ Map, String> classToInterface = new LinkedHashMap<>();
+
+ for (String interfaceName : FACTORY_INTERFACES) {
+ try {
+ Class> interfaceClass = Class.forName(interfaceName);
+ Set extends Class>> implementations =
+ getSubTypesOfWildcard(reflections, interfaceClass);
+
+ for (Class> impl : implementations) {
+ if (!Modifier.isAbstract(impl.getModifiers())
+ && !Modifier.isInterface(impl.getModifiers())) {
+ factoryClasses.add(impl);
+ classToInterface.put(impl, interfaceClass.getSimpleName());
+ LOG.debug("Found: {}", impl.getSimpleName());
+ }
+ }
+ } catch (ClassNotFoundException e) {
+ LOG.debug("Factory interface not on classpath: {}", interfaceName);
+ }
+ }
+
+ LOG.info("Discovered {} factory/plugin classes", factoryClasses.size());
+
+ // Extract parameters from each class
+ for (Class> factoryClass : factoryClasses) {
+ List params = extractParameters(factoryClass);
+
+ if (!params.isEmpty() || classToInterface.containsKey(factoryClass)) {
+ registry.registerFactory(
+ factoryClass.getName(),
+ factoryClass.getSimpleName(),
+ params,
+ classToInterface.getOrDefault(factoryClass, "Unknown"));
+ }
+ }
+
+ LOG.info(
+ "Extracted {} unique parameters from {} factories",
+ registry.getTotalParameterCount(),
+ registry.getFactoryCount());
+
+ return registry;
+ }
+
+ /**
+ * Extract all parameter names from a factory/plugin class. Supports multiple patterns:
+ * *_PROP_NAME constants, ConfigKey/ParamKey enums, and companion constant classes in the same
+ * package.
+ *
+ * @param factoryClass the factory implementation class to inspect
+ * @return discovered parameter names (may be empty)
+ */
+ private List extractParameters(Class> factoryClass) {
+ String className = factoryClass.getSimpleName();
+ List params = new ArrayList<>();
+
+ LOG.debug("Extracting params from {}", className);
+
+ // Pattern 1: *_PROP_NAME constants in the class itself
+ params.addAll(extractFromConstants(factoryClass));
+
+ // Pattern 2: ConfigKey/ParamKey enums
+ params.addAll(extractFromConfigKeyEnum(factoryClass));
+
+ // Pattern 3: Companion constant classes (e.g., TransportConstants)
+ params.addAll(extractFromCompanionClass(factoryClass));
+
+ // Store results
+ if (!params.isEmpty()) {
+ LOG.debug("Found {} params: {}", params.size(), params);
+ } else {
+ LOG.debug("No params found");
+ }
+
+ return params;
+ }
+
+ /**
+ * Extract parameter names from static final String *_PROP_NAME constants on the class.
+ *
+ * @param clazz the class to inspect for constant fields
+ * @return discovered parameter name values
+ */
+ private List extractFromConstants(Class> clazz) {
+ List params = new ArrayList<>();
+
+ for (Field field : clazz.getDeclaredFields()) {
+ if (Modifier.isStatic(field.getModifiers())
+ && Modifier.isFinal(field.getModifiers())
+ && field.getType() == String.class) {
+
+ String fieldName = field.getName();
+
+ // Match: *_PROP_NAME, *_PROP, *_PROPERTY, *_PARAM_NAME, etc.
+ if (fieldName.endsWith("_PROP_NAME")
+ || fieldName.endsWith("_PROP")
+ || fieldName.endsWith("_PROPERTY")
+ || fieldName.endsWith("_PARAM_NAME")
+ || fieldName.endsWith("_PARAM")) {
+
+ try {
+ field.setAccessible(true);
+ String paramValue = (String) field.get(null);
+
+ // For qualified property names (e.g., "org.apache.activemq.jaas.properties.user"),
+ // prefer the SHORT form for documentation since that's what users type
+ // The full form is an implementation detail
+ String paramToDocument = paramValue;
+ if (paramValue.contains(".")) {
+ String shortForm = paramValue.substring(paramValue.lastIndexOf(".") + 1);
+ if (!shortForm.isEmpty()) {
+ paramToDocument = shortForm;
+ }
+ }
+ params.add(paramToDocument);
+ } catch (IllegalAccessException e) {
+ LOG.debug(
+ "Cannot access field {} in {}: {}",
+ field.getName(),
+ clazz.getSimpleName(),
+ e.getMessage());
+ }
+ }
+ }
+ }
+
+ return params;
+ }
+
+ /**
+ * Extract parameter names from inner ConfigKey/ParamKey enums via getName() or constant names.
+ *
+ * @param clazz the class whose inner enums to inspect
+ * @return discovered parameter names
+ */
+ private List extractFromConfigKeyEnum(Class> clazz) {
+ List params = new ArrayList<>();
+
+ for (Class> innerClass : clazz.getDeclaredClasses()) {
+ if (innerClass.isEnum()) {
+ String enumName = innerClass.getSimpleName();
+
+ if (enumName.equals("ConfigKey")
+ || enumName.equals("ParamKey")
+ || enumName.endsWith("Key")) {
+
+ Object[] enumConstants = innerClass.getEnumConstants();
+
+ try {
+ // Try getName() method (e.g., LDAPLoginModule)
+ java.lang.reflect.Method getNameMethod = innerClass.getMethod("getName");
+ for (Object enumConstant : enumConstants) {
+ String paramName = (String) getNameMethod.invoke(enumConstant);
+ params.add(paramName);
+ }
+ } catch (NoSuchMethodException e) {
+ // No getName(), use enum constant names
+ for (Object enumConstant : enumConstants) {
+ String paramName = ((Enum>) enumConstant).name();
+ // Convert SCREAMING_SNAKE_CASE to camelCase
+ paramName = toCamelCase(paramName);
+ params.add(paramName);
+ }
+ } catch (Exception e) {
+ LOG.debug(
+ "Failed to extract enum params from {}: {}",
+ innerClass.getSimpleName(),
+ e.getMessage());
+ }
+ }
+ }
+ }
+
+ return params;
+ }
+
+ /**
+ * Extract parameters from companion constant classes in the same package. E.g.,
+ * NettyAcceptorFactory → look for TransportConstants.
+ *
+ * @param factoryClass the factory class whose package is searched for companions
+ * @return discovered parameter names from the companion's *_PROP_NAME constants
+ */
+ private List extractFromCompanionClass(Class> factoryClass) {
+ List params = new ArrayList<>();
+
+ // Try common companion class names
+ String[] companionNames = {
+ "TransportConstants",
+ "Constants",
+ factoryClass.getSimpleName() + "Constants",
+ factoryClass.getSimpleName() + "Params"
+ };
+
+ String packageName = factoryClass.getPackage().getName();
+
+ for (String companionName : companionNames) {
+ try {
+ String fullClassName = packageName + "." + companionName;
+ Class> companionClass = Class.forName(fullClassName);
+
+ // Extract *_PROP_NAME constants from companion class
+ for (Field field : companionClass.getDeclaredFields()) {
+ if (Modifier.isStatic(field.getModifiers())
+ && Modifier.isFinal(field.getModifiers())
+ && field.getType() == String.class
+ && field.getName().endsWith("_PROP_NAME")) {
+
+ try {
+ field.setAccessible(true);
+ String paramValue = (String) field.get(null);
+ params.add(paramValue);
+ } catch (IllegalAccessException e) {
+ LOG.debug("Cannot access companion field {}: {}", field.getName(), e.getMessage());
+ }
+ }
+ }
+
+ if (!params.isEmpty()) {
+ LOG.debug("Found companion class: {}", companionName);
+ break; // Found constants, don't look for other companion classes
+ }
+ } catch (ClassNotFoundException e) {
+ // Companion class doesn't exist, try next
+ }
+ }
+
+ return params;
+ }
+
+ /**
+ * Convert SCREAMING_SNAKE_CASE to camelCase.
+ *
+ * @param screamingSnakeCase input in SCREAMING_SNAKE_CASE format
+ * @return camelCase equivalent
+ */
+ private String toCamelCase(String screamingSnakeCase) {
+ String[] parts = screamingSnakeCase.toLowerCase().split("_");
+ StringBuilder result = new StringBuilder(parts[0]);
+ for (int i = 1; i < parts.length; i++) {
+ if (!parts[i].isEmpty()) {
+ result.append(Character.toUpperCase(parts[i].charAt(0)));
+ result.append(parts[i].substring(1));
+ }
+ }
+ return result.toString();
+ }
+
+ /**
+ * @return extractor name for logging
+ */
+ public String getName() {
+ return "FactoryDiscovery";
+ }
+
+ /**
+ * Bridge for Reflections.getSubTypesOf when the type is only known as Class<?>. The
+ * unchecked cast is unavoidable when classes are loaded by name at runtime.
+ *
+ * @param reflections configured Reflections instance
+ * @param type the interface or superclass to query
+ * @return set of discovered subtypes
+ */
+ @SuppressWarnings("unchecked")
+ private static Set extends Class>> getSubTypesOfWildcard(
+ org.reflections.Reflections reflections, Class> type) {
+ return reflections.getSubTypesOf((Class) type);
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/FactoryParameterRegistry.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/FactoryParameterRegistry.java
new file mode 100644
index 00000000000..26433709811
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/FactoryParameterRegistry.java
@@ -0,0 +1,186 @@
+/*
+ * 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.artemis.jsonschema.factories;
+
+import java.util.*;
+
+/**
+ * Registry mapping factory classes to their discoverable parameters.
+ *
+ * Populated by FactoryDiscovery during Pipeline Phase 1. Consumed by
+ * TransportFactoryVariantBuilder to inject factory-specific parameter properties into the IR.
+ */
+public class FactoryParameterRegistry {
+
+ /** Map: fully qualified factory class name → parameter info */
+ private final Map factories = new LinkedHashMap<>();
+
+ /**
+ * Add a factory and its parameters to the registry.
+ *
+ * @param fullClassName Fully qualified class name (e.g., "org.apache...NettyAcceptorFactory")
+ * @param simpleClassName Simple class name (e.g., "NettyAcceptorFactory")
+ * @param parameters List of parameter names this factory supports
+ * @param interfaceType Interface this factory implements (e.g., "LoginModule", "AcceptorFactory")
+ */
+ public void registerFactory(
+ String fullClassName, String simpleClassName, List parameters, String interfaceType) {
+ factories.put(fullClassName, new FactoryInfo(simpleClassName, parameters, interfaceType));
+ }
+
+ /**
+ * Get factory info by full class name.
+ *
+ * @param fullClassName fully qualified class name
+ * @return factory info, or {@code null} if not registered
+ */
+ public FactoryInfo getFactory(String fullClassName) {
+ return factories.get(fullClassName);
+ }
+
+ /**
+ * Get all registered factory class names.
+ *
+ * @return live set of fully qualified class names (iteration order is insertion order)
+ */
+ public Set getFactoryClassNames() {
+ return factories.keySet();
+ }
+
+ /**
+ * Check if a class name is a registered factory.
+ *
+ * @param className fully qualified class name to test
+ * @return {@code true} if the class has been registered
+ */
+ public boolean isFactory(String className) {
+ return factories.containsKey(className);
+ }
+
+ /**
+ * Get parameters for a specific factory.
+ *
+ * @param fullClassName fully qualified class name
+ * @return parameter names, or empty list if factory not found
+ */
+ public List getParameters(String fullClassName) {
+ FactoryInfo info = factories.get(fullClassName);
+ return info != null ? info.getParameters() : Collections.emptyList();
+ }
+
+ /**
+ * Number of registered factory classes.
+ *
+ * @return count of distinct registered factories
+ */
+ public int getFactoryCount() {
+ return factories.size();
+ }
+
+ /**
+ * Sum of all parameter names across all factories (for logging).
+ *
+ * @return total parameter count across all factories
+ */
+ public int getTotalParameterCount() {
+ return factories.values().stream().mapToInt(f -> f.getParameters().size()).sum();
+ }
+
+ /**
+ * Get factory class names filtered by the property that selects the factory. "factoryClassName" →
+ * returns AcceptorFactory/ConnectorFactory types. "loginModuleClass" → returns LoginModule types.
+ * Unknown property name → returns all (safe fallback).
+ *
+ * @param propertyName config property name that discriminates the factory type
+ * @return matching fully qualified class names
+ */
+ public List getFactoriesForProperty(String propertyName) {
+ List result = new ArrayList<>();
+
+ for (Map.Entry entry : factories.entrySet()) {
+ FactoryInfo info = entry.getValue();
+
+ // Filter by property name pattern
+ if (propertyName.contains("loginModule") || propertyName.contains("LoginModule")) {
+ if (info.interfaceType.contains("LoginModule")) {
+ result.add(entry.getKey());
+ }
+ } else if (propertyName.contains("factory") || propertyName.contains("Factory")) {
+ if (info.interfaceType.contains("Factory")) {
+ result.add(entry.getKey());
+ }
+ } else {
+ // Unknown pattern - include all
+ result.add(entry.getKey());
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Get factory class names filtered by the interface they implement.
+ *
+ * @param interfaceSimpleName simple name of the interface (e.g. "LoginModule", "AcceptorFactory")
+ * @return matching fully qualified class names
+ */
+ public List getFactoriesByInterface(String interfaceSimpleName) {
+ List result = new ArrayList<>();
+ for (Map.Entry entry : factories.entrySet()) {
+ if (entry.getValue().interfaceType.contains(interfaceSimpleName)) {
+ result.add(entry.getKey());
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Holds the metadata for one discovered factory: its simple name, the parameter names it
+ * supports, and which interface it implements.
+ */
+ public static class FactoryInfo {
+ private final String simpleClassName;
+ private final List parameters;
+ private final String interfaceType;
+
+ /**
+ * Construct factory info with the given metadata.
+ *
+ * @param simpleClassName unqualified class name
+ * @param parameters parameter names (defensively copied)
+ * @param interfaceType simple name of the interface this factory implements
+ */
+ public FactoryInfo(String simpleClassName, List parameters, String interfaceType) {
+ this.simpleClassName = simpleClassName;
+ this.parameters = new ArrayList<>(parameters);
+ this.interfaceType = interfaceType;
+ }
+
+ public String getSimpleClassName() {
+ return simpleClassName;
+ }
+
+ public List getParameters() {
+ return Collections.unmodifiableList(parameters);
+ }
+
+ public String getInterfaceType() {
+ return interfaceType;
+ }
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/FactoryVariantBuilder.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/FactoryVariantBuilder.java
new file mode 100644
index 00000000000..87a1c433b04
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/FactoryVariantBuilder.java
@@ -0,0 +1,235 @@
+/*
+ * 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.artemis.jsonschema.factories;
+
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.artemis.jsonschema.ir.Location;
+import org.apache.artemis.jsonschema.ir.SchemaIR;
+import org.apache.artemis.jsonschema.ir.SchemaType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Base class for building synthetic oneOf variant ClassNodes from a factory registry.
+ *
+ * Why synthetic nodes?
+ *
+ * Artemis uses a map-of-strings pattern for plugin extensibility: a configuration class (e.g.
+ * {@code TransportConfiguration}, {@code JaasAppConfigurationEntry}) holds a discriminator field
+ * ({@code factoryClassName}, {@code loginModuleClass}) selecting the implementation, and an opaque
+ * {@code Map params} whose valid keys depend on which implementation is selected.
+ *
+ * There is no {@code NettyAcceptorFactoryConfiguration.class} with typed bean properties for
+ * {@code host}, {@code port}, {@code sslEnabled} — those are convention-based string keys looked up
+ * via {@code params.get("host")} in the implementation code. Same for JAAS: no {@code
+ * LDAPLoginModuleConfiguration.class} — the 25 LDAP params live as strings in a map, discovered
+ * from {@code LDAPLoginModule}'s {@code ConfigKey} enum.
+ *
+ *
Because the IRBuilder walks the Java class graph via reflection, it only sees the base class
+ * with its opaque map. The factory-specific params are invisible to reflection. This builder runs
+ * after the IR is built to create synthetic ClassNodes that reconstruct the type information
+ * from convention ({@code *_PROP_NAME} constants, {@code ConfigKey} enums) rather than from the
+ * type system.
+ *
+ *
What each variant node contains
+ *
+ *
+ * All base class properties copied from the reflected ClassNode
+ * The discriminator field locked to a {@code const} + {@code default} + {@code required}
+ * The params field enriched with implementation-specific property names from the registry
+ *
+ *
+ * Subclasses define which target class triggers variant building, which fields are the
+ * discriminator and params, and how to filter the factory registry for relevant implementations.
+ *
+ * @see TransportFactoryVariantBuilder
+ * @see LoginModuleVariantBuilder
+ */
+public abstract class FactoryVariantBuilder {
+
+ private static final Logger LOG = LoggerFactory.getLogger(FactoryVariantBuilder.class);
+
+ protected final SchemaIR ir;
+ protected final FactoryParameterRegistry factoryRegistry;
+
+ protected FactoryVariantBuilder(SchemaIR ir, FactoryParameterRegistry factoryRegistry) {
+ this.ir = ir;
+ this.factoryRegistry = factoryRegistry;
+ }
+
+ /**
+ * Create all variant builders, performing factory discovery once.
+ *
+ * @param ir the IR graph to populate with synthetic variant nodes
+ * @param artemisRoot path to Artemis source root (for factory discovery)
+ * @return list of variant builders ready to use, or empty list if discovery fails
+ */
+ public static List createAll(SchemaIR ir, Path artemisRoot) {
+ FactoryDiscovery discovery = new FactoryDiscovery();
+ FactoryParameterRegistry registry;
+ try {
+ registry = discovery.extractRegistry(artemisRoot);
+ } catch (Exception e) {
+ LOG.warn(
+ "Factory discovery failed, schema will lack factory-specific params: {}", e.getMessage());
+ return List.of();
+ }
+
+ if (registry == null) {
+ return List.of();
+ }
+
+ LOG.info(
+ "Discovered {} factories with {} total parameters",
+ registry.getFactoryCount(),
+ registry.getTotalParameterCount());
+
+ return List.of(
+ new TransportFactoryVariantBuilder(ir, registry),
+ new LoginModuleVariantBuilder(ir, registry));
+ }
+
+ /** Fully qualified name of the class this builder handles. */
+ protected abstract String getTargetClassName();
+
+ /** Property name used as the oneOf discriminator (e.g. "factoryClassName"). */
+ protected abstract String getDiscriminatorField();
+
+ /** Property name holding implementation-specific key-value params. */
+ protected abstract String getParamsField();
+
+ /**
+ * Filter the full factory list to only those relevant for the given property context.
+ *
+ * @param propertyName the leaf property name where this map appears
+ * @return filtered factory class names
+ */
+ protected abstract List filterFactories(String propertyName);
+
+ /**
+ * Scan the built IR for property nodes targeting this builder's class, and create synthetic
+ * variant nodes for each.
+ *
+ * This runs as pass 2 after the full IR is built, so base ClassNodes are fully populated and
+ * can be copied into variants.
+ *
+ * @param ir the fully populated IR graph to scan
+ */
+ public void buildVariants(SchemaIR ir) {
+ if (factoryRegistry == null) {
+ return;
+ }
+
+ String targetClass = getTargetClassName();
+ ir.markAsFactoryBase(targetClass);
+
+ // Snapshot: iterate a copy since createVariantNode adds nodes to the IR
+ for (SchemaIR.ClassNode classNode : List.copyOf(ir.getAllNodes())) {
+ for (Map.Entry entry : classNode.getProperties().entrySet()) {
+ SchemaIR.PropertyNode propNode = entry.getValue();
+
+ if (!targetClass.equals(propNode.getTargetClassName())) {
+ continue;
+ }
+
+ Location location = propNode.getLocation();
+ if (location == null) {
+ continue;
+ }
+
+ String propertyName = location.leafName();
+ List factories = filterFactories(propertyName);
+ SchemaIR.ClassNode baseNode = ir.getOrCreateNode(targetClass);
+
+ for (String factoryClassName : factories) {
+ List params = factoryRegistry.getParameters(factoryClassName);
+ propNode.addFactoryVariant(factoryClassName, params);
+ createVariantNode(factoryClassName, params, baseNode, location.wildcard());
+ }
+ }
+ }
+ }
+
+ /**
+ * Create a synthetic ClassNode for one factory variant. Copies base class properties, locks the
+ * discriminator to a const, and injects factory-specific params into the params field.
+ */
+ private void createVariantNode(
+ String factoryClassName,
+ List factoryParams,
+ SchemaIR.ClassNode baseNode,
+ Location contextLocation) {
+ String simpleName = getSimpleClassName(factoryClassName);
+ SchemaIR.ClassNode variantNode = ir.getOrCreateNode(simpleName);
+
+ variantNode.getClassMetadata().setDescription("Configuration for " + simpleName);
+ variantNode.getClassMetadata().setFactoryVariant(true);
+ variantNode.getClassMetadata().setContextPath(contextLocation.toDotted());
+ variantNode.getClassMetadata().setContextPath(contextLocation.toDotted());
+
+ String discriminator = getDiscriminatorField();
+ String paramsField = getParamsField();
+
+ for (Map.Entry baseProp : baseNode.getProperties().entrySet()) {
+ String propName = baseProp.getKey();
+ SchemaIR.PropertyNode basePropNode = baseProp.getValue();
+ SchemaIR.PropertyNode variantProp = variantNode.getOrCreateProperty(propName);
+
+ if (propName.equals(discriminator)) {
+ variantProp.setSchemaField("const", factoryClassName);
+ variantProp.setSchemaField("default", factoryClassName);
+ variantProp.setSchemaType(new SchemaType(SchemaType.Kind.STRING));
+ variantProp.setSchemaField("x-access", "RW");
+ variantNode.setRequired(List.of(discriminator));
+ } else if (propName.equals(paramsField)) {
+ for (Map.Entry schemaEntry : basePropNode.getSchema().entrySet()) {
+ variantProp.setSchemaField(schemaEntry.getKey(), schemaEntry.getValue());
+ }
+ variantProp.setPropertyType(SchemaIR.PropertyType.PRIMITIVE);
+
+ Map paramProperties = new LinkedHashMap<>();
+ for (String paramName : factoryParams) {
+ Map paramSchema = new LinkedHashMap<>();
+ paramSchema.put("type", new SchemaType(SchemaType.Kind.STRING).toSchemaValue());
+ paramSchema.put("x-access", "RW");
+ paramProperties.put(paramName, paramSchema);
+ }
+ variantProp.setSchemaField("properties", paramProperties);
+ } else {
+ for (Map.Entry schemaEntry : basePropNode.getSchema().entrySet()) {
+ variantProp.setSchemaField(schemaEntry.getKey(), schemaEntry.getValue());
+ }
+ if (basePropNode.getTargetClassName() != null) {
+ variantProp.setTargetClassName(basePropNode.getTargetClassName());
+ }
+ variantProp.setPropertyType(basePropNode.getPropertyType());
+ }
+ }
+
+ ir.recordUsage(simpleName, "factory-variant");
+ ir.recordUsage(simpleName, "factory-variant-extracted");
+ }
+
+ protected static String getSimpleClassName(String fullClassName) {
+ int lastDot = fullClassName.lastIndexOf('.');
+ return lastDot >= 0 ? fullClassName.substring(lastDot + 1) : fullClassName;
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/LoginModuleVariantBuilder.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/LoginModuleVariantBuilder.java
new file mode 100644
index 00000000000..f75f3761018
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/LoginModuleVariantBuilder.java
@@ -0,0 +1,64 @@
+/*
+ * 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.artemis.jsonschema.factories;
+
+import java.util.List;
+import org.apache.artemis.jsonschema.ir.SchemaIR;
+
+/**
+ * Builds oneOf variants for JaasAppConfigurationEntry (login modules). Discriminator: {@code
+ * loginModuleClass}. Params field: {@code params}.
+ *
+ * Each LoginModule implementation (PropertiesLoginModule, LDAPLoginModule, GuestLoginModule,
+ * etc.) accepts different params. This builder creates a $def per module with the specific params
+ * documented, and a oneOf on the modules map so the schema expresses which params are valid for
+ * which module.
+ */
+public class LoginModuleVariantBuilder extends FactoryVariantBuilder {
+
+ public LoginModuleVariantBuilder(SchemaIR ir, FactoryParameterRegistry factoryRegistry) {
+ super(ir, factoryRegistry);
+ }
+
+ @Override
+ protected String getTargetClassName() {
+ return "org.apache.activemq.artemis.core.config.JaasAppConfigurationEntry";
+ }
+
+ @Override
+ protected String getDiscriminatorField() {
+ return "loginModuleClass";
+ }
+
+ @Override
+ protected String getParamsField() {
+ return "params";
+ }
+
+ @Override
+ protected List filterFactories(String propertyName) {
+ // All LoginModule implementations are relevant regardless of context.
+ // Unlike transport factories, there's no acceptor/connector split.
+ List all = factoryRegistry.getFactoriesForProperty("loginModuleClass");
+ if (all != null && !all.isEmpty()) {
+ return all;
+ }
+ // Fallback: get all LoginModule implementations from the registry
+ return factoryRegistry.getFactoriesByInterface("LoginModule");
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/MapConstantKeysBuilder.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/MapConstantKeysBuilder.java
new file mode 100644
index 00000000000..7634fc43be5
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/MapConstantKeysBuilder.java
@@ -0,0 +1,268 @@
+/*
+ * 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.artemis.jsonschema.factories;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.artemis.jsonschema.config.SchemaGeneratorConfig;
+import org.apache.artemis.jsonschema.ir.Location;
+import org.apache.artemis.jsonschema.ir.SchemaIR;
+import org.apache.artemis.jsonschema.ir.SchemaType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Discovers known keys for {@code Map} properties by scanning constant classes
+ * that define the valid property key names (e.g., AMQPBridgeConstants, AMQPFederationConstants).
+ *
+ * Reads mappings from {@code mapConstantKeys} in schema-generator-config.json and injects
+ * the discovered keys as typed properties alongside additionalProperties on the target map fields.
+ *
+ *
This builder handles structural discovery only (key names and types). Descriptions are
+ * added during the enrichment phase by extractors that process Javadoc from the same source files.
+ */
+public class MapConstantKeysBuilder {
+
+ private static final Logger LOG = LoggerFactory.getLogger(MapConstantKeysBuilder.class);
+
+ private final SchemaIR ir;
+ private final Path artemisRoot;
+ private final Map> mappings;
+
+ /**
+ * @param ir the intermediate representation to inject discovered properties into
+ * @param artemisRoot path to Artemis source root for Javadoc extraction
+ */
+ public MapConstantKeysBuilder(SchemaIR ir, Path artemisRoot) {
+ this.ir = ir;
+ this.artemisRoot = artemisRoot;
+ this.mappings = SchemaGeneratorConfig.load().getMapConstantKeys();
+ }
+
+ /**
+ * Process all configured constant classes and inject their keys into the IR as enrichments.
+ * Each mapping entry associates a constants class with the schema paths where its keys apply.
+ */
+ public void build() {
+ for (Map.Entry> entry : mappings.entrySet()) {
+ buildOne(entry.getKey(), entry.getValue());
+ }
+ }
+
+ /**
+ * Process a single constants class: extract its String constants, build a properties schema
+ * from them, and inject it as an enrichment at each configured map property path.
+ *
+ * @param constantsClassName fully qualified class name of the constants class
+ * @param mapPropertyPaths schema paths to the map properties that use these constants
+ */
+ private void buildOne(String constantsClassName, List mapPropertyPaths) {
+ try {
+ Class> constantsClass = Class.forName(constantsClassName);
+ Map constants = extractConstants(constantsClass);
+
+ if (constants.isEmpty()) {
+ LOG.warn("No String constants found in {}", constantsClassName);
+ return;
+ }
+
+ LOG.info("{}: {} property keys discovered", constantsClass.getSimpleName(), constants.size());
+
+ Map javadocs = extractJavadocs(constantsClassName);
+
+ Map knownProperties = new LinkedHashMap<>();
+ for (Map.Entry entry : constants.entrySet()) {
+ String key = entry.getKey();
+ ConstantInfo info = entry.getValue();
+
+ Map propSchema = new LinkedHashMap<>();
+ propSchema.put("type", inferType(info));
+ String javadoc = javadocs.get(info.fieldName);
+ if (javadoc != null) {
+ propSchema.put("description", javadoc);
+ }
+ knownProperties.put(key, propSchema);
+ }
+
+ for (String path : mapPropertyPaths) {
+ Map enrichment = new LinkedHashMap<>();
+ enrichment.put("properties", knownProperties);
+ ir.enrich(Location.of(path), enrichment);
+ }
+
+ } catch (ClassNotFoundException e) {
+ LOG.warn("Constants class not on classpath: {}", constantsClassName);
+ }
+ }
+
+ /**
+ * Extract all user-settable String constants from a class via reflection. Filters out
+ * internal/protocol constants (SCREAMING_CASE values, addresses containing $ or /).
+ * Pairs each constant with its default value (from DEFAULT_* fields) for type inference.
+ *
+ * @param clazz the constants class to scan
+ * @return map of property key (the constant's String value) to its metadata
+ */
+ private Map extractConstants(Class> clazz) {
+ Map constants = new LinkedHashMap<>();
+ Map defaults = extractDefaults(clazz);
+
+ for (Field field : clazz.getDeclaredFields()) {
+ int mods = field.getModifiers();
+ if (!Modifier.isPublic(mods) || !Modifier.isStatic(mods) || !Modifier.isFinal(mods)) {
+ continue;
+ }
+ if (field.getType() != String.class) {
+ continue;
+ }
+
+ try {
+ String value = (String) field.get(null);
+ if (value == null || value.isEmpty()) {
+ continue;
+ }
+ if (value.contains("_") && value.equals(value.toUpperCase())) {
+ continue;
+ }
+ if (value.contains("$") || value.contains("/")) {
+ continue;
+ }
+
+ Object defaultValue = defaults.get(field.getName());
+ constants.put(value, new ConstantInfo(field.getName(), defaultValue));
+ } catch (IllegalAccessException e) {
+ // skip inaccessible fields
+ }
+ }
+ return constants;
+ }
+
+ /**
+ * Extract DEFAULT_* fields to infer types for their corresponding constants.
+ * Values are used only for type inference, not surfaced in the schema.
+ *
+ * @param clazz the constants class to scan for DEFAULT_ fields
+ * @return map of constant field name (without DEFAULT_ prefix) to default value (for type inference only)
+ */
+ private Map extractDefaults(Class> clazz) {
+ Map defaults = new LinkedHashMap<>();
+ for (Field field : clazz.getDeclaredFields()) {
+ int mods = field.getModifiers();
+ if (!Modifier.isPublic(mods) || !Modifier.isStatic(mods) || !Modifier.isFinal(mods)) {
+ continue;
+ }
+ String name = field.getName();
+ if (!name.startsWith("DEFAULT_")) {
+ continue;
+ }
+ try {
+ Object value = field.get(null);
+ String keyFieldName = name.substring("DEFAULT_".length());
+ defaults.put(keyFieldName, value);
+ } catch (IllegalAccessException e) {
+ // skip inaccessible fields
+ }
+ }
+ return defaults;
+ }
+
+ /**
+ * Extract Javadoc comments for {@code public static final String} fields from the class's
+ * source file. Searches {@code javadocSourceDirs} from config.
+ *
+ * @param className fully qualified class name
+ * @return map of field name to cleaned Javadoc text
+ */
+ private Map extractJavadocs(String className) {
+ Map javadocs = new LinkedHashMap<>();
+ try {
+ String classPath = className.replace('.', '/') + ".java";
+ List sourceDirs = SchemaGeneratorConfig.load().getJavadocSourceDirs();
+ Path sourceFile = null;
+ for (String dir : sourceDirs) {
+ Path candidate = artemisRoot.resolve(dir).resolve(classPath);
+ if (Files.exists(candidate)) {
+ sourceFile = candidate;
+ break;
+ }
+ }
+ if (sourceFile == null) {
+ return javadocs;
+ }
+
+ String source = Files.readString(sourceFile);
+ Pattern fieldPattern = Pattern.compile(
+ "/\\*\\*([^*]|\\*(?!/))*\\*/\\s*\n\\s*public static final String (\\w+)");
+ Matcher m = fieldPattern.matcher(source);
+ while (m.find()) {
+ String rawDoc = m.group(0)
+ .replaceAll("public static final String \\w+", "")
+ .replaceAll("/\\*\\*|\\*/", "")
+ .replaceAll("\\n\\s*\\*\\s?", " ")
+ .replaceAll("\\{@link [^}]*}", "")
+ .replaceAll("\\{@code ([^}]*)}", "$1")
+ .trim();
+ String fieldName = m.group(2);
+ if (!rawDoc.isEmpty()) {
+ javadocs.put(fieldName, rawDoc);
+ }
+ }
+ } catch (Exception e) {
+ LOG.debug("Could not extract Javadocs for {}: {}", className, e.getMessage());
+ }
+ return javadocs;
+ }
+
+ /**
+ * Infer the JSON Schema type for a constant key based on its DEFAULT_* field type.
+ * Falls back to "string" when no default exists.
+ *
+ * @param info the constant's metadata (default value for type inference)
+ * @return the JSON Schema type string ("string", "integer", or "boolean")
+ */
+ private String inferType(ConstantInfo info) {
+ if (info.defaultValue instanceof Boolean) {
+ return new SchemaType(SchemaType.Kind.BOOLEAN).toSchemaValue().toString();
+ } else if (info.defaultValue instanceof Number) {
+ return new SchemaType(SchemaType.Kind.INTEGER).toSchemaValue().toString();
+ }
+ return new SchemaType(SchemaType.Kind.STRING).toSchemaValue().toString();
+ }
+
+ /**
+ * Metadata for a single discovered constant key.
+ */
+ private static class ConstantInfo {
+ final String fieldName;
+ final Object defaultValue;
+
+ /**
+ * @param fieldName Java field name (used to look up Javadoc)
+ * @param defaultValue value from the corresponding DEFAULT_* field, or null (for type inference)
+ */
+ ConstantInfo(String fieldName, Object defaultValue) {
+ this.fieldName = fieldName;
+ this.defaultValue = defaultValue;
+ }
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/TransportFactoryVariantBuilder.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/TransportFactoryVariantBuilder.java
new file mode 100644
index 00000000000..a7e3bb7040e
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/factories/TransportFactoryVariantBuilder.java
@@ -0,0 +1,76 @@
+/*
+ * 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.artemis.jsonschema.factories;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.artemis.jsonschema.annotation.Heuristic;
+import org.apache.artemis.jsonschema.ir.SchemaIR;
+
+/**
+ * Builds oneOf variants for TransportConfiguration (acceptors/connectors). Discriminator: {@code
+ * factoryClassName}. Params field: {@code params}.
+ */
+public class TransportFactoryVariantBuilder extends FactoryVariantBuilder {
+
+ public TransportFactoryVariantBuilder(SchemaIR ir, FactoryParameterRegistry factoryRegistry) {
+ super(ir, factoryRegistry);
+ }
+
+ @Override
+ protected String getTargetClassName() {
+ return "org.apache.activemq.artemis.api.core.TransportConfiguration";
+ }
+
+ @Override
+ protected String getDiscriminatorField() {
+ return "factoryClassName";
+ }
+
+ @Override
+ protected String getParamsField() {
+ return "params";
+ }
+
+ @Override
+ @Heuristic("Relies on 'acceptor'/'connector' appearing in property names")
+ protected List filterFactories(String propertyName) {
+ List allFactories = factoryRegistry.getFactoriesForProperty("factoryClassName");
+ List filtered = new ArrayList<>();
+
+ String lower = propertyName.toLowerCase();
+
+ for (String factoryClass : allFactories) {
+ String simple = getSimpleClassName(factoryClass);
+
+ if (lower.contains("acceptor") && !lower.contains("connector")) {
+ if (simple.contains("Acceptor") && !simple.contains("Connector")) {
+ filtered.add(factoryClass);
+ }
+ } else if (lower.contains("connector") && !lower.contains("acceptor")) {
+ if (simple.contains("Connector") && !simple.contains("Acceptor")) {
+ filtered.add(factoryClass);
+ }
+ } else {
+ filtered.add(factoryClass);
+ }
+ }
+
+ return filtered;
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/ClassMetadata.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/ClassMetadata.java
new file mode 100644
index 00000000000..3bef197c31e
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/ClassMetadata.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.artemis.jsonschema.ir;
+
+import java.util.Map;
+
+/**
+ * Typed metadata for a ClassNode in the IR. Mirrors PropertyMetadata but for class-level
+ * annotations that get emitted as sibling keys in the JSON Schema output.
+ */
+public class ClassMetadata {
+
+ private String javaClass;
+ private String description;
+ private boolean factoryVariant;
+ private String contextPath;
+
+ public String getJavaClass() {
+ return javaClass;
+ }
+
+ public void setJavaClass(String javaClass) {
+ this.javaClass = javaClass;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public boolean isFactoryVariant() {
+ return factoryVariant;
+ }
+
+ public void setFactoryVariant(boolean factoryVariant) {
+ this.factoryVariant = factoryVariant;
+ }
+
+ public String getContextPath() {
+ return contextPath;
+ }
+
+ public void setContextPath(String contextPath) {
+ this.contextPath = contextPath;
+ }
+
+ /**
+ * Emit all non-null fields into a schema map. Called by SchemaEmitter to inject class-level
+ * annotations into the output.
+ *
+ * @param schema mutable map to populate with class-level annotation keys
+ */
+ public void emitInto(Map schema) {
+ if (javaClass != null) {
+ schema.put("x-java-class", javaClass);
+ }
+ if (description != null) {
+ schema.put("description", description);
+ }
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/Enrichment.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/Enrichment.java
new file mode 100644
index 00000000000..0b70726b938
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/Enrichment.java
@@ -0,0 +1,70 @@
+/*
+ * 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.artemis.jsonschema.ir;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Metadata enrichment for a property at a given Location. Contains key-value pairs like
+ * description, default, minimum, x-deprecated, etc.
+ *
+ * Extends LinkedHashMap to preserve insertion order (matches JSON output order) while giving the
+ * concept a proper name in the type system.
+ */
+public class Enrichment extends LinkedHashMap {
+
+ /** Construct an empty enrichment. */
+ public Enrichment() {
+ super();
+ }
+
+ /**
+ * Construct an enrichment pre-populated with the given entries.
+ *
+ * @param initial initial key-value pairs to copy in
+ */
+ public Enrichment(Map initial) {
+ super(initial);
+ }
+
+ /**
+ * Merge another enrichment's entries into this one. Existing keys are overwritten by the incoming
+ * values.
+ *
+ * @param other enrichment whose entries take precedence
+ * @return new merged enrichment (this instance is not mutated)
+ */
+ public Enrichment merge(Enrichment other) {
+ Enrichment merged = new Enrichment(this);
+ merged.putAll(other);
+ return merged;
+ }
+
+ /**
+ * Merge raw map entries (from extractor-produced metadata).
+ *
+ * @param other raw entries whose values take precedence
+ * @return new merged enrichment (this instance is not mutated)
+ */
+ public Enrichment merge(Map other) {
+ Enrichment merged = new Enrichment(this);
+ merged.putAll(other);
+ return merged;
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/IRBuilder.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/IRBuilder.java
new file mode 100644
index 00000000000..6c36ea1d0c6
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/IRBuilder.java
@@ -0,0 +1,745 @@
+/*
+ * 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.artemis.jsonschema.ir;
+
+import org.apache.activemq.artemis.core.config.Configuration;
+import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl;
+import org.apache.artemis.jsonschema.annotation.ConfigProperty;
+import org.apache.artemis.jsonschema.config.SchemaGeneratorConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.beans.BeanInfo;
+import java.beans.Introspector;
+import java.lang.reflect.*;
+import java.util.*;
+
+/**
+ * IR-based JSON Schema generator using two-phase architecture.
+ *
+ * Architecture:
+ *
+ * Build IR graph from reflection (track all class usage)
+ * Enrichers mutate IR nodes (XSD, JavaDoc, etc.)
+ * Analyze graph to identify $def candidates (usageCount > 1)
+ * Emit JSON schema with proper $ref usage
+ *
+ *
+ * This prevents invalid schemas and enables K8s CRD compatibility (<1MB).
+ */
+public class IRBuilder {
+
+ private static final Logger LOG = LoggerFactory.getLogger(IRBuilder.class);
+
+ private static final Set IGNORED_PROPERTIES = new HashSet<>(
+ SchemaGeneratorConfig.load().getIgnoredProperties()
+ );
+
+ private Configuration configInstance;
+ private final SchemaIR ir = new SchemaIR();
+ private final Set processingClasses = new HashSet<>();
+ private PolymorphismResolver polymorphismResolver;
+
+ public IRBuilder() {
+ }
+
+ /**
+ * Generate the IR graph by recursively reflecting over ConfigurationImpl and all reachable types.
+ *
+ * Populates class nodes, property nodes, usage counts, and factory variant structures.
+ * The returned IR is mutable and intended to be enriched by subsequent extractor passes
+ * before being handed to {@link SchemaEmitter#emitSchema(SchemaIR)}.
+ *
+ * @return Populated IR graph ready for enrichment and emission
+ * @throws Exception if ConfigurationImpl cannot be introspected (e.g., missing on classpath)
+ */
+ public void generateIR() throws Exception {
+ configInstance = getConfigInstance();
+ polymorphismResolver = new PolymorphismResolver(ir);
+
+ buildClassIR(ConfigurationImpl.class, Location.root(), configInstance);
+ }
+
+ /**
+ * @return the populated IR graph (only valid after generateIR() has been called)
+ */
+ public SchemaIR getIR() {
+ return ir;
+ }
+
+ /**
+ * Log summary statistics about the generated IR graph.
+ */
+ public void logStats() {
+ LOG.info("Generated IR: {} classes tracked", ir.getAllNodes().size());
+ int extractable = 0;
+ for (SchemaIR.ClassNode node : ir.getAllNodes()) {
+ if (ir.shouldExtract(node.getClassName())) {
+ extractable++;
+ }
+ }
+ LOG.info("{} classes identified for $defs extraction (used 2+ times)", extractable);
+ }
+
+ /**
+ * Log documentation coverage: how many root properties have descriptions after enrichment.
+ */
+ public void logDocumentationCoverage() {
+ SchemaIR.ClassNode rootNode = ir.getOrCreateNode(ConfigurationImpl.class.getName());
+
+ int totalProps = 0;
+ int documentedProps = 0;
+ List undocumented = new ArrayList<>();
+
+ for (SchemaIR.PropertyNode prop : rootNode.getProperties().values()) {
+ totalProps++;
+ Location propLocation = Location.root().child(prop);
+ Map enrichment = ir.getEnrichment(propLocation);
+ if (enrichment.containsKey("description")) {
+ documentedProps++;
+ } else {
+ undocumented.add(prop.getName());
+ }
+ }
+
+ double coverage = totalProps > 0 ? (100.0 * documentedProps / totalProps) : 0;
+ LOG.info("Documentation coverage: {}/{} root properties ({} %)",
+ documentedProps, totalProps, String.format("%.1f", coverage));
+
+ if (!undocumented.isEmpty() && LOG.isDebugEnabled()) {
+ LOG.debug("Undocumented root properties: {}", undocumented);
+ }
+ }
+
+ /**
+ * Log how many classes qualify for $defs extraction (determined during IR generation).
+ */
+ public void logExtractionStats() {
+ int total = ir.getAllNodes().size();
+ int extracted = 0;
+ for (SchemaIR.ClassNode node : ir.getAllNodes()) {
+ if (ir.shouldExtract(node.getClassName())) {
+ extracted++;
+ }
+ }
+ LOG.info("Emitted schema with {}/{} classes extracted to $defs", extracted, total);
+ }
+
+
+ /**
+ * Recursively build IR nodes for a class and all its bean properties.
+ * Handles circular references via the processingClasses guard set.
+ *
+ * @param clazz the class to introspect (null is a no-op)
+ * @param location typed property path from root
+ * @param instance a live instance of clazz used to extract default values via getter invocation,
+ * or null if no defaults can be extracted (e.g., nested objects without a parent instance)
+ */
+ private void buildClassIR(Class> clazz, Location location, Object instance) throws Exception {
+ if (clazz == null) {
+ return;
+ }
+ String className = clazz.getName();
+
+ // Record usage FIRST (even for circular refs, we want to track all usage contexts)
+ ir.recordUsage(className, location);
+
+ // Circular reference check - if already processing, don't recurse
+ if (processingClasses.contains(className)) {
+ return;
+ }
+
+ processingClasses.add(className);
+
+ SchemaIR.ClassNode classNode = ir.getOrCreateNode(className);
+
+ // Store class-level metadata
+ if (!className.startsWith("java.")) {
+ classNode.getClassMetadata().setJavaClass(className);
+ }
+
+ // Introspect properties
+ BeanInfo beanInfo = Introspector.getBeanInfo(clazz);
+ java.beans.PropertyDescriptor[] descriptors = beanInfo.getPropertyDescriptors();
+ Arrays.sort(descriptors, Comparator.comparing(java.beans.PropertyDescriptor::getName));
+
+ for (java.beans.PropertyDescriptor pd : descriptors) {
+ String propName = pd.getName();
+
+ if (IGNORED_PROPERTIES.contains(propName)) {
+ continue;
+ }
+
+ try {
+ buildPropertyIR(pd, location, instance, classNode);
+ } catch (Exception e) {
+ // Record failure but continue
+ SchemaIR.PropertyNode propNode = classNode.getOrCreateProperty(propName);
+ propNode.setSchemaField("type", new SchemaType(SchemaType.Kind.STRING).toSchemaValue()); // fallback for unknown types
+ propNode.setSchemaField("x-exploration-status", "reflection-failed");
+ propNode.setSchemaField("x-exploration-reason", e.getMessage());
+ }
+ }
+
+ processingClasses.remove(className);
+ }
+
+ /**
+ * Build an IR property node from a single JavaBean property descriptor.
+ * Determines the property's JSON Schema type category (primitive, enum, map,
+ * collection, or nested object) and delegates to the appropriate handler.
+ * Also extracts access mode, default values via getter invocation, and
+ * {@link ConfigProperty} annotation metadata.
+ *
+ * @param pd the bean property descriptor; properties with a null type are skipped
+ * @param parentLocation typed path of the owning class
+ * @param parentInstance live instance of the parent class used to invoke the getter
+ * for default value extraction, or null if unavailable
+ * @param classNode the IR class node to attach the resulting property node to
+ * @throws Exception if recursive introspection of a nested/collection/map value type fails
+ */
+ private void buildPropertyIR(java.beans.PropertyDescriptor pd, Location parentLocation, Object parentInstance, SchemaIR.ClassNode classNode)
+ throws Exception {
+
+ String propName = pd.getName();
+ Location fullLocation = parentLocation.child(propName);
+ String fullPath = fullLocation.toDotted();
+ Class> propType = pd.getPropertyType();
+ Method getter = pd.getReadMethod();
+ // java.beans.Introspector misses fluent setters (non-void return type).
+ // Fall back to scanning the declaring class for a set method directly.
+ Method setter = pd.getWriteMethod() != null ? pd.getWriteMethod()
+ : findFluentSetter(pd.getName(), propType, getter);
+
+ if (propType == null) {
+ return;
+ }
+
+ SchemaIR.PropertyNode propNode = classNode.getOrCreateProperty(propName);
+
+ String access;
+ if (getter != null && setter != null) {
+ access = "RW";
+ } else if (getter != null) {
+ access = "RO";
+ } else if (setter != null) {
+ access = "WO";
+ } else {
+ access = "UNKNOWN";
+ LOG.error("Property {} has neither getter nor setter — this should not happen", fullPath);
+ }
+ propNode.setSchemaField("x-access", access);
+
+ // Process @ConfigProperty annotation if present (highest-priority metadata)
+ applyConfigPropertyAnnotation(getter, setter, propNode);
+
+ // Detect Java @Deprecated on getter or setter
+ if ((getter != null && getter.isAnnotationPresent(Deprecated.class))
+ || (setter != null && setter.isAnnotationPresent(Deprecated.class))) {
+ propNode.setSchemaField("x-deprecated", true);
+ }
+
+ // Handle different property types (no default extraction — defaults are
+ // unreliable from code inspection due to layered XML/template overrides)
+ if (propType.isPrimitive() || isWrapperType(propType) || isStringLikeType(propType)) {
+ handlePrimitiveIR(propType, null, propNode);
+ } else if (propType.isEnum()) {
+ handleEnumIR(propType, null, propNode);
+ } else if (Map.class.isAssignableFrom(propType)) {
+ handleMapIR(getter, setter, fullLocation, propNode);
+ } else if (Collection.class.isAssignableFrom(propType)) {
+ handleCollectionIR(getter, setter, fullLocation, propNode);
+ } else {
+ // Nested object
+ propNode.setTargetClassName(propType.getName());
+ propNode.setPropertyType(SchemaIR.PropertyType.NESTED_OBJECT);
+
+ // Get nested instance for recursive introspection (type discovery only)
+ Object nestedInstance = null;
+ if (parentInstance != null && getter != null) {
+ try {
+ nestedInstance = getter.invoke(parentInstance);
+ } catch (Exception e) {
+ LOG.trace("Could not get nested instance for {}: {}", fullPath, e.getMessage());
+ }
+ }
+ ir.recordUsage(propType.getName(), fullLocation);
+ buildClassIR(propType, fullLocation, nestedInstance);
+ }
+ }
+
+ /**
+ * Map a Java class to its JSON Schema type and store it on the property node.
+ *
+ * @param javaType the Java class from reflection (primitive, wrapper, or string-like)
+ * @param defaultValue unused, retained for interface compatibility
+ * @param propNode target property node whose schema fields will be populated
+ */
+ private void handlePrimitiveIR(Class> javaType, Object defaultValue, SchemaIR.PropertyNode propNode) {
+ propNode.setPropertyType(SchemaIR.PropertyType.PRIMITIVE);
+ propNode.setSchemaField("type", javaTypeToSchemaType(javaType).toSchemaValue());
+ }
+
+ private static SchemaType javaTypeToSchemaType(Class> javaType) {
+ if (javaType == boolean.class || javaType == Boolean.class) {
+ return new SchemaType(SchemaType.Kind.BOOLEAN);
+ }
+ if (javaType == int.class || javaType == Integer.class ||
+ javaType == long.class || javaType == Long.class ||
+ javaType == short.class || javaType == Short.class ||
+ javaType == byte.class || javaType == Byte.class) {
+ return new SchemaType(SchemaType.Kind.INTEGER);
+ }
+ if (javaType == double.class || javaType == Double.class ||
+ javaType == float.class || javaType == Float.class) {
+ return new SchemaType(SchemaType.Kind.NUMBER);
+ }
+ return new SchemaType(SchemaType.Kind.STRING);
+ }
+
+ /**
+ * Map a Java enum type to a JSON Schema string with an {@code enum} constraint
+ * listing every declared constant.
+ *
+ * @param enumType the enum class; its constants are serialised via {@code toString()}
+ * @param defaultValue unused, retained for interface compatibility
+ * @param propNode target property node whose schema fields will be populated
+ */
+ private void handleEnumIR(Class> enumType, Object defaultValue, SchemaIR.PropertyNode propNode) {
+ propNode.setPropertyType(SchemaIR.PropertyType.ENUM);
+ propNode.setSchemaField("type", new SchemaType(SchemaType.Kind.STRING).toSchemaValue());
+
+ Object[] constants = enumType.getEnumConstants();
+ if (constants != null) {
+ List enumValues = new ArrayList<>();
+ for (Object constant : constants) {
+ enumValues.add(constant.toString());
+ }
+ propNode.setSchemaField("enum", enumValues);
+ } else {
+ LOG.error("Class {} is marked as enum but has no constants", enumType.getName());
+ }
+
+ }
+
+ /**
+ * Build IR for a {@code Map}-typed property. The map key is always assumed to
+ * be {@code String}. The value type determines the schema shape:
+ *
+ * Primitive/enum/Object values → {@code additionalProperties} with
+ * the corresponding inline schema
+ * Complex object values → a target class reference plus recursive
+ * introspection, with factory-variant detection for polymorphic maps
+ * (e.g., acceptors/connectors)
+ *
+ *
+ * @param getter read method for the property, used to resolve generic type args;
+ * may be null if the property is write-only
+ * @param setter write method, used as fallback for generic type resolution;
+ * may be null if the property is read-only
+ * @param fullLocation typed path of this property from the config root
+ * @param propNode target property node whose schema fields will be populated
+ * @throws Exception if recursive introspection of a complex value type fails
+ */
+ private void handleMapIR(Method getter, Method setter, Location fullLocation, SchemaIR.PropertyNode propNode)
+ throws Exception {
+ propNode.setSchemaField("type", new SchemaType(SchemaType.Kind.OBJECT).toSchemaValue());
+
+ Type genericType = getter != null ? getter.getGenericReturnType() :
+ (setter != null ? setter.getGenericParameterTypes()[0] : null);
+
+ if (genericType instanceof ParameterizedType) {
+ ParameterizedType paramType = (ParameterizedType) genericType;
+ Type[] typeArgs = paramType.getActualTypeArguments();
+
+ if (typeArgs.length < 2) {
+ LOG.error("Map at {} has {} type arguments (expected 2)", fullLocation, typeArgs.length);
+ } else {
+ Type valueType = typeArgs[1];
+ Class> valueClass = extractClass(valueType);
+
+ if (valueClass == null) {
+ LOG.error("Map at {} has generic value type {} that could not be resolved to a class",
+ fullLocation, valueType);
+ } else {
+ // V is Object → type-erased, reflection cannot determine actual content.
+ // Default to string; enrichment extractors (XSD, Constants, factory params)
+ // refine individual properties to their real types in later phases.
+ if (valueClass == Object.class) {
+ propNode.setSchemaField("additionalProperties", false);
+
+ // V is a primitive/wrapper/String → Simple scalars.
+ } else if (valueClass.isPrimitive() || isWrapperType(valueClass) || isStringLikeType(valueClass)) {
+ SchemaIR.PropertyNode tempNode = new SchemaIR.PropertyNode("temp");
+ handlePrimitiveIR(valueClass, null, tempNode);
+ propNode.setSchemaField("additionalProperties", tempNode.getSchema());
+
+ // V is an enum → Same as primitive but with an enum constraint.
+ } else if (valueClass.isEnum()) {
+ SchemaIR.PropertyNode tempNode = new SchemaIR.PropertyNode("temp");
+ handleEnumIR(valueClass, null, tempNode);
+ propNode.setSchemaField("additionalProperties", tempNode.getSchema());
+
+ // V is a nested container (Collection or Map) → unwrap recursively.
+ // Each layer adds one additionalProperties level in the emitted schema,
+ // matching broker.properties: prop......=value
+ } else if ((Collection.class.isAssignableFrom(valueClass)
+ || Map.class.isAssignableFrom(valueClass))
+ && valueType instanceof ParameterizedType) {
+
+ int nestingDepth = 0;
+ Type currentType = valueType;
+ Class> currentClass = valueClass;
+
+ while (currentClass != null && currentType instanceof ParameterizedType) {
+ ParameterizedType pt = (ParameterizedType) currentType;
+ if (Collection.class.isAssignableFrom(currentClass)) {
+ currentType = pt.getActualTypeArguments()[0];
+ currentClass = extractClass(currentType);
+ nestingDepth++;
+ } else if (Map.class.isAssignableFrom(currentClass)) {
+ currentType = pt.getActualTypeArguments()[1];
+ currentClass = extractClass(currentType);
+ nestingDepth++;
+ } else {
+ break;
+ }
+ }
+
+ if (currentClass != null && !currentClass.isPrimitive()
+ && !isWrapperType(currentClass) && !isStringLikeType(currentClass)
+ && !currentClass.isEnum() && currentClass != Object.class) {
+ propNode.setTargetClassName(currentClass.getName());
+ propNode.setPropertyType(SchemaIR.PropertyType.MAP_COLLECTION_VALUE);
+ propNode.setCollectionNestingDepth(nestingDepth);
+ propNode.setLocation(fullLocation);
+ Location wildcardLocation = fullLocation.wildcard();
+ for (int i = 0; i < nestingDepth; i++) {
+ wildcardLocation = wildcardLocation.wildcard();
+ }
+ ir.recordUsage(currentClass.getName(), wildcardLocation);
+ buildClassIR(currentClass, wildcardLocation, null);
+ } else {
+ LOG.warn("Map with nested containers at {} — leaf type {} is scalar, "
+ + "defaulting to string additionalProperties",
+ fullLocation, currentClass);
+ Map valueSchema = new LinkedHashMap<>();
+ valueSchema.put("type", new SchemaType(SchemaType.Kind.STRING).toSchemaValue());
+ propNode.setSchemaField("additionalProperties", valueSchema);
+ }
+
+ // V is a complex class → The map values have their own properties
+ // (like TransportConfiguration has factoryClassName, params, name).
+ // We recurse into that class and build its IR. If it's TransportConfiguration
+ // specifically, we also detect factory polymorphism (Netty vs InVM → oneOf).
+ } else {
+ propNode.setTargetClassName(valueClass.getName());
+ propNode.setPropertyType(SchemaIR.PropertyType.MAP_VALUE);
+ propNode.setLocation(fullLocation);
+ Location wildcardLocation = fullLocation.wildcard();
+ ir.recordUsage(valueClass.getName(), wildcardLocation);
+ buildClassIR(valueClass, wildcardLocation, null);
+
+ // Class-based polymorphism: if the value type is an interface or
+ // abstract class with concrete subclasses, discover them so the
+ // emitter can produce oneOf with $refs.
+ List> subclasses = polymorphismResolver.findSubclasses(valueClass);
+ if (!subclasses.isEmpty()) {
+ SchemaIR.ClassNode baseNode = ir.getOrCreateNode(valueClass.getName());
+ for (Class> subclass : subclasses) {
+ ir.recordUsage(subclass.getName(), wildcardLocation);
+ buildClassIR(subclass, wildcardLocation, null);
+ baseNode.addSubclass(subclass.getName());
+ SchemaIR.ClassNode subNode = ir.getOrCreateNode(subclass.getName());
+ subNode.setSuperclass(valueClass.getName());
+ }
+ }
+ }
+ }
+ }
+ } else {
+ // Raw Map without generic type parameters — cannot determine value type.
+ LOG.warn("Map property at {} has no generic type info, schema will be untyped", fullLocation);
+ propNode.setPropertyType(SchemaIR.PropertyType.MAP_VALUE);
+ }
+ }
+
+ /**
+ * Build IR for a {@code Collection}-typed property. The element type drives
+ * the schema shape:
+ *
+ * Primitive/enum/Object elements → {@code array} with {@code items}
+ * Complex object elements → {@code object} with
+ * {@code additionalProperties} (broker.properties flat-key convention),
+ * plus subclass discovery for polymorphic hierarchies
+ *
+ *
+ * @param getter read method for the property, used to resolve generic type args;
+ * may be null if the property is write-only
+ * @param setter write method, used as fallback for generic type resolution;
+ * may be null if the property is read-only
+ * @param fullLocation typed path of this property from the config root
+ * @param propNode target property node whose schema fields will be populated
+ * @throws Exception if recursive introspection of a complex element type fails
+ */
+ private void handleCollectionIR(Method getter, Method setter, Location fullLocation, SchemaIR.PropertyNode propNode)
+ throws Exception {
+ Type genericType = getter != null ? getter.getGenericReturnType() :
+ (setter != null ? setter.getGenericParameterTypes()[0] : null);
+
+ if (genericType instanceof ParameterizedType) {
+ ParameterizedType paramType = (ParameterizedType) genericType;
+ Type[] typeArgs = paramType.getActualTypeArguments();
+
+ if (typeArgs.length < 1) {
+ LOG.error("Collection at {} has no type arguments", fullLocation);
+ return;
+ }
+
+ Type elementType = typeArgs[0];
+ Class> elementClass = extractClass(elementType);
+
+ if (elementClass == null) {
+ LOG.error("Collection at {} has element type {} that could not be resolved",
+ fullLocation, elementType);
+ return;
+ }
+
+ boolean isComplexObject = !elementClass.isPrimitive() &&
+ !isWrapperType(elementClass) &&
+ !isStringLikeType(elementClass) &&
+ !elementClass.isEnum() &&
+ elementClass != Object.class;
+
+ // Collections of complex objects use object/additionalProperties in the schema.
+ // In broker.properties: propName.0.nestedField=value (indexed access).
+ if (isComplexObject) {
+ propNode.setSchemaField("type", new SchemaType(SchemaType.Kind.OBJECT).toSchemaValue());
+ propNode.setTargetClassName(elementClass.getName());
+ propNode.setPropertyType(SchemaIR.PropertyType.COLLECTION_ELEMENT);
+ propNode.setLocation(fullLocation);
+ Location wildcardLocation = fullLocation.wildcard();
+ ir.recordUsage(elementClass.getName(), wildcardLocation);
+ buildClassIR(elementClass, wildcardLocation, null);
+
+ // Class-based polymorphism: if the element type has concrete subclasses
+ // (e.g., AMQPBrokerConnectionElement → Bridge, Mirror, Federated...),
+ // discover them and build IR so the emitter can produce oneOf with $refs.
+ List> subclasses = polymorphismResolver.findSubclasses(elementClass);
+ if (subclasses.isEmpty()) {
+ // No subtypes found — element class is used directly (not polymorphic).
+ LOG.debug("No subclasses for {} at {}", elementClass.getSimpleName(), fullLocation);
+ } else {
+ SchemaIR.ClassNode baseNode = ir.getOrCreateNode(elementClass.getName());
+ for (Class> subclass : subclasses) {
+ ir.recordUsage(subclass.getName(), wildcardLocation);
+ buildClassIR(subclass, wildcardLocation, null);
+ baseNode.addSubclass(subclass.getName());
+ SchemaIR.ClassNode subNode = ir.getOrCreateNode(subclass.getName());
+ subNode.setSuperclass(elementClass.getName());
+ }
+ }
+
+
+ // Collections of scalars/enums use the standard JSON Schema array/items format.
+ // In broker.properties: propName[0]=value (simple list).
+ } else {
+ propNode.setSchemaField("type", new SchemaType(SchemaType.Kind.ARRAY).toSchemaValue());
+ SchemaIR.PropertyNode tempNode = new SchemaIR.PropertyNode("temp");
+
+ if (elementClass == Object.class) {
+ tempNode.setSchemaField("type", new SchemaType(SchemaType.Kind.STRING).toSchemaValue());
+ } else if (elementClass.isPrimitive() || isWrapperType(elementClass) || isStringLikeType(elementClass)) {
+ handlePrimitiveIR(elementClass, null, tempNode);
+ } else if (elementClass.isEnum()) {
+ handleEnumIR(elementClass, null, tempNode);
+ } else {
+ LOG.error("Collection element at {} has unexpected scalar type: {}",
+ fullLocation, elementClass.getName());
+ }
+
+ propNode.setSchemaField("items", tempNode.getSchema());
+ }
+ }
+ }
+
+
+ /**
+ * Extract metadata from a {@link ConfigProperty} annotation on the getter or
+ * setter and apply it to the property node. Setter annotations take precedence.
+ * If neither method carries the annotation, this is a no-op.
+ *
+ * @param getter read method, or null if the property is write-only
+ * @param setter write method, or null if the property is read-only
+ * @param propNode target property node to enrich with annotation-derived fields
+ * (description, deprecated, hot-reloadable, min/max)
+ */
+ private void applyConfigPropertyAnnotation(Method getter, Method setter, SchemaIR.PropertyNode propNode) {
+ // Look for @ConfigProperty on setter first (more specific), then getter.
+ // Most properties won't have this annotation — it's opt-in for explicit metadata.
+ ConfigProperty annotation = null;
+ if (setter != null) {
+ annotation = setter.getAnnotation(ConfigProperty.class);
+ }
+ if (annotation == null && getter != null) {
+ annotation = getter.getAnnotation(ConfigProperty.class);
+ }
+ if (annotation == null) {
+ return;
+ }
+
+ // Each annotation field is applied only if it differs from its "unset" default.
+ // This allows partial annotation: you can set just description without touching min/max.
+ if (!annotation.description().isEmpty()) {
+ propNode.setSchemaField("description", annotation.description());
+ }
+ if (annotation.deprecated()) {
+ propNode.setSchemaField("x-deprecated", true);
+ }
+ if (annotation.hotReloadable()) {
+ propNode.setSchemaField("x-hot-reloadable", true);
+ }
+ if (annotation.min() != Long.MIN_VALUE) {
+ propNode.setSchemaField("minimum", annotation.min());
+ }
+ if (annotation.max() != Long.MAX_VALUE) {
+ propNode.setSchemaField("maximum", annotation.max());
+ }
+ }
+
+ /**
+ * Lazily instantiate and cache a {@link ConfigurationImpl} used to read
+ * default property values via getter invocation. If instantiation fails,
+ * returns null and the generator proceeds without defaults.
+ *
+ * Runtime dependencies for successful instantiation:
+ *
+ * artemis-server (ConfigurationImpl itself)
+ * artemis-core-client (TransportConfiguration, QueueConfiguration, etc.)
+ * commons-beanutils (used by ConfigurationImpl.populateWithProperties)
+ * SLF4J (logging inside ConfigurationImpl constructor)
+ *
+ *
+ * If any of these are missing from the classpath, instantiation will throw
+ * and the schema will be generated without default values (types and structure
+ * are still correct, only "default" fields will be absent).
+ *
+ * @return a live {@link Configuration} instance, or null if construction failed
+ */
+ private Configuration getConfigInstance() {
+ if (configInstance == null) {
+ try {
+ configInstance = new ConfigurationImpl();
+ LOG.debug("ConfigurationImpl instantiated for default value extraction");
+ } catch (Throwable e) {
+ throw new IllegalStateException(
+ "Cannot instantiate ConfigurationImpl. Ensure artemis-server, " +
+ "artemis-core-client, and commons-beanutils are on the classpath.", e);
+ }
+ }
+ return configInstance;
+ }
+
+ /**
+ * True if the type is a boxed primitive (Boolean, Integer, Long, etc.).
+ * These map to JSON Schema primitive types the same way their unboxed counterparts do.
+ */
+ private boolean isWrapperType(Class> type) {
+ return type == Boolean.class || type == Integer.class || type == Long.class ||
+ type == Double.class || type == Float.class || type == Short.class ||
+ type == Byte.class || type == Character.class;
+ }
+
+ /**
+ * True if the type should be represented as a JSON Schema "string".
+ * Includes String, CharSequence, and path/URL types that serialize to string
+ * in broker.properties.
+ */
+ /**
+ * Find a fluent setter (returns non-void) that java.beans.Introspector misses.
+ * Scans the declaring class of the getter for a method named set that
+ * accepts the property type as its single parameter.
+ */
+ private Method findFluentSetter(String propName, Class> propType, Method getter) {
+ if (getter == null || propType == null) {
+ return null;
+ }
+ String setterName = "set" + Character.toUpperCase(propName.charAt(0)) + propName.substring(1);
+ Class> declaringClass = getter.getDeclaringClass();
+ try {
+ return declaringClass.getMethod(setterName, propType);
+ } catch (NoSuchMethodException e) {
+ // Also try with primitive/wrapper variants
+ Class> alt = propType.isPrimitive() ? toWrapper(propType) : toPrimitive(propType);
+ if (alt != null) {
+ try {
+ return declaringClass.getMethod(setterName, alt);
+ } catch (NoSuchMethodException e2) {
+ // no setter found
+ }
+ }
+ }
+ return null;
+ }
+
+ private static Class> toWrapper(Class> primitive) {
+ if (primitive == boolean.class) return Boolean.class;
+ if (primitive == int.class) return Integer.class;
+ if (primitive == long.class) return Long.class;
+ if (primitive == double.class) return Double.class;
+ if (primitive == float.class) return Float.class;
+ if (primitive == short.class) return Short.class;
+ if (primitive == byte.class) return Byte.class;
+ return null;
+ }
+
+ private static Class> toPrimitive(Class> wrapper) {
+ if (wrapper == Boolean.class) return boolean.class;
+ if (wrapper == Integer.class) return int.class;
+ if (wrapper == Long.class) return long.class;
+ if (wrapper == Double.class) return double.class;
+ if (wrapper == Float.class) return float.class;
+ if (wrapper == Short.class) return short.class;
+ if (wrapper == Byte.class) return byte.class;
+ return null;
+ }
+
+ private boolean isStringLikeType(Class> type) {
+ return type == String.class || type == CharSequence.class ||
+ type.getName().equals("java.io.File") ||
+ type.getName().equals("java.net.URL") ||
+ type.getName().equals("java.net.URI") ||
+ type.getName().equals("org.apache.activemq.artemis.api.core.SimpleString");
+ }
+
+ /**
+ * Resolve a {@link Type} to a raw {@link Class}, unwrapping parameterised types.
+ *
+ * @param type the type to resolve (may be a Class, ParameterizedType, or other)
+ * @return the raw class, or {@code null} if resolution fails (e.g. TypeVariable, WildcardType)
+ */
+ private Class> extractClass(Type type) {
+ if (type instanceof Class) {
+ return (Class>) type;
+ } else if (type instanceof ParameterizedType) {
+ return extractClass(((ParameterizedType) type).getRawType());
+ }
+ return null;
+ }
+
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/Location.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/Location.java
new file mode 100644
index 00000000000..8a78c2436bc
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/Location.java
@@ -0,0 +1,150 @@
+/*
+ * 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.artemis.jsonschema.ir;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Immutable representation of a property path in the schema IR.
+ *
+ * This is the identity key that links a property across all pipeline phases: IR building,
+ * enrichment storage, and emission. Using a typed class instead of raw strings prevents malformed
+ * paths from typos and makes the path-building API self-documenting.
+ *
+ *
Examples:
+ *
+ *
+ * {@code Location.root()} → ""
+ * {@code Location.root().child("acceptorConfigurations")} → "acceptorConfigurations"
+ * {@code location.wildcard()} → "acceptorConfigurations.*"
+ * {@code location.child("params").child("host")} → "acceptorConfigurations.*.params.host"
+ *
+ */
+public final class Location {
+
+ private static final Location ROOT = new Location(Collections.emptyList());
+
+ private final List segments;
+
+ private Location(List segments) {
+ this.segments = segments;
+ }
+
+ /**
+ * The root location (empty path, represents ConfigurationImpl itself).
+ *
+ * @return the singleton root location
+ */
+ public static Location root() {
+ return ROOT;
+ }
+
+ /**
+ * Parse a dotted string into a Location. Used at boundaries with extractor-produced paths.
+ *
+ * @param dottedPath dot-separated path string, or null/empty for root
+ * @return parsed location, or root if the input is null or empty
+ */
+ public static Location of(String dottedPath) {
+ if (dottedPath == null || dottedPath.isEmpty()) {
+ return ROOT;
+ }
+ return new Location(Arrays.asList(dottedPath.split("\\.")));
+ }
+
+ /**
+ * Derive a child location by appending a property name.
+ *
+ * @param name property name segment to append
+ * @return new location with the segment appended
+ */
+ public Location child(String name) {
+ List newSegments = new ArrayList<>(segments.size() + 1);
+ newSegments.addAll(segments);
+ newSegments.add(name);
+ return new Location(newSegments);
+ }
+
+ /**
+ * Derive a child location from a PropertyNode's name.
+ *
+ * @param prop property node whose name becomes the new segment
+ * @return new location with the property name appended
+ */
+ public Location child(SchemaIR.PropertyNode prop) {
+ return child(prop.getName());
+ }
+
+ /**
+ * Append a wildcard segment (represents user-defined map/collection keys).
+ *
+ * @return new location with a {@code *} segment appended
+ */
+ public Location wildcard() {
+ return child("*");
+ }
+
+ /**
+ * The last segment of the path (property name at this level).
+ *
+ * @return leaf segment, or empty string if this is the root location
+ */
+ public String leafName() {
+ return segments.isEmpty() ? "" : segments.get(segments.size() - 1);
+ }
+
+ /**
+ * True if this is the root location (empty path).
+ *
+ * @return {@code true} if this location has no segments
+ */
+ public boolean isEmpty() {
+ return segments.isEmpty();
+ }
+
+ /**
+ * Convert to the dot-separated string used as enrichment key and log output.
+ *
+ * @return dot-joined path string, or empty string for root
+ */
+ public String toDotted() {
+ return String.join(".", segments);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Location location = (Location) o;
+ return segments.equals(location.segments);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(segments);
+ }
+
+ @Override
+ public String toString() {
+ return toDotted();
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/PolymorphismResolver.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/PolymorphismResolver.java
new file mode 100644
index 00000000000..de118739809
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/PolymorphismResolver.java
@@ -0,0 +1,93 @@
+/*
+ * 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.artemis.jsonschema.ir;
+
+import java.lang.reflect.Modifier;
+import java.util.*;
+import org.apache.artemis.jsonschema.annotation.Heuristic;
+
+/**
+ * Discovers concrete subclasses of abstract config classes via Reflections classpath scanning. Used
+ * during IR construction to detect class-based polymorphism (e.g., AMQPBrokerConnectionElement →
+ * Bridge, Mirror, Federated variants).
+ */
+public class PolymorphismResolver {
+
+ private final SchemaIR ir;
+
+ /**
+ * @param ir the IR graph that will receive discovered polymorphic class registrations
+ */
+ public PolymorphismResolver(SchemaIR ir) {
+ this.ir = ir;
+ }
+
+ /**
+ * Discover concrete subclasses of a base class via classpath scanning. Only scans classes under
+ * org.apache.activemq.artemis.core.config to avoid expensive full-classpath scans for JDK/library
+ * types encountered during traversal.
+ *
+ * @param baseClass the abstract/interface class to find subtypes of
+ * @return sorted list of concrete subclasses, or empty if none found or class is outside scope
+ */
+ @Heuristic("Assumes polymorphic config classes live under core.config package")
+ public List> findSubclasses(Class> baseClass) {
+ if (!baseClass.getName().startsWith("org.apache.activemq.artemis.core.config")) {
+ return new ArrayList<>();
+ }
+
+ try {
+ String packageName = baseClass.getPackage().getName();
+ org.reflections.Reflections reflections =
+ new org.reflections.Reflections(
+ new org.reflections.util.ConfigurationBuilder()
+ .forPackages(packageName)
+ .setScanners(org.reflections.scanners.Scanners.SubTypes));
+
+ Set extends Class>> subtypes = getSubTypes(reflections, baseClass);
+
+ List> subclasses = new ArrayList<>();
+ for (Class> subtype : subtypes) {
+ if (!subtype.equals(baseClass)
+ && !Modifier.isAbstract(subtype.getModifiers())
+ && !subtype.isInterface()) {
+ subclasses.add(subtype);
+ }
+ }
+
+ subclasses.sort(Comparator.comparing(Class::getSimpleName));
+ return subclasses;
+ } catch (Exception e) {
+ return new ArrayList<>();
+ }
+ }
+
+ /**
+ * Bridge for Reflections generic erasure — unavoidable cast isolated here.
+ *
+ * @param reflections configured Reflections instance
+ * @param baseClass base class to query subtypes for
+ * @param base type parameter
+ * @return set of discovered subtypes
+ */
+ @SuppressWarnings("unchecked")
+ private static Set extends Class>> getSubTypes(
+ org.reflections.Reflections reflections, Class baseClass) {
+ return (Set extends Class>>) (Set>) reflections.getSubTypesOf(baseClass);
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/PropertyDescriptor.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/PropertyDescriptor.java
new file mode 100644
index 00000000000..74920a8dcd1
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/PropertyDescriptor.java
@@ -0,0 +1,173 @@
+/*
+ * 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.artemis.jsonschema.ir;
+
+import java.util.LinkedHashSet;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Unified property descriptor representing a single broker configuration property.
+ *
+ * Aggregates metadata from multiple extraction sources (reflection, XSD, JavaDoc, etc.) for a
+ * single property path in the broker.properties format.
+ */
+public class PropertyDescriptor {
+ /** Property path in broker.properties format (e.g., "acceptorConfigurations.*.params.host") */
+ private Location path;
+
+ /** Aggregated metadata from all sources */
+ private PropertyMetadata metadata;
+
+ /** Which extractors contributed to this property (insertion order preserved) */
+ private Set sources;
+
+ /** Enrichment pattern if this was enriched (e.g., "acceptorConfigurations.*") */
+ private String enrichmentPattern;
+
+ /** Construct an empty descriptor with no path or sources. */
+ public PropertyDescriptor() {
+ this.metadata = new PropertyMetadata();
+ this.sources = new LinkedHashSet<>();
+ }
+
+ /**
+ * Construct a descriptor for the given dotted property path.
+ *
+ * @param path dot-separated property path (e.g. "acceptorConfigurations.*.params.host")
+ */
+ public PropertyDescriptor(String path) {
+ this();
+ this.path = Location.of(path);
+ }
+
+ /**
+ * Construct a descriptor for the given path, attributed to a single extraction source.
+ *
+ * @param path dot-separated property path
+ * @param source the extractor that produced this descriptor
+ */
+ public PropertyDescriptor(String path, PropertySource source) {
+ this(path);
+ this.sources.add(source);
+ }
+
+ public Location getPath() {
+ return path;
+ }
+
+ public void setPath(String path) {
+ this.path = Location.of(path);
+ }
+
+ public PropertyMetadata getMetadata() {
+ return metadata;
+ }
+
+ public void setMetadata(PropertyMetadata metadata) {
+ this.metadata = metadata;
+ }
+
+ public Set getSources() {
+ return sources;
+ }
+
+ public void setSources(Set sources) {
+ this.sources = sources;
+ }
+
+ public void addSource(PropertySource source) {
+ this.sources.add(source);
+ }
+
+ public String getEnrichmentPattern() {
+ return enrichmentPattern;
+ }
+
+ public void setEnrichmentPattern(String enrichmentPattern) {
+ this.enrichmentPattern = enrichmentPattern;
+ }
+
+ /**
+ * Merge another descriptor into this one. Combines sources and merges metadata according to
+ * precedence rules.
+ *
+ * @param other descriptor to merge; must share the same path
+ * @throws IllegalArgumentException if paths differ
+ */
+ public void merge(PropertyDescriptor other) {
+ if (!this.path.equals(other.path)) {
+ throw new IllegalArgumentException(
+ "Cannot merge descriptors with different paths: " + this.path + " vs " + other.path);
+ }
+
+ // Merge sources
+ this.sources.addAll(other.sources);
+
+ // Merge metadata - determine which source has precedence
+ PropertySource thisPrimary = getPrimarySource(this.sources);
+ PropertySource otherPrimary = getPrimarySource(other.sources);
+ this.metadata.merge(other.metadata, otherPrimary, thisPrimary);
+
+ // Enrichment pattern: take first non-null
+ if (other.enrichmentPattern != null && this.enrichmentPattern == null) {
+ this.enrichmentPattern = other.enrichmentPattern;
+ }
+ }
+
+ /**
+ * Determine the primary source for precedence rules. Order: REFLECTION > XSD > XML_PARSER >
+ * ENRICHMENT > METADATA
+ *
+ * @param sources set of sources to evaluate
+ * @return the highest-priority source present in the set
+ */
+ private PropertySource getPrimarySource(Set sources) {
+ if (sources.contains(PropertySource.REFLECTION)) return PropertySource.REFLECTION;
+ if (sources.contains(PropertySource.XSD)) return PropertySource.XSD;
+ if (sources.contains(PropertySource.XML_PARSER)) return PropertySource.XML_PARSER;
+ if (sources.contains(PropertySource.ENRICHMENT)) return PropertySource.ENRICHMENT;
+ return PropertySource.METADATA;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ PropertyDescriptor that = (PropertyDescriptor) o;
+ return Objects.equals(path, that.path);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(path);
+ }
+
+ @Override
+ public String toString() {
+ return "PropertyDescriptor{"
+ + "path='"
+ + path
+ + '\''
+ + ", sources="
+ + sources
+ + ", metadata="
+ + metadata
+ + '}';
+ }
+}
diff --git a/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/PropertyMetadata.java b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/PropertyMetadata.java
new file mode 100644
index 00000000000..ae85d3e96ff
--- /dev/null
+++ b/artemis-jsonschema/src/main/java/org/apache/artemis/jsonschema/ir/PropertyMetadata.java
@@ -0,0 +1,444 @@
+/*
+ * 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.artemis.jsonschema.ir;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Transport format carrying all known facts about a single broker configuration property.
+ *
+ * Extractors populate instances with whatever they can discover; the pipeline then {@linkplain
+ * #merge merges} them according to source-priority rules so that the best value for each field
+ * survives into the final schema.
+ */
+public class PropertyMetadata {
+ /** JSON Schema type — single or union. */
+ private SchemaType type;
+
+ /**
+ * Compile-time or runtime default; stored as the natural Java type (String, Long, Boolean, …).
+ */
+ private Object defaultValue;
+
+ /** Human-readable description emitted into the schema's {@code description} keyword. */
+ private String description;
+
+ /** Access mode: {@code "RW"}, {@code "R"}, or {@code "W"}. */
+ private String access;
+
+ /** Allowed values for enum-typed properties; {@code null} when unconstrained. */
+ private List enumValues;
+
+ /** Lower bound (inclusive); must be a {@link Number} subtype or {@code null}. */
+ private Object minimum;
+
+ /** Upper bound (inclusive); must be a {@link Number} subtype or {@code null}. */
+ private Object maximum;
+
+ private Boolean required;
+ private List exampleValues;
+
+ /** Parallel to {@link #exampleValues} — records which extractor contributed each example. */
+ private List exampleSources;
+
+ private Boolean deprecated;
+
+ /** Factories that support this parameter (e.g. {@code ["NettyAcceptorFactory"]}). */
+ private List factorySpecific;
+
+ /** Fully-qualified Java class for object-typed properties. */
+ private String javaClass;
+
+ /** Whether the property can be changed at runtime without a broker restart. */
+ private Boolean hotReloadable;
+
+ /** Whether the broker has explicit validation logic for this property. */
+ private Boolean validated;
+
+ /** Regex pattern for input validation (e.g. byte notation). */
+ private String pattern;
+
+ /** Name of the runtime converter method (e.g. "ByteUtil.convertTextBytes"). */
+ private String converter;
+
+ public PropertyMetadata() {}
+
+ public SchemaType getType() {
+ return type;
+ }
+
+ public void setType(SchemaType type) {
+ this.type = type;
+ }
+
+ public Object getDefaultValue() {
+ return defaultValue;
+ }
+
+ public void setDefaultValue(Object defaultValue) {
+ this.defaultValue = defaultValue;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ /**
+ * Set the human-readable description; rejects blank strings as extractor bugs.
+ *
+ * @param description description text, or {@code null} to mean "unknown"
+ * @throws IllegalArgumentException if {@code description} is non-null but blank
+ */
+ public void setDescription(String description) {
+ if (description != null && description.trim().isEmpty()) {
+ throw new IllegalArgumentException("Description cannot be empty string");
+ }
+ this.description = description;
+ }
+
+ public String getAccess() {
+ return access;
+ }
+
+ public void setAccess(String access) {
+ this.access = access;
+ }
+
+ /**
+ * Returns an unmodifiable view, or {@code null} if unset.
+ *
+ * @return immutable enum value list, or {@code null}
+ */
+ public List getEnumValues() {
+ return enumValues == null ? null : Collections.unmodifiableList(enumValues);
+ }
+
+ /**
+ * Defensively copies the list; callers may freely mutate the original after this call.
+ *
+ * @param enumValues allowed values, or {@code null} to clear
+ */
+ public void setEnumValues(List enumValues) {
+ if (enumValues != null) {
+ this.enumValues = new ArrayList<>(enumValues);
+ } else {
+ this.enumValues = null;
+ }
+ }
+
+ public Object getMinimum() {
+ return minimum;
+ }
+
+ /**
+ * Set the lower bound constraint for numeric properties.
+ *
+ * @param minimum a {@link Number} subtype, or {@code null} for unconstrained
+ * @throws IllegalArgumentException if {@code minimum} is non-null and not a {@link Number}
+ */
+ public void setMinimum(Object minimum) {
+ if (minimum != null && !(minimum instanceof Number)) {
+ throw new IllegalArgumentException(
+ "Minimum must be Number, got: " + minimum.getClass().getSimpleName());
+ }
+ this.minimum = minimum;
+ }
+
+ public Object getMaximum() {
+ return maximum;
+ }
+
+ /**
+ * Set the upper bound constraint for numeric properties.
+ *
+ * @param maximum a {@link Number} subtype, or {@code null} for unconstrained
+ * @throws IllegalArgumentException if {@code maximum} is non-null and not a {@link Number}
+ */
+ public void setMaximum(Object maximum) {
+ if (maximum != null && !(maximum instanceof Number)) {
+ throw new IllegalArgumentException(
+ "Maximum must be Number, got: " + maximum.getClass().getSimpleName());
+ }
+ this.maximum = maximum;
+ }
+
+ public Boolean getRequired() {
+ return required;
+ }
+
+ public void setRequired(Boolean required) {
+ this.required = required;
+ }
+
+ /**
+ * Returns an unmodifiable view, or {@code null} if unset.
+ *
+ * @return immutable example values list, or {@code null}
+ */
+ public List getExampleValues() {
+ return exampleValues == null ? null : Collections.unmodifiableList(exampleValues);
+ }
+
+ /**
+ * Defensively copies the list; callers may freely mutate the original after this call.
+ *
+ * @param exampleValues example values, or {@code null} to clear
+ */
+ public void setExampleValues(List exampleValues) {
+ if (exampleValues != null) {
+ this.exampleValues = new ArrayList<>(exampleValues);
+ } else {
+ this.exampleValues = null;
+ }
+ }
+
+ /**
+ * Returns an unmodifiable view, or {@code null} if unset.
+ *
+ * @return immutable example source list, or {@code null}
+ */
+ public List