This is an automated email from the ASF dual-hosted git repository.

kdoran pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/nifi-registry.git


The following commit(s) were added to refs/heads/master by this push:
     new d026611  NIFIREG-233 Setup ExtensionDocWriter with HTML implementation 
and REST resource to retrieve docs
d026611 is described below

commit d026611b8db58ab3e9c71cf824fa5c144abdc8c7
Author: Bryan Bende <bbe...@apache.org>
AuthorDate: Mon Mar 11 16:29:57 2019 -0400

    NIFIREG-233 Setup ExtensionDocWriter with HTML implementation and REST 
resource to retrieve docs
    
    - Added end-point to retrieve additional details content
    - Added links to docs from extension metadata and extension repo
    - Added client methods to retrieve docs
    
    This closes #163.
    
    Signed-off-by: Kevin Doran <kdo...@apache.org>
---
 .../nifi/registry/client/BundleVersionClient.java  |  13 +
 .../nifi/registry/client/ExtensionRepoClient.java  |  16 +
 .../client/impl/JerseyBundleVersionClient.java     |  28 +
 .../client/impl/JerseyExtensionRepoClient.java     |  27 +
 .../nifi/registry/extension/bundle/BundleInfo.java |  11 +
 .../extension/component/ExtensionMetadata.java     |  31 +-
 .../repo/ExtensionRepoExtensionMetadata.java       |  21 +-
 .../apache/nifi/registry/link/LinkableDocs.java    |  36 +
 nifi-registry-core/nifi-registry-framework/pom.xml |   1 +
 .../nifi/registry/db/DatabaseMetadataService.java  |  28 +-
 .../entity/ExtensionAdditionalDetailsEntity.java   |  43 ++
 .../nifi/registry/db/entity/ExtensionEntity.java   |  20 +
 .../db/mapper/ExtensionEntityRowMapper.java        |   2 +
 .../nifi/registry/service/MetadataService.java     |  10 +
 .../nifi/registry/service/RegistryService.java     |  20 +
 .../service/extension/ExtensionService.java        |  20 +
 .../extension/StandardExtensionService.java        |  82 ++-
 .../extension/docs/DocumentationConstants.java     |  36 +
 .../service/extension/docs/ExtensionDocWriter.java |  37 +
 .../extension/docs/HtmlExtensionDocWriter.java     | 769 +++++++++++++++++++++
 .../registry/service/mapper/ExtensionMappings.java |   2 +
 .../resources/db/migration/V3__AddExtensions.sql   |   1 +
 .../registry/db/TestDatabaseMetadataService.java   |  32 +
 .../extension/docs/TestHtmlExtensionDocWriter.java |  95 +++
 .../service/extension/docs/XmlValidator.java       |  47 ++
 .../db/migration/V999999.1__test-setup.sql         |  12 +-
 .../extensions/ConsumeKafkaRecord_1_0.json         | 369 ++++++++++
 .../nifi/registry/web/api/BundleResource.java      |  73 ++
 .../registry/web/api/ExtensionRepoResource.java    |  89 ++-
 .../apache/nifi/registry/web/link/LinkService.java |  90 ++-
 .../web/api/UnsecuredNiFiRegistryClientIT.java     |  29 +
 .../nifi/registry/web/link/TestLinkService.java    |  28 +-
 .../src/main/webapp/css/component-usage.css        |  13 +-
 .../src/main/webapp/images/iconInfo.png            | Bin 0 -> 562 bytes
 pom.xml                                            |   6 +-
 35 files changed, 2100 insertions(+), 37 deletions(-)

diff --git 
a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/BundleVersionClient.java
 
