http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/extension/TestFileSystemExtensionBundlePersistenceProvider.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/extension/TestFileSystemExtensionBundlePersistenceProvider.java
 
b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/extension/TestFileSystemExtensionBundlePersistenceProvider.java
new file mode 100644
index 0000000..5a611bb
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/extension/TestFileSystemExtensionBundlePersistenceProvider.java
@@ -0,0 +1,295 @@
+/*
+ * 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.provider.extension;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.nifi.registry.extension.ExtensionBundleContext;
+import org.apache.nifi.registry.extension.ExtensionBundlePersistenceException;
+import org.apache.nifi.registry.extension.ExtensionBundlePersistenceProvider;
+import org.apache.nifi.registry.provider.ProviderConfigurationContext;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.mockito.Mockito.when;
+
+public class TestFileSystemExtensionBundlePersistenceProvider {
+
+    static final String EXTENSION_STORAGE_DIR = "target/extension_storage";
+
+    static final ProviderConfigurationContext CONFIGURATION_CONTEXT = new 
ProviderConfigurationContext() {
+        @Override
+        public Map<String, String> getProperties() {
+            final Map<String,String> props = new HashMap<>();
+            
props.put(FileSystemExtensionBundlePersistenceProvider.BUNDLE_STORAGE_DIR_PROP, 
EXTENSION_STORAGE_DIR);
+            return props;
+        }
+    };
+
+    private File bundleStorageDir;
+    private ExtensionBundlePersistenceProvider fileSystemBundleProvider;
+
+    @Before
+    public void setup() throws IOException {
+        bundleStorageDir = new File(EXTENSION_STORAGE_DIR);
+        if (bundleStorageDir.exists()) {
+            org.apache.commons.io.FileUtils.cleanDirectory(bundleStorageDir);
+            bundleStorageDir.delete();
+        }
+
+        Assert.assertFalse(bundleStorageDir.exists());
+
+        fileSystemBundleProvider = new 
FileSystemExtensionBundlePersistenceProvider();
+        fileSystemBundleProvider.onConfigured(CONFIGURATION_CONTEXT);
+        Assert.assertTrue(bundleStorageDir.exists());
+    }
+
+    @Test
+    public void testSaveSuccessfully() throws IOException {
+        // first version in b1
+        final String content1 = "g1-a1-1.0.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, "b1", "g1", "a1", 
"1.0.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content1);
+        verifyBundleVersion(bundleStorageDir, "b1", "g1", "a1", "1.0.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content1);
+
+        // second version in b1
+        final String content2 = "g1-a1-1.1.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, "b1", "g1", "a1", 
"1.1.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content2);
+        verifyBundleVersion(bundleStorageDir, "b1", "g1", "a1", "1.1.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content2);
+
+        // same bundle but in b2
+        final String content3 = "g1-a1-1.1.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, "b2", "g1", "a1", 
"1.1.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content3);
+        verifyBundleVersion(bundleStorageDir, "b2", "g1", "a1", "1.1.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content2);
+    }
+
+    @Test
+    public void testSaveWhenBundleVersionAlreadyExists() throws IOException {
+        final String content1 = "g1-a1-1.0.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, "b1", "g1", "a1", 
"1.0.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content1);
+        verifyBundleVersion(bundleStorageDir, "b1", "g1", "a1", "1.0.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content1);
+
+        // try to save same bundle version that already exists
+        try {
+            final String newContent = "new content";
+            createAndSaveBundleVersion(fileSystemBundleProvider, "b1", "g1", 
"a1", "1.0.0",
+                    ExtensionBundleContext.BundleType.NIFI_NAR, newContent);
+            Assert.fail("Should have thrown exception");
+        } catch (ExtensionBundlePersistenceException e) {
+
+        }
+
+        // verify existing content wasn't modified
+        verifyBundleVersion(bundleStorageDir, "b1", "g1", "a1", "1.0.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content1);
+    }
+
+    @Test
+    public void testSaveAndGet() throws IOException {
+        final String bucketName = "b1";
+        final String groupId = "g1";
+        final String artifactId = "a1";
+
+        final String content1 = groupId + "-" + artifactId + "-" + "1.0.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, bucketName, 
groupId, artifactId, "1.0.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content1);
+
+        final String content2 = groupId + "-" + artifactId + "-" + "1.1.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, bucketName, 
groupId, artifactId, "1.1.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content2);
+
+        try (final OutputStream out = new ByteArrayOutputStream()) {
+            final ExtensionBundleContext context = getExtensionBundleContext(
+                    bucketName, groupId, artifactId, "1.0.0", 
ExtensionBundleContext.BundleType.NIFI_NAR);
+            fileSystemBundleProvider.getBundleVersion(context, out);
+
+            final String retrievedContent1 = new 
String(((ByteArrayOutputStream) out).toByteArray(), StandardCharsets.UTF_8);
+            Assert.assertEquals(content1, retrievedContent1);
+        }
+
+        try (final OutputStream out = new ByteArrayOutputStream()) {
+            final ExtensionBundleContext context = getExtensionBundleContext(
+                    bucketName, groupId, artifactId, "1.1.0", 
ExtensionBundleContext.BundleType.NIFI_NAR);
+            fileSystemBundleProvider.getBundleVersion(context, out);
+
+            final String retrievedContent2 = new 
String(((ByteArrayOutputStream) out).toByteArray(), StandardCharsets.UTF_8);
+            Assert.assertEquals(content2, retrievedContent2);
+        }
+    }
+
+    @Test(expected = ExtensionBundlePersistenceException.class)
+    public void testGetWhenDoesNotExist() throws IOException {
+        final String bucketName = "b1";
+        final String groupId = "g1";
+        final String artifactId = "a1";
+
+        try (final OutputStream out = new ByteArrayOutputStream()) {
+            final ExtensionBundleContext context = getExtensionBundleContext(
+                    bucketName, groupId, artifactId, "1.0.0", 
ExtensionBundleContext.BundleType.NIFI_NAR);
+            fileSystemBundleProvider.getBundleVersion(context, out);
+            Assert.fail("Should have thrown exception");
+        }
+    }
+
+    @Test
+    public void testDeleteExtensionBundleVersion() throws IOException {
+        final String bucketName = "b1";
+        final String groupId = "g1";
+        final String artifactId = "a1";
+        final String version = "1.0.0";
+        final ExtensionBundleContext.BundleType bundleType = 
ExtensionBundleContext.BundleType.NIFI_NAR;
+
+        // create and verify the bundle version
+        final String content1 = groupId + "-" + artifactId + "-" + "1.0.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, bucketName, 
groupId, artifactId, version, bundleType, content1);
+        verifyBundleVersion(bundleStorageDir, bucketName, groupId, artifactId, 
version, bundleType, content1);
+
+        // delete the bundle version
+        
fileSystemBundleProvider.deleteBundleVersion(getExtensionBundleContext(bucketName,
 groupId, artifactId, version, bundleType));
+
+        // verify it was deleted
+        final File bundleVersionDir = 
FileSystemExtensionBundlePersistenceProvider.getBundleVersionDirectory(
+                bundleStorageDir, bucketName, groupId, artifactId, version);
+
+        final File bundleFile = 
FileSystemExtensionBundlePersistenceProvider.getBundleFile(
+                bundleVersionDir, artifactId, version, bundleType);
+        Assert.assertFalse(bundleFile.exists());
+    }
+
+    @Test
+    public void testDeleteExtensionBundleVersionWhenDoesNotExist() throws 
IOException {
+        final String bucketName = "b1";
+        final String groupId = "g1";
+        final String artifactId = "a1";
+        final String version = "1.0.0";
+        final ExtensionBundleContext.BundleType bundleType = 
ExtensionBundleContext.BundleType.NIFI_NAR;
+
+        // verify the bundle version does not already exist
+        final File bundleVersionDir = 
FileSystemExtensionBundlePersistenceProvider.getBundleVersionDirectory(
+                bundleStorageDir, bucketName, groupId, artifactId, version);
+
+        final File bundleFile = 
FileSystemExtensionBundlePersistenceProvider.getBundleFile(
+                bundleVersionDir, artifactId, version, bundleType);
+        Assert.assertFalse(bundleFile.exists());
+
+        // delete the bundle version
+        
fileSystemBundleProvider.deleteBundleVersion(getExtensionBundleContext(bucketName,
 groupId, artifactId, version, bundleType));
+    }
+
+    @Test
+    public void testDeleteAllBundleVersions() throws IOException {
+        final String bucketName = "b1";
+        final String groupId = "g1";
+        final String artifactId = "a1";
+        final String version1 = "1.0.0";
+        final String version2 = "2.0.0";
+        final ExtensionBundleContext.BundleType bundleType = 
ExtensionBundleContext.BundleType.NIFI_NAR;
+
+        // create and verify the bundle version 1
+        final String content1 = groupId + "-" + artifactId + "-" + version1;
+        createAndSaveBundleVersion(fileSystemBundleProvider, bucketName, 
groupId, artifactId, version1, bundleType, content1);
+        verifyBundleVersion(bundleStorageDir, bucketName, groupId, artifactId, 
version1, bundleType, content1);
+
+        // create and verify the bundle version 2
+        final String content2 = groupId + "-" + artifactId + "-" + version2;
+        createAndSaveBundleVersion(fileSystemBundleProvider, bucketName, 
groupId, artifactId, version2, bundleType, content2);
+        verifyBundleVersion(bundleStorageDir, bucketName, groupId, artifactId, 
version2, bundleType, content2);
+
+        fileSystemBundleProvider.deleteAllBundleVersions(bucketName, 
bucketName, groupId, artifactId);
+        Assert.assertEquals(0, bundleStorageDir.listFiles().length);
+    }
+
+    @Test
+    public void testDeleteAllBundleVersionsWhenDoesNotExist() throws 
IOException {
+        final String bucketName = "b1";
+        final String groupId = "g1";
+        final String artifactId = "a1";
+
+        Assert.assertEquals(0, bundleStorageDir.listFiles().length);
+        fileSystemBundleProvider.deleteAllBundleVersions(bucketName, 
bucketName, groupId, artifactId);
+        Assert.assertEquals(0, bundleStorageDir.listFiles().length);
+    }
+
+    private void createAndSaveBundleVersion(final 
ExtensionBundlePersistenceProvider persistenceProvider,
+                                            final String bucketName,
+                                            final String groupId,
+                                            final String artifactId,
+                                            final String version,
+                                            final 
ExtensionBundleContext.BundleType bundleType,
+                                            final String content) throws 
IOException {
+
+        final ExtensionBundleContext context = 
getExtensionBundleContext(bucketName, groupId, artifactId, version, bundleType);
+
+        try (final InputStream in = new 
ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
+            persistenceProvider.saveBundleVersion(context, in);
+        }
+    }
+
+    private static ExtensionBundleContext getExtensionBundleContext(final 
String bucketName,
+                                                                    final 
String groupId,
+                                                                    final 
String artifactId,
+                                                                    final 
String version,
+                                                                    final 
ExtensionBundleContext.BundleType bundleType) {
+        final ExtensionBundleContext context = 
Mockito.mock(ExtensionBundleContext.class);
+        when(context.getBucketName()).thenReturn(bucketName);
+        when(context.getBundleGroupId()).thenReturn(groupId);
+        when(context.getBundleArtifactId()).thenReturn(artifactId);
+        when(context.getBundleVersion()).thenReturn(version);
+        when(context.getBundleType()).thenReturn(bundleType);
+        return context;
+    }
+
+    private static void verifyBundleVersion(final File storageDir,
+                                     final String bucketName,
+                                     final String groupId,
+                                     final String artifactId,
+                                     final String version,
+                                     final ExtensionBundleContext.BundleType 
bundleType,
+                                     final String contentString) throws 
IOException {
+
+        final File bundleVersionDir = 
FileSystemExtensionBundlePersistenceProvider.getBundleVersionDirectory(
+                storageDir, bucketName, groupId, artifactId, version);
+
+        final File bundleFile = 
FileSystemExtensionBundlePersistenceProvider.getBundleFile(
+                bundleVersionDir, artifactId, version, bundleType);
+        Assert.assertTrue(bundleFile.exists());
+
+        try (InputStream in = new FileInputStream(bundleFile)) {
+            Assert.assertEquals(contentString, IOUtils.toString(in, 
StandardCharsets.UTF_8));
+        }
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/security/ldap/tenants/LdapUserGroupProviderTest.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/security/ldap/tenants/LdapUserGroupProviderTest.java
 
b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/security/ldap/tenants/LdapUserGroupProviderTest.java
index 242cf28..9a408b2 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/security/ldap/tenants/LdapUserGroupProviderTest.java
+++ 
b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/security/ldap/tenants/LdapUserGroupProviderTest.java
@@ -67,7 +67,7 @@ import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
-import static org.mockito.Matchers.anyString;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/TestRegistryService.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/TestRegistryService.java
 
b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/TestRegistryService.java
index 95e2d1a..0af08c1 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/TestRegistryService.java
+++ 
b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/TestRegistryService.java
@@ -32,6 +32,8 @@ import org.apache.nifi.registry.flow.VersionedProcessGroup;
 import org.apache.nifi.registry.flow.VersionedProcessor;
 import org.apache.nifi.registry.serialization.Serializer;
 import org.apache.nifi.registry.serialization.VersionedProcessGroupSerializer;
+import org.apache.nifi.registry.service.extension.ExtensionService;
+import org.apache.nifi.registry.service.extension.StandardExtensionService;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
@@ -73,6 +75,7 @@ public class TestRegistryService {
     private MetadataService metadataService;
     private FlowPersistenceProvider flowPersistenceProvider;
     private Serializer<VersionedProcessGroup> snapshotSerializer;
+    private ExtensionService extensionService;
     private Validator validator;
 
     private RegistryService registryService;
@@ -82,11 +85,12 @@ public class TestRegistryService {
         metadataService = mock(MetadataService.class);
         flowPersistenceProvider = mock(FlowPersistenceProvider.class);
         snapshotSerializer = mock(VersionedProcessGroupSerializer.class);
+        extensionService = mock(StandardExtensionService.class);
 
         final ValidatorFactory validatorFactory = 
Validation.buildDefaultValidatorFactory();
         validator = validatorFactory.getValidator();
 
-        registryService = new RegistryService(metadataService, 
flowPersistenceProvider, snapshotSerializer, validator);
+        registryService = new RegistryService(metadataService, 
flowPersistenceProvider, snapshotSerializer, extensionService, validator);
     }
 
     // ---------------------- Test Bucket methods 
---------------------------------------------

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-framework/src/test/resources/db/migration/V999999.1__test-setup.sql
----------------------------------------------------------------------
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 8f3cfa8..b8e0d3a 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
@@ -67,4 +67,225 @@ insert into flow_snapshot (flow_id, version, created, 
created_by, comments)
 -- test data for signing keys
 
 insert into signing_key (id, tenant_identity, key_value)
-  values ('1', 'unit_test_tenant_identity', '0123456789abcdef');
\ No newline at end of file
+  values ('1', 'unit_test_tenant_identity', '0123456789abcdef');
+
+-- test data for extension bundles
+
+-- processors bundle, depends on service api bundle
+insert into bucket_item (
+  id,
+  name,
+  description,
+  created,
+  modified,
+  item_type,
+  bucket_id
+) values (
+  'eb1',
+  'nifi-example-processors-nar',
+  'Example processors bundle',
+  parsedatetime('2018-11-02 12:56:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  parsedatetime('2018-11-02 12:56:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  'EXTENSION_BUNDLE',
+  '3'
+);
+
+insert into extension_bundle (
+  id,
+  bucket_id,
+  bundle_type,
+  group_id,
+  artifact_id
+) values (
+  'eb1',
+  '3',
+  'NIFI_NAR',
+  'org.apache.nifi',
+  'nifi-example-processors-nar'
+);
+
+insert into extension_bundle_version (
+  id,
+  extension_bundle_id,
+  version,
+  created,
+  created_by,
+  description,
+  sha_256_hex,
+  sha_256_supplied
+) values (
+  'eb1-v1',
+  'eb1',
+  '1.0.0',
+  parsedatetime('2018-11-02 13:00:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  'user1',
+  'First version of eb1',
+  '123456789',
+  '1'
+);
+
+insert into extension_bundle_version_dependency (
+  id,
+  extension_bundle_version_id,
+  group_id,
+  artifact_id,
+  version
+) values (
+  'eb1-v1-dep1',
+  'eb1-v1',
+  'org.apache.nifi',
+  'nifi-example-service-api-nar',
+  '2.0.0'
+);
+
+-- service impl bundle, depends on service api bundle
+insert into bucket_item (
+  id,
+  name,
+  description,
+  created,
+  modified,
+  item_type,
+  bucket_id
+) values (
+  'eb2',
+  'nifi-example-services-nar',
+  'Example services bundle',
+  parsedatetime('2018-11-02 12:57:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  parsedatetime('2018-11-02 12:57:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  'EXTENSION_BUNDLE',
+  '3'
+);
+
+insert into extension_bundle (
+  id,
+  bucket_id,
+  bundle_type,
+  group_id,
+  artifact_id
+) values (
+  'eb2',
+  '3',
+  'NIFI_NAR',
+  'org.apache.nifi',
+  'nifi-example-services-nar'
+);
+
+insert into extension_bundle_version (
+  id,
+  extension_bundle_id,
+  version,
+  created,
+  created_by,
+  description,
+  sha_256_hex,
+  sha_256_supplied
+) values (
+  'eb2-v1',
+  'eb2',
+  '1.0.0',
+  parsedatetime('2018-11-02 13:00:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  'user1',
+  'First version of eb2',
+  '123456789',
+  '1'
+);
+
+insert into extension_bundle_version_dependency (
+  id,
+  extension_bundle_version_id,
+  group_id,
+  artifact_id,
+  version
+) values (
+  'eb2-v1-dep1',
+  'eb2-v1',
+  'org.apache.nifi',
+  'nifi-example-service-api-nar',
+  '2.0.0'
+);
+
+-- service api bundle
+insert into bucket_item (
+  id,
+  name,
+  description,
+  created,
+  modified,
+  item_type,
+  bucket_id
+) values (
+  'eb3',
+  'nifi-example-service-api-nar',
+  'Example service API bundle',
+  parsedatetime('2018-11-02 12:58:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  parsedatetime('2017-11-02 12:58:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  'EXTENSION_BUNDLE',
+  '3'
+);
+
+insert into extension_bundle (
+  id,
+  bucket_id,
+  bundle_type,
+  group_id,
+  artifact_id
+) values (
+  'eb3',
+  '3',
+  'NIFI_NAR',
+  'org.apache.nifi',
+  'nifi-example-service-api-nar'
+);
+
+insert into extension_bundle_version (
+  id,
+  extension_bundle_id,
+  version,
+  created,
+  created_by,
+  description,
+  sha_256_hex,
+  sha_256_supplied
+) values (
+  'eb3-v1',
+  'eb3',
+  '2.0.0',
+  parsedatetime('2018-11-02 13:00:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  'user1',
+  'First version of eb3',
+  '123456789',
+  '1'
+);
+
+-- test data for extensions
+
+insert into extension (
+  id, extension_bundle_version_id, type, type_description, is_restricted, 
category, tags
+) values (
+  'e1', 'eb1-v1', 'org.apache.nifi.ExampleProcessor', 'This is Example 
Processor 1', 0, 'PROCESSOR', 'example, processor'
+);
+
+insert into extension (
+  id, extension_bundle_version_id, type, type_description, is_restricted, 
category, tags)
+values (
+  'e2', 'eb1-v1', 'org.apache.nifi.ExampleProcessorRestricted', 'This is 
Example Processor Restricted', 1, 'PROCESSOR', 'example, processor, restricted'
+);
+
+insert into extension (
+  id, extension_bundle_version_id, type, type_description, is_restricted, 
category, tags)
+values (
+  'e3', 'eb2-v1', 'org.apache.nifi.ExampleService', 'This is Example Service', 
0, 'CONTROLLER_SERVICE', 'example, service'
+);
+
+-- test data for extension tags
+
+insert into extension_tag (extension_id, tag) values ('e1', 'example');
+insert into extension_tag (extension_id, tag) values ('e1', 'processor');
+
+insert into extension_tag (extension_id, tag) values ('e2', 'example');
+insert into extension_tag (extension_id, tag) values ('e2', 'processor');
+insert into extension_tag (extension_id, tag) values ('e2', 'restricted');
+
+insert into extension_tag (extension_id, tag) values ('e3', 'example');
+insert into extension_tag (extension_id, tag) values ('e3', 'service');
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/hook/bad-script-provider.xml
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/hook/bad-script-provider.xml
 
b/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/hook/bad-script-provider.xml
index 568e756..1e23386 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/hook/bad-script-provider.xml
+++ 
b/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/hook/bad-script-provider.xml
@@ -27,4 +27,10 @@
        <property name="Working Directory"></property>
     </eventHookProvider>
 
+    <extensionBundlePersistenceProvider>
+        
<class>org.apache.nifi.registry.provider.MockExtensionBundlePersistenceProvider</class>
+        <property name="Extension Property 1">extension foo</property>
+        <property name="Extension Property 2">extension bar</property>
+    </extensionBundlePersistenceProvider>
+
 </providers>
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-class-not-found.xml
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-class-not-found.xml
 
b/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-class-not-found.xml
index 9adba54..7031ca9 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-class-not-found.xml
+++ 
b/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-class-not-found.xml
@@ -21,4 +21,10 @@
         <property name="Flow Property 2">bar</property>
     </flowPersistenceProvider>
 
+    <extensionBundlePersistenceProvider>
+        
<class>org.apache.nifi.registry.provider.MockExtensionBundlePersistenceProvider</class>
+        <property name="Extension Property 1">extension foo</property>
+        <property name="Extension Property 2">extension bar</property>
+    </extensionBundlePersistenceProvider>
+
 </providers>
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-good.xml
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-good.xml
 
b/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-good.xml
index 32414e5..fc963f1 100644
--- 
a/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-good.xml
+++ 
b/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-good.xml
@@ -21,4 +21,10 @@
         <property name="Flow Property 2">flow bar</property>
     </flowPersistenceProvider>
 
+    <extensionBundlePersistenceProvider>
+        
<class>org.apache.nifi.registry.provider.MockExtensionBundlePersistenceProvider</class>
+        <property name="Extension Property 1">extension foo</property>
+        <property name="Extension Property 2">extension bar</property>
+    </extensionBundlePersistenceProvider>
+
 </providers>
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
 
b/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
index 1dcb0f7..89a8e60 100644
--- 
a/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
+++ 
b/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
@@ -16,16 +16,16 @@
  */
 package org.apache.nifi.registry.properties;
 
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.io.File;
 import java.util.Enumeration;
 import java.util.HashSet;
 import java.util.Properties;
 import java.util.Set;
 
