You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@felix.apache.org by da...@apache.org on 2021/08/18 08:04:07 UTC

[felix-dev] branch master updated: FELIX-6444 Contribute a compatible implementation of OSGi Features

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/felix-dev.git


The following commit(s) were added to refs/heads/master by this push:
     new d381c9c  FELIX-6444 Contribute a compatible implementation of OSGi Features
     new 6eff0d0  Merge pull request #88 from bosschaert/features-contribution
d381c9c is described below

commit d381c9c52b2148606c79a1c10d410a1a2a98b9ea
Author: David Bosschaert <da...@apache.org>
AuthorDate: Thu Aug 12 16:20:55 2021 +0100

    FELIX-6444 Contribute a compatible implementation of OSGi Features
    
    This implementation was initially made in the Apache Sling Whiteboard
    component at
    https://github.com/apache/sling-whiteboard/tree/master/osgi-featuremodel
---
 features/pom.xml                                   | 141 ++++++++
 .../felix/feature/impl/ArtifactBuilderImpl.java    |  95 +++++
 .../felix/feature/impl/BuilderFactoryImpl.java     |  59 +++
 .../felix/feature/impl/BundleBuilderImpl.java      |  95 +++++
 .../feature/impl/ConfigurationBuilderImpl.java     | 125 +++++++
 .../felix/feature/impl/ExtensionBuilderImpl.java   | 151 ++++++++
 .../felix/feature/impl/FeatureBuilderImpl.java     | 275 ++++++++++++++
 .../felix/feature/impl/FeatureServiceImpl.java     | 399 +++++++++++++++++++++
 .../java/org/apache/felix/feature/impl/IDImpl.java | 214 +++++++++++
 .../felix/feature/impl/FeatureServiceImplTest.java | 295 +++++++++++++++
 .../src/test/resources/features/test-exfeat1.json  |  26 ++
 .../src/test/resources/features/test-exfeat2.json  |   9 +
 .../src/test/resources/features/test-feature.json  |  28 ++
 .../src/test/resources/features/test-feature2.json |  19 +
 14 files changed, 1931 insertions(+)

