-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.js
321 lines (260 loc) · 9.37 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
/* global window, fetch, jsyaml */
const FILE_LOOKUP_CACHE = {}
const YAML_TYPES = [
'text/x-yaml',
'text/yaml',
'text/yml',
'application/x-yaml',
'application/x-yml',
'application/yaml',
'application/yml'
]
/**
* simple object type recognition to recognize js objects from parsed documents
*/
const objectType = obj => {
const objectTypePattern = /\[object (.*)\]/
const stringifiedType = Object.prototype.toString.call(obj)
const match = stringifiedType.match(objectTypePattern)
if (match) {
return match[1]
} else {
throw new Error(`unknown type of object for "${obj}"`)
}
}
const isObject = item => objectType(item) === 'Object'
/**
* simple browser recognition. If window context is not given, we assume nodejs runtime.
*/
const isBrowser = () => { try { return !!window && !!window.location } catch (e) { return false } }
/**
* best effort method to derive origin.
* If not used in browser context no origin is returned,
* all passed URIs must be passed full qualified then.
*/
const origin = () => {
if (isBrowser()) {
const location = window.location
if (location.origin) {
return location.origin
}
return `${location.protocol}//${location.host}`
}
return undefined // no origin to derive
}
/**
* simple file extension check
*/
const extname = (path = '') => {
const dotSegments = path.split('.')
return dotSegments.length > 1 ? dotSegments[dotSegments.length - 1] : ''
}
/**
* library default function.
*/
export const skeme = async (pathOrUrl, options = { }) => {
let fetchClient, yamlClient
const fetchOptions = options.fetchOptions || {}
const useCache = !!options.cache || true // cache file lookups by default
const keepRefs = !!options.keepRefs || false
// try to bind mandatory dependencies
try {
fetchClient = options.fetch || fetch
} catch (e) {
if (e instanceof ReferenceError) {
console.error(`${e.message}. Please provide the dependency via "options" or via global context (e.g in "window")`)
}
throw e
}
// try to bind optional dependencies
try {
yamlClient = options.yaml || jsyaml
} catch (e) {
if (e instanceof ReferenceError) {
console.warn(`${e.message}. The dependency has not been provided. Functionality, like e.g. YAML parsing might not be given.
Please provide the dependency via "options" or via global context (e.g in "window") if you experience missing functionality.`)
}
}
const baseUrl = options.baseUrl || origin()
/**
* skeme supports json and yaml format of schema files.
* Here we try to deserialize what has been loaded before.
*/
const deserialize = async (res, url) => {
const fileExt = extname(url.pathname)
const contentType = res.headers.get('Content-Type')
// we check the content-type first, but we also guess for file extension of the path segment.
// so for files matching either of that criterias we deserialize to yaml.
if ((contentType && !!YAML_TYPES.find(t => contentType.includes(t))) || /^(yml|yaml)$/.test(fileExt)) {
if (yamlClient) {
const text = await res.text()
return yamlClient.load(text)
}
console.warn('content type indicates YAML content. No YAML parsing library has been provided.')
}
// for everything else we assume json
return res.json()
}
const cacheUrl = url => url.href.split('#')[0]
/**
* if caching is not disabled, search for cached
* results and fetch the file from server if not.
*/
const cachedFetch = async (url, options = {}) => {
const key = cacheUrl(url)
if (useCache && !!FILE_LOOKUP_CACHE[key]) {
const data = await FILE_LOOKUP_CACHE[key]
return [data, true]
}
const data = await fetchClient(url, options)
return [data, false]
}
/**
* update the cache if caching is enabled
*/
const updateCache = (key, data, hit) => {
if (useCache && !hit) {
// if not yet cached, write deserialized js-Objects to cache
FILE_LOOKUP_CACHE[key] = data
}
}
// loading schema files via HTTP
const load = async url => {
const defaults = { method: 'GET', mode: 'cors', credentials: 'include', redirect: 'follow' }
const [res, hit] = await cachedFetch(url, Object.assign({}, defaults, fetchOptions))
if (hit) {
// return deserialized entry from cache on cache HIT
return res
}
// On cache misses, we have an URL response here.
// So we need to check for success. 4xx is success, otherwise.
// Only 5xx errors are hitting the catch blocks.
if (!res.ok) {
throw new Error(`cannot resolve url ${url}, status: ${res.status}`)
}
// deserialize json or yaml responses
const deserialized = deserialize(res, url)
updateCache(cacheUrl(url), deserialized, hit)
return deserialized
}
const urlHashToPropertyList = rawHash => {
const hash = rawHash.indexOf('#') === 0 ? rawHash.slice(1) : rawHash // remove initial `#`
return hash.split('/').filter(prop => !!prop)
}
/**
* $ref links allow referencing deep nested properties
* by adding a property path as anchor / url hash.
* This method takes this hash and tries to resolve
* the targeted value.
*/
const getNestedPropertyByUrlHash = (item, hash) => {
try {
return urlHashToPropertyList(hash).reduce((nested, prop, index) => {
if (Object.prototype.hasOwnProperty.call(nested, prop)) {
return nested[prop]
}
throw new Error(`Property ${prop} is not contained in ${JSON.stringify(nested)}`)
}, item)
} catch (e) {
throw new Error(`The object
${JSON.stringify(item)}
cannot be resolved with the hash:
${e.message}
${hash}
`)
}
}
/**
* schema file loading and deserializing supporting json and yaml files.
* yaml files are recognized by content-type or file-extension (yml, yaml).
*/
const loadSchema = async (pathOrUrl, { baseUrl, resolveChain }) => {
// as we fetch the schema file by HTTP lookup we need to care for a full qualified URI.
// This also helps to check for circular dependencies later.
const url = new URL(pathOrUrl, baseUrl)
if (resolveChain.indexOf(url.toString()) > 0) {
throw new Error(`reference cycle for ${url.toString()}`)
}
const newChain = resolveChain.slice(0)
newChain.push(url.toString())
// const hash = url.hash
const result = await load(url)
return [result, url.toString(), newChain]
}
/**
* resolve objects with $ref reference
*/
const resolveRef = async (schema, { baseUrl, resolveChain }) => {
const ref = schema.$ref
const url = new URL(ref, baseUrl)
const hash = url.hash
const [fullData, newBaseUrl, newResolveChain] = await loadSchema(url, { baseUrl, resolveChain })
const data = hash ? getNestedPropertyByUrlHash(fullData, hash) : fullData
if (isObject(data)) {
const cleanedSchema = Object.keys(schema).filter(k => k !== '$ref').reduce((result, k) => {
result[k] = schema[k]
return result
}, {})
const merged = keepRefs
? Object.assign({}, cleanedSchema, data, { $deref: schema.$ref })
: Object.assign({}, cleanedSchema, data)
return [merged, newBaseUrl, newResolveChain]
}
return [data, newBaseUrl, newResolveChain]
}
/**
* resolves HashMaps / Objects provided as values in json schemas.
*/
const resolveObject = async (schema, { baseUrl, resolveChain }) => {
if (schema.$ref) {
const [data, newBaseUrl, newResolveChain] = await resolveRef(schema, { baseUrl, resolveChain })
return resolveSchema(data, { baseUrl: newBaseUrl, resolveChain: newResolveChain })
}
const properties = Object.keys(schema)
return properties.reduce(async (resultFuture, prop) => {
const result = await resultFuture
const value = schema[prop]
const [data, newBaseUrl, newResolveChain] = value && value.$ref
? await resolveRef(value, { baseUrl, resolveChain })
: [value, baseUrl, resolveChain]
const dereferenced = await resolveSchema(data, { baseUrl: newBaseUrl, resolveChain: newResolveChain })
result[prop] = dereferenced
return result
}, Promise.resolve({}))
}
/**
* resolves Arrays provided as values in json schemas.
*/
const resolveArray = async (schema, { baseUrl, resolveChain }) =>
Promise.all(schema.map(async data => resolveSchema(data, { baseUrl, resolveChain })))
/**
* Map of different resolver functions, to transform and resolve
* the content to an expanded object, not containing any unresolved `$ref`
* properties anymore.
* So this map does only contain collection objects, that might contain nested
* schema references.
*
* Keys refer to Object Types identified by `objectType(schema)`
*/
const schemaResolver = {
Object: resolveObject,
Array: resolveArray
}
/**
* takes in arbitrary "schemas" and tries to resolve
* the content to a plain js object.
*/
const resolveSchema = async (schema, { baseUrl, resolveChain }) => {
const type = objectType(schema)
const resolve = schemaResolver[type]
if (resolve) {
return resolve(schema, { baseUrl, resolveChain })
}
// if there is no specific resolver, return the plain result
return schema
}
const [data, newBaseUrl, resolveChain] = await loadSchema(pathOrUrl, { baseUrl, resolveChain: [] })
return resolveSchema(data, { baseUrl: newBaseUrl, resolveChain })
}
// default module function
export default skeme