Completed
Pull Request — master (#41)
by Matt
03:01
created

Dereferencer::isInternalRef()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 2
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 4
ccs 2
cts 2
cp 1
crap 2
rs 10
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 156
    public function __construct()
24
    {
25 156
        $this->registerFileLoader();
26 156
        $this->registerDefaultWebLoaders();
27 156
    }
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 156
    public function dereference($schema)
38
    {
39 156
        $uri = is_string($schema) ? $schema : null;
40
41 156
        if ($uri) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $uri of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
42 22
            $schema = $this->loadExternalRef($uri);
43 22
            $schema = $this->resolveFragment($uri, $schema);
44 22
            $uri    = strip_fragment($uri);
45 22
        }
46
47 156
        return $this->crawl($schema, $uri);
0 ignored issues
show
Bug introduced by
It seems like $schema defined by parameter $schema on line 37 can also be of type string; however, League\JsonGuard\Dereferencer::crawl() does only seem to accept object, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
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 36
    private function getLoader($prefix)
70
    {
71 36
        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 36
        return $this->loaders[$prefix];
76
    }
77
78
    /**
79
     * Register the default file loader.
80
     */
81 156
    private function registerFileLoader()
82
    {
83 156
        $this->loaders['file'] = new FileLoader();
84 156
    }
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 156
    private function registerDefaultWebLoaders()
93
    {
94 156
        if (function_exists('curl_init')) {
95 156
            $this->loaders['https'] = new CurlWebLoader('https://');
96 156
            $this->loaders['http']  = new CurlWebLoader('http://');
97 156
        } else {
98
            $this->loaders['https'] = new FileGetContentsWebLoader('https://');
99
            $this->loaders['http']  = new FileGetContentsWebLoader('http://');
100
        }
101 156
    }
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 156
    private function crawl($schema, $currentUri = null)
112
    {
113 156
        $references = $this->getReferences($schema);
114
115 156
        foreach ($references as $path => $ref) {
116
            // resolve
117 46
            if ($this->isExternalRef($ref)) {
118 20
                $resolved = $this->resolveExternalReference($schema, $path, $ref, $currentUri);
119 18
            } else {
120 40
                $resolved = new Reference($schema, $ref);
121
            }
122
123
            // handle any fragments
124 44
            $resolved = $this->resolveFragment($ref, $resolved);
125
126
            // merge
127 44
            $this->mergeResolvedReference($schema, $resolved, $path);
128 154
        }
129
130 154
        return $schema;
131
    }
132
133
    /**
134
     * Resolve the external referefence 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
     * @return object                  The schema with the reference resolved.
141
     */
142 20
    private function resolveExternalReference($schema, $path, $ref, $currentUri)
143
    {
144 20
        $ref      = $this->makeReferenceAbsolute($schema, $path, $ref, $currentUri);
145 20
        $resolved = $this->loadExternalRef($ref);
146
147 18
        return $this->crawl($resolved, strip_fragment($ref));
148
    }
149
150
    /**
151
     * Merge the resolved reference with the schema, at the given path.
152
     *
153
     * @param  object $schema   The schema to merge the resolved reference with
154
     * @param  object $resolved The resolved schema
155
     * @param  string $path     A JSON pointer to the path where the reference should be merged.
156
     * @return void
157
     */
158 44
    private function mergeResolvedReference($schema, $resolved, $path)
159
    {
160 44
        if ($path === '') {
161
            // Immediately resolve any root references.
162 16
            while ($resolved instanceof Reference) {
163 8
                $resolved = $resolved->resolve();
164 8
            }
165 16
            $this->mergeRootRef($schema, $resolved);
166 16
        } else {
167 42
            $pointer = new Pointer($schema);
168 42
            if ($pointer->has($path)) {
169 42
                $pointer->set($path, $resolved);
170 42
            }
171
        }
172 44
    }
173
174
    /**
175
     * Check if the reference contains a fragment and resolve
176
     * the pointer.  Otherwise returns the original schema.
177
     *
178
     * @param  string $ref
179
     * @param  object $schema
180
     * @return object
181
     */
182 46
    private function resolveFragment($ref, $schema)
183
    {
184 46
        $fragment = parse_url($ref, PHP_URL_FRAGMENT);
185 46
        if ($this->isExternalRef($ref) && is_string($fragment)) {
186 8
            $pointer  = new Pointer($schema);
187 8
            return $pointer->get($fragment);
188
        }
189
190 46
        return $schema;
191
    }
192
193
    /**
194
     * Recursively get all of the references for the given schema.
195
     * Returns an associative array like [path => reference].
196
     * Example:
197
     *
198
     * ['/properties' => '#/definitions/b']
199
     *
200
     * The path does NOT include the $ref.
201
     *
202
     * @param object $schema The schema to resolve references for.
203
     * @param string $path   The current schema path.
204
     *
205
     * @return array
206
     */
207 156
    private function getReferences($schema, $path = '')
208
    {
209 156
        $refs = [];
210
211 156
        if (!is_array($schema) && !is_object($schema)) {
212 40
            return $refs;
213
        }
214
215 156
        foreach ($schema as $attribute => $parameter) {
216 156
            switch (true) {
217 156
                case $this->isRef($attribute, $parameter):
218 46
                    $refs[$path] = $parameter;
219 46
                    break;
220 154
                case is_object($parameter):
221 88
                    $refs = array_merge($refs, $this->getReferences($parameter, $this->pathPush($path, $attribute)));
222 88
                    break;
223 152
                case is_array($parameter):
224 62
                    foreach ($parameter as $k => $v) {
225 58
                        $refs = array_merge(
226 58
                            $refs,
227 58
                            $this->getReferences($v, $this->pathPush($this->pathPush($path, $attribute), $k))
228 58
                        );
229 62
                    }
230 62
                    break;
231
            }
232 156
        }
233
234 156
        return $refs;
235
    }
236
237
    /**
238
     * Push a segment onto the given path.
239
     *
240
     * @param string $path
241
     * @param string $segment
242
     *
243
     * @return string
244
     */
245 102
    private function pathPush($path, $segment)
246
    {
247 102
        return $path . '/' . escape_pointer($segment);
248
    }
249
250
    /**
251
     * @param string $attribute
252
     * @param mixed $attributeValue
253
     *
254
     * @return bool
255
     */
256 156
    private function isRef($attribute, $attributeValue)
257
    {
258 156
        return $attribute === '$ref' && is_string($attributeValue);
259
    }
260
261
    /**
262
     * @param string $value
263
     *
264
     * @return bool
265
     */
266 48
    private function isInternalRef($value)
267
    {
268 48
        return is_string($value) && substr($value, 0, 1) === '#';
269
    }
270
271
    /**
272
     * @param string $value
273
     *
274
     * @return bool
275
     */
276 48
    private function isExternalRef($value)
277
    {
278 48
        return !$this->isInternalRef($value);
279
    }
280
281
    /**
282
     * Load an external ref and return the JSON object.
283
     *
284
     * @param string $reference
285
     *
286
     * @return object
287
     */
288 38
    private function loadExternalRef($reference)
289
    {
290 38
        $this->validateAbsolutePath($reference);
291 36
        list($prefix, $path) = explode('://', $reference, 2);
292
293 36
        $loader = $this->getLoader($prefix);
294
295 36
        $schema = $loader->load($path);
296
297 36
        return $schema;
298
    }
299
300
    /**
301
     * Merge a resolved reference into the root of the given schema.
302
     *
303
     * @param object $rootSchema
304
     * @param object $resolvedRef
305
     */
306 16
    private function mergeRootRef($rootSchema, $resolvedRef)
307
    {
308 16
        $ref = '$ref';
309 16
        unset($rootSchema->$ref);
310 16
        foreach (get_object_vars($resolvedRef) as $prop => $value) {
311 16
            $rootSchema->$prop = $value;
312 16
        }
313 16
    }
314
315
    /**
316
     * Validate an absolute path is valid.
317
     *
318
     * @param string $path
319
     */
320 38
    private function validateAbsolutePath($path)
321
    {
322 38
        if (!preg_match('#^.+\:\/\/.*#', $path)) {
323 2
            throw new \InvalidArgumentException(
324 2
                sprintf(
325
                    'Your path  "%s" is missing a valid prefix.  ' .
326 2
                    'The schema path should start with a prefix i.e. "file://".',
327
                    $path
328 2
                )
329 2
            );
330
        }
331 36
    }
332
333
    /**
334
     * Determine if a reference is relative.
335
     * A reference is relative if it does not being with a prefix.
336
     *
337
     * @param string $ref
338
     *
339
     * @return bool
340
     */
341 20
    private function isRelativeRef($ref)
342
    {
343 20
        return !preg_match('#^.+\:\/\/.*#', $ref);
344
    }
345
346
    /**
347
     * Take a relative reference, and prepend the id of the schema and any
348
     * sub schemas to get the absolute url.
349
     *
350
     * @param object      $schema
351
     * @param string      $path
352
     * @param string      $ref
353
     * @param string|null $currentUri
354
     *
355
     * @return string
356
     */
357 20
    private function makeReferenceAbsolute($schema, $path, $ref, $currentUri = null)
358
    {
359 20
        if (!$this->isRelativeRef($ref)) {
360 14
            return $ref;
361
        }
362
363 12
        $scope = $this->getInitialResolutionScope($currentUri);
364 12
        $scope = $this->getResolvedResolutionScope($schema, $path, $scope);
365
366 12
        return $scope . $ref;
367
    }
368
369
    /**
370
     * Given the URI of the schema, get the intial resolution scope.
371
     *
372
     * If a URI is given, this method returns the URI without the schema filename or any reference fragment.
373
     * I.E, Given 'http://localhost:1234/album.json#/artist', this method would return `http://localhost:1234/`.
374
     *
375
     * @param  string|null $uri
376
     * @return string
377
     */
378 12
    private function getInitialResolutionScope($uri)
379
    {
380 12
        return $uri ? strip_fragment(str_replace(basename($uri), '', $uri)) : '';
381
    }
382
383
    /**
384
     * Given a JSON pointer, walk the path and resolve any found IDs against the parent scope.
385
     *
386
     * @param  object $schema      The JSON Schema object.
387
     * @param  string $path        A JSON Pointer to the path we are resolving the scope for.
388
     * @param  string $parentScope The initial resolution scope.  Usually the URI of the schema.
389
     * @return string              The resolved scope
390
     */
391 12
    private function getResolvedResolutionScope($schema, $path, $parentScope)
392
    {
393 12
        $pointer = new Pointer($schema);
394
395
        // When an id is encountered, an implementation MUST resolve this id against the most
396
        // immediate parent scope.  The resolved URI will be the new resolution scope
397
        // for this subschema and all its children, until another id is encountered.
398
399 12
        $currentPath = '';
400 12
        foreach (explode('/', $path) as $segment) {
401 12
            if (!empty($segment)) {
402 8
                $currentPath .= '/' . $segment;
403 8
            }
404 12
            if ($pointer->has($currentPath . '/id')) {
405 6
                $parentScope = $this->resolveIdAgainstParentScope($pointer->get($currentPath . '/id'), $parentScope);
406 6
            }
407 12
        }
408
409 12
        return $parentScope;
410
    }
411
412
    /**
413
     * Resolve an ID against the parent scope, and return the resolved scope.
414
     *
415
     * @param  string $id          The ID of the Schema.
416
     * @param  string $parentScope The parent scope of the ID.
417
     * @return string
418
     */
419 6
    private function resolveIdAgainstParentScope($id, $parentScope)
420
    {
421 6
        if ($this->isRelativeRef($id)) {
422
            // A relative reference is appended to the current scope.
423 4
            return $parentScope .= $id;
424
        }
425
426
        // An absolute reference replaces the scope entirely.
427 6
        return $id;
428
    }
429
}
430