Skip to content

Commit

Permalink
Merge pull request #137 from holidayextras/parse-and-joi-validate-sea…
Browse files Browse the repository at this point in the history
…rch-filter

Parse and joi validate search filter: update changelog and bump version
  • Loading branch information
pmcnr-hx committed May 27, 2016
2 parents d37589d + 076d6ff commit 0acd35b
Show file tree
Hide file tree
Showing 9 changed files with 218 additions and 83 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
141 changes: 141 additions & 0 deletions lib/filter.js
Original file line number Diff line number Diff line change
@@ -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;
};
6 changes: 5 additions & 1 deletion lib/postProcess.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
64 changes: 13 additions & 51 deletions lib/postProcessing/filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand All @@ -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];
Expand All @@ -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;
}
});
Expand All @@ -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;
}
});
Expand Down
4 changes: 4 additions & 0 deletions lib/routes/find.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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);
},
Expand Down
28 changes: 3 additions & 25 deletions lib/routes/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions test/get-resource-id-related.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
});
Expand Down
Loading

0 comments on commit 0acd35b

Please sign in to comment.