View Javadoc
1   /*
2    * The contents of this file are subject to the terms of the Common Development and
3    * Distribution License (the License). You may not use this file except in compliance with the
4    * License.
5    *
6    * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
7    * specific language governing permission and limitations under the License.
8    *
9    * When distributing Covered Software, include this CDDL Header Notice in each file and include
10   * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
11   * Header, with the fields enclosed by brackets [] replaced by your own identifying
12   * information: "Portions copyright [year] [name of copyright owner]".
13   *
14   * Copyright 2016 ForgeRock AS.
15   * Portions Copyright 2018 Wren Security.
16   */
17  
18  package org.forgerock.api.markup;
19  
20  import static java.util.Collections.unmodifiableList;
21  import static org.forgerock.api.markup.asciidoc.AsciiDoc.asciiDoc;
22  import static org.forgerock.api.markup.asciidoc.AsciiDoc.normalizeName;
23  import static org.forgerock.api.markup.asciidoc.AsciiDocTable.COLUMN_WIDTH_MEDIUM;
24  import static org.forgerock.api.markup.asciidoc.AsciiDocTable.COLUMN_WIDTH_SMALL;
25  import static org.forgerock.api.markup.asciidoc.AsciiDocTableColumnStyles.ASCII_DOC_CELL;
26  import static org.forgerock.api.markup.asciidoc.AsciiDocTableColumnStyles.MONO_CELL;
27  import static org.forgerock.api.util.PathUtil.buildPath;
28  import static org.forgerock.api.util.PathUtil.buildPathParameters;
29  import static org.forgerock.api.util.PathUtil.mergeParameters;
30  import static org.forgerock.api.util.ValidationUtil.isEmpty;
31  import static org.forgerock.util.Reject.checkNotNull;
32  
33  import java.io.IOException;
34  import java.nio.file.Files;
35  import java.nio.file.Path;
36  import java.nio.file.StandardCopyOption;
37  import java.util.ArrayList;
38  import java.util.Collections;
39  import java.util.HashMap;
40  import java.util.HashSet;
41  import java.util.LinkedHashMap;
42  import java.util.List;
43  import java.util.Map;
44  import java.util.Set;
45  import java.util.regex.Matcher;
46  import java.util.regex.Pattern;
47  
48  import org.forgerock.api.enums.CountPolicy;
49  import org.forgerock.api.enums.CreateMode;
50  import org.forgerock.api.enums.PagingMode;
51  import org.forgerock.api.enums.PatchOperation;
52  import org.forgerock.api.enums.Stability;
53  import org.forgerock.api.jackson.JacksonUtils;
54  import org.forgerock.api.markup.asciidoc.AsciiDoc;
55  import org.forgerock.api.markup.asciidoc.AsciiDocTable;
56  import org.forgerock.api.models.Action;
57  import org.forgerock.api.models.ApiDescription;
58  import org.forgerock.api.models.ApiError;
59  import org.forgerock.api.models.Create;
60  import org.forgerock.api.models.Items;
61  import org.forgerock.api.models.Operation;
62  import org.forgerock.api.models.Parameter;
63  import org.forgerock.api.models.Patch;
64  import org.forgerock.api.models.Paths;
65  import org.forgerock.api.models.Query;
66  import org.forgerock.api.models.Reference;
67  import org.forgerock.api.models.Resource;
68  import org.forgerock.api.models.Schema;
69  import org.forgerock.api.models.Services;
70  import org.forgerock.api.models.SubResources;
71  import org.forgerock.api.models.VersionedPath;
72  import org.forgerock.api.util.ReferenceResolver;
73  import org.forgerock.api.util.ValidationUtil;
74  import org.forgerock.http.routing.Version;
75  import org.forgerock.util.i18n.LocalizableString;
76  import org.forgerock.util.i18n.PreferredLocales;
77  
78  import com.fasterxml.jackson.databind.ObjectMapper;
79  
80  /**
81   * Generates static AsciiDoc documentation for CREST API Descriptors.
82   */
83  public final class ApiDocGenerator {
84  
85      private static final ObjectMapper OBJECT_MAPPER = JacksonUtils.createGenericMapper();
86  
87      private static final PreferredLocales PREFERRED_LOCALES = new PreferredLocales();
88  
89      enum CrestMethod {
90          CREATE, READ, UPDATE, DELETE, PATCH, ACTION, QUERY
91      }
92  
93      /**
94       * Regex pattern that matches services names with format,
95       * <ul>
96       * <li>name:1</li>
97       * <li>name:1.0</li>
98       * </ul>
99       * Where match group 1 contains the {@code name} and group 2 contains the version.
100      */
101     private static final Pattern SERVICE_NAME_PATTERN = Pattern.compile("^([^:]+)[:](\\d(?:\\.\\d)?)$");
102 
103     /**
104      * {@code .adoc} file extension for generated AsciiDoc files.
105      */
106     private static final String ADOC_EXTENSION = ".adoc";
107 
108     /**
109      * {@code true} when there is no {@link #outputDirPath filesystem} to write to, and we must build the complete
110      * AsciiDoc as a string.
111      */
112     private final boolean inMemoryMode;
113 
114     /**
115      * Root output directory.
116      */
117     private final Path outputDirPath;
118 
119     /**
120      * Optional input directory or {@code null}.
121      */
122     private final Path inputDirPath;
123 
124     /**
125      * Group doc sections by path-name, version, resource-ADOC-filename.
126      */
127     private final Map<String, Map<Version, String>> pathTree;
128 
129     /**
130      * Lookup map of rendered AsciiDoc string, for in-memory mode, from filename (key) to AsciiDoc (value).
131      */
132     private final Map<String, String> adocMap;
133 
134     /**
135      * Entries from {@link ApiDescription#getServices()} that have been referenced, whereas unreferenced services
136      * will be listed under the {@code <undefined>} path in the documentation.
137      */
138     private final Set<Resource> referencedServices;
139 
140     private final ApiDescription apiDescription;
141 
142     private final ReferenceResolver referenceResolver;
143 
144     /**
145      * Constructor that sets the root output directory for AsciiDoc files, which will be created if it does not exist,
146      * and an input directory used for overriding AsciiDoc files (e.g., descriptions).
147      *
148      * @param inputDirPath Input directory or {@code null}
149      * @param outputDirPath Root output directory or {@code null} for in-memory mode
150      */
151     private ApiDocGenerator(final ApiDescription apiDescription, final Path inputDirPath, final Path outputDirPath,
152             final ApiDescription... externalApiDescriptions) {
153 
154         pathTree = new HashMap<>();
155         adocMap = new HashMap<>();
156         referencedServices = new HashSet<>();
157         this.apiDescription = checkNotNull(apiDescription, "apiDescription required");
158         this.inputDirPath = inputDirPath;
159         this.outputDirPath = outputDirPath;
160         inMemoryMode = outputDirPath == null;
161 
162         if (outputDirPath != null && outputDirPath.equals(inputDirPath)) {
163             throw new ApiDocGeneratorException("inputDirPath and outputDirPath can not be equal");
164         }
165 
166         referenceResolver = new ReferenceResolver(apiDescription);
167         referenceResolver.registerAll(externalApiDescriptions);
168     }
169 
170     /**
171      * Generates AsciiDoc documentation for a CREST API Descriptor, to an output-directory.
172      *
173      * @param title API title
174      * @param apiDescription API Description
175      * @param externalApiDescriptions External CREST API Descriptions, for resolving {@link Reference}s, or {@code null}
176      * @param inputDirPath Input directory or {@code null} if not overriding ADOC files
177      * @param outputDirPath Root output directory
178      */
179     public static void execute(final String title, final ApiDescription apiDescription,
180             final Path inputDirPath, final Path outputDirPath, final ApiDescription... externalApiDescriptions) {
181 
182         final ApiDocGenerator thisInstance = new ApiDocGenerator(apiDescription, inputDirPath, outputDirPath,
183                 externalApiDescriptions);
184         thisInstance.doExecute(title);
185     }
186 
187     /**
188      * Generates AsciiDoc documentation for a CREST API Descriptor, to a {@code String}.
189      *
190      * @param title API title
191      * @param apiDescription API Description
192      * @param externalApiDescriptions External CREST API Descriptions, for resolving {@link Reference}s, or {@code null}
193      * @param inputDirPath Input directory or {@code null} if not overriding ADOC files
194      * @return Resulting AsciiDoc markup as a {@code String}
195      */
196     public static String execute(final String title, final ApiDescription apiDescription,
197             final Path inputDirPath, final ApiDescription... externalApiDescriptions) {
198 
199         final ApiDocGenerator thisInstance = new ApiDocGenerator(apiDescription, inputDirPath, null,
200                 externalApiDescriptions);
201         final String rootFilename = thisInstance.doExecute(title);
202         return thisInstance.toString(rootFilename);
203     }
204 
205     private String doExecute(final String title) {
206         final String namespace = apiDescription.getId();
207         try {
208             final String pathsFilename = outputPaths(namespace);
209             return outputRoot(checkNotNull(title, "title is required"), pathsFilename, namespace);
210         } catch (IOException e) {
211             throw new ApiDocGeneratorException("Unable to output doc file", e);
212         }
213     }
214 
215     /**
216      * Outputs a top-level AsciiDoc file that imports all other second-level files generated by this class.
217      *
218      * @param title API title
219      * @param pathsFilename Paths file-path suitable for AsciiDoc import-statement
220      * @param parentNamespace Parent namespace
221      * @return File path suitable for AsciiDoc import-statement
222      * @throws IOException Unable to output AsciiDoc file
223      */
224     private String outputRoot(final String title, final String pathsFilename, final String parentNamespace)
225             throws IOException {
226         final String namespace = normalizeName(parentNamespace, "index");
227 
228         final AsciiDoc pathsDoc = asciiDoc()
229                 .documentTitle(title)
230                 .rawParagraph(asciiDoc().rawText("*API ID:* ").mono(apiDescription.getId()).toString())
231                 .rawParagraph(asciiDoc().rawText("*API Version:* ").mono(apiDescription.getVersion()).toString())
232                 .rawLine(":toc: left")
233                 .rawLine(":toclevels: 5")
234                 .newline();
235         final String description = toTranslatedString(apiDescription.getDescription());
236         final String descriptionFilename = outputDescriptionBlock(description, namespace);
237         pathsDoc.include(descriptionFilename);
238 
239         if (pathsFilename != null) {
240             pathsDoc.include(pathsFilename);
241         }
242 
243         final String filename = namespace + ADOC_EXTENSION;
244         if (inMemoryMode) {
245             adocMap.put(filename, pathsDoc.toString());
246         } else {
247             pathsDoc.toFile(outputDirPath, filename);
248         }
249         return filename;
250     }
251 
252     /**
253      * Outputs an AsciiDoc file for a description-block. The file will be blank when no description is defined.
254      * <p>
255      * This method will use a replacement-file from {@link #inputDirPath}, if it exists, to override the description.
256      * </p>
257      *
258      * @param defaultDescription Default description for the block or {@code null}
259      * @param parentNamespace Parent namespace
260      * @return File path suitable for AsciiDoc import-statement
261      * @throws IOException Unable to output AsciiDoc file
262      */
263     private String outputDescriptionBlock(final String defaultDescription, final String parentNamespace)
264             throws IOException {
265         final String namespace = normalizeName(parentNamespace, "description");
266         final String filename = namespace + ADOC_EXTENSION;
267         if (!copyReplacementFile(filename)) {
268             final AsciiDoc blockDoc = asciiDoc();
269             if (!isEmpty(defaultDescription)) {
270                 blockDoc.rawParagraph(defaultDescription);
271             }
272             if (inMemoryMode) {
273                 adocMap.put(filename, blockDoc.toString());
274             } else {
275                 blockDoc.toFile(outputDirPath, filename);
276             }
277         }
278         return filename;
279     }
280 
281     /**
282      * Outputs an AsciiDoc file for each path, which imports a file for each version under that path, and another
283      * file that imports each path.
284      *
285      * @param parentNamespace Parent namespace
286      * @return File path suitable for AsciiDoc import-statement
287      * @throws IOException Unable to output AsciiDoc file
288      */
289     private String outputPaths(final String parentNamespace) throws IOException {
290         final String allPathsDocNamespace = normalizeName(parentNamespace, "paths");
291         final AsciiDoc allPathsDoc = asciiDoc()
292                 .sectionTitle1("Paths");
293 
294         final Paths paths = apiDescription.getPaths();
295         final List<String> pathNames = new ArrayList<>(paths.getNames());
296         Collections.sort(pathNames);
297         for (final String pathName : pathNames) {
298             // path
299             final String pathDocNamespace = normalizeName(allPathsDocNamespace, pathName);
300 
301             final VersionedPath versionedPath = paths.get(pathName);
302             final List<Version> versions = new ArrayList<>(versionedPath.getVersions());
303             Collections.sort(versions);
304 
305             for (final Version version : versions) {
306                 final String versionDocNamespace;
307                 if (VersionedPath.UNVERSIONED.equals(version)) {
308                     versionDocNamespace = pathDocNamespace;
309                 } else {
310                     // version
311                     final String versionName = version.toString();
312                     versionDocNamespace = normalizeName(pathDocNamespace, versionName);
313                 }
314 
315                 // resource path
316                 Resource resource = versionedPath.get(version);
317                 if (resource.getReference() != null) {
318                     resource = referenceResolver.getService(resource.getReference());
319                     referencedServices.add(resource);
320                 }
321                 final String resourceFilename = outputResource(pathName, version, resource,
322                         Collections.<Parameter>emptyList(), versionDocNamespace);
323                 addPathResource(pathName, version, resourceFilename);
324             }
325         }
326 
327         outputUndefinedServices(allPathsDocNamespace);
328 
329         // output paths and versions by traversing pathTree
330         final List<String> pathKeys = new ArrayList<>(pathTree.keySet());
331         Collections.sort(pathKeys);
332         for (final String pathKey : pathKeys) {
333             allPathsDoc.sectionTitle2(asciiDoc().mono(pathKey).toString());
334 
335             final Map<Version, String> versionMap = pathTree.get(pathKey);
336             for (final Map.Entry<Version, String> entry : versionMap.entrySet()) {
337                 if (!VersionedPath.UNVERSIONED.equals(entry.getKey())) {
338                     allPathsDoc.sectionTitle3(asciiDoc().mono(entry.getKey().toString()).toString());
339                 }
340                 allPathsDoc.include(entry.getValue());
341             }
342         }
343 
344         // output all-paths-file
345         final String filename = allPathsDocNamespace + ADOC_EXTENSION;
346         if (inMemoryMode) {
347             adocMap.put(filename, allPathsDoc.toString());
348         } else {
349             allPathsDoc.toFile(outputDirPath, filename);
350         }
351         return filename;
352     }
353 
354     /**
355      * Search for unreferenced services, and add them to the documentation under the {@code <undefined>} path.
356      *
357      * @param parentNamespace Parent namespace
358      * @throws IOException Unable to output AsciiDoc file
359      */
360     private void outputUndefinedServices(final String parentNamespace) throws IOException {
361         //
362         Services services = apiDescription.getServices();
363         if (services != null && !services.getNames().isEmpty()) {
364             for (final String name : apiDescription.getServices().getNames()) {
365                 final Resource resource = apiDescription.getServices().get(name);
366                 if (!referencedServices.contains(resource)) {
367                     // parse service version, from end of name, if provided
368                     final Matcher m = SERVICE_NAME_PATTERN.matcher(name);
369                     final String serviceName;
370                     final Version serviceVersion;
371                     if (m.matches()) {
372                         serviceName = m.group(1);
373                         serviceVersion = Version.version(m.group(2));
374                     } else {
375                         serviceName = name;
376                         serviceVersion = VersionedPath.UNVERSIONED;
377                     }
378 
379                     final String pathDocNamespace = normalizeName(parentNamespace, "<undefined>");
380                     final String versionDocNamespace;
381                     if (VersionedPath.UNVERSIONED.equals(serviceVersion)) {
382                         versionDocNamespace = pathDocNamespace;
383                     } else {
384                         // version
385                         final String versionName = serviceVersion.toString();
386                         versionDocNamespace = normalizeName(pathDocNamespace, versionName);
387                     }
388 
389                     final String pathName = "<undefined>/" + serviceName;
390                     final String resourceFilename = outputResource(pathName, serviceVersion, resource,
391                             Collections.<Parameter>emptyList(), versionDocNamespace);
392                     addPathResource(pathName, serviceVersion, resourceFilename);
393                 }
394             }
395         }
396     }
397 
398     private String outputResource(final String pathName, final Version version, final Resource resource,
399             final List<Parameter> parameters, final String parentNamespace) throws IOException {
400         final boolean unversioned = VersionedPath.UNVERSIONED.equals(version);
401         final int sectionLevel = unversioned ? 3 : 4;
402 
403         final String namespace = normalizeName(parentNamespace, "resource");
404         final AsciiDoc resourceDoc = asciiDoc();
405 
406         final String descriptionFilename = outputDescriptionBlock(
407                 toTranslatedString(resource.getDescription()), namespace);
408         resourceDoc.include(descriptionFilename);
409 
410         outputOperation(CrestMethod.CREATE, resource, sectionLevel, parameters, namespace, resourceDoc);
411         outputOperation(CrestMethod.READ, resource, sectionLevel, parameters, namespace, resourceDoc);
412         outputOperation(CrestMethod.UPDATE, resource, sectionLevel, parameters, namespace, resourceDoc);
413         outputOperation(CrestMethod.DELETE, resource, sectionLevel, parameters, namespace, resourceDoc);
414         outputOperation(CrestMethod.PATCH, resource, sectionLevel, parameters, namespace, resourceDoc);
415 
416         outputActionOperations(resource, sectionLevel, parameters, namespace, resourceDoc);
417         outputQueryOperations(resource, sectionLevel, parameters, namespace, resourceDoc);
418 
419         // collections path
420         outputItems(version, resource, parameters, pathName, parentNamespace);
421 
422         // sub-resource paths
423         outputSubResources(version, resource.getSubresources(), parameters, pathName, parentNamespace);
424 
425         // output resource-file
426         final String resourceFilename = namespace + ADOC_EXTENSION;
427         if (inMemoryMode) {
428             adocMap.put(resourceFilename, resourceDoc.toString());
429         } else {
430             resourceDoc.toFile(outputDirPath, resourceFilename);
431         }
432         return resourceFilename;
433     }
434 
435     private void outputItems(final Version version, final Resource resource, final List<Parameter> parameters,
436             final String parentPathName, final String parentNamespace) throws IOException {
437         Items items = resource.getItems();
438         if (items != null) {
439             Parameter pathParameter = items.getPathParameter();
440             final String itemsPathName = parentPathName + "/{" + pathParameter.getName() + "}";
441             final String itemsPathDocNamespace = normalizeName(parentNamespace, itemsPathName);
442 
443             final Resource itemsResource = items.asResource(resource.isMvccSupported(),
444                     resource.getResourceSchema(), resource.getTitle(), resource.getDescription());
445 
446             final List<Parameter> itemsParameters = mergeParameters(mergeParameters(new ArrayList<>(parameters),
447                     resource.getParameters()), pathParameter);
448 
449             outputSubResources(version, items.getSubresources(), itemsParameters, itemsPathName, itemsPathDocNamespace);
450 
451             final String resourceFilename = outputResource(parentPathName, version,
452                     itemsResource, itemsParameters, itemsPathDocNamespace);
453             addPathResource(itemsPathName, version, resourceFilename);
454         }
455     }
456 
457     private void outputSubResources(final Version version, final SubResources subResources,
458             final List<Parameter> parameters, final String parentPathName, final String parentNamespace)
459             throws IOException {
460         if (subResources != null) {
461             final List<String> subPathNames = new ArrayList<>(subResources.getNames());
462             Collections.sort(subPathNames);
463 
464             for (final String name : subPathNames) {
465                 final String subPathName = buildPath(parentPathName, name);
466 
467                 // create path-parameters, for any path-variables found in subPathName
468                 final List<Parameter> subresourcesParameters = unmodifiableList(mergeParameters(
469                         new ArrayList<>(parameters), buildPathParameters(subPathName)));
470 
471                 final String subPathDocNamespace = normalizeName(parentNamespace, subPathName);
472 
473                 Resource subResource = subResources.get(name);
474                 if (subResource.getReference() != null) {
475                     subResource = referenceResolver.getService(subResource.getReference());
476                     referencedServices.add(subResource);
477                 }
478 
479                 final String resourceFilename = outputResource(subPathName, version,
480                         subResource, subresourcesParameters, subPathDocNamespace);
481                 addPathResource(subPathName, version, resourceFilename);
482             }
483         }
484     }
485 
486     /**
487      * Outputs an AsciiDoc file for various CREST operations, and a file that imports each of those files.
488      *
489      * @param crestMethod CREST operation-method
490      * @param resource Resource
491      * @param sectionLevel Starting <a href="http://asciidoctor.org/docs/user-manual/#sections">section</a>-level
492      * @param parameters Inherited CREST operation parameters
493      * @param parentNamespace Parent namespace
494      * @param parentDoc Parent AsciiDoc
495      * @throws IOException Unable to output AsciiDoc file
496      */
497     private void outputOperation(final CrestMethod crestMethod, final Resource resource, final int sectionLevel,
498             final List<Parameter> parameters, final String parentNamespace, final AsciiDoc parentDoc)
499             throws IOException {
500 
501         final Operation operation;
502         final boolean responseOnly;
503         final String displayName;
504         switch (crestMethod) {
505         case CREATE:
506             displayName = "Create";
507             responseOnly = false;
508             operation = resource.getCreate();
509             break;
510         case READ:
511             displayName = "Read";
512             responseOnly = true;
513             operation = resource.getRead();
514             break;
515         case UPDATE:
516             displayName = "Update";
517             responseOnly = false;
518             operation = resource.getUpdate();
519             break;
520         case DELETE:
521             displayName = "Delete";
522             responseOnly = true;
523             operation = resource.getDelete();
524             break;
525         case PATCH:
526             displayName = "Patch";
527             responseOnly = true;
528             operation = resource.getPatch();
529             break;
530         default:
531             // this method only handles a subset of CREST methods
532             throw new ApiDocGeneratorException("Unsupported CREST method: " + crestMethod);
533         }
534 
535         if (operation != null) {
536             final String namespace = normalizeName(parentNamespace, displayName);
537             final AsciiDoc operationDoc = asciiDoc()
538                     .sectionTitle(displayName, sectionLevel);
539             final String description = toTranslatedString(operation.getDescription());
540             final String descriptionFilename = outputDescriptionBlock(description, namespace);
541             operationDoc.include(descriptionFilename);
542 
543             final List<String> headers = new ArrayList<>();
544             final List<Integer> columnWidths = new ArrayList<>();
545             final AsciiDocTable table = operationDoc.tableStart();
546             outputStability(operation.getStability(), table, headers, columnWidths);
547             outputMvccSupport(resource.isMvccSupported(), table, headers, columnWidths);
548             if (crestMethod == CrestMethod.CREATE) {
549                 final Create create = resource.getCreate();
550                 outputCreateMode(create.getMode(), table, headers, columnWidths);
551                 outputSingletonStatus(create.isSingleton(), table, headers, columnWidths);
552             } else if (crestMethod == CrestMethod.PATCH) {
553                 final Patch patch = resource.getPatch();
554                 outputSupportedPatchOperations(patch.getOperations(), table, headers, columnWidths);
555             }
556             table.headers(headers)
557                     .columnWidths(columnWidths)
558                     .tableEnd();
559 
560             outputParameters(operation.getParameters(), parameters, namespace, operationDoc);
561             outputResourceEntity(resource, responseOnly, operationDoc);
562             outputErrors(operation.getApiErrors(), operationDoc);
563 
564             parentDoc.horizontalRule();
565 
566             if (inMemoryMode) {
567                 parentDoc.rawText(operationDoc.toString());
568             } else {
569                 final String filename = namespace + ADOC_EXTENSION;
570                 operationDoc.toFile(outputDirPath, filename);
571                 parentDoc.include(filename);
572             }
573         }
574     }
575 
576     /**
577      * Outputs an AsciiDoc file for {@link Action}-operations, and a file that imports each of those files.
578      *
579      * @param resource Resource
580      * @param sectionLevel Starting <a href="http://asciidoctor.org/docs/user-manual/#sections">section</a>-level
581      * @param parameters Inherited CREST operation parameters
582      * @param parentNamespace Parent namespace
583      * @param parentDoc Parent AsciiDoc
584      * @throws IOException Unable to output AsciiDoc file
585      */
586     private void outputActionOperations(final Resource resource, final int sectionLevel,
587             final List<Parameter> parameters, final String parentNamespace, final AsciiDoc parentDoc)
588             throws IOException {
589         if (!isEmpty(resource.getActions())) {
590             final String namespace = normalizeName(parentNamespace, "action");
591             final AsciiDoc operationDoc = asciiDoc();
592 
593             for (final Action action : resource.getActions()) {
594                 final String filename = outputActionOperation(resource, action, sectionLevel, parameters, namespace);
595                 operationDoc.include(filename);
596             }
597 
598             if (inMemoryMode) {
599                 parentDoc.rawText(operationDoc.toString());
600             } else {
601                 final String filename = namespace + ADOC_EXTENSION;
602                 operationDoc.toFile(outputDirPath, filename);
603                 parentDoc.include(filename);
604             }
605         }
606     }
607 
608     /**
609      * Outputs an AsciiDoc file a single {@link Action}-operation, and a file for that action.
610      *
611      * @param resource Resource
612      * @param action Action operation
613      * @param sectionLevel Starting <a href="http://asciidoctor.org/docs/user-manual/#sections">section</a>-level
614      * @param parameters Inherited CREST operation parameters
615      * @param parentNamespace Parent namespace
616      * @return File path suitable for AsciiDoc import-statement
617      * @throws IOException Unable to output AsciiDoc file
618      */
619     private String outputActionOperation(final Resource resource, final Action action, final int sectionLevel,
620             final List<Parameter> parameters, final String parentNamespace) throws IOException {
621         final String namespace = normalizeName(parentNamespace, action.getName());
622         final AsciiDoc operationDoc = asciiDoc()
623                 .horizontalRule()
624                 .sectionTitle(asciiDoc().rawText("Action: ").mono(action.getName()).toString(), sectionLevel);
625         final String description = toTranslatedString(action.getDescription());
626         final String descriptionFilename = outputDescriptionBlock(description, namespace);
627         operationDoc.include(descriptionFilename);
628 
629         final List<String> headers = new ArrayList<>();
630         final List<Integer> columnWidths = new ArrayList<>();
631         final AsciiDocTable table = operationDoc.tableStart();
632         outputStability(action.getStability(), table, headers, columnWidths);
633         outputMvccSupport(resource.isMvccSupported(), table, headers, columnWidths);
634         table.headers(headers)
635                 .columnWidths(columnWidths)
636                 .tableEnd();
637 
638         outputParameters(action.getParameters(), parameters, namespace, operationDoc);
639 
640         if (action.getRequest() != null) {
641             final Schema schema = action.getRequest().getReference() == null
642                     ? action.getRequest()
643                     : referenceResolver.getDefinition(action.getRequest().getReference());
644 
645             final AsciiDoc blockDoc = asciiDoc()
646                     .blockTitle("Request Entity")
647                     .rawParagraph("This operation takes a request resource that conforms to the following schema:")
648                     .listingBlock(OBJECT_MAPPER.writeValueAsString(schema.getSchema().getObject()), "json");
649             operationDoc.rawText(blockDoc.toString());
650         }
651 
652         if (action.getResponse() != null) {
653             final Schema schema = action.getResponse().getReference() == null
654                     ? action.getResponse()
655                     : referenceResolver.getDefinition(action.getResponse().getReference());
656 
657             final AsciiDoc blockDoc = asciiDoc()
658                     .blockTitle("Response Entity")
659                     .rawParagraph("This operation returns a response resource that conforms to the following schema:")
660                     .listingBlock(OBJECT_MAPPER.writeValueAsString(schema.getSchema().getObject()), "json");
661             operationDoc.rawText(blockDoc.toString());
662         }
663 
664         outputErrors(action.getApiErrors(), operationDoc);
665 
666         final String filename = namespace + ADOC_EXTENSION;
667         if (inMemoryMode) {
668             adocMap.put(filename, operationDoc.toString());
669         } else {
670             operationDoc.toFile(outputDirPath, filename);
671         }
672         return filename;
673     }
674 
675     /**
676      * Outputs an AsciiDoc file for a group of {@link Query}-operations, and a file that imports each of those files.
677      *
678      * @param resource Resource
679      * @param sectionLevel Starting <a href="http://asciidoctor.org/docs/user-manual/#sections">section</a>-level
680      * @param parameters Inherited CREST operation parameters
681      * @param parentNamespace Parent namespace
682      * @param parentDoc Parent AsciiDoc
683      * @throws IOException Unable to output AsciiDoc file
684      */
685     private void outputQueryOperations(final Resource resource, final int sectionLevel,
686             final List<Parameter> parameters, final String parentNamespace,
687             final AsciiDoc parentDoc) throws IOException {
688         if (!isEmpty(resource.getQueries())) {
689             final String namespace = normalizeName(parentNamespace, "query");
690             final AsciiDoc operationDoc = asciiDoc();
691 
692             for (final Query query : resource.getQueries()) {
693                 final String filename = outputQueryOperation(resource, query, sectionLevel, parameters,
694                         namespace);
695                 operationDoc.include(filename);
696             }
697 
698             final String filename = namespace + ADOC_EXTENSION;
699             if (inMemoryMode) {
700                 adocMap.put(filename, operationDoc.toString());
701             } else {
702                 operationDoc.toFile(outputDirPath, filename);
703             }
704             parentDoc.include(filename);
705         }
706     }
707 
708     /**
709      * Outputs an AsciiDoc file for a single {@link Query}-operation.
710      *
711      * @param resource Resource
712      * @param query Query operation
713      * @param sectionLevel Starting <a href="http://asciidoctor.org/docs/user-manual/#sections">section</a>-level
714      * @param parameters Inherited CREST operation parameters
715      * @param parentNamespace Parent namespace
716      * @return File path suitable for AsciiDoc import-statement
717      * @throws IOException Unable to output AsciiDoc file
718      */
719     private String outputQueryOperation(final Resource resource, final Query query, final int sectionLevel,
720             final List<Parameter> parameters, final String parentNamespace) throws IOException {
721         final String namespace;
722         final AsciiDoc operationDoc = asciiDoc()
723                 .horizontalRule();
724 
725         switch (query.getType()) {
726         case ID:
727             namespace = normalizeName(parentNamespace, "id", query.getQueryId());
728             operationDoc.sectionTitle(asciiDoc().rawText("Query by ID: ").mono(query.getQueryId()).toString(),
729                     sectionLevel);
730             break;
731         case FILTER:
732             namespace = normalizeName(parentNamespace, "filter");
733             operationDoc.sectionTitle("Query by Filter", sectionLevel);
734             break;
735         case EXPRESSION:
736             namespace = normalizeName(parentNamespace, "expression");
737             operationDoc.sectionTitle("Query by Expression", sectionLevel);
738             break;
739         default:
740             throw new ApiDocGeneratorException("Unsupported QueryType: " + query.getType());
741         }
742 
743         final String descriptionFilename = outputDescriptionBlock(
744                 toTranslatedString(query.getDescription()), namespace);
745         operationDoc.include(descriptionFilename);
746 
747         final List<String> headers = new ArrayList<>();
748         final List<Integer> columnWidths = new ArrayList<>();
749         final AsciiDocTable table = operationDoc.tableStart();
750         outputStability(query.getStability(), table, headers, columnWidths);
751         outputMvccSupport(resource.isMvccSupported(), table, headers, columnWidths);
752 
753         if (!isEmpty(query.getQueryableFields())) {
754             headers.add(asciiDoc().link("query-queryable-fields", "Queryable Fields").toString());
755             columnWidths.add(COLUMN_WIDTH_MEDIUM);
756 
757             final AsciiDoc blockDoc = asciiDoc();
758             for (final String field : query.getQueryableFields()) {
759                 blockDoc.unorderedList1(asciiDoc().mono(field).toString());
760             }
761             table.columnCell(blockDoc.toString(), ASCII_DOC_CELL);
762         }
763 
764         if (!isEmpty(query.getPagingModes())) {
765             headers.add(asciiDoc().link("query-paging-modes", "Paging Modes").toString());
766             columnWidths.add(COLUMN_WIDTH_MEDIUM);
767 
768             final AsciiDoc blockDoc = asciiDoc();
769             for (final PagingMode pagingMode : query.getPagingModes()) {
770                 blockDoc.unorderedList1(asciiDoc().mono(pagingMode.toString()).toString());
771             }
772             table.columnCell(blockDoc.toString(), ASCII_DOC_CELL);
773         }
774 
775         if (!isEmpty(query.getCountPolicies())) {
776             headers.add(asciiDoc().link("query-page-count-policies", "Page Count Policies").toString());
777             columnWidths.add(COLUMN_WIDTH_MEDIUM);
778 
779             final AsciiDoc blockDoc = asciiDoc();
780             for (final CountPolicy countPolicy : query.getCountPolicies()) {
781                 blockDoc.unorderedList1(asciiDoc().mono(countPolicy.toString()).toString());
782             }
783             table.columnCell(blockDoc.toString(), ASCII_DOC_CELL);
784         }
785 
786         if (!isEmpty(query.getSupportedSortKeys())) {
787             headers.add(asciiDoc().link("query-sort-keys", "Supported Sort Keys").toString());
788             columnWidths.add(COLUMN_WIDTH_MEDIUM);
789 
790             final AsciiDoc blockDoc = asciiDoc();
791             for (final String sortKey : query.getSupportedSortKeys()) {
792                 blockDoc.unorderedList1(asciiDoc().mono(sortKey).toString());
793             }
794             table.columnCell(blockDoc.toString(), ASCII_DOC_CELL);
795         }
796 
797         table.headers(headers)
798                 .columnWidths(columnWidths)
799                 .tableEnd();
800 
801         outputParameters(query.getParameters(), parameters, namespace, operationDoc);
802 
803         // TODO determine if this needs to be formatted differently here
804         outputResourceEntity(resource, true, operationDoc);
805 
806         outputErrors(query.getApiErrors(), operationDoc);
807 
808         final String filename = namespace + ADOC_EXTENSION;
809         if (inMemoryMode) {
810             adocMap.put(filename, operationDoc.toString());
811         } else {
812             operationDoc.toFile(outputDirPath, filename);
813         }
814         return filename;
815     }
816 
817     /**
818      * Outputs operation stability.
819      *
820      * @param stability Operation stability or {@code null} to use default {@link Stability#STABLE}
821      * @param table AsciiDoc table to write to
822      * @param headers Table headers, which will have an entry added
823      * @param columnWidths Relative table column-widths (range [1,99]), which will have an entry added
824      */
825     private static void outputStability(Stability stability, final AsciiDocTable table, final List<String> headers,
826             final List<Integer> columnWidths) {
827         if (stability == null) {
828             stability = Stability.STABLE;
829         }
830 
831         headers.add(asciiDoc().link("interface-stability-definitions", "Stability").toString());
832         columnWidths.add(COLUMN_WIDTH_SMALL);
833 
834         table.columnCell(stability.name());
835     }
836 
837     /**
838      * Outputs MVCC support.
839      *
840      * @param mvccSupported MVCC support flag
841      * @param table AsciiDoc table to write to
842      * @param headers Table headers, which will have an entry added
843      * @param columnWidths Relative table column-widths (range [1,99]), which will have an entry added
844      */
845     private static void outputMvccSupport(final boolean mvccSupported, final AsciiDocTable table,
846             final List<String> headers, final List<Integer> columnWidths) {
847         headers.add(asciiDoc().link("MVCC", "MVCC").toString());
848         columnWidths.add(COLUMN_WIDTH_SMALL);
849 
850         // ✓ or ⃠
851         table.columnCell(mvccSupported ? "&#10003;" : "&#8416;");
852     }
853 
854     /**
855      * Outputs an operation's resource schema.
856      *
857      * @param resource Resource
858      * @param responseOnly {@code true} when resource is sent only in response and {@code false} for request/response
859      * @param doc AsciiDoc to write to
860      */
861     private void outputResourceEntity(final Resource resource, final boolean responseOnly, final AsciiDoc doc)
862             throws IOException {
863         if (resource.getResourceSchema() != null) {
864             final Schema schema = resource.getResourceSchema().getReference() == null
865                     ? resource.getResourceSchema()
866                     : referenceResolver.getDefinition(resource.getResourceSchema().getReference());
867             final AsciiDoc blockDoc = asciiDoc()
868                     .blockTitle("Resource Entity");
869             if (responseOnly) {
870                 blockDoc.rawParagraph(
871                         "This operation returns a response resource that conforms to the following schema:");
872             } else {
873                 blockDoc.rawParagraph("This operation takes a request body and returns a response resource that "
874                         + "conforms to the following schema:");
875             }
876             blockDoc.listingBlock(OBJECT_MAPPER.writeValueAsString(schema.getSchema().getObject()), "json");
877 
878             doc.rawText(blockDoc.toString());
879         }
880     }
881 
882     /**
883      * Outputs singleton status for {@link Create} operation.
884      *
885      * @param isSingleton Singleton status
886      * @param table AsciiDoc table to write to
887      * @param headers Table headers, which will have an entry added
888      * @param columnWidths Relative table column-widths (range [1,99]), which will have an entry added
889      */
890     private static void outputSingletonStatus(final boolean isSingleton, final AsciiDocTable table,
891             final List<String> headers, final List<Integer> columnWidths) {
892         headers.add(asciiDoc().link("create-singleton", "Singleton").toString());
893         columnWidths.add(COLUMN_WIDTH_SMALL);
894 
895         // ✓ or ⃠
896         table.columnCell(isSingleton ? "&#10003;" : "&#8416;");
897     }
898 
899     /**
900      * Outputs operation parameters.
901      *
902      * @param operationParameters Operation parameters or {@code null}/empty for pass-through
903      * @param inheritedParameters Inherited CREST operation parameters
904      * @param parentNamespace Parent namespace
905      * @param doc AsciiDoc to write to
906      */
907     private void outputParameters(final Parameter[] operationParameters, final List<Parameter> inheritedParameters,
908             final String parentNamespace, final AsciiDoc doc)
909             throws IOException {
910         final List<Parameter> parameters = mergeParameters(new ArrayList<>(inheritedParameters), operationParameters);
911         if (parameters.isEmpty()) {
912             return;
913         }
914         final String parametersNamespace = normalizeName(parentNamespace, "parameters");
915         final AsciiDocTable table = doc.tableStart()
916                 .title("Parameters")
917                 .headers("Name", "Type", "Description", "Required", "In", "Values")
918                 .columnWidths(2, 1, 4, 1, 1, 2);
919         for (final Parameter parameter : parameters) {
920             // format optional enumValues
921             String enumValuesContent = null;
922             if (!isEmpty(parameter.getEnumValues()) || !isEmpty(parameter.getDefaultValue())) {
923                 final AsciiDoc enumValuesDoc = asciiDoc();
924                 if (!isEmpty(parameter.getDefaultValue())) {
925                     enumValuesDoc.italic("Default:")
926                             .rawText(" ")
927                             .mono(parameter.getDefaultValue())
928                             .newline();
929                 }
930                 if (!isEmpty(parameter.getEnumValues())) {
931                     final String[] enumValues = parameter.getEnumValues();
932                     final String[] enumTitles = parameter.getEnumTitles();
933                     for (int i = 0; i < enumValues.length; ++i) {
934                         final AsciiDoc enumDoc = asciiDoc()
935                                 .mono(enumValues[i]);
936                         if (enumTitles != null) {
937                             enumDoc.rawText(": " + enumTitles[i]);
938                         }
939                         enumValuesDoc.unorderedList1(enumDoc.toString());
940                     }
941                 }
942                 enumValuesContent = enumValuesDoc.toString();
943             }
944 
945             final String namespace = normalizeName(parametersNamespace, parameter.getName());
946             final String description = toTranslatedString(parameter.getDescription());
947             final String descriptionFilename = outputDescriptionBlock(description, namespace);
948 
949             // format table
950             table.columnCell(parameter.getName(), MONO_CELL)
951                     .columnCell(parameter.getType(), MONO_CELL)
952                     .columnCell(asciiDoc().include(descriptionFilename).toString(), ASCII_DOC_CELL)
953                     .columnCell(ValidationUtil.nullToFalse(parameter.isRequired()) ? "&#10003;" : null)
954                     .columnCell(parameter.getSource().name(), MONO_CELL)
955                     .columnCell(enumValuesContent, ASCII_DOC_CELL)
956                     .rowEnd();
957         }
958         table.tableEnd();
959     }
960 
961     /**
962      * Translates text, using preferred locales.
963      *
964      * @param localizableString Text to translate or {@code null}
965      * @return Translated text or {@code null}
966      */
967     private String toTranslatedString(LocalizableString localizableString) {
968         return localizableString == null
969                         ? null
970                         : localizableString.toTranslatedString(PREFERRED_LOCALES);
971     }
972 
973     /**
974      * Outputs create-mode for a {@link Create} operation.
975      *
976      * @param createMode Create-mode
977      * @param table AsciiDoc table to write to
978      * @param headers Table headers, which will have an entry added
979      * @param columnWidths Relative table column-widths (range [1,99]), which will have an entry added
980      */
981     private static void outputCreateMode(final CreateMode createMode, final AsciiDocTable table,
982             final List<String> headers, final List<Integer> columnWidths) {
983         headers.add("Resource ID");
984         columnWidths.add(COLUMN_WIDTH_MEDIUM);
985 
986         final AsciiDoc doc = asciiDoc();
987 
988         switch (createMode) {
989         case ID_FROM_CLIENT:
990             doc.rawText("Assigned by client");
991             break;
992         case ID_FROM_SERVER:
993             doc.rawText("Assigned by server (do not supply)");
994             break;
995         default:
996             throw new ApiDocGeneratorException("Unsupported CreateMode: " + createMode);
997         }
998 
999         table.columnCell(doc.toString());
1000     }
1001 
1002     /**
1003      * Outputs supported patch-operations for a {@link Patch} operation.
1004      *
1005      * @param patchOperations Supported patch-operations
1006      * @param table AsciiDoc table to write to
1007      * @param headers Table headers, which will have an entry added
1008      * @param columnWidths Relative table column-widths (range [1,99]), which will have an entry added
1009      */
1010     private static void outputSupportedPatchOperations(final PatchOperation[] patchOperations,
1011             final AsciiDocTable table, final List<String> headers, final List<Integer> columnWidths) {
1012         headers.add(asciiDoc().link("patch-operations", "Patch Operations").toString());
1013         columnWidths.add(COLUMN_WIDTH_MEDIUM);
1014 
1015         final AsciiDoc blockDoc = asciiDoc();
1016         for (final PatchOperation patchOperation : patchOperations) {
1017             blockDoc.unorderedList1(patchOperation.name());
1018         }
1019         table.columnCell(blockDoc.toString(), ASCII_DOC_CELL);
1020     }
1021 
1022     /**
1023      * Outputs operation errors.
1024      *
1025      * @param apiErrors Operation errors or {@code null}/empty for pass-through
1026      * @param doc AsciiDoc to write to
1027      */
1028     private void outputErrors(final ApiError[] apiErrors, final AsciiDoc doc) throws IOException {
1029         if (!isEmpty(apiErrors)) {
1030             doc.blockTitle("Errors");
1031             final AsciiDocTable table = doc.tableStart()
1032                     .headers("Code", "Description")
1033                     .columnWidths(1, 10);
1034 
1035             // resolve error references before sorting
1036             final List<ApiError> resolvedErrors = new ArrayList<>(apiErrors.length);
1037             for (final ApiError error : apiErrors) {
1038                 if (error.getReference() != null) {
1039                     final ApiError resolved = referenceResolver.getError(error.getReference());
1040                     if (resolved != null && resolved.getReference() == null) {
1041                         resolvedErrors.add(resolved);
1042                     }
1043                 } else {
1044                     resolvedErrors.add(error);
1045                 }
1046             }
1047             Collections.sort(resolvedErrors, ApiError.ERROR_COMPARATOR);
1048 
1049             for (final ApiError error : resolvedErrors) {
1050                 table.columnCell(String.valueOf(error.getCode()), MONO_CELL);
1051                 if (error.getSchema() == null) {
1052                     table.columnCell(toTranslatedString(error.getDescription()));
1053                 } else {
1054                     final Schema schema = error.getSchema().getReference() == null
1055                             ? error.getSchema()
1056                             : referenceResolver.getDefinition(error.getSchema().getReference());
1057 
1058                     final AsciiDoc blockDoc = asciiDoc()
1059                             .rawParagraph(toTranslatedString(error.getDescription()))
1060                             .rawParagraph("This error may contain an underlying `cause` that conforms to the following "
1061                                     + "schema:")
1062                             .listingBlock(OBJECT_MAPPER.writeValueAsString(schema.getSchema().getObject()), "json");
1063                     table.columnCell(blockDoc.toString(), ASCII_DOC_CELL);
1064                 }
1065                 table.rowEnd();
1066             }
1067             table.tableEnd();
1068         }
1069     }
1070 
1071     /**
1072      * Checks for a given {@code filename} within {@link #inputDirPath}, and if it exists, will copy that file
1073      * into {@link #outputDirPath}.
1074      *
1075      * @param filename Filename
1076      * @return {@code true} if a replacement existed and was copied and {@code false} otherwise
1077      * @throws IOException Unable to copy file
1078      */
1079     private boolean copyReplacementFile(final String filename) throws IOException {
1080         if (inputDirPath != null) {
1081             final Path inputFilePath = inputDirPath.resolve(filename);
1082             final Path outputFilePath = outputDirPath.resolve(filename);
1083             if (Files.exists(inputFilePath)) {
1084                 // replacement file exists, so copy it
1085                 Files.copy(inputFilePath, outputFilePath, StandardCopyOption.REPLACE_EXISTING);
1086                 return true;
1087             }
1088         }
1089         return false;
1090     }
1091 
1092     /**
1093      * Add resource-file to path tree.
1094      *
1095      * @param pathName Full endpoint path.
1096      * @param version Resource version.
1097      * @param resourceFilename Resource ADOC filename.
1098      */
1099     private void addPathResource(final String pathName, final Version version, final String resourceFilename) {
1100         if (!pathTree.containsKey(pathName)) {
1101             pathTree.put(pathName, new LinkedHashMap<Version, String>());
1102         }
1103         final Map<Version, String> versionTree = pathTree.get(pathName);
1104         if (!versionTree.containsKey(version)) {
1105             versionTree.put(version, resourceFilename);
1106         }
1107     }
1108 
1109     /**
1110      * Merges AsciiDoc include-statements, to build AsciiDoc string. This method is only invoked when
1111      * {@link #inMemoryMode} is {@code true}.
1112      *
1113      * @param rootFilename Name of root AsciiDoc file
1114      * @return AsciiDoc string
1115      */
1116     private String toString(final String rootFilename) {
1117         if (!inMemoryMode) {
1118             throw new ApiDocGeneratorException("Expected inMemoryMode");
1119         }
1120         String s = adocMap.get(checkNotNull(rootFilename));
1121         final Matcher m = AsciiDoc.INCLUDE_PATTERN.matcher(s);
1122         while (m.find()) {
1123             final String content = adocMap.get(m.group(1));
1124             s = m.replaceFirst(isEmpty(content) ? "" : Matcher.quoteReplacement(content));
1125             m.reset(s);
1126         }
1127         return s;
1128     }
1129 
1130 }