Skip to content

Commit 730c533

Browse files
authored
First implementation (#1)
1 parent 233c844 commit 730c533

14 files changed

+675
-1
lines changed

.github/workflows/ci.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: ci
2+
3+
on: [push, pull_request]
4+
5+
env:
6+
CI: true
7+
8+
jobs:
9+
test:
10+
runs-on: ${{ matrix.os }}
11+
12+
strategy:
13+
matrix:
14+
node-version: [10.x, 12.x, 14.x]
15+
os: [ubuntu-latest, windows-latest]
16+
17+
steps:
18+
- uses: actions/checkout@v2
19+
20+
- name: Use Node.js
21+
uses: actions/setup-node@v1
22+
with:
23+
node-version: ${{ matrix.node-version }}
24+
25+
- name: Install
26+
run: |
27+
npm install --ignore-scripts
28+
29+
- name: Run tests
30+
run: |
31+
npm test

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,7 @@ dist
102102

103103
# TernJS port file
104104
.tern-port
105+
106+
package-lock.json
107+
108+
out*.json

README.md

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,88 @@
11
# json-schema-resolver
2-
Resolve all you $refs
2+
3+
[![CI](https://github.com/Eomm/json-schema-resolver/workflows/ci/badge.svg)](https://github.com/Eomm/json-schema-resolver/actions?query=workflow%3Aci)
4+
[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](http://standardjs.com/)
5+
6+
Resolve all `$refs` in your [JSON schema](https://json-schema.org/specification.html)!
7+
8+
9+
## Install
10+
11+
```sh
12+
npm install json-schema-resolver
13+
```
14+
15+
This plugin support Node.js >= 10
16+
17+
## Usage
18+
19+
```js
20+
const RefResolver = require('json-schema-resolver')
21+
22+
const ref = RefResolver({
23+
clone: true // Clone the input schema without changing it. Default: false
24+
})
25+
26+
const inputSchema = {
27+
$id: 'http://example.com/SimplePerson',
28+
type: 'object',
29+
properties: {
30+
name: { type: 'string' },
31+
address: { $ref: 'relativeAddress#' },
32+
houses: { type: 'array', items: { $ref: 'relativeAddress#' } }
33+
}
34+
}
35+
36+
const addresSchema = {
37+
$id: 'relativeAddress', // Note: prefer always absolute URI like: http://mysite.com
38+
type: 'object',
39+
properties: {
40+
zip: { type: 'string' },
41+
city: { type: 'string' }
42+
}
43+
}
44+
45+
const singleSchema = ref.resolve(inputSchema, { externalSchemas: [addresSchema] })
46+
// mySchema is untouched thanks to clone:true
47+
```
48+
49+
`singleSchema` will be like:
50+
51+
```json
52+
{
53+
"$id": "http://example.com/SimplePerson",
54+
"type": "object",
55+
"properties": {
56+
"name": {
57+
"type": "string"
58+
},
59+
"address": {
60+
"$ref": "#/definitions/def-0"
61+
},
62+
"houses": {
63+
"type": "array",
64+
"items": {
65+
"$ref": "#/definitions/def-0"
66+
}
67+
}
68+
},
69+
"definitions": {
70+
"def-0": {
71+
"$id": "relativeAddress",
72+
"type": "object",
73+
"properties": {
74+
"zip": {
75+
"type": "string"
76+
},
77+
"city": {
78+
"type": "string"
79+
}
80+
}
81+
}
82+
}
83+
}
84+
```
85+
86+
## License
87+
88+
Licensed under [MIT](./LICENSE).

package.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"name": "json-schema-resolver",
3+
"version": "1.0.0",
4+
"description": "Resolve all your $refs",
5+
"main": "ref-resolver.js",
6+
"scripts": {
7+
"lint": "standard",
8+
"lint:fix": "standard --fix",
9+
"test": "npm run lint && tap test/**/*.test.js --cov"
10+
},
11+
"engines": {
12+
"node": ">=10"
13+
},
14+
"repository": {
15+
"type": "git",
16+
"url": "git+https://github.com/Eomm/json-schema-resolver.git"
17+
},
18+
"author": "Manuel Spigolon <[email protected]> (https://github.com/Eomm)",
19+
"license": "MIT",
20+
"bugs": {
21+
"url": "https://github.com/Eomm/json-schema-resolver/issues"
22+
},
23+
"homepage": "https://github.com/Eomm/json-schema-resolver#readme",
24+
"devDependencies": {
25+
"standard": "^14.3.3",
26+
"tap": "^12.7.0"
27+
},
28+
"dependencies": {
29+
"rfdc": "^1.1.4",
30+
"uri-js": "^4.2.2"
31+
},
32+
"keywords": [
33+
"json",
34+
"schema",
35+
"json-schema",
36+
"ref",
37+
"$ref"
38+
]
39+
}

ref-resolver.js

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
'use strict'
2+
3+
const URI = require('uri-js')
4+
const cloner = require('rfdc')({ proto: true, circles: false })
5+
const { EventEmitter } = require('events')
6+
const debug = require('debug')('json-schema-resolver')
7+
8+
const kIgnore = Symbol('json-schema-resolver.ignore') // untrack a schema (usually the root one)
9+
const kRefToDef = Symbol('json-schema-resolver.refToDef') // assign to an external json a new reference
10+
const kConsumed = Symbol('json-schema-resolver.consumed') // when an external json has been referenced
11+
12+
// ! Target: DRAFT-07
13+
// https://tools.ietf.org/html/draft-handrews-json-schema-01
14+
15+
// ? Open to DRAFT 08
16+
// https://json-schema.org/draft/2019-09/json-schema-core.html
17+
18+
const defaultOpts = {
19+
target: 'draft-07',
20+
clone: false
21+
}
22+
23+
const targetSupported = ['draft-07'] // TODO , 'draft-08'
24+
const targetCfg = {
25+
'draft-07': {
26+
def: 'definitions'
27+
},
28+
'draft-08': {
29+
def: '$defs'
30+
}
31+
}
32+
33+
// logic: https://json-schema.org/draft/2019-09/json-schema-core.html#rfc.appendix.B.1
34+
function jsonSchemaResolver (options) {
35+
const ee = new EventEmitter()
36+
const { clone, target } = Object.assign({}, defaultOpts, options)
37+
38+
if (!targetSupported.includes(target)) {
39+
throw new Error(`Unsupported JSON schema version ${target}`)
40+
}
41+
42+
const allIds = new Map()
43+
let rolling = 0
44+
ee.on('$id', collectIds)
45+
46+
const allRefs = []
47+
ee.on('$ref', collectRefs)
48+
49+
return {
50+
resolve
51+
}
52+
53+
function resolve (rootSchema, opts) {
54+
const { externalSchemas } = opts || {}
55+
56+
allIds.clear()
57+
allRefs.length = 0
58+
59+
if (clone) {
60+
rootSchema = cloner(rootSchema)
61+
}
62+
63+
// If present, the value for this keyword MUST be a string, and MUST
64+
// represent a valid URI-reference [RFC3986]. This value SHOULD be
65+
// normalized, and SHOULD NOT be an empty fragment <#> or an empty
66+
// string <>.
67+
const appUri = URI.parse(rootSchema.$id)
68+
appUri.fragment = undefined // remove fragment
69+
debug('Found app URI %o', appUri)
70+
71+
if (externalSchemas) {
72+
for (const es of externalSchemas) { mapIds(ee, appUri, es) }
73+
debug('Processed external schemas')
74+
}
75+
76+
const baseUri = URI.serialize(appUri) // canonical absolute-URI
77+
rootSchema.$id = baseUri // fix the schema $id value
78+
rootSchema[kIgnore] = true
79+
80+
mapIds(ee, appUri, rootSchema)
81+
debug('Processed root schema')
82+
83+
debug('Generating %d refs', allRefs.length)
84+
allRefs.forEach(({ baseUri, ref, json }) => {
85+
debug('Evaluating $ref %s', ref)
86+
if (ref[0] === '#') { return }
87+
88+
const evaluatedJson = allIds.get(baseUri)
89+
if (!evaluatedJson) {
90+
debug('External $ref %s not provided', ref)
91+
return
92+
}
93+
evaluatedJson[kConsumed] = true
94+
json.$ref = `#/definitions/${evaluatedJson[kRefToDef]}`
95+
})
96+
97+
const defKey = targetCfg[target].def
98+
allIds.forEach((json, baseUri) => {
99+
if (json[kConsumed] === true) {
100+
if (!rootSchema[defKey]) {
101+
rootSchema[defKey] = {}
102+
}
103+
104+
rootSchema[defKey][json[kRefToDef]] = json
105+
}
106+
})
107+
108+
return rootSchema
109+
}
110+
111+
function collectIds (json, baseUri, relative) {
112+
if (json[kIgnore]) { return }
113+
114+
const rel = (relative && URI.serialize(relative)) || ''
115+
const id = URI.serialize(baseUri) + rel
116+
if (!allIds.has(id)) {
117+
debug('Collected $id %s', id)
118+
json[kRefToDef] = `def-${rolling++}`
119+
allIds.set(id, json)
120+
} else {
121+
debug('WARN duplicated id %s .. IGNORED - ', id)
122+
}
123+
}
124+
125+
function collectRefs (json, baseUri, refVal) {
126+
const refUri = URI.parse(refVal)
127+
debug('Pre enqueue $ref %o', refUri)
128+
129+
// "same-document";
130+
// "relative";
131+
// "absolute";
132+
// "uri";
133+
if (refUri.reference === 'relative') {
134+
refUri.scheme = baseUri.scheme
135+
refUri.userinfo = baseUri.userinfo
136+
refUri.host = baseUri.host
137+
refUri.port = baseUri.port
138+
139+
const newBaseUri = Object.assign({}, baseUri)
140+
newBaseUri.path = refUri.path
141+
baseUri = newBaseUri
142+
}
143+
144+
const ref = URI.serialize(refUri)
145+
allRefs.push({
146+
baseUri: URI.serialize(baseUri),
147+
ref,
148+
json
149+
})
150+
debug('Enqueue $ref %s', ref)
151+
}
152+
}
153+
154+
/**
155+
*
156+
* @param {URI} baseUri
157+
* @param {*} json
158+
*/
159+
function mapIds (ee, baseUri, json) {
160+
if (!(json instanceof Object)) return
161+
162+
if (json.$id) {
163+
const $idUri = URI.parse(json.$id)
164+
let fragment = null
165+
166+
if ($idUri.reference === 'absolute') {
167+
// "$id": "http://example.com/root.json"
168+
baseUri = $idUri // a new baseURI for children
169+
} else if ($idUri.reference === 'relative') {
170+
// "$id": "other.json",
171+
const newBaseUri = Object.assign({}, baseUri)
172+
newBaseUri.path = $idUri.path
173+
newBaseUri.fragment = $idUri.fragment
174+
baseUri = newBaseUri
175+
} else {
176+
// { "$id": "#bar" }
177+
fragment = $idUri
178+
}
179+
ee.emit('$id', json, baseUri, fragment)
180+
}
181+
// else if (json.$anchor) {
182+
// TODO the $id should manage $anchor to support draft 08
183+
// }
184+
185+
const fields = Object.keys(json)
186+
for (const prop of fields) {
187+
if (prop === '$ref') {
188+
ee.emit('$ref', json, baseUri, json[prop])
189+
}
190+
mapIds(ee, baseUri, json[prop])
191+
}
192+
}
193+
194+
module.exports = jsonSchemaResolver

0 commit comments

Comments
 (0)