UNPKG

ember-mirage-sauce

Version:

Mirage Sauce adds some common functionality to mirage based json apis.

653 lines (549 loc) 18.1 kB
import { JSONAPISerializer } from "miragejs"; import { A } from "@ember/array"; import { isEmpty } from "@ember/utils"; import { get } from "@ember/object"; import { dasherize } from "@ember/string"; import { pluralize } from "ember-inflector"; import findNestedRelationship from "ember-mirage-sauce/utils/find-nested-relationship"; import config from "ember-get-config"; const DEBUG = config["mirage-sauce"] ? config["mirage-sauce"].debug : false; const DEFAULT_SEARCH_FIELDS = ["email", "name", "firstName", "lastName"]; /** A custom JSONAPISerializer that adds sorting, filtering, search & pagination to api requests ```js // in mirage/serializers/application.js import JSONAPISerializer from 'ember-mirage-sauce/mirage-serializers/json-api-serializer'; export default JSONAPISerializer.extend({}); ``` @class JSONAPISerializer */ export default JSONAPISerializer.extend({ /** Define an array of fields in the model to fuzzy search @property searchByFields @type {Array} */ searchByFields: DEFAULT_SEARCH_FIELDS, /** Query param name for the search parameter _Default: "search" @property searchKey @default "search" @type {string} */ searchKey: "search", /** Query param name for the sort parameter _Default: "sort" @property sortKey @default "sort" @type {string} */ sortKey: "sort", /** Query param name for the filter parameters _Default: "filter" @property filterKey @default "filter" @type {string} */ filterKey: "filter", /** Define any filter keys to ignore in mirage queries _Default: [] @property ignoreFilters @default "[]" @type {array} */ ignoreFilters: A([]), /** Define a hook to do your own filtering on a request before it is paginated. This closure function is passed the json and request objects and should return the json object _Default: null @property filterHook @default null @type {function} */ filterHook: null, /** Override the parent serializer to add support for search, filter, sort & pagination @method serialize @access public @param {Object} object @param {Object} request @return {Hash} */ serialize(object, request) { // This is how to call super, as Mirage borrows [Backbone's implementation of extend](http://backbonejs.org/#Model-extend) let json = JSONAPISerializer.prototype.serialize.apply(this, arguments); // Add metadata, sort parts of the response, etc. // Is this a list response if (Array.isArray(json.data)) { return this._serialize(json, request); } // return json; }, _serialize(json, request) { this.log("===================="); this.log("MIRAGE SAUCE REQUEST"); this.log("===================="); // Get filter params from request this.log("> Payload data", json); this.log("> Request object", request); let filters = this._extractFilterParams(request.queryParams); // Filter data json.data = this.filterResponse(json, filters); // Sort data json.data = this.sortResponse(json, get(request.queryParams, this.sortKey)); // Any Hooks? const hook = this.filterHook; if (hook) { json = hook(json, request); this.log(`3. Filter hook called`); } else { this.log(`3. Filter hook not set`); } // Paginate? json = this.paginate(json, request); return json; }, /** Filter responses by filter params _NOTE! to filter by a relationship id it must be included in the requests "include" param. Otherwise this serializer does not include data from that relationship in it's 'data'_ @access protected @method filterResponse @param {Array} data @param {Array} filters @return {Array} */ filterResponse(json, filters) { let data = json.data; if (filters.length) { this.log("1. Filter the response: filters", filters); filters.forEach((filter, index) => { this.log(`1.${index}.0 filter`, filter); if (this.ignoreFilters.indexOf(filter.property) !== -1) { this.log(`1.${index}.1 ignoring ${filter.property} filter`); return; } const attributePath = `attributes.${dasherize(filter.property)}`; let logFirst = true; data = data.filter((record) => { let match = false; filter.property = dasherize(filter.property); filter.values.forEach((value) => { if (logFirst) { this.log(`1.${index}.1 Filter ${filter.property}="${value}"`); } // Check for an attribute match // Is this a search term? if (filter.property === this.searchKey && value) { if (logFirst) { this.log( `1.${index}.2 Filter by search key: ${filter.property}="${value}"` ); this.log( `1.${index}.3 Search the followin attributes: ${this.searchByFields.join( ", " )}` ); } if (this.filterBySearch(record, value)) { match = true; } } // Is this an attribute filter? else if (this._isAttributeKey(filter.property, record)) { let attribute = get(record, attributePath); // Convert bool to string if (typeof attribute === "boolean") { attribute = attribute.toString(); } // Convert number to string if (typeof attribute === "number") { attribute = attribute.toString(); } if (logFirst) { this.log(`1.${index}.2 Filter by attribute ${filter.property}`); } if (value === attribute) { match = true; } } // Is this a related belongs to id? else if (filter.property.endsWith("-id")) { let relationship = filter.property.replace("-id", ""), path = `relationships.${relationship}.data.id`; if (logFirst) { this.log( `1.${index}.2 Filter by "${filter.property}" is a belongsTo relationship. Path: ${path}` ); } // check the related model is present in the response if (this._hasIncludedRelationship(relationship, json.included)) { // Check for a relationship match if (parseInt(value) === parseInt(get(record, path))) { match = true; } } else { if (logFirst) { this.log( `1.${index}.3 There were no "${relationship}" models found in the includes response! Did you include them in your request?` ); } } } // Is this a related hasMany to id(s)? else if (filter.property.endsWith("-ids")) { // Has Many Relationship let relationship = filter.property.replace("-ids", ""), path = `relationships.${pluralize(relationship)}.data`; if (logFirst) { this.log( `1.${index}.2 Filter by "${filter.property}" is a hasMany relationship. Path: ${path}` ); } // check the related model is present in the response if (this._hasIncludedRelationship(relationship, json.included)) { // Loop though relationships for a match get(record, path).forEach((related) => { if (parseInt(value) === parseInt(related.id)) { match = true; } }); } else { if (logFirst) { this.log( `1.${index}.3 There were no "${relationship}" models found in the includes response! Did you include them in your request?` ); } } } // Is this a related attribute? else if (filter.property.includes(".")) { let segments = filter.property.split("."), // last item will be the property relationshipProperty = segments[segments.length - 1]; // check this path exists in the includes property of our response data if (relationshipProperty !== "id") { relationshipProperty = `attributes.${relationshipProperty}`; } // find the nested relationship from the included array let relationship = findNestedRelationship( record, json.included, filter.property ); if (logFirst) { this.log( `1.${index}.2 Filter by "${filter.property}" is a relationship attribute. Path: "${relationshipProperty}"` ); } if (relationship) { if (get(relationship, relationshipProperty) == value) { match = true; } } } else { if (logFirst) { this.log( `1.${index}.2 Filter did not know how to handle "${filter.property}" ${record.id} so it was ignored` ); } match = true; } }); logFirst = false; return match; }); }); } else { this.log("1. Filter the response: No filters set"); } return data; }, /** Check if the model passes search filter @access protected @method filterBySearch @param {object} record Serialised model instance to search @param {string} term The search term @return {boolean} */ filterBySearch(record, term) { const searchFields = this.searchByFields; if (isEmpty(searchFields)) { // no search fields - return record return true; } let matched = false; searchFields.forEach((field) => { const fieldValue = get(record, `attributes.${dasherize(field)}`); if ( !isEmpty(fieldValue) && fieldValue.toLowerCase().search(term.toLowerCase()) !== -1 ) { matched = true; } }); return matched; }, /** Order responses by sort param _Supports one sort param atm..._ http://jsonapi.org/format/#fetching-sorting @access protected @method sortResponse @param {Array} data @param {Array} filters @return {Array} */ sortResponse(json, sort) { let desc = false, data = json.data; if (sort && data.length > 0) { this.log("2. Sort the response", sort); // does this sort param start with "-" if (sort.indexOf("-") === 0) { // sort decending desc = true; // remove prefixed '-' sort = sort.substring(1); this.log("2.0 Sort direction: descending"); } else { this.log("2.0 Sort direction: ascending"); } // find the sort path if (this._isAttribute(sort)) { let path = this._getAttributePath(sort, data[0]); this.log(`2.1 Sort by attribute "${sort}". Path:`); this.log(`2.2 Sort by path "${path}"`); // sort by property data = A(data).sortBy(path); } else if (this._isRelatedAttribute(sort)) { this.log(`2.1 Sort by related attribute "${sort}".`); // sort by related data = this._sortByIncludedProperty(data, json.included, sort); } // reverse sort order? if (desc) { data = A(data).reverseObjects(); } } else { this.log("2. Sort the response: No sort defined"); } return data; }, /** Paginate response @access protected @method paginate @param {object} results data to be paginated @param {object} request request object @return {object} */ paginate(res, request) { if ( request.queryParams["page[number]"] && request.queryParams["page[size]"] ) { const page = parseInt(request.queryParams["page[number]"]), size = parseInt(request.queryParams["page[size]"]), total = res ? res.data.length : 0, pages = Math.ceil(total / size); this.log(`4. Pagination the response page "${page}" size "${size}"`); res.data = this._sliceResults(res.data, page, size); res.meta = this._buildMetadata(page, size, total, pages); return res; } else { this.log(`4. Pagination not set`); return res; } }, // ------- // PRIVATE // ------- _sliceResults(results, page, size) { const start = (page - 1) * size; const end = start + size; this.log(`3.0 total results: ${results.length}`); this.log(`3.1 slice results at index: ${start} - ${end}`); return results.slice(start, end); }, _buildMetadata(page, size, total, pages) { this.log(`3.2 total pages: ${pages}`); return { page, size, total, pages, }; }, /** Extract filter parameters from the request @access private @param {Object} params @return {Array} */ _extractFilterParams(params) { let filters = A([]); for (var key in params) { // loop though params and match any that follow the // filter[foo] pattern. Then extract foo. if (key.substr(0, 6) === this.filterKey) { let property = key.substr(7, key.length - 8), value = params[key], values = null; if (value) { // make sure it's a string before we split it values = (value + "").split(","); } if (!isEmpty(values)) { filters.pushObject({ property, values, }); } } } return filters; }, /** Sort models by a related property @access private @param {Array} data Array of serialized models to sort @param {Array} included Collection of included serialized models @param {string} sort Sort property @return {Array} */ _sortByIncludedProperty(data, included, sort) { let idPath = this._getRelatedIdPath(sort, data[0]), model = this._getRelatedModel(sort), attrPath = this._getRelatedAttributePath(sort, data[0]); this.log(`2.2 Sort by path of included model "${model}" "${attrPath}"`); let logFirst = true; return data.sort((a, b) => { const aId = get(a, idPath), bId = get(b, idPath), aRelated = this._findIncludedModelById(included, model, aId), bRelated = this._findIncludedModelById(included, model, bId); // Bale if we didnt find a related model if (!aRelated || !bRelated) { this.log(`2.3 Couldnt find related model ${model} in response`); return 0; } let aVal = get(aRelated, attrPath), bVal = get(bRelated, attrPath); // are they numbers? if (!isNaN(parseFloat(aVal)) && !isNaN(parseFloat(bVal))) { if (logFirst) { this.log(`2.3 Sort by values as numbers`); logFirst = false; } aVal = parseFloat(aVal); bVal = parseFloat(bVal); } else { if (logFirst) { this.log(`2.3 Sort by by values as strings`); logFirst = false; } } if (aVal > bVal) { return 1; } else if (aVal < bVal) { return -1; } else { return 0; } }); // return data; }, _isAttribute(path) { return path.split(".").length === 1; }, _isAttributeKey(attribute, record) { return Object.keys(record.attributes).includes(attribute); }, _hasIncludedRelationship(model, included) { return A(included).filterBy("type", pluralize(model)).length > 0; }, _isRelatedAttribute(path) { return path.split(".").length === 2; }, _getRelatedIdPath(property) { // ensure param is underscored property = dasherize(property); // destructure property const relatedModel = property.split(".")[0]; // define full path const path = `relationships.${relatedModel}.data.id`; return path; }, _getAttributePath(property, record) { // ensure param is underscored property = dasherize(property); // define full path const path = `attributes.${property}`; // check if path is found if (typeof get(record, path) === "undefined") { this.log(`Mirage: Could not find path ${path}`); this.log(record); } return path; }, _getRelatedModel(property) { // ensure param is underscored property = dasherize(property); // destructure property property = property.split(".")[0]; return property; }, _getRelatedAttributePath(property) { // ensure param is underscored property = dasherize(property); // destructure property property = property.split(".")[1]; // define full path const path = `attributes.${property}`; return path; }, _findIncludedModelById(array, model, id) { return array.find(function (item) { return item.type === pluralize(model) && item.id === id; }); }, _findRecordPath(property, record) { let path; // ensure param is underscored property = dasherize(property); // destructure property const [a, b] = property.split("."); // work out if this is a related property or not // and return the key if (!isEmpty(b)) { path = `relationships.${a}.data.${b}`; } else { path = `attributes.${a}`; } // check if path is found if (typeof get(record, path) === "undefined") { this.log(`Mirage: Could not find path ${path}`); this.log(record); } // warn user else return path; }, log(...args) { if (DEBUG) { window.console.log(...args); } }, });