Skip to content
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this change designed to try and remove the leading & from the query string? It's quite hard to read by comparison, I'd rather we either live with the leading ampersand or we simply substring it off with a comment saying // trim the leading ampersand.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wasn't just that, it was because we weren't passing the filter to the included resources rerouted request (see 54-56). With the new strategy of not altering the original filter and adding the parsed filter as a new property we won't need to touch lib/postProcess.js at all so I am going to remove these changes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh right, cool 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed it back to how it was before but we still need to pass the query string to the related resources. I am not sure how the test was working before... 😖

});

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
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