diff --git a/CHANGELOG.md b/CHANGELOG.md index 804a5c2e..75dd99c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +- 2016-05-27 - v1.9.0 +- 2016-05-27 - Make parsed and validated filter available in request for handlers - 2016-05-24 - v1.8.0 - 2016-05-24 - HTTPS support - 2016-05-24 - v1.7.0 diff --git a/lib/filter.js b/lib/filter.js new file mode 100644 index 00000000..fe5eeb60 --- /dev/null +++ b/lib/filter.js @@ -0,0 +1,141 @@ +/* @flow weak */ +"use strict"; +var filter = module.exports = { }; + + +var FILTER_OPERATORS = ["<", ">", "~", ":"]; +var STRING_ONLY_OPERATORS = ["~", ":"]; + + +filter._resourceDoesNotHaveProperty = function(resourceConfig, key) { + if (resourceConfig.attributes[key]) return null; + return { + status: "403", + code: "EFORBIDDEN", + title: "Invalid filter", + detail: resourceConfig.resource + " do not have attribute or relationship '" + key + "'" + }; +}; + +filter._relationshipIsForeign = function(resourceConfig, key) { + var relationSettings = resourceConfig.attributes[key]._settings; + if (!relationSettings || !relationSettings.__as) return null; + return { + status: "403", + code: "EFORBIDDEN", + title: "Invalid filter", + detail: "Filter relationship '" + key + "' is a foreign reference and does not exist on " + resourceConfig.resource + }; +}; + +filter._splitElement = function(element) { + if (!element) return null; + if (FILTER_OPERATORS.indexOf(element[0]) !== -1) { + return { operator: element[0], value: element.substring(1) }; + } + return { operator: null, value: element }; +}; + +filter._stringOnlyOperator = function(operator, attributeConfig) { + if (!operator || !attributeConfig) return null; + if (STRING_ONLY_OPERATORS.indexOf(operator) !== -1 && attributeConfig._type !== "string") { + return "operator " + operator + " can only be applied to string attributes"; + } + return null; +}; + +filter._parseScalarFilterElement = function(attributeConfig, scalarElement) { + if (!scalarElement) return { error: "invalid or empty filter element" }; + + var splitElement = filter._splitElement(scalarElement); + if (!splitElement) return { error: "empty filter" }; + + var error = filter._stringOnlyOperator(splitElement.operator, attributeConfig); + if (error) return { error: error }; + + if (attributeConfig._settings) { // relationship attribute: no further validation + return { result: splitElement }; + } + + var validateResult = attributeConfig.validate(splitElement.value); + if (validateResult.error) { + return { error: validateResult.error.message }; + } + + var validatedElement = { operator: splitElement.operator, value: validateResult.value }; + return { result: validatedElement }; +}; + +filter._parseFilterElementHelper = function(attributeConfig, filterElement) { + if (!filterElement) return { error: "invalid or empty filter element" }; + + var parsedElements = [].concat(filterElement).map(function(scalarElement) { + return filter._parseScalarFilterElement(attributeConfig, scalarElement); + }); + + if (parsedElements.length === 1) return parsedElements[0]; + + var errors = parsedElements.reduce(function(combined, element) { + if (!combined) { + if (!element.error) return combined; + return [ element.error ]; + } + return combined.concat(element.error); + }, null); + + if (errors) return { error: errors }; + + var results = parsedElements.map(function(element) { + return element.result; + }); + + return { result: results }; +}; + +filter._parseFilterElement = function(attributeName, attributeConfig, filterElement) { + var helperResult = filter._parseFilterElementHelper(attributeConfig, filterElement); + + if (helperResult.error) { + return { + error: { + status: "403", + code: "EFORBIDDEN", + title: "Invalid filter", + detail: "Filter value for key '" + attributeName + "' is invalid: " + helperResult.error + } + }; + } + return { result: helperResult.result }; +}; + +filter.parseAndValidate = function(request) { + if (!request.params.filter) return null; + + var resourceConfig = request.resourceConfig; + + var processedFilter = { }; + var error; + var filterElement; + var parsedFilterElement; + + for (var key in request.params.filter) { + filterElement = request.params.filter[key]; + + if (!Array.isArray(filterElement) && filterElement instanceof Object) continue; // skip deep filters + + error = filter._resourceDoesNotHaveProperty(resourceConfig, key); + if (error) return error; + + error = filter._relationshipIsForeign(resourceConfig, key); + if (error) return error; + + parsedFilterElement = filter._parseFilterElement(key, resourceConfig.attributes[key], filterElement); + if (parsedFilterElement.error) return parsedFilterElement.error; + + processedFilter[key] = [].concat(parsedFilterElement.result); + } + + request.processedFilter = processedFilter; + + return null; +}; diff --git a/lib/postProcess.js b/lib/postProcess.js index 1c9a675c..c6e050f4 100644 --- a/lib/postProcess.js +++ b/lib/postProcess.js @@ -49,7 +49,11 @@ postProcess._fetchRelatedResources = function(request, mainResource, callback) { var ids = resourcesToFetch[type]; var urlJoiner = "&filter[id]="; ids = urlJoiner + ids.join(urlJoiner); - return jsonApi._apiConfig.pathPrefix + type + "/?" + ids; + var uri = jsonApi._apiConfig.pathPrefix + type + "/?" + ids; + if (request.route.query) { + uri += "&" + request.route.query; + } + return uri; }); async.map(resourcesToFetch, function(related, done) { diff --git a/lib/postProcessing/filter.js b/lib/postProcessing/filter.js index 083ea084..5f6b03fc 100644 --- a/lib/postProcessing/filter.js +++ b/lib/postProcessing/filter.js @@ -8,40 +8,20 @@ var _ = { }; var debug = require("../debugging.js"); -var FILTER_OPERATORS = ["<", ">", "~", ":"]; - filter.action = function(request, response, callback) { - var allFilters = _.assign({ }, request.params.filter); - if (!allFilters) return callback(); - - var filters = { }; - for (var i in allFilters) { - if (!request.resourceConfig.attributes[i]) { - return callback({ - status: "403", - code: "EFORBIDDEN", - title: "Invalid filter", - detail: request.resourceConfig.resource + " do not have property " + i - }); - } - if (allFilters[i] instanceof Array) { - allFilters[i] = allFilters[i].join(","); - } - if (typeof allFilters[i] === "string") { - filters[i] = allFilters[i]; - } - } + var filters = request.processedFilter; + if (!filters) return callback(); if (response.data instanceof Array) { for (var j = 0; j < response.data.length; j++) { - if (!filter._filterKeepObject(response.data[j], filters, request.resourceConfig.attributes)) { + if (!filter._filterKeepObject(response.data[j], filters)) { debug.filter("removed", filters, JSON.stringify(response.data[j].attributes)); response.data.splice(j, 1); j--; } } } else if (response.data instanceof Object) { - if (!filter._filterKeepObject(response.data, filters, request.resourceConfig.attributes)) { + if (!filter._filterKeepObject(response.data, filters)) { debug.filter("removed", filters, JSON.stringify(response.data.attributes)); response.data = null; } @@ -50,27 +30,10 @@ filter.action = function(request, response, callback) { return callback(); }; -filter._splitFilterElement = function(filterElementStr) { - if (FILTER_OPERATORS.indexOf(filterElementStr[0]) !== -1) { - return { operator: filterElementStr[0], value: filterElementStr.substring(1) }; - } - return { operator: null, value: filterElementStr }; -}; - -filter._filterMatches = function(filterElementStr, attributeValue, attributeConfig) { - var filterElement = filter._splitFilterElement(filterElementStr); - var validationResult = attributeConfig.validate(filterElement.value); - if (validationResult.error) { - debug.filter("invalid filter condition value:", validationResult.error); - return false; - } - filterElement.value = validationResult.value; +filter._filterMatches = function(filterElement, attributeValue) { if (!filterElement.operator) { return _.isEqual(attributeValue, filterElement.value); } - if (["~", ":"].indexOf(filterElement.operator) !== -1 && typeof filterElement.value !== "string") { - return false; - } var filterFunction = { ">": function filterGreaterThan(attrValue, filterValue) { return attrValue > filterValue; @@ -89,15 +52,14 @@ filter._filterMatches = function(filterElementStr, attributeValue, attributeConf return result; }; -filter._filterKeepObject = function(someObject, filters, attributesConfig) { +filter._filterKeepObject = function(someObject, filters) { for (var filterName in filters) { - var whitelist = filters[filterName].split(","); - var attributeConfig = attributesConfig[filterName]; + var whitelist = filters[filterName]; if (someObject.attributes.hasOwnProperty(filterName) || (filterName === "id")) { var attributeValue = someObject.attributes[filterName]; if (filterName === "id") attributeValue = someObject.id; - var attributeMatches = filter._attributesMatchesOR(attributeValue, attributeConfig, whitelist); + var attributeMatches = filter._attributesMatchesOR(attributeValue, whitelist); if (!attributeMatches) return false; } else if (someObject.relationships.hasOwnProperty(filterName)) { var relationships = someObject.relationships[filterName]; @@ -110,10 +72,10 @@ filter._filterKeepObject = function(someObject, filters, attributesConfig) { return true; }; -filter._attributesMatchesOR = function(attributeValue, attributeConfig, whitelist) { +filter._attributesMatchesOR = function(attributeValue, whitelist) { var matchOR = false; - whitelist.forEach(function(filterElementStr) { - if (filter._filterMatches(filterElementStr, attributeValue, attributeConfig)) { + whitelist.forEach(function(filterElement) { + if (filter._filterMatches(filterElement, attributeValue)) { matchOR = true; } }); @@ -131,8 +93,8 @@ filter._relationshipMatchesOR = function(relationships, whitelist) { return relation.id; }); - whitelist.forEach(function(filterElementStr) { - if (data.indexOf(filterElementStr) !== -1) { + whitelist.forEach(function(filterElement) { + if (data.indexOf(filterElement.value) !== -1) { matchOR = true; } }); diff --git a/lib/routes/find.js b/lib/routes/find.js index 14e6e972..a66c4ed9 100644 --- a/lib/routes/find.js +++ b/lib/routes/find.js @@ -5,6 +5,7 @@ var findRoute = module.exports = { }; var async = require("async"); var helper = require("./helper.js"); var router = require("../router.js"); +var filter = require("../filter.js"); var postProcess = require("../postProcess.js"); var responseHelper = require("../responseHelper.js"); @@ -21,6 +22,9 @@ findRoute.register = function() { function(callback) { helper.verifyRequest(request, resourceConfig, res, "find", callback); }, + function parseAndValidateFilter(callback) { + return callback(filter.parseAndValidate(request)); + }, function(callback) { resourceConfig.handlers.find(request, callback); }, diff --git a/lib/routes/search.js b/lib/routes/search.js index c45402d7..1d18d3b5 100644 --- a/lib/routes/search.js +++ b/lib/routes/search.js @@ -5,6 +5,7 @@ var searchRoute = module.exports = { }; var async = require("async"); var helper = require("./helper.js"); var router = require("../router.js"); +var filter = require("../filter.js"); var pagination = require("../pagination.js"); var postProcess = require("../postProcess.js"); var responseHelper = require("../responseHelper.js"); @@ -26,31 +27,8 @@ searchRoute.register = function() { function(callback) { helper.validate(request.params, resourceConfig.searchParams, callback); }, - function validateFilterParams(callback) { - if (!request.params.filter) return callback(); - - for (var i in request.params.filter) { - if (request.params.filter[i] instanceof Object) continue; - if (!request.resourceConfig.attributes[i]) { - return callback({ - status: "403", - code: "EFORBIDDEN", - title: "Invalid filter", - detail: request.resourceConfig.resource + " do not have property " + i - }); - } - var relationSettings = request.resourceConfig.attributes[i]._settings; - if (relationSettings && relationSettings.__as) { - return callback({ - status: "403", - code: "EFORBIDDEN", - title: "Request validation failed", - detail: "Requested relation \"" + i + "\" is a foreign reference and does not exist on " + request.params.type - }); - } - } - - return callback(); + function parseAndValidateFilter(callback) { + return callback(filter.parseAndValidate(request)); }, function validatePaginationParams(callback) { pagination.validatePaginationParams(request); diff --git a/package.json b/package.json index 9d791eba..01bdf0e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jsonapi-server", - "version": "1.8.0", + "version": "1.9.0", "description": "A config driven NodeJS framework implementing json:api", "keywords": [ "jsonapi", diff --git a/test/get-resource-id-related.js b/test/get-resource-id-related.js index b4d0d3ec..ae0ff0ed 100644 --- a/test/get-resource-id-related.js +++ b/test/get-resource-id-related.js @@ -102,7 +102,7 @@ describe("Testing jsonapi-server", function() { }); it("with filter", function(done) { - var url = "http://localhost:16006/rest/articles/de305d54-75b4-431b-adb2-eb6b9e546014/author?filter[email]=email"; + var url = "http://localhost:16006/rest/articles/de305d54-75b4-431b-adb2-eb6b9e546014/author?filter[email]=email@example.com"; helpers.request({ method: "GET", url: url @@ -111,7 +111,7 @@ describe("Testing jsonapi-server", function() { json = helpers.validateJson(json); assert.equal(res.statusCode, "200", "Expecting 200 OK"); - assert.deepEqual(json.data, null); + assert(!json.data); done(); }); diff --git a/test/get-resource.js b/test/get-resource.js index ecca0138..27ebf744 100644 --- a/test/get-resource.js +++ b/test/get-resource.js @@ -97,7 +97,43 @@ describe("Testing jsonapi-server", function() { }, function(err, res, json) { assert.equal(err, null); json = helpers.validateError(json); - assert.equal(res.statusCode, "403", "Expecting 403"); + assert.equal(res.statusCode, "403", "Expecting 403 FORBIDDEN"); + var error = json.errors[0]; + assert.equal(error.code, "EFORBIDDEN"); + assert.equal(error.title, "Invalid filter"); + done(); + }); + }); + + it("unknown multiple attribute should error", function(done) { + var url = "http://localhost:16006/rest/articles?filter[foo]=bar&filter[foo]=baz"; + helpers.request({ + method: "GET", + url: url + }, function(err, res, json) { + assert.equal(err, null); + json = helpers.validateError(json); + assert.equal(res.statusCode, "403", "Expecting 403 FORBIDDEN"); + var error = json.errors[0]; + assert.equal(error.code, "EFORBIDDEN"); + assert.equal(error.title, "Invalid filter"); + done(); + }); + }); + + it("value of wrong type should error", function(done) { + var url = "http://localhost:16006/rest/photos?filter[raw]=bob"; + helpers.request({ + method: "GET", + url: url + }, function(err, res, json) { + assert.equal(err, null); + json = helpers.validateError(json); + assert.equal(res.statusCode, "403", "Expecting 403 FORBIDDEN"); + var error = json.errors[0]; + assert.equal(error.code, "EFORBIDDEN"); + assert.equal(error.title, "Invalid filter"); + assert(error.detail.match("Filter value for key '.*?' is invalid")); done(); }); }); @@ -584,7 +620,11 @@ describe("Testing jsonapi-server", function() { assert.equal(err, null); json = helpers.validateError(json); - assert.equal(res.statusCode, "403", "Expecting 403 EFORBIDDEN"); + assert.equal(res.statusCode, "403", "Expecting 403 FORBIDDEN"); + var error = json.errors[0]; + assert.equal(error.code, "EFORBIDDEN"); + assert.equal(error.title, "Invalid filter"); + assert(error.detail.match("do not have attribute or relationship")); done(); }); }); @@ -598,7 +638,11 @@ describe("Testing jsonapi-server", function() { assert.equal(err, null); json = helpers.validateError(json); - assert.equal(res.statusCode, "403", "Expecting 403 EFORBIDDEN"); + assert.equal(res.statusCode, "403", "Expecting 403 FORBIDDEN"); + var error = json.errors[0]; + assert.equal(error.code, "EFORBIDDEN"); + assert.equal(error.title, "Invalid filter"); + assert(error.detail.match("is a foreign reference and does not exist on")); done(); }); });