diff --git a/README.md b/README.md index c1e636b..21dc5bb 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ It has support for generating & sending documents with: - resource collections - to-one and to-many relationships - errors (easily turning exceptions into jsonapi output) -- v1.1 extensions via profiles +- v1.1 extensions and profiles - v1.1 @-members for JSON-LD and others Also there's tools to help processing of incoming requests: @@ -189,9 +189,10 @@ Also there's tools to help processing of incoming requests: - parse request options (include paths, sparse fieldsets, sort fields, pagination, filtering) - parse request documents for creating, updating and deleting resources and relationships -Next to custom extensions, the following [official extensions](https://jsonapi.org/extensions/) are included: +Next to custom extensions/profiles, the following [official extensions/profiles](https://jsonapi.org/extensions/) are included: -- Cursor Pagination ([example code](/examples/cursor_pagination_profile.php), [specification](https://jsonapi.org/profiles/ethanresnick/cursor-pagination/)) +- Atomic Operations extension ([example code](/examples/atomic_operations_extension.php), [specification](https://jsonapi.org/ext/atomic/)) +- Cursor Pagination profile ([example code](/examples/cursor_pagination_profile.php), [specification](https://jsonapi.org/profiles/ethanresnick/cursor-pagination/)) Plans for the future include: diff --git a/examples/atomic_operations_extension.php b/examples/atomic_operations_extension.php new file mode 100644 index 0000000..4e97c6d --- /dev/null +++ b/examples/atomic_operations_extension.php @@ -0,0 +1,33 @@ +add('name', 'Ford'); +$user2->add('name', 'Arthur'); +$user42->add('name', 'Zaphod'); + +$document->addResults($user1); +$document->addResults($user2); +$document->addResults($user42); + +/** + * get the json + */ + +$options = [ + 'prettyPrint' => true, +]; +echo '
'.$document->toJson($options);
diff --git a/examples/bootstrap_examples.php b/examples/bootstrap_examples.php
index 40bc6cd..fc4793d 100644
--- a/examples/bootstrap_examples.php
+++ b/examples/bootstrap_examples.php
@@ -2,6 +2,7 @@
 
 use alsvanzelf\jsonapi\Document;
 use alsvanzelf\jsonapi\ResourceDocument;
+use alsvanzelf\jsonapi\interfaces\ExtensionInterface;
 use alsvanzelf\jsonapi\interfaces\ProfileInterface;
 use alsvanzelf\jsonapi\interfaces\ResourceInterface;
 
@@ -101,25 +102,59 @@ function getCurrentLocation() {
 	}
 }
 
