This is an automated email from the ASF dual-hosted git repository.

opwvhk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/avro.git


The following commit(s) were added to refs/heads/main by this push:
     new 362aef8a0 AVRO-3677: Add SchemaFormatter (#2885)
362aef8a0 is described below

commit 362aef8a07bc17969601a4ff2cbf60ef7488d13c
Author: Oscar Westra van Holthe - Kind <[email protected]>
AuthorDate: Mon May 6 11:31:21 2024 +0200

    AVRO-3677: Add SchemaFormatter (#2885)
    
    * AVRO-3677: Introduce Named Schema Formatters
    
    Adds a SchemaFormatter interface and factory method to format schemas to
    different formats by name. The initial implementation supports JSON
    (both inline and pretty printed), the parsing canonical form, and the IDL
    format.
---
 doc/themes/docsy                                   |   2 +-
 lang/java/avro/pom.xml                             |   3 +
 .../avro/CanonicalSchemaFormatterFactory.java      |  37 ++
 .../java/org/apache/avro/JsonSchemaFormatter.java  |  33 ++
 .../apache/avro/JsonSchemaFormatterFactory.java    |  40 ++
 .../avro/src/main/java/org/apache/avro/Schema.java |  24 +-
 .../main/java/org/apache/avro/SchemaFormatter.java | 127 +++++
 .../org/apache/avro/SchemaFormatterFactory.java    | 106 ++++
 .../org.apache.avro.SchemaFormatterFactory         |  19 +
 .../java/org/apache/avro/SchemaFormatterTest.java  |  88 ++++
 .../org/apache/avro/idl/IdlSchemaFormatter.java    |  35 ++
 .../apache/avro/idl/IdlSchemaFormatterFactory.java |  28 ++
 .../java/org/apache/avro/idl/IdlSchemaParser.java  |  18 +-
 .../main/java/org/apache/avro/util/IdlUtils.java   | 542 +++++++++++++++++++++
 .../org.apache.avro.SchemaFormatterFactory         |  18 +
 .../avro/idl/IdlSchemaFormatterFactoryTest.java    |  61 +++
 .../java/org/apache/avro/util/IdlUtilsTest.java    | 195 ++++++++
 .../apache/avro/util/idl_utils_test_protocol.avdl  |  45 ++
 .../apache/avro/util/idl_utils_test_schema.avdl    |  35 ++
 19 files changed, 1449 insertions(+), 7 deletions(-)

diff --git a/doc/themes/docsy b/doc/themes/docsy
index 7dc708374..a77761a6d 160000
--- a/doc/themes/docsy
+++ b/doc/themes/docsy
@@ -1 +1 @@
-Subproject commit 7dc70837461881b639215e40d90b6502974e3a14
+Subproject commit a77761a6de8c5d4bb284dab5d0b47447883eb6d2
diff --git a/lang/java/avro/pom.xml b/lang/java/avro/pom.xml
index c4770a927..9d5b40d69 100644
--- a/lang/java/avro/pom.xml
+++ b/lang/java/avro/pom.xml
@@ -54,6 +54,9 @@
           <include>org/apache/avro/data/Json.avsc</include>
         </includes>
       </resource>
+      <resource>
+        <directory>src/main/resources</directory>
+      </resource>
     </resources>
     <testResources>
       <testResource>
diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/CanonicalSchemaFormatterFactory.java
 
b/lang/java/avro/src/main/java/org/apache/avro/CanonicalSchemaFormatterFactory.java
new file mode 100644
index 000000000..8ddec8155
--- /dev/null
+++ 
b/lang/java/avro/src/main/java/org/apache/avro/CanonicalSchemaFormatterFactory.java
@@ -0,0 +1,37 @@
+/*
+ * 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
+ *
+ *     https://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.avro;
+
+/**
+ * Schema formatter factory that supports the "Parsing Canonical Form".
+ *
+ * @see <a href=
+ *      
"https://avro.apache.org/docs/1.11.1/specification/#parsing-canonical-form-for-schemas";>Specification:
+ *      Parsing Canonical Form for Schemas</a>
+ */
+public class CanonicalSchemaFormatterFactory implements 
SchemaFormatterFactory, SchemaFormatter {
+  @Override
+  public SchemaFormatter getDefaultFormatter() {
+    return this;
+  }
+
+  @Override
+  public String format(Schema schema) {
+    return SchemaNormalization.toParsingForm(schema);
+  }
+}
diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/JsonSchemaFormatter.java 
b/lang/java/avro/src/main/java/org/apache/avro/JsonSchemaFormatter.java
new file mode 100644
index 000000000..5d3726586
--- /dev/null
+++ b/lang/java/avro/src/main/java/org/apache/avro/JsonSchemaFormatter.java
@@ -0,0 +1,33 @@
+/*
+ * 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
+ *
+ *     https://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.avro;
+
+public class JsonSchemaFormatter implements SchemaFormatter {
+  private final boolean prettyPrinted;
+
+  public JsonSchemaFormatter(boolean prettyPrinted) {
+    this.prettyPrinted = prettyPrinted;
+  }
+
+  @Override
+  public String format(Schema schema) {
+    // TODO: Move the toString implementation here and have Schema#toString()
+    // use SchemaFormatter with the formats "json/pretty" and "json/inline"
+    return schema.toString(prettyPrinted);
+  }
+}
diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/JsonSchemaFormatterFactory.java 
b/lang/java/avro/src/main/java/org/apache/avro/JsonSchemaFormatterFactory.java
new file mode 100644
index 000000000..915a671eb
--- /dev/null
+++ 
b/lang/java/avro/src/main/java/org/apache/avro/JsonSchemaFormatterFactory.java
@@ -0,0 +1,40 @@
+/*
+ * 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
+ *
+ *     https://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.avro;
+
+public class JsonSchemaFormatterFactory implements SchemaFormatterFactory {
+
+  public static final String VARIANT_NAME_PRETTY = "pretty";
+  public static final String VARIANT_NAME_INLINE = "inline";
+
+  @Override
+  public SchemaFormatter getDefaultFormatter() {
+    return getFormatterForVariant(VARIANT_NAME_PRETTY);
+  }
+
+  @Override
+  public SchemaFormatter getFormatterForVariant(String variantName) {
+    if (VARIANT_NAME_PRETTY.equals(variantName)) {
+      return new JsonSchemaFormatter(true);
+    } else if (VARIANT_NAME_INLINE.equals(variantName)) {
+      return new JsonSchemaFormatter(false);
+    } else {
+      throw new AvroRuntimeException("Unknown JSON variant: " + variantName);
+    }
+  }
+}
diff --git a/lang/java/avro/src/main/java/org/apache/avro/Schema.java 
b/lang/java/avro/src/main/java/org/apache/avro/Schema.java
index e38e31d40..3120cfd54 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/Schema.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/Schema.java
@@ -393,7 +393,16 @@ public abstract class Schema extends JsonProperties 
implements Serializable {
     throw new AvroRuntimeException("Not fixed: " + this);
   }
 
-  /** Render this as <a href="https://json.org/";>JSON</a>. */
+  /**
+   * <p>
+   * Render this as <a href="https://json.org/";>JSON</a>.
+   * </p>
+   *
+   * <p>
+   * This method is equivalent to:
+   * {@code SchemaFormatter.getInstance("json").format(this)}
+   * </p>
+   */
   @Override
   public String toString() {
     return toString(false);
@@ -403,7 +412,10 @@ public abstract class Schema extends JsonProperties 
implements Serializable {
    * Render this as <a href="https://json.org/";>JSON</a>.
    *
    * @param pretty if true, pretty-print JSON.
+   * @deprecated Use {@link SchemaFormatter#format(Schema)} instead, using the
+   *             format {@code json/pretty} or {@code json/inline}
    */
+  @Deprecated
   public String toString(boolean pretty) {
     return toString(new HashSet<String>(), pretty);
   }
@@ -427,6 +439,7 @@ public abstract class Schema extends JsonProperties 
implements Serializable {
     return toString(knownNames, pretty);
   }
 
+  @Deprecated
   String toString(Set<String> knownNames, boolean pretty) {
     try {
       StringWriter writer = new StringWriter();
@@ -441,6 +454,7 @@ public abstract class Schema extends JsonProperties 
implements Serializable {
     }
   }
 
+  @Deprecated
   void toJson(Set<String> knownNames, String namespace, JsonGenerator gen) 
throws IOException {
     if (!hasProps()) { // no props defined
       gen.writeString(getName()); // just write name
@@ -452,6 +466,7 @@ public abstract class Schema extends JsonProperties 
implements Serializable {
     }
   }
 
+  @Deprecated
   void fieldsToJson(Set<String> knownNames, String namespace, JsonGenerator 
gen) throws IOException {
     throw new AvroRuntimeException("Not a record: " + this);
   }
@@ -1012,6 +1027,7 @@ public abstract class Schema extends JsonProperties 
implements Serializable {
     }
 
     @Override
+    @Deprecated
     void toJson(Set<String> knownNames, String currentNamespace, JsonGenerator 
gen) throws IOException {
       if (writeNameRef(knownNames, currentNamespace, gen))
         return;
@@ -1033,6 +1049,7 @@ public abstract class Schema extends JsonProperties 
implements Serializable {
     }
 
     @Override
+    @Deprecated
     void fieldsToJson(Set<String> knownNames, String namespace, JsonGenerator 
gen) throws IOException {
       gen.writeStartArray();
       for (Field f : fields) {
@@ -1120,6 +1137,7 @@ public abstract class Schema extends JsonProperties 
implements Serializable {
     }
 
     @Override
+    @Deprecated
     void toJson(Set<String> knownNames, String currentNamespace, JsonGenerator 
gen) throws IOException {
       if (writeNameRef(knownNames, currentNamespace, gen))
         return;
@@ -1169,6 +1187,7 @@ public abstract class Schema extends JsonProperties 
implements Serializable {
     }
 
     @Override
+    @Deprecated
     void toJson(Set<String> knownNames, String namespace, JsonGenerator gen) 
throws IOException {
       gen.writeStartObject();
       gen.writeStringField("type", "array");
@@ -1208,6 +1227,7 @@ public abstract class Schema extends JsonProperties 
implements Serializable {
     }
 
     @Override
+    @Deprecated
     void toJson(Set<String> knownNames, String currentNamespace, JsonGenerator 
gen) throws IOException {
       gen.writeStartObject();
       gen.writeStringField("type", "map");
@@ -1285,6 +1305,7 @@ public abstract class Schema extends JsonProperties 
implements Serializable {
     }
 
     @Override
+    @Deprecated
     void toJson(Set<String> knownNames, String currentNamespace, JsonGenerator 
gen) throws IOException {
       gen.writeStartArray();
       for (Schema type : types)
@@ -1329,6 +1350,7 @@ public abstract class Schema extends JsonProperties 
implements Serializable {
     }
 
     @Override
+    @Deprecated
     void toJson(Set<String> knownNames, String currentNamespace, JsonGenerator 
gen) throws IOException {
       if (writeNameRef(knownNames, currentNamespace, gen))
         return;
diff --git a/lang/java/avro/src/main/java/org/apache/avro/SchemaFormatter.java 
b/lang/java/avro/src/main/java/org/apache/avro/SchemaFormatter.java
new file mode 100644
index 000000000..6303b01fb
--- /dev/null
+++ b/lang/java/avro/src/main/java/org/apache/avro/SchemaFormatter.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     https://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.avro;
+
+import java.util.Locale;
+import java.util.ServiceLoader;
+
+/**
+ * Interface and factory to format schemas to text.
+ *
+ * <p>
+ * Schema formats have a name, and optionally a variant (all lowercase). The
+ * Avro library supports a few formats out of the box:
+ * </p>
+ *
+ * <dl>
+ *
+ * <dt>{@code json}</dt>
+ * <dd>Classic schema definition (which is a form of JSON). Supports the
+ * variants {@code pretty} (the default) and {@code inline}. Can be written as
+ * .avsc files. See the specification (<a href=
+ * 
"https://avro.apache.org/docs/current/specification/#schema-declaration";>Schema
+ * Declaration</a>) for more details.</dd>
+ *
+ * <dt>{@code canonical}</dt>
+ * <dd>Parsing Canonical Form; this uniquely defines how Avro data is written.
+ * Used to generate schema fingerprints.<br/>
+ * See the specification (<a href=
+ * 
"https://avro.apache.org/docs/current/specification/#parsing-canonical-form-for-schemas";>Parsing
+ * Canonical Form for Schemas</a>) for more details.</dd>
+ *
+ * <dt>{@code idl}</dt>
+ * <dd>IDL: a format that looks much like source code, and is arguably easier 
to
+ * read than JSON. Available when the module {@code avro-idl} is on the
+ * classpath. See
+ * <a href="https://avro.apache.org/docs/current/idl-language/";>IDL 
Language</a>
+ * for more details.</dd>
+ *
+ * </dl>
+ *
+ * <p>
+ * Additional formats can be defined by implementing
+ * {@link SchemaFormatterFactory}.
+ * </p>
+ *
+ * @see <a href=
+ *      
"https://avro.apache.org/docs/current/specification/#schema-declaration";>Specification:
+ *      Schema Declaration</a>
+ * @see <a href=
+ *      
"https://avro.apache.org/docs/current/specification/#parsing-canonical-form-for-schemas";>Specification:
+ *      Parsing Canonical Form for Schemas</a>
+ * @see <a href="https://avro.apache.org/docs/current/idl-language/";>IDL
+ *      Language</a>
+ */
+public interface SchemaFormatter {
+  /**
+   * Get the schema formatter for the specified format name with optional 
variant.
+   *
+   * @param name a format with optional variant, for example "json/pretty",
+   *             "canonical" or "idl"
+   * @return the schema formatter for the specified format
+   * @throws AvroRuntimeException if the schema format is not supported
+   */
+  static SchemaFormatter getInstance(String name) {
+    int slashPos = name.indexOf("/");
+    // SchemaFormatterFactory.getFormatterForVariant(String) receives the name 
of
+    // the variant in lowercase (as stated in its javadoc). We're doing a
+    // case-insensitive comparison on the format name instead, so we don't 
have to
+    // convert the format name provided by the factory to lower case.
+    // This ensures the least amount of assumptions about implementations.
+    String formatName = slashPos < 0 ? name : name.substring(0, slashPos);
+    String variantName = slashPos < 0 ? null : name.substring(slashPos + 
1).toLowerCase(Locale.ROOT);
+
+    for (SchemaFormatterFactory formatterFactory : 
SchemaFormatterCache.LOADER) {
+      if (formatName.equalsIgnoreCase(formatterFactory.formatName())) {
+        if (variantName == null) {
+          return formatterFactory.getDefaultFormatter();
+        } else {
+          return formatterFactory.getFormatterForVariant(variantName);
+        }
+      }
+    }
+    throw new AvroRuntimeException("Unsupported schema format: " + name + "; 
see the javadoc for valid examples");
+  }
+
+  /**
+   * Format a schema with the specified format. Shorthand for
+   * {@code getInstance(name).format(schema)}.
+   *
+   * @param name   the name of the schema format
+   * @param schema the schema to format
+   * @return the formatted schema
+   * @throws AvroRuntimeException if the schema format is not supported
+   * @see #getInstance(String)
+   * @see #format(Schema)
+   */
+  static String format(String name, Schema schema) {
+    return getInstance(name).format(schema);
+  }
+
+  /**
+   * Write the specified schema as a String.
+   *
+   * @param schema the schema to write
+   * @return the formatted schema
+   */
+  String format(Schema schema);
+}
+
+class SchemaFormatterCache {
+  static final ServiceLoader<SchemaFormatterFactory> LOADER = 
ServiceLoader.load(SchemaFormatterFactory.class);
+}
diff --git 
a/lang/java/avro/src/main/java/org/apache/avro/SchemaFormatterFactory.java 
b/lang/java/avro/src/main/java/org/apache/avro/SchemaFormatterFactory.java
new file mode 100644
index 000000000..be731a86d
--- /dev/null
+++ b/lang/java/avro/src/main/java/org/apache/avro/SchemaFormatterFactory.java
@@ -0,0 +1,106 @@
+/*
+ * 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
+ *
+ *     https://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.avro;
+
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Service Provider Interface (SPI) for {@link SchemaFormatter}.
+ *
+ * <p>
+ * Notes to implementers:
+ * </p>
+ *
+ * <ul>
+ *
+ * <li>Implementations are located using a {@link java.util.ServiceLoader}. See
+ * that class for details.</li>
+ *
+ * <li>Implementing classes should either be named
+ * {@code <format>SchemaFormatterFactory} (where the format is alphanumeric), 
or
+ * implement {@link #formatName()}.</li>
+ *
+ * <li>Implement at least {@link #getDefaultFormatter()}; use it to call
+ * {@link #getFormatterForVariant(String)} if the format supports multiple
+ * variants.</li>
+ *
+ * <li>Example implementations are {@link JsonSchemaFormatterFactory} and
+ * {@link CanonicalSchemaFormatterFactory}</li>
+ *
+ * </ul>
+ *
+ * @see java.util.ServiceLoader
+ */
+public interface SchemaFormatterFactory {
+  /**
+   * Return the name of the format this formatter factory supports.
+   *
+   * <p>
+   * The default implementation returns the lowercase prefix of the 
implementing
+   * class if it is named {@code <format>SchemaFormatterFactory}. That is, if 
the
+   * implementing class is named {@code 
some.package.JsonSchemaFormatterFactory},
+   * it returns: {@literal "json"}
+   * </p>
+   *
+   * @return the name of the format
+   */
+  default String formatName() {
+    String simpleName = getClass().getSimpleName();
+    Matcher matcher = 
SchemaFormatterFactoryConstants.SIMPLE_NAME_PATTERN.matcher(simpleName);
+    if (matcher.matches()) {
+      return matcher.group(1).toLowerCase(Locale.ROOT);
+    } else {
+      throw new AvroRuntimeException(
+          "Formatter is not named \"<format>SchemaFormatterFactory\"; cannot 
determine format name.");
+    }
+  }
+
+  /**
+   * Get the default formatter for this schema format. Instances should be
+   * thread-safe, as they may be cached.
+   *
+   * <p>
+   * Implementations should either return the only formatter for this format, 
or
+   * call {@link #getFormatterForVariant(String)} with the default variant and
+   * implement that method as well.
+   * </p>
+   *
+   * @return the default formatter for this schema format
+   */
+  SchemaFormatter getDefaultFormatter();
+
+  /**
+   * Get a formatter for the specified schema format variant, if multiple 
variants
+   * are supported. Instances should be thread-safe, as they may be cached.
+   *
+   * @param variantName the name of the format variant (lower case), if 
specified
+   * @return if the factory supports the format, a schema writer; {@code null}
+   *         otherwise
+   */
+  default SchemaFormatter getFormatterForVariant(String variantName) {
+    throw new AvroRuntimeException("The schema format \"" + formatName() + "\" 
has no variants.");
+  }
+}
+
+class SchemaFormatterFactoryConstants {
+  static final Pattern SIMPLE_NAME_PATTERN = Pattern.compile(
+      "([a-z][0-9a-z]*)" + SchemaFormatterFactory.class.getSimpleName(),
+      Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
+}
diff --git 
a/lang/java/avro/src/main/resources/META-INF/services/org.apache.avro.SchemaFormatterFactory
 
b/lang/java/avro/src/main/resources/META-INF/services/org.apache.avro.SchemaFormatterFactory
new file mode 100644
index 000000000..06f140bde
--- /dev/null
+++ 
b/lang/java/avro/src/main/resources/META-INF/services/org.apache.avro.SchemaFormatterFactory
@@ -0,0 +1,19 @@
+#
+# 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
+#
+#     https://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.
+#
+org.apache.avro.JsonSchemaFormatterFactory
+org.apache.avro.CanonicalSchemaFormatterFactory
diff --git 
a/lang/java/avro/src/test/java/org/apache/avro/SchemaFormatterTest.java 
b/lang/java/avro/src/test/java/org/apache/avro/SchemaFormatterTest.java
new file mode 100644
index 000000000..00b76e28b
--- /dev/null
+++ b/lang/java/avro/src/test/java/org/apache/avro/SchemaFormatterTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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
+ *
+ *     https://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.avro;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SchemaFormatterTest {
+
+  @Test
+  void validateDefaultNaming() {
+    assertEquals("json", new JsonSchemaFormatterFactory().formatName());
+    assertThrows(AvroRuntimeException.class, () -> new 
Wrongly_Named_SchemaFormatterFactory().formatName());
+    assertThrows(AvroRuntimeException.class, () -> new 
SchemaFormatterFactoryWithOddName().formatName());
+  }
+
+  @Test
+  void validateJsonFormatDefaultsToPrettyPrinting() {
+    Schema schema = Schema.createFixed("ns.Fixed", null, null, 16);
+    assertEquals(SchemaFormatter.format("json", schema), 
SchemaFormatter.format("json/pretty", schema));
+  }
+
+  @Test
+  void validateSupportForPrettyJsonFormat() {
+    Schema schema = Schema.createFixed("ns.Fixed", null, null, 16);
+    assertEquals("{\n  \"type\" : \"fixed\",\n  \"name\" : \"Fixed\",\n  
\"namespace\" : \"ns\",\n  \"size\" : 16\n}",
+        SchemaFormatter.format("json/pretty", schema));
+  }
+
+  @Test
+  void validateSupportForInlineJsonFormat() {
+    Schema schema = Schema.createFixed("ns.Fixed", null, null, 16);
+    
assertEquals("{\"type\":\"fixed\",\"name\":\"Fixed\",\"namespace\":\"ns\",\"size\":16}",
+        SchemaFormatter.format("json/inline", schema));
+  }
+
+  @Test
+  void checkThatJsonHasNoExtraVariant() {
+    assertThrows(AvroRuntimeException.class, () -> 
SchemaFormatter.getInstance("json/extra"));
+  }
+
+  @Test
+  void validateSupportForCanonicalFormat() {
+    Schema schema = Schema.createFixed("Fixed", "Another test", "ns", 16);
+    assertEquals("{\"name\":\"ns.Fixed\",\"type\":\"fixed\",\"size\":16}", 
SchemaFormatter.format("canonical", schema));
+  }
+
+  @Test
+  void checkThatCanonicalFormHasNoVariants() {
+    assertThrows(AvroRuntimeException.class, () -> 
SchemaFormatter.getInstance("canonical/foo"));
+  }
+
+  @Test
+  void checkExceptionForMissingFormat() {
+    assertThrows(AvroRuntimeException.class, () -> 
SchemaFormatter.getInstance("unknown"));
+  }
+
+  private static class Wrongly_Named_SchemaFormatterFactory implements 
SchemaFormatterFactory {
+
+    @Override
+    public SchemaFormatter getDefaultFormatter() {
+      return null;
+    }
+  }
+
+  private static class SchemaFormatterFactoryWithOddName implements 
SchemaFormatterFactory {
+    @Override
+    public SchemaFormatter getDefaultFormatter() {
+      return null;
+    }
+  }
+}
diff --git 
a/lang/java/idl/src/main/java/org/apache/avro/idl/IdlSchemaFormatter.java 
b/lang/java/idl/src/main/java/org/apache/avro/idl/IdlSchemaFormatter.java
new file mode 100644
index 000000000..c9115cf3c
--- /dev/null
+++ b/lang/java/idl/src/main/java/org/apache/avro/idl/IdlSchemaFormatter.java
@@ -0,0 +1,35 @@
+/*
+ * 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
+ *
+ *     https://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.avro.idl;
+
+import java.io.StringWriter;
+
+import org.apache.avro.Schema;
+import org.apache.avro.SchemaFormatter;
+import org.apache.avro.util.IdlUtils;
+
+public class IdlSchemaFormatter implements SchemaFormatter {
+  @Override
+  public String format(Schema schema) {
+    return IdlUtils.uncheckExceptions(() -> {
+      StringWriter buffer = new StringWriter();
+      IdlUtils.writeIdlSchema(buffer, schema);
+      return buffer.toString();
+    });
+  }
+}
diff --git 
a/lang/java/idl/src/main/java/org/apache/avro/idl/IdlSchemaFormatterFactory.java
 
b/lang/java/idl/src/main/java/org/apache/avro/idl/IdlSchemaFormatterFactory.java
new file mode 100644
index 000000000..acbbcf947
--- /dev/null
+++ 
b/lang/java/idl/src/main/java/org/apache/avro/idl/IdlSchemaFormatterFactory.java
@@ -0,0 +1,28 @@
+/*
+ * 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
+ *
+ *     https://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.avro.idl;
+
+import org.apache.avro.SchemaFormatter;
+import org.apache.avro.SchemaFormatterFactory;
+
+public class IdlSchemaFormatterFactory implements SchemaFormatterFactory {
+  @Override
+  public SchemaFormatter getDefaultFormatter() {
+    return new IdlSchemaFormatter();
+  }
+}
diff --git 
a/lang/java/idl/src/main/java/org/apache/avro/idl/IdlSchemaParser.java 
b/lang/java/idl/src/main/java/org/apache/avro/idl/IdlSchemaParser.java
index 618ac6254..63e22096b 100644
--- a/lang/java/idl/src/main/java/org/apache/avro/idl/IdlSchemaParser.java
+++ b/lang/java/idl/src/main/java/org/apache/avro/idl/IdlSchemaParser.java
@@ -27,15 +27,23 @@ import java.net.URI;
 import java.util.regex.Pattern;
 
 public class IdlSchemaParser implements FormattedSchemaParser {
+  /**
+   * Pattern to check if the input can be IDL. It matches initial whitespace 
and
+   * comments, plus all possible starting keywords. The match on the start of 
the
+   * input as well as the use of possessive quantifiers is deliberate: it 
should
+   * fail as fast as possible, as it is assumed that most schemata will not be 
in
+   * IDL format.
+   */
+  private static final Pattern START_OF_IDL_PATTERN = Pattern.compile("\\A" + 
// Start of input
+      "(?:\\s*+|/\\*(?:[^*]|\\*(?!/))*+\\*/|//(?:(?!\\R).)*+\\R)*+" + // 
Initial whitespace & comments
+      "(?:@|(?:namespace|schema|protocol|record|enum|fixed|import)\\s)", // 
First keyword or @
+      Pattern.UNICODE_CHARACTER_CLASS | Pattern.MULTILINE);
 
   @Override
   public Schema parse(ParseContext parseContext, URI baseUri, CharSequence 
formattedSchema)
       throws IOException, SchemaParseException {
-    boolean valid = Pattern.compile("^\\A*!" + // Initial whitespace
-        "(?:/\\*(?:[^*]|\\*[^/])*!\\*/\\s*!|//(!=\\R)*!\\R\\s*!)*!" + // 
Comments
-        "(?:namespace|schema|protocol|record|enum|fixed|import)\\s", // First 
keyword
-        Pattern.UNICODE_CHARACTER_CLASS | 
Pattern.MULTILINE).matcher(formattedSchema).find();
-    if (valid) {
+    boolean inputCanBeIdl = 
START_OF_IDL_PATTERN.matcher(formattedSchema).find();
+    if (inputCanBeIdl) {
       IdlReader idlReader = new IdlReader(parseContext);
       IdlFile idlFile = idlReader.parse(baseUri, formattedSchema);
       return idlFile.getMainSchema();
diff --git a/lang/java/idl/src/main/java/org/apache/avro/util/IdlUtils.java 
b/lang/java/idl/src/main/java/org/apache/avro/util/IdlUtils.java
new file mode 100644
index 000000000..480f3a03c
--- /dev/null
+++ b/lang/java/idl/src/main/java/org/apache/avro/util/IdlUtils.java
@@ -0,0 +1,542 @@
+/*
+ * 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
+ *
+ *     https://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.avro.util;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.util.MinimalPrettyPrinter;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+import org.apache.avro.AvroRuntimeException;
+import org.apache.avro.JsonProperties;
+import org.apache.avro.LogicalTypes;
+import org.apache.avro.Protocol;
+import org.apache.avro.Schema;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singleton;
+import static java.util.Collections.singletonList;
+import static java.util.Collections.unmodifiableSet;
+import static java.util.Objects.requireNonNull;
+
+public final class IdlUtils {
+  static final ObjectMapper MAPPER;
+  private static final Function<Schema.Field, JsonNode> DEFAULT_VALUE;
+  private static final Pattern NEWLINE_PATTERN = Pattern.compile("(?U)\\R");
+  private static final String NEWLINE = System.lineSeparator();
+  private static final Set<String> KEYWORDS = unmodifiableSet(new HashSet<>(
+      asList("array", "boolean", "bytes", "date", "decimal", "double", "enum", 
"error", "false", "fixed", "float",
+          "idl", "import", "int", "local_timestamp_ms", "long", "map", 
"namespace", "null", "oneway", "protocol",
+          "record", "schema", "string", "throws", "timestamp_ms", "time_ms", 
"true", "union", "uuid", "void")));
+  private static final EnumSet<Schema.Type> NULLABLE_TYPES = EnumSet
+      .complementOf(EnumSet.of(Schema.Type.ARRAY, Schema.Type.MAP, 
Schema.Type.UNION));
+
+  static {
+    SimpleModule module = new SimpleModule();
+    module.addSerializer(new 
StdSerializer<JsonProperties.Null>(JsonProperties.Null.class) {
+      @Override
+      public void serialize(JsonProperties.Null value, JsonGenerator gen, 
SerializerProvider provider)
+          throws IOException {
+        gen.writeNull();
+      }
+    });
+    module.addSerializer(new StdSerializer<byte[]>(byte[].class) {
+      @Override
+      public void serialize(byte[] value, JsonGenerator gen, 
SerializerProvider provider) throws IOException {
+        MAPPER.writeValueAsString(new String(value, 
StandardCharsets.ISO_8859_1));
+      }
+    });
+
+    ObjectMapper jsonMapper = getFieldValue(getField(Schema.class, "MAPPER"), 
null);
+    MAPPER = 
jsonMapper.copy().registerModule(module).disable(DeserializationFeature.READ_ENUMS_USING_TO_STRING)
+        .disable(SerializationFeature.WRITE_ENUMS_USING_INDEX, 
SerializationFeature.WRITE_ENUMS_USING_TO_STRING)
+        
.enable(SerializationFeature.INDENT_OUTPUT).setDefaultPrettyPrinter(new 
MinimalPrettyPrinter() {
+          @Override
+          public void writeObjectEntrySeparator(JsonGenerator jg) throws 
IOException {
+            jg.writeRaw(',');
+            jg.writeRaw(' ');
+          }
+
+          @Override
+          public void writeArrayValueSeparator(JsonGenerator jg) throws 
IOException {
+            jg.writeRaw(',');
+            jg.writeRaw(' ');
+          }
+        });
+
+    java.lang.reflect.Field defaultValueField = getField(Schema.Field.class, 
"defaultValue");
+    DEFAULT_VALUE = field -> getFieldValue(defaultValueField, field);
+  }
+
+  static java.lang.reflect.Field getField(Class<?> type, String name) {
+    try {
+      java.lang.reflect.Field field = type.getDeclaredField(name);
+      field.setAccessible(true);
+      return field;
+    } catch (NoSuchFieldException e) {
+      throw new IllegalStateException("Programmer error", e);
+    }
+  }
+
+  static <T> T getFieldValue(java.lang.reflect.Field field, Object owner) {
+    try {
+      return (T) field.get(owner);
+    } catch (IllegalAccessException e) {
+      throw new IllegalStateException("Programmer error", e);
+    }
+  }
+
+  private IdlUtils() {
+    // Utility class: do not instantiate.
+  }
+
+  /**
+   * Calls the given callable, wrapping any checked exception in an
+   * {@link AvroRuntimeException}.
+   *
+   * @param callable the callable to call
+   * @return the result of the callable
+   */
+  public static <T> T uncheckExceptions(Callable<T> callable) {
+    try {
+      return requireNonNull(callable).call();
+    } catch (RuntimeException e) {
+      throw e;
+    } catch (Throwable e) {
+      throw new AvroRuntimeException(e.getMessage(), e);
+    }
+  }
+
+  public static void writeIdlSchema(Writer writer, Schema schema) throws 
IOException {
+    writeIdlSchemas(writer, schema.getNamespace(), singleton(schema));
+  }
+
+  public static void writeIdlSchemas(Writer writer, String namespace, 
Collection<Schema> schemas) throws IOException {
+    if (schemas.isEmpty()) {
+      throw new IllegalArgumentException("Cannot write 0 schemas");
+    }
+    if (namespace != null) {
+      writer.append("namespace ");
+      writer.append(namespace);
+      writer.append(";");
+      writer.append(NEWLINE);
+      writer.append(NEWLINE);
+    }
+
+    Set<String> alreadyDeclared = new HashSet<>(4);
+    Set<Schema> toDeclare = new LinkedHashSet<>();
+    if (schemas.size() == 1) {
+      Schema schema = schemas.iterator().next();
+      writer.append("schema ");
+      // Note: as alreadyDeclared is empty, writeFieldSchema adds schema to 
toDeclare
+      writeFieldSchema(schema, writer, alreadyDeclared, toDeclare, namespace);
+      writer.append(";");
+      writer.append(NEWLINE);
+      writer.append(NEWLINE);
+    } else {
+      toDeclare.addAll(schemas);
+    }
+
+    while (!toDeclare.isEmpty()) {
+      if (!alreadyDeclared.isEmpty()) {
+        writer.append(NEWLINE);
+      }
+      Iterator<Schema> iterator = toDeclare.iterator();
+      Schema s = iterator.next();
+      iterator.remove();
+      writeSchema(s, false, writer, namespace, alreadyDeclared, toDeclare);
+    }
+  }
+
+  public static void writeIdlProtocol(Writer writer, Protocol protocol) throws 
IOException {
+    final String protocolFullName = protocol.getName();
+    final int lastDotPos = protocolFullName.lastIndexOf(".");
+    final String protocolNameSpace;
+    if (lastDotPos < 0) {
+      protocolNameSpace = protocol.getNamespace();
+    } else if (lastDotPos > 0) {
+      protocolNameSpace = protocolFullName.substring(0, lastDotPos);
+    } else {
+      protocolNameSpace = null;
+    }
+    writeIdlProtocol(writer, protocol, protocolNameSpace, 
protocolFullName.substring(lastDotPos + 1),
+        protocol.getTypes(), protocol.getMessages().values());
+  }
+
+  public static void writeIdlProtocol(Writer writer, Schema schema) throws 
IOException {
+    final JsonProperties emptyProperties = Schema.create(Schema.Type.NULL);
+    writeIdlProtocol(writer, emptyProperties, schema.getNamespace(), 
"Protocol", singletonList(schema), emptyList());
+  }
+
+  public static void writeIdlProtocol(Writer writer, JsonProperties 
properties, String protocolNameSpace,
+      String protocolName, Collection<Schema> schemas, 
Collection<Protocol.Message> messages) throws IOException {
+    if (protocolNameSpace != null) {
+      
writer.append("@namespace(\"").append(protocolNameSpace).append("\")").append(NEWLINE);
+    }
+    writeJsonProperties(properties, singleton("namespace"), writer, "");
+    writer.append("protocol 
").append(requireNonNull(safeName(protocolName))).append(" {").append(NEWLINE);
+
+    Set<String> alreadyDeclared = new HashSet<>(4);
+    Set<Schema> toDeclare = new LinkedHashSet<>(schemas);
+    boolean first = true;
+    while (!toDeclare.isEmpty()) {
+      if (!first) {
+        writer.append(NEWLINE);
+      }
+      Iterator<Schema> iterator = toDeclare.iterator();
+      Schema schema = iterator.next();
+      iterator.remove();
+      writeSchema(schema, true, writer, protocolNameSpace, alreadyDeclared, 
toDeclare);
+      first = false;
+    }
+    if (!schemas.isEmpty() && !messages.isEmpty()) {
+      writer.append(NEWLINE);
+    }
+    for (Protocol.Message message : messages) {
+      writeMessage(message, writer, protocolNameSpace, alreadyDeclared);
+    }
+    writer.append("}").append(NEWLINE);
+  }
+
+  private static String safeName(String name) {
+    if (KEYWORDS.contains(name)) {
+      return String.format("`%s`", name);
+    }
+    return name;
+  }
+
+  private static void writeSchema(Schema schema, boolean insideProtocol, 
Writer writer, String defaultNamespace,
+      Set<String> alreadyDeclared, Set<Schema> toDeclare) throws IOException {
+    String indent = insideProtocol ? "    " : "";
+    Schema.Type type = schema.getType();
+    writeSchemaAttributes(indent, schema, writer);
+    String namespace = schema.getNamespace(); // Fails for unnamed schema 
types (other types than record, enum & fixed)
+    if (!Objects.equals(namespace, defaultNamespace)) {
+      
writer.append(indent).append("@namespace(\"").append(namespace).append("\")").append(NEWLINE);
+    }
+    Set<String> schemaAliases = schema.getAliases();
+    if (!schemaAliases.isEmpty()) {
+      
writer.append(indent).append("@aliases(").append(MAPPER.writeValueAsString(schemaAliases)).append(")")
+          .append(NEWLINE);
+    }
+    String schemaName = safeName(schema.getName());
+    if (type == Schema.Type.RECORD) {
+      String declarationType = schema.isError() ? "error" : "record";
+      writer.append(indent).append("").append(declarationType).append(" 
").append(schemaName).append(" {")
+          .append(NEWLINE);
+      alreadyDeclared.add(schema.getFullName());
+      for (Schema.Field field : schema.getFields()) {
+        writeField(schema.getNamespace(), field, writer, alreadyDeclared, 
toDeclare,
+            insideProtocol ? FieldIndent.INSIDE_PROTOCOL : 
FieldIndent.TOPLEVEL_SCHEMA);
+        writer.append(";").append(NEWLINE);
+      }
+      writer.append(indent).append("}").append(NEWLINE);
+    } else if (type == Schema.Type.ENUM) {
+      writer.append(indent).append("enum ").append(schemaName).append(" 
{").append(NEWLINE);
+      alreadyDeclared.add(schema.getFullName());
+      Iterator<String> i = schema.getEnumSymbols().iterator();
+      if (i.hasNext()) {
+        writer.append(indent).append("    ").append(i.next());
+        while (i.hasNext()) {
+          writer.append(", ");
+          writer.append(i.next());
+        }
+      } else {
+        throw new AvroRuntimeException("Enum schema must have at least a 
symbol " + schema);
+      }
+      writer.append(NEWLINE).append(indent).append("}").append(NEWLINE);
+    } else /* (type == Schema.Type.FIXED) */ {
+      writer.append(indent).append("fixed ").append(schemaName).append('(')
+          
.append(Integer.toString(schema.getFixedSize())).append(");").append(NEWLINE);
+      alreadyDeclared.add(schema.getFullName());
+    }
+  }
+
+  private static void writeField(String namespace, Schema.Field field, Writer 
writer, Set<String> alreadyDeclared,
+      Set<Schema> toDeclare, FieldIndent fieldIndent) throws IOException {
+    // Note: indentField must not be NONE if any field of the containing
+    // record/method has documentation
+    switch (fieldIndent) {
+    case TOPLEVEL_SCHEMA:
+      writeDocumentation(writer, "    ", field.doc());
+      writer.append("    ");
+      break;
+    case INSIDE_PROTOCOL:
+      writeDocumentation(writer, "        ", field.doc());
+      writer.append("        ");
+      break;
+    }
+    writeFieldSchema(field.schema(), writer, alreadyDeclared, toDeclare, 
namespace);
+    writer.append(' ');
+    Set<String> fieldAliases = field.aliases();
+    if (!fieldAliases.isEmpty()) {
+      
writer.append("@aliases(").append(MAPPER.writeValueAsString(fieldAliases)).append(")
 ");
+    }
+    Schema.Field.Order order = field.order();
+    if (order != Schema.Field.Order.ASCENDING) {
+      writer.append("@order(\"").append(order.name()).append("\") ");
+    }
+    writeJsonProperties(field, writer, null);
+    writer.append(field.name());
+    JsonNode defaultValue = DEFAULT_VALUE.apply(field);
+    if (defaultValue != null) {
+      Object datum = field.defaultVal();
+      writer.append(" = ").append(MAPPER.writeValueAsString(datum));
+    }
+  }
+
+  private static void writeDocumentation(Writer writer, String indent, String 
doc) throws IOException {
+    if (doc == null || doc.trim().isEmpty()) {
+      return;
+    }
+    writer.append(formatDocumentationComment(indent, doc));
+  }
+
+  private static String formatDocumentationComment(String indent, String doc) {
+    assert !doc.trim().isEmpty() : "There must be documentation to format!";
+
+    StringBuffer buffer = new StringBuffer();
+    buffer.append(indent).append("/** ");
+    boolean foundMatch = false;
+    final Matcher matcher = NEWLINE_PATTERN.matcher(doc);
+    final String newlinePlusIndent = NEWLINE + indent + " * ";
+    while (matcher.find()) {
+      if (!foundMatch) {
+        buffer.append(newlinePlusIndent);
+        foundMatch = true;
+      }
+      matcher.appendReplacement(buffer, newlinePlusIndent);
+    }
+    if (foundMatch) {
+      matcher.appendTail(buffer);
+      buffer.append(NEWLINE).append(indent).append(" */").append(NEWLINE);
+    } else {
+      buffer.append(doc).append(" */").append(NEWLINE);
+    }
+    return buffer.toString();
+  }
+
+  private static void writeFieldSchema(Schema schema, Writer writer, 
Set<String> alreadyDeclared, Set<Schema> toDeclare,
+      String recordNameSpace) throws IOException {
+    Schema.Type type = schema.getType();
+    if (type == Schema.Type.RECORD || type == Schema.Type.ENUM || type == 
Schema.Type.FIXED) {
+      if (Objects.equals(recordNameSpace, schema.getNamespace())) {
+        writer.append(schema.getName());
+      } else {
+        writer.append(schema.getFullName());
+      }
+      if (!alreadyDeclared.contains(schema.getFullName())) {
+        toDeclare.add(schema);
+      }
+    } else if (type == Schema.Type.ARRAY) {
+      writeJsonProperties(schema, writer, null);
+      writer.append("array<");
+      writeFieldSchema(schema.getElementType(), writer, alreadyDeclared, 
toDeclare, recordNameSpace);
+      writer.append('>');
+    } else if (type == Schema.Type.MAP) {
+      writeJsonProperties(schema, writer, null);
+      writer.append("map<");
+      writeFieldSchema(schema.getValueType(), writer, alreadyDeclared, 
toDeclare, recordNameSpace);
+      writer.append('>');
+    } else if (type == Schema.Type.UNION) {
+      // Note: unions cannot have properties
+      Schema schemaForNullableSyntax = getNullableUnionType(schema);
+      if (schemaForNullableSyntax != null) {
+        writeFieldSchema(schemaForNullableSyntax, writer, alreadyDeclared, 
toDeclare, recordNameSpace);
+        writer.append('?');
+      } else {
+        writer.append("union{");
+        List<Schema> types = schema.getTypes();
+        Iterator<Schema> iterator = types.iterator();
+        if (iterator.hasNext()) {
+          writeFieldSchema(iterator.next(), writer, alreadyDeclared, 
toDeclare, recordNameSpace);
+          while (iterator.hasNext()) {
+            writer.append(", ");
+            writeFieldSchema(iterator.next(), writer, alreadyDeclared, 
toDeclare, recordNameSpace);
+          }
+        } else {
+          throw new AvroRuntimeException("Union schemas must have member types 
" + schema);
+        }
+        writer.append('}');
+      }
+    } else {
+      Set<String> propertiesToSkip = new HashSet<>();
+      String typeName;
+      if (schema.getLogicalType() == null) {
+        typeName = schema.getName();
+      } else {
+        String logicalName = schema.getLogicalType().getName();
+        switch (logicalName) {
+        // TODO: Use constants from org.apache.avro.LogicalTypes
+        case "date":
+        case "time-millis":
+        case "timestamp-millis":
+          propertiesToSkip.add("logicalType");
+          typeName = logicalName.replace("-millis", "_ms");
+          break;
+        case "decimal":
+          propertiesToSkip.addAll(asList("logicalType", "precision", "scale"));
+          LogicalTypes.Decimal decimal = (LogicalTypes.Decimal) 
schema.getLogicalType();
+          typeName = String.format("decimal(%d,%d)", decimal.getPrecision(), 
decimal.getScale());
+          break;
+        default:
+          propertiesToSkip = Collections.emptySet();
+          typeName = schema.getName();
+          break;
+        }
+      }
+      writeJsonProperties(schema, propertiesToSkip, writer, null);
+      writer.append(typeName);
+    }
+  }
+
+  /**
+   * Get the type from a nullable 2-type union if that type is eligible for the
+   * '?'-syntax.
+   *
+   * @param unionSchema a union schema
+   * @return the non-null schema in a nullable 2-type union iff not a container
+   */
+  private static Schema getNullableUnionType(Schema unionSchema) {
+    List<Schema> types = unionSchema.getTypes();
+    if (unionSchema.isNullable() && types.size() == 2) {
+      Schema nonNullSchema = !types.get(0).isNullable() ? types.get(0) : 
types.get(1);
+      if (NULLABLE_TYPES.contains(nonNullSchema.getType())) {
+        return nonNullSchema;
+      }
+    }
+    return null;
+  }
+
+  private static void writeSchemaAttributes(String indent, Schema schema, 
Writer writer) throws IOException {
+    writeDocumentation(writer, indent, schema.getDoc());
+    writeJsonProperties(schema, writer, indent);
+  }
+
+  private static void writeJsonProperties(JsonProperties props, Writer writer, 
String indent) throws IOException {
+    writeJsonProperties(props, Collections.emptySet(), writer, indent);
+  }
+
+  private static void writeJsonProperties(JsonProperties props, Set<String> 
propertiesToSkip, Writer writer,
+      String indent) throws IOException {
+    Map<String, Object> objectProps = props.getObjectProps();
+    for (Map.Entry<String, Object> entry : objectProps.entrySet()) {
+      if (propertiesToSkip.contains(entry.getKey())) {
+        continue;
+      }
+      if (indent != null) {
+        writer.append(indent);
+      }
+      writer.append('@').append(entry.getKey()).append('(');
+      writer.append(MAPPER.writeValueAsString(entry.getValue())).append(')');
+      writer.append(indent == null ? ' ' : '\n');
+    }
+  }
+
+  private static void writeMessage(Protocol.Message message, Writer writer, 
String protocolNameSpace,
+      Set<String> alreadyDeclared) throws IOException {
+    writeMessageAttributes(message, writer);
+    final Set<Schema> toDeclare = Collections.unmodifiableSet(new 
HashSet<>()); // Crash if a type hasn't been declared
+    // yet.
+    writer.append("    ");
+    writeFieldSchema(message.getResponse(), writer, alreadyDeclared, 
toDeclare, protocolNameSpace);
+    writer.append(' ');
+    writer.append(message.getName());
+
+    Schema request = message.getRequest(); // MUST be a record type
+    boolean indentParameters = request.getFields().stream()
+        .anyMatch(field -> field.doc() != null && 
!field.doc().trim().isEmpty());
+    writer.append('(');
+    if (indentParameters) {
+      writer.append("\n");
+    }
+
+    boolean first = true;
+    for (Schema.Field field : request.getFields()) {
+      if (first) {
+        first = false;
+      } else if (indentParameters) {
+        writer.append(",\n");
+      } else {
+        writer.append(", ");
+      }
+      writeField(protocolNameSpace, field, writer, alreadyDeclared, toDeclare,
+          indentParameters ? FieldIndent.INSIDE_PROTOCOL : FieldIndent.NONE);
+    }
+    if (indentParameters) {
+      writer.append("\n    ");
+    }
+    writer.append(')');
+
+    if (message.isOneWay()) {
+      writer.append(" oneway;\n");
+    } else {
+      first = true;
+      // MUST be a union of error types
+      for (Schema error : message.getErrors().getTypes()) {
+        if (error.getType() == Schema.Type.STRING) {
+          continue; // Skip system error type
+        }
+        if (first) {
+          first = false;
+          writer.append(" throws ");
+        } else {
+          writer.append(", ");
+        }
+        if (Objects.equals(protocolNameSpace, error.getNamespace())) {
+          writer.append(error.getName());
+        } else {
+          writer.append(error.getFullName());
+        }
+      }
+      writer.append(";\n");
+    }
+  }
+
+  private static void writeMessageAttributes(Protocol.Message message, Writer 
writer) throws IOException {
+    writeDocumentation(writer, "    ", message.getDoc());
+    writeJsonProperties(message, writer, "    ");
+  }
+
+  private enum FieldIndent {
+    NONE, TOPLEVEL_SCHEMA, INSIDE_PROTOCOL
+  }
+}
diff --git 
a/lang/java/idl/src/main/resources/META-INF/services/org.apache.avro.SchemaFormatterFactory
 
b/lang/java/idl/src/main/resources/META-INF/services/org.apache.avro.SchemaFormatterFactory
new file mode 100644
index 000000000..72696e970
--- /dev/null
+++ 
b/lang/java/idl/src/main/resources/META-INF/services/org.apache.avro.SchemaFormatterFactory
@@ -0,0 +1,18 @@
+#
+# 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
+#
+#     https://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.
+#
+org.apache.avro.idl.IdlSchemaFormatterFactory
diff --git 
a/lang/java/idl/src/test/java/org/apache/avro/idl/IdlSchemaFormatterFactoryTest.java
 
b/lang/java/idl/src/test/java/org/apache/avro/idl/IdlSchemaFormatterFactoryTest.java
new file mode 100644
index 000000000..6332f4b07
--- /dev/null
+++ 
b/lang/java/idl/src/test/java/org/apache/avro/idl/IdlSchemaFormatterFactoryTest.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
+ *
+ *     https://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.avro.idl;
+
+import org.apache.avro.Schema;
+import org.apache.avro.SchemaFormatter;
+import org.apache.avro.SchemaParser;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.StringWriter;
+
+import static java.util.Objects.requireNonNull;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class IdlSchemaFormatterFactoryTest {
+  @Test
+  void verifyIdlFormatting() throws IOException {
+    SchemaFormatter idlFormatter = SchemaFormatter.getInstance("idl");
+    assertEquals(IdlSchemaFormatter.class, idlFormatter.getClass());
+
+    String formattedHappyFlowSchema = 
getResourceAsString("../util/idl_utils_test_schema.avdl");
+
+    String schemaResourceName = "../util/idl_utils_test_schema.avdl";
+    try (InputStream stream = 
getClass().getResourceAsStream(schemaResourceName)) {
+      Schema happyFlowSchema = new 
SchemaParser().parse(formattedHappyFlowSchema).mainSchema();
+      // The Avro project indents .avdl files less than common
+      String formatted = idlFormatter.format(happyFlowSchema).replaceAll("    
", "\t").replaceAll("\t", "  ");
+      assertEquals(formattedHappyFlowSchema, formatted);
+    }
+  }
+
+  private String getResourceAsString(String name) throws IOException {
+    StringWriter schemaBuffer = new StringWriter();
+    try (InputStreamReader reader = new 
InputStreamReader(requireNonNull(getClass().getResourceAsStream(name)))) {
+      char[] buf = new char[1024];
+      int charsRead;
+      while ((charsRead = reader.read(buf)) > -1) {
+        schemaBuffer.write(buf, 0, charsRead);
+      }
+    }
+    return schemaBuffer.toString();
+  }
+}
diff --git a/lang/java/idl/src/test/java/org/apache/avro/util/IdlUtilsTest.java 
b/lang/java/idl/src/test/java/org/apache/avro/util/IdlUtilsTest.java
new file mode 100644
index 000000000..0cb5a5702
--- /dev/null
+++ b/lang/java/idl/src/test/java/org/apache/avro/util/IdlUtilsTest.java
@@ -0,0 +1,195 @@
+/*
+ * 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
+ *
+ *     https://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.avro.util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import org.apache.avro.AvroRuntimeException;
+import org.apache.avro.JsonProperties;
+import org.apache.avro.Protocol;
+import org.apache.avro.Schema;
+import org.apache.avro.idl.IdlFile;
+import org.apache.avro.idl.IdlReader;
+import org.junit.jupiter.api.Test;
+
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static java.util.Objects.requireNonNull;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class IdlUtilsTest {
+  @Test
+  public void idlUtilsUtilitiesThrowRuntimeExceptionsOnProgrammerError() {
+    assertThrows(IllegalStateException.class, () -> 
IdlUtils.getField(Object.class, "noSuchField"), "Programmer error");
+    assertThrows(IllegalStateException.class,
+        () -> IdlUtils.getFieldValue(String.class.getDeclaredField("value"), 
"anything"), "Programmer error");
+
+    assertEquals("foo", IdlUtils.uncheckExceptions(() -> "foo"));
+    assertThrows(IllegalArgumentException.class, () -> 
IdlUtils.uncheckExceptions(() -> {
+      throw new IllegalArgumentException("Oops");
+    }), "Oops");
+    assertThrows(AvroRuntimeException.class, () -> 
IdlUtils.uncheckExceptions(() -> {
+      throw new IOException("Oops");
+    }), "Oops");
+  }
+
+  @Test
+  public void validateHappyFlowForProtocol() throws IOException {
+    Protocol protocol = 
parseIdlResource("idl_utils_test_protocol.avdl").getProtocol();
+
+    StringWriter buffer = new StringWriter();
+    IdlUtils.writeIdlProtocol(buffer, protocol);
+
+    assertEquals(getResourceAsString("idl_utils_test_protocol.avdl"), 
buffer.toString());
+  }
+
+  private IdlFile parseIdlResource(String name) throws IOException {
+    IdlFile idlFile;
+    IdlReader idlReader = new IdlReader();
+    try (InputStream stream = getClass().getResourceAsStream(name)) {
+      idlFile = idlReader.parse(requireNonNull(stream));
+    }
+    return idlFile;
+  }
+
+  private String getResourceAsString(String name) throws IOException {
+    StringWriter schemaBuffer = new StringWriter();
+    try (InputStreamReader reader = new 
InputStreamReader(requireNonNull(getClass().getResourceAsStream(name)))) {
+      char[] buf = new char[1024];
+      int charsRead;
+      while ((charsRead = reader.read(buf)) > -1) {
+        schemaBuffer.write(buf, 0, charsRead);
+      }
+    }
+    return schemaBuffer.toString();
+  }
+
+  @Test
+  public void validateHappyFlowForSingleSchema() throws IOException {
+    final IdlFile idlFile = parseIdlResource("idl_utils_test_schema.avdl");
+    Schema mainSchema = idlFile.getMainSchema();
+
+    StringWriter buffer = new StringWriter();
+    IdlUtils.writeIdlSchema(buffer, mainSchema.getTypes().iterator().next());
+
+    assertEquals(getResourceAsString("idl_utils_test_schema.avdl"), 
buffer.toString());
+  }
+
+  @Test
+  public void cannotWriteProtocolWithUnnamedTypes() {
+    assertThrows(AvroRuntimeException.class,
+        () -> IdlUtils.writeIdlProtocol(new StringWriter(), 
Schema.create(Schema.Type.STRING)));
+  }
+
+  @Test
+  public void cannotWriteEmptyEnums() {
+    assertThrows(AvroRuntimeException.class,
+        () -> IdlUtils.writeIdlProtocol(new StringWriter(), 
Schema.createEnum("Single", null, "naming", emptyList())));
+  }
+
+  @Test
+  public void cannotWriteEmptyUnionTypes() {
+    assertThrows(AvroRuntimeException.class,
+        () -> IdlUtils.writeIdlProtocol(new StringWriter(), 
Schema.createRecord("Single", null, "naming", false,
+            singletonList(new Schema.Field("field", Schema.createUnion())))));
+  }
+
+  @Test
+  public void validateNullToJson() throws IOException {
+    assertEquals("null", callToJson(JsonProperties.NULL_VALUE));
+  }
+
+  @Test
+  public void validateMapToJson() throws IOException {
+    Map<String, Object> data = new LinkedHashMap<>();
+    data.put("key", "name");
+    data.put("value", 81763);
+    assertEquals("{\"key\":\"name\",\"value\":81763}", callToJson(data));
+  }
+
+  @Test
+  public void validateCollectionToJson() throws IOException {
+    assertEquals("[123,\"abc\"]", callToJson(Arrays.asList(123, "abc")));
+  }
+
+  @Test
+  public void validateBytesToJson() throws IOException {
+    assertEquals("\"getalletjes\"", 
callToJson("getalletjes".getBytes(StandardCharsets.US_ASCII)));
+  }
+
+  @Test
+  public void validateStringToJson() throws IOException {
+    assertEquals("\"foo\"", callToJson("foo"));
+  }
+
+  @Test
+  public void validateEnumToJson() throws IOException {
+    assertEquals("\"FILE_NOT_FOUND\"", callToJson(SingleValue.FILE_NOT_FOUND));
+  }
+
+  @Test
+  public void validateDoubleToJson() throws IOException {
+    assertEquals("25000.025", callToJson(25_000.025));
+  }
+
+  @Test
+  public void validateFloatToJson() throws IOException {
+    assertEquals("15000.002", callToJson(15_000.002f));
+  }
+
+  @Test
+  public void validateLongToJson() throws IOException {
+    assertEquals("7254378234", callToJson(7254378234L));
+  }
+
+  @Test
+  public void validateIntegerToJson() throws IOException {
+    assertEquals("123", callToJson(123));
+  }
+
+  @Test
+  public void validateBooleanToJson() throws IOException {
+    assertEquals("true", callToJson(true));
+  }
+
+  @Test
+  public void validateUnknownCannotBeWrittenAsJson() {
+    assertThrows(AvroRuntimeException.class, () -> callToJson(new Object()));
+  }
+
+  private String callToJson(Object datum) throws IOException {
+    StringWriter buffer = new StringWriter();
+    try (JsonGenerator generator = IdlUtils.MAPPER.createGenerator(buffer)) {
+      IdlUtils.MAPPER.writeValueAsString(datum);
+    }
+    return buffer.toString();
+  }
+
+  private enum SingleValue {
+    FILE_NOT_FOUND
+  }
+}
diff --git 
a/lang/java/idl/src/test/resources/org/apache/avro/util/idl_utils_test_protocol.avdl
 
b/lang/java/idl/src/test/resources/org/apache/avro/util/idl_utils_test_protocol.avdl
new file mode 100644
index 000000000..fa59f5b35
--- /dev/null
+++ 
b/lang/java/idl/src/test/resources/org/apache/avro/util/idl_utils_test_protocol.avdl
@@ -0,0 +1,45 @@
+@namespace("naming")
+@version("1.0.5")
+protocol HappyFlow {
+    /** A sample record type. */
+    @version(2)
+    @aliases(["naming.OldMessage"])
+    record NewMessage {
+        string @generator("uuid-type1") id;
+        @my-key("my-value") string? @aliases(["text","msg"]) message = null;
+        @my-key("my-value") map<common.Flag> @order("DESCENDING") flags;
+        Counter mainCounter;
+        /** A list of counters. */
+        union{null, @my-key("my-value") array<Counter>} otherCounters = null;
+        Nonce nonce;
+        date my_date;
+        time_ms my_time;
+        timestamp_ms my_timestamp;
+        decimal(12,3) my_number;
+        @logicalType("time-micros") long my_dummy;
+    }
+
+    @namespace("common")
+    enum Flag {ON, OFF, CANARY}
+
+    record Counter {
+        string name;
+        int count;
+        /** Because the Flag field is defined earlier in NewMessage, it's 
already defined and does not need repeating below. */
+        common.Flag flag;
+    }
+
+    fixed Nonce(8);
+
+    error Failure {
+        string reason;
+    }
+
+    null send(Counter counter) oneway;
+    /** Simple echoing service */
+    string echo(
+        /** this message will be returned count times. */
+        string message,
+        int count
+    ) throws Failure;
+}
diff --git 
a/lang/java/idl/src/test/resources/org/apache/avro/util/idl_utils_test_schema.avdl
 
b/lang/java/idl/src/test/resources/org/apache/avro/util/idl_utils_test_schema.avdl
new file mode 100644
index 000000000..b500bde00
--- /dev/null
+++ 
b/lang/java/idl/src/test/resources/org/apache/avro/util/idl_utils_test_schema.avdl
@@ -0,0 +1,35 @@
+namespace naming;
+
+schema NewMessage;
+
+/** A sample record type. */
+@version(2)
+@aliases(["naming.OldMessage"])
+record NewMessage {
+  string @generator("uuid-type1") id;
+  @my-key("my-value") string? @aliases(["text", "msg"]) message = null;
+  @my-key("my-value") map<common.Flag> @order("DESCENDING") flags;
+  Counter mainCounter;
+  /** A list of counters. */
+  union{null, @my-key("my-value") array<Counter>} otherCounters = null;
+  Nonce nonce;
+  date my_date;
+  time_ms my_time;
+  timestamp_ms my_timestamp;
+  decimal(12,3) my_number;
+  @logicalType("time-micros") long my_dummy;
+}
+
+@namespace("common")
+enum Flag {
+  ON, OFF, CANARY
+}
+
+record Counter {
+  string name;
+  int count;
+  /** Because the Flag field is defined earlier in NewMessage, it's already 
defined and does not need repeating below. */
+  common.Flag flag;
+}
+
+fixed Nonce(8);

Reply via email to