diff --git a/gradle/build.gradle b/gradle/build.gradle index ed7843afe..761c329b4 100644 --- a/gradle/build.gradle +++ b/gradle/build.gradle @@ -58,6 +58,7 @@ if (!project.hasProperty('showAllOutput')) { gradleLint.autoLintAfterFailure = false // Paths to main and test sources. +ext.projectRootDir = "${rootDir}/.." ext.javaDir = "${rootDir}/../java" ext.javatestsDir = "${rootDir}/../javatests" diff --git a/gradle/core/build.gradle b/gradle/core/build.gradle index 8e9d2815d..d1c4d03c0 100644 --- a/gradle/core/build.gradle +++ b/gradle/core/build.gradle @@ -172,6 +172,7 @@ dependencies { maybeRuntime deps['com.squareup:javawriter'] maybeRuntime deps['com.sun.activation:javax.activation'] maybeRuntime deps['com.thoughtworks.paranamer:paranamer'] + testCompile deps['com.thoughtworks.qdox:qdox'] maybeRuntime deps['commons-codec:commons-codec'] maybeRuntime deps['commons-logging:commons-logging'] compile deps['dnsjava:dnsjava'] @@ -613,6 +614,22 @@ task generateGoldenImages(type: Test) { } generateGoldenImages.finalizedBy(findGoldenImages) +task flowDocsTool(type: JavaExec) { + systemProperty 'test.projectRoot', rootProject.projectRootDir + systemProperty 'test.resourcesDir', resourcesDir + + classpath = sourceSets.main.runtimeClasspath + main = 'google.registry.documentation.FlowDocumentationTool' + + def arguments = [] + if (rootProject.flowDocsFile) { + arguments << "--output_file=${rootProject.flowDocsFile}" + } else { + arguments << "--output_file=${rootProject.projectRootDir}/docs/flows.md" + } + args arguments +} + test { // Common exclude pattern. See README in parent directory for explanation. exclude "**/*TestCase.*", "**/*TestSuite.*" @@ -627,6 +644,9 @@ test { // Sets the maximum number of test executors that may exist at the same time. maxParallelForks 5 + systemProperty 'test.projectRoot', rootProject.projectRootDir + systemProperty 'test.resourcesDir', resourcesDir + doFirst { new File(screenshotsDir).deleteDir() } diff --git a/gradle/core/gradle/dependency-locks/testCompile.lockfile b/gradle/core/gradle/dependency-locks/testCompile.lockfile index db19e97a2..3c87d31d8 100644 --- a/gradle/core/gradle/dependency-locks/testCompile.lockfile +++ b/gradle/core/gradle/dependency-locks/testCompile.lockfile @@ -122,6 +122,7 @@ com.squareup.okhttp3:okhttp:3.11.0 com.squareup.okhttp:okhttp:2.5.0 com.squareup.okio:okio:1.14.0 com.thoughtworks.paranamer:paranamer:2.7 +com.thoughtworks.qdox:qdox:1.12.1 commons-codec:commons-codec:1.10 commons-logging:commons-logging:1.2 dnsjava:dnsjava:2.1.7 diff --git a/gradle/core/gradle/dependency-locks/testCompileClasspath.lockfile b/gradle/core/gradle/dependency-locks/testCompileClasspath.lockfile index 45ea53b11..625db6f84 100644 --- a/gradle/core/gradle/dependency-locks/testCompileClasspath.lockfile +++ b/gradle/core/gradle/dependency-locks/testCompileClasspath.lockfile @@ -121,6 +121,7 @@ com.squareup.okhttp3:okhttp:3.11.0 com.squareup.okhttp:okhttp:2.5.0 com.squareup.okio:okio:1.14.0 com.thoughtworks.paranamer:paranamer:2.7 +com.thoughtworks.qdox:qdox:1.12.1 commons-codec:commons-codec:1.10 commons-logging:commons-logging:1.2 dnsjava:dnsjava:2.1.7 diff --git a/gradle/core/gradle/dependency-locks/testRuntime.lockfile b/gradle/core/gradle/dependency-locks/testRuntime.lockfile index 783a2abd1..11687a489 100644 --- a/gradle/core/gradle/dependency-locks/testRuntime.lockfile +++ b/gradle/core/gradle/dependency-locks/testRuntime.lockfile @@ -124,6 +124,7 @@ com.squareup.okio:okio:1.14.0 com.sun.istack:istack-commons-runtime:3.0.5 com.sun.xml.fastinfoset:FastInfoset:1.2.13 com.thoughtworks.paranamer:paranamer:2.7 +com.thoughtworks.qdox:qdox:1.12.1 commons-codec:commons-codec:1.10 commons-logging:commons-logging:1.2 dnsjava:dnsjava:2.1.7 diff --git a/gradle/core/gradle/dependency-locks/testRuntimeClasspath.lockfile b/gradle/core/gradle/dependency-locks/testRuntimeClasspath.lockfile index 783a2abd1..11687a489 100644 --- a/gradle/core/gradle/dependency-locks/testRuntimeClasspath.lockfile +++ b/gradle/core/gradle/dependency-locks/testRuntimeClasspath.lockfile @@ -124,6 +124,7 @@ com.squareup.okio:okio:1.14.0 com.sun.istack:istack-commons-runtime:3.0.5 com.sun.xml.fastinfoset:FastInfoset:1.2.13 com.thoughtworks.paranamer:paranamer:2.7 +com.thoughtworks.qdox:qdox:1.12.1 commons-codec:commons-codec:1.10 commons-logging:commons-logging:1.2 dnsjava:dnsjava:2.1.7 diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index 66e0eb6f4..c19745704 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -84,6 +84,7 @@ ext { 'com.sun.xml.bind:jaxb-osgi:2.2.11', 'com.sun.xml.bind:jaxb-xjc:2.2.11', 'com.thoughtworks.paranamer:paranamer:2.7', + 'com.thoughtworks.qdox:qdox:1.12.1', 'dnsjava:dnsjava:2.1.7', 'io.netty:netty-buffer:4.1.31.Final', 'io.netty:netty-codec:4.1.31.Final', diff --git a/gradle/gradle.properties b/gradle/gradle.properties index 8620c8578..833ee488a 100644 --- a/gradle/gradle.properties +++ b/gradle/gradle.properties @@ -4,3 +4,4 @@ uploaderDestination= uploaderCredentialsFile= uploaderMultithreadedUpload= mavenCoordinateFile= +flowDocsFile= diff --git a/gradle/gradle/dependency-locks/buildscript-classpath.lockfile b/gradle/gradle/dependency-locks/buildscript-classpath.lockfile index fa8d30f36..869237678 100644 --- a/gradle/gradle/dependency-locks/buildscript-classpath.lockfile +++ b/gradle/gradle/dependency-locks/buildscript-classpath.lockfile @@ -13,8 +13,8 @@ com.jcraft:jzlib:1.1.1 com.moowork.gradle:gradle-node-plugin:1.2.0 com.moowork.node:com.moowork.node.gradle.plugin:1.2.0 com.netflix.nebula:gradle-lint-plugin:10.4.2 -com.netflix.nebula:nebula-gradle-interop:1.0.3 -com.netflix.nebula:nebula-test:7.2.1 +com.netflix.nebula:nebula-gradle-interop:1.0.6 +com.netflix.nebula:nebula-test:7.2.3 commons-codec:commons-codec:1.9 commons-io:commons-io:2.5 commons-lang:commons-lang:2.6 diff --git a/gradle/proxy/gradle/dependency-locks/testCompile.lockfile b/gradle/proxy/gradle/dependency-locks/testCompile.lockfile index b54d0cd3c..e47ab00f1 100644 --- a/gradle/proxy/gradle/dependency-locks/testCompile.lockfile +++ b/gradle/proxy/gradle/dependency-locks/testCompile.lockfile @@ -124,6 +124,7 @@ com.squareup.okio:okio:1.14.0 com.sun.istack:istack-commons-runtime:3.0.5 com.sun.xml.fastinfoset:FastInfoset:1.2.13 com.thoughtworks.paranamer:paranamer:2.7 +com.thoughtworks.qdox:qdox:1.12.1 commons-codec:commons-codec:1.10 commons-logging:commons-logging:1.2 dnsjava:dnsjava:2.1.7 diff --git a/gradle/proxy/gradle/dependency-locks/testCompileClasspath.lockfile b/gradle/proxy/gradle/dependency-locks/testCompileClasspath.lockfile index 85f516dea..867f8feff 100644 --- a/gradle/proxy/gradle/dependency-locks/testCompileClasspath.lockfile +++ b/gradle/proxy/gradle/dependency-locks/testCompileClasspath.lockfile @@ -124,6 +124,7 @@ com.squareup.okio:okio:1.14.0 com.sun.istack:istack-commons-runtime:3.0.5 com.sun.xml.fastinfoset:FastInfoset:1.2.13 com.thoughtworks.paranamer:paranamer:2.7 +com.thoughtworks.qdox:qdox:1.12.1 commons-codec:commons-codec:1.10 commons-logging:commons-logging:1.2 dnsjava:dnsjava:2.1.7 diff --git a/gradle/proxy/gradle/dependency-locks/testRuntime.lockfile b/gradle/proxy/gradle/dependency-locks/testRuntime.lockfile index 8622178e9..923836d65 100644 --- a/gradle/proxy/gradle/dependency-locks/testRuntime.lockfile +++ b/gradle/proxy/gradle/dependency-locks/testRuntime.lockfile @@ -124,6 +124,7 @@ com.squareup.okio:okio:1.14.0 com.sun.istack:istack-commons-runtime:3.0.5 com.sun.xml.fastinfoset:FastInfoset:1.2.13 com.thoughtworks.paranamer:paranamer:2.7 +com.thoughtworks.qdox:qdox:1.12.1 commons-codec:commons-codec:1.10 commons-logging:commons-logging:1.2 dnsjava:dnsjava:2.1.7 diff --git a/gradle/proxy/gradle/dependency-locks/testRuntimeClasspath.lockfile b/gradle/proxy/gradle/dependency-locks/testRuntimeClasspath.lockfile index 8622178e9..923836d65 100644 --- a/gradle/proxy/gradle/dependency-locks/testRuntimeClasspath.lockfile +++ b/gradle/proxy/gradle/dependency-locks/testRuntimeClasspath.lockfile @@ -124,6 +124,7 @@ com.squareup.okio:okio:1.14.0 com.sun.istack:istack-commons-runtime:3.0.5 com.sun.xml.fastinfoset:FastInfoset:1.2.13 com.thoughtworks.paranamer:paranamer:2.7 +com.thoughtworks.qdox:qdox:1.12.1 commons-codec:commons-codec:1.10 commons-logging:commons-logging:1.2 dnsjava:dnsjava:2.1.7 diff --git a/gradle/third_party/gradle/dependency-locks/compile.lockfile b/gradle/third_party/gradle/dependency-locks/compile.lockfile new file mode 100644 index 000000000..656c5dbcc --- /dev/null +++ b/gradle/third_party/gradle/dependency-locks/compile.lockfile @@ -0,0 +1,3 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. diff --git a/gradle/third_party/gradle/dependency-locks/compileClasspath.lockfile b/gradle/third_party/gradle/dependency-locks/compileClasspath.lockfile new file mode 100644 index 000000000..656c5dbcc --- /dev/null +++ b/gradle/third_party/gradle/dependency-locks/compileClasspath.lockfile @@ -0,0 +1,3 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. diff --git a/gradle/third_party/gradle/dependency-locks/runtimeClasspath.lockfile b/gradle/third_party/gradle/dependency-locks/runtimeClasspath.lockfile new file mode 100644 index 000000000..656c5dbcc --- /dev/null +++ b/gradle/third_party/gradle/dependency-locks/runtimeClasspath.lockfile @@ -0,0 +1,3 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. diff --git a/gradle/third_party/gradle/dependency-locks/testCompile.lockfile b/gradle/third_party/gradle/dependency-locks/testCompile.lockfile new file mode 100644 index 000000000..656c5dbcc --- /dev/null +++ b/gradle/third_party/gradle/dependency-locks/testCompile.lockfile @@ -0,0 +1,3 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. diff --git a/gradle/third_party/gradle/dependency-locks/testRuntimeClasspath.lockfile b/gradle/third_party/gradle/dependency-locks/testRuntimeClasspath.lockfile new file mode 100644 index 000000000..656c5dbcc --- /dev/null +++ b/gradle/third_party/gradle/dependency-locks/testRuntimeClasspath.lockfile @@ -0,0 +1,3 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. diff --git a/gradle/util/gradle/dependency-locks/testCompile.lockfile b/gradle/util/gradle/dependency-locks/testCompile.lockfile index 783a2abd1..11687a489 100644 --- a/gradle/util/gradle/dependency-locks/testCompile.lockfile +++ b/gradle/util/gradle/dependency-locks/testCompile.lockfile @@ -124,6 +124,7 @@ com.squareup.okio:okio:1.14.0 com.sun.istack:istack-commons-runtime:3.0.5 com.sun.xml.fastinfoset:FastInfoset:1.2.13 com.thoughtworks.paranamer:paranamer:2.7 +com.thoughtworks.qdox:qdox:1.12.1 commons-codec:commons-codec:1.10 commons-logging:commons-logging:1.2 dnsjava:dnsjava:2.1.7 diff --git a/gradle/util/gradle/dependency-locks/testCompileClasspath.lockfile b/gradle/util/gradle/dependency-locks/testCompileClasspath.lockfile index c927960a5..f678a2d07 100644 --- a/gradle/util/gradle/dependency-locks/testCompileClasspath.lockfile +++ b/gradle/util/gradle/dependency-locks/testCompileClasspath.lockfile @@ -124,6 +124,7 @@ com.squareup.okio:okio:1.14.0 com.sun.istack:istack-commons-runtime:3.0.5 com.sun.xml.fastinfoset:FastInfoset:1.2.13 com.thoughtworks.paranamer:paranamer:2.7 +com.thoughtworks.qdox:qdox:1.12.1 commons-codec:commons-codec:1.10 commons-logging:commons-logging:1.2 dnsjava:dnsjava:2.1.7 diff --git a/gradle/util/gradle/dependency-locks/testRuntime.lockfile b/gradle/util/gradle/dependency-locks/testRuntime.lockfile index 783a2abd1..11687a489 100644 --- a/gradle/util/gradle/dependency-locks/testRuntime.lockfile +++ b/gradle/util/gradle/dependency-locks/testRuntime.lockfile @@ -124,6 +124,7 @@ com.squareup.okio:okio:1.14.0 com.sun.istack:istack-commons-runtime:3.0.5 com.sun.xml.fastinfoset:FastInfoset:1.2.13 com.thoughtworks.paranamer:paranamer:2.7 +com.thoughtworks.qdox:qdox:1.12.1 commons-codec:commons-codec:1.10 commons-logging:commons-logging:1.2 dnsjava:dnsjava:2.1.7 diff --git a/gradle/util/gradle/dependency-locks/testRuntimeClasspath.lockfile b/gradle/util/gradle/dependency-locks/testRuntimeClasspath.lockfile index 783a2abd1..11687a489 100644 --- a/gradle/util/gradle/dependency-locks/testRuntimeClasspath.lockfile +++ b/gradle/util/gradle/dependency-locks/testRuntimeClasspath.lockfile @@ -124,6 +124,7 @@ com.squareup.okio:okio:1.14.0 com.sun.istack:istack-commons-runtime:3.0.5 com.sun.xml.fastinfoset:FastInfoset:1.2.13 com.thoughtworks.paranamer:paranamer:2.7 +com.thoughtworks.qdox:qdox:1.12.1 commons-codec:commons-codec:1.10 commons-logging:commons-logging:1.2 dnsjava:dnsjava:2.1.7 diff --git a/java/google/registry/documentation/DocumentationGenerator.java b/java/google/registry/documentation/DocumentationGenerator.java new file mode 100644 index 000000000..332c6589f --- /dev/null +++ b/java/google/registry/documentation/DocumentationGenerator.java @@ -0,0 +1,70 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.documentation; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.util.Comparator.comparing; + +import com.google.common.collect.ImmutableList; +import com.sun.javadoc.ClassDoc; +import com.sun.javadoc.RootDoc; +import google.registry.documentation.FlowDocumentation.ErrorCase; +import java.io.IOException; +import java.util.Arrays; +import java.util.stream.Stream; + +/** + * Main entry point class for documentation generation. An instance of this class reads data + * via the javadoc system upon creation and stores it for answering future queries for + * documentation information. + */ +public final class DocumentationGenerator { + + private final RootDoc sourceRoot; + + /** Returns a new DocumentationGenerator object with parsed information from javadoc. */ + public DocumentationGenerator() throws IOException { + sourceRoot = JavadocWrapper.getRootDoc(); + } + + /** Returns generated Markdown output for the flows. Convenience method for clients. */ + public String generateMarkdown() { + return MarkdownDocumentationFormatter.generateMarkdownOutput(getFlowDocs()); + } + + /** Returns a list of flow documentation objects derived from this generator's data. */ + public ImmutableList getFlowDocs() { + // Relevant flows are leaf flows: precisely the concrete subclasses of Flow. + return getConcreteSubclassesStream(FlowDocumentation.BASE_FLOW_CLASS_NAME) + .sorted(comparing(ClassDoc::typeName)) + .map(FlowDocumentation::new) + .collect(toImmutableList()); + } + + /** Returns a list of all possible error cases that might occur. */ + public ImmutableList getAllErrors() { + // Relevant error cases are precisely the concrete subclasses of EppException. + return getConcreteSubclassesStream(FlowDocumentation.EXCEPTION_CLASS_NAME) + .map(ErrorCase::new) + .collect(toImmutableList()); + } + + /** Helper to return all concrete subclasses of a given named class. */ + private Stream getConcreteSubclassesStream(String baseClassName) { + final ClassDoc baseFlowClassDoc = sourceRoot.classNamed(baseClassName); + return Arrays.stream(sourceRoot.classes()) + .filter(classDoc -> classDoc.subclassOf(baseFlowClassDoc) && !classDoc.isAbstract()); + } +} diff --git a/java/google/registry/documentation/FlowDocumentation.java b/java/google/registry/documentation/FlowDocumentation.java new file mode 100644 index 000000000..94d2abbb6 --- /dev/null +++ b/java/google/registry/documentation/FlowDocumentation.java @@ -0,0 +1,278 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.documentation; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.collect.MoreCollectors.onlyElement; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Multimaps; +import com.sun.javadoc.AnnotationDesc; +import com.sun.javadoc.ClassDoc; +import com.sun.javadoc.FieldDoc; +import com.sun.javadoc.SeeTag; +import com.sun.javadoc.Tag; +import google.registry.model.eppoutput.Result.Code; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.TreeMap; +import java.util.stream.Stream; +import javax.annotation.Nullable; + +/** + * Class to represent documentation information for a single EPP flow. + * + *

The static method getFlowDocs() on this class returns a list of FlowDocumentation + * instances corresponding to the leaf flows in the flows package, constructing the instances + * from class information returned from the javadoc system. Each instance has methods for + * retrieving relevant information about the flow, such as a description, error conditions, etc. + */ +public class FlowDocumentation { + + /** Constants for names of various relevant packages and classes. */ + static final String FLOW_PACKAGE_NAME = "google.registry.flows"; + static final String BASE_FLOW_CLASS_NAME = FLOW_PACKAGE_NAME + ".Flow"; + static final String EXCEPTION_CLASS_NAME = FLOW_PACKAGE_NAME + ".EppException"; + static final String CODE_ANNOTATION_NAME = EXCEPTION_CLASS_NAME + ".EppResultCode"; + + /** Name of the class for this flow. */ + private final String name; + + /** Fully qualified name of the class for this flow. */ + private final String qualifiedName; + + /** Name of the package in which this flow resides. */ + private final String packageName; + + /** Class docs for the flow. */ + private final String classDocs; + + /** Javadoc-tagged error conditions for this flow in list form. */ + private final List errors; + + /** Javadoc-tagged error conditions for this flow, organized by underlying error code. */ + private final ListMultimap errorsByCode; + + /** + * Creates a FlowDocumentation for this flow class using data from javadoc tags. Not public + * because clients should get FlowDocumentation objects via the DocumentationGenerator class. + */ + protected FlowDocumentation(ClassDoc flowDoc) { + name = flowDoc.name(); + qualifiedName = flowDoc.qualifiedName(); + packageName = flowDoc.containingPackage().name(); + classDocs = flowDoc.commentText(); + errors = new ArrayList<>(); + // Store error codes in sorted order, and leave reasons in insert order. + errorsByCode = + Multimaps.newListMultimap(new TreeMap>(), ArrayList::new); + parseTags(flowDoc); + } + + public String getName() { + return name; + } + + public String getQualifiedName() { + return qualifiedName; + } + + public String getPackageName() { + return packageName; + } + + public String getClassDocs() { + return classDocs; + } + + public ImmutableList getErrors() { + return ImmutableList.copyOf(errors); + } + + public ImmutableMultimap getErrorsByCode() { + return ImmutableMultimap.copyOf(errorsByCode); + } + + /** Iterates through javadoc tags on the underlying class and calls specific parsing methods. */ + private void parseTags(ClassDoc flowDoc) { + for (Tag tag : flowDoc.tags()) { + switch (tag.name()) { + case "@error": + parseErrorTag(tag); + break; + default: + // Not a relevant tag. + } + } + } + + /** Exception to throw when an @error tag cannot be parsed correctly. */ + private static class BadErrorTagFormatException extends IllegalStateException { + /** Makes a message to use as a prefix for the reason passed up to the superclass. */ + private static String makeMessage(String reason, Tag tag) { + return String.format("Bad @error tag format at %s - %s", tag.position(), reason); + } + + private BadErrorTagFormatException(String reason, Tag tag) { + super(makeMessage(reason, tag)); + } + + private BadErrorTagFormatException(String reason, Tag tag, Exception cause) { + super(makeMessage(reason, tag), cause); + } + } + + /** Parses a javadoc tag corresponding to an error case and updates the error mapping. */ + private void parseErrorTag(Tag tag) { + // Parse the @error tag text to find the @link inline tag. + SeeTag linkedTag; + try { + linkedTag = + Stream.of(tag.inlineTags()) + .filter(SeeTag.class::isInstance) + .map(SeeTag.class::cast) + .collect(onlyElement()); + } catch (NoSuchElementException | IllegalArgumentException e) { + throw new BadErrorTagFormatException( + String.format("expected one @link tag in tag text but found %s: %s", + (e instanceof NoSuchElementException ? "none" : "multiple"), + tag.text()), + tag, e); + } + // Check to see if the @link tag references a valid class. + ClassDoc exceptionRef = linkedTag.referencedClass(); + if (exceptionRef == null) { + throw new BadErrorTagFormatException( + "could not resolve class from @link tag text: " + linkedTag.text(), + tag); + } + // Try to convert the referenced class into an ErrorCase; fail if it's not an EppException. + ErrorCase error; + try { + error = new ErrorCase(exceptionRef); + } catch (IllegalStateException | IllegalArgumentException e) { + throw new BadErrorTagFormatException( + "class referenced in @link is not a valid EppException: " + exceptionRef.qualifiedName(), + tag, e); + } + // Success; store this as a parsed error case. + errors.add(error); + errorsByCode.put(error.getCode(), error); + } + + /** + * Represents an error case for a flow, with a reason for the error and the EPP error code. + * + *

This class is an immutable wrapper for the name of an EppException subclass that gets + * thrown to indicate an error condition. It overrides equals() and hashCode() so that + * instances of this class can be used in collections in the normal fashion. + */ + public static class ErrorCase { + + /** The non-qualified name of the exception class. */ + private final String name; + + /** The fully-qualified name of the exception class. */ + private final String className; + + /** The reason this error was thrown, normally documented on the low-level exception class. */ + private final String reason; + + /** The EPP error code value corresponding to this error condition. */ + private final long errorCode; + + /** Constructs an ErrorCase from the corresponding class for a low-level flow exception. */ + protected ErrorCase(ClassDoc exceptionDoc) { + name = exceptionDoc.name(); + className = exceptionDoc.qualifiedName(); + // The javadoc comment on the class explains the reason for the error condition. + reason = exceptionDoc.commentText(); + ClassDoc highLevelExceptionDoc = getHighLevelExceptionFrom(exceptionDoc); + errorCode = extractErrorCode(highLevelExceptionDoc); + checkArgument(!exceptionDoc.isAbstract(), + "Cannot use an abstract subclass of EppException as an error case"); + } + + public String getName() { + return name; + } + + protected String getClassName() { + return className; + } + + public String getReason() { + return reason; + } + + public long getCode() { + return errorCode; + } + + /** Returns the direct subclass of EppException that this class is a subclass of (or is). */ + private ClassDoc getHighLevelExceptionFrom(ClassDoc exceptionDoc) { + // While we're not yet at the root, move up the class hierarchy looking for EppException. + while (exceptionDoc.superclass() != null) { + if (exceptionDoc.superclass().qualifiedTypeName().equals(EXCEPTION_CLASS_NAME)) { + return exceptionDoc; + } + exceptionDoc = exceptionDoc.superclass(); + } + // Failure; we reached the root without finding a subclass of EppException. + throw new IllegalArgumentException( + String.format("Class referenced is not a subclass of %s", EXCEPTION_CLASS_NAME)); + } + + /** Returns the corresponding EPP error code for an annotated subclass of EppException. */ + private long extractErrorCode(ClassDoc exceptionDoc) { + try { + // We're looking for a specific annotation by name that should appear only once. + AnnotationDesc errorCodeAnnotation = + Arrays.stream(exceptionDoc.annotations()) + .filter( + anno -> anno.annotationType().qualifiedTypeName().equals(CODE_ANNOTATION_NAME)) + .findFirst() + .get(); + // The annotation should have one element whose value converts to an EppResult.Code. + AnnotationDesc.ElementValuePair pair = errorCodeAnnotation.elementValues()[0]; + String enumConstant = ((FieldDoc) pair.value().value()).name(); + return Code.valueOf(enumConstant).code; + } catch (IllegalStateException e) { + throw new IllegalStateException( + "No error code annotation found on exception " + exceptionDoc.name(), e); + } catch (ArrayIndexOutOfBoundsException | ClassCastException | IllegalArgumentException e) { + throw new IllegalStateException("Bad annotation on exception " + exceptionDoc.name(), e); + } + } + + @Override + public boolean equals(@Nullable Object object) { + // The className field canonically identifies the EppException wrapped by this class, and + // all other instance state is derived from that exception, so we only check className. + return object instanceof ErrorCase && this.className.equals(((ErrorCase) object).className); + } + + @Override + public int hashCode() { + // See note for equals() - only className is needed for comparisons. + return className.hashCode(); + } + } +} diff --git a/java/google/registry/documentation/FlowDocumentationTool.java b/java/google/registry/documentation/FlowDocumentationTool.java new file mode 100644 index 000000000..ead468a40 --- /dev/null +++ b/java/google/registry/documentation/FlowDocumentationTool.java @@ -0,0 +1,90 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.documentation; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.beust.jcommander.JCommander; +import com.beust.jcommander.Parameter; +import com.beust.jcommander.ParameterException; +import com.beust.jcommander.Parameters; +import com.google.common.io.Files; +import java.io.File; +import java.io.IOException; + +/** + * Tool to generate documentation for the EPP flows and corresponding external API. + * + *

Mostly responsible for producing standalone documentation files (HTML + * and Markdown) from flow information objects; those call into javadoc to + * extract documentation information from the flows package source files. + * See the {@link FlowDocumentation} class for more details. + */ +@Parameters(separators = " =", commandDescription = "Tool to generate EPP API documentation") +public class FlowDocumentationTool { + + @Parameter(names = {"-o", "--output_file"}, + description = "file where generated documentation will be written (use '-' for stdout)") + private String outputFileName; + + @Parameter(names = {"--help", "--helpshort"}, description = "print this help", help = true) + private boolean displayHelp = false; + + /** Parses command line flags and then runs the documentation tool. */ + public static void main(String[] args) { + FlowDocumentationTool docTool = new FlowDocumentationTool(); + JCommander jcommander = new JCommander(docTool); + jcommander.setProgramName("flow_docs_tool"); + + try { + jcommander.parse(args); + } catch (ParameterException e) { + jcommander.usage(); + throw e; + } + + if (docTool.displayHelp) { + jcommander.usage(); + return; + } + + docTool.run(); + } + + /** Generates flow documentation and then outputs it to the specified file. */ + public void run() { + DocumentationGenerator docGenerator; + try { + docGenerator = new DocumentationGenerator(); + } catch (IOException e) { + throw new RuntimeException("IO error while running Javadoc tool", e); + } + String output = docGenerator.generateMarkdown(); + if (outputFileName.equals("-")) { + System.out.println(output); + } else { + if (outputFileName == null) { + outputFileName = "doclet.html"; + } + try { + Files.asCharSink(new File(outputFileName), UTF_8).write(output); + } catch (IOException e) { + throw new RuntimeException("Could not write to specified output file", e); + } + } + } +} + + diff --git a/java/google/registry/documentation/JavadocWrapper.java b/java/google/registry/documentation/JavadocWrapper.java new file mode 100644 index 000000000..bcdb90eb8 --- /dev/null +++ b/java/google/registry/documentation/JavadocWrapper.java @@ -0,0 +1,158 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.documentation; + +import static google.registry.util.BuildPathUtils.getProjectRoot; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableSet; +import com.google.common.flogger.FluentLogger; +import com.google.common.io.CharStreams; +import com.sun.javadoc.RootDoc; +import com.sun.tools.javac.file.JavacFileManager; +import com.sun.tools.javac.util.Context; +import com.sun.tools.javac.util.ListBuffer; +import com.sun.tools.javadoc.JavadocTool; +import com.sun.tools.javadoc.Messager; +import com.sun.tools.javadoc.ModifierFilter; +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import javax.annotation.Nullable; +import javax.tools.StandardLocation; + +/** + * Wrapper class to simplify calls to the javadoc system and hide internal details. An instance + * represents a set of parameters for calling out to javadoc; these parameters can be set via + * the appropriate methods, and determine what files and packages javadoc will process. The + * actual running of javadoc occurs when calling getRootDoc() to retrieve a javadoc RootDoc. + */ +public final class JavadocWrapper { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + /** Shows any member visible at at least the default (package) level. */ + private static final long VISIBILITY_MASK = + Modifier.PUBLIC | Modifier.PROTECTED | ModifierFilter.PACKAGE; + + /** Root directory for source files. If null, will use the current directory. */ + private static final String SOURCE_PATH = getProjectRoot().resolve("java").toString(); + /** Specific source files to generate documentation for. */ + private static final ImmutableSet SOURCE_FILE_NAMES = ImmutableSet.of(); + + /** Specific packages to generate documentation for. */ + private static final ImmutableSet SOURCE_PACKAGE_NAMES = + ImmutableSet.of(FlowDocumentation.FLOW_PACKAGE_NAME); + + /** Whether or not the Javadoc tool should eschew excessive log output. */ + private static final boolean QUIET = true; + + /** + * Obtains a Javadoc {@link RootDoc} object containing raw Javadoc documentation. + * Wraps a call to the static method createRootDoc() and passes in instance-specific settings. + */ + public static RootDoc getRootDoc() throws IOException { + logger.atInfo().log("Starting Javadoc tool"); + File sourceFilePath = new File(SOURCE_PATH); + logger.atInfo().log("Using source directory: %s", sourceFilePath.getAbsolutePath()); + try { + return createRootDoc( + SOURCE_PATH, + SOURCE_PACKAGE_NAMES, + SOURCE_FILE_NAMES, + VISIBILITY_MASK, + QUIET); + } finally { + logger.atInfo().log("Javadoc tool finished"); + } + } + + /** + * Obtains a Javadoc root document object for the specified source path and package/Java names. + * If the source path is null, then the working directory is assumed as the source path. + * + *

If a list of package names is provided, then Javadoc will run on these packages and all + * their subpackages, based out of the specified source path. + * + *

If a list of file names is provided, then Javadoc will also run on these Java source files. + * The specified source path is not considered in this case. + * + * @see Testing Java doclets + * @see JavadocTool + */ + private static RootDoc createRootDoc( + @Nullable String sourcePath, + Collection packageNames, + Collection fileNames, + long visibilityMask, + boolean quiet) throws IOException { + // Create a context to hold settings for Javadoc. + Context context = new Context(); + + // Redirect Javadoc stdout/stderr to null writers, since otherwise the Java compiler + // issues lots of errors for classes that are imported and visible to blaze but not + // visible locally to the compiler. + // TODO(b/19124943): Find a way to ignore those errors so we can show real ones? + Messager.preRegister( + context, + JavadocWrapper.class.getName(), + new PrintWriter(CharStreams.nullWriter()), // For errors. + new PrintWriter(CharStreams.nullWriter()), // For warnings. + new PrintWriter(CharStreams.nullWriter())); // For notices. + + // Set source path option for Javadoc. + try (JavacFileManager fileManager = new JavacFileManager(context, true, UTF_8)) { + List sourcePathFiles = new ArrayList<>(); + if (sourcePath != null) { + for (String sourcePathEntry : Splitter.on(':').split(sourcePath)) { + sourcePathFiles.add(new File(sourcePathEntry)); + } + } + fileManager.setLocation(StandardLocation.SOURCE_PATH, sourcePathFiles); + + // Create an instance of Javadoc. + JavadocTool javadocTool = JavadocTool.make0(context); + + // Convert the package and file lists to a format Javadoc can understand. + ListBuffer subPackages = new ListBuffer<>(); + subPackages.addAll(packageNames); + ListBuffer javaNames = new ListBuffer<>(); + javaNames.addAll(fileNames); + + // Invoke Javadoc and ask it for a RootDoc containing the specified packages. + return javadocTool.getRootDocImpl( + Locale.US.toString(), // Javadoc comment locale + UTF_8.name(), // Source character encoding + new ModifierFilter(visibilityMask), // Element visibility filter + javaNames.toList(), // Included Java file names + com.sun.tools.javac.util.List.nil(), // Doclet options + com.sun.tools.javac.util.List.nil(), // Source files + false, // Don't use BreakIterator + subPackages.toList(), // Included sub-package names + com.sun.tools.javac.util.List.nil(), // Excluded package names + false, // Read source files, not classes + false, // Don't run legacy doclet + quiet); // If asked, run Javadoc quietly + } + } + + private JavadocWrapper() {} +} diff --git a/java/google/registry/documentation/MarkdownDocumentationFormatter.java b/java/google/registry/documentation/MarkdownDocumentationFormatter.java new file mode 100644 index 000000000..fd6136755 --- /dev/null +++ b/java/google/registry/documentation/MarkdownDocumentationFormatter.java @@ -0,0 +1,182 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.documentation; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.CharMatcher; +import com.google.common.base.Splitter; +import com.google.common.base.Strings; +import com.google.re2j.Matcher; +import com.google.re2j.Pattern; +import google.registry.documentation.FlowDocumentation.ErrorCase; +import java.util.ArrayList; +import java.util.List; + +/** + * Formatter that converts flow documentation into Markdown. + */ +public final class MarkdownDocumentationFormatter { + + /** Header for flow documentation HTML output. */ + private static final String MARKDOWN_HEADER = "# Nomulus EPP Command API Documentation\n\n"; + + /** Pattern that naively matches HTML tags and entity references. */ + private static final Pattern HTML_TAG_AND_ENTITY_PATTERN = Pattern.compile("<[^>]*>|&[^;]*;"); + + /** 8 character indentation. */ + private static final String INDENT8 = Strings.repeat(" ", 8); + + /** Max linewidth for our markdown docs. */ + private static final int LINE_WIDTH = 80; + + /** + * Returns the string with all HTML tags stripped. Also, removes a single space after any + * newlines that have one (we get a single space indent for all lines but the first because of + * the way that javadocs are written in comments). + */ + @VisibleForTesting + static String fixHtml(String value) { + Matcher matcher = HTML_TAG_AND_ENTITY_PATTERN.matcher(value); + int pos = 0; + StringBuilder result = new StringBuilder(); + while (matcher.find(pos)) { + result.append(value, pos, matcher.start()); + switch (matcher.group(0)) { + case "

": + //

is simply removed. + break; + case "&": + result.append("&"); + break; + case "<": + result.append("<"); + break; + case ">": + result.append(">"); + break; + case "&squot;": + result.append("'"); + break; + case """: + result.append("\""); + break; + default: + throw new IllegalArgumentException("Unrecognized HTML sequence: " + matcher.group(0)); + } + pos = matcher.end(); + } + + // Add the string after the last HTML sequence. + result.append(value.substring(pos)); + + return result.toString().replace("\n ", "\n"); + } + + /** + * Formats a list of words into a paragraph with less than maxWidth characters per line. + */ + @VisibleForTesting + static String formatParagraph(List words, int maxWidth) { + int lineLength = 0; + + StringBuilder output = new StringBuilder(); + for (String word : words) { + // This check ensures that 1) don't add a space before the word and 2) always have at least + // one word per line, so that we don't mishandle a very long word at the end of a line by + // adding a blank line before the word. + if (lineLength > 0) { + // Do we have enough room for another word? + if (lineLength + 1 + word.length() > maxWidth) { + // No. End the line. + output.append('\n'); + lineLength = 0; + } else { + // Yes: Insert a space before the word. + output.append(' '); + ++lineLength; + } + } + + output.append(word); + lineLength += word.length(); + } + + output.append('\n'); + return output.toString(); + } + + /** + * Returns 'value' with words reflowed to maxWidth characters. + */ + @VisibleForTesting + static String reflow(String text, int maxWidth) { + + // A list of words that will be constructed into the list of words in a paragraph. + ArrayList words = new ArrayList<>(); + + // Read through the lines, process a paragraph every time we get a blank line. + StringBuilder resultBuilder = new StringBuilder(); + for (String line : Splitter.on('\n').trimResults().split(text)) { + + // If we got a blank line, format our current paragraph and start fresh. + if (line.trim().isEmpty()) { + resultBuilder.append(formatParagraph(words, maxWidth)); + resultBuilder.append('\n'); + words.clear(); + continue; + } + + // Split the line into words and add them to the current paragraph. + words.addAll(Splitter.on( + CharMatcher.breakingWhitespace()).omitEmptyStrings().splitToList(line)); + } + + // Format the last paragraph, if any. + if (!words.isEmpty()) { + resultBuilder.append(formatParagraph(words, maxWidth)); + } + + return resultBuilder.toString(); + } + + /** Returns a string of HTML representing the provided flow documentation objects. */ + public static String generateMarkdownOutput(Iterable flowDocs) { + StringBuilder output = new StringBuilder(); + output.append(MARKDOWN_HEADER); + for (FlowDocumentation flowDoc : flowDocs) { + output.append(String.format("## %s\n\n", flowDoc.getName())); + output.append("### Description\n\n"); + output.append(String.format("%s\n\n", reflow(fixHtml(flowDoc.getClassDocs()), LINE_WIDTH))); + output.append("### Errors\n\n"); + for (Long code : flowDoc.getErrorsByCode().keySet()) { + output.append(String.format("* %d\n", code)); + + + for (ErrorCase error : flowDoc.getErrorsByCode().get(code)) { + output.append(" * "); + String wrappedReason = reflow(fixHtml(error.getReason()), LINE_WIDTH - 8); + + // Replace internal newlines with indentation and strip the final newline. + output.append(wrappedReason.trim().replace("\n", "\n" + INDENT8)); + output.append('\n'); + } + } + output.append('\n'); + } + return output.toString(); + } + + private MarkdownDocumentationFormatter() {} +} diff --git a/java/google/registry/documentation/generate_javadoc.sh b/java/google/registry/documentation/generate_javadoc.sh new file mode 100755 index 000000000..47691b1bb --- /dev/null +++ b/java/google/registry/documentation/generate_javadoc.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Copyright 2017 The Nomulus Authors. All Rights Reserved. +# +# Licensed 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. + + +# Generate javadoc for the project + +if (( $# != 3 )); then + echo "Usage: $0 JAVADOC ZIP OUT" 1>&2 + exit 1 +fi + +JAVADOC_BINARY="$1" +ZIP_BINARY="$2" +TARGETFILE="$3" +TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/generate_javadoc.XXXXXXXX")" +PWDDIR="$(pwd)" + +"${JAVADOC_BINARY}" -d "${TMPDIR}" \ + $(find java -name \*.java) \ + -tag error:t:'EPP Errors' \ + -subpackages google.registry \ + -exclude google.registry.dns:google.registry.proxy:google.registry.monitoring.blackbox +cd "${TMPDIR}" +"${PWDDIR}/${ZIP_BINARY}" -rXoq "${PWDDIR}/${TARGETFILE}" . +cd - +rm -rf "${TMPDIR}" diff --git a/java/google/registry/util/BuildPathUtils.java b/java/google/registry/util/BuildPathUtils.java new file mode 100644 index 000000000..427535965 --- /dev/null +++ b/java/google/registry/util/BuildPathUtils.java @@ -0,0 +1,40 @@ +// Copyright 2019 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.util; + +import java.nio.file.Path; +import java.nio.file.Paths; + +/** Utilities methods related to build path. */ +public final class BuildPathUtils { + // When we run the build from gradlew's directory, the current working directory would be + // ${projectRoot}/gradle/${subproject}. So, the project root is the grand parent of it. + private static final Path PROJECT_ROOT = + Paths.get(System.getProperty("test.projectRoot", "../../")).normalize(); + private static final Path RESOURCES_DIR = + Paths.get(System.getProperty("test.resourcesDir", "build/resources/main")).normalize(); + + /** Returns the {@link Path} to the project root directory. */ + public static Path getProjectRoot() { + return PROJECT_ROOT; + } + + /** Returns the {@link Path} to the resources directory. */ + public static Path getResourcesDir() { + return RESOURCES_DIR; + } + + private BuildPathUtils() {} +} diff --git a/javatests/google/registry/documentation/FlowContext.java b/javatests/google/registry/documentation/FlowContext.java new file mode 100644 index 000000000..0b871c92f --- /dev/null +++ b/javatests/google/registry/documentation/FlowContext.java @@ -0,0 +1,268 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.documentation; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static com.google.common.collect.MoreCollectors.onlyElement; +import static google.registry.util.BuildPathUtils.getProjectRoot; +import static java.util.stream.Collectors.joining; + + +import com.google.common.base.Ascii; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.MultimapBuilder; +import com.google.common.collect.SetMultimap; +import com.google.common.collect.Sets; +import com.google.common.collect.Streams; +import com.thoughtworks.qdox.JavaDocBuilder; +import com.thoughtworks.qdox.model.JavaSource; +import google.registry.documentation.FlowDocumentation.ErrorCase; +import java.io.File; +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +/** + * Stores the context for a flow and computes exception mismatches between javadoc and tests. + * + *

This class uses the flow_docs library built for the documentation generator tool to pull out + * the set of flow exceptions documented by custom javadoc tags on the specified flow. It then + * derives the corresponding test files for that flow and pulls out the imported names from those + * files, checking against a set of all possible flow exceptions to determine those used by this + * particular test. The set of javadoc-based exceptions and the set of imported exceptions should + * be identical, ensuring a correspondence between error cases listed in the documentation and + * those tested in the flow unit tests. + * + *

If the two sets are not identical, the getMismatchedExceptions() method on this class will + * return a non-empty string containing messages about what the mismatches were and which lines + * need to be added or removed in which files in order to satisfy the correspondence condition. + */ +public class FlowContext { + /** Represents one of the two possible places an exception may be referenced from. */ + // TODO(b/19124943): This enum is only used in ErrorCaseMismatch and ideally belongs there, but + // can't go in the inner class because it's not a static inner class. At some point it might + // be worth refactoring so that this enum can be properly scoped. + private enum SourceType { JAVADOC, IMPORT } + + /** The package in which this flow resides. */ + final String packageName; + + /** The source file for this flow, used for help messages. */ + final String sourceFilename; + + /** The test files for this flow, used for help messages and extracting imported exceptions. */ + final Set testFilenames; + + /** The set of all possible exceptions that could be error cases for a flow. */ + final Set possibleExceptions; + + /** The set of exceptions referenced from the javadoc on this flow. */ + final Set javadocExceptions; + + /** Maps exceptions imported by the test files for this flow to the files in which they occur. */ + final SetMultimap importExceptionsToFilenames; + + /** + * Creates a FlowContext from a FlowDocumentation object and a set of all possible exceptions. + * The latter parameter is needed in order to filter imported names in the flow test file. + */ + public FlowContext(FlowDocumentation flowDoc, Set possibleExceptions) + throws IOException { + packageName = flowDoc.getPackageName(); + // Assume the standard filename conventions for locating the flow class's source file. + sourceFilename = "java/" + flowDoc.getQualifiedName().replace('.', '/') + ".java"; + testFilenames = getTestFilenames(flowDoc.getQualifiedName()); + checkState(testFilenames.size() >= 1, "No test files found for %s.", flowDoc.getName()); + this.possibleExceptions = possibleExceptions; + javadocExceptions = Sets.newHashSet(flowDoc.getErrors()); + importExceptionsToFilenames = getImportExceptions(); + } + + /** + * Helper to locate test files for this flow by looking in javatests/ for all files with the + * exact same relative filename as the flow file, but with a "*Test{,Case}.java" suffix. + */ + private static Set getTestFilenames(String flowName) throws IOException { + String commonPrefix = + getProjectRoot().resolve("javatests").resolve(flowName.replace('.', '/')).toString(); + return Sets.union( + getFilenamesMatchingGlob(commonPrefix + "*Test.java"), + getFilenamesMatchingGlob(commonPrefix + "*TestCase.java")); + } + + /** + * Helper to return the set of filenames matching the given glob. The glob should only have + * asterisks in the portion following the last slash (if there is one). + */ + private static Set getFilenamesMatchingGlob(String fullGlob) throws IOException { + Path globPath = FileSystems.getDefault().getPath(fullGlob); + Path dirPath = globPath.getParent(); + String glob = globPath.getFileName().toString(); + try (DirectoryStream dirStream = Files.newDirectoryStream(dirPath, glob)) { + return Streams.stream(dirStream).map(Object::toString).collect(toImmutableSet()); + } + } + + /** + * Returns a multimap mapping each exception imported in test files for this flow to the set of + * filenames for files that import that exception. + */ + private SetMultimap getImportExceptions() throws IOException { + ImmutableMultimap.Builder builder = new ImmutableMultimap.Builder<>(); + for (String testFileName : testFilenames) { + builder.putAll(testFileName, getImportExceptionsFromFile(testFileName)); + } + // Invert the mapping so that later we can easily map exceptions to where they were imported. + return MultimapBuilder.hashKeys().hashSetValues().build(builder.build().inverse()); + } + + /** + * Returns the set of exceptions imported in this test file. First extracts the set of + * all names imported by the test file, and then uses these to filter a global list of possible + * exceptions, so that the additional exception information available via the global list objects + * (which are ErrorCases wrapping exception names) can be preserved. + */ + private Set getImportExceptionsFromFile(String filename) throws IOException { + JavaDocBuilder builder = new JavaDocBuilder(); + JavaSource src = builder.addSource(new File(filename)); + final Set importedNames = Sets.newHashSet(src.getImports()); + return possibleExceptions + .stream() + .filter(errorCase -> importedNames.contains(errorCase.getClassName())) + .collect(toImmutableSet()); + } + + + /** + * Represents a mismatch in this flow for a specific error case and documents how to fix it. + * A mismatch occurs when the exception for this error case appears in either the source file + * javadoc or at least one matching test file, but not in both. + */ + private class ErrorCaseMismatch { + + /** The format for an import statement for a given exception name. */ + static final String IMPORT_FORMAT = "import %s;"; + + /** The format for a javadoc tag referencing a given exception name. */ + static final String JAVADOC_FORMAT = "@error {@link %s}"; + + // Template strings for printing output. + static final String TEMPLATE_HEADER = "Extra %s for %s:\n"; + static final String TEMPLATE_ADD = " Add %s to %s:\n + %s\n"; + static final String TEMPLATE_ADD_MULTIPLE = " Add %s to one or more of:\n%s + %s\n"; + static final String TEMPLATE_REMOVE = " Or remove %s in %s:\n - %s\n"; + static final String TEMPLATE_REMOVE_MULTIPLE = " Or remove %ss in:\n%s - %s\n"; + static final String TEMPLATE_MULTIPLE_FILES = " * %s\n"; + + /** The error case for which the mismatch was detected. */ + final ErrorCase errorCase; + + /** The source type where references could be added to fix the mismatch. */ + final SourceType addType; + + /** The source type where references could be removed to fix the mismatch. */ + final SourceType removeType; + + /** + * Constructs an ErrorCaseMismatch for the given ErrorCase and SourceType. The latter parameter + * indicates the source type this exception was referenced from. + */ + public ErrorCaseMismatch(ErrorCase errorCase, SourceType foundType) { + this.errorCase = errorCase; + // Effectively addType = !foundType. + addType = (foundType == SourceType.IMPORT ? SourceType.JAVADOC : SourceType.IMPORT); + removeType = foundType; + } + + /** Returns the line of code needed to refer to this exception from the given source type. */ + public String getCodeLineAs(SourceType sourceType) { + return sourceType == SourceType.JAVADOC + // Strip the flow package prefix from the exception class name if possible, for brevity. + ? String.format(JAVADOC_FORMAT, errorCase.getClassName().replace(packageName + ".", "")) + : String.format(IMPORT_FORMAT, errorCase.getClassName()); + } + + /** Helper to format a set of filenames for printing in a mismatch message. */ + private String formatMultipleFiles(Set filenames) { + checkArgument(filenames.size() >= 1, "Cannot format empty list of files."); + if (filenames.size() == 1) { + return filenames.stream().collect(onlyElement()); + } + return filenames + .stream() + .map(filename -> String.format(TEMPLATE_MULTIPLE_FILES, filename)) + .collect(joining("")); + } + + /** Helper to format the section describing how to add references to fix the mismatch. */ + private String makeAddSection() { + String addTypeString = Ascii.toLowerCase(addType.toString()); + String codeLine = getCodeLineAs(addType); + Set files = (addType == SourceType.JAVADOC + ? ImmutableSet.of(sourceFilename) + : testFilenames); + return (files.size() == 1 + ? String.format( + TEMPLATE_ADD, addTypeString, formatMultipleFiles(files), codeLine) + : String.format( + TEMPLATE_ADD_MULTIPLE, addTypeString, formatMultipleFiles(files), codeLine)); + } + + /** Helper to format the section describing how to remove references to fix the mismatch. */ + // TODO(b/19124943): Repeating structure from makeAddSection() - would be nice to clean up. + private String makeRemoveSection() { + String removeTypeString = Ascii.toLowerCase(removeType.toString()); + String codeLine = getCodeLineAs(removeType); + Set files = (removeType == SourceType.JAVADOC + ? ImmutableSet.of(sourceFilename) + : importExceptionsToFilenames.get(errorCase)); + return (files.size() == 1 + ? String.format( + TEMPLATE_REMOVE, removeTypeString, formatMultipleFiles(files), codeLine) + : String.format( + TEMPLATE_REMOVE_MULTIPLE, removeTypeString, formatMultipleFiles(files), codeLine)); + } + + /** Returns a string describing the mismatch for this flow exception and how to fix it. */ + @Override + public String toString() { + String headerSection = String.format( + TEMPLATE_HEADER, Ascii.toLowerCase(removeType.toString()), errorCase.getName()); + return headerSection + makeAddSection() + makeRemoveSection(); + } + } + + /** + * Returns a single string describing all mismatched exceptions for this flow. An empty string + * means no mismatched exceptions were found. + */ + public String getMismatchedExceptions() { + Set importExceptions = importExceptionsToFilenames.keySet(); + StringBuilder builder = new StringBuilder(); + for (ErrorCase errorCase : Sets.difference(javadocExceptions, importExceptions)) { + builder.append(new ErrorCaseMismatch(errorCase, SourceType.JAVADOC)).append("\n"); + } + for (ErrorCase errorCase : Sets.difference(importExceptions, javadocExceptions)) { + builder.append(new ErrorCaseMismatch(errorCase, SourceType.IMPORT)).append("\n"); + } + return builder.toString(); + } +} diff --git a/javatests/google/registry/documentation/FlowDocumentationTest.java b/javatests/google/registry/documentation/FlowDocumentationTest.java new file mode 100644 index 000000000..00e5178e9 --- /dev/null +++ b/javatests/google/registry/documentation/FlowDocumentationTest.java @@ -0,0 +1,58 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.documentation; + +import static com.google.common.truth.Truth.assert_; +import static google.registry.util.BuildPathUtils.getProjectRoot; + +import com.google.common.base.Joiner; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests to ensure that generated flow documentation matches the expected documentation. */ +@RunWith(JUnit4.class) +public class FlowDocumentationTest { + private static final Path GOLDEN_MARKDOWN_FILEPATH = getProjectRoot().resolve("docs/flows.md"); + + private static final String UPDATE_COMMAND = "./gradlew :core:flowDocsTool"; + private static final String UPDATE_INSTRUCTIONS = + Joiner.on('\n') + .join( + "", + "-----------------------------------------------------------------------------------", + "Your changes affect the flow API documentation output. To update the golden version " + + "of the documentation, run:", + UPDATE_COMMAND, + ""); + + @Test + public void testGeneratedMatchesGolden() throws IOException { + // Read the markdown file. + Path goldenMarkdownPath = + GOLDEN_MARKDOWN_FILEPATH; + + String goldenMarkdown = new String(Files.readAllBytes(goldenMarkdownPath), "UTF-8"); + + // Don't use Truth's isEqualTo() because the output is huge and unreadable for large files. + DocumentationGenerator generator = new DocumentationGenerator(); + if (!generator.generateMarkdown().equals(goldenMarkdown)) { + assert_().fail(UPDATE_INSTRUCTIONS); + } + } +} diff --git a/javatests/google/registry/documentation/FlowExceptionsTest.java b/javatests/google/registry/documentation/FlowExceptionsTest.java new file mode 100644 index 000000000..4ff58786e --- /dev/null +++ b/javatests/google/registry/documentation/FlowExceptionsTest.java @@ -0,0 +1,76 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.documentation; + +import static com.google.common.truth.Truth.assertWithMessage; + +import com.google.common.collect.Sets; +import com.google.common.flogger.FluentLogger; +import google.registry.documentation.FlowDocumentation.ErrorCase; +import java.io.IOException; +import java.util.Set; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Test to ensure accurate documentation of flow exceptions. + * + *

This test goes through each flow and ensures that the exceptions listed in custom javadoc + * tags on the flow class match the import statements in the test case for that flow. Thus it + * catches the case where someone adds a test case for an exception without updating the javadoc, + * and the case where someone adds a javadoc tag to a flow without writing a test for this error + * condition. For example, there should always be a matching pair of lines such as the following: + * + *

+ *   java/.../flows/session/LoginFlow.java:
+ *     @error {@link AlreadyLoggedInException}
+ *
+ *   javatests/.../flows/session/LoginFlowTest.java:
+ *     import .....flows.session.LoginFlow.AlreadyLoggedInException;
+ * 
+ * + * If the first line is missing, this test fails and suggests adding the javadoc tag or removing + * the import. If the second line is missing, this test fails and suggests adding the import or + * removing the javadoc tag. + */ +@SuppressWarnings("javadoc") +@RunWith(JUnit4.class) +public class FlowExceptionsTest { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + @Test + public void testExceptionCorrespondence() throws IOException { + DocumentationGenerator docGenerator = new DocumentationGenerator(); + Set possibleErrors = Sets.newHashSet(docGenerator.getAllErrors()); + Set mismatchingFlows = Sets.newHashSet(); + for (FlowDocumentation flow : docGenerator.getFlowDocs()) { + FlowContext context = new FlowContext(flow, possibleErrors); + String mismatches = context.getMismatchedExceptions(); + if (!mismatches.isEmpty()) { + logger.atWarning().log("%-40s FAIL\n\n%s", flow.getName(), mismatches); + mismatchingFlows.add(flow.getName()); + } else { + logger.atInfo().log("%-40s OK", flow.getName()); + } + } + assertWithMessage( + "Mismatched exceptions between flow documentation and tests. See test log for full " + + "details. The set of failing flows follows.") + .that(mismatchingFlows) + .isEmpty(); + } +} diff --git a/javatests/google/registry/documentation/MarkdownDocumentationFormatterTest.java b/javatests/google/registry/documentation/MarkdownDocumentationFormatterTest.java new file mode 100644 index 000000000..cd2ad419f --- /dev/null +++ b/javatests/google/registry/documentation/MarkdownDocumentationFormatterTest.java @@ -0,0 +1,92 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.documentation; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.testing.JUnitBackports.assertThrows; + +import java.util.Arrays; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Test conversion of javadocs to markdown. */ +@RunWith(JUnit4.class) +public class MarkdownDocumentationFormatterTest { + @Test + public void testHtmlSanitization() { + assertThat( + MarkdownDocumentationFormatter.fixHtml( + "First.

Second. < > & &squot; "")) + .isEqualTo("First. Second. < > & ' \""); + assertThat(MarkdownDocumentationFormatter.fixHtml("

Leading substitution.")) + .isEqualTo("Leading substitution."); + assertThat(MarkdownDocumentationFormatter.fixHtml("No substitution.")) + .isEqualTo("No substitution."); + } + + @Test + public void testDedents() { + assertThat(MarkdownDocumentationFormatter.fixHtml( + "First line\n\n

Second line.\n Third line.")) + .isEqualTo("First line\n\nSecond line.\nThird line."); + } + + @Test + public void testUnknownSequences() { + assertThrows( + IllegalArgumentException.class, () -> MarkdownDocumentationFormatter.fixHtml("&blech;")); + } + + @Test + public void testParagraphFormatting() { + String[] words = {"first", "second", "third", "really-really-long-word", "more", "stuff"}; + String formatted = MarkdownDocumentationFormatter.formatParagraph(Arrays.asList(words), 16); + assertThat(formatted).isEqualTo("first second\nthird\nreally-really-long-word\nmore stuff\n"); + } + + @Test + public void testReflow() { + String input = + "This is the very first line.\n" + + " \n" // add a little blank space to this line just to make things interesting. + + "This is the second paragraph. Aint\n" + + "it sweet?\n" + + "\n" + + "This is our third and final paragraph.\n" + + "It is multi-line and ends with no blank\n" + + "line."; + + String expected = + "This is the very\n" + + "first line.\n" + + "\n" + + "This is the\n" + + "second\n" + + "paragraph. Aint\n" + + "it sweet?\n" + + "\n" + + "This is our\n" + + "third and final\n" + + "paragraph. It is\n" + + "multi-line and\n" + + "ends with no\n" + + "blank line.\n"; + + assertThat(MarkdownDocumentationFormatter.reflow(input, 16)).isEqualTo(expected); + } + + public MarkdownDocumentationFormatterTest() {} +} diff --git a/javatests/google/registry/server/RegistryTestServer.java b/javatests/google/registry/server/RegistryTestServer.java index 7bd9ac690..8f7dd90b6 100644 --- a/javatests/google/registry/server/RegistryTestServer.java +++ b/javatests/google/registry/server/RegistryTestServer.java @@ -15,6 +15,8 @@ package google.registry.server; import static google.registry.server.Route.route; +import static google.registry.util.BuildPathUtils.getProjectRoot; +import static google.registry.util.BuildPathUtils.getResourcesDir; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -25,27 +27,22 @@ import google.registry.module.backend.BackendServlet; import google.registry.module.frontend.FrontendServlet; import java.net.URL; import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Optional; import javax.servlet.Filter; /** Lightweight HTTP server for testing the Nomulus Admin and Registrar consoles. */ public final class RegistryTestServer { - // In most cases, the current working dir is ${projectRoot}/gradle/${subproject} - private static final String PROJECT_ROOT = - Optional.ofNullable(System.getProperty("test.projectRoot")).orElse("../../"); - private static final String RESOURCES_DIR = - Optional.ofNullable(System.getProperty("test.resourcesDir")).orElse("build/resources/main"); + private static final Path PROJECT_ROOT = getProjectRoot(); + private static final Path RESOURCES_DIR = getResourcesDir(); public static final ImmutableMap RUNFILES = new ImmutableMap.Builder() - .put("/index.html", Paths.get(PROJECT_ROOT, "/java/google/registry/ui/html/index.html")) - .put("/error.html", Paths.get(PROJECT_ROOT, "/java/google/registry/ui/html/error.html")) - .put("/assets/js/*", Paths.get(RESOURCES_DIR, "/google/registry/ui")) - .put("/assets/css/*", Paths.get(RESOURCES_DIR, "/google/registry/ui/css")) - .put("/assets/sources/*", Paths.get(PROJECT_ROOT)) - .put("/assets/*", Paths.get(PROJECT_ROOT, "/java/google/registry/ui/assets")) + .put("/index.html", PROJECT_ROOT.resolve("java/google/registry/ui/html/index.html")) + .put("/error.html", PROJECT_ROOT.resolve("java/google/registry/ui/html/error.html")) + .put("/assets/js/*", RESOURCES_DIR.resolve("google/registry/ui")) + .put("/assets/css/*", RESOURCES_DIR.resolve("google/registry/ui/css")) + .put("/assets/sources/*", PROJECT_ROOT) + .put("/assets/*", PROJECT_ROOT.resolve("java/google/registry/ui/assets")) .build(); private static final ImmutableList ROUTES =