You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by bd...@apache.org on 2021/07/15 15:58:09 UTC

[sling-org-apache-sling-graphql-schema-aggregator] 01/01: SLING-10551 - initial commit, moving from sling-whiteboard (commit c0219939)

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

bdelacretaz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-graphql-schema-aggregator.git

commit b8d51775c81ca8b00fe58b800bfd59f04dd043c5
Author: Bertrand Delacretaz <bd...@apache.org>
AuthorDate: Thu Jul 15 17:55:46 2021 +0200

    SLING-10551 - initial commit, moving from sling-whiteboard (commit c0219939)
---
 .asf.yaml                                          |   7 +
 .gitignore                                         |  12 +
 CODE_OF_CONDUCT.md                                 |  22 ++
 CONTRIBUTING.md                                    |  24 ++
 Jenkinsfile                                        |  20 ++
 README.md                                          | 121 +++++++++
 pom.xml                                            | 298 +++++++++++++++++++++
 .../schema/aggregator/api/SchemaAggregator.java    |  42 +++
 .../schema/aggregator/api/package-info.java        |  22 ++
 .../impl/BundleEntryPartialProvider.java           |  89 ++++++
 .../aggregator/impl/DefaultSchemaAggregator.java   | 173 ++++++++++++
 .../graphql/schema/aggregator/impl/Partial.java    |  47 ++++
 .../schema/aggregator/impl/PartialConstants.java   |  28 ++
 .../schema/aggregator/impl/PartialReader.java      | 173 ++++++++++++
 .../aggregator/impl/ProviderBundleTracker.java     | 127 +++++++++
 .../schema/aggregator/impl/URLReaderSupplier.java  |  47 ++++
 .../servlet/SchemaAggregatorServlet.java           | 162 +++++++++++
 .../graphql/schema/aggregator/LogCapture.java      |  70 +++++
 .../apache/sling/graphql/schema/aggregator/U.java  | 134 +++++++++
 .../impl/BundleEntryPartialProviderTest.java       |  34 +++
 .../schema/aggregator/impl/CapitalizeTest.java     |  43 +++
 .../impl/DefaultSchemaAggregatorTest.java          | 175 ++++++++++++
 .../schema/aggregator/impl/PartialReaderTest.java  | 129 +++++++++
 .../aggregator/impl/ProviderBundleTrackerTest.java | 103 +++++++
 .../impl/SchemaAggregatorServletTest.java          |  58 ++++
 .../aggregator/it/SchemaAggregatorServletIT.java   |  74 +++++
 .../aggregator/it/SchemaAggregatorTestSupport.java | 150 +++++++++++
 src/test/resources/logback.xml                     |  31 +++
 src/test/resources/partials/a.sdl.txt              |  21 ++
 src/test/resources/partials/b.sdl.txt              |  10 +
 src/test/resources/partials/c.sdl.txt              |  10 +
 src/test/resources/partials/circularA.txt          |   6 +
 src/test/resources/partials/circularB.txt          |   6 +
 .../partials/duplicate.section.partial.txt         |   6 +
 src/test/resources/partials/example.partial.txt    |  27 ++
 src/test/resources/partials/utf8.partial.txt       |   2 +
 src/test/resources/several-providers-output.txt    |  16 ++
 37 files changed, 2519 insertions(+)

