@modernpoacher/halacious 7.0.3 → 7.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -17
- package/babel.config.cjs +44 -0
- package/package.json +9 -2
- package/src/index.d.mts +205 -20
- package/src/plugin.mjs +765 -671
- package/src/representation.mjs +282 -108
- package/src/utils/getFieldValueByPath.mjs +20 -0
- package/src/utils/getMediaType.mjs +15 -0
- package/src/utils/getTemplateContext.mjs +32 -0
- package/src/utils.mjs +382 -0
- package/views/namespaces.jade +11 -0
- package/views/rel.jade +7 -0
- package/index.d.mts +0 -7
package/src/plugin.mjs
CHANGED
|
@@ -1,59 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import('@modernpoacher/halacious').HalaciousTypes.Representation} Representation
|
|
3
|
+
*/
|
|
4
|
+
|
|
1
5
|
import joi from 'joi'
|
|
2
6
|
import * as boom from '@hapi/boom'
|
|
3
7
|
import * as hoek from '@hapi/hoek'
|
|
4
|
-
import
|
|
5
|
-
|
|
8
|
+
import {
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync
|
|
11
|
+
} from 'node:fs'
|
|
6
12
|
import path from 'node:path'
|
|
13
|
+
import util from 'node:util'
|
|
14
|
+
import url from 'node:url'
|
|
15
|
+
import _ from 'lodash'
|
|
7
16
|
import pug from 'pug'
|
|
8
|
-
import util from 'util'
|
|
9
17
|
import async from 'async'
|
|
10
18
|
import {
|
|
11
19
|
marked
|
|
12
20
|
} from 'marked'
|
|
13
|
-
import
|
|
21
|
+
import debug from 'debug'
|
|
14
22
|
import URI from 'urijs'
|
|
15
23
|
import * as urlTemplate from 'url-template'
|
|
16
|
-
import Negotiator from 'negotiator'
|
|
17
24
|
import URITemplate from 'urijs/src/URITemplate.js'
|
|
18
|
-
import IAM from '#where-am-i'
|
|
19
|
-
import RepresentationFactory from './representation.mjs'
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const REG = /{([^{}]+)}|([^{}]+)/g
|
|
24
|
-
|
|
25
|
-
const PKG = JSON.parse(fs.readFileSync(path.join(IAM, './package.json')))
|
|
26
|
-
|
|
27
|
-
function reach (object, path) {
|
|
28
|
-
const parts = path ? path.split('.') : []
|
|
26
|
+
import PKG from '#package' assert { type: 'json' }
|
|
27
|
+
import IAM from '#where-am-i'
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
object = object[parts[i]]
|
|
32
|
-
}
|
|
29
|
+
import RepresentationFactory from './representation.mjs'
|
|
33
30
|
|
|
34
|
-
|
|
31
|
+
import {
|
|
32
|
+
getRepresentationEntity,
|
|
33
|
+
getRepresentationSelfHref,
|
|
34
|
+
getRepresentationRequest,
|
|
35
|
+
getRepresentationRequestPath,
|
|
36
|
+
getRouteSettingsPluginsHal,
|
|
37
|
+
getRouteSettingsPluginsHalApi,
|
|
38
|
+
getRouteSettingsPluginsHalQuery,
|
|
39
|
+
getRouteSettingsIsInternal,
|
|
40
|
+
getRequestRoute,
|
|
41
|
+
getRequestHeadersAccept,
|
|
42
|
+
getRequestServer,
|
|
43
|
+
getRequestResponse,
|
|
44
|
+
getRequestResponseSource,
|
|
45
|
+
getRequestResponseHeadersLocation,
|
|
46
|
+
getResponseVariety,
|
|
47
|
+
getResponseStatusCode,
|
|
48
|
+
isResponseStatusCodeInSuccessRange,
|
|
49
|
+
getPath,
|
|
50
|
+
hasPath,
|
|
51
|
+
getName,
|
|
52
|
+
hasName,
|
|
53
|
+
getPrefix,
|
|
54
|
+
getRels,
|
|
55
|
+
hasRels,
|
|
56
|
+
getAbsolute,
|
|
57
|
+
getQuery,
|
|
58
|
+
getIgnore,
|
|
59
|
+
getMediaType,
|
|
60
|
+
getTemplateContext,
|
|
61
|
+
isRelativePath
|
|
62
|
+
} from './utils.mjs'
|
|
63
|
+
|
|
64
|
+
function sortByName ({ name: alpha }, { name: omega }) {
|
|
65
|
+
return (
|
|
66
|
+
alpha.localeCompare(omega)
|
|
67
|
+
)
|
|
35
68
|
}
|
|
36
69
|
|
|
37
|
-
|
|
38
|
-
* evaluates and flattens deep expressions (e.g. '/{foo.a.b}') into a single level context object: {'foo.a.b': value}
|
|
39
|
-
* so that it may be used by url-template library
|
|
40
|
-
* @param template
|
|
41
|
-
* @param ctx
|
|
42
|
-
* @return {{}}
|
|
43
|
-
*/
|
|
44
|
-
function flattenContext (template, ctx) {
|
|
45
|
-
let arr
|
|
46
|
-
const result = {}
|
|
70
|
+
const HAL_MIME_TYPE = 'application/hal+json'
|
|
47
71
|
|
|
48
|
-
|
|
49
|
-
if (arr[1]) {
|
|
50
|
-
const value = reach(ctx, arr[1])
|
|
51
|
-
result[arr[1]] = value && value.toString()
|
|
52
|
-
}
|
|
53
|
-
}
|
|
72
|
+
const log = debug('@modernpoacher/halacious')
|
|
54
73
|
|
|
55
|
-
|
|
56
|
-
}
|
|
74
|
+
log('`@modernpoacher/halacious` is awake')
|
|
57
75
|
|
|
58
76
|
const optionsSchema = joi.object({
|
|
59
77
|
absolute: joi.boolean().default(false),
|
|
@@ -80,268 +98,164 @@ const optionsSchema = joi.object({
|
|
|
80
98
|
marked: joi.object().default({})
|
|
81
99
|
})
|
|
82
100
|
|
|
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
|
-
const internals = {}
|
|
108
|
-
|
|
109
|
-
// for tracking down namespaces
|
|
110
|
-
internals.byName = {}
|
|
111
|
-
internals.byPrefix = {}
|
|
112
|
-
|
|
113
|
-
// valid rel options
|
|
114
|
-
internals.relSchema = joi.object({
|
|
115
|
-
// the rel name, will default to file's basename if available
|
|
116
|
-
name: joi.string().required(),
|
|
117
|
-
|
|
118
|
-
// a path to the rel's documentation in html or markdown
|
|
119
|
-
file: joi.string().optional(),
|
|
120
|
-
|
|
121
|
-
// a short textual description
|
|
122
|
-
description: joi.string().optional(),
|
|
123
|
-
|
|
124
|
-
// returns the qualified name of the rel (including the namespace)
|
|
125
|
-
qname: joi
|
|
126
|
-
.function()
|
|
127
|
-
.optional()
|
|
128
|
-
.default(function () {
|
|
129
|
-
return function () {
|
|
130
|
-
return this.namespace
|
|
131
|
-
? util.format('%s:%s', this.namespace.prefix, this.name)
|
|
132
|
-
: this.name
|
|
133
|
-
}
|
|
134
|
-
})
|
|
135
|
-
}).unknown()
|
|
136
|
-
|
|
137
|
-
// valid namespace options
|
|
138
|
-
internals.nsSchema = joi.object({
|
|
139
|
-
// the namespace name, will default to dir basename if available
|
|
140
|
-
name: joi.string().required(),
|
|
141
|
-
|
|
142
|
-
// a path to a directory containing rel descriptors. all rels will automatically be added
|
|
143
|
-
dir: joi.string().optional(),
|
|
144
|
-
|
|
145
|
-
// the namespace prefix for shorthand rel addressing (e.g. 'prefix:relname')
|
|
146
|
-
prefix: joi.string().optional().default(joi.ref('name')),
|
|
147
|
-
|
|
148
|
-
// a short description
|
|
149
|
-
description: joi.string().optional(),
|
|
150
|
-
|
|
151
|
-
// a map of rel objects, keyed by name
|
|
152
|
-
rels: joi.object().optional(),
|
|
101
|
+
// valid rel options
|
|
102
|
+
const relSchema = joi.object({
|
|
103
|
+
// the rel name, will default to file's basename if available
|
|
104
|
+
name: joi.string().required(),
|
|
105
|
+
|
|
106
|
+
// a path to the rel's documentation in html or markdown
|
|
107
|
+
file: joi.string().optional(),
|
|
108
|
+
|
|
109
|
+
// a short textual description
|
|
110
|
+
description: joi.string().optional(),
|
|
111
|
+
|
|
112
|
+
// returns the qualified name of the rel (including the namespace)
|
|
113
|
+
qname: joi
|
|
114
|
+
.function()
|
|
115
|
+
.optional()
|
|
116
|
+
.default(function getQName () {
|
|
117
|
+
return function qname () {
|
|
118
|
+
return this.namespace
|
|
119
|
+
? util.format('%s:%s', getPrefix(this.namespace), this.name)
|
|
120
|
+
: this.name
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
}).unknown()
|
|
153
124
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
.default(function () {
|
|
159
|
-
return function (rel) {
|
|
160
|
-
this.rels = this.rels || {}
|
|
125
|
+
// valid namespace options
|
|
126
|
+
const namespaceSchema = joi.object({
|
|
127
|
+
// the namespace name, will default to dir basename if available
|
|
128
|
+
name: joi.string().required(),
|
|
161
129
|
|
|
162
|
-
|
|
130
|
+
// a path to a directory containing rel descriptors. all rels will automatically be added
|
|
131
|
+
dir: joi.string().optional(),
|
|
163
132
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
(rel.file && path.basename(rel.file, path.extname(rel.file)))
|
|
133
|
+
// the namespace prefix for shorthand rel addressing (e.g. 'prefix:relname')
|
|
134
|
+
prefix: joi.string().optional().default(joi.ref('name')),
|
|
167
135
|
|
|
168
|
-
|
|
169
|
-
|
|
136
|
+
// a short description
|
|
137
|
+
description: joi.string().optional(),
|
|
170
138
|
|
|
171
|
-
|
|
139
|
+
// a map of rel objects, keyed by name
|
|
140
|
+
rels: joi.object().optional(),
|
|
172
141
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
scanDirectory: joi
|
|
181
|
-
.function()
|
|
182
|
-
.optional()
|
|
183
|
-
.default(function () {
|
|
184
|
-
return function (directory) {
|
|
185
|
-
const files = fs.readdirSync(directory)
|
|
186
|
-
files.forEach(function (file) {
|
|
187
|
-
this.rel({ file: path.join(directory, file) })
|
|
188
|
-
}, this)
|
|
189
|
-
|
|
190
|
-
return this
|
|
191
|
-
}
|
|
192
|
-
})
|
|
193
|
-
})
|
|
142
|
+
// validates and adds a rel to the namespace
|
|
143
|
+
rel: joi
|
|
144
|
+
.function()
|
|
145
|
+
.optional()
|
|
146
|
+
.default(function getRel () {
|
|
147
|
+
return function rel (rel) {
|
|
148
|
+
if (!hasRels(this)) this.rels = {}
|
|
194
149
|
|
|
195
|
-
|
|
196
|
-
return _.get(request.route.settings, 'plugins.hal', true)
|
|
197
|
-
}
|
|
150
|
+
if (_.isString(rel)) rel = { name: rel }
|
|
198
151
|
|
|
199
|
-
|
|
200
|
-
* Returns a list of all registered namespaces sorted by name
|
|
201
|
-
* @return {*}
|
|
202
|
-
*/
|
|
203
|
-
internals.namespaces = function () {
|
|
204
|
-
return _.sortBy(_.values(internals.byName), 'name')
|
|
205
|
-
}
|
|
152
|
+
if (!hasName(rel)) rel.name = (rel.file && path.basename(rel.file, path.extname(rel.file)))
|
|
206
153
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
* @param namespace the namespace config
|
|
210
|
-
* @return {*} a new namespace object
|
|
211
|
-
*/
|
|
212
|
-
internals.namespaces.add = function (namespace) {
|
|
213
|
-
// if only dir is specified
|
|
214
|
-
namespace.name =
|
|
215
|
-
namespace.name || (namespace.dir && path.basename(namespace.dir))
|
|
154
|
+
const { error, value } = relSchema.validate(rel)
|
|
155
|
+
if (error) throw error
|
|
216
156
|
|
|
217
|
-
|
|
218
|
-
const { error, value } = internals.nsSchema.validate(namespace)
|
|
157
|
+
rel = value
|
|
219
158
|
|
|
220
|
-
|
|
159
|
+
const relName = getName(rel)
|
|
160
|
+
const rels = getRels(this)
|
|
221
161
|
|
|
222
|
-
|
|
162
|
+
Reflect.set(rels, relName, rel)
|
|
223
163
|
|
|
224
|
-
|
|
225
|
-
namespace.rels = {}
|
|
164
|
+
rel.namespace = this
|
|
226
165
|
|
|
227
|
-
|
|
228
|
-
namespace.scanDirectory(namespace.dir)
|
|
166
|
+
return this
|
|
229
167
|
}
|
|
168
|
+
}),
|
|
169
|
+
|
|
170
|
+
// synchronously scans a directory for rel descriptors and adds them to the namespace
|
|
171
|
+
scanDirectory: joi
|
|
172
|
+
.function()
|
|
173
|
+
.optional()
|
|
174
|
+
.default(function getScanDirectory () {
|
|
175
|
+
return function scanDirectory (directory) {
|
|
176
|
+
const files = readdirSync(directory)
|
|
177
|
+
|
|
178
|
+
files
|
|
179
|
+
.forEach((file) => {
|
|
180
|
+
this.rel({ file: path.join(directory, file) })
|
|
181
|
+
})
|
|
230
182
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return namespace
|
|
236
|
-
}
|
|
183
|
+
return this
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
})
|
|
237
187
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
188
|
+
// see http://tools.ietf.org/html/draft-kelly-json-hal-06#section-8.2
|
|
189
|
+
const linkSchema = joi.object({
|
|
190
|
+
href: joi.alternatives([joi.string(), joi.function()]).required(),
|
|
191
|
+
templated: joi.boolean().optional(),
|
|
192
|
+
title: joi.string().optional(),
|
|
193
|
+
type: joi.string().optional(),
|
|
194
|
+
deprecation: joi.string().optional(),
|
|
195
|
+
name: joi.string().optional(),
|
|
196
|
+
profile: joi.string().optional(),
|
|
197
|
+
hreflang: joi.string().optional()
|
|
198
|
+
})
|
|
244
199
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Wraps callback functions
|
|
202
|
+
*
|
|
203
|
+
* @param {(err: Error | null, any) => void} done
|
|
204
|
+
* @return {(err: Error | null, any) => void}
|
|
205
|
+
*/
|
|
206
|
+
function wrap (done, representation) {
|
|
207
|
+
return function wrap (err, result) {
|
|
208
|
+
if (err instanceof Error) {
|
|
209
|
+
done(err)
|
|
210
|
+
} else {
|
|
211
|
+
done(null, result || representation)
|
|
255
212
|
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
256
215
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Registers plugin routes and an "api" object with the hapi server
|
|
218
|
+
*
|
|
219
|
+
* @param server
|
|
220
|
+
* @param opts
|
|
221
|
+
* @param {(err: Error | null, any) => void} done
|
|
222
|
+
*/
|
|
223
|
+
export const plugin = {
|
|
224
|
+
pkg: PKG,
|
|
265
225
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
* @return {*}
|
|
269
|
-
*/
|
|
270
|
-
internals.rels = function () {
|
|
271
|
-
let rels = []
|
|
272
|
-
_.values(internals.byName).forEach((ns) => {
|
|
273
|
-
rels = rels.concat(_.values(ns.rels) || [])
|
|
274
|
-
})
|
|
275
|
-
return _.sortBy(rels, 'name')
|
|
276
|
-
}
|
|
226
|
+
async register (server, opts) {
|
|
227
|
+
const { error, value: settings } = optionsSchema.validate(opts)
|
|
277
228
|
|
|
278
|
-
|
|
279
|
-
* Adds a new rel configuration to a namespace
|
|
280
|
-
* @param {String} namespace the namespace name
|
|
281
|
-
* @param rel the rel configuration
|
|
282
|
-
* @return the new rel
|
|
283
|
-
*/
|
|
284
|
-
internals.rels.add = function (namespace, rel) {
|
|
285
|
-
const ns = internals.byName[namespace]
|
|
286
|
-
if (!ns) throw new Error(`Invalid namespace ${namespace}`)
|
|
287
|
-
ns.rel(rel)
|
|
288
|
-
return ns.rels[rel.name]
|
|
289
|
-
}
|
|
229
|
+
if (error) throw error
|
|
290
230
|
|
|
291
|
-
|
|
292
|
-
* Looks up a rel under a given namespace
|
|
293
|
-
* @param {String} namespace the namespace name
|
|
294
|
-
* @param {String} name the rel name
|
|
295
|
-
* @return {*} the rel or undefined if not found
|
|
296
|
-
*/
|
|
297
|
-
internals.rel = function (namespace, name) {
|
|
298
|
-
let parts, ns, rel
|
|
231
|
+
marked.setOptions(settings.marked)
|
|
299
232
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
parts = namespace.split(':')
|
|
304
|
-
ns = internals.byPrefix[parts[0]]
|
|
305
|
-
name = parts[1]
|
|
306
|
-
}
|
|
307
|
-
} else {
|
|
308
|
-
ns = internals.byName[namespace]
|
|
309
|
-
}
|
|
233
|
+
const selection = settings.apiServerLabel
|
|
234
|
+
? server.select(settings.apiServerLabel)
|
|
235
|
+
: server
|
|
310
236
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if (ns.rels[name]) {
|
|
314
|
-
// rel has been defined
|
|
315
|
-
rel = ns.rels[name]
|
|
316
|
-
} else if (!settings.strict) {
|
|
317
|
-
// lazily create the rel
|
|
318
|
-
ns.rel({ name })
|
|
319
|
-
rel = ns.rels[name]
|
|
320
|
-
} else {
|
|
321
|
-
// could be a typo, fail fast to let the developer know
|
|
322
|
-
throw new Error(`No such rel: "${namespace}"`)
|
|
323
|
-
}
|
|
324
|
-
} else {
|
|
325
|
-
// could be globally qualified (e.g. 'self')
|
|
326
|
-
const { error, value } = internals.relSchema.validate({ name: namespace })
|
|
327
|
-
if (error) throw error
|
|
237
|
+
const byName = new Map()
|
|
238
|
+
const byPrefix = new Map()
|
|
328
239
|
|
|
329
|
-
|
|
330
|
-
|
|
240
|
+
// keeps found routes in a cache
|
|
241
|
+
const routeCache = new Map()
|
|
331
242
|
|
|
332
|
-
|
|
243
|
+
const internals = {
|
|
244
|
+
byName,
|
|
245
|
+
byPrefix
|
|
333
246
|
}
|
|
334
247
|
|
|
335
248
|
/**
|
|
336
249
|
* Route handler for /rels
|
|
250
|
+
*
|
|
337
251
|
* @type {{handler: handler}}
|
|
338
252
|
*/
|
|
339
|
-
|
|
253
|
+
function getRelsRouteConfig (auth) {
|
|
340
254
|
return {
|
|
341
|
-
auth
|
|
342
|
-
handler (
|
|
255
|
+
auth,
|
|
256
|
+
handler ({ path }, h) {
|
|
343
257
|
return h.view('namespaces', {
|
|
344
|
-
path
|
|
258
|
+
path,
|
|
345
259
|
namespaces: internals.namespaces()
|
|
346
260
|
})
|
|
347
261
|
}
|
|
@@ -350,432 +264,322 @@ export const plugin = {
|
|
|
350
264
|
|
|
351
265
|
/**
|
|
352
266
|
* Route handler for /rels/{namespace}/{rel}
|
|
267
|
+
*
|
|
353
268
|
* @type {{handler: handler}}
|
|
354
269
|
*/
|
|
355
|
-
|
|
270
|
+
function getRelRouteConfig (auth) {
|
|
356
271
|
return {
|
|
357
|
-
auth
|
|
358
|
-
handler (
|
|
359
|
-
const rel = internals.rel(
|
|
272
|
+
auth,
|
|
273
|
+
handler ({ params: { namespace: namespaceName, rel: relName } }, h) {
|
|
274
|
+
const rel = internals.rel(namespaceName, relName)
|
|
275
|
+
|
|
360
276
|
if (!rel) {
|
|
361
|
-
|
|
277
|
+
log(`Invalid rel "${namespaceName}" ("${relName}")`)
|
|
362
278
|
throw boom.notFound()
|
|
363
279
|
}
|
|
364
280
|
|
|
365
|
-
|
|
366
|
-
|
|
281
|
+
const {
|
|
282
|
+
file
|
|
283
|
+
} = rel
|
|
284
|
+
|
|
285
|
+
if (file) {
|
|
286
|
+
const buffer = readFileSync(file)
|
|
287
|
+
|
|
367
288
|
if (settings.relsTemplate) {
|
|
368
289
|
return h.view('rel', {
|
|
369
290
|
rel,
|
|
370
|
-
relData: marked(
|
|
291
|
+
relData: marked(buffer.toString())
|
|
371
292
|
})
|
|
372
293
|
}
|
|
373
|
-
return marked(data.toString())
|
|
374
|
-
}
|
|
375
|
-
return h.view('rel', { rel })
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// see http://tools.ietf.org/html/draft-kelly-json-hal-06#section-8.2
|
|
381
|
-
internals.linkSchema = joi.object({
|
|
382
|
-
href: joi.alternatives([joi.string(), joi.function()]).required(),
|
|
383
|
-
templated: joi.boolean().optional(),
|
|
384
|
-
title: joi.string().optional(),
|
|
385
|
-
type: joi.string().optional(),
|
|
386
|
-
deprecation: joi.string().optional(),
|
|
387
|
-
name: joi.string().optional(),
|
|
388
|
-
profile: joi.string().optional(),
|
|
389
|
-
hreflang: joi.string().optional()
|
|
390
|
-
})
|
|
391
|
-
|
|
392
|
-
internals.isRelativePath = function (path) {
|
|
393
|
-
return (
|
|
394
|
-
path &&
|
|
395
|
-
(path.substring(0, 2) === './' || path.substring(0, 3) === '../')
|
|
396
|
-
)
|
|
397
|
-
}
|
|
398
294
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
* @param link
|
|
402
|
-
* @param relativeTo
|
|
403
|
-
*/
|
|
404
|
-
internals.link = function (link, relativeTo) {
|
|
405
|
-
relativeTo = relativeTo && relativeTo.split('?')[0]
|
|
406
|
-
link =
|
|
407
|
-
_.isFunction(link) || _.isString(link)
|
|
408
|
-
? { href: link }
|
|
409
|
-
: hoek.clone(link)
|
|
410
|
-
const { error, value } = internals.linkSchema.validate(link)
|
|
411
|
-
|
|
412
|
-
if (error) throw error
|
|
413
|
-
link = value
|
|
295
|
+
return marked(buffer.toString())
|
|
296
|
+
}
|
|
414
297
|
|
|
415
|
-
|
|
416
|
-
relativeTo &&
|
|
417
|
-
(internals.isRelativePath(link.href) || settings.absolute)
|
|
418
|
-
) {
|
|
419
|
-
try {
|
|
420
|
-
link.href = new URI(link.href)
|
|
421
|
-
.absoluteTo(`${relativeTo}/`)
|
|
422
|
-
.toString()
|
|
423
|
-
} catch (e) {
|
|
424
|
-
console.warn(e)
|
|
425
|
-
return link
|
|
298
|
+
return h.view('rel', { rel })
|
|
426
299
|
}
|
|
427
300
|
}
|
|
428
|
-
return link
|
|
429
301
|
}
|
|
430
302
|
|
|
431
|
-
// keeps found routes in a cache
|
|
432
|
-
internals.routeCache = {}
|
|
433
|
-
|
|
434
303
|
/**
|
|
435
304
|
* Locates a named route. This feature may not belong here
|
|
305
|
+
*
|
|
436
306
|
* @param routeName
|
|
437
307
|
* @return {*}
|
|
438
308
|
*/
|
|
439
|
-
|
|
440
|
-
if (
|
|
441
|
-
|
|
309
|
+
function getRouteFromRouteCache (routeName) {
|
|
310
|
+
if (!routeCache.has(routeName)) {
|
|
311
|
+
routeCache.set(routeName, (
|
|
312
|
+
server
|
|
313
|
+
.table()
|
|
314
|
+
.find((route) => (
|
|
315
|
+
getName(getRouteSettingsPluginsHal(route)) === routeName
|
|
316
|
+
))
|
|
317
|
+
))
|
|
442
318
|
}
|
|
443
319
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
routes.forEach((route) => {
|
|
447
|
-
if (
|
|
448
|
-
route.settings.plugins.hal &&
|
|
449
|
-
route.settings.plugins.hal.name === routeName
|
|
450
|
-
) {
|
|
451
|
-
internals.routeCache[routeName] = route
|
|
452
|
-
}
|
|
453
|
-
})
|
|
454
|
-
|
|
455
|
-
return internals.routeCache[routeName]
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Locates a named route and expands templated parameters
|
|
460
|
-
* @param routeId
|
|
461
|
-
* @param params
|
|
462
|
-
* @return String the expanded path to the named route
|
|
463
|
-
*/
|
|
464
|
-
internals.route = function (routeId, params) {
|
|
465
|
-
const route = server.lookup(routeId) || internals.locateRoute(routeId)
|
|
466
|
-
if (!route) throw new Error(`No route found with id or name ${routeId}`)
|
|
467
|
-
const href = _.template(route.path, {
|
|
468
|
-
interpolate: /{([\s\S]+?)(?:\?|\*\d*)??}/g
|
|
469
|
-
})(
|
|
470
|
-
_.mapValues(params, (val) =>
|
|
471
|
-
typeof val !== 'object' ? encodeURIComponent(val) : val
|
|
472
|
-
)
|
|
320
|
+
return (
|
|
321
|
+
routeCache.get(routeName)
|
|
473
322
|
)
|
|
474
|
-
const query = hoek.reach(route.settings, 'plugins.hal.query')
|
|
475
|
-
return query ? href + query : href
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Returns the documentation link to a namespace
|
|
480
|
-
* @param request
|
|
481
|
-
* @param namespace
|
|
482
|
-
* @return {*}
|
|
483
|
-
*/
|
|
484
|
-
internals.namespaceUrl = function (request, namespace) {
|
|
485
|
-
const path = [settings.relsPath, namespace.name].join('/')
|
|
486
|
-
|
|
487
|
-
if (settings.absolute) {
|
|
488
|
-
return internals.buildUrl(request, path)
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
return path
|
|
492
323
|
}
|
|
493
324
|
|
|
494
325
|
/**
|
|
495
326
|
* Configures a representation with parameters specified by a hapi route config. The configuration object may
|
|
496
327
|
* include 'links', 'embedded', and 'prepare' properties.
|
|
497
|
-
*
|
|
498
|
-
* @param {
|
|
499
|
-
* @param
|
|
328
|
+
*
|
|
329
|
+
* @param {Record<string, any>} config the config object
|
|
330
|
+
* @param {Representation} representation the representation to configure
|
|
331
|
+
* @param {(err: Error | null, any) => void} done
|
|
500
332
|
*/
|
|
501
|
-
|
|
502
|
-
rep,
|
|
333
|
+
function configureRepresentation (
|
|
503
334
|
config,
|
|
504
|
-
|
|
335
|
+
representation,
|
|
336
|
+
done
|
|
505
337
|
) {
|
|
506
|
-
|
|
338
|
+
function getHref (href, context) {
|
|
507
339
|
return _.isFunction(href)
|
|
508
|
-
? href(
|
|
509
|
-
: urlTemplate.parseTemplate(href).expand(
|
|
340
|
+
? href(representation, context)
|
|
341
|
+
: urlTemplate.parseTemplate(href).expand(getTemplateContext(href, context))
|
|
510
342
|
}
|
|
511
343
|
|
|
512
|
-
|
|
513
|
-
|
|
344
|
+
/**
|
|
345
|
+
* @param {Record<string, any>} config the config object
|
|
346
|
+
* @param {Representation} representation the representation to configure
|
|
347
|
+
* @param {(err: Error | null, any) => void} done
|
|
348
|
+
*/
|
|
349
|
+
function prepareEntity (representation, done) {
|
|
350
|
+
const { prepare } = config
|
|
351
|
+
|
|
352
|
+
if (_.isFunction(prepare)) {
|
|
353
|
+
prepare(representation, wrap(done, representation))
|
|
354
|
+
} else {
|
|
355
|
+
done(null, representation)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
514
358
|
|
|
359
|
+
/**
|
|
360
|
+
* @param {Record<string, any>} config the config object
|
|
361
|
+
* @param {Representation} representation the representation to configure
|
|
362
|
+
* @param {(err: Error | null, any) => void} done
|
|
363
|
+
*/
|
|
364
|
+
function cascadeEntity (config, representation, done) {
|
|
365
|
+
representation.ignore(getIgnore(config))
|
|
366
|
+
|
|
367
|
+
// cascade the async config functions
|
|
368
|
+
prepareEntity(representation, done)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
try {
|
|
515
372
|
// shorthand prepare function
|
|
516
373
|
if (_.isFunction(config)) config = { prepare: config }
|
|
517
374
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const repLink = internals.link(link, rep.self.href)
|
|
521
|
-
repLink.href = resolveHref(repLink.href, entity)
|
|
522
|
-
rep.link(rel, repLink)
|
|
375
|
+
if (Reflect.has(config, 'links')) {
|
|
376
|
+
const links = Reflect.get(config, 'links')
|
|
523
377
|
|
|
524
|
-
|
|
525
|
-
if (config.query) {
|
|
526
|
-
repLink.href += config.query
|
|
527
|
-
}
|
|
528
|
-
})
|
|
378
|
+
const entity = getRepresentationEntity(representation)
|
|
529
379
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const wrap = function (callback) {
|
|
536
|
-
return function (err, result) {
|
|
537
|
-
if (err instanceof Error) {
|
|
538
|
-
callback(err)
|
|
539
|
-
} else {
|
|
540
|
-
callback(null, result || rep)
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
380
|
+
Object.entries(links)
|
|
381
|
+
.forEach(([rel, link]) => {
|
|
382
|
+
const representationLink = internals.link(link, getRepresentationSelfHref(representation))
|
|
383
|
+
representationLink.href = getHref(representationLink.href, entity)
|
|
384
|
+
representation.link(rel, representationLink)
|
|
544
385
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
*/
|
|
551
|
-
const prepareEntity = function (rep, callback) {
|
|
552
|
-
if (_.isFunction(config.prepare)) {
|
|
553
|
-
config.prepare(rep, wrap(callback))
|
|
554
|
-
} else {
|
|
555
|
-
callback(null, rep)
|
|
556
|
-
}
|
|
386
|
+
// grab query options
|
|
387
|
+
if (config.query) {
|
|
388
|
+
representationLink.href += config.query
|
|
389
|
+
}
|
|
390
|
+
})
|
|
557
391
|
}
|
|
558
392
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
(rel, cb) => {
|
|
563
|
-
const embed = config.embedded[rel]
|
|
393
|
+
if (Reflect.has(config, 'embedded')) {
|
|
394
|
+
// configure embedded declarations. each rel entry is also a representation config object
|
|
395
|
+
const embedded = Reflect.get(config, 'embedded')
|
|
564
396
|
|
|
565
|
-
|
|
566
|
-
if (!embed.path) {
|
|
567
|
-
throw new Error(
|
|
568
|
-
`Error in route ${rep.request.path}: "embedded" route configuration property requires a path`
|
|
569
|
-
)
|
|
570
|
-
}
|
|
571
|
-
let embedded = hoek.reach(entity, embed.path)
|
|
572
|
-
if (!embedded) return cb()
|
|
397
|
+
const entity = getRepresentationEntity(representation)
|
|
573
398
|
|
|
574
|
-
|
|
575
|
-
|
|
399
|
+
async.each(
|
|
400
|
+
Object.entries(embedded),
|
|
401
|
+
([rel, route], done) => {
|
|
402
|
+
// assume that arrays should be embedded as a collection
|
|
403
|
+
if (!hasPath(route)) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Invalid route "${getRepresentationRequestPath(representation)}": "embedded" route configuration property requires a path`
|
|
406
|
+
)
|
|
407
|
+
}
|
|
576
408
|
|
|
577
|
-
|
|
578
|
-
embedded = [].concat(embedded)
|
|
409
|
+
let embedded = hoek.reach(entity, getPath(route))
|
|
579
410
|
|
|
580
|
-
|
|
581
|
-
rep.ignore(embed.path)
|
|
411
|
+
if (!embedded) return done()
|
|
582
412
|
|
|
583
|
-
|
|
584
|
-
embedded,
|
|
585
|
-
(item, acb) => {
|
|
586
|
-
const link = internals.link(
|
|
587
|
-
resolveHref(embed.href, { self: entity, item }),
|
|
588
|
-
rep.self.href
|
|
589
|
-
)
|
|
413
|
+
// force the embed array to be inialized. no self rel is necessary
|
|
414
|
+
if (_.isArray(embedded)) representation.embed(rel, null, [])
|
|
590
415
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
embeddedRep = _.isArray(embeddedRep)
|
|
595
|
-
? embeddedRep
|
|
596
|
-
: [embeddedRep]
|
|
597
|
-
// recursively process its links/embedded declarations
|
|
598
|
-
async.each(
|
|
599
|
-
embeddedRep,
|
|
600
|
-
(e, bcb) => {
|
|
601
|
-
internals.halifyAndConfigure(e, embed, bcb)
|
|
602
|
-
},
|
|
603
|
-
acb
|
|
604
|
-
)
|
|
605
|
-
},
|
|
606
|
-
cb
|
|
607
|
-
)
|
|
608
|
-
},
|
|
609
|
-
(err) => {
|
|
610
|
-
if (err) return callback(err)
|
|
416
|
+
// force into an array for iterating
|
|
417
|
+
embedded = [].concat(embedded)
|
|
611
418
|
|
|
612
|
-
|
|
419
|
+
// embedded reps probably also shouldnt appear in the object payload
|
|
420
|
+
representation.ignore(getPath(route))
|
|
613
421
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
422
|
+
async.each(
|
|
423
|
+
embedded,
|
|
424
|
+
(item, done) => {
|
|
425
|
+
const link = internals.link(
|
|
426
|
+
getHref(route.href, { self: entity, item }),
|
|
427
|
+
getRepresentationSelfHref(representation)
|
|
428
|
+
)
|
|
622
429
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
430
|
+
// create the embedded representation from the possibly templated href
|
|
431
|
+
let embedded = representation.embed(rel, link, item)
|
|
432
|
+
|
|
433
|
+
// force into an array for iterating
|
|
434
|
+
embedded = [].concat(embedded)
|
|
435
|
+
|
|
436
|
+
// recursively process its links/embedded declarations
|
|
437
|
+
async.each(
|
|
438
|
+
embedded,
|
|
439
|
+
(representation, done) => {
|
|
440
|
+
transformRepresentationEntity(route, representation, done)
|
|
441
|
+
},
|
|
442
|
+
done
|
|
443
|
+
)
|
|
444
|
+
},
|
|
445
|
+
done
|
|
446
|
+
)
|
|
447
|
+
},
|
|
448
|
+
(err) => {
|
|
449
|
+
if (err) return done(err)
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
cascadeEntity(config, representation, done)
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
} else {
|
|
457
|
+
return (
|
|
458
|
+
cascadeEntity(config, representation, done)
|
|
459
|
+
)
|
|
636
460
|
}
|
|
461
|
+
} catch (e) {
|
|
462
|
+
done(e)
|
|
637
463
|
}
|
|
464
|
+
}
|
|
638
465
|
|
|
466
|
+
function transformRepresentationEntity (
|
|
467
|
+
config,
|
|
468
|
+
representation,
|
|
469
|
+
done
|
|
470
|
+
) {
|
|
639
471
|
/**
|
|
640
|
-
* Looks for a toHal(representation,
|
|
641
|
-
* representation or pass back a completely new representation by calling
|
|
642
|
-
*
|
|
472
|
+
* Looks for a toHal(representation, done) method on the entity. If found, it is called asynchronously. The method may modify the
|
|
473
|
+
* representation or pass back a completely new representation by calling done(newRep)
|
|
474
|
+
*
|
|
475
|
+
* @param {(err: Error | null, any) => void} done
|
|
643
476
|
*/
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
477
|
+
function transformEntity (done) {
|
|
478
|
+
const { toHal } = getRepresentationEntity(representation)
|
|
479
|
+
|
|
480
|
+
if (_.isFunction(toHal)) {
|
|
481
|
+
toHal(representation, wrap(done, representation))
|
|
647
482
|
} else {
|
|
648
|
-
|
|
483
|
+
done(null, representation)
|
|
649
484
|
}
|
|
650
485
|
}
|
|
651
486
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
* Selects the media type based on the request's Accept header and a ranked ordering of configured
|
|
661
|
-
* media types.
|
|
662
|
-
* @param mediaTypes
|
|
663
|
-
* @param request
|
|
664
|
-
* @return {*}
|
|
665
|
-
*/
|
|
666
|
-
internals.getMediaType = function (mediaTypes, request) {
|
|
667
|
-
return new Negotiator(request).mediaType(
|
|
668
|
-
_.isArray(mediaTypes) ? mediaTypes : [mediaTypes]
|
|
487
|
+
async.waterfall(
|
|
488
|
+
[
|
|
489
|
+
transformEntity,
|
|
490
|
+
(representation, done) => {
|
|
491
|
+
configureRepresentation(config, representation, done)
|
|
492
|
+
}
|
|
493
|
+
],
|
|
494
|
+
done
|
|
669
495
|
)
|
|
670
496
|
}
|
|
671
497
|
|
|
672
498
|
/**
|
|
673
|
-
* Expands the
|
|
674
|
-
*
|
|
675
|
-
* @param path
|
|
676
|
-
* @param search
|
|
677
|
-
* @return {*}
|
|
678
|
-
*/
|
|
679
|
-
internals.buildUrl = function (request, path, search) {
|
|
680
|
-
return url.format({
|
|
681
|
-
host: settings.host || request.headers.host,
|
|
682
|
-
hostname: settings.hostname || request.info.host,
|
|
683
|
-
port: settings.port || request.info.port,
|
|
684
|
-
pathname: path,
|
|
685
|
-
protocol: settings.protocol || request.info.protocol || 'http',
|
|
686
|
-
search
|
|
687
|
-
})
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* Expands the query string template, if present, using query parameter values in the request.
|
|
499
|
+
* Expands the query string template, if present, using query parameter values in the request
|
|
500
|
+
*
|
|
692
501
|
* @param request
|
|
693
502
|
* @param queryTemplate
|
|
694
|
-
* @param {
|
|
503
|
+
* @param {boolean} isAbsolute whether the link should be expanded to include the server
|
|
695
504
|
* @return {*}
|
|
696
505
|
*/
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
? internals.buildUrl(request, request.path)
|
|
702
|
-
: request.path
|
|
506
|
+
function getRequestPath (request, queryTemplate, isAbsolute) {
|
|
507
|
+
const path = isAbsolute
|
|
508
|
+
? internals.buildUrl(request, getPath(request))
|
|
509
|
+
: getPath(request)
|
|
703
510
|
|
|
704
511
|
if (queryTemplate) {
|
|
705
|
-
uriTemplate = new URITemplate(path + queryTemplate)
|
|
706
|
-
return
|
|
512
|
+
const uriTemplate = new URITemplate(path + queryTemplate)
|
|
513
|
+
return (
|
|
514
|
+
uriTemplate.expand(request.query)
|
|
515
|
+
)
|
|
707
516
|
}
|
|
517
|
+
|
|
708
518
|
return path
|
|
709
519
|
}
|
|
710
520
|
|
|
711
521
|
/**
|
|
712
|
-
* Resolves a relative
|
|
522
|
+
* Resolves to a relative URL
|
|
523
|
+
*
|
|
713
524
|
* @param request
|
|
714
|
-
* @param uri
|
|
715
|
-
* @param
|
|
525
|
+
* @param {string} uri
|
|
526
|
+
* @param {boolean} isAbsolute
|
|
716
527
|
* @return {*}
|
|
717
528
|
*/
|
|
718
|
-
|
|
719
|
-
|
|
529
|
+
function getRelativeUrl (request, uri, isAbsolute) {
|
|
530
|
+
let relativeUrl = null
|
|
531
|
+
let queryParams = null
|
|
720
532
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
if (isAbsolute) {
|
|
725
|
-
path = uri
|
|
533
|
+
if (uri.match(/^\w+:\/\//)) {
|
|
534
|
+
relativeUrl = uri
|
|
726
535
|
} else {
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
search = parts[1]
|
|
732
|
-
}
|
|
733
|
-
}
|
|
536
|
+
const [
|
|
537
|
+
route = '',
|
|
538
|
+
query = null
|
|
539
|
+
] = uri.split('?')
|
|
734
540
|
|
|
735
|
-
|
|
736
|
-
|
|
541
|
+
relativeUrl = route.startsWith('/') ? route : '/' + route
|
|
542
|
+
queryParams = query || queryParams
|
|
737
543
|
}
|
|
738
|
-
return path
|
|
739
|
-
}
|
|
740
544
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
545
|
+
if (isAbsolute) {
|
|
546
|
+
relativeUrl = internals.buildUrl(request, relativeUrl, queryParams)
|
|
547
|
+
}
|
|
744
548
|
|
|
745
|
-
|
|
746
|
-
return _.isObject(source) && !_.isArray(source)
|
|
549
|
+
return relativeUrl
|
|
747
550
|
}
|
|
748
551
|
|
|
749
|
-
|
|
750
|
-
const accept = request.headers.accept || ''
|
|
751
|
-
|
|
552
|
+
function isAcceptHeaderValid (request) {
|
|
752
553
|
return (
|
|
753
554
|
!settings.requireHalJsonAcceptHeader ||
|
|
754
|
-
|
|
555
|
+
(getRequestHeadersAccept(request) ?? '').toLowerCase().includes(HAL_MIME_TYPE)
|
|
755
556
|
)
|
|
756
557
|
}
|
|
757
558
|
|
|
758
|
-
|
|
759
|
-
|
|
559
|
+
function isSourceEligible (source) {
|
|
560
|
+
return _.isObject(source) && !_.isArray(source)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function isRequestEligible (request) {
|
|
760
564
|
return (
|
|
761
|
-
!request
|
|
762
|
-
|
|
565
|
+
!getRouteSettingsIsInternal(getRequestRoute(request)) && // hapi 9/10 routes can be marked internal only
|
|
566
|
+
isAcceptHeaderValid(request) &&
|
|
763
567
|
internals.filter(request)
|
|
764
568
|
)
|
|
765
569
|
}
|
|
766
570
|
|
|
767
|
-
|
|
571
|
+
function isResponseEligible (response) {
|
|
768
572
|
return (
|
|
769
|
-
response
|
|
770
|
-
|
|
573
|
+
getResponseVariety(response) === 'plain' &&
|
|
574
|
+
isResponseStatusCodeInSuccessRange(getResponseStatusCode(response))
|
|
771
575
|
)
|
|
772
576
|
}
|
|
773
577
|
|
|
774
|
-
|
|
578
|
+
function isEligible (request) {
|
|
775
579
|
return (
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
580
|
+
isRequestEligible(request) &&
|
|
581
|
+
isResponseEligible(getRequestResponse(request)) &&
|
|
582
|
+
isSourceEligible(getRequestResponseSource(request))
|
|
779
583
|
)
|
|
780
584
|
}
|
|
781
585
|
|
|
@@ -784,85 +588,367 @@ export const plugin = {
|
|
|
784
588
|
* the plugins.hal.api configuration settings. This function is exported for convenience if the developer wishes to
|
|
785
589
|
* define his or her own api handler in order to include metadata in the payload
|
|
786
590
|
*
|
|
787
|
-
* @param
|
|
788
|
-
* @param
|
|
789
|
-
* @param
|
|
591
|
+
* @param {boolean} isAbsolute
|
|
592
|
+
* @param {Representation} representation
|
|
593
|
+
* @param {(err: Error | null, any) => void} done
|
|
790
594
|
*/
|
|
791
|
-
|
|
595
|
+
function toHal (isAbsolute, representation, done) {
|
|
596
|
+
const request = getRepresentationRequest(representation)
|
|
597
|
+
|
|
792
598
|
// grab the routing table and iterate
|
|
793
|
-
const
|
|
599
|
+
const server = getRequestServer(request)
|
|
794
600
|
|
|
795
|
-
|
|
601
|
+
server.table()
|
|
602
|
+
.forEach((route) => {
|
|
603
|
+
const api = getRouteSettingsPluginsHalApi(route)
|
|
796
604
|
|
|
797
|
-
|
|
798
|
-
|
|
605
|
+
if (api) {
|
|
606
|
+
let {
|
|
607
|
+
path: uri = ''
|
|
608
|
+
} = route
|
|
799
609
|
|
|
800
|
-
|
|
610
|
+
if (isAbsolute) {
|
|
611
|
+
uri = internals.buildUrl(request, uri)
|
|
612
|
+
}
|
|
801
613
|
|
|
802
|
-
|
|
803
|
-
const rel = halConfig.api
|
|
804
|
-
let href = routes[i].path
|
|
614
|
+
const query = getRouteSettingsPluginsHalQuery(route)
|
|
805
615
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
616
|
+
// grab query options
|
|
617
|
+
if (query) {
|
|
618
|
+
uri += query
|
|
619
|
+
}
|
|
809
620
|
|
|
810
|
-
|
|
811
|
-
if (halConfig.query) {
|
|
812
|
-
href += halConfig.query
|
|
621
|
+
representation.link(api, uri)
|
|
813
622
|
}
|
|
623
|
+
})
|
|
814
624
|
|
|
815
|
-
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
next()
|
|
625
|
+
done()
|
|
819
626
|
}
|
|
820
627
|
|
|
821
628
|
/**
|
|
822
629
|
* Creates an auto api route configuration
|
|
823
|
-
*
|
|
824
|
-
* @param
|
|
825
|
-
* @
|
|
630
|
+
*
|
|
631
|
+
* @param auth
|
|
632
|
+
* @param {boolean} isAbsolute
|
|
633
|
+
* @return {{auth: *, handler: handler, plugins: {hal: toHal}}}
|
|
826
634
|
*/
|
|
827
|
-
|
|
635
|
+
function getApiRouteConfig (auth, isAbsolute) {
|
|
828
636
|
return {
|
|
829
|
-
auth
|
|
637
|
+
auth,
|
|
830
638
|
handler (req, h) {
|
|
831
639
|
return h.response({}).type(HAL_MIME_TYPE)
|
|
832
640
|
},
|
|
833
641
|
plugins: {
|
|
834
|
-
hal:
|
|
642
|
+
hal: toHal.bind(null, isAbsolute)
|
|
835
643
|
}
|
|
836
644
|
}
|
|
837
645
|
}
|
|
838
646
|
|
|
839
647
|
/**
|
|
840
648
|
* Creates a redirector to redirect the browser from /api to /api/
|
|
841
|
-
*
|
|
842
|
-
* @param
|
|
649
|
+
*
|
|
650
|
+
* @param auth
|
|
651
|
+
* @param {string} url
|
|
843
652
|
* @return {{auth: *, handler: handler}}
|
|
844
653
|
*/
|
|
845
|
-
|
|
654
|
+
function getApiRedirectConfig (auth, url) {
|
|
846
655
|
return {
|
|
847
|
-
auth
|
|
656
|
+
auth,
|
|
848
657
|
handler (req, h) {
|
|
849
|
-
return h.redirect(
|
|
658
|
+
return h.redirect(url.concat('/'))
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Returns namespaces sorted by name
|
|
665
|
+
*
|
|
666
|
+
* @return {*}
|
|
667
|
+
*/
|
|
668
|
+
internals.namespaces = function namespaces () {
|
|
669
|
+
return (
|
|
670
|
+
Array
|
|
671
|
+
.from(byName.values())
|
|
672
|
+
.sort(sortByName)
|
|
673
|
+
)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Validates and adds a new namespace configuration
|
|
678
|
+
*
|
|
679
|
+
* @param namespace the namespace config
|
|
680
|
+
* @return {*} a new namespace object
|
|
681
|
+
*/
|
|
682
|
+
internals.namespaces.add = function add (namespace) {
|
|
683
|
+
// if only dir is specified
|
|
684
|
+
if (!hasName(namespace)) namespace.name = (namespace.dir && path.basename(namespace.dir))
|
|
685
|
+
|
|
686
|
+
// fail fast if the namespace isnt valid
|
|
687
|
+
const { error, value } = namespaceSchema.validate(namespace)
|
|
688
|
+
|
|
689
|
+
if (error) throw error
|
|
690
|
+
|
|
691
|
+
namespace = value
|
|
692
|
+
|
|
693
|
+
// would prefer to initialize w/ joi but it keeps a static reference to the value for some reason
|
|
694
|
+
namespace.rels = {}
|
|
695
|
+
|
|
696
|
+
if (namespace.dir) {
|
|
697
|
+
namespace.scanDirectory(namespace.dir)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// index and return
|
|
701
|
+
const name = getName(namespace)
|
|
702
|
+
const prefix = getPrefix(namespace)
|
|
703
|
+
|
|
704
|
+
byName
|
|
705
|
+
.set(name, namespace)
|
|
706
|
+
byPrefix
|
|
707
|
+
.set(prefix, namespace)
|
|
708
|
+
|
|
709
|
+
return namespace
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Removes one or all registered namespaces. Mainly used for testing
|
|
714
|
+
*
|
|
715
|
+
* @param {string} name the namespace to remove. a falsy value will remove all namespaces
|
|
716
|
+
*/
|
|
717
|
+
internals.namespaces.remove = function remove (name) {
|
|
718
|
+
if (!name) {
|
|
719
|
+
byName
|
|
720
|
+
.clear()
|
|
721
|
+
byPrefix
|
|
722
|
+
.clear()
|
|
723
|
+
} else {
|
|
724
|
+
if (byName.has(name)) {
|
|
725
|
+
const namespace = byName.get(name)
|
|
726
|
+
|
|
727
|
+
const prefix = getPrefix(namespace)
|
|
728
|
+
|
|
729
|
+
byName
|
|
730
|
+
.delete(name)
|
|
731
|
+
byPrefix
|
|
732
|
+
.delete(prefix)
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Looks up a specific namespace
|
|
739
|
+
*
|
|
740
|
+
* @param namespace
|
|
741
|
+
* @return {*}
|
|
742
|
+
*/
|
|
743
|
+
internals.namespace = function namespace (name) {
|
|
744
|
+
return (
|
|
745
|
+
byName.get(name)
|
|
746
|
+
)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Returns namespace rels sorted by name
|
|
751
|
+
*
|
|
752
|
+
* @return {*}
|
|
753
|
+
*/
|
|
754
|
+
internals.rels = function rels () {
|
|
755
|
+
return (
|
|
756
|
+
Array
|
|
757
|
+
.from(byName.values())
|
|
758
|
+
.reduce((accumulator, { rels = {} }) => accumulator.concat(Object.values(rels)), [])
|
|
759
|
+
.sort(sortByName)
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Adds a new rel configuration to a namespace
|
|
765
|
+
*
|
|
766
|
+
* @param {string} name the namespace name
|
|
767
|
+
* @param rel the rel configuration
|
|
768
|
+
* @return the new rel
|
|
769
|
+
*/
|
|
770
|
+
internals.rels.add = function add (name, rel) {
|
|
771
|
+
if (!byName.has(name)) throw new Error(`Invalid namespace "${name}"`)
|
|
772
|
+
|
|
773
|
+
const namespace = byName.get(name)
|
|
774
|
+
|
|
775
|
+
namespace.rel(rel)
|
|
776
|
+
|
|
777
|
+
const relName = getName(rel)
|
|
778
|
+
const rels = getRels(namespace)
|
|
779
|
+
|
|
780
|
+
return (
|
|
781
|
+
Reflect.get(rels, relName)
|
|
782
|
+
)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Looks up a rel under a given namespace
|
|
787
|
+
* @param {string} namespaceName the namespace name
|
|
788
|
+
* @param {string} relName the rel name
|
|
789
|
+
* @return {*} the rel or undefined if not found
|
|
790
|
+
*/
|
|
791
|
+
internals.rel = function rel (namespaceName, relName) {
|
|
792
|
+
if (!namespaceName) throw new Error(`Invalid rel "${namespaceName}"`)
|
|
793
|
+
|
|
794
|
+
let namespace
|
|
795
|
+
let rel
|
|
796
|
+
|
|
797
|
+
if (!relName) {
|
|
798
|
+
// for shorthand namespace:rel notation
|
|
799
|
+
if (namespaceName.includes(':')) {
|
|
800
|
+
const [prefix, name] = namespaceName.split(':')
|
|
801
|
+
namespace = byPrefix.get(prefix)
|
|
802
|
+
relName = name
|
|
803
|
+
}
|
|
804
|
+
} else {
|
|
805
|
+
namespace = byName.get(namespaceName)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// namespace is valid, check for rel
|
|
809
|
+
if (namespace) {
|
|
810
|
+
const rels = getRels(namespace)
|
|
811
|
+
|
|
812
|
+
if (!Reflect.has(rels, relName)) {
|
|
813
|
+
if (!settings.strict) {
|
|
814
|
+
// lazily create the rel
|
|
815
|
+
namespace.rel({ name: relName })
|
|
816
|
+
} else {
|
|
817
|
+
// could be a typo, fail fast to let the developer know
|
|
818
|
+
throw new Error(`Invalid rel "${namespaceName}" ("${relName}")`)
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
rel = Reflect.get(rels, relName)
|
|
823
|
+
} else {
|
|
824
|
+
// could be globally qualified (e.g. 'self')
|
|
825
|
+
const { error, value } = relSchema.validate({ name: namespaceName })
|
|
826
|
+
if (error) throw error
|
|
827
|
+
|
|
828
|
+
rel = value
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return rel
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Resolves a name
|
|
836
|
+
*
|
|
837
|
+
* @param link
|
|
838
|
+
* @param relativeTo
|
|
839
|
+
*/
|
|
840
|
+
internals.link = function link (link, relativeTo) {
|
|
841
|
+
link = (
|
|
842
|
+
_.isFunction(link) || _.isString(link)
|
|
843
|
+
? { href: link }
|
|
844
|
+
: hoek.clone(link)
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
const { error, value } = linkSchema.validate(link)
|
|
848
|
+
|
|
849
|
+
if (error) throw error
|
|
850
|
+
|
|
851
|
+
link = value
|
|
852
|
+
|
|
853
|
+
relativeTo = (relativeTo ?? '').split('?').shift().trim()
|
|
854
|
+
|
|
855
|
+
if (
|
|
856
|
+
relativeTo &&
|
|
857
|
+
(isRelativePath(link.href) || settings.absolute)
|
|
858
|
+
) {
|
|
859
|
+
try {
|
|
860
|
+
link.href = (
|
|
861
|
+
new URI(link.href)
|
|
862
|
+
.absoluteTo(relativeTo.concat('/'))
|
|
863
|
+
.toString()
|
|
864
|
+
)
|
|
865
|
+
} catch (e) {
|
|
866
|
+
log(`Invalid URL "${link.href}"`)
|
|
867
|
+
return link
|
|
850
868
|
}
|
|
851
869
|
}
|
|
870
|
+
|
|
871
|
+
return link
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Locates a named route and expands templated parameters
|
|
876
|
+
*
|
|
877
|
+
* @param routeName
|
|
878
|
+
* @param params
|
|
879
|
+
* @return String the expanded path to the named route
|
|
880
|
+
*/
|
|
881
|
+
internals.route = function route (routeName, params) {
|
|
882
|
+
const route = server.lookup(routeName) || getRouteFromRouteCache(routeName)
|
|
883
|
+
if (!route) throw new Error(`Invalid route "${routeName}"`)
|
|
884
|
+
|
|
885
|
+
const i = /{([\s\S]+?)(?:\?|\*\d*)??}/g
|
|
886
|
+
const c = Object.fromEntries(Object.entries(params).map(([key, value]) => [key, typeof value !== 'object' ? encodeURIComponent(value) : value]))
|
|
887
|
+
|
|
888
|
+
const href = _.template(getPath(route), { interpolate: i })(c)
|
|
889
|
+
|
|
890
|
+
const query = getRouteSettingsPluginsHalQuery(route) // hoek.reach(route.settings, 'plugins.hal.query')
|
|
891
|
+
|
|
892
|
+
if (query) {
|
|
893
|
+
return href + query
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return href
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Returns the documentation link to a namespace
|
|
901
|
+
*
|
|
902
|
+
* @param request
|
|
903
|
+
* @param namespace
|
|
904
|
+
* @return {*}
|
|
905
|
+
*/
|
|
906
|
+
internals.namespaceUrl = function namespaceUrl (request, namespace) {
|
|
907
|
+
const path = [settings.relsPath, getName(namespace)].join('/')
|
|
908
|
+
|
|
909
|
+
if (settings.absolute) {
|
|
910
|
+
return internals.buildUrl(request, path)
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return path
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
internals.filter = function filter (request) {
|
|
917
|
+
return getRouteSettingsPluginsHal(getRequestRoute(request)) // _.get(getRouteSettings(getRequestRoute(request)), 'plugins.hal', true)
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Expands the url path to include protocol://server:port
|
|
922
|
+
*
|
|
923
|
+
* @param request
|
|
924
|
+
* @param {string} pathname
|
|
925
|
+
* @param {string | void} search
|
|
926
|
+
* @return {*}
|
|
927
|
+
*/
|
|
928
|
+
internals.buildUrl = function buildUrl (request, pathname, search) {
|
|
929
|
+
return url.format({
|
|
930
|
+
protocol: settings.protocol || request.info.protocol || 'http',
|
|
931
|
+
hostname: settings.hostname || request.info.host,
|
|
932
|
+
pathname,
|
|
933
|
+
host: settings.host || request.headers.host,
|
|
934
|
+
port: settings.port || request.info.port,
|
|
935
|
+
search
|
|
936
|
+
})
|
|
852
937
|
}
|
|
853
938
|
|
|
854
939
|
/**
|
|
855
|
-
* Assigns a filter function to test routes before applying the hal interceptor
|
|
856
|
-
*
|
|
940
|
+
* Assigns a filter function to test routes before applying the hal interceptor
|
|
941
|
+
*
|
|
942
|
+
* @param filter
|
|
857
943
|
*/
|
|
858
|
-
internals.setFilter = function (
|
|
859
|
-
const { error } = joi.function().validate(
|
|
944
|
+
internals.setFilter = function setFilter (filter) {
|
|
945
|
+
const { error } = joi.function().validate(filter)
|
|
860
946
|
if (error) throw error
|
|
861
947
|
|
|
862
|
-
internals.filter =
|
|
948
|
+
internals.filter = filter
|
|
863
949
|
}
|
|
864
950
|
|
|
865
|
-
internals.setUrlBuilder = function (urlBuilder) {
|
|
951
|
+
internals.setUrlBuilder = function setUrlBuilder (urlBuilder) {
|
|
866
952
|
const { error } = joi.function().validate(urlBuilder)
|
|
867
953
|
if (error) throw error
|
|
868
954
|
|
|
@@ -876,58 +962,61 @@ export const plugin = {
|
|
|
876
962
|
link: internals.link,
|
|
877
963
|
rels: internals.rels,
|
|
878
964
|
rel: internals.rel,
|
|
879
|
-
resolve: internals.resolve,
|
|
880
965
|
route: internals.route,
|
|
881
|
-
|
|
966
|
+
toHal,
|
|
882
967
|
filter: internals.setFilter,
|
|
883
968
|
urlBuilder: internals.setUrlBuilder,
|
|
884
|
-
configureRepresentation
|
|
969
|
+
configureRepresentation
|
|
885
970
|
}
|
|
886
971
|
|
|
887
972
|
/**
|
|
888
|
-
* A
|
|
973
|
+
* A Hapi lifecycle method that looks for the application/hal+json accept header and wraps the response entity into a
|
|
889
974
|
* HAL representation
|
|
975
|
+
*
|
|
890
976
|
* @param request
|
|
891
977
|
* @param h
|
|
892
978
|
*/
|
|
893
|
-
internals.postHandler = function (request, h) {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
979
|
+
internals.postHandler = function postHandler (request, h) {
|
|
980
|
+
if (isEligible(request)) {
|
|
981
|
+
const mediaType = getMediaType(request, settings.mediaTypes)
|
|
982
|
+
if (mediaType) {
|
|
983
|
+
const config = getRouteSettingsPluginsHal(getRequestRoute(request))
|
|
984
|
+
|
|
985
|
+
// e.g. honor the location header if it has been set using response.created(...) or response.location(...)
|
|
986
|
+
const location = getRequestResponseHeadersLocation(request)
|
|
987
|
+
const isAbsolute = Boolean(getAbsolute(config) || getAbsolute(settings))
|
|
988
|
+
|
|
989
|
+
const self = (
|
|
990
|
+
location
|
|
991
|
+
? getRelativeUrl(request, location, isAbsolute)
|
|
992
|
+
: getRequestPath(request, getQuery(config), isAbsolute)
|
|
993
|
+
)
|
|
907
994
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
? internals.location(request, location, absolute)
|
|
912
|
-
: internals.getRequestPath(request, halConfig.query, absolute)
|
|
995
|
+
// all new representations for the request will be built by this guy
|
|
996
|
+
const representationFactory = new RepresentationFactory(api, request)
|
|
997
|
+
const representation = representationFactory.create(getRequestResponseSource(request), self)
|
|
913
998
|
|
|
914
|
-
|
|
999
|
+
return (
|
|
1000
|
+
new Promise((resolve, reject) => {
|
|
1001
|
+
transformRepresentationEntity(config, representation, (err, representation) => {
|
|
1002
|
+
if (err) return reject(err)
|
|
915
1003
|
|
|
916
|
-
|
|
917
|
-
internals.halifyAndConfigure(rep, halConfig, (err, rep) => {
|
|
918
|
-
if (err) {
|
|
919
|
-
return reject(err)
|
|
920
|
-
}
|
|
1004
|
+
const response = getRequestResponse(request)
|
|
921
1005
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1006
|
+
// send back what they asked for (as plain object
|
|
1007
|
+
// so validation can be done correctly)
|
|
1008
|
+
response.source = representation.toJSON()
|
|
1009
|
+
response.type(mediaType)
|
|
926
1010
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1011
|
+
return (
|
|
1012
|
+
resolve(h.continue)
|
|
1013
|
+
)
|
|
1014
|
+
})
|
|
1015
|
+
})
|
|
1016
|
+
)
|
|
1017
|
+
}
|
|
930
1018
|
}
|
|
1019
|
+
|
|
931
1020
|
return h.continue
|
|
932
1021
|
}
|
|
933
1022
|
|
|
@@ -940,24 +1029,27 @@ export const plugin = {
|
|
|
940
1029
|
// bind the API handler to api root + '/'. Ending with '/' is necessary for resolving relative links on the client
|
|
941
1030
|
selection.route({
|
|
942
1031
|
method: 'GET',
|
|
943
|
-
path:
|
|
944
|
-
config:
|
|
1032
|
+
path: settings.apiPath.concat('/'),
|
|
1033
|
+
config: getApiRouteConfig(
|
|
1034
|
+
settings.apiAuth,
|
|
1035
|
+
settings.absolute
|
|
1036
|
+
)
|
|
945
1037
|
})
|
|
946
1038
|
|
|
947
1039
|
// set up a redirect to api root + '/'
|
|
948
|
-
if (settings.apiPath
|
|
1040
|
+
if (settings.apiPath) {
|
|
949
1041
|
selection.route({
|
|
950
1042
|
method: 'GET',
|
|
951
1043
|
path: settings.apiPath,
|
|
952
|
-
config:
|
|
953
|
-
settings.
|
|
954
|
-
settings.
|
|
1044
|
+
config: getApiRedirectConfig(
|
|
1045
|
+
settings.apiAuth,
|
|
1046
|
+
settings.apiPath
|
|
955
1047
|
)
|
|
956
1048
|
})
|
|
957
1049
|
}
|
|
958
1050
|
}
|
|
959
1051
|
|
|
960
|
-
internals.preStart = function (server) {
|
|
1052
|
+
internals.preStart = function preStart (server) {
|
|
961
1053
|
if (_.isFunction(server.views)) {
|
|
962
1054
|
server.views({
|
|
963
1055
|
engines: {
|
|
@@ -966,15 +1058,17 @@ export const plugin = {
|
|
|
966
1058
|
path: path.join(IAM, './views'),
|
|
967
1059
|
isCached: false
|
|
968
1060
|
})
|
|
1061
|
+
|
|
969
1062
|
server.route({
|
|
970
|
-
method: '
|
|
1063
|
+
method: 'GET',
|
|
971
1064
|
path: settings.relsPath,
|
|
972
|
-
config:
|
|
1065
|
+
config: getRelsRouteConfig(settings.relsAuth)
|
|
973
1066
|
})
|
|
1067
|
+
|
|
974
1068
|
server.route({
|
|
975
|
-
method: '
|
|
1069
|
+
method: 'GET',
|
|
976
1070
|
path: `${settings.relsPath}/{namespace}/{rel}`,
|
|
977
|
-
config:
|
|
1071
|
+
config: getRelRouteConfig(settings.relsAuth)
|
|
978
1072
|
})
|
|
979
1073
|
}
|
|
980
1074
|
}
|