You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@nifi.apache.org by kd...@apache.org on 2019/04/02 14:14:33 UTC

[nifi-registry] branch master updated: NIFIREG-233 Setup ExtensionDocWriter with HTML implementation and REST resource to retrieve docs

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