diff --git a/src/Bridge/Symfony/Bundle/Controller/OperationController.php b/src/Bridge/Symfony/Bundle/Controller/OperationController.php
index 9606431fd94665b0fa7d02be8b037544b09ae442..edc7c18b2824715f59d4411457cb29eda1766175 100644
--- a/src/Bridge/Symfony/Bundle/Controller/OperationController.php
+++ b/src/Bridge/Symfony/Bundle/Controller/OperationController.php
@@ -20,6 +20,7 @@
 
 namespace Irstea\ApiMetadata\Bridge\Symfony\Bundle\Controller;
 
+use Irstea\ApiMetadata\Bridge\Symfony\Serializer\ObjectMetadataNormalizer;
 use Irstea\ApiMetadata\Factory\Context;
 use Irstea\ApiMetadata\Factory\Operation\OperationFactoryInterface;
 use Irstea\ApiMetadata\Factory\Type\TypeFactoryInterface;
@@ -82,7 +83,7 @@ class OperationController extends AbstractController
         $ctx = new Context($this->typeFactory);
         $metadata = $this->operationFactory->createOperation($operationId, $ctx);
 
-        $json = $this->serializer->serialize($metadata, 'json', ['root' => $metadata]);
+        $json = $this->serializer->serialize($metadata, 'json', [ObjectMetadataNormalizer::BASE_URI_CTX_KEY => $this->uriGenerator->generateURI($operationId)]);
 
         return JsonResponse::fromJsonString($json);
     }
diff --git a/src/Bridge/Symfony/Bundle/Controller/ResourceController.php b/src/Bridge/Symfony/Bundle/Controller/ResourceController.php
index a6c808a3afa75ab55a2be76563d206830dacd1fb..fa055b136cda23ccfdf065984e52ac64ade3967f 100644
--- a/src/Bridge/Symfony/Bundle/Controller/ResourceController.php
+++ b/src/Bridge/Symfony/Bundle/Controller/ResourceController.php
@@ -20,6 +20,7 @@
 
 namespace Irstea\ApiMetadata\Bridge\Symfony\Bundle\Controller;
 
+use Irstea\ApiMetadata\Bridge\Symfony\Serializer\ObjectMetadataNormalizer;
 use Irstea\ApiMetadata\Factory\Context;
 use Irstea\ApiMetadata\Helper\PropertyInfoType;
 use Irstea\ApiMetadata\Model\Identity\ResourceIdentityInterface;
@@ -51,7 +52,7 @@ class ResourceController extends AbstractController
             $metadata = $metadata->getTarget();
         }
 
-        $json = $this->serializer->serialize($metadata, 'json', ['root' => $metadata]);
+        $json = $this->serializer->serialize($metadata, 'json', [ObjectMetadataNormalizer::BASE_URI_CTX_KEY => $this->uriGenerator->generateURI($resourceId)]);
 
         return JsonResponse::fromJsonString($json);
     }
diff --git a/src/Bridge/Symfony/Serializer/ObjectMetadataNormalizer.php b/src/Bridge/Symfony/Serializer/ObjectMetadataNormalizer.php
index b8f0250e63c35459b1e129bd893d3f9c9a469ad4..eb6fd360fce704c1785c1abae6deaccf6450d7e4 100644
--- a/src/Bridge/Symfony/Serializer/ObjectMetadataNormalizer.php
+++ b/src/Bridge/Symfony/Serializer/ObjectMetadataNormalizer.php
@@ -21,11 +21,13 @@
 namespace Irstea\ApiMetadata\Bridge\Symfony\Serializer;
 
 use Assert\Assertion;
+use Irstea\ApiMetadata\JSON\ClassMapping;
+use Irstea\ApiMetadata\JSON\Document;
+use Irstea\ApiMetadata\JSON\Pointer;
 use Irstea\ApiMetadata\Model\ObjectMetadata;
 use Irstea\ApiMetadata\Model\PropertyMetadata;
 use Irstea\ApiMetadata\Model\ResourceMetadata;
 use Irstea\ApiMetadata\URI\URIGeneratorInterface;