diff --git a/features/pom.xml b/features/pom.xml
new file mode 100644
index 0000000..cd4f4d6
--- /dev/null
+++ b/features/pom.xml
@@ -0,0 +1,141 @@
+<?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.felix</groupId>
+		<artifactId>felix-parent</artifactId>
+		<version>7</version>
+		<relativePath />
+	</parent>
+
+	<artifactId>org.apache.felix.feature</artifactId>
+	<version>0.0.1-SNAPSHOT</version>
+	<packaging>jar</packaging>
+
+	<name>OSGi Feature Model API</name>
+
+    <properties>
+      <felix.java.version>11</felix.java.version>
+    </properties>
+        
+	<repositories>
+	  <repository>
+	    <id>sonatype.snapshots</id>
+	    <name>OSGi Snapshot</name>
+	    <url>https://oss.sonatype.org/content/repositories/snapshots</url>
+	  </repository>
+    </repositories>
+    
+	<build>
+		<plugins>
+            <plugin>
+                <groupId>biz.aQute.bnd</groupId>
+                <artifactId>bnd-maven-plugin</artifactId>
+                <version>5.3.0</version>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>bnd-process</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+			<plugin>
+				<groupId>org.apache.maven.plugins</groupId>
+				<artifactId>maven-jar-plugin</artifactId>
+				<configuration>
+					<archive>
+						<manifestFile>${project.build.outputDirectory}/META-INF/MANIFEST.MF</manifestFile>
+					</archive>
+				</configuration>
+			</plugin>
+			<plugin>
+				<groupId>org.apache.rat</groupId>
+				<artifactId>apache-rat-plugin</artifactId>
+				<configuration>
+					<excludes>
+						<exclude>*.md</exclude>
+						<exclude>src/main/resources/META-INF/services/*</exclude>
+					</excludes>
+				</configuration>
+			</plugin>
+		</plugins>
+	</build>
+	<dependencies>
+		<dependency>
+			<groupId>org.osgi</groupId>
+			<artifactId>osgi.annotation</artifactId>
+			<version>8.0.0</version>
+			<scope>provided</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.osgi</groupId>
+			<artifactId>osgi.core</artifactId>
+			<version>8.0.0</version>
+			<scope>provided</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.osgi</groupId>
+			<artifactId>org.osgi.service.feature</artifactId>
+			<version>1.0.0-SNAPSHOT</version>
+			<scope>provided</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.osgi</groupId>
+			<artifactId>org.osgi.util.function</artifactId>
+			<version>1.0.0</version>
+			<scope>provided</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.geronimo.specs</groupId>
+			<artifactId>geronimo-json_1.1_spec</artifactId>
+			<version>1.3</version>
+			<scope>provided</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.felix</groupId>
+			<artifactId>org.apache.felix.converter</artifactId>
+			<version>1.0.18</version>
+			<scope>provided</scope>
+		</dependency>
+        <dependency>
+            <groupId>org.apache.felix</groupId>
+            <artifactId>org.apache.felix.cm.json</artifactId>
+            <version>1.0.6</version>
+            <scope>provided</scope>
+        </dependency>
+
+		<!-- Testing -->
+		<dependency>
+			<groupId>junit</groupId>
+			<artifactId>junit</artifactId>
+			<version>4.13.2</version>
+			<scope>test</scope>
+		</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.2.2</version>
+			<scope>test</scope>
+		</dependency>
+	</dependencies>
+</project>
diff --git a/features/src/main/java/org/apache/felix/feature/impl/ArtifactBuilderImpl.java b/features/src/main/java/org/apache/felix/feature/impl/ArtifactBuilderImpl.java
new file mode 100644
index 0000000..4fe6584
--- /dev/null
+++ b/features/src/main/java/org/apache/felix/feature/impl/ArtifactBuilderImpl.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.felix.feature.impl;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import org.osgi.service.feature.FeatureArtifact;
+import org.osgi.service.feature.FeatureArtifactBuilder;
+import org.osgi.service.feature.ID;
+
+class ArtifactBuilderImpl implements FeatureArtifactBuilder {
+    private final ID id;
+
+    private final Map<String,Object> metadata = new LinkedHashMap<>();
+
+    ArtifactBuilderImpl(ID id) {
+        this.id = id;
+    }
+
+    @Override
+    public FeatureArtifactBuilder addMetadata(String key, Object value) {
+        this.metadata.put(key, value);
+        return this;
+    }
+
+    @Override
+    public FeatureArtifactBuilder addMetadata(Map<String,Object> md) {
+        this.metadata.putAll(md);
+        return this;
+    }
+
+    @Override
+    public FeatureArtifact build() {
+        return new ArtifactImpl(id, metadata);
+    }
+
+    private static class ArtifactImpl implements FeatureArtifact {
+        private final ID id;
+        private final Map<String, Object> metadata;
+
+        private ArtifactImpl(ID id, Map<String, Object> metadata) {
+            this.id = id;
+            this.metadata = Collections.unmodifiableMap(metadata);
+        }
+
+        @Override
+        public ID getID() {
+            return id;
+        }
+        
+        @Override
+        public Map<String, Object> getMetadata() {
+            return metadata;
+        }
+
+        @Override
+		public int hashCode() {
+			return Objects.hash(id, metadata);
+		}
+
+		@Override
+		public boolean equals(Object obj) {
+			if (this == obj)
+				return true;
+			if (obj == null)
+				return false;
+			if (getClass() != obj.getClass())
+				return false;
+			ArtifactImpl other = (ArtifactImpl) obj;
+			return Objects.equals(id, other.id) && Objects.equals(metadata, other.metadata);
+		}
+
+		@Override
+        public String toString() {
+            return "ArtifactImpl [getID()=" + getID() + "]";
+        }
+    }
+}
diff --git a/features/src/main/java/org/apache/felix/feature/impl/BuilderFactoryImpl.java b/features/src/main/java/org/apache/felix/feature/impl/BuilderFactoryImpl.java
new file mode 100644
index 0000000..7622aed
--- /dev/null
+++ b/features/src/main/java/org/apache/felix/feature/impl/BuilderFactoryImpl.java
@@ -0,0 +1,59 @@
+/*
+ * 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.felix.feature.impl;
+
+import org.osgi.service.feature.BuilderFactory;
+import org.osgi.service.feature.FeatureArtifactBuilder;
+import org.osgi.service.feature.FeatureBuilder;
+import org.osgi.service.feature.FeatureBundleBuilder;
+import org.osgi.service.feature.FeatureConfigurationBuilder;
+import org.osgi.service.feature.FeatureExtension.Kind;
+import org.osgi.service.feature.FeatureExtension.Type;
+import org.osgi.service.feature.FeatureExtensionBuilder;
+import org.osgi.service.feature.ID;
+
+class BuilderFactoryImpl implements BuilderFactory {
+	@Override
+	public FeatureArtifactBuilder newArtifactBuilder(ID id) {
+		return new ArtifactBuilderImpl(id);
+	}
+
+	@Override
+    public FeatureBundleBuilder newBundleBuilder(ID id) {
+        return new BundleBuilderImpl(id);
+    }
+
+    @Override
+    public FeatureConfigurationBuilder newConfigurationBuilder(String pid) {
+        return new ConfigurationBuilderImpl(pid);
+    }
+
+    @Override
+    public FeatureConfigurationBuilder newConfigurationBuilder(String factoryPid, String name) {
+        return new ConfigurationBuilderImpl(factoryPid, name);
+    }
+
+    @Override
+    public FeatureBuilder newFeatureBuilder(ID id) {
+        return new FeatureBuilderImpl(id);
+    }
+
+    @Override
+    public FeatureExtensionBuilder newExtensionBuilder(String name, Type type, Kind kind) {
+        return new ExtensionBuilderImpl(name, type, kind);
+    }
+}
diff --git a/features/src/main/java/org/apache/felix/feature/impl/BundleBuilderImpl.java b/features/src/main/java/org/apache/felix/feature/impl/BundleBuilderImpl.java
new file mode 100644
index 0000000..e19a2b1
--- /dev/null
+++ b/features/src/main/java/org/apache/felix/feature/impl/BundleBuilderImpl.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.felix.feature.impl;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import org.osgi.service.feature.FeatureBundle;
+import org.osgi.service.feature.FeatureBundleBuilder;
+import org.osgi.service.feature.ID;
+
+class BundleBuilderImpl implements FeatureBundleBuilder {
+    private final ID id;
+
+    private final Map<String,Object> metadata = new LinkedHashMap<>();
+
+    BundleBuilderImpl(ID id) {
+        this.id = id;
+    }
+
+    @Override
+    public FeatureBundleBuilder addMetadata(String key, Object value) {
+        this.metadata.put(key, value);
+        return this;
+    }
+
+    @Override
+    public FeatureBundleBuilder addMetadata(Map<String,Object> md) {
+        this.metadata.putAll(md);
+        return this;
+    }
+
+    @Override
+    public FeatureBundle build() {
+        return new BundleImpl(id, metadata);
+    }
+
+    private static class BundleImpl implements FeatureBundle {
+        private final ID id;
+        private final Map<String, Object> metadata;
+
+        private BundleImpl(ID id, Map<String, Object> metadata) {
+            this.id = id;
+            this.metadata = Collections.unmodifiableMap(metadata);
+        }
+
+        @Override
+        public ID getID() {
+            return id;
+        }
+        
+        @Override
+        public Map<String, Object> getMetadata() {
+            return metadata;
+        }
+
+        @Override
+		public int hashCode() {
+			return Objects.hash(id, metadata);
+		}
+
+		@Override
+		public boolean equals(Object obj) {
+			if (this == obj)
+				return true;
+			if (obj == null)
+				return false;
+			if (getClass() != obj.getClass())
+				return false;
+			BundleImpl other = (BundleImpl) obj;
+			return Objects.equals(id, other.id) && Objects.equals(metadata, other.metadata);
+		}
+
+        @Override
+        public String toString() {
+            return "BundleImpl [getID()=" + getID() + "]";
+        }
+    }
+}
diff --git a/features/src/main/java/org/apache/felix/feature/impl/ConfigurationBuilderImpl.java b/features/src/main/java/org/apache/felix/feature/impl/ConfigurationBuilderImpl.java
new file mode 100644
index 0000000..62da552
--- /dev/null
+++ b/features/src/main/java/org/apache/felix/feature/impl/ConfigurationBuilderImpl.java
@@ -0,0 +1,125 @@
+/*
+ * 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.felix.feature.impl;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.osgi.service.feature.FeatureConfiguration;
+import org.osgi.service.feature.FeatureConfigurationBuilder;
+
+class ConfigurationBuilderImpl implements FeatureConfigurationBuilder {
+    private final String p;
+    private final String name;
+
+    private final Map<String,Object> values = new LinkedHashMap<>();
+
+    ConfigurationBuilderImpl(String pid) {
+        this.p = pid;
+        this.name = null;
+    }
+
+    ConfigurationBuilderImpl(String factoryPid, String name) {
+        this.p = factoryPid;
+        this.name = name;
+    }
+
+    ConfigurationBuilderImpl(FeatureConfiguration c) {
+        if (c.getFactoryPid() == null) {
+            p = c.getPid();
+            name = null;
+        } else {
+            // TODO
+            p = null;
+            name = null;
+        }
+
+        addValues(c.getValues());
+    }
+
+    @Override
+    public FeatureConfigurationBuilder addValue(String key, Object value) {
+        // TODO can do some validation on the configuration
+        this.values.put(key, value);
+        return this;
+    }
+
+    @Override
+    public FeatureConfigurationBuilder addValues(Map<String, Object> cfg) {
+        // TODO can do some validation on the configuration
+        this.values.putAll(cfg);
+        return this;
+    }
+
+    @Override
+    public FeatureConfiguration build() {
+        if (name == null) {
+            return new ConfigurationImpl(p, null, values);
+        } else {
+            return new ConfigurationImpl(p + "~" + name, p, values);
+        }
+    }
+
+    private static class ConfigurationImpl implements FeatureConfiguration {
+        private final String pid;
+        private final Optional<String> factoryPid;
+        private final Map<String, Object> values;
+
+        private ConfigurationImpl(String pid, String factoryPid,
+                Map<String, Object> values) {
+            this.pid = pid;
+            this.factoryPid = Optional.ofNullable(factoryPid);
+            this.values = Collections.unmodifiableMap(values);
+        }
+
+        public String getPid() {
+            return pid;
+        }
+
+        public Optional<String> getFactoryPid() {
+            return factoryPid;
+        }
+
+        public Map<String, Object> getValues() {
+            return values;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(factoryPid, pid, values);
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (this == obj)
+                return true;
+            if (!(obj instanceof ConfigurationImpl))
+                return false;
+            ConfigurationImpl other = (ConfigurationImpl) obj;
+            return Objects.equals(factoryPid, other.factoryPid) && Objects.equals(pid, other.pid)
+                    && Objects.equals(values, other.values);
+        }
+
+        @Override
+        public String toString() {
+            return "ConfigurationImpl [pid=" + pid + "]";
+        }
+    }
+}
diff --git a/features/src/main/java/org/apache/felix/feature/impl/ExtensionBuilderImpl.java b/features/src/main/java/org/apache/felix/feature/impl/ExtensionBuilderImpl.java
new file mode 100644
index 0000000..81da44c
--- /dev/null
+++ b/features/src/main/java/org/apache/felix/feature/impl/ExtensionBuilderImpl.java
@@ -0,0 +1,151 @@
+/*
+ * 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.felix.feature.impl;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import org.osgi.service.feature.FeatureArtifact;
+import org.osgi.service.feature.FeatureExtension;
+import org.osgi.service.feature.FeatureExtension.Kind;
+import org.osgi.service.feature.FeatureExtension.Type;
+import org.osgi.service.feature.FeatureExtensionBuilder;
+
+class ExtensionBuilderImpl implements FeatureExtensionBuilder {
+    private final String name;
+    private final Type type;
+    private final Kind kind;
+
+    private final List<String> content = new ArrayList<>();
+    private final List<FeatureArtifact> artifacts = new ArrayList<>();
+
+    ExtensionBuilderImpl(String name, Type type, Kind kind) {
+        this.name = name;
+        this.type = type;
+        this.kind = kind;
+    }
+
+    @Override
+    public FeatureExtensionBuilder addText(String text) {
+        if (type != Type.TEXT)
+            throw new IllegalStateException("Cannot add text to extension of type " + type);
+
+        content.add(text);
+        return this;
+    }
+
+    @Override
+    public FeatureExtensionBuilder setJSON(String json) {
+        if (type != Type.JSON)
+            throw new IllegalStateException("Cannot add text to extension of type " + type);
+
+        content.clear(); // Clear any previous value
+        content.add(json);
+        return this;
+    }
+
+    @Override
+    public FeatureExtensionBuilder addArtifact(FeatureArtifact art) {
+        if (type != Type.ARTIFACTS)
+            throw new IllegalStateException("Cannot add artifacts to extension of type " + type);
+
+        artifacts.add(art);
+        return this;
+    }
+    
+    @Override
+    public FeatureExtension build() {
+        return new ExtensionImpl(name, type, kind, content, artifacts);
+    }
+
+    private static class ExtensionImpl implements FeatureExtension {
+        private final String name;
+        private final Type type;
+        private final Kind kind;
+        private final List<String> content;
+        private final List<FeatureArtifact> artifacts;
+
+        private ExtensionImpl(String name, Type type, Kind kind, List<String> content, List<FeatureArtifact> artifacts) {
+            this.name = name;
+            this.type = type;
+            this.kind = kind;
+            this.content = Collections.unmodifiableList(content);
+            this.artifacts = Collections.unmodifiableList(artifacts);
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        public Type getType() {
+            return type;
+        }
+
+        public Kind getKind() {
+            return kind;
+        }
+
+        public String getJSON() {
+            if (type != Type.JSON)
+                throw new IllegalStateException("Extension is not of type JSON " + type);
+
+            if (content.isEmpty())
+                return null;
+
+            return content.get(0);
+        }
+
+        public List<String> getText() {
+            if (type != Type.TEXT)
+                throw new IllegalStateException("Extension is not of type Text " + type);
+
+            return content;
+        }
+
+        public List<FeatureArtifact> getArtifacts() {
+            if (type != Type.ARTIFACTS)
+                throw new IllegalStateException("Extension is not of type Text " + type);
+
+            return artifacts;
+        }
+
+        @Override
+		public int hashCode() {
+			return Objects.hash(artifacts, content, kind, name, type);
+		}
+
+        @Override
+		public boolean equals(Object obj) {
+			if (this == obj)
+				return true;
+			if (obj == null)
+				return false;
+			if (getClass() != obj.getClass())
+				return false;
+			ExtensionImpl other = (ExtensionImpl) obj;
+			return Objects.equals(artifacts, other.artifacts) && Objects.equals(content, other.content)
+					&& kind == other.kind && Objects.equals(name, other.name) && type == other.type;
+		}
+
+		@Override
+        public String toString() {
+            return "ExtensionImpl [name=" + name + ", type=" + type + "]";
+        }
+    }
+}
diff --git a/features/src/main/java/org/apache/felix/feature/impl/FeatureBuilderImpl.java b/features/src/main/java/org/apache/felix/feature/impl/FeatureBuilderImpl.java
new file mode 100644
index 0000000..2f86860
--- /dev/null
+++ b/features/src/main/java/org/apache/felix/feature/impl/FeatureBuilderImpl.java
@@ -0,0 +1,275 @@
+/*
+ * 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.felix.feature.impl;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.osgi.service.feature.Feature;
+import org.osgi.service.feature.FeatureBuilder;
+import org.osgi.service.feature.FeatureBundle;
+import org.osgi.service.feature.FeatureConfiguration;
+import org.osgi.service.feature.FeatureExtension;
+import org.osgi.service.feature.ID;
+
+class FeatureBuilderImpl implements FeatureBuilder {
+    private final ID id;
+
+    private String name;
+    private String description;
+    private String docURL;
+    private String license;
+    private String scm;
+    private String vendor;
+    private boolean complete;
+
+    private final List<FeatureBundle> bundles = new ArrayList<>();
+    private final List<String> categories = new ArrayList<>();
+    private final Map<String,FeatureConfiguration> configurations = new LinkedHashMap<>();
+    private final Map<String,FeatureExtension> extensions = new LinkedHashMap<>();
+    private final Map<String,String> variables = new LinkedHashMap<>();
+
+    FeatureBuilderImpl(ID id) {
+        this.id = id;
+    }
+
+    @Override
+    public FeatureBuilder setName(String name) {
+        this.name = name;
+        return this;
+    }
+
+    @Override
+    public FeatureBuilder setDocURL(String url) {
+        this.docURL = url;
+        return this;
+    }
+
+    @Override
+    public FeatureBuilder setVendor(String vendor) {
+        this.vendor = vendor;
+        return this;
+    }
+
+    @Override
+    public FeatureBuilder setLicense(String license) {
+        this.license = license;
+        return this;
+    }
+
+    @Override
+    public FeatureBuilder setComplete(boolean complete) {
+        this.complete = complete;
+        return this;
+    }
+
+    @Override
+    public FeatureBuilder setDescription(String description) {
+        this.description = description;
+        return this;
+    }
+
+    @Override
+    public FeatureBuilder setSCM(String scm) {
+        this.scm = scm;
+        return this;
+    }
+
+    @Override
+    public FeatureBuilder addBundles(FeatureBundle ... bundles) {
+        this.bundles.addAll(Arrays.asList(bundles));
+        return this;
+    }
+
+    
+    @Override
+	public FeatureBuilder addCategories(String ...categories) {
+    	this.categories.addAll(Arrays.asList(categories));
+		return this;
+	}
+
+	@Override
+    public FeatureBuilder addConfigurations(FeatureConfiguration ... configs) {
+        for (FeatureConfiguration cfg : configs) {
+            this.configurations.put(cfg.getPid(), cfg);
+        }
+        return this;
+    }
+
+    @Override
+    public FeatureBuilder addExtensions(FeatureExtension ... extensions) {
+        for (FeatureExtension ex : extensions) {
+            this.extensions.put(ex.getName(), ex);
+        }
+        return this;
+    }
+
+    @Override
+    public FeatureBuilder addVariable(String key, String value) {
+        this.variables.put(key, value);
+        return this;
+    }
+
+    @Override
+    public FeatureBuilder addVariables(Map<String,String> variables) {
+        this.variables.putAll(variables);
+        return this;
+    }
+
+    @Override
+    public Feature build() {
+        return new FeatureImpl(id, name, description, docURL,
+                license, scm, vendor, complete,
+                bundles, categories, configurations, extensions, variables);
+    }
+
+    private static class FeatureImpl implements Feature {
+        private final ID id;
+        private final Optional<String> name;
+        private final Optional<String> description;
+        private final Optional<String> docURL;
+        private final Optional<String> license;
+        private final Optional<String> scm;
+        private final Optional<String> vendor;
+        private final boolean complete;
+
+        private final List<FeatureBundle> bundles;
+        private final List<String> categories;
+        private final Map<String,FeatureConfiguration> configurations;
+        private final Map<String,FeatureExtension> extensions;
+        private final Map<String,String> variables;
+
+        private FeatureImpl(ID id, String aName, String desc, String docs, String lic, String sc, String vnd,
+                boolean comp, List<FeatureBundle> bs, List<String> cats, Map<String,FeatureConfiguration> cs,
+                Map<String,FeatureExtension> es, Map<String,String> vars) {
+            this.id = id;
+            name = Optional.ofNullable(aName);
+            description = Optional.ofNullable(desc);
+            docURL = Optional.ofNullable(docs);
+            license = Optional.ofNullable(lic);
+            scm = Optional.ofNullable(sc);
+            vendor = Optional.ofNullable(vnd);
+            complete = comp;
+
+            bundles = Collections.unmodifiableList(bs);
+            categories = Collections.unmodifiableList(cats);
+            configurations = Collections.unmodifiableMap(cs);
+            extensions = Collections.unmodifiableMap(es);
+            variables = Collections.unmodifiableMap(vars);
+        }
+
+        @Override
+        public ID getID() {
+            return id;
+        }
+        
+        @Override
+        public Optional<String> getName() {
+            return name;
+        }
+
+        @Override
+        public Optional<String> getDescription() {
+            return description;
+        }
+
+        @Override
+        public Optional<String> getVendor() {
+            return vendor;
+        }
+
+        @Override
+        public Optional<String> getLicense() {
+            return license;
+        }
+
+        @Override
+        public Optional<String> getDocURL() {
+            return docURL;
+        }
+
+        @Override
+        public Optional<String> getSCM() {
+            return scm;
+        }
+
+        @Override
+        public boolean isComplete() {
+            return complete;
+        }
+
+        @Override
+        public List<FeatureBundle> getBundles() {
+            return bundles;
+        }
+
+        @Override
+        public List<String> getCategories() {
+            return categories;
+        }
+
+        @Override
+        public Map<String,FeatureConfiguration> getConfigurations() {
+            return configurations;
+        }
+
+        @Override
+        public Map<String,FeatureExtension> getExtensions() {
+            return extensions;
+        }
+
+        @Override
+        public Map<String,String> getVariables() {
+            return variables;
+        }
+
+        @Override
+		public int hashCode() {
+			return Objects.hash(bundles, categories, complete, configurations, description, docURL,
+					extensions, id, license, name, scm, variables, vendor);
+		}
+
+		@Override
+		public boolean equals(Object obj) {
+			if (this == obj)
+				return true;
+			if (obj == null)
+				return false;
+			if (getClass() != obj.getClass())
+				return false;
+			FeatureImpl other = (FeatureImpl) obj;
+			return Objects.equals(bundles, other.bundles) && Objects.equals(categories, other.categories)
+					&& complete == other.complete && Objects.equals(configurations, other.configurations)
+					&& Objects.equals(description, other.description)
+					&& Objects.equals(docURL, other.docURL) && Objects.equals(extensions, other.extensions)
+					&& Objects.equals(id, other.id) && Objects.equals(license, other.license)
+					&& Objects.equals(name, other.name) && Objects.equals(scm, other.scm)
+					&& Objects.equals(variables, other.variables) && Objects.equals(vendor, other.vendor);
+		}
+
+		@Override
+        public String toString() {
+            return "FeatureImpl [getID()=" + getID() + "]";
+        }
+    }
+}
diff --git a/features/src/main/java/org/apache/felix/feature/impl/FeatureServiceImpl.java b/features/src/main/java/org/apache/felix/feature/impl/FeatureServiceImpl.java
new file mode 100644
index 0000000..10dc999
--- /dev/null
+++ b/features/src/main/java/org/apache/felix/feature/impl/FeatureServiceImpl.java
@@ -0,0 +1,399 @@
+/*
+ * 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.felix.feature.impl;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.io.Writer;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;	
+
+import javax.json.Json;
+import javax.json.JsonArray;
+import javax.json.JsonArrayBuilder;
+import javax.json.JsonNumber;
+import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
+import javax.json.JsonString;
+import javax.json.JsonValue;
+import javax.json.stream.JsonGenerator;
+import javax.json.stream.JsonGeneratorFactory;
+
+import org.apache.felix.cm.json.impl.JsonSupport;
+import org.apache.felix.cm.json.impl.TypeConverter;
+import org.osgi.service.feature.BuilderFactory;
+import org.osgi.service.feature.Feature;
+import org.osgi.service.feature.FeatureArtifact;
+import org.osgi.service.feature.FeatureArtifactBuilder;
+import org.osgi.service.feature.FeatureBuilder;
+import org.osgi.service.feature.FeatureBundle;
+import org.osgi.service.feature.FeatureBundleBuilder;
+import org.osgi.service.feature.FeatureConfiguration;
+import org.osgi.service.feature.FeatureConfigurationBuilder;
+import org.osgi.service.feature.FeatureExtension;
+import org.osgi.service.feature.FeatureExtensionBuilder;
+import org.osgi.service.feature.FeatureService;
+import org.osgi.service.feature.ID;
+
+public class FeatureServiceImpl implements FeatureService {
+    private final BuilderFactoryImpl builderFactory = new BuilderFactoryImpl();
+
+    public BuilderFactory getBuilderFactory() {
+        return builderFactory;
+    }
+    
+    @Override
+	public ID getIDfromMavenCoordinates(String mavenID) {
+    	return IDImpl.fromMavenID(mavenID);
+	}
+
+	@Override
+	public ID getID(String groupId, String artifactId, String version) {
+		return new IDImpl(groupId, artifactId, version, null, null);
+	}
+
+	@Override
+	public ID getID(String groupId, String artifactId, String version, String type) {
+		return new IDImpl(groupId, artifactId, version, type, null);
+	}
+
+	@Override
+	public ID getID(String groupId, String artifactId, String version, String type, String classifier) {
+		return new IDImpl(groupId, artifactId, version, type, classifier);
+	}
+
+	public Feature readFeature(Reader jsonReader) throws IOException {
+        JsonObject json = Json.createReader(
+        		JsonSupport.createCommentRemovingReader(jsonReader)).readObject();
+
+        String id = json.getString("id");
+        FeatureBuilder builder = builderFactory.newFeatureBuilder(getIDfromMavenCoordinates(id));
+
+        builder.setName(json.getString("name", null));
+        builder.setDescription(json.getString("description", null));
+        builder.setDocURL(json.getString("docURL", null));
+        builder.setLicense(json.getString("license", null));
+        builder.setSCM(json.getString("scm", null));
+        builder.setVendor(json.getString("vendor", null));
+
+        builder.setComplete(json.getBoolean("complete", false));
+
+        builder.addBundles(getBundles(json));
+        builder.addCategories(getCategories(json));
+        builder.addConfigurations(getConfigurations(json));
+        builder.addExtensions(getExtensions(json));
+
+        return builder.build();
+    }
+
+    private FeatureBundle[] getBundles(JsonObject json) {
+        JsonArray ja = json.getJsonArray("bundles");
+        if (ja == null)
+            return new FeatureBundle[] {};
+
+        List<FeatureBundle> bundles = new ArrayList<>();
+
+        for (JsonValue val : ja) {
+            if (val.getValueType() == JsonValue.ValueType.OBJECT) {
+                JsonObject jo = val.asJsonObject();
+                String bid = jo.getString("id");
+                FeatureBundleBuilder builder = builderFactory.newBundleBuilder(getIDfromMavenCoordinates(bid));
+
+                for (Map.Entry<String, JsonValue> entry : jo.entrySet()) {
+                    if (entry.getKey().equals("id"))
+                        continue;
+
+                    JsonValue value = entry.getValue();
+
+                    Object v;
+                    switch (value.getValueType()) {
+                    case NUMBER:
+                        v = ((JsonNumber) value).longValueExact();
+                        break;
+                    case STRING:
+                        v = ((JsonString) value).getString();
+                        break;
+                    default:
+                        v = value.toString();
+                    }
+                    builder.addMetadata(entry.getKey(), v);
+                }
+                bundles.add(builder.build());
+            }
+        }
+
+        return bundles.toArray(new FeatureBundle[0]);
+    }
+
+    private String[] getCategories(JsonObject json) {
+        JsonArray ja = json.getJsonArray("categories");
+        if (ja == null)
+            return new String[] {};
+
+        List<String> cats = ja.getValuesAs(JsonString::getString);
+        return cats.toArray(new String[] {});
+    }
+
+    private FeatureConfiguration[] getConfigurations(JsonObject json) {
+        JsonObject jo = json.getJsonObject("configurations");
+        if (jo == null)
+            return new FeatureConfiguration[] {};
+
+        List<FeatureConfiguration> configs = new ArrayList<>();
+
+        for (Map.Entry<String, JsonValue> entry : jo.entrySet()) {
+
+            String p = entry.getKey();
+            String factoryPid = null;
+            int idx = p.indexOf('~');
+            if (idx > 0) {
+                factoryPid = p.substring(0, idx);
+                p = p.substring(idx + 1);
+            }
+
+            FeatureConfigurationBuilder builder;
+            if (factoryPid == null) {
+                builder = builderFactory.newConfigurationBuilder(p);
+            } else {
+                builder = builderFactory.newConfigurationBuilder(factoryPid, p);
+            }
+
+            JsonObject values = entry.getValue().asJsonObject();
+            for (Map.Entry<String, JsonValue> value : values.entrySet()) {
+            	String key = value.getKey();
+            	String typeInfo = null;
+            	int cidx = key.indexOf(':');
+            	if (cidx > 0) {
+            		typeInfo = key.substring(cidx + 1);
+            		key = key.substring(0, cidx);
+            	}
+            	
+                JsonValue val = value.getValue();
+                // TODO ensure that binary support works as well
+                Object v = TypeConverter.convertObjectToType(val, typeInfo);                
+                builder.addValue(key, v);
+            }
+            configs.add(builder.build());
+        }
+
+        return configs.toArray(new FeatureConfiguration[] {});
+    }
+
+    private FeatureExtension[] getExtensions(JsonObject json) {
+        JsonObject jo = json.getJsonObject("extensions");
+        if (jo == null)
+            return new FeatureExtension[] {};
+
+        List<FeatureExtension> extensions = new ArrayList<>();
+
+        for (Map.Entry<String,JsonValue> entry : jo.entrySet()) {
+            JsonObject exData = entry.getValue().asJsonObject();
+            FeatureExtension.Type type;
+            if (exData.containsKey("text")) {
+                type = FeatureExtension.Type.TEXT;
+            } else if (exData.containsKey("artifacts")) {
+                type = FeatureExtension.Type.ARTIFACTS;
+            } else if (exData.containsKey("json")) {
+                type = FeatureExtension.Type.JSON;
+            } else {
+                throw new IllegalStateException("Invalid extension: " + entry);
+            }
+            String k = exData.getString("kind", "optional");
+            FeatureExtension.Kind kind = FeatureExtension.Kind.valueOf(k.toUpperCase());
+
+            FeatureExtensionBuilder builder = builderFactory.newExtensionBuilder(entry.getKey(), type, kind);
+
+            switch (type) {
+            case TEXT:
+                exData.getJsonArray("text")
+                	.stream()
+                	.filter(jv -> jv.getValueType() == JsonValue.ValueType.STRING)
+                	.map(jv -> ((JsonString) jv).getString())
+                	.forEach(builder::addText);
+                
+                break;
+            case ARTIFACTS:
+            	exData.getJsonArray("artifacts")
+            		.stream()
+            		.filter(jv -> jv.getValueType() == JsonValue.ValueType.OBJECT)
+            		.map(jv -> (JsonObject) jv)
+            		.forEach(md -> {
+            			Map<String, JsonValue> v = new HashMap<>(md);
+            			JsonString idVal = (JsonString) v.remove("id");
+            			
+            			ID id = getIDfromMavenCoordinates(idVal.getString());
+            			FeatureArtifactBuilder fab = builderFactory.newArtifactBuilder(id);
+            			
+            			for (Map.Entry<String,JsonValue> mde : v.entrySet()) {
+            				JsonValue val = mde.getValue();
+            				switch (val.getValueType()) {
+            				case STRING:
+            					fab.addMetadata(mde.getKey(), ((JsonString) val).getString());
+            					break;
+            				case FALSE:
+            					fab.addMetadata(mde.getKey(), false);
+            					break;
+            				case TRUE:
+            					fab.addMetadata(mde.getKey(), true);
+            					break;
+            				case NUMBER:
+            					JsonNumber num = (JsonNumber) val;
+            					if (num.toString().contains(".")) {
+                					fab.addMetadata(mde.getKey(), num.doubleValue());            						
+            					} else {
+            						fab.addMetadata(mde.getKey(), num.longValue());
+            					}
+            					break;
+            				default:
+            					// do nothing
+            					break;
+            				}
+            			}
+            			
+            			builder.addArtifact(fab.build());
+            		});
+
+            	break;
+            case JSON:
+                builder.setJSON(exData.getJsonObject("json").toString());
+                break;
+            }
+            extensions.add(builder.build());
+        }
+
+        return extensions.toArray(new FeatureExtension[] {});
+    }
+
+    public void writeFeature(Feature feature, Writer jsonWriter) throws IOException {
+    	// LinkedHashMap to give it some order, we'd like 'id' and 'name' first.
+    	Map<String,Object> attrs = new LinkedHashMap<>();
+    	
+    	attrs.put("id", feature.getID().toString());
+    	feature.getName().ifPresent(n -> attrs.put("name", n));
+    	feature.getDescription().ifPresent(d -> attrs.put("description", d));
+    	feature.getDocURL().ifPresent(d -> attrs.put("docURL", d));
+    	feature.getLicense().ifPresent(l -> attrs.put("license", l));
+    	feature.getSCM().ifPresent(s -> attrs.put("scm", s));
+    	feature.getVendor().ifPresent(v -> attrs.put("vendor", v));
+    	
+		JsonObjectBuilder json = Json.createObjectBuilder(attrs);
+
+		JsonArray bundles = getBundles(feature);
+		if (bundles != null) {
+			json.add("bundles", bundles);
+		}
+		
+		JsonObject configs = getConfigurations(feature);
+		if (configs != null) {
+			json.add("configurations", configs);
+		}
+		
+		JsonObject extensions = getExtensions(feature);
+		if (extensions != null) {
+			json.add("extensions", extensions);
+		}
+		
+		// TODO add variables
+		// TODO add frameworkproperties
+		
+		JsonObject fo = json.build();
+		
+		JsonGeneratorFactory gf = Json.createGeneratorFactory(Collections.singletonMap(JsonGenerator.PRETTY_PRINTING, true));
+		try (JsonGenerator gr = gf.createGenerator(jsonWriter)) {
+			gr.write(fo);
+		}
+    }
+
+	private JsonArray getBundles(Feature feature) {
+		List<FeatureBundle> bundles = feature.getBundles();
+		if (bundles == null || bundles.size() == 0)
+			return null;
+		
+		JsonArrayBuilder ab = Json.createArrayBuilder();
+		
+		for (FeatureBundle bundle : bundles) {
+			Map<String, Object> attrs = new LinkedHashMap<>();
+			attrs.put("id", bundle.getID().toString());
+			attrs.putAll(bundle.getMetadata());
+			ab.add(Json.createObjectBuilder(attrs));
+		}
+		
+		return ab.build();
+	}
+
+	private JsonObject getConfigurations(Feature feature) {
+		Map<String, FeatureConfiguration> configs = feature.getConfigurations();
+		if (configs == null || configs.size() == 0)
+			return null;
+		
+		JsonObjectBuilder ob = Json.createObjectBuilder();
+		
+		for (Map.Entry<String,FeatureConfiguration> cfg : configs.entrySet()) {
+			JsonObjectBuilder cb = Json.createObjectBuilder();
+			
+			for (Map.Entry<String,Object> prop : cfg.getValue().getValues().entrySet()) {
+				Map.Entry<String, JsonValue> je = TypeConverter.convertObjectToTypedJsonValue(prop.getValue());
+				String tk = je.getKey();
+				cb.add(TypeConverter.NO_TYPE_INFO.equals(tk) ? prop.getKey() : prop.getKey() + ":" + tk, je.getValue());
+			}
+			ob.add(cfg.getKey(), cb.build());
+		}
+		return ob.build();
+	}
+
+	private JsonObject getExtensions(Feature feature) {
+		Map<String, FeatureExtension> extensions = feature.getExtensions();
+		if (extensions == null || extensions.size() == 0)
+			return null;
+		
+		JsonObjectBuilder ob = Json.createObjectBuilder();
+		
+		for (Map.Entry<String,FeatureExtension> entry : extensions.entrySet()) {
+			FeatureExtension extVal = entry.getValue();
+
+			JsonObjectBuilder vb = Json.createObjectBuilder();
+			vb.add("kind", extVal.getKind().toString().toLowerCase());
+			
+			switch (extVal.getType()) {
+			case TEXT:
+				vb.add("text", Json.createArrayBuilder(extVal.getText()).build());
+				break;
+			case ARTIFACTS:
+				JsonArrayBuilder arr = Json.createArrayBuilder();
+				for (FeatureArtifact art : extVal.getArtifacts()) {
+					Map<String,Object> attrs = new LinkedHashMap<>();
+					attrs.put("id", art.getID().toString());
+					attrs.putAll(art.getMetadata());
+					arr.add(Json.createObjectBuilder(attrs)).build();
+				}
+				
+				vb.add("artifacts", arr.build());
+				break;
+			case JSON:
+				vb.add("json", Json.createReader(new StringReader(extVal.getJSON())).readValue());
+				break;
+			}
+			ob.add(entry.getKey(), vb.build());
+		}
+		return ob.build();
+	}
+}
diff --git a/features/src/main/java/org/apache/felix/feature/impl/IDImpl.java b/features/src/main/java/org/apache/felix/feature/impl/IDImpl.java
new file mode 100644
index 0000000..76ef473
--- /dev/null
+++ b/features/src/main/java/org/apache/felix/feature/impl/IDImpl.java
@@ -0,0 +1,214 @@
+/*
+ * 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.felix.feature.impl;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import org.osgi.service.feature.ID;
+
+public class IDImpl implements ID {
+    private final String groupId;
+    private final String artifactId;
+    private final String version; // The Artifact Version may not follow OSGi version rules
+    private final String type;
+    private final String classifier;
+
+    /**
+	 * Construct an ID from a Maven ID. Maven IDs have the following syntax:
+	 * <p>
+	 * {@code group-id ':' artifact-id [ ':' [type] [ ':' classifier ] ] ':' version}
+	 *
+	 * @param mavenID
+	 * @return The ID
+	 * @throws IllegalArgumentException if the mavenID does not match the Syntax
+	 */
+	public static IDImpl fromMavenID(String mavenID)
+			throws IllegalArgumentException {
+        String[] parts = mavenID.split(":");
+
+        if (parts.length < 3 || parts.length > 5)
+            throw new IllegalArgumentException("Not a valid maven ID" + mavenID);
+
+        String gid = parts[0];
+        String aid = parts[1];
+		String ver = null;
+		String t = null;
+		String c = null;
+
+		if (parts.length == 3) {
+			ver = parts[2];
+		} else if (parts.length == 4) {
+			t = parts[2];
+			ver = parts[3];
+		} else {
+			t = parts[2];
+			c = parts[3];
+			ver = parts[4];
+		}
+        return new IDImpl(gid, aid, ver, t, c);
+    }
+
+    /**
+	 * Construct an ID
+	 * 
+	 * @param groupId The group ID.
+	 * @param artifactId The artifact ID.
+	 * @param version The version.
+	 * @param type The type identifier.
+	 * @param classifier The classifier.
+	 * @throws NullPointerException if one of the parameters (groupId,
+	 *             artifactId, version) is null.
+	 * @throws IllegalArgumentException if one of the parameters is empty or
+	 *             contains an colon `:` or if a classifier is used without a
+	 *             type.
+	 */
+	public IDImpl(String groupId, String artifactId, String version, String type,
+			String classifier)
+			throws NullPointerException, IllegalArgumentException {
+
+		Objects.requireNonNull(groupId, "groupId");
+		Objects.requireNonNull(artifactId, "artifact");
+		Objects.requireNonNull(version, "version");
+
+		if (groupId.isEmpty()) {
+			throw new IllegalArgumentException("groupId must not be empty");
+		}
+		if (artifactId.isEmpty()) {
+			throw new IllegalArgumentException("artifactId must not be empty");
+		}
+		if (version.isEmpty()) {
+			throw new IllegalArgumentException("version must not be empty");
+		}
+
+		if (type != null && type.isEmpty()) {
+			throw new IllegalArgumentException("type must not be empty");
+		}
+
+		if (classifier != null && classifier.isEmpty()) {
+			throw new IllegalArgumentException("classifier must not be empty");
+		}
+
+		if (groupId.contains(":")) {
+			throw new IllegalArgumentException(
+					"groupId must not contain a colon `:`");
+		}
+		if (artifactId.contains(":")) {
+			throw new IllegalArgumentException(
+					"artifactId must not contain a colon `:`");
+		}
+		if (version.contains(":")) {
+			throw new IllegalArgumentException(
+					"version must not contain a colon `:`");
+		}
+		if (type != null && type.contains(":")) {
+			throw new IllegalArgumentException(
+					"type must not contain a colon `:`");
+		}
+		if (classifier != null && classifier.contains(":")) {
+			throw new IllegalArgumentException(
+					"classifier must not contain a colon `:`");
+		}
+		if (type == null && classifier != null) {
+			throw new IllegalArgumentException(
+					"type must not be `null` if a classifier is set");
+		}
+		this.groupId = groupId;
+		this.artifactId = artifactId;
+		this.version = version;
+		this.type = type;
+		this.classifier = classifier;
+    }
+
+    /**
+     * Get the group ID.
+     * @return The group ID.
+     */
+    public String getGroupId() {
+        return groupId;
+    }
+
+    /**
+     * Get the artifact ID.
+     * @return The artifact ID.
+     */
+    public String getArtifactId() {
+        return artifactId;
+    }
+
+    /**
+     * Get the version.
+     * @return The version.
+     */
+    public String getVersion() {
+        return version;
+    }
+
+    /**
+     * Get the type identifier.
+     * @return The type identifier.
+     */
+    public Optional<String> getType() {
+        return Optional.ofNullable(type);
+    }
+
+    /**
+     * Get the classifier.
+     * @return The classifier.
+     */
+    public Optional<String> getClassifier() {
+        return Optional.ofNullable(classifier);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(artifactId, classifier, groupId, type, version);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj)
+            return true;
+        if (!(obj instanceof IDImpl))
+            return false;
+        IDImpl other = (IDImpl) obj;
+        return Objects.equals(artifactId, other.artifactId) && Objects.equals(classifier, other.classifier)
+                && Objects.equals(groupId, other.groupId) && Objects.equals(type, other.type)
+                && Objects.equals(version, other.version);
+    }
+
+	/**
+	 * Returns the the mavenID. Maven IDs have the following syntax:
+	 * <p>
+	 * {@code group-id ':' artifact-id [ ':' [type] [ ':' classifier ] ] ':' version}
+	 * 
+	 * @return the mavenID.
+	 */
+    @Override
+    public String toString() {
+		StringBuilder sb = new StringBuilder(groupId).append(":")
+				.append(artifactId);
+
+		if (type != null) {
+			sb = sb.append(":").append(type);
+			if (classifier != null) {
+				sb = sb.append(":").append(classifier);
+			}
+		}
+		return sb.append(":").append(version).toString();
+    }
+}
diff --git a/features/src/test/java/org/apache/felix/feature/impl/FeatureServiceImplTest.java b/features/src/test/java/org/apache/felix/feature/impl/FeatureServiceImplTest.java
new file mode 100644
index 0000000..9b2334b
--- /dev/null
+++ b/features/src/test/java/org/apache/felix/feature/impl/FeatureServiceImplTest.java
@@ -0,0 +1,295 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.felix.feature.impl;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.net.URL;
+import java.util.List;
+import java.util.Map;
+
+import javax.json.Json;
+import javax.json.JsonObject;
+import javax.json.JsonReader;
+
+import org.apache.felix.feature.impl.FeatureServiceImpl;
+import org.junit.Before;
+import org.junit.Test;
+import org.osgi.service.feature.BuilderFactory;
+import org.osgi.service.feature.Feature;
+import org.osgi.service.feature.FeatureArtifact;
+import org.osgi.service.feature.FeatureBuilder;
+import org.osgi.service.feature.FeatureBundle;
+import org.osgi.service.feature.FeatureConfiguration;
+import org.osgi.service.feature.FeatureExtension;
+import org.osgi.service.feature.FeatureService;
+
+public class FeatureServiceImplTest {
+	FeatureServiceImpl features;
+
+    @Before
+    public void setUp() {
+        features = new FeatureServiceImpl();
+    }
+
+    @Test
+    public void testReadFeature() throws IOException {
+        BuilderFactory bf = features.getBuilderFactory();
+
+        URL res = getClass().getResource("/features/test-feature.json");
+        
+        Feature f;
+        try (Reader r = new InputStreamReader(res.openStream())) {
+            f = features.readFeature(r);
+
+            assertTrue(f.getName().isEmpty());
+            assertEquals("The feature description", f.getDescription().get());
+            assertFalse(f.getDocURL().isPresent());
+            assertFalse(f.getLicense().isPresent());
+            assertFalse(f.getSCM().isPresent());
+            assertFalse(f.getVendor().isPresent());
+
+            List<FeatureBundle> bundles = f.getBundles();
+            assertEquals(3, bundles.size());
+
+            FeatureBundle bundle = bf.newBundleBuilder(features.getID("org.osgi", "osgi.promise", "7.0.1"))
+                    .addMetadata("hash", "4632463464363646436")
+                    .addMetadata("start-order", 1L)
+                    .build();
+
+            FeatureBundle ba = bundles.get(0);
+            ba.equals(bundle);
+
+            assertTrue(bundles.contains(bundle));
+            assertTrue(bundles.contains(bf.newBundleBuilder(features.getID("org.slf4j", "slf4j-api", "1.7.29")).build()));
+            assertTrue(bundles.contains(bf.newBundleBuilder(features.getID("org.slf4j", "slf4j-simple", "1.7.29")).build()));
+            
+            Map<String, FeatureConfiguration> configs = f.getConfigurations();
+            assertEquals(2, configs.size());
+            
+            FeatureConfiguration cfg1 = configs.get("my.pid");
+            assertEquals("my.pid", cfg1.getPid());
+            assertFalse(cfg1.getFactoryPid().isPresent());
+            Map<String, Object> values1 = cfg1.getValues();
+            assertEquals(3, values1.size());
+            assertEquals(Long.valueOf(5), values1.get("foo"));
+            assertEquals("test", values1.get("bar"));
+            assertEquals(Integer.valueOf(7), values1.get("number"));
+            
+            FeatureConfiguration cfg2 = configs.get("my.factory.pid~name");
+            assertEquals("my.factory.pid~name", cfg2.getPid());
+            assertEquals("my.factory.pid", cfg2.getFactoryPid().get());
+            Map<String, Object> values2 = cfg2.getValues();
+            assertEquals(1, values2.size());
+            assertArrayEquals(new String[] {"yeah", "yeah", "yeah"}, (String[]) values2.get("a.value"));
+        }
+
+        testWriteFeature(f, res);
+    }
+    
+    @Test
+    public void testReadFeature2() throws Exception {
+        URL res = getClass().getResource("/features/test-feature2.json");
+        try (Reader r = new InputStreamReader(res.openStream())) {
+            Feature f = features.readFeature(r);
+
+            assertEquals("org.apache.sling:test-feature2:osgifeature:cls_abc:1.1", f.getID().toString());
+            assertEquals("test-feature2", f.getName().get());
+            assertEquals("The feature description", f.getDescription().get());
+            assertEquals(List.of("foo", "bar"), f.getCategories());
+            assertEquals("http://foo.bar.com/abc", f.getDocURL().get());
+            assertEquals("Apache-2.0; link=\"http://opensource.org/licenses/apache2.0.php\"", f.getLicense().get());
+            assertEquals("url=https://github.com/apache/sling-aggregator, connection=scm:git:https://github.com/apache/sling-aggregator.git, developerConnection=scm:git:git@github.com:apache/sling-aggregator.git", 
+            		f.getSCM().get());
+            assertEquals("The Apache Software Foundation", f.getVendor().get());
+        }    	
+    }
+
+    @Test
+    public void testWriteFeature() throws Exception {
+        BuilderFactory factory = features.getBuilderFactory();
+
+        String desc = "This is the main ACME app, from where all functionality can be reached.";
+
+        FeatureBuilder builder = factory.newFeatureBuilder(features.getID("org.acme", "acmeapp", "1.0.0"));
+        builder.setName("The ACME app");
+		builder.setDescription(desc);
+
+        Feature f = builder.build();
+        StringWriter sw = new StringWriter();
+        features.writeFeature(f, sw);
+        
+        // Now check the generated JSON
+        JsonReader jr = Json.createReader(new StringReader(sw.toString()));
+        JsonObject fo = jr.readObject();
+        assertEquals("org.acme:acmeapp:1.0.0", fo.getString("id"));
+        assertEquals("The ACME app", fo.getString("name"));
+        assertEquals(desc, fo.getString("description"));
+        assertFalse(fo.containsKey("docURL"));
+        assertFalse(fo.containsKey("license"));
+        assertFalse(fo.containsKey("scm"));
+        assertFalse(fo.containsKey("vendor"));
+    }
+
+    @Test
+    public void testFeatureWithExtension() throws Exception {
+        URL res = getClass().getResource("/features/test-exfeat1.json");
+        Feature f;
+        try (Reader r = new InputStreamReader(res.openStream())) {
+            f = features.readFeature(r);
+            
+            Map<String, FeatureExtension> extensions = f.getExtensions();
+            assertEquals(3, extensions.size());
+            
+            FeatureExtension textEx = extensions.get("my-text-ex");
+            assertEquals(FeatureExtension.Kind.OPTIONAL, textEx.getKind());
+            assertEquals(FeatureExtension.Type.TEXT, textEx.getType());
+            assertEquals(List.of("ABC", "DEF"), textEx.getText());
+            
+            FeatureExtension artEx = extensions.get("my-art-ex");
+            assertEquals(FeatureExtension.Kind.MANDATORY, artEx.getKind());
+            assertEquals(FeatureExtension.Type.ARTIFACTS, artEx.getType());
+            List<FeatureArtifact> arts = artEx.getArtifacts();
+            assertEquals(2, arts.size());
+            
+            FeatureArtifact art1 = arts.get(0);
+            assertEquals("g:a:1", art1.getID().toString());
+            assertEquals(1, art1.getMetadata().size());
+            assertEquals(12345L, art1.getMetadata().get("my-md"));
+            
+            FeatureArtifact art2 = arts.get(1);
+            assertEquals("g:a:zip:foobar:2", art2.getID().toString());
+            assertEquals(0, art2.getMetadata().size());
+            
+            FeatureExtension jsonEx = extensions.get("my-json-ex");
+            assertEquals(FeatureExtension.Kind.TRANSIENT, jsonEx.getKind());
+            assertEquals(FeatureExtension.Type.JSON, jsonEx.getType());
+            assertEquals("{\"foo\":[1,2,3]}", jsonEx.getJSON());
+        }    	
+        
+        testWriteFeature(f, res);
+    }
+
+	private void testWriteFeature(Feature feature, URL expectedURL) throws IOException {
+		StringWriter sw = new StringWriter();
+        features.writeFeature(feature, sw);
+        
+        String expected = new String(expectedURL.openStream().readAllBytes()).replaceAll("\\s","");
+        String actual = sw.toString().replaceAll("\\s","");
+        assertEquals(expected, actual);
+	}
+    
+    @Test
+    public void testCreateFeatureBundle() {
+        BuilderFactory factory = features.getBuilderFactory();
+
+        FeatureBuilder builder = factory.newFeatureBuilder(
+        		features.getID("org.acme", "acmeapp", "1.0.1"));
+        builder.setName("The Acme Application");
+        builder.setLicense("https://opensource.org/licenses/Apache-2.0");
+        builder.setComplete(true);
+
+        FeatureBundle b1 = factory.newBundleBuilder(
+                features.getIDfromMavenCoordinates("org.osgi:org.osgi.util.function:1.1.0"))
+                .build();
+        FeatureBundle b2 = factory.newBundleBuilder(
+        		features.getIDfromMavenCoordinates("org.osgi:org.osgi.util.promise:1.1.1"))
+                .build();
+
+        FeatureBundle b3 = factory.newBundleBuilder(
+        		features.getIDfromMavenCoordinates("org.apache.commons:commons-email:1.1.5"))
+                .addMetadata("org.acme.javadoc.link",
+                        "https://commons.apache.org/proper/commons-email/javadocs/api-1.5")
+                .build();
+
+        FeatureBundle b4 = factory.newBundleBuilder(
+        		features.getIDfromMavenCoordinates("com.acme:acmelib:1.7.2"))
+                .build();
+
+        builder.addBundles(b1, b2, b3, b4);
+
+        Feature f = builder.build();
+        System.out.println("***" + f);
+    }
+    
+	@Test
+	public void testCreateFeature() {
+		FeatureService fs = features;
+		BuilderFactory factory = fs.getBuilderFactory();
+		
+		FeatureBuilder builder = factory.newFeatureBuilder(fs.getID("org.acme", "acmeapp", "1.0.0"));
+		builder.setName("The ACME app");
+		builder.setDescription("This is the main ACME app, from where all functionality can be reached.");
+		Feature f = builder.build();
+		
+		assertEquals(fs.getIDfromMavenCoordinates("org.acme:acmeapp:1.0.0"), f.getID());
+		assertEquals("The ACME app", f.getName().get());
+		assertEquals("This is the main ACME app, from where all functionality can be reached.", f.getDescription().get());
+	}
+	
+	@Test
+	public void testCreateFeatureWithBundles() {
+		FeatureService fs = features;
+		BuilderFactory factory = fs.getBuilderFactory();
+		FeatureBuilder builder = factory.newFeatureBuilder(fs.getID("org.acme", "acmeapp", "1.0.1"));
+		builder.setName("The Acme Application");
+		builder.setLicense("https://opensource.org/licenses/Apache-2.0");
+		builder.setComplete(true);
+		FeatureBundle b1 = factory
+				.newBundleBuilder(fs.getIDfromMavenCoordinates("org.osgi:org.osgi.util.function:1.1.0")).build();
+		FeatureBundle b2 = factory
+				.newBundleBuilder(fs.getIDfromMavenCoordinates("org.osgi:org.osgi.util.promise:1.1.1")).build();
+		FeatureBundle b3 = factory
+				.newBundleBuilder(fs.getIDfromMavenCoordinates("org.apache.commons:commons-email:1.1.5"))
+				.addMetadata("org.acme.javadoc.link",
+						"https://commons.apache.org/proper/commons-email/javadocs/api-1.5")
+				.build();
+		FeatureBundle b4 = factory.newBundleBuilder(fs.getIDfromMavenCoordinates("com.acme:acmelib:1.7.2")).build();
+		builder.addBundles(b1, b2, b3, b4);
+		Feature f = builder.build();
+		
+		assertEquals("https://opensource.org/licenses/Apache-2.0", f.getLicense().get());
+		assertTrue(f.isComplete());
+		
+		assertEquals(4, f.getBundles().size());
+		
+		FeatureBundle fb1 = f.getBundles().get(0);
+		assertEquals("org.osgi:org.osgi.util.function:1.1.0", fb1.getID().toString());
+		assertEquals(0, fb1.getMetadata().size());
+		
+		FeatureBundle fb2 = f.getBundles().get(1);
+		assertEquals("org.osgi:org.osgi.util.promise:1.1.1", fb2.getID().toString());
+		assertEquals(0, fb2.getMetadata().size());
+
+		FeatureBundle fb3 = f.getBundles().get(2);
+		assertEquals("org.apache.commons:commons-email:1.1.5", fb3.getID().toString());
+		assertEquals(1, fb3.getMetadata().size());
+		assertEquals("https://commons.apache.org/proper/commons-email/javadocs/api-1.5", fb3.getMetadata().get("org.acme.javadoc.link"));
+
+		FeatureBundle fb4 = f.getBundles().get(3);
+		assertEquals("com.acme:acmelib:1.7.2", fb4.getID().toString());
+		assertEquals(0, fb4.getMetadata().size());
+	}
+}
diff --git a/features/src/test/resources/features/test-exfeat1.json b/features/src/test/resources/features/test-exfeat1.json
new file mode 100644
index 0000000..7112392
--- /dev/null
+++ b/features/src/test/resources/features/test-exfeat1.json
@@ -0,0 +1,26 @@
+{
+    "id" : "org.apache.sling:test-extension-feature:1.0.0",
+    "extensions" : {
+        "my-text-ex": {
+            "kind": "optional",
+            "text": [
+            	"ABC", "DEF"
+        	] 
+        },
+        "my-art-ex": {
+            "kind": "mandatory", 
+            "artifacts": [
+            	{
+					"id": "g:a:1",
+					"my-md": 12345
+				}, {
+					"id": "g:a:zip:foobar:2"
+				}
+			]
+        },
+        "my-json-ex": {
+           "kind": "transient", 
+            "json": {"foo": [1, 2, 3] }
+        }
+    }
+}
\ No newline at end of file
diff --git a/features/src/test/resources/features/test-exfeat2.json b/features/src/test/resources/features/test-exfeat2.json
new file mode 100644
index 0000000..5fa93e4
--- /dev/null
+++ b/features/src/test/resources/features/test-exfeat2.json
@@ -0,0 +1,9 @@
+{
+    "id" : "org.apache.sling:test-extension-feature2:1.0.0",
+    "extensions" : {
+        "my-text-ex": 
+        {
+            "text": "DEF"
+        }
+    }
+}
\ No newline at end of file
diff --git a/features/src/test/resources/features/test-feature.json b/features/src/test/resources/features/test-feature.json
new file mode 100644
index 0000000..323fb79
--- /dev/null
+++ b/features/src/test/resources/features/test-feature.json
@@ -0,0 +1,28 @@
+{
+    "id" : "org.apache.sling:test-feature:1.1",
+    "description": "The feature description",
+
+    "bundles" :[
+            {
+              "id" : "org.osgi:osgi.promise:7.0.1",
+              "hash" : "4632463464363646436",
+              "start-order" : 1
+            },
+            {
+              "id" : "org.slf4j:slf4j-api:1.7.29"
+            },
+            {
+              "id" : "org.slf4j:slf4j-simple:1.7.29"
+            }
+    ],
+    "configurations" : {
+        "my.pid" : {
+           "foo" : 5,
+           "bar" : "test",
+           "number:Integer" : 7
+        },
+        "my.factory.pid~name" : {
+           "a.value" : ["yeah", "yeah", "yeah"]
+        }
+    }
+}
\ No newline at end of file
diff --git a/features/src/test/resources/features/test-feature2.json b/features/src/test/resources/features/test-feature2.json
new file mode 100644
index 0000000..e12d2d8
--- /dev/null
+++ b/features/src/test/resources/features/test-feature2.json
@@ -0,0 +1,19 @@
+{
+    /** This is a JSMin comment */
+    "id" : "org.apache.sling:test-feature2:osgifeature:cls_abc:1.1",
+    "name": "test-feature2",
+    "description": "The feature description",
+    "categories": ["foo", "bar"],
+    "docURL": "http://foo.bar.com/abc",
+    "license": "Apache-2.0; link=\"http://opensource.org/licenses/apache2.0.php\"",
+    "scm": "url=https://github.com/apache/sling-aggregator, connection=scm:git:https://github.com/apache/sling-aggregator.git, developerConnection=scm:git:git@github.com:apache/sling-aggregator.git",
+    "vendor": "The Apache Software Foundation",
+
+	// complex entities below here
+	
+    "configurations" : {
+        "my.pid" : {
+           "bar" : "toast"
+        }
+    }
+}
\ No newline at end of file