-import org.apache.commons.lang3.StringUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 public class NiFiRegistryProperties extends Properties {
 
     private static final Logger logger = 
LoggerFactory.getLogger(NiFiRegistryProperties.class);
@@ -58,6 +58,8 @@ public class NiFiRegistryProperties extends Properties {
 
     public static final String PROVIDERS_CONFIGURATION_FILE = 
"nifi.registry.providers.configuration.file";
 
+    public static final String EXTENSIONS_WORKING_DIR = 
"nifi.registry.extensions.working.directory";
+
     // Original DB properties
     public static final String DATABASE_DIRECTORY = 
"nifi.registry.db.directory";
     public static final String DATABASE_URL_APPEND = 
"nifi.registry.db.url.append";
@@ -86,6 +88,7 @@ public class NiFiRegistryProperties extends Properties {
     public static final String DEFAULT_SECURITY_AUTHORIZERS_CONFIGURATION_FILE 
= "./conf/authorizers.xml";
     public static final String 
DEFAULT_SECURITY_IDENTITY_PROVIDER_CONFIGURATION_FILE = 
"./conf/identity-providers.xml";
     public static final String DEFAULT_AUTHENTICATION_EXPIRATION = "12 hours";
+    public static final String DEFAULT_EXTENSIONS_WORKING_DIR = 
"./work/extensions";
 
     public int getWebThreads() {
         int webThreads = 200;
@@ -158,6 +161,10 @@ public class NiFiRegistryProperties extends Properties {
         return new File(getProperty(WEB_WORKING_DIR, DEFAULT_WEB_WORKING_DIR));
     }
 
+    public File getExtensionsWorkingDirectory() {
+        return  new File(getProperty(EXTENSIONS_WORKING_DIR, 
DEFAULT_EXTENSIONS_WORKING_DIR));
+    }
+
     public File getProvidersConfigurationFile() {
         return getPropertyAsFile(PROVIDERS_CONFIGURATION_FILE, 
DEFAULT_PROVIDERS_CONFIGURATION_FILE);
     }

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleContext.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleContext.java
 
b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleContext.java
new file mode 100644
index 0000000..2681849
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleContext.java
@@ -0,0 +1,79 @@
+/*
+ * 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.extension;
+
+/**
+ * The context that will be passed to the {@link 
ExtensionBundlePersistenceProvider} when saving a new version of an extension 
bundle.
+ */
+public interface ExtensionBundleContext {
+
+    enum BundleType {
+        NIFI_NAR,
+        MINIFI_CPP;
+    }
+
+    /**
+     * @return the id of the bucket the bundle belongs to
+     */
+    String getBucketId();
+
+    /**
+     * @return the name of the bucket the bundle belongs to
+     */
+    String getBucketName();
+
+    /**
+     * @return the type of the bundle
+     */
+    BundleType getBundleType();
+
+    /**
+     * @return the NiFi Registry id of the bundle
+     */
+    String getBundleId();
+
+    /**
+     * @return the group id of the bundle
+     */
+    String getBundleGroupId();
+
+    /**
+     * @return the artifact id of the bundle
+     */
+    String getBundleArtifactId();
+
+    /**
+     * @return the version of the bundle
+     */
+    String getBundleVersion();
+
+    /**
+     * @return the comments for the version of the bundle
+     */
+    String getDescription();
+
+    /**
+     * @return the timestamp the bundle was created
+     */
+    long getTimestamp();
+
+    /**
+     * @return the user that created the bundle
+     */
+    String getAuthor();
+
+}

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceException.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceException.java
 
b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceException.java
new file mode 100644
index 0000000..4722604
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceException.java
@@ -0,0 +1,32 @@
+/*
+ * 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.extension;
+
+/**
+ * An Exception for errors encountered when a 
ExtensionBundlePersistenceProvider saves or retrieves a bundle.
+ */
+public class ExtensionBundlePersistenceException extends RuntimeException {
+
+    public ExtensionBundlePersistenceException(String message) {
+        super(message);
+    }
+
+    public ExtensionBundlePersistenceException(String message, Throwable 
cause) {
+        super(message, cause);
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceProvider.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceProvider.java
 
b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceProvider.java
new file mode 100644
index 0000000..9ef2646
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceProvider.java
@@ -0,0 +1,67 @@
+/*
+ * 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.extension;
+
+import org.apache.nifi.registry.provider.Provider;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Responsible for storing and retrieving the binary content of a version of 
an extension bundle.
+ */
+public interface ExtensionBundlePersistenceProvider extends Provider {
+
+    /**
+     * Persists the binary content of a version of an extension bundle.
+     *
+     * @param context the context about the bundle version being persisted
+     * @param contentStream the stream of binary content to persist
+     * @throws ExtensionBundlePersistenceException if an error occurs storing 
the content
+     */
+    void saveBundleVersion(ExtensionBundleContext context, InputStream 
contentStream) throws ExtensionBundlePersistenceException;
+
+    /**
+     * Writes the binary content of the bundle specified by the 
bucket-group-artifact-version to the provided OutputStream.
+     *
+     * @param context the context about the bundle version being retrieved
+     * @param outputStream the output stream to write the contents to
+     * @throws ExtensionBundlePersistenceException if an error occurs 
retrieving the content
+     */
+    void getBundleVersion(ExtensionBundleContext context, OutputStream 
outputStream) throws ExtensionBundlePersistenceException;
+
+    /**
+     * Deletes the content of the bundle version specified by 
bucket-group-artifact-version.
+     *
+     * @param context the context about the bundle version being deleted
+     * @throws ExtensionBundlePersistenceException if an error occurs deleting 
the content
+     */
+    void deleteBundleVersion(ExtensionBundleContext context) throws 
ExtensionBundlePersistenceException;
+
+    /**
+     * Deletes the content for all versions of the bundle specified by 
bucket-group-artifact.
+     *
+     * @param bucketId the id of the bucket where the bundle is located
+     * @param bucketName the bucket name where the bundle is located
+     * @param groupId the group id of the bundle
+     * @param artifactId the artifact id of the bundle
+     * @throws ExtensionBundlePersistenceException if an error occurs deleting 
the content
+     */
+    void deleteAllBundleVersions(String bucketId, String bucketName, String 
groupId, String artifactId)
+            throws ExtensionBundlePersistenceException;
+
+}

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventFieldName.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventFieldName.java
 
b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventFieldName.java
index 35b0cfe..3f2fa6e 100644
--- 
a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventFieldName.java
+++ 
b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventFieldName.java
@@ -23,6 +23,7 @@ public enum EventFieldName {
 
     BUCKET_ID,
     FLOW_ID,
+    EXTENSION_BUNDLE_ID,
     VERSION,
     USER,
     COMMENT;

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventType.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventType.java
 
b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventType.java
index c11a60c..0af35dc 100644
--- 
a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventType.java
+++ 
b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventType.java
@@ -41,6 +41,17 @@ public enum EventType {
             EventFieldName.VERSION,
             EventFieldName.USER,
             EventFieldName.COMMENT),
+    CREATE_EXTENSION_BUNDLE(
+            EventFieldName.BUCKET_ID,
+            EventFieldName.EXTENSION_BUNDLE_ID,
+            EventFieldName.USER
+    ),
+    CREATE_EXTENSION_BUNDLE_VERSION(
+            EventFieldName.BUCKET_ID,
+            EventFieldName.EXTENSION_BUNDLE_ID,
+            EventFieldName.VERSION,
+            EventFieldName.USER
+    ),
     REGISTRY_START(),
     UPDATE_BUCKET(
             EventFieldName.BUCKET_ID,
@@ -55,7 +66,19 @@ public enum EventType {
     DELETE_FLOW(
             EventFieldName.BUCKET_ID,
             EventFieldName.FLOW_ID,
-            EventFieldName.USER);
+            EventFieldName.USER),
+    DELETE_EXTENSION_BUNDLE(
+            EventFieldName.BUCKET_ID,
+            EventFieldName.EXTENSION_BUNDLE_ID,
+            EventFieldName.USER
+    ),
+    DELETE_EXTENSION_BUNDLE_VERSION(
+            EventFieldName.BUCKET_ID,
+            EventFieldName.EXTENSION_BUNDLE_ID,
+            EventFieldName.VERSION,
+            EventFieldName.USER
+    )
+    ;
 
 
     private List<EventFieldName> fieldNames;

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
 
b/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
index fb77a07..ce4377f 100644
--- 
a/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
+++ 
b/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
@@ -42,6 +42,9 @@ 
nifi.registry.security.identity.provider=${nifi.registry.security.identity.provi
 # providers properties #
 
nifi.registry.providers.configuration.file=${nifi.registry.providers.configuration.file}
 
+# extensions working dir #
+nifi.registry.extensions.working.directory=${nifi.registry.extensions.working.directory}
+
 # legacy database properties, used to migrate data from original DB to new DB 
below
 # NOTE: Users upgrading from 0.1.0 should leave these populated, but new 
installs after 0.1.0 should leave these empty
 nifi.registry.db.directory=${nifi.registry.db.directory}

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/providers.xml
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/providers.xml
 
b/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/providers.xml
index faf8d4f..306c073 100644
--- 
a/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/providers.xml
+++ 
b/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/providers.xml
@@ -51,4 +51,9 @@
     </eventHookProvider>
     -->
 
+    <extensionBundlePersistenceProvider>
+        
<class>org.apache.nifi.registry.provider.extension.FileSystemExtensionBundlePersistenceProvider</class>
+        <property name="Extension Bundle Storage 
Directory">./extension_bundles</property>
+    </extensionBundlePersistenceProvider>
+
 </providers>
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-web-api/pom.xml
----------------------------------------------------------------------
diff --git a/nifi-registry-core/nifi-registry-web-api/pom.xml 
b/nifi-registry-core/nifi-registry-web-api/pom.xml
index e0af632..6e9fa10 100644
--- a/nifi-registry-core/nifi-registry-web-api/pom.xml
+++ b/nifi-registry-core/nifi-registry-web-api/pom.xml
@@ -231,6 +231,13 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-jersey</artifactId>
             <version>${spring.boot.version}</version>
+            <exclusions>
+                <!-- spring-boot-starter-jersey brings in a Spring 4.x version 
of spring-aop which causes problems -->
+                <exclusion>
+                    <groupId>org.springframework</groupId>
+                    <artifactId>spring-aop</artifactId>
+                </exclusion>
+            </exclusions>
         </dependency>
         <!-- Exclude micrometer-core because it creates a class cast issue 
with logback, revisit later -->
         <dependency>
@@ -308,6 +315,10 @@
             <artifactId>swagger-annotations</artifactId>
         </dependency>
         <dependency>
+            <groupId>org.glassfish.jersey.media</groupId>
+            <artifactId>jersey-media-multipart</artifactId>
+        </dependency>
+        <dependency>
             <groupId>io.jsonwebtoken</groupId>
             <artifactId>jjwt</artifactId>
             <version>0.7.0</version>

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/NiFiRegistryApiApplication.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/NiFiRegistryApiApplication.java
 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/NiFiRegistryApiApplication.java
index d06555d..2ffefbb 100644
--- 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/NiFiRegistryApiApplication.java
+++ 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/NiFiRegistryApiApplication.java
@@ -54,6 +54,13 @@ public class NiFiRegistryApiApplication extends 
SpringBootServletInitializer {
     protected SpringApplicationBuilder configure(SpringApplicationBuilder 
application) {
         final Properties defaultProperties = new Properties();
 
+        // Spring Boot 2.1.0 disabled bean overriding so this re-enables it
+        
defaultProperties.setProperty("spring.main.allow-bean-definition-overriding", 
"true");
+
+        // Disable unnecessary Spring MVC filters that cause problems with 
Jersey
+        
defaultProperties.setProperty("spring.mvc.hiddenmethod.filter.enabled", 
"false");
+        defaultProperties.setProperty("spring.mvc.formcontent.filter.enabled", 
"false");
+
         // Enable Actuator Endpoints
         defaultProperties.setProperty("management.endpoints.web.expose", "*");
 

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/NiFiRegistryResourceConfig.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/NiFiRegistryResourceConfig.java
 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/NiFiRegistryResourceConfig.java
index a5ab5ef..7394e8c 100644
--- 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/NiFiRegistryResourceConfig.java
+++ 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/NiFiRegistryResourceConfig.java
@@ -18,12 +18,16 @@ package org.apache.nifi.registry.web;
 
 import org.apache.nifi.registry.web.api.AccessPolicyResource;
 import org.apache.nifi.registry.web.api.AccessResource;
+import org.apache.nifi.registry.web.api.BucketExtensionResource;
 import org.apache.nifi.registry.web.api.BucketFlowResource;
 import org.apache.nifi.registry.web.api.BucketResource;
 import org.apache.nifi.registry.web.api.ConfigResource;
+import org.apache.nifi.registry.web.api.ExtensionRepositoryResource;
+import org.apache.nifi.registry.web.api.ExtensionResource;
 import org.apache.nifi.registry.web.api.FlowResource;
 import org.apache.nifi.registry.web.api.ItemResource;
 import org.apache.nifi.registry.web.api.TenantResource;
+import org.glassfish.jersey.media.multipart.MultiPartFeature;
 import org.glassfish.jersey.server.ResourceConfig;
 import org.glassfish.jersey.server.ServerProperties;
 import org.glassfish.jersey.server.filter.HttpMethodOverrideFilter;
@@ -57,11 +61,17 @@ public class NiFiRegistryResourceConfig extends 
ResourceConfig {
         register(AccessResource.class);
         register(BucketResource.class);
         register(BucketFlowResource.class);
+        register(BucketExtensionResource.class);
+        register(ExtensionResource.class);
+        register(ExtensionRepositoryResource.class);
         register(FlowResource.class);
         register(ItemResource.class);
         register(TenantResource.class);
         register(ConfigResource.class);
 
+        // register multipart feature
+        register(MultiPartFeature.class);
+
         // include bean validation errors in response
         property(ServerProperties.BV_SEND_ERROR_IN_RESPONSE, true);
 

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ApplicationResource.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ApplicationResource.java
 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ApplicationResource.java
index 776a693..22c5211 100644
--- 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ApplicationResource.java
+++ 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ApplicationResource.java
@@ -71,9 +71,8 @@ public class ApplicationResource {
         }
     }
 
-    protected String generateResourceUri(final String... path) {
+    protected URI getBaseUri() {
         final UriBuilder uriBuilder = uriInfo.getBaseUriBuilder();
-        uriBuilder.segment(path);
         URI uri = uriBuilder.build();
         try {
 
@@ -126,7 +125,13 @@ public class ApplicationResource {
         } catch (final URISyntaxException use) {
             throw new UriBuilderException(use);
         }
-        return uri.toString();
+        return uri;
+    }
+
+    protected String generateResourceUri(final String... path) {
+        final URI baseUri = getBaseUri();
+        final URI fullUri = UriBuilder.fromUri(baseUri).segment(path).build();
+        return fullUri.toString();
     }
 
     /**

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketExtensionResource.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketExtensionResource.java
 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketExtensionResource.java
new file mode 100644
index 0000000..4477acd
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketExtensionResource.java
@@ -0,0 +1,179 @@
+/*
+ * 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.web.api;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import io.swagger.annotations.ApiResponse;
+import io.swagger.annotations.ApiResponses;
+import io.swagger.annotations.Authorization;
+import io.swagger.annotations.Extension;
+import io.swagger.annotations.ExtensionProperty;
+import org.apache.nifi.registry.event.EventFactory;
+import org.apache.nifi.registry.event.EventService;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+import org.apache.nifi.registry.extension.ExtensionBundleType;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.security.authorization.RequestAction;
+import org.apache.nifi.registry.service.AuthorizationService;
+import org.apache.nifi.registry.service.RegistryService;
+import org.apache.nifi.registry.web.link.LinkService;
+import org.apache.nifi.registry.web.security.PermissionsService;
+import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
+import org.glassfish.jersey.media.multipart.FormDataParam;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+@Component
+@Path("/buckets/{bucketId}/extensions")
+@Api(
+        value = "bucket_extensions",
+        description = "Create extension bundles scoped to an existing bucket 
in the registry.",
+        authorizations = { @Authorization("Authorization") }
+)
+public class BucketExtensionResource extends AuthorizableApplicationResource {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(BucketExtensionResource.class);
+
+    private final RegistryService registryService;
+    private final LinkService linkService;
+    private final PermissionsService permissionsService;
+
+    @Autowired
+    public BucketExtensionResource(
+            final RegistryService registryService,
+            final LinkService linkService,
+            final PermissionsService permissionsService,
+            final AuthorizationService authorizationService,
+            final EventService eventService) {
+        super(authorizationService, eventService);
+        this.registryService = registryService;
+        this.linkService = linkService;
+        this.permissionsService =permissionsService;
+    }
+
+    @POST
+    @Path("bundles/{bundleType}")
+    @Consumes(MediaType.MULTIPART_FORM_DATA)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Creates a version of an extension bundle by uploading a 
binary artifact",
+            notes = "If an extension bundle already exists in the given bucket 
with the same group id and artifact id " +
+                    "as that of the bundle being uploaded, then it will be 
added as a new version to the existing bundle. " +
+                    "If an extension bundle does not already exist in the 
given bucket with the same group id and artifact id, " +
+                    "then a new extension bundle will be created and this 
version will be added to the new bundle. " +
+                    "Client's may optionally supply a SHA-256 in hex format 
through the multi-part form field 'sha256'. " +
+                    "If supplied, then this value will be compared against the 
SHA-256 computed by the server, and the bundle " +
+                    "will be rejected if the values do not match. If not 
supplied, the bundle will be accepted, but will be marked " +
+                    "to indicate that the client did not supply a SHA-256 
during creation.",
+            response = ExtensionBundleVersion.class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = 
"write"),
+                            @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 createExtensionBundleVersion(
+            @PathParam("bucketId")
+            @ApiParam("The bucket identifier")
+                final String bucketId,
+            @PathParam("bundleType")
+            @ApiParam("The type of the bundle")
+                final String bundleType,
+            @FormDataParam("file")
+                final InputStream fileInputStream,
+            @FormDataParam("file")
+                final FormDataContentDisposition fileMetaData,
+            @FormDataParam("sha256")
+                final String clientSha256) throws IOException {
+
+        authorizeBucketAccess(RequestAction.WRITE, bucketId);
+
+        final ExtensionBundleType extensionBundleType = 
ExtensionBundleType.fromString(bundleType);
+        LOGGER.debug("Creating extension bundle version for bundle type {}", 
new Object[]{extensionBundleType});
+
+        final ExtensionBundleVersion createdBundleVersion = 
registryService.createExtensionBundleVersion(
+                bucketId, extensionBundleType, fileInputStream, clientSha256);
+
+        
publish(EventFactory.extensionBundleCreated(createdBundleVersion.getExtensionBundle()));
+        
publish(EventFactory.extensionBundleVersionCreated(createdBundleVersion));
+
+        linkService.populateLinks(createdBundleVersion.getVersionMetadata());
+        linkService.populateLinks(createdBundleVersion.getExtensionBundle());
+        linkService.populateLinks(createdBundleVersion.getBucket());
+
+        
permissionsService.populateItemPermissions(createdBundleVersion.getExtensionBundle());
+
+        return 
Response.status(Response.Status.OK).entity(createdBundleVersion).build();
+    }
+
+    @GET
+    @Path("bundles")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets all extension bundles in the given bucket",
+            response = ExtensionBundle.class,
+            responseContainer = "List",
+            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 getExtensionBundles(
+            @PathParam("bucketId")
+            @ApiParam("The bucket identifier")
+                final String bucketId
+    ) {
+        authorizeBucketAccess(RequestAction.READ, bucketId);
+
+        final List<ExtensionBundle> bundles = 
registryService.getExtensionBundlesByBucket(bucketId);
+        permissionsService.populateItemPermissions(bundles);
+        linkService.populateLinks(bundles);
+
+        return Response.status(Response.Status.OK).entity(bundles).build();
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketFlowResource.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketFlowResource.java
 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketFlowResource.java
index 942a3d4..ccf41d9 100644
--- 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketFlowResource.java
+++ 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketFlowResource.java
@@ -119,7 +119,7 @@ public class BucketFlowResource extends 
AuthorizableApplicationResource {
         publish(EventFactory.flowCreated(createdFlow));
 
         permissionsService.populateItemPermissions(createdFlow);
-        linkService.populateFlowLinks(createdFlow);
+        linkService.populateLinks(createdFlow);
         return Response.status(Response.Status.OK).entity(createdFlow).build();
     }
 
@@ -151,7 +151,7 @@ public class BucketFlowResource extends 
AuthorizableApplicationResource {
 
         final List<VersionedFlow> flows = registryService.getFlows(bucketId);
         permissionsService.populateItemPermissions(flows);
-        linkService.populateFlowLinks(flows);
+        linkService.populateLinks(flows);
 
         return Response.status(Response.Status.OK).entity(flows).build();
     }
@@ -187,7 +187,7 @@ public class BucketFlowResource extends 
AuthorizableApplicationResource {
 
         final VersionedFlow flow = registryService.getFlow(bucketId, flowId);
         permissionsService.populateItemPermissions(flow);
-        linkService.populateFlowLinks(flow);
+        linkService.populateLinks(flow);
 
         return Response.status(Response.Status.OK).entity(flow).build();
     }
@@ -230,7 +230,7 @@ public class BucketFlowResource extends 
AuthorizableApplicationResource {
         final VersionedFlow updatedFlow = registryService.updateFlow(flow);
         publish(EventFactory.flowUpdated(updatedFlow));
         permissionsService.populateItemPermissions(updatedFlow);
-        linkService.populateFlowLinks(updatedFlow);
+        linkService.populateLinks(updatedFlow);
 
         return Response.status(Response.Status.OK).entity(updatedFlow).build();
     }
@@ -311,11 +311,11 @@ public class BucketFlowResource extends 
AuthorizableApplicationResource {
         publish(EventFactory.flowVersionCreated(createdSnapshot));
 
         if (createdSnapshot.getSnapshotMetadata() != null) {
-            
linkService.populateSnapshotLinks(createdSnapshot.getSnapshotMetadata());
+            linkService.populateLinks(createdSnapshot.getSnapshotMetadata());
         }
         if (createdSnapshot.getBucket() != null) {
             
permissionsService.populateBucketPermissions(createdSnapshot.getBucket());
-            linkService.populateBucketLinks(createdSnapshot.getBucket());
+            linkService.populateLinks(createdSnapshot.getBucket());
         }
         return 
Response.status(Response.Status.OK).entity(createdSnapshot).build();
     }
@@ -351,7 +351,7 @@ public class BucketFlowResource extends 
AuthorizableApplicationResource {
 
         final SortedSet<VersionedFlowSnapshotMetadata> snapshots = 
registryService.getFlowSnapshots(bucketId, flowId);
         if (snapshots != null ) {
-            linkService.populateSnapshotLinks(snapshots);
+            linkService.populateLinks(snapshots);
         }
 
         return Response.status(Response.Status.OK).entity(snapshots).build();
@@ -421,7 +421,7 @@ public class BucketFlowResource extends 
AuthorizableApplicationResource {
         authorizeBucketAccess(RequestAction.READ, bucketId);
 
         final VersionedFlowSnapshotMetadata latest = 
registryService.getLatestFlowSnapshotMetadata(bucketId, flowId);
-        linkService.populateSnapshotLinks(latest);
+        linkService.populateLinks(latest);
 
         return Response.status(Response.Status.OK).entity(latest).build();
     }
@@ -502,16 +502,16 @@ public class BucketFlowResource extends 
AuthorizableApplicationResource {
 
     private void populateLinksAndPermissions(VersionedFlowSnapshot snapshot) {
         if (snapshot.getSnapshotMetadata() != null) {
-            linkService.populateSnapshotLinks(snapshot.getSnapshotMetadata());
+            linkService.populateLinks(snapshot.getSnapshotMetadata());
         }
 
         if (snapshot.getFlow() != null) {
-            linkService.populateFlowLinks(snapshot.getFlow());
+            linkService.populateLinks(snapshot.getFlow());
         }
 
         if (snapshot.getBucket() != null) {
             permissionsService.populateBucketPermissions(snapshot.getBucket());
-            linkService.populateBucketLinks(snapshot.getBucket());
+            linkService.populateLinks(snapshot.getBucket());
         }
 
     }

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketResource.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketResource.java
 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketResource.java
index e905973..e7c0df4 100644
--- 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketResource.java
+++ 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketResource.java
@@ -118,7 +118,7 @@ public class BucketResource extends 
AuthorizableApplicationResource {
         publish(EventFactory.bucketCreated(createdBucket));
 
         permissionsService.populateBucketPermissions(createdBucket);
-        linkService.populateBucketLinks(createdBucket);
+        linkService.populateLinks(createdBucket);
         return 
Response.status(Response.Status.OK).entity(createdBucket).build();
     }
 
@@ -152,7 +152,7 @@ public class BucketResource extends 
AuthorizableApplicationResource {
 
         final List<Bucket> buckets = 
registryService.getBuckets(authorizedBucketIds);
         permissionsService.populateBucketPermissions(buckets);
-        linkService.populateBucketLinks(buckets);
+        linkService.populateLinks(buckets);
 
         return Response.status(Response.Status.OK).entity(buckets).build();
     }
@@ -182,7 +182,7 @@ public class BucketResource extends 
AuthorizableApplicationResource {
         authorizeBucketAccess(RequestAction.READ, bucketId);
         final Bucket bucket = registryService.getBucket(bucketId);
         permissionsService.populateBucketPermissions(bucket);
-        linkService.populateBucketLinks(bucket);
+        linkService.populateLinks(bucket);
 
         return Response.status(Response.Status.OK).entity(bucket).build();
     }
@@ -233,7 +233,7 @@ public class BucketResource extends 
AuthorizableApplicationResource {
         publish(EventFactory.bucketUpdated(updatedBucket));
 
         permissionsService.populateBucketPermissions(updatedBucket);
-        linkService.populateBucketLinks(updatedBucket);
+        linkService.populateLinks(updatedBucket);
         return 
Response.status(Response.Status.OK).entity(updatedBucket).build();
     }
 

http://git-wip-us.apache.org/repos/asf/nifi-registry/blob/f1e5aef7/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionRepositoryResource.java
----------------------------------------------------------------------
diff --git 
a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionRepositoryResource.java
 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionRepositoryResource.java
new file mode 100644
index 0000000..46be907
--- /dev/null
+++ 
b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionRepositoryResource.java
@@ -0,0 +1,374 @@
+/*
+ * 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.web.api;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import io.swagger.annotations.ApiResponse;
+import io.swagger.annotations.ApiResponses;
+import io.swagger.annotations.Authorization;
+import io.swagger.annotations.Extension;
+import io.swagger.annotations.ExtensionProperty;
+import org.apache.nifi.registry.bucket.Bucket;
+import org.apache.nifi.registry.bucket.BucketItem;
+import org.apache.nifi.registry.event.EventService;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoArtifact;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoBucket;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoGroup;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersion;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersionSummary;
+import org.apache.nifi.registry.security.authorization.RequestAction;
+import org.apache.nifi.registry.service.AuthorizationService;
+import org.apache.nifi.registry.service.RegistryService;
+import 
org.apache.nifi.registry.service.extension.ExtensionBundleVersionCoordinate;
+import org.apache.nifi.registry.web.link.LinkService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Link;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.StreamingOutput;
+import java.util.ArrayList;
+import java.util.Set;
+import java.util.SortedSet;
+
+@Component
+@Path("/extensions/repo")
+@Api(
+        value = "extension_repository",
+        description = "Interact with extension bundles via the hierarchy of 
bucket/group/artifact/version.",
+        authorizations = { @Authorization("Authorization") }
+)
+public class ExtensionRepositoryResource extends 
AuthorizableApplicationResource {
+
+    public static final String CONTENT_DISPOSITION_HEADER = 
"content-disposition";
+    private final RegistryService registryService;
+    private final LinkService linkService;
+
+    @Autowired
+    public ExtensionRepositoryResource(
+            final RegistryService registryService,
+            final LinkService linkService,
+            final AuthorizationService authorizationService,
+            final EventService eventService) {
+        super(authorizationService, eventService);
+        this.registryService = registryService;
+        this.linkService = linkService;
+    }
+
+    @GET
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the names of the buckets the current user is 
authorized for in order to browse the repo by bucket",
+            response = ExtensionRepoBucket.class,
+            responseContainer = "List"
+    )
+    @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 getExtensionRepoBuckets() {
+
+        final Set<String> authorizedBucketIds = 
getAuthorizedBucketIds(RequestAction.READ);
+        if (authorizedBucketIds == null || authorizedBucketIds.isEmpty()) {
+            // not authorized for any bucket, return empty list of items
+            return Response.status(Response.Status.OK).entity(new 
ArrayList<BucketItem>()).build();
+        }
+
+        final SortedSet<ExtensionRepoBucket> repoBuckets = 
registryService.getExtensionRepoBuckets(authorizedBucketIds);
+        linkService.populateFullLinks(repoBuckets, getBaseUri());
+        return Response.status(Response.Status.OK).entity(repoBuckets).build();
+    }
+
+    @GET
+    @Path("{bucketName}")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the groups in the extension repository in the bucket 
with the given name",
+            response = ExtensionRepoGroup.class,
+            responseContainer = "List",
+            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 getExtensionRepoGroups(
+            @PathParam("bucketName")
+            @ApiParam("The bucket name")
+                final String bucketName
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final SortedSet<ExtensionRepoGroup> repoGroups = 
registryService.getExtensionRepoGroups(bucket);
+        linkService.populateFullLinks(repoGroups, getBaseUri());
+        return Response.status(Response.Status.OK).entity(repoGroups).build();
+    }
+
+    @GET
+    @Path("{bucketName}/{groupId}")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the artifacts in the extension repository with the 
given group in the bucket with the given name",
+            response = ExtensionRepoArtifact.class,
+            responseContainer = "List",
+            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 getExtensionRepoArtifacts(
+            @PathParam("bucketName")
+            @ApiParam("The bucket name")
+                final String bucketName,
+            @PathParam("groupId")
+            @ApiParam("The group id")
+                final String groupId
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final SortedSet<ExtensionRepoArtifact> repoArtifacts = 
registryService.getExtensionRepoArtifacts(bucket, groupId);
+        linkService.populateFullLinks(repoArtifacts, getBaseUri());
+        return 
Response.status(Response.Status.OK).entity(repoArtifacts).build();
+    }
+
+    @GET
+    @Path("{bucketName}/{groupId}/{artifactId}")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the versions of the artifact in the extension 
repository specified by the given bucket, group, artifact, and version",
+            response = ExtensionRepoVersionSummary.class,
+            responseContainer = "List",
+            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 getExtensionBundleVersions(
+            @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
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final SortedSet<ExtensionRepoVersionSummary> repoVersions = 
registryService.getExtensionRepoVersions(bucket, groupId, artifactId);
+        linkService.populateFullLinks(repoVersions, getBaseUri());
+        return 
Response.status(Response.Status.OK).entity(repoVersions).build();
+    }
+
+    @GET
+    @Path("{bucketName}/{groupId}/{artifactId}/{version}")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the information about the version specified by the 
given bucket, group, artifact, and version",
+            response = ExtensionRepoVersion.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 getExtensionBundleVersion(
+            @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
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final ExtensionBundleVersionCoordinate versionCoordinate = new 
ExtensionBundleVersionCoordinate(
+                bucket.getIdentifier(), groupId, artifactId, version);
+
+        final ExtensionBundleVersion bundleVersion = 
registryService.getExtensionBundleVersion(versionCoordinate);
+
+        final String downloadUri = generateResourceUri(
+                "extensions", "repo",
+                bundleVersion.getBucket().getName(),
+                bundleVersion.getExtensionBundle().getGroupId(),
+                bundleVersion.getExtensionBundle().getArtifactId(),
+                bundleVersion.getVersionMetadata().getVersion(),
+                "content");
+
+        final String sha256Uri = generateResourceUri(
+                "extensions", "repo",
+                bundleVersion.getBucket().getName(),
+                bundleVersion.getExtensionBundle().getGroupId(),
+                bundleVersion.getExtensionBundle().getArtifactId(),
+                bundleVersion.getVersionMetadata().getVersion(),
+                "sha256");
+
+        final ExtensionRepoVersion repoVersion = new ExtensionRepoVersion();
+        
repoVersion.setDownloadLink(Link.fromUri(downloadUri).rel("content").build());
+        
repoVersion.setSha256Link(Link.fromUri(sha256Uri).rel("sha256").build());
+        
repoVersion.setSha256Supplied(bundleVersion.getVersionMetadata().getSha256Supplied());
+
+        return Response.ok(repoVersion).build();
+    }
+
+    @GET
+    @Path("{bucketName}/{groupId}/{artifactId}/{version}/content")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_OCTET_STREAM)
+    @ApiOperation(
+            value = "Gets the binary content of the extension bundle specified 
by the given bucket, group, artifact, and version",
+            response = byte[].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 getExtensionBundleVersionContent(
+            @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
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final ExtensionBundleVersionCoordinate versionCoordinate = new 
ExtensionBundleVersionCoordinate(
+                bucket.getIdentifier(), groupId, artifactId, version);
+
+        final ExtensionBundleVersion bundleVersion = 
registryService.getExtensionBundleVersion(versionCoordinate);
+        final StreamingOutput streamingOutput = (output) -> 
registryService.writeExtensionBundleVersionContent(bundleVersion, output);
+
+        return Response.ok(streamingOutput)
+                .header(CONTENT_DISPOSITION_HEADER,"attachment; filename = " + 
bundleVersion.getFilename())
+                .build();
+    }
+
+    @GET
+    @Path("{bucketName}/{groupId}/{artifactId}/{version}/sha256")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.TEXT_PLAIN)
+    @ApiOperation(
+            value = "Gets the hex representation of the SHA-256 digest for the 
binary content of the version of the extension bundle",
+            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 getExtensionBundleVersionSha256(
+            @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
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final ExtensionBundleVersionCoordinate versionCoordinate = new 
ExtensionBundleVersionCoordinate(
+                bucket.getIdentifier(), groupId, artifactId, version);
+
+        final ExtensionBundleVersion bundleVersion = 
registryService.getExtensionBundleVersion(versionCoordinate);
+        final String sha256Hex = 
bundleVersion.getVersionMetadata().getSha256();
+
+        return Response.ok(sha256Hex, MediaType.TEXT_PLAIN).build();
+    }
+
+
+}

Reply via email to