b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/BundleVersionClient.java
index 5a6ab97..8512fae 100644
--- 
a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/BundleVersionClient.java
+++ 
b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/BundleVersionClient.java
@@ -157,6 +157,19 @@ public interface BundleVersionClient {
     Extension getExtension(String bundleId, String version, String name) 
throws IOException, NiFiRegistryException;
 
     /**
+     * Obtains an InputStream for the html docs of the given extension.
+     *
+     * @param bundleId the bundle id
+     * @param version the version of the bundle
+     * @param name the name of the extensions
+     * @return the InputStream for the extension docs
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    InputStream getExtensionDocs(String bundleId, String version, String name) 
throws IOException, NiFiRegistryException;
+
+    /**
      * Obtains an InputStream for the binary content for the version of the 
given bundle.
      *
      * @param bundleId the bundle id
diff --git 
a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionRepoClient.java
 
b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionRepoClient.java
index 3650d63..359dee9 100644
--- 
a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionRepoClient.java
+++ 
b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionRepoClient.java
@@ -129,6 +129,22 @@ public interface ExtensionRepoClient {
             throws IOException, NiFiRegistryException;
 
     /**
+     * Gets an InputStream for the html docs of the extension with the given 
name in the given bucket, group, artifact, and version.
+     *
+     * @param bucketName the bucket name
+     * @param groupId the group id
+     * @param artifactId the artifact id
+     * @param version the version
+     * @param extensionName the extension name
+     * @return the InputStream for the html docs
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    InputStream getVersionExtensionDocs(String bucketName, String groupId, 
String artifactId, String version, String extensionName)
+            throws IOException, NiFiRegistryException;
+
+    /**
      * Gets an InputStream for the binary content of the specified version.
      *
      * @param bucketName the bucket name
diff --git 
a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyBundleVersionClient.java
 
b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyBundleVersionClient.java
index 32b8bb2..e9867ba 100644
--- 
a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyBundleVersionClient.java
+++ 
b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyBundleVersionClient.java
@@ -265,6 +265,34 @@ public class JerseyBundleVersionClient extends 
AbstractJerseyClient implements B
     }
 
     @Override
+    public InputStream getExtensionDocs(final String bundleId, final String 
version, final String name) throws IOException, NiFiRegistryException {
+        if (StringUtils.isBlank(bundleId)) {
+            throw new IllegalArgumentException("Bundle id cannot be null or 
blank");
+        }
+
+        if (StringUtils.isBlank(version)) {
+            throw new IllegalArgumentException("Version cannot be null or 
blank");
+        }
+
+        if (StringUtils.isBlank(name)) {
+            throw new IllegalArgumentException("Extension name cannot be null 
or blank");
+        }
+
+        return executeAction("Error getting extension", () -> {
+            final WebTarget target = extensionBundlesTarget
+                    
.path("{bundleId}/versions/{version}/extensions/{name}/docs")
+                    .resolveTemplate("bundleId", bundleId)
+                    .resolveTemplate("version", version)
+                    .resolveTemplate("name", name);
+
+            return getRequestBuilder(target)
+                    .accept(MediaType.TEXT_HTML)
+                    .get()
+                    .readEntity(InputStream.class);
+        });
+    }
+
+    @Override
     public InputStream getBundleVersionContent(final String bundleId, final 
String version)
             throws IOException, NiFiRegistryException {
 
diff --git 
a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionRepoClient.java
 
b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionRepoClient.java
index 1235b03..f4ad5d5 100644
--- 
a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionRepoClient.java
+++ 
b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionRepoClient.java
@@ -191,6 +191,33 @@ public class JerseyExtensionRepoClient extends 
AbstractJerseyClient implements E
     }
 
     @Override
+    public InputStream getVersionExtensionDocs(final String bucketName, final 
String groupId, final String artifactId,
+                                         final String version, final String 
extensionName)
+            throws IOException, NiFiRegistryException {
+
+        validate(bucketName, groupId, artifactId, version);
+
+        if (StringUtils.isBlank(extensionName)) {
+            throw new IllegalArgumentException("Extension name is required");
+        }
+
+        return executeAction("Error retrieving versions for extension repo", 
() -> {
+            final WebTarget target = extensionRepoTarget
+                    
.path("{bucketName}/{groupId}/{artifactId}/{version}/extensions/{extensionName}/docs")
+                    .resolveTemplate("bucketName", bucketName)
+                    .resolveTemplate("groupId", groupId)
+                    .resolveTemplate("artifactId", artifactId)
+                    .resolveTemplate("version", version)
+                    .resolveTemplate("extensionName", extensionName);
+
+            return getRequestBuilder(target)
+                    .accept(MediaType.TEXT_HTML)
+                    .get()
+                    .readEntity(InputStream.class);
+        });
+    }
+
+    @Override
     public InputStream getVersionContent(final String bucketName, final String 
groupId, final String artifactId, final String version)
             throws IOException, NiFiRegistryException {
 
diff --git 
a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/bundle/BundleInfo.java
 
b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/bundle/BundleInfo.java
index 324b944..4177968 100644
--- 
a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/bundle/BundleInfo.java
+++ 
b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/bundle/BundleInfo.java
@@ -32,6 +32,8 @@ public class BundleInfo {
     private String artifactId;
     private String version;
 
+    private String systemApiVersion;
+
     @ApiModelProperty(value = "The id of the bucket where the bundle is 
located")
     public String getBucketId() {
         return bucketId;
@@ -94,4 +96,13 @@ public class BundleInfo {
     public void setVersion(String version) {
         this.version = version;
     }
+
+    @ApiModelProperty(value = "The version of the system API the bundle was 
built against")
+    public String getSystemApiVersion() {
+        return systemApiVersion;
+    }
+
+    public void setSystemApiVersion(String systemApiVersion) {
+        this.systemApiVersion = systemApiVersion;
+    }
 }
diff --git 
a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/component/ExtensionMetadata.java
 
b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/component/ExtensionMetadata.java
index a8ff0b7..64146ea 100644
--- 
a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/component/ExtensionMetadata.java
+++ 
b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/component/ExtensionMetadata.java
@@ -23,14 +23,19 @@ import 
org.apache.nifi.registry.extension.component.manifest.DeprecationNotice;
 import org.apache.nifi.registry.extension.component.manifest.ExtensionType;
 import 
org.apache.nifi.registry.extension.component.manifest.ProvidedServiceAPI;
 import org.apache.nifi.registry.extension.component.manifest.Restricted;
+import org.apache.nifi.registry.link.LinkAdapter;
+import org.apache.nifi.registry.link.LinkableDocs;
 import org.apache.nifi.registry.link.LinkableEntity;
 
+import javax.ws.rs.core.Link;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Objects;
 
 @ApiModel
-public class ExtensionMetadata extends LinkableEntity implements 
Comparable<ExtensionMetadata> {
+public class ExtensionMetadata extends LinkableEntity implements LinkableDocs, 
Comparable<ExtensionMetadata> {
 
     private String name;
     private String displayName;
@@ -41,6 +46,8 @@ public class ExtensionMetadata extends LinkableEntity 
implements Comparable<Exte
     private Restricted restricted;
     private List<ProvidedServiceAPI> providedServiceAPIs;
     private BundleInfo bundleInfo;
+    private boolean hasAdditionalDetails;
+    private Link linkDocs;
 
     @ApiModelProperty(value = "The name of the extension")
     public String getName() {
@@ -123,6 +130,28 @@ public class ExtensionMetadata extends LinkableEntity 
implements Comparable<Exte
         this.bundleInfo = bundleInfo;
     }
 
+    @ApiModelProperty(value = "Whether or not the extension has additional 
detail documentation")
+    public boolean getHasAdditionalDetails() {
+        return hasAdditionalDetails;
+    }
+
+    public void setHasAdditionalDetails(boolean hasAdditionalDetails) {
+        this.hasAdditionalDetails = hasAdditionalDetails;
+    }
+
+    @Override
+    @XmlElement
+    @XmlJavaTypeAdapter(LinkAdapter.class)
+    @ApiModelProperty(value = "A WebLink to the documentation for this 
extension.", readOnly = true)
+    public Link getLinkDocs() {
+        return linkDocs;
+    }
+
+    @Override
+    public void setLinkDocs(Link link) {
+        this.linkDocs = link;
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) return true;
diff --git 
a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoExtensionMetadata.java
 
b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoExtensionMetadata.java
index 76de267..bf4d4ae 100644
--- 
a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoExtensionMetadata.java
+++ 
b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoExtensionMetadata.java
@@ -19,14 +19,20 @@ package org.apache.nifi.registry.extension.repo;
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import org.apache.nifi.registry.extension.component.ExtensionMetadata;
+import org.apache.nifi.registry.link.LinkAdapter;
+import org.apache.nifi.registry.link.LinkableDocs;
 import org.apache.nifi.registry.link.LinkableEntity;
 
+import javax.ws.rs.core.Link;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
 import java.util.Comparator;
 
 @ApiModel
-public class ExtensionRepoExtensionMetadata extends LinkableEntity implements 
Comparable<ExtensionRepoExtensionMetadata> {
+public class ExtensionRepoExtensionMetadata extends LinkableEntity implements 
LinkableDocs, Comparable<ExtensionRepoExtensionMetadata> {
 
     private ExtensionMetadata extensionMetadata;
+    private Link linkDocs;
 
     public ExtensionRepoExtensionMetadata() {
     }
@@ -45,6 +51,19 @@ public class ExtensionRepoExtensionMetadata extends 
LinkableEntity implements Co
     }
 
     @Override
+    @XmlElement
+    @XmlJavaTypeAdapter(LinkAdapter.class)
+    @ApiModelProperty(value = "A WebLink to the documentation for this 
extension.", readOnly = true)
+    public Link getLinkDocs() {
+        return linkDocs;
+    }
+
+    @Override
+    public void setLinkDocs(Link link) {
+        this.linkDocs = link;
+    }
+
+    @Override
     public int compareTo(ExtensionRepoExtensionMetadata o) {
         return 
Comparator.comparing(ExtensionRepoExtensionMetadata::getExtensionMetadata).compare(this,
 o);
     }
diff --git 
a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/link/LinkableDocs.java
 
b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/link/LinkableDocs.java
new file mode 100644
index 0000000..12d9dc4
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/link/LinkableDocs.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.link;
+
+import javax.ws.rs.core.Link;
+
+/**
+ * An entity that has documentation that can be linked to.
+ */
+public interface LinkableDocs {
+
+    /**
+     * @return the web link for the docs
+     */
+    Link getLinkDocs();
+
+    /**
+     * @param link the web link for the docs
+     */
+    void setLinkDocs(Link link);
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/pom.xml 
b/nifi-registry-core/nifi-registry-framework/pom.xml
index c226e42..5996326 100644
--- a/nifi-registry-core/nifi-registry-framework/pom.xml
+++ b/nifi-registry-core/nifi-registry-framework/pom.xml
@@ -143,6 +143,7 @@
                         
<exclude>src/test/resources/serialization/ver1.snapshot</exclude>
                         
<exclude>src/test/resources/serialization/ver2.snapshot</exclude>
                         
<exclude>src/test/resources/serialization/ver3.snapshot</exclude>
+                        
<exclude>src/test/resources/extensions/ConsumeKafkaRecord_1_0.json</exclude>
                     </excludes>
                 </configuration>
             </plugin>
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/DatabaseMetadataService.java
 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/DatabaseMetadataService.java
index bbd566c..2ab945b 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/DatabaseMetadataService.java
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/DatabaseMetadataService.java
@@ -23,6 +23,7 @@ import 
org.apache.nifi.registry.db.entity.BucketItemEntityType;
 import org.apache.nifi.registry.db.entity.BundleEntity;
 import org.apache.nifi.registry.db.entity.BundleVersionDependencyEntity;
 import org.apache.nifi.registry.db.entity.BundleVersionEntity;
+import org.apache.nifi.registry.db.entity.ExtensionAdditionalDetailsEntity;
 import org.apache.nifi.registry.db.entity.ExtensionEntity;
 import org.apache.nifi.registry.db.entity.ExtensionProvidedServiceApiEntity;
 import org.apache.nifi.registry.db.entity.ExtensionRestrictionEntity;
@@ -58,6 +59,7 @@ import java.util.HashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 
 @Repository
@@ -847,11 +849,13 @@ public class DatabaseMetadataService implements 
MetadataService {
                 "e.display_name AS DISPLAY_NAME, " +
                 "e.type AS TYPE, " +
                 "e.content AS CONTENT," +
+                "e.has_additional_details AS HAS_ADDITIONAL_DETAILS, " +
                 "eb.id AS BUNDLE_ID, " +
                 "eb.group_id AS GROUP_ID, " +
                 "eb.artifact_id AS ARTIFACT_ID, " +
                 "eb.bundle_type AS BUNDLE_TYPE, " +
                 "ebv.version AS VERSION, " +
+                "ebv.system_api_version AS SYSTEM_API_VERSION, " +
                 "b.id AS BUCKET_ID, " +
                 "b.name as BUCKET_NAME " +
             "FROM " +
@@ -874,8 +878,9 @@ public class DatabaseMetadataService implements 
MetadataService {
                     "DISPLAY_NAME, " +
                     "TYPE, " +
                     "CONTENT, " +
-                    "ADDITIONAL_DETAILS " +
-                ") VALUES (?, ?, ?, ?, ?, ?, ?)";
+                    "ADDITIONAL_DETAILS, " +
+                    "HAS_ADDITIONAL_DETAILS " +
+                ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
 
         jdbcTemplate.update(insertExtensionSql,
                 extension.getId(),
@@ -884,7 +889,8 @@ public class DatabaseMetadataService implements 
MetadataService {
                 extension.getDisplayName(),
                 extension.getExtensionType().name(),
                 extension.getContent(),
-                extension.getAdditionalDetails()
+                extension.getAdditionalDetails(),
+                extension.getAdditionalDetails() != null ? 1 : 0
         );
 
         // insert tags...
@@ -938,6 +944,22 @@ public class DatabaseMetadataService implements 
MetadataService {
     }
 
     @Override
+    public ExtensionAdditionalDetailsEntity 
getExtensionAdditionalDetails(final String bundleVersionId, final String name) {
+        final String selectSql = "SELECT id, additional_details FROM extension 
WHERE bundle_version_id = ? AND name = ?";
+        try {
+            final Object[] args = {bundleVersionId, name};
+            return jdbcTemplate.queryForObject(selectSql, args, (rs, i) -> {
+                final ExtensionAdditionalDetailsEntity entity = new 
ExtensionAdditionalDetailsEntity();
+                entity.setExtensionId(rs.getString("ID"));
+                
entity.setAdditionalDetails(Optional.ofNullable(rs.getString("ADDITIONAL_DETAILS")));
+                return entity;
+            });
+        } catch (EmptyResultDataAccessException e) {
+            return null;
+        }
+    }
+
+    @Override
     public List<ExtensionEntity> getExtensions(final Set<String> 
bucketIdentifiers, final ExtensionFilterParams filterParams) {
         if (bucketIdentifiers == null || bucketIdentifiers.isEmpty()) {
             return Collections.emptyList();
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionAdditionalDetailsEntity.java
 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionAdditionalDetailsEntity.java
new file mode 100644
index 0000000..ce1509b
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionAdditionalDetailsEntity.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.db.entity;
+
+import java.util.Optional;
+
+public class ExtensionAdditionalDetailsEntity {
+
+    private String extensionId;
+
+    private Optional<String> additionalDetails;
+
+    public String getExtensionId() {
+        return extensionId;
+    }
+
+    public void setExtensionId(String extensionId) {
+        this.extensionId = extensionId;
+    }
+
+    public Optional<String> getAdditionalDetails() {
+        return additionalDetails;
+    }
+
+    public void setAdditionalDetails(Optional<String> additionalDetails) {
+        this.additionalDetails = additionalDetails;
+    }
+
+}
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionEntity.java
 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionEntity.java
index 8513625..4c465a9 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionEntity.java
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionEntity.java
@@ -35,6 +35,9 @@ public class ExtensionEntity {
     // populated during creation if provided, but typically won't be populated 
on retrieval
     private String additionalDetails;
 
+    // read-only to let consumers know there are additional details that have 
not be returned, but can be retrieved later
+    private boolean hasAdditionalDetails;
+
     // populated during creation to insert into child tables, but won't be 
populated on retrieval b/c the
     // content field contains all of this info and will be deserialized into 
the full extension
     private Set<String> tags;
@@ -48,6 +51,7 @@ public class ExtensionEntity {
     private String groupId;
     private String artifactId;
     private String version;
+    private String systemApiVersion;
     private BundleType bundleType;
 
 
@@ -107,6 +111,14 @@ public class ExtensionEntity {
         this.additionalDetails = additionalDetails;
     }
 
+    public boolean getHasAdditionalDetails() {
+        return hasAdditionalDetails;
+    }
+
+    public void setHasAdditionalDetails(boolean hasAdditionalDetails) {
+        this.hasAdditionalDetails = hasAdditionalDetails;
+    }
+
     public Set<String> getTags() {
         return tags;
     }
@@ -180,6 +192,14 @@ public class ExtensionEntity {
         this.version = version;
     }
 
+    public String getSystemApiVersion() {
+        return systemApiVersion;
+    }
+
+    public void setSystemApiVersion(String systemApiVersion) {
+        this.systemApiVersion = systemApiVersion;
+    }
+
     public BundleType getBundleType() {
         return bundleType;
     }
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionEntityRowMapper.java
 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionEntityRowMapper.java
index f2c395d..473445a 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionEntityRowMapper.java
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionEntityRowMapper.java
@@ -37,6 +37,7 @@ public class ExtensionEntityRowMapper implements 
RowMapper<ExtensionEntity> {
         entity.setDisplayName(rs.getString("DISPLAY_NAME"));
         entity.setExtensionType(ExtensionType.valueOf(rs.getString("TYPE")));
         entity.setContent(rs.getString("CONTENT"));
+        entity.setHasAdditionalDetails(rs.getInt("HAS_ADDITIONAL_DETAILS") == 
1 ? true : false);
 
         // fields from joined tables that we know will be there...
         entity.setBucketId(rs.getString("BUCKET_ID"));
@@ -45,6 +46,7 @@ public class ExtensionEntityRowMapper implements 
RowMapper<ExtensionEntity> {
         entity.setGroupId(rs.getString("GROUP_ID"));
         entity.setArtifactId(rs.getString("ARTIFACT_ID"));
         entity.setVersion(rs.getString("VERSION"));
+        entity.setSystemApiVersion(rs.getString("SYSTEM_API_VERSION"));
         entity.setBundleType(BundleType.valueOf(rs.getString("BUNDLE_TYPE")));
 
         return entity;
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/MetadataService.java
 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/MetadataService.java
index 34c8a35..639a116 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/MetadataService.java
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/MetadataService.java
@@ -21,6 +21,7 @@ import org.apache.nifi.registry.db.entity.BucketItemEntity;
 import org.apache.nifi.registry.db.entity.BundleEntity;
 import org.apache.nifi.registry.db.entity.BundleVersionDependencyEntity;
 import org.apache.nifi.registry.db.entity.BundleVersionEntity;
+import org.apache.nifi.registry.db.entity.ExtensionAdditionalDetailsEntity;
 import org.apache.nifi.registry.db.entity.ExtensionEntity;
 import org.apache.nifi.registry.db.entity.FlowEntity;
 import org.apache.nifi.registry.db.entity.FlowSnapshotEntity;
@@ -413,6 +414,15 @@ public interface MetadataService {
     ExtensionEntity getExtensionByName(String bundleVersionId, String name);
 
     /**
+     * Retrieves the additional details documentation for the given extension.
+     *
+     * @param bundleVersionId the bundle version id
+     * @param name the name of the extension
+     * @return the additional details content, or an empty optional
+     */
+    ExtensionAdditionalDetailsEntity getExtensionAdditionalDetails(String 
bundleVersionId, String name);
+
+    /**
      * Retrieves all extensions in the given buckets.
      *
      * @param bucketIdentifiers the bucket identifiers to retrieve extensions 
from
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/RegistryService.java
 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/RegistryService.java
index 1e213b2..d4de627 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/RegistryService.java
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/RegistryService.java
@@ -1173,6 +1173,26 @@ public class RegistryService {
         }
     }
 
+    public void writeExtensionDocs(final BundleVersion bundleVersion, final 
String name, final OutputStream outputStream)
+            throws IOException {
+        readLock.lock();
+        try {
+            extensionService.writeExtensionDocs(bundleVersion, name, 
outputStream);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    public void writeAdditionalDetailsDocs(final BundleVersion bundleVersion, 
final String name, final OutputStream outputStream)
+            throws IOException {
+        readLock.lock();
+        try {
+            extensionService.writeAdditionalDetailsDocs(bundleVersion, name, 
outputStream);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
     public SortedSet<TagCount> getExtensionTags() {
         readLock.lock();
         try {
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionService.java
 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionService.java
index eee60e0..e8276a6 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionService.java
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionService.java
@@ -190,6 +190,26 @@ public interface ExtensionService {
     Extension getExtension(BundleVersion bundleVersion, String name);
 
     /**
+     * Writes the documentation for the extension with the given name and 
bundle to the given output stream.
+     *
+     * @param bundleVersion the bundle version
+     * @param name the name of the extension
+     * @param outputStream the output stream to write to
+     * @throws IOException if an error occurs writing to the output stream
+     */
+    void writeExtensionDocs(BundleVersion bundleVersion, String name, 
OutputStream outputStream) throws IOException;
+
+    /**
+     * Writes the additional details documentation for the extension with the 
given name and bundle to the given output stream.
+     *
+     * @param bundleVersion the bundle version
+     * @param name the name of the extension
+     * @param outputStream the output stream to write to
+     * @throws IOException if an error occurs writing to the output stream
+     */
+    void writeAdditionalDetailsDocs(BundleVersion bundleVersion, String name, 
OutputStream outputStream) throws IOException;
+
+    /**
      * @return all know tags
      */
     SortedSet<TagCount> getExtensionTags();
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/StandardExtensionService.java
 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/StandardExtensionService.java
index 3d544dd..c7cca08 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/StandardExtensionService.java
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/StandardExtensionService.java
@@ -29,6 +29,7 @@ import org.apache.nifi.registry.db.entity.BucketEntity;
 import org.apache.nifi.registry.db.entity.BundleEntity;
 import org.apache.nifi.registry.db.entity.BundleVersionDependencyEntity;
 import org.apache.nifi.registry.db.entity.BundleVersionEntity;
+import org.apache.nifi.registry.db.entity.ExtensionAdditionalDetailsEntity;
 import org.apache.nifi.registry.db.entity.ExtensionEntity;
 import org.apache.nifi.registry.exception.ResourceNotFoundException;
 import org.apache.nifi.registry.extension.BundleContext;
@@ -55,6 +56,8 @@ import 
org.apache.nifi.registry.provider.extension.StandardBundleContext;
 import org.apache.nifi.registry.security.authorization.user.NiFiUserUtils;
 import org.apache.nifi.registry.serialization.Serializer;
 import org.apache.nifi.registry.service.MetadataService;
+import org.apache.nifi.registry.service.extension.docs.DocumentationConstants;
+import org.apache.nifi.registry.service.extension.docs.ExtensionDocWriter;
 import org.apache.nifi.registry.service.mapper.BucketMappings;
 import org.apache.nifi.registry.service.mapper.ExtensionMappings;
 import org.apache.nifi.registry.util.FileUtils;
@@ -73,6 +76,7 @@ import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
 import java.security.DigestInputStream;
 import java.security.MessageDigest;
 import java.util.Collections;
@@ -94,6 +98,7 @@ public class StandardExtensionService implements 
ExtensionService {
     static final String SNAPSHOT_VERSION_SUFFIX = "SNAPSHOT";
 
     private final Serializer<Extension> extensionSerializer;
+    private final ExtensionDocWriter extensionDocWriter;
     private final MetadataService metadataService;
     private final Map<BundleType, BundleExtractor> extractors;
     private final BundlePersistenceProvider bundlePersistenceProvider;
@@ -102,12 +107,14 @@ public class StandardExtensionService implements 
ExtensionService {
 
     @Autowired
     public StandardExtensionService(final Serializer<Extension> 
extensionSerializer,
+                                    final ExtensionDocWriter 
extensionDocWriter,
                                     final MetadataService metadataService,
                                     final Map<BundleType, BundleExtractor> 
extractors,
                                     final BundlePersistenceProvider 
bundlePersistenceProvider,
                                     final Validator validator,
                                     final NiFiRegistryProperties properties) {
         this.extensionSerializer = extensionSerializer;
+        this.extensionDocWriter = extensionDocWriter;
         this.metadataService = metadataService;
         this.extractors = extractors;
         this.bundlePersistenceProvider = bundlePersistenceProvider;
@@ -333,7 +340,7 @@ public class StandardExtensionService implements 
ExtensionService {
 
             // Check the additionalDetails map to see if there is an entry, 
and if so populate it
             final String additionalDetailsContent = 
additionalDetails.get(extensionEntity.getName());
-            if (StringUtils.isBlank(additionalDetailsContent)) {
+            if (!StringUtils.isBlank(additionalDetailsContent)) {
                 LOGGER.debug("Found additional details documentation for 
extension '{}'", new Object[]{extensionEntity.getName()});
                 extensionEntity.setAdditionalDetails(additionalDetailsContent);
             }
@@ -727,6 +734,79 @@ public class StandardExtensionService implements 
ExtensionService {
     }
 
     @Override
+    public void writeExtensionDocs(final BundleVersion bundleVersion, final 
String name, final OutputStream outputStream)
+            throws IOException {
+        if (bundleVersion == null) {
+            throw new IllegalArgumentException("Bundle version cannot be 
null");
+        }
+
+        if (bundleVersion.getVersionMetadata() == null || 
StringUtils.isBlank(bundleVersion.getVersionMetadata().getId())) {
+            throw new IllegalArgumentException("Bundle version must contain a 
version metadata with a bundle version id");
+        }
+
+        if (StringUtils.isBlank(name)) {
+            throw new IllegalArgumentException("Extension name cannot be null 
or blank");
+        }
+
+        if (outputStream == null) {
+            throw new IllegalArgumentException("Output stream cannot be null");
+        }
+
+        final ExtensionEntity entity = 
metadataService.getExtensionByName(bundleVersion.getVersionMetadata().getId(), 
name);
+        if (entity == null) {
+            LOGGER.warn("The specified extension [{}] does not exist in the 
specified bundle version [{}].",
+                    new Object[]{name, 
bundleVersion.getVersionMetadata().getId()});
+            throw new ResourceNotFoundException("The specified extension does 
not exist in this registry.");
+        }
+
+        final ExtensionMetadata extensionMetadata = 
ExtensionMappings.mapToMetadata(entity, extensionSerializer);
+        final Extension extension = ExtensionMappings.map(entity, 
extensionSerializer);
+        extensionDocWriter.write(extensionMetadata, extension, outputStream);
+    }
+
+    @Override
+    public void writeAdditionalDetailsDocs(final BundleVersion bundleVersion, 
final String name, final OutputStream outputStream) throws IOException {
+        if (bundleVersion == null) {
+            throw new IllegalArgumentException("Bundle version cannot be 
null");
+        }
+
+        if (bundleVersion.getVersionMetadata() == null || 
StringUtils.isBlank(bundleVersion.getVersionMetadata().getId())) {
+            throw new IllegalArgumentException("Bundle version must contain a 
version metadata with a bundle version id");
+        }
+
+        if (StringUtils.isBlank(name)) {
+            throw new IllegalArgumentException("Extension name cannot be null 
or blank");
+        }
+
+        if (outputStream == null) {
+            throw new IllegalArgumentException("Output stream cannot be null");
+        }
+
+        final ExtensionAdditionalDetailsEntity additionalDetailsEntity = 
metadataService.getExtensionAdditionalDetails(
+                bundleVersion.getVersionMetadata().getId(), name);
+
+        if (additionalDetailsEntity == null) {
+            LOGGER.warn("The specified extension [{}] does not exist in the 
specified bundle version [{}].",
+                    new Object[]{name, 
bundleVersion.getVersionMetadata().getId()});
+            throw new ResourceNotFoundException("The specified extension does 
not exist in this registry.");
+        }
+
+        if (!additionalDetailsEntity.getAdditionalDetails().isPresent()) {
+            LOGGER.warn("The specified extension [{}] does not have additional 
details in the specified bundle version [{}].",
+                    new Object[]{name, 
bundleVersion.getVersionMetadata().getId()});
+            throw new IllegalStateException("The specified extension does not 
have additional details.");
+        }
+
+        final String additionalDetailsContent = 
additionalDetailsEntity.getAdditionalDetails().get();
+
+        // The additional details content may have come from NiFi which has a 
different path to the css so we need to fix the location
+        final String componentUsageCssRef = DocumentationConstants.CSS_PATH + 
"component-usage.css";
+        final String updatedContent = 
additionalDetailsContent.replace("../../../../../css/component-usage.css", 
componentUsageCssRef);
+
+        IOUtils.write(updatedContent, outputStream, StandardCharsets.UTF_8);
+    }
+
+    @Override
     public SortedSet<TagCount> getExtensionTags() {
         final SortedSet<TagCount> tagCounts = new TreeSet<>();
         metadataService.getAllExtensionTags().forEach(tc -> 
tagCounts.add(ExtensionMappings.map(tc)));
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/docs/DocumentationConstants.java
 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/docs/DocumentationConstants.java
new file mode 100644
index 0000000..8504b24
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/docs/DocumentationConstants.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.service.extension.docs;
+
+public interface DocumentationConstants {
+
+    /**
+     * The context path for the nifi-registry-docs webapp.
+     */
+    String RESOURCE_PATH = "/nifi-registry-docs";
+
+    /**
+     * The path for images in the nifi-registry-docs webapp.
+     */
+    String IMAGE_PATH = RESOURCE_PATH + "/images/";
+
+    /**
+     * The path for css in the nifi-registry-docs webapp.
+     */
+    String CSS_PATH = RESOURCE_PATH + "/css/";
+
+}
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/docs/ExtensionDocWriter.java
 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/docs/ExtensionDocWriter.java
new file mode 100644
index 0000000..b94da9f
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/docs/ExtensionDocWriter.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.service.extension.docs;
+
+import org.apache.nifi.registry.extension.component.ExtensionMetadata;
+import org.apache.nifi.registry.extension.component.manifest.Extension;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+public interface ExtensionDocWriter {
+
+    /**
+     * Generates the documentation for the given Extension and writes it to 
the given OutputStream.
+     *
+     * @param extensionMetadata the metadata for the extension
+     * @param extension the extension descriptor
+     * @param outputStream the output stream to write the docs to
+     * @throws IOException if an error occurs writing the documentation to the 
given output stream
+     */
+    void write(ExtensionMetadata extensionMetadata, Extension extension, 
OutputStream outputStream) throws IOException;
+
+}
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/docs/HtmlExtensionDocWriter.java
 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/docs/HtmlExtensionDocWriter.java
new file mode 100644
index 0000000..6352846
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/docs/HtmlExtensionDocWriter.java
@@ -0,0 +1,769 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.service.extension.docs;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.registry.extension.bundle.BundleInfo;
+import org.apache.nifi.registry.extension.component.ExtensionMetadata;
+import org.apache.nifi.registry.extension.component.manifest.AllowableValue;
+import 
org.apache.nifi.registry.extension.component.manifest.ControllerServiceDefinition;
+import org.apache.nifi.registry.extension.component.manifest.DeprecationNotice;
+import org.apache.nifi.registry.extension.component.manifest.DynamicProperty;
+import 
org.apache.nifi.registry.extension.component.manifest.ExpressionLanguageScope;
+import org.apache.nifi.registry.extension.component.manifest.Extension;
+import org.apache.nifi.registry.extension.component.manifest.InputRequirement;
+import org.apache.nifi.registry.extension.component.manifest.Property;
+import 
org.apache.nifi.registry.extension.component.manifest.ProvidedServiceAPI;
+import org.apache.nifi.registry.extension.component.manifest.Restricted;
+import org.apache.nifi.registry.extension.component.manifest.Restriction;
+import org.apache.nifi.registry.extension.component.manifest.Stateful;
+import 
org.apache.nifi.registry.extension.component.manifest.SystemResourceConsideration;
+import org.springframework.stereotype.Service;
+
+import javax.xml.stream.FactoryConfigurationError;
+import javax.xml.stream.XMLOutputFactory;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static 
org.apache.nifi.registry.service.extension.docs.DocumentationConstants.CSS_PATH;
+
+@Service
+public class HtmlExtensionDocWriter implements ExtensionDocWriter {
+
+    @Override
+    public void write(final ExtensionMetadata extensionMetadata, final 
Extension extension, final OutputStream outputStream) throws IOException {
+        try {
+            final XMLStreamWriter xmlStreamWriter = 
XMLOutputFactory.newInstance().createXMLStreamWriter(outputStream, "UTF-8");
+            xmlStreamWriter.writeDTD("<!DOCTYPE html>");
+            xmlStreamWriter.writeStartElement("html");
+            xmlStreamWriter.writeAttribute("lang", "en");
+            writeHead(extensionMetadata, xmlStreamWriter);
+            writeBody(extensionMetadata, extension, xmlStreamWriter);
+            xmlStreamWriter.writeEndElement();
+            xmlStreamWriter.close();
+            outputStream.flush();
+        } catch (XMLStreamException | FactoryConfigurationError e) {
+            throw new IOException("Unable to create XMLOutputStream", e);
+        }
+    }
+
+    private void writeHead(final ExtensionMetadata extensionMetadata, final 
XMLStreamWriter xmlStreamWriter) throws XMLStreamException {
+        xmlStreamWriter.writeStartElement("head");
+        xmlStreamWriter.writeStartElement("meta");
+        xmlStreamWriter.writeAttribute("charset", "utf-8");
+        xmlStreamWriter.writeEndElement();
+        writeSimpleElement(xmlStreamWriter, "title", 
extensionMetadata.getDisplayName());
+
+        final String componentUsageCss = CSS_PATH + "component-usage.css";
+        xmlStreamWriter.writeStartElement("link");
+        xmlStreamWriter.writeAttribute("rel", "stylesheet");
+        xmlStreamWriter.writeAttribute("href", componentUsageCss);
+        xmlStreamWriter.writeAttribute("type", "text/css");
+        xmlStreamWriter.writeEndElement();
+        xmlStreamWriter.writeEndElement();
+
+        xmlStreamWriter.writeStartElement("script");
+        xmlStreamWriter.writeAttribute("type", "text/javascript");
+        xmlStreamWriter.writeCharacters("window.onload = 
function(){if(self==top) { " +
+                "document.getElementById('nameHeader').style.display = 
\"inherit\"; } }" );
+        xmlStreamWriter.writeEndElement();
+    }
+
+    private void writeBody(final ExtensionMetadata extensionMetadata, final 
Extension extension,
+                           final XMLStreamWriter xmlStreamWriter) throws 
XMLStreamException {
+        xmlStreamWriter.writeStartElement("body");
+
+        writeHeader(extensionMetadata, extension, xmlStreamWriter);
+        writeBundleInfo(extensionMetadata, xmlStreamWriter);
+        writeDeprecationWarning(extension, xmlStreamWriter);
+        writeDescription(extensionMetadata, extension, xmlStreamWriter);
+        writeTags(extension, xmlStreamWriter);
+        writeProperties(extension, xmlStreamWriter);
+        writeDynamicProperties(extension, xmlStreamWriter);
+        writeAdditionalBodyInfo(extension, xmlStreamWriter);
+        writeStatefulInfo(extension, xmlStreamWriter);
+        writeRestrictedInfo(extension, xmlStreamWriter);
+        writeInputRequirementInfo(extension, xmlStreamWriter);
+        writeSystemResourceConsiderationInfo(extension, xmlStreamWriter);
+        writeProvidedServiceApis(extension, xmlStreamWriter);
+        writeSeeAlso(extension, xmlStreamWriter);
+
+        // end body
+        xmlStreamWriter.writeEndElement();
+    }
+
+    /**
+     * This method may be overridden by sub classes to write additional
+     * information to the body of the documentation.
+     *
+     * @param extension the component to describe
+     * @param xmlStreamWriter the stream writer
+     * @throws XMLStreamException thrown if there was a problem writing to the 
XML stream
+     */
+    protected void writeAdditionalBodyInfo(final Extension extension, final 
XMLStreamWriter xmlStreamWriter) throws XMLStreamException {
+
+    }
+
+    private void writeHeader(final ExtensionMetadata extensionMetadata, final 
Extension extension,
+                             final XMLStreamWriter xmlStreamWriter) throws 
XMLStreamException {
+        xmlStreamWriter.writeStartElement("h1");
+        xmlStreamWriter.writeAttribute("id", "nameHeader");
+        xmlStreamWriter.writeAttribute("style", "display: none;");
+        xmlStreamWriter.writeCharacters(extensionMetadata.getDisplayName());
+        xmlStreamWriter.writeEndElement();
+    }
+
+    private void writeBundleInfoString(final ExtensionMetadata 
extensionMetadata, final XMLStreamWriter xmlStreamWriter) throws 
XMLStreamException {
+        final BundleInfo bundleInfo = extensionMetadata.getBundleInfo();
+        final String bundleInfoText = bundleInfo.getGroupId() + "-" + 
bundleInfo.getArtifactId() + "-" + bundleInfo.getVersion();
+        xmlStreamWriter.writeStartElement("p");
+        xmlStreamWriter.writeStartElement("i");
+        xmlStreamWriter.writeCharacters(bundleInfoText);
+        xmlStreamWriter.writeEndElement();
+        xmlStreamWriter.writeEndElement();
+    }
+
+    private void writeBundleInfo(final ExtensionMetadata extensionMetadata, 
final XMLStreamWriter xmlStreamWriter) throws XMLStreamException {
+        final BundleInfo bundleInfo = extensionMetadata.getBundleInfo();
+
+        final String extenstionType;
+        switch (extensionMetadata.getType()) {
+            case PROCESSOR:
+                extenstionType = "Processor";
+                break;
+            case CONTROLLER_SERVICE:
+                extenstionType = "Controller Service";
+                break;
+            case REPORTING_TASK:
+                extenstionType = "Reporting Task";
+                break;
+            default:
+                throw new IllegalArgumentException("Unknown extension type: " 
+ extensionMetadata.getType());
+        }
+
+        xmlStreamWriter.writeStartElement("table");
+
+        xmlStreamWriter.writeStartElement("tr");
+        writeSimpleElement(xmlStreamWriter, "th", "Extension Info");
+        writeSimpleElement(xmlStreamWriter, "th", "Value");
+        xmlStreamWriter.writeEndElement();
+
+        xmlStreamWriter.writeStartElement("tr");
+        writeSimpleElement(xmlStreamWriter, "td", "Full Name", true, 
"bundle-info");
+        writeSimpleElement(xmlStreamWriter, "td", extensionMetadata.getName());
+        xmlStreamWriter.writeEndElement();
+
+        xmlStreamWriter.writeStartElement("tr");
+        writeSimpleElement(xmlStreamWriter, "td", "Type", true, "bundle-info");
+        writeSimpleElement(xmlStreamWriter, "td", extenstionType);
+        xmlStreamWriter.writeEndElement();
+
+        xmlStreamWriter.writeStartElement("tr");
+        writeSimpleElement(xmlStreamWriter, "td", "Bundle Group", true, 
"bundle-info");
+        writeSimpleElement(xmlStreamWriter, "td", bundleInfo.getGroupId());
+        xmlStreamWriter.writeEndElement();
+
+        xmlStreamWriter.writeStartElement("tr");
+        writeSimpleElement(xmlStreamWriter, "td", "Bundle Artifact", true, 
"bundle-info");
+        writeSimpleElement(xmlStreamWriter, "td", bundleInfo.getArtifactId());
+        xmlStreamWriter.writeEndElement();
+
+        xmlStreamWriter.writeStartElement("tr");
+        writeSimpleElement(xmlStreamWriter, "td", "Bundle Version", true, 
"bundle-info");
+        writeSimpleElement(xmlStreamWriter, "td", bundleInfo.getVersion());
+        xmlStreamWriter.writeEndElement();
+
+        xmlStreamWriter.writeStartElement("tr");
+        writeSimpleElement(xmlStreamWriter, "td", "Bundle Type", true, 
"bundle-info");
+        writeSimpleElement(xmlStreamWriter, "td", 
bundleInfo.getBundleType().toString());
+        xmlStreamWriter.writeEndElement();
+
+        xmlStreamWriter.writeStartElement("tr");
+        writeSimpleElement(xmlStreamWriter, "td", "System API Version", true, 
"bundle-info");
+        writeSimpleElement(xmlStreamWriter, "td", 
bundleInfo.getSystemApiVersion());
+        xmlStreamWriter.writeEndElement();
+
+        xmlStreamWriter.writeEndElement(); // end table
+    }
+
+    private void writeDeprecationWarning(final Extension extension, final 
XMLStreamWriter xmlStreamWriter) throws XMLStreamException {
+        final DeprecationNotice deprecationNotice = 
extension.getDeprecationNotice();
+        if (deprecationNotice != null) {
+            xmlStreamWriter.writeStartElement("h2");
+            xmlStreamWriter.writeCharacters("Deprecation notice: ");
+            xmlStreamWriter.writeEndElement();
+
+            xmlStreamWriter.writeStartElement("p");
+            xmlStreamWriter.writeCharacters("");
+            if (!StringUtils.isEmpty(deprecationNotice.getReason())) {
+                xmlStreamWriter.writeCharacters(deprecationNotice.getReason());
+            } else {
+                xmlStreamWriter.writeCharacters("Please be aware this 
processor is deprecated and may be removed in the near future.");
+            }
+            xmlStreamWriter.writeEndElement();
+
+            xmlStreamWriter.writeStartElement("p");
+            xmlStreamWriter.writeCharacters("Please consider using one the 
following alternatives: ");
+
+            final List<String> alternatives = 
deprecationNotice.getAlternatives();
+            if (alternatives != null && alternatives.size() > 0) {
+                xmlStreamWriter.writeStartElement("ul");
+                for (final String alternative : alternatives) {
+                    xmlStreamWriter.writeStartElement("li");
+                    xmlStreamWriter.writeCharacters(alternative);
+                    xmlStreamWriter.writeEndElement();
+                }
+                xmlStreamWriter.writeEndElement();
+            } else {
+                xmlStreamWriter.writeCharacters("No alternative components 
suggested.");
+            }
+
+            xmlStreamWriter.writeEndElement();
+        }
+    }
+
+    private void writeDescription(final ExtensionMetadata extensionMetadata, 
final Extension extension, final XMLStreamWriter xmlStreamWriter)
+            throws XMLStreamException {
+        final String description = 
StringUtils.isBlank(extension.getDescription())
+                ? "No description provided." : extension.getDescription();
+        writeSimpleElement(xmlStreamWriter, "h2", "Description: ");
+        writeSimpleElement(xmlStreamWriter, "p", description);
+
+        if (extensionMetadata.getHasAdditionalDetails()) {
+            xmlStreamWriter.writeStartElement("p");
+            final BundleInfo bundleInfo = extensionMetadata.getBundleInfo();
+            final String bucketName = bundleInfo.getBucketName();
+            final String groupId = bundleInfo.getGroupId();
+            final String artifactId = bundleInfo.getArtifactId();
+            final String version = bundleInfo.getVersion();
+            final String extensionName = extensionMetadata.getName();
+
+            final String additionalDetailsPath = 
"/nifi-registry-api/extension-repository/"
+                    + bucketName + "/" + groupId + "/" + artifactId + "/" + 
version
+                    + "/extensions/" + extensionName + 
"/docs/additional-details";
+
+            writeLink(xmlStreamWriter, "Additional Details...", 
additionalDetailsPath);
+            xmlStreamWriter.writeEndElement();
+        }
+    }
+
+    private void writeTags(final Extension extension, final XMLStreamWriter 
xmlStreamWriter) throws XMLStreamException {
+        final List<String> tags =  extension.getTags();
+        xmlStreamWriter.writeStartElement("h3");
+        xmlStreamWriter.writeCharacters("Tags: ");
+        xmlStreamWriter.writeEndElement();
+        xmlStreamWriter.writeStartElement("p");
+        if (tags != null) {
+            final String tagString =  StringUtils.join(tags, ", ");
+            xmlStreamWriter.writeCharacters(tagString);
+        } else {
+            xmlStreamWriter.writeCharacters("No tags provided.");
+        }
+        xmlStreamWriter.writeEndElement();
+    }
+
+    protected void writeProperties(final Extension extension, final 
XMLStreamWriter xmlStreamWriter) throws XMLStreamException {
+
+        final List<Property> properties = extension.getProperties();
+        writeSimpleElement(xmlStreamWriter, "h3", "Properties: ");
+
+        if (properties != null && properties.size() > 0) {
+            final boolean containsExpressionLanguage = 
containsExpressionLanguage(extension);
+            final boolean containsSensitiveProperties = 
containsSensitiveProperties(extension);
+            xmlStreamWriter.writeStartElement("p");
+            xmlStreamWriter.writeCharacters("In the list below, the names of 
required properties appear in ");
+            writeSimpleElement(xmlStreamWriter, "strong", "bold");
+            xmlStreamWriter.writeCharacters(". Any other properties (not in 
bold) are considered optional. " +
+                    "The table also indicates any default values");
+            if (containsExpressionLanguage) {
+                if (!containsSensitiveProperties) {
+                    xmlStreamWriter.writeCharacters(", and ");
+                } else {
+                    xmlStreamWriter.writeCharacters(", ");
+                }
+                xmlStreamWriter.writeCharacters("whether a property supports 
the NiFi Expression Language");
+            }
+            if (containsSensitiveProperties) {
+                xmlStreamWriter.writeCharacters(", and whether a property is 
considered " + "\"sensitive\", meaning that its value will be encrypted");
+            }
+            xmlStreamWriter.writeCharacters(".");
+            xmlStreamWriter.writeEndElement();
+
+            xmlStreamWriter.writeStartElement("table");
+            xmlStreamWriter.writeAttribute("id", "properties");
+
+            // write the header row
+            xmlStreamWriter.writeStartElement("tr");
+            writeSimpleElement(xmlStreamWriter, "th", "Name");
+            writeSimpleElement(xmlStreamWriter, "th", "Default Value");
+            writeSimpleElement(xmlStreamWriter, "th", "Allowable Values");
+            writeSimpleElement(xmlStreamWriter, "th", "Description");
+            xmlStreamWriter.writeEndElement();
+
+            // write the individual properties
+            for (Property property : properties) {
+                xmlStreamWriter.writeStartElement("tr");
+                xmlStreamWriter.writeStartElement("td");
+                xmlStreamWriter.writeAttribute("id", "name");
+                if (property.isRequired()) {
+                    writeSimpleElement(xmlStreamWriter, "strong", 
property.getDisplayName());
+                } else {
+                    xmlStreamWriter.writeCharacters(property.getDisplayName());
+                }
+
+                xmlStreamWriter.writeEndElement();
+                writeSimpleElement(xmlStreamWriter, "td", 
property.getDefaultValue(), false, "default-value");
+                xmlStreamWriter.writeStartElement("td");
+                xmlStreamWriter.writeAttribute("id", "allowable-values");
+                writeValidValues(xmlStreamWriter, property);
+                xmlStreamWriter.writeEndElement();
+                xmlStreamWriter.writeStartElement("td");
+                xmlStreamWriter.writeAttribute("id", "description");
+                if (property.getDescription() != null && 
property.getDescription().trim().length() > 0) {
+                    xmlStreamWriter.writeCharacters(property.getDescription());
+                } else {
+                    xmlStreamWriter.writeCharacters("No Description 
Provided.");
+                }
+
+                if (property.isSensitive()) {
+                    xmlStreamWriter.writeEmptyElement("br");
+                    writeSimpleElement(xmlStreamWriter, "strong", "Sensitive 
Property: true");
+                }
+
+                if (property.isExpressionLanguageSupported()) {
+                    xmlStreamWriter.writeEmptyElement("br");
+                    String text = "Supports Expression Language: true";
+                    final String perFF = " (will be evaluated using flow file 
attributes and variable registry)";
+                    final String registry = " (will be evaluated using 
variable registry only)";
+                    final InputRequirement inputRequirement = 
extension.getInputRequirement();
+
+                    switch(property.getExpressionLanguageScope()) {
+                        case FLOWFILE_ATTRIBUTES:
+                            if(inputRequirement != null && 
inputRequirement.equals(InputRequirement.INPUT_FORBIDDEN)) {
+                                text += registry;
+                            } else {
+                                text += perFF;
+                            }
+                            break;
+                        case VARIABLE_REGISTRY:
+                            text += registry;
+                            break;
+                        case NONE:
+                        default:
+                            // in case legacy/deprecated method has been used 
to specify EL support
+                            text += " (undefined scope)";
+                            break;
+                    }
+
+                    writeSimpleElement(xmlStreamWriter, "strong", text);
+                }
+                xmlStreamWriter.writeEndElement();
+
+                xmlStreamWriter.writeEndElement();
+            }
+
+            xmlStreamWriter.writeEndElement();
+
+        } else {
+            writeSimpleElement(xmlStreamWriter, "p", "This component has no 
required or optional properties.");
+        }
+    }
+
+    private boolean containsExpressionLanguage(final Extension extension) {
+        for (Property property : extension.getProperties()) {
+            if (property.isExpressionLanguageSupported()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean containsSensitiveProperties(final Extension extension) {
+        for (Property property : extension.getProperties()) {
+            if (property.isSensitive()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    protected void writeValidValues(final XMLStreamWriter xmlStreamWriter, 
final Property property) throws XMLStreamException {
+        if (property.getAllowableValues() != null && 
property.getAllowableValues().size() > 0) {
+            xmlStreamWriter.writeStartElement("ul");
+            for (AllowableValue value : property.getAllowableValues()) {
+                xmlStreamWriter.writeStartElement("li");
+                xmlStreamWriter.writeCharacters(value.getDisplayName());
+
+                if (!StringUtils.isBlank(value.getDescription())) {
+                    writeValidValueDescription(xmlStreamWriter, 
value.getDescription());
+                }
+                xmlStreamWriter.writeEndElement();
+            }
+            xmlStreamWriter.writeEndElement();
+        } else if (property.getControllerServiceDefinition() != null) {
+            final ControllerServiceDefinition serviceDefinition = 
property.getControllerServiceDefinition();
+            final String controllerServiceClass = 
getSimpleName(serviceDefinition.getClassName());
+
+            final String group = serviceDefinition.getGroupId() == null ? 
"unknown" : serviceDefinition.getGroupId();
+            final String artifact = serviceDefinition.getArtifactId() == null 
? "unknown" : serviceDefinition.getArtifactId();
+            final String version = serviceDefinition.getVersion() == null ? 
"unknown" : serviceDefinition.getVersion();
+
+            writeSimpleElement(xmlStreamWriter, "strong", "Controller Service 
API: ");
+            xmlStreamWriter.writeEmptyElement("br");
+            xmlStreamWriter.writeCharacters(controllerServiceClass);
+
+            writeValidValueDescription(xmlStreamWriter, group + "-" + artifact 
+ "-" + version);
+
+//            xmlStreamWriter.writeEmptyElement("br");
+//            xmlStreamWriter.writeCharacters(group);
+//            xmlStreamWriter.writeEmptyElement("br");
+//            xmlStreamWriter.writeCharacters(artifact);
+//            xmlStreamWriter.writeEmptyElement("br");
+//            xmlStreamWriter.writeCharacters(version);
+        }
+    }
+
+    private String getSimpleName(final String extensionName) {
+        int index = extensionName.lastIndexOf('.');
+        if (index > 0 && (index < (extensionName.length() - 1))) {
+            return extensionName.substring(index + 1);
+        } else {
+            return extensionName;
+        }
+    }
+
+    private void writeValidValueDescription(final XMLStreamWriter 
xmlStreamWriter, final String description) throws XMLStreamException {
+        xmlStreamWriter.writeCharacters(" ");
+        xmlStreamWriter.writeStartElement("img");
+        xmlStreamWriter.writeAttribute("src", 
"/nifi-registry-docs/images/iconInfo.png");
+        xmlStreamWriter.writeAttribute("alt", description);
+        xmlStreamWriter.writeAttribute("title", description);
+        xmlStreamWriter.writeEndElement();
+    }
+
+    private void writeDynamicProperties(final Extension extension, final 
XMLStreamWriter xmlStreamWriter) throws XMLStreamException {
+
+        final List<DynamicProperty> dynamicProperties = 
extension.getDynamicProperties();
+
+        if (dynamicProperties != null && dynamicProperties.size() > 0) {
+            writeSimpleElement(xmlStreamWriter, "h3", "Dynamic Properties: ");
+            xmlStreamWriter.writeStartElement("p");
+            xmlStreamWriter.writeCharacters("Dynamic Properties allow the user 
to specify both the name and value of a property.");
+            xmlStreamWriter.writeStartElement("table");
+            xmlStreamWriter.writeAttribute("id", "dynamic-properties");
+            xmlStreamWriter.writeStartElement("tr");
+            writeSimpleElement(xmlStreamWriter, "th", "Name");
+            writeSimpleElement(xmlStreamWriter, "th", "Value");
+            writeSimpleElement(xmlStreamWriter, "th", "Description");
+            xmlStreamWriter.writeEndElement();
+
+            for (final DynamicProperty dynamicProperty : dynamicProperties) {
+                final String name = 
StringUtils.isBlank(dynamicProperty.getName()) ? "Not Specified" : 
dynamicProperty.getName();
+                final String value = 
StringUtils.isBlank(dynamicProperty.getValue()) ? "Not Specified" : 
dynamicProperty.getValue();
+                final String description = 
StringUtils.isBlank(dynamicProperty.getDescription()) ? "Not Specified" : 
dynamicProperty.getDescription();
+
+                xmlStreamWriter.writeStartElement("tr");
+                writeSimpleElement(xmlStreamWriter, "td", name, false, "name");
+                writeSimpleElement(xmlStreamWriter, "td", value, false, 
"value");
+                xmlStreamWriter.writeStartElement("td");
+                xmlStreamWriter.writeCharacters(description);
+                xmlStreamWriter.writeEmptyElement("br");
+
+                final ExpressionLanguageScope elScope = 
dynamicProperty.getExpressionLanguageScope() == null
+                        ? ExpressionLanguageScope.NONE : 
dynamicProperty.getExpressionLanguageScope();
+
+                String text;
+                if(elScope.equals(ExpressionLanguageScope.NONE)) {
+                    if(dynamicProperty.isExpressionLanguageSupported()) {
+                        text = "Supports Expression Language: true (undefined 
scope)";
+                    } else {
+                        text = "Supports Expression Language: false";
+                    }
+                } else {
+                    switch(elScope) {
+                        case FLOWFILE_ATTRIBUTES:
+                            text = "Supports Expression Language: true (will 
be evaluated using flow file attributes and variable registry)";
+                            break;
+                        case VARIABLE_REGISTRY:
+                            text = "Supports Expression Language: true (will 
be evaluated using variable registry only)";
+                            break;
+                        case NONE:
+                        default:
+                            text = "Supports Expression Language: false";
+                            break;
+                    }
+                }
+
+                writeSimpleElement(xmlStreamWriter, "strong", text);
+                xmlStreamWriter.writeEndElement();
+                xmlStreamWriter.writeEndElement();
+            }
+
+            xmlStreamWriter.writeEndElement();
+            xmlStreamWriter.writeEndElement();
+        }
+    }
+
+    private void writeStatefulInfo(final Extension extension, final 
XMLStreamWriter xmlStreamWriter)
+            throws XMLStreamException {
+        final Stateful stateful = extension.getStateful();
+        writeSimpleElement(xmlStreamWriter, "h3", "State management: ");
+
+        if(stateful != null) {
+            final List<String> scopes = 
Optional.ofNullable(stateful.getScopes())
+                    .map(List::stream)
+                    .orElseGet(Stream::empty)
+                    .map(s -> s.toString())
+                    .collect(Collectors.toList());
+
+            final String description = 
StringUtils.isBlank(stateful.getDescription()) ? "Not Specified" : 
stateful.getDescription();
+
+            xmlStreamWriter.writeStartElement("table");
+            xmlStreamWriter.writeAttribute("id", "stateful");
+            xmlStreamWriter.writeStartElement("tr");
+            writeSimpleElement(xmlStreamWriter, "th", "Scope");
+            writeSimpleElement(xmlStreamWriter, "th", "Description");
+            xmlStreamWriter.writeEndElement();
+
+            xmlStreamWriter.writeStartElement("tr");
+            writeSimpleElement(xmlStreamWriter, "td", StringUtils.join(scopes, 
", "));
+            writeSimpleElement(xmlStreamWriter, "td", description);
+            xmlStreamWriter.writeEndElement();
+
+            xmlStreamWriter.writeEndElement();
+        } else {
+            xmlStreamWriter.writeCharacters("This component does not store 
state.");
+        }
+    }
+
+    private void writeRestrictedInfo(final Extension extension, final 
XMLStreamWriter xmlStreamWriter)
+            throws XMLStreamException {
+        final Restricted restricted = extension.getRestricted();
+        writeSimpleElement(xmlStreamWriter, "h3", "Restricted: ");
+
+        if(restricted != null) {
+            final String generalRestrictionExplanation = 
restricted.getGeneralRestrictionExplanation();
+            if (!StringUtils.isBlank(generalRestrictionExplanation)) {
+                xmlStreamWriter.writeCharacters(generalRestrictionExplanation);
+            }
+
+            final List<Restriction> restrictions = 
restricted.getRestrictions();
+            if (restrictions != null && restrictions.size() > 0) {
+                xmlStreamWriter.writeStartElement("table");
+                xmlStreamWriter.writeAttribute("id", "restrictions");
+                xmlStreamWriter.writeStartElement("tr");
+                writeSimpleElement(xmlStreamWriter, "th", "Required 
Permission");
+                writeSimpleElement(xmlStreamWriter, "th", "Explanation");
+                xmlStreamWriter.writeEndElement();
+
+                for (Restriction restriction : restrictions) {
+                    final String permission = 
StringUtils.isBlank(restriction.getRequiredPermission())
+                            ? "Not Specified" : 
restriction.getRequiredPermission();
+
+                    final String explanation = 
StringUtils.isBlank(restriction.getExplanation())
+                            ? "Not Specified" : restriction.getExplanation();
+
+                    xmlStreamWriter.writeStartElement("tr");
+                    writeSimpleElement(xmlStreamWriter, "td", permission);
+                    writeSimpleElement(xmlStreamWriter, "td", explanation);
+                    xmlStreamWriter.writeEndElement();
+                }
+
+                xmlStreamWriter.writeEndElement();
+            } else {
+                xmlStreamWriter.writeCharacters("This component requires 
access to restricted components regardless of restriction.");
+            }
+        } else {
+            xmlStreamWriter.writeCharacters("This component is not 
restricted.");
+        }
+    }
+
+    private void writeInputRequirementInfo(final Extension extension, final 
XMLStreamWriter xmlStreamWriter)
+            throws XMLStreamException {
+        final InputRequirement inputRequirement = 
extension.getInputRequirement();
+        if(inputRequirement != null) {
+            writeSimpleElement(xmlStreamWriter, "h3", "Input requirement: ");
+            switch (inputRequirement) {
+                case INPUT_FORBIDDEN:
+                    xmlStreamWriter.writeCharacters("This component does not 
allow an incoming relationship.");
+                    break;
+                case INPUT_ALLOWED:
+                    xmlStreamWriter.writeCharacters("This component allows an 
incoming relationship.");
+                    break;
+                case INPUT_REQUIRED:
+                    xmlStreamWriter.writeCharacters("This component requires 
an incoming relationship.");
+                    break;
+                default:
+                    xmlStreamWriter.writeCharacters("This component does not 
have input requirement.");
+                    break;
+            }
+        }
+    }
+
+    private void writeSystemResourceConsiderationInfo(final Extension 
extension, final XMLStreamWriter xmlStreamWriter)
+            throws XMLStreamException {
+
+        List<SystemResourceConsideration> systemResourceConsiderations = 
extension.getSystemResourceConsiderations();
+
+        writeSimpleElement(xmlStreamWriter, "h3", "System Resource 
Considerations:");
+        if (systemResourceConsiderations != null && 
systemResourceConsiderations.size() > 0) {
+            xmlStreamWriter.writeStartElement("table");
+            xmlStreamWriter.writeAttribute("id", 
"system-resource-considerations");
+            xmlStreamWriter.writeStartElement("tr");
+            writeSimpleElement(xmlStreamWriter, "th", "Resource");
+            writeSimpleElement(xmlStreamWriter, "th", "Description");
+            xmlStreamWriter.writeEndElement();
+
+            for (SystemResourceConsideration systemResourceConsideration : 
systemResourceConsiderations) {
+                final String resource = 
StringUtils.isBlank(systemResourceConsideration.getResource())
+                        ? "Not Specified" : 
systemResourceConsideration.getResource();
+                final String description = 
StringUtils.isBlank(systemResourceConsideration.getDescription())
+                        ? "Not Specified" : 
systemResourceConsideration.getDescription();
+
+                xmlStreamWriter.writeStartElement("tr");
+                writeSimpleElement(xmlStreamWriter, "td", resource);
+                writeSimpleElement(xmlStreamWriter, "td", description);
+                xmlStreamWriter.writeEndElement();
+            }
+            xmlStreamWriter.writeEndElement();
+
+        } else {
+            xmlStreamWriter.writeCharacters("None specified.");
+        }
+    }
+
+    private void writeProvidedServiceApis(final Extension extension, final 
XMLStreamWriter xmlStreamWriter) throws XMLStreamException {
+        final List<ProvidedServiceAPI> serviceAPIS = 
extension.getProvidedServiceAPIs();
+        if (serviceAPIS != null && serviceAPIS.size() > 0) {
+            writeSimpleElement(xmlStreamWriter, "h3", "Provided Service 
APIs:");
+
+            xmlStreamWriter.writeStartElement("ul");
+
+            for (final ProvidedServiceAPI serviceAPI : serviceAPIS) {
+                final String name = getSimpleName(serviceAPI.getClassName());
+                final String bundleInfo = " (" + serviceAPI.getGroupId() + "-" 
+ serviceAPI.getArtifactId() + "-" + serviceAPI.getVersion() + ")";
+
+                xmlStreamWriter.writeStartElement("li");
+                xmlStreamWriter.writeCharacters(name);
+                xmlStreamWriter.writeStartElement("i");
+                xmlStreamWriter.writeCharacters(bundleInfo);
+                xmlStreamWriter.writeEndElement();
+                xmlStreamWriter.writeEndElement();
+            }
+
+            xmlStreamWriter.writeEndElement();
+        }
+    }
+
+    private void writeSeeAlso(final Extension extension, final XMLStreamWriter 
xmlStreamWriter)
+            throws XMLStreamException {
+        final List<String> seeAlsos = extension.getSeeAlso();
+        if (seeAlsos != null && seeAlsos.size() > 0) {
+            writeSimpleElement(xmlStreamWriter, "h3", "See Also:");
+
+            xmlStreamWriter.writeStartElement("ul");
+            for (final String seeAlso : seeAlsos) {
+                writeSimpleElement(xmlStreamWriter, "li", seeAlso);
+            }
+            xmlStreamWriter.writeEndElement();
+        }
+    }
+
+    /**
+     * Writes a begin element, then text, then end element for the element of a
+     * users choosing. Example: &lt;p&gt;text&lt;/p&gt;
+     *
+     * @param writer the stream writer to use
+     * @param elementName the name of the element
+     * @param characters the characters to insert into the element
+     * @throws XMLStreamException thrown if there was a problem writing to the
+     * stream
+     */
+    protected final static void writeSimpleElement(final XMLStreamWriter 
writer, final String elementName,
+                                                   final String characters) 
throws XMLStreamException {
+        writeSimpleElement(writer, elementName, characters, false);
+    }
+
+    /**
+     * Writes a begin element, then text, then end element for the element of a
+     * users choosing. Example: &lt;p&gt;text&lt;/p&gt;
+     *
+     * @param writer the stream writer to use
+     * @param elementName the name of the element
+     * @param characters the characters to insert into the element
+     * @param strong whether the characters should be strong or not.
+     * @throws XMLStreamException thrown if there was a problem writing to the
+     * stream.
+     */
+    protected final static void writeSimpleElement(final XMLStreamWriter 
writer, final String elementName,
+                                                   final String characters, 
boolean strong) throws XMLStreamException {
+        writeSimpleElement(writer, elementName, characters, strong, null);
+    }
+
+    /**
+     * Writes a begin element, an id attribute(if specified), then text, then
+     * end element for element of the users choosing. Example: &lt;p
+     * id="p-id"&gt;text&lt;/p&gt;
+     *
+     * @param writer the stream writer to use
+     * @param elementName the name of the element
+     * @param characters the text of the element
+     * @param strong whether to bold the text of the element or not
+     * @param id the id of the element. specifying null will cause no element 
to
+     * be written.
+     * @throws XMLStreamException xse
+     */
+    protected final static void writeSimpleElement(final XMLStreamWriter 
writer, final String elementName,
+                                                   final String characters, 
boolean strong, String id) throws XMLStreamException {
+        writer.writeStartElement(elementName);
+        if (id != null) {
+            writer.writeAttribute("id", id);
+        }
+        if (strong) {
+            writer.writeStartElement("strong");
+        }
+        writer.writeCharacters(characters);
+        if (strong) {
+            writer.writeEndElement();
+        }
+        writer.writeEndElement();
+    }
+
+    /**
+     * A helper method to write a link
+     *
+     * @param xmlStreamWriter the stream to write to
+     * @param text the text of the link
+     * @param location the location of the link
+     * @throws XMLStreamException thrown if there was a problem writing to the
+     * stream
+     */
+    protected void writeLink(final XMLStreamWriter xmlStreamWriter, final 
String text, final String location)
+            throws XMLStreamException {
+        xmlStreamWriter.writeStartElement("a");
+        xmlStreamWriter.writeAttribute("href", location);
+        xmlStreamWriter.writeCharacters(text);
+        xmlStreamWriter.writeEndElement();
+    }
+
+}
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/mapper/ExtensionMappings.java
 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/mapper/ExtensionMappings.java
index f3094c1..37dedd7 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/mapper/ExtensionMappings.java
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/mapper/ExtensionMappings.java
@@ -243,6 +243,7 @@ public class ExtensionMappings {
         bundleInfo.setArtifactId(entity.getArtifactId());
         bundleInfo.setVersion(entity.getVersion());
         bundleInfo.setBundleType(entity.getBundleType());
+        bundleInfo.setSystemApiVersion(entity.getSystemApiVersion());
 
         final ExtensionMetadata metadata = new ExtensionMetadata();
         metadata.setName(extension.getName());
@@ -254,6 +255,7 @@ public class ExtensionMappings {
         metadata.setProvidedServiceAPIs(extension.getProvidedServiceAPIs());
         metadata.setTags(extension.getTags());
         metadata.setBundleInfo(bundleInfo);
+        metadata.setHasAdditionalDetails(entity.getHasAdditionalDetails());
         return metadata;
     }
 
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/main/resources/db/migration/V3__AddExtensions.sql
 
b/nifi-registry-core/nifi-registry-framework/src/main/resources/db/migration/V3__AddExtensions.sql
index 47f5376..3bd9820 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/main/resources/db/migration/V3__AddExtensions.sql
+++ 
b/nifi-registry-core/nifi-registry-framework/src/main/resources/db/migration/V3__AddExtensions.sql
@@ -67,6 +67,7 @@ CREATE TABLE EXTENSION (
     TYPE VARCHAR(100) NOT NULL,
     CONTENT TEXT NOT NULL,
     ADDITIONAL_DETAILS TEXT,
+    HAS_ADDITIONAL_DETAILS INT NOT NULL,
     CONSTRAINT PK__EXTENSION_ID PRIMARY KEY (ID),
     CONSTRAINT FK__EXTENSION_BUNDLE_VERSION_ID FOREIGN KEY (BUNDLE_VERSION_ID) 
REFERENCES BUNDLE_VERSION(ID) ON DELETE CASCADE,
     CONSTRAINT UNIQUE__EXTENSION_BUNDLE_VERSION_ID_AND_NAME UNIQUE 
(BUNDLE_VERSION_ID, NAME)
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/db/TestDatabaseMetadataService.java
 
b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/db/TestDatabaseMetadataService.java
index 6b1a6f3..dc58e18 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/db/TestDatabaseMetadataService.java
+++ 
b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/db/TestDatabaseMetadataService.java
@@ -22,6 +22,7 @@ import 
org.apache.nifi.registry.db.entity.BucketItemEntityType;
 import org.apache.nifi.registry.db.entity.BundleEntity;
 import org.apache.nifi.registry.db.entity.BundleVersionDependencyEntity;
 import org.apache.nifi.registry.db.entity.BundleVersionEntity;
+import org.apache.nifi.registry.db.entity.ExtensionAdditionalDetailsEntity;
 import org.apache.nifi.registry.db.entity.ExtensionEntity;
 import org.apache.nifi.registry.db.entity.ExtensionProvidedServiceApiEntity;
 import org.apache.nifi.registry.db.entity.ExtensionRestrictionEntity;
@@ -891,6 +892,15 @@ public class TestDatabaseMetadataService extends 
DatabaseBaseTest {
         assertEquals("e1", extension.getId());
         assertEquals("org.apache.nifi.ExampleProcessor", extension.getName());
         assertEquals("{ \"name\" : \"org.apache.nifi.ExampleProcessor\", 
\"type\" : \"PROCESSOR\" }", extension.getContent());
+        assertFalse(extension.getHasAdditionalDetails());
+    }
+
+    @Test
+    public void testGetExtensionByIdWhenHasAdditionalDetails() {
+        final ExtensionEntity extension = 
metadataService.getExtensionById("e3");
+        assertNotNull(extension);
+        assertEquals("e3", extension.getId());
+        assertTrue(extension.getHasAdditionalDetails());
     }
 
     @Test
@@ -932,6 +942,28 @@ public class TestDatabaseMetadataService extends 
DatabaseBaseTest {
     }
 
     @Test
+    public void testGetExtensionAdditionalDetailsWhenPresent() {
+        final ExtensionAdditionalDetailsEntity entity = 
metadataService.getExtensionAdditionalDetails("eb2-v1", 
"org.apache.nifi.ExampleService");
+        assertNotNull(entity);
+        assertEquals("e3", entity.getExtensionId());
+        assertTrue(entity.getAdditionalDetails().isPresent());
+    }
+
+    @Test
+    public void testGetExtensionAdditionalDetailsWhenNotPresent() {
+        final ExtensionAdditionalDetailsEntity entity = 
metadataService.getExtensionAdditionalDetails("eb1-v1", 
"org.apache.nifi.ExampleProcessor");
+        assertNotNull(entity);
+        assertEquals("e1", entity.getExtensionId());
+        assertFalse(entity.getAdditionalDetails().isPresent());
+    }
+
+    @Test
+    public void testGetExtensionAdditionalDetailsWhenExtensionDoesNotExist() {
+        final ExtensionAdditionalDetailsEntity entity = 
metadataService.getExtensionAdditionalDetails("eb1-v1", 
"org.apache.nifi.DOESNOTEXIST");
+        assertNull(entity);
+    }
+
+    @Test
     public void testGetAllExtensions() {
         final Set<String> bucketIds = new HashSet<>();
         bucketIds.add("1");
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/extension/docs/TestHtmlExtensionDocWriter.java
 
b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/extension/docs/TestHtmlExtensionDocWriter.java
new file mode 100644
index 0000000..7d39b55
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/extension/docs/TestHtmlExtensionDocWriter.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.service.extension.docs;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.nifi.registry.db.entity.ExtensionEntity;
+import org.apache.nifi.registry.extension.bundle.BundleType;
+import org.apache.nifi.registry.extension.component.ExtensionMetadata;
+import org.apache.nifi.registry.extension.component.manifest.Extension;
+import org.apache.nifi.registry.serialization.ExtensionSerializer;
+import org.apache.nifi.registry.serialization.Serializer;
+import org.apache.nifi.registry.serialization.jackson.ObjectMapperProvider;
+import org.apache.nifi.registry.service.mapper.ExtensionMappings;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import static org.junit.Assert.assertNotNull;
+
+public class TestHtmlExtensionDocWriter {
+
+    private ExtensionDocWriter docWriter;
+    private Serializer<Extension> extensionSerializer;
+
+    @Before
+    public void setup() {
+        docWriter = new HtmlExtensionDocWriter();
+        extensionSerializer = new ExtensionSerializer();
+    }
+
+    @Test
+    public void testWriteDocsForConsumeKafkaRecord() throws IOException {
+        final File rawExtensionJson = new 
File("src/test/resources/extensions/ConsumeKafkaRecord_1_0.json");
+        final String serializedExtension = 
getSerializedExtension(rawExtensionJson);
+
+        final ExtensionEntity entity = new ExtensionEntity();
+        entity.setContent(serializedExtension);
+        entity.setBucketId(UUID.randomUUID().toString());
+        entity.setBucketName("My Bucket");
+        entity.setGroupId("org.apache.nifi");
+        entity.setArtifactId("nifi-kakfa-bundle");
+        entity.setVersion("1.9.1");
+        entity.setSystemApiVersion("1.9.1");
+        entity.setBundleId(UUID.randomUUID().toString());
+        entity.setBundleType(BundleType.NIFI_NAR);
+        entity.setDisplayName("ConsumeKafkaRecord_1_0");
+
+        final ExtensionMetadata metadata = 
ExtensionMappings.mapToMetadata(entity, extensionSerializer);
+        assertNotNull(entity);
+
+        final Extension extension = ExtensionMappings.map(entity, 
extensionSerializer);
+        assertNotNull(extension);
+
+        final ByteArrayOutputStream out = new ByteArrayOutputStream();
+        docWriter.write(metadata, extension, out);
+
+        final String docsResult = new String(out.toByteArray(), 
StandardCharsets.UTF_8);
+        assertNotNull(docsResult);
+
+        XmlValidator.assertXmlValid(docsResult);
+        XmlValidator.assertContains(docsResult, entity.getDisplayName());
+    }
+
+    private String getSerializedExtension(final File rawExtensionJson) throws 
IOException {
+        final ByteArrayOutputStream serializedExtension = new 
ByteArrayOutputStream();
+        try (final InputStream inputStream = new 
FileInputStream(rawExtensionJson)) {
+            final String rawJson = IOUtils.toString(inputStream, 
StandardCharsets.UTF_8);
+            final Extension tempExtension = 
ObjectMapperProvider.getMapper().readValue(rawJson, Extension.class);
+            extensionSerializer.serialize(tempExtension, serializedExtension);
+        }
+
+        return serializedExtension.toString("UTF-8");
+    }
+}
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/extension/docs/XmlValidator.java
 
b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/extension/docs/XmlValidator.java
new file mode 100644
index 0000000..41cb657
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/extension/docs/XmlValidator.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.service.extension.docs;
+
+import org.junit.Assert;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import java.io.IOException;
+import java.io.StringReader;
+
+public class XmlValidator {
+
+    public static void assertXmlValid(String xml) {
+        try {
+            final DocumentBuilderFactory dbf = 
DocumentBuilderFactory.newInstance();
+            dbf.setNamespaceAware(true);
+            dbf.newDocumentBuilder().parse(new InputSource(new 
StringReader(xml)));
+        } catch (SAXException | IOException | ParserConfigurationException e) {
+            Assert.fail(e.getMessage());
+        }
+    }
+
+    public static void assertContains(String original, String subword) {
+        Assert.assertTrue(original + " did not contain: " + subword, 
original.contains(subword));
+    }
+
+    public static void assertNotContains(String original, String subword) {
+        Assert.assertFalse(original + " did contain: " + subword, 
original.contains(subword));
+    }
+}
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/test/resources/db/migration/V999999.1__test-setup.sql
 
b/nifi-registry-core/nifi-registry-framework/src/test/resources/db/migration/V999999.1__test-setup.sql
index 3d3b8ae..a9a17d7 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/test/resources/db/migration/V999999.1__test-setup.sql
+++ 
b/nifi-registry-core/nifi-registry-framework/src/test/resources/db/migration/V999999.1__test-setup.sql
@@ -267,21 +267,21 @@ insert into bundle_version (
 -- test data for extensions
 
 insert into extension (
-  id, bundle_version_id, name, display_name, type, content
+  id, bundle_version_id, name, display_name, type, content, 
has_additional_details
 ) values (
-  'e1', 'eb1-v1', 'org.apache.nifi.ExampleProcessor', 'ExampleProcessor', 
'PROCESSOR', '{ "name" : "org.apache.nifi.ExampleProcessor", "type" : 
"PROCESSOR" }'
+  'e1', 'eb1-v1', 'org.apache.nifi.ExampleProcessor', 'ExampleProcessor', 
'PROCESSOR', '{ "name" : "org.apache.nifi.ExampleProcessor", "type" : 
"PROCESSOR" }', 0
 );
 
 insert into extension (
-  id, bundle_version_id, name, display_name, type, content
+  id, bundle_version_id, name, display_name, type, content, 
has_additional_details
 ) values (
-  'e2', 'eb1-v1', 'org.apache.nifi.ExampleProcessorRestricted', 
'ExampleProcessorRestricted', 'PROCESSOR', '{ "name" : 
"org.apache.nifi.ExampleProcessorRestricted", "type" : "PROCESSOR" }'
+  'e2', 'eb1-v1', 'org.apache.nifi.ExampleProcessorRestricted', 
'ExampleProcessorRestricted', 'PROCESSOR', '{ "name" : 
"org.apache.nifi.ExampleProcessorRestricted", "type" : "PROCESSOR" }', 0
 );
 
 insert into extension (
-  id, bundle_version_id, name, display_name, type, content
+  id, bundle_version_id, name, display_name, type, content, 
additional_details, has_additional_details
 ) values (
-  'e3', 'eb2-v1', 'org.apache.nifi.ExampleService', 'ExampleService', 
'CONTROLLER_SERVICE', '{ "name" : "org.apache.nifi.ExampleService", "type" : 
"CONTROLLER_SERVICE" }'
+  'e3', 'eb2-v1', 'org.apache.nifi.ExampleService', 'ExampleService', 
'CONTROLLER_SERVICE', '{ "name" : "org.apache.nifi.ExampleService", "type" : 
"CONTROLLER_SERVICE" }', 'extra docs', 1
 );
 
 -- test data for extension restrictions
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/test/resources/extensions/ConsumeKafkaRecord_1_0.json
 
b/nifi-registry-core/nifi-registry-framework/src/test/resources/extensions/ConsumeKafkaRecord_1_0.json
new file mode 100644
index 0000000..6e12ca0
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-framework/src/test/resources/extensions/ConsumeKafkaRecord_1_0.json
@@ -0,0 +1,369 @@
+{
+  "description": "Consumes messages from Apache Kafka specifically built 
against the Kafka 1.0 Consumer API. The complementary NiFi processor for 
sending messages is PublishKafkaRecord_1_0. Please note that, at this time, the 
Processor assumes that all records that are retrieved from a given partition 
have the same schema. If any of the Kafka messages are pulled but cannot be 
parsed or written with the configured Record Reader or Record Writer, the 
contents of the message will be written [...]
+  "dynamicProperty": [
+    {
+      "description": "These properties will be added on the Kafka 
configuration after loading any provided configuration properties. In the event 
a dynamic property represents a property that was already set, its value will 
be ignored and WARN message logged. For the list of available Kafka properties 
please refer to: http://kafka.apache.org/documentation.html#configuration. ",
+      "expressionLanguageScope": "VARIABLE_REGISTRY",
+      "expressionLanguageSupported": false,
+      "name": "The name of a Kafka configuration property.",
+      "value": "The value of a given Kafka configuration property."
+    }
+  ],
+  "inputRequirement": "INPUT_FORBIDDEN",
+  "name": "org.apache.nifi.processors.kafka.pubsub.ConsumeKafkaRecord_1_0",
+  "property": [
+    {
+      "defaultValue": "localhost:9092",
+      "description": "A comma-separated list of known Kafka Brokers in the 
format <host>:<port>",
+      "displayName": "Kafka Brokers",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "VARIABLE_REGISTRY",
+      "expressionLanguageSupported": true,
+      "name": "bootstrap.servers",
+      "required": true,
+      "sensitive": false
+    },
+    {
+      "defaultValue": "",
+      "description": "The name of the Kafka Topic(s) to pull from. More than 
one can be supplied if comma separated.",
+      "displayName": "Topic Name(s)",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "VARIABLE_REGISTRY",
+      "expressionLanguageSupported": true,
+      "name": "topic",
+      "required": true,
+      "sensitive": false
+    },
+    {
+      "allowableValue": [
+        {
+          "description": "Topic is a full topic name or comma separated list 
of names",
+          "displayName": "names",
+          "value": "names"
+        },
+        {
+          "description": "Topic is a regex using the Java Pattern syntax",
+          "displayName": "pattern",
+          "value": "pattern"
+        }
+      ],
+      "defaultValue": "names",
+      "description": "Specifies whether the Topic(s) provided are a comma 
separated list of names or a single regular expression",
+      "displayName": "Topic Name Format",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "NONE",
+      "expressionLanguageSupported": false,
+      "name": "topic_type",
+      "required": true,
+      "sensitive": false
+    },
+    {
+      "controllerServiceDefinition": {
+        "artifactId": "nifi-standard-services-api-nar",
+        "className": "org.apache.nifi.serialization.RecordReaderFactory",
+        "groupId": "org.apache.nifi",
+        "version": "1.10.0-SNAPSHOT"
+      },
+      "defaultValue": "",
+      "description": "The Record Reader to use for incoming FlowFiles",
+      "displayName": "Record Reader",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "NONE",
+      "expressionLanguageSupported": false,
+      "name": "record-reader",
+      "required": true,
+      "sensitive": false
+    },
+    {
+      "controllerServiceDefinition": {
+        "artifactId": "nifi-standard-services-api-nar",
+        "className": "org.apache.nifi.serialization.RecordSetWriterFactory",
+        "groupId": "org.apache.nifi",
+        "version": "1.10.0-SNAPSHOT"
+      },
+      "defaultValue": "",
+      "description": "The Record Writer to use in order to serialize the data 
before sending to Kafka",
+      "displayName": "Record Writer",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "NONE",
+      "expressionLanguageSupported": false,
+      "name": "record-writer",
+      "required": true,
+      "sensitive": false
+    },
+    {
+      "allowableValue": [
+        {
+          "description": "",
+          "displayName": "true",
+          "value": "true"
+        },
+        {
+          "description": "",
+          "displayName": "false",
+          "value": "false"
+        }
+      ],
+      "defaultValue": "true",
+      "description": "Specifies whether or not NiFi should honor transactional 
guarantees when communicating with Kafka. If false, the Processor will use an 
\"isolation level\" of read_uncomitted. This means that messages will be 
received as soon as they are written to Kafka but will be pulled, even if the 
producer cancels the transactions. If this value is true, NiFi will not receive 
any messages for which the producer's transaction was canceled, but this can 
result in some latency sinc [...]
+      "displayName": "Honor Transactions",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "NONE",
+      "expressionLanguageSupported": false,
+      "name": "honor-transactions",
+      "required": true,
+      "sensitive": false
+    },
+    {
+      "allowableValue": [
+        {
+          "description": "PLAINTEXT",
+          "displayName": "PLAINTEXT",
+          "value": "PLAINTEXT"
+        },
+        {
+          "description": "SSL",
+          "displayName": "SSL",
+          "value": "SSL"
+        },
+        {
+          "description": "SASL_PLAINTEXT",
+          "displayName": "SASL_PLAINTEXT",
+          "value": "SASL_PLAINTEXT"
+        },
+        {
+          "description": "SASL_SSL",
+          "displayName": "SASL_SSL",
+          "value": "SASL_SSL"
+        }
+      ],
+      "defaultValue": "PLAINTEXT",
+      "description": "Protocol used to communicate with brokers. Corresponds 
to Kafka's 'security.protocol' property.",
+      "displayName": "Security Protocol",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "NONE",
+      "expressionLanguageSupported": false,
+      "name": "security.protocol",
+      "required": true,
+      "sensitive": false
+    },
+    {
+      "controllerServiceDefinition": {
+        "artifactId": "nifi-standard-services-api-nar",
+        "className": "org.apache.nifi.kerberos.KerberosCredentialsService",
+        "groupId": "org.apache.nifi",
+        "version": "1.10.0-SNAPSHOT"
+      },
+      "defaultValue": "",
+      "description": "Specifies the Kerberos Credentials Controller Service 
that should be used for authenticating with Kerberos",
+      "displayName": "Kerberos Credentials Service",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "NONE",
+      "expressionLanguageSupported": false,
+      "name": "kerberos-credentials-service",
+      "required": false,
+      "sensitive": false
+    },
+    {
+      "defaultValue": "",
+      "description": "The Kerberos principal name that Kafka runs as. This can 
be defined either in Kafka's JAAS config or in Kafka's config. Corresponds to 
Kafka's 'security.protocol' property.It is ignored unless one of the SASL 
options of the <Security Protocol> are selected.",
+      "displayName": "Kerberos Service Name",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "VARIABLE_REGISTRY",
+      "expressionLanguageSupported": true,
+      "name": "sasl.kerberos.service.name",
+      "required": false,
+      "sensitive": false
+    },
+    {
+      "defaultValue": "",
+      "description": "The Kerberos principal that will be used to connect to 
brokers. If not set, it is expected to set a JAAS configuration file in the JVM 
properties defined in the bootstrap.conf file. This principal will be set into 
'sasl.jaas.config' Kafka's property.",
+      "displayName": "Kerberos Principal",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "VARIABLE_REGISTRY",
+      "expressionLanguageSupported": true,
+      "name": "sasl.kerberos.principal",
+      "required": false,
+      "sensitive": false
+    },
+    {
+      "defaultValue": "",
+      "description": "The Kerberos keytab that will be used to connect to 
brokers. If not set, it is expected to set a JAAS configuration file in the JVM 
properties defined in the bootstrap.conf file. This principal will be set into 
'sasl.jaas.config' Kafka's property.",
+      "displayName": "Kerberos Keytab",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "VARIABLE_REGISTRY",
+      "expressionLanguageSupported": true,
+      "name": "sasl.kerberos.keytab",
+      "required": false,
+      "sensitive": false
+    },
+    {
+      "controllerServiceDefinition": {
+        "artifactId": "nifi-standard-services-api-nar",
+        "className": "org.apache.nifi.ssl.SSLContextService",
+        "groupId": "org.apache.nifi",
+        "version": "1.10.0-SNAPSHOT"
+      },
+      "defaultValue": "",
+      "description": "Specifies the SSL Context Service to use for 
communicating with Kafka.",
+      "displayName": "SSL Context Service",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "NONE",
+      "expressionLanguageSupported": false,
+      "name": "ssl.context.service",
+      "required": false,
+      "sensitive": false
+    },
+    {
+      "defaultValue": "",
+      "description": "A Group ID is used to identify consumers that are within 
the same consumer group. Corresponds to Kafka's 'group.id' property.",
+      "displayName": "Group ID",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "VARIABLE_REGISTRY",
+      "expressionLanguageSupported": true,
+      "name": "group.id",
+      "required": true,
+      "sensitive": false
+    },
+    {
+      "allowableValue": [
+        {
+          "description": "Automatically reset the offset to the earliest 
offset",
+          "displayName": "earliest",
+          "value": "earliest"
+        },
+        {
+          "description": "Automatically reset the offset to the latest offset",
+          "displayName": "latest",
+          "value": "latest"
+        },
+        {
+          "description": "Throw exception to the consumer if no previous 
offset is found for the consumer's group",
+          "displayName": "none",
+          "value": "none"
+        }
+      ],
+      "defaultValue": "latest",
+      "description": "Allows you to manage the condition when there is no 
initial offset in Kafka or if the current offset does not exist any more on the 
server (e.g. because that data has been deleted). Corresponds to Kafka's 
'auto.offset.reset' property.",
+      "displayName": "Offset Reset",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "NONE",
+      "expressionLanguageSupported": false,
+      "name": "auto.offset.reset",
+      "required": true,
+      "sensitive": false
+    },
+    {
+      "defaultValue": "UTF-8",
+      "description": "Any message header that is found on a Kafka message will 
be added to the outbound FlowFile as an attribute. This property indicates the 
Character Encoding to use for deserializing the headers.",
+      "displayName": "Message Header Encoding",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "NONE",
+      "expressionLanguageSupported": false,
+      "name": "message-header-encoding",
+      "required": false,
+      "sensitive": false
+    },
+    {
+      "defaultValue": "",
+      "description": "A Regular Expression that is matched against all message 
headers. Any message header whose name matches the regex will be added to the 
FlowFile as an Attribute. If not specified, no Header values will be added as 
FlowFile attributes. If two messages have a different value for the same header 
and that header is selected by the provided regex, then those two messages must 
be added to different FlowFiles. As a result, users should be cautious about 
using a regex like \ [...]
+      "displayName": "Headers to Add as Attributes (Regex)",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "NONE",
+      "expressionLanguageSupported": false,
+      "name": "header-name-regex",
+      "required": false,
+      "sensitive": false
+    },
+    {
+      "defaultValue": "10000",
+      "description": "Specifies the maximum number of records Kafka should 
return in a single poll.",
+      "displayName": "Max Poll Records",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "NONE",
+      "expressionLanguageSupported": false,
+      "name": "max.poll.records",
+      "required": false,
+      "sensitive": false
+    },
+    {
+      "defaultValue": "1 secs",
+      "description": "Specifies the maximum amount of time allowed to pass 
before offsets must be committed. This value impacts how often offsets will be 
committed.  Committing offsets less often increases throughput but also 
increases the window of potential data duplication in the event of a rebalance 
or JVM restart between commits.  This value is also related to maximum poll 
records and the use of a message demarcator.  When using a message demarcator 
we can have far more uncommitted  [...]
+      "displayName": "Max Uncommitted Time",
+      "dynamic": false,
+      "dynamicallyModifiesClasspath": false,
+      "expressionLanguageScope": "NONE",
+      "expressionLanguageSupported": false,
+      "name": "max-uncommit-offset-wait",
+      "required": false,
+      "sensitive": false
+    }
+  ],
+  "relationship": [
+    {
+      "autoTerminated": false,
+      "description": "FlowFiles received from Kafka.  Depending on demarcation 
strategy it is a flow file per message or a bundle of messages grouped by topic 
and partition.",
+      "name": "success"
+    },
+    {
+      "autoTerminated": false,
+      "description": "If a message from Kafka cannot be parsed using the 
configured Record Reader, the contents of the message will be routed to this 
Relationship as its own individual FlowFile.",
+      "name": "parse.failure"
+    }
+  ],
+  "see": [
+    "org.apache.nifi.processors.kafka.pubsub.ConsumeKafka_1_0",
+    "org.apache.nifi.processors.kafka.pubsub.PublishKafka_1_0",
+    "org.apache.nifi.processors.kafka.pubsub.PublishKafkaRecord_1_0"
+  ],
+  "tag": [
+    "Kafka",
+    "Get",
+    "Record",
+    "csv",
+    "avro",
+    "json",
+    "Ingest",
+    "Ingress",
+    "Topic",
+    "PubSub",
+    "Consume",
+    "1.0"
+  ],
+  "type": "PROCESSOR",
+  "writesAttribute": [
+    {
+      "description": "The number of records received",
+      "name": "record.count"
+    },
+    {
+      "description": "The MIME Type that is provided by the configured Record 
Writer",
+      "name": "mime.type"
+    },
+    {
+      "description": "The partition of the topic the records are from",
+      "name": "kafka.partition"
+    },
+    {
+      "description": "The topic records are from",
+      "name": "kafka.topic"
+    }
+  ]
+}
\ No newline at end of file
diff --git 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BundleResource.java
 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BundleResource.java
index 6215ad7..9b891ab 100644
--- 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BundleResource.java
+++ 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BundleResource.java
@@ -459,6 +459,79 @@ public class BundleResource extends 
AuthorizableApplicationResource {
         return Response.ok(extension).build();
     }
 
+    @GET
+    @Path("{bundleId}/versions/{version}/extensions/{name}/docs")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.TEXT_HTML)
+    @ApiOperation(
+            value = "Gets the documentation for the given extension in the 
given extension bundle version",
+            response = String.class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = 
"read"),
+                            @ExtensionProperty(name = "resource", value = 
"/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) 
})
+    public Response getBundleVersionExtensionDocs(
+            @PathParam("bundleId")
+            @ApiParam("The extension bundle identifier")
+                final String bundleId,
+            @PathParam("version")
+            @ApiParam("The version of the bundle")
+                final String version,
+            @PathParam("name")
+            @ApiParam("The fully qualified name of the extension")
+                final String name
+    ) {
+        final Bundle bundle = getBundleWithBucketReadAuthorization(bundleId);
+        final BundleVersion bundleVersion = 
registryService.getBundleVersion(bundle.getBucketIdentifier(), bundleId, 
version);
+
+        final StreamingOutput streamingOutput = (output) -> 
registryService.writeExtensionDocs(bundleVersion, name, output);
+        return Response.ok(streamingOutput).build();
+    }
+
+    @GET
+    
@Path("{bundleId}/versions/{version}/extensions/{name}/docs/additional-details")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.TEXT_HTML)
+    @ApiOperation(
+            value = "Gets the additional details documentation for the given 
extension in the given extension bundle version",
+            response = String.class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = 
"read"),
+                            @ExtensionProperty(name = "resource", value = 
"/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) 
})
+    public Response getBundleVersionExtensionAdditionalDetailsDocs(
+            @PathParam("bundleId")
+            @ApiParam("The extension bundle identifier")
+                final String bundleId,
+            @PathParam("version")
+            @ApiParam("The version of the bundle")
+                final String version,
+            @PathParam("name")
+            @ApiParam("The fully qualified name of the extension")
+                final String name
+    ) {
+        final Bundle bundle = getBundleWithBucketReadAuthorization(bundleId);
+        final BundleVersion bundleVersion = 
registryService.getBundleVersion(bundle.getBucketIdentifier(), bundleId, 
version);
+
+        final StreamingOutput streamingOutput = (output) -> 
registryService.writeAdditionalDetailsDocs(bundleVersion, name, output);
+        return Response.ok(streamingOutput).build();
+    }
 
     /**
      * Retrieves the extension bundle with the given id and ensures the 
current user has authorization to read the bucket it belongs to.
diff --git 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionRepoResource.java
 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionRepoResource.java
index 1471d4c..a7d6d04 100644
--- 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionRepoResource.java
+++ 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionRepoResource.java
@@ -343,7 +343,6 @@ public class ExtensionRepoResource extends 
AuthorizableApplicationResource {
     @ApiOperation(
             value = "Gets the information about the extension in the extension 
bundle specified by the given bucket, group, artifact, and version",
             response = 
org.apache.nifi.registry.extension.component.manifest.Extension.class,
-            responseContainer = "List",
             extensions = {
                     @Extension(name = "access-policy", properties = {
                             @ExtensionProperty(name = "action", value = 
"read"),
@@ -382,6 +381,94 @@ public class ExtensionRepoResource extends 
AuthorizableApplicationResource {
     }
 
     @GET
+    
@Path("{bucketName}/{groupId}/{artifactId}/{version}/extensions/{name}/docs")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.TEXT_HTML)
+    @ApiOperation(
+            value = "Gets the documentation for the given extension",
+            response = String.class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = 
"read"),
+                            @ExtensionProperty(name = "resource", value = 
"/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) 
})
+    public Response getExtensionRepoVersionExtensionDocs(
+            @PathParam("bucketName")
+            @ApiParam("The bucket name")
+                final String bucketName,
+            @PathParam("groupId")
+            @ApiParam("The group identifier")
+                final String groupId,
+            @PathParam("artifactId")
+            @ApiParam("The artifact identifier")
+                final String artifactId,
+            @PathParam("version")
+            @ApiParam("The version")
+                final String version,
+            @PathParam("name")
+            @ApiParam("The fully qualified name of the extension")
+                final String name
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final BundleVersion bundleVersion = 
registryService.getBundleVersion(bucket.getIdentifier(), groupId, artifactId, 
version);
+        final StreamingOutput streamingOutput = (output) -> 
registryService.writeExtensionDocs(bundleVersion, name, output);
+        return Response.ok(streamingOutput).build();
+    }
+
+    @GET
+    
@Path("{bucketName}/{groupId}/{artifactId}/{version}/extensions/{name}/docs/additional-details")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.TEXT_HTML)
+    @ApiOperation(
+            value = "Gets the additional details documentation for the given 
extension",
+            response = String.class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = 
"read"),
+                            @ExtensionProperty(name = "resource", value = 
"/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) 
})
+    public Response getExtensionRepoVersionExtensionAdditionalDetailsDocs(
+            @PathParam("bucketName")
+            @ApiParam("The bucket name")
+                final String bucketName,
+            @PathParam("groupId")
+            @ApiParam("The group identifier")
+                final String groupId,
+            @PathParam("artifactId")
+            @ApiParam("The artifact identifier")
+                final String artifactId,
+            @PathParam("version")
+            @ApiParam("The version")
+                final String version,
+            @PathParam("name")
+            @ApiParam("The fully qualified name of the extension")
+                final String name
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final BundleVersion bundleVersion = 
registryService.getBundleVersion(bucket.getIdentifier(), groupId, artifactId, 
version);
+        final StreamingOutput streamingOutput = (output) -> 
registryService.writeAdditionalDetailsDocs(bundleVersion, name, output);
+        return Response.ok(streamingOutput).build();
+    }
+
+    @GET
     @Path("{bucketName}/{groupId}/{artifactId}/{version}/content")
     @Consumes(MediaType.WILDCARD)
     @Produces(MediaType.APPLICATION_OCTET_STREAM)
diff --git 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/LinkService.java
 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/LinkService.java
index 07602f1..e79964e 100644
--- 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/LinkService.java
+++ 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/LinkService.java
@@ -29,6 +29,7 @@ import 
org.apache.nifi.registry.extension.repo.ExtensionRepoGroup;
 import org.apache.nifi.registry.extension.repo.ExtensionRepoVersionSummary;
 import org.apache.nifi.registry.flow.VersionedFlow;
 import org.apache.nifi.registry.flow.VersionedFlowSnapshotMetadata;
+import org.apache.nifi.registry.link.LinkableDocs;
 import org.apache.nifi.registry.link.LinkableEntity;
 import org.springframework.stereotype.Service;
 
@@ -51,12 +52,14 @@ public class LinkService {
     private static final String EXTENSION_BUNDLE_VERSION_PATH = 
"bundles/{bundleId}/versions/{version}";
     private static final String EXTENSION_BUNDLE_VERSION_CONTENT_PATH = 
"bundles/{bundleId}/versions/{version}/content";
     private static final String EXTENSION_BUNDLE_VERSION_EXTENSION_PATH = 
"bundles/{bundleId}/versions/{version}/extensions/{name}";
+    private static final String EXTENSION_BUNDLE_VERSION_EXTENSION_DOCS_PATH = 
"bundles/{bundleId}/versions/{version}/extensions/{name}/docs";
 
     private static final String EXTENSION_REPO_BUCKET_PATH = 
"extension-repository/{bucketName}";
     private static final String EXTENSION_REPO_GROUP_PATH = 
"extension-repository/{bucketName}/{groupId}";
     private static final String EXTENSION_REPO_ARTIFACT_PATH = 
"extension-repository/{bucketName}/{groupId}/{artifactId}";
     private static final String EXTENSION_REPO_VERSION_PATH = 
"extension-repository/{bucketName}/{groupId}/{artifactId}/{version}";
     private static final String EXTENSION_REPO_EXTENSION_PATH = 
"extension-repository/{bucketName}/{groupId}/{artifactId}/{version}/extensions/{name}";
+    private static final String EXTENSION_REPO_EXTENSION_DOCS_PATH = 
"extension-repository/{bucketName}/{groupId}/{artifactId}/{version}/extensions/{name}/docs";
 
 
     private static final LinkBuilder<Bucket> BUCKET_LINK_BUILDER = (bucket) -> 
{
@@ -154,6 +157,20 @@ public class LinkService {
         return Link.fromUri(uri).rel("self").build();
     });
 
+    private static final LinkBuilder<ExtensionMetadata> 
EXTENSION_METADATA_DOCS_LINK_BUILDER = (extensionMetadata -> {
+        if (extensionMetadata == null) {
+            return null;
+        }
+
+        final URI uri = 
UriBuilder.fromPath(EXTENSION_BUNDLE_VERSION_EXTENSION_DOCS_PATH)
+                .resolveTemplate("bundleId", 
extensionMetadata.getBundleInfo().getBundleId())
+                .resolveTemplate("version", 
extensionMetadata.getBundleInfo().getVersion())
+                .resolveTemplate("name", extensionMetadata.getName())
+                .build();
+
+        return Link.fromUri(uri).rel("docs").build();
+    });
+
     // -- Extension Repo LinkBuilders
 
     private static final LinkBuilder<ExtensionRepoBucket> 
EXTENSION_REPO_BUCKET_LINK_BUILDER = (extensionRepoBucket -> {
@@ -231,6 +248,27 @@ public class LinkService {
         return Link.fromUri(uri).rel("self").build();
     });
 
+    private static final LinkBuilder<ExtensionRepoExtensionMetadata> 
EXTENSION_REPO_EXTENSION_METADATA_DOCS_LINK_BUILDER = (extensionMetadata -> {
+        if (extensionMetadata == null
+                || extensionMetadata.getExtensionMetadata() == null
+                || extensionMetadata.getExtensionMetadata().getBundleInfo() == 
null) {
+            return null;
+        }
+
+        final ExtensionMetadata metadata = 
extensionMetadata.getExtensionMetadata();
+        final BundleInfo bundleInfo = metadata.getBundleInfo();
+
+        final URI uri = UriBuilder.fromPath(EXTENSION_REPO_EXTENSION_DOCS_PATH)
+                .resolveTemplate("bucketName", bundleInfo.getBucketName())
+                .resolveTemplate("groupId", bundleInfo.getGroupId())
+                .resolveTemplate("artifactId", bundleInfo.getArtifactId())
+                .resolveTemplate("version", bundleInfo.getVersion())
+                .resolveTemplate("name", metadata.getName())
+                .build();
+
+        return Link.fromUri(uri).rel("docs").build();
+    });
+
 
     private static final Map<Class,LinkBuilder> LINK_BUILDERS;
     static {
@@ -258,6 +296,15 @@ public class LinkService {
         LINK_BUILDERS = Collections.unmodifiableMap(builderMap);
     }
 
+    private static final Map<Class,LinkBuilder> DOCS_LINK_BUILDERS;
+    static {
+        final Map<Class,LinkBuilder> builderMap = new HashMap<>();
+        builderMap.put(ExtensionMetadata.class, 
EXTENSION_METADATA_DOCS_LINK_BUILDER);
+        builderMap.put(ExtensionRepoExtensionMetadata.class, 
EXTENSION_REPO_EXTENSION_METADATA_DOCS_LINK_BUILDER);
+        DOCS_LINK_BUILDERS = Collections.unmodifiableMap(builderMap);
+    }
+
+
     public <E extends LinkableEntity> void populateLinks(final E entity) {
         final LinkBuilder linkBuilder = LINK_BUILDERS.get(entity.getClass());
         if (linkBuilder == null) {
@@ -266,6 +313,17 @@ public class LinkService {
 
         final Link link = linkBuilder.createLink(entity);
         entity.setLink(link);
+
+        if (entity instanceof LinkableDocs) {
+            final LinkBuilder docsLinkBuilder = 
DOCS_LINK_BUILDERS.get(entity.getClass());
+            if (docsLinkBuilder == null) {
+                throw new IllegalArgumentException("No documentation 
LinkBuilder found for " + entity.getClass().getCanonicalName());
+            }
+
+            final Link docsLink = docsLinkBuilder.createLink(entity);
+            final LinkableDocs docsEntity = (LinkableDocs) entity;
+            docsEntity.setLinkDocs(docsLink);
+        }
     }
 
     public <E extends LinkableEntity> void populateLinks(final Iterable<E> 
entities) {
@@ -287,17 +345,21 @@ public class LinkService {
         }
 
         final Link relativeLink = linkBuilder.createLink(entity);
-        final URI relativeUri = relativeLink.getUri();
+        final Link fullLink = getFullLink(baseUri, relativeLink);
+        entity.setLink(fullLink);
 
-        final URI fullUri = UriBuilder.fromUri(baseUri)
-                .path(relativeUri.getPath())
-                .build();
+        if (entity instanceof LinkableDocs) {
+            final LinkBuilder docsLinkBuilder = 
DOCS_LINK_BUILDERS.get(entity.getClass());
+            if (docsLinkBuilder == null) {
+                throw new IllegalArgumentException("No documentation 
LinkBuilder found for " + entity.getClass().getCanonicalName());
+            }
 
-        final Link fullLink = Link.fromUri(fullUri)
-                .rel(relativeLink.getRel())
-                .build();
+            final Link relativeDocsLink = docsLinkBuilder.createLink(entity);
+            final Link fullDocsLink = getFullLink(baseUri, relativeDocsLink);
 
-        entity.setLink(fullLink);
+            final LinkableDocs docsEntity = (LinkableDocs) entity;
+            docsEntity.setLinkDocs(fullDocsLink);
+        }
     }
 
     public <E extends LinkableEntity> void populateFullLinks(final Iterable<E> 
entities, final URI baseUri) {
@@ -308,4 +370,16 @@ public class LinkService {
         entities.forEach(e -> populateFullLinks(e, baseUri));
     }
 
+    private Link getFullLink(final URI baseUri, final Link relativeLink) {
+        final URI relativeUri = relativeLink.getUri();
+
+        final URI fullUri = UriBuilder.fromUri(baseUri)
+                .path(relativeUri.getPath())
+                .build();
+
+        return Link.fromUri(fullUri)
+                .rel(relativeLink.getRel())
+                .build();
+    }
+
 }
diff --git 
a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredNiFiRegistryClientIT.java
 
b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredNiFiRegistryClientIT.java
index c572461..077eab9 100644
--- 
a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredNiFiRegistryClientIT.java
+++ 
b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredNiFiRegistryClientIT.java
@@ -17,6 +17,7 @@
 package org.apache.nifi.registry.web.api;
 
 import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.io.IOUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.registry.authorization.CurrentUser;
 import org.apache.nifi.registry.authorization.Permissions;
@@ -82,6 +83,7 @@ import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
 import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -487,6 +489,17 @@ public class UnsecuredNiFiRegistryClientIT extends 
UnsecuredITBase {
         assertNotNull(fooNarV2SnapshotB3Extension);
         assertEquals(fooNarV2SnapshotB3ExtensionName, 
fooNarV2SnapshotB3Extension.getName());
 
+        // verify getting the docs for an extension for a specific bundle 
version
+        try (final InputStream docsInput = 
bundleVersionClient.getExtensionDocs(
+                createdFooNarV2SnapshotB3.getVersionMetadata().getBundleId(),
+                createdFooNarV2SnapshotB3.getVersionMetadata().getVersion(),
+                fooNarV2SnapshotB3ExtensionName
+        )) {
+            final String docsContent = IOUtils.toString(docsInput, 
StandardCharsets.UTF_8);
+            assertNotNull(docsContent);
+            assertTrue(docsContent.startsWith("<!DOCTYPE html>"));
+        }
+
         // verify getting bundles by bucket
         assertEquals(2, 
bundleClient.getByBucket(bundlesBucket.getIdentifier()).size());
         assertEquals(0, 
bundleClient.getByBucket(flowsBucket.getIdentifier()).size());
@@ -610,6 +623,10 @@ public class UnsecuredNiFiRegistryClientIT extends 
UnsecuredITBase {
         assertNotNull(extensions);
         assertTrue(extensions.length > 0);
         checkExtensionMetadata(Stream.of(extensions).map(e -> 
e.getExtensionMetadata()).collect(Collectors.toSet()));
+        Stream.of(extensions).forEach(e -> {
+            assertNotNull(e.getLink());
+            assertNotNull(e.getLinkDocs());
+        });
 
         // verify the client methods for content input stream, content sha256, 
and extensions
         try (final InputStream repoVersionInputStream = 
extensionRepoClient.getVersionContent(bundlesBucketName, repoGroupId, 
repoArtifactId, repoVersionString)) {
@@ -628,6 +645,7 @@ public class UnsecuredNiFiRegistryClientIT extends 
UnsecuredITBase {
             extensionList.forEach(em -> {
                 assertNotNull(em.getExtensionMetadata());
                 assertNotNull(em.getLink());
+                assertNotNull(em.getLinkDocs());
             });
 
             final String extensionName = 
extensionList.get(0).getExtensionMetadata().getName();
@@ -635,6 +653,15 @@ public class UnsecuredNiFiRegistryClientIT extends 
UnsecuredITBase {
                     bundlesBucketName, repoGroupId, repoArtifactId, 
repoVersionString, extensionName);
             assertNotNull(extension);
             assertEquals(extensionName, extension.getName());
+
+            // verify getting the docs for an extension from extension repo
+            try (final InputStream docsInput = 
extensionRepoClient.getVersionExtensionDocs(
+                    bundlesBucketName, repoGroupId, repoArtifactId, 
repoVersionString, extensionName)
+            ) {
+                final String docsContent = IOUtils.toString(docsInput, 
StandardCharsets.UTF_8);
+                assertNotNull(docsContent);
+                assertTrue(docsContent.startsWith("<!DOCTYPE html>"));
+            }
         }
 
         final Optional<String> repoSha256HexDoesNotExist = 
extensionRepoClient.getVersionSha256(repoGroupId, repoArtifactId, 
"DOES-NOT-EXIST");
@@ -668,6 +695,8 @@ public class UnsecuredNiFiRegistryClientIT extends 
UnsecuredITBase {
         allExtensions.getExtensions().forEach(e -> {
             assertNotNull(e.getName());
             assertNotNull(e.getDisplayName());
+            assertNotNull(e.getLink());
+            assertNotNull(e.getLinkDocs());
         });
 
         final ExtensionMetadataContainer processorExtensions = 
extensionClient.findExtensions(
diff --git 
a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/link/TestLinkService.java
 
b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/link/TestLinkService.java
index 927c00d..5303146 100644
--- 
a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/link/TestLinkService.java
+++ 
b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/link/TestLinkService.java
@@ -275,15 +275,19 @@ public class TestLinkService {
     }
 
     @Test
-    public void testPopulateExtensionBundleVersionExtensionLinks() {
+    public void testPopulateExtensionBundleVersionExtensionMetadataLinks() {
         extensionMetadata.forEach(i -> Assert.assertNull(i.getLink()));
+        extensionMetadata.forEach(i -> Assert.assertNull(i.getLinkDocs()));
+
         linkService.populateLinks(extensionMetadata);
-        extensionMetadata.forEach(e -> Assert.assertEquals(
-                "bundles/" + e.getBundleInfo().getBundleId()
-                        + "/versions/" + e.getBundleInfo().getVersion()
-                        + "/extensions/" + e.getName(),
-                e.getLink().getUri().toString()));
 
+        extensionMetadata.forEach(e -> {
+            final String extensionUri = "bundles/" + 
e.getBundleInfo().getBundleId()
+                    + "/versions/" + e.getBundleInfo().getVersion()
+                    + "/extensions/" + e.getName();
+            Assert.assertEquals(extensionUri, e.getLink().getUri().toString());
+            Assert.assertEquals(extensionUri + "/docs", 
e.getLinkDocs().getUri().toString());
+        });
     }
 
     @Test
@@ -343,13 +347,15 @@ public class TestLinkService {
     @Test
     public void testPopulateExtensionRepoExtensionMetdataFullLinks() {
         extensionRepoExtensionMetadata.forEach(i -> 
Assert.assertNull(i.getLink()));
+        extensionRepoExtensionMetadata.forEach(i -> 
Assert.assertNull(i.getLinkDocs()));
+
         linkService.populateFullLinks(extensionRepoExtensionMetadata, baseUri);
         extensionRepoExtensionMetadata.forEach(i -> {
             final BundleInfo bi = i.getExtensionMetadata().getBundleInfo();
-            Assert.assertEquals(
-                    BASE_URI + "/extension-repository/" + bi.getBucketName() + 
"/" + bi.getGroupId() + "/"
-                            + bi.getArtifactId() + "/" + bi.getVersion() + 
"/extensions/" + i.getExtensionMetadata().getName(),
-                    i.getLink().getUri().toString()); }
-        );
+            final String extensionUri = BASE_URI + "/extension-repository/" + 
bi.getBucketName() + "/" + bi.getGroupId() + "/"
+                    + bi.getArtifactId() + "/" + bi.getVersion() + 
"/extensions/" + i.getExtensionMetadata().getName();
+            Assert.assertEquals(extensionUri, i.getLink().getUri().toString());
+            Assert.assertEquals(extensionUri + "/docs", 
i.getLinkDocs().getUri().toString());
+        });
     }
 }
diff --git 
a/nifi-registry-core/nifi-registry-web-docs/src/main/webapp/css/component-usage.css
 
b/nifi-registry-core/nifi-registry-web-docs/src/main/webapp/css/component-usage.css
index 1ae578b..65f62ca 100644
--- 
a/nifi-registry-core/nifi-registry-web-docs/src/main/webapp/css/component-usage.css
+++ 
b/nifi-registry-core/nifi-registry-web-docs/src/main/webapp/css/component-usage.css
@@ -26,6 +26,7 @@ body {
     margin: 0 auto;
     display: block;
     font-family: "Open Sans","DejaVu Sans",sans-serif;
+       padding-left: 20px;
 }
 
 .title {
@@ -113,14 +114,22 @@ table tr:last-child td:last-child {
        border-bottom-right-radius:3px;
 }
 
-td#allowable-values, td#default-value, td#name, td#value {
+td#default-value, td#name, td#value {
        max-width: 200px;
 }
 
+td#allowable-values {
+       max-width: 300px;
+}
+
 td#description {
        vertical-align: middle;
 }
 
+td#bundle-info {
+    max-width: 50px;
+}
+
 /* links */
 
 a, a:link, a:visited {
@@ -180,4 +189,4 @@ pre {
     color: #555;
     margin-bottom: 10px;
     padding: 5px 8px;
-}
\ No newline at end of file
+}
diff --git 
a/nifi-registry-core/nifi-registry-web-docs/src/main/webapp/images/iconInfo.png 
b/nifi-registry-core/nifi-registry-web-docs/src/main/webapp/images/iconInfo.png
new file mode 100644
index 0000000..8c53b4c
Binary files /dev/null and 
b/nifi-registry-core/nifi-registry-web-docs/src/main/webapp/images/iconInfo.png 
differ
diff --git a/pom.xml b/pom.xml
index bfb77b7..7c3eddf 100644
--- a/pom.xml
+++ b/pom.xml
@@ -94,9 +94,9 @@
         <jetty.version>9.4.11.v20180605</jetty.version>
         <jax.rs.api.version>2.1</jax.rs.api.version>
         <jersey.version>2.27</jersey.version>
-        <jackson.version>2.9.7</jackson.version>
-        <spring.boot.version>2.1.1.RELEASE</spring.boot.version>
-        <spring.security.version>5.1.2.RELEASE</spring.security.version>
+        <jackson.version>2.9.8</jackson.version>
+        <spring.boot.version>2.1.3.RELEASE</spring.boot.version>
+        <spring.security.version>5.1.3.RELEASE</spring.security.version>
         <flyway.version>5.2.1</flyway.version>
         <flyway.tests.version>5.1.0</flyway.tests.version>
         <swagger.ui.version>3.12.0</swagger.ui.version>

Reply via email to