You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by da...@apache.org on 2018/04/27 09:56:00 UTC

[sling-org-apache-sling-feature-io] 01/02: [Sling Feature Model] Split off IO packages into separate module.

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

davidb pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-feature-io.git

commit 168c956f7d2ef3d0c22072d9779362b05d88d51d
Author: David Bosschaert <da...@gmail.com>
AuthorDate: Wed Apr 25 10:58:43 2018 +0100

    [Sling Feature Model] Split off IO packages into separate module.
---
 pom.xml                                            | 120 +++++
 .../apache/sling/feature/io/ArtifactHandler.java   |  42 ++
 .../apache/sling/feature/io/ArtifactManager.java   | 366 +++++++++++++++
 .../sling/feature/io/ArtifactManagerConfig.java    | 142 ++++++
 .../org/apache/sling/feature/io/FileUtils.java     | 207 +++++++++
 .../feature/io/json/ApplicationJSONReader.java     |  92 ++++
 .../feature/io/json/ApplicationJSONWriter.java     |  97 ++++
 .../feature/io/json/ConfigurationJSONReader.java   |  78 ++++
 .../feature/io/json/ConfigurationJSONWriter.java   |  64 +++
 .../sling/feature/io/json/FeatureJSONReader.java   | 431 +++++++++++++++++
 .../sling/feature/io/json/FeatureJSONWriter.java   | 207 +++++++++
 .../sling/feature/io/json/JSONConstants.java       |  88 ++++
 .../sling/feature/io/json/JSONReaderBase.java      | 479 +++++++++++++++++++
 .../sling/feature/io/json/JSONWriterBase.java      | 259 +++++++++++
 .../sling/feature/io/json/ManifestUtils.java       | 515 +++++++++++++++++++++
 .../apache/sling/feature/io/json/package-info.java |  23 +
 .../org/apache/sling/feature/io/package-info.java  |  23 +
 .../sling/feature/io/spi/ArtifactProvider.java     |  55 +++
 .../feature/io/spi/ArtifactProviderContext.java    |  46 ++
 .../apache/sling/feature/io/spi/package-info.java  |  23 +
 .../sling/feature/io/ArtifactManagerTest.java      | 102 ++++
 .../apache/sling/feature/io/FeatureUtilTest.java   |  50 ++
 .../feature/io/json/FeatureJSONReaderTest.java     | 222 +++++++++
 .../feature/io/json/FeatureJSONWriterTest.java     |  50 ++
 .../java/org/apache/sling/feature/io/json/U.java   |  89 ++++
 src/test/resources/features/repoinit.json          |   4 +
 src/test/resources/features/repoinit2.json         |   7 +
 src/test/resources/features/test.json              |  92 ++++
 src/test/resources/features/test2.json             | 107 +++++
 src/test/resources/features/test3.json             | 116 +++++
 30 files changed, 4196 insertions(+)

diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..8053a89
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="ISO-8859-1"?>
+    <!--
+        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.
+    -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.sling</groupId>
+        <artifactId>sling</artifactId>
+        <version>33</version>
+        <relativePath />
+    </parent>
+
+    <artifactId>org.apache.sling.feature.io</artifactId>
+    <version>0.0.1-SNAPSHOT</version>
+    <packaging>bundle</packaging>
+    
+    <name>Apache Sling Feature IO Module</name>
+    <description>
+        IO functionality for the Feature Model
+    </description>
+
+    <properties>
+        <sling.java.version>8</sling.java.version>
+    </properties>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.felix</groupId>
+                <artifactId>maven-bundle-plugin</artifactId>
+                <extensions>true</extensions>
+                <configuration>
+                    <instructions>
+                        <Conditional-Package>
+                            org.apache.felix.configurator.impl.json,
+                            org.apache.felix.configurator.impl.model                            
+                        </Conditional-Package>
+                    </instructions>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.felix</groupId>
+            <artifactId>org.apache.felix.converter</artifactId>
+            <version>0.1.0-SNAPSHOT</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.felix</groupId>
+            <artifactId>org.apache.felix.configurator</artifactId>
+            <version>0.0.1-SNAPSHOT</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.felix</groupId>
+            <artifactId>org.apache.felix.utils</artifactId>
+            <version>1.11.0-SNAPSHOT</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.geronimo.specs</groupId>
+            <artifactId>geronimo-json_1.0_spec</artifactId>
+            <version>1.0-alpha-1</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.sling</groupId>
+            <artifactId>org.apache.sling.feature</artifactId>
+            <version>0.0.1-SNAPSHOT</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.annotation.versioning</artifactId>
+            <version>1.0.0</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>osgi.core</artifactId>
+            <version>6.0.0</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+        </dependency>
+
+        <!-- Testing -->
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>2.8.9</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.johnzon</groupId>
+            <artifactId>johnzon-core</artifactId>
+            <version>1.0.0</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/src/main/java/org/apache/sling/feature/io/ArtifactHandler.java b/src/main/java/org/apache/sling/feature/io/ArtifactHandler.java
new file mode 100644
index 0000000..45f27be
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/ArtifactHandler.java
@@ -0,0 +1,42 @@
+/*
+ * 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.sling.feature.io;
+
+import java.io.File;
+
+/**
+ * A handler provides a file object for an artifact.
+ */
+public class ArtifactHandler {
+
+    private final String url;
+
+    private final File file;
+
+    public ArtifactHandler(final String url, final File file) {
+        this.url = url;
+        this.file = file;
+    }
+
+    public String getUrl() {
+        return url;
+    }
+
+    public File getFile() {
+        return file;
+    }
+}
diff --git a/src/main/java/org/apache/sling/feature/io/ArtifactManager.java b/src/main/java/org/apache/sling/feature/io/ArtifactManager.java
new file mode 100644
index 0000000..954ac1a
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/ArtifactManager.java
@@ -0,0 +1,366 @@
+/*
+ * 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.sling.feature.io;
+
+import org.apache.sling.feature.ArtifactId;
+import org.apache.sling.feature.io.spi.ArtifactProvider;
+import org.apache.sling.feature.io.spi.ArtifactProviderContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.MalformedURLException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.nio.file.Files;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.ServiceLoader;
+
+/**
+ * The artifact manager is the central service to get artifacts.
+ * It uses {@link ArtifactProvider}s to get artifacts. The
+ * providers are loaded using the service loader.
+ */
+public class ArtifactManager {
+
+    private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+    /** The map of providers. */
+    private final Map<String, ArtifactProvider> providers;
+
+    /** The configuration */
+    private final ArtifactManagerConfig config;
+
+    /**
+     * Get an artifact manager based on the configuration
+     * @param config The configuration
+     * @return The artifact manager
+     * @throws IOException If the manager can't be initialized
+     */
+    public static ArtifactManager getArtifactManager(final ArtifactManagerConfig config) throws IOException {
+        final ServiceLoader<ArtifactProvider> loader = ServiceLoader.load(ArtifactProvider.class);
+        final Map<String, ArtifactProvider> providers = new HashMap<>();
+        for(final ArtifactProvider provider : loader) {
+            providers.put(provider.getProtocol(), provider);
+        }
+
+        final String[] repositoryURLs = new String[config.getRepositoryUrls().length];
+        int index = 0;
+        for(final String urlString : config.getRepositoryUrls()) {
+            repositoryURLs[index] = urlString;
+            index++;
+        }
+        // default
+        if ( !providers.containsKey("*") ) {
+            providers.put("*", new DefaultArtifactHandler());
+        }
+
+        return new ArtifactManager(config, providers);
+    }
+
+    ArtifactManager(final ArtifactManagerConfig config, final Map<String, ArtifactProvider> providers)
+    throws IOException {
+        this.config = config;
+        this.providers = providers;
+        try {
+            for(final ArtifactProvider provider : this.providers.values()) {
+                provider.init(config);
+            }
+        } catch ( final IOException io) {
+            shutdown();
+            throw io;
+        }
+    }
+
+    /**
+     * Shutdown the artifact manager.
+     */
+    public void shutdown() {
+        for(final ArtifactProvider provider : this.providers.values()) {
+            provider.shutdown();
+        }
+        this.providers.clear();
+    }
+
+    private final File getArtifactFromProviders(final String url, final String relativeCachePath) throws IOException {
+        final int pos = url.indexOf(":");
+        final String scheme = url.substring(0, pos);
+
+        ArtifactProvider provider = this.providers.get(scheme);
+        if ( provider == null ) {
+            provider = this.providers.get("*");
+        }
+        if ( provider == null ) {
+            throw new IOException("No URL provider found for " + url);
+        }
+        return provider.getArtifact(url, relativeCachePath);
+    }
+
+    /**
+     * Get the full artifact url and file for an artifact.
+     * @param url Artifact url or relative path.
+     * @return Absolute url and file in the form of a handler.
+     * @throws IOException If something goes wrong.
+     */
+    public ArtifactHandler getArtifactHandler(final String url) throws IOException {
+        logger.debug("Trying to get artifact for {}", url);
+
+        final String path;
+
+        if ( url.startsWith("mvn:") ) {
+            // mvn url
+            path = ArtifactId.fromMvnUrl(url).toMvnPath();
+
+        } else if ( url.startsWith(":") ) {
+            // repository path
+            path = url.substring(1);
+
+        } else if ( url.indexOf(":/") > 0 ) {
+
+            // absolute URL
+            int pos = url.indexOf(":/") + 2;
+            while ( url.charAt(pos) == '/') {
+                pos++;
+            }
+            final File file = this.getArtifactFromProviders(url, url.substring(pos));
+            if ( file == null || !file.exists()) {
+                throw new IOException("Artifact " + url + " not found.");
+            }
+            return new ArtifactHandler(url, file);
+
+        } else {
+            // file (either relative or absolute)
+            final File f = new File(url);
+            if ( !f.exists()) {
+                throw new IOException("Artifact " + url + " not found.");
+            }
+            return new ArtifactHandler(f.toURI().toString(), f);
+        }
+        logger.debug("Querying repositories for {}", path);
+
+        for(final String repoUrl : this.config.getRepositoryUrls()) {
+            final StringBuilder builder = new StringBuilder();
+            builder.append(repoUrl);
+            builder.append('/');
+            builder.append(path);
+
+            final String artifactUrl = builder.toString();
+            final int pos = artifactUrl.indexOf(":");
+            final String scheme = artifactUrl.substring(0, pos);
+
+            ArtifactProvider handler = this.providers.get(scheme);
+            if ( handler == null ) {
+                handler = this.providers.get("*");
+            }
+            if ( handler == null ) {
+                throw new IOException("No URL handler found for " + artifactUrl);
+            }
+
+            logger.debug("Checking {} to get artifact from {}", handler, artifactUrl);
+
+            final File file = handler.getArtifact(artifactUrl, path);
+            if ( file != null ) {
+                logger.debug("Found artifact {}", artifactUrl);
+                return new ArtifactHandler(artifactUrl, file);
+            }
+
+            // check for SNAPSHOT
+            final int lastSlash = artifactUrl.lastIndexOf('/');
+            final int startSnapshot = artifactUrl.indexOf("-SNAPSHOT", lastSlash + 1);
+
+            if ( startSnapshot > -1 ) {
+                // special snapshot handling
+                final String metadataUrl = artifactUrl.substring(0, lastSlash) + "/maven-metadata.xml";
+                try {
+                    final ArtifactHandler metadataHandler = this.getArtifactHandler(metadataUrl);
+
+                    final String contents = getFileContents(metadataHandler);
+
+                    final String latestVersion = getLatestSnapshot(contents);
+                    if ( latestVersion != null ) {
+                        final String name = artifactUrl.substring(lastSlash); // includes slash
+                        final String fullURL = artifactUrl.substring(0, lastSlash) + name.replace("SNAPSHOT", latestVersion);
+                        int pos2 = fullURL.indexOf(":/") + 2;
+                        while ( fullURL.charAt(pos2) == '/') {
+                            pos2++;
+                        }
+                        final File file2 = this.getArtifactFromProviders(fullURL, path);
+                        if ( file2 == null || !file2.exists()) {
+                            throw new IOException("Artifact " + fullURL + " not found.");
+                        }
+                        return new ArtifactHandler(artifactUrl, file2);
+                    }
+                } catch ( final IOException ignore ) {
+                    // we ignore this but report the original 404
+                }
+            }
+        }
+
+        throw new IOException("Artifact " + url + " not found in any repository.");
+    }
+
+    protected String getFileContents(final ArtifactHandler handler) throws IOException {
+        final StringBuilder sb = new StringBuilder();
+        for(final String line : Files.readAllLines(handler.getFile().toPath())) {
+            sb.append(line).append('\n');
+        }
+
+        return sb.toString();
+    }
+
+    public static String getValue(final String xml, final String[] xpath) {
+        String value = null;
+        int pos = 0;
+        for(final String name : xpath) {
+            final String element = '<' + name + '>';
+
+            pos = xml.indexOf(element, pos);
+            if ( pos == -1 ) {
+                final String elementWithAttributes = '<' + name + ' ';
+                pos = xml.indexOf(elementWithAttributes, pos);
+                if ( pos == -1 ) {
+                    break;
+                }
+            }
+            pos = xml.indexOf('>', pos) + 1;
+        }
+        if ( pos != -1 ) {
+            final int endPos = xml.indexOf("</", pos);
+            if ( endPos != -1 ) {
+                value = xml.substring(pos, endPos).trim();
+            }
+        }
+        return value;
+    }
+    public static String getLatestSnapshot(final String mavenMetadata) {
+        final String timestamp = getValue(mavenMetadata, new String[] {"metadata", "versioning", "snapshot", "timestamp"});
+        final String buildNumber = getValue(mavenMetadata, new String[] {"metadata", "versioning", "snapshot", "buildNumber"});
+
+        if ( timestamp != null && buildNumber != null ) {
+            return timestamp + '-' + buildNumber;
+        }
+
+        return null;
+    }
+
+    private static final class DefaultArtifactHandler implements ArtifactProvider {
+
+        private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+        private volatile File cacheDir;
+
+        private volatile ArtifactProviderContext config;
+
+        @Override
+        public String getProtocol() {
+            return "*";
+        }
+
+        @Override
+        public void init(final ArtifactProviderContext config) throws IOException {
+            this.cacheDir = config.getCacheDirectory();
+            this.config = config;
+        }
+
+        @Override
+        public void shutdown() {
+            this.config = null;
+            this.cacheDir = null;
+        }
+
+        @Override
+        public File getArtifact(final String url, final String relativeCachePath) {
+            logger.debug("Checking url to be local file {}", url);
+            // check if this is already a local file
+            try {
+                final File f = new File(new URL(url).toURI());
+                if ( f.exists() ) {
+                    this.config.incLocalArtifacts();
+                    return f;
+                }
+                return null;
+            } catch ( final URISyntaxException ise) {
+                // ignore
+            } catch ( final IllegalArgumentException iae) {
+                // ignore
+            } catch ( final MalformedURLException mue) {
+                // ignore
+            }
+            logger.debug("Checking remote url {}", url);
+            try {
+                // check for url
+                if ( url.indexOf(":") == -1 ) {
+                    return null;
+                }
+
+                final String filePath = (this.cacheDir.getAbsolutePath() + File.separatorChar + relativeCachePath).replace('/', File.separatorChar);
+                final File cacheFile = new File(filePath);
+
+                if ( !cacheFile.exists() ) {
+                    cacheFile.getParentFile().mkdirs();
+                    final URL u = new URL(url);
+                    final URLConnection con = u.openConnection();
+                    con.connect();
+
+                    final InputStream readIS = con.getInputStream();
+                    final byte[] buffer = new byte[32768];
+                    int l;
+                    OutputStream os = null;
+                    try {
+                        os = new FileOutputStream(cacheFile);
+                        while ( (l = readIS.read(buffer)) >= 0 ) {
+                            os.write(buffer, 0, l);
+                        }
+                    } finally {
+                        try {
+                            readIS.close();
+                        } catch ( final IOException ignore) {
+                            // ignore
+                        }
+                        if ( os != null ) {
+                            try {
+                                os.close();
+                            } catch ( final IOException ignore ) {
+                                // ignore
+
+                            }
+                        }
+                    }
+                    this.config.incDownloadedArtifacts();
+                } else {
+                    this.config.incCachedArtifacts();
+                }
+                return cacheFile;
+            } catch ( final Exception e) {
+                logger.info("Artifact not found in one repository", e);
+                // ignore for now
+                return null;
+            }
+        }
+
+        @Override
+        public String toString() {
+            return "DefaultArtifactHandler";
+        }
+    }
+}
diff --git a/src/main/java/org/apache/sling/feature/io/ArtifactManagerConfig.java b/src/main/java/org/apache/sling/feature/io/ArtifactManagerConfig.java
new file mode 100644
index 0000000..5df9ed8
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/ArtifactManagerConfig.java
@@ -0,0 +1,142 @@
+/*
+ * 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.sling.feature.io;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+import org.apache.sling.feature.io.spi.ArtifactProviderContext;
+
+/**
+ * This class holds the configuration of artifact manager.
+ */
+public class ArtifactManagerConfig implements ArtifactProviderContext {
+
+    /** The repository urls. */
+    private volatile String[] repositoryUrls;
+
+    /** The cache directory. */
+    private volatile File cacheDirectory;
+
+    private volatile long cachedArtifacts;
+
+    private volatile long downloadedArtifacts;
+
+    private volatile long localArtifacts;
+
+    /**
+     * Create a new configuration object.
+     * Set the default values
+     */
+    public ArtifactManagerConfig() {
+        // set defaults
+        this.repositoryUrls = new String[] {
+                "file://" + System.getProperty("user.home") + "/.m2/repository",
+                "https://repo.maven.apache.org/maven2",
+                "https://repository.apache.org/content/groups/snapshots"
+                };
+        try {
+            this.cacheDirectory = Files.createTempDirectory("slingfeature").toFile();
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Set the repository urls
+     * @param urls The repository urls
+     */
+    public void setRepositoryUrls(final String[] urls) {
+        if ( urls == null || urls.length == 0 ) {
+            this.repositoryUrls = null;
+        } else {
+            this.repositoryUrls = new String[urls.length];
+            System.arraycopy(urls, 0, this.repositoryUrls, 0, urls.length);
+            for(int i=0; i<this.repositoryUrls.length; i++) {
+                if ( this.repositoryUrls[i].endsWith("/") ) {
+                    this.repositoryUrls[i] = this.repositoryUrls[i].substring(0, this.repositoryUrls[i].length() - 1);
+                }
+            }
+        }
+    }
+
+    /**
+     * Get the repository urls.
+     * A repository url does not end with a slash.
+     * @return The repository urls.
+     */
+    public String[] getRepositoryUrls() {
+        return repositoryUrls;
+    }
+
+    /**
+     * Get the cache directory
+     * @return The cache directory.
+     */
+    @Override
+    public File getCacheDirectory() {
+        return cacheDirectory;
+    }
+
+    /**
+     * Set the cache directory
+     * @param dir The cache directory
+     */
+    public void setCacheDirectory(final File dir) {
+        this.cacheDirectory = dir;
+    }
+
+    @Override
+    public void incCachedArtifacts() {
+        this.cachedArtifacts++;
+    }
+
+    @Override
+    public void incDownloadedArtifacts() {
+        this.downloadedArtifacts++;
+    }
+
+    @Override
+    public void incLocalArtifacts() {
+        this.localArtifacts++;
+    }
+
+    /**
+     * Get the number of cached artifacts
+     * @return The number of cached artifacts
+     */
+    public long getCachedArtifacts() {
+        return this.cachedArtifacts;
+    }
+
+    /**
+     * Get the number of downloaded artifacts
+     * @return The number of downloaded artifacts
+     */
+    public long getDownloadedArtifacts() {
+        return this.downloadedArtifacts;
+    }
+
+    /**
+     * Get the number of local artifacts
+     * @return The number of local artifacts
+     */
+    public long getLocalArtifacts() {
+        return this.localArtifacts;
+    }
+}
diff --git a/src/main/java/org/apache/sling/feature/io/FileUtils.java b/src/main/java/org/apache/sling/feature/io/FileUtils.java
new file mode 100644
index 0000000..a54d933
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/FileUtils.java
@@ -0,0 +1,207 @@
+/*
+ * 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.sling.feature.io;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+public class FileUtils {
+
+    /** The extension for a reference file. */
+    public static final String EXTENSION_REF_FILE = ".ref";
+
+    /** The extension for a feature file. */
+    public static final String EXTENSION_FEATURE_FILE = ".json";
+
+    /** The default directory to search for features. */
+    public static final String DEFAULT_DIRECTORY = "features";
+
+    /** The default name of the feature file. */
+    public static final String DEFAULT_FEATURE_FILE = "feature" + EXTENSION_FEATURE_FILE;
+
+    /**
+     * Parse a feature reference file
+     * @param file The file
+     * @return The referenced features
+     * @throws IOException If reading fails
+     */
+    public static List<String> parseFeatureRefFile(final File file)
+    throws IOException {
+        final List<String> result = new ArrayList<>();
+        final List<String> lines = Files.readAllLines(file.toPath());
+        for(String line : lines) {
+            line = line.trim();
+            if ( !line.isEmpty() && !line.startsWith("#") ) {
+                if ( line.indexOf(':') == -1 ) {
+                    result.add(new File(line).getAbsolutePath());
+                } else {
+                    result.add(line);
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Get the list of feature files.
+     * If the provided list of files is {@code null} or an empty array, the default is used.
+     * The default checks for the following places, the first one found is used. If none is
+     * found an empty list is returned.
+     * <ol>
+     *   <li>A directory named {@link #DEFAULT_DIRECTORY} in the current directory
+     *   <li>A file named {@link #DEFAULT_FEATURE_FILE} in the current directory
+     *   <li>A directory named {@link #DEFAULT_DIRECTORY} in the home directory
+     *   <li>A file named {@link #DEFAULT_FEATURE_FILE} in the home directory
+     * </ol>
+     *
+     * The list of files is processed one after the other. If it is relative, it is
+     * first tried to be resolved against the current directory and then against the
+     * home directory.
+     * If an entry denotes a directory, all children ending in {@link #EXTENSION_FEATURE_FILE} or
+     * {@link #EXTENSION_REF_FILE} of that directory are read.
+     * If a file ends in {@link #EXTENSION_REF_FILE} the contents is read and every line not
+     * starting with the hash sign is considered a reference to a feature artifact.
+     *
+     * @param homeDirectory If relative files should be resolved, this is the directory to use
+     * @param files Optional list of files. If none is provided, a default is used.
+     * @return The list of files.
+     * @throws IOException If an error occurs.
+     */
+    public static List<String> getFeatureFiles(final File homeDirectory, final String... files)
+    throws IOException {
+        String[] featureFiles = files;
+        if ( featureFiles == null || featureFiles.length == 0 ) {
+            // Default value - check feature directory otherwise features file
+            final File[] candidates = new File[] {
+                    new File(homeDirectory, DEFAULT_DIRECTORY),
+                    new File(homeDirectory, DEFAULT_FEATURE_FILE),
+                    new File(DEFAULT_DIRECTORY),
+                    new File(DEFAULT_FEATURE_FILE)
+            };
+            File f = null;
+            for(final File c : candidates) {
+                if ( c.exists() ) {
+                    f = c;
+                    break;
+                }
+            }
+            // nothing found, we default to the first candidate and fail later
+            if ( f == null ) {
+                f = candidates[0];
+            }
+
+            featureFiles = new String[] {f.getAbsolutePath()};
+        }
+
+        final List<String> paths = new ArrayList<>();
+        for(final String name : featureFiles) {
+            // check for absolute
+            if ( name.indexOf(':') > 1 ) {
+                paths.add(name);
+            } else {
+                // file or relative
+                File f = null;
+                final File test = new File(name);
+                if ( test.isAbsolute() ) {
+                    f = test;
+                } else {
+                    final File[] candidates = {
+                            new File(homeDirectory, name),
+                            new File(homeDirectory, DEFAULT_DIRECTORY + File.separatorChar + name),
+                            new File(name),
+                            new File(DEFAULT_DIRECTORY + File.separatorChar + name),
+                    };
+                    for(final File c : candidates) {
+                        if ( c.exists() && c.isFile() ) {
+                            f = c;
+                            break;
+                        }
+                    }
+                }
+
+                if ( f != null && f.exists() ) {
+                    if ( f.isFile() ) {
+                        processFile(paths, f);
+                    } else {
+                        processDir(paths, f);
+                    }
+                } else {
+                    // we simply add the path and fail later on
+                    paths.add(new File(name).getAbsolutePath());
+                }
+            }
+        }
+
+        Collections.sort(paths, FEATURE_PATH_COMP);
+        return paths;
+    }
+
+    static final Comparator<String> FEATURE_PATH_COMP = new Comparator<String>() {
+
+        @Override
+        public int compare(final String o1, final String o2) {
+            // windows path conversion
+            final String key1 = o1.replace(File.separatorChar, '/');
+            final String key2 = o2.replace(File.separatorChar, '/');
+
+            final int lastSlash1 = key1.lastIndexOf('/');
+            final int lastSlash2 = key2.lastIndexOf('/');
+            if ( lastSlash1 == -1 || lastSlash2 == -1 ) {
+                return o1.compareTo(o2);
+            }
+            final String path1 = key1.substring(0, lastSlash1 + 1);
+            final String path2 = key2.substring(0, lastSlash2 + 1);
+            if ( path1.equals(path2) ) {
+                return o1.compareTo(o2);
+            }
+            if ( path1.startsWith(path2) ) {
+                return 1;
+            } else if ( path2.startsWith(path1) ) {
+                return -1;
+            }
+            return o1.compareTo(o2);
+        }
+    };
+
+    private static void processDir(final List<String> paths, final File dir)
+    throws IOException {
+        for(final File f : dir.listFiles()) {
+            if ( f.isFile() && !f.getName().startsWith(".")) {
+                // check if file is a reference
+                if ( f.getName().endsWith(EXTENSION_REF_FILE) || f.getName().endsWith(EXTENSION_FEATURE_FILE) ) {
+                    processFile(paths, f);
+                }
+            }
+        }
+    }
+
+
+
+    private static void processFile(final List<String> paths, final File f)
+    throws IOException {
+        if ( f.getName().endsWith(EXTENSION_REF_FILE) ) {
+            paths.addAll(parseFeatureRefFile(f));
+        } else {
+            paths.add(f.getAbsolutePath());
+        }
+    }
+}
diff --git a/src/main/java/org/apache/sling/feature/io/json/ApplicationJSONReader.java b/src/main/java/org/apache/sling/feature/io/json/ApplicationJSONReader.java
new file mode 100644
index 0000000..88d3bc5
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/json/ApplicationJSONReader.java
@@ -0,0 +1,92 @@
+/*
+ * 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.sling.feature.io.json;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.util.Map;
+
+import javax.json.Json;
+import javax.json.JsonObject;
+
+import org.apache.felix.configurator.impl.json.JSONUtil;
+import org.apache.sling.feature.Application;
+import org.apache.sling.feature.ArtifactId;
+
+/**
+ * This class offers a method to read an {@code Application} using a {@code Reader} instance.
+ */
+public class ApplicationJSONReader extends JSONReaderBase {
+
+    /**
+     * Read a new application from the reader
+     * The reader is not closed. It is up to the caller to close the reader.
+     *
+     * @param reader The reader for the feature
+     * @return The application
+     * @throws IOException If an IO errors occurs or the JSON is invalid.
+     */
+    public static Application read(final Reader reader)
+    throws IOException {
+        try {
+            final ApplicationJSONReader mr = new ApplicationJSONReader();
+            mr.readApplication(reader);
+            return mr.app;
+        } catch (final IllegalStateException | IllegalArgumentException e) {
+            throw new IOException(e);
+        }
+    }
+
+    /** The read application. */
+    private final Application app;
+
+    /**
+     * Private constructor
+     */
+    private ApplicationJSONReader() {
+        super(null);
+        this.app = new Application();
+    }
+
+    /**
+     * Read a full application
+     * @param reader The reader
+     * @throws IOException If an IO error occurs or the JSON is not valid.
+     */
+    private void readApplication(final Reader reader)
+    throws IOException {
+        final JsonObject json = Json.createReader(new StringReader(minify(reader))).readObject();
+
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> map = (Map<String, Object>) JSONUtil.getValue(json);
+
+        final String frameworkId = this.getProperty(map, JSONConstants.APP_FRAMEWORK);
+        if ( frameworkId != null ) {
+            app.setFramework(ArtifactId.parse(frameworkId));
+        }
+        this.readBundles(map, app.getBundles(), app.getConfigurations());
+        this.readFrameworkProperties(map, app.getFrameworkProperties());
+        this.readConfigurations(map, app.getConfigurations());
+
+        this.readExtensions(map,
+                JSONConstants.APP_KNOWN_PROPERTIES,
+                this.app.getExtensions(), this.app.getConfigurations());
+    }
+}
+
+
diff --git a/src/main/java/org/apache/sling/feature/io/json/ApplicationJSONWriter.java b/src/main/java/org/apache/sling/feature/io/json/ApplicationJSONWriter.java
new file mode 100644
index 0000000..bdf4902
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/json/ApplicationJSONWriter.java
@@ -0,0 +1,97 @@
+/*
+ * 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.sling.feature.io.json;
+
+import org.apache.sling.feature.Application;
+import org.apache.sling.feature.ArtifactId;
+import org.apache.sling.feature.Configuration;
+import org.apache.sling.feature.Configurations;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.Collections;
+
+import javax.json.Json;
+import javax.json.JsonArrayBuilder;
+import javax.json.JsonObjectBuilder;
+import javax.json.JsonWriter;
+import javax.json.JsonWriterFactory;
+import javax.json.stream.JsonGenerator;
+
+/**
+ * Simple JSON writer for an application
+ */
+public class ApplicationJSONWriter extends JSONWriterBase {
+
+    /**
+     * Writes the application to the writer.
+     * The writer is not closed.
+     * @param writer Writer
+     * @param app The application
+     * @throws IOException If writing fails
+     */
+    public static void write(final Writer writer, final Application app)
+    throws IOException {
+        final ApplicationJSONWriter w = new ApplicationJSONWriter();
+        w.writeApp(writer, app);
+    }
+
+   private void writeApp(final Writer writer, final Application app)
+    throws IOException {
+       JsonObjectBuilder ob = Json.createObjectBuilder();
+
+        // framework
+        if ( app.getFramework() != null ) {
+            ob.add(JSONConstants.APP_FRAMEWORK, app.getFramework().toMvnId());
+        }
+
+        // features
+        if ( !app.getFeatureIds().isEmpty() ) {
+            JsonArrayBuilder featuresArr = Json.createArrayBuilder();
+
+            for(final ArtifactId id : app.getFeatureIds()) {
+                featuresArr.add(id.toMvnId());
+            }
+            ob.add(JSONConstants.APP_FEATURES, featuresArr.build());
+        }
+
+        // bundles
+        writeBundles(ob, app.getBundles(), app.getConfigurations());
+
+        // configurations
+        final Configurations cfgs = new Configurations();
+        for(final Configuration cfg : app.getConfigurations()) {
+            final String artifactProp = (String)cfg.getProperties().get(Configuration.PROP_ARTIFACT);
+            if (  artifactProp == null ) {
+                cfgs.add(cfg);
+            }
+        }
+        writeConfigurations(ob, cfgs);
+
+        // framework properties
+        writeFrameworkProperties(ob, app.getFrameworkProperties());
+
+        // extensions
+        writeExtensions(ob, app.getExtensions(), app.getConfigurations());
+
+        JsonWriterFactory writerFactory = Json.createWriterFactory(
+                Collections.singletonMap(JsonGenerator.PRETTY_PRINTING, true));
+        JsonWriter jw = writerFactory.createWriter(writer);
+        jw.writeObject(ob.build());
+        jw.close();
+    }
+}
diff --git a/src/main/java/org/apache/sling/feature/io/json/ConfigurationJSONReader.java b/src/main/java/org/apache/sling/feature/io/json/ConfigurationJSONReader.java
new file mode 100644
index 0000000..5fca482
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/json/ConfigurationJSONReader.java
@@ -0,0 +1,78 @@
+/*
+ * 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.sling.feature.io.json;
+
+import org.apache.felix.configurator.impl.json.JSONUtil;
+import org.apache.sling.feature.Configurations;
+
+import javax.json.Json;
+import javax.json.JsonObject;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * JSON Reader for configurations.
+ */
+public class ConfigurationJSONReader extends JSONReaderBase {
+
+    /**
+     * Read a map of configurations from the reader
+     * The reader is not closed. It is up to the caller to close the reader.
+     *
+     * @param reader The reader for the configuration
+     * @param location Optional location
+     * @return The read configurations
+     * @throws IOException If an IO errors occurs or the JSON is invalid.
+     */
+    public static Configurations read(final Reader reader, final String location)
+    throws IOException {
+        try {
+            final ConfigurationJSONReader mr = new ConfigurationJSONReader(location);
+            return mr.readConfigurations(reader);
+        } catch (final IllegalStateException | IllegalArgumentException e) {
+            throw new IOException(e);
+        }
+    }
+
+    /**
+     * Private constructor
+     * @param location Optional location
+     */
+    ConfigurationJSONReader(final String location) {
+        super(location);
+    }
+
+    Configurations readConfigurations(final Reader reader) throws IOException {
+        final Configurations result = new Configurations();
+
+        final JsonObject json = Json.createReader(new StringReader(minify(reader))).readObject();
+
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> map = (Map<String, Object>) JSONUtil.getValue(json);
+
+        final Map<String, Object> objMap = Collections.singletonMap(JSONConstants.FEATURE_CONFIGURATIONS, (Object)map);
+
+        readConfigurations(objMap, result);
+
+        return result;
+    }
+}
+
+
diff --git a/src/main/java/org/apache/sling/feature/io/json/ConfigurationJSONWriter.java b/src/main/java/org/apache/sling/feature/io/json/ConfigurationJSONWriter.java
new file mode 100644
index 0000000..64e937d
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/json/ConfigurationJSONWriter.java
@@ -0,0 +1,64 @@
+/*
+ * 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.sling.feature.io.json;
+
+import org.apache.sling.feature.Configurations;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.Collections;
+
+import javax.json.Json;
+import javax.json.JsonObjectBuilder;
+import javax.json.JsonWriter;
+import javax.json.JsonWriterFactory;
+import javax.json.stream.JsonGenerator;
+
+
+/**
+ * JSON writer for configurations
+ */
+public class ConfigurationJSONWriter extends JSONWriterBase {
+
+    /**
+     * Writes the configurations to the writer.
+     * The writer is not closed.
+     * @param writer Writer
+     * @param configs List of configurations
+     * @throws IOException If writing fails
+     */
+    public static void write(final Writer writer, final Configurations configs)
+    throws IOException {
+        final ConfigurationJSONWriter w = new ConfigurationJSONWriter();
+        w.writeConfigurations(writer, configs);
+    }
+
+    private void writeConfigurations(final Writer writer, final Configurations configs)
+    throws IOException {
+        JsonObjectBuilder ob = Json.createObjectBuilder();
+
+        // TODO is this correct?
+        ob.add(JSONConstants.FEATURE_CONFIGURATIONS,
+                writeConfigurationsMap(configs));
+
+        JsonWriterFactory writerFactory = Json.createWriterFactory(
+                Collections.singletonMap(JsonGenerator.PRETTY_PRINTING, true));
+        JsonWriter jw = writerFactory.createWriter(writer);
+        jw.writeObject(ob.build());
+        jw.close();
+    }
+}
diff --git a/src/main/java/org/apache/sling/feature/io/json/FeatureJSONReader.java b/src/main/java/org/apache/sling/feature/io/json/FeatureJSONReader.java
new file mode 100644
index 0000000..0d21598
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/json/FeatureJSONReader.java
@@ -0,0 +1,431 @@
+/*
+ * 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.sling.feature.io.json;
+
+import org.apache.felix.utils.resource.CapabilityImpl;
+import org.apache.felix.utils.resource.RequirementImpl;
+import org.apache.sling.feature.ArtifactId;
+import org.apache.sling.feature.Feature;
+import org.apache.sling.feature.Include;
+import org.apache.sling.feature.KeyValueMap;
+import org.osgi.resource.Capability;
+import org.osgi.resource.Requirement;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiConsumer;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.json.Json;
+import javax.json.JsonObject;
+
+/**
+ * This class offers a method to read a {@code Feature} using a {@code Reader} instance.
+ */
+public class FeatureJSONReader extends JSONReaderBase {
+    public enum SubstituteVariables { NONE, RESOLVE, LAUNCH }
+
+    // The pattern that variables in Feature JSON follow
+    private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\{[a-zA-Z0-9.-_]+\\}");
+
+    /**
+     * Read a new feature from the reader
+     * The reader is not closed. It is up to the caller to close the reader.
+     *
+     * @param reader The reader for the feature
+     * @param location Optional location
+     * @return The read feature
+     * @throws IOException If an IO errors occurs or the JSON is invalid.
+     */
+    public static Feature read(final Reader reader, final String location, final SubstituteVariables phase)
+    throws IOException {
+        return read(reader, null, location, phase);
+    }
+
+    /**
+     * Read a new feature from the reader
+     * The reader is not closed. It is up to the caller to close the reader.
+     *
+     * @param reader The reader for the feature
+     * @param providedId Optional artifact id
+     * @param location Optional location
+     * @return The read feature
+     * @throws IOException If an IO errors occurs or the JSON is invalid.
+     */
+    public static Feature read(final Reader reader,
+            final ArtifactId providedId,
+            final String location,
+            final SubstituteVariables phase)
+    throws IOException {
+        try {
+            final FeatureJSONReader mr = new FeatureJSONReader(providedId, location, phase);
+            return mr.readFeature(reader);
+        } catch (final IllegalStateException | IllegalArgumentException e) {
+            throw new IOException(e);
+        }
+    }
+
+    /** The read feature. */
+    private Feature feature;
+
+    /** The provided id. */
+    private final ArtifactId providedId;
+
+    /** The variables from the JSON. */
+    private Map<String, String> variables;
+
+    /** The current reading phase. */
+    private final SubstituteVariables phase;
+
+    /**
+     * Private constructor
+     * @param pId Optional id
+     * @param location Optional location
+     */
+    FeatureJSONReader(final ArtifactId pId, final String location, final SubstituteVariables phase) {
+        super(location);
+        this.providedId = pId;
+        this.phase = phase;
+    }
+
+    /**
+     * Read a full feature
+     * @param reader The reader
+     * @return The feature object
+     * @throws IOException If an IO error occurs or the JSON is not valid.
+     */
+    private Feature readFeature(final Reader reader)
+    throws IOException {
+        final JsonObject json = Json.createReader(new StringReader(minify(reader))).readObject();
+        final Map<String, Object> map = getJsonMap(json);
+
+        checkModelVersion(map);
+
+        final ArtifactId fId;
+        if ( !map.containsKey(JSONConstants.FEATURE_ID) ) {
+            if ( this.providedId == null ) {
+                throw new IOException(this.exceptionPrefix + "Feature id is missing");
+            }
+            fId = this.providedId;
+        } else {
+            final Object idObj = map.get(JSONConstants.FEATURE_ID);
+            checkType(JSONConstants.FEATURE_ID, idObj, String.class);
+            fId = ArtifactId.parse(idObj.toString());
+        }
+        this.feature = new Feature(fId);
+        this.feature.setLocation(this.location);
+
+        // title, description, vendor and license
+        this.feature.setTitle(getProperty(map, JSONConstants.FEATURE_TITLE));
+        this.feature.setDescription(getProperty(map, JSONConstants.FEATURE_DESCRIPTION));
+        this.feature.setVendor(getProperty(map, JSONConstants.FEATURE_VENDOR));
+        this.feature.setLicense(getProperty(map, JSONConstants.FEATURE_LICENSE));
+
+        this.readVariables(map, feature.getVariables());
+        this.readBundles(map, feature.getBundles(), feature.getConfigurations());
+        this.readFrameworkProperties(map, feature.getFrameworkProperties());
+        this.readConfigurations(map, feature.getConfigurations());
+
+        this.readCapabilities(map);
+        this.readRequirements(map);
+        this.readIncludes(map);
+
+        this.readExtensions(map,
+                JSONConstants.FEATURE_KNOWN_PROPERTIES,
+                this.feature.getExtensions(), this.feature.getConfigurations());
+
+        return feature;
+    }
+
+    private void checkModelVersion(final Map<String, Object> map) throws IOException {
+        String modelVersion = getProperty(map, JSONConstants.FEATURE_MODEL_VERSION);
+        if (modelVersion == null) {
+            modelVersion = "1";
+        }
+        if (!"1".equals(modelVersion)) {
+            throw new IOException("Unsupported model version: " + modelVersion);
+        }
+    }
+
+    @Override
+    protected Object handleResolveVars(Object val) {
+        if (phase == SubstituteVariables.RESOLVE) {
+            return handleVars(val);
+        } else {
+            return val;
+        }
+    }
+
+    @Override
+    protected Object handleLaunchVars(Object val) {
+        if (phase == SubstituteVariables.LAUNCH) {
+            return handleVars(val);
+        }
+        return val;
+    }
+
+    private Object handleVars(Object value) {
+        if (!(value instanceof String)) {
+            return value;
+        }
+
+        String textWithVars = (String) value;
+
+        Matcher m = VARIABLE_PATTERN.matcher(textWithVars.toString());
+        StringBuffer sb = new StringBuffer();
+        while (m.find()) {
+            String var = m.group();
+
+            int len = var.length();
+            String name = var.substring(2, len - 1);
+            String val = variables.get(name);
+            if (val != null) {
+                m.appendReplacement(sb, Matcher.quoteReplacement(val));
+            } else {
+                throw new IllegalStateException("Undefined variable: " + name);
+            }
+        }
+        m.appendTail(sb);
+
+        return sb.toString();
+    }
+
+    private void readVariables(Map<String, Object> map, KeyValueMap kvMap) throws IOException {
+        variables = new HashMap<>();
+
+        if (map.containsKey(JSONConstants.FEATURE_VARIABLES)) {
+            final Object variablesObj = map.get(JSONConstants.FEATURE_VARIABLES);
+            checkType(JSONConstants.FEATURE_VARIABLES, variablesObj, Map.class);
+
+            @SuppressWarnings("unchecked")
+            final Map<String, Object> vars = (Map<String, Object>) variablesObj;
+            for (final Map.Entry<String, Object> entry : vars.entrySet()) {
+                checkType("variable value", entry.getValue(), String.class, Boolean.class, Number.class);
+
+                String key = entry.getKey();
+                if (kvMap.get(key) != null) {
+                    throw new IOException(this.exceptionPrefix + "Duplicate variable " + key);
+                }
+                String value = "" + entry.getValue();
+                kvMap.put(key, value);
+                variables.put(key, value);
+            }
+        }
+    }
+
+    private void readIncludes(final Map<String, Object> map) throws IOException {
+        if ( map.containsKey(JSONConstants.FEATURE_INCLUDES)) {
+            final Object includesObj = map.get(JSONConstants.FEATURE_INCLUDES);
+            checkType(JSONConstants.FEATURE_INCLUDES, includesObj, List.class);
+
+            @SuppressWarnings("unchecked")
+            final List<Object> includes = (List<Object>)includesObj;
+            for(final Object inc : includes) {
+                checkType("Include", inc, Map.class, String.class);
+                final Include include;
+                if ( inc instanceof String ) {
+                    final ArtifactId id = ArtifactId.parse(inc.toString());
+                    include = new Include(id);
+                } else {
+                    @SuppressWarnings("unchecked")
+                    final Map<String, Object> obj = (Map<String, Object>) inc;
+                    if ( !obj.containsKey(JSONConstants.ARTIFACT_ID) ) {
+                        throw new IOException(exceptionPrefix + " include is missing required artifact id");
+                    }
+                    checkType("Include " + JSONConstants.ARTIFACT_ID, obj.get(JSONConstants.ARTIFACT_ID), String.class);
+                    final ArtifactId id = ArtifactId.parse(handleResolveVars(obj.get(JSONConstants.ARTIFACT_ID)).toString());
+                    include = new Include(id);
+
+                    if ( obj.containsKey(JSONConstants.INCLUDE_REMOVALS) ) {
+                        checkType("Include removals", obj.get(JSONConstants.INCLUDE_REMOVALS), Map.class);
+                        @SuppressWarnings("unchecked")
+                        final Map<String, Object> removalObj = (Map<String, Object>) obj.get(JSONConstants.INCLUDE_REMOVALS);
+                        if ( removalObj.containsKey(JSONConstants.FEATURE_BUNDLES) ) {
+                            checkType("Include removal bundles", removalObj.get(JSONConstants.FEATURE_BUNDLES), List.class);
+                            @SuppressWarnings("unchecked")
+                            final List<Object> list = (List<Object>)removalObj.get(JSONConstants.FEATURE_BUNDLES);
+                            for(final Object val : list) {
+                                checkType("Include removal bundles", val, String.class);
+                                include.getBundleRemovals().add(ArtifactId.parse(val.toString()));
+                            }
+                        }
+                        if ( removalObj.containsKey(JSONConstants.FEATURE_CONFIGURATIONS) ) {
+                            checkType("Include removal configuration", removalObj.get(JSONConstants.FEATURE_CONFIGURATIONS), List.class);
+                            @SuppressWarnings("unchecked")
+                            final List<Object> list = (List<Object>)removalObj.get(JSONConstants.FEATURE_CONFIGURATIONS);
+                            for(final Object val : list) {
+                                checkType("Include removal bundles", val, String.class);
+                                include.getConfigurationRemovals().add(val.toString());
+                            }
+                        }
+                        if ( removalObj.containsKey(JSONConstants.FEATURE_FRAMEWORK_PROPERTIES) ) {
+                            checkType("Include removal framework properties", removalObj.get(JSONConstants.FEATURE_FRAMEWORK_PROPERTIES), List.class);
+                            @SuppressWarnings("unchecked")
+                            final List<Object> list = (List<Object>)removalObj.get(JSONConstants.FEATURE_FRAMEWORK_PROPERTIES);
+                            for(final Object val : list) {
+                                checkType("Include removal bundles", val, String.class);
+                                include.getFrameworkPropertiesRemovals().add(val.toString());
+                            }
+                        }
+                        if ( removalObj.containsKey(JSONConstants.INCLUDE_EXTENSION_REMOVALS) ) {
+                            checkType("Include removal extensions", removalObj.get(JSONConstants.INCLUDE_EXTENSION_REMOVALS), List.class);
+                            @SuppressWarnings("unchecked")
+                            final List<Object> list = (List<Object>)removalObj.get(JSONConstants.INCLUDE_EXTENSION_REMOVALS);
+                            for(final Object val : list) {
+                                checkType("Include removal extension", val, String.class, Map.class);
+                                if ( val instanceof String ) {
+                                    include.getExtensionRemovals().add(val.toString());
+                                } else {
+                                    @SuppressWarnings("unchecked")
+                                    final Map<String, Object> removalMap = (Map<String, Object>)val;
+                                    final Object nameObj = removalMap.get("name");
+                                    checkType("Include removal extension", nameObj, String.class);
+                                    if ( removalMap.containsKey("artifacts") ) {
+                                        checkType("Include removal extension artifacts", removalMap.get("artifacts"), List.class);
+                                        @SuppressWarnings("unchecked")
+                                        final List<Object> artifactList = (List<Object>)removalMap.get("artifacts");
+                                        final List<ArtifactId> ids = new ArrayList<>();
+                                        for(final Object aid : artifactList) {
+                                            checkType("Include removal extension artifact", aid, String.class);
+                                            ids.add(ArtifactId.parse(aid.toString()));
+                                        }
+                                        include.getArtifactExtensionRemovals().put(nameObj.toString(), ids);
+                                    } else {
+                                        include.getExtensionRemovals().add(nameObj.toString());
+                                    }
+                                }
+                            }
+                        }
+
+                    }
+                }
+                for(final Include i : feature.getIncludes()) {
+                    if ( i.getId().equals(include.getId()) ) {
+                        throw new IOException(exceptionPrefix + "Duplicate include of " + include.getId());
+                    }
+                }
+                feature.getIncludes().add(include);
+            }
+        }
+    }
+
+    private void readRequirements(Map<String, Object> map) throws IOException {
+        if ( map.containsKey(JSONConstants.FEATURE_REQUIREMENTS)) {
+            final Object reqObj = map.get(JSONConstants.FEATURE_REQUIREMENTS);
+            checkType(JSONConstants.FEATURE_REQUIREMENTS, reqObj, List.class);
+
+            @SuppressWarnings("unchecked")
+            final List<Object> requirements = (List<Object>)reqObj;
+            for(final Object req : requirements) {
+                checkType("Requirement", req, Map.class);
+                @SuppressWarnings("unchecked")
+                final Map<String, Object> obj = (Map<String, Object>) req;
+
+                if ( !obj.containsKey(JSONConstants.REQCAP_NAMESPACE) ) {
+                    throw new IOException(this.exceptionPrefix + "Namespace is missing for requirement");
+                }
+                checkType("Requirement namespace", obj.get(JSONConstants.REQCAP_NAMESPACE), String.class);
+
+                Map<String, Object> attrMap = new HashMap<>();
+                if ( obj.containsKey(JSONConstants.REQCAP_ATTRIBUTES) ) {
+                    checkType("Requirement attributes", obj.get(JSONConstants.REQCAP_ATTRIBUTES), Map.class);
+                    @SuppressWarnings("unchecked")
+                    final Map<String, Object> attrs = (Map<String, Object>)obj.get(JSONConstants.REQCAP_ATTRIBUTES);
+                    attrs.forEach(rethrowBiConsumer((key, value) -> ManifestUtils.unmarshalAttribute(key, handleResolveVars(value), attrMap::put)));
+                }
+
+                Map<String, String> dirMap = new HashMap<>();
+                if ( obj.containsKey(JSONConstants.REQCAP_DIRECTIVES) ) {
+                    checkType("Requirement directives", obj.get(JSONConstants.REQCAP_DIRECTIVES), Map.class);
+                    @SuppressWarnings("unchecked")
+                    final Map<String, Object> dirs = (Map<String, Object>)obj.get(JSONConstants.REQCAP_DIRECTIVES);
+                    dirs.forEach(rethrowBiConsumer((key, value) -> ManifestUtils.unmarshalDirective(key, handleResolveVars(value), dirMap::put)));
+                }
+
+                final Requirement r = new RequirementImpl(null, handleResolveVars(obj.get(JSONConstants.REQCAP_NAMESPACE)).toString(), dirMap, attrMap);
+                feature.getRequirements().add(r);
+            }
+        }
+    }
+
+    private void readCapabilities(Map<String, Object> map) throws IOException {
+        if ( map.containsKey(JSONConstants.FEATURE_CAPABILITIES)) {
+            final Object capObj = map.get(JSONConstants.FEATURE_CAPABILITIES);
+            checkType(JSONConstants.FEATURE_CAPABILITIES, capObj, List.class);
+
+            @SuppressWarnings("unchecked")
+            final List<Object> capabilities = (List<Object>)capObj;
+            for(final Object cap : capabilities) {
+                checkType("Capability", cap, Map.class);
+                @SuppressWarnings("unchecked")
+                final Map<String, Object> obj = (Map<String, Object>) cap;
+
+                if ( !obj.containsKey(JSONConstants.REQCAP_NAMESPACE) ) {
+                    throw new IOException(this.exceptionPrefix + "Namespace is missing for capability");
+                }
+                checkType("Capability namespace", obj.get(JSONConstants.REQCAP_NAMESPACE), String.class);
+
+                Map<String, Object> attrMap = new HashMap<>();
+                if ( obj.containsKey(JSONConstants.REQCAP_ATTRIBUTES) ) {
+                    checkType("Capability attributes", obj.get(JSONConstants.REQCAP_ATTRIBUTES), Map.class);
+                    @SuppressWarnings("unchecked")
+                    final Map<String, Object> attrs = (Map<String, Object>)obj.get(JSONConstants.REQCAP_ATTRIBUTES);
+                    attrs.forEach(rethrowBiConsumer((key, value) -> ManifestUtils.unmarshalAttribute(key, handleResolveVars(value), attrMap::put)));
+                }
+
+                Map<String, String> dirMap = new HashMap<>();
+                if ( obj.containsKey(JSONConstants.REQCAP_DIRECTIVES) ) {
+                    checkType("Capability directives", obj.get(JSONConstants.REQCAP_DIRECTIVES), Map.class);
+                    @SuppressWarnings("unchecked")
+                    final Map<String, Object> dirs = (Map<String, Object>) obj.get(JSONConstants.REQCAP_DIRECTIVES);
+                    dirs.forEach(rethrowBiConsumer((key, value) -> ManifestUtils.unmarshalDirective(key, handleResolveVars(value), dirMap::put)));
+                }
+
+                final Capability c = new CapabilityImpl(null, handleResolveVars(obj.get(JSONConstants.REQCAP_NAMESPACE)).toString(), dirMap, attrMap);
+                feature.getCapabilities().add(c);
+            }
+        }
+    }
+
+    @FunctionalInterface
+    private interface BiConsumer_WithExceptions<T, V, E extends Exception> {
+        void accept(T t, V u) throws E;
+    }
+
+    private static <T, V, E extends Exception> BiConsumer<T, V> rethrowBiConsumer(BiConsumer_WithExceptions<T, V, E> biConsumer) {
+        return (t, u) -> {
+            try {
+                biConsumer.accept(t, u);
+            } catch (Exception exception) {
+                throwAsUnchecked(exception);
+            }
+        };
+    }
+
+    @SuppressWarnings ("unchecked")
+    private static <E extends Throwable> void throwAsUnchecked(Exception exception) throws E {
+        throw (E) exception;
+    }
+}
+
+
diff --git a/src/main/java/org/apache/sling/feature/io/json/FeatureJSONWriter.java b/src/main/java/org/apache/sling/feature/io/json/FeatureJSONWriter.java
new file mode 100644
index 0000000..57428e1
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/json/FeatureJSONWriter.java
@@ -0,0 +1,207 @@
+/*
+ * 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.sling.feature.io.json;
+
+import org.apache.sling.feature.ArtifactId;
+import org.apache.sling.feature.Configuration;
+import org.apache.sling.feature.Configurations;
+import org.apache.sling.feature.Feature;
+import org.apache.sling.feature.Include;
+import org.osgi.resource.Capability;
+import org.osgi.resource.Requirement;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import javax.json.Json;
+import javax.json.JsonArrayBuilder;
+import javax.json.JsonObjectBuilder;
+import javax.json.JsonWriter;
+import javax.json.JsonWriterFactory;
+import javax.json.stream.JsonGenerator;
+
+/**
+ * Simple JSON writer for a feature
+ */
+public class FeatureJSONWriter extends JSONWriterBase {
+    private FeatureJSONWriter() {}
+
+    /**
+     * Writes the feature to the writer.
+     * The writer is not closed.
+     * @param writer Writer
+     * @param feature Feature
+     * @throws IOException If writing fails
+     */
+    public static void write(final Writer writer, final Feature feature)
+    throws IOException {
+        final FeatureJSONWriter w = new FeatureJSONWriter();
+        w.writeFeature(writer, feature);
+    }
+
+    private void writeProperty(final JsonObjectBuilder ob, final String key, final String value) {
+        if ( value != null ) {
+            ob.add(key, value);
+        }
+    }
+
+    private void writeFeature(final Writer writer, final Feature feature)
+    throws IOException {
+        JsonObjectBuilder ob = Json.createObjectBuilder();
+        ob.add(JSONConstants.FEATURE_ID, feature.getId().toMvnId());
+
+        // title, description, vendor, license
+        writeProperty(ob, JSONConstants.FEATURE_TITLE, feature.getTitle());
+        writeProperty(ob, JSONConstants.FEATURE_DESCRIPTION, feature.getDescription());
+        writeProperty(ob, JSONConstants.FEATURE_VENDOR, feature.getVendor());
+        writeProperty(ob, JSONConstants.FEATURE_LICENSE, feature.getLicense());
+
+        // variables
+        writeVariables(ob, feature.getVariables());
+
+        // includes
+        if ( !feature.getIncludes().isEmpty() ) {
+            JsonArrayBuilder incArray = Json.createArrayBuilder();
+            for(final Include inc : feature.getIncludes()) {
+                if ( inc.getArtifactExtensionRemovals().isEmpty()
+                     && inc.getBundleRemovals().isEmpty()
+                     && inc.getConfigurationRemovals().isEmpty()
+                     && inc.getFrameworkPropertiesRemovals().isEmpty() ) {
+                    incArray.add(inc.getId().toMvnId());
+                } else {
+                    JsonObjectBuilder includeObj = Json.createObjectBuilder();
+                    includeObj.add(JSONConstants.ARTIFACT_ID, inc.getId().toMvnId());
+
+                    JsonObjectBuilder removalsObj = Json.createObjectBuilder();
+                    if ( !inc.getArtifactExtensionRemovals().isEmpty()
+                         || inc.getExtensionRemovals().isEmpty() ) {
+                        JsonArrayBuilder extRemovals = Json.createArrayBuilder();
+                        for(final String id : inc.getExtensionRemovals()) {
+                            extRemovals.add(id);
+                        }
+                        for(final Map.Entry<String, List<ArtifactId>> entry : inc.getArtifactExtensionRemovals().entrySet()) {
+                            JsonArrayBuilder ab = Json.createArrayBuilder();
+                            for(final ArtifactId id : entry.getValue()) {
+                                ab.add(id.toMvnId());
+                            }
+                            extRemovals.add(Json.createObjectBuilder().add(entry.getKey(),
+                                    ab.build()).build());
+                        }
+                        removalsObj.add(JSONConstants.INCLUDE_EXTENSION_REMOVALS, extRemovals.build());
+                    }
+                    if ( !inc.getConfigurationRemovals().isEmpty() ) {
+                        JsonArrayBuilder cfgRemovals = Json.createArrayBuilder();
+                        for(final String val : inc.getConfigurationRemovals()) {
+                            cfgRemovals.add(val);
+                        }
+                        removalsObj.add(JSONConstants.FEATURE_CONFIGURATIONS, cfgRemovals.build());
+                    }
+                    if ( !inc.getBundleRemovals().isEmpty() ) {
+                        JsonArrayBuilder bundleRemovals = Json.createArrayBuilder();
+                        for(final ArtifactId val : inc.getBundleRemovals()) {
+                            bundleRemovals.add(val.toMvnId());
+                        }
+                        removalsObj.add(JSONConstants.FEATURE_BUNDLES, bundleRemovals.build());
+                    }
+                    if ( !inc.getFrameworkPropertiesRemovals().isEmpty() ) {
+                        JsonArrayBuilder propRemovals = Json.createArrayBuilder();
+                        for(final String val : inc.getFrameworkPropertiesRemovals()) {
+                            propRemovals.add(val);
+                        }
+                        removalsObj.add(JSONConstants.FEATURE_FRAMEWORK_PROPERTIES, propRemovals.build());
+                    }
+                    includeObj.add(JSONConstants.INCLUDE_REMOVALS, removalsObj.build());
+
+                    incArray.add(includeObj.build());
+                }
+            }
+            ob.add(JSONConstants.FEATURE_INCLUDES, incArray.build());
+        }
+
+        // requirements
+        if ( !feature.getRequirements().isEmpty() ) {
+            JsonArrayBuilder requirements = Json.createArrayBuilder();
+
+            for(final Requirement req : feature.getRequirements()) {
+                JsonObjectBuilder requirementObj = Json.createObjectBuilder();
+                requirementObj.add(JSONConstants.REQCAP_NAMESPACE, req.getNamespace());
+                if ( !req.getAttributes().isEmpty() ) {
+                    JsonObjectBuilder attrObj = Json.createObjectBuilder();
+                    req.getAttributes().forEach((key, value) -> ManifestUtils.marshalAttribute(key, value, attrObj::add));
+                    requirementObj.add(JSONConstants.REQCAP_ATTRIBUTES, attrObj.build());
+                }
+                if ( !req.getDirectives().isEmpty() ) {
+                    JsonObjectBuilder reqObj = Json.createObjectBuilder();
+                    req.getDirectives().forEach((key, value) -> ManifestUtils.marshalDirective(key, value, reqObj::add));
+                    requirementObj.add(JSONConstants.REQCAP_DIRECTIVES, reqObj.build());
+                }
+                requirements.add(requirementObj.build());
+            }
+            ob.add(JSONConstants.FEATURE_REQUIREMENTS, requirements.build());
+        }
+
+        // capabilities
+        if ( !feature.getCapabilities().isEmpty() ) {
+            JsonArrayBuilder capabilities = Json.createArrayBuilder();
+
+            for(final Capability cap : feature.getCapabilities()) {
+                JsonObjectBuilder capabilityObj = Json.createObjectBuilder();
+                capabilityObj.add(JSONConstants.REQCAP_NAMESPACE, cap.getNamespace());
+                if ( !cap.getAttributes().isEmpty() ) {
+                    JsonObjectBuilder attrObj = Json.createObjectBuilder();
+                    cap.getAttributes().forEach((key, value) -> ManifestUtils.marshalAttribute(key, value, attrObj::add));
+                    capabilityObj.add(JSONConstants.REQCAP_ATTRIBUTES, attrObj.build());
+                }
+                if ( !cap.getDirectives().isEmpty() ) {
+                    JsonObjectBuilder reqObj = Json.createObjectBuilder();
+                    cap.getDirectives().forEach((key, value) -> ManifestUtils.marshalDirective(key, value, reqObj::add));
+                    capabilityObj.add(JSONConstants.REQCAP_DIRECTIVES, reqObj.build());
+                }
+                capabilities.add(capabilityObj.build());
+            }
+            ob.add(JSONConstants.FEATURE_CAPABILITIES, capabilities.build());
+        }
+
+        // bundles
+        writeBundles(ob, feature.getBundles(), feature.getConfigurations());
+
+        // configurations
+        final Configurations cfgs = new Configurations();
+        for(final Configuration cfg : feature.getConfigurations()) {
+            final String artifactProp = (String)cfg.getProperties().get(Configuration.PROP_ARTIFACT);
+            if (  artifactProp == null ) {
+                cfgs.add(cfg);
+            }
+        }
+        writeConfigurations(ob, cfgs);
+
+        // framework properties
+        writeFrameworkProperties(ob, feature.getFrameworkProperties());
+
+        // extensions
+        writeExtensions(ob, feature.getExtensions(), feature.getConfigurations());
+
+        JsonWriterFactory writerFactory = Json.createWriterFactory(
+                Collections.singletonMap(JsonGenerator.PRETTY_PRINTING, true));
+        JsonWriter jw = writerFactory.createWriter(writer);
+        jw.writeObject(ob.build());
+        jw.close();
+    }
+}
diff --git a/src/main/java/org/apache/sling/feature/io/json/JSONConstants.java b/src/main/java/org/apache/sling/feature/io/json/JSONConstants.java
new file mode 100644
index 0000000..3b6f707
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/json/JSONConstants.java
@@ -0,0 +1,88 @@
+/*
+ * 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.sling.feature.io.json;
+
+import org.apache.sling.feature.Configuration;
+
+import java.util.Arrays;
+import java.util.List;
+
+public abstract class JSONConstants {
+
+    public static final String FEATURE_ID = "id";
+
+    public static final String FEATURE_VARIABLES = "variables";
+
+    public static final String FEATURE_BUNDLES = "bundles";
+
+    public static final String FEATURE_FRAMEWORK_PROPERTIES = "framework-properties";
+
+    public static final String FEATURE_CONFIGURATIONS = "configurations";
+
+    public static final String FEATURE_INCLUDES = "includes";
+
+    public static final String FEATURE_REQUIREMENTS = "requirements";
+
+    public static final String FEATURE_CAPABILITIES = "capabilities";
+
+    public static final String FEATURE_TITLE = "title";
+
+    public static final String FEATURE_DESCRIPTION = "description";
+
+    public static final String FEATURE_VENDOR = "vendor";
+
+    public static final String FEATURE_LICENSE = "license";
+
+    public static final String FEATURE_MODEL_VERSION = "model-version";
+
+    public static final List<String> FEATURE_KNOWN_PROPERTIES = Arrays.asList(FEATURE_ID,
+            FEATURE_MODEL_VERSION,
+            FEATURE_VARIABLES,
+            FEATURE_BUNDLES,
+            FEATURE_FRAMEWORK_PROPERTIES,
+            FEATURE_CONFIGURATIONS,
+            FEATURE_INCLUDES,
+            FEATURE_REQUIREMENTS,
+            FEATURE_CAPABILITIES,
+            FEATURE_TITLE,
+            FEATURE_DESCRIPTION,
+            FEATURE_VENDOR,
+            FEATURE_LICENSE);
+
+    public static final String ARTIFACT_ID = "id";
+
+    public static final List<String> ARTIFACT_KNOWN_PROPERTIES = Arrays.asList(ARTIFACT_ID,
+            Configuration.PROP_ARTIFACT,
+            FEATURE_CONFIGURATIONS);
+
+    public static final String INCLUDE_REMOVALS = "removals";
+
+    public static final String INCLUDE_EXTENSION_REMOVALS = "extensions";
+
+    public static final String REQCAP_NAMESPACE = "namespace";
+    public static final String REQCAP_ATTRIBUTES = "attributes";
+    public static final String REQCAP_DIRECTIVES = "directives";
+
+    public static final String APP_FRAMEWORK = "frameworkId";
+    public static final String APP_FEATURES = "features";
+
+    public static final List<String> APP_KNOWN_PROPERTIES = Arrays.asList(APP_FRAMEWORK,
+            FEATURE_BUNDLES,
+            FEATURE_FRAMEWORK_PROPERTIES,
+            FEATURE_CONFIGURATIONS,
+            APP_FEATURES);
+}
diff --git a/src/main/java/org/apache/sling/feature/io/json/JSONReaderBase.java b/src/main/java/org/apache/sling/feature/io/json/JSONReaderBase.java
new file mode 100644
index 0000000..fe3b89e
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/json/JSONReaderBase.java
@@ -0,0 +1,479 @@
+/*
+ * 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.sling.feature.io.json;
+
+import org.apache.felix.configurator.impl.json.JSMin;
+import org.apache.felix.configurator.impl.json.JSONUtil;
+import org.apache.felix.configurator.impl.json.TypeConverter;
+import org.apache.felix.configurator.impl.model.Config;
+import org.apache.sling.feature.Artifact;
+import org.apache.sling.feature.ArtifactId;
+import org.apache.sling.feature.Bundles;
+import org.apache.sling.feature.Configuration;
+import org.apache.sling.feature.Configurations;
+import org.apache.sling.feature.Extension;
+import org.apache.sling.feature.ExtensionType;
+import org.apache.sling.feature.Extensions;
+import org.apache.sling.feature.KeyValueMap;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import javax.json.Json;
+import javax.json.JsonArrayBuilder;
+import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
+import javax.json.JsonStructure;
+import javax.json.JsonWriter;
+
+/**
+ * Common methods for JSON reading.
+ */
+abstract class JSONReaderBase {
+
+    /** The optional location. */
+    protected final String location;
+
+    /** Exception prefix containing the location (if set) */
+    protected final String exceptionPrefix;
+
+    /**
+     * Private constructor
+     * @param location Optional location
+     */
+    JSONReaderBase(final String location) {
+        this.location = location;
+        if ( location == null ) {
+            exceptionPrefix = "";
+        } else {
+            exceptionPrefix = location + " : ";
+        }
+    }
+
+    protected String minify(final Reader reader) throws IOException {
+       // minify JSON (remove comments)
+        final String contents;
+        try ( final Writer out = new StringWriter()) {
+            final JSMin min = new JSMin(reader, out);
+            min.jsmin();
+            contents = out.toString();
+        }
+        return contents;
+    }
+
+    /** Get the JSON object as a map, removing all comments that start with a '#' character
+     */
+    protected Map<String, Object> getJsonMap(JsonObject json) {
+        @SuppressWarnings("unchecked")
+        Map<String, Object> m = (Map<String, Object>) JSONUtil.getValue(json);
+
+        removeComments(m);
+        return m;
+    }
+
+    private void removeComments(Map<String, Object> m) {
+        for(Iterator<Map.Entry<String, Object>> it = m.entrySet().iterator(); it.hasNext(); ) {
+            Entry<String, ?> entry = it.next();
+            if (entry.getKey().startsWith("#")) {
+                it.remove();
+            } else if (entry.getValue() instanceof Map) {
+                @SuppressWarnings("unchecked")
+                Map<String, Object> embedded = (Map<String, Object>) entry.getValue();
+                removeComments(embedded);
+            } else if (entry.getValue() instanceof Collection) {
+                Collection<?> embedded = (Collection<?>) entry.getValue();
+                removeComments(embedded);
+            }
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private void removeComments(Collection<?> embedded) {
+        for (Object el : embedded) {
+            if (el instanceof Collection) {
+                removeComments((Collection<?>) el);
+            } else if (el instanceof Map) {
+                removeComments((Map<String, Object>) el);
+            }
+        }
+    }
+
+    protected String getProperty(final Map<String, Object> map, final String key) throws IOException {
+        final Object val = map.get(key);
+        if ( val != null ) {
+            checkType(key, val, String.class);
+            return val.toString();
+        }
+        return null;
+    }
+
+    /**
+     * Read the bundles / start levels section
+     * @param map The map describing the feature
+     * @param container The bundles container
+     * @param configContainer The configurations container
+     * @throws IOException If the json is invalid.
+     */
+    protected void readBundles(
+            final Map<String, Object> map,
+            final Bundles container,
+            final Configurations configContainer) throws IOException {
+        if ( map.containsKey(JSONConstants.FEATURE_BUNDLES)) {
+            final Object bundlesObj = map.get(JSONConstants.FEATURE_BUNDLES);
+            checkType(JSONConstants.FEATURE_BUNDLES, bundlesObj, List.class);
+
+            final List<Artifact> list = new ArrayList<>();
+            readArtifacts(JSONConstants.FEATURE_BUNDLES, "bundle", list, bundlesObj, configContainer);
+
+            for(final Artifact a : list) {
+                Artifact sameFound = container.getSame(a.getId());
+                if ( sameFound != null) {
+                    String str1 = a.getMetadata().get("run-modes");
+                    String str2 = sameFound.getMetadata().get("run-modes");
+
+                    if (str1 == null ? str2 == null : str1.equals(str2)) {
+                        throw new IOException(exceptionPrefix + "Duplicate bundle " + a.getId().toMvnId());
+                    }
+                }
+                try {
+                    // check start order
+                    a.getStartOrder();
+                } catch ( final IllegalArgumentException nfe) {
+                    throw new IOException(exceptionPrefix + "Illegal start order '" + a.getMetadata().get(Artifact.KEY_START_ORDER) + "'");
+                }
+                container.add(a);
+            }
+        }
+    }
+
+    protected void readArtifacts(final String section,
+            final String artifactType,
+            final List<Artifact> artifacts,
+            final Object listObj,
+            final Configurations container)
+    throws IOException {
+        checkType(section, listObj, List.class);
+        @SuppressWarnings("unchecked")
+        final List<Object> list = (List<Object>) listObj;
+        for(final Object entry : list) {
+            final Artifact artifact;
+            checkType(artifactType, entry, Map.class, String.class);
+            if ( entry instanceof String ) {
+                artifact = new Artifact(ArtifactId.parse(handleResolveVars(entry).toString()));
+            } else {
+                @SuppressWarnings("unchecked")
+                final Map<String, Object> bundleObj = (Map<String, Object>) entry;
+                if ( !bundleObj.containsKey(JSONConstants.ARTIFACT_ID) ) {
+                    throw new IOException(exceptionPrefix + " " + artifactType + " is missing required artifact id");
+                }
+                checkType(artifactType + " " + JSONConstants.ARTIFACT_ID, bundleObj.get(JSONConstants.ARTIFACT_ID), String.class);
+                final ArtifactId id = ArtifactId.parse(handleResolveVars(bundleObj.get(JSONConstants.ARTIFACT_ID)).toString());
+
+                artifact = new Artifact(id);
+                for(final Map.Entry<String, Object> metadataEntry : bundleObj.entrySet()) {
+                    final String key = metadataEntry.getKey();
+                    if ( JSONConstants.ARTIFACT_KNOWN_PROPERTIES.contains(key) ) {
+                        continue;
+                    }
+                    checkType(artifactType + " metadata " + key, metadataEntry.getValue(), String.class, Number.class, Boolean.class);
+                    artifact.getMetadata().put(key, metadataEntry.getValue().toString());
+                }
+                if ( bundleObj.containsKey(JSONConstants.FEATURE_CONFIGURATIONS) ) {
+                    checkType(artifactType + " configurations", bundleObj.get(JSONConstants.FEATURE_CONFIGURATIONS), Map.class);
+                    List<Configuration> bundleConfigs = addConfigurations(bundleObj, artifact, container);
+                    artifact.getMetadata().put(JSONConstants.FEATURE_CONFIGURATIONS, bundleConfigs);
+                }
+            }
+            artifacts.add(artifact);
+        }
+    }
+
+    /** Substitutes variables that need to be specified before the resolver executes.
+     * These are variables in features, artifacts (such as bundles), requirements
+     * and capabilities.
+     * @param val The value that may contain a variable.
+     * @return The value with the variable substitiuted.
+     */
+    protected Object handleResolveVars(Object val) {
+        // No variable substitution at this level, but subclasses can add this in
+        return val;
+    }
+
+    /** Substitutes variables that need to be substituted at launch time.
+     * These are all variables that are not needed by the resolver.
+     * @param val The value that may contain a variable.
+     * @return The value with the variable substitiuted.
+     */
+    protected Object handleLaunchVars(Object val) {
+        // No variable substitution at this level, but subclasses can add this in
+        return val;
+    }
+
+    protected List<Configuration> addConfigurations(final Map<String, Object> map,
+            final Artifact artifact,
+            final Configurations container) throws IOException {
+        final JSONUtil.Report report = new JSONUtil.Report();
+        @SuppressWarnings("unchecked")
+        final List<Config> configs = JSONUtil.readConfigurationsJSON(new TypeConverter(null),
+                0, "", (Map<String, ?>)map.get(JSONConstants.FEATURE_CONFIGURATIONS), report);
+        if ( !report.errors.isEmpty() || !report.warnings.isEmpty() ) {
+            final StringBuilder builder = new StringBuilder(exceptionPrefix);
+            builder.append("Errors in configurations:");
+            for(final String w : report.warnings) {
+                builder.append("\n");
+                builder.append(w);
+            }
+            for(final String e : report.errors) {
+                builder.append("\n");
+                builder.append(e);
+            }
+            throw new IOException(builder.toString());
+        }
+
+        List<Configuration> newConfigs = new ArrayList<>();
+        for(final Config c : configs) {
+            final int pos = c.getPid().indexOf('~');
+            final Configuration config;
+            if ( pos != -1 ) {
+                config = new Configuration(c.getPid().substring(0, pos), c.getPid().substring(pos + 1));
+            } else {
+                config = new Configuration(c.getPid());
+            }
+            final Enumeration<String> keyEnum = c.getProperties().keys();
+            while ( keyEnum.hasMoreElements() ) {
+                final String key = keyEnum.nextElement();
+                if ( key.startsWith(":configurator:") ) {
+                    throw new IOException(exceptionPrefix + "Configuration must not define configurator property " + key);
+                }
+                final Object val = c.getProperties().get(key);
+                config.getProperties().put(key, handleLaunchVars(val));
+            }
+            if ( config.getProperties().get(Configuration.PROP_ARTIFACT) != null ) {
+                throw new IOException(exceptionPrefix + "Configuration must not define property " + Configuration.PROP_ARTIFACT);
+            }
+            if ( artifact != null ) {
+                config.getProperties().put(Configuration.PROP_ARTIFACT, artifact.getId().toMvnId());
+            }
+            for(final Configuration current : container) {
+                if ( current.equals(config) ) {
+                    throw new IOException(exceptionPrefix + "Duplicate configuration " + config);
+                }
+            }
+            container.add(config);
+            newConfigs.add(config);
+        }
+        return newConfigs;
+    }
+
+
+    protected void readConfigurations(final Map<String, Object> map,
+            final Configurations container) throws IOException {
+        if ( map.containsKey(JSONConstants.FEATURE_CONFIGURATIONS) ) {
+            checkType(JSONConstants.FEATURE_CONFIGURATIONS, map.get(JSONConstants.FEATURE_CONFIGURATIONS), Map.class);
+            addConfigurations(map, null, container);
+        }
+    }
+
+    protected void readFrameworkProperties(final Map<String, Object> map,
+            final KeyValueMap container) throws IOException {
+        if ( map.containsKey(JSONConstants.FEATURE_FRAMEWORK_PROPERTIES) ) {
+            final Object propsObj= map.get(JSONConstants.FEATURE_FRAMEWORK_PROPERTIES);
+            checkType(JSONConstants.FEATURE_FRAMEWORK_PROPERTIES, propsObj, Map.class);
+
+            @SuppressWarnings("unchecked")
+            final Map<String, Object> props = (Map<String, Object>) propsObj;
+            for(final Map.Entry<String, Object> entry : props.entrySet()) {
+                checkType("framework property value", entry.getValue(), String.class, Boolean.class, Number.class);
+                if ( container.get(entry.getKey()) != null ) {
+                    throw new IOException(this.exceptionPrefix + "Duplicate framework property " + entry.getKey());
+                }
+                container.put(entry.getKey(), handleLaunchVars(entry.getValue()).toString());
+            }
+
+        }
+    }
+
+    protected void readExtensions(final Map<String, Object> map,
+            final List<String> keywords,
+            final Extensions container,
+            final Configurations configContainer) throws IOException {
+        final Set<String> keySet = new HashSet<>(map.keySet());
+        keySet.removeAll(keywords);
+        // the remaining keys are considered extensions!
+        for(final String key : keySet) {
+            final int pos = key.indexOf(':');
+            final String postfix = pos == -1 ? null : key.substring(pos + 1);
+            final int sep = (postfix == null ? key.indexOf('|') : postfix.indexOf('|'));
+            final String name;
+            final String type;
+            final String optional;
+            if ( pos == -1 ) {
+                type = ExtensionType.ARTIFACTS.name();
+                if ( sep == -1 ) {
+                    name = key;
+                    optional = Boolean.FALSE.toString();
+                } else {
+                    name = key.substring(0, sep);
+                    optional = key.substring(sep + 1);
+                }
+            } else {
+                name = key.substring(0, pos);
+                if ( sep == -1 ) {
+                    type = postfix;
+                    optional = Boolean.FALSE.toString();
+                } else {
+                    type = postfix.substring(0, sep);
+                    optional = postfix.substring(sep + 1);
+                }
+            }
+            if ( JSONConstants.APP_KNOWN_PROPERTIES.contains(name) ) {
+                throw new IOException(this.exceptionPrefix + "Extension is using reserved name : " + name);
+            }
+            if ( JSONConstants.FEATURE_KNOWN_PROPERTIES.contains(name) ) {
+                throw new IOException(this.exceptionPrefix + "Extension is using reserved name : " + name);
+            }
+            if ( container.getByName(name) != null ) {
+                throw new IOException(exceptionPrefix + "Duplicate extension with name " + name);
+            }
+
+            final ExtensionType extType = ExtensionType.valueOf(type);
+            final boolean opt = Boolean.valueOf(optional).booleanValue();
+
+            final Extension ext = new Extension(extType, name, opt);
+            final Object value = map.get(key);
+            switch ( extType ) {
+                case ARTIFACTS : final List<Artifact> list = new ArrayList<>();
+                                 readArtifacts("Extension " + name, "artifact", list, value, configContainer);
+                                 for(final Artifact a : list) {
+                                     if ( ext.getArtifacts().contains(a) ) {
+                                         throw new IOException(exceptionPrefix + "Duplicate artifact in extension " + name + " : " + a.getId().toMvnId());
+                                     }
+                                     ext.getArtifacts().add(a);
+                                 }
+                                 break;
+                case JSON : checkType("JSON Extension " + name, value, Map.class, List.class);
+                            final JsonStructure struct = build(value);
+                            try ( final StringWriter w = new StringWriter()) {
+                                final JsonWriter jw = Json.createWriter(w);
+                                jw.write(struct);
+                                w.flush();
+                                ext.setJSON(w.toString());
+                            }
+                            break;
+                case TEXT : checkType("Text Extension " + name, value, String.class, List.class);
+                            if ( value instanceof String ) {
+                                // string
+                                ext.setText(value.toString());
+                            } else {
+                                // list (array of strings)
+                                @SuppressWarnings("unchecked")
+                                final List<Object> l = (List<Object>)value;
+                                final StringBuilder sb = new StringBuilder();
+                                for(final Object o : l) {
+                                    checkType("Text Extension " + name + ", value " + o, o, String.class);
+                                    sb.append(o.toString());
+                                    sb.append('\n');
+                                }
+                                ext.setText(sb.toString());
+                            }
+                            break;
+            }
+
+            container.add(ext);
+        }
+    }
+
+    private JsonStructure build(final Object value) {
+        if ( value instanceof List ) {
+            @SuppressWarnings("unchecked")
+            final List<Object> list = (List<Object>)value;
+            final JsonArrayBuilder builder = Json.createArrayBuilder();
+            for(final Object obj : list) {
+                if ( obj instanceof String ) {
+                    builder.add(obj.toString());
+                } else if ( obj instanceof Long ) {
+                    builder.add((Long)obj);
+                } else if ( obj instanceof Double ) {
+                    builder.add((Double)obj);
+                } else if (obj instanceof Boolean ) {
+                    builder.add((Boolean)obj);
+                } else if ( obj instanceof Map ) {
+                    builder.add(build(obj));
+                } else if ( obj instanceof List ) {
+                    builder.add(build(obj));
+                }
+
+            }
+            return builder.build();
+        } else if ( value instanceof Map ) {
+            @SuppressWarnings("unchecked")
+            final Map<String, Object> map = (Map<String, Object>)value;
+            final JsonObjectBuilder builder = Json.createObjectBuilder();
+            for(final Map.Entry<String, Object> entry : map.entrySet()) {
+                if ( entry.getValue() instanceof String ) {
+                    builder.add(entry.getKey(), entry.getValue().toString());
+                } else if ( entry.getValue() instanceof Long ) {
+                    builder.add(entry.getKey(), (Long)entry.getValue());
+                } else if ( entry.getValue() instanceof Double ) {
+                    builder.add(entry.getKey(), (Double)entry.getValue());
+                } else if ( entry.getValue() instanceof Boolean ) {
+                    builder.add(entry.getKey(), (Boolean)entry.getValue());
+                } else if ( entry.getValue() instanceof Map ) {
+                    builder.add(entry.getKey(), build(entry.getValue()));
+                } else if ( entry.getValue() instanceof List ) {
+                    builder.add(entry.getKey(), build(entry.getValue()));
+                }
+            }
+            return builder.build();
+        }
+        return null;
+    }
+
+    /**
+     * Check if the value is one of the provided types
+     * @param key A key for the error message
+     * @param val The value to check
+     * @param types The allowed types
+     * @throws IOException If the val is not of the specified types
+     */
+    protected void checkType(final String key, final Object val, Class<?>...types) throws IOException {
+        boolean valid = false;
+        for(final Class<?> c : types) {
+            if ( c.isInstance(val) ) {
+                valid = true;
+                break;
+            }
+        }
+        if ( !valid ) {
+            throw new IOException(this.exceptionPrefix + "Key " + key + " is not one of the allowed types " + Arrays.toString(types) + " : " + val.getClass());
+        }
+    }
+}
+
+
diff --git a/src/main/java/org/apache/sling/feature/io/json/JSONWriterBase.java b/src/main/java/org/apache/sling/feature/io/json/JSONWriterBase.java
new file mode 100644
index 0000000..ff502af
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/json/JSONWriterBase.java
@@ -0,0 +1,259 @@
+/*
+ * 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.sling.feature.io.json;
+
+import org.apache.sling.feature.Artifact;
+import org.apache.sling.feature.Bundles;
+import org.apache.sling.feature.Configuration;
+import org.apache.sling.feature.Configurations;
+import org.apache.sling.feature.Extension;
+import org.apache.sling.feature.ExtensionType;
+import org.apache.sling.feature.KeyValueMap;
+
+import java.io.StringReader;
+import java.lang.reflect.Array;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Map;
+
+import javax.json.Json;
+import javax.json.JsonArrayBuilder;
+import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
+import javax.json.JsonStructure;
+
+/**
+ * Common functionality for writing JSON
+ */
+abstract class JSONWriterBase {
+    protected void writeBundles(final JsonObjectBuilder ob,
+            final Bundles bundles,
+            final Configurations allConfigs) {
+        // bundles
+        if ( !bundles.isEmpty() ) {
+            JsonArrayBuilder bundleArray = Json.createArrayBuilder();
+
+            for(final Artifact artifact : bundles) {
+                final Configurations cfgs = new Configurations();
+                for(final Configuration cfg : allConfigs) {
+                    String artifactProp = (String)cfg.getProperties().get(Configuration.PROP_ARTIFACT);
+                    if (artifactProp != null) {
+                        if (artifactProp.startsWith("mvn:")) {
+                            // Change Maven URL to maven GAV syntax
+                            artifactProp = artifactProp.substring("mvn:".length());
+                            artifactProp = artifactProp.replace('/', ':');
+                        }
+                        if (artifact.getId().toMvnId().equals(artifactProp)) {
+                            cfgs.add(cfg);
+                        }
+                    }
+                }
+                KeyValueMap md = artifact.getMetadata();
+                if ( md.isEmpty() && cfgs.isEmpty() ) {
+                    bundleArray.add(artifact.getId().toMvnId());
+                } else {
+                    JsonObjectBuilder bundleObj = Json.createObjectBuilder();
+                    bundleObj.add(JSONConstants.ARTIFACT_ID, artifact.getId().toMvnId());
+
+                    if (md.get("start-level") == null) {
+                        String so = md.get("start-order");
+                        if (so != null) {
+                            md.put("start-level", so);
+                        }
+                    }
+
+                    Object runmodes = md.remove("runmodes");
+                    if (runmodes instanceof String) {
+                        md.put("run-modes", runmodes);
+                    }
+
+                    for(final Map.Entry<String, String> me : md) {
+                        bundleObj.add(me.getKey(), me.getValue());
+                    }
+
+                    writeConfigurations(bundleObj, cfgs);
+
+                    bundleArray.add(bundleObj.build());
+                }
+            }
+            ob.add(JSONConstants.FEATURE_BUNDLES, bundleArray.build());
+        }
+    }
+
+    /**
+     * Write the list of configurations into a "configurations" element
+     * @param ob The json generator
+     * @param cfgs The list of configurations
+     */
+    protected void writeConfigurations(final JsonObjectBuilder ob, final Configurations cfgs) {
+        if ( !cfgs.isEmpty() ) {
+            ob.add(JSONConstants.FEATURE_CONFIGURATIONS,
+                    writeConfigurationsMap(cfgs));
+        }
+    }
+
+    /**
+     * Write the list of configurations into a "configurations" element
+     * @param w The json generator
+     * @param cfgs The list of configurations
+     * @return
+     */
+    protected JsonObject writeConfigurationsMap(final Configurations cfgs) {
+        JsonObjectBuilder configObj = Json.createObjectBuilder();
+        for(final Configuration cfg : cfgs) {
+            final String key;
+            if ( cfg.isFactoryConfiguration() ) {
+                key = cfg.getFactoryPid() + "~" + cfg.getName();
+            } else {
+                key = cfg.getPid();
+            }
+            JsonObjectBuilder cfgValObj = Json.createObjectBuilder();
+
+            final Enumeration<String> e = cfg.getProperties().keys();
+            while ( e.hasMoreElements() ) {
+                final String name = e.nextElement();
+                if ( Configuration.PROP_ARTIFACT.equals(name) ) {
+                    continue;
+                }
+
+                final Object val = cfg.getProperties().get(name);
+
+                String typePostFix = null;
+                final Object typeCheck;
+                if ( val.getClass().isArray() ) {
+                    if ( Array.getLength(val) > 0 ) {
+                        typeCheck = Array.get(val, 0);
+                    } else {
+                        typeCheck = null;
+                    }
+                } else {
+                    typeCheck = val;
+                }
+
+                if ( typeCheck instanceof Integer ) {
+                    typePostFix = ":Integer";
+                } else if ( typeCheck instanceof Byte ) {
+                    typePostFix = ":Byte";
+                } else if ( typeCheck instanceof Character ) {
+                    typePostFix = ":Character";
+                } else if ( typeCheck instanceof Float ) {
+                    typePostFix = ":Float";
+                }
+
+                if ( val.getClass().isArray() ) {
+                    JsonArrayBuilder ab = Json.createArrayBuilder();
+                    for(int i=0; i<Array.getLength(val);i++ ) {
+                        final Object obj = Array.get(val, i);
+                        if ( typePostFix == null ) {
+                            if ( obj instanceof String ) {
+                                ab.add((String)obj);
+                            } else if ( obj instanceof Boolean ) {
+                                ab.add((Boolean)obj);
+                            } else if ( obj instanceof Long ) {
+                                ab.add((Long)obj);
+                            } else if ( obj instanceof Double ) {
+                                ab.add((Double)obj);
+                            }
+                        } else {
+                            ab.add(obj.toString());
+                        }
+                    }
+                    cfgValObj.add(name, ab.build());
+                } else {
+                    if ( typePostFix == null ) {
+                        if ( val instanceof String ) {
+                            cfgValObj.add(name, (String)val);
+                        } else if ( val instanceof Boolean ) {
+                            cfgValObj.add(name, (Boolean)val);
+                        } else if ( val instanceof Long ) {
+                            cfgValObj.add(name, (Long)val);
+                        } else if ( val instanceof Double ) {
+                            cfgValObj.add(name, (Double)val);
+                        }
+                    } else {
+                        cfgValObj.add(name + typePostFix, val.toString());
+                    }
+                }
+            }
+            configObj.add(key, cfgValObj.build());
+        }
+        return configObj.build();
+    }
+
+    protected void writeVariables(final JsonObjectBuilder ob, final KeyValueMap vars) {
+        if ( !vars.isEmpty()) {
+            JsonObjectBuilder varsObj = Json.createObjectBuilder();
+            for (final Map.Entry<String, String> entry : vars) {
+                varsObj.add(entry.getKey(), entry.getValue());
+            }
+            ob.add(JSONConstants.FEATURE_VARIABLES, varsObj.build());
+        }
+    }
+
+    protected void writeFrameworkProperties(final JsonObjectBuilder ob, final KeyValueMap props) {
+        // framework properties
+        if ( !props.isEmpty() ) {
+            JsonObjectBuilder propsObj = Json.createObjectBuilder();
+            for(final Map.Entry<String, String> entry : props) {
+                propsObj.add(entry.getKey(), entry.getValue());
+            }
+            ob.add(JSONConstants.FEATURE_FRAMEWORK_PROPERTIES, propsObj.build());
+        }
+    }
+
+    protected void writeExtensions(final JsonObjectBuilder ob,
+            final List<Extension> extensions,
+            final Configurations allConfigs) {
+        for(final Extension ext : extensions) {
+            final String key = ext.getName() + ":" + ext.getType().name() + "|" + ext.isOptional();
+            if ( ext.getType() == ExtensionType.JSON ) {
+                final JsonStructure struct;
+                try ( final StringReader reader = new StringReader(ext.getJSON()) ) {
+                    struct = Json.createReader(reader).read();
+                }
+                ob.add(key, struct);
+            } else if ( ext.getType() == ExtensionType.TEXT ) {
+                ob.add(key, ext.getText());
+            } else {
+                JsonArrayBuilder extensionArr = Json.createArrayBuilder();
+                for(final Artifact artifact : ext.getArtifacts()) {
+                    final Configurations artifactCfgs = new Configurations();
+                    for(final Configuration cfg : allConfigs) {
+                        final String artifactProp = (String)cfg.getProperties().get(Configuration.PROP_ARTIFACT);
+                        if (  artifact.getId().toMvnId().equals(artifactProp) ) {
+                            artifactCfgs.add(cfg);
+                        }
+                    }
+                    if ( artifact.getMetadata().isEmpty() && artifactCfgs.isEmpty() ) {
+                        extensionArr.add(artifact.getId().toMvnId());
+                    } else {
+                        JsonObjectBuilder extObj = Json.createObjectBuilder();
+                        extObj.add(JSONConstants.ARTIFACT_ID, artifact.getId().toMvnId());
+
+                        for(final Map.Entry<String, String> me : artifact.getMetadata()) {
+                            extObj.add(me.getKey(), me.getValue());
+                        }
+
+                        writeConfigurations(ob, artifactCfgs);
+                        extensionArr.add(extObj.build());
+                    }
+                }
+                ob.add(key, extensionArr.build());
+            }
+        }
+    }
+}
diff --git a/src/main/java/org/apache/sling/feature/io/json/ManifestUtils.java b/src/main/java/org/apache/sling/feature/io/json/ManifestUtils.java
new file mode 100644
index 0000000..ee6585f
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/json/ManifestUtils.java
@@ -0,0 +1,515 @@
+/*
+ * 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.sling.feature.io.json;
+
+import org.apache.felix.utils.resource.CapabilityImpl;
+import org.osgi.framework.BundleException;
+import org.osgi.framework.Version;
+import org.osgi.resource.Capability;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+// This class can be picked up from Felix Utils once it has been moved there. At that point
+// this class can be removed.
+class ManifestUtils {
+    public static void unmarshalAttribute(String key, Object value, BiConsumer<String, Object> sink) throws IOException {
+        unmarshal(key + "=" + value, Capability::getAttributes, sink);
+    }
+
+    public static void unmarshalDirective(String key, Object value, BiConsumer<String, String> sink) throws IOException {
+        unmarshal(key + ":=" + value, Capability::getDirectives, sink);
+    }
+
+    private static <T> void unmarshal(String header, Function<Capability, Map<String, T>> lookup, BiConsumer<String, T> sink) throws IOException {
+        try {
+            convertProvideCapabilities(
+                    normalizeCapabilityClauses(parseStandardHeader("foo;" + header), "2"))
+                    .forEach(capability -> lookup.apply(capability).forEach(sink));
+        } catch (Exception e) {
+            throw new IOException(e);
+        }
+    }
+
+    public static void marshalAttribute(String key, Object value, BiConsumer<String, String> sink) {
+        marshal(key, value, sink);
+    }
+
+    public static void marshalDirective(String key, Object value, BiConsumer<String, String> sink) {
+        marshal(key, value, sink);
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    private static void marshal(String key, Object value, BiConsumer<String, String> sink) {
+        StringBuilder keyBuilder = new StringBuilder(key);
+        if (value instanceof  List) {
+            List list = (List) value;
+            keyBuilder.append(":List");
+            if (!list.isEmpty()) {
+                String type = type(list.get(0));
+                if (!type.equals("String")) {
+                    keyBuilder.append('<').append(type).append('>');
+                }
+                value = list.stream().map(
+                        v -> v.toString().replace(",", "\\,")
+                ).collect(Collectors.joining(","));
+            }
+            else {
+                value = "";
+            }
+        }
+        else {
+            String type = type(value);
+            if (!type.equals("String")) {
+                keyBuilder.append(':').append(type);
+            }
+        }
+        sink.accept(keyBuilder.toString(), value.toString());
+    }
+
+    private static String type(Object value) {
+        if (value instanceof Long) {
+            return "Long";
+        }
+        else if (value instanceof Double)
+        {
+            return "Double";
+        }
+        else if (value instanceof Version)
+        {
+            return "Version";
+        }
+        else
+        {
+            return "String";
+        }
+    }
+
+    public static List<Capability> convertProvideCapabilities(
+            List<ParsedHeaderClause> clauses)
+            throws BundleException
+    {
+        List<Capability> capList = new ArrayList<>();
+        for (ParsedHeaderClause clause : clauses)
+        {
+            for (String path : clause.m_paths)
+            {
+                if (path.startsWith("osgi.wiring."))
+                {
+                    throw new BundleException("Manifest cannot use Provide-Capability for '"
+                            + path
+                            + "' namespace.");
+                }
+
+                Capability capability = new CapabilityImpl(null, path, clause.m_dirs, clause.m_attrs);
+                // Create package capability and add to capability list.
+                capList.add(capability);
+            }
+        }
+
+        return capList;
+    }
+
+    public static List<ParsedHeaderClause> normalizeCapabilityClauses(
+            List<ParsedHeaderClause> clauses, String mv)
+            throws BundleException
+    {
+
+        if (!mv.equals("2") && !clauses.isEmpty())
+        {
+            // Should we error here if we are not an R4 bundle?
+        }
+
+        // Convert attributes into specified types.
+        for (ParsedHeaderClause clause : clauses)
+        {
+            for (Entry<String, String> entry : clause.m_types.entrySet())
+            {
+                String type = entry.getValue();
+                if (!type.equals("String"))
+                {
+                    if (type.equals("Double"))
+                    {
+                        clause.m_attrs.put(
+                                entry.getKey(),
+                                new Double(clause.m_attrs.get(entry.getKey()).toString().trim()));
+                    }
+                    else if (type.equals("Version"))
+                    {
+                        clause.m_attrs.put(
+                                entry.getKey(),
+                                new Version(clause.m_attrs.get(entry.getKey()).toString().trim()));
+                    }
+                    else if (type.equals("Long"))
+                    {
+                        clause.m_attrs.put(
+                                entry.getKey(),
+                                new Long(clause.m_attrs.get(entry.getKey()).toString().trim()));
+                    }
+                    else if (type.startsWith("List"))
+                    {
+                        int startIdx = type.indexOf('<');
+                        int endIdx = type.indexOf('>');
+                        if (((startIdx > 0) && (endIdx <= startIdx))
+                                || ((startIdx < 0) && (endIdx > 0)))
+                        {
+                            throw new BundleException(
+                                    "Invalid Provide-Capability attribute list type for '"
+                                            + entry.getKey()
+                                            + "' : "
+                                            + type);
+                        }
+
+                        String listType = "String";
+                        if (endIdx > startIdx)
+                        {
+                            listType = type.substring(startIdx + 1, endIdx).trim();
+                        }
+
+                        List<String> tokens = parseDelimitedString(
+                                clause.m_attrs.get(entry.getKey()).toString(), ",", false);
+                        List<Object> values = new ArrayList<>(tokens.size());
+                        for (String token : tokens)
+                        {
+                            if (listType.equals("String"))
+                            {
+                                values.add(token);
+                            }
+                            else if (listType.equals("Double"))
+                            {
+                                values.add(new Double(token.trim()));
+                            }
+                            else if (listType.equals("Version"))
+                            {
+                                values.add(new Version(token.trim()));
+                            }
+                            else if (listType.equals("Long"))
+                            {
+                                values.add(new Long(token.trim()));
+                            }
+                            else
+                            {
+                                throw new BundleException(
+                                        "Unknown Provide-Capability attribute list type for '"
+                                                + entry.getKey()
+                                                + "' : "
+                                                + type);
+                            }
+                        }
+                        clause.m_attrs.put(
+                                entry.getKey(),
+                                values);
+                    }
+                    else
+                    {
+                        throw new BundleException(
+                                "Unknown Provide-Capability attribute type for '"
+                                        + entry.getKey()
+                                        + "' : "
+                                        + type);
+                    }
+                }
+            }
+        }
+
+        return clauses;
+    }
+
+    private static final char EOF = (char) -1;
+
+    private static char charAt(int pos, String headers, int length)
+    {
+        if (pos >= length)
+        {
+            return EOF;
+        }
+        return headers.charAt(pos);
+    }
+
+    private static final int CLAUSE_START = 0;
+    private static final int PARAMETER_START = 1;
+    private static final int KEY = 2;
+    private static final int DIRECTIVE_OR_TYPEDATTRIBUTE = 4;
+    private static final int ARGUMENT = 8;
+    private static final int VALUE = 16;
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    public static List<ParsedHeaderClause> parseStandardHeader(String header)
+    {
+        List<ParsedHeaderClause> clauses = new ArrayList<>();
+        if (header == null)
+        {
+            return clauses;
+        }
+        ParsedHeaderClause clause = null;
+        String key = null;
+        Map targetMap = null;
+        int state = CLAUSE_START;
+        int currentPosition = 0;
+        int startPosition = 0;
+        int length = header.length();
+        boolean quoted = false;
+        boolean escaped = false;
+
+        char currentChar = EOF;
+        do
+        {
+            currentChar = charAt(currentPosition, header, length);
+            switch (state)
+            {
+                case CLAUSE_START:
+                    clause = new ParsedHeaderClause(
+                            new ArrayList<>(),
+                            new HashMap<>(),
+                            new HashMap<>(),
+                            new HashMap<>());
+                    clauses.add(clause);
+                    state = PARAMETER_START;
+                case PARAMETER_START:
+                    startPosition = currentPosition;
+                    state = KEY;
+                case KEY:
+                    switch (currentChar)
+                    {
+                        case ':':
+                        case '=':
+                            key = header.substring(startPosition, currentPosition).trim();
+                            startPosition = currentPosition + 1;
+                            targetMap = clause.m_attrs;
+                            state = currentChar == ':' ? DIRECTIVE_OR_TYPEDATTRIBUTE : ARGUMENT;
+                            break;
+                        case EOF:
+                        case ',':
+                        case ';':
+                            clause.m_paths.add(header.substring(startPosition, currentPosition).trim());
+                            state = currentChar == ',' ? CLAUSE_START : PARAMETER_START;
+                            break;
+                        default:
+                            break;
+                    }
+                    currentPosition++;
+                    break;
+                case DIRECTIVE_OR_TYPEDATTRIBUTE:
+                    switch(currentChar)
+                    {
+                        case '=':
+                            if (startPosition != currentPosition)
+                            {
+                                clause.m_types.put(key, header.substring(startPosition, currentPosition).trim());
+                            }
+                            else
+                            {
+                                targetMap = clause.m_dirs;
+                            }
+                            state = ARGUMENT;
+                            startPosition = currentPosition + 1;
+                            break;
+                        default:
+                            break;
+                    }
+                    currentPosition++;
+                    break;
+                case ARGUMENT:
+                    if (currentChar == '\"')
+                    {
+                        quoted = true;
+                        currentPosition++;
+                    }
+                    else
+                    {
+                        quoted = false;
+                    }
+                    if (!Character.isWhitespace(currentChar)) {
+                        state = VALUE;
+                    }
+                    else {
+                        currentPosition++;
+                    }
+                    break;
+                case VALUE:
+                    if (escaped)
+                    {
+                        escaped = false;
+                    }
+                    else
+                    {
+                        if (currentChar == '\\' )
+                        {
+                            escaped = true;
+                        }
+                        else if (quoted && currentChar == '\"')
+                        {
+                            quoted = false;
+                        }
+                        else if (!quoted)
+                        {
+                            String value = null;
+                            switch(currentChar)
+                            {
+                                case EOF:
+                                case ';':
+                                case ',':
+                                    value = header.substring(startPosition, currentPosition).trim();
+                                    if (value.startsWith("\"") && value.endsWith("\""))
+                                    {
+                                        value = value.substring(1, value.length() - 1);
+                                    }
+                                    if (targetMap.put(key, value) != null)
+                                    {
+                                        throw new IllegalArgumentException(
+                                                "Duplicate '" + key + "' in: " + header);
+                                    }
+                                    state = currentChar == ';' ? PARAMETER_START : CLAUSE_START;
+                                    break;
+                                default:
+                                    break;
+                            }
+                        }
+                    }
+                    currentPosition++;
+                    break;
+                default:
+                    break;
+            }
+        } while ( currentChar != EOF);
+
+        if (state > PARAMETER_START)
+        {
+            throw new IllegalArgumentException("Unable to parse header: " + header);
+        }
+        return clauses;
+    }
+
+    /**
+     * Parses delimited string and returns an array containing the tokens. This
+     * parser obeys quotes, so the delimiter character will be ignored if it is
+     * inside of a quote. This method assumes that the quote character is not
+     * included in the set of delimiter characters.
+     * @param value the delimited string to parse.
+     * @param delim the characters delimiting the tokens.
+     * @return a list of string or an empty list if there are none.
+     **/
+    public static List<String> parseDelimitedString(String value, String delim, boolean trim)
+    {
+        if (value == null)
+        {
+            value = "";
+        }
+
+        List<String> list = new ArrayList<>();
+
+        int CHAR = 1;
+        int DELIMITER = 2;
+        int STARTQUOTE = 4;
+        int ENDQUOTE = 8;
+
+        StringBuffer sb = new StringBuffer();
+
+        int expecting = (CHAR | DELIMITER | STARTQUOTE);
+
+        boolean isEscaped = false;
+        for (int i = 0; i < value.length(); i++)
+        {
+            char c = value.charAt(i);
+
+            boolean isDelimiter = (delim.indexOf(c) >= 0);
+
+            if (!isEscaped && (c == '\\'))
+            {
+                isEscaped = true;
+                continue;
+            }
+
+            if (isEscaped)
+            {
+                sb.append(c);
+            }
+            else if (isDelimiter && ((expecting & DELIMITER) > 0))
+            {
+                if (trim)
+                {
+                    list.add(sb.toString().trim());
+                }
+                else
+                {
+                    list.add(sb.toString());
+                }
+                sb.delete(0, sb.length());
+                expecting = (CHAR | DELIMITER | STARTQUOTE);
+            }
+            else if ((c == '"') && ((expecting & STARTQUOTE) > 0))
+            {
+                sb.append(c);
+                expecting = CHAR | ENDQUOTE;
+            }
+            else if ((c == '"') && ((expecting & ENDQUOTE) > 0))
+            {
+                sb.append(c);
+                expecting = (CHAR | STARTQUOTE | DELIMITER);
+            }
+            else if ((expecting & CHAR) > 0)
+            {
+                sb.append(c);
+            }
+            else
+            {
+                throw new IllegalArgumentException("Invalid delimited string: " + value);
+            }
+
+            isEscaped = false;
+        }
+
+        if (sb.length() > 0)
+        {
+            if (trim)
+            {
+                list.add(sb.toString().trim());
+            }
+            else
+            {
+                list.add(sb.toString());
+            }
+        }
+
+        return list;
+    }
+
+    static class ParsedHeaderClause
+    {
+        public final List<String> m_paths;
+        public final Map<String, String> m_dirs;
+        public final Map<String, Object> m_attrs;
+        public final Map<String, String> m_types;
+
+        public ParsedHeaderClause(
+                List<String> paths, Map<String, String> dirs, Map<String, Object> attrs,
+                Map<String, String> types)
+        {
+            m_paths = paths;
+            m_dirs = dirs;
+            m_attrs = attrs;
+            m_types = types;
+        }
+    }
+}
diff --git a/src/main/java/org/apache/sling/feature/io/json/package-info.java b/src/main/java/org/apache/sling/feature/io/json/package-info.java
new file mode 100644
index 0000000..6b84931
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/json/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+@org.osgi.annotation.versioning.Version("1.0.0")
+package org.apache.sling.feature.io.json;
+
+
diff --git a/src/main/java/org/apache/sling/feature/io/package-info.java b/src/main/java/org/apache/sling/feature/io/package-info.java
new file mode 100644
index 0000000..25e2f46
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+@org.osgi.annotation.versioning.Version("1.0.0")
+package org.apache.sling.feature.io;
+
+
diff --git a/src/main/java/org/apache/sling/feature/io/spi/ArtifactProvider.java b/src/main/java/org/apache/sling/feature/io/spi/ArtifactProvider.java
new file mode 100644
index 0000000..5f250d7
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/spi/ArtifactProvider.java
@@ -0,0 +1,55 @@
+/*
+ * 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.sling.feature.io.spi;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * The artifact provider is an extension point for providing artifacts
+ * from different sources, like for example s3.
+ */
+public interface ArtifactProvider {
+
+    /**
+     * The protocol name of the provider, e.g. "s3"
+     * @return The protocol name.
+     */
+    String getProtocol();
+
+    /**
+     * Initialize the provider.
+     * @param context The context
+     * @throws IOException If the provider can't be initialized.
+     */
+    void init(ArtifactProviderContext context) throws IOException;
+
+    /**
+     * Shutdown the provider.
+     */
+    void shutdown();
+
+    /**
+     * Get a local file for the artifact URL.
+     *
+     * @param url Artifact url
+     * @param relativeCachePath A relative path that can be used as a cache path
+     *                          by the provider. The path does not start with a slash.
+     * @return A file if the artifact exists or {@code null}
+     */
+    File getArtifact(String url, String relativeCachePath);
+}
diff --git a/src/main/java/org/apache/sling/feature/io/spi/ArtifactProviderContext.java b/src/main/java/org/apache/sling/feature/io/spi/ArtifactProviderContext.java
new file mode 100644
index 0000000..c837548
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/spi/ArtifactProviderContext.java
@@ -0,0 +1,46 @@
+/*
+ * 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.sling.feature.io.spi;
+
+import java.io.File;
+
+/**
+ * This is the context for the artifact providers
+ */
+public interface ArtifactProviderContext {
+
+    /**
+     * Get the cache directory
+     * @return The cache directory.
+     */
+    File getCacheDirectory();
+
+    /**
+     * Inform about an artifact found in the cache.
+     */
+    void incCachedArtifacts();
+
+    /**
+     * Inform about an artifact being downloaded
+     */
+    void incDownloadedArtifacts();
+
+    /**
+     * Inform about an artifact found locally.
+     */
+    void incLocalArtifacts();
+}
diff --git a/src/main/java/org/apache/sling/feature/io/spi/package-info.java b/src/main/java/org/apache/sling/feature/io/spi/package-info.java
new file mode 100644
index 0000000..b69692b
--- /dev/null
+++ b/src/main/java/org/apache/sling/feature/io/spi/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+@org.osgi.annotation.versioning.Version("1.0.0")
+package org.apache.sling.feature.io.spi;
+
+
diff --git a/src/test/java/org/apache/sling/feature/io/ArtifactManagerTest.java b/src/test/java/org/apache/sling/feature/io/ArtifactManagerTest.java
new file mode 100644
index 0000000..f89c40b
--- /dev/null
+++ b/src/test/java/org/apache/sling/feature/io/ArtifactManagerTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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.sling.feature.io;
+
+import org.apache.sling.feature.io.ArtifactHandler;
+import org.apache.sling.feature.io.ArtifactManager;
+import org.apache.sling.feature.io.ArtifactManagerConfig;
+import org.apache.sling.feature.io.spi.ArtifactProvider;
+import org.junit.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class ArtifactManagerTest {
+
+    private static final String METADATA = "<metadata modelVersion=\"1.1.0\">\n" +
+            "<groupId>org.apache.sling.samples</groupId>\n" +
+            "<artifactId>slingshot</artifactId>\n" +
+            "<version>0-DEFAULT-SNAPSHOT</version>\n" +
+            "<versioning>\n" +
+                "<snapshot>\n" +
+                    "<timestamp>20160321.103951</timestamp>\n" +
+                    "<buildNumber>1</buildNumber>\n" +
+                "</snapshot>\n" +
+                "<lastUpdated>20160321103951</lastUpdated>\n" +
+                "<snapshotVersions>\n" +
+                    "<snapshotVersion>\n" +
+                        "<extension>txt</extension>\n" +
+                        "<value>0-DEFAULT-20160321.103951-1</value>\n" +
+                        "<updated>20160321103951</updated>\n" +
+                    "</snapshotVersion>\n" +
+                    "<snapshotVersion>\n" +
+                        "<extension>pom</extension>\n" +
+                        "<value>0-DEFAULT-20160321.103951-1</value>\n" +
+                        "<updated>20160321103951</updated>\n" +
+                    "</snapshotVersion>\n" +
+                "</snapshotVersions>\n" +
+            "</versioning></metadata>";
+
+    @Test public void testMetadataParsing() {
+        final String version = ArtifactManager.getLatestSnapshot(METADATA);
+        assertEquals("20160321.103951-1", version);
+    }
+
+    @Test public void testSnapshotHandling() throws IOException {
+        final String REPO = "http://org.apache.sling";
+        final ArtifactManagerConfig config = mock(ArtifactManagerConfig.class);
+        when(config.getRepositoryUrls()).thenReturn(new String[] {REPO});
+
+        final File metadataFile = mock(File.class);
+        when(metadataFile.exists()).thenReturn(true);
+        when(metadataFile.getPath()).thenReturn("/maven-metadata.xml");
+
+        final File artifactFile = mock(File.class);
+        when(artifactFile.exists()).thenReturn(true);
+
+        final ArtifactProvider provider = mock(ArtifactProvider.class);
+        when(provider.getArtifact(REPO + "/group/artifact/1.0.0-SNAPSHOT/artifact-1.0.0-SNAPSHOT.txt", "group/artifact/1.0.0-SNAPSHOT/artifact-1.0.0-SNAPSHOT.txt")).thenReturn(null);
+        when(provider.getArtifact(REPO + "/group/artifact/1.0.0-SNAPSHOT/maven-metadata.xml", "org.apache.sling/group/artifact/1.0.0-SNAPSHOT/maven-metadata.xml")).thenReturn(metadataFile);
+        when(provider.getArtifact(REPO + "/group/artifact/1.0.0-SNAPSHOT/artifact-1.0.0-20160321.103951-1.txt", "group/artifact/1.0.0-SNAPSHOT/artifact-1.0.0-SNAPSHOT.txt")).thenReturn(artifactFile);
+
+        final Map<String, ArtifactProvider> providers = new HashMap<>();
+        providers.put("*", provider);
+
+        final ArtifactManager mgr = new ArtifactManager(config, providers) {
+
+            @Override
+            protected String getFileContents(final ArtifactHandler handler) throws IOException {
+                final String path = handler.getFile().getPath();
+                if ( "/maven-metadata.xml".equals(path) ) {
+                    return METADATA;
+                }
+                return super.getFileContents(handler);
+            }
+        };
+
+        final ArtifactHandler handler = mgr.getArtifactHandler("mvn:group/artifact/1.0.0-SNAPSHOT/txt");
+        assertNotNull(handler);
+        assertEquals(artifactFile, handler.getFile());
+    }
+}
diff --git a/src/test/java/org/apache/sling/feature/io/FeatureUtilTest.java b/src/test/java/org/apache/sling/feature/io/FeatureUtilTest.java
new file mode 100644
index 0000000..e06b175
--- /dev/null
+++ b/src/test/java/org/apache/sling/feature/io/FeatureUtilTest.java
@@ -0,0 +1,50 @@
+/*
+ * 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.sling.feature.io;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.sling.feature.io.FileUtils;
+import org.junit.Test;
+
+public class FeatureUtilTest {
+
+    @Test public void testFileSort() {
+        final String[] files = new String[] {
+            "/different/path/app.json",
+            "/path/to/base.json",
+            "/path/to/feature.json",
+            "/path/to/amode/feature.json",
+            "/path/to/later/feature.json",
+            "http://sling.apache.org/features/one.json",
+            "http://sling.apache.org/features/two.json",
+            "http://sling.apache.org/features/amode/feature.json"
+        };
+
+        final List<String> l = new ArrayList<>(Arrays.asList(files));
+        Collections.sort(l, FileUtils.FEATURE_PATH_COMP);
+        for(int i=0; i<files.length; i++) {
+            assertEquals(files[i], l.get(i));
+        }
+    }
+
+}
diff --git a/src/test/java/org/apache/sling/feature/io/json/FeatureJSONReaderTest.java b/src/test/java/org/apache/sling/feature/io/json/FeatureJSONReaderTest.java
new file mode 100644
index 0000000..47ab83b
--- /dev/null
+++ b/src/test/java/org/apache/sling/feature/io/json/FeatureJSONReaderTest.java
@@ -0,0 +1,222 @@
+/*
+ * 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.sling.feature.io.json;
+
+import org.apache.sling.feature.ArtifactId;
+import org.apache.sling.feature.Bundles;
+import org.apache.sling.feature.Configuration;
+import org.apache.sling.feature.Configurations;
+import org.apache.sling.feature.Extension;
+import org.apache.sling.feature.Extensions;
+import org.apache.sling.feature.Feature;
+import org.apache.sling.feature.Include;
+import org.apache.sling.feature.KeyValueMap;
+import org.apache.sling.feature.io.json.FeatureJSONReader;
+import org.apache.sling.feature.io.json.FeatureJSONReader.SubstituteVariables;
+import org.junit.Test;
+import org.osgi.resource.Capability;
+import org.osgi.resource.Requirement;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Dictionary;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class FeatureJSONReaderTest {
+
+    @Test public void testRead() throws Exception {
+        final Feature feature = U.readFeature("test");
+        assertNotNull(feature);
+        assertNotNull(feature.getId());
+        assertEquals("org.apache.sling", feature.getId().getGroupId());
+        assertEquals("test-feature", feature.getId().getArtifactId());
+        assertEquals("1.1", feature.getId().getVersion());
+        assertEquals("jar", feature.getId().getType());
+        assertNull(feature.getId().getClassifier());
+
+        assertEquals(2, feature.getConfigurations().size());
+        final Configuration cfg1 = U.findConfiguration(feature.getConfigurations(), "my.pid");
+        assertEquals(7, cfg1.getProperties().get("number"));
+        final Configuration cfg2 = U.findFactoryConfiguration(feature.getConfigurations(), "my.factory.pid", "name");
+        assertEquals("yeah", cfg2.getProperties().get("a.value"));
+
+        assertEquals(3, feature.getCapabilities().size());
+        Capability capability = U.findCapability(feature.getCapabilities(),"osgi.service");
+        assertNotNull(capability.getAttributes().get("objectClass"));
+
+        assertEquals(Arrays.asList("org.osgi.service.http.runtime.HttpServiceRuntime"), capability.getAttributes().get("objectClass"));
+
+    }
+
+    @Test public void testReadWithVariablesResolve() throws Exception {
+        final Feature feature = U.readFeature("test2");
+
+        List<Include> includes = feature.getIncludes();
+        assertEquals(1, includes.size());
+        Include include = includes.get(0);
+        assertEquals("org.apache.sling:sling:9", include.getId().toMvnId());
+
+        List<Requirement> reqs = feature.getRequirements();
+        Requirement req = reqs.get(0);
+        assertEquals("osgi.contract", req.getNamespace());
+        assertEquals("(&(osgi.contract=JavaServlet)(&(version>=3.0)(!(version>=4.0))))",
+                req.getDirectives().get("filter"));
+
+        List<Capability> caps = feature.getCapabilities();
+        Capability cap = null;
+        for (Capability c : caps) {
+            if ("osgi.service".equals(c.getNamespace())) {
+                cap = c;
+                break;
+            }
+        }
+        assertEquals(Collections.singletonList("org.osgi.service.http.runtime.HttpServiceRuntime"),
+                cap.getAttributes().get("objectClass"));
+        assertEquals("org.osgi.service.http.runtime",
+                cap.getDirectives().get("uses"));
+        // TODO this seems quite broken: fix!
+        // assertEquals("org.osgi.service.http.runtime,org.osgi.service.http.runtime.dto",
+        //        cap.getDirectives().get("uses"));
+
+        KeyValueMap fwProps = feature.getFrameworkProperties();
+        assertEquals("Framework property substitution should not happen at resolve time",
+                "${something}", fwProps.get("brave"));
+
+        Bundles bundles = feature.getBundles();
+        ArtifactId id = new ArtifactId("org.apache.sling", "foo-xyz", "1.2.3", null, null);
+        assertTrue(bundles.containsExact(id));
+        ArtifactId id2 = new ArtifactId("org.apache.sling", "bar-xyz", "1.2.3", null, null);
+        assertTrue(bundles.containsExact(id2));
+
+        Configurations configurations = feature.getConfigurations();
+        Configuration config = configurations.getConfiguration("my.pid2");
+        Dictionary<String, Object> props = config.getProperties();
+        assertEquals("Configuration substitution should not happen at resolve time",
+                "aa${ab_config}", props.get("a.value"));
+        assertEquals("${ab_config}bb", props.get("b.value"));
+        assertEquals("c${c_config}c", props.get("c.value"));
+    }
+
+    @Test public void testReadWithVariablesLaunch() throws Exception {
+        final Feature feature = U.readFeature("test3", SubstituteVariables.LAUNCH);
+
+        List<Include> includes = feature.getIncludes();
+        assertEquals(1, includes.size());
+        Include include = includes.get(0);
+        assertEquals("Include substitution should not happen at launch time",
+                "${sling.gid}:sling:10", include.getId().toMvnId());
+
+        List<Requirement> reqs = feature.getRequirements();
+        Requirement req = reqs.get(0);
+        assertEquals("Requirement substitution should not happen at launch time",
+                "osgi.${ns}", req.getNamespace());
+        assertEquals("Requirement substitution should not happen at launch time",
+                "(&(osgi.contract=${contract.name})(&(version>=3.0)(!(version>=4.0))))",
+                req.getDirectives().get("filter"));
+        assertEquals("There should be 1 directive, comments should be ignored",
+                1, req.getDirectives().size());
+
+        List<Capability> caps = feature.getCapabilities();
+        Capability cap = null;
+        for (Capability c : caps) {
+            if ("osgi.${svc}".equals(c.getNamespace())) {
+                cap = c;
+                break;
+            }
+        }
+        assertEquals("Capability substitution should not happen at launch time",
+                Collections.singletonList("org.osgi.${svc}.http.runtime.HttpServiceRuntime"),
+                cap.getAttributes().get("objectClass"));
+        assertEquals("There should be 1 attribute, comments should be ignored",
+                1, cap.getAttributes().size());
+
+        KeyValueMap fwProps = feature.getFrameworkProperties();
+        assertEquals("something", fwProps.get("brave"));
+        assertEquals("There should be 3 framework properties, comments not included",
+                3, fwProps.size());
+
+        Configurations configurations = feature.getConfigurations();
+        assertEquals("There should be 2 configurations, comments not included",
+                2, configurations.size());
+
+        Configuration config1 = configurations.getConfiguration("my.pid2");
+        for (Enumeration<?> en = config1.getProperties().elements(); en.hasMoreElements(); ) {
+            assertFalse("The comment should not show up in the configuration",
+                "comment".equals(en.nextElement()));
+        }
+
+        Configuration config2 = configurations.getConfiguration("my.pid2");
+        Dictionary<String, Object> props = config2.getProperties();
+        assertEquals("aaright!", props.get("a.value"));
+        assertEquals("right!bb", props.get("b.value"));
+        assertEquals("creally?c", props.get("c.value"));
+        assertEquals("The variable definition looks like a variable definition, this escaping mechanism should work",
+                "${refvar}", props.get("refvar"));
+    }
+
+    @Test public void testReadRepoInitExtension() throws Exception {
+        Feature feature = U.readFeature("repoinit");
+        Extensions extensions = feature.getExtensions();
+        assertEquals(1, extensions.size());
+        Extension ext = extensions.iterator().next();
+        assertEquals("some repo init\ntext", ext.getText());
+    }
+
+    @Test public void testReadRepoInitExtensionArray() throws Exception {
+        Feature feature = U.readFeature("repoinit2");
+        Extensions extensions = feature.getExtensions();
+        assertEquals(1, extensions.size());
+        Extension ext = extensions.iterator().next();
+        assertEquals("some repo init\ntext\n", ext.getText());
+    }
+
+    @Test public void testHandleVars() throws Exception {
+        FeatureJSONReader reader = new FeatureJSONReader(null, null, SubstituteVariables.LAUNCH);
+        Map<String, Object> vars = new HashMap<>();
+        vars.put("var1", "bar");
+        vars.put("varvariable", "${myvar}");
+        vars.put("var.2", "2");
+        setPrivateField(reader, "variables", vars);
+
+        assertEquals("foobarfoo", reader.handleLaunchVars("foo${var1}foo"));
+        assertEquals("barbarbar", reader.handleLaunchVars("${var1}${var1}${var1}"));
+        assertEquals("${}test${myvar}2", reader.handleLaunchVars("${}test${varvariable}${var.2}"));
+        try {
+            reader.handleLaunchVars("${undefined}");
+            fail("Should throw an exception on the undefined variable");
+        } catch (IllegalStateException ise) {
+            // good
+        }
+    }
+
+    private void setPrivateField(Object obj, String name, Object value) throws Exception {
+        Field field = obj.getClass().getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(obj, value);
+    }
+}
diff --git a/src/test/java/org/apache/sling/feature/io/json/FeatureJSONWriterTest.java b/src/test/java/org/apache/sling/feature/io/json/FeatureJSONWriterTest.java
new file mode 100644
index 0000000..81db5e6
--- /dev/null
+++ b/src/test/java/org/apache/sling/feature/io/json/FeatureJSONWriterTest.java
@@ -0,0 +1,50 @@
+/*
+ * 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.sling.feature.io.json;
+
+import org.apache.sling.feature.Feature;
+import org.apache.sling.feature.io.json.FeatureJSONReader;
+import org.apache.sling.feature.io.json.FeatureJSONWriter;
+import org.apache.sling.feature.io.json.FeatureJSONReader.SubstituteVariables;
+import org.junit.Test;
+
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.util.Arrays;
+
+import static org.junit.Assert.assertEquals;
+
+public class FeatureJSONWriterTest {
+
+    @Test public void testRead() throws Exception {
+        final Feature f = U.readFeature("test");
+        final Feature rf;
+        try ( final StringWriter writer = new StringWriter() ) {
+            FeatureJSONWriter.write(writer, f);
+            try ( final StringReader reader = new StringReader(writer.toString()) ) {
+                rf = FeatureJSONReader.read(reader, null, SubstituteVariables.RESOLVE);
+            }
+        }
+        assertEquals(f.getId(), rf.getId());
+        assertEquals("org.apache.sling:test-feature:1.1", rf.getId().toMvnId());
+        assertEquals("The feature description", rf.getDescription());
+
+        assertEquals(Arrays.asList("org.osgi.service.http.runtime.HttpServiceRuntime"),
+                U.findCapability(rf.getCapabilities(), "osgi.service").getAttributes().get("objectClass"));
+    }
+
+}
diff --git a/src/test/java/org/apache/sling/feature/io/json/U.java b/src/test/java/org/apache/sling/feature/io/json/U.java
new file mode 100644
index 0000000..904f158
--- /dev/null
+++ b/src/test/java/org/apache/sling/feature/io/json/U.java
@@ -0,0 +1,89 @@
+/*
+ * 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.sling.feature.io.json;
+
+import org.apache.sling.feature.Configuration;
+import org.apache.sling.feature.Feature;
+import org.apache.sling.feature.io.json.FeatureJSONReader;
+import org.apache.sling.feature.io.json.FeatureJSONReader.SubstituteVariables;
+import org.osgi.resource.Capability;
+import org.osgi.resource.Requirement;
+
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.List;
+
+import static org.junit.Assert.fail;
+
+/** Test utilities */
+public class U {
+
+    /** Read the feature from the provided resource
+     */
+    public static Feature readFeature(final String name) throws Exception {
+        return readFeature(name, SubstituteVariables.RESOLVE);
+    }
+
+    public static Feature readFeature(final String name, final SubstituteVariables phase) throws Exception {
+        try ( final Reader reader = new InputStreamReader(U.class.getResourceAsStream("/features/" + name + ".json"),
+                "UTF-8") ) {
+            return FeatureJSONReader.read(reader, name, phase);
+        }
+    }
+
+    public static Configuration findConfiguration(final List<Configuration> cfgs, final String pid) {
+        for(final Configuration c : cfgs) {
+            if ( !c.isFactoryConfiguration() && pid.equals(c.getPid()) ) {
+                return c;
+            }
+        }
+        fail("Configuration not found " + pid);
+        return null;
+    }
+
+    public static Configuration findFactoryConfiguration(final List<Configuration> cfgs, final String factoryid, final String name) {
+        for(final Configuration c : cfgs) {
+            if ( c.isFactoryConfiguration() && factoryid.equals(c.getFactoryPid()) && name.equals(c.getName())) {
+                return c;
+            }
+        }
+        fail("Factory Configuration not found " + factoryid + "~" + name);
+        return null;
+    }
+
+    public static Capability findCapability(List<Capability> capabilities, final String namespace) {
+        for (Capability capability : capabilities) {
+            if (capability.getNamespace().equals(namespace)) {
+                return capability;
+            }
+        }
+
+        fail(String.format("No Capability with namespace '%s' found", namespace));
+        return null;
+    }
+
+    public static Requirement findRequirement(List<Requirement> requirements, final String namespace) {
+        for (Requirement requirement : requirements) {
+            if (requirement.getNamespace().equals(namespace)) {
+                return requirement;
+            }
+        }
+
+        fail(String.format("No Requirement with namespace '%s' found", namespace));
+        return null;
+    }
+}
diff --git a/src/test/resources/features/repoinit.json b/src/test/resources/features/repoinit.json
new file mode 100644
index 0000000..2177702
--- /dev/null
+++ b/src/test/resources/features/repoinit.json
@@ -0,0 +1,4 @@
+{
+    "id": "test/repoinit/1.0.0",
+    "repoinit:TEXT|false": "some repo init\ntext"
+}
diff --git a/src/test/resources/features/repoinit2.json b/src/test/resources/features/repoinit2.json
new file mode 100644
index 0000000..beef986
--- /dev/null
+++ b/src/test/resources/features/repoinit2.json
@@ -0,0 +1,7 @@
+{
+    "id": "test/repoinit2/1.0.0",
+    "repoinit:TEXT|false": [
+        "some repo init",
+        "text"
+    ]
+}
diff --git a/src/test/resources/features/test.json b/src/test/resources/features/test.json
new file mode 100644
index 0000000..8ae346c
--- /dev/null
+++ b/src/test/resources/features/test.json
@@ -0,0 +1,92 @@
+{
+    "id" : "org.apache.sling/test-feature/1.1",
+    "description": "The feature description",
+
+    "includes" : [
+         {
+             "id" : "org.apache.sling/sling/9",
+             "removals" : {
+                 "configurations" : [
+                 ],
+                 "bundles" : [
+                 ],
+                 "framework-properties" : [
+                 ]
+             }
+         }
+    ],
+    "requirements" : [
+          {
+              "namespace" : "osgi.contract",
+              "directives" : {
+                  "filter" : "(&(osgi.contract=JavaServlet)(&(version>=3.0)(!(version>=4.0))))"
+              }
+          }
+    ],
+    "capabilities" : [
+        {
+             "namespace" : "osgi.implementation",
+             "attributes" : {
+                   "osgi.implementation" : "osgi.http",
+                   "version:Version" : "1.1"
+             },
+             "directives" : {
+                  "uses" : "javax.servlet,javax.servlet.http,org.osgi.service.http.context,org.osgi.service.http.whiteboard"
+             }
+        },
+        {
+             "namespace" : "osgi.service",
+             "attributes" : {
+                  "objectClass:List<String>" : "org.osgi.service.http.runtime.HttpServiceRuntime"
+             },
+             "directives" : {
+                  "uses" : "org.osgi.service.http.runtime,org.osgi.service.http.runtime.dto"
+             }
+        },
+        {
+          "namespace" : "osgi.contract",
+          "attributes" : {
+            "osgi.contract" : "JavaServlet",
+            "osgi.implementation" : "osgi.http",
+            "version:Version" : "3.1"
+          },
+          "directives" : {
+            "uses" : "org.osgi.service.http.runtime,org.osgi.service.http.runtime.dto"
+          }
+        }
+    ],
+    "framework-properties" : {
+        "foo" : 1,
+        "brave" : "something",
+        "org.apache.felix.scr.directory" : "launchpad/scr"
+    },
+    "bundles" :[
+            {
+              "id" : "org.apache.sling/oak-server/1.0.0",
+              "hash" : "4632463464363646436",
+              "start-order" : 1
+            },
+            {
+              "id" : "org.apache.sling/application-bundle/2.0.0",
+              "start-order" : 1
+            },
+            {
+              "id" : "org.apache.sling/another-bundle/2.1.0",
+              "start-order" : 1
+            },
+            {
+              "id" : "org.apache.sling/foo-xyz/1.2.3",
+              "start-order" : 2
+            }
+    ],
+    "configurations" : {
+        "my.pid" : {
+           "foo" : 5,
+           "bar" : "test",
+           "number:Integer" : 7
+        },
+        "my.factory.pid~name" : {
+           "a.value" : "yeah"
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/test/resources/features/test2.json b/src/test/resources/features/test2.json
new file mode 100644
index 0000000..0e5c1c6
--- /dev/null
+++ b/src/test/resources/features/test2.json
@@ -0,0 +1,107 @@
+{
+    "model-version": "1",
+    "id" : "org.apache.sling/test2/1.1",
+
+    "variables": {
+         "common.version": "1.2.3",
+         "contract.name": "JavaServlet",
+         "ab_config": "right!",
+         "c_config": "really?",
+         "includever": "9",
+         "ns": "contract",
+         "sling.gid": "org.apache.sling",
+         "something": "something",
+         "svc": "service"
+    },
+
+    "includes" : [
+         {
+             "id" : "${sling.gid}/sling/${includever}",
+             "removals" : {
+                 "configurations" : [
+                 ],
+                 "bundles" : [
+                 ],
+                 "framework-properties" : [
+                 ]
+             }
+         }
+    ],
+    "requirements" : [
+          {
+              "namespace" : "osgi.${ns}",
+              "directives" : {
+                  "filter" : "(&(osgi.contract=${contract.name})(&(version>=3.0)(!(version>=4.0))))"
+              }
+          }
+    ],
+    "capabilities" : [
+        {
+             "namespace" : "osgi.implementation",
+             "attributes" : {
+                   "osgi.implementation" : "osgi.http",
+                   "version:Version" : "1.1"
+             },
+             "directives" : {
+                  "uses" : "javax.servlet,javax.servlet.http,org.osgi.service.http.context,org.osgi.service.http.whiteboard"
+             }
+        },
+        {
+             "namespace" : "osgi.${svc}",
+             "attributes" : {
+                  "objectClass:List<String>" : "org.osgi.${svc}.http.runtime.HttpServiceRuntime"
+             },
+             "directives" : {
+                  "uses" : "org.osgi.${svc}.http.runtime,org.osgi.${svc}.http.runtime.dto"
+             }
+        },
+        {
+          "namespace" : "osgi.contract",
+          "attributes" : {
+            "osgi.contract" : "JavaServlet",
+            "osgi.implementation" : "osgi.http",
+            "version:Version" : "3.1"
+          },
+          "directives" : {
+            "uses" : "org.osgi.service.http.runtime,org.osgi.service.http.runtime.dto"
+          }
+        }
+    ],
+    "framework-properties" : {
+        "foo" : 1,
+        "brave" : "${something}",
+        "org.apache.felix.scr.directory" : "launchpad/scr"
+    },
+    "bundles" :[
+            {
+              "id" : "org.apache.sling/oak-server/1.0.0",
+              "hash" : "4632463464363646436",
+              "start-order" : 1
+            },
+            {
+              "id" : "org.apache.sling/application-bundle/2.0.0",
+              "start-order" : 1
+            },
+            {
+              "id" : "org.apache.sling/another-bundle/2.1.0",
+              "start-order" : 1
+            },
+            {
+              "id" : "org.apache.sling/foo-xyz/${common.version}",
+              "start-order" : 2
+            },
+            "org.apache.sling/bar-xyz/${common.version}"
+    ],
+    "configurations" : {
+        "my.pid" : {
+           "foo" : 5,
+           "bar" : "test",
+           "number:Integer" : 7
+        },
+        "my.pid2" : {
+           "a.value" : "aa${ab_config}",
+           "b.value" : "${ab_config}bb",
+           "c.value" : "c${c_config}c"
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/test/resources/features/test3.json b/src/test/resources/features/test3.json
new file mode 100644
index 0000000..630239d
--- /dev/null
+++ b/src/test/resources/features/test3.json
@@ -0,0 +1,116 @@
+{
+    "#": "A comment",
+    "# array": ["array", "comment"],
+    "id": "org.apache.sling/test2/1.1",
+
+    "variables": {
+         "common.version": "1.2.3",
+         "contract.name": "JavaServlet",
+         "ab_config": "right!",
+         "c_config": "really?",
+         "includever": "9",
+         "ns": "contract",
+         "sling.gid": "org.apache.sling",
+         "something": "something",
+         "svc": "service",
+         "refvar": "${refvar}"
+    },
+
+    "includes" : [
+         {
+             "#": "comment",
+             "id" : "${sling.gid}/sling/10",
+             "removals" : {
+                 "configurations" : [
+                 ],
+                 "bundles" : [
+                 ],
+                 "#": "comment",
+                 "framework-properties" : [
+                 ]
+             }
+         }
+    ],
+    "requirements" : [
+          {
+              "namespace" : "osgi.${ns}",
+              "#": "comment",
+              "directives" : {
+                  "#": "comment",
+                  "filter" : "(&(osgi.contract=${contract.name})(&(version>=3.0)(!(version>=4.0))))"
+              }
+          }
+    ],
+    "capabilities" : [
+        {
+             "#": "comment",
+             "namespace" : "osgi.implementation",
+             "attributes" : {
+                   "osgi.implementation" : "osgi.http",
+                   "version:Version" : "1.1"
+             },
+             "directives" : {
+                  "uses" : "javax.servlet,javax.servlet.http,org.osgi.service.http.context,org.osgi.service.http.whiteboard"
+             }
+        },
+        {
+             "namespace" : "osgi.${svc}",
+             "attributes" : {
+                  "#": "comment",
+                  "objectClass:List<String>" : "org.osgi.${svc}.http.runtime.HttpServiceRuntime"
+             },
+             "directives" : {
+                  "uses" : "org.osgi.${svc}.http.runtime,org.osgi.${svc}.http.runtime.dto"
+             }
+        },
+        {
+          "namespace" : "osgi.contract",
+          "attributes" : {
+            "osgi.contract" : "JavaServlet",
+            "osgi.implementation" : "osgi.http",
+            "version:Version" : "3.1"
+          },
+          "directives" : {
+            "uses" : "org.osgi.service.http.runtime,org.osgi.service.http.runtime.dto"
+          }
+        }
+    ],
+    "framework-properties" : {
+        "# one": "comment",
+        "# two": "comment",
+        "foo" : 1,
+        "brave" : "${something}",
+        "org.apache.felix.scr.directory" : "launchpad/scr"
+    },
+    "bundles" :[
+            {
+              "id" : "org.apache.sling/oak-server/1.0.0",
+              "hash" : "4632463464363646436",
+              "start-order" : 1,
+              "#": "comment"
+            },
+            {
+              "id" : "org.apache.sling/application-bundle/2.0.0",
+              "start-order" : 1
+            },
+            {
+              "id" : "org.apache.sling/another-bundle/2.1.0",
+              "start-order" : 1
+            }
+    ],
+    "configurations" : {
+        "#": "comment",
+        "my.pid" : {
+           "#": "comment",
+           "foo" : 5,
+           "bar" : "test",
+           "number:Integer" : 7
+        },
+        "my.pid2" : {
+           "a.value" : "aa${ab_config}",
+           "b.value" : "${ab_config}bb",
+           "c.value" : "c${c_config}c",
+           "refvar": "${refvar}"
+        }
+    }
+}
\ No newline at end of file

-- 
To stop receiving notification emails like this one, please contact
davidb@apache.org.