Completed
Push — master ( 26c529...f44c95 )
by Matt
02:23
created

Dereferencer::makeReferenceAbsolute()   B

Complexity

Conditions 6
Paths 11

Size

Total Lines 33
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
eloc 13
nc 11
nop 4
dl 0
loc 33
ccs 8
cts 8
cp 1
crap 6
rs 8.439
c 0
b 0
f 0
1
<?php
2
3
namespace League\JsonGuard;
4
5
use League\JsonGuard\Loaders\CurlWebLoader;
6
use League\JsonGuard\Loaders\FileGetContentsWebLoader;
7
use League\JsonGuard\Loaders\FileLoader;
8
9
/**
10
 * The Dereferencer resolves all external $refs and replaces
11
 * internal references with Reference objects.
12
 */
13
class Dereferencer
14
{
15
    /**
16
     * @var array
17
     */
18
    private $loaders;
19
20
    /**
21
     * Create a new Dereferencer.
22
     */
23 158
    public function __construct()
24
    {
25 158
        $this->registerFileLoader();
26 158
        $this->registerDefaultWebLoaders();
27 158
    }
28
29
    /**
30
     * Return the schema with all references resolved.
31
     *
32
     * @param string|object $schema Either a valid path like "http://json-schema.org/draft-03/schema#"
33
     *                              or the object resulting from a json_decode call.
34
     *
35
     * @return object
36
     */
37 158
    public function dereference($schema)
38
    {
39 158
        if (is_string($schema)) {
40 24
            $uri    = $schema;
41 24
            $schema = $this->loadExternalRef($uri);
42 24
            $schema = $this->resolveFragment($uri, $schema);
43
44 24
            return $this->crawl($schema, strip_fragment($uri));
45
        }
46
47 134
        return $this->crawl($schema);
48
    }
49
50
    /**
51
     * Register a Loader for the given prefix.
52
     *
53
     * @param Loader $loader
54
     * @param string $prefix
55
     */
56 122
    public function registerLoader(Loader $loader, $prefix)
57
    {
58 122
        $this->loaders[$prefix] = $loader;
59 122
    }
60
61
    /**
62
     * Get the loader for the given prefix.
63
     *
64
     * @param $prefix
65
     *
66
     * @return Loader
67
     * @throws \InvalidArgumentException
68
     */
69 38
    private function getLoader($prefix)
70
    {
71 38
        if (!array_key_exists($prefix, $this->loaders)) {
72
            throw new \InvalidArgumentException(sprintf('A loader is not registered for the prefix "%s"', $prefix));
73
        }
74
75 38
        return $this->loaders[$prefix];
76
    }
77
78
    /**
79
     * Register the default file loader.
80
     */
81 158
    private function registerFileLoader()
82
    {
83 158
        $this->loaders['file'] = new FileLoader();
84 158
    }
85
86
    /**
87
     * Register the default web loaders.  If the curl extension is loaded,
88
     * the CurlWebLoader will be used.  Otherwise the FileGetContentsWebLoader
89
     * will be used.  You can override this by registering your own loader
90
     * for the 'http' and 'https' protocols.
91
     */
92 158
    private function registerDefaultWebLoaders()
93
    {
94 158
        if (function_exists('curl_init')) {
95 158
            $this->loaders['https'] = new CurlWebLoader('https://');
96 158
            $this->loaders['http']  = new CurlWebLoader('http://');
97 158
        } else {
98
            $this->loaders['https'] = new FileGetContentsWebLoader('https://');
99
            $this->loaders['http']  = new FileGetContentsWebLoader('http://');
100
        }
101 158
    }
102
103
    /**
104
     * Crawl the schema and resolve any references.
105
     *
106
     * @param object      $schema
107
     * @param string|null $currentUri
108
     *
109
     * @return object
110
     */
111 158
    private function crawl($schema, $currentUri = null)
112
    {
113 158
        $references = $this->getReferences($schema);
114
115 158
        foreach ($references as $path => $ref) {
116
            // resolve
117 48
            if ($this->isExternalRef($ref)) {
118 20
                $resolved = $this->resolveExternalReference($schema, $path, $ref, $currentUri);
119 18
            } else {
120 42
                $resolved = new Reference($schema, $ref);
121
            }
122
123
            // handle any fragments
124 46
            $resolved = $this->resolveFragment($ref, $resolved);
125
126
            // merge
127 46
            $this->mergeResolvedReference($schema, $resolved, $path);
128 156
        }
129
130 156
        return $schema;
131
    }
132
133
    /**
134
     * Resolve the external reference at the given path.
135
     *
136
     * @param  object      $schema     The JSON Schema
137
     * @param  string      $path       A JSON pointer to the $ref's location in the schema.
138
     * @param  string      $ref        The JSON reference
139
     * @param  string|null $currentUri The URI of the schema, or null if the schema was loaded from an object.
140
     *
141
     * @return object                  The schema with the reference resolved.
142 20
     */
143
    private function resolveExternalReference($schema, $path, $ref, $currentUri)
144 20
    {
145 20
        $ref      = $this->makeReferenceAbsolute($schema, $path, $ref, $currentUri);
146
        $resolved = $this->loadExternalRef($ref);
147 18
148
        return $this->crawl($resolved, strip_fragment($ref));
149
    }
150
151
    /**
152
     * Merge the resolved reference with the schema, at the given path.
153
     *
154
     * @param  object $schema   The schema to merge the resolved reference with
155
     * @param  object $resolved The resolved schema
156
     * @param  string $path     A JSON pointer to the path where the reference should be merged.
157
     *
158 46
     * @return void
159
     */
160 46
    private function mergeResolvedReference($schema, $resolved, $path)
161
    {
162 16
        if ($path === '') {
163 8
            // Immediately resolve any root references.
164 8
            while ($resolved instanceof Reference) {
165 16
                $resolved = $resolved->resolve();
166 16
            }
167 44
            $this->mergeRootRef($schema, $resolved);
168 44
        } else {
169 44
            $pointer = new Pointer($schema);
170 44
            if ($pointer->has($path)) {
171
                $pointer->set($path, $resolved);
172 46
            }
173
        }
174
    }
175
176
    /**
177
     * Check if the reference contains a fragment and resolve
178
     * the pointer.  Otherwise returns the original schema.
179
     *
180
     * @param  string $ref
181
     * @param  object $schema
182 48
     *
183
     * @return object
184 48
     */
185 48
    private function resolveFragment($ref, $schema)
186 10
    {
187 10
        $fragment = parse_url($ref, PHP_URL_FRAGMENT);
188
        if ($this->isExternalRef($ref) && is_string($fragment)) {
189
            $pointer = new Pointer($schema);
190 48
191
            return $pointer->get($fragment);
192
        }
193
194
        return $schema;
195
    }
196
197
    /**
198
     * Recursively get all of the references for the given schema.
199
     * Returns an associative array like [path => reference].
200
     * Example:
201
     *
202
     * ['/properties' => '#/definitions/b']
203
     *
204
     * The path does NOT include the $ref.
205
     *
206
     * @param object $schema The schema to resolve references for.
207 158
     * @param string $path   The current schema path.
208
     *
209 158
     * @return array
210
     */
211 158
    private function getReferences($schema, $path = '')
212 40
    {
213
        $refs = [];
214
215 158
        if (!is_array($schema) && !is_object($schema)) {
216 158
            return $refs;
217 158
        }
218 48
219 48
        foreach ($schema as $attribute => $parameter) {
220 156
            switch (true) {
221 90
                case $this->isRef($attribute, $parameter):
222 90
                    $refs[$path] = $parameter;
223 154
                    break;
224 62
                case is_object($parameter):
225 58
                    $refs = array_merge($refs, $this->getReferences($parameter, $this->pathPush($path, $attribute)));
226 58
                    break;
227 58
                case is_array($parameter):
228 58
                    foreach ($parameter as $k => $v) {
229 62
                        $refs = array_merge(
230 62
                            $refs,
231
                            $this->getReferences($v, $this->pathPush($this->pathPush($path, $attribute), $k))
232 158
                        );
233
                    }
234 158
                    break;
235
            }
236
        }
237
238
        return $refs;
239
    }
240
241
    /**
242
     * Push a segment onto the given path.
243
     *
244
     * @param string $path
245 104
     * @param string $segment
246
     *
247 104
     * @return string
248
     */
249
    private function pathPush($path, $segment)
250
    {
251
        return $path . '/' . escape_pointer($segment);
252
    }
253
254
    /**
255
     * @param string $attribute
256 158
     * @param mixed  $attributeValue
257
     *
258 158
     * @return bool
259
     */
260
    private function isRef($attribute, $attributeValue)
261
    {
262
        return $attribute === '$ref' && is_string($attributeValue);
263
    }
264
265
    /**
266 50
     * @param string $value
267
     *
268 50
     * @return bool
269
     */
270
    private function isInternalRef($value)
271
    {
272
        return is_string($value) && substr($value, 0, 1) === '#';
273
    }
274
275
    /**
276 50
     * @param string $value
277
     *
278 50
     * @return bool
279
     */
280
    private function isExternalRef($value)
281
    {
282
        return !$this->isInternalRef($value);
283
    }
284
285
    /**
286
     * Load an external ref and return the JSON object.
287
     *
288 40
     * @param string $reference
289
     *
290 40
     * @return object
291 38
     */
292
    private function loadExternalRef($reference)
293 38
    {
294
        $this->validateAbsolutePath($reference);
295 38
        list($prefix, $path) = explode('://', $reference, 2);
296
297 38
        $loader = $this->getLoader($prefix);
298
299
        $schema = $loader->load($path);
300
301
        return $schema;
302
    }
303
304
    /**
305
     * Merge a resolved reference into the root of the given schema.
306 16
     *
307
     * @param object $rootSchema
308 16
     * @param object $resolvedRef
309 16
     */
310 16
    private function mergeRootRef($rootSchema, $resolvedRef)
311 16
    {
312 16
        $ref = '$ref';
313 16
        unset($rootSchema->$ref);
314
        foreach (get_object_vars($resolvedRef) as $prop => $value) {
315
            $rootSchema->$prop = $value;
316
        }
317
    }
318
319
    /**
320 40
     * Validate an absolute path is valid.
321
     *
322 40
     * @param string $path
323 2
     */
324 2
    private function validateAbsolutePath($path)
325
    {
326 2
        if (!preg_match('#^.+\:\/\/.*#', $path)) {
327
            throw new \InvalidArgumentException(
328 2
                sprintf(
329 2
                    'Your path  "%s" is missing a valid prefix.  ' .
330
                    'The schema path should start with a prefix i.e. "file://".',
331 38
                    $path
332
                )
333
            );
334
        }
335
    }
336
337
    /**
338
     * Take a relative reference, and prepend the id of the schema and any
339
     * sub schemas to get the absolute url.
340
     *
341 20
     * @param object      $schema
342
     * @param string      $path
343 20
     * @param string      $ref
344
     * @param string|null $currentUri
345
     *
346
     * @return string
347
     */
348
    private function makeReferenceAbsolute($schema, $path, $ref, $currentUri = null)
349
    {
350
        // If the reference is absolute, we can just return it without walking the schema.
351
        if (!is_relative_ref($ref)) {
352
            return $ref;
353
        }
354
355
        // Otherwise we need to walk the schema and resolve every ID against the most immediate parent scope.
356
        // Once we have determined the resolution scope at the path, we can finally resolve the reference.
357 20
358
        // 7.1.) The initial resolution scope of a schema is the URI of the schema itself, if any, or the empty URI
359 20
        // if the schema was not loaded from a URI.
360 14
361
        // 7.2.2.) The "id" keyword (or "id", for short) is used to alter the resolution scope. When an id is
362
        // encountered, an implementation MUST resolve this id against the most immediate parent scope. The resolved
363 12
        // URI will be the new resolution scope for this subschema and all its children, until another id is
364 12
        // encountered.
365
366 12
        $pointer     = new Pointer($schema);
367
        $currentPath = '';
368
        $scope = $currentUri ?: '';
369
        foreach (explode('/', $path) as $segment) {
370
            if (!empty($segment)) {
371
                $currentPath .= '/' . $segment;
372
            }
373
            if ($pointer->has($currentPath . '/id')) {
374
                $id = $pointer->get($currentPath . '/id');
375
                $scope = resolve_uri($id, $scope);
376
            }
377
        }
378 12
379
        return resolve_uri($ref, $scope);
380 12
    }
381
}
382