Enable flow documentation in external build

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=241934689
This commit is contained in:
shicong 2019-04-04 08:29:03 -07:00 committed by jianglai
parent 9b80b31917
commit 387042bf3a
34 changed files with 1412 additions and 15 deletions

View file

@ -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"

View file

@ -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()
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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',

View file

@ -4,3 +4,4 @@ uploaderDestination=
uploaderCredentialsFile=
uploaderMultithreadedUpload=
mavenCoordinateFile=
flowDocsFile=

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<FlowDocumentation> 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<ErrorCase> 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<ClassDoc> getConcreteSubclassesStream(String baseClassName) {
final ClassDoc baseFlowClassDoc = sourceRoot.classNamed(baseClassName);
return Arrays.stream(sourceRoot.classes())
.filter(classDoc -> classDoc.subclassOf(baseFlowClassDoc) && !classDoc.isAbstract());
}
}

View file

@ -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.
*
* <p>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<ErrorCase> errors;
/** Javadoc-tagged error conditions for this flow, organized by underlying error code. */
private final ListMultimap<Long, ErrorCase> 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<Long, Collection<ErrorCase>>(), 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<ErrorCase> getErrors() {
return ImmutableList.copyOf(errors);
}
public ImmutableMultimap<Long, ErrorCase> 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.
*
* <p>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();
}
}
}

View file

@ -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.
*
* <p>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);
}
}
}
}

View file

@ -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<String> SOURCE_FILE_NAMES = ImmutableSet.of();
/** Specific packages to generate documentation for. */
private static final ImmutableSet<String> 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.
*
* <p>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.
*
* <p>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 <a href="http://relation.to/12969.lace">Testing Java doclets</a>
* @see <a href="http://www.docjar.com/docs/api/com/sun/tools/javadoc/JavadocTool.html">JavadocTool</a>
*/
private static RootDoc createRootDoc(
@Nullable String sourcePath,
Collection<String> packageNames,
Collection<String> 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<File> 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<String> subPackages = new ListBuffer<>();
subPackages.addAll(packageNames);
ListBuffer<String> 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() {}
}

View file

@ -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 "<p>":
// <p> is simply removed.
break;
case "&amp;":
result.append("&");
break;
case "&lt;":
result.append("<");
break;
case "&gt;":
result.append(">");
break;
case "&squot;":
result.append("'");
break;
case "&quot;":
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<String> 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<String> 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<FlowDocumentation> 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() {}
}

View file

@ -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}"

View file

@ -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() {}
}

View file

@ -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.
*
* <p>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.
*
* <p>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<String> testFilenames;
/** The set of all possible exceptions that could be error cases for a flow. */
final Set<ErrorCase> possibleExceptions;
/** The set of exceptions referenced from the javadoc on this flow. */
final Set<ErrorCase> javadocExceptions;
/** Maps exceptions imported by the test files for this flow to the files in which they occur. */
final SetMultimap<ErrorCase, String> 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<ErrorCase> 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<String> 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<String> getFilenamesMatchingGlob(String fullGlob) throws IOException {
Path globPath = FileSystems.getDefault().getPath(fullGlob);
Path dirPath = globPath.getParent();
String glob = globPath.getFileName().toString();
try (DirectoryStream<Path> 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<ErrorCase, String> getImportExceptions() throws IOException {
ImmutableMultimap.Builder<String, ErrorCase> 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<ErrorCase> getImportExceptionsFromFile(String filename) throws IOException {
JavaDocBuilder builder = new JavaDocBuilder();
JavaSource src = builder.addSource(new File(filename));
final Set<String> 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<String> 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<String> 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<String> 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<ErrorCase> 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();
}
}

View file

@ -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);
}
}
}

View file

@ -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.
*
* <p>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:
*
* <pre>
* java/.../flows/session/LoginFlow.java:
* @error {&#64;link AlreadyLoggedInException}
*
* javatests/.../flows/session/LoginFlowTest.java:
* import .....flows.session.LoginFlow.AlreadyLoggedInException;
* </pre>
*
* 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<ErrorCase> possibleErrors = Sets.newHashSet(docGenerator.getAllErrors());
Set<String> 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();
}
}

View file

@ -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. <p>Second. &lt; &gt; &amp; &squot; &quot;"))
.isEqualTo("First. Second. < > & ' \"");
assertThat(MarkdownDocumentationFormatter.fixHtml("<p>Leading substitution."))
.isEqualTo("Leading substitution.");
assertThat(MarkdownDocumentationFormatter.fixHtml("No substitution."))
.isEqualTo("No substitution.");
}
@Test
public void testDedents() {
assertThat(MarkdownDocumentationFormatter.fixHtml(
"First line\n\n <p>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() {}
}

View file

@ -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<String, Path> RUNFILES =
new ImmutableMap.Builder<String, Path>()
.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<Route> ROUTES =