ApiDocGenerator.java
/*
* The contents of this file are subject to the terms of the Common Development and
* Distribution License (the License). You may not use this file except in compliance with the
* License.
*
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
* specific language governing permission and limitations under the License.
*
* When distributing Covered Software, include this CDDL Header Notice in each file and include
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2016 ForgeRock AS.
* Portions Copyright 2018 Wren Security.
*/
package org.forgerock.api.markup;
import static java.util.Collections.unmodifiableList;
import static org.forgerock.api.markup.asciidoc.AsciiDoc.asciiDoc;
import static org.forgerock.api.markup.asciidoc.AsciiDoc.normalizeName;
import static org.forgerock.api.markup.asciidoc.AsciiDocTable.COLUMN_WIDTH_MEDIUM;
import static org.forgerock.api.markup.asciidoc.AsciiDocTable.COLUMN_WIDTH_SMALL;
import static org.forgerock.api.markup.asciidoc.AsciiDocTableColumnStyles.ASCII_DOC_CELL;
import static org.forgerock.api.markup.asciidoc.AsciiDocTableColumnStyles.MONO_CELL;
import static org.forgerock.api.util.PathUtil.buildPath;
import static org.forgerock.api.util.PathUtil.buildPathParameters;
import static org.forgerock.api.util.PathUtil.mergeParameters;
import static org.forgerock.api.util.ValidationUtil.isEmpty;
import static org.forgerock.util.Reject.checkNotNull;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.forgerock.api.enums.CountPolicy;
import org.forgerock.api.enums.CreateMode;
import org.forgerock.api.enums.PagingMode;
import org.forgerock.api.enums.PatchOperation;
import org.forgerock.api.enums.Stability;
import org.forgerock.api.jackson.JacksonUtils;
import org.forgerock.api.markup.asciidoc.AsciiDoc;
import org.forgerock.api.markup.asciidoc.AsciiDocTable;
import org.forgerock.api.models.Action;
import org.forgerock.api.models.ApiDescription;
import org.forgerock.api.models.ApiError;
import org.forgerock.api.models.Create;
import org.forgerock.api.models.Items;
import org.forgerock.api.models.Operation;
import org.forgerock.api.models.Parameter;
import org.forgerock.api.models.Patch;
import org.forgerock.api.models.Paths;
import org.forgerock.api.models.Query;
import org.forgerock.api.models.Reference;
import org.forgerock.api.models.Resource;
import org.forgerock.api.models.Schema;
import org.forgerock.api.models.Services;
import org.forgerock.api.models.SubResources;
import org.forgerock.api.models.VersionedPath;
import org.forgerock.api.util.ReferenceResolver;
import org.forgerock.api.util.ValidationUtil;
import org.forgerock.http.routing.Version;
import org.forgerock.util.i18n.LocalizableString;
import org.forgerock.util.i18n.PreferredLocales;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Generates static AsciiDoc documentation for CREST API Descriptors.
*/
public final class ApiDocGenerator {
private static final ObjectMapper OBJECT_MAPPER = JacksonUtils.createGenericMapper();
private static final PreferredLocales PREFERRED_LOCALES = new PreferredLocales();
enum CrestMethod {
CREATE, READ, UPDATE, DELETE, PATCH, ACTION, QUERY
}
/**
* Regex pattern that matches services names with format,
* <ul>
* <li>name:1</li>
* <li>name:1.0</li>
* </ul>
* Where match group 1 contains the {@code name} and group 2 contains the version.
*/
private static final Pattern SERVICE_NAME_PATTERN = Pattern.compile("^([^:]+)[:](\\d(?:\\.\\d)?)$");
/**
* {@code .adoc} file extension for generated AsciiDoc files.
*/
private static final String ADOC_EXTENSION = ".adoc";
/**
* {@code true} when there is no {@link #outputDirPath filesystem} to write to, and we must build the complete
* AsciiDoc as a string.
*/
private final boolean inMemoryMode;
/**
* Root output directory.
*/
private final Path outputDirPath;
/**
* Optional input directory or {@code null}.
*/
private final Path inputDirPath;
/**
* Group doc sections by path-name, version, resource-ADOC-filename.
*/
private final Map<String, Map<Version, String>> pathTree;
/**
* Lookup map of rendered AsciiDoc string, for in-memory mode, from filename (key) to AsciiDoc (value).
*/
private final Map<String, String> adocMap;
/**
* Entries from {@link ApiDescription#getServices()} that have been referenced, whereas unreferenced services
* will be listed under the {@code <undefined>} path in the documentation.
*/
private final Set<Resource> referencedServices;
private final ApiDescription apiDescription;
private final ReferenceResolver referenceResolver;
/**
* Constructor that sets the root output directory for AsciiDoc files, which will be created if it does not exist,
* and an input directory used for overriding AsciiDoc files (e.g., descriptions).
*
* @param inputDirPath Input directory or {@code null}
* @param outputDirPath Root output directory or {@code null} for in-memory mode
*/
private ApiDocGenerator(final ApiDescription apiDescription, final Path inputDirPath, final Path outputDirPath,
final ApiDescription... externalApiDescriptions) {
pathTree = new HashMap<>();
adocMap = new HashMap<>();
referencedServices = new HashSet<>();
this.apiDescription = checkNotNull(apiDescription, "apiDescription required");
this.inputDirPath = inputDirPath;
this.outputDirPath = outputDirPath;
inMemoryMode = outputDirPath == null;
if (outputDirPath != null && outputDirPath.equals(inputDirPath)) {
throw new ApiDocGeneratorException("inputDirPath and outputDirPath can not be equal");
}
referenceResolver = new ReferenceResolver(apiDescription);
referenceResolver.registerAll(externalApiDescriptions);
}
/**
* Generates AsciiDoc documentation for a CREST API Descriptor, to an output-directory.
*
* @param title API title
* @param apiDescription API Description
* @param externalApiDescriptions External CREST API Descriptions, for resolving {@link Reference}s, or {@code null}
* @param inputDirPath Input directory or {@code null} if not overriding ADOC files
* @param outputDirPath Root output directory
*/
public static void execute(final String title, final ApiDescription apiDescription,
final Path inputDirPath, final Path outputDirPath, final ApiDescription... externalApiDescriptions) {
final ApiDocGenerator thisInstance = new ApiDocGenerator(apiDescription, inputDirPath, outputDirPath,
externalApiDescriptions);
thisInstance.doExecute(title);
}
/**
* Generates AsciiDoc documentation for a CREST API Descriptor, to a {@code String}.
*
* @param title API title
* @param apiDescription API Description
* @param externalApiDescriptions External CREST API Descriptions, for resolving {@link Reference}s, or {@code null}
* @param inputDirPath Input directory or {@code null} if not overriding ADOC files
* @return Resulting AsciiDoc markup as a {@code String}
*/
public static String execute(final String title, final ApiDescription apiDescription,
final Path inputDirPath, final ApiDescription... externalApiDescriptions) {
final ApiDocGenerator thisInstance = new ApiDocGenerator(apiDescription, inputDirPath, null,
externalApiDescriptions);
final String rootFilename = thisInstance.doExecute(title);
return thisInstance.toString(rootFilename);
}
private String doExecute(final String title) {
final String namespace = apiDescription.getId();
try {
final String pathsFilename = outputPaths(namespace);
return outputRoot(checkNotNull(title, "title is required"), pathsFilename, namespace);
} catch (IOException e) {
throw new ApiDocGeneratorException("Unable to output doc file", e);
}
}
/**
* Outputs a top-level AsciiDoc file that imports all other second-level files generated by this class.
*
* @param title API title
* @param pathsFilename Paths file-path suitable for AsciiDoc import-statement
* @param parentNamespace Parent namespace
* @return File path suitable for AsciiDoc import-statement
* @throws IOException Unable to output AsciiDoc file
*/
private String outputRoot(final String title, final String pathsFilename, final String parentNamespace)
throws IOException {
final String namespace = normalizeName(parentNamespace, "index");
final AsciiDoc pathsDoc = asciiDoc()
.documentTitle(title)
.rawParagraph(asciiDoc().rawText("*API ID:* ").mono(apiDescription.getId()).toString())
.rawParagraph(asciiDoc().rawText("*API Version:* ").mono(apiDescription.getVersion()).toString())
.rawLine(":toc: left")
.rawLine(":toclevels: 5")
.newline();
final String description = toTranslatedString(apiDescription.getDescription());
final String descriptionFilename = outputDescriptionBlock(description, namespace);
pathsDoc.include(descriptionFilename);
if (pathsFilename != null) {
pathsDoc.include(pathsFilename);
}
final String filename = namespace + ADOC_EXTENSION;
if (inMemoryMode) {
adocMap.put(filename, pathsDoc.toString());
} else {
pathsDoc.toFile(outputDirPath, filename);
}
return filename;
}
/**
* Outputs an AsciiDoc file for a description-block. The file will be blank when no description is defined.
* <p>
* This method will use a replacement-file from {@link #inputDirPath}, if it exists, to override the description.
* </p>
*
* @param defaultDescription Default description for the block or {@code null}
* @param parentNamespace Parent namespace
* @return File path suitable for AsciiDoc import-statement
* @throws IOException Unable to output AsciiDoc file
*/
private String outputDescriptionBlock(final String defaultDescription, final String parentNamespace)
throws IOException {
final String namespace = normalizeName(parentNamespace, "description");
final String filename = namespace + ADOC_EXTENSION;
if (!copyReplacementFile(filename)) {
final AsciiDoc blockDoc = asciiDoc();
if (!isEmpty(defaultDescription)) {
blockDoc.rawParagraph(defaultDescription);
}
if (inMemoryMode) {
adocMap.put(filename, blockDoc.toString());
} else {
blockDoc.toFile(outputDirPath, filename);
}
}
return filename;
}
/**
* Outputs an AsciiDoc file for each path, which imports a file for each version under that path, and another
* file that imports each path.
*
* @param parentNamespace Parent namespace
* @return File path suitable for AsciiDoc import-statement
* @throws IOException Unable to output AsciiDoc file
*/
private String outputPaths(final String parentNamespace) throws IOException {
final String allPathsDocNamespace = normalizeName(parentNamespace, "paths");
final AsciiDoc allPathsDoc = asciiDoc()
.sectionTitle1("Paths");
final Paths paths = apiDescription.getPaths();
final List<String> pathNames = new ArrayList<>(paths.getNames());
Collections.sort(pathNames);
for (final String pathName : pathNames) {
// path
final String pathDocNamespace = normalizeName(allPathsDocNamespace, pathName);
final VersionedPath versionedPath = paths.get(pathName);
final List<Version> versions = new ArrayList<>(versionedPath.getVersions());
Collections.sort(versions);
for (final Version version : versions) {
final String versionDocNamespace;
if (VersionedPath.UNVERSIONED.equals(version)) {
versionDocNamespace = pathDocNamespace;
} else {
// version
final String versionName = version.toString();
versionDocNamespace = normalizeName(pathDocNamespace, versionName);
}
// resource path
Resource resource = versionedPath.get(version);
if (resource.getReference() != null) {
resource = referenceResolver.getService(resource.getReference());
referencedServices.add(resource);
}
final String resourceFilename = outputResource(pathName, version, resource,
Collections.<Parameter>emptyList(), versionDocNamespace);
addPathResource(pathName, version, resourceFilename);
}
}
outputUndefinedServices(allPathsDocNamespace);
// output paths and versions by traversing pathTree
final List<String> pathKeys = new ArrayList<>(pathTree.keySet());
Collections.sort(pathKeys);
for (final String pathKey : pathKeys) {
allPathsDoc.sectionTitle2(asciiDoc().mono(pathKey).toString());
final Map<Version, String> versionMap = pathTree.get(pathKey);
for (final Map.Entry<Version, String> entry : versionMap.entrySet()) {
if (!VersionedPath.UNVERSIONED.equals(entry.getKey())) {
allPathsDoc.sectionTitle3(asciiDoc().mono(entry.getKey().toString()).toString());
}
allPathsDoc.include(entry.getValue());
}
}
// output all-paths-file
final String filename = allPathsDocNamespace + ADOC_EXTENSION;
if (inMemoryMode) {
adocMap.put(filename, allPathsDoc.toString());
} else {
allPathsDoc.toFile(outputDirPath, filename);
}
return filename;
}
/**
* Search for unreferenced services, and add them to the documentation under the {@code <undefined>} path.
*
* @param parentNamespace Parent namespace
* @throws IOException Unable to output AsciiDoc file
*/
private void outputUndefinedServices(final String parentNamespace) throws IOException {
//
Services services = apiDescription.getServices();
if (services != null && !services.getNames().isEmpty()) {
for (final String name : apiDescription.getServices().getNames()) {
final Resource resource = apiDescription.getServices().get(name);
if (!referencedServices.contains(resource)) {
// parse service version, from end of name, if provided
final Matcher m = SERVICE_NAME_PATTERN.matcher(name);
final String serviceName;
final Version serviceVersion;
if (m.matches()) {
serviceName = m.group(1);
serviceVersion = Version.version(m.group(2));
} else {
serviceName = name;
serviceVersion = VersionedPath.UNVERSIONED;
}
final String pathDocNamespace = normalizeName(parentNamespace, "<undefined>");
final String versionDocNamespace;
if (VersionedPath.UNVERSIONED.equals(serviceVersion)) {
versionDocNamespace = pathDocNamespace;
} else {
// version
final String versionName = serviceVersion.toString();
versionDocNamespace = normalizeName(pathDocNamespace, versionName);
}
final String pathName = "<undefined>/" + serviceName;
final String resourceFilename = outputResource(pathName, serviceVersion, resource,
Collections.<Parameter>emptyList(), versionDocNamespace);
addPathResource(pathName, serviceVersion, resourceFilename);
}
}
}
}
private String outputResource(final String pathName, final Version version, final Resource resource,
final List<Parameter> parameters, final String parentNamespace) throws IOException {
final boolean unversioned = VersionedPath.UNVERSIONED.equals(version);
final int sectionLevel = unversioned ? 3 : 4;
final String namespace = normalizeName(parentNamespace, "resource");
final AsciiDoc resourceDoc = asciiDoc();
final String descriptionFilename = outputDescriptionBlock(
toTranslatedString(resource.getDescription()), namespace);
resourceDoc.include(descriptionFilename);
outputOperation(CrestMethod.CREATE, resource, sectionLevel, parameters, namespace, resourceDoc);
outputOperation(CrestMethod.READ, resource, sectionLevel, parameters, namespace, resourceDoc);
outputOperation(CrestMethod.UPDATE, resource, sectionLevel, parameters, namespace, resourceDoc);
outputOperation(CrestMethod.DELETE, resource, sectionLevel, parameters, namespace, resourceDoc);
outputOperation(CrestMethod.PATCH, resource, sectionLevel, parameters, namespace, resourceDoc);
outputActionOperations(resource, sectionLevel, parameters, namespace, resourceDoc);
outputQueryOperations(resource, sectionLevel, parameters, namespace, resourceDoc);
// collections path
outputItems(version, resource, parameters, pathName, parentNamespace);
// sub-resource paths
outputSubResources(version, resource.getSubresources(), parameters, pathName, parentNamespace);
// output resource-file
final String resourceFilename = namespace + ADOC_EXTENSION;
if (inMemoryMode) {
adocMap.put(resourceFilename, resourceDoc.toString());
} else {
resourceDoc.toFile(outputDirPath, resourceFilename);
}
return resourceFilename;
}
private void outputItems(final Version version, final Resource resource, final List<Parameter> parameters,
final String parentPathName, final String parentNamespace) throws IOException {
Items items = resource.getItems();
if (items != null) {
Parameter pathParameter = items.getPathParameter();
final String itemsPathName = parentPathName + "/{" + pathParameter.getName() + "}";
final String itemsPathDocNamespace = normalizeName(parentNamespace, itemsPathName);
final Resource itemsResource = items.asResource(resource.isMvccSupported(),
resource.getResourceSchema(), resource.getTitle(), resource.getDescription());
final List<Parameter> itemsParameters = mergeParameters(mergeParameters(new ArrayList<>(parameters),
resource.getParameters()), pathParameter);
outputSubResources(version, items.getSubresources(), itemsParameters, itemsPathName, itemsPathDocNamespace);
final String resourceFilename = outputResource(parentPathName, version,
itemsResource, itemsParameters, itemsPathDocNamespace);
addPathResource(itemsPathName, version, resourceFilename);
}
}
private void outputSubResources(final Version version, final SubResources subResources,
final List<Parameter> parameters, final String parentPathName, final String parentNamespace)
throws IOException {
if (subResources != null) {
final List<String> subPathNames = new ArrayList<>(subResources.getNames());
Collections.sort(subPathNames);
for (final String name : subPathNames) {
final String subPathName = buildPath(parentPathName, name);
// create path-parameters, for any path-variables found in subPathName
final List<Parameter> subresourcesParameters = unmodifiableList(mergeParameters(
new ArrayList<>(parameters), buildPathParameters(subPathName)));
final String subPathDocNamespace = normalizeName(parentNamespace, subPathName);
Resource subResource = subResources.get(name);
if (subResource.getReference() != null) {
subResource = referenceResolver.getService(subResource.getReference());
referencedServices.add(subResource);
}
final String resourceFilename = outputResource(subPathName, version,
subResource, subresourcesParameters, subPathDocNamespace);
addPathResource(subPathName, version, resourceFilename);
}
}
}
/**
* Outputs an AsciiDoc file for various CREST operations, and a file that imports each of those files.
*
* @param crestMethod CREST operation-method
* @param resource Resource
* @param sectionLevel Starting <a href="http://asciidoctor.org/docs/user-manual/#sections">section</a>-level
* @param parameters Inherited CREST operation parameters
* @param parentNamespace Parent namespace
* @param parentDoc Parent AsciiDoc
* @throws IOException Unable to output AsciiDoc file
*/
private void outputOperation(final CrestMethod crestMethod, final Resource resource, final int sectionLevel,
final List<Parameter> parameters, final String parentNamespace, final AsciiDoc parentDoc)
throws IOException {
final Operation operation;
final boolean responseOnly;
final String displayName;
switch (crestMethod) {
case CREATE:
displayName = "Create";
responseOnly = false;
operation = resource.getCreate();
break;
case READ:
displayName = "Read";
responseOnly = true;
operation = resource.getRead();
break;
case UPDATE:
displayName = "Update";
responseOnly = false;
operation = resource.getUpdate();
break;
case DELETE:
displayName = "Delete";
responseOnly = true;
operation = resource.getDelete();
break;
case PATCH:
displayName = "Patch";
responseOnly = true;
operation = resource.getPatch();
break;
default:
// this method only handles a subset of CREST methods
throw new ApiDocGeneratorException("Unsupported CREST method: " + crestMethod);
}
if (operation != null) {
final String namespace = normalizeName(parentNamespace, displayName);
final AsciiDoc operationDoc = asciiDoc()
.sectionTitle(displayName, sectionLevel);
final String description = toTranslatedString(operation.getDescription());
final String descriptionFilename = outputDescriptionBlock(description, namespace);
operationDoc.include(descriptionFilename);
final List<String> headers = new ArrayList<>();
final List<Integer> columnWidths = new ArrayList<>();
final AsciiDocTable table = operationDoc.tableStart();
outputStability(operation.getStability(), table, headers, columnWidths);
outputMvccSupport(resource.isMvccSupported(), table, headers, columnWidths);
if (crestMethod == CrestMethod.CREATE) {
final Create create = resource.getCreate();
outputCreateMode(create.getMode(), table, headers, columnWidths);
outputSingletonStatus(create.isSingleton(), table, headers, columnWidths);
} else if (crestMethod == CrestMethod.PATCH) {
final Patch patch = resource.getPatch();
outputSupportedPatchOperations(patch.getOperations(), table, headers, columnWidths);
}
table.headers(headers)
.columnWidths(columnWidths)
.tableEnd();
outputParameters(operation.getParameters(), parameters, namespace, operationDoc);
outputResourceEntity(resource, responseOnly, operationDoc);
outputErrors(operation.getApiErrors(), operationDoc);
parentDoc.horizontalRule();
if (inMemoryMode) {
parentDoc.rawText(operationDoc.toString());
} else {
final String filename = namespace + ADOC_EXTENSION;
operationDoc.toFile(outputDirPath, filename);
parentDoc.include(filename);
}
}
}
/**
* Outputs an AsciiDoc file for {@link Action}-operations, and a file that imports each of those files.
*
* @param resource Resource
* @param sectionLevel Starting <a href="http://asciidoctor.org/docs/user-manual/#sections">section</a>-level
* @param parameters Inherited CREST operation parameters
* @param parentNamespace Parent namespace
* @param parentDoc Parent AsciiDoc
* @throws IOException Unable to output AsciiDoc file
*/
private void outputActionOperations(final Resource resource, final int sectionLevel,
final List<Parameter> parameters, final String parentNamespace, final AsciiDoc parentDoc)
throws IOException {
if (!isEmpty(resource.getActions())) {
final String namespace = normalizeName(parentNamespace, "action");
final AsciiDoc operationDoc = asciiDoc();
for (final Action action : resource.getActions()) {
final String filename = outputActionOperation(resource, action, sectionLevel, parameters, namespace);
operationDoc.include(filename);
}
if (inMemoryMode) {
parentDoc.rawText(operationDoc.toString());
} else {
final String filename = namespace + ADOC_EXTENSION;
operationDoc.toFile(outputDirPath, filename);
parentDoc.include(filename);
}
}
}
/**
* Outputs an AsciiDoc file a single {@link Action}-operation, and a file for that action.
*
* @param resource Resource
* @param action Action operation
* @param sectionLevel Starting <a href="http://asciidoctor.org/docs/user-manual/#sections">section</a>-level
* @param parameters Inherited CREST operation parameters
* @param parentNamespace Parent namespace
* @return File path suitable for AsciiDoc import-statement
* @throws IOException Unable to output AsciiDoc file
*/
private String outputActionOperation(final Resource resource, final Action action, final int sectionLevel,
final List<Parameter> parameters, final String parentNamespace) throws IOException {
final String namespace = normalizeName(parentNamespace, action.getName());
final AsciiDoc operationDoc = asciiDoc()
.horizontalRule()
.sectionTitle(asciiDoc().rawText("Action: ").mono(action.getName()).toString(), sectionLevel);
final String description = toTranslatedString(action.getDescription());
final String descriptionFilename = outputDescriptionBlock(description, namespace);
operationDoc.include(descriptionFilename);
final List<String> headers = new ArrayList<>();
final List<Integer> columnWidths = new ArrayList<>();
final AsciiDocTable table = operationDoc.tableStart();
outputStability(action.getStability(), table, headers, columnWidths);
outputMvccSupport(resource.isMvccSupported(), table, headers, columnWidths);
table.headers(headers)
.columnWidths(columnWidths)
.tableEnd();
outputParameters(action.getParameters(), parameters, namespace, operationDoc);
if (action.getRequest() != null) {
final Schema schema = action.getRequest().getReference() == null
? action.getRequest()
: referenceResolver.getDefinition(action.getRequest().getReference());
final AsciiDoc blockDoc = asciiDoc()
.blockTitle("Request Entity")
.rawParagraph("This operation takes a request resource that conforms to the following schema:")
.listingBlock(OBJECT_MAPPER.writeValueAsString(schema.getSchema().getObject()), "json");
operationDoc.rawText(blockDoc.toString());
}
if (action.getResponse() != null) {
final Schema schema = action.getResponse().getReference() == null
? action.getResponse()
: referenceResolver.getDefinition(action.getResponse().getReference());
final AsciiDoc blockDoc = asciiDoc()
.blockTitle("Response Entity")
.rawParagraph("This operation returns a response resource that conforms to the following schema:")
.listingBlock(OBJECT_MAPPER.writeValueAsString(schema.getSchema().getObject()), "json");
operationDoc.rawText(blockDoc.toString());
}
outputErrors(action.getApiErrors(), operationDoc);
final String filename = namespace + ADOC_EXTENSION;
if (inMemoryMode) {
adocMap.put(filename, operationDoc.toString());
} else {
operationDoc.toFile(outputDirPath, filename);
}
return filename;
}
/**
* Outputs an AsciiDoc file for a group of {@link Query}-operations, and a file that imports each of those files.
*
* @param resource Resource
* @param sectionLevel Starting <a href="http://asciidoctor.org/docs/user-manual/#sections">section</a>-level
* @param parameters Inherited CREST operation parameters
* @param parentNamespace Parent namespace
* @param parentDoc Parent AsciiDoc
* @throws IOException Unable to output AsciiDoc file
*/
private void outputQueryOperations(final Resource resource, final int sectionLevel,
final List<Parameter> parameters, final String parentNamespace,
final AsciiDoc parentDoc) throws IOException {
if (!isEmpty(resource.getQueries())) {
final String namespace = normalizeName(parentNamespace, "query");
final AsciiDoc operationDoc = asciiDoc();
for (final Query query : resource.getQueries()) {
final String filename = outputQueryOperation(resource, query, sectionLevel, parameters,
namespace);
operationDoc.include(filename);
}
final String filename = namespace + ADOC_EXTENSION;
if (inMemoryMode) {
adocMap.put(filename, operationDoc.toString());
} else {
operationDoc.toFile(outputDirPath, filename);
}
parentDoc.include(filename);
}
}
/**
* Outputs an AsciiDoc file for a single {@link Query}-operation.
*
* @param resource Resource
* @param query Query operation
* @param sectionLevel Starting <a href="http://asciidoctor.org/docs/user-manual/#sections">section</a>-level
* @param parameters Inherited CREST operation parameters
* @param parentNamespace Parent namespace
* @return File path suitable for AsciiDoc import-statement
* @throws IOException Unable to output AsciiDoc file
*/
private String outputQueryOperation(final Resource resource, final Query query, final int sectionLevel,
final List<Parameter> parameters, final String parentNamespace) throws IOException {
final String namespace;
final AsciiDoc operationDoc = asciiDoc()
.horizontalRule();
switch (query.getType()) {
case ID:
namespace = normalizeName(parentNamespace, "id", query.getQueryId());
operationDoc.sectionTitle(asciiDoc().rawText("Query by ID: ").mono(query.getQueryId()).toString(),
sectionLevel);
break;
case FILTER:
namespace = normalizeName(parentNamespace, "filter");
operationDoc.sectionTitle("Query by Filter", sectionLevel);
break;
case EXPRESSION:
namespace = normalizeName(parentNamespace, "expression");
operationDoc.sectionTitle("Query by Expression", sectionLevel);
break;
default:
throw new ApiDocGeneratorException("Unsupported QueryType: " + query.getType());
}
final String descriptionFilename = outputDescriptionBlock(
toTranslatedString(query.getDescription()), namespace);
operationDoc.include(descriptionFilename);
final List<String> headers = new ArrayList<>();
final List<Integer> columnWidths = new ArrayList<>();
final AsciiDocTable table = operationDoc.tableStart();
outputStability(query.getStability(), table, headers, columnWidths);
outputMvccSupport(resource.isMvccSupported(), table, headers, columnWidths);
if (!isEmpty(query.getQueryableFields())) {
headers.add(asciiDoc().link("query-queryable-fields", "Queryable Fields").toString());
columnWidths.add(COLUMN_WIDTH_MEDIUM);
final AsciiDoc blockDoc = asciiDoc();
for (final String field : query.getQueryableFields()) {
blockDoc.unorderedList1(asciiDoc().mono(field).toString());
}
table.columnCell(blockDoc.toString(), ASCII_DOC_CELL);
}
if (!isEmpty(query.getPagingModes())) {
headers.add(asciiDoc().link("query-paging-modes", "Paging Modes").toString());
columnWidths.add(COLUMN_WIDTH_MEDIUM);
final AsciiDoc blockDoc = asciiDoc();
for (final PagingMode pagingMode : query.getPagingModes()) {
blockDoc.unorderedList1(asciiDoc().mono(pagingMode.toString()).toString());
}
table.columnCell(blockDoc.toString(), ASCII_DOC_CELL);
}
if (!isEmpty(query.getCountPolicies())) {
headers.add(asciiDoc().link("query-page-count-policies", "Page Count Policies").toString());
columnWidths.add(COLUMN_WIDTH_MEDIUM);
final AsciiDoc blockDoc = asciiDoc();
for (final CountPolicy countPolicy : query.getCountPolicies()) {
blockDoc.unorderedList1(asciiDoc().mono(countPolicy.toString()).toString());
}
table.columnCell(blockDoc.toString(), ASCII_DOC_CELL);
}
if (!isEmpty(query.getSupportedSortKeys())) {
headers.add(asciiDoc().link("query-sort-keys", "Supported Sort Keys").toString());
columnWidths.add(COLUMN_WIDTH_MEDIUM);
final AsciiDoc blockDoc = asciiDoc();
for (final String sortKey : query.getSupportedSortKeys()) {
blockDoc.unorderedList1(asciiDoc().mono(sortKey).toString());
}
table.columnCell(blockDoc.toString(), ASCII_DOC_CELL);
}
table.headers(headers)
.columnWidths(columnWidths)
.tableEnd();
outputParameters(query.getParameters(), parameters, namespace, operationDoc);
// TODO determine if this needs to be formatted differently here
outputResourceEntity(resource, true, operationDoc);
outputErrors(query.getApiErrors(), operationDoc);
final String filename = namespace + ADOC_EXTENSION;
if (inMemoryMode) {
adocMap.put(filename, operationDoc.toString());
} else {
operationDoc.toFile(outputDirPath, filename);
}
return filename;
}
/**
* Outputs operation stability.
*
* @param stability Operation stability or {@code null} to use default {@link Stability#STABLE}
* @param table AsciiDoc table to write to
* @param headers Table headers, which will have an entry added
* @param columnWidths Relative table column-widths (range [1,99]), which will have an entry added
*/
private static void outputStability(Stability stability, final AsciiDocTable table, final List<String> headers,
final List<Integer> columnWidths) {
if (stability == null) {
stability = Stability.STABLE;
}
headers.add(asciiDoc().link("interface-stability-definitions", "Stability").toString());
columnWidths.add(COLUMN_WIDTH_SMALL);
table.columnCell(stability.name());
}
/**
* Outputs MVCC support.
*
* @param mvccSupported MVCC support flag
* @param table AsciiDoc table to write to
* @param headers Table headers, which will have an entry added
* @param columnWidths Relative table column-widths (range [1,99]), which will have an entry added
*/
private static void outputMvccSupport(final boolean mvccSupported, final AsciiDocTable table,
final List<String> headers, final List<Integer> columnWidths) {
headers.add(asciiDoc().link("MVCC", "MVCC").toString());
columnWidths.add(COLUMN_WIDTH_SMALL);
// ✓ or ⃠
table.columnCell(mvccSupported ? "✓" : "⃠");
}
/**
* Outputs an operation's resource schema.
*
* @param resource Resource
* @param responseOnly {@code true} when resource is sent only in response and {@code false} for request/response
* @param doc AsciiDoc to write to
*/
private void outputResourceEntity(final Resource resource, final boolean responseOnly, final AsciiDoc doc)
throws IOException {
if (resource.getResourceSchema() != null) {
final Schema schema = resource.getResourceSchema().getReference() == null
? resource.getResourceSchema()
: referenceResolver.getDefinition(resource.getResourceSchema().getReference());
final AsciiDoc blockDoc = asciiDoc()
.blockTitle("Resource Entity");
if (responseOnly) {
blockDoc.rawParagraph(
"This operation returns a response resource that conforms to the following schema:");
} else {
blockDoc.rawParagraph("This operation takes a request body and returns a response resource that "
+ "conforms to the following schema:");
}
blockDoc.listingBlock(OBJECT_MAPPER.writeValueAsString(schema.getSchema().getObject()), "json");
doc.rawText(blockDoc.toString());
}
}
/**
* Outputs singleton status for {@link Create} operation.
*
* @param isSingleton Singleton status
* @param table AsciiDoc table to write to
* @param headers Table headers, which will have an entry added
* @param columnWidths Relative table column-widths (range [1,99]), which will have an entry added
*/
private static void outputSingletonStatus(final boolean isSingleton, final AsciiDocTable table,
final List<String> headers, final List<Integer> columnWidths) {
headers.add(asciiDoc().link("create-singleton", "Singleton").toString());
columnWidths.add(COLUMN_WIDTH_SMALL);
// ✓ or ⃠
table.columnCell(isSingleton ? "✓" : "⃠");
}
/**
* Outputs operation parameters.
*
* @param operationParameters Operation parameters or {@code null}/empty for pass-through
* @param inheritedParameters Inherited CREST operation parameters
* @param parentNamespace Parent namespace
* @param doc AsciiDoc to write to
*/
private void outputParameters(final Parameter[] operationParameters, final List<Parameter> inheritedParameters,
final String parentNamespace, final AsciiDoc doc)
throws IOException {
final List<Parameter> parameters = mergeParameters(new ArrayList<>(inheritedParameters), operationParameters);
if (parameters.isEmpty()) {
return;
}
final String parametersNamespace = normalizeName(parentNamespace, "parameters");
final AsciiDocTable table = doc.tableStart()
.title("Parameters")
.headers("Name", "Type", "Description", "Required", "In", "Values")
.columnWidths(2, 1, 4, 1, 1, 2);
for (final Parameter parameter : parameters) {
// format optional enumValues
String enumValuesContent = null;
if (!isEmpty(parameter.getEnumValues()) || !isEmpty(parameter.getDefaultValue())) {
final AsciiDoc enumValuesDoc = asciiDoc();
if (!isEmpty(parameter.getDefaultValue())) {
enumValuesDoc.italic("Default:")
.rawText(" ")
.mono(parameter.getDefaultValue())
.newline();
}
if (!isEmpty(parameter.getEnumValues())) {
final String[] enumValues = parameter.getEnumValues();
final String[] enumTitles = parameter.getEnumTitles();
for (int i = 0; i < enumValues.length; ++i) {
final AsciiDoc enumDoc = asciiDoc()
.mono(enumValues[i]);
if (enumTitles != null) {
enumDoc.rawText(": " + enumTitles[i]);
}
enumValuesDoc.unorderedList1(enumDoc.toString());
}
}
enumValuesContent = enumValuesDoc.toString();
}
final String namespace = normalizeName(parametersNamespace, parameter.getName());
final String description = toTranslatedString(parameter.getDescription());
final String descriptionFilename = outputDescriptionBlock(description, namespace);
// format table
table.columnCell(parameter.getName(), MONO_CELL)
.columnCell(parameter.getType(), MONO_CELL)
.columnCell(asciiDoc().include(descriptionFilename).toString(), ASCII_DOC_CELL)
.columnCell(ValidationUtil.nullToFalse(parameter.isRequired()) ? "✓" : null)
.columnCell(parameter.getSource().name(), MONO_CELL)
.columnCell(enumValuesContent, ASCII_DOC_CELL)
.rowEnd();
}
table.tableEnd();
}
/**
* Translates text, using preferred locales.
*
* @param localizableString Text to translate or {@code null}
* @return Translated text or {@code null}
*/
private String toTranslatedString(LocalizableString localizableString) {
return localizableString == null
? null
: localizableString.toTranslatedString(PREFERRED_LOCALES);
}
/**
* Outputs create-mode for a {@link Create} operation.
*
* @param createMode Create-mode
* @param table AsciiDoc table to write to
* @param headers Table headers, which will have an entry added
* @param columnWidths Relative table column-widths (range [1,99]), which will have an entry added
*/
private static void outputCreateMode(final CreateMode createMode, final AsciiDocTable table,
final List<String> headers, final List<Integer> columnWidths) {
headers.add("Resource ID");
columnWidths.add(COLUMN_WIDTH_MEDIUM);
final AsciiDoc doc = asciiDoc();
switch (createMode) {
case ID_FROM_CLIENT:
doc.rawText("Assigned by client");
break;
case ID_FROM_SERVER:
doc.rawText("Assigned by server (do not supply)");
break;
default:
throw new ApiDocGeneratorException("Unsupported CreateMode: " + createMode);
}
table.columnCell(doc.toString());
}
/**
* Outputs supported patch-operations for a {@link Patch} operation.
*
* @param patchOperations Supported patch-operations
* @param table AsciiDoc table to write to
* @param headers Table headers, which will have an entry added
* @param columnWidths Relative table column-widths (range [1,99]), which will have an entry added
*/
private static void outputSupportedPatchOperations(final PatchOperation[] patchOperations,
final AsciiDocTable table, final List<String> headers, final List<Integer> columnWidths) {
headers.add(asciiDoc().link("patch-operations", "Patch Operations").toString());
columnWidths.add(COLUMN_WIDTH_MEDIUM);
final AsciiDoc blockDoc = asciiDoc();
for (final PatchOperation patchOperation : patchOperations) {
blockDoc.unorderedList1(patchOperation.name());
}
table.columnCell(blockDoc.toString(), ASCII_DOC_CELL);
}
/**
* Outputs operation errors.
*
* @param apiErrors Operation errors or {@code null}/empty for pass-through
* @param doc AsciiDoc to write to
*/
private void outputErrors(final ApiError[] apiErrors, final AsciiDoc doc) throws IOException {
if (!isEmpty(apiErrors)) {
doc.blockTitle("Errors");
final AsciiDocTable table = doc.tableStart()
.headers("Code", "Description")
.columnWidths(1, 10);
// resolve error references before sorting
final List<ApiError> resolvedErrors = new ArrayList<>(apiErrors.length);
for (final ApiError error : apiErrors) {
if (error.getReference() != null) {
final ApiError resolved = referenceResolver.getError(error.getReference());
if (resolved != null && resolved.getReference() == null) {
resolvedErrors.add(resolved);
}
} else {
resolvedErrors.add(error);
}
}
Collections.sort(resolvedErrors, ApiError.ERROR_COMPARATOR);
for (final ApiError error : resolvedErrors) {
table.columnCell(String.valueOf(error.getCode()), MONO_CELL);
if (error.getSchema() == null) {
table.columnCell(toTranslatedString(error.getDescription()));
} else {
final Schema schema = error.getSchema().getReference() == null
? error.getSchema()
: referenceResolver.getDefinition(error.getSchema().getReference());
final AsciiDoc blockDoc = asciiDoc()
.rawParagraph(toTranslatedString(error.getDescription()))
.rawParagraph("This error may contain an underlying `cause` that conforms to the following "
+ "schema:")
.listingBlock(OBJECT_MAPPER.writeValueAsString(schema.getSchema().getObject()), "json");
table.columnCell(blockDoc.toString(), ASCII_DOC_CELL);
}
table.rowEnd();
}
table.tableEnd();
}
}
/**
* Checks for a given {@code filename} within {@link #inputDirPath}, and if it exists, will copy that file
* into {@link #outputDirPath}.
*
* @param filename Filename
* @return {@code true} if a replacement existed and was copied and {@code false} otherwise
* @throws IOException Unable to copy file
*/
private boolean copyReplacementFile(final String filename) throws IOException {
if (inputDirPath != null) {
final Path inputFilePath = inputDirPath.resolve(filename);
final Path outputFilePath = outputDirPath.resolve(filename);
if (Files.exists(inputFilePath)) {
// replacement file exists, so copy it
Files.copy(inputFilePath, outputFilePath, StandardCopyOption.REPLACE_EXISTING);
return true;
}
}
return false;
}
/**
* Add resource-file to path tree.
*
* @param pathName Full endpoint path.
* @param version Resource version.
* @param resourceFilename Resource ADOC filename.
*/
private void addPathResource(final String pathName, final Version version, final String resourceFilename) {
if (!pathTree.containsKey(pathName)) {
pathTree.put(pathName, new LinkedHashMap<Version, String>());
}
final Map<Version, String> versionTree = pathTree.get(pathName);
if (!versionTree.containsKey(version)) {
versionTree.put(version, resourceFilename);
}
}
/**
* Merges AsciiDoc include-statements, to build AsciiDoc string. This method is only invoked when
* {@link #inMemoryMode} is {@code true}.
*
* @param rootFilename Name of root AsciiDoc file
* @return AsciiDoc string
*/
private String toString(final String rootFilename) {
if (!inMemoryMode) {
throw new ApiDocGeneratorException("Expected inMemoryMode");
}
String s = adocMap.get(checkNotNull(rootFilename));
final Matcher m = AsciiDoc.INCLUDE_PATTERN.matcher(s);
while (m.find()) {
final String content = adocMap.get(m.group(1));
s = m.replaceFirst(isEmpty(content) ? "" : Matcher.quoteReplacement(content));
m.reset(s);
}
return s;
}
}