// 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 static java.nio.charset.StandardCharsets.UTF_8; 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.google.re2j.Matcher; import com.google.re2j.Pattern; import com.sun.source.doctree.DocCommentTree; import com.sun.source.doctree.DocTree; import com.sun.source.doctree.DocTree.Kind; import com.sun.source.doctree.LinkTree; import com.sun.source.doctree.ReferenceTree; import com.sun.source.doctree.UnknownBlockTagTree; import google.registry.flows.EppException; import google.registry.model.eppoutput.Result.Code; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.NoSuchElementException; import java.util.StringJoiner; import java.util.TreeMap; import javax.annotation.Nullable; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.AnnotationValue; import javax.lang.model.element.Modifier; import javax.lang.model.element.TypeElement; import javax.lang.model.type.TypeMirror; import javax.lang.model.util.Elements; import javax.lang.model.util.Types; import jdk.javadoc.doclet.DocletEnvironment; /** * Class to represent documentation information for a single EPP flow. * *

The static method {@link DocumentationGenerator#getFlowDocs} returns a list of {@link * 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"; /** Root of the source doclet environment. */ private final DocletEnvironment sourceRoot; /** Type Element of the class. */ private final TypeElement typeElement; /** Doc tree for the flow. */ private final DocCommentTree docTree; /** 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 {@link FlowDocumentation} for this flow class using data from javadoc tags. Not * public because clients should get FlowDocumentation objects via the DocumentationGenerator * class. */ protected FlowDocumentation(TypeElement typeElement, DocletEnvironment sourceRoot) { this.sourceRoot = sourceRoot; this.typeElement = typeElement; this.docTree = sourceRoot.getDocTrees().getDocCommentTree(typeElement); errors = new ArrayList<>(); // Store error codes in sorted order, and leave reasons in insert order. errorsByCode = Multimaps.newListMultimap(new TreeMap<>(), ArrayList::new); parseTags(); } /** Name of the class for this flow. */ public String getName() { return typeElement.getSimpleName().toString(); } /** Fully qualified name of the class for this flow. */ public String getQualifiedName() { return typeElement.getQualifiedName().toString(); } /** Name of the package in which this flow resides. */ public String getPackageName() { return sourceRoot.getElementUtils().getPackageOf(typeElement).getQualifiedName().toString(); } /** Javadoc of the class. */ public String getDocTree() { StringJoiner joiner = new StringJoiner(""); docTree.getFullBody().forEach(dt -> joiner.add(dt.toString())); return joiner.toString(); } 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() { for (DocTree tag : docTree.getBlockTags()) { if (tag.getKind() == DocTree.Kind.UNKNOWN_BLOCK_TAG) { UnknownBlockTagTree unknownBlockTagTree = (UnknownBlockTagTree) tag; // Everything else is not a relevant tag. if (unknownBlockTagTree.getTagName().equals("error")) { parseErrorTag(unknownBlockTagTree); } } } } /** 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, TypeElement typeElement, UnknownBlockTagTree tagTree) { return String.format( "Bad @error tag format (%s) in class %s - %s", tagTree.toString(), typeElement.getQualifiedName(), reason); } private BadErrorTagFormatException( String reason, TypeElement typeElement, UnknownBlockTagTree tagTree) { super(makeMessage(reason, typeElement, tagTree)); } private BadErrorTagFormatException( String reason, TypeElement typeElement, UnknownBlockTagTree tagTree, Exception cause) { super(makeMessage(reason, typeElement, tagTree), cause); } } /** Parses a javadoc tag corresponding to an error case and updates the error mapping. */ private void parseErrorTag(UnknownBlockTagTree tagTree) { // Parse the @error tag text to find the @link inline tag. LinkTree linkedTag; try { linkedTag = tagTree.getContent().stream() .filter(docTree -> docTree.getKind() == Kind.LINK) .map(LinkTree.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"), tagTree.toString()), typeElement, tagTree, e); } // Check to see if the @link tag references a valid class. ReferenceTree referenceTree = linkedTag.getReference(); TypeElement referencedTypeElement = getReferencedElement(referenceTree); if (referencedTypeElement == null) { throw new BadErrorTagFormatException( "could not resolve class from @link tag text: " + linkedTag.toString(), typeElement, tagTree); } // Try to convert the referenced class into an ErrorCase; fail if it's not an EppException. ErrorCase error; try { DocCommentTree docCommentTree = sourceRoot.getDocTrees().getDocCommentTree(referencedTypeElement); error = new ErrorCase(referencedTypeElement, docCommentTree, sourceRoot.getTypeUtils()); } catch (IllegalStateException | IllegalArgumentException e) { throw new BadErrorTagFormatException( "class referenced in @link is not a valid EppException: " + referencedTypeElement.getQualifiedName(), typeElement, tagTree, e); } // Success; store this as a parsed error case. errors.add(error); errorsByCode.put(error.getCode(), error); } /** * Try to find the {@link TypeElement} of the class in the {@link ReferenceTree}. * *

Unfortunately the new Javadoc API doesn't expose the referenced class object directly, so we * have to find it by trying to find out its fully qualified class name and then loading it from * the {@link Elements}. */ private TypeElement getReferencedElement(ReferenceTree referenceTree) { String signature = referenceTree.getSignature(); Elements elements = sourceRoot.getElementUtils(); TypeElement referencedTypeElement = elements.getTypeElement(signature); // If the signature is already a qualified class name, we should find it directly. Otherwise // only the simple class name is used in the @error tag and we try to find its package name. if (referencedTypeElement == null) { // First try if the error class is in the same package as the flow class that we are // processing. referencedTypeElement = elements.getTypeElement(String.format("%s.%s", getPackageName(), signature)); } if (referencedTypeElement == null) { // Then try if the error class is a nested class of the flow class that we are processing. referencedTypeElement = elements.getTypeElement(String.format("%s.%s", getQualifiedName(), signature)); } if (referencedTypeElement == null) { // Lastly, the error class must have been imported. We read the flow class file, and try to // find the import statement that ends with the simple class name. String currentClassFilename = String.format( "%s/%s.java", JavadocWrapper.SOURCE_PATH, getQualifiedName().replaceAll("\\.", "\\/")); String unusedClassFileContent; try { unusedClassFileContent = Files.readString(Path.of(currentClassFilename), UTF_8); } catch (IOException e) { throw new RuntimeException(e); } // To understand this regex: the import statement must start with a new line or a semicolon, // followed by any number of whitespaces, the word "import" (we don't consider static import), // any number of whitespaces, repeats of "\w*." (this is not exactly precise, but for all // well-named classes it should suffice), the signature, any number of whitespaces, and // finally an ending semicolon. "?:" is used to designate non-capturing groups as we are only // interested in capturing the fully qualified class name. Pattern pattern = Pattern.compile(String.format("(?:\\n|;)\\s*import\\s+((?:\\w*\\.)*%s)\\s*;", signature)); Matcher matcher = pattern.matcher(unusedClassFileContent); if (matcher.find()) { referencedTypeElement = elements.getTypeElement(matcher.group(1)); } } return referencedTypeElement; } /** * 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 {@link EppException} subclass that * gets thrown to indicate an error condition. It overrides {@code equals()} and {@code * 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; /** Utility class to convert {@link TypeMirror} to {@link TypeElement}. */ private final Types types; /** 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(TypeElement typeElement, DocCommentTree commentTree, Types types) { name = typeElement.getSimpleName().toString(); className = typeElement.getQualifiedName().toString(); // The javadoc comment on the class explains the reason for the error condition. reason = commentTree.getFullBody().toString(); this.types = types; TypeElement highLevelExceptionTypeElement = getHighLevelExceptionFrom(typeElement); errorCode = extractErrorCode(highLevelExceptionTypeElement); checkArgument( !typeElement.getModifiers().contains(Modifier.ABSTRACT), "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 TypeElement getHighLevelExceptionFrom(TypeElement typeElement) { // While we're not yet at the root, move up the class hierarchy looking for EppException. while (typeElement.getSuperclass() != null) { TypeElement superClassTypeElement = (TypeElement) types.asElement(typeElement.getSuperclass()); if (superClassTypeElement.getQualifiedName().toString().equals(EXCEPTION_CLASS_NAME)) { return typeElement; } typeElement = superClassTypeElement; } // 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(TypeElement typeElement) { try { // We're looking for a specific annotation by name that should appear only once. AnnotationMirror errorCodeAnnotation = typeElement.getAnnotationMirrors().stream() .filter(anno -> anno.getAnnotationType().toString().equals(CODE_ANNOTATION_NAME)) .findFirst() .get(); // The annotation should have one element whose value converts to an EppResult.Code. AnnotationValue value = errorCodeAnnotation.getElementValues().entrySet().iterator().next().getValue(); return Code.valueOf(value.getValue().toString()).code; } catch (IllegalStateException e) { throw new IllegalStateException( "No error code annotation found on exception " + typeElement.getQualifiedName(), e); } catch (ArrayIndexOutOfBoundsException | ClassCastException | IllegalArgumentException e) { throw new IllegalStateException( "Bad annotation on exception " + typeElement.getQualifiedName(), 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(); } } }