├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmrc ├── .travis.yml ├── LICENSE ├── README.md ├── lib ├── helpers.js ├── index.js └── settings.js ├── package.json └── test ├── index.js └── json-api-response-schema.json /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 'test' 2 | 3 | on: [pull_request, push] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: ljharb/actions/node/install@main 14 | name: 'nvm install lts/* && npm install' 15 | - run: npm test 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.log 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | 5 | node_js: 6 | - "12.14" 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Dali Zheng (http://daliwa.li) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fortune JSON API Serializer 2 | 3 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/fortunejs/fortune-json-api/test.yml) 4 | [![npm Version](https://img.shields.io/npm/v/fortune-json-api.svg?style=flat-square)](https://www.npmjs.com/package/fortune-json-api) 5 | [![License](https://img.shields.io/npm/l/fortune-json-api.svg?style=flat-square)](https://raw.githubusercontent.com/fortunejs/fortune-json-api/master/LICENSE) 6 | 7 | This is a [JSON API](http://jsonapi.org) serializer for [Fortune.js](http://fortune.js.org/), which implements all of the features in the [base specification](http://jsonapi.org/format/), and follows the [recommendations](http://jsonapi.org/recommendations/) as much as possible. 8 | 9 | ```sh 10 | $ npm install fortune fortune-http fortune-json-api 11 | ``` 12 | 13 | 14 | ## Usage 15 | 16 | ```js 17 | const http = require('http') 18 | const fortune = require('fortune') 19 | const fortuneHTTP = require('fortune-http') 20 | const jsonApiSerializer = require('fortune-json-api') 21 | 22 | // `instance` is an instance of Fortune.js. 23 | const listener = fortuneHTTP(instance, { 24 | serializers: [ 25 | // The `options` object here is optional. 26 | [ jsonApiSerializer, options ] 27 | ] 28 | }) 29 | // The listener function may be used as a standalone server, or 30 | // may be composed as part of a framework. 31 | const server = http.createServer((request, response) => 32 | listener(request, response) 33 | .catch(error => { /* error logging */ })) 34 | 35 | server.listen(8080) 36 | ``` 37 | 38 | The `options` object is as follows: 39 | 40 | - `prefix`: hyperlink prefix. If this prefix starts with `/`, then it will rewrite paths relative to the prefix. For example, a prefix valued `/api` will handle requests at that route like `/api/users/1`. Default: `""` (empty string). 41 | - `inflectType`: pluralize and dasherize the record type name in the URI. Can be Boolean to enable/disable all inflections or an object specifying each type in specific with unreferenced types set to default, ex: `{ faculty: false }`. Default: `true`. 42 | - `inflectKeys`: camelize the field names per record. Default: `true`. 43 | - `maxLimit`: maximum number of records to show per page. Default: `1000`. 44 | - `includeLimit`: maximum depth of fields per include. Default: `3`. 45 | - `bufferEncoding`: which encoding type to use for input buffer fields. Default: `base64`. 46 | - `jsonSpaces`: how many spaces to use for pretty printing JSON. Default: `2`. 47 | - `jsonapi`: top-level object mainly used for describing version. Default: `{ version: '1.0' }`. 48 | - `castNumericIds`: whether to cast numeric id strings to numbers. Default: `true`. 49 | 50 | Internal options: 51 | 52 | - `uriTemplate`: URI template string. 53 | - `allowLevel`: HTTP methods to allow ordered by appearance in URI template. 54 | 55 | 56 | ## License 57 | 58 | This software is licensed under the [MIT license](https://raw.githubusercontent.com/fortunejs/fortune-json-api/master/LICENSE). 59 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const inflection = require('inflection') 4 | const deepEqual = require('deep-equal') 5 | 6 | const settings = require('./settings') 7 | const typeInflections = settings.typeInflections 8 | const reservedKeys = settings.reservedKeys 9 | const inBrackets = settings.inBrackets 10 | const isField = settings.isField 11 | const isFilter = settings.isFilter 12 | const mediaType = settings.mediaType 13 | const pageOffset = settings.pageOffset 14 | const pageLimit = settings.pageLimit 15 | const inflectTypeDef = settings.defaults.inflectType 16 | 17 | 18 | module.exports = { 19 | initializeContext, mapRecord, mapId, matchId, castId, 20 | underscore, parseBuffer, checkLowerCase, setInflectType 21 | } 22 | 23 | 24 | function initializeContext (contextRequest, request, response) { 25 | const uriTemplate = this.uriTemplate 26 | const methodMap = this.methodMap 27 | const recordTypes = this.recordTypes 28 | const adapter = this.adapter 29 | const keys = this.keys 30 | const methods = this.methods 31 | 32 | const options = this.options 33 | const prefix = options.prefix 34 | const inflectType = options.inflectType 35 | const inflectKeys = options.inflectKeys 36 | const allowLevel = options.allowLevel 37 | 38 | const errors = this.errors 39 | const NotAcceptableError = errors.NotAcceptableError 40 | const NotFoundError = errors.NotFoundError 41 | 42 | // According to the spec, if the media type is provided in the Accept 43 | // header, it should be included at least once without any media type 44 | // parameters. 45 | if (request.headers['accept'] && 46 | ~request.headers['accept'].indexOf(mediaType)) { 47 | const escapedMediaType = mediaType.replace(/[\+\.]/g, '\\$&') 48 | const mediaTypeRegex = new RegExp(`${escapedMediaType}(?!;)`, 'g') 49 | if (!request.headers['accept'].match(mediaTypeRegex)) 50 | throw new NotAcceptableError('The "Accept" header should contain ' + 51 | 'at least one instance of the JSON media type without any ' + 52 | 'media type parameters.') 53 | } 54 | 55 | request.meta = {} 56 | 57 | const meta = contextRequest.meta 58 | 59 | const method = contextRequest.method = request.meta.method = 60 | methodMap[request.method] 61 | 62 | // URL rewriting for prefix parameter. 63 | if ((prefix || '').charAt(0) === '/' && request.url.indexOf(prefix) === 0) 64 | request.url = request.url.slice(prefix.length) 65 | 66 | // Decode URI Component only for the query string. 67 | const uriObject = contextRequest.uriObject = request.meta.uriObject = 68 | uriTemplate.fromUri(request.url) 69 | 70 | if (!Object.keys(uriObject).length && request.url.length > 1) 71 | throw new NotFoundError('Invalid URI.') 72 | 73 | const type = contextRequest.type = request.meta.type = 74 | uriObject.type ? inflectType[uriObject.type] ? 75 | checkLowerCase( 76 | inflection.transform(underscore(uriObject.type), typeInflections[0]), 77 | recordTypes 78 | ) : uriObject.type : null 79 | 80 | // Show allow options. 81 | if (request.method === 'OPTIONS' && (!type || type in recordTypes)) { 82 | delete uriObject.query 83 | 84 | // Avoid making an internal request by throwing the response. 85 | const output = new Error() 86 | 87 | output.isMethodInvalid = true 88 | output.meta = { 89 | headers: { 90 | 'Allow': allowLevel[Object.keys(uriObject) 91 | .filter(key => uriObject[key]).length].join(', ') 92 | } 93 | } 94 | 95 | response.statusCode = 204 96 | 97 | throw output 98 | } 99 | 100 | const ids = contextRequest.ids = request.meta.ids = 101 | uriObject.ids ? (Array.isArray(uriObject.ids) ? 102 | uriObject.ids : [ uriObject.ids ]).map(castId.bind(this)) : null 103 | 104 | const fields = recordTypes[type] 105 | 106 | attachQueries.call(this, contextRequest) 107 | request.meta.options = contextRequest.options 108 | 109 | let relatedField = uriObject.relatedField 110 | const relationship = uriObject.relationship 111 | 112 | if (relationship) { 113 | if (relatedField !== reservedKeys.relationships) 114 | throw new NotFoundError('Invalid relationship URI.') 115 | 116 | // This is a little unorthodox, but POST and DELETE requests to a 117 | // relationship entity should be treated as updates. 118 | if (method === methods.create || method === methods.delete) { 119 | contextRequest.originalMethod = method 120 | contextRequest.method = methods.update 121 | } 122 | 123 | relatedField = relationship 124 | } 125 | 126 | if (relatedField && inflectKeys) 127 | relatedField = inflection.camelize(underscore(relatedField), true) 128 | 129 | if (relatedField && (!(relatedField in fields) || 130 | !(keys.link in fields[relatedField]) || 131 | fields[relatedField][keys.denormalizedInverse])) 132 | throw new NotFoundError(`The field "${relatedField}" is ` + 133 | `not a link on the type "${type}".`) 134 | 135 | return relatedField ? adapter.find(type, ids, { 136 | // We only care about getting the related field. 137 | fields: { [relatedField]: true } 138 | }, meta) 139 | .then(records => { 140 | // Reduce the related IDs from all of the records into an array of 141 | // unique IDs. 142 | const relatedIds = Array.from((records || []).reduce((ids, record) => { 143 | const value = record[relatedField] 144 | 145 | if (Array.isArray(value)) for (const id of value) ids.add(id) 146 | else ids.add(value) 147 | 148 | return ids 149 | }, new Set())) 150 | 151 | const relatedType = fields[relatedField][keys.link] 152 | 153 | // Copy the original type and IDs to temporary keys. 154 | contextRequest.relatedField = request.meta.relatedField = relatedField 155 | contextRequest.relationship = request.meta.relationship = relationship 156 | contextRequest.originalType = request.meta.originalType = type 157 | contextRequest.originalIds = request.meta.originalIds = ids 158 | 159 | // Write the related info to the request, which should take 160 | // precedence over the original type and IDs. 161 | contextRequest.type = request.meta.type = relatedType 162 | contextRequest.ids = request.meta.ids = relatedIds 163 | 164 | return contextRequest 165 | }) : contextRequest 166 | } 167 | 168 | 169 | /** 170 | * Internal function to map a record to JSON API format. It must be 171 | * called directly within the context of the serializer. Within this 172 | * function, IDs must be cast to strings, per the spec. 173 | */ 174 | function mapRecord (type, record) { 175 | const keys = this.keys 176 | const uriTemplate = this.uriTemplate 177 | const recordTypes = this.recordTypes 178 | const fields = recordTypes[type] 179 | const options = this.options 180 | const prefix = options.prefix 181 | const inflectType = options.inflectType 182 | const inflectKeys = options.inflectKeys 183 | const clone = {} 184 | 185 | const id = record[keys.primary] 186 | 187 | clone[reservedKeys.type] = inflectType[type] ? 188 | inflection.transform(type, typeInflections[1]) : type 189 | clone[reservedKeys.id] = id.toString() 190 | clone[reservedKeys.meta] = {} 191 | clone[reservedKeys.attributes] = {} 192 | clone[reservedKeys.relationships] = {} 193 | clone[reservedKeys.links] = { 194 | [reservedKeys.self]: prefix + uriTemplate.fillFromObject({ 195 | type: inflectType[type] ? 196 | inflection.transform(type, typeInflections[1]) : type, 197 | ids: id 198 | }) 199 | } 200 | 201 | const unionFields = union(Object.keys(fields), Object.keys(record)) 202 | 203 | for (let i = 0, j = unionFields.length; i < j; i++) { 204 | let field = unionFields[i] 205 | 206 | if (field === keys.primary) continue 207 | 208 | const fieldDefinition = fields[field] 209 | const hasField = field in record 210 | 211 | if (!hasField && !fieldDefinition[keys.link]) continue 212 | 213 | const originalField = field 214 | 215 | // Per the recommendation, dasherize keys. 216 | if (inflectKeys) 217 | field = inflection.transform(field, 218 | [ 'underscore', 'dasherize' ]) 219 | 220 | // Handle meta/attributes. 221 | if (!fieldDefinition || fieldDefinition[keys.type]) { 222 | const value = record[originalField] 223 | 224 | if (!fieldDefinition) clone[reservedKeys.meta][field] = value 225 | else clone[reservedKeys.attributes][field] = value 226 | 227 | continue 228 | } 229 | 230 | // Handle link fields. 231 | const ids = record[originalField] 232 | 233 | const linkedType = inflectType[fieldDefinition[keys.link]] ? 234 | inflection.transform(fieldDefinition[keys.link], typeInflections[1]) : 235 | fieldDefinition[keys.link] 236 | 237 | clone[reservedKeys.relationships][field] = { 238 | [reservedKeys.links]: { 239 | [reservedKeys.self]: prefix + uriTemplate.fillFromObject({ 240 | type: inflectType[type] ? 241 | inflection.transform(type, typeInflections[1]) : type, 242 | ids: id, 243 | relatedField: reservedKeys.relationships, 244 | relationship: inflectKeys ? inflection.transform(field, 245 | [ 'underscore', 'dasherize' ]) : field 246 | }), 247 | [reservedKeys.related]: prefix + uriTemplate.fillFromObject({ 248 | type: inflectType[type] ? 249 | inflection.transform(type, typeInflections[1]) : type, 250 | ids: id, 251 | relatedField: inflectKeys ? inflection.transform(field, 252 | [ 'underscore', 'dasherize' ]) : field 253 | }) 254 | } 255 | } 256 | 257 | if (hasField) 258 | clone[reservedKeys.relationships][field][reservedKeys.primary] = 259 | fieldDefinition[keys.isArray] ? 260 | ids.map(toIdentifier.bind(null, linkedType)) : 261 | (ids ? toIdentifier(linkedType, ids) : null) 262 | } 263 | 264 | if (!Object.keys(clone[reservedKeys.attributes]).length) 265 | delete clone[reservedKeys.attributes] 266 | 267 | if (!Object.keys(clone[reservedKeys.meta]).length) 268 | delete clone[reservedKeys.meta] 269 | 270 | if (!Object.keys(clone[reservedKeys.relationships]).length) 271 | delete clone[reservedKeys.relationships] 272 | 273 | return clone 274 | } 275 | 276 | 277 | function toIdentifier (type, id) { 278 | return { 279 | [reservedKeys.type]: type, 280 | [reservedKeys.id]: id.toString() 281 | } 282 | } 283 | 284 | 285 | function attachQueries (request) { 286 | const recordTypes = this.recordTypes 287 | const keys = this.keys 288 | const castValue = this.castValue 289 | const BadRequestError = this.errors.BadRequestError 290 | const options = this.options 291 | const inflectKeys = options.inflectKeys 292 | const inflectType = options.inflectType 293 | const includeLimit = options.includeLimit 294 | const maxLimit = options.maxLimit 295 | const type = request.type 296 | const fields = recordTypes[type] 297 | const reduceFields = (fields, field) => { 298 | fields[inflect(field)] = true 299 | return fields 300 | } 301 | const castMap = (type, options, x) => castValue(x, type, options) 302 | 303 | let query = request.uriObject.query 304 | if (!query) query = {} 305 | request.options = {} 306 | 307 | // Iterate over dynamic query strings. 308 | for (const parameter of Object.keys(query)) 309 | // Attach fields option. 310 | if (parameter.match(isField)) { 311 | const sparseField = Array.isArray(query[parameter]) ? 312 | query[parameter] : query[parameter].split(',') 313 | const fields = sparseField.reduce(reduceFields, {}) 314 | let sparseType = (parameter.match(inBrackets) || [])[1] 315 | 316 | if (inflectType[type]) 317 | sparseType = checkLowerCase( 318 | inflection.transform(sparseType, typeInflections[0]), 319 | recordTypes 320 | ) 321 | 322 | if (sparseType === type) 323 | request.options.fields = fields 324 | } 325 | 326 | // Attach match option. 327 | else if (parameter.match(isFilter)) { 328 | const matches = parameter.match(inBrackets) 329 | if (!matches) continue 330 | const field = inflect(matches[1]) 331 | const filterType = matches[2] 332 | 333 | if (isRelationFilter(field)) { 334 | const relationPath = field 335 | if (filterType !== 'fuzzy-match') 336 | throw new BadRequestError( 337 | `Filtering relationship only 338 | supported on fuzzy-match for now 339 | `) 340 | 341 | const isValidPath = 342 | isValidRelationPath( recordTypes, 343 | fields, 344 | getRelationFilterSegments(field) ) 345 | if (! isValidPath ) 346 | throw new BadRequestError(`Path ${relationPath} is not valid`) 347 | } 348 | 349 | else if (!(field in fields)) 350 | throw new BadRequestError(`The field "${field}" is non-existent.`) 351 | 352 | const filterSegments = getRelationFilterSegments(field) 353 | const fieldType = getLastTypeInPath( recordTypes, 354 | fields, filterSegments )[keys.type] 355 | 356 | if (filterType === void 0) { 357 | if (!('match' in request.options)) request.options.match = {} 358 | const value = Array.isArray(query[parameter]) ? 359 | query[parameter] : query[parameter].split(',') 360 | request.options.match[field] = 361 | value.map(castMap.bind(null, fieldType, options)) 362 | } 363 | else if (filterType === 'exists') { 364 | if (!('exists' in request.options)) request.options.exists = {} 365 | request.options.exists[field] = bool(query[parameter]) 366 | } 367 | else if (filterType === 'min' || filterType === 'max') { 368 | if (!('range' in request.options)) request.options.range = {} 369 | if (!(field in request.options.range)) 370 | request.options.range[field] = [null, null] 371 | const index = filterType === 'min' ? 0 : 1 372 | request.options.range[field][index] = 373 | castValue(query[parameter], fieldType, options) 374 | } 375 | else if (filterType === 'fuzzy-match') { 376 | const lastTypeInPath = 377 | getLastTypeInPath( recordTypes, 378 | fields, 379 | getRelationFilterSegments(field) ) 380 | if ( ! lastTypeInPath[keys.type] ) 381 | throw new BadRequestError( 382 | `fuzzy-match only allowed on attributes. For ${field}` ) 383 | 384 | 385 | if ( lastTypeInPath[keys.type].name !== 'String') 386 | throw new BadRequestError( 387 | `fuzzy-match only allowed on String types. 388 | ${field} is of type ${lastTypeInPath[keys.type].name} 389 | ` ) 390 | 391 | 392 | if (!('fuzzyMatch' in request.options)) 393 | request.options['fuzzyMatch'] = {} 394 | request.options.fuzzyMatch[field] = query[parameter] 395 | } 396 | else throw new BadRequestError( 397 | `The filter "${filterType}" is not valid.`) 398 | } 399 | 400 | 401 | // Attach include option. 402 | if (reservedKeys.include in query) { 403 | request.include = (Array.isArray(query[reservedKeys.include]) ? 404 | query[reservedKeys.include] : 405 | query[reservedKeys.include].split(',')) 406 | .map(i => i.split('.').map(x => inflect(x)).slice(0, includeLimit)) 407 | 408 | // Manually expand nested includes. 409 | for (const path of request.include) 410 | for (let i = path.length - 1; i > 0; i--) { 411 | const j = path.slice(0, i) 412 | if (!request.include.some(deepEqual.bind(null, j))) 413 | request.include.push(j) 414 | } 415 | } 416 | 417 | // Attach sort option. 418 | if (reservedKeys.sort in query) 419 | request.options.sort = (Array.isArray(query.sort) ? 420 | query.sort : query.sort.split(',')) 421 | .reduce((sort, field) => { 422 | if (field.charAt(0) === '-') sort[inflect(field.slice(1))] = false 423 | else sort[inflect(field)] = true 424 | return sort 425 | }, {}) 426 | 427 | // Attach offset option. 428 | if (pageOffset in query) 429 | request.options.offset = Math.abs(parseInt(query[pageOffset], 10)) 430 | 431 | // Attach limit option. 432 | if (pageLimit in query) 433 | request.options.limit = Math.abs(parseInt(query[pageLimit], 10)) 434 | 435 | // Check limit option. 436 | const limit = request.options.limit 437 | if (!limit || limit > maxLimit) request.options.limit = maxLimit 438 | 439 | // Internal function to inflect field names. 440 | function inflect (x) { 441 | return inflectKeys ? inflection.camelize(underscore(x), true) : x 442 | } 443 | } 444 | 445 | 446 | function mapId (relatedType, link) { 447 | const ConflictError = this.errors.ConflictError 448 | 449 | if (link[reservedKeys.type] !== relatedType) 450 | throw new ConflictError('Data object field ' + 451 | `"${reservedKeys.type}" is invalid, it must be ` + 452 | `"${relatedType}", not "${link[reservedKeys.type]}".`) 453 | 454 | return castId.call(this, link[reservedKeys.id]) 455 | } 456 | 457 | 458 | function matchId (object, id) { 459 | return id === castId.call(this, object[reservedKeys.id]) 460 | } 461 | 462 | 463 | function castId (id) { 464 | if (!this.options.castNumericIds) return id 465 | 466 | // Stolen from jQuery source code: 467 | // https://api.jquery.com/jQuery.isNumeric/ 468 | const float = Number.parseFloat(id) 469 | return id - float + 1 >= 0 ? float : id 470 | } 471 | 472 | 473 | // Due to the inflection library not implementing the underscore feature as 474 | // as expected, it's done here. 475 | function underscore (s) { 476 | return s.replace(/-/g, '_') 477 | } 478 | 479 | 480 | function parseBuffer (payload) { 481 | const BadRequestError = this.errors.BadRequestError 482 | 483 | if (!Buffer.isBuffer(payload)) return payload 484 | 485 | try { 486 | return JSON.parse(payload.toString()) 487 | } 488 | catch (error) { 489 | throw new BadRequestError(`Invalid JSON: ${error.message}`) 490 | } 491 | } 492 | 493 | 494 | function union () { 495 | const result = [] 496 | const seen = {} 497 | let value 498 | let array 499 | 500 | for (let g = 0, h = arguments.length; g < h; g++) { 501 | array = arguments[g] 502 | 503 | for (let i = 0, j = array.length; i < j; i++) { 504 | value = array[i] 505 | if (!(value in seen)) { 506 | seen[value] = true 507 | result.push(value) 508 | } 509 | } 510 | } 511 | 512 | return result 513 | } 514 | 515 | 516 | function checkLowerCase (type, recordTypes) { 517 | const lowerCasedType = type.charAt(0).toLowerCase() + type.slice(1) 518 | return lowerCasedType in recordTypes ? lowerCasedType : type 519 | } 520 | 521 | 522 | function bool (value) { 523 | if (typeof value === 'string') 524 | return /^(true|t|yes|y|1)$/i.test(value.trim()) 525 | if (typeof value === 'number') return value === 1 526 | if (typeof value === 'boolean') return value 527 | 528 | return false 529 | } 530 | 531 | /** 532 | * Generate a list of which types are inflected. 533 | * @param {Boolean|Object} inflect setting from defaults or user override 534 | * @param {String[]} types array of declared types 535 | * @return {Object} 536 | */ 537 | function setInflectType (inflect, types) { 538 | // use inflections if set as object else start fresh 539 | const out = typeof inflect === 'boolean' ? {} : inflect 540 | 541 | // if inflect is boolean use it for all types, 542 | // else use default for unset types 543 | const setInflect = typeof inflect === 'boolean' ? inflect : inflectTypeDef 544 | 545 | // For each Type set inflection, if not set 546 | for (const t of types) { 547 | const plural = inflection.transform(t, typeInflections[1]) 548 | if (!(t in out)) 549 | out[t] = setInflect 550 | 551 | // allow checking on pluralized too 552 | if (!(plural in out)) 553 | out[plural] = out[t] 554 | } 555 | 556 | return out 557 | } 558 | 559 | function isValidRelationPath ( recordTypes, 560 | fieldsCurrentType, 561 | remainingPathSegments ) { 562 | if ( !remainingPathSegments.length ) 563 | return false 564 | 565 | const pathSegment = remainingPathSegments[0] 566 | 567 | if ( !fieldsCurrentType[pathSegment] ) 568 | return false 569 | 570 | if ( fieldsCurrentType[pathSegment].type 571 | && remainingPathSegments.length === 1 ) 572 | return true 573 | 574 | 575 | const nextTypeToCompare = fieldsCurrentType[pathSegment].link 576 | if ( nextTypeToCompare ) 577 | return isValidRelationPath( recordTypes, 578 | recordTypes[nextTypeToCompare], 579 | remainingPathSegments.slice(1) ) 580 | 581 | return false 582 | } 583 | 584 | function getLastTypeInPath ( recordTypes, 585 | fieldsCurrentType, 586 | remainingPathSegments ) { 587 | if ( !remainingPathSegments.length ) 588 | return fieldsCurrentType 589 | 590 | 591 | const pathSegment = remainingPathSegments[0] 592 | 593 | if ( !fieldsCurrentType[pathSegment] ) 594 | return {} 595 | 596 | 597 | const nextType = fieldsCurrentType[pathSegment].link 598 | if ( nextType ) 599 | return getLastTypeInPath( recordTypes, 600 | recordTypes[nextType], 601 | remainingPathSegments.slice(1) ) 602 | 603 | return fieldsCurrentType[pathSegment] 604 | } 605 | 606 | function isRelationFilter ( field ) { 607 | return field.split(':').length > 1 608 | } 609 | 610 | function getRelationFilterSegments ( field ) { 611 | return field.split(':') 612 | } 613 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const uriTemplates = require('uri-templates') 4 | const inflection = require('inflection') 5 | 6 | const settings = require('./settings') 7 | const typeInflections = settings.typeInflections 8 | const mediaType = settings.mediaType 9 | const reservedKeys = settings.reservedKeys 10 | const defaults = settings.defaults 11 | 12 | const pageLimit = settings.pageLimit 13 | const pageOffset = settings.pageOffset 14 | const encodedLimit = encodeURIComponent(pageLimit) 15 | const encodedOffset = encodeURIComponent(pageOffset) 16 | 17 | const helpers = require('./helpers') 18 | const mapRecord = helpers.mapRecord 19 | const matchId = helpers.matchId 20 | const mapId = helpers.mapId 21 | const castId = helpers.castId 22 | const initializeContext = helpers.initializeContext 23 | const underscore = helpers.underscore 24 | const parseBuffer = helpers.parseBuffer 25 | const checkLowerCase = helpers.checkLowerCase 26 | const setInflectType = helpers.setInflectType 27 | 28 | 29 | // JSON API is a compromise. There are many incidental complexities involved 30 | // to cover everyone's hypothetical use cases, such as resource identifier 31 | // objects for polymorphic link fields, and relationship entities which are an 32 | // entirely unnecessary complication. From a web architecture standpoint, it 33 | // assumes tight coupling with the API consumer and the HTTP protocol. For 34 | // example, it assumes that the client has *a priori* knowledge of types that 35 | // exist on the server, since links are optional. 36 | // 37 | // The format is verbose and tedious to implement by hand, I would not 38 | // recommend doing it yourself unless you're a masochist. Following the 39 | // recommendations and other de facto additions in clients such as Ember Data 40 | // are entirely superfluous. In short, I don't think it's a viable format for 41 | // the web at large. 42 | 43 | 44 | module.exports = Serializer => { 45 | let methods 46 | let keys 47 | let errors 48 | let castValue 49 | 50 | return Object.assign(class JsonApiSerializer extends Serializer { 51 | constructor (dependencies) { 52 | super(dependencies) 53 | 54 | const options = this.options 55 | methods = this.methods 56 | keys = this.keys 57 | errors = this.errors 58 | castValue = this.castValue 59 | 60 | const methodMap = { 61 | GET: methods.find, 62 | POST: methods.create, 63 | PATCH: methods.update, 64 | DELETE: methods.delete 65 | } 66 | 67 | // Set options. 68 | for (const key in defaults) 69 | if (!(key in options)) 70 | options[key] = defaults[key] 71 | 72 | // Convert type inflection check to object 73 | options.inflectType = setInflectType( 74 | options.inflectType, 75 | Object.getOwnPropertyNames(this.recordTypes) 76 | ) 77 | 78 | const uriTemplate = uriTemplates((options ? 79 | options.uriTemplate : null) || defaults.uriTemplate) 80 | 81 | Object.defineProperties(this, { 82 | 83 | // Parse the URI template. 84 | uriTemplate: { value: uriTemplate }, 85 | 86 | // Default method mapping. 87 | methodMap: { value: methodMap } 88 | 89 | }) 90 | } 91 | 92 | 93 | processRequest (contextRequest, request, response) { 94 | return initializeContext.call(this, contextRequest, request, response) 95 | } 96 | 97 | 98 | processResponse (contextResponse, request, response) { 99 | const options = this.options 100 | const jsonSpaces = options.jsonSpaces 101 | const bufferEncoding = options.bufferEncoding 102 | let payload = contextResponse.payload 103 | 104 | if (!contextResponse.meta) contextResponse.meta = {} 105 | if (!contextResponse.meta.headers) contextResponse.meta.headers = {} 106 | if (payload && payload.records) 107 | contextResponse = this.showResponse(contextResponse, 108 | request, payload.records, payload.include) 109 | if (contextResponse instanceof Error) { 110 | if (contextResponse.isMethodInvalid) return contextResponse 111 | if (contextResponse.isTypeUnspecified) 112 | this.showIndex(contextResponse, request, response) 113 | else this.showError(contextResponse) 114 | } 115 | 116 | payload = contextResponse.payload 117 | if (!payload) return contextResponse 118 | 119 | contextResponse.payload = JSON.stringify(payload, (key, value) => { 120 | // Duck type checking for buffer stringification. 121 | if (value && value.type === 'Buffer' && 122 | Array.isArray(value.data) && 123 | Object.keys(value).length === 2) 124 | return Buffer.from(value.data).toString(bufferEncoding) 125 | 126 | return value 127 | }, jsonSpaces) 128 | 129 | return contextResponse 130 | } 131 | 132 | 133 | showIndex (contextResponse, request, response) { 134 | const recordTypes = this.recordTypes 135 | const uriTemplate = this.uriTemplate 136 | const options = this.options 137 | const jsonapi = options.jsonapi 138 | const inflectType = options.inflectType 139 | const prefix = options.prefix 140 | 141 | contextResponse.payload = { 142 | [reservedKeys.jsonapi]: jsonapi, 143 | [reservedKeys.meta]: {}, 144 | [reservedKeys.links]: {} 145 | } 146 | 147 | for (let type in recordTypes) { 148 | if (inflectType[type]) type = 149 | inflection.transform(type, typeInflections[1]) 150 | contextResponse.payload[reservedKeys.links][type] = prefix + 151 | uriTemplate.fillFromObject({ type }) 152 | } 153 | response.statusCode = 200 154 | } 155 | 156 | 157 | showResponse (contextResponse, request, records, include) { 158 | const uriTemplate = this.uriTemplate 159 | const recordTypes = this.recordTypes 160 | const options = this.options 161 | const jsonapi = options.jsonapi 162 | const prefix = options.prefix 163 | const inflectType = options.inflectType 164 | const inflectKeys = options.inflectKeys 165 | const NotFoundError = errors.NotFoundError 166 | 167 | const meta = request.meta 168 | const method = meta.method 169 | const type = meta.type 170 | const ids = meta.ids 171 | const relatedField = meta.relatedField 172 | const relationship = meta.relationship 173 | const originalType = meta.originalType 174 | const originalIds = meta.originalIds 175 | const updateModified = contextResponse.meta.updateModified 176 | 177 | if (relationship) 178 | return this.showRelationship(contextResponse, request, records) 179 | 180 | // Handle a not found error. 181 | if (ids && ids.length && method === methods.find && 182 | !relatedField && !records.length) 183 | return new NotFoundError('No records match the request.') 184 | 185 | // Delete and update requests may not respond with anything. 186 | if (method === methods.delete || 187 | (method === methods.update && !updateModified)) { 188 | delete contextResponse.payload 189 | return contextResponse 190 | } 191 | 192 | const output = { [reservedKeys.jsonapi]: jsonapi } 193 | 194 | // Show collection. 195 | if (!ids && method === methods.find) { 196 | const count = records.count 197 | const query = meta.uriObject.query 198 | const limit = meta.options.limit 199 | const offset = meta.options.offset 200 | const collection = prefix + uriTemplate.fillFromObject({ 201 | query, 202 | type: inflectType[type] ? 203 | inflection.transform(type, typeInflections[1]) : type 204 | }) 205 | 206 | output[reservedKeys.meta] = { count } 207 | output[reservedKeys.links] = { 208 | [reservedKeys.self]: collection 209 | } 210 | output[reservedKeys.primary] = [] 211 | // Set top-level pagination links. 212 | if (count > limit) { 213 | let queryLength = 0 214 | 215 | if (query) { 216 | delete query[pageOffset] 217 | delete query[pageLimit] 218 | queryLength = Object.keys(query).length 219 | } 220 | 221 | const paged = prefix + uriTemplate.fillFromObject({ 222 | query, 223 | type: inflectType[type] ? 224 | inflection.transform(type, typeInflections[1]) : type 225 | }) 226 | 227 | Object.assign(output[reservedKeys.links], { 228 | [reservedKeys.first]: `${paged}${queryLength ? '&' : '?'}` + 229 | `${encodedOffset}=0` + 230 | `&${encodeURIComponent(pageLimit)}=${limit}`, 231 | [reservedKeys.last]: `${paged}${queryLength ? '&' : '?'}` + 232 | `${encodedOffset}=${Math.floor((count - 1) / limit) * limit}` + 233 | `&${encodedLimit}=${limit}` 234 | }, 235 | limit + (offset || 0) < count ? { 236 | [reservedKeys.next]: `${paged}${queryLength ? '&' : '?'}` + 237 | `${encodedOffset}=${(Math.floor((offset || 0) / limit) + 1) * 238 | limit}&${encodedLimit}=${limit}` 239 | } : null, 240 | (offset || 0) >= limit ? { 241 | [reservedKeys.prev]: `${paged}${queryLength ? '&' : '?'}` + 242 | `${encodedOffset}=${(Math.floor((offset || 0) / limit) - 1) * 243 | limit}&${encodedLimit}=${limit}` 244 | } : null) 245 | } 246 | } 247 | 248 | if (records.length) { 249 | if (ids) 250 | output[reservedKeys.links] = { 251 | [reservedKeys.self]: prefix + uriTemplate.fillFromObject({ 252 | type: inflectType[type] ? 253 | inflection.transform(type, typeInflections[1]) : type, 254 | ids 255 | }) 256 | } 257 | 258 | output[reservedKeys.primary] = records.map(record => 259 | mapRecord.call(this, type, record)) 260 | 261 | if ((!originalType || (originalType && 262 | !recordTypes[originalType][relatedField][keys.isArray])) && 263 | ((ids && ids.length === 1) || 264 | (method === methods.create && records.length === 1))) 265 | output[reservedKeys.primary] = output[reservedKeys.primary][0] 266 | 267 | if (method === methods.create) 268 | contextResponse.meta.headers['Location'] = prefix + 269 | uriTemplate.fillFromObject({ 270 | type: inflectType[type] ? 271 | inflection.transform(type, typeInflections[1]) : type, 272 | ids: records.map(record => record[keys.primary]) 273 | }) 274 | } 275 | else if (relatedField) 276 | output[reservedKeys.primary] = 277 | recordTypes[originalType][relatedField][keys.isArray] ? [] : null 278 | 279 | // Set related records. 280 | if (relatedField) 281 | output[reservedKeys.links] = { 282 | [reservedKeys.self]: prefix + uriTemplate.fillFromObject({ 283 | type: inflectType[originalType] ? 284 | inflection.transform(originalType, typeInflections[1]) : 285 | originalType, 286 | ids: originalIds, 287 | relatedField: inflectKeys ? inflection.transform(relatedField, 288 | [ 'underscore', 'dasherize' ]) : relatedField 289 | }) 290 | } 291 | 292 | // To show included records, we have to flatten them :( 293 | if (include) { 294 | output[reservedKeys.included] = [] 295 | 296 | for (const type of Object.keys(include)) 297 | Array.prototype.push.apply(output[reservedKeys.included], 298 | include[type].map(mapRecord.bind(this, type))) 299 | } 300 | 301 | if (Object.keys(output).length) 302 | contextResponse.payload = output 303 | 304 | return contextResponse 305 | } 306 | 307 | 308 | showRelationship (contextResponse, request, records) { 309 | const meta = request.meta 310 | const method = meta.method 311 | const type = meta.type 312 | const relatedField = meta.relatedField 313 | const originalType = meta.originalType 314 | const originalIds = meta.originalIds 315 | 316 | const uriTemplate = this.uriTemplate 317 | const recordTypes = this.recordTypes 318 | const options = this.options 319 | const jsonapi = options.jsonapi 320 | const prefix = options.prefix 321 | const inflectType = options.inflectType 322 | const inflectKeys = options.inflectKeys 323 | const BadRequestError = errors.BadRequestError 324 | 325 | if (originalIds.length > 1) 326 | throw new BadRequestError( 327 | 'Can only show relationships for one record at a time.') 328 | 329 | if (method !== methods.find) { 330 | delete contextResponse.payload 331 | return contextResponse 332 | } 333 | 334 | const output = { 335 | [reservedKeys.jsonapi]: jsonapi, 336 | [reservedKeys.links]: { 337 | [reservedKeys.self]: prefix + uriTemplate.fillFromObject({ 338 | type: inflectType[originalType] ? 339 | inflection.transform(originalType, typeInflections[1]) : 340 | originalType, 341 | ids: originalIds, relatedField: reservedKeys.relationships, 342 | relationship: inflectKeys ? inflection.transform(relatedField, 343 | [ 'underscore', 'dasherize' ]) : relatedField 344 | }), 345 | [reservedKeys.related]: prefix + uriTemplate.fillFromObject({ 346 | type: inflectType[originalType] ? 347 | inflection.transform(originalType, typeInflections[1]) : 348 | originalType, 349 | ids: originalIds, 350 | relatedField: inflectKeys ? inflection.transform(relatedField, 351 | [ 'underscore', 'dasherize' ]) : relatedField 352 | }) 353 | } 354 | } 355 | 356 | const isArray = recordTypes[originalType][relatedField][keys.isArray] 357 | const identifiers = records.map(record => ({ 358 | [reservedKeys.type]: inflectType[type] ? 359 | inflection.transform(type, typeInflections[1]) : type, 360 | [reservedKeys.id]: record[keys.primary].toString() 361 | })) 362 | 363 | output[reservedKeys.primary] = isArray ? identifiers : 364 | identifiers.length ? identifiers[0] : null 365 | 366 | contextResponse.payload = output 367 | 368 | return contextResponse 369 | } 370 | 371 | 372 | parsePayload (contextRequest) { 373 | const method = contextRequest.method 374 | 375 | if (method === methods.create) 376 | return this.parseCreate(contextRequest) 377 | else if (method === methods.update) 378 | return this.parseUpdate(contextRequest) 379 | 380 | throw new Error('Method is invalid.') 381 | } 382 | 383 | 384 | parseCreate (contextRequest) { 385 | contextRequest.payload = parseBuffer.call(this, contextRequest.payload) 386 | const recordTypes = this.recordTypes 387 | const options = this.options 388 | const inflectType = options.inflectType 389 | const inflectKeys = options.inflectKeys 390 | const MethodError = errors.MethodError 391 | const BadRequestError = errors.BadRequestError 392 | const ConflictError = errors.ConflictError 393 | 394 | const payload = contextRequest.payload 395 | const relatedField = contextRequest.relatedField 396 | const type = contextRequest.type 397 | const ids = contextRequest.ids 398 | 399 | const fields = recordTypes[type] 400 | const cast = (type, options) => value => castValue(value, type, options) 401 | 402 | // Can not create with IDs specified in route. 403 | if (ids) 404 | throw new MethodError('Can not create with ID in the route.') 405 | 406 | // Can not create if related records are specified. 407 | if (relatedField) 408 | throw new MethodError('Can not create related record.') 409 | 410 | let data = payload[reservedKeys.primary] 411 | 412 | // No bulk extension for now. 413 | if (Array.isArray(data)) 414 | throw new BadRequestError('Data must be singular.') 415 | 416 | data = [ data ] 417 | 418 | return data.map(record => { 419 | if (!(reservedKeys.type in record)) 420 | throw new BadRequestError( 421 | `The required field "${reservedKeys.type}" is missing.`) 422 | 423 | const clone = {} 424 | const recordType = inflectType[record[reservedKeys.type]] ? 425 | checkLowerCase(inflection.transform( 426 | underscore(record[reservedKeys.type]), 427 | typeInflections[0]), recordTypes) : 428 | record[reservedKeys.type] 429 | 430 | if (recordType !== type) 431 | throw new ConflictError('Incorrect type.') 432 | 433 | if (reservedKeys.id in record) 434 | clone[reservedKeys.id] = castId.call(this, record[keys.primary]) 435 | 436 | if (reservedKeys.attributes in record) 437 | for (let field in record[reservedKeys.attributes]) { 438 | const value = record[reservedKeys.attributes][field] 439 | 440 | if (inflectKeys) 441 | field = inflection.camelize(underscore(field), true) 442 | 443 | const fieldDefinition = fields[field] || {} 444 | const fieldType = fieldDefinition[keys.type] 445 | 446 | clone[field] = Array.isArray(value) ? 447 | value.map(cast(fieldType, options)) : 448 | castValue(value, fieldType, options) 449 | } 450 | 451 | if (reservedKeys.relationships in record) 452 | for (let field of Object.keys(record[reservedKeys.relationships])) { 453 | const value = record[reservedKeys.relationships][field] 454 | 455 | if (inflectKeys) 456 | field = inflection.camelize(underscore(field), true) 457 | 458 | if (!(reservedKeys.primary in value)) 459 | throw new BadRequestError('The ' + 460 | `"${reservedKeys.primary}" field is missing.`) 461 | 462 | const linkKey = fields[field][keys.link] 463 | const relatedType = inflectType[linkKey] ? inflection.transform( 464 | linkKey, typeInflections[1]) : 465 | linkKey 466 | const relatedIsArray = fields[field][keys.isArray] 467 | const data = value[reservedKeys.primary] 468 | 469 | clone[field] = data ? (Array.isArray(data) ? data : [ data ]) 470 | .map(mapId.bind(this, relatedType)) : null 471 | 472 | if (clone[field] && !relatedIsArray) 473 | clone[field] = clone[field][0] 474 | } 475 | 476 | return clone 477 | }) 478 | } 479 | 480 | 481 | parseUpdate (contextRequest) { 482 | contextRequest.payload = parseBuffer.call(this, contextRequest.payload) 483 | 484 | const recordTypes = this.recordTypes 485 | 486 | const options = this.options 487 | const inflectType = options.inflectType 488 | const inflectKeys = options.inflectKeys 489 | 490 | const MethodError = errors.MethodError 491 | const BadRequestError = errors.BadRequestError 492 | const ConflictError = errors.ConflictError 493 | 494 | const payload = contextRequest.payload 495 | const type = contextRequest.type 496 | const ids = contextRequest.ids 497 | const relatedField = contextRequest.relatedField 498 | const relationship = contextRequest.relationship 499 | 500 | const cast = (type, options) => value => castValue(value, type, options) 501 | 502 | if (relationship) return this.updateRelationship(contextRequest) 503 | 504 | // No related record update. 505 | if (relatedField) throw new MethodError( 506 | 'Can not update related record indirectly.') 507 | 508 | // Can't update collections. 509 | if (!Array.isArray(ids) || !ids.length) 510 | throw new BadRequestError('IDs unspecified.') 511 | 512 | const fields = recordTypes[type] 513 | const updates = [] 514 | let data = payload[reservedKeys.primary] 515 | 516 | // No bulk/patch extension for now. 517 | if (Array.isArray(data)) 518 | throw new BadRequestError('Data must be singular.') 519 | 520 | data = [ data ] 521 | 522 | for (const update of data) { 523 | const replace = {} 524 | const updateType = inflectType[update[reservedKeys.type]] ? 525 | checkLowerCase(inflection.transform( 526 | underscore(update[reservedKeys.type]), 527 | typeInflections[0]), recordTypes) : 528 | update[reservedKeys.type] 529 | 530 | if (!ids.some(matchId.bind(this, update))) 531 | throw new ConflictError('Invalid ID.') 532 | 533 | if (updateType !== type) 534 | throw new ConflictError('Incorrect type.') 535 | 536 | if (reservedKeys.attributes in update) 537 | for (let field in update[reservedKeys.attributes]) { 538 | const value = update[reservedKeys.attributes][field] 539 | 540 | if (inflectKeys) 541 | field = inflection.camelize(underscore(field), true) 542 | 543 | const fieldDefinition = fields[field] || {} 544 | const fieldType = fieldDefinition[keys.type] 545 | 546 | replace[field] = Array.isArray(value) ? 547 | value.map(cast(fieldType, options)) : 548 | castValue(value, fieldType, options) 549 | } 550 | 551 | if (reservedKeys.relationships in update) 552 | for (let field of Object.keys(update[reservedKeys.relationships])) { 553 | const value = update[reservedKeys.relationships][field] 554 | 555 | if (inflectKeys) 556 | field = inflection.camelize(underscore(field), true) 557 | 558 | if (!(reservedKeys.primary in value)) 559 | throw new BadRequestError( 560 | `The "${reservedKeys.primary}" field is missing.`) 561 | 562 | const relatedType = inflectType[fields[field][keys.link]] ? 563 | inflection.transform(fields[field][keys.link], 564 | typeInflections[1]) : fields[field][keys.link] 565 | const relatedIsArray = fields[field][keys.isArray] 566 | const data = value[reservedKeys.primary] 567 | 568 | replace[field] = data ? (Array.isArray(data) ? data : [ data ]) 569 | .map(mapId.bind(this, relatedType)) : null 570 | 571 | if (replace[field] && !relatedIsArray) 572 | replace[field] = replace[field][0] 573 | } 574 | 575 | updates.push({ 576 | id: castId.call(this, update[keys.primary]), 577 | replace 578 | }) 579 | } 580 | 581 | if (updates.length < ids.length) 582 | throw new BadRequestError('An update is missing.') 583 | 584 | return updates 585 | } 586 | 587 | 588 | updateRelationship (contextRequest) { 589 | const recordTypes = this.recordTypes 590 | const options = this.options 591 | const inflectType = options.inflectType 592 | 593 | const NotFoundError = errors.NotFoundError 594 | const MethodError = errors.MethodError 595 | const BadRequestError = errors.BadRequestError 596 | const ConflictError = errors.ConflictError 597 | 598 | const payload = contextRequest.payload 599 | const type = contextRequest.type 600 | const relatedField = contextRequest.relatedField 601 | const originalMethod = contextRequest.originalMethod 602 | const originalType = contextRequest.originalType 603 | const originalIds = contextRequest.originalIds 604 | 605 | const isArray = recordTypes[originalType][relatedField][keys.isArray] 606 | const isNull = !isArray && payload[reservedKeys.primary] === null 607 | 608 | if (isNull) { 609 | // Rewrite type and IDs. 610 | contextRequest.type = originalType 611 | contextRequest.ids = null 612 | 613 | return [{ 614 | id: originalIds[0], 615 | replace: { 616 | [relatedField]: null 617 | } 618 | }] 619 | } 620 | 621 | if (originalIds.length > 1) 622 | throw new NotFoundError( 623 | 'Can only update relationships for one record at a time.') 624 | 625 | if (!isArray && originalMethod) 626 | throw new MethodError('Can not ' + 627 | `${originalMethod === methods.create ? 'push to' : 'pull from'}` + 628 | ' a to-one relationship.') 629 | 630 | const updates = [] 631 | const operation = originalMethod ? originalMethod === methods.create ? 632 | 'push' : 'pull' : 'replace' 633 | let updateIds = payload[reservedKeys.primary] 634 | 635 | if (!isArray) 636 | if (!Array.isArray(updateIds)) updateIds = [ updateIds ] 637 | else throw new BadRequestError('Data must be singular.') 638 | 639 | updateIds = updateIds.map(update => { 640 | const updateType = inflectType[update[reservedKeys.type]] ? 641 | checkLowerCase(inflection.transform( 642 | underscore(update[reservedKeys.type]), 643 | typeInflections[0]), recordTypes) : 644 | update[reservedKeys.type] 645 | 646 | if (updateType !== type) 647 | throw new ConflictError('Incorrect type.') 648 | 649 | if (!(reservedKeys.id in update)) 650 | throw new BadRequestError('ID is unspecified.') 651 | 652 | return castId.call(this, update[keys.primary]) 653 | }) 654 | 655 | updates.push({ 656 | id: originalIds[0], 657 | [operation]: { 658 | [relatedField]: isArray ? updateIds : updateIds[0] 659 | } 660 | }) 661 | 662 | // Rewrite type and IDs. 663 | contextRequest.type = originalType 664 | contextRequest.ids = null 665 | 666 | return updates 667 | } 668 | 669 | 670 | showError (error) { 671 | const obj = { 672 | title: error.name, 673 | detail: error.message 674 | } 675 | 676 | for (const key in error) { 677 | // Omit useless keys from the error. 678 | if (key === 'meta' && Object.keys(error[key]).length === 1) continue 679 | if (key === 'payload' && !error[key]) continue 680 | obj[key] = error[key] 681 | } 682 | 683 | error.payload = { 684 | [reservedKeys.jsonapi]: this.options.jsonapi, 685 | [reservedKeys.errors]: [ obj ] 686 | } 687 | } 688 | }, { mediaType }) 689 | } 690 | -------------------------------------------------------------------------------- /lib/settings.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // Reserved keys from the JSON API specification. 4 | const reservedKeys = { 5 | // Top-level description. 6 | jsonapi: 'jsonapi', 7 | 8 | // Document structure. 9 | primary: 'data', 10 | attributes: 'attributes', 11 | relationships: 'relationships', 12 | type: 'type', 13 | id: 'id', 14 | meta: 'meta', 15 | errors: 'errors', 16 | included: 'included', 17 | 18 | // Hypertext. 19 | links: 'links', 20 | href: 'href', 21 | related: 'related', 22 | self: 'self', 23 | 24 | // Reserved query strings. 25 | include: 'include', 26 | fields: 'fields', 27 | filter: 'filter', 28 | sort: 'sort', 29 | page: 'page', 30 | 31 | // Pagination keys. 32 | first: 'first', 33 | last: 'last', 34 | prev: 'prev', 35 | next: 'next' 36 | } 37 | 38 | const defaults = { 39 | // Inflect the record type name in the URI. The expectation is that the 40 | // record type names are singular, so this will pluralize types. 41 | inflectType: true, 42 | 43 | // Inflect the names of the fields per record. The expectation is that the 44 | // keys are lower camel cased, and the output is dasherized. 45 | inflectKeys: true, 46 | 47 | // Maximum number of records to show per page. 48 | maxLimit: 1000, 49 | 50 | // Maximum number of fields per include. 51 | includeLimit: 3, 52 | 53 | // What encoding to use for input buffer fields. 54 | bufferEncoding: 'base64', 55 | 56 | // How many spaces to use for pretty printing JSON. 57 | jsonSpaces: 2, 58 | 59 | // Hyperlink prefix, without leading or trailing slashes. 60 | prefix: '', 61 | 62 | // Versioning information. 63 | jsonapi: { 64 | version: '1.0' 65 | }, 66 | 67 | // Turn numeric string IDs into numbers. 68 | castNumericIds: true, 69 | 70 | // URI Template. See RFC 6570: 71 | // https://tools.ietf.org/html/rfc6570 72 | uriTemplate: '{/type,ids,relatedField,relationship}{?query*}', 73 | 74 | // What HTTP methods may be allowed, ordered by appearance in URI template. 75 | allowLevel: [ 76 | [ 'GET' ], // Index 77 | [ 'GET', 'POST' ], // Collection 78 | [ 'GET', 'PATCH', 'DELETE' ], // Records 79 | [ 'GET' ], // Related 80 | [ 'GET', 'POST', 'PATCH', 'DELETE' ] // Relationship 81 | ] 82 | } 83 | 84 | module.exports = { 85 | reservedKeys, defaults, 86 | 87 | typeInflections: [ 88 | [ 'singularize', 'camelize' ], // Input 89 | [ 'pluralize', 'underscore', 'dasherize' ] // Output 90 | ], 91 | 92 | // Registered media type. 93 | mediaType: 'application/vnd.api+json', 94 | 95 | // Regular expressions. 96 | inBrackets: /\[([^\]]+)\](?:\[([^\]]+)\])?/, 97 | isField: new RegExp(`^${reservedKeys.fields}`), 98 | isFilter: new RegExp(`^${reservedKeys.filter}`), 99 | pageLimit: `${reservedKeys.page}[limit]`, 100 | pageOffset: `${reservedKeys.page}[offset]` 101 | } 102 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fortune-json-api", 3 | "description": "JSON API serializer for Fortune.", 4 | "version": "2.3.1", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "git@github.com:fortunejs/fortune-json-api.git" 9 | }, 10 | "bugs": "https://github.com/fortunejs/fortune-json-api/issues", 11 | "scripts": { 12 | "postpublish": "npm run tag", 13 | "tag": "git tag `npm v fortune-json-api version` && git push origin --tags", 14 | "test": "npm run lint && node test", 15 | "lint": "eslint lib test" 16 | }, 17 | "dependencies": { 18 | "deep-equal": "^2.0.1", 19 | "inflection": "^1.13.4", 20 | "uri-templates": "^0.2.0" 21 | }, 22 | "devDependencies": { 23 | "ajv": "^8", 24 | "ajv-formats": "^2.1.1", 25 | "chalk": "^3.0.0", 26 | "eslint": "^6.8.0", 27 | "eslint-config-boss": "^1.0.6", 28 | "fortune": "^5.5.18", 29 | "fortune-http": "^1.2.26", 30 | "tapdance": "^5.1.1" 31 | }, 32 | "files": [ 33 | "lib/", 34 | "LICENSE" 35 | ], 36 | "main": "lib/index.js", 37 | "eslintConfig": { 38 | "extends": "boss", 39 | "rules": { 40 | "strict": 0, 41 | "indent": 0 42 | } 43 | }, 44 | "keywords": [ 45 | "json", 46 | "api", 47 | "fortune", 48 | "http", 49 | "hypermedia", 50 | "rest", 51 | "serializer" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const deepEqual = require('deep-equal') 4 | const qs = require('querystring') 5 | const Ajv = require('ajv').default 6 | const addFormats = require('ajv-formats').default 7 | 8 | const run = require('tapdance') 9 | 10 | const httpTest = require('fortune-http/test/http_test') 11 | const jsonApi = require('../lib') 12 | const jsonApiResponseSchema = require('./json-api-response-schema.json') 13 | 14 | const ajv = new Ajv({allErrors: true, v5: true}) 15 | addFormats(ajv) 16 | const validate = ajv.compile(jsonApiResponseSchema) 17 | 18 | const mediaType = 'application/vnd.api+json' 19 | const test = httpTest.bind(null, { 20 | serializers: [ 21 | [ 22 | jsonApi, { 23 | prefix: '' 24 | } 25 | ] 26 | ] 27 | }) 28 | const inflectTest = httpTest.bind(null, { 29 | serializers: [ 30 | [ 31 | jsonApi, { 32 | prefix: '', 33 | inflectType: { 34 | animal: false 35 | } 36 | } 37 | ] 38 | ] 39 | }) 40 | const uncastNumericIdsTest = httpTest.bind(null, { 41 | serializers: [ 42 | [ 43 | jsonApi, { 44 | prefix: '', 45 | castNumericIds: false 46 | } 47 | ] 48 | ] 49 | }) 50 | const prefixTest = httpTest.bind(null, { 51 | serializers: [ 52 | [ 53 | jsonApi, { 54 | prefix: 'https://example.com' 55 | } 56 | ] 57 | ] 58 | }) 59 | 60 | 61 | run((assert, comment) => { 62 | comment('get ad hoc index') 63 | return test('/', null, response => { 64 | assert(validate(response.body), 'response adheres to json api') 65 | assert(response.status === 200, 'status is correct') 66 | assert(response.headers['content-type'] === mediaType, 67 | 'content type is correct') 68 | }) 69 | }) 70 | 71 | 72 | run((assert, comment) => { 73 | comment('create record') 74 | return test('/animals', { 75 | method: 'post', 76 | headers: { 'Content-Type': mediaType }, 77 | body: { 78 | data: { 79 | id: 4, 80 | type: 'animal', 81 | attributes: { 82 | name: 'Rover', 83 | type: 'Chihuahua', 84 | birthday: new Date().toJSON(), 85 | picture: Buffer.from('This is a string.').toString('base64'), 86 | 'is-neutered': true, 87 | nicknames: [ 'Doge', 'The Dog' ], 88 | 'some-date': '2015-01-04T00:00:00.000Z' 89 | }, 90 | relationships: { 91 | owner: { 92 | data: { type: 'users', id: 1 } 93 | } 94 | } 95 | } 96 | } 97 | }, response => { 98 | assert(validate(response.body), 'response adheres to json api') 99 | assert(response.status === 201, 'status is correct') 100 | assert(response.headers['content-type'] === mediaType, 101 | 'content type is correct') 102 | assert(~response.headers['location'].indexOf('/animals/4'), 103 | 'location header looks right') 104 | assert(response.body.data.type === 'animals', 'type is correct') 105 | assert(response.body.data.attributes['is-neutered'] === true, 106 | 'inflected key value is correct') 107 | assert(Buffer.from(response.body.data.attributes.picture, 'base64') 108 | .toString() === 'This is a string.', 'buffer is correct') 109 | assert(Date.now() - new Date(response.body.data.attributes.birthday) 110 | .getTime() < 60 * 1000, 'date is close enough') 111 | }) 112 | }) 113 | 114 | 115 | run((assert, comment) => { 116 | comment('create record with existing ID should fail') 117 | return test('/users', { 118 | method: 'post', 119 | headers: { 'Content-Type': mediaType }, 120 | body: { 121 | data: { 122 | id: 1, 123 | type: 'user' 124 | } 125 | } 126 | }, response => { 127 | assert(validate(response.body), 'response adheres to json api') 128 | assert(response.status === 409, 'status is correct') 129 | assert(response.headers['content-type'] === mediaType, 130 | 'content type is correct') 131 | assert(response.body.errors.length === 1, 'error is correct') 132 | }) 133 | }) 134 | 135 | 136 | run((assert, comment) => { 137 | comment('create record with wrong route should fail') 138 | return test('/users/4', { 139 | method: 'post', 140 | headers: { 'Content-Type': mediaType }, 141 | body: {} 142 | }, response => { 143 | assert(validate(response.body), 'response adheres to json api') 144 | assert(response.status === 405, 'status is correct') 145 | assert(response.body.errors.length === 1, 'error exists') 146 | }) 147 | }) 148 | 149 | 150 | run((assert, comment) => { 151 | comment('create record with missing payload should fail') 152 | return test('/users', { 153 | method: 'post', 154 | headers: { 'Content-Type': mediaType }, 155 | body: {} 156 | }, response => { 157 | assert(validate(response.body), 'response adheres to json api') 158 | assert(response.status === 400, 'status is correct') 159 | assert(response.headers['content-type'] === mediaType, 160 | 'content type is correct') 161 | assert(response.body.errors.length === 1, 'error exists') 162 | }) 163 | }) 164 | 165 | 166 | run((assert, comment) => { 167 | comment('update record #1') 168 | return test('/users/2', { 169 | method: 'patch', 170 | headers: { 'Content-Type': mediaType }, 171 | body: { 172 | data: { 173 | id: 2, 174 | type: 'users', 175 | attributes: { 176 | name: 'Jenny Death', 177 | 'camel-case-field': 'foobar', 178 | 'birthday': '2015-01-07' 179 | }, 180 | relationships: { 181 | spouse: { 182 | data: { type: 'users', id: 3 } 183 | }, 184 | 'owned-pets': { 185 | data: [ 186 | { type: 'animals', id: 3 } 187 | ] 188 | }, 189 | enemies: { 190 | data: [ 191 | { type: 'users', id: 3 } 192 | ] 193 | }, 194 | friends: { 195 | data: [ 196 | { type: 'users', id: 1 }, 197 | { type: 'users', id: 3 } 198 | ] 199 | } 200 | } 201 | } 202 | } 203 | }, response => { 204 | assert(validate(response.body), 'response adheres to json api') 205 | assert(response.status === 200, 'status is correct') 206 | assert(Math.abs(new Date(response.body.data.attributes['last-modified-at']) 207 | .getTime() - Date.now()) < 5 * 1000, 'update modifier is correct') 208 | assert(response.body.data.attributes['birthday'] === 209 | '2015-01-07T00:00:00.000Z', 'inflected casted value is correct') 210 | }) 211 | }) 212 | 213 | 214 | run((assert, comment) => { 215 | comment('update record #2') 216 | return test('/animals/1', { 217 | method: 'patch', 218 | headers: { 'Content-Type': mediaType }, 219 | body: { 220 | data: { 221 | id: 1, 222 | type: 'animals', 223 | attributes: { 224 | nicknames: [ 'Foo', 'Bar' ] 225 | } 226 | } 227 | } 228 | }, response => { 229 | assert(validate(response.body), 'response adheres to json api') 230 | assert(response.status === 200, 'status is correct') 231 | assert(Math.abs(new Date(response.body.data.attributes['last-modified-at']) 232 | .getTime() - Date.now()) < 5 * 1000, 'update modifier is correct') 233 | }) 234 | }) 235 | 236 | 237 | run((assert, comment) => { 238 | comment('sort a collection and use sparse fields') 239 | return test( 240 | `/users?${qs.stringify({ 241 | 'sort': 'birthday,-name', 242 | 'fields[users]': 'name,birthday' 243 | })}`, null, response => { 244 | assert(validate(response.body), 'response adheres to json api') 245 | assert(response.status === 200, 'status is correct') 246 | assert(~response.body.links.self.indexOf('/users'), 'link is correct') 247 | assert(deepEqual( 248 | response.body.data.map(record => record.attributes.name), 249 | [ 'John Doe', 'Microsoft Bob', 'Jane Doe' ]), 250 | 'sort order is correct') 251 | }) 252 | }) 253 | 254 | 255 | run((assert, comment) => { 256 | comment('use limit option') 257 | return test( 258 | `/users?${qs.stringify({ 259 | 'page[limit]': 1 260 | })}`, null, response => { 261 | assert(validate(response.body), 'response adheres to json api') 262 | assert(response.status === 200, 'status is correct') 263 | assert('first' in response.body.links, 'pagination first included') 264 | assert('last' in response.body.links, 'pagination last included') 265 | assert('next' in response.body.links, 'pagination next included') 266 | 267 | for (const key in response.body.links) { 268 | if (key === 'self') continue 269 | assert(Object.keys(qs.parse( 270 | response.body.links[key].split('?')[1])).length === 2, 271 | 'number of query options correct') 272 | } 273 | 274 | assert(response.body.data.length === 1, 'limit option applied') 275 | }) 276 | }) 277 | 278 | 279 | run((assert, comment) => { 280 | comment('filter a collection') 281 | return test(`/users?${qs.stringify({ 282 | 'filter[name]': 'John Doe,Jane Doe', 283 | 'filter[birthday]': '1992-12-07' 284 | })}`, null, response => { 285 | assert(validate(response.body), 'response adheres to json api') 286 | assert(response.status === 200, 'status is correct') 287 | assert(~response.body.links.self.indexOf('/users'), 'link is correct') 288 | assert(deepEqual( 289 | response.body.data.map(record => record.attributes.name).sort(), 290 | [ 'John Doe' ]), 'match is correct') 291 | }) 292 | }) 293 | 294 | run((assert, comment) => { 295 | comment('filter a collection for exists') 296 | return test(`/users?${qs.stringify({ 297 | 'filter[picture][exists]': 'true' 298 | })}`, null, response => { 299 | assert(validate(response.body), 'response adheres to json api') 300 | assert(response.status === 200, 'status is correct') 301 | assert(~response.body.links.self.indexOf('/users'), 'link is correct') 302 | assert(deepEqual( 303 | response.body.data.map(record => record.attributes.name).sort(), 304 | [ 'Jane Doe', 'John Doe', 'Microsoft Bob' ]), 'match is correct') 305 | }) 306 | }) 307 | 308 | run((assert, comment) => { 309 | comment('filter a collection for not exists') 310 | return test(`/users?${qs.stringify({ 311 | 'filter[picture][exists]': 'false' 312 | })}`, null, response => { 313 | assert(validate(response.body), 'response adheres to json api') 314 | assert(response.status === 200, 'status is correct') 315 | assert(~response.body.links.self.indexOf('/users'), 'link is correct') 316 | assert(deepEqual( 317 | response.body.data.map(record => record.attributes.name).sort(), 318 | [ ]), 'match is correct') 319 | }) 320 | }) 321 | 322 | run((assert, comment) => { 323 | comment('filter a collection for range') 324 | return test(`/users?${qs.stringify({ 325 | 'filter[name][min]': 'Max', 326 | 'filter[name][max]': 'Min' 327 | })}`, null, response => { 328 | assert(validate(response.body), 'response adheres to json api') 329 | assert(response.status === 200, 'status is correct') 330 | assert(~response.body.links.self.indexOf('/users'), 'link is correct') 331 | assert(deepEqual( 332 | response.body.data.map(record => record.attributes.name).sort(), 333 | [ 'Microsoft Bob' ]), 'match is correct') 334 | }) 335 | }) 336 | 337 | run((assert, comment) => { 338 | comment(`filter fuzzy-match: Jane and John have 339 | a common friend called something like "soft".`) 340 | return test(`/users?${qs.stringify({ 341 | 'filter[friends:name][fuzzy-match]': 'soft' 342 | })}`, null, response => { 343 | assert(validate(response.body), 'response adheres to json api') 344 | assert(response.status === 200, 'status is correct') 345 | assert(~response.body.links.self.indexOf('/users'), 'link is correct') 346 | assert(deepEqual( 347 | response.body.data.map(record => record.attributes.name).sort(), 348 | [ 'Jane Doe', 'John Doe' ]), 'match is correct') 349 | }) 350 | }) 351 | 352 | run((assert, comment) => { 353 | comment('dasherizes the camel cased fields') 354 | return test('/users/1', null, response => { 355 | assert(validate(response.body), 'response adheres to json api') 356 | assert('created-at' in response.body.data.attributes, 357 | 'camel case field is correct') 358 | }) 359 | }) 360 | 361 | 362 | run((assert, comment) => { 363 | comment('find a single record with include') 364 | return test( 365 | `/animals/1?${qs.stringify({ include: 'owner.friends' })}`, 366 | null, response => { 367 | assert(validate(response.body), 'response adheres to json api') 368 | assert(response.status === 200, 'status is correct') 369 | assert(~response.body.links.self.indexOf('/animals/1'), 'link is correct') 370 | assert(response.body.data.id === '1', 'id is correct') 371 | assert(deepEqual(response.body.included.map(record => record.type), 372 | [ 'users', 'users' ]), 'type is correct') 373 | assert(deepEqual(response.body.included.map(record => record.id) 374 | .sort((a, b) => a - b), [ '1', '3' ]), 'id is correct') 375 | }) 376 | }) 377 | 378 | 379 | run((assert, comment) => { 380 | comment('show individual record with encoded ID') 381 | return test('/animals/%2Fwtf', null, response => { 382 | assert(validate(response.body), 'response adheres to json api') 383 | assert(response.status === 200, 'status is correct') 384 | assert(~response.body.links.self.indexOf('/animals/%2Fwtf'), 385 | 'link is correct') 386 | assert(response.body.data.id === '/wtf', 'id is correct') 387 | }) 388 | }) 389 | 390 | 391 | run((assert, comment) => { 392 | comment('find a single non-existent record') 393 | return test('/animals/404', null, response => { 394 | assert(validate(response.body), 'response adheres to json api') 395 | assert(response.status === 404, 'status is correct') 396 | assert('errors' in response.body, 'errors object exists') 397 | assert(response.body.errors[0].title === 'NotFoundError', 398 | 'title is correct') 399 | assert(response.body.errors[0].detail.length, 'detail exists') 400 | }) 401 | }) 402 | 403 | 404 | run((assert, comment) => { 405 | comment('delete a single record') 406 | return test('/animals/2', { method: 'delete' }, response => { 407 | assert(response.status === 204, 'status is correct') 408 | }) 409 | }) 410 | 411 | 412 | run((assert, comment) => { 413 | comment('find a singular related record') 414 | return test('/users/2/spouse', null, response => { 415 | assert(validate(response.body), 'response adheres to json api') 416 | assert(response.status === 200, 'status is correct') 417 | assert(~response.body.links.self.indexOf('/users/2/spouse'), 418 | 'link is correct') 419 | assert(!Array.isArray(response.body.data), 'data type is correct') 420 | }) 421 | }) 422 | 423 | 424 | run((assert, comment) => { 425 | comment('find a plural related record') 426 | return test('/users/2/owned-pets', null, response => { 427 | assert(validate(response.body), 'response adheres to json api') 428 | assert(response.status === 200, 'status is correct') 429 | assert(~response.body.links.self.indexOf('/users/2/owned-pets'), 430 | 'link is correct') 431 | assert(response.body.data.length === 2, 'data length is correct') 432 | }) 433 | }) 434 | 435 | 436 | run((assert, comment) => { 437 | comment('find a collection of non-existent related records') 438 | return test('/users/3/owned-pets', null, response => { 439 | assert(validate(response.body), 'response adheres to json api') 440 | assert(response.status === 200, 'status is correct') 441 | assert(~response.body.links.self.indexOf('/users/3/owned-pets'), 442 | 'link is correct') 443 | assert(Array.isArray(response.body.data) && !response.body.data.length, 444 | 'data is empty array') 445 | }) 446 | }) 447 | 448 | 449 | run((assert, comment) => { 450 | comment('find an empty collection') 451 | return test(encodeURI('/☯s'), null, response => { 452 | assert(validate(response.body), 'response adheres to json api') 453 | assert(response.status === 200, 'status is correct') 454 | assert(~response.body.links.self.indexOf( 455 | encodeURI('/☯s')), 'link is correct') 456 | assert(Array.isArray(response.body.data) && !response.body.data.length, 457 | 'data is empty array') 458 | }) 459 | }) 460 | 461 | 462 | run((assert, comment) => { 463 | comment('get an array relationship entity') 464 | return test('/users/2/relationships/owned-pets', null, response => { 465 | assert(validate(response.body), 'response adheres to json api') 466 | assert(response.status === 200, 'status is correct') 467 | assert(~response.body.links.self 468 | .indexOf('/users/2/relationships/owned-pets'), 469 | 'link is correct') 470 | assert(deepEqual(response.body.data.map(data => data.id), [ 2, 3 ]), 471 | 'ids are correct') 472 | }) 473 | }) 474 | 475 | 476 | run((assert, comment) => { 477 | comment('get an empty array relationship entity') 478 | return test('/users/3/relationships/owned-pets', null, response => { 479 | assert(validate(response.body), 'response adheres to json api') 480 | assert(response.status === 200, 'status is correct') 481 | assert(~response.body.links.self 482 | .indexOf('/users/3/relationships/owned-pets'), 483 | 'link is correct') 484 | assert(deepEqual(response.body.data, []), 'data is correct') 485 | }) 486 | }) 487 | 488 | 489 | run((assert, comment) => { 490 | comment('get a singular relationship entity') 491 | return test('/users/1/relationships/spouse', null, response => { 492 | assert(validate(response.body), 'response adheres to json api') 493 | assert(response.status === 200, 'status is correct') 494 | assert(~response.body.links.self.indexOf('/users/1/relationships/spouse'), 495 | 'link is correct') 496 | assert(response.body.data.type === 'users', 'type is correct') 497 | assert(response.body.data.id === '2', 'id is correct') 498 | }) 499 | }) 500 | 501 | 502 | run((assert, comment) => { 503 | comment('get an empty singular relationship entity') 504 | return test('/users/3/relationships/spouse', null, response => { 505 | assert(validate(response.body), 'response adheres to json api') 506 | assert(response.status === 200, 'status is correct') 507 | assert(~response.body.links.self.indexOf('/users/3/relationships/spouse'), 508 | 'link is correct') 509 | assert(response.body.data === null, 'data is correct') 510 | }) 511 | }) 512 | 513 | 514 | run((assert, comment) => { 515 | comment('update a singular relationship entity') 516 | return test('/users/2/relationships/spouse', { 517 | method: 'patch', 518 | headers: { 'Content-Type': mediaType }, 519 | body: { 520 | data: { type: 'users', id: 3 } 521 | } 522 | }, response => { 523 | assert(response.status === 204, 'status is correct') 524 | }) 525 | }) 526 | 527 | 528 | run((assert, comment) => { 529 | comment('update a singular relationship entity with null') 530 | return test('/users/2/relationships/spouse', { 531 | method: 'patch', 532 | headers: { 'Content-Type': mediaType }, 533 | body: { 534 | data: null 535 | } 536 | }, response => { 537 | assert(response.status === 204, 'status is correct') 538 | }) 539 | }) 540 | 541 | 542 | run((assert, comment) => { 543 | comment('update an array relationship entity') 544 | return test('/users/1/relationships/owned-pets', { 545 | method: 'patch', 546 | headers: { 'Content-Type': mediaType }, 547 | body: { 548 | data: [ { type: 'animals', id: 2 } ] 549 | } 550 | }, response => { 551 | assert(response.status === 204, 'status is correct') 552 | }) 553 | }) 554 | 555 | 556 | run((assert, comment) => { 557 | comment('post to an array relationship entity') 558 | return test('/users/1/relationships/owned-pets', { 559 | method: 'post', 560 | headers: { 'Content-Type': mediaType }, 561 | body: { 562 | data: [ { type: 'animals', id: 2 } ] 563 | } 564 | }, response => { 565 | assert(response.status === 204, 'status is correct') 566 | }) 567 | }) 568 | 569 | 570 | run((assert, comment) => { 571 | comment('delete from an array relationship entity') 572 | return test('/users/1/relationships/friends', { 573 | method: 'delete', 574 | headers: { 'Content-Type': mediaType }, 575 | body: { 576 | data: [ { type: 'users', id: 3 } ] 577 | } 578 | }, response => { 579 | assert(response.status === 204, 'status is correct') 580 | }) 581 | }) 582 | 583 | 584 | run((assert, comment) => { 585 | comment('respond to options: index') 586 | return test('/', { method: 'options' }, response => { 587 | assert(response.status === 204, 'status is correct') 588 | assert(response.headers['allow'] === 589 | 'GET', 'allow header is correct') 590 | }) 591 | }) 592 | 593 | 594 | run((assert, comment) => { 595 | comment('respond to options: collection') 596 | return test('/animals', { method: 'options' }, response => { 597 | assert(response.status === 204, 'status is correct') 598 | assert(response.headers['allow'] === 599 | 'GET, POST', 'allow header is correct') 600 | }) 601 | }) 602 | 603 | 604 | run((assert, comment) => { 605 | comment('respond to options: individual') 606 | return test('/animals/1', { method: 'options' }, response => { 607 | assert(response.status === 204, 'status is correct') 608 | assert(response.headers['allow'] === 609 | 'GET, PATCH, DELETE', 'allow header is correct') 610 | }) 611 | }) 612 | 613 | 614 | run((assert, comment) => { 615 | comment('respond to options: link') 616 | return test('/animals/1/owner', { method: 'options' }, response => { 617 | assert(response.status === 204, 'status is correct') 618 | assert(response.headers['allow'] === 619 | 'GET', 'allow header is correct') 620 | }) 621 | }) 622 | 623 | 624 | run((assert, comment) => { 625 | comment('respond to options: relationships') 626 | return test('/animals/1/relationships/owner', { method: 'options' }, 627 | response => { 628 | assert(response.status === 204, 'status is correct') 629 | assert(response.headers['allow'] === 630 | 'GET, POST, PATCH, DELETE', 'allow header is correct') 631 | }) 632 | }) 633 | 634 | 635 | run((assert, comment) => { 636 | comment('respond to options: fail') 637 | return test('/foo', { method: 'options' }, response => { 638 | assert(validate(response.body), 'response adheres to json api') 639 | assert(response.status === 404, 'status is correct') 640 | }) 641 | }) 642 | 643 | 644 | /* 645 | Testing Custom Inflections 646 | */ 647 | run((assert, comment) => { 648 | comment('create record with custom inflections') 649 | return inflectTest('/animal', { 650 | method: 'post', 651 | headers: { 'Content-Type': mediaType }, 652 | body: { 653 | data: { 654 | id: 4, 655 | type: 'animal', 656 | attributes: { 657 | name: 'Rover', 658 | type: 'Chihuahua', 659 | birthday: new Date().toJSON(), 660 | picture: Buffer.from('This is a string.').toString('base64'), 661 | 'is-neutered': true, 662 | nicknames: [ 'Doge', 'The Dog' ], 663 | 'some-date': '2015-01-04T00:00:00.000Z' 664 | }, 665 | relationships: { 666 | owner: { 667 | data: { type: 'users', id: 1 } 668 | } 669 | } 670 | } 671 | } 672 | }, response => { 673 | assert(validate(response.body), 'response adheres to json api') 674 | assert(response.status === 201, 'status is correct') 675 | assert(response.headers['content-type'] === mediaType, 676 | 'content type is correct') 677 | assert(~response.headers['location'].indexOf('/animal/4'), 678 | 'location header looks right') 679 | assert(response.body.data.type === 'animal', 'type is correct') 680 | assert(response.body.data.attributes['is-neutered'] === true, 681 | 'inflected key value is correct') 682 | assert(Buffer.from(response.body.data.attributes.picture, 'base64') 683 | .toString() === 'This is a string.', 'buffer is correct') 684 | assert(Date.now() - new Date(response.body.data.attributes.birthday) 685 | .getTime() < 60 * 1000, 'date is close enough') 686 | }) 687 | }) 688 | 689 | 690 | run((assert, comment) => { 691 | comment('update record #1') 692 | return inflectTest('/users/2', { 693 | method: 'patch', 694 | headers: { 'Content-Type': mediaType }, 695 | body: { 696 | data: { 697 | id: 2, 698 | type: 'users', 699 | attributes: { 700 | name: 'Jenny Death', 701 | 'camel-case-field': 'foobar', 702 | 'birthday': '2015-01-07' 703 | }, 704 | relationships: { 705 | spouse: { 706 | data: { type: 'users', id: 3 } 707 | }, 708 | 'owned-pets': { 709 | data: [ 710 | { type: 'animal', id: 3 } 711 | ] 712 | }, 713 | enemies: { 714 | data: [ 715 | { type: 'users', id: 3 } 716 | ] 717 | }, 718 | friends: { 719 | data: [ 720 | { type: 'users', id: 1 }, 721 | { type: 'users', id: 3 } 722 | ] 723 | } 724 | } 725 | } 726 | } 727 | }, response => { 728 | assert(validate(response.body), 'response adheres to json api') 729 | assert(response.status === 200, 'status is correct') 730 | assert(Math.abs(new Date(response.body.data.attributes['last-modified-at']) 731 | .getTime() - Date.now()) < 5 * 1000, 'update modifier is correct') 732 | assert(response.body.data.attributes['birthday'] === 733 | '2015-01-07T00:00:00.000Z', 'inflected casted value is correct') 734 | }) 735 | }) 736 | 737 | 738 | run((assert, comment) => { 739 | comment('show individual record with encoded ID') 740 | return inflectTest('/animal/%2Fwtf', null, response => { 741 | assert(validate(response.body), 'response adheres to json api') 742 | assert(response.status === 200, 'status is correct') 743 | assert(~response.body.links.self.indexOf('/animal/%2Fwtf'), 744 | 'link is correct') 745 | assert(response.body.data.id === '/wtf', 'id is correct') 746 | }) 747 | }) 748 | 749 | 750 | run((assert, comment) => { 751 | comment('find a single non-existent record') 752 | return inflectTest('/animal/404', null, response => { 753 | assert(validate(response.body), 'response adheres to json api') 754 | assert(response.status === 404, 'status is correct') 755 | assert('errors' in response.body, 'errors object exists') 756 | assert(response.body.errors[0].title === 'NotFoundError', 757 | 'title is correct') 758 | assert(response.body.errors[0].detail.length, 'detail exists') 759 | }) 760 | }) 761 | 762 | 763 | run((assert, comment) => { 764 | comment('delete a single record') 765 | return inflectTest('/animal/2', { method: 'delete' }, response => { 766 | assert(response.status === 204, 'status is correct') 767 | }) 768 | }) 769 | 770 | run((assert, comment) => { 771 | comment('uncast numeric ids') 772 | return uncastNumericIdsTest('/animals', { 773 | method: 'post', 774 | headers: { 'Content-Type': mediaType }, 775 | body: { 776 | data: { 777 | id: '4', 778 | type: 'animal', 779 | attributes: { 780 | name: 'Rover', 781 | type: 'Chihuahua', 782 | birthday: new Date().toJSON(), 783 | picture: Buffer.from('This is a string.').toString('base64'), 784 | 'is-neutered': true, 785 | nicknames: [ 'Doge', 'The Dog' ], 786 | 'some-date': '2015-01-04T00:00:00.000Z' 787 | }, 788 | relationships: { 789 | owner: { 790 | data: { type: 'users', id: '1' } 791 | } 792 | } 793 | } 794 | } 795 | }, response => { 796 | assert(validate(response.body), 'response adheres to json api') 797 | assert(response.status === 400, 'status is correct') 798 | assert(response.headers['content-type'] === mediaType, 799 | 'content type is correct') 800 | }) 801 | }) 802 | 803 | run((assert, comment) => { 804 | comment('deserialize prefix') 805 | return prefixTest('/', null, response => { 806 | assert(response.status === 200, 'status is correct') 807 | for (const key in response.body.links) { 808 | const value = response.body.links[key] 809 | assert(value.indexOf('https://') === 0, 'prefix is correct') 810 | } 811 | }) 812 | }) 813 | -------------------------------------------------------------------------------- /test/json-api-response-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "title": "JSON API Schema", 4 | "description": "This is a schema for responses in the JSON API format. For more, see http://jsonapi.org", 5 | "oneOf": [ 6 | { 7 | "$ref": "#/definitions/success" 8 | }, 9 | { 10 | "$ref": "#/definitions/failure" 11 | }, 12 | { 13 | "$ref": "#/definitions/info" 14 | } 15 | ], 16 | 17 | "definitions": { 18 | "success": { 19 | "type": "object", 20 | "required": [ 21 | "data" 22 | ], 23 | "properties": { 24 | "data": { 25 | "$ref": "#/definitions/data" 26 | }, 27 | "included": { 28 | "description": "To reduce the number of HTTP requests, servers **MAY** allow responses that include related resources along with the requested primary resources. Such responses are called \"compound documents\".", 29 | "type": "array", 30 | "items": { 31 | "$ref": "#/definitions/resource" 32 | }, 33 | "uniqueItems": true 34 | }, 35 | "meta": { 36 | "$ref": "#/definitions/meta" 37 | }, 38 | "links": { 39 | "description": "Link members related to the primary data.", 40 | "allOf": [ 41 | { 42 | "$ref": "#/definitions/links" 43 | }, 44 | { 45 | "$ref": "#/definitions/pagination" 46 | } 47 | ] 48 | }, 49 | "jsonapi": { 50 | "$ref": "#/definitions/jsonapi" 51 | } 52 | }, 53 | "additionalProperties": false 54 | }, 55 | "failure": { 56 | "type": "object", 57 | "required": [ 58 | "errors" 59 | ], 60 | "properties": { 61 | "errors": { 62 | "type": "array", 63 | "items": { 64 | "$ref": "#/definitions/error" 65 | }, 66 | "uniqueItems": true 67 | }, 68 | "meta": { 69 | "$ref": "#/definitions/meta" 70 | }, 71 | "jsonapi": { 72 | "$ref": "#/definitions/jsonapi" 73 | }, 74 | "links": { 75 | "$ref": "#/definitions/links" 76 | } 77 | }, 78 | "additionalProperties": false 79 | }, 80 | "info": { 81 | "type": "object", 82 | "required": [ 83 | "meta" 84 | ], 85 | "properties": { 86 | "meta": { 87 | "$ref": "#/definitions/meta" 88 | }, 89 | "links": { 90 | "$ref": "#/definitions/links" 91 | }, 92 | "jsonapi": { 93 | "$ref": "#/definitions/jsonapi" 94 | } 95 | }, 96 | "additionalProperties": false 97 | }, 98 | 99 | "meta": { 100 | "description": "Non-standard meta-information that can not be represented as an attribute or relationship.", 101 | "type": "object", 102 | "additionalProperties": true 103 | }, 104 | "data": { 105 | "description": "The document's \"primary data\" is a representation of the resource or collection of resources targeted by a request.", 106 | "oneOf": [ 107 | { 108 | "$ref": "#/definitions/resource" 109 | }, 110 | { 111 | "description": "An array of resource objects, an array of resource identifier objects, or an empty array ([]), for requests that target resource collections.", 112 | "type": "array", 113 | "items": { 114 | "$ref": "#/definitions/resource" 115 | }, 116 | "uniqueItems": true 117 | }, 118 | { 119 | "description": "null if the request is one that might correspond to a single resource, but doesn't currently.", 120 | "type": "null" 121 | } 122 | ] 123 | }, 124 | "resource": { 125 | "description": "\"Resource objects\" appear in a JSON API document to represent resources.", 126 | "type": "object", 127 | "required": [ 128 | "type", 129 | "id" 130 | ], 131 | "properties": { 132 | "type": { 133 | "type": "string" 134 | }, 135 | "id": { 136 | "type": "string" 137 | }, 138 | "attributes": { 139 | "$ref": "#/definitions/attributes" 140 | }, 141 | "relationships": { 142 | "$ref": "#/definitions/relationships" 143 | }, 144 | "links": { 145 | "$ref": "#/definitions/links" 146 | }, 147 | "meta": { 148 | "$ref": "#/definitions/meta" 149 | } 150 | }, 151 | "additionalProperties": false 152 | }, 153 | 154 | "links": { 155 | "description": "A resource object **MAY** contain references to other resource objects (\"relationships\"). Relationships may be to-one or to-many. Relationships can be specified by including a member in a resource's links object.", 156 | "type": "object", 157 | "properties": { 158 | "self": { 159 | "description": "A `self` member, whose value is a URL for the relationship itself (a \"relationship URL\"). This URL allows the client to directly manipulate the relationship. For example, it would allow a client to remove an `author` from an `article` without deleting the people resource itself.", 160 | "type": "string", 161 | "format": "uri-reference" 162 | }, 163 | "related": { 164 | "$ref": "#/definitions/link" 165 | } 166 | }, 167 | "additionalProperties": true 168 | }, 169 | "link": { 170 | "description": "A link **MUST** be represented as either: a string containing the link's URL or a link object.", 171 | "oneOf": [ 172 | { 173 | "description": "A string containing the link's URL.", 174 | "type": "string", 175 | "format": "uri-reference" 176 | }, 177 | { 178 | "type": "object", 179 | "required": [ 180 | "href" 181 | ], 182 | "properties": { 183 | "href": { 184 | "description": "A string containing the link's URL.", 185 | "type": "string", 186 | "format": "uri-reference" 187 | }, 188 | "meta": { 189 | "$ref": "#/definitions/meta" 190 | } 191 | } 192 | } 193 | ] 194 | }, 195 | 196 | "attributes": { 197 | "description": "Members of the attributes object (\"attributes\") represent information about the resource object in which it's defined.", 198 | "type": "object", 199 | "patternProperties": { 200 | "^(?!relationships$|links$)\\w[-\\w_]*$": { 201 | "description": "Attributes may contain any valid JSON value." 202 | } 203 | }, 204 | "additionalProperties": false 205 | }, 206 | 207 | "relationships": { 208 | "description": "Members of the relationships object (\"relationships\") represent references from the resource object in which it's defined to other resource objects.", 209 | "type": "object", 210 | "patternProperties": { 211 | "^\\w[-\\w_]*$": { 212 | "properties": { 213 | "links": { 214 | "$ref": "#/definitions/links" 215 | }, 216 | "data": { 217 | "description": "Member, whose value represents \"resource linkage\".", 218 | "oneOf": [ 219 | { 220 | "$ref": "#/definitions/relationshipToOne" 221 | }, 222 | { 223 | "$ref": "#/definitions/relationshipToMany" 224 | } 225 | ] 226 | }, 227 | "meta": { 228 | "$ref": "#/definitions/meta" 229 | } 230 | }, 231 | "anyOf": [ 232 | {"required": ["data"]}, 233 | {"required": ["meta"]}, 234 | {"required": ["links"]} 235 | ], 236 | "additionalProperties": false 237 | } 238 | }, 239 | "additionalProperties": false 240 | }, 241 | "relationshipToOne": { 242 | "description": "References to other resource objects in a to-one (\"relationship\"). Relationships can be specified by including a member in a resource's links object.", 243 | "anyOf": [ 244 | { 245 | "$ref": "#/definitions/empty" 246 | }, 247 | { 248 | "$ref": "#/definitions/linkage" 249 | } 250 | ] 251 | }, 252 | "relationshipToMany": { 253 | "description": "An array of objects each containing \"type\" and \"id\" members for to-many relationships.", 254 | "type": "array", 255 | "items": { 256 | "$ref": "#/definitions/linkage" 257 | }, 258 | "uniqueItems": true 259 | }, 260 | "empty": { 261 | "description": "Describes an empty to-one relationship.", 262 | "type": "null" 263 | }, 264 | "linkage": { 265 | "description": "The \"type\" and \"id\" to non-empty members.", 266 | "type": "object", 267 | "required": [ 268 | "type", 269 | "id" 270 | ], 271 | "properties": { 272 | "type": { 273 | "type": "string" 274 | }, 275 | "id": { 276 | "type": "string" 277 | }, 278 | "meta": { 279 | "$ref": "#/definitions/meta" 280 | } 281 | }, 282 | "additionalProperties": false 283 | }, 284 | "pagination": { 285 | "type": "object", 286 | "properties": { 287 | "first": { 288 | "description": "The first page of data", 289 | "oneOf": [ 290 | { "type": "string", "format": "uri-reference" }, 291 | { "type": "null" } 292 | ] 293 | }, 294 | "last": { 295 | "description": "The last page of data", 296 | "oneOf": [ 297 | { "type": "string", "format": "uri-reference" }, 298 | { "type": "null" } 299 | ] 300 | }, 301 | "prev": { 302 | "description": "The previous page of data", 303 | "oneOf": [ 304 | { "type": "string", "format": "uri-reference" }, 305 | { "type": "null" } 306 | ] 307 | }, 308 | "next": { 309 | "description": "The next page of data", 310 | "oneOf": [ 311 | { "type": "string", "format": "uri-reference" }, 312 | { "type": "null" } 313 | ] 314 | } 315 | } 316 | }, 317 | 318 | "jsonapi": { 319 | "description": "An object describing the server's implementation", 320 | "type": "object", 321 | "properties": { 322 | "version": { 323 | "type": "string" 324 | }, 325 | "meta": { 326 | "$ref": "#/definitions/meta" 327 | } 328 | }, 329 | "additionalProperties": false 330 | }, 331 | 332 | "error": { 333 | "type": "object", 334 | "properties": { 335 | "id": { 336 | "description": "A unique identifier for this particular occurrence of the problem.", 337 | "type": "string" 338 | }, 339 | "links": { 340 | "$ref": "#/definitions/links" 341 | }, 342 | "status": { 343 | "description": "The HTTP status code applicable to this problem, expressed as a string value.", 344 | "type": "string" 345 | }, 346 | "code": { 347 | "description": "An application-specific error code, expressed as a string value.", 348 | "type": "string" 349 | }, 350 | "title": { 351 | "description": "A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.", 352 | "type": "string" 353 | }, 354 | "detail": { 355 | "description": "A human-readable explanation specific to this occurrence of the problem.", 356 | "type": "string" 357 | }, 358 | "source": { 359 | "type": "object", 360 | "properties": { 361 | "pointer": { 362 | "description": "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].", 363 | "type": "string" 364 | }, 365 | "parameter": { 366 | "description": "A string indicating which query parameter caused the error.", 367 | "type": "string" 368 | } 369 | } 370 | }, 371 | "meta": { 372 | "$ref": "#/definitions/meta" 373 | } 374 | }, 375 | "additionalProperties": false 376 | } 377 | } 378 | } 379 | --------------------------------------------------------------------------------