UNPKG

fortune-json-api

Version:
690 lines (548 loc) 23.5 kB
'use strict' const uriTemplates = require('uri-templates') const inflection = require('inflection') const settings = require('./settings') const typeInflections = settings.typeInflections const mediaType = settings.mediaType const reservedKeys = settings.reservedKeys const defaults = settings.defaults const pageLimit = settings.pageLimit const pageOffset = settings.pageOffset const encodedLimit = encodeURIComponent(pageLimit) const encodedOffset = encodeURIComponent(pageOffset) const helpers = require('./helpers') const mapRecord = helpers.mapRecord const matchId = helpers.matchId const mapId = helpers.mapId const castId = helpers.castId const initializeContext = helpers.initializeContext const underscore = helpers.underscore const parseBuffer = helpers.parseBuffer const checkLowerCase = helpers.checkLowerCase const setInflectType = helpers.setInflectType // JSON API is a compromise. There are many incidental complexities involved // to cover everyone's hypothetical use cases, such as resource identifier // objects for polymorphic link fields, and relationship entities which are an // entirely unnecessary complication. From a web architecture standpoint, it // assumes tight coupling with the API consumer and the HTTP protocol. For // example, it assumes that the client has *a priori* knowledge of types that // exist on the server, since links are optional. // // The format is verbose and tedious to implement by hand, I would not // recommend doing it yourself unless you're a masochist. Following the // recommendations and other de facto additions in clients such as Ember Data // are entirely superfluous. In short, I don't think it's a viable format for // the web at large. module.exports = Serializer => { let methods let keys let errors let castValue return Object.assign(class JsonApiSerializer extends Serializer { constructor (dependencies) { super(dependencies) const options = this.options methods = this.methods keys = this.keys errors = this.errors castValue = this.castValue const methodMap = { GET: methods.find, POST: methods.create, PATCH: methods.update, DELETE: methods.delete } // Set options. for (const key in defaults) if (!(key in options)) options[key] = defaults[key] // Convert type inflection check to object options.inflectType = setInflectType( options.inflectType, Object.getOwnPropertyNames(this.recordTypes) ) const uriTemplate = uriTemplates((options ? options.uriTemplate : null) || defaults.uriTemplate) Object.defineProperties(this, { // Parse the URI template. uriTemplate: { value: uriTemplate }, // Default method mapping. methodMap: { value: methodMap } }) } processRequest (contextRequest, request, response) { return initializeContext.call(this, contextRequest, request, response) } processResponse (contextResponse, request, response) { const options = this.options const jsonSpaces = options.jsonSpaces const bufferEncoding = options.bufferEncoding let payload = contextResponse.payload if (!contextResponse.meta) contextResponse.meta = {} if (!contextResponse.meta.headers) contextResponse.meta.headers = {} if (payload && payload.records) contextResponse = this.showResponse(contextResponse, request, payload.records, payload.include) if (contextResponse instanceof Error) { if (contextResponse.isMethodInvalid) return contextResponse if (contextResponse.isTypeUnspecified) this.showIndex(contextResponse, request, response) else this.showError(contextResponse) } payload = contextResponse.payload if (!payload) return contextResponse contextResponse.payload = JSON.stringify(payload, (key, value) => { // Duck type checking for buffer stringification. if (value && value.type === 'Buffer' && Array.isArray(value.data) && Object.keys(value).length === 2) return Buffer.from(value.data).toString(bufferEncoding) return value }, jsonSpaces) return contextResponse } showIndex (contextResponse, request, response) { const recordTypes = this.recordTypes const uriTemplate = this.uriTemplate const options = this.options const jsonapi = options.jsonapi const inflectType = options.inflectType const prefix = options.prefix contextResponse.payload = { [reservedKeys.jsonapi]: jsonapi, [reservedKeys.meta]: {}, [reservedKeys.links]: {} } for (let type in recordTypes) { if (inflectType[type]) type = inflection.transform(type, typeInflections[1]) contextResponse.payload[reservedKeys.links][type] = prefix + uriTemplate.fillFromObject({ type }) } response.statusCode = 200 } showResponse (contextResponse, request, records, include) { const uriTemplate = this.uriTemplate const recordTypes = this.recordTypes const options = this.options const jsonapi = options.jsonapi const prefix = options.prefix const inflectType = options.inflectType const inflectKeys = options.inflectKeys const NotFoundError = errors.NotFoundError const meta = request.meta const method = meta.method const type = meta.type const ids = meta.ids const relatedField = meta.relatedField const relationship = meta.relationship const originalType = meta.originalType const originalIds = meta.originalIds const updateModified = contextResponse.meta.updateModified if (relationship) return this.showRelationship(contextResponse, request, records) // Handle a not found error. if (ids && ids.length && method === methods.find && !relatedField && !records.length) return new NotFoundError('No records match the request.') // Delete and update requests may not respond with anything. if (method === methods.delete || (method === methods.update && !updateModified)) { delete contextResponse.payload return contextResponse } const output = { [reservedKeys.jsonapi]: jsonapi } // Show collection. if (!ids && method === methods.find) { const count = records.count const query = meta.uriObject.query const limit = meta.options.limit const offset = meta.options.offset const collection = prefix + uriTemplate.fillFromObject({ query, type: inflectType[type] ? inflection.transform(type, typeInflections[1]) : type }) output[reservedKeys.meta] = { count } output[reservedKeys.links] = { [reservedKeys.self]: collection } output[reservedKeys.primary] = [] // Set top-level pagination links. if (count > limit) { let queryLength = 0 if (query) { delete query[pageOffset] delete query[pageLimit] queryLength = Object.keys(query).length } const paged = prefix + uriTemplate.fillFromObject({ query, type: inflectType[type] ? inflection.transform(type, typeInflections[1]) : type }) Object.assign(output[reservedKeys.links], { [reservedKeys.first]: `${paged}${queryLength ? '&' : '?'}` + `${encodedOffset}=0` + `&${encodeURIComponent(pageLimit)}=${limit}`, [reservedKeys.last]: `${paged}${queryLength ? '&' : '?'}` + `${encodedOffset}=${Math.floor((count - 1) / limit) * limit}` + `&${encodedLimit}=${limit}` }, limit + (offset || 0) < count ? { [reservedKeys.next]: `${paged}${queryLength ? '&' : '?'}` + `${encodedOffset}=${(Math.floor((offset || 0) / limit) + 1) * limit}&${encodedLimit}=${limit}` } : null, (offset || 0) >= limit ? { [reservedKeys.prev]: `${paged}${queryLength ? '&' : '?'}` + `${encodedOffset}=${(Math.floor((offset || 0) / limit) - 1) * limit}&${encodedLimit}=${limit}` } : null) } } if (records.length) { if (ids) output[reservedKeys.links] = { [reservedKeys.self]: prefix + uriTemplate.fillFromObject({ type: inflectType[type] ? inflection.transform(type, typeInflections[1]) : type, ids }) } output[reservedKeys.primary] = records.map(record => mapRecord.call(this, type, record)) if ((!originalType || (originalType && !recordTypes[originalType][relatedField][keys.isArray])) && ((ids && ids.length === 1) || (method === methods.create && records.length === 1))) output[reservedKeys.primary] = output[reservedKeys.primary][0] if (method === methods.create) contextResponse.meta.headers['Location'] = prefix + uriTemplate.fillFromObject({ type: inflectType[type] ? inflection.transform(type, typeInflections[1]) : type, ids: records.map(record => record[keys.primary]) }) } else if (relatedField) output[reservedKeys.primary] = recordTypes[originalType][relatedField][keys.isArray] ? [] : null // Set related records. if (relatedField) output[reservedKeys.links] = { [reservedKeys.self]: prefix + uriTemplate.fillFromObject({ type: inflectType[originalType] ? inflection.transform(originalType, typeInflections[1]) : originalType, ids: originalIds, relatedField: inflectKeys ? inflection.transform(relatedField, [ 'underscore', 'dasherize' ]) : relatedField }) } // To show included records, we have to flatten them :( if (include) { output[reservedKeys.included] = [] for (const type of Object.keys(include)) Array.prototype.push.apply(output[reservedKeys.included], include[type].map(mapRecord.bind(this, type))) } if (Object.keys(output).length) contextResponse.payload = output return contextResponse } showRelationship (contextResponse, request, records) { const meta = request.meta const method = meta.method const type = meta.type const relatedField = meta.relatedField const originalType = meta.originalType const originalIds = meta.originalIds const uriTemplate = this.uriTemplate const recordTypes = this.recordTypes const options = this.options const jsonapi = options.jsonapi const prefix = options.prefix const inflectType = options.inflectType const inflectKeys = options.inflectKeys const BadRequestError = errors.BadRequestError if (originalIds.length > 1) throw new BadRequestError( 'Can only show relationships for one record at a time.') if (method !== methods.find) { delete contextResponse.payload return contextResponse } const output = { [reservedKeys.jsonapi]: jsonapi, [reservedKeys.links]: { [reservedKeys.self]: prefix + uriTemplate.fillFromObject({ type: inflectType[originalType] ? inflection.transform(originalType, typeInflections[1]) : originalType, ids: originalIds, relatedField: reservedKeys.relationships, relationship: inflectKeys ? inflection.transform(relatedField, [ 'underscore', 'dasherize' ]) : relatedField }), [reservedKeys.related]: prefix + uriTemplate.fillFromObject({ type: inflectType[originalType] ? inflection.transform(originalType, typeInflections[1]) : originalType, ids: originalIds, relatedField: inflectKeys ? inflection.transform(relatedField, [ 'underscore', 'dasherize' ]) : relatedField }) } } const isArray = recordTypes[originalType][relatedField][keys.isArray] const identifiers = records.map(record => ({ [reservedKeys.type]: inflectType[type] ? inflection.transform(type, typeInflections[1]) : type, [reservedKeys.id]: record[keys.primary].toString() })) output[reservedKeys.primary] = isArray ? identifiers : identifiers.length ? identifiers[0] : null contextResponse.payload = output return contextResponse } parsePayload (contextRequest) { const method = contextRequest.method if (method === methods.create) return this.parseCreate(contextRequest) else if (method === methods.update) return this.parseUpdate(contextRequest) throw new Error('Method is invalid.') } parseCreate (contextRequest) { contextRequest.payload = parseBuffer.call(this, contextRequest.payload) const recordTypes = this.recordTypes const options = this.options const inflectType = options.inflectType const inflectKeys = options.inflectKeys const MethodError = errors.MethodError const BadRequestError = errors.BadRequestError const ConflictError = errors.ConflictError const payload = contextRequest.payload const relatedField = contextRequest.relatedField const type = contextRequest.type const ids = contextRequest.ids const fields = recordTypes[type] const cast = (type, options) => value => castValue(value, type, options) // Can not create with IDs specified in route. if (ids) throw new MethodError('Can not create with ID in the route.') // Can not create if related records are specified. if (relatedField) throw new MethodError('Can not create related record.') let data = payload[reservedKeys.primary] // No bulk extension for now. if (Array.isArray(data)) throw new BadRequestError('Data must be singular.') data = [ data ] return data.map(record => { if (!(reservedKeys.type in record)) throw new BadRequestError( `The required field "${reservedKeys.type}" is missing.`) const clone = {} const recordType = inflectType[record[reservedKeys.type]] ? checkLowerCase(inflection.transform( underscore(record[reservedKeys.type]), typeInflections[0]), recordTypes) : record[reservedKeys.type] if (recordType !== type) throw new ConflictError('Incorrect type.') if (reservedKeys.id in record) clone[reservedKeys.id] = castId.call(this, record[keys.primary]) if (reservedKeys.attributes in record) for (let field in record[reservedKeys.attributes]) { const value = record[reservedKeys.attributes][field] if (inflectKeys) field = inflection.camelize(underscore(field), true) const fieldDefinition = fields[field] || {} const fieldType = fieldDefinition[keys.type] clone[field] = Array.isArray(value) ? value.map(cast(fieldType, options)) : castValue(value, fieldType, options) } if (reservedKeys.relationships in record) for (let field of Object.keys(record[reservedKeys.relationships])) { const value = record[reservedKeys.relationships][field] if (inflectKeys) field = inflection.camelize(underscore(field), true) if (!(reservedKeys.primary in value)) throw new BadRequestError('The ' + `"${reservedKeys.primary}" field is missing.`) const linkKey = fields[field][keys.link] const relatedType = inflectType[linkKey] ? inflection.transform( linkKey, typeInflections[1]) : linkKey const relatedIsArray = fields[field][keys.isArray] const data = value[reservedKeys.primary] clone[field] = data ? (Array.isArray(data) ? data : [ data ]) .map(mapId.bind(this, relatedType)) : null if (clone[field] && !relatedIsArray) clone[field] = clone[field][0] } return clone }) } parseUpdate (contextRequest) { contextRequest.payload = parseBuffer.call(this, contextRequest.payload) const recordTypes = this.recordTypes const options = this.options const inflectType = options.inflectType const inflectKeys = options.inflectKeys const MethodError = errors.MethodError const BadRequestError = errors.BadRequestError const ConflictError = errors.ConflictError const payload = contextRequest.payload const type = contextRequest.type const ids = contextRequest.ids const relatedField = contextRequest.relatedField const relationship = contextRequest.relationship const cast = (type, options) => value => castValue(value, type, options) if (relationship) return this.updateRelationship(contextRequest) // No related record update. if (relatedField) throw new MethodError( 'Can not update related record indirectly.') // Can't update collections. if (!Array.isArray(ids) || !ids.length) throw new BadRequestError('IDs unspecified.') const fields = recordTypes[type] const updates = [] let data = payload[reservedKeys.primary] // No bulk/patch extension for now. if (Array.isArray(data)) throw new BadRequestError('Data must be singular.') data = [ data ] for (const update of data) { const replace = {} const updateType = inflectType[update[reservedKeys.type]] ? checkLowerCase(inflection.transform( underscore(update[reservedKeys.type]), typeInflections[0]), recordTypes) : update[reservedKeys.type] if (!ids.some(matchId.bind(this, update))) throw new ConflictError('Invalid ID.') if (updateType !== type) throw new ConflictError('Incorrect type.') if (reservedKeys.attributes in update) for (let field in update[reservedKeys.attributes]) { const value = update[reservedKeys.attributes][field] if (inflectKeys) field = inflection.camelize(underscore(field), true) const fieldDefinition = fields[field] || {} const fieldType = fieldDefinition[keys.type] replace[field] = Array.isArray(value) ? value.map(cast(fieldType, options)) : castValue(value, fieldType, options) } if (reservedKeys.relationships in update) for (let field of Object.keys(update[reservedKeys.relationships])) { const value = update[reservedKeys.relationships][field] if (inflectKeys) field = inflection.camelize(underscore(field), true) if (!(reservedKeys.primary in value)) throw new BadRequestError( `The "${reservedKeys.primary}" field is missing.`) const relatedType = inflectType[fields[field][keys.link]] ? inflection.transform(fields[field][keys.link], typeInflections[1]) : fields[field][keys.link] const relatedIsArray = fields[field][keys.isArray] const data = value[reservedKeys.primary] replace[field] = data ? (Array.isArray(data) ? data : [ data ]) .map(mapId.bind(this, relatedType)) : null if (replace[field] && !relatedIsArray) replace[field] = replace[field][0] } updates.push({ id: castId.call(this, update[keys.primary]), replace }) } if (updates.length < ids.length) throw new BadRequestError('An update is missing.') return updates } updateRelationship (contextRequest) { const recordTypes = this.recordTypes const options = this.options const inflectType = options.inflectType const NotFoundError = errors.NotFoundError const MethodError = errors.MethodError const BadRequestError = errors.BadRequestError const ConflictError = errors.ConflictError const payload = contextRequest.payload const type = contextRequest.type const relatedField = contextRequest.relatedField const originalMethod = contextRequest.originalMethod const originalType = contextRequest.originalType const originalIds = contextRequest.originalIds const isArray = recordTypes[originalType][relatedField][keys.isArray] const isNull = !isArray && payload[reservedKeys.primary] === null if (isNull) { // Rewrite type and IDs. contextRequest.type = originalType contextRequest.ids = null return [{ id: originalIds[0], replace: { [relatedField]: null } }] } if (originalIds.length > 1) throw new NotFoundError( 'Can only update relationships for one record at a time.') if (!isArray && originalMethod) throw new MethodError('Can not ' + `${originalMethod === methods.create ? 'push to' : 'pull from'}` + ' a to-one relationship.') const updates = [] const operation = originalMethod ? originalMethod === methods.create ? 'push' : 'pull' : 'replace' let updateIds = payload[reservedKeys.primary] if (!isArray) if (!Array.isArray(updateIds)) updateIds = [ updateIds ] else throw new BadRequestError('Data must be singular.') updateIds = updateIds.map(update => { const updateType = inflectType[update[reservedKeys.type]] ? checkLowerCase(inflection.transform( underscore(update[reservedKeys.type]), typeInflections[0]), recordTypes) : update[reservedKeys.type] if (updateType !== type) throw new ConflictError('Incorrect type.') if (!(reservedKeys.id in update)) throw new BadRequestError('ID is unspecified.') return castId.call(this, update[keys.primary]) }) updates.push({ id: originalIds[0], [operation]: { [relatedField]: isArray ? updateIds : updateIds[0] } }) // Rewrite type and IDs. contextRequest.type = originalType contextRequest.ids = null return updates } showError (error) { const obj = { title: error.name, detail: error.message } for (const key in error) { // Omit useless keys from the error. if (key === 'meta' && Object.keys(error[key]).length === 1) continue if (key === 'payload' && !error[key]) continue obj[key] = error[key] } error.payload = { [reservedKeys.jsonapi]: this.options.jsonapi, [reservedKeys.errors]: [ obj ] } } }, { mediaType }) }