diff --git a/README.md b/README.md index 792fe51..c715304 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 2832970..fc4793d 100644
--- a/examples/bootstrap_examples.php
+++ b/examples/bootstrap_examples.php
@@ -2,7 +2,7 @@
 
 use alsvanzelf\jsonapi\Document;
 use alsvanzelf\jsonapi\ResourceDocument;
-use alsvanzelf\jsonapi\helpers\ProfileAliasManager;
+use alsvanzelf\jsonapi\interfaces\ExtensionInterface;
 use alsvanzelf\jsonapi\interfaces\ProfileInterface;
 use alsvanzelf\jsonapi\interfaces\ResourceInterface;
 
@@ -102,29 +102,59 @@ function getCurrentLocation() {
 	}
 }
 
-class ExampleVersionProfile extends ProfileAliasManager implements ProfileInterface {
+class ExampleVersionExtension implements ExtensionInterface {
 	/**
-	 * the required methods (next to extending ProfileAliasManager)
+	 * the required method
 	 */
 	
 	public function getOfficialLink() {
-		return 'https://jsonapi.org/format/1.1/#profile-keywords-and-aliases';
+		return 'https://jsonapi.org/format/1.1/#extension-rules';
 	}
 	
-	public function getOfficialKeywords() {
-		return ['version'];
+	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($this->getKeyword('version'), $version, $level=Document::LEVEL_RESOURCE);
+			$resource->getResource()->addExtensionMember($this, 'id', $version);
 		}
 		else {
-			$resource->addMeta($this->getKeyword('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/errors_all_options.php b/examples/errors_all_options.php
index 5be9ab7..4f628cc 100644
--- a/examples/errors_all_options.php
+++ b/examples/errors_all_options.php
@@ -38,7 +38,7 @@
 $errorSpecApi->setHumanTitle($genericTitle='Too much options');
 $errorSpecApi->setHumanDetails($specificDetails='Please, choose a bit less. Consult your ...');
 $errorSpecApi->setAboutLink($specificAboutLink='https://www.example.com/explanation.html', ['foo'=>'bar']);
-$errorSpecApi->appendTypeLink($genericTypeLink='https://www.example.com/documentation.html', ['foo'=>'bar']);
+$errorSpecApi->setTypeLink($genericTypeLink='https://www.example.com/documentation.html', ['foo'=>'bar']);
 
 /**
  * prepare multiple error objects for the errors response
diff --git a/examples/example_profile.php b/examples/example_profile.php
deleted file mode 100644
index 66cef95..0000000
--- a/examples/example_profile.php
+++ /dev/null
@@ -1,32 +0,0 @@
- 'ref']);
-
-$document = new ResourceDocument('user', 42);
-$document->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 2967cd2..ddb75d9 100644
    --- a/src/Document.php
    +++ b/src/Document.php
    @@ -2,26 +2,30 @@
     
     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\LinkObject;
     use alsvanzelf\jsonapi\objects\LinksObject;
     use alsvanzelf\jsonapi\objects\MetaObject;
    -use alsvanzelf\jsonapi\objects\ProfileLinkObject;
     
     /**
      * @see ResourceDocument, CollectionDocument, ErrorsDocument or MetaDocument
      */
     abstract class Document implements DocumentInterface, \JsonSerializable {
    -	use AtMemberManager, HttpStatusCodeManager, LinksManager;
    +	use AtMemberManager, ExtensionMemberManager, HttpStatusCodeManager, LinksManager {
    +		LinksManager::addLink as linkManagerAddLink;
    +	}
     	
     	const JSONAPI_VERSION_1_0 = '1.0';
     	const JSONAPI_VERSION_1_1 = '1.1';
    @@ -39,6 +43,8 @@ abstract class Document implements DocumentInterface, \JsonSerializable {
     	protected $meta;
     	/** @var JsonapiObject */
     	protected $jsonapi;
    +	/** @var ExtensionInterface[] */
    +	protected $extensions = [];
     	/** @var ProfileInterface[] */
     	protected $profiles = [];
     	/** @var array */
    @@ -94,11 +100,7 @@ public function __construct() {
     	 */
     	public function addLink($key, $href, array $meta=[], $level=Document::LEVEL_ROOT) {
     		if ($level === Document::LEVEL_ROOT) {
    -			if ($this->links === null) {
    -				$this->setLinksObject(new LinksObject());
    -			}
    -			
    -			$this->links->add($key, $href, $meta);
    +			$this->linkManagerAddLink($key, $href, $meta);
     		}
     		elseif ($level === Document::LEVEL_JSONAPI) {
     			throw new InputException('level "jsonapi" can not be used for links');
    @@ -114,12 +116,38 @@ public function addLink($key, $href, array $meta=[], $level=Document::LEVEL_ROOT
     	/**
     	 * set the self link on the document
     	 * 
    +	 * @note a LinkObject is added when extensions or profiles are applied
    +	 * 
     	 * @param string $href
     	 * @param array  $meta optional, if given a LinkObject is added, otherwise a link string is added
     	 * @param string $level one of the Document::LEVEL_* constants, optional, defaults to Document::LEVEL_ROOT
     	 */
     	public function setSelfLink($href, array $meta=[], $level=Document::LEVEL_ROOT) {
    -		$this->addLink('self', $href, $meta, $level);
    +		if ($level === Document::LEVEL_ROOT && ($this->extensions !== [] || $this->profiles !== [])) {
    +			$contentType = Converter::prepareContentType(Document::CONTENT_TYPE_OFFICIAL, $this->extensions, $this->profiles);
    +			
    +			$linkObject = new LinkObject($href, $meta);
    +			$linkObject->setMediaType($contentType);
    +			
    +			$this->addLinkObject('self', $linkObject);
    +		}
    +		else {
    +			$this->addLink('self', $href, $meta, $level);
    +		}
    +	}
    +	
    +	/**
    +	 * set a link describing the current document
    +	 * 
    +	 * for example this could link to an OpenAPI or JSON Schema document
    +	 * 
    +	 * @note according to the spec, this can only be set to Document::LEVEL_ROOT
    +	 * 
    +	 * @param string $href
    +	 * @param array  $meta optional, if given a LinkObject is added, otherwise a link string is added
    +	 */
    +	public function setDescribedByLink($href, array $meta=[]) {
    +		$this->addLink('describedby', $href, $meta, $level=Document::LEVEL_ROOT);
     	}
     	
     	/**
    @@ -178,6 +206,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
     	 * 
    @@ -185,23 +243,15 @@ 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());
    -		}
    -		
    -		$link = $profile->getAliasedLink();
    -		if ($link instanceof LinkObject) {
    -			$this->links->appendLinkObject('profile', $link);
    -		}
    -		else {
    -			$this->links->append('profile', $link);
    +		if ($this->jsonapi !== null) {
    +			$this->jsonapi->addProfile($profile);
     		}
     	}
     	
    @@ -213,7 +263,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();
    @@ -267,7 +324,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/ResourceDocument.php b/src/ResourceDocument.php
    index a94c16e..8db9d09 100644
    --- a/src/ResourceDocument.php
    +++ b/src/ResourceDocument.php
    @@ -179,6 +179,13 @@ public function setId($id) {
     		$this->resource->setId($id);
     	}
     	
    +	/**
    +	 * @param string|int $localId will be casted to a string
    +	 */
    +	public function setLocalId($localId) {
    +		$this->resource->setLocalId($localId);
    +	}
    +	
     	/**
     	 * @param AttributesObject $attributesObject
     	 * @param array            $options          optional {@see ResourceObject::$defaults}
    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) {
    -			$link = $profile->getAliasedLink();
    -			$profileLinks[] = ($link instanceof LinkObject) ? $link->toArray()['href'] : $link;
    +		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/helpers/LinksManager.php b/src/helpers/LinksManager.php
    index 97c1c7f..ab234a4 100644
    --- a/src/helpers/LinksManager.php
    +++ b/src/helpers/LinksManager.php
    @@ -29,6 +29,8 @@ public function addLink($key, $href, array $meta=[]) {
     	/**
     	 * append a link to a key with an array of links
     	 * 
    +	 * @deprecated array links are not supported anymore {@see ->addLink()}
    +	 * 
     	 * @param string $key
     	 * @param string $href
     	 * @param array  $meta optional, if given a LinkObject is added, otherwise a link string is added
    @@ -56,6 +58,8 @@ public function addLinkObject($key, LinkObject $linkObject) {
     	/**
     	 * set a key containing a LinksArray
     	 * 
    +	 * @deprecated array links are not supported anymore {@see ->addLinkObject()}
    +	 * 
     	 * @param string     $key
     	 * @param LinksArray $linksArray
     	 */
    @@ -67,6 +71,8 @@ public function addLinksArray($key, LinksArray $linksArray) {
     	/**
     	 * append a LinkObject to a key with a LinksArray
     	 * 
    +	 * @deprecated array links are not supported anymore {@see ->addLinkObject()}
    +	 * 
     	 * @param string     $key
     	 * @param LinkObject $linkObject
     	 */
    diff --git a/src/helpers/ProfileAliasManager.php b/src/helpers/ProfileAliasManager.php
    deleted file mode 100644
    index 1ef0075..0000000
    --- a/src/helpers/ProfileAliasManager.php
    +++ /dev/null
    @@ -1,80 +0,0 @@
    -getOfficialKeywords();
    -		if ($officialKeywords === []) {
    -			return;
    -		}
    -		
    -		$this->keywordMapping = array_combine($officialKeywords, $officialKeywords);
    -		if ($aliases === []) {
    -			return;
    -		}
    -		
    -		foreach ($aliases as $keyword => $alias) {
    -			if ($alias === $keyword) {
    -				throw new InputException('an alias should be different from its keyword');
    -			}
    -			if (in_array($keyword, $officialKeywords, $strict=true) === false) {
    -				throw new InputException('unknown keyword "'.$keyword.'" to alias');
    -			}
    -			Validator::checkMemberName($alias);
    -			
    -			$this->keywordMapping[$keyword] = $alias;
    -		}
    -		
    -		$this->aliasMapping = $aliases;
    -	}
    -	
    -	/**
    -	 * @inheritDoc
    -	 */
    -	public function getKeyword($keyword) {
    -		if (isset($this->keywordMapping[$keyword]) === false) {
    -			throw new InputException('unknown keyword "'.$keyword.'"');
    -		}
    -		
    -		return $this->keywordMapping[$keyword];
    -	}
    -	
    -	/**
    -	 * @inheritDoc
    -	 */
    -	abstract public function getOfficialKeywords();
    -	
    -	/**
    -	 * @inheritDoc
    -	 */
    -	abstract public function getOfficialLink();
    -	
    -	/**
    -	 * @inheritDoc
    -	 */
    -	public function getAliasedLink() {
    -		if ($this->aliasMapping === []) {
    -			return $this->getOfficialLink();
    -		}
    -		
    -		return new ProfileLinkObject($this->getOfficialLink(), $this->aliasMapping);
    -	}
    -}
    diff --git a/src/helpers/RequestParser.php b/src/helpers/RequestParser.php
    index 5ae8f25..5209fda 100644
    --- a/src/helpers/RequestParser.php
    +++ b/src/helpers/RequestParser.php
    @@ -248,6 +248,20 @@ public function getFilter() {
     		return $this->queryParameters['filter'];
     	}
     	
    +	/**
    +	 * @return boolean
    +	 */
    +	public function hasLocalId() {
    +		return (isset($this->document['data']['lid']));
    +	}
    +	
    +	/**
    +	 * @return string
    +	 */
    +	public function getLocalId() {
    +		return $this->document['data']['lid'];
    +	}
    +	
     	/**
     	 * @param  string $attributeName
     	 * @return boolean
    diff --git a/src/helpers/Validator.php b/src/helpers/Validator.php
    index 6e814d7..692d473 100644
    --- a/src/helpers/Validator.php
    +++ b/src/helpers/Validator.php
    @@ -12,6 +12,7 @@
     class Validator {
     	const OBJECT_CONTAINER_TYPE          = 'type';
     	const OBJECT_CONTAINER_ID            = 'id';
    +	const OBJECT_CONTAINER_LID           = 'lid';
     	const OBJECT_CONTAINER_ATTRIBUTES    = 'attributes';
     	const OBJECT_CONTAINER_RELATIONSHIPS = 'relationships';
     	
    @@ -84,7 +85,7 @@ public function clearUsedFields($objectContainerToClear) {
     	 */
     	public function claimUsedResourceIdentifier(ResourceInterface $resource) {
     		if ($resource->getResource()->hasIdentification() === false) {
    -			throw new InputException('can not validate resource without identifier, set type and id first');
    +			throw new InputException('can not validate resource without identifier, set type and id/lid first');
     		}
     		
     		$resourceKey = $resource->getResource()->getIdentificationKey();
    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 @@
    +alias('version', 'v') was called before, this would return 'v'
    -	 * 
    -	 * @param  string $keyword
    -	 * @return string
    -	 * 
    -	 * @throws InputException if the keyword is not known to the profile
    -	 */
    -	public function getKeyword($keyword);
    -	
    -	/**
    -	 * returns an array of official keywords this profile defines
    -	 * 
    -	 * @internal
    -	 * 
    -	 * @return string[]
    -	 */
    -	public function getOfficialKeywords();
    -	
     	/**
     	 * the unique link identifying and describing the profile
     	 * 
    @@ -52,15 +11,4 @@ public function getOfficialKeywords();
     	 * @return string
     	 */
     	public function getOfficialLink();
    -	
    -	/**
    -	 * get the official link, or a LinkObject with the link and its aliases
    -	 * 
    -	 * optionally also contains the aliases applied
    -	 * 
    -	 * @internal
    -	 * 
    -	 * @return LinkObject|string
    -	 */
    -	public function getAliasedLink();
     }
    diff --git a/src/objects/AttributesObject.php b/src/objects/AttributesObject.php
    index 5cbc5b1..867c45a 100644
    --- a/src/objects/AttributesObject.php
    +++ b/src/objects/AttributesObject.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 AttributesObject implements ObjectInterface {
    -	use AtMemberManager;
    +	use AtMemberManager, ExtensionMemberManager;
     	
     	/** @var array */
     	protected $attributes = [];
    @@ -85,13 +86,32 @@ public function getKeys() {
     	 * @inheritDoc
     	 */
     	public function isEmpty() {
    -		return ($this->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..3d40d78 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;
    @@ -137,7 +138,7 @@ public function setHumanExplanation($genericTitle, $specificDetails=null, $speci
     			$this->setAboutLink($specificAboutLink);
     		}
     		if ($genericTypeLink !== null) {
    -			$this->appendTypeLink($genericTypeLink);
    +			$this->setTypeLink($genericTypeLink);
     		}
     	}
     	
    @@ -151,9 +152,21 @@ public function setAboutLink($href, array $meta=[]) {
     		$this->addLink('about', $href, $meta);
     	}
     	
    +	/**
    +	 * set the link of the generic type of this error, explained in a human-friendly way
    +	 * 
    +	 * @param string $href
    +	 * @param array  $meta optional, if given a LinkObject is added, otherwise a link string is added
    +	 */
    +	public function setTypeLink($href, array $meta=[]) {
    +		$this->addLink('type', $href, $meta);
    +	}
    +	
     	/**
     	 * append a link of the generic type of this error, explained in a human-friendly way
     	 * 
    +	 * @deprecated array links are not supported anymore {@see ->setTypeLink()}
    +	 * 
     	 * @param string $href
     	 * @param array  $meta optional, if given a LinkObject is added, otherwise a link string is added
     	 */
    @@ -288,6 +301,9 @@ public function isEmpty() {
     		if ($this->hasAtMembers()) {
     			return false;
     		}
    +		if ($this->hasExtensionMembers()) {
    +			return false;
    +		}
     		
     		return true;
     	}
    @@ -296,8 +312,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 1eab36d..244d103 100644
    --- a/src/objects/LinkObject.php
    +++ b/src/objects/LinkObject.php
    @@ -3,14 +3,25 @@
     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;
    +	/** @var string */
    +	protected $rel;
    +	/** @var LinkObject */
    +	protected $describedby;
    +	/** @var string */
    +	protected $title;
    +	/** @var string */
    +	protected $type;
    +	/** @var string[] */
    +	protected $hreflang = [];
     	/** @var MetaObject */
     	protected $meta;
     	
    @@ -31,6 +42,25 @@ public function __construct($href=null, array $meta=[]) {
     	 * human api
     	 */
     	
    +	/**
    +	 * @param string $href
    +	 */
    +	public function setDescribedBy($href) {
    +		$this->setDescribedByLinkObject(new LinkObject($href));
    +	}
    +	
    +	/**
    +	 * @param string $language
    +	 */
    +	public function addLanguage($language) {
    +		if ($this->hreflang === []) {
    +			$this->setHreflang($language);
    +		}
    +		else {
    +			$this->setHreflang(...array_merge($this->hreflang, [$language]));
    +		}
    +	}
    +	
     	/**
     	 * @param string $key
     	 * @param mixed  $value
    @@ -54,6 +84,45 @@ public function setHref($href) {
     		$this->href = $href;
     	}
     	
    +	/**
    +	 * @todo validate according to https://tools.ietf.org/html/rfc8288#section-2.1
    +	 * 
    +	 * @param string $relationType
    +	 */
    +	public function setRelationType($relationType) {
    +		$this->rel = $relationType;
    +	}
    +	
    +	/**
    +	 * @param LinkObject $describedBy
    +	 */
    +	public function setDescribedByLinkObject(LinkObject $describedBy) {
    +		$this->describedby = $describedBy;
    +	}
    +	
    +	/**
    +	 * @param string $friendlyTitle
    +	 */
    +	public function setHumanTitle($humanTitle) {
    +		$this->title = $humanTitle;
    +	}
    +	
    +	/**
    +	 * @param string $mediaType
    +	 */
    +	public function setMediaType($mediaType) {
    +		$this->type = $mediaType;
    +	}
    +	
    +	/**
    +	 * @todo validate according to https://tools.ietf.org/html/rfc5646
    +	 * 
    +	 * @param string ...$hreflang
    +	 */
    +	public function setHreflang(...$hreflang) {
    +		$this->hreflang = $hreflang;
    +	}
    +	
     	/**
     	 * @param MetaObject $metaObject
     	 */
    @@ -72,12 +141,30 @@ public function isEmpty() {
     		if ($this->href !== null) {
     			return false;
     		}
    +		if ($this->rel !== null) {
    +			return false;
    +		}
    +		if ($this->title !== null) {
    +			return false;
    +		}
    +		if ($this->type !== null) {
    +			return false;
    +		}
    +		if ($this->hreflang !== []) {
    +			return false;
    +		}
    +		if ($this->describedby !== null && $this->describedby->isEmpty() === false) {
    +			return false;
    +		}
     		if ($this->meta !== null && $this->meta->isEmpty() === false) {
     			return false;
     		}
     		if ($this->hasAtMembers()) {
     			return false;
     		}
    +		if ($this->hasExtensionMembers()) {
    +			return false;
    +		}
     		
     		return true;
     	}
    @@ -86,10 +173,37 @@ 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;
     		
    +		if ($this->rel !== null) {
    +			$array['rel'] = $this->rel;
    +		}
    +		if ($this->title !== null) {
    +			$array['title'] = $this->title;
    +		}
    +		if ($this->type !== null) {
    +			$array['type'] = $this->type;
    +		}
    +		if ($this->hreflang !== []) {
    +			if (count($this->hreflang) === 1) {
    +				$array['hreflang'] = $this->hreflang[0];
    +			}
    +			else {
    +				$array['hreflang'] = $this->hreflang;
    +			}
    +		}
    +		if ($this->describedby !== null && $this->describedby->isEmpty() === false) {
    +			$array['describedby'] = $this->describedby->toArray();
    +		}
     		if ($this->meta !== null && $this->meta->isEmpty() === false) {
     			$array['meta'] = $this->meta->toArray();
     		}
    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..1e759ee 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 = [];
    @@ -63,6 +64,8 @@ public function add($key, $href, array $meta=[]) {
     	 * 
     	 * @see LinksArray for use cases
     	 * 
    +	 * @deprecated array links are not supported anymore {@see ->add()}
    +	 * 
     	 * @param string $key
     	 * @param string $href
     	 * @param array  $meta optional, if given a LinkObject is added, otherwise a link string is added
    @@ -119,6 +122,8 @@ public function addLinkObject($key, LinkObject $linkObject) {
     	}
     	
     	/**
    +	 * @deprecated array links are not supported anymore {@see ->addLinkObject()}
    +	 * 
     	 * @param string     $key
     	 * @param LinksArray $linksArray
     	 * 
    @@ -135,6 +140,8 @@ public function addLinksArray($key, LinksArray $linksArray) {
     	}
     	
     	/**
    +	 * @deprecated array links are not supported anymore {@see ->addLinkObject()}
    +	 * 
     	 * @param  string     $key
     	 * @param  LinkObject $linkObject
     	 * 
    @@ -161,14 +168,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/ProfileLinkObject.php b/src/objects/ProfileLinkObject.php
    deleted file mode 100644
    index 4652a53..0000000
    --- a/src/objects/ProfileLinkObject.php
    +++ /dev/null
    @@ -1,47 +0,0 @@
    -aliases = $aliases;
    -	}
    -	
    -	/**
    -	 * human api
    -	 */
    -	
    -	/**
    -	 * spec api
    -	 */
    -	
    -	/**
    -	 * ObjectInterface
    -	 */
    -	
    -	/**
    -	 * @inheritDoc
    -	 */
    -	public function toArray() {
    -		$array = parent::toArray();
    -		
    -		if ($this->aliases !== []) {
    -			$array['aliases'] = $this->aliases;
    -		}
    -		
    -		return $array;
    -	}
    -}
    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 fe32d91..05aaa6b 100644
    --- a/src/objects/ResourceIdentifierObject.php
    +++ b/src/objects/ResourceIdentifierObject.php
    @@ -3,19 +3,23 @@
     namespace alsvanzelf\jsonapi\objects;
     
     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;
     	/** @var string */
     	protected $id;
    +	/** @var string */
    +	protected $lid;
     	/** @var MetaObject */
     	protected $meta;
     	/** @var Validator */
    @@ -42,6 +46,7 @@ public function __construct($type=null, $id=null) {
     		// always mark as used, as these keys are reserved
     		$this->validator->claimUsedFields($fieldNames=['type'], Validator::OBJECT_CONTAINER_TYPE);
     		$this->validator->claimUsedFields($fieldNames=['id'], Validator::OBJECT_CONTAINER_ID);
    +		$this->validator->claimUsedFields($fieldNames=['lid'], Validator::OBJECT_CONTAINER_LID);
     	}
     	
     	/**
    @@ -73,11 +78,34 @@ public function setType($type) {
     	
     	/**
     	 * @param string|int $id will be casted to a string
    +	 * 
    +	 * @throws DuplicateException if localId is already set
     	 */
     	public function setId($id) {
    +		if ($this->lid !== null) {
    +			throw new DuplicateException('id is not allowed when localId is already set');
    +		}
    +		
     		$this->id = (string) $id;
     	}
     	
    +	/**
    +	 * set a local id to connect resources to each other when created on the client
    +	 * 
    +	 * @note this should not be used to send back from the server to the client
    +	 * 
    +	 * @param string|int $localId will be casted to a string
    +	 * 
    +	 * @throws DuplicateException if normal id is already set
    +	 */
    +	public function setLocalId($localId) {
    +		if ($this->id !== null) {
    +			throw new DuplicateException('localId is not allowed when id is already set');
    +		}
    +		
    +		$this->lid = (string) $localId;
    +	}
    +	
     	/**
     	 * @param MetaObject $metaObject
     	 */
    @@ -96,7 +124,7 @@ public function setMetaObject(MetaObject $metaObject) {
     	 * @return ResourceIdentifierObject
     	 */
     	public static function fromResourceObject(ResourceObject $resourceObject) {
    -		$resourceIdentifierObject = new self($resourceObject->type, $resourceObject->id);
    +		$resourceIdentifierObject = new self($resourceObject->type, $resourceObject->primaryId());
     		
     		if ($resourceObject->meta !== null) {
     			$resourceIdentifierObject->setMetaObject($resourceObject->meta);
    @@ -127,7 +155,7 @@ public function equals(ResourceInterface $resource) {
     	 * @return boolean
     	 */
     	public function hasIdentification() {
    -		return ($this->type !== null && $this->id !== null);
    +		return ($this->type !== null && $this->primaryId() !== null);
     	}
     	
     	/**
    @@ -144,7 +172,7 @@ public function getIdentificationKey() {
     			throw new Exception('resource has no identification yet');
     		}
     		
    -		return $this->type.'|'.$this->id;
    +		return $this->type.'|'.$this->primaryId();
     	}
     	
     	/**
    @@ -155,7 +183,7 @@ public function getIdentificationKey() {
     	 * @inheritDoc
     	 */
     	public function isEmpty() {
    -		if ($this->type !== null || $this->id !== null) {
    +		if ($this->type !== null || $this->primaryId() !== null) {
     			return false;
     		}
     		if ($this->meta !== null && $this->meta->isEmpty() === false) {
    @@ -164,6 +192,9 @@ public function isEmpty() {
     		if ($this->hasAtMembers()) {
     			return false;
     		}
    +		if ($this->hasExtensionMembers()) {
    +			return false;
    +		}
     		
     		return true;
     	}
    @@ -172,13 +203,23 @@ public function isEmpty() {
     	 * @inheritDoc
     	 */
     	public function toArray() {
    -		$array = $this->getAtMembers();
    +		$array = [];
     		
     		$array['type'] = $this->type;
     		
     		if ($this->id !== null) {
     			$array['id'] = $this->id;
     		}
    +		elseif ($this->lid !== null) {
    +			$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();
    @@ -197,4 +238,16 @@ public function toArray() {
     	public function getResource($identifierOnly=false) {
     		return $this;
     	}
    +	
    +	/**
    +	 * @internal
    +	 */
    +	
    +	private function primaryId() {
    +		if ($this->lid !== null) {
    +			return $this->lid;
    +		}
    +		
    +		return $this->id;
    +	}
     }
    diff --git a/src/profiles/CursorPaginationProfile.php b/src/profiles/CursorPaginationProfile.php
    index 1700f92..e95f523 100644
    --- a/src/profiles/CursorPaginationProfile.php
    +++ b/src/profiles/CursorPaginationProfile.php
    @@ -4,7 +4,6 @@
     
     use alsvanzelf\jsonapi\Document;
     use alsvanzelf\jsonapi\ResourceDocument;
    -use alsvanzelf\jsonapi\helpers\ProfileAliasManager;
     use alsvanzelf\jsonapi\interfaces\PaginableInterface;
     use alsvanzelf\jsonapi\interfaces\ProfileInterface;
     use alsvanzelf\jsonapi\interfaces\ResourceInterface;
    @@ -44,7 +43,7 @@
      * - {@see get*ErrorObject} to generate ErrorObjects for specific error cases
      * - {@see generatePreviousLink} {@see generateNextLink} to apply the links manually
      */
    -class CursorPaginationProfile extends ProfileAliasManager implements ProfileInterface {
    +class CursorPaginationProfile implements ProfileInterface {
     	/**
     	 * human api
     	 */
    @@ -115,7 +114,7 @@ public function setCount(PaginableInterface $paginable, $exactTotal=null, $bestG
     	 * @return string
     	 */
     	public function generatePreviousLink($baseOrCurrentUrl, $beforeCursor) {
    -		return $this->setQueryParameter($baseOrCurrentUrl, $this->getKeyword('page').'[before]', $beforeCursor);
    +		return $this->setQueryParameter($baseOrCurrentUrl, 'page[before]', $beforeCursor);
     	}
     	
     	/**
    @@ -126,7 +125,7 @@ public function generatePreviousLink($baseOrCurrentUrl, $beforeCursor) {
     	 * @return string
     	 */
     	public function generateNextLink($baseOrCurrentUrl, $afterCursor) {
    -		return $this->setQueryParameter($baseOrCurrentUrl, $this->getKeyword('page').'[after]', $afterCursor);
    +		return $this->setQueryParameter($baseOrCurrentUrl, 'page[after]', $afterCursor);
     	}
     	
     	/**
    @@ -192,10 +191,10 @@ public function setItemMeta(ResourceInterface $resource, $cursor) {
     		];
     		
     		if ($resource instanceof ResourceDocument) {
    -			$resource->addMeta($this->getKeyword('page'), $metadata, $level=Document::LEVEL_RESOURCE);
    +			$resource->addMeta('page', $metadata, $level=Document::LEVEL_RESOURCE);
     		}
     		else {
    -			$resource->addMeta($this->getKeyword('page'), $metadata);
    +			$resource->addMeta('page', $metadata);
     		}
     	}
     	
    @@ -229,7 +228,7 @@ public function setPaginationMeta(PaginableInterface $paginable, $exactTotal=nul
     			$metadata['rangeTruncated'] = $rangeIsTruncated;
     		}
     		
    -		$paginable->addMeta($this->getKeyword('page'), $metadata);
    +		$paginable->addMeta('page', $metadata);
     	}
     	
     	/**
    @@ -249,7 +248,7 @@ public function setPaginationMeta(PaginableInterface $paginable, $exactTotal=nul
     	 */
     	public function getUnsupportedSortErrorObject($genericTitle=null, $specificDetails=null) {
     		$errorObject = new ErrorObject('Unsupported sort');
    -		$errorObject->appendTypeLink('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/unsupported-sort');
    +		$errorObject->setTypeLink('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/unsupported-sort');
     		$errorObject->blameQueryParameter('sort');
     		$errorObject->setHttpStatusCode(400);
     		
    @@ -279,10 +278,10 @@ public function getUnsupportedSortErrorObject($genericTitle=null, $specificDetai
     	 */
     	public function getMaxPageSizeExceededErrorObject($maxSize, $genericTitle=null, $specificDetails=null) {
     		$errorObject = new ErrorObject('Max page size exceeded');
    -		$errorObject->appendTypeLink('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/max-size-exceeded');
    -		$errorObject->blameQueryParameter($this->getKeyword('page').'[size]');
    +		$errorObject->setTypeLink('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/max-size-exceeded');
    +		$errorObject->blameQueryParameter('page[size]');
     		$errorObject->setHttpStatusCode(400);
    -		$errorObject->addMeta($this->getKeyword('page'), $value=['maxSize' => $maxSize]);
    +		$errorObject->addMeta('page', $value=['maxSize' => $maxSize]);
     		
     		if ($genericTitle !== null) {
     			$errorObject->setHumanExplanation($genericTitle, $specificDetails);
    @@ -302,7 +301,7 @@ public function getMaxPageSizeExceededErrorObject($maxSize, $genericTitle=null,
     	 * - /errors/0/title            optional
     	 * - /errors/0/detail           optional
     	 * 
    -	 * @param  int    $queryParameter  e.g. 'sort' or 'page[size]', aliasing should already be done using {@see getKeyword}
    +	 * @param  int    $queryParameter  e.g. 'sort' or 'page[size]'
     	 * @param  string $typeLink        optional
     	 * @param  string $genericTitle    optional, e.g. 'Invalid Parameter.'
     	 * @param  string $specificDetails optional, e.g. 'page[size] must be a positive integer; got 0'
    @@ -314,7 +313,7 @@ public function getInvalidParameterValueErrorObject($queryParameter, $typeLink=n
     		$errorObject->setHttpStatusCode(400);
     		
     		if ($typeLink !== null) {
    -			$errorObject->appendTypeLink($typeLink);
    +			$errorObject->setTypeLink($typeLink);
     		}
     		
     		if ($genericTitle !== null) {
    @@ -338,7 +337,7 @@ public function getInvalidParameterValueErrorObject($queryParameter, $typeLink=n
     	 */
     	public function getRangePaginationNotSupportedErrorObject($genericTitle=null, $specificDetails=null) {
     		$errorObject = new ErrorObject('Range pagination not supported');
    -		$errorObject->appendTypeLink('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/range-pagination-not-supported');
    +		$errorObject->setTypeLink('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/range-pagination-not-supported');
     		$errorObject->setHttpStatusCode(400);
     		
     		if ($genericTitle !== null) {
    @@ -394,9 +393,14 @@ public function getOfficialLink() {
     	}
     	
     	/**
    -	 * @inheritDoc
    +	 * returns the keyword without aliasing
    +	 * 
    +	 * @deprecated since aliasing was removed from the profiles spec
    +	 * 
    +	 * @param  string $keyword
    +	 * @return string
     	 */
    -	public function getOfficialKeywords() {
    -		return ['page'];
    +	public function getKeyword($keyword) {
    +		return $keyword;
     	}
     }
    diff --git a/tests/ConverterTest.php b/tests/ConverterTest.php
    index ad33478..f5c7d51 100644
    --- a/tests/ConverterTest.php
    +++ b/tests/ConverterTest.php
    @@ -4,7 +4,7 @@
     
     use alsvanzelf\jsonapi\helpers\Converter;
     use alsvanzelf\jsonapi\objects\AttributesObject;
    -use alsvanzelf\jsonapi\objects\LinkObject;
    +use alsvanzelf\jsonapiTests\extensions\TestExtension;
     use alsvanzelf\jsonapiTests\profiles\TestProfile;
     use PHPUnit\Framework\TestCase;
     
    @@ -64,32 +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() {
    -		$profile = new TestProfile();
    -		$profile->setAliasedLink('bar');
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testPrepareContentType_WithExtensionStringLink() {
    +		$extension = new TestExtension();
    +		$extension->setOfficialLink('bar');
     		
    -		$this->assertSame('foo;profile="bar", foo', Converter::mergeProfilesInContentType('foo', [$profile]));
    +		$this->assertSame('foo; ext="bar"', Converter::prepareContentType('foo', [$extension], []));
     	}
     	
    -	public function testMergeProfilesInContentType_WithProfileObjectLink() {
    +	/**
    +	 * @group Profiles
    +	 */
    +	public function testPrepareContentType_WithProfileStringLink() {
     		$profile = new TestProfile();
    -		$profile->setAliasedLink(new LinkObject('bar'));
    +		$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->setAliasedLink('bar');
    +		$profile1->setOfficialLink('bar');
     		
     		$profile2 = new TestProfile();
    -		$profile2->setAliasedLink(new LinkObject('baz'));
    +		$profile2->setOfficialLink('baz');
    +		
    +		$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 baz", foo', Converter::mergeProfilesInContentType('foo', [$profile1, $profile2]));
    +		$this->assertSame('foo; profile="bar"', Converter::mergeProfilesInContentType('foo', [$profile]));
     	}
     }
     
    diff --git a/tests/DocumentTest.php b/tests/DocumentTest.php
    index 2cd0916..020a70a 100644
    --- a/tests/DocumentTest.php
    +++ b/tests/DocumentTest.php
    @@ -2,11 +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\jsonapi\objects\ProfileLinkObject;
     use alsvanzelf\jsonapiTests\TestableNonAbstractDocument as Document;
    +use alsvanzelf\jsonapiTests\extensions\TestExtension;
     use alsvanzelf\jsonapiTests\profiles\TestProfile;
     use PHPUnit\Framework\TestCase;
     
    @@ -98,6 +99,58 @@ public function testAddLink_BlocksUnknownLevel() {
     		$document->addLink('foo', 'https://jsonapi.org', $meta=[], $level='foo');
     	}
     	
    +	public function testSetSelfLink_HappyPath() {
    +		$document = new Document();
    +		
    +		$array = $document->toArray();
    +		$this->assertArrayNotHasKey('links', $array);
    +		
    +		$document->setSelfLink('https://jsonapi.org/foo');
    +		
    +		$array = $document->toArray();
    +		$this->assertArrayHasKey('links', $array);
    +		$this->assertCount(1, $array['links']);
    +		$this->assertArrayHasKey('self', $array['links']);
    +		$this->assertSame('https://jsonapi.org/foo', $array['links']['self']);
    +	}
    +	
    +	public function testSetDescribedByLink_HappyPath() {
    +		$document = new Document();
    +		$document->setDescribedByLink('https://jsonapi.org/format', ['version' => '1.1']);
    +		
    +		$array = $document->toArray();
    +		
    +		$this->assertCount(1, $array['links']);
    +		if (method_exists($this, 'assertIsArray')) {
    +			$this->assertIsArray($array['links']['describedby']);
    +		}
    +		else {
    +			$this->assertInternalType('array', $array['links']['describedby']);
    +		}
    +		$this->assertCount(2, $array['links']['describedby']);
    +		$this->assertArrayHasKey('href', $array['links']['describedby']);
    +		$this->assertArrayHasKey('meta', $array['links']['describedby']);
    +		$this->assertSame('https://jsonapi.org/format', $array['links']['describedby']['href']);
    +		$this->assertCount(1, $array['links']['describedby']['meta']);
    +		$this->assertArrayHasKey('version', $array['links']['describedby']['meta']);
    +		$this->assertSame('1.1', $array['links']['describedby']['meta']['version']);
    +	}
    +	
    +	public function testSetDescribedByLink_WithMeta() {
    +		$document = new Document();
    +		
    +		$array = $document->toArray();
    +		$this->assertArrayNotHasKey('links', $array);
    +		
    +		$document->setDescribedByLink('https://jsonapi.org/format');
    +		
    +		$array = $document->toArray();
    +		$this->assertArrayHasKey('links', $array);
    +		$this->assertCount(1, $array['links']);
    +		$this->assertArrayHasKey('describedby', $array['links']);
    +		$this->assertSame('https://jsonapi.org/format', $array['links']['describedby']);
    +	}
    +	
     	public function testAddMeta_HappyPath() {
     		$document = new Document();
     		
    @@ -178,48 +231,107 @@ public function testAddLinkObject_HappyPath() {
     		$this->assertSame('https://jsonapi.org', $array['links']['foo']['href']);
     	}
     	
    -	public function testApplyProfile_HappyPath() {
    -		$profile = new TestProfile();
    -		$profile->setAliasedLink('https://jsonapi.org');
    +	/**
    +	 * @group Extensions
    +	 */
    +	public function testApplyExtension_HappyPath() {
    +		$extension = new TestExtension();
    +		$extension->setNamespace('test');
    +		$extension->setOfficialLink('https://jsonapi.org/extension');
     		
     		$document = new Document();
    -		$document->applyProfile($profile);
    +		$document->applyExtension($extension);
    +		$document->addExtensionMember($extension, 'foo', 'bar');
    +		$document->setSelfLink('https://jsonapi.org/foo');
     		
     		$array = $document->toArray();
     		
    +		$this->assertCount(3, $array);
    +		$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/extension', $array['jsonapi']['ext'][0]);
    +		$this->assertArrayHasKey('test:foo', $array);
    +		$this->assertSame('bar', $array['test:foo']);
     		$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('self', $array['links']);
    +		$this->assertCount(2, $array['links']['self']);
    +		$this->assertArrayHasKey('href', $array['links']['self']);
    +		$this->assertArrayHasKey('type', $array['links']['self']);
    +		$this->assertSame('https://jsonapi.org/foo', $array['links']['self']['href']);
    +		$this->assertSame('application/vnd.api+json; ext="https://jsonapi.org/extension"', $array['links']['self']['type']);
    +	}
    +	
    +	/**
    +	 * @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);
     	}
     	
    -	public function testApplyProfile_WithLinkObject() {
    +	/**
    +	 * @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->setAliasedLink(new ProfileLinkObject('https://jsonapi.org', $aliases=['foo' => 'bar'], $meta=['baz' => 'baf']));
    +		$profile->setOfficialLink('https://jsonapi.org/profile');
     		
     		$document = new Document();
     		$document->applyProfile($profile);
    +		$document->setSelfLink('https://jsonapi.org/foo');
     		
     		$array = $document->toArray();
     		
    +		$this->assertCount(2, $array);
    +		$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/profile', $array['jsonapi']['profile'][0]);
     		$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->assertCount(3, $array['links']['profile'][0]);
    -		$this->assertArrayHasKey('href', $array['links']['profile'][0]);
    -		$this->assertArrayHasKey('aliases', $array['links']['profile'][0]);
    -		$this->assertArrayHasKey('meta', $array['links']['profile'][0]);
    -		$this->assertSame('https://jsonapi.org', $array['links']['profile'][0]['href']);
    -		$this->assertCount(1, $array['links']['profile'][0]['aliases']);
    -		$this->assertArrayHasKey('foo', $array['links']['profile'][0]['aliases']);
    -		$this->assertSame('bar', $array['links']['profile'][0]['aliases']['foo']);
    -		$this->assertCount(1, $array['links']['profile'][0]['meta']);
    -		$this->assertArrayHasKey('baz', $array['links']['profile'][0]['meta']);
    -		$this->assertSame('baf', $array['links']['profile'][0]['meta']['baz']);
    +		$this->assertArrayHasKey('self', $array['links']);
    +		$this->assertCount(2, $array['links']['self']);
    +		$this->assertArrayHasKey('href', $array['links']['self']);
    +		$this->assertArrayHasKey('type', $array['links']['self']);
    +		$this->assertSame('https://jsonapi.org/foo', $array['links']['self']['href']);
    +		$this->assertSame('application/vnd.api+json; profile="https://jsonapi.org/profile"', $array['links']['self']['type']);
     	}
     	
     	public function testToJson_HappyPath() {
    diff --git a/tests/ResourceDocumentTest.php b/tests/ResourceDocumentTest.php
    index 8c352f2..36bcf7e 100644
    --- a/tests/ResourceDocumentTest.php
    +++ b/tests/ResourceDocumentTest.php
    @@ -132,6 +132,19 @@ public function testAddMeta_RecreateJsonapiObject() {
     		$this->assertSame('jsonapi', $array['jsonapi']['meta']['baz']);
     	}
     	
    +	public function testSetLocalId_HappyPath() {
    +		$document = new ResourceDocument();
    +		$document->setType('user');
    +		$document->setLocalId('42');
    +		
    +		$array = $document->toArray();
    +		
    +		$this->assertArrayHasKey('data', $array);
    +		$this->assertArrayHasKey('lid', $array['data']);
    +		$this->assertArrayNotHasKey('id', $array['data']);
    +		$this->assertSame('42', $array['data']['lid']);
    +	}
    +	
     	public function testAddRelationshipObject_WithIncluded() {
     		$resourceObject = new ResourceObject('user', 42);
     		$resourceObject->add('foo', 'bar');
    diff --git a/tests/SeparateProcessTest.php b/tests/SeparateProcessTest.php
    index 0582826..ec00ccd 100644
    --- a/tests/SeparateProcessTest.php
    +++ b/tests/SeparateProcessTest.php
    @@ -2,8 +2,8 @@
     
     namespace alsvanzelf\jsonapiTests;
     
    -use alsvanzelf\jsonapi\objects\ProfileLinkObject;
     use alsvanzelf\jsonapiTests\TestableNonAbstractDocument as Document;
    +use alsvanzelf\jsonapiTests\extensions\TestExtension;
     use alsvanzelf\jsonapiTests\profiles\TestProfile;
     use PHPUnit\Framework\TestCase;
     
    @@ -75,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) {
    @@ -82,7 +115,7 @@ public function testSendResponse_ContentTypeHeaderWithProfiles() {
     		}
     		
     		$profile = new TestProfile();
    -		$profile->setAliasedLink('https://jsonapi.org');
    +		$profile->setOfficialLink('https://jsonapi.org');
     		
     		$document = new Document();
     		$document->applyProfile($profile);
    @@ -90,16 +123,16 @@ 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->setAliasedLink('https://jsonapi.org/2');
    +		$profile->setOfficialLink('https://jsonapi.org/2');
     		$document->applyProfile($profile);
     		
     		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 824a2e6..0000000
    --- a/tests/example_output/ExampleVersionProfile.php
    +++ /dev/null
    @@ -1,36 +0,0 @@
    -addMeta($this->getKeyword('version'), $version, $level=Document::LEVEL_RESOURCE);
    -		}
    -		else {
    -			$resource->addMeta($this->getKeyword('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/errors_all_options/errors_all_options.json b/tests/example_output/errors_all_options/errors_all_options.json
    index b0cd4a3..11c8e33 100644
    --- a/tests/example_output/errors_all_options/errors_all_options.json
    +++ b/tests/example_output/errors_all_options/errors_all_options.json
    @@ -17,9 +17,7 @@
     			"detail": "Please, choose a bit less. Consult your ...",
     			"links": {
     				"about": "https://www.example.com/explanation.html",
    -				"type": [
    -					"https://www.example.com/documentation.html"
    -				]
    +				"type": "https://www.example.com/documentation.html"
     			}
     		},
     		{
    @@ -35,14 +33,12 @@
     						"foo": "bar"
     					}
     				},
    -				"type": [
    -					{
    -						"href": "https://www.example.com/documentation.html",
    -						"meta": {
    -							"foo": "bar"
    -						}
    +				"type": {
    +					"href": "https://www.example.com/documentation.html",
    +					"meta": {
    +						"foo": "bar"
     					}
    -				]
    +				}
     			},
     			"source": {
     				"pointer": "/data/attributes/title",
    diff --git a/tests/example_output/errors_all_options/errors_all_options.php b/tests/example_output/errors_all_options/errors_all_options.php
    index 7ca6e96..f0a4076 100644
    --- a/tests/example_output/errors_all_options/errors_all_options.php
    +++ b/tests/example_output/errors_all_options/errors_all_options.php
    @@ -19,7 +19,7 @@ public static function createJsonapiDocument() {
     		$errorSpecApi->setHumanTitle($genericTitle='Too much options');
     		$errorSpecApi->setHumanDetails($specificDetails='Please, choose a bit less. Consult your ...');
     		$errorSpecApi->setAboutLink($specificAboutLink='https://www.example.com/explanation.html', ['foo'=>'bar']);
    -		$errorSpecApi->appendTypeLink($genericTypeLink='https://www.example.com/documentation.html', ['foo'=>'bar']);
    +		$errorSpecApi->setTypeLink($genericTypeLink='https://www.example.com/documentation.html', ['foo'=>'bar']);
     		
     		$metaObject = new \stdClass();
     		$metaObject->property = 'value';
    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 e7ee9ff..0000000
    --- a/tests/example_output/example_profile/example_profile.json
    +++ /dev/null
    @@ -1,22 +0,0 @@
    -{
    -	"jsonapi": {
    -		"version": "1.1"
    -	},
    -	"links": {
    -		"profile": [
    -			{
    -				"href": "https://jsonapi.org/format/1.1/#profile-keywords-and-aliases",
    -				"aliases": {
    -					"version": "ref"
    -				}
    -			}
    -		]
    -	},
    -	"data": {
    -		"type": "user",
    -		"id": "42",
    -		"meta": {
    -			"ref": "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 c881f25..0000000
    --- a/tests/example_output/example_profile/example_profile.php
    +++ /dev/null
    @@ -1,19 +0,0 @@
    - 'ref']);
    -		
    -		$document = new ResourceDocument('user', 42);
    -		$document->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..e1bac4f
    --- /dev/null
    +++ b/tests/extensions/AtomicOperationsDocumentTest.php
    @@ -0,0 +1,59 @@
    +add('name', 'Ford');
    +		$resource2->add('name', 'Arthur');
    +		$resource3->add('name', 'Zaphod');
    +		$document->addResults($resource1, $resource2, $resource3);
    +		$document->setSelfLink('https://example.org/operations');
    +		
    +		$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('links', $array);
    +		$this->assertArrayHasKey('self', $array['links']);
    +		$this->assertArrayHasKey('href', $array['links']['self']);
    +		$this->assertArrayHasKey('type', $array['links']['self']);
    +		$this->assertSame('https://example.org/operations', $array['links']['self']['href']);
    +		$this->assertSame('application/vnd.api+json; ext="'.(new AtomicOperationsExtension())->getOfficialLink().'"', $array['links']['self']['type']);
    +		
    +		$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..745cd64
    --- /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/LinksManagerTest.php b/tests/helpers/LinksManagerTest.php
    index 750ae69..be87fa2 100644
    --- a/tests/helpers/LinksManagerTest.php
    +++ b/tests/helpers/LinksManagerTest.php
    @@ -82,6 +82,9 @@ public function testAddLinkObject_HappyPath() {
     		$this->assertSame('https://jsonapi.org', $array['foo']['href']);
     	}
     	
    +	/**
    +	 * @deprecated array links are not supported anymore
    +	 */
     	public function testAddLinksArray_HappyPath() {
     		$linksArray = new LinksArray();
     		$linksArray->add('https://jsonapi.org');
    @@ -98,6 +101,9 @@ public function testAddLinksArray_HappyPath() {
     		$this->assertSame('https://jsonapi.org', $array['foo'][0]);
     	}
     	
    +	/**
    +	 * @deprecated array links are not supported anymore
    +	 */
     	public function testAppendLinkObject_HappyPath() {
     		$linksManager = new LinksManager();
     		$linksManager->addLinksArray('foo', LinksArray::fromArray(['https://jsonapi.org/1']));
    diff --git a/tests/helpers/ProfileAliasManagerTest.php b/tests/helpers/ProfileAliasManagerTest.php
    deleted file mode 100644
    index fe086f3..0000000
    --- a/tests/helpers/ProfileAliasManagerTest.php
    +++ /dev/null
    @@ -1,87 +0,0 @@
    -assertSame([], $profileAliasManager->getAliasMapping());
    -		$this->assertSame(['foo' => 'foo', 'bar' => 'bar'], $profileAliasManager->getKeywordMapping());
    -	}
    -	
    -	public function testConstructor_WithAliases() {
    -		$profileAliasManager = new ProfileAliasManager(['bar' => 'baz']);
    -		
    -		$this->assertSame(['bar' => 'baz'], $profileAliasManager->getAliasMapping());
    -		$this->assertSame(['foo' => 'foo', 'bar' => 'baz'], $profileAliasManager->getKeywordMapping());
    -	}
    -	
    -	public function testConstructor_WithoutOfficialKeywords() {
    -		$profileAliasManager = new ProfileAliasManager_WithoutKeywords();
    -		
    -		$this->assertSame([], $profileAliasManager->getAliasMapping());
    -		$this->assertSame([], $profileAliasManager->getKeywordMapping());
    -	}
    -	
    -	public function testConstructor_NonAdjustedAliases() {
    -		$this->expectException(InputException::class);
    -		
    -		new ProfileAliasManager(['foo' => 'foo']);
    -	}
    -	
    -	public function testConstructor_NonExistingKeyword() {
    -		$this->expectException(InputException::class);
    -		
    -		new ProfileAliasManager(['baz' => 'bar']);
    -	}
    -	
    -	public function testGetKeyword_HappyPath() {
    -		$profileAliasManager = new ProfileAliasManager(['bar' => 'baz']);
    -		
    -		$this->assertSame('foo', $profileAliasManager->getKeyword('foo'));
    -		$this->assertSame('baz', $profileAliasManager->getKeyword('bar'));
    -	}
    -	
    -	public function testGetKeyword_NonExistingKeyword() {
    -		$profileAliasManager = new ProfileAliasManager();
    -		
    -		$this->expectException(InputException::class);
    -		
    -		$profileAliasManager->getKeyword('baz');
    -	}
    -	
    -	public function testGetAliasedLink_HappyPath() {
    -		$profileAliasManager = new ProfileAliasManager();
    -		
    -		if (method_exists($this, 'assertIsString')) {
    -			$this->assertIsString($profileAliasManager->getAliasedLink());
    -		}
    -		else {
    -			$this->assertInternalType('string', $profileAliasManager->getAliasedLink());
    -		}
    -		$this->assertSame('https://jsonapi.org', $profileAliasManager->getAliasedLink());
    -	}
    -	
    -	public function testGetAliasedLink_ObjectWithAliases() {
    -		$profileAliasManager = new ProfileAliasManager(['bar' => 'baz']);
    -		
    -		$this->assertInstanceOf(ProfileLinkObject::class, $profileAliasManager->getAliasedLink());
    -		
    -		$array = $profileAliasManager->getAliasedLink()->toArray();
    -		
    -		$this->assertArrayHasKey('href', $array);
    -		$this->assertSame('https://jsonapi.org', $array['href']);
    -		
    -		$this->assertArrayHasKey('aliases', $array);
    -		$this->assertCount(1, $array['aliases']);
    -		$this->assertArrayHasKey('bar', $array['aliases']);
    -		$this->assertSame('baz', $array['aliases']['bar']);
    -	}
    -}
    diff --git a/tests/helpers/RequestParserTest.php b/tests/helpers/RequestParserTest.php
    index 6410c78..fd17061 100644
    --- a/tests/helpers/RequestParserTest.php
    +++ b/tests/helpers/RequestParserTest.php
    @@ -336,6 +336,31 @@ public function testGetFilter() {
     		$this->assertSame(['foo' => 'bar'], $requestParser->getFilter());
     	}
     	
    +	public function testHasLocalId() {
    +		$document = [
    +			'data' => [
    +				'id' => 'foo',
    +			],
    +		];
    +		$requestParser = new RequestParser($selfLink='', $quaryParameters=[], $document);
    +		
    +		$this->assertArrayHasKey('data', $requestParser->getDocument());
    +		$this->assertArrayHasKey('id', $requestParser->getDocument()['data']);
    +		$this->assertFalse($requestParser->hasLocalId());
    +		
    +		$document = [
    +			'data' => [
    +				'lid' => 'foo',
    +			],
    +		];
    +		$requestParser = new RequestParser($selfLink='', $quaryParameters=[], $document);
    +		
    +		$this->assertArrayHasKey('data', $requestParser->getDocument());
    +		$this->assertArrayNotHasKey('id', $requestParser->getDocument()['data']);
    +		$this->assertTrue($requestParser->hasLocalId());
    +		$this->assertSame('foo', $requestParser->getLocalId());
    +	}
    +	
     	public function testHasAttribute() {
     		$requestParser = new RequestParser();
     		$this->assertFalse($requestParser->hasAttribute('foo'));
    diff --git a/tests/helpers/TestableNonAbstractProfileAliasManager.php b/tests/helpers/TestableNonAbstractProfileAliasManager.php
    deleted file mode 100644
    index e73d7dd..0000000
    --- a/tests/helpers/TestableNonAbstractProfileAliasManager.php
    +++ /dev/null
    @@ -1,39 +0,0 @@
    -setAccessible(true);
    -		
    -		return $aliasMapping->getValue($this);
    -	}
    -	
    -	public function getKeywordMapping() {
    -		$keywordMapping = new \ReflectionProperty(ProfileAliasManager::class, 'keywordMapping');
    -		$keywordMapping->setAccessible(true);
    -		
    -		return $keywordMapping->getValue($this);
    -	}
    -	
    -	public function getOfficialKeywords() {
    -		return ['foo', 'bar'];
    -	}
    -	
    -	public function getOfficialLink() {
    -		return 'https://jsonapi.org';
    -	}
    -}
    -
    -class TestableNonAbstractProfileAliasManager_WithoutKeywords extends TestableNonAbstractProfileAliasManager {
    -	public function getOfficialKeywords() {
    -		return [];
    -	}
    -}
    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 @@
    +assertTrue($errorObject->isEmpty());
    +		
    +		$errorObject->appendTypeLink('https://jsonapi.org');
    +		
    +		$this->assertFalse($errorObject->isEmpty());
    +		
    +		$array = $errorObject->toArray();
    +		
    +		$this->assertArrayHasKey('links', $array);
    +		$this->assertArrayHasKey('type', $array['links']);
    +		$this->assertSame(['https://jsonapi.org'], $array['links']['type']);
    +	}
    +	
     	public function testIsEmpty_All() {
     		$errorObject = new ErrorObject();
     		$this->assertTrue($errorObject->isEmpty());
    @@ -152,6 +171,28 @@ public function testIsEmpty_All() {
     		$errorObject = new ErrorObject();
     		$errorObject->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 bdf7e89..fd356c9 100644
    --- a/tests/objects/LinkObjectTest.php
    +++ b/tests/objects/LinkObjectTest.php
    @@ -3,9 +3,53 @@
     namespace alsvanzelf\jsonapiTests\objects;
     
     use alsvanzelf\jsonapi\objects\LinkObject;
    +use alsvanzelf\jsonapiTests\extensions\TestExtension;
     use PHPUnit\Framework\TestCase;
     
     class LinkObjectTest extends TestCase {
    +	public function testSetDescribedBy_HappyPath() {
    +		$linkObject = new LinkObject();
    +		
    +		$this->assertTrue($linkObject->isEmpty());
    +		
    +		$linkObject->setDescribedBy('https://jsonapi.org');
    +		
    +		$this->assertFalse($linkObject->isEmpty());
    +		
    +		$array = $linkObject->toArray();
    +		
    +		$this->assertArrayHasKey('describedby', $array);
    +		$this->assertArrayHasKey('href', $array['describedby']);
    +		$this->assertSame('https://jsonapi.org', $array['describedby']['href']);
    +	}
    +	
    +	public function testAddLanguage_HappyPath() {
    +		$linkObject = new LinkObject();
    +		
    +		$this->assertTrue($linkObject->isEmpty());
    +		
    +		$linkObject->addLanguage('nl-NL');
    +		
    +		$this->assertFalse($linkObject->isEmpty());
    +		
    +		$array = $linkObject->toArray();
    +		
    +		$this->assertArrayHasKey('hreflang', $array);
    +		$this->assertSame('nl-NL', $array['hreflang']);
    +	}
    +	
    +	public function testAddLanguage_Multiple() {
    +		$linkObject = new LinkObject();
    +		
    +		$linkObject->addLanguage('nl-NL');
    +		$array = $linkObject->toArray();
    +		$this->assertSame('nl-NL', $array['hreflang']);
    +		
    +		$linkObject->addLanguage('en-US');
    +		$array = $linkObject->toArray();
    +		$this->assertSame(['nl-NL', 'en-US'], $array['hreflang']);
    +	}
    +	
     	public function testAddMeta_HappyPath() {
     		$linkObject = new LinkObject();
     		
    @@ -22,6 +66,83 @@ public function testAddMeta_HappyPath() {
     		$this->assertSame('bar', $array['meta']['foo']);
     	}
     	
    +	public function testSetRelationType_HappyPath() {
    +		$linkObject = new LinkObject();
    +		
    +		$this->assertTrue($linkObject->isEmpty());
    +		
    +		$linkObject->setRelationType('external');
    +		
    +		$this->assertFalse($linkObject->isEmpty());
    +		
    +		$array = $linkObject->toArray();
    +		
    +		$this->assertArrayHasKey('rel', $array);
    +		$this->assertSame('external', $array['rel']);
    +	}
    +	
    +	public function testSetDescribedByLinkObject_HappyPath() {
    +		$linkObject = new LinkObject();
    +		
    +		$this->assertTrue($linkObject->isEmpty());
    +		
    +		$describedBy = new LinkObject('https://jsonapi.org');
    +		$linkObject->setDescribedByLinkObject($describedBy);
    +		
    +		$this->assertFalse($linkObject->isEmpty());
    +		
    +		$array = $linkObject->toArray();
    +		
    +		$this->assertArrayHasKey('describedby', $array);
    +		$this->assertArrayHasKey('href', $array['describedby']);
    +		$this->assertSame('https://jsonapi.org', $array['describedby']['href']);
    +	}
    +	
    +	public function testSetHumanTitle_HappyPath() {
    +		$linkObject = new LinkObject();
    +		
    +		$this->assertTrue($linkObject->isEmpty());
    +		
    +		$linkObject->setHumanTitle('A link');
    +		
    +		$this->assertFalse($linkObject->isEmpty());
    +		
    +		$array = $linkObject->toArray();
    +		
    +		$this->assertArrayHasKey('title', $array);
    +		$this->assertSame('A link', $array['title']);
    +	}
    +	
    +	public function testSetMediaType_HappyPath() {
    +		$linkObject = new LinkObject();
    +		
    +		$this->assertTrue($linkObject->isEmpty());
    +		
    +		$linkObject->setMediaType('text/html');
    +		
    +		$this->assertFalse($linkObject->isEmpty());
    +		
    +		$array = $linkObject->toArray();
    +		
    +		$this->assertArrayHasKey('type', $array);
    +		$this->assertSame('text/html', $array['type']);
    +	}
    +	
    +	public function testSetHreflang_HappyPath() {
    +		$linkObject = new LinkObject();
    +		
    +		$this->assertTrue($linkObject->isEmpty());
    +		
    +		$linkObject->setHreflang('nl-NL', 'en-US');
    +		
    +		$this->assertFalse($linkObject->isEmpty());
    +		
    +		$array = $linkObject->toArray();
    +		
    +		$this->assertArrayHasKey('hreflang', $array);
    +		$this->assertSame(['nl-NL', 'en-US'], $array['hreflang']);
    +	}
    +	
     	public function testIsEmpty_WithAtMembers() {
     		$linkObject = new LinkObject();
     		
    @@ -31,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/LinksArrayTest.php b/tests/objects/LinksArrayTest.php
    index 5b9945c..f0d0c7d 100644
    --- a/tests/objects/LinksArrayTest.php
    +++ b/tests/objects/LinksArrayTest.php
    @@ -5,6 +5,9 @@
     use alsvanzelf\jsonapi\objects\LinksArray;
     use PHPUnit\Framework\TestCase;
     
    +/**
    + * @deprecated array links are not supported anymore
    + */
     class LinksArrayTest extends TestCase {
     	public function testFromObject_HappyPath() {
     		$object = new \stdClass();
    diff --git a/tests/objects/LinksObjectTest.php b/tests/objects/LinksObjectTest.php
    index ac7b166..ac2995f 100644
    --- a/tests/objects/LinksObjectTest.php
    +++ b/tests/objects/LinksObjectTest.php
    @@ -109,6 +109,9 @@ public function testAddLinkObject_ExistingKey() {
     		$linksObject->addLinkObject($key='foo', $linkObject);
     	}
     	
    +	/**
    +	 * @deprecated array links are not supported anymore
    +	 */
     	public function testAddLinksArray_HappyPath() {
     		$linksObject = new LinksObject();
     		$linksObject->addLinksArray('foo', LinksArray::fromArray(['https://jsonapi.org']));
    @@ -122,6 +125,9 @@ public function testAddLinksArray_HappyPath() {
     		$this->assertSame('https://jsonapi.org', $array['foo'][0]);
     	}
     	
    +	/**
    +	 * @deprecated array links are not supported anymore
    +	 */
     	public function testAddLinksArray_BlocksReusingNonArray() {
     		$linksObject = new LinksObject();
     		$linksObject->add('foo', 'https://jsonapi.org');
    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 2508479..b7eb1ea 100644
    --- a/tests/objects/ResourceIdentifierObjectTest.php
    +++ b/tests/objects/ResourceIdentifierObjectTest.php
    @@ -3,10 +3,56 @@
     namespace alsvanzelf\jsonapiTests\objects;
     
     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 {
    +	public function testSetId_HappyPath() {
    +		$resourceIdentifierObject = new ResourceIdentifierObject();
    +		$resourceIdentifierObject->setType('test');
    +		$resourceIdentifierObject->setId('1');
    +		
    +		$array = $resourceIdentifierObject->toArray();
    +		
    +		$this->assertArrayHasKey('id', $array);
    +		$this->assertArrayNotHasKey('lid', $array);
    +		$this->assertSame('1', $array['id']);
    +	}
    +	
    +	public function testSetId_WithLocalIdAlreadySet() {
    +		$resourceIdentifierObject = new ResourceIdentifierObject();
    +		$resourceIdentifierObject->setType('test');
    +		$resourceIdentifierObject->setLocalId('uuid-1');
    +		
    +		$this->expectException(DuplicateException::class);
    +		
    +		$resourceIdentifierObject->setId('1');
    +	}
    +	
    +	public function testSetLocalId_HappyPath() {
    +		$resourceIdentifierObject = new ResourceIdentifierObject();
    +		$resourceIdentifierObject->setType('test');
    +		$resourceIdentifierObject->setLocalId('uuid-1');
    +		
    +		$array = $resourceIdentifierObject->toArray();
    +		
    +		$this->assertArrayHasKey('lid', $array);
    +		$this->assertArrayNotHasKey('id', $array);
    +		$this->assertSame('uuid-1', $array['lid']);
    +	}
    +	
    +	public function testSetLocalId_WithIdAlreadySet() {
    +		$resourceIdentifierObject = new ResourceIdentifierObject();
    +		$resourceIdentifierObject->setType('test');
    +		$resourceIdentifierObject->setId('1');
    +		
    +		$this->expectException(DuplicateException::class);
    +		
    +		$resourceIdentifierObject->setLocalId('uuid-1');
    +	}
    +	
     	public function testEquals_HappyPath() {
     		$one = new ResourceIdentifierObject('test', 1);
     		$two = new ResourceIdentifierObject('test', 2);
    @@ -25,6 +71,19 @@ public function testEquals_WithoutIdentification() {
     		$one->equals($two);
     	}
     	
    +	public function testEquals_WithLocalId() {
    +		$one = new ResourceIdentifierObject('test');
    +		$two = new ResourceIdentifierObject('test');
    +		$new = new ResourceIdentifierObject('test');
    +		
    +		$one->setLocalId('uuid-1');
    +		$two->setLocalId('uuid-2');
    +		$new->setLocalId('uuid-1');
    +		
    +		$this->assertFalse($one->equals($two));
    +		$this->assertTrue($one->equals($new));
    +	}
    +	
     	public function testGetIdentificationKey_HappyPath() {
     		$resourceIdentifierObject = new ResourceIdentifierObject('user', 42);
     		
    @@ -32,6 +91,7 @@ public function testGetIdentificationKey_HappyPath() {
     		
     		$this->assertArrayHasKey('type', $array);
     		$this->assertArrayHasKey('id', $array);
    +		$this->assertArrayNotHasKey('lid', $array);
     		$this->assertSame('user', $array['type']);
     		$this->assertSame('42', $array['id']);
     		$this->assertTrue($resourceIdentifierObject->hasIdentification());
    @@ -59,6 +119,23 @@ public function testGetIdentificationKey_SetAfterwards() {
     		$this->assertSame('user|42', $resourceIdentifierObject->getIdentificationKey());
     	}
     	
    +	public function testGetIdentificationKey_WithLocalId() {
    +		$resourceIdentifierObject = new ResourceIdentifierObject();
    +		
    +		$resourceIdentifierObject->setType('user');
    +		$resourceIdentifierObject->setLocalId('uuid-42');
    +		
    +		$array = $resourceIdentifierObject->toArray();
    +		
    +		$this->assertArrayHasKey('type', $array);
    +		$this->assertArrayHasKey('lid', $array);
    +		$this->assertArrayNotHasKey('id', $array);
    +		$this->assertSame('user', $array['type']);
    +		$this->assertSame('uuid-42', $array['lid']);
    +		$this->assertTrue($resourceIdentifierObject->hasIdentification());
    +		$this->assertSame('user|uuid-42', $resourceIdentifierObject->getIdentificationKey());
    +	}
    +	
     	public function testGetIdentificationKey_NoIdentification() {
     		$resourceIdentifierObject = new ResourceIdentifierObject();
     		
    @@ -98,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 183df74..97704d3 100644
    --- a/tests/profiles/CursorPaginationProfileTest.php
    +++ b/tests/profiles/CursorPaginationProfileTest.php
    @@ -14,29 +14,36 @@
      */
     class CursorPaginationProfileTest extends TestCase {
     	public function testSetLinks_HappyPath() {
    -		$profile          = new CursorPaginationProfile(['page' => 'pagination']);
    +		$profile          = new CursorPaginationProfile();
     		$collection       = new CollectionDocument();
    -		$baseOrCurrentUrl = '/people?'.$profile->getKeyword('page').'[size]=10';
    +		$baseOrCurrentUrl = '/people?page[size]=10';
     		$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']);
     		$this->assertArrayHasKey('next', $array['links']);
     		$this->assertArrayHasKey('href', $array['links']['prev']);
     		$this->assertArrayHasKey('href', $array['links']['next']);
    -		$this->assertSame('/people?'.$profile->getKeyword('page').'[size]=10&'.$profile->getKeyword('page').'[before]='.$firstCursor, $array['links']['prev']['href']);
    -		$this->assertSame('/people?'.$profile->getKeyword('page').'[size]=10&'.$profile->getKeyword('page').'[after]='.$lastCursor, $array['links']['next']['href']);
    +		$this->assertSame('/people?page[size]=10&page[before]='.$firstCursor, $array['links']['prev']['href']);
    +		$this->assertSame('/people?page[size]=10&page[after]='.$lastCursor, $array['links']['next']['href']);
     	}
     	
     	public function test_WithRelationship() {
    -		$profile  = new CursorPaginationProfile(['page' => 'pagination']);
    +		$profile  = new CursorPaginationProfile();
     		$document = new ResourceDocument('test', 1);
    +		$document->applyProfile($profile);
     		
     		$person1  = new ResourceObject('person', 1);
     		$person2  = new ResourceObject('person', 2);
    @@ -45,7 +52,7 @@ public function test_WithRelationship() {
     		$profile->setCursor($person2, 'arthur');
     		$profile->setCursor($person42, 'zaphod');
     		
    -		$baseOrCurrentUrl = '/people?'.$profile->getKeyword('page').'[size]=10';
    +		$baseOrCurrentUrl = '/people?page[size]=10';
     		$firstCursor      = 'ford';
     		$lastCursor       = 'zaphod';
     		$exactTotal       = 3;
    @@ -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']);
    @@ -67,79 +79,103 @@ public function test_WithRelationship() {
     		$this->assertArrayHasKey('meta', $array['data']['relationships']['people']);
     		$this->assertArrayHasKey('prev', $array['data']['relationships']['people']['links']);
     		$this->assertArrayHasKey('next', $array['data']['relationships']['people']['links']);
    -		$this->assertArrayHasKey('pagination', $array['data']['relationships']['people']['meta']);
    +		$this->assertArrayHasKey('page', $array['data']['relationships']['people']['meta']);
     		$this->assertArrayHasKey('href', $array['data']['relationships']['people']['links']['prev']);
     		$this->assertArrayHasKey('href', $array['data']['relationships']['people']['links']['next']);
    -		$this->assertArrayHasKey('total', $array['data']['relationships']['people']['meta']['pagination']);
    -		$this->assertArrayHasKey('estimatedTotal', $array['data']['relationships']['people']['meta']['pagination']);
    -		$this->assertArrayHasKey('bestGuess', $array['data']['relationships']['people']['meta']['pagination']['estimatedTotal']);
    +		$this->assertArrayHasKey('total', $array['data']['relationships']['people']['meta']['page']);
    +		$this->assertArrayHasKey('estimatedTotal', $array['data']['relationships']['people']['meta']['page']);
    +		$this->assertArrayHasKey('bestGuess', $array['data']['relationships']['people']['meta']['page']['estimatedTotal']);
     		$this->assertCount(3, $array['data']['relationships']['people']['data']);
     		$this->assertArrayHasKey('meta', $array['data']['relationships']['people']['data'][0]);
    -		$this->assertArrayHasKey('pagination', $array['data']['relationships']['people']['data'][0]['meta']);
    -		$this->assertArrayHasKey('cursor', $array['data']['relationships']['people']['data'][0]['meta']['pagination']);
    +		$this->assertArrayHasKey('page', $array['data']['relationships']['people']['data'][0]['meta']);
    +		$this->assertArrayHasKey('cursor', $array['data']['relationships']['people']['data'][0]['meta']['page']);
     	}
     	
     	public function testSetLinksFirstPage_HappyPath() {
    -		$profile          = new CursorPaginationProfile(['page' => 'pagination']);
    +		$profile          = new CursorPaginationProfile();
     		$collection       = new CollectionDocument();
    -		$baseOrCurrentUrl = '/people?'.$profile->getKeyword('page').'[size]=10';
    +		$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']);
     		$this->assertArrayHasKey('next', $array['links']);
     		$this->assertNull($array['links']['prev']);
     		$this->assertArrayHasKey('href', $array['links']['next']);
    -		$this->assertSame('/people?'.$profile->getKeyword('page').'[size]=10&'.$profile->getKeyword('page').'[after]='.$lastCursor, $array['links']['next']['href']);
    +		$this->assertSame('/people?page[size]=10&page[after]='.$lastCursor, $array['links']['next']['href']);
     	}
     	
     	public function testSetLinksLastPage_HappyPath() {
    -		$profile          = new CursorPaginationProfile(['page' => 'pagination']);
    +		$profile          = new CursorPaginationProfile();
     		$collection       = new CollectionDocument();
    -		$baseOrCurrentUrl = '/people?'.$profile->getKeyword('page').'[size]=10';
    +		$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']);
     		$this->assertArrayHasKey('next', $array['links']);
     		$this->assertArrayHasKey('href', $array['links']['prev']);
     		$this->assertNull($array['links']['next']);
    -		$this->assertSame('/people?'.$profile->getKeyword('page').'[size]=10&'.$profile->getKeyword('page').'[before]='.$firstCursor, $array['links']['prev']['href']);
    +		$this->assertSame('/people?page[size]=10&page[before]='.$firstCursor, $array['links']['prev']['href']);
     	}
     	
     	public function testSetCursor() {
    -		$profile          = new CursorPaginationProfile(['page' => 'pagination']);
    +		$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('pagination', $array['data']['meta']);
    -		$this->assertArrayHasKey('cursor', $array['data']['meta']['pagination']);
    -		$this->assertSame('foo', $array['data']['meta']['pagination']['cursor']);
    +		$this->assertArrayHasKey('page', $array['data']['meta']);
    +		$this->assertArrayHasKey('cursor', $array['data']['meta']['page']);
    +		$this->assertSame('foo', $array['data']['meta']['page']['cursor']);
     	}
     	
     	public function testSetPaginationLinkObjectsExplicitlyEmpty_HapptPath() {
    -		$profile    = new CursorPaginationProfile(['page' => 'pagination']);
    +		$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']);
    @@ -149,29 +185,35 @@ public function testSetPaginationLinkObjectsExplicitlyEmpty_HapptPath() {
     	}
     	
     	public function testSetPaginationMeta() {
    -		$profile          = new CursorPaginationProfile(['page' => 'pagination']);
    +		$profile          = new CursorPaginationProfile();
     		$collection       = new CollectionDocument();
     		$exactTotal       = 42;
     		$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('pagination', $array['meta']);
    -		$this->assertArrayHasKey('total', $array['meta']['pagination']);
    -		$this->assertArrayHasKey('estimatedTotal', $array['meta']['pagination']);
    -		$this->assertArrayHasKey('bestGuess', $array['meta']['pagination']['estimatedTotal']);
    -		$this->assertArrayHasKey('rangeTruncated', $array['meta']['pagination']);
    -		$this->assertSame(42, $array['meta']['pagination']['total']);
    -		$this->assertSame(100, $array['meta']['pagination']['estimatedTotal']['bestGuess']);
    -		$this->assertSame(true, $array['meta']['pagination']['rangeTruncated']);
    +		$this->assertArrayHasKey('page', $array['meta']);
    +		$this->assertArrayHasKey('total', $array['meta']['page']);
    +		$this->assertArrayHasKey('estimatedTotal', $array['meta']['page']);
    +		$this->assertArrayHasKey('bestGuess', $array['meta']['page']['estimatedTotal']);
    +		$this->assertArrayHasKey('rangeTruncated', $array['meta']['page']);
    +		$this->assertSame(42, $array['meta']['page']['total']);
    +		$this->assertSame(100, $array['meta']['page']['estimatedTotal']['bestGuess']);
    +		$this->assertSame(true, $array['meta']['page']['rangeTruncated']);
     	}
     	
     	public function testGetUnsupportedSortErrorObject_HappyPath() {
    -		$profile         = new CursorPaginationProfile(['page' => 'pagination']);
    +		$profile         = new CursorPaginationProfile();
     		$genericTitle    = 'foo';
     		$specificDetails = 'bar';
     		
    @@ -187,17 +229,16 @@ public function testGetUnsupportedSortErrorObject_HappyPath() {
     		$this->assertArrayHasKey('type', $array['links']);
     		$this->assertArrayHasKey('source', $array);
     		$this->assertArrayHasKey('parameter', $array['source']);
    -		$this->assertCount(1, $array['links']['type']);
     		$this->assertSame('400', $array['status']);
     		$this->assertSame('Unsupported sort', $array['code']);
     		$this->assertSame($genericTitle, $array['title']);
     		$this->assertSame($specificDetails, $array['detail']);
    -		$this->assertSame('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/unsupported-sort', $array['links']['type'][0]);
    +		$this->assertSame('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/unsupported-sort', $array['links']['type']);
     		$this->assertSame('sort', $array['source']['parameter']);
     	}
     	
     	public function testGetMaxPageSizeExceededErrorObject_HappyPath() {
    -		$profile         = new CursorPaginationProfile(['page' => 'pagination']);
    +		$profile         = new CursorPaginationProfile();
     		$maxSize         = 42;
     		$genericTitle    = 'foo';
     		$specificDetails = 'bar';
    @@ -215,21 +256,20 @@ public function testGetMaxPageSizeExceededErrorObject_HappyPath() {
     		$this->assertArrayHasKey('source', $array);
     		$this->assertArrayHasKey('parameter', $array['source']);
     		$this->assertArrayHasKey('meta', $array);
    -		$this->assertArrayHasKey('pagination', $array['meta']);
    -		$this->assertArrayHasKey('maxSize', $array['meta']['pagination']);
    -		$this->assertCount(1, $array['links']['type']);
    +		$this->assertArrayHasKey('page', $array['meta']);
    +		$this->assertArrayHasKey('maxSize', $array['meta']['page']);
     		$this->assertSame('400', $array['status']);
     		$this->assertSame('Max page size exceeded', $array['code']);
     		$this->assertSame($genericTitle, $array['title']);
     		$this->assertSame($specificDetails, $array['detail']);
    -		$this->assertSame('pagination[size]', $array['source']['parameter']);
    -		$this->assertSame('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/max-size-exceeded', $array['links']['type'][0]);
    -		$this->assertSame(42, $array['meta']['pagination']['maxSize']);
    +		$this->assertSame('page[size]', $array['source']['parameter']);
    +		$this->assertSame('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/max-size-exceeded', $array['links']['type']);
    +		$this->assertSame(42, $array['meta']['page']['maxSize']);
     	}
     	
     	public function testGetInvalidParameterValueErrorObject_HappyPath() {
    -		$profile         = new CursorPaginationProfile(['page' => 'pagination']);
    -		$queryParameter  = 'pagination[size]';
    +		$profile         = new CursorPaginationProfile();
    +		$queryParameter  = 'page[size]';
     		$typeLink        = 'https://jsonapi.org';
     		$genericTitle    = 'foo';
     		$specificDetails = 'bar';
    @@ -246,17 +286,16 @@ public function testGetInvalidParameterValueErrorObject_HappyPath() {
     		$this->assertArrayHasKey('type', $array['links']);
     		$this->assertArrayHasKey('source', $array);
     		$this->assertArrayHasKey('parameter', $array['source']);
    -		$this->assertCount(1, $array['links']['type']);
     		$this->assertSame('400', $array['status']);
     		$this->assertSame('Invalid parameter value', $array['code']);
     		$this->assertSame($genericTitle, $array['title']);
     		$this->assertSame($specificDetails, $array['detail']);
    -		$this->assertSame('pagination[size]', $array['source']['parameter']);
    -		$this->assertSame('https://jsonapi.org', $array['links']['type'][0]);
    +		$this->assertSame('page[size]', $array['source']['parameter']);
    +		$this->assertSame('https://jsonapi.org', $array['links']['type']);
     	}
     	
     	public function testGetRangePaginationNotSupportedErrorObject_HappyPath() {
    -		$profile         = new CursorPaginationProfile(['page' => 'pagination']);
    +		$profile         = new CursorPaginationProfile();
     		$genericTitle    = 'foo';
     		$specificDetails = 'bar';
     		
    @@ -270,12 +309,11 @@ public function testGetRangePaginationNotSupportedErrorObject_HappyPath() {
     		$this->assertArrayHasKey('detail', $array);
     		$this->assertArrayHasKey('links', $array);
     		$this->assertArrayHasKey('type', $array['links']);
    -		$this->assertCount(1, $array['links']['type']);
     		$this->assertSame('400', $array['status']);
     		$this->assertSame('Range pagination not supported', $array['code']);
     		$this->assertSame($genericTitle, $array['title']);
     		$this->assertSame($specificDetails, $array['detail']);
    -		$this->assertSame('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/range-pagination-not-supported', $array['links']['type'][0]);
    +		$this->assertSame('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/range-pagination-not-supported', $array['links']['type']);
     	}
     	
     	public function testSetQueryParameter_HappyPath() {
    @@ -305,4 +343,15 @@ public function testSetQueryParameter_EncodedUrl() {
     		
     		$this->assertSame('/people?sort=x&page%5Bsize%5D=10&page%5Bafter%5D=bar', $newUrl);
     	}
    +	
    +	/**
    +	 * test method while it is part of the interface
    +	 */
    +	public function testGetKeyword_HappyPath() {
    +		$profile = new CursorPaginationProfile();
    +		
    +		$keyword = $profile->getKeyword('page');
    +		
    +		$this->assertSame('page', $keyword);
    +	}
     }
    diff --git a/tests/profiles/TestProfile.php b/tests/profiles/TestProfile.php
    index 91fe146..331e1a8 100644
    --- a/tests/profiles/TestProfile.php
    +++ b/tests/profiles/TestProfile.php
    @@ -5,17 +5,13 @@
     use alsvanzelf\jsonapi\interfaces\ProfileInterface;
     
     class TestProfile implements ProfileInterface {
    -	private $aliasedLink;
    +	private $officialLink;
     	
    -	public function setAliasedLink($aliasedLink) {
    -		$this->aliasedLink = $aliasedLink;
    +	public function setOfficialLink($officialLink) {
    +		$this->officialLink = $officialLink;
     	}
     	
    -	public function __construct(array $aliases=[]) {}
    -	public function getKeyword($keyword) {}
    -	public function getOfficialKeywords() {}
    -	public function getOfficialLink() {}
    -	public function getAliasedLink() {
    -		return $this->aliasedLink;
    +	public function getOfficialLink() {
    +		return $this->officialLink;
     	}
     }