mirror of
https://github.com/oven-sh/bun
synced 2026-02-07 17:38:46 +00:00
Compare commits
9 Commits
dylan/pyth
...
claude/req
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
817d18d35c | ||
|
|
4acba9e72d | ||
|
|
a5073c8ae5 | ||
|
|
8482588276 | ||
|
|
9552cf94cb | ||
|
|
a78aed8060 | ||
|
|
ca8d2fad2e | ||
|
|
ec76f45369 | ||
|
|
23016b2a82 |
@@ -23,6 +23,7 @@ src/bun.js/bindings/BunJSCEventLoop.cpp
|
||||
src/bun.js/bindings/BunObject.cpp
|
||||
src/bun.js/bindings/BunPlugin.cpp
|
||||
src/bun.js/bindings/BunProcess.cpp
|
||||
src/bun.js/bindings/BunRequestParams.cpp
|
||||
src/bun.js/bindings/BunString.cpp
|
||||
src/bun.js/bindings/BunWorkerGlobalScope.cpp
|
||||
src/bun.js/bindings/c-bindings.cpp
|
||||
|
||||
17
packages/bun-types/bun.d.ts
vendored
17
packages/bun-types/bun.d.ts
vendored
@@ -3240,6 +3240,23 @@ declare module "bun" {
|
||||
interface BunRequest<T extends string = string> extends Request {
|
||||
params: RouterTypes.ExtractRouteParams<T>;
|
||||
readonly cookies: CookieMap;
|
||||
/**
|
||||
* An object containing the parsed query string parameters from the URL.
|
||||
* Uses Rails-style parameter parsing for nested objects and arrays.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // URL: /api/users?name=john&age=30
|
||||
* req.query // { name: "john", age: "30" }
|
||||
*
|
||||
* // URL: /api/users?user[name]=john&user[email]=john@example.com
|
||||
* req.query // { user: { name: "john", email: "john@example.com" } }
|
||||
*
|
||||
* // URL: /api/users?ids[]=1&ids[]=2&ids[]=3
|
||||
* req.query // { ids: ["1", "2", "3"] }
|
||||
* ```
|
||||
*/
|
||||
readonly query: Record<string, any>;
|
||||
|
||||
clone(): BunRequest<T>;
|
||||
}
|
||||
|
||||
329
src/bun.js/bindings/BunRequestParams.cpp
Normal file
329
src/bun.js/bindings/BunRequestParams.cpp
Normal file
@@ -0,0 +1,329 @@
|
||||
#include "BunRequestParams.h"
|
||||
#include <JavaScriptCore/JSArray.h>
|
||||
#include <JavaScriptCore/ObjectConstructor.h>
|
||||
#include <wtf/URLParser.h>
|
||||
#include <wtf/URL.h>
|
||||
#include <wtf/text/StringToIntegerConversion.h>
|
||||
|
||||
namespace Bun {
|
||||
|
||||
using namespace JSC;
|
||||
|
||||
// Helper to check if a string represents a valid array index (non-negative integer)
|
||||
static bool isArrayIndex(const String& key, unsigned& index)
|
||||
{
|
||||
if (key.isEmpty())
|
||||
return false;
|
||||
|
||||
// Check if all characters are digits
|
||||
for (auto c : StringView(key).codeUnits()) {
|
||||
if (!isASCIIDigit(c))
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the integer
|
||||
auto parseResult = parseInteger<unsigned>(StringView(key));
|
||||
if (!parseResult.has_value())
|
||||
return false;
|
||||
|
||||
index = parseResult.value();
|
||||
|
||||
// Prevent creating huge sparse arrays - limit to reasonable size
|
||||
// Rails typically limits array indices to prevent DoS
|
||||
// We'll use a high limit that prevents obvious abuse
|
||||
if (index > 10000)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper function to parse Rails-style query parameters into nested objects
|
||||
// Returns false if an exception was thrown
|
||||
static bool parseRailsStyleParams(JSC::JSGlobalObject* globalObject, JSC::JSObject* result, const String& key, const String& value)
|
||||
{
|
||||
auto& vm = globalObject->vm();
|
||||
auto throwScope = DECLARE_THROW_SCOPE(vm);
|
||||
|
||||
// Find the first bracket
|
||||
size_t bracketPos = key.find('[');
|
||||
|
||||
// No brackets - simple key-value pair
|
||||
if (bracketPos == notFound) {
|
||||
// Ignore __proto__ for security
|
||||
if (key == "__proto__"_s)
|
||||
return true;
|
||||
|
||||
// Simple key-value assignment - last value wins
|
||||
// Use putDirectMayBeIndex since key could be numeric
|
||||
result->putDirectMayBeIndex(globalObject, Identifier::fromString(vm, key), jsString(vm, value));
|
||||
RETURN_IF_EXCEPTION(throwScope, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract the base key
|
||||
String baseKey = key.substring(0, bracketPos);
|
||||
if (baseKey == "__proto__"_s)
|
||||
return true;
|
||||
|
||||
// Parse the rest of the key to determine structure
|
||||
String remainder = key.substring(bracketPos);
|
||||
|
||||
// Get existing value at baseKey
|
||||
JSValue existing = result->getDirect(vm, Identifier::fromString(vm, baseKey));
|
||||
|
||||
// Handle [] notation (array append)
|
||||
if (remainder.startsWith("[]"_s)) {
|
||||
JSArray* array = nullptr;
|
||||
|
||||
// Check if we already have a value at this key
|
||||
if (!existing.isEmpty()) {
|
||||
if (!existing.isObject())
|
||||
return true; // Can't convert primitive to array
|
||||
|
||||
JSObject* obj = asObject(existing);
|
||||
if (!obj->inherits<JSArray>())
|
||||
return true; // Type conflict - it's an object, not an array
|
||||
|
||||
array = jsCast<JSArray*>(obj);
|
||||
} else {
|
||||
// Create new array
|
||||
array = JSArray::create(vm, globalObject->arrayStructureForIndexingTypeDuringAllocation(ArrayWithContiguous), 0);
|
||||
result->putDirect(vm, Identifier::fromString(vm, baseKey), array);
|
||||
RETURN_IF_EXCEPTION(throwScope, false);
|
||||
}
|
||||
|
||||
// Check if there's more nesting after []
|
||||
if (remainder.length() > 2 && remainder[2] == '[') {
|
||||
// Handle cases like users[][name] - create object and recursively parse
|
||||
String nestedRemainder = remainder.substring(2);
|
||||
|
||||
// Create a new object for this array element
|
||||
JSObject* nestedObj = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure());
|
||||
array->putDirectIndex(globalObject, array->length(), nestedObj);
|
||||
RETURN_IF_EXCEPTION(throwScope, false);
|
||||
|
||||
// Recursively parse the nested structure
|
||||
// Remove the leading [ and find the closing ]
|
||||
size_t closeBracket = nestedRemainder.find(']');
|
||||
if (closeBracket != notFound) {
|
||||
String nestedKey = nestedRemainder.substring(1, closeBracket - 1);
|
||||
String afterBracket = closeBracket + 1 < nestedRemainder.length()
|
||||
? nestedRemainder.substring(closeBracket + 1)
|
||||
: String();
|
||||
|
||||
if (afterBracket.isEmpty()) {
|
||||
// Simple nested property like users[][name]
|
||||
if (nestedKey != "__proto__"_s) {
|
||||
// Use putDirectMayBeIndex since nestedKey could be empty or numeric
|
||||
nestedObj->putDirectMayBeIndex(globalObject, Identifier::fromString(vm, nestedKey), jsString(vm, value));
|
||||
RETURN_IF_EXCEPTION(throwScope, false);
|
||||
}
|
||||
} else {
|
||||
// More complex nesting like users[][address][street]
|
||||
String fullNestedKey = makeString(nestedKey, afterBracket);
|
||||
if (!parseRailsStyleParams(globalObject, nestedObj, fullNestedKey, value))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Simple array append - users[]=value
|
||||
array->putDirectIndex(globalObject, array->length(), jsString(vm, value));
|
||||
RETURN_IF_EXCEPTION(throwScope, false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle [key] notation (could be array index or object property)
|
||||
size_t closeBracket = remainder.find(']');
|
||||
if (closeBracket == notFound)
|
||||
return true; // Malformed
|
||||
|
||||
String innerKey = remainder.substring(1, closeBracket - 1);
|
||||
|
||||
// Determine if this should be an array (numeric index) or object (string key)
|
||||
unsigned index = 0;
|
||||
bool isIndex = isArrayIndex(innerKey, index);
|
||||
|
||||
// Get or create the container (array or object)
|
||||
JSObject* container = nullptr;
|
||||
bool isArray = false;
|
||||
|
||||
if (!existing.isEmpty()) {
|
||||
if (!existing.isObject())
|
||||
return true; // Can't index into primitive
|
||||
|
||||
container = asObject(existing);
|
||||
isArray = container->inherits<JSArray>();
|
||||
|
||||
// Type consistency check
|
||||
if (isIndex && !isArray)
|
||||
return true; // Trying to use array index on object
|
||||
if (!isIndex && isArray)
|
||||
return true; // Trying to use string key on array
|
||||
} else {
|
||||
// Create new container based on the key type
|
||||
if (isIndex) {
|
||||
container = JSArray::create(vm, globalObject->arrayStructureForIndexingTypeDuringAllocation(ArrayWithContiguous), 0);
|
||||
isArray = true;
|
||||
} else {
|
||||
container = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure());
|
||||
isArray = false;
|
||||
}
|
||||
result->putDirect(vm, Identifier::fromString(vm, baseKey), container);
|
||||
RETURN_IF_EXCEPTION(throwScope, false);
|
||||
}
|
||||
|
||||
// Check if there's more nesting
|
||||
size_t nextBracket = remainder.find('[', closeBracket + 1);
|
||||
if (nextBracket != notFound) {
|
||||
// More nesting - recursively parse
|
||||
String nestedRemainder = remainder.substring(closeBracket + 1);
|
||||
|
||||
// Get or create nested object
|
||||
JSObject* nestedObj = nullptr;
|
||||
|
||||
if (isArray) {
|
||||
JSArray* array = jsCast<JSArray*>(container);
|
||||
JSValue existingAtIndex = index < array->length() ? array->getIndexQuickly(index) : JSValue();
|
||||
|
||||
if (!existingAtIndex.isEmpty() && existingAtIndex.isObject()) {
|
||||
nestedObj = asObject(existingAtIndex);
|
||||
} else {
|
||||
nestedObj = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure());
|
||||
array->putDirectIndex(globalObject, index, nestedObj);
|
||||
RETURN_IF_EXCEPTION(throwScope, false);
|
||||
}
|
||||
} else {
|
||||
// Skip __proto__ for security
|
||||
if (innerKey == "__proto__"_s)
|
||||
return true;
|
||||
|
||||
JSValue existingNested = container->getDirect(vm, Identifier::fromString(vm, innerKey));
|
||||
|
||||
if (!existingNested.isEmpty() && existingNested.isObject()) {
|
||||
nestedObj = asObject(existingNested);
|
||||
} else {
|
||||
nestedObj = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure());
|
||||
// Use putDirectMayBeIndex since innerKey could be numeric
|
||||
container->putDirectMayBeIndex(globalObject, Identifier::fromString(vm, innerKey), nestedObj);
|
||||
RETURN_IF_EXCEPTION(throwScope, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the nested structure
|
||||
if (nestedRemainder.startsWith("["_s) && nestedRemainder.length() > 1) {
|
||||
size_t endBracket = nestedRemainder.find(']');
|
||||
if (endBracket != notFound) {
|
||||
String propertyName = nestedRemainder.substring(1, endBracket - 1);
|
||||
String afterProperty = endBracket + 1 < nestedRemainder.length()
|
||||
? nestedRemainder.substring(endBracket + 1)
|
||||
: String();
|
||||
|
||||
if (afterProperty.isEmpty()) {
|
||||
// Simple property assignment
|
||||
if (propertyName != "__proto__"_s) {
|
||||
// Use putDirectMayBeIndex since propertyName could be empty or numeric
|
||||
nestedObj->putDirectMayBeIndex(globalObject, Identifier::fromString(vm, propertyName), jsString(vm, value));
|
||||
RETURN_IF_EXCEPTION(throwScope, false);
|
||||
}
|
||||
} else {
|
||||
// More complex nesting
|
||||
String fullNestedKey = makeString(propertyName, afterProperty);
|
||||
if (!parseRailsStyleParams(globalObject, nestedObj, fullNestedKey, value))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No more nesting - assign the value
|
||||
if (isArray) {
|
||||
JSArray* array = jsCast<JSArray*>(container);
|
||||
array->putDirectIndex(globalObject, index, jsString(vm, value));
|
||||
RETURN_IF_EXCEPTION(throwScope, false);
|
||||
} else {
|
||||
// Skip __proto__ for security
|
||||
if (innerKey != "__proto__"_s) {
|
||||
// Use putDirectMayBeIndex since innerKey could be numeric
|
||||
container->putDirectMayBeIndex(globalObject, Identifier::fromString(vm, innerKey), jsString(vm, value));
|
||||
RETURN_IF_EXCEPTION(throwScope, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
JSObject* parseQueryParams(JSGlobalObject* globalObject, const String& queryString)
|
||||
{
|
||||
auto& vm = globalObject->vm();
|
||||
auto throwScope = DECLARE_THROW_SCOPE(vm);
|
||||
|
||||
// Create result object with null prototype for security
|
||||
JSObject* queryObject = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure());
|
||||
|
||||
if (queryString.isEmpty()) {
|
||||
return queryObject;
|
||||
}
|
||||
|
||||
// Parse query string using WebKit's URLParser
|
||||
auto params = WTF::URLParser::parseURLEncodedForm(queryString);
|
||||
|
||||
// Process each parameter with Rails-style parsing
|
||||
for (const auto& param : params) {
|
||||
if (!parseRailsStyleParams(globalObject, queryObject, param.key, param.value)) {
|
||||
RETURN_IF_EXCEPTION(throwScope, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
return queryObject;
|
||||
}
|
||||
|
||||
JSObject* parseURLQueryParams(JSGlobalObject* globalObject, const String& urlString)
|
||||
{
|
||||
auto& vm = globalObject->vm();
|
||||
auto throwScope = DECLARE_THROW_SCOPE(vm);
|
||||
|
||||
// Parse the URL to extract query string
|
||||
URL url(urlString);
|
||||
StringView queryView = url.query();
|
||||
String queryString = queryView.toString();
|
||||
|
||||
JSObject* result = parseQueryParams(globalObject, queryString);
|
||||
RETURN_IF_EXCEPTION(throwScope, nullptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
JSC_DEFINE_HOST_FUNCTION(jsBunParseQueryParams, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
|
||||
{
|
||||
auto& vm = globalObject->vm();
|
||||
auto throwScope = DECLARE_THROW_SCOPE(vm);
|
||||
|
||||
if (callFrame->argumentCount() < 1) {
|
||||
return JSValue::encode(jsUndefined());
|
||||
}
|
||||
|
||||
JSValue arg = callFrame->argument(0);
|
||||
if (!arg.isString()) {
|
||||
return JSValue::encode(jsUndefined());
|
||||
}
|
||||
|
||||
String queryString = arg.toWTFString(globalObject);
|
||||
RETURN_IF_EXCEPTION(throwScope, encodedJSValue());
|
||||
|
||||
JSObject* result = parseQueryParams(globalObject, queryString);
|
||||
RETURN_IF_EXCEPTION(throwScope, encodedJSValue());
|
||||
|
||||
// parseQueryParams might return nullptr if an exception occurred
|
||||
if (!result) {
|
||||
return JSValue::encode(jsUndefined());
|
||||
}
|
||||
|
||||
return JSValue::encode(result);
|
||||
}
|
||||
|
||||
extern "C" JSC::EncodedJSValue Bun__parseQueryParams(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callFrame)
|
||||
{
|
||||
return jsBunParseQueryParams(globalObject, callFrame);
|
||||
}
|
||||
|
||||
} // namespace Bun
|
||||
21
src/bun.js/bindings/BunRequestParams.h
Normal file
21
src/bun.js/bindings/BunRequestParams.h
Normal file
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "root.h"
|
||||
#include <JavaScriptCore/JSGlobalObject.h>
|
||||
#include <JavaScriptCore/JSObject.h>
|
||||
#include <JavaScriptCore/CallFrame.h>
|
||||
#include <wtf/text/WTFString.h>
|
||||
|
||||
namespace Bun {
|
||||
|
||||
// Parse query string into Rails-style nested object
|
||||
JSC::JSObject* parseQueryParams(JSC::JSGlobalObject* globalObject, const WTF::String& queryString);
|
||||
|
||||
// Parse URL and extract query params into Rails-style nested object
|
||||
JSC::JSObject* parseURLQueryParams(JSC::JSGlobalObject* globalObject, const WTF::String& urlString);
|
||||
|
||||
// Export for testing
|
||||
JSC_DECLARE_HOST_FUNCTION(jsBunParseQueryParams);
|
||||
extern "C" JSC::EncodedJSValue Bun__parseQueryParams(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callFrame);
|
||||
|
||||
} // namespace Bun
|
||||
@@ -13,17 +13,20 @@
|
||||
#include "CookieMap.h"
|
||||
#include "ErrorCode.h"
|
||||
#include "JSDOMExceptionHandling.h"
|
||||
#include "BunRequestParams.h"
|
||||
|
||||
namespace Bun {
|
||||
|
||||
static JSC_DECLARE_CUSTOM_GETTER(jsJSBunRequestGetParams);
|
||||
static JSC_DECLARE_CUSTOM_GETTER(jsJSBunRequestGetCookies);
|
||||
static JSC_DECLARE_CUSTOM_GETTER(jsJSBunRequestGetQuery);
|
||||
|
||||
static JSC_DECLARE_HOST_FUNCTION(jsJSBunRequestClone);
|
||||
|
||||
static const HashTableValue JSBunRequestPrototypeValues[] = {
|
||||
{ "params"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsJSBunRequestGetParams, nullptr } },
|
||||
{ "cookies"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsJSBunRequestGetCookies, nullptr } },
|
||||
{ "query"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsJSBunRequestGetQuery, nullptr } },
|
||||
{ "clone"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsJSBunRequestClone, 1 } }
|
||||
};
|
||||
|
||||
@@ -247,6 +250,36 @@ JSC_DEFINE_CUSTOM_GETTER(jsJSBunRequestGetCookies, (JSC::JSGlobalObject * global
|
||||
return JSValue::encode(cookies);
|
||||
}
|
||||
|
||||
JSC_DEFINE_CUSTOM_GETTER(jsJSBunRequestGetQuery, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName))
|
||||
{
|
||||
auto& vm = globalObject->vm();
|
||||
auto throwScope = DECLARE_THROW_SCOPE(vm);
|
||||
|
||||
JSBunRequest* request = jsDynamicCast<JSBunRequest*>(JSValue::decode(thisValue));
|
||||
if (!request)
|
||||
return JSValue::encode(jsUndefined());
|
||||
|
||||
// Get the URL from the request
|
||||
JSValue urlValue = request->get(globalObject, Identifier::fromString(vm, "url"_s));
|
||||
RETURN_IF_EXCEPTION(throwScope, encodedJSValue());
|
||||
|
||||
if (!urlValue.isString())
|
||||
return JSValue::encode(jsUndefined());
|
||||
|
||||
String urlString = urlValue.toWTFString(globalObject);
|
||||
RETURN_IF_EXCEPTION(throwScope, encodedJSValue());
|
||||
|
||||
// Use the extracted parsing function
|
||||
JSObject* queryObject = parseURLQueryParams(globalObject, urlString);
|
||||
RETURN_IF_EXCEPTION(throwScope, encodedJSValue());
|
||||
|
||||
// parseURLQueryParams might return nullptr if an exception occurred
|
||||
if (!queryObject)
|
||||
return JSValue::encode(jsUndefined());
|
||||
|
||||
return JSValue::encode(queryObject);
|
||||
}
|
||||
|
||||
JSC_DEFINE_HOST_FUNCTION(jsJSBunRequestClone, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
|
||||
{
|
||||
auto& vm = globalObject->vm();
|
||||
|
||||
@@ -16,6 +16,9 @@ export const canonicalizeIP = $newCppFunction("NodeTLS.cpp", "Bun__canonicalizeI
|
||||
|
||||
export const SQL = $cpp("JSSQLStatement.cpp", "createJSSQLStatementConstructor");
|
||||
|
||||
// Parse query parameters into Rails-style nested objects
|
||||
export const parseQueryParams = $newCppFunction("BunRequestParams.cpp", "Bun__parseQueryParams", 1);
|
||||
|
||||
export const patchInternals = {
|
||||
parse: $newZigFunction("patch.zig", "TestingAPIs.parse", 1),
|
||||
apply: $newZigFunction("patch.zig", "TestingAPIs.apply", 2),
|
||||
|
||||
450
test/js/bun/http/bun-request-query.test.ts
Normal file
450
test/js/bun/http/bun-request-query.test.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
console.log("Test file loaded");
|
||||
|
||||
test("req.query - simple parameters", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?name=john&age=30&active=true`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
name: "john",
|
||||
age: "30",
|
||||
active: "true",
|
||||
});
|
||||
});
|
||||
|
||||
test("req.query - empty query string", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({});
|
||||
});
|
||||
|
||||
test("req.query - URL encoded values", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?message=Hello%20World&special=%40%23%24%25`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
message: "Hello World",
|
||||
special: "@#$%",
|
||||
});
|
||||
});
|
||||
|
||||
test("req.query - Rails-style nested objects", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?user[name]=john&user[age]=30&user[email]=john@example.com`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
user: {
|
||||
name: "john",
|
||||
age: "30",
|
||||
email: "john@example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("req.query - Rails-style deeply nested objects", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`${server.url}?person[address][street]=123%20Main&person[address][city]=Portland&person[name]=Bob`,
|
||||
);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
person: {
|
||||
address: {
|
||||
street: "123 Main",
|
||||
city: "Portland",
|
||||
},
|
||||
name: "Bob",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("req.query - Rails-style arrays with empty brackets", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?ids[]=1&ids[]=2&ids[]=3`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
ids: ["1", "2", "3"],
|
||||
});
|
||||
});
|
||||
|
||||
test("req.query - Rails-style indexed arrays", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?items[0]=apple&items[1]=banana&items[2]=orange`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
items: ["apple", "banana", "orange"],
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Known limitation - nested arrays like user[tags][] require lookahead to properly parse
|
||||
// This test is temporarily disabled due to a crash in the parser
|
||||
// test("req.query - Rails-style nested arrays", async () => {
|
||||
// await using server = Bun.serve({
|
||||
// port: 0,
|
||||
// routes: {
|
||||
// "/": {
|
||||
// GET(req) {
|
||||
// return Response.json(req.query);
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
//
|
||||
// const res = await fetch(`${server.url}?user[tags][]=admin&user[tags][]=developer&user[name]=alice`);
|
||||
// const data = await res.json();
|
||||
//
|
||||
// expect(data).toEqual({
|
||||
// user: {
|
||||
// tags: ["admin", "developer"],
|
||||
// name: "alice",
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
|
||||
test("req.query - duplicate keys (last wins)", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?color=red&color=blue&color=green`);
|
||||
const data = await res.json();
|
||||
|
||||
// In simple key-value pairs, last value wins
|
||||
expect(data).toEqual({
|
||||
color: "green",
|
||||
});
|
||||
});
|
||||
|
||||
test("req.query - mixed simple and nested parameters", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?simple=value&nested[key]=nestedValue&array[]=1&array[]=2`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
simple: "value",
|
||||
nested: {
|
||||
key: "nestedValue",
|
||||
},
|
||||
array: ["1", "2"],
|
||||
});
|
||||
});
|
||||
|
||||
test("req.query - numeric-looking keys", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?123=numeric&0=zero&normal=text`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
"123": "numeric",
|
||||
"0": "zero",
|
||||
normal: "text",
|
||||
});
|
||||
});
|
||||
|
||||
test("req.query - empty values", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?empty=&also_empty&has_value=yes`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
empty: "",
|
||||
also_empty: "",
|
||||
has_value: "yes",
|
||||
});
|
||||
});
|
||||
|
||||
test("req.query - complex nested structure", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?users[0][name]=alice&users[0][age]=25&users[1][name]=bob&users[1][age]=30`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
users: [
|
||||
{ name: "alice", age: "25" },
|
||||
{ name: "bob", age: "30" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("req.query - __proto__ is ignored for security", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?__proto__=evil&user[__proto__]=bad&normal=ok`);
|
||||
const data = await res.json();
|
||||
|
||||
// __proto__ keys should be ignored
|
||||
expect(data).toEqual({
|
||||
normal: "ok",
|
||||
user: {},
|
||||
});
|
||||
|
||||
// Verify prototype wasn't polluted
|
||||
expect(Object.prototype.hasOwnProperty("evil")).toBe(false);
|
||||
});
|
||||
|
||||
test("req.query - null prototype object", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
const query = req.query;
|
||||
// Verify the object has null prototype
|
||||
const proto = Object.getPrototypeOf(query);
|
||||
return Response.json({
|
||||
hasNullProto: proto === null,
|
||||
query,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?test=value`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data.hasNullProto).toBe(true);
|
||||
expect(data.query).toEqual({ test: "value" });
|
||||
});
|
||||
|
||||
test("req.query - special characters in keys", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?key%20with%20spaces=value&symbols!%40%23=test`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
"key with spaces": "value",
|
||||
"symbols!@#": "test",
|
||||
});
|
||||
});
|
||||
|
||||
test("req.query - works only with routes (Bun.serve)", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req, server) {
|
||||
// Routes are required for BunRequest
|
||||
return server.upgrade(req) ? undefined : Response.json({ hasQuery: "query" in req });
|
||||
},
|
||||
websocket: {
|
||||
open() {},
|
||||
message() {},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?test=value`);
|
||||
const data = await res.json();
|
||||
|
||||
// Without routes, req.query won't be available (regular Request, not BunRequest)
|
||||
expect(data.hasQuery).toBe(false);
|
||||
});
|
||||
|
||||
test("req.query - with routes", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/test": {
|
||||
GET(req) {
|
||||
return Response.json({
|
||||
hasQuery: "query" in req,
|
||||
query: req.query,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}/test?foo=bar`);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data.hasQuery).toBe(true);
|
||||
expect(data.query).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
test("req.query - sparse indexed arrays", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}?arr[0]=first&arr[2]=third&arr[5]=sixth`);
|
||||
const data = await res.json();
|
||||
|
||||
// Sparse arrays will have null in JSON for missing indices
|
||||
expect(data).toEqual({
|
||||
arr: ["first", null, "third", null, null, "sixth"],
|
||||
});
|
||||
});
|
||||
|
||||
test("req.query - array and object type conflict", async () => {
|
||||
await using server = Bun.serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
GET(req) {
|
||||
return Response.json(req.query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// When there's a type conflict (treating same key as both array and object),
|
||||
// the first type wins and conflicting params are ignored
|
||||
const res = await fetch(`${server.url}?items[]=array&items[key]=object`);
|
||||
const data = await res.json();
|
||||
|
||||
// First param established items as array, so object notation is ignored
|
||||
expect(data).toEqual({
|
||||
items: ["array"],
|
||||
});
|
||||
});
|
||||
139
test/js/bun/http/parse-query-params.test.ts
Normal file
139
test/js/bun/http/parse-query-params.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { parseQueryParams } from "bun:internal-for-testing";
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("parseQueryParams - simple parameters", () => {
|
||||
const result = parseQueryParams("name=john&age=30&active=true");
|
||||
expect(result).toEqual({
|
||||
name: "john",
|
||||
age: "30",
|
||||
active: "true",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseQueryParams - empty query string", () => {
|
||||
const result = parseQueryParams("");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("parseQueryParams - URL encoded values", () => {
|
||||
const result = parseQueryParams("message=Hello%20World&special=%40%23%24%25");
|
||||
expect(result).toEqual({
|
||||
message: "Hello World",
|
||||
special: "@#$%",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseQueryParams - Rails-style nested objects", () => {
|
||||
const result = parseQueryParams("user[name]=john&user[age]=30&user[email]=john@example.com");
|
||||
expect(result).toEqual({
|
||||
user: {
|
||||
name: "john",
|
||||
age: "30",
|
||||
email: "john@example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("parseQueryParams - Rails-style deeply nested objects", () => {
|
||||
const result = parseQueryParams("person[address][street]=123%20Main&person[address][city]=Portland&person[name]=Bob");
|
||||
expect(result).toEqual({
|
||||
person: {
|
||||
address: {
|
||||
street: "123 Main",
|
||||
city: "Portland",
|
||||
},
|
||||
name: "Bob",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("parseQueryParams - Rails-style arrays with empty brackets", () => {
|
||||
const result = parseQueryParams("ids[]=1&ids[]=2&ids[]=3");
|
||||
expect(result).toEqual({
|
||||
ids: ["1", "2", "3"],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseQueryParams - Rails-style indexed arrays", () => {
|
||||
const result = parseQueryParams("items[0]=apple&items[1]=banana&items[2]=orange");
|
||||
expect(result).toEqual({
|
||||
items: ["apple", "banana", "orange"],
|
||||
});
|
||||
});
|
||||
|
||||
test.skip("parseQueryParams - Rails-style nested arrays", () => {
|
||||
// TODO: This is a known limitation - nested arrays like user[tags][] are not fully supported yet
|
||||
// Currently creates an object instead of array
|
||||
const result = parseQueryParams("user[tags][]=admin&user[tags][]=developer&user[name]=alice");
|
||||
expect(result).toEqual({
|
||||
user: {
|
||||
tags: ["admin", "developer"],
|
||||
name: "alice",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("parseQueryParams - duplicate keys (last wins)", () => {
|
||||
const result = parseQueryParams("color=red&color=blue&color=green");
|
||||
expect(result).toEqual({
|
||||
color: "green",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseQueryParams - mixed simple and nested parameters", () => {
|
||||
const result = parseQueryParams("simple=value&nested[key]=nestedValue&array[]=1&array[]=2");
|
||||
expect(result).toEqual({
|
||||
simple: "value",
|
||||
nested: {
|
||||
key: "nestedValue",
|
||||
},
|
||||
array: ["1", "2"],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseQueryParams - complex nested structure", () => {
|
||||
const result = parseQueryParams("users[0][name]=alice&users[0][age]=25&users[1][name]=bob&users[1][age]=30");
|
||||
console.log("Result:", JSON.stringify(result, null, 2));
|
||||
expect(result).toEqual({
|
||||
users: [
|
||||
{ name: "alice", age: "25" },
|
||||
{ name: "bob", age: "30" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseQueryParams - __proto__ is ignored for security", () => {
|
||||
const result = parseQueryParams("__proto__=evil&user[__proto__]=bad&normal=ok");
|
||||
// __proto__ keys are ignored, but the parent object is still created
|
||||
expect(result).toEqual({
|
||||
normal: "ok",
|
||||
user: {},
|
||||
});
|
||||
|
||||
// Verify prototype wasn't polluted
|
||||
expect(Object.prototype.hasOwnProperty("evil")).toBe(false);
|
||||
});
|
||||
|
||||
test("parseQueryParams - special characters in keys", () => {
|
||||
const result = parseQueryParams("key%20with%20spaces=value&symbols!%40%23=test");
|
||||
expect(result).toEqual({
|
||||
"key with spaces": "value",
|
||||
"symbols!@#": "test",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseQueryParams - sparse indexed arrays", () => {
|
||||
const result = parseQueryParams("arr[0]=first&arr[2]=third&arr[5]=sixth");
|
||||
// Sparse arrays will have undefined for missing indices
|
||||
expect(result).toEqual({
|
||||
arr: ["first", undefined, "third", undefined, undefined, "sixth"],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseQueryParams - array and object type conflict", () => {
|
||||
const result = parseQueryParams("items[]=array&items[key]=object");
|
||||
// First param established items as array, so object notation is ignored
|
||||
expect(result).toEqual({
|
||||
items: ["array"],
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user