-use Irstea\ApiMetadata\URI\Util;
 use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
 use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
 use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -35,12 +37,11 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
  */
 class ObjectMetadataNormalizer implements NormalizerAwareInterface, NormalizerInterface
 {
-    public const BASE_URI_CTX_KEY = 'baseURI';
+    public const BASE_URI_CTX_KEY = '$id';
 
-    private const URI_GENERATOR_CTX_KEY = __CLASS__ . 'uriGenerator';
+    private const URI_GENERATOR_CTX_KEY = __CLASS__ . '->uriGenerator';
 
-    private const REFERENCES_CTX_KEY = __CLASS__ . 'references';
-    private const DEFINITIONS_CTX_KEY = __CLASS__ . 'definitions';
+    private const CLASS_MAPPING_KEY = __CLASS__ . '->classMapping';
 
     use NormalizerAwareTrait;
 
@@ -72,16 +73,14 @@ class ObjectMetadataNormalizer implements NormalizerAwareInterface, NormalizerIn
      */
     public function normalize($object, $format = null, array $context = [])
     {
-        Assertion::keyIsset($context, 'root');
+        /* @var ObjectMetadata $object */
         Assertion::isInstanceOf($object, ObjectMetadata::class);
-        /** @var ObjectMetadata $object */
-        if ($object === $context['root']) {
-            Assertion::isInstanceOf($object, ResourceMetadata::class);
 
-            return $this->normalizeRoot($object, $format, $context);
+        if ($object instanceof ResourceMetadata && !isset($context[self::CLASS_MAPPING_KEY])) {
+            return (object) $this->normalizeRoot($object, $format, $context);
         }
 
-        return $this->normalizeNonRoot($object, $format, $context);
+        return (object) $this->normalizeNonRoot($object, $format, $context);
     }
 
     /**
@@ -95,36 +94,32 @@ class ObjectMetadataNormalizer implements NormalizerAwareInterface, NormalizerIn
      */
     private function normalizeRoot(ResourceMetadata $root, $format, array $context): array
     {
-        $class = $root->getClass();
+        $rootURI = $context[self::BASE_URI_CTX_KEY] ?? $this->generateURI($root, $context);
+        $rootPointer = Pointer::from($rootURI);
 
-        if (isset($context[self::REFERENCES_CTX_KEY])) {
-            return ['$ref' => $context[self::REFERENCES_CTX_KEY][$class]];
-        }
+        $document = new Document($rootPointer);
+        $mapping = new ClassMapping($document, $rootPointer->join(Pointer::from('#definitions/')));
 
-        $context[self::DEFINITIONS_CTX_KEY] = $defs = new \ArrayObject();
-        $context[self::REFERENCES_CTX_KEY] = new \ArrayObject();
+        $context[self::CLASS_MAPPING_KEY] = $mapping;
+        $context[self::BASE_URI_CTX_KEY] = $rootURI;
 
-        $context[self::REFERENCES_CTX_KEY][$class] = $uri = $this->generateURI($root, $context, false);
-        $context[self::BASE_URI_CTX_KEY] = $uri;
+        $ptr = $mapping->resolve(
+            $root->getClass(),
+            function () use ($root, $format, $context) {
+                return $this->doNormalize($root, $format, $context);
+            }
+        );
 
-        $data = $this->doNormalize($root, $format, $context);
+        $document['#'] = $document[$ptr];
+        unset($document[$ptr]);
 
         $operations = [];
         foreach ($root->getOperations() as $operation) {
-            $operations[] = ['$ref' => $this->generateURI($operation, $context, true)];
+            $operations[] = (object) ['$ref' => $this->generateURI($operation, $context)];
         }
+        $document['#operations'] = $operations;
 
-        return array_merge(
-            [
-                '$id'     => $uri,
-                '$schema' => 'http://json-schema.org/schema#',
-            ],
-            $data,
-            [
-                'operations'  => $operations,
-                'definitions' => (object) ($defs->getArrayCopy()),
-            ]
-        );
+        return $document->toArray();
     }
 
     /**
@@ -134,19 +129,11 @@ class ObjectMetadataNormalizer implements NormalizerAwareInterface, NormalizerIn
      *
      * @return string
      */
-    private function generateURI($resource, array $context, bool $relative): string
+    private function generateURI($resource, array $context): string
     {
         $uriGenerator = $context[self::URI_GENERATOR_CTX_KEY] ?? $this->uriGenerator;
-        $uri = $uriGenerator->generateURI($resource);
-
-        if ($relative) {
-            $base = $context[self::BASE_URI_CTX_KEY] ?? '';
-            if ($base) {
-                return Util::makeRelativeURI($base, $uri);
-            }
-        }
 
-        return $uri;
+        return $uriGenerator->generateURI($resource);
     }
 
     /**
@@ -160,21 +147,16 @@ class ObjectMetadataNormalizer implements NormalizerAwareInterface, NormalizerIn
      */
     private function normalizeNonRoot(ObjectMetadata $object, $format, array $context): array
     {
-        $class = $object->getClass();
+        $mapping = $context[self::CLASS_MAPPING_KEY];
 
-        if (isset($context[self::REFERENCES_CTX_KEY][$class])) {
-            return ['$ref' => $context[self::REFERENCES_CTX_KEY][$class]];
-        }
-
-        $context[self::REFERENCES_CTX_KEY][$class] = $uri = $this->generateURI($object, $context, true);
-
-        if (strpos($uri, '#/definitions') === 0) {
-            $key = substr($uri, 14);
-
-            $context[self::DEFINITIONS_CTX_KEY][$key] = $this->doNormalize($object, $format, $context);
-        }
+        $pointer = $mapping->resolve(
+            $object->getClass(),
+            function () use ($object, $format, $context) {
+                return $this->doNormalize($object, $format, $context);
+            }
+        );
 
-        return ['$ref' => $uri];
+        return ['$ref' => $pointer];
     }
 
     /**
diff --git a/src/JSON/ClassMapping.php b/src/JSON/ClassMapping.php
new file mode 100644
index 0000000000000000000000000000000000000000..a0bca2954047e769b1ee6f9f403e751814f7e898
--- /dev/null
+++ b/src/JSON/ClassMapping.php
@@ -0,0 +1,70 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of "irstea/api-metadata".
+ *
+ * Copyright (C) 2019 IRSTEA
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License and the GNU
+ * Lesser General Public License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+
+namespace Irstea\ApiMetadata\JSON;
+
+/**
+ * Class ClassMapping.
+ */
+class ClassMapping implements ClassMappingInterface
+{
+    /**
+     * @var Document
+     */
+    private $document;
+
+    /**
+     * @var Pointer
+     */
+    private $base;
+
+    /**
+     * @var array<string, Pointer>
+     */
+    private $pointers = [];
+
+    /**
+     * ClassMapping constructor.
+     *
+     * @param Document $document
+     * @param Pointer  $base
+     */
+    public function __construct(Document $document, Pointer $base)
+    {
+        $this->document = $document;
+        $this->base = $base;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function resolve(string $className, callable $resolver)
+    {
+        if (isset($this->pointers[$className])) {
+            return $this->pointers[$className];
+        }
+
+        $pointer = $this->base->join(Pointer::from('#' . $className));
+        $this->pointers[$className] = $pointer;
+        $this->document[$pointer] = $resolver($className);
+
+        return $pointer;
+    }
+}
diff --git a/src/JSON/ClassMappingInterface.php b/src/JSON/ClassMappingInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..6ef2c4ed2c3375fef57283426a53d33be795669e
--- /dev/null
+++ b/src/JSON/ClassMappingInterface.php
@@ -0,0 +1,35 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of "irstea/api-metadata".
+ *
+ * Copyright (C) 2019 IRSTEA
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License and the GNU
+ * Lesser General Public License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+
+namespace Irstea\ApiMetadata\JSON;
+
+/**
+ * Interface ClassMappingInterface.
+ */
+interface ClassMappingInterface
+{
+    /**
+     * @param string   $className
+     * @param callable $resolver
+     *
+     * @return mixed
+     */
+    public function resolve(string $className, callable $resolver);
+}
diff --git a/src/JSON/Document.php b/src/JSON/Document.php
new file mode 100644
index 0000000000000000000000000000000000000000..e72d280740de200428825b7bbfe4d7565559699a
--- /dev/null
+++ b/src/JSON/Document.php
@@ -0,0 +1,137 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of "irstea/api-metadata".
+ *
+ * Copyright (C) 2019 IRSTEA
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License and the GNU
+ * Lesser General Public License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+
+namespace Irstea\ApiMetadata\JSON;
+
+/**
+ * Class Document.
+ */
+class Document implements \ArrayAccess
+{
+    /** @var Pointer */
+    private $root;
+
+    /**
+     * @var array<string, mixed>
+     */
+    private $data;
+
+    /**
+     * Document constructor.
+     *
+     * @param Pointer $root
+     */
+    public function __construct(Pointer $root)
+    {
+        $this->root = $root->getDocumentRoot();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function offsetExists($offset): bool
+    {
+        $pointer = Pointer::from($offset);
+
+        return $pointer->inSameDocumentAs($this->root) && \array_key_exists($pointer->getFragment(), $this->data);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function offsetGet($offset)
+    {
+        $pointer = Pointer::from($offset);
+        if (!$pointer->inSameDocumentAs($this->root)) {
+            return null;
+        }
+
+        return  $this->data[$pointer->getFragment()] ?? null;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function offsetSet($offset, $value): void
+    {
+        $pointer = Pointer::from($offset);
+        if (!$pointer->inSameDocumentAs($this->root)) {
+            return;
+        }
+        $this->data[$pointer->getFragment()] = $value;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function offsetUnset($offset): void
+    {
+        $pointer = Pointer::from($offset);
+        if (!$pointer->inSameDocumentAs($this->root)) {
+            return;
+        }
+        unset($this->data[$pointer->getFragment()]);
+    }
+
+    /**
+     * @return array
+     */
+    public function toArray(): array
+    {
+        $parts = [['$id' => $this->root->toString()]];
+
+        $paths = array_keys($this->data);
+        usort($paths, [self::class, 'comparePaths']);
+
+        foreach ($paths as $path) {
+            if (!$path) {
+                $parts[] = $this->data[$path];
+                continue;
+            }
+
+            $root = [];
+            $current = &$root;
+            foreach (explode('/', $path) as $next) {
+                if (!isset($current[$next])) {
+                    $current[$next] = [];
+                }
+                $current = &$current[$next];
+            }
+            $current = $this->data[$path];
+            $parts[] = $root;
+        }
+
+        return \array_replace_recursive(...$parts);
+    }
+
+    /**
+     * @param string $a
+     * @param string $b
+     *
+     * @return bool
+     */
+    public static function comparePaths(string $a, string $b): bool
+    {
+        $aParts = explode('/', $a);
+        $bParts = explode('/', $b);
+
+        return $aParts > $bParts;
+    }
+}
diff --git a/src/JSON/Pointer.php b/src/JSON/Pointer.php
new file mode 100644
index 0000000000000000000000000000000000000000..adad6a4477823718a03980458ba86c35af4136be
--- /dev/null
+++ b/src/JSON/Pointer.php
@@ -0,0 +1,158 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of "irstea/api-metadata".
+ *
+ * Copyright (C) 2019 IRSTEA
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License and the GNU
+ * Lesser General Public License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+
+namespace Irstea\ApiMetadata\JSON;
+
+use function Sabre\Uri\build;
+use function Sabre\Uri\normalize;
+use function Sabre\Uri\parse;
+
+/**
+ * Class Pointer.
+ */
+final class Pointer
+{
+    /**
+     * @var string
+     */
+    private $fragment;
+
+    /**
+     * @var string
+     */
+    private $url;
+
+    /**
+     * Pointer constructor.
+     *
+     * @param string $url
+     * @param string $fragment
+     */
+    private function __construct(string $url, string $fragment)
+    {
+        $this->url = $url;
+        $this->fragment = $fragment;
+    }
+
+    /**
+     * Get fragment.
+     *
+     * @return string
+     */
+    public function getFragment(): string
+    {
+        return $this->fragment;
+    }
+
+    /**
+     * Get url.
+     *
+     * @return string
+     */
+    public function getURL(): string
+    {
+        return $this->url;
+    }
+
+    /**
+     * @param Pointer $other
+     *
+     * @return bool
+     */
+    public function isEqualTo(Pointer $other): bool
+    {
+        return $this->inSameDocumentAs($other) && $this->fragment === $other->fragment;
+    }
+
+    /**
+     * @param Pointer $other
+     *
+     * @return bool
+     */
+    public function inSameDocumentAs(Pointer $other): bool
+    {
+        return !$other->url || !$this->url || $other->url === $this->url;
+    }
+
+    /**
+     * @return Pointer
+     */
+    public function getDocumentRoot(): Pointer
+    {
+        return $this->fragment ? new self($this->url, '') : $this;
+    }
+
+    /**
+     * @return string
+     */
+    public function toString(): string
+    {
+        return $this->url . '#' . $this->fragment;
+    }
+
+    /**
+     * @return string
+     */
+    public function __toString(): string
+    {
+        return $this->toString();
+    }
+
+    /**
+     * @param Pointer $other
+     *
+     * @return Pointer
+     */
+    public function join(Pointer $other): Pointer
+    {
+        if (!$other->inSameDocumentAs($this)) {
+            return $other;
+        }
+
+        $thisFragment = $this->fragment;
+        $i = strrpos($thisFragment, '/');
+
+        $otherFragment = $other->fragment;
+        if ($i === false || strpos($otherFragment, '/') === 0) {
+            return new Pointer($this->url, $otherFragment);
+        }
+
+        return new Pointer($this->url, substr($thisFragment, 0, $i + 1) . $otherFragment);
+    }
+
+    /**
+     * @param string|Pointer $uri
+     *
+     * @return Pointer
+     */
+    public static function from($uri): Pointer
+    {
+        if ($uri instanceof self) {
+            return $uri;
+        }
+
+        $data = parse(normalize($uri));
+        $fragment = $data['fragment'] ?: '';
+        unset($data['fragment']);
+        $url = build($data);
+
+        return new self($url, $fragment);
+    }
+}
diff --git a/tests/JSON/DocumentTest.php b/tests/JSON/DocumentTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a11bbf60f8a8ef947b60d8ba73a3932b8000093f
--- /dev/null
+++ b/tests/JSON/DocumentTest.php
@@ -0,0 +1,85 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of "irstea/api-metadata".
+ *
+ * Copyright (C) 2019 IRSTEA
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License and the GNU
+ * Lesser General Public License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+
+namespace Irstea\ApiMetadata\Tests\JSON;
+
+use Irstea\ApiMetadata\JSON\Document;
+use Irstea\ApiMetadata\JSON\Pointer;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Class DocumentTest.
+ *
+ * @covers \Irstea\ApiMetadata\JSON\Pointer
+ * @covers \Irstea\ApiMetadata\JSON\Document
+ */
+class DocumentTest extends TestCase
+{
+    public function testArrayAccess()
+    {
+        $root = Pointer::from('document#root');
+
+        $doc = new Document($root);
+
+        $doc['document#a/0/k'] = 5;
+        $doc['document#a/1/k'] = 8;
+        $doc['document#b'] = 'truc';
+        $doc['document#a/5/k'] = 7;
+        $doc['document#'] = ['foo' => 'bar'];
+
+        unset($doc['document#a/5/k']);
+        unset($doc['external#a/5/k']);
+
+        self::assertTrue(isset($doc['document#a/0/k']));
+        self::assertEquals(5, $doc['document#a/0/k']);
+
+        self::assertFalse(isset($doc['document#a/5/k']));
+
+        self::assertFalse(isset($doc['external#a/0/k']));
+        self::assertNull($doc['external#a/0/k']);
+    }
+
+    public function testToArray()
+    {
+        $root = Pointer::from('document#root');
+
+        $doc = new Document($root);
+
+        $doc['document#a'] = [2 => 7];
+        $doc['document#a/0/k'] = 5;
+        $doc['document#a/1/k'] = 8;
+        $doc['document#b'] = 'truc';
+        $doc['document#'] = ['foo' => 'bar'];
+
+        self::assertEquals(
+            [
+                '$id' => $root->getDocumentRoot()->toString(),
+                'foo' => 'bar',
+                'a'   => [
+                    ['k' => 5],
+                    ['k' => 8],
+                    7,
+                ],
+                'b' => 'truc',
+            ],
+            $doc->toArray()
+        );
+    }
+}
diff --git a/tests/JSON/PointerTest.php b/tests/JSON/PointerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..035bebab208e3da9f9bd8613a1dff0eadc571c2d
--- /dev/null
+++ b/tests/JSON/PointerTest.php
@@ -0,0 +1,149 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of "irstea/api-metadata".
+ *
+ * Copyright (C) 2019 IRSTEA
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License and the GNU
+ * Lesser General Public License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+
+namespace Irstea\ApiMetadata\Tests\JSON;
+
+use Irstea\ApiMetadata\JSON\Pointer;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Class Test.
+ *
+ * @covers \Irstea\ApiMetadata\JSON\Pointer
+ */
+class PointerTest extends TestCase
+{
+    /**
+     * @param string $expected
+     * @param string $uri
+     *
+     * @dataProvider getFromTestCases
+     */
+    public function testFrom(string $expected, string $uri): void
+    {
+        $pointer = Pointer::from($uri);
+        self::assertEquals($expected, $pointer->toString());
+    }
+
+    /**
+     * @return array
+     */
+    public function getFromTestCases(): array
+    {
+        return [
+            ['http://example.com/#', 'http://example.com'],
+            ['http://example.com/#/', 'http://example.com/#/'],
+            ['http://example.com/#document', 'http://example.com#document'],
+            ['http://example.com/#/document', 'http://example.com#/document'],
+            ['http://example.com/#document/', 'http://example.com#document/'],
+            ['http://example.com/#/document/', 'http://example.com#/document/'],
+        ];
+    }
+
+    /**
+     * @param string $expected
+     * @param string $uri
+     *
+     * @dataProvider getGetDocumentRootTestCases
+     */
+    public function testGetDocumentRoot(string $expected, string $uri): void
+    {
+        $pointer = Pointer::from($uri);
+        self::assertEquals($expected, $pointer->getDocumentRoot()->toString());
+    }
+
+    /**
+     * @return array
+     */
+    public function getGetDocumentRootTestCases(): array
+    {
+        return [
+            ['http://example.com/#', 'http://example.com'],
+            ['http://example.com/#', 'http://example.com/#/'],
+            ['http://example.com/#', 'http://example.com#document'],
+            ['http://example.com/#', 'http://example.com#/document'],
+            ['http://example.com/#', 'http://example.com#document/'],
+            ['http://example.com/#', 'http://example.com#/document/bla/'],
+        ];
+    }
+
+    /**
+     * @param bool   $expected
+     * @param string $uri
+     *
+     * @dataProvider getInSameDocumentAsTestCases
+     */
+    public function testinSameDocumentAs(bool $expected, string $a, string $b): void
+    {
+        $pa = Pointer::from($a);
+        $pb = Pointer::from($b);
+        if ($expected) {
+            self::assertTrue($pa->inSameDocumentAs($pb));
+            self::assertTrue($pb->inSameDocumentAs($pa));
+        } else {
+            self::assertFalse($pa->inSameDocumentAs($pb));
+            self::assertFalse($pb->inSameDocumentAs($pa));
+        }
+    }
+
+    /**
+     * @return array
+     */
+    public function getInSameDocumentAsTestCases(): array
+    {
+        return [
+            [true, 'http://example.com/#', 'http://example.com'],
+            [true, 'http://example.com/#/', 'http://example.com/#/'],
+            [true, 'http://example.com/#document/bla', 'http://example.com#document'],
+            [false, 'http://example.org/#', 'http://example.com#'],
+            [false, 'http://example.org/#', 'http://example.com#document/'],
+        ];
+    }
+
+    /**
+     * @param string $expected
+     * @param string $base
+     * @param string $other
+     *
+     * @dataProvider getJoinTestCases
+     */
+    public function testJoin(string $expected, string $base, string $other): void
+    {
+        $basePointer = Pointer::from($base);
+        $otherPointer = Pointer::from($other);
+
+        $result = $basePointer->join($otherPointer);
+
+        self::assertEquals($expected, $result->toString());
+    }
+
+    /**
+     * @return array
+     */
+    public function getJoinTestCases(): array
+    {
+        return [
+            ['http://example.com/#/documents', 'http://example.com', '#/documents'],
+            ['http://example.com/#/documents', 'http://example.com#/bla', '#/documents'],
+            ['http://example.com/#/documents', 'http://example.com#/bla', '#documents'],
+            ['http://example.com/#/bla/documents', 'http://example.com#/bla/', '#documents'],
+        ];
+    }
+}