Chapter 06
Ex. Query Parameters (Advanced)
Query parameters, also known as URL
parameters or GET
parameters, are an important concept in HTTP based communication. They provide a way to pass data to a web server through the URL of a web page or any client application that's able to make GET
requests. Query parameters are an essential part of the HTTP protocol and play a crucial role in creating dynamic and interactive web/server applications.
In this chapter, we'll dive deep into query parameters, exploring their structure, usage, and best practices. We'll also discuss their importance in modern web development and how they differ from other methods of data transmission.
Anatomy of a URL with Query Parameters
Before we delve into the specifics of query parameters, let's break down the structure of a URL that includes them:
https://www.cachelane.com/path/to/page?param1=value1¶m2=value2
This URL can be divided into several components:
- Protocol:
https://
- Domain:
www.cachelane.com
- Path:
/path/to/page
- Query string:
?param1=value1¶m2=value2
The query string begins with a question mark (?
) and contains the query parameters. Each parameter is a key-value pair, where the key and value are separated by an equals sign (=
). Multiple parameters are separated by ampersands (&
).
Key Components of Query Parameters
Question Mark (?):
- Signals the start of the query string.
- Separates the path from the query parameters
Parameter Name:
- The key in the key-value pair
- Typically descriptive of the data it represents
Equals Sign (=):
- Separates the parameter name from its value
Parameter Value:
- The actual data being passed
- Can be a string, number, or even a more complex data structure (when properly encoded)
Ampersand (&):
- Used to separate multiple parameters in the query string. For example,
?param1=value1¶m2=value2
contains two parameters -param1
andparam2
, with the corresponding valuesvalue1
andvalue2
respectively.
Challenge 1: Implementing Basic Query Parameter Parsing
In this challenge, we'll enhance our Router
class to parse and handle basic query parameters. We'll focus on extracting query parameters from the URL and making them available to route handlers.
Modify the Router
class to parse query parameters from incoming URLs and include them in the route matching result.
Requirements
- Update the
findRoute
method to separate the path from query parameters. - Implement a new method
parseQueryParams
to extract query parameters from the URL. - Include the parsed query parameters in the object returned by
findRoute
. - Ensure that routes without query parameters still work as before.
- Important: Handle URL encoding and decoding properly. Learn more about URL encoding.
Hints
- You may use the
URL
orURLSearchParams
class in Node.js to parse query parameters. - Don't worry about the performance yet, just make sure that your logic works correctly.
- Use the current
Router
class implementation as your starting point. You'll need to modify thefindRoute
method and add a newparseQueryParams
method.
Expected Outcome
After implementing this feature, the findRoute
method should return an object that includes:
params
: Dynamic route parameters (as before)query
: An object containing parsed query parametershandler
: The matched route handler (as before)
A quick heads up
You may need to change the implementation of the findRoute
method, and update how it's extracting the path params. For example, you might notice that if the path parameter is at the end of the URL, the current implementation will not work as expected. You may need to update the implementation to handle such cases.
Example
const router = new Router();
router.get("/users/:id", (req, res) => {
console.log(req.params.id);
console.log(req.query);
});
const result = router.findRoute("/users/123?name=Velocy&age=1", "GET");
console.log(result);
// Expected output:
// {
// params: { id: '123' },
// query: { name: 'Velocy', age: '1' },
// handler: [Function]
// }
// Actual output:
// {
// params: { id: '123?name=Velocy&age=1' },
// query: { name: 'velocy', age: '1' },
// handler: [Function]
// }
Solution
Here's the basic solution I came up with for this challenge:
// file: lib/router.js
class Router {
...
/**
* Parse query parameters from the URL
* @param {string} queryString - URL segment containing query parameters
* @returns {object} - Parsed query parameters as key-value pairs
*/
#parseQueryParams(queryString) {
if (!queryString) return {};
// Extract the query string from the URL
const queryParams = {};
// Split the query string into key-value pairs
const pairs = queryString.split("&");
for (const pair of pairs) {
// Split each pair into key and value
const [key, value] = pair.split("=");
if (key && value) {
// Decode the value and store the key-value pair
queryParams[key] = decodeURIComponent(value);
}
}
return queryParams;
}
...
}
The updated findRoute
method:
findRoute(path, method) {
+ const indexOfDelimiter = path.indexOf("?");
+ let _path, querySegment;
+
+ if (indexOfDelimiter !== -1) {
+ _path = path.substring(0, indexOfDelimiter);
+ querySegment = path.substring(indexOfDelimiter + 1);
+ } else {
+ _path = path;
+ }
+
+ let segments = _path.split("/").filter(Boolean);
- let segments = path.split("/").filter(Boolean);
...
+ let query = querySegment ? this.#parseQueryParams(querySegment) : {};
return {
params,
+ query,
handler: currentNode.handler.get(method),
};
}
The updated run
method inside lib/index.js
// file: lib/index.js
function run(router, port) {
...
createServer(function _create(req, res) {
const route = router.findRoute(req.url, req.method);
if (route?.handler) {
req.params = route.params || {};
+ req.query = route.query || {};
route.handler(req, res);
} else {
...
}
}).listen(port);
}
Explanation
// file lib/index.js
function run(router, port) {
createServer(function _create(req, res) {
... unchanged
if (route?.handler) {
req.params = route.params || {};
req.query = route.query || {}; // added this line
route.handler(req, res);
} else {
...
}
... unchanged
})
}
req.query = route.query || {};
The very first thing we need to make sure is that the run
method in lib/index.js
is updated to include the query
object in the req
object passed to the route handler.
That way, the users of our library can access the query parameters in their route handlers using req.query
.
findRoute(path, method) {
// Separate path from query parameters
const indexOfDelimiter = path.indexOf("?");
let _path, querySegment;
// If query parameters exist, split the URL into path and query segments
if (indexOfDelimiter !== -1) {
_path = path.substring(0, indexOfDelimiter);
querySegment = path.substring(indexOfDelimiter + 1);
} else {
_path = path;
}
...
}
First, we need to separate the path from the query parameters. We do this by finding the position of the ?
character in the URL using path.indexOf("?")
. If the ?
character is found, ie. path.indexOf("?") !== -1
, we split the URL into _path
(the part before ?
) and querySegment
(the part after ?
). If the ?
character is not found, _path
is set to the entire URL, and querySegment
remains undefined.
let query = querySegment ? this.#parseQueryParams(querySegment) : {};
At the end of the findRoute
method, we need to parse the query parameters if they exist. We check if querySegment
is defined. If it is, we call this.#parseQueryParams(querySegment)
to parse the query parameters. If it is not, we set query
to an empty object {}
.
return {
params,
query,
handler: currentNode.handler.get(method),
};
Finally, we include the parsed query parameters in the object returned by the findRoute
method. We add the query
object to the return value, alongside params
and handler
.
Now, let's break down the #parseQueryParams
method step by step.
if (!queryString) return {};
Next, we check if the queryString
is empty. If it is, we return immediately since there are no query parameters to parse.
const queryParams = {};
const pairs = queryString.split("&");
We then initialize an empty object queryParams
to store the parsed key-value pairs. We split the queryString
by the &
character to get individual key-value pairs. Each pair represents a single query parameter.
for (const pair of pairs) {
const [key, value] = pair.split("=");
if (key && value) {
queryParams[key] = decodeURIComponent(value);
}
}
return queryParams;
We iterate over each pair
in the pairs
array. For each pair, we split it by the =
character to separate the key and value and check if both key
and value
are present. If they are, we decode the value using decodeURIComponent
to handle URL encoding and store the key-value pair in the queryParams
object. For example, name=Velocy%20Framework
would be parsed as { name: 'Velocy Framework' }
.
Finally, we return the queryParams
object containing all the parsed query parameters.
Let's test our implementation
❯ node test.js
{
params: [Object: null prototype] { id: '123' },
query: { name: 'John', age: '30' },
handler: [Function (anonymous)]
}
Everything looks good.
We need to tackle more edge cases
The current implementation of the #parseQueryParams
method works well for basic query parameters. However, it doesn't handle more complex scenarios, such as:
- Special characters in parameter names and values
- URL-encoded characters in both the key and value parts
- Efficiency and performance optimizations by avoiding unnecessary string allocations
Challenge 2: Parsing Query Parameters Manually
In this challenge, we'll take our query parameter parsing to the next level by implementing it manually without relying on built-in Node.js classes like URL
or URLSearchParams
, or using the decodeURIComponent
function. This will give you a deeper understanding of how query parameter parsing works under the hood.
Requirements
- Improve our
parseQueryParams
method that takes a query string and returns an object with parsed parameters. - Handle multiple parameters, including those with the same name. For now, you can overwrite the value if a parameter appears multiple times.
- Properly decode URL-encoded characters in both parameter names and values.
- Handle edge cases such as empty values, missing values, and special characters.
- Integrate this custom parsing function into your
Router
class. - Remember to handle URL decoding for both keys and values.
Expected Outcome
Your custom parseQueryParams
function should be able to handle a variety of query strings and return an object with correctly parsed parameters. The Router
class should use this function to populate the query
property in the object returned by findRoute
.
Example
function parseQueryParams(queryString) {
// Your implementation here
}
// Test cases
console.log(parseQueryParams("name=John&age=30"));
// Expected: { name: 'John', age: '30' }
console.log(parseQueryParams("color=red&color=blue&color=green"));
// Expected: { color: 'green' } // any one value, based on what you want - the first or the last one.
console.log(parseQueryParams("my%20message=Hello%20World%21&empty=&noval"));
// Expected: { 'my message': 'Hello World!', empty: '', noval: '' }
// Integration with Router class
const router = new Router();
router.get("/search", (req, res) => {
console.log(req.query);
});
const result = router.findRoute("/search?q=nodejs&limit=10", "GET");
console.log(result.query);
// Expected: { q: 'nodejs', limit: '10' }
Solution
The very first thing that I did, was to create a new file lib/utils.js
and created an object that maps all the special characters to their respective values.
// file lib/utils.js
// prettier-ignore
const encodedMap = { "3A": ":", "2F": "/", "3F": "?", 23: "#", "5B": "[", "5D": "]", 40: "@", 21: "!", 24: "$", 26: "&", 27: "'", 28: "(", 29: ")", "2A": "*", "2B": "+", "2C": ",", "3B": ";", "3D": "=", 25: "%", 20: " ", 22: '"', "2D": "-", "2E": ".", 30: "0", 31: "1", 32: "2", 33: "3", 34: "4", 35: "5", 36: "6", 37: "7", 38: "8", 39: "9", 41: "A", 42: "B", 43: "C", 44: "D", 45: "E", 46: "F", 47: "G", 48: "H", 49: "I", "4A": "J", "4B": "K", "4C": "L", "4D": "M", "4E": "N", "4F": "O", 50: "P", 51: "Q", 52: "R", 53: "S", 54: "T", 55: "U", 56: "V", 57: "W", 58: "X", 59: "Y", "5A": "Z", 61: "a", 62: "b", 63: "c", 64: "d", 65: "e", 66: "f", 67: "g", 68: "h", 69: "i", "6A": "j", "6B": "k", "6C": "l", "6D": "m", "6E": "n", "6F": "o", 70: "p", 71: "q", 72: "r", 73: "s", 74: "t", 75: "u", 76: "v", 77: "w", 78: "x", 79: "y", "7A": "z", "5E": "^", "5F": "_", 60: "`", "7B": "{", "7C": "|", "7D": "}", "7E": "~", }; // prettier-ignore
Here's a new decoder function that I created to parse query parameters manually, using the encodedMap
object:
// file lib/utils.js
let encodedMap = {...}
function fastDecode(string) {
let result = "";
let lastIndex = 0;
let index = string.indexOf("%");
while (index !== -1) {
result += string.substring(lastIndex, index);
const hexVal = string.substring(index + 1, index + 3);
result += encodedMap[hexVal] || "%" + hexVal;
lastIndex = index + 3;
index = string.indexOf("%", lastIndex);
}
return result + string.substring(lastIndex);
}
Here are the changes that I made to the parseQueryParams
method:
// file lib/router.js
// Added a default value for queryString to handle empty strings
// and more performance improvements
##parseQueryParams(queryString = "") {
if (!queryString) return {};
const queryParams = {};
const pairs = queryString.split("&");
for (const pair of pairs) {
const splitPair = pair.split("=");
let key = splitPair[0];
let value = splitPair[1] || "";
if (key.indexOf("%") !== -1) {
key = fastDecode(key);
}
if (value.indexOf("%") !== -1) {
value = fastDecode(value);
}
if (key) {
queryParams[key] = value;
}
}
return queryParams;
}
Explanation
The fastDecode
function is an optimized method for decoding URL-encoded strings. Let's break down this function and examine why and how it works
// Define the function that takes a string as input
function fastDecodeAll(string) {
// Initialize an empty result string
let result = "";
// Set the initial lastIndex to 0
let lastIndex = 0;
// Find the first occurrence of '%' in the string
let index = string.indexOf("%");
// More code follows...
}
This function initializes key variables: result
to store the decoded string, lastIndex
to keep track of the last processed index, and index
to find encoded characters.
Main Decoding Loop
// Continue loop while '%' is found in the string
while (index !== -1) {
// Add the substring from lastIndex to current index to the result
result += string.substring(lastIndex, index);
// Extract the two characters following '%'
const hexVal = string.substring(index + 1, index + 3);
// Look up the decoded value in encodedMap or keep original if not found
result += encodedMap[hexVal] || "%" + hexVal;
// Update lastIndex to skip the processed encoded character
lastIndex = index + 3;
// Find the next '%' in the string
index = string.indexOf("%", lastIndex);
}
This is the core of the decoding process. It efficiently handles encoded characters by:
Appending unencoded substrings directly to the result. Using a pre-computed encodedMap
for quick lookups of decoded values. Updating indices to skip processed parts of the string.
Efficiency: This approach is more efficient than our previous solution for several reasons:
- It avoids repeated string allocations by using a single
result
string. - It uses a pre-computed map for constant-time lookups of decoded values.
- It processes the string in a single pass, reducing time complexity.
Handling the Remainder
// Add any remaining unprocessed part of the string to the result
return result + string.substring(lastIndex);
This final step ensures that any remaining unencoded portion of the string is included in the result.
Explanation of #parseQueryParams
Function Declaration and Initial Check
// Define the function with a default empty string parameter
##parseQueryParams(queryString = "") {
// Return an empty object if queryString is falsy
if (!queryString) return {};
// Initialize an empty object to store parsed query parameters
const queryParams = {};
This part of the code remains largely unchanged. The function now has a default parameter of an empty string, which is a minor improvement in handling edge cases.
Splitting the Query String
// Split the query string into key-value pairs
const pairs = queryString.split("&");
This line splits the query string into an array of key-value pairs. It's identical in both versions and has a time complexity of O(n), where n is the length of the query string.
Parsing Individual Pairs
for (const pair of pairs) {
// Split each pair into key and value
const splitPair = pair.split("=");
// Assign the key (always exists)
let key = splitPair[0];
// Assign the value (may be undefined, default to empty string)
let value = splitPair[1] || "";
This section shows is almost simpler, but the main difference is that we're not destructuring the splitPair
array directly into key
and value
. Instead, we're assigning them individually.
Decoding Key and Value
// Check if key needs decoding
if (key.indexOf("%") !== -1) {
key = fastDecode(key);
}
// Check if value needs decoding
if (value.indexOf("%") !== -1) {
value = fastDecode(value);
}
This is the most crucial change in the new implementation. Instead of always using decodeURIComponent
, it first checks if decoding is necessary by looking for the '%' character. If found, it uses a custom fastDecode
(not shown in the snippet) to decode the string.
This approach is more efficient for several reasons:
- It avoids unnecessary decoding operations when the string doesn't contain encoded characters.
- The custom
fastDecode
can be optimized for the specific use case, potentially outperforming the genericdecodeURIComponent
.
Assigning to Query Params Object
// Add the key-value pair to the queryParams object if key exists
if (key) {
queryParams[key] = value;
}
This part is similar to the original version, but it doesn't need to check if the value exists because we've already defaulted it to an empty string if it was undefined.
Returning the Result
// Return the object containing all parsed query parameters
return queryParams;
}
Here's a quick overview of the changes made to the #parseQueryParams
method:
for (const pair of pairs) {
- const [key, value] = pair.split("=");
- if (key && value) {
- queryParams[key] = decodeURIComponent(value);
- }
+ const splitPair = pair.split("=");
+ let key = splitPair[0];
+ let value = splitPair[1] || "";
+
+ if (key.indexOf("%") !== -1) {
+ key = fastDecode(key);
+ }
+
+ if (value.indexOf("%") !== -1) {
+ value = fastDecode(value);
+ }
+
+ if (key) {
+ queryParams[key] = value;
+ }
}
Note
Note: The fastDecode
function here is still not the complete implementation. You may need to add more characters to the encodedMap
object to handle all possible encoded characters.
Conclusion
Query parameters are a powerful and flexible tool, which allow for the transmission of data through URLs, enabling dynamic and interactive client-side applications. By understanding their structure, usage, and best practices, we can create more efficient, user-friendly, and secure web applications.
Remember to always consider security implications when working with query parameters, and choose the most appropriate method of data transmission based on your specific use case and requirements.