-class ExampleVersionProfile implements ProfileInterface {
+class ExampleVersionExtension implements ExtensionInterface {
 	/**
 	 * the required method
 	 */
 	
 	public function getOfficialLink() {
-		return 'https://jsonapi.org/format/1.1/#profile-keywords';
+		return 'https://jsonapi.org/format/1.1/#extension-rules';
+	}
+	
+	public function getNamespace() {
+		return 'version';
 	}
 	
 	/**
-	 * optionally helpers for the specific profile
+	 * optionally helpers for the specific extension
 	 */
 	
 	public function setVersion(ResourceInterface $resource, $version) {
 		if ($resource instanceof ResourceDocument) {
-			$resource->addMeta('version', $version, $level=Document::LEVEL_RESOURCE);
+			$resource->getResource()->addExtensionMember($this, 'id', $version);
 		}
 		else {
-			$resource->addMeta('version', $version);
+			$resource->addExtensionMember($this, 'id', $version);
+		}
+	}
+}
+
+class ExampleTimestampsProfile implements ProfileInterface {
+	/**
+	 * the required method
+	 */
+	
+	public function getOfficialLink() {
+		return 'https://jsonapi.org/recommendations/#authoring-profiles';
+	}
+	
+	/**
+	 * optionally helpers for the specific profile
+	 */
+	
+	public function setTimestamps(ResourceInterface $resource, \DateTimeInterface $created=null, \DateTimeInterface $updated=null) {
+		if ($resource instanceof ResourceIdentifierObject) {
+			throw new Exception('cannot add attributes to identifier objects');
+		}
+		
+		$timestamps = [];
+		if ($created !== null) {
+			$timestamps['created'] = $created->format(\DateTime::ISO8601);
 		}
+		if ($updated !== null) {
+			$timestamps['updated'] = $updated->format(\DateTime::ISO8601);
+		}
+		
+		$resource->add('timestamps', $timestamps);
 	}
 }
diff --git a/examples/example_profile.php b/examples/example_profile.php
deleted file mode 100644
index ac12aa1..0000000
--- a/examples/example_profile.php
+++ /dev/null
@@ -1,30 +0,0 @@
-applyProfile($profile);
-
-/**
- * you can apply the rules of the profile manually
- * or use methods of the profile if provided
- */
-
-$profile->setVersion($document, '2019');
-
-/**
- * get the json
- */
-
-$options = [
-	'prettyPrint' => true,
-];
-echo '
'.$document->toJson($options);
diff --git a/examples/extension.php b/examples/extension.php
new file mode 100644
index 0000000..74738bf
--- /dev/null
+++ b/examples/extension.php
@@ -0,0 +1,37 @@
+applyExtension($extension);
+
+$document->add('foo', 'bar');
+
+/**
+ * you can apply the rules of the extension manually
+ * or use methods of the extension if provided
+ */
+
+$extension->setVersion($document, '2019');
+
+/**
+ * get the json
+ */
+
+$contentType = Converter::prepareContentType(Document::CONTENT_TYPE_OFFICIAL, [$extension], []);
+echo 'Content-Type: '.$contentType.''.PHP_EOL;
+
+$options = [
+	'prettyPrint' => true,
+];
+echo '
'.$document->toJson($options);
diff --git a/examples/index.html b/examples/index.html
index 8c1963c..1687b28 100644
--- a/examples/index.html
+++ b/examples/index.html
@@ -49,7 +49,9 @@ 

Misc

  • Null values if explicitly not available
  • Meta-only use-cases
  • Status-only
  • -
  • Example profile
  • +
  • Example extension
  • +
  • Atomic operations extension
  • +
  • Example profile
  • Cursor pagination profile
  • Different ways to output
  • diff --git a/examples/profile.php b/examples/profile.php new file mode 100644 index 0000000..8b070b6 --- /dev/null +++ b/examples/profile.php @@ -0,0 +1,39 @@ +applyProfile($profile); + +$document->add('foo', 'bar'); + +/** + * you can apply the rules of the profile manually + * or use methods of the profile if provided + */ + +$created = new \DateTime('-1 year'); +$updated = new \DateTime('-1 month'); +$profile->setTimestamps($document, $created, $updated); + +/** + * get the json + */ + +$contentType = Converter::prepareContentType(Document::CONTENT_TYPE_OFFICIAL, [], [$profile]); +echo 'Content-Type: '.$contentType.''.PHP_EOL; + +$options = [ + 'prettyPrint' => true, +]; +echo '
    '.$document->toJson($options);
    diff --git a/src/Document.php b/src/Document.php
    index 5085e2f..cafa441 100644
    --- a/src/Document.php
    +++ b/src/Document.php
    @@ -2,14 +2,17 @@
     
     namespace alsvanzelf\jsonapi;
     
    +use alsvanzelf\jsonapi\exceptions\DuplicateException;
     use alsvanzelf\jsonapi\exceptions\Exception;
     use alsvanzelf\jsonapi\exceptions\InputException;
     use alsvanzelf\jsonapi\helpers\AtMemberManager;
     use alsvanzelf\jsonapi\helpers\Converter;
    +use alsvanzelf\jsonapi\helpers\ExtensionMemberManager;
     use alsvanzelf\jsonapi\helpers\HttpStatusCodeManager;
     use alsvanzelf\jsonapi\helpers\LinksManager;
     use alsvanzelf\jsonapi\helpers\Validator;
     use alsvanzelf\jsonapi\interfaces\DocumentInterface;
    +use alsvanzelf\jsonapi\interfaces\ExtensionInterface;
     use alsvanzelf\jsonapi\interfaces\ProfileInterface;
     use alsvanzelf\jsonapi\objects\JsonapiObject;
     use alsvanzelf\jsonapi\objects\LinksObject;
    @@ -19,7 +22,7 @@
      * @see ResourceDocument, CollectionDocument, ErrorsDocument or MetaDocument
      */
     abstract class Document implements DocumentInterface, \JsonSerializable {
    -	use AtMemberManager, HttpStatusCodeManager, LinksManager;
    +	use AtMemberManager, ExtensionMemberManager, HttpStatusCodeManager, LinksManager;
     	
     	const JSONAPI_VERSION_1_0 = '1.0';
     	const JSONAPI_VERSION_1_1 = '1.1';
    @@ -37,6 +40,8 @@ abstract class Document implements DocumentInterface, \JsonSerializable {
     	protected $meta;
     	/** @var JsonapiObject */
     	protected $jsonapi;
    +	/** @var ExtensionInterface[] */
    +	protected $extensions = [];
     	/** @var ProfileInterface[] */
     	protected $profiles = [];
     	/** @var array */
    @@ -190,6 +195,36 @@ public function unsetJsonapiObject() {
     		$this->jsonapi = null;
     	}
     	
    +	/**
    +	 * apply a extension which adds the link and sets a correct content-type
    +	 * 
    +	 * note that the rules from the extension are not automatically enforced
    +	 * applying the rules, and applying them correctly, is manual
    +	 * however the $extension could have custom methods to help
    +	 * 
    +	 * @see https://jsonapi.org/extensions/#extensions
    +	 * 
    +	 * @param ExtensionInterface $extension
    +	 * 
    +	 * @throws Exception if namespace uses illegal characters
    +	 * @throws DuplicateException if namespace conflicts with another applied extension
    +	 */
    +	public function applyExtension(ExtensionInterface $extension) {
    +		$namespace = $extension->getNamespace();
    +		if (strlen($namespace) < 1 || preg_match('{[^a-zA-Z0-9]}', $namespace) === 1) {
    +			throw new Exception('invalid namespace "'.$namespace.'"');
    +		}
    +		if (isset($this->extensions[$namespace])) {
    +			throw new DuplicateException('an extension with namespace "'.$namespace.'" is already applied');
    +		}
    +		
    +		$this->extensions[$namespace] = $extension;
    +		
    +		if ($this->jsonapi !== null) {
    +			$this->jsonapi->addExtension($extension);
    +		}
    +	}
    +	
     	/**
     	 * apply a profile which adds the link and sets a correct content-type
     	 * 
    @@ -197,18 +232,16 @@ public function unsetJsonapiObject() {
     	 * applying the rules, and applying them correctly, is manual
     	 * however the $profile could have custom methods to help
     	 * 
    -	 * @see https://jsonapi.org/format/1.1/#profiles
    +	 * @see https://jsonapi.org/extensions/#profiles
     	 * 
     	 * @param ProfileInterface $profile
     	 */
     	public function applyProfile(ProfileInterface $profile) {
     		$this->profiles[] = $profile;
     		
    -		if ($this->links === null) {
    -			$this->setLinksObject(new LinksObject());
    +		if ($this->jsonapi !== null) {
    +			$this->jsonapi->addProfile($profile);
     		}
    -		
    -		$this->links->append('profile', $profile->getOfficialLink());
     	}
     	
     	/**
    @@ -219,7 +252,14 @@ public function applyProfile(ProfileInterface $profile) {
     	 * @inheritDoc
     	 */
     	public function toArray() {
    -		$array = $this->getAtMembers();
    +		$array = [];
    +		
    +		if ($this->hasAtMembers()) {
    +			$array = array_merge($array, $this->getAtMembers());
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			$array = array_merge($array, $this->getExtensionMembers());
    +		}
     		
     		if ($this->jsonapi !== null && $this->jsonapi->isEmpty() === false) {
     			$array['jsonapi'] = $this->jsonapi->toArray();
    @@ -273,7 +313,7 @@ public function sendResponse(array $options=[]) {
     		
     		http_response_code($this->httpStatusCode);
     		
    -		$contentType = Converter::mergeProfilesInContentType($options['contentType'], $this->profiles);
    +		$contentType = Converter::prepareContentType($options['contentType'], $this->extensions, $this->profiles);
     		header('Content-Type: '.$contentType);
     		
     		echo $json;
    diff --git a/src/extensions/AtomicOperationsDocument.php b/src/extensions/AtomicOperationsDocument.php
    new file mode 100644
    index 0000000..1599fd8
    --- /dev/null
    +++ b/src/extensions/AtomicOperationsDocument.php
    @@ -0,0 +1,57 @@
    +extension = new AtomicOperationsExtension();
    +		$this->applyExtension($this->extension);
    +	}
    +	
    +	/**
    +	 * add resources as results of the operations
    +	 * 
    +	 * @param ResourceInterface[] ...$resources
    +	 */
    +	public function addResults(ResourceInterface ...$resources) {
    +		$this->results = array_merge($this->results, $resources);
    +	}
    +	
    +	/**
    +	 * DocumentInterface
    +	 */
    +	
    +	/**
    +	 * @inheritDoc
    +	 */
    +	public function toArray() {
    +		$results = [];
    +		foreach ($this->results as $result) {
    +			$results[] = [
    +				'data' => $result->getResource()->toArray(),
    +			];
    +		}
    +		
    +		$this->addExtensionMember($this->extension, 'results', $results);
    +		
    +		return parent::toArray();
    +	}
    +}
    diff --git a/src/extensions/AtomicOperationsExtension.php b/src/extensions/AtomicOperationsExtension.php
    new file mode 100644
    index 0000000..b94cfe6
    --- /dev/null
    +++ b/src/extensions/AtomicOperationsExtension.php
    @@ -0,0 +1,32 @@
    +getOfficialLink();
    +			}
    +			$extensionLinks = implode(' ', $extensionLinks);
    +			
    +			$contentType .= '; ext="'.$extensionLinks.'"';
     		}
     		
    -		$profileLinks = [];
    -		foreach ($profiles as $profile) {
    -			$profileLinks[] = $profile->getOfficialLink();
    +		if ($profiles !== []) {
    +			$profileLinks = [];
    +			foreach ($profiles as $profile) {
    +				$profileLinks[] = $profile->getOfficialLink();
    +			}
    +			$profileLinks = implode(' ', $profileLinks);
    +			
    +			$contentType .= '; profile="'.$profileLinks.'"';
     		}
    -		$profileLinks = implode(' ', $profileLinks);
     		
    -		return $contentType.';profile="'.$profileLinks.'", '.$contentType;
    +		return $contentType;
    +	}
    +	
    +	/**
    +	 * @deprecated {@see prepareContentType()}
    +	 */
    +	public static function mergeProfilesInContentType($contentType, array $profiles) {
    +		return self::prepareContentType($contentType, $extensions=[], $profiles);
     	}
     }
    diff --git a/src/helpers/ExtensionMemberManager.php b/src/helpers/ExtensionMemberManager.php
    new file mode 100644
    index 0000000..4261628
    --- /dev/null
    +++ b/src/helpers/ExtensionMemberManager.php
    @@ -0,0 +1,63 @@
    +getNamespace();
    +		
    +		if (strpos($key, $namespace.':') === 0) {
    +			$key = substr($key, strlen($namespace.':'));
    +		}
    +		
    +		Validator::checkMemberName($key);
    +		
    +		if (is_object($value)) {
    +			$value = Converter::objectToArray($value);
    +		}
    +		
    +		$this->extensionMembers[$namespace.':'.$key] = $value;
    +	}
    +	
    +	/**
    +	 * internal api
    +	 */
    +	
    +	/**
    +	 * @internal
    +	 * 
    +	 * @return boolean
    +	 */
    +	public function hasExtensionMembers() {
    +		return ($this->extensionMembers !== []);
    +	}
    +	
    +	/**
    +	 * @internal
    +	 * 
    +	 * @return array
    +	 */
    +	public function getExtensionMembers() {
    +		return $this->extensionMembers;
    +	}
    +}
    diff --git a/src/interfaces/ExtensionInterface.php b/src/interfaces/ExtensionInterface.php
    new file mode 100644
    index 0000000..3c07497
    --- /dev/null
    +++ b/src/interfaces/ExtensionInterface.php
    @@ -0,0 +1,21 @@
    +attributes === [] && $this->hasAtMembers() === false);
    +		if ($this->attributes !== []) {
    +			return false;
    +		}
    +		if ($this->hasAtMembers()) {
    +			return false;
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			return false;
    +		}
    +		
    +		return true;
     	}
     	
     	/**
     	 * @inheritDoc
     	 */
     	public function toArray() {
    -		return array_merge($this->getAtMembers(), $this->attributes);
    +		$array = [];
    +		
    +		if ($this->hasAtMembers()) {
    +			$array = array_merge($array, $this->getAtMembers());
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			$array = array_merge($array, $this->getExtensionMembers());
    +		}
    +		
    +		return array_merge($array, $this->attributes);
     	}
     }
    diff --git a/src/objects/ErrorObject.php b/src/objects/ErrorObject.php
    index 1473b04..9f0eade 100644
    --- a/src/objects/ErrorObject.php
    +++ b/src/objects/ErrorObject.php
    @@ -6,13 +6,14 @@
     use alsvanzelf\jsonapi\exceptions\InputException;
     use alsvanzelf\jsonapi\helpers\AtMemberManager;
     use alsvanzelf\jsonapi\helpers\Converter;
    +use alsvanzelf\jsonapi\helpers\ExtensionMemberManager;
     use alsvanzelf\jsonapi\helpers\HttpStatusCodeManager;
     use alsvanzelf\jsonapi\helpers\LinksManager;
     use alsvanzelf\jsonapi\helpers\Validator;
     use alsvanzelf\jsonapi\interfaces\ObjectInterface;
     
     class ErrorObject implements ObjectInterface {
    -	use AtMemberManager, HttpStatusCodeManager, LinksManager;
    +	use AtMemberManager, ExtensionMemberManager, HttpStatusCodeManager, LinksManager;
     	
     	/** @var string */
     	protected $id;
    @@ -288,6 +289,9 @@ public function isEmpty() {
     		if ($this->hasAtMembers()) {
     			return false;
     		}
    +		if ($this->hasExtensionMembers()) {
    +			return false;
    +		}
     		
     		return true;
     	}
    @@ -296,8 +300,14 @@ public function isEmpty() {
     	 * @inheritDoc
     	 */
     	public function toArray() {
    -		$array = $this->getAtMembers();
    +		$array = [];
     		
    +		if ($this->hasAtMembers()) {
    +			$array = array_merge($array, $this->getAtMembers());
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			$array = array_merge($array, $this->getExtensionMembers());
    +		}
     		if ($this->id !== null) {
     			$array['id'] = $this->id;
     		}
    diff --git a/src/objects/JsonapiObject.php b/src/objects/JsonapiObject.php
    index ad04da2..980b0b2 100644
    --- a/src/objects/JsonapiObject.php
    +++ b/src/objects/JsonapiObject.php
    @@ -4,14 +4,21 @@
     
     use alsvanzelf\jsonapi\Document;
     use alsvanzelf\jsonapi\helpers\AtMemberManager;
    +use alsvanzelf\jsonapi\helpers\ExtensionMemberManager;
    +use alsvanzelf\jsonapi\interfaces\ExtensionInterface;
     use alsvanzelf\jsonapi\interfaces\ObjectInterface;
    +use alsvanzelf\jsonapi\interfaces\ProfileInterface;
     use alsvanzelf\jsonapi\objects\MetaObject;
     
     class JsonapiObject implements ObjectInterface {
    -	use AtMemberManager;
    +	use AtMemberManager, ExtensionMemberManager;
     	
     	/** @var string */
     	protected $version;
    +	/** @var ExtensionInterface[] */
    +	protected $extensions = [];
    +	/** @var ProfileInterface */
    +	protected $profiles = [];
     	/** @var MetaObject */
     	protected $meta;
     	
    @@ -51,6 +58,20 @@ public function setVersion($version) {
     		$this->version = $version;
     	}
     	
    +	/**
    +	 * @param ExtensionInterface $extension
    +	 */
    +	public function addExtension(ExtensionInterface $extension) {
    +		$this->extensions[] = $extension;
    +	}
    +	
    +	/**
    +	 * @param ProfileInterface $profile
    +	 */
    +	public function addProfile(ProfileInterface $profile) {
    +		$this->profiles[] = $profile;
    +	}
    +	
     	/**
     	 * @param MetaObject $metaObject
     	 */
    @@ -69,12 +90,21 @@ public function isEmpty() {
     		if ($this->version !== null) {
     			return false;
     		}
    +		if ($this->extensions !== []) {
    +			return false;
    +		}
    +		if ($this->profiles !== []) {
    +			return false;
    +		}
     		if ($this->meta !== null && $this->meta->isEmpty() === false) {
     			return false;
     		}
     		if ($this->hasAtMembers()) {
     			return false;
     		}
    +		if ($this->hasExtensionMembers()) {
    +			return false;
    +		}
     		
     		return true;
     	}
    @@ -83,11 +113,29 @@ public function isEmpty() {
     	 * @inheritDoc
     	 */
     	public function toArray() {
    -		$array = $this->getAtMembers();
    +		$array = [];
     		
    +		if ($this->hasAtMembers()) {
    +			$array = array_merge($array, $this->getAtMembers());
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			$array = array_merge($array, $this->getExtensionMembers());
    +		}
     		if ($this->version !== null) {
     			$array['version'] = $this->version;
     		}
    +		if ($this->extensions !== []) {
    +			$array['ext'] = [];
    +			foreach ($this->extensions as $extension) {
    +				$array['ext'][] = $extension->getOfficialLink();
    +			}
    +		}
    +		if ($this->profiles !== []) {
    +			$array['profile'] = [];
    +			foreach ($this->profiles as $profile) {
    +				$array['profile'][] = $profile->getOfficialLink();
    +			}
    +		}
     		if ($this->meta !== null && $this->meta->isEmpty() === false) {
     			$array['meta'] = $this->meta->toArray();
     		}
    diff --git a/src/objects/LinkObject.php b/src/objects/LinkObject.php
    index 837875e..244d103 100644
    --- a/src/objects/LinkObject.php
    +++ b/src/objects/LinkObject.php
    @@ -3,11 +3,12 @@
     namespace alsvanzelf\jsonapi\objects;
     
     use alsvanzelf\jsonapi\helpers\AtMemberManager;
    +use alsvanzelf\jsonapi\helpers\ExtensionMemberManager;
     use alsvanzelf\jsonapi\interfaces\ObjectInterface;
     use alsvanzelf\jsonapi\objects\MetaObject;
     
     class LinkObject implements ObjectInterface {
    -	use AtMemberManager;
    +	use AtMemberManager, ExtensionMemberManager;
     	
     	/** @var string */
     	protected $href;
    @@ -161,6 +162,9 @@ public function isEmpty() {
     		if ($this->hasAtMembers()) {
     			return false;
     		}
    +		if ($this->hasExtensionMembers()) {
    +			return false;
    +		}
     		
     		return true;
     	}
    @@ -169,7 +173,14 @@ public function isEmpty() {
     	 * @inheritDoc
     	 */
     	public function toArray() {
    -		$array = $this->getAtMembers();
    +		$array = [];
    +		
    +		if ($this->hasAtMembers()) {
    +			$array = array_merge($array, $this->getAtMembers());
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			$array = array_merge($array, $this->getExtensionMembers());
    +		}
     		
     		$array['href'] = $this->href;
     		
    diff --git a/src/objects/LinksArray.php b/src/objects/LinksArray.php
    index d390e01..d4ae042 100644
    --- a/src/objects/LinksArray.php
    +++ b/src/objects/LinksArray.php
    @@ -9,7 +9,6 @@
     /**
      * an array of links (strings and LinkObjects), used for:
      * - type links in an ErrorObject
    - * - profile links at root level
      */
     class LinksArray implements ObjectInterface {
     	/** @var array with string|LinkObject */
    diff --git a/src/objects/LinksObject.php b/src/objects/LinksObject.php
    index 9e6347b..67e9640 100644
    --- a/src/objects/LinksObject.php
    +++ b/src/objects/LinksObject.php
    @@ -5,13 +5,14 @@
     use alsvanzelf\jsonapi\exceptions\DuplicateException;
     use alsvanzelf\jsonapi\helpers\AtMemberManager;
     use alsvanzelf\jsonapi\helpers\Converter;
    +use alsvanzelf\jsonapi\helpers\ExtensionMemberManager;
     use alsvanzelf\jsonapi\helpers\Validator;
     use alsvanzelf\jsonapi\interfaces\ObjectInterface;
     use alsvanzelf\jsonapi\objects\LinkObject;
     use alsvanzelf\jsonapi\objects\LinksArray;
     
     class LinksObject implements ObjectInterface {
    -	use AtMemberManager;
    +	use AtMemberManager, ExtensionMemberManager;
     	
     	/** @var array with string|LinkObject */
     	protected $links = [];
    @@ -161,14 +162,31 @@ public function appendLinkObject($key, LinkObject $linkObject) {
     	 * @inheritDoc
     	 */
     	public function isEmpty() {
    -		return ($this->links === [] && $this->hasAtMembers() === false);
    +		if ($this->links !== []) {
    +			return false;
    +		}
    +		if ($this->hasAtMembers()) {
    +			return false;
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			return false;
    +		}
    +		
    +		return true;
     	}
     	
     	/**
     	 * @inheritDoc
     	 */
     	public function toArray() {
    -		$array = $this->getAtMembers();
    +		$array = [];
    +		
    +		if ($this->hasAtMembers()) {
    +			$array = array_merge($array, $this->getAtMembers());
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			$array = array_merge($array, $this->getExtensionMembers());
    +		}
     		
     		foreach ($this->links as $key => $link) {
     			if ($link instanceof LinkObject && $link->isEmpty() === false) {
    diff --git a/src/objects/MetaObject.php b/src/objects/MetaObject.php
    index f31eb28..03e9e1e 100644
    --- a/src/objects/MetaObject.php
    +++ b/src/objects/MetaObject.php
    @@ -4,11 +4,12 @@
     
     use alsvanzelf\jsonapi\helpers\AtMemberManager;
     use alsvanzelf\jsonapi\helpers\Converter;
    +use alsvanzelf\jsonapi\helpers\ExtensionMemberManager;
     use alsvanzelf\jsonapi\helpers\Validator;
     use alsvanzelf\jsonapi\interfaces\ObjectInterface;
     
     class MetaObject implements ObjectInterface {
    -	use AtMemberManager;
    +	use AtMemberManager, ExtensionMemberManager;
     	
     	/** @var array */
     	protected $meta = [];
    @@ -67,13 +68,32 @@ public function add($key, $value) {
     	 * @inheritDoc
     	 */
     	public function isEmpty() {
    -		return ($this->meta === [] && $this->hasAtMembers() === false);
    +		if ($this->meta !== []) {
    +			return false;
    +		}
    +		if ($this->hasAtMembers()) {
    +			return false;
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			return false;
    +		}
    +		
    +		return true;
     	}
     	
     	/**
     	 * @inheritDoc
     	 */
     	public function toArray() {
    -		return array_merge($this->getAtMembers(), $this->meta);
    +		$array = [];
    +		
    +		if ($this->hasAtMembers()) {
    +			$array = array_merge($array, $this->getAtMembers());
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			$array = array_merge($array, $this->getExtensionMembers());
    +		}
    +		
    +		return array_merge($array, $this->meta);
     	}
     }
    diff --git a/src/objects/RelationshipObject.php b/src/objects/RelationshipObject.php
    index ea62561..7644cd5 100644
    --- a/src/objects/RelationshipObject.php
    +++ b/src/objects/RelationshipObject.php
    @@ -5,6 +5,7 @@
     use alsvanzelf\jsonapi\CollectionDocument;
     use alsvanzelf\jsonapi\exceptions\InputException;
     use alsvanzelf\jsonapi\helpers\AtMemberManager;
    +use alsvanzelf\jsonapi\helpers\ExtensionMemberManager;
     use alsvanzelf\jsonapi\helpers\LinksManager;
     use alsvanzelf\jsonapi\interfaces\ObjectInterface;
     use alsvanzelf\jsonapi\interfaces\PaginableInterface;
    @@ -15,7 +16,7 @@
     use alsvanzelf\jsonapi\objects\ResourceObject;
     
     class RelationshipObject implements ObjectInterface, PaginableInterface, RecursiveResourceContainerInterface {
    -	use AtMemberManager, LinksManager;
    +	use AtMemberManager, ExtensionMemberManager, LinksManager;
     	
     	const TO_ONE  = 'one';
     	const TO_MANY = 'many';
    @@ -273,6 +274,9 @@ public function isEmpty() {
     		if ($this->hasAtMembers()) {
     			return false;
     		}
    +		if ($this->hasExtensionMembers()) {
    +			return false;
    +		}
     		
     		return true;
     	}
    @@ -281,7 +285,14 @@ public function isEmpty() {
     	 * @inheritDoc
     	 */
     	public function toArray() {
    -		$array = $this->getAtMembers();
    +		$array = [];
    +		
    +		if ($this->hasAtMembers()) {
    +			$array = array_merge($array, $this->getAtMembers());
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			$array = array_merge($array, $this->getExtensionMembers());
    +		}
     		
     		if ($this->links !== null && $this->links->isEmpty() === false) {
     			$array['links'] = $this->links->toArray();
    diff --git a/src/objects/RelationshipsObject.php b/src/objects/RelationshipsObject.php
    index 6764aa3..c0bf3e0 100644
    --- a/src/objects/RelationshipsObject.php
    +++ b/src/objects/RelationshipsObject.php
    @@ -4,6 +4,7 @@
     
     use alsvanzelf\jsonapi\exceptions\DuplicateException;
     use alsvanzelf\jsonapi\helpers\AtMemberManager;
    +use alsvanzelf\jsonapi\helpers\ExtensionMemberManager;
     use alsvanzelf\jsonapi\helpers\Validator;
     use alsvanzelf\jsonapi\interfaces\ObjectInterface;
     use alsvanzelf\jsonapi\interfaces\RecursiveResourceContainerInterface;
    @@ -12,7 +13,7 @@
     use alsvanzelf\jsonapi\objects\ResourceObject;
     
     class RelationshipsObject implements ObjectInterface, RecursiveResourceContainerInterface {
    -	use AtMemberManager;
    +	use AtMemberManager, ExtensionMemberManager;
     	
     	/** @var RelationshipObject[] */
     	protected $relationships = [];
    @@ -77,14 +78,31 @@ public function getKeys() {
     	 * @inheritDoc
     	 */
     	public function isEmpty() {
    -		return ($this->relationships === [] && $this->hasAtMembers() === false);
    +		if ($this->relationships !== []) {
    +			return false;
    +		}
    +		if ($this->hasAtMembers()) {
    +			return false;
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			return false;
    +		}
    +		
    +		return true;
     	}
     	
     	/**
     	 * @inheritDoc
     	 */
     	public function toArray() {
    -		$array = $this->getAtMembers();
    +		$array = [];
    +		
    +		if ($this->hasAtMembers()) {
    +			$array = array_merge($array, $this->getAtMembers());
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			$array = array_merge($array, $this->getExtensionMembers());
    +		}
     		
     		foreach ($this->relationships as $key => $relationshipObject) {
     			$array[$key] = $relationshipObject->toArray();
    diff --git a/src/objects/ResourceIdentifierObject.php b/src/objects/ResourceIdentifierObject.php
    index a06c33f..05aaa6b 100644
    --- a/src/objects/ResourceIdentifierObject.php
    +++ b/src/objects/ResourceIdentifierObject.php
    @@ -5,13 +5,14 @@
     use alsvanzelf\jsonapi\exceptions\Exception;
     use alsvanzelf\jsonapi\exceptions\DuplicateException;
     use alsvanzelf\jsonapi\helpers\AtMemberManager;
    +use alsvanzelf\jsonapi\helpers\ExtensionMemberManager;
     use alsvanzelf\jsonapi\helpers\Validator;
     use alsvanzelf\jsonapi\interfaces\ObjectInterface;
     use alsvanzelf\jsonapi\interfaces\ResourceInterface;
     use alsvanzelf\jsonapi\objects\MetaObject;
     
     class ResourceIdentifierObject implements ObjectInterface, ResourceInterface {
    -	use AtMemberManager;
    +	use AtMemberManager, ExtensionMemberManager;
     	
     	/** @var string */
     	protected $type;
    @@ -191,6 +192,9 @@ public function isEmpty() {
     		if ($this->hasAtMembers()) {
     			return false;
     		}
    +		if ($this->hasExtensionMembers()) {
    +			return false;
    +		}
     		
     		return true;
     	}
    @@ -199,7 +203,7 @@ public function isEmpty() {
     	 * @inheritDoc
     	 */
     	public function toArray() {
    -		$array = $this->getAtMembers();
    +		$array = [];
     		
     		$array['type'] = $this->type;
     		
    @@ -210,6 +214,13 @@ public function toArray() {
     			$array['lid'] = $this->lid;
     		}
     		
    +		if ($this->hasAtMembers()) {
    +			$array = array_merge($array, $this->getAtMembers());
    +		}
    +		if ($this->hasExtensionMembers()) {
    +			$array = array_merge($array, $this->getExtensionMembers());
    +		}
    +		
     		if ($this->meta !== null && $this->meta->isEmpty() === false) {
     			$array['meta'] = $this->meta->toArray();
     		}
    diff --git a/tests/ConverterTest.php b/tests/ConverterTest.php
    index 85839c9..f5c7d51 100644
    --- a/tests/ConverterTest.php
    +++ b/tests/ConverterTest.php
    @@ -4,6 +4,7 @@
     
     use alsvanzelf\jsonapi\helpers\Converter;
     use alsvanzelf\jsonapi\objects\AttributesObject;
    +use alsvanzelf\jsonapiTests\extensions\TestExtension;
     use alsvanzelf\jsonapiTests\profiles\TestProfile;
     use PHPUnit\Framework\TestCase;
     
    @@ -63,25 +64,63 @@ public function dataProviderCamelCaseToWords_HappyPath() {
     		];
     	}
     	
    -	public function testMergeProfilesInContentType_HappyPath() {
    -		$this->assertSame('foo', Converter::mergeProfilesInContentType('foo', []));
    +	/**
    +	 * @group Extensions
    +	 * @group Profiles
    +	 */
    +	public function testPrepareContentType_HappyPath() {
    +		$this->assertSame('foo', Converter::prepareContentType('foo', [], []));
     	}
     	
    -	public function testMergeProfilesInContentType_WithProfileStringLink() {
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testPrepareContentType_WithExtensionStringLink() {
    +		$extension = new TestExtension();
    +		$extension->setOfficialLink('bar');
    +		
    +		$this->assertSame('foo; ext="bar"', Converter::prepareContentType('foo', [$extension], []));
    +	}
    +	
    +	/**
    +	 * @group Profiles
    +	 */
    +	public function testPrepareContentType_WithProfileStringLink() {
     		$profile = new TestProfile();
     		$profile->setOfficialLink('bar');
     		
    -		$this->assertSame('foo;profile="bar", foo', Converter::mergeProfilesInContentType('foo', [$profile]));
    +		$this->assertSame('foo; profile="bar"', Converter::prepareContentType('foo', [], [$profile]));
     	}
     	
    -	public function testMergeProfilesInContentType_WithMultipleProfiles() {
    +	/**
    +	 * @group Extensions
    +	 * @group Profiles
    +	 */
    +	public function testPrepareContentType_WithMultipleExtensionsAndProfiles() {
    +		$extension1 = new TestExtension();
    +		$extension1->setOfficialLink('bar');
    +		
    +		$extension2 = new TestExtension();
    +		$extension2->setOfficialLink('baz');
    +		
     		$profile1 = new TestProfile();
     		$profile1->setOfficialLink('bar');
     		
     		$profile2 = new TestProfile();
     		$profile2->setOfficialLink('baz');
     		
    -		$this->assertSame('foo;profile="bar baz", foo', Converter::mergeProfilesInContentType('foo', [$profile1, $profile2]));
    +		$this->assertSame('foo; ext="bar baz"; profile="bar baz"', Converter::prepareContentType('foo', [$extension1, $extension2], [$profile1, $profile2]));
    +	}
    +	
    +	/**
    +	 * test method while it is part of the interface
    +	 * @group Profiles
    +	 */
    +	public function testMergeProfilesInContentType_HappyPath() {
    +		$profile = new TestProfile();
    +		$profile->setOfficialLink('bar');
    +		
    +		$this->assertSame('foo; profile="bar"', Converter::mergeProfilesInContentType('foo', [$profile]));
     	}
     }
     
    diff --git a/tests/DocumentTest.php b/tests/DocumentTest.php
    index d363b8e..30043cb 100644
    --- a/tests/DocumentTest.php
    +++ b/tests/DocumentTest.php
    @@ -2,10 +2,12 @@
     
     namespace alsvanzelf\jsonapiTests;
     
    +use alsvanzelf\jsonapi\exceptions\DuplicateException;
     use alsvanzelf\jsonapi\exceptions\Exception;
     use alsvanzelf\jsonapi\exceptions\InputException;
     use alsvanzelf\jsonapi\objects\LinkObject;
     use alsvanzelf\jsonapiTests\TestableNonAbstractDocument as Document;
    +use alsvanzelf\jsonapiTests\extensions\TestExtension;
     use alsvanzelf\jsonapiTests\profiles\TestProfile;
     use PHPUnit\Framework\TestCase;
     
    @@ -229,6 +231,71 @@ public function testAddLinkObject_HappyPath() {
     		$this->assertSame('https://jsonapi.org', $array['links']['foo']['href']);
     	}
     	
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testApplyExtension_HappyPath() {
    +		$extension = new TestExtension();
    +		$extension->setNamespace('test');
    +		$extension->setOfficialLink('https://jsonapi.org');
    +		
    +		$document = new Document();
    +		$document->applyExtension($extension);
    +		$document->addExtensionMember($extension, 'foo', 'bar');
    +		
    +		$array = $document->toArray();
    +		
    +		$this->assertArrayHasKey('jsonapi', $array);
    +		$this->assertCount(2, $array['jsonapi']);
    +		$this->assertSame('1.1', $array['jsonapi']['version']);
    +		$this->assertArrayHasKey('ext', $array['jsonapi']);
    +		$this->assertCount(1, $array['jsonapi']['ext']);
    +		$this->assertArrayHasKey(0, $array['jsonapi']['ext']);
    +		$this->assertSame('https://jsonapi.org', $array['jsonapi']['ext'][0]);
    +		$this->assertArrayHasKey('test:foo', $array);
    +		$this->assertSame('bar', $array['test:foo']);
    +	}
    +	
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testApplyExtension_InvalidNamespace() {
    +		$document  = new Document();
    +		$extension = new TestExtension();
    +		$extension->setNamespace('foo-bar');
    +		
    +		$this->expectException(Exception::class);
    +		$this->expectExceptionMessage('invalid namespace "foo-bar"');
    +		
    +		$document->applyExtension($extension);
    +	}
    +	
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testApplyExtension_ConflictingNamespace() {
    +		$document  = new Document();
    +		
    +		$extension1 = new TestExtension();
    +		$extension1->setNamespace('foo');
    +		$document->applyExtension($extension1);
    +		
    +		$extension2 = new TestExtension();
    +		$extension2->setNamespace('bar');
    +		$document->applyExtension($extension2);
    +		
    +		$extension3 = new TestExtension();
    +		$extension3->setNamespace('foo');
    +		
    +		$this->expectException(DuplicateException::class);
    +		$this->expectExceptionMessage('an extension with namespace "foo" is already applied');
    +		
    +		$document->applyExtension($extension3);
    +	}
    +	
    +	/**
    +	 * @group Profiles
    +	 */
     	public function testApplyProfile_HappyPath() {
     		$profile = new TestProfile();
     		$profile->setOfficialLink('https://jsonapi.org');
    @@ -238,12 +305,13 @@ public function testApplyProfile_HappyPath() {
     		
     		$array = $document->toArray();
     		
    -		$this->assertArrayHasKey('links', $array);
    -		$this->assertCount(1, $array['links']);
    -		$this->assertArrayHasKey('profile', $array['links']);
    -		$this->assertCount(1, $array['links']['profile']);
    -		$this->assertArrayHasKey(0, $array['links']['profile']);
    -		$this->assertSame('https://jsonapi.org', $array['links']['profile'][0]);
    +		$this->assertArrayHasKey('jsonapi', $array);
    +		$this->assertCount(2, $array['jsonapi']);
    +		$this->assertSame('1.1', $array['jsonapi']['version']);
    +		$this->assertArrayHasKey('profile', $array['jsonapi']);
    +		$this->assertCount(1, $array['jsonapi']['profile']);
    +		$this->assertArrayHasKey(0, $array['jsonapi']['profile']);
    +		$this->assertSame('https://jsonapi.org', $array['jsonapi']['profile'][0]);
     	}
     	
     	public function testToJson_HappyPath() {
    diff --git a/tests/SeparateProcessTest.php b/tests/SeparateProcessTest.php
    index d81453f..ec00ccd 100644
    --- a/tests/SeparateProcessTest.php
    +++ b/tests/SeparateProcessTest.php
    @@ -3,6 +3,7 @@
     namespace alsvanzelf\jsonapiTests;
     
     use alsvanzelf\jsonapiTests\TestableNonAbstractDocument as Document;
    +use alsvanzelf\jsonapiTests\extensions\TestExtension;
     use alsvanzelf\jsonapiTests\profiles\TestProfile;
     use PHPUnit\Framework\TestCase;
     
    @@ -74,6 +75,39 @@ public function testSendResponse_ContentTypeHeader() {
     	
     	/**
     	 * @runInSeparateProcess
    +	 * @group Extensions
    +	 */
    +	public function testSendResponse_ContentTypeHeaderWithExtensions() {
    +		if (extension_loaded('xdebug') === false) {
    +			$this->markTestSkipped('can not run without xdebug');
    +		}
    +		
    +		$extension = new TestExtension();
    +		$extension->setNamespace('one');
    +		$extension->setOfficialLink('https://jsonapi.org');
    +		
    +		$document = new Document();
    +		$document->applyExtension($extension);
    +		
    +		ob_start();
    +		$document->sendResponse();
    +		ob_end_clean();
    +		$this->assertSame(['Content-Type: '.Document::CONTENT_TYPE_OFFICIAL.'; ext="https://jsonapi.org"'], xdebug_get_headers());
    +		
    +		$extension = new TestExtension();
    +		$extension->setNamespace('two');
    +		$extension->setOfficialLink('https://jsonapi.org/2');
    +		$document->applyExtension($extension);
    +		
    +		ob_start();
    +		$document->sendResponse();
    +		ob_end_clean();
    +		$this->assertSame(['Content-Type: '.Document::CONTENT_TYPE_OFFICIAL.'; ext="https://jsonapi.org https://jsonapi.org/2"'], xdebug_get_headers());
    +	}
    +	
    +	/**
    +	 * @runInSeparateProcess
    +	 * @group Profiles
     	 */
     	public function testSendResponse_ContentTypeHeaderWithProfiles() {
     		if (extension_loaded('xdebug') === false) {
    @@ -89,7 +123,7 @@ public function testSendResponse_ContentTypeHeaderWithProfiles() {
     		ob_start();
     		$document->sendResponse();
     		ob_end_clean();
    -		$this->assertSame(['Content-Type: '.Document::CONTENT_TYPE_OFFICIAL.';profile="https://jsonapi.org", '.Document::CONTENT_TYPE_OFFICIAL], xdebug_get_headers());
    +		$this->assertSame(['Content-Type: '.Document::CONTENT_TYPE_OFFICIAL.'; profile="https://jsonapi.org"'], xdebug_get_headers());
     		
     		$profile = new TestProfile();
     		$profile->setOfficialLink('https://jsonapi.org/2');
    @@ -98,7 +132,7 @@ public function testSendResponse_ContentTypeHeaderWithProfiles() {
     		ob_start();
     		$document->sendResponse();
     		ob_end_clean();
    -		$this->assertSame(['Content-Type: '.Document::CONTENT_TYPE_OFFICIAL.';profile="https://jsonapi.org https://jsonapi.org/2", '.Document::CONTENT_TYPE_OFFICIAL], xdebug_get_headers());
    +		$this->assertSame(['Content-Type: '.Document::CONTENT_TYPE_OFFICIAL.'; profile="https://jsonapi.org https://jsonapi.org/2"'], xdebug_get_headers());
     	}
     	
     	/**
    diff --git a/tests/example_output/ExampleEverywhereExtension.php b/tests/example_output/ExampleEverywhereExtension.php
    new file mode 100644
    index 0000000..885e26b
    --- /dev/null
    +++ b/tests/example_output/ExampleEverywhereExtension.php
    @@ -0,0 +1,15 @@
    +format(\DateTime::ISO8601);
    +		}
    +		if ($updated !== null) {
    +			$timestamps['updated'] = $updated->format(\DateTime::ISO8601);
    +		}
    +		
    +		$resource->add('timestamps', $timestamps);
    +	}
    +}
    diff --git a/tests/example_output/ExampleVersionExtension.php b/tests/example_output/ExampleVersionExtension.php
    new file mode 100644
    index 0000000..bde454a
    --- /dev/null
    +++ b/tests/example_output/ExampleVersionExtension.php
    @@ -0,0 +1,26 @@
    +getResource()->addExtensionMember($this, 'id', $version);
    +		}
    +		else {
    +			$resource->addExtensionMember($this, 'id', $version);
    +		}
    +	}
    +}
    diff --git a/tests/example_output/ExampleVersionProfile.php b/tests/example_output/ExampleVersionProfile.php
    deleted file mode 100644
    index 266be3f..0000000
    --- a/tests/example_output/ExampleVersionProfile.php
    +++ /dev/null
    @@ -1,27 +0,0 @@
    -addMeta('version', $version, $level=Document::LEVEL_RESOURCE);
    -		}
    -		else {
    -			$resource->addMeta('version', $version);
    -		}
    -	}
    -}
    diff --git a/tests/example_output/at_members_everywhere/at_members_everywhere.json b/tests/example_output/at_members_everywhere/at_members_everywhere.json
    index feb9aea..76638c7 100644
    --- a/tests/example_output/at_members_everywhere/at_members_everywhere.json
    +++ b/tests/example_output/at_members_everywhere/at_members_everywhere.json
    @@ -21,9 +21,9 @@
     		"@context": "/meta/@context"
     	},
     	"data": {
    -		"@context": "/data/@context",
     		"type": "user",
     		"id": "42",
    +		"@context": "/data/@context",
     		"meta": {
     			"@context": "/data/meta/@context"
     		},
    @@ -48,9 +48,9 @@
     			"bar": {
     				"@context": "/data/relationships/bar/@context",
     				"data": {
    -					"@context": "/data/relationships/bar/data/@context",
     					"type": "user",
     					"id": "2",
    +					"@context": "/data/relationships/bar/data/@context",
     					"meta": {
     						"@context": "/data/relationships/bar/data/meta/@context"
     					}
    @@ -70,17 +70,17 @@
     	},
     	"included": [
     		{
    -			"@context": "/included/0/@context",
     			"type": "user",
     			"id": "1",
    +			"@context": "/included/0/@context",
     			"attributes": {
     				"@context": "/included/0/attributes/@context"
     			}
     		},
     		{
    -			"@context": "/included/1/@context",
     			"type": "user",
     			"id": "3",
    +			"@context": "/included/1/@context",
     			"relationships": {
     				"@context": "/included/1/relationships/@context"
     			}
    diff --git a/tests/example_output/cursor_pagination_profile/cursor_pagination_profile.json b/tests/example_output/cursor_pagination_profile/cursor_pagination_profile.json
    index cb84fda..02b81ad 100644
    --- a/tests/example_output/cursor_pagination_profile/cursor_pagination_profile.json
    +++ b/tests/example_output/cursor_pagination_profile/cursor_pagination_profile.json
    @@ -1,11 +1,11 @@
     {
     	"jsonapi": {
    -		"version": "1.1"
    -	},
    -	"links": {
    +		"version": "1.1",
     		"profile": [
     			"https://jsonapi.org/profiles/ethanresnick/cursor-pagination/"
    -		],
    +		]
    +	},
    +	"links": {
     		"prev": null,
     		"next": {
     			"href": "/users?sort=42&page[size]=10&page[after]=zaphod"
    diff --git a/tests/example_output/example_profile/example_profile.json b/tests/example_output/example_profile/example_profile.json
    deleted file mode 100644
    index 53daa9f..0000000
    --- a/tests/example_output/example_profile/example_profile.json
    +++ /dev/null
    @@ -1,17 +0,0 @@
    -{
    -	"jsonapi": {
    -		"version": "1.1"
    -	},
    -	"links": {
    -		"profile": [
    -			"https://jsonapi.org/format/1.1/#profile-keywords"
    -		]
    -	},
    -	"data": {
    -		"type": "user",
    -		"id": "42",
    -		"meta": {
    -			"version": "2019"
    -		}
    -	}
    -}
    diff --git a/tests/example_output/example_profile/example_profile.php b/tests/example_output/example_profile/example_profile.php
    deleted file mode 100644
    index 65b3bb4..0000000
    --- a/tests/example_output/example_profile/example_profile.php
    +++ /dev/null
    @@ -1,19 +0,0 @@
    -applyProfile($profile);
    -		
    -		$profile->setVersion($document, '2019');
    -		
    -		return $document;
    -	}
    -}
    diff --git a/tests/example_output/extension/extension.json b/tests/example_output/extension/extension.json
    new file mode 100644
    index 0000000..4b857c0
    --- /dev/null
    +++ b/tests/example_output/extension/extension.json
    @@ -0,0 +1,13 @@
    +{
    +	"jsonapi": {
    +		"version": "1.1",
    +		"ext": [
    +			"https://jsonapi.org/format/1.1/#extension-rules"
    +		]
    +	},
    +	"data": {
    +		"type": "user",
    +		"id": "42",
    +		"version:id": "2019"
    +	}
    +}
    diff --git a/tests/example_output/extension/extension.php b/tests/example_output/extension/extension.php
    new file mode 100644
    index 0000000..4535c63
    --- /dev/null
    +++ b/tests/example_output/extension/extension.php
    @@ -0,0 +1,19 @@
    +applyExtension($extension);
    +		
    +		$extension->setVersion($document, '2019');
    +		
    +		return $document;
    +	}
    +}
    diff --git a/tests/example_output/extension_members_everywhere/extension_members_everywhere.json b/tests/example_output/extension_members_everywhere/extension_members_everywhere.json
    new file mode 100644
    index 0000000..f41faa3
    --- /dev/null
    +++ b/tests/example_output/extension_members_everywhere/extension_members_everywhere.json
    @@ -0,0 +1,89 @@
    +{
    +	"everywhere:key": "/key",
    +	"jsonapi": {
    +		"everywhere:key": "/jsonapi/key",
    +		"version": "1.1",
    +		"meta": {
    +			"everywhere:key": "/jsonapi/meta/key"
    +		}
    +	},
    +	"links": {
    +		"everywhere:key": "/links/key",
    +		"foo": {
    +			"everywhere:key": "/links/foo/key",
    +			"href": "https://jsonapi.org",
    +			"meta": {
    +				"everywhere:key": "/links/foo/meta/key"
    +			}
    +		}
    +	},
    +	"meta": {
    +		"everywhere:key": "/meta/key"
    +	},
    +	"data": {
    +		"type": "user",
    +		"id": "42",
    +		"everywhere:key": "/data/key",
    +		"meta": {
    +			"everywhere:key": "/data/meta/key"
    +		},
    +		"attributes": {
    +			"everywhere:key": "/data/attributes/key"
    +		},
    +		"relationships": {
    +			"everywhere:key": "/data/relationships/key",
    +			"foo": {
    +				"everywhere:key": "/data/relationships/foo/key",
    +				"links": {
    +					"everywhere:key": "/data/relationships/foo/links/key"
    +				},
    +				"data": {
    +					"type": "user",
    +					"id": "1"
    +				},
    +				"meta": {
    +					"everywhere:key": "/data/relationships/foo/meta/key"
    +				}
    +			},
    +			"bar": {
    +				"everywhere:key": "/data/relationships/bar/key",
    +				"data": {
    +					"type": "user",
    +					"id": "2",
    +					"everywhere:key": "/data/relationships/bar/data/key",
    +					"meta": {
    +						"everywhere:key": "/data/relationships/bar/data/meta/key"
    +					}
    +				}
    +			}
    +		},
    +		"links": {
    +			"everywhere:key": "/data/links/key",
    +			"foo": {
    +				"everywhere:key": "/data/links/foo/key",
    +				"href": "https://jsonapi.org",
    +				"meta": {
    +					"everywhere:key": "/data/links/foo/meta/key"
    +				}
    +			}
    +		}
    +	},
    +	"included": [
    +		{
    +			"type": "user",
    +			"id": "1",
    +			"everywhere:key": "/included/0/key",
    +			"attributes": {
    +				"everywhere:key": "/included/0/attributes/key"
    +			}
    +		},
    +		{
    +			"type": "user",
    +			"id": "3",
    +			"everywhere:key": "/included/1/key",
    +			"relationships": {
    +				"everywhere:key": "/included/1/relationships/key"
    +			}
    +		}
    +	]
    +}
    diff --git a/tests/example_output/extension_members_everywhere/extension_members_everywhere.php b/tests/example_output/extension_members_everywhere/extension_members_everywhere.php
    new file mode 100644
    index 0000000..f7747f2
    --- /dev/null
    +++ b/tests/example_output/extension_members_everywhere/extension_members_everywhere.php
    @@ -0,0 +1,173 @@
    +applyExtension($extension);
    +		
    +		/**
    +		 * root
    +		 */
    +		
    +		$document->addExtensionMember($extension, 'key', '/key');
    +		
    +		/**
    +		 * jsonapi
    +		 */
    +		
    +		$metaObject = new MetaObject();
    +		$metaObject->addExtensionMember($extension, 'key', '/jsonapi/meta/key');
    +		
    +		$jsonapiObject = new JsonapiObject();
    +		$jsonapiObject->addExtensionMember($extension, 'key', '/jsonapi/key');
    +		$jsonapiObject->setMetaObject($metaObject);
    +		$document->setJsonapiObject($jsonapiObject);
    +		
    +		/**
    +		 * links
    +		 */
    +		
    +		$metaObject = new MetaObject();
    +		$metaObject->addExtensionMember($extension, 'key', '/links/foo/meta/key');
    +		
    +		$linkObject = new LinkObject('https://jsonapi.org');
    +		$linkObject->addExtensionMember($extension, 'key', '/links/foo/key');
    +		$linkObject->setMetaObject($metaObject);
    +		
    +		$linksObject = new LinksObject();
    +		$linksObject->addExtensionMember($extension, 'key', '/links/key');
    +		$linksObject->addLinkObject('foo', $linkObject);
    +		$document->setLinksObject($linksObject);
    +		
    +		/**
    +		 * meta
    +		 */
    +		
    +		$metaObject = new MetaObject();
    +		$metaObject->addExtensionMember($extension, 'key', '/meta/key');
    +		$document->setMetaObject($metaObject);
    +		
    +		/**
    +		 * resource
    +		 */
    +		
    +		/**
    +		 * resource - relationships
    +		 * 
    +		 * @todo make it work to have extension members in both the identifier and the resource parts
    +		 *       e.g. it is missing in the data of the first relationship (`data.relationships.foo.data.key`)
    +		 *       whereas it does appear in the second relationship (`data.relationships.bar.data.key`)
    +		 * @see https://github.com/json-api/json-api/issues/1367
    +		 */
    +		
    +		$relationshipsObject = new RelationshipsObject();
    +		$relationshipsObject->addExtensionMember($extension, 'key', '/data/relationships/key');
    +		
    +		$attributesObject = new AttributesObject();
    +		$attributesObject->addExtensionMember($extension, 'key', '/included/0/attributes/key');
    +		
    +		$resourceObject = new ResourceObject('user', 1);
    +		$resourceObject->addExtensionMember($extension, 'key', '/included/0/key');
    +		$resourceObject->setAttributesObject($attributesObject);
    +		
    +		$linksObject = new LinksObject();
    +		$linksObject->addExtensionMember($extension, 'key', '/data/relationships/foo/links/key');
    +		
    +		$metaObject = new MetaObject();
    +		$metaObject->addExtensionMember($extension, 'key', '/data/relationships/foo/meta/key');
    +		
    +		$relationshipObject = new RelationshipObject(RelationshipObject::TO_ONE);
    +		$relationshipObject->addExtensionMember($extension, 'key', '/data/relationships/foo/key');
    +		$relationshipObject->setResource($resourceObject);
    +		$relationshipObject->setLinksObject($linksObject);
    +		$relationshipObject->setMetaObject($metaObject);
    +		
    +		$relationshipsObject->addRelationshipObject('foo', $relationshipObject);
    +		
    +		$metaObject = new MetaObject();
    +		$metaObject->addExtensionMember($extension, 'key', '/data/relationships/bar/data/meta/key');
    +		
    +		$resourceIdentifierObject = new ResourceIdentifierObject('user', 2);
    +		$resourceIdentifierObject->addExtensionMember($extension, 'key', '/data/relationships/bar/data/key');
    +		$resourceIdentifierObject->setMetaObject($metaObject);
    +		
    +		$relationshipObject = new RelationshipObject(RelationshipObject::TO_ONE);
    +		$relationshipObject->addExtensionMember($extension, 'key', '/data/relationships/bar/key');
    +		$relationshipObject->setResource($resourceIdentifierObject);
    +		
    +		$relationshipsObject->addRelationshipObject('bar', $relationshipObject);
    +		
    +		/**
    +		 * resource - attributes
    +		 */
    +		
    +		$attributesObject = new AttributesObject();
    +		$attributesObject->addExtensionMember($extension, 'key', '/data/attributes/key');
    +		
    +		/**
    +		 * resource - links
    +		 */
    +		
    +		$metaObject = new MetaObject();
    +		$metaObject->addExtensionMember($extension, 'key', '/data/links/foo/meta/key');
    +		
    +		$linkObject = new LinkObject('https://jsonapi.org');
    +		$linkObject->addExtensionMember($extension, 'key', '/data/links/foo/key');
    +		$linkObject->setMetaObject($metaObject);
    +		
    +		$linksObject = new LinksObject();
    +		$linksObject->addExtensionMember($extension, 'key', '/data/links/key');
    +		$linksObject->addLinkObject('foo', $linkObject);
    +		
    +		/**
    +		 * resource - meta
    +		 */
    +		
    +		$metaObject = new MetaObject();
    +		$metaObject->addExtensionMember($extension, 'key', '/data/meta/key');
    +		
    +		/**
    +		 * resource - resource
    +		 */
    +		
    +		$resourceObject = new ResourceObject('user', 42);
    +		$resourceObject->addExtensionMember($extension, 'key', '/data/key');
    +		$resourceObject->setAttributesObject($attributesObject);
    +		$resourceObject->setLinksObject($linksObject);
    +		$resourceObject->setMetaObject($metaObject);
    +		$resourceObject->setRelationshipsObject($relationshipsObject);
    +		
    +		$document->setPrimaryResource($resourceObject);
    +		
    +		/**
    +		 * included
    +		 */
    +		
    +		$relationshipsObject = new RelationshipsObject();
    +		$relationshipsObject->addExtensionMember($extension, 'key', '/included/1/relationships/key');
    +		
    +		$resourceObject = new ResourceObject('user', 3);
    +		$resourceObject->addExtensionMember($extension, 'key', '/included/1/key');
    +		$resourceObject->setRelationshipsObject($relationshipsObject);
    +		
    +		$document->addIncludedResourceObject($resourceObject);
    +		
    +		return $document;
    +	}
    +}
    diff --git a/tests/example_output/profile/profile.json b/tests/example_output/profile/profile.json
    new file mode 100644
    index 0000000..49d5808
    --- /dev/null
    +++ b/tests/example_output/profile/profile.json
    @@ -0,0 +1,18 @@
    +{
    +	"jsonapi": {
    +		"version": "1.1",
    +		"profile": [
    +			"https://jsonapi.org/recommendations/#authoring-profiles"
    +		]
    +	},
    +	"data": {
    +		"type": "user",
    +		"id": "42",
    +		"attributes": {
    +			"timestamps": {
    +				"created": "2019-01-01T00:00:00+0000",
    +				"updated": "2021-01-01T00:00:00+0000"
    +			}
    +		}
    +	}
    +}
    diff --git a/tests/example_output/profile/profile.php b/tests/example_output/profile/profile.php
    new file mode 100644
    index 0000000..16deba8
    --- /dev/null
    +++ b/tests/example_output/profile/profile.php
    @@ -0,0 +1,19 @@
    +applyProfile($profile);
    +		
    +		$profile->setTimestamps($document, new \DateTime('2019-01-01T00:00:00+0000'), new \DateTime('2021-01-01T00:00:00+0000'));
    +		
    +		return $document;
    +	}
    +}
    diff --git a/tests/extensions/AtomicOperationsDocumentTest.php b/tests/extensions/AtomicOperationsDocumentTest.php
    new file mode 100644
    index 0000000..523ca22
    --- /dev/null
    +++ b/tests/extensions/AtomicOperationsDocumentTest.php
    @@ -0,0 +1,51 @@
    +add('name', 'Ford');
    +		$resource2->add('name', 'Arthur');
    +		$resource3->add('name', 'Zaphod');
    +		$document->addResults($resource1, $resource2, $resource3);
    +		
    +		$array = $document->toArray();
    +		
    +		$this->assertArrayHasKey('jsonapi', $array);
    +		$this->assertArrayHasKey('ext', $array['jsonapi']);
    +		$this->assertCount(1, $array['jsonapi']['ext']);
    +		$this->assertSame((new AtomicOperationsExtension())->getOfficialLink(), $array['jsonapi']['ext'][0]);
    +		
    +		$this->assertArrayHasKey('atomic:results', $array);
    +		$this->assertCount(3, $array['atomic:results']);
    +		$this->assertSame(['data' => $resource1->toArray()], $array['atomic:results'][0]);
    +		$this->assertSame(['data' => $resource2->toArray()], $array['atomic:results'][1]);
    +		$this->assertSame(['data' => $resource3->toArray()], $array['atomic:results'][2]);
    +	}
    +	
    +	public function testSetResults_EmptySuccessResults() {
    +		$document = new AtomicOperationsDocument();
    +		$array    = $document->toArray();
    +		
    +		$this->assertArrayHasKey('jsonapi', $array);
    +		$this->assertArrayHasKey('ext', $array['jsonapi']);
    +		$this->assertCount(1, $array['jsonapi']['ext']);
    +		$this->assertSame((new AtomicOperationsExtension())->getOfficialLink(), $array['jsonapi']['ext'][0]);
    +		
    +		$this->assertArrayHasKey('atomic:results', $array);
    +		$this->assertCount(0, $array['atomic:results']);
    +	}
    +}
    diff --git a/tests/extensions/TestExtension.php b/tests/extensions/TestExtension.php
    new file mode 100644
    index 0000000..9b57f9e
    --- /dev/null
    +++ b/tests/extensions/TestExtension.php
    @@ -0,0 +1,26 @@
    +namespace = $namespace;
    +	}
    +	
    +	public function setOfficialLink($officialLink) {
    +		$this->officialLink = $officialLink;
    +	}
    +	
    +	public function getNamespace() {
    +		return $this->namespace;
    +	}
    +	
    +	public function getOfficialLink() {
    +		return $this->officialLink;
    +	}
    +}
    diff --git a/tests/helpers/ExtensionMemberManagerTest.php b/tests/helpers/ExtensionMemberManagerTest.php
    new file mode 100644
    index 0000000..92616bb
    --- /dev/null
    +++ b/tests/helpers/ExtensionMemberManagerTest.php
    @@ -0,0 +1,70 @@
    +setNamespace('test');
    +		
    +		$this->assertFalse($helper->hasExtensionMembers());
    +		$this->assertSame([], $helper->getExtensionMembers());
    +		
    +		$helper->addExtensionMember($extension, 'foo', 'bar');
    +		
    +		$array = $helper->getExtensionMembers();
    +		
    +		$this->assertTrue($helper->hasExtensionMembers());
    +		$this->assertCount(1, $array);
    +		$this->assertArrayHasKey('test:foo', $array);
    +		$this->assertSame('bar', $array['test:foo']);
    +	}
    +	
    +	public function testAddExtensionMember_WithNamespacePrefixed() {
    +		$helper    = new ExtensionMemberManager();
    +		$extension = new TestExtension();
    +		$extension->setNamespace('test');
    +		
    +		$helper->addExtensionMember($extension, 'test:foo', 'bar');
    +		
    +		$array = $helper->getExtensionMembers();
    +		
    +		$this->assertArrayHasKey('test:foo', $array);
    +	}
    +	
    +	public function testAddExtensionMember_WithObjectValue() {
    +		$helper    = new ExtensionMemberManager();
    +		$extension = new TestExtension();
    +		$extension->setNamespace('test');
    +		
    +		$object = new \stdClass();
    +		$object->bar = 'baz';
    +		
    +		$helper->addExtensionMember($extension, 'foo', $object);
    +		
    +		$array = $helper->getExtensionMembers();
    +		
    +		$this->assertArrayHasKey('test:foo', $array);
    +		$this->assertArrayHasKey('bar', $array['test:foo']);
    +		$this->assertSame('baz', $array['test:foo']['bar']);
    +	}
    +	
    +	public function testAddExtensionMember_InvalidNamespaceOrCharacter() {
    +		$helper    = new ExtensionMemberManager();
    +		$extension = new TestExtension();
    +		$extension->setNamespace('test');
    +		
    +		$this->expectException(InputException::class);
    +		
    +		$helper->addExtensionMember($extension, 'foo:bar', 'baz');
    +	}
    +}
    diff --git a/tests/helpers/TestableNonTraitExtensionMemberManager.php b/tests/helpers/TestableNonTraitExtensionMemberManager.php
    new file mode 100644
    index 0000000..516f103
    --- /dev/null
    +++ b/tests/helpers/TestableNonTraitExtensionMemberManager.php
    @@ -0,0 +1,12 @@
    +addAtMember('context', 'test');
     		$this->assertFalse($errorObject->isEmpty());
    +		
    +		$errorObject = new ErrorObject();
    +		$errorObject->addExtensionMember(new TestExtension(), 'foo', 'bar');
    +		$this->assertFalse($errorObject->isEmpty());
    +	}
    +	
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testToArray_WithExtensionMembers() {
    +		$errorObject = new ErrorObject();
    +		$extension   = new TestExtension();
    +		$extension->setNamespace('test');
    +		
    +		$this->assertSame([], $errorObject->toArray());
    +		
    +		$errorObject->addExtensionMember($extension, 'foo', 'bar');
    +		
    +		$array = $errorObject->toArray();
    +		
    +		$this->assertArrayHasKey('test:foo', $array);
    +		$this->assertSame('bar', $array['test:foo']);
     	}
     }
     
    diff --git a/tests/objects/JsonapiObjectTest.php b/tests/objects/JsonapiObjectTest.php
    index 0807a19..882c708 100644
    --- a/tests/objects/JsonapiObjectTest.php
    +++ b/tests/objects/JsonapiObjectTest.php
    @@ -3,6 +3,8 @@
     namespace alsvanzelf\jsonapiTests\objects;
     
     use alsvanzelf\jsonapi\objects\JsonapiObject;
    +use alsvanzelf\jsonapiTests\extensions\TestExtension;
    +use alsvanzelf\jsonapiTests\profiles\TestProfile;
     use PHPUnit\Framework\TestCase;
     
     class JsonapiObjectTest extends TestCase {
    @@ -31,4 +33,43 @@ public function testIsEmpty_WithAtMembers() {
     		
     		$this->assertFalse($jsonapiObject->isEmpty());
     	}
    +	
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testIsEmpty_WithExtensionLink() {
    +		$jsonapiObject = new JsonapiObject($version=null);
    +		
    +		$this->assertTrue($jsonapiObject->isEmpty());
    +		
    +		$jsonapiObject->addExtension(new TestExtension());
    +		
    +		$this->assertFalse($jsonapiObject->isEmpty());
    +	}
    +	
    +	/**
    +	 * @group Profiles
    +	 */
    +	public function testIsEmpty_WithProfileLink() {
    +		$jsonapiObject = new JsonapiObject($version=null);
    +		
    +		$this->assertTrue($jsonapiObject->isEmpty());
    +		
    +		$jsonapiObject->addProfile(new TestProfile());
    +		
    +		$this->assertFalse($jsonapiObject->isEmpty());
    +	}
    +	
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testIsEmpty_WithExtensionMembers() {
    +		$jsonapiObject = new JsonapiObject($version=null);
    +		
    +		$this->assertTrue($jsonapiObject->isEmpty());
    +		
    +		$jsonapiObject->addExtensionMember(new TestExtension(), 'foo', 'bar');
    +		
    +		$this->assertFalse($jsonapiObject->isEmpty());
    +	}
     }
    diff --git a/tests/objects/LinkObjectTest.php b/tests/objects/LinkObjectTest.php
    index ad41068..fd356c9 100644
    --- a/tests/objects/LinkObjectTest.php
    +++ b/tests/objects/LinkObjectTest.php
    @@ -3,6 +3,7 @@
     namespace alsvanzelf\jsonapiTests\objects;
     
     use alsvanzelf\jsonapi\objects\LinkObject;
    +use alsvanzelf\jsonapiTests\extensions\TestExtension;
     use PHPUnit\Framework\TestCase;
     
     class LinkObjectTest extends TestCase {
    @@ -151,4 +152,17 @@ public function testIsEmpty_WithAtMembers() {
     		
     		$this->assertFalse($linkObject->isEmpty());
     	}
    +	
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testIsEmpty_WithExtensionMembers() {
    +		$linkObject = new LinkObject();
    +		
    +		$this->assertTrue($linkObject->isEmpty());
    +		
    +		$linkObject->addExtensionMember(new TestExtension(), 'foo', 'bar');
    +		
    +		$this->assertFalse($linkObject->isEmpty());
    +	}
     }
    diff --git a/tests/objects/MetaObjectTest.php b/tests/objects/MetaObjectTest.php
    index 5014060..fc4cb6a 100644
    --- a/tests/objects/MetaObjectTest.php
    +++ b/tests/objects/MetaObjectTest.php
    @@ -3,6 +3,7 @@
     namespace alsvanzelf\jsonapiTests\objects;
     
     use alsvanzelf\jsonapi\objects\MetaObject;
    +use alsvanzelf\jsonapiTests\extensions\TestExtension;
     use PHPUnit\Framework\TestCase;
     
     class MetaObjectTest extends TestCase {
    @@ -18,4 +19,27 @@ public function testFromObject_HappyPath() {
     		$this->assertArrayHasKey('foo', $array);
     		$this->assertSame('bar', $array['foo']);
     	}
    +	
    +	public function testIsEmpty_WithAtMembers() {
    +		$metaObject = new MetaObject();
    +		
    +		$this->assertTrue($metaObject->isEmpty());
    +		
    +		$metaObject->addAtMember('context', 'test');
    +		
    +		$this->assertFalse($metaObject->isEmpty());
    +	}
    +	
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testIsEmpty_WithExtensionMembers() {
    +		$metaObject = new MetaObject();
    +		
    +		$this->assertTrue($metaObject->isEmpty());
    +		
    +		$metaObject->addExtensionMember(new TestExtension(), 'foo', 'bar');
    +		
    +		$this->assertFalse($metaObject->isEmpty());
    +	}
     }
    diff --git a/tests/objects/RelationshipObjectTest.php b/tests/objects/RelationshipObjectTest.php
    index 346480a..e5a99fb 100644
    --- a/tests/objects/RelationshipObjectTest.php
    +++ b/tests/objects/RelationshipObjectTest.php
    @@ -9,6 +9,7 @@
     use alsvanzelf\jsonapi\objects\RelationshipObject;
     use alsvanzelf\jsonapi\objects\ResourceIdentifierObject;
     use alsvanzelf\jsonapi\objects\ResourceObject;
    +use alsvanzelf\jsonapiTests\extensions\TestExtension;
     use PHPUnit\Framework\TestCase;
     
     class RelationshipObjectTest extends TestCase {
    @@ -315,6 +316,19 @@ public function testIsEmpty_WithAtMembers() {
     		$this->assertFalse($relationshipObject->isEmpty());
     	}
     	
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testIsEmpty_WithExtensionMembers() {
    +		$relationshipObject = new RelationshipObject(RelationshipObject::TO_ONE);
    +		
    +		$this->assertTrue($relationshipObject->isEmpty());
    +		
    +		$relationshipObject->addExtensionMember(new TestExtension(), 'foo', 'bar');
    +		
    +		$this->assertFalse($relationshipObject->isEmpty());
    +	}
    +	
     	private function validateToOneRelationshipArray(array $array) {
     		$this->assertNotEmpty($array);
     		$this->assertArrayHasKey('data', $array);
    diff --git a/tests/objects/ResourceIdentifierObjectTest.php b/tests/objects/ResourceIdentifierObjectTest.php
    index edee0bd..b7eb1ea 100644
    --- a/tests/objects/ResourceIdentifierObjectTest.php
    +++ b/tests/objects/ResourceIdentifierObjectTest.php
    @@ -5,6 +5,7 @@
     use alsvanzelf\jsonapi\exceptions\Exception;
     use alsvanzelf\jsonapi\exceptions\DuplicateException;
     use alsvanzelf\jsonapi\objects\ResourceIdentifierObject;
    +use alsvanzelf\jsonapiTests\extensions\TestExtension;
     use PHPUnit\Framework\TestCase;
     
     class ResourceIdentifierObjectTest extends TestCase {
    @@ -174,4 +175,17 @@ public function testIsEmpty_WithAtMembers() {
     		
     		$this->assertFalse($resourceIdentifierObject->isEmpty());
     	}
    +	
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testIsEmpty_WithExtensionMembers() {
    +		$resourceIdentifierObject = new ResourceIdentifierObject();
    +		
    +		$this->assertTrue($resourceIdentifierObject->isEmpty());
    +		
    +		$resourceIdentifierObject->addExtensionMember(new TestExtension(), 'foo', 'bar');
    +		
    +		$this->assertFalse($resourceIdentifierObject->isEmpty());
    +	}
     }
    diff --git a/tests/profiles/CursorPaginationProfileTest.php b/tests/profiles/CursorPaginationProfileTest.php
    index cd40ec4..4fb3db8 100644
    --- a/tests/profiles/CursorPaginationProfileTest.php
    +++ b/tests/profiles/CursorPaginationProfileTest.php
    @@ -20,10 +20,16 @@ public function testSetLinks_HappyPath() {
     		$firstCursor      = 'bar';
     		$lastCursor       = 'foo';
     		
    +		$collection->applyProfile($profile);
     		$profile->setLinks($collection, $baseOrCurrentUrl, $firstCursor, $lastCursor);
     		
     		$array = $collection->toArray();
     		
    +		$this->assertArrayHasKey('jsonapi', $array);
    +		$this->assertArrayHasKey('profile', $array['jsonapi']);
    +		$this->assertCount(1, $array['jsonapi']['profile']);
    +		$this->assertSame($profile->getOfficialLink(), $array['jsonapi']['profile'][0]);
    +		
     		$this->assertArrayHasKey('links', $array);
     		$this->assertCount(2, $array['links']);
     		$this->assertArrayHasKey('prev', $array['links']);
    @@ -37,6 +43,7 @@ public function testSetLinks_HappyPath() {
     	public function test_WithRelationship() {
     		$profile  = new CursorPaginationProfile();
     		$document = new ResourceDocument('test', 1);
    +		$document->applyProfile($profile);
     		
     		$person1  = new ResourceObject('person', 1);
     		$person2  = new ResourceObject('person', 2);
    @@ -59,6 +66,11 @@ public function test_WithRelationship() {
     		
     		$array = $document->toArray();
     		
    +		$this->assertArrayHasKey('jsonapi', $array);
    +		$this->assertArrayHasKey('profile', $array['jsonapi']);
    +		$this->assertCount(1, $array['jsonapi']['profile']);
    +		$this->assertSame($profile->getOfficialLink(), $array['jsonapi']['profile'][0]);
    +		
     		$this->assertArrayHasKey('data', $array);
     		$this->assertArrayHasKey('relationships', $array['data']);
     		$this->assertArrayHasKey('people', $array['data']['relationships']);
    @@ -85,10 +97,16 @@ public function testSetLinksFirstPage_HappyPath() {
     		$baseOrCurrentUrl = '/people?page[size]=10';
     		$lastCursor       = 'foo';
     		
    +		$collection->applyProfile($profile);
     		$profile->setLinksFirstPage($collection, $baseOrCurrentUrl, $lastCursor);
     		
     		$array = $collection->toArray();
     		
    +		$this->assertArrayHasKey('jsonapi', $array);
    +		$this->assertArrayHasKey('profile', $array['jsonapi']);
    +		$this->assertCount(1, $array['jsonapi']['profile']);
    +		$this->assertSame($profile->getOfficialLink(), $array['jsonapi']['profile'][0]);
    +		
     		$this->assertArrayHasKey('links', $array);
     		$this->assertCount(2, $array['links']);
     		$this->assertArrayHasKey('prev', $array['links']);
    @@ -104,10 +122,16 @@ public function testSetLinksLastPage_HappyPath() {
     		$baseOrCurrentUrl = '/people?page[size]=10';
     		$firstCursor      = 'bar';
     		
    +		$collection->applyProfile($profile);
     		$profile->setLinksLastPage($collection, $baseOrCurrentUrl, $firstCursor);
     		
     		$array = $collection->toArray();
     		
    +		$this->assertArrayHasKey('jsonapi', $array);
    +		$this->assertArrayHasKey('profile', $array['jsonapi']);
    +		$this->assertCount(1, $array['jsonapi']['profile']);
    +		$this->assertSame($profile->getOfficialLink(), $array['jsonapi']['profile'][0]);
    +		
     		$this->assertArrayHasKey('links', $array);
     		$this->assertCount(2, $array['links']);
     		$this->assertArrayHasKey('prev', $array['links']);
    @@ -121,10 +145,16 @@ public function testSetCursor() {
     		$profile          = new CursorPaginationProfile();
     		$resourceDocument = new ResourceDocument('user', 42);
     		
    +		$resourceDocument->applyProfile($profile);
     		$profile->setCursor($resourceDocument, 'foo');
     		
     		$array = $resourceDocument->toArray();
     		
    +		$this->assertArrayHasKey('jsonapi', $array);
    +		$this->assertArrayHasKey('profile', $array['jsonapi']);
    +		$this->assertCount(1, $array['jsonapi']['profile']);
    +		$this->assertSame($profile->getOfficialLink(), $array['jsonapi']['profile'][0]);
    +		
     		$this->assertArrayHasKey('data', $array);
     		$this->assertArrayHasKey('meta', $array['data']);
     		$this->assertArrayHasKey('page', $array['data']['meta']);
    @@ -136,10 +166,16 @@ public function testSetPaginationLinkObjectsExplicitlyEmpty_HapptPath() {
     		$profile    = new CursorPaginationProfile();
     		$collection = new CollectionDocument();
     		
    +		$collection->applyProfile($profile);
     		$profile->setPaginationLinkObjectsExplicitlyEmpty($collection);
     		
     		$array = $collection->toArray();
     		
    +		$this->assertArrayHasKey('jsonapi', $array);
    +		$this->assertArrayHasKey('profile', $array['jsonapi']);
    +		$this->assertCount(1, $array['jsonapi']['profile']);
    +		$this->assertSame($profile->getOfficialLink(), $array['jsonapi']['profile'][0]);
    +		
     		$this->assertArrayHasKey('links', $array);
     		$this->assertCount(2, $array['links']);
     		$this->assertArrayHasKey('prev', $array['links']);
    @@ -155,10 +191,16 @@ public function testSetPaginationMeta() {
     		$bestGuessTotal   = 100;
     		$rangeIsTruncated = true;
     		
    +		$collection->applyProfile($profile);
     		$profile->setPaginationMeta($collection, $exactTotal, $bestGuessTotal, $rangeIsTruncated);
     		
     		$array = $collection->toArray();
     		
    +		$this->assertArrayHasKey('jsonapi', $array);
    +		$this->assertArrayHasKey('profile', $array['jsonapi']);
    +		$this->assertCount(1, $array['jsonapi']['profile']);
    +		$this->assertSame($profile->getOfficialLink(), $array['jsonapi']['profile'][0]);
    +		
     		$this->assertArrayHasKey('meta', $array);
     		$this->assertArrayHasKey('page', $array['meta']);
     		$this->assertArrayHasKey('total', $array['meta']['page']);