diff --git a/.asf.yaml b/.asf.yaml
new file mode 100644
index 0000000..7a30853
--- /dev/null
+++ b/.asf.yaml
@@ -0,0 +1,7 @@
+github:
+  description: "Apache Sling GraphQL Schema Aggregator"
+  homepage: "https://sling.apache.org/"
+  labels:
+    - sling
+    - java
+    - graphql
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..08082a5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+.project
+.classpath
+.settings
+target
+bin
+*.iml
+.idea
+.DS_Store
+dependency-reduced-pom.xml
+.vscode
+node_modules
+openwhisk_action.zip
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..0fa18e5
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,22 @@
+<!--/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+  ~ 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.
+  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/-->
+Apache Software Foundation Code of Conduct
+====
+
+Being an Apache project, Apache Sling adheres to the Apache Software Foundation's [Code of Conduct](https://www.apache.org/foundation/policies/conduct.html).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..ac82a1a
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,24 @@
+<!--/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+  ~ 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.
+  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/-->
+Contributing
+====
+
+Thanks for choosing to contribute!
+
+You will find all the necessary details about how you can do this at https://sling.apache.org/contributing.html.
diff --git a/Jenkinsfile b/Jenkinsfile
new file mode 100644
index 0000000..f582519
--- /dev/null
+++ b/Jenkinsfile
@@ -0,0 +1,20 @@
+/**
+ * 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.
+ */
+
+slingOsgiBundleBuild()
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0e5d6f3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,121 @@
+[![Apache Sling](https://sling.apache.org/res/logos/sling.png)](https://sling.apache.org)
+
+&#32;[![Build Status](https://ci-builds.apache.org/job/Sling/job/modules/job/sling-org-apache-sling-graphql-schema-aggregator/job/master/badge/icon)](https://ci-builds.apache.org/job/Sling/job/modules/job/sling-org-apache-sling-graphql-schema-aggregator/job/master/)&#32;[![Test Status](https://img.shields.io/jenkins/tests.svg?jobUrl=https://ci-builds.apache.org/job/Sling/job/modules/job/sling-org-apache-sling-graphql-schema-aggregator/job/master/)](https://ci-builds.apache.org/job/Sling/ [...]
+
+Apache Sling GraphQL Schema Aggregator
+----
+
+_This module is one of several which provide [GraphQL support for Apache Sling](https://github.com/search?q=topic%3Asling+topic%3Agraphql+org%3Aapache&type=Repositories)._
+
+The Sling GraphQL Schema Aggregator provides services to combine partial GraphQL
+schema ("partials") supplied by _provider bundles_.
+
+The partials are structured text files, supplied as OSGi bundle resources, that provide sections (like query,
+mutation, types sections) that are aggregated to build a GraphQL Schema using the SDL (Schema 
+Definition  Language) syntax.
+
+A GraphQL schema must contain one `Query` statement and can contain a most one `Mutation` statement,
+so partials cannot be assembled by just concatenating them. The schema assembler defines a simple
+section-based syntax for the partials, so that they can be aggregated efficiently.
+
+This module also provides a `SchemaAggregatorServlet` that generates schemas by aggregating partials, by
+mapping request selectors to lists of partial names. The result can be used directly by the Sling GraphQL
+Core module, which makes an internal Sling request to get the schema.
+
+Partials can also depend on others by declaring the required dependencies by name, to make sure the
+aggregated schemas are valid.
+
+With this mechanism, an OSGi bundle can provide both a partial schema and the Sling data fetching and
+processing services that go with it. This allows a GraphQL "API plane" (usually defined by a specific
+instance of the Sling `GraphQLServlet`) to be built out of several OSGi bundles which each focus on a
+specific set of queries, mutations and types.
+
+## Provider bundles
+
+To provide partials, a bundle sets a `Sling-GraphQL-Schema` header in its OSGi manifest, with a value that
+points to a path under which partials are found in the bundle resources.
+
+A partial is a text file with the structure described below. As usual, The Truth Is In The Tests, see
+the [example partial in the test sources](./src/test/resources/partials/example.partial.txt) for a
+reference that's guaranteed to be valid.
+
+    # Example GraphQL partial schema
+    # Any text before the first section is ignored.
+
+    PARTIAL: Example GraphQL schema partial
+    The contents of the PARTIAL section are ignored, only its
+    description (the text follows the PARTIAL section name
+    above) is used.
+
+    PARTIAL is the only required section.
+
+    REQUIRE: base.scalars, base.schema
+    The description of the optional REQUIRE section is a
+    comma-separated list of partials which are required for this
+    one to be valid. The content of this section is ignored, only
+    its description is used to build that list.
+
+    PROLOGUE:
+    The content of the optional PROLOGUE section is concatenated
+    in the aggregated schema, before all the other sections.
+
+    QUERY:
+    The content of the optional QUERY sections of all partials
+    is aggregated in a `type QUERY {...}` section in the output.
+
+    MUTATION:
+    Like for the QUERY section, the content of the optional
+    MUTATION sections of all partials is aggregated in
+    a `type MUTATION {...}` section in the output.
+
+    TYPES:
+    The content of the TYPES sections of all partials is
+    aggregated in the output, after all the other sections.
+
+## Partial names
+
+The name of a partial, used in the selector mappings of the
+`SchemaAggregatorServlet`, is defined by its filename in the
+bundle resources, omitting the file extension. A partial
+found under `/path-set-by-the-bundle-header/this.is.txt` in its bundle is named
+`this.is` . Partial names must be unique system-wide, so it's
+good to use some form of namespacing or agreed upon naming
+convention for them.
+
+## SchemaAggregatorServlet configuration
+Here's a configuration example from the test code.
+
+    // Configure the org.apache.sling.graphql.schema.aggregator.SchemaAggregatorServlet
+    factoryConfiguration(AGGREGATOR_SERVLET_CONFIG_PID)
+        .put("sling.servlet.resourceTypes", "sling/servlet/default")
+
+        // The extension must be the one used by the GraphQLServlet to retrieve schemas
+        // which by default is 'GQLschema'
+        .put("sling.servlet.extensions", GQL_SCHEMA_EXT)
+
+        // The GraphQLServlet uses an internal GET request for the schema
+        .put("sling.servlet.methods", new String[] { "GET" })
+
+        // Several selectors can be configured to setup API planes, each with their own GraphQL schema
+        .put("sling.servlet.selectors", new String[] { "X", "Y" })
+
+        // This mapping defines which partials to use to build the schema for each selector
+        // The lists can use either the exact names of partials, or (Java flavored) regular expressions on
+        // their names, identified by a starting and ending slash.
+        .put("selectors.to.partials.mapping", new String[] { "X:firstA,secondB", "Y:secondA,firstB,/second.*/" })
+
+## TODO / wishlist
+Invalid section names in partials should cause parsing errors.
+
+The REQUIRES section of partial should be translated to OSGi capabilities, to be able to detect
+missing requirements at system assembly time or using the
+[Feature Model Analyser](https://github.com/apache/sling-org-apache-sling-feature-analyser).
+
+We'll probably need a utility to aggregate schemas for automated tests, to allow test code
+to include required schema partials.
+
+Errors like invalid or missing partials are currently only logged, it would be useful to
+have them cause louder errors, like schema aggregation failing with error messages when
+things went wrong, and/or this module providing a Health Check service to detect problems.
+
+Caching is probably not needed in this module, as the GraphQL Core caches compiled schemas.
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..cd56bb3
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,298 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    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/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.apache.sling</groupId>
+    <artifactId>sling-bundle-parent</artifactId>
+    <version>43</version>
+    <relativePath />
+  </parent>
+
+  <artifactId>org.apache.sling.graphql.schema.aggregator</artifactId>
+  <version>0.0.1-SNAPSHOT</version>
+
+  <name>Apache Sling GraphQL Schema Aggregator</name>
+  <description>Builds GraphQL Schemas from partials provided by OSGi bundles</description>
+
+  <properties>
+    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+    <org.ops4j.pax.exam.version>4.13.3</org.ops4j.pax.exam.version>
+    <site.javadoc.exclude>org.apache.sling.graphql.schema.aggregator.*</site.javadoc.exclude>
+    <!-- additional options that can be passed to Pax before executing the tests -->
+    <pax.vm.options />
+  </properties>
+
+  <scm>
+      <connection>scm:git:https://gitbox.apache.org/repos/asf/sling-org-apache-sling-graphql-schema-aggregator.git</connection>
+      <developerConnection>scm:git:https://gitbox.apache.org/repos/asf/sling-org-apache-sling-graphql-schema-aggregator.git</developerConnection>
+      <url>https://gitbox.apache.org/repos/asf?p=sling-org-apache-sling-graphql-schema-aggregator.git</url>
+    <tag>org.apache.sling.graphql.core-0.0.2</tag>
+  </scm>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>biz.aQute.bnd</groupId>
+        <artifactId>bnd-maven-plugin</artifactId>
+      </plugin>
+      <plugin>
+        <groupId>biz.aQute.bnd</groupId>
+        <artifactId>bnd-baseline-maven-plugin</artifactId>
+        <configuration>
+          <!-- TODO remove this once we have a release of this module -->
+          <failOnMissing>false</failOnMissing>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-failsafe-plugin</artifactId>
+        <executions>
+          <execution>
+            <goals>
+              <goal>integration-test</goal>
+              <goal>verify</goal>
+            </goals>
+          </execution>
+        </executions>
+        <configuration>
+          <systemPropertyVariables>
+            <bundle.filename>${basedir}/target/${project.build.finalName}.jar</bundle.filename>
+            <pax.vm.options>${pax.vm.options}</pax.vm.options>
+          </systemPropertyVariables>
+          <redirectTestOutputToFile>true</redirectTestOutputToFile>
+          <!-- pax exam bug, often times out at exit -->
+          <forkedProcessExitTimeoutInSeconds>1</forkedProcessExitTimeoutInSeconds>
+        </configuration>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.servicemix.tooling</groupId>
+        <artifactId>depends-maven-plugin</artifactId>
+      </plugin>
+      <plugin>
+        <groupId>org.apache.rat</groupId>
+        <artifactId>apache-rat-plugin</artifactId>
+        <configuration>
+          <excludes>                        
+            <exclude>src/test/resources/several-providers-output.txt</exclude>
+            <exclude>src/test/resources/partials/**</exclude>
+          </excludes>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.osgi</groupId>
+      <artifactId>osgi.core</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.osgi</groupId>
+      <artifactId>osgi.cmpn</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.osgi</groupId>
+      <artifactId>org.osgi.service.component.annotations</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.osgi</groupId>
+      <artifactId>org.osgi.service.metatype.annotations</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.osgi</groupId>
+      <artifactId>org.osgi.annotation.versioning</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.osgi</groupId>
+      <artifactId>org.osgi.annotation.bundle</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.felix</groupId>
+      <artifactId>org.apache.felix.framework</artifactId>
+      <version>6.0.3</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.jetbrains</groupId>
+      <artifactId>annotations</artifactId>
+      <version>16.0.3</version>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.sling</groupId>
+      <artifactId>org.apache.sling.api</artifactId>
+      <version>2.18.4</version>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.sling</groupId>
+      <artifactId>org.apache.sling.engine</artifactId>
+      <version>2.6.22</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.sling</groupId>
+      <artifactId>org.apache.sling.resource.presence</artifactId>
+      <version>0.0.2</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>javax.servlet</groupId>
+      <artifactId>javax.servlet-api</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>commons-io</groupId>
+      <artifactId>commons-io</artifactId>
+      <version>2.10.0</version>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-api</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.sling</groupId>
+      <artifactId>org.apache.sling.servlet-helpers</artifactId>
+      <version>1.4.2</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>junit</groupId>
+      <artifactId>junit</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-inline</artifactId>
+      <version>3.5.11</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.sling</groupId>
+      <artifactId>org.apache.sling.testing.sling-mock.junit4</artifactId>
+      <version>2.4.0</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>javax.inject</groupId>
+      <artifactId>javax.inject</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.sling</groupId>
+      <artifactId>org.apache.sling.testing.paxexam</artifactId>
+      <version>3.1.0</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.ops4j.pax.exam</groupId>
+      <artifactId>pax-exam</artifactId>
+      <version>${org.ops4j.pax.exam.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.ops4j.pax.exam</groupId>
+      <artifactId>pax-exam-cm</artifactId>
+      <version>${org.ops4j.pax.exam.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.ops4j.pax.exam</groupId>
+      <artifactId>pax-exam-container-forked</artifactId>
+      <version>${org.ops4j.pax.exam.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.ops4j.pax.exam</groupId>
+      <artifactId>pax-exam-junit4</artifactId>
+      <version>${org.ops4j.pax.exam.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.ops4j.pax.exam</groupId>
+      <artifactId>pax-exam-link-mvn</artifactId>
+      <version>${org.ops4j.pax.exam.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.ops4j.pax.url</groupId>
+      <artifactId>pax-url-wrap</artifactId>
+      <version>2.3.0</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.servicemix.bundles</groupId>
+      <artifactId>org.apache.servicemix.bundles.hamcrest</artifactId>
+      <version>1.3_1</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.httpcomponents</groupId>
+      <artifactId>httpclient-osgi</artifactId>
+      <version>4.5.10</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>ch.qos.logback</groupId>
+      <artifactId>logback-classic</artifactId>
+      <version>1.2.3</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.graphql-java</groupId>
+      <artifactId>graphql-java</artifactId>
+      <version>15.0</version>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+
+  <reporting>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-javadoc-plugin</artifactId>
+        <reportSets>
+          <reportSet>
+            <reports>
+              <report>javadoc</report>
+            </reports>
+          </reportSet>
+        </reportSets>
+        <configuration>
+          <stylesheet>maven</stylesheet>
+          <excludePackageNames>*.core:*.impl:*.internal:${site.javadoc.exclude}</excludePackageNames>
+        </configuration>
+      </plugin>
+    </plugins>
+  </reporting>
+
+</project>
diff --git a/src/main/java/org/apache/sling/graphql/schema/aggregator/api/SchemaAggregator.java b/src/main/java/org/apache/sling/graphql/schema/aggregator/api/SchemaAggregator.java
new file mode 100644
index 0000000..1667669
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/schema/aggregator/api/SchemaAggregator.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.graphql.schema.aggregator.api;
+
+import java.io.IOException;
+import java.io.Writer;
+
+import org.jetbrains.annotations.NotNull;
+import org.osgi.annotation.versioning.ProviderType;
+
+@ProviderType
+public interface SchemaAggregator {
+    /** Aggregate the schemas supplied by partial schema providers which match the exact names
+     *  or patterns supplied.
+     *
+     *  @param target where to write the output
+     *
+     *  @param providerNamesOrRegexp a value that starts and ends with a slash is used a a regular
+     *      expression to match provider names (after removing the starting and ending slash), other
+     *      values are used as exact provider names, which are then required.
+     *
+     *  @throws IOException if an exact provider name is not found
+     */
+    void aggregate(@NotNull Writer target, @NotNull String ... providerNamesOrRegexp) throws IOException;
+}
diff --git a/src/main/java/org/apache/sling/graphql/schema/aggregator/api/package-info.java b/src/main/java/org/apache/sling/graphql/schema/aggregator/api/package-info.java
new file mode 100644
index 0000000..cd0895d
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/schema/aggregator/api/package-info.java
@@ -0,0 +1,22 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
+@Version("0.0.1")
+package org.apache.sling.graphql.schema.aggregator.api;
+
+import org.osgi.annotation.versioning.Version;
diff --git a/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/BundleEntryPartialProvider.java b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/BundleEntryPartialProvider.java
new file mode 100644
index 0000000..cb522bd
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/BundleEntryPartialProvider.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.graphql.schema.aggregator.impl;
+
+import java.io.IOException;
+import java.net.URL;
+
+import org.osgi.framework.Bundle;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** {@PartialSchemaProvider} build out of a Bundle entry, which must be a valid
+ *  partial schema file.
+ */
+class BundleEntryPartialProvider extends PartialReader implements Comparable<BundleEntryPartialProvider> {
+    private static final Logger log = LoggerFactory.getLogger(BundleEntryPartialProvider.class.getName());
+    private final String key;
+    private final long bundleId;
+
+    private BundleEntryPartialProvider(Bundle b, URL bundleEntry) throws IOException {
+        super(getPartialName(bundleEntry), new URLReaderSupplier(bundleEntry));
+        this.bundleId = b.getBundleId();
+        this.key = String.format("%s(%d):%s", b.getSymbolicName(), b.getBundleId(), bundleEntry.toString());
+    }
+
+    /** The partial's name is whatever follows the last slash, excluding the file extension */
+    static String getPartialName(URL url) {
+        final String [] parts = url.toString().split("/");
+        String result = parts[parts.length - 1];
+        final int lastDot = result.lastIndexOf(".");
+        return lastDot > 0 ? result.substring(0, lastDot) : result;
+    }
+
+    /** @return a BundleEntryPartialProvider for the entryPath in
+     *  the supplied Bundle, or null if none can be built.
+      */
+    static BundleEntryPartialProvider forBundle(Bundle b, String entryPath) throws IOException {
+        final URL entry = b.getEntry(entryPath);
+        if(entry == null) {
+            log.info("Entry {} not found for bundle {}", entryPath, b.getSymbolicName());
+            return null;
+        } else {
+            return new BundleEntryPartialProvider(b, entry);
+        }
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if(other instanceof BundleEntryPartialProvider) {
+            return ((BundleEntryPartialProvider)other).key.equals(key);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return key.hashCode();
+    }
+
+    public String toString() {
+        return String.format("%s: %s", getClass().getSimpleName(), key);
+    }
+
+    public long getBundleId() {
+        return bundleId;
+    }
+
+    @Override
+    public int compareTo(BundleEntryPartialProvider o) {
+        return getName().compareTo(o.getName());
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/DefaultSchemaAggregator.java b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/DefaultSchemaAggregator.java
new file mode 100644
index 0000000..8942b20
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/DefaultSchemaAggregator.java
@@ -0,0 +1,173 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.graphql.schema.aggregator.impl;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.sling.graphql.schema.aggregator.api.SchemaAggregator;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static org.apache.sling.graphql.schema.aggregator.impl.PartialConstants.S_MUTATION;
+import static org.apache.sling.graphql.schema.aggregator.impl.PartialConstants.S_PROLOGUE;
+import static org.apache.sling.graphql.schema.aggregator.impl.PartialConstants.S_QUERY;
+import static org.apache.sling.graphql.schema.aggregator.impl.PartialConstants.S_TYPES;
+
+@Component(service = SchemaAggregator.class)
+public class DefaultSchemaAggregator implements SchemaAggregator {
+    private static final Logger log = LoggerFactory.getLogger(DefaultSchemaAggregator.class.getName());
+    public static final int MAX_REQUIREMENTS_RECURSION_LEVEL = 5;
+
+    @Reference
+    private ProviderBundleTracker tracker;
+
+    static String capitalize(String s) {
+        if(s == null) {
+            return null;
+        } else if(s.length() >  1) {
+            return String.format("%s%s", s.substring(0, 1).toUpperCase(), s.substring(1).toLowerCase());
+        } else {
+            return s.toUpperCase();
+        }
+    }
+
+    private void copySection(Set<Partial> selected, String sectionName, boolean inBlock, Writer target) throws IOException {
+        String prefixToWrite = inBlock ? String.format("%ntype %s {%n", capitalize(sectionName)) : null;
+        boolean anyOutput = false;
+        for(Partial p : selected) {
+            final Optional<Partial.Section> section = p.getSection(sectionName);
+            if(section.isPresent()) {
+                anyOutput = true;
+                if(prefixToWrite != null) {
+                    target.write(prefixToWrite);
+                    prefixToWrite = null;
+                }
+                writeSourceInfo(target, p);
+                IOUtils.copy(section.get().getContent(), target);
+            }
+        }
+        if(anyOutput && inBlock) {
+            target.write(String.format("%n}%n"));
+        }
+    }
+
+    private void writeSourceInfo(Writer target, Partial p) throws IOException {
+        target.write(String.format("%n# %s.source=%s%n", getClass().getSimpleName(), p.getName()));
+    }
+
+    @Override
+    public void aggregate(Writer target, String ...providerNamesOrRegexp) throws IOException {
+        final String info = String.format("Schema aggregated by %s%n", getClass().getSimpleName());
+        target.write(String.format("# %s", info));
+
+        // build list of selected providers
+        final Map<String, Partial> providers = tracker.getSchemaProviders();
+        if(log.isDebugEnabled()) {
+            log.debug("Aggregating schemas, request={}, providers={}", Arrays.asList(providerNamesOrRegexp), providers.keySet());
+        }
+        final Set<String> missing = new HashSet<>();
+        final Set<Partial> selected = selectProviders(providers, missing, providerNamesOrRegexp);
+
+        if(!missing.isEmpty()) {
+            log.debug("Requested providers {} not found in {}", missing, providers.keySet());
+            throw new IOException(String.format("Missing providers: %s", missing));
+        }
+
+        // copy sections that belong in the output SDL
+        copySection(selected, S_PROLOGUE, false, target);
+        copySection(selected, S_QUERY, true, target);
+        copySection(selected, S_MUTATION, true, target);
+        copySection(selected, S_TYPES, false, target);
+
+        final StringBuilder partialNames = new StringBuilder();
+        selected.forEach(p -> {
+            if(partialNames.length() > 0) {
+                partialNames.append(",");
+            }
+            partialNames.append(p.getName());
+        });
+        target.write(String.format("%n# End of Schema aggregated from {%s} by %s", partialNames, getClass().getSimpleName()));
+    }
+
+    Set<Partial> selectProviders(Map<String, Partial> providers, Set<String> missing, String ... providerNamesOrRegexp) {
+        final Set<Partial> result= new LinkedHashSet<>();
+        for(String str : providerNamesOrRegexp) {
+            final Pattern p = toRegexp(str);
+            if(p != null) {
+                log.debug("Selecting providers matching {}", p);
+                providers.entrySet().stream()
+                    .filter(e -> p.matcher(e.getKey()).matches())
+                    .sorted(Comparator.comparing(e -> e.getValue().getName()))
+                    .forEach(e -> addWithRequirements(providers, result, missing, e.getValue(), 0))
+                ;
+            } else {
+                log.debug("Selecting provider with key={}", str);
+                final Partial psp = providers.get(str);
+                if(psp == null) {
+                    missing.add(str);
+                    continue;
+                }
+                addWithRequirements(providers, result, missing, psp, 0);
+            }
+        }
+        return result;
+    }
+
+    private void addWithRequirements(Map<String, Partial> providers, Set<Partial> addTo, Set<String> missing, Partial p, int recursionLevel) {
+
+        // simplistic cycle detection
+        if(recursionLevel > MAX_REQUIREMENTS_RECURSION_LEVEL) {
+            throw new RuntimeException(String.format(
+                "Requirements depth over %d, requirements cycle suspected at partial %s", 
+                MAX_REQUIREMENTS_RECURSION_LEVEL,
+                p.getName()
+            ));
+        }
+
+        addTo.add(p);
+        for(String req : p.getRequiredPartialNames()) {
+            final Partial preq = providers.get(req);
+            if(preq == null) {
+                missing.add(req);
+            } else {
+                addWithRequirements(providers, addTo, missing, preq, recursionLevel + 1);
+            }
+        }
+    }
+
+    static Pattern toRegexp(String input) {
+        if(input.startsWith("/") && input.endsWith("/")) {
+            return Pattern.compile(input.substring(1, input.length() - 1));
+        }
+        return null;
+    }
+}
diff --git a/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/Partial.java b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/Partial.java
new file mode 100644
index 0000000..9b3d418
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/Partial.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.graphql.schema.aggregator.impl;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Optional;
+import java.util.Set;
+
+/** Wrapper for the partials format, that parses a partial file and
+ *  provides access to its sections.
+ *  See the example.partial.txt and the tests for a description of
+ *  the format.
+  */
+interface Partial {
+    /** A section in the partial */
+    interface Section {
+        String getName();
+        String getDescription();
+        Reader getContent() throws IOException;
+    }
+
+    /** The name of this partial */
+    String getName();
+
+    /** Return a specific section of the partial, by name */
+    Optional<Section> getSection(String name);
+
+    /** Names of the Partials on which this one depends */
+    Set<String> getRequiredPartialNames();
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/PartialConstants.java b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/PartialConstants.java
new file mode 100644
index 0000000..aac50b2
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/PartialConstants.java
@@ -0,0 +1,28 @@
+/*
+ * 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.graphql.schema.aggregator.impl;
+
+class PartialConstants {
+    public static final String S_PARTIAL = "PARTIAL";
+    public static final String S_REQUIRES = "REQUIRES";
+    public static final String S_PROLOGUE = "PROLOGUE";
+    public static final String S_TYPES = "TYPES";
+    public static final String S_MUTATION = "MUTATION";
+    public static final String S_QUERY = "QUERY";
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/PartialReader.java b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/PartialReader.java
new file mode 100644
index 0000000..783a9d3
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/PartialReader.java
@@ -0,0 +1,173 @@
+/*
+ * 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.graphql.schema.aggregator.impl;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+import java.util.function.Supplier;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.BoundedReader;
+
+/** Reader for the partials format, which parses a partial file and
+ *  provides access to its sections.
+ *  See the example.partial.txt and the tests for a description of
+ *  the format.
+  */
+class PartialReader implements Partial {
+    private static final Pattern SECTION_LINE = Pattern.compile("([A-Z]+) *:(.*)");
+    private static final int EOL = '\n';
+
+    private final Map<String, Section> sections = new HashMap<>();
+    private final String name;
+    private final Set<String> requiredPartialNames;
+
+    /** The PARTIAL section is the only required one */
+    public static final String PARTIAL_SECTION = "PARTIAL";
+
+    static class SyntaxException extends IOException {
+        SyntaxException(String reason) {
+            super(reason);
+        }
+    }
+
+    static class ParsedSection implements Partial.Section {
+        private final Supplier<Reader> sectionSource;
+        private final String name;
+        private final String description;
+        private final int startCharIndex;
+        private final int endCharIndex;
+
+        ParsedSection(Supplier<Reader> sectionSource, String name, String description, int start, int end) {
+            this.sectionSource = sectionSource;
+            this.name = name;
+            this.description = description;
+            this.startCharIndex = start;
+            this.endCharIndex = end;
+        }
+
+        @Override
+        public String getName() {
+            return name;
+        }
+
+        @Override
+        public String getDescription() {
+            return description;
+        }
+
+        @Override
+        public Reader getContent() throws IOException {
+            final Reader r = sectionSource.get();
+            r.skip(startCharIndex);
+            return new BoundedReader(r, endCharIndex - startCharIndex);
+        }
+    }
+    
+    PartialReader(String name, Supplier<Reader> source) throws IOException {
+        this.name = name;
+        parse(source);
+        final Partial.Section requirements = sections.get(PartialConstants.S_REQUIRES);
+        if(requirements == null) {
+            requiredPartialNames = Collections.emptySet();
+        } else {
+            requiredPartialNames = new HashSet<>();
+            Stream.of(
+                requirements.getDescription().split(",")
+            )
+                .map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .forEach(requiredPartialNames::add)
+            ;
+        }
+    }
+
+    /* Detect lines that start with a <SECTION>: name
+     *  in the input, and save them as sections
+     */
+    private void parse(Supplier<Reader> source) throws IOException {
+        final Reader input = source.get();
+        StringBuilder line = new StringBuilder();
+        int c;
+        int charCount = 0;
+        int lastSectionStart = 0;
+        String sectionName = null;
+        String sectionDescription = "";
+        while((c = input.read()) != -1) {
+            if(c == EOL) {
+                final Matcher m = SECTION_LINE.matcher(line);
+                if(m.matches()) {
+                    // Add previous section
+                    addSectionIfNameIsSet(source, sectionName, sectionDescription, lastSectionStart, charCount - line.length());
+                    // And setup for the new section
+                    sectionName = m.group(1).trim();
+                    sectionDescription = m.group(2).trim();
+                    lastSectionStart = charCount + 1;
+                }
+                line = new StringBuilder();
+            } else {
+                line.append((char)c);
+            }
+            charCount++;
+        }
+
+        // Add last section
+        addSectionIfNameIsSet(source, sectionName, sectionDescription, lastSectionStart, Integer.MAX_VALUE);
+
+        // And validate
+        if(!sections.containsKey(PARTIAL_SECTION)) {
+            throw new SyntaxException(String.format("Missing required %s section", PARTIAL_SECTION));
+        }
+        
+    }
+
+    private void addSectionIfNameIsSet(Supplier<Reader> sectionSource, String name, String description, int start, int end) throws SyntaxException {
+        if(name != null) {
+            if(sections.containsKey(name)) {
+                throw new SyntaxException(String.format("Duplicate section %s", name));
+            }
+            sections.put(name, new ParsedSection(sectionSource, name, description, start, end));
+        }
+    }
+
+    @Override
+    public Optional<Section> getSection(String name) {
+        final Section s = sections.get(name);
+        return s == null ? Optional.empty() : Optional.of(s);
+    }
+
+    @Override
+    public String getName() {
+        return name;
+    }
+
+    @Override
+    public Set<String> getRequiredPartialNames() {
+        return requiredPartialNames;
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/ProviderBundleTracker.java b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/ProviderBundleTracker.java
new file mode 100644
index 0000000..7d38f9b
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/ProviderBundleTracker.java
@@ -0,0 +1,127 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.graphql.schema.aggregator.impl;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.osgi.annotation.bundle.Capability;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.BundleEvent;
+import org.osgi.framework.wiring.BundleRevision;
+import org.osgi.framework.wiring.BundleWire;
+import org.osgi.framework.wiring.BundleWiring;
+import org.osgi.namespace.extender.ExtenderNamespace;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.util.tracker.BundleTracker;
+import org.osgi.util.tracker.BundleTrackerCustomizer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Tracks bundles which provide partial schemas and collects the corresponding set of schemas.
+ */
+@Component(
+        service = {ProviderBundleTracker.class}
+)
+@Capability(
+        namespace = ExtenderNamespace.EXTENDER_NAMESPACE,
+        name = "sling.graphql-schema-aggregator",
+        version = "0.1"
+)
+public class ProviderBundleTracker implements BundleTrackerCustomizer<Object> {
+
+    public static final String SCHEMA_PATH_HEADER = "Sling-GraphQL-Schema";
+
+    private final Logger log = LoggerFactory.getLogger(getClass().getName());
+    private final Map<String, BundleEntryPartialProvider> schemaProviders = new ConcurrentHashMap<>();
+
+    private BundleContext bundleContext;
+
+    @Activate
+    public void activate(BundleContext bundleContext) {
+        this.bundleContext = bundleContext;
+        BundleTracker<?> bt = new BundleTracker<>(bundleContext, Bundle.ACTIVE, this);
+        bt.open();
+    }
+
+    @Override
+    public Object addingBundle(Bundle bundle, BundleEvent event) {
+        BundleWiring bundleWiring = bundle.adapt(BundleWiring.class);
+        Bundle us = bundleContext.getBundle();
+        if (bundleWiring.getRequiredWires(ExtenderNamespace.EXTENDER_NAMESPACE).stream().map(BundleWire::getProvider)
+                .map(BundleRevision::getBundle).anyMatch(us::equals)) {
+            final String providersPath = bundle.getHeaders().get(SCHEMA_PATH_HEADER);
+            if (providersPath == null) {
+                log.debug("Bundle {} has no {} header, ignored", bundle.getSymbolicName(), SCHEMA_PATH_HEADER);
+            } else {
+                // For now we only support file entries which are directly under providersPath
+                final Enumeration<String> paths = bundle.getEntryPaths(providersPath);
+                if (paths != null) {
+                    while (paths.hasMoreElements()) {
+                        final String path = paths.nextElement();
+                        try {
+                            addIfNotPresent(BundleEntryPartialProvider.forBundle(bundle, path));
+                        } catch (IOException ioe) {
+                            // TODO save errors and refuse to work if any happended?
+                            log.error("Error reading partial " + path, ioe);
+                        }
+                    }
+                }
+            }
+        }
+        return bundle;
+    }
+
+    private void addIfNotPresent(BundleEntryPartialProvider a) {
+        if(a != null) {
+            if(schemaProviders.containsKey(a.getName())) {
+                log.warn("Partial provider with name {} already present, new one will be ignored", a.getName());
+            } else {
+                log.info("Registering {}", a);
+                schemaProviders.put(a.getName(), a);
+            }
+        }
+    }
+
+    @Override
+    public void removedBundle(Bundle bundle, BundleEvent event, Object object) {
+        final long id = bundle.getBundleId();
+        schemaProviders.forEach((key, value) -> {
+            if (id == value.getBundleId()) {
+                log.info("Removing {}", value);
+                schemaProviders.remove(key);
+            }
+        });
+    }
+
+    @Override
+    public void modifiedBundle(Bundle bundle, BundleEvent event, Object object) {
+        // do nothing
+    }
+
+    Map<String, Partial> getSchemaProviders() {
+        return Collections.unmodifiableMap(schemaProviders);
+    }
+}
diff --git a/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/URLReaderSupplier.java b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/URLReaderSupplier.java
new file mode 100644
index 0000000..ec5c925
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/schema/aggregator/impl/URLReaderSupplier.java
@@ -0,0 +1,47 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements.  See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership.  The ASF licenses this file
+ ~ to you under the Apache License, Version 2.0 (the
+ ~ "License"); you may not use this file except in compliance
+ ~ with the License.  You may obtain a copy of the License at
+ ~
+ ~   http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing,
+ ~ software distributed under the License is distributed on an
+ ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ ~ KIND, either express or implied.  See the License for the
+ ~ specific language governing permissions and limitations
+ ~ under the License.
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
+
+package org.apache.sling.graphql.schema.aggregator.impl;
+
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.net.URL;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.function.Supplier;
+
+class URLReaderSupplier implements Supplier<Reader> {
+    /** Partials must use this character set */
+    private static final Charset PARTIAL_CHARSET = StandardCharsets.UTF_8;
+
+    private final URL url;
+
+    URLReaderSupplier(URL url) {
+        this.url = url;
+    }
+
+    @Override
+    public Reader get() {
+        try {
+            return new InputStreamReader(url.openConnection().getInputStream(), PARTIAL_CHARSET);
+        } catch(Exception e) {
+            throw new RuntimeException("Error creating Reader for URL " + url, e);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/graphql/schema/aggregator/servlet/SchemaAggregatorServlet.java b/src/main/java/org/apache/sling/graphql/schema/aggregator/servlet/SchemaAggregatorServlet.java
new file mode 100644
index 0000000..dc82945
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/schema/aggregator/servlet/SchemaAggregatorServlet.java
@@ -0,0 +1,162 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.graphql.schema.aggregator.servlet;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.servlet.Servlet;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.sling.api.SlingHttpServletRequest;
+import org.apache.sling.api.SlingHttpServletResponse;
+import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
+import org.apache.sling.graphql.schema.aggregator.api.SchemaAggregator;
+import org.osgi.framework.BundleContext;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.ConfigurationPolicy;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * <p>
+ * Servlet that aggregates GraphQL schemas provided by bundles that have a Sling-GraphQL-Schema header. The value of that header is a path
+ * under which bundle entries are found which follow the rules defined by our minimal parser to be used as partial GraphQL schemas
+ * ("partial").
+ * </p>
+ * <p>
+ * The name of such a partial is defined by its filename in the bundle, omitting the file extension. A partial found at /p/partial.1.txt is
+ * named 'partial.1', for example.
+ * </p>
+ * <p>
+ * The servlet must be mounted with at least one selector, and the first selector of the request is mapped to a configurable set of names or
+ * regular expressions used to select partial schema providers by name.
+ * </p>
+ * <p>
+ * If used with the Sling GraphQL Core module, the servlet must be configured with the 'GQLschema' extension as that module makes an
+ * internal request with this extension (+ selectors) to retrieve a GraphQL schema that uses the SDL syntax.
+ * </p>
+ * <p>
+ * Multiple selectors can be mapped to different sets of partial names or regular expressions. This can be used to define "API planes" which
+ * each have their own GraphQL schemas and are each addressed with a specific selector.
+ * </p>
+ */
+@Component(
+    service = Servlet.class,
+    name = "org.apache.sling.graphql.schema.aggregator.SchemaAggregatorServlet",
+    configurationPolicy=ConfigurationPolicy.REQUIRE,
+    property = {
+        "service.description=Sling GraphQL Schema Aggregator Servlet",
+        "service.vendor=The Apache Software Foundation"
+    })
+@Designate(ocd = SchemaAggregatorServlet.Config.class, factory=true)
+public class SchemaAggregatorServlet extends SlingSafeMethodsServlet {
+
+    private final transient Logger log = LoggerFactory.getLogger(getClass().getName());
+
+    @ObjectClassDefinition(
+        name = "Apache Sling GraphQL Schema Aggregator Servlet",
+        description = "Servlet that aggregates GraphQL schemas")
+    public @interface Config {
+        @AttributeDefinition(
+            name = "Selectors",
+            description="Standard Sling servlet property")
+        String[] sling_servlet_selectors() default "";
+
+        @AttributeDefinition(
+            name = "Resource Types",
+            description="Standard Sling servlet property")
+        String[] sling_servlet_resourceTypes() default "sling/servlet/default";
+
+        @AttributeDefinition(
+            name = "Methods",
+            description="Standard Sling servlet property")
+        String[] sling_servlet_methods() default "GET";
+
+        @AttributeDefinition(
+            name = "Extensions",
+            description="Standard Sling servlet property")
+        String[] sling_servlet_extensions() default "GQLschema";
+
+        @AttributeDefinition(
+            name = "Selectors to partials mapping",
+            description=
+                "Each entry is in the format S:P1,P2,... where S is the first selector of the incoming request "
+                + "and P* lists the names of the corresponding schema partials to use, "
+                + "and/or regular expressions such as /.*authoring.*/ to select all partials that match")
+        String[] selectors_to_partials_mapping() default {};
+
+    }
+    
+    @Reference
+    private transient SchemaAggregator aggregator;
+
+    private Map<String, String[]> selectorsToPartialNames = new HashMap<>();
+
+    @Activate
+    public void activate(BundleContext ctx, Config cfg) {
+        for(String str : cfg.selectors_to_partials_mapping()) {
+            final String [] parts = str.split("[:,]");
+            if(parts.length < 2) {
+                log.warn("Invalid selectors_to_partials_mapping configuration string [{}]", str);
+                continue;
+            }
+            final String selector = parts[0].trim();
+            final String [] names = new String[parts.length - 1];
+            for(int i=1; i < parts.length; i++) {
+                names[i-1] = parts[i].trim();
+            }
+            if(log.isInfoEnabled()) {
+                log.info("Registering selector mapping: {} -> {}", selector, Arrays.asList(names));
+            }
+            selectorsToPartialNames.put(selector, names);
+        }
+    }
+
+    @Override
+    public void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException {
+        final String [] selectors = request.getRequestPathInfo().getSelectors();
+        if(selectors.length < 1) {
+           response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing required schema selector");
+           return;
+        }
+
+        response.setContentType("text/plain");
+        response.setCharacterEncoding("UTF-8");
+
+        final String key = selectors[0];
+        final String[] partialNames = selectorsToPartialNames.get(key);
+        if(partialNames == null) {
+            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "No partial names defined for selector " + key);
+            return;
+        }
+        if(log.isDebugEnabled()) {
+            log.debug("Selector {} maps to partial names {}", key, Arrays.asList(partialNames));
+        }
+        aggregator.aggregate(response.getWriter(), partialNames);
+    }
+}
diff --git a/src/test/java/org/apache/sling/graphql/schema/aggregator/LogCapture.java b/src/test/java/org/apache/sling/graphql/schema/aggregator/LogCapture.java
new file mode 100644
index 0000000..312e8a9
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/schema/aggregator/LogCapture.java
@@ -0,0 +1,70 @@
+/*
+ * 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.graphql.schema.aggregator;
+
+import static org.junit.Assert.fail;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+
+import org.slf4j.LoggerFactory;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+
+/** Capture logs for testing */
+public class LogCapture extends ListAppender<ILoggingEvent> implements Closeable {
+    private final boolean verboseFailure;
+
+    /** Setup the capture and start it */
+    public LogCapture(String loggerName, boolean verboseFailure) {
+        this.verboseFailure = verboseFailure;
+        Logger logger = (Logger) LoggerFactory.getLogger(loggerName);
+        logger.setLevel(Level.ALL);
+        setContext((LoggerContext) LoggerFactory.getILoggerFactory());
+        logger.addAppender(this);
+        start();
+    }
+
+    public boolean anyMatch(Predicate<ILoggingEvent> p) {
+        return this.list.stream().anyMatch(p);
+    }
+
+    public void assertContains(Level atLevel, String ... substrings) {
+        Stream.of(substrings).forEach(substring -> {
+            if(!anyMatch(event -> event.getLevel() == atLevel && event.getFormattedMessage().contains(substring))) {
+                if(verboseFailure) {
+                    fail(String.format("No log message contains [%s] in log\n%s", substring, this.list.toString()));
+                } else {
+                    fail(String.format("No log message contains [%s]", substring));
+                }
+            }
+        });
+    }
+
+    @Override
+    public void close() throws IOException {
+        stop();
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/graphql/schema/aggregator/U.java b/src/test/java/org/apache/sling/graphql/schema/aggregator/U.java
new file mode 100644
index 0000000..72fea60
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/schema/aggregator/U.java
@@ -0,0 +1,134 @@
+/*
+ * 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.graphql.schema.aggregator;
+
+import org.osgi.framework.Bundle;
+
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.ops4j.pax.tinybundles.core.TinyBundles.bundle;
+
+import org.apache.sling.graphql.schema.aggregator.impl.ProviderBundleTracker;
+import org.ops4j.pax.exam.Option;
+import org.ops4j.pax.tinybundles.core.TinyBundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+import org.osgi.framework.wiring.BundleRevision;
+import org.osgi.framework.wiring.BundleWire;
+import org.osgi.framework.wiring.BundleWiring;
+import org.osgi.namespace.extender.ExtenderNamespace;
+
+import static org.ops4j.pax.exam.CoreOptions.streamBundle;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Dictionary;
+import java.util.Hashtable;
+import java.util.List;
+
+/** Test Utilities */
+public class U {
+    
+    public static Bundle mockProviderBundle(BundleContext bc, String symbolicName, long id, String ... schemaNames) throws IOException {
+        final Bundle b = mock(Bundle.class);
+        final BundleWiring wiring = mock(BundleWiring.class);
+        when(b.getSymbolicName()).thenReturn(symbolicName);
+        when(b.getBundleId()).thenReturn(id);
+        when(b.adapt(BundleWiring.class)).thenReturn(wiring);
+        final BundleWire wire = mock(BundleWire.class);
+        when(wiring.getRequiredWires(ExtenderNamespace.EXTENDER_NAMESPACE)).thenReturn(Collections.singletonList(wire));
+        final BundleRevision revision = mock(BundleRevision.class);
+        when(wire.getProvider()).thenReturn(revision);
+        when(revision.getBundle()).thenAnswer(invocationOnMock -> bc.getBundle());
+
+        final Dictionary<String, String> headers = new Hashtable<>();
+        String fakePath = symbolicName + "/path/" + id;
+        headers.put(ProviderBundleTracker.SCHEMA_PATH_HEADER, fakePath);
+        when(b.getHeaders()).thenReturn(headers);
+
+        final List<String> resources = new ArrayList<>();
+        for(String name : schemaNames) {
+            URL partial = testFileURL(name);
+            if(partial == null) {
+                partial = fakePartialURL(name);
+            }
+            String fakeResource = fakePath + "/resource/" + name;
+            resources.add(fakeResource);
+            when(b.getEntry(fakeResource)).thenReturn(partial);
+        }
+        when(b.getEntryPaths(fakePath)).thenReturn(Collections.enumeration(resources));
+        return b;
+    }
+
+    /** Simple way to get a URL: create a temp file */
+    public static URL fakePartialURL(String name) throws IOException {
+        final File f = File.createTempFile(name, "txt");
+        f.deleteOnExit();
+        final PrintWriter w = new PrintWriter(new FileWriter(f));
+        w.print(fakePartialSchema(name));
+        w.flush();
+        w.close();
+        // Safe in our case, we're using acceptable characters in the path
+        return f.toURL();
+    }
+
+    public static URL testFileURL(String name) {
+        return U.class.getResource(String.format("/partials/%s", name));
+    }
+
+    public static String fakePartialSchema(String name) {
+        return String.format("PARTIAL:%s\nQUERY:%s\nFake query for %s\n", name, name, name);
+    }
+
+    public static Option tinyProviderBundle(String symbolicName, String ... partialsNames) {
+        final String schemaPath = symbolicName + "/schemas";
+        final TinyBundle b = bundle()
+            .set(ProviderBundleTracker.SCHEMA_PATH_HEADER, schemaPath)
+            .set(Constants.BUNDLE_SYMBOLICNAME, symbolicName)
+            .set(
+                    Constants.REQUIRE_CAPABILITY,
+                    "osgi.extender;filter:=\"(&(osgi.extender=sling.graphql-schema-aggregator)(version>=0.1)(!(version>=1.0)))\""
+            )
+        ;
+
+        for(String name : partialsNames) {
+            final String resourcePath = schemaPath + "/" + name + ".txt";
+            b.add(resourcePath, new ByteArrayInputStream(fakePartialSchema(name).getBytes()));
+        }
+
+        return streamBundle(b.build());
+    }
+
+    public static void assertPartialsFoundInSchema(String output, String ... partialName) {
+        for(String name : partialName) {
+            final String expected = "DefaultSchemaAggregator.source=" + name;
+            if(!output.contains(expected)) {
+                fail(String.format("Expecting output to contain %s: %s", expected, output));
+            }
+        }
+    }
+}
diff --git a/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/BundleEntryPartialProviderTest.java b/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/BundleEntryPartialProviderTest.java
new file mode 100644
index 0000000..428ac1b
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/BundleEntryPartialProviderTest.java
@@ -0,0 +1,34 @@
+/*
+ * 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.graphql.schema.aggregator.impl;
+
+import static org.junit.Assert.assertEquals;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import org.junit.Test;
+
+public class BundleEntryPartialProviderTest {
+    @Test
+    public void partialName() throws MalformedURLException {
+        final URL url = new URL("http://stuff/some/path/the.name.txt");
+        assertEquals("the.name", BundleEntryPartialProvider.getPartialName(url));
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/CapitalizeTest.java b/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/CapitalizeTest.java
new file mode 100644
index 0000000..aaca60d
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/CapitalizeTest.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.graphql.schema.aggregator.impl;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+public class CapitalizeTest {
+    @Test
+    public void normalStrings() throws Exception {
+        assertEquals("Voici", DefaultSchemaAggregator.capitalize("voici"));
+        assertEquals("Ou bien", DefaultSchemaAggregator.capitalize("OU BIEN"));
+   }
+
+   @Test
+   public void emptyStrings() throws Exception {
+       assertEquals("", DefaultSchemaAggregator.capitalize(""));
+       assertEquals(null, DefaultSchemaAggregator.capitalize(null));
+  }
+
+  @Test
+  public void shortStrings() throws Exception {
+      assertEquals("A", DefaultSchemaAggregator.capitalize("a"));
+      assertEquals("B", DefaultSchemaAggregator.capitalize("B"));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/DefaultSchemaAggregatorTest.java b/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/DefaultSchemaAggregatorTest.java
new file mode 100644
index 0000000..2726ccf
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/DefaultSchemaAggregatorTest.java
@@ -0,0 +1,175 @@
+/*
+ * 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.graphql.schema.aggregator.impl;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringWriter;
+import java.lang.reflect.Field;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import graphql.language.TypeDefinition;
+import graphql.schema.idl.SchemaParser;
+import graphql.schema.idl.TypeDefinitionRegistry;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.sling.graphql.schema.aggregator.U;
+import org.junit.Before;
+import org.junit.Test;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class DefaultSchemaAggregatorTest {
+    private DefaultSchemaAggregator dsa;
+    private ProviderBundleTracker tracker;
+    private BundleContext bundleContext;
+
+    @Before
+    public void setup() throws Exception {
+        dsa = new DefaultSchemaAggregator();
+        final Field f = dsa.getClass().getDeclaredField("tracker");
+        f.setAccessible(true);
+        bundleContext = mock(BundleContext.class);
+        when(bundleContext.getBundle()).thenReturn(mock(Bundle.class));
+        tracker = new ProviderBundleTracker();
+        tracker.activate(bundleContext);
+        f.set(dsa, tracker);
+    }
+
+    private void assertContainsIgnoreCase(String substring, String source) {
+        assertTrue("Expecting '" + substring + "' in source string ", source.toLowerCase().contains(substring.toLowerCase()));
+    }
+
+    @Test
+    public void noProviders() throws Exception{
+        final StringWriter target = new StringWriter();
+        final IOException iox = assertThrows(IOException.class, () -> dsa.aggregate(target, "Aprov", "Bprov"));
+        assertContainsIgnoreCase("missing providers", iox.getMessage());
+        assertContainsIgnoreCase("Aprov", iox.getMessage());
+        assertContainsIgnoreCase("Bprov", iox.getMessage());
+        assertContainsIgnoreCase("schema aggregated by DefaultSchemaAggregator", target.toString());
+    }
+
+    @Test
+    public void severalProviders() throws Exception{
+        final StringWriter target = new StringWriter();
+        tracker.addingBundle(U.mockProviderBundle(bundleContext, "A", 1, "1.txt", "2.z.w", "3abc", "4abc"), null);
+        tracker.addingBundle(U.mockProviderBundle(bundleContext, "B", 2, "B1a.txt", "B2.xy"), null);
+        dsa.aggregate(target, "B1a", "B2", "2.z");
+        final String sdl = target.toString().trim();
+        assertContainsIgnoreCase("schema aggregated by DefaultSchemaAggregator", sdl);
+
+        try(InputStream is = getClass().getResourceAsStream("/several-providers-output.txt")) {
+            assertNotNull("Expecting test resource to be present", is);
+            final String expected = IOUtils.toString(is, "UTF-8");
+            assertEquals(expected, sdl);
+        }
+    }
+
+    @Test
+    public void regexpSelection() throws Exception {
+        final StringWriter target = new StringWriter();
+        tracker.addingBundle(U.mockProviderBundle(bundleContext, "A", 1, "a.authoring.1.txt", "a.authoring.2.txt", "3.txt", "4.txt"), null);
+        tracker.addingBundle(U.mockProviderBundle(bundleContext, "B", 2, "B1.txt", "B.authoring.txt"), null);
+        dsa.aggregate(target, "B1", "/.*\\.authoring.*/");
+        assertContainsIgnoreCase("schema aggregated by DefaultSchemaAggregator", target.toString());
+        U.assertPartialsFoundInSchema(target.toString(), "a.authoring.1", "a.authoring.2", "B.authoring", "B1");
+    }
+
+    @Test
+    public void parseResult() throws Exception {
+        final StringWriter target = new StringWriter();
+        tracker.addingBundle(U.mockProviderBundle(bundleContext, "SDL", 1, "a.sdl.txt", "b.sdl.txt", "c.sdl.txt"), null);
+
+        dsa.aggregate(target, "/.*/");
+
+        // Parse the output with a real SDL parser
+        final String sdl = target.toString();
+        final TypeDefinitionRegistry reg = new SchemaParser().parse(sdl);
+
+        // And make sure it contains what we expect
+        assertTrue(reg.getDirectiveDefinition("fetcher").isPresent());
+        assertTrue(reg.getType("SlingResourceConnection").isPresent());
+        assertTrue(reg.getType("PageInfo").isPresent());
+        
+        final Optional<TypeDefinition> query = reg.getType("Query");
+        assertTrue("Expecting Query", query.isPresent());
+        assertTrue(query.get().getChildren().toString().contains("oneSchemaResource"));
+        assertTrue(query.get().getChildren().toString().contains("oneSchemaQuery"));
+
+        final Optional<TypeDefinition> mutation = reg.getType("Mutation");
+        assertTrue("Expecting Mutation", mutation.isPresent());
+        assertTrue(mutation.get().getChildren().toString().contains("someMutation"));
+    }
+
+    @Test
+    public void requires() throws Exception {
+        final StringWriter target = new StringWriter();
+        tracker.addingBundle(U.mockProviderBundle(bundleContext, "SDL", 1, "a.sdl.txt", "b.sdl.txt", "c.sdl.txt"), null);
+        dsa.aggregate(target, "c.sdl");
+        final String sdl = target.toString();
+
+        // Verify that required partials are included
+        Stream.of(
+            "someMutation",
+            "typeFromB",
+            "typeFromA"
+        ).forEach((s -> {
+            assertTrue("Expecting aggregate to contain " + s, sdl.contains(s));
+        }));
+   }
+
+    @Test
+    public void cycleInRequirements() throws Exception {
+        final StringWriter target = new StringWriter();
+        tracker.addingBundle(U.mockProviderBundle(bundleContext, "SDL", 1, "circularA.txt", "circularB.txt"), null);
+        final RuntimeException rex = assertThrows(RuntimeException.class, () -> dsa.aggregate(target, "circularA"));
+
+        Stream.of(
+            "requirements cycle",
+            "circularA"
+        ).forEach((s -> {
+            assertTrue(String.format("Expecting message to contain %s: %s",  s, rex.getMessage()), rex.getMessage().contains(s));
+        }));
+    }
+
+    @Test
+    public void providersOrdering() throws Exception {
+        final StringWriter target = new StringWriter();
+        tracker.addingBundle(U.mockProviderBundle(bundleContext, "ordering", 1, "Aprov.txt", "Cprov.txt", "Z_test.txt", "A_test.txt",
+                "Zprov.txt",
+                "Z_test.txt", "Bprov.txt", "C_test.txt"), null);
+        dsa.aggregate(target, "Aprov", "Zprov", "/[A-Z]_test/", "A_test", "Cprov");
+        final String sdl = target.toString();
+
+        // The order of named partials is kept, regexp selected ones are ordered by name
+        // And A_test has already been used so it's not used again when called explicitly after regexp
+        final String expected = "End of Schema aggregated from {Aprov,Zprov,A_test,C_test,Z_test,Cprov} by DefaultSchemaAggregator";
+        assertTrue(String.format("Expecting schema to contain [%s]: %s", expected, sdl), sdl.contains(expected));
+   }
+}
diff --git a/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/PartialReaderTest.java b/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/PartialReaderTest.java
new file mode 100644
index 0000000..737f9e1
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/PartialReaderTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.graphql.schema.aggregator.impl;
+
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringReader;
+import java.io.UnsupportedEncodingException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Optional;
+import java.util.function.Supplier;
+import java.util.regex.Pattern;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.sling.graphql.schema.aggregator.U;
+import org.junit.Test;
+
+public class PartialReaderTest {
+    public static final String CHARSET = "UTF-8";
+    private static final String NONAME = "<NO NAME>";
+
+    private void assertSection(Partial p, String name, String description, String contentRegexp) throws IOException {
+        final Optional<Partial.Section> os = p.getSection(name);
+        assertTrue("Expecting section " + name, os.isPresent());
+        final Partial.Section s = os.get();
+        if(description != null) {
+            assertEquals("For section " + name, description, s.getDescription());
+        }
+        if(contentRegexp != null) {
+            try(Reader r = s.getContent()) {
+                final String actual = IOUtils.toString(s.getContent()).trim();
+                final Pattern regexp = Pattern.compile(contentRegexp, Pattern.DOTALL);
+                assertTrue(
+                    String.format("Expecting section %s to match %s but was [%s]", name, contentRegexp, actual),
+                    regexp.matcher(actual).matches()
+                );
+            }
+        }
+    }
+
+    private Supplier<Reader> getResourceReaderSupplier(String resourceName) {
+        return () -> {
+            try {
+                final InputStream input = getClass().getResourceAsStream(resourceName);
+                assertNotNull("Expecting resource " + resourceName, input);
+                return new InputStreamReader(input, CHARSET);
+            } catch(UnsupportedEncodingException uee) {
+                throw new RuntimeException("Unsupported encoding " + CHARSET, uee);
+            }
+        };
+    }
+
+    private Supplier<Reader> getStringReaderSupplier(String content) {
+        return () -> new StringReader(content);
+    }
+
+    @Test
+    public void parseExample() throws Exception {
+        final PartialReader p = new PartialReader(NONAME, getResourceReaderSupplier("/partials/example.partial.txt"));
+        assertSection(p, "PARTIAL", "Example GraphQL schema partial", "The contents.*PARTIAL.*PARTIAL.*PARTIAL.*equired section\\.");
+        assertSection(p, "REQUIRE", "base.scalars, base.schema", null);
+        assertSection(p, "PROLOGUE", "", "The prologue content.*the aggregated schema.*other sections\\.");
+        assertSection(p, "QUERY", "", "The optional query sections of all partials are aggregated in a query \\{\\} section in the output\\.");
+        assertSection(p, "MUTATION", "", "The optional mutation sections of all partials are aggregated in a mutation \\{\\} section in the output\\.");
+        assertSection(p, "TYPES", "", "The types sections.*mutation(\\s)+sections\\.");
+    }
+
+    @Test
+    public void accentedCharacters() throws Exception {
+        final PartialReader p = new PartialReader(NONAME, getResourceReaderSupplier("/partials/utf8.partial.txt"));
+        assertSection(p, "PARTIAL", 
+            "Example GraphQL schema partial with caract\u00E8res accentu\u00E9s",
+            "L'\u00E9t\u00E9 nous \u00E9vitons l'\u00E2tre et pr\u00E9f\u00E9rons Chateaun\u00F6f et les \u00E4kr\u00E0s."
+        );
+    }
+
+    @Test
+    public void missingPartialSection() throws Exception {
+        final Exception e = assertThrows(
+            PartialReader.SyntaxException.class, 
+            () -> new PartialReader(NONAME, getStringReaderSupplier(""))
+        );
+        final String expected = "Missing required PARTIAL section";
+        assertTrue(String.format("Expected %s in %s", expected, e.getMessage()), e.getMessage().contains(expected));
+    }
+
+    @Test
+    public void duplicateSection() throws Exception {
+        final Exception e = assertThrows(
+            PartialReader.SyntaxException.class, 
+            () -> new PartialReader(NONAME, getResourceReaderSupplier("/partials/duplicate.section.partial.txt"))
+        );
+        final String expected = "Duplicate section DUPLICATE";
+        assertTrue(String.format("Expected %s in %s", expected, e.getMessage()), e.getMessage().contains(expected));
+    }
+
+    @Test
+    public void requires() throws Exception {
+        final PartialReader p = new PartialReader(NONAME, getResourceReaderSupplier("/partials/c.sdl.txt"));
+        assertTrue("Expecting requires section", p.getSection(PartialConstants.S_REQUIRES).isPresent());
+        assertEquals("[a.sdl, b.sdl]", p.getRequiredPartialNames().toString());
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/ProviderBundleTrackerTest.java b/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/ProviderBundleTrackerTest.java
new file mode 100644
index 0000000..7a82e7a
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/ProviderBundleTrackerTest.java
@@ -0,0 +1,103 @@
+/*
+ * 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.graphql.schema.aggregator.impl;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.sling.graphql.schema.aggregator.LogCapture;
+import org.apache.sling.graphql.schema.aggregator.U;
+import org.junit.Before;
+import org.junit.Test;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+
+import ch.qos.logback.classic.Level;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class ProviderBundleTrackerTest {
+    private ProviderBundleTracker tracker;
+    private static long bundleId;
+    private BundleContext bundleContext;
+
+    @Before
+    public void setup() {
+        bundleId = 0;
+        bundleContext = mock(BundleContext.class);
+        when(bundleContext.getBundle()).thenReturn(mock(Bundle.class));
+        tracker = new ProviderBundleTracker();
+        tracker.activate(bundleContext);
+    }
+
+    @Test
+    public void addBundle() throws Exception {
+        final Bundle a = U.mockProviderBundle(bundleContext, "A", ++bundleId, "1.txt");
+        tracker.addingBundle(a, null);
+        assertEquals(1, tracker.getSchemaProviders().size());
+
+        final Partial s = tracker.getSchemaProviders().values().iterator().next();
+        assertTrue(s.toString().contains(a.getSymbolicName()));
+        assertTrue(s.toString().contains("1.txt"));
+    }
+
+    @Test
+    public void addAndRemoveBundles() throws Exception {
+        final Bundle a = U.mockProviderBundle(bundleContext, "A", ++bundleId, "1.graphql.txt");
+        final Bundle b = U.mockProviderBundle(bundleContext, "B", ++bundleId, "2.txt", "1.txt");
+        tracker.addingBundle(a, null);
+        tracker.addingBundle(b, null);
+        assertEquals(3, tracker.getSchemaProviders().size());
+        tracker.removedBundle(b, null, null);
+        assertEquals(1, tracker.getSchemaProviders().size());
+        tracker.removedBundle(a, null, null);
+        assertEquals(0, tracker.getSchemaProviders().size());
+        tracker.removedBundle(a, null, null);
+        assertEquals(0, tracker.getSchemaProviders().size());
+    }
+
+    @Test
+    public void duplicatePartialName() throws Exception {
+        final LogCapture capture = new LogCapture(ProviderBundleTracker.class.getName(), true);
+        final Bundle a = U.mockProviderBundle(bundleContext, "A", ++bundleId, "TT.txt");
+        final Bundle b = U.mockProviderBundle(bundleContext, "B", ++bundleId, "TT.txt", "another.x");
+        tracker.addingBundle(a, null);
+        tracker.addingBundle(b, null);
+        capture.assertContains(Level.WARN, "Partial provider with name TT already present");
+        assertEquals(2, tracker.getSchemaProviders().size());
+    }
+
+    private void assertSectionContent(Partial p, String name, String expected) throws IOException {
+        final Optional<Partial.Section> os = p.getSection(name);
+        assertTrue("Expecting section " + name, os.isPresent());
+        assertEquals(expected, IOUtils.toString(os.get().getContent()).trim());
+    }
+ 
+    @Test
+    public void getSectionsContent() throws IOException {
+        final Bundle a = U.mockProviderBundle(bundleContext, "A", ++bundleId, "1.txt");
+        tracker.addingBundle(a, null);
+        final Partial p = tracker.getSchemaProviders().values().iterator().next();
+        assertSectionContent(p, PartialConstants.S_QUERY, "Fake query for 1.txt");
+    }
+}
diff --git a/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/SchemaAggregatorServletTest.java b/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/SchemaAggregatorServletTest.java
new file mode 100644
index 0000000..aa801e6
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/schema/aggregator/impl/SchemaAggregatorServletTest.java
@@ -0,0 +1,58 @@
+/*
+ * 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.graphql.schema.aggregator.impl;
+
+import org.apache.sling.graphql.schema.aggregator.servlet.SchemaAggregatorServlet;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Field;
+import java.util.Map;
+
+public class SchemaAggregatorServletTest {
+
+    private void assertMappings(Map<String, String[]> data, String selector, String expected) {
+        final String [] names = data.get(selector);
+        assertNotNull("Expecting field names for selector " + selector, names);
+        assertEquals(expected, String.join(",", names));
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void selectorMappingConfig() throws Exception {
+        final SchemaAggregatorServlet s = new SchemaAggregatorServlet();
+        final SchemaAggregatorServlet.Config cfg = mock(SchemaAggregatorServlet.Config.class);
+        final String [] cfgMappings = {
+            "\t S1\t :one, two,   \t three  \t",
+            "selector_2:4,5"
+        };
+        when(cfg.selectors_to_partials_mapping()).thenReturn(cfgMappings);
+        s.activate(null, cfg);
+        final Field f = s.getClass().getDeclaredField("selectorsToPartialNames");
+        f.setAccessible(true);
+        final Map<String, String[]> actualMappings = (Map<String, String[]>)f.get(s);
+        assertEquals(2, actualMappings.size());
+        assertMappings(actualMappings, "S1", "one,two,three");
+        assertMappings(actualMappings, "selector_2", "4,5");
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/graphql/schema/aggregator/it/SchemaAggregatorServletIT.java b/src/test/java/org/apache/sling/graphql/schema/aggregator/it/SchemaAggregatorServletIT.java
new file mode 100644
index 0000000..e3fb2e3
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/schema/aggregator/it/SchemaAggregatorServletIT.java
@@ -0,0 +1,74 @@
+/*
+ * 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.graphql.schema.aggregator.it;
+
+import org.ops4j.pax.exam.Configuration;
+import org.ops4j.pax.exam.Option;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerClass;
+
+import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.factoryConfiguration;
+
+import org.apache.sling.graphql.schema.aggregator.U;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerClass.class)
+public class SchemaAggregatorServletIT extends SchemaAggregatorTestSupport {
+    private static final String AGGREGATOR_SERVLET_CONFIG_PID = "org.apache.sling.graphql.schema.aggregator.SchemaAggregatorServlet";
+    private static final String GQL_SCHEMA_EXT = "GQLschema";
+
+    @Configuration
+    public Option[] configuration() {
+        return new Option[]{
+            baseConfiguration(),
+
+            U.tinyProviderBundle("firstProvider", "firstA", "firstB","secondN"),
+            U.tinyProviderBundle("secondProvider", "secondA", "secondB","secondOther"),
+
+            // Configure the org.apache.sling.graphql.schema.aggregator.SchemaAggregatorServlet
+            factoryConfiguration(AGGREGATOR_SERVLET_CONFIG_PID)
+                .put("sling.servlet.resourceTypes", "sling/servlet/default")
+                // The extension must be the one used by the GraphQLServlet to retrieve schemas
+                .put("sling.servlet.extensions", GQL_SCHEMA_EXT)
+                // The GraphQLServlet uses an internal GET request for the schema
+                .put("sling.servlet.methods", new String[] { "GET" })
+                // Several selectors can be configured to setup API planes, each with their own GraphQL schema
+                .put("sling.servlet.selectors", new String[] { "X", "Y", "nomappings" })
+                // This mapping defines which partials to use to build the schema for each selector
+                // The lists can use either the exact names of partials, or (Java flavored) regular expressions on
+                // their names, identified by a starting an ending slash.
+                .put("selectors.to.partials.mapping", new String[] { "X:firstA,secondB", "Y:secondA,firstB,/second.*/" })
+                .asOption(),
+        };
+    }
+
+    @Test
+    public void basicAggregation() throws Exception {
+        U.assertPartialsFoundInSchema(getContent("/.X." + GQL_SCHEMA_EXT), "firstA", "secondB");
+        U.assertPartialsFoundInSchema(getContent("/.Y." + GQL_SCHEMA_EXT), "secondA", "firstB", "secondB","secondOther","secondN");
+    }
+
+    @Test
+    public void unmappedSelector() throws Exception {
+        executeRequest("GET", "/.nomappings." + GQL_SCHEMA_EXT, null, null, null, 400);
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/graphql/schema/aggregator/it/SchemaAggregatorTestSupport.java b/src/test/java/org/apache/sling/graphql/schema/aggregator/it/SchemaAggregatorTestSupport.java
new file mode 100644
index 0000000..8163c41
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/schema/aggregator/it/SchemaAggregatorTestSupport.java
@@ -0,0 +1,150 @@
+/*
+ * 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.graphql.schema.aggregator.it;
+
+import java.io.Reader;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import javax.inject.Inject;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.engine.SlingRequestProcessor;
+import org.apache.sling.servlethelpers.MockSlingHttpServletResponse;
+import org.apache.sling.servlethelpers.internalrequests.SlingInternalRequest;
+import org.apache.sling.testing.paxexam.TestSupport;
+import org.junit.Before;
+import org.ops4j.pax.exam.Option;
+import org.ops4j.pax.exam.options.ModifiableCompositeOption;
+import org.ops4j.pax.exam.options.extra.VMOption;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static org.apache.sling.testing.paxexam.SlingOptions.slingQuickstartOakTar;
+import static org.apache.sling.testing.paxexam.SlingOptions.slingScripting;
+import static org.apache.sling.testing.paxexam.SlingOptions.slingScriptingJsp;
+import static org.junit.Assert.fail;
+import static org.ops4j.pax.exam.CoreOptions.composite;
+import static org.ops4j.pax.exam.CoreOptions.junitBundles;
+import static org.ops4j.pax.exam.CoreOptions.mavenBundle;
+import static org.ops4j.pax.exam.CoreOptions.when;
+import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.newConfiguration;
+
+public abstract class SchemaAggregatorTestSupport extends TestSupport {
+
+    private final Logger log = LoggerFactory.getLogger(getClass().getName());
+    private final static int STARTUP_WAIT_SECONDS = 30;
+
+    @Inject
+    protected ResourceResolverFactory resourceResolverFactory;
+
+    @Inject
+    protected SlingRequestProcessor requestProcessor;
+
+    protected ModifiableCompositeOption baseConfiguration() {
+        final String vmOpt = System.getProperty("pax.vm.options");
+        VMOption vmOption = null;
+        if (StringUtils.isNotEmpty(vmOpt)) {
+            vmOption = new VMOption(vmOpt);
+        }
+
+        final String jacocoOpt = System.getProperty("jacoco.command");
+        VMOption jacocoCommand = null;
+        if (StringUtils.isNotEmpty(jacocoOpt)) {
+            jacocoCommand = new VMOption(jacocoOpt);
+        }
+
+        return composite(
+            when(vmOption != null).useOptions(vmOption),
+            when(jacocoCommand != null).useOptions(jacocoCommand),
+            super.baseConfiguration(),
+            slingQuickstart(),
+            testBundle("bundle.filename"),
+            newConfiguration("org.apache.sling.jcr.base.internal.LoginAdminWhitelist")
+                .put("whitelist.bundles.regexp", "^PAXEXAM.*$")
+                .asOption(),
+            mavenBundle().groupId("org.apache.sling").artifactId("org.apache.sling.servlet-helpers").versionAsInProject(),
+            junitBundles()
+        );
+    }
+
+    private Option slingQuickstart() {
+        final int httpPort = findFreePort();
+        log.info("Using HTTP port {}", httpPort);
+        final String workingDirectory = workingDirectory();
+        return composite(
+            slingQuickstartOakTar(workingDirectory, httpPort),
+            slingScripting(),
+            slingScriptingJsp()
+        );
+    }
+
+    /**
+     * Injecting the appropriate services to wait for would be more elegant but this is very reliable..
+     */
+    @Before
+    public void waitForSling() throws Exception {
+        final int expectedStatus = 200;
+        final List<Integer> statuses = new ArrayList<>();
+        final String path = "/.json";
+        final Instant endTime = Instant.now().plus(Duration.ofSeconds(STARTUP_WAIT_SECONDS));
+
+        while(Instant.now().isBefore(endTime)) {
+            final int status = executeRequest("GET", path, null, null, null, -1).getStatus();
+            statuses.add(status);
+            if (status == expectedStatus) {
+                return;
+            }
+            Thread.sleep(250);
+        }
+
+        fail("Did not get a " + expectedStatus + " status at " + path + " got " + statuses);
+    }
+
+    protected MockSlingHttpServletResponse executeRequest(final String method, 
+        final String path, Map<String, Object> params, String contentType, 
+        Reader body, final int expectedStatus) throws Exception {
+
+        // Admin resolver is fine for testing    
+        @SuppressWarnings("deprecation")            
+        final ResourceResolver resourceResolver = resourceResolverFactory.getAdministrativeResourceResolver(null);
+
+        final int [] statusParam = expectedStatus == -1 ? null : new int[] { expectedStatus };
+
+        return (MockSlingHttpServletResponse)
+            new SlingInternalRequest(resourceResolver, requestProcessor, path)
+            .withRequestMethod(method)
+            .withParameters(params)
+            .withContentType(contentType)
+            .withBody(body)
+            .execute()
+            .checkStatus(statusParam)
+            .getResponse()
+            ;
+    }
+
+    protected String getContent(String path) throws Exception {
+        return executeRequest("GET", path, null, null, null, 200).getOutputAsString();
+    }
+}
\ No newline at end of file
diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml
new file mode 100644
index 0000000..254317a
--- /dev/null
+++ b/src/test/resources/logback.xml
@@ -0,0 +1,31 @@
+<!--
+  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.
+-->
+<configuration>
+  <appender name="file" class="ch.qos.logback.core.FileAppender">
+    <file>target/test.log</file>
+    <append>true</append>
+    <encoder>
+      <pattern>%date level=%level thread=%thread logger=%logger sourcefile=%file line=%line %mdc message=%msg%n</pattern>
+    </encoder>
+  </appender>
+
+  <root level="INFO">
+    <appender-ref ref="file" />
+  </root>
+
+  <logger name="org.apache.sling.graphql" level="DEBUG"/>
+</configuration>
\ No newline at end of file
diff --git a/src/test/resources/partials/a.sdl.txt b/src/test/resources/partials/a.sdl.txt
new file mode 100644
index 0000000..55bb978
--- /dev/null
+++ b/src/test/resources/partials/a.sdl.txt
@@ -0,0 +1,21 @@
+PARTIAL: Test A, SDL
+
+PROLOGUE:
+directive @fetcher(
+    name : String,
+    options : String = "",
+    source : String = ""
+) on FIELD_DEFINITION
+
+TYPES:
+type SlingResource {
+    path: String!
+}
+
+type PageInfo {
+    count: Int
+}
+
+type typeFromA {
+    path : String
+}
\ No newline at end of file
diff --git a/src/test/resources/partials/b.sdl.txt b/src/test/resources/partials/b.sdl.txt
new file mode 100644
index 0000000..4e4c2bb
--- /dev/null
+++ b/src/test/resources/partials/b.sdl.txt
@@ -0,0 +1,10 @@
+PARTIAL: Test B, SDL
+
+TYPES:
+type SlingResourceConnection {
+    pageInfo : PageInfo
+}
+
+type typeFromB {
+    path : String
+}
\ No newline at end of file
diff --git a/src/test/resources/partials/c.sdl.txt b/src/test/resources/partials/c.sdl.txt
new file mode 100644
index 0000000..a9a4613
--- /dev/null
+++ b/src/test/resources/partials/c.sdl.txt
@@ -0,0 +1,10 @@
+PARTIAL: Test C, SDL
+
+REQUIRES: a.sdl, b.sdl
+
+QUERY:
+oneSchemaResource : SlingResource @fetcher(name:"test/pipe" source:"$")
+oneSchemaQuery : SlingResourceConnection @connection(for: "SlingResource") @fetcher(name:"test/query")
+
+MUTATION:
+someMutation : SlingResource
\ No newline at end of file
diff --git a/src/test/resources/partials/circularA.txt b/src/test/resources/partials/circularA.txt
new file mode 100644
index 0000000..76e1c6f
--- /dev/null
+++ b/src/test/resources/partials/circularA.txt
@@ -0,0 +1,6 @@
+PARTIAL: Test circular requirements
+
+REQUIRES: circularB
+
+TYPES:
+type typeFromCircularA { name: String }
diff --git a/src/test/resources/partials/circularB.txt b/src/test/resources/partials/circularB.txt
new file mode 100644
index 0000000..15f5f90
--- /dev/null
+++ b/src/test/resources/partials/circularB.txt
@@ -0,0 +1,6 @@
+PARTIAL: Test circular requirements
+
+REQUIRES: circularA
+
+TYPES:
+type typeFromCircularB { name: String }
\ No newline at end of file
diff --git a/src/test/resources/partials/duplicate.section.partial.txt b/src/test/resources/partials/duplicate.section.partial.txt
new file mode 100644
index 0000000..f92e311
--- /dev/null
+++ b/src/test/resources/partials/duplicate.section.partial.txt
@@ -0,0 +1,6 @@
+PARTIAL: some description
+
+DUPLICATE: will be provided twice
+
+DUPLICATE: here's the second one
+And the last line, required for correct parsing
\ No newline at end of file
diff --git a/src/test/resources/partials/example.partial.txt b/src/test/resources/partials/example.partial.txt
new file mode 100644
index 0000000..4c76c53
--- /dev/null
+++ b/src/test/resources/partials/example.partial.txt
@@ -0,0 +1,27 @@
+# Example GraphQL partial schema
+# Everything before the PARTIAL section is ignored
+# Such partials are aggregated into a composite GraphQL schema,
+# named "output" below.
+
+PARTIAL: Example GraphQL schema partial
+The contents of the PARTIAL section are not included in the output.
+The text that follows PARTIAL is a description of this partial.
+PARTIAL is the only required section.
+
+REQUIRE: base.scalars, base.schema
+The REQUIRE section indicates partials which are required for this
+one to be valid. Its contents are not included in the output.
+
+PROLOGUE:
+The prologue content of all partials is concatenated in the aggregated schema
+before all other sections.
+
+QUERY:
+The optional query sections of all partials are aggregated in a query {} section in the output.
+
+MUTATION:
+The optional mutation sections of all partials are aggregated in a mutation {} section in the output.
+
+TYPES:
+The types sections of all partials are aggregated in the output, after the QUERY and mutation
+sections.
\ No newline at end of file
diff --git a/src/test/resources/partials/utf8.partial.txt b/src/test/resources/partials/utf8.partial.txt
new file mode 100644
index 0000000..17fabb9
--- /dev/null
+++ b/src/test/resources/partials/utf8.partial.txt
@@ -0,0 +1,2 @@
+PARTIAL: Example GraphQL schema partial with caractères accentués
+L'été nous évitons l'âtre et préférons Chateaunöf et les äkràs.
\ No newline at end of file
diff --git a/src/test/resources/several-providers-output.txt b/src/test/resources/several-providers-output.txt
new file mode 100644
index 0000000..b3fa1f7
--- /dev/null
+++ b/src/test/resources/several-providers-output.txt
@@ -0,0 +1,16 @@
+# Schema aggregated by DefaultSchemaAggregator
+
+type Query {
+
+# DefaultSchemaAggregator.source=B1a
+Fake query for B1a.txt
+
+# DefaultSchemaAggregator.source=B2
+Fake query for B2.xy
+
+# DefaultSchemaAggregator.source=2.z
+Fake query for 2.z.w
+
+}
+
+# End of Schema aggregated from {B1a,B2,2.z} by DefaultSchemaAggregator
\ No newline at end of file