View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.javascript.host;
16  
17  import java.net.MalformedURLException;
18  import java.util.ArrayList;
19  import java.util.Collections;
20  import java.util.Iterator;
21  import java.util.List;
22  import java.util.ListIterator;
23  import java.util.Map;
24  
25  import org.apache.commons.lang3.StringUtils;
26  import org.apache.commons.logging.Log;
27  import org.apache.commons.logging.LogFactory;
28  import org.htmlunit.FormEncodingType;
29  import org.htmlunit.WebRequest;
30  import org.htmlunit.corejs.javascript.ClassDescriptor;
31  import org.htmlunit.corejs.javascript.Context;
32  import org.htmlunit.corejs.javascript.ES6Iterator;
33  import org.htmlunit.corejs.javascript.EcmaError;
34  import org.htmlunit.corejs.javascript.Function;
35  import org.htmlunit.corejs.javascript.IteratorLikeIterable;
36  import org.htmlunit.corejs.javascript.NativeObject;
37  import org.htmlunit.corejs.javascript.ScriptRuntime;
38  import org.htmlunit.corejs.javascript.Scriptable;
39  import org.htmlunit.corejs.javascript.SymbolKey;
40  import org.htmlunit.corejs.javascript.TopLevel;
41  import org.htmlunit.corejs.javascript.VarScope;
42  import org.htmlunit.javascript.HtmlUnitScriptable;
43  import org.htmlunit.javascript.JavaScriptEngine;
44  import org.htmlunit.javascript.configuration.JsxClass;
45  import org.htmlunit.javascript.configuration.JsxConstructor;
46  import org.htmlunit.javascript.configuration.JsxFunction;
47  import org.htmlunit.javascript.configuration.JsxGetter;
48  import org.htmlunit.javascript.configuration.JsxSymbol;
49  import org.htmlunit.javascript.host.xml.FormData.FormDataIterator;
50  import org.htmlunit.util.NameValuePair;
51  import org.htmlunit.util.UrlUtils;
52  
53  /**
54   * JavaScript host object for {@code URLSearchParams}.
55   *
56   * @author Ahmed Ashour
57   * @author Ronald Brill
58   * @author Ween Jiann
59   * @author cd alexndr
60   * @author Lai Quang Duong
61   *
62   * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams">MDN Documentation</a>
63   */
64  @JsxClass
65  public class URLSearchParams extends HtmlUnitScriptable {
66  
67      private static final Log LOG = LogFactory.getLog(URLSearchParams.class);
68  
69      /** Constant used to register the prototype in the context. */
70      private static final String URL_SEARCH_PARMS_ITERATOR_TAG = "URLSearchParams Iterator";
71  
72      private URL url_;
73  
74      /**
75       * {@link ES6Iterator} implementation for JavaScript support.
76       */
77      public static final class NativeParamsIterator extends ES6Iterator {
78  
79          private static final ClassDescriptor DESCRIPTOR =
80                  ES6Iterator.makeDescriptor(URL_SEARCH_PARMS_ITERATOR_TAG, URL_SEARCH_PARMS_ITERATOR_TAG);
81  
82          enum Type { KEYS, VALUES, BOTH }
83  
84          private final Type type_;
85          private final String className_;
86          private final transient Iterator<NameValuePair> iterator_;
87  
88          /**
89           * Initializes the iterator prototype.
90           *
91           * @param cx the {@link Context}
92           * @param scope the scope
93           * @param className the class name
94           */
95          public static void init(final Context cx, final TopLevel scope, final String className) {
96              ES6Iterator.initialize(
97                      DESCRIPTOR, cx, scope, new FormDataIterator(className), false, URL_SEARCH_PARMS_ITERATOR_TAG);
98          }
99  
100         /**
101          * Creates an empty instance (used for prototype initialization).
102          *
103          * @param className the class name
104          */
105         public NativeParamsIterator(final String className) {
106             super();
107             iterator_ = Collections.emptyIterator();
108             type_ = Type.BOTH;
109             className_ = className;
110         }
111 
112         /**
113          * Creates an instance backed by the given iterator.
114          *
115          * @param scope the scope
116          * @param className the class name
117          * @param type the iteration type (keys, values, or both)
118          * @param iterator the backing iterator
119          */
120         public NativeParamsIterator(final VarScope scope, final String className, final Type type,
121                                         final Iterator<NameValuePair> iterator) {
122             super(scope, className);
123             iterator_ = iterator;
124             type_ = type;
125             className_ = className;
126         }
127 
128         @Override
129         public String getClassName() {
130             return className_;
131         }
132 
133         @Override
134         protected boolean isDone(final Context cx, final VarScope scope) {
135             return !iterator_.hasNext();
136         }
137 
138         @Override
139         protected Object nextValue(final Context cx, final VarScope scope) {
140             final NameValuePair e = iterator_.next();
141             return switch (type_) {
142                 case KEYS -> e.getName();
143                 case VALUES -> e.getValue();
144                 case BOTH -> cx.newArray(scope, new Object[]{e.getName(), e.getValue()});
145             };
146         }
147     }
148 
149     /**
150      * Default constructor.
151      */
152     public URLSearchParams() {
153         super();
154     }
155 
156     /**
157      * Constructs a new instance backed by the given URL.
158      *
159      * @param url the base URL whose search string is managed by this instance
160      */
161     URLSearchParams(final URL url) {
162         super();
163         url_ = url;
164     }
165 
166     /**
167      * Creates an instance of this object.
168      *
169      * @param params initial search parameters; may be a string, a sequence of
170      *        {@code [name, value]} pairs, or a record object
171      */
172     @JsxConstructor
173     public void jsConstructor(final Object params) {
174         url_ = new URL();
175         url_.jsConstructor("http://www.htmlunit.org", "");
176 
177         if (params == null || JavaScriptEngine.isUndefined(params)) {
178             return;
179         }
180 
181         try {
182             url_.setSearch(resolveParams(params));
183         }
184         catch (final EcmaError e) {
185             throw JavaScriptEngine.typeError("Failed to construct 'URLSearchParams': " + e.getErrorMessage());
186         }
187         catch (final MalformedURLException e) {
188             LOG.error(e.getMessage(), e);
189         }
190     }
191 
192     /*
193      * Implementation follows https://url.spec.whatwg.org/#urlsearchparams-initialize
194      */
195     private static List<NameValuePair> resolveParams(final Object params) {
196         // if params is a sequence
197         if (params instanceof Scriptable paramsScriptable && hasProperty(paramsScriptable, SymbolKey.ITERATOR)) {
198 
199             final Context cx = Context.getCurrentContext();
200 
201             final List<NameValuePair> nameValuePairs = new ArrayList<>();
202 
203             try (IteratorLikeIterable itr = buildIteratorLikeIterable(cx, paramsScriptable)) {
204                 for (final Object nameValue : itr) {
205                     if (!(nameValue instanceof Scriptable)) {
206                         throw JavaScriptEngine.typeError("The provided value cannot be converted to a sequence.");
207                     }
208                     if (!hasProperty((Scriptable) nameValue, SymbolKey.ITERATOR)) {
209                         throw JavaScriptEngine.typeError("The object must have a callable @@iterator property.");
210                     }
211 
212                     try (IteratorLikeIterable nameValueItr = buildIteratorLikeIterable(cx, (Scriptable) nameValue)) {
213 
214                         final Iterator<Object> nameValueIterator = nameValueItr.iterator();
215                         final Object name =
216                                 nameValueIterator.hasNext() ? nameValueIterator.next() : NOT_FOUND;
217                         final Object value =
218                                 nameValueIterator.hasNext() ? nameValueIterator.next() : NOT_FOUND;
219 
220                         if (name == NOT_FOUND
221                                 || value == NOT_FOUND
222                                 || nameValueIterator.hasNext()) {
223                             throw JavaScriptEngine.typeError("Sequence initializer must only contain pair elements.");
224                         }
225 
226                         nameValuePairs.add(new NameValuePair(
227                                 JavaScriptEngine.toString(name),
228                                 JavaScriptEngine.toString(value)));
229                     }
230                 }
231             }
232 
233             return nameValuePairs;
234         }
235 
236         // if params is a record
237         if (params instanceof NativeObject object) {
238             final List<NameValuePair> nameValuePairs = new ArrayList<>();
239             for (final Map.Entry<Object, Object> keyValuePair : object.entrySet()) {
240                 nameValuePairs.add(
241                         new NameValuePair(
242                                 JavaScriptEngine.toString(keyValuePair.getKey()),
243                                 JavaScriptEngine.toString(keyValuePair.getValue())));
244             }
245             return nameValuePairs;
246         }
247 
248         // otherwise handle it as string
249         return splitQuery(JavaScriptEngine.toString(params));
250     }
251 
252     private List<NameValuePair> splitQuery() {
253         return splitQuery(url_.getSearch());
254     }
255 
256     private static List<NameValuePair> splitQuery(String params) {
257         final List<NameValuePair> splitted = new ArrayList<>();
258 
259         params = StringUtils.stripStart(params, "?");
260         if (org.htmlunit.util.StringUtils.isEmptyOrNull(params)) {
261             return splitted;
262         }
263 
264         final String[] parts = StringUtils.split(params, '&');
265         for (final String part : parts) {
266             final NameValuePair pair = splitQueryParameter(part);
267             splitted.add(new NameValuePair(UrlUtils.decode(pair.getName()), UrlUtils.decode(pair.getValue())));
268         }
269         return splitted;
270     }
271 
272     private static NameValuePair splitQueryParameter(final String singleParam) {
273         final int idx = singleParam.indexOf('=');
274 
275         if (idx > -1) {
276             final String key = singleParam.substring(0, idx);
277             final String value = singleParam.substring(idx + 1); // always safe, may be empty string
278             return new NameValuePair(key, value);
279         }
280 
281         return new NameValuePair(singleParam, "");
282     }
283 
284     private static IteratorLikeIterable buildIteratorLikeIterable(final Context cx, final Scriptable iterable) {
285         final Object iterator = ScriptRuntime.callIterator(iterable, cx, iterable.getParentScope());
286         return new IteratorLikeIterable(cx, iterable.getParentScope(), iterator);
287     }
288 
289     /**
290      * Appends a new key/value pair as a search parameter.
291      *
292      * @param name the name of the parameter to append
293      * @param value the value of the parameter to append
294      */
295     @JsxFunction
296     public void append(final String name, final String value) {
297         final String search = url_.getSearch();
298 
299         final List<NameValuePair> pairs;
300         if (search == null || search.isEmpty()) {
301             pairs = new ArrayList<>(1);
302         }
303         else {
304             pairs = splitQuery(search);
305         }
306 
307         pairs.add(new NameValuePair(name, value));
308         try {
309             url_.setSearch(pairs);
310         }
311         catch (final MalformedURLException e) {
312             LOG.error(e.getMessage(), e);
313         }
314     }
315 
316     /**
317      * Deletes the given search parameter and all its associated values.
318      *
319      * @param name the name of the parameter to delete
320      */
321     @JsxFunction
322     @Override
323     public void delete(final String name) {
324         final List<NameValuePair> splitted = splitQuery();
325         splitted.removeIf(entry -> entry.getName().equals(name));
326 
327         if (splitted.isEmpty()) {
328             try {
329                 url_.setSearch((String) null);
330             }
331             catch (final MalformedURLException e) {
332                 LOG.error(e.getMessage(), e);
333             }
334             return;
335         }
336 
337         try {
338             url_.setSearch(splitted);
339         }
340         catch (final MalformedURLException e) {
341             LOG.error(e.getMessage(), e);
342         }
343     }
344 
345     /**
346      * Returns the first value associated with the given search parameter.
347      *
348      * @param name the name of the parameter to find
349      * @return the first value for the given name, or {@code null} if not found
350      */
351     @JsxFunction
352     public String get(final String name) {
353         final List<NameValuePair> splitted = splitQuery();
354         for (final NameValuePair param : splitted) {
355             if (param.getName().equals(name)) {
356                 return param.getValue();
357             }
358         }
359         return null;
360     }
361 
362     /**
363      * Returns all values associated with the given search parameter.
364      *
365      * @param name the name of the parameter to find
366      * @return an array of all values for the given name
367      */
368     @JsxFunction
369     public Scriptable getAll(final String name) {
370         final List<NameValuePair> splitted = splitQuery();
371         final List<String> result = new ArrayList<>(splitted.size());
372         for (final NameValuePair param : splitted) {
373             if (param.getName().equals(name)) {
374                 result.add(param.getValue());
375             }
376         }
377 
378         return JavaScriptEngine.newArray(getParentScope(), result.toArray());
379     }
380 
381     /**
382      * Sets the value associated with the given search parameter, replacing all existing values.
383      * If the parameter does not exist, it is appended.
384      *
385      * @param name the name of the parameter to set
386      * @param value the new value
387      */
388     @JsxFunction
389     public void set(final String name, final String value) {
390         final List<NameValuePair> splitted = splitQuery();
391 
392         boolean change = true;
393         final ListIterator<NameValuePair> iter = splitted.listIterator();
394         while (iter.hasNext()) {
395             final NameValuePair entry = iter.next();
396             if (entry.getName().equals(name)) {
397                 if (change) {
398                     iter.set(new NameValuePair(name, value));
399                     change = false;
400                 }
401                 else {
402                     iter.remove();
403                 }
404             }
405         }
406 
407         if (change) {
408             splitted.add(new NameValuePair(name, value));
409         }
410 
411         try {
412             url_.setSearch(splitted);
413         }
414         catch (final MalformedURLException e) {
415             LOG.error(e.getMessage(), e);
416         }
417     }
418 
419     /**
420      * Returns whether a parameter with the given name exists.
421      *
422      * @param name the name of the parameter to check
423      * @return {@code true} if the parameter exists, {@code false} otherwise
424      */
425     @JsxFunction
426     public boolean has(final String name) {
427         final List<NameValuePair> splitted = splitQuery();
428 
429         for (final NameValuePair param : splitted) {
430             if (param.getName().equals(name)) {
431                 return true;
432             }
433         }
434         return false;
435     }
436 
437     /**
438      * Iterates over all key/value pairs, calling the given callback for each.
439      *
440      * @param callback the function to call for each key/value pair
441      */
442     @JsxFunction
443     public void forEach(final Object callback) {
444         if (!(callback instanceof Function fun)) {
445             throw JavaScriptEngine.typeError(
446                     "Foreach callback '" + JavaScriptEngine.toString(callback) + "' is not a function");
447         }
448 
449         String currentSearch = null;
450         List<NameValuePair> params = null;
451         // This must be indexes instead of iterator() for correct behavior when of list changes while iterating
452         for (int i = 0;; i++) {
453             final String search = url_.getSearch();
454             if (!search.equals(currentSearch)) {
455                 params = splitQuery(search);
456                 currentSearch = search;
457             }
458             if (i >= params.size()) {
459                 break;
460             }
461 
462             final NameValuePair param = params.get(i);
463             fun.call(Context.getCurrentContext(), getParentScope(), this,
464                         new Object[] {param.getValue(), param.getName(), this});
465         }
466     }
467 
468     /**
469      * Returns an iterator over all key/value pairs in this object.
470      * Keys and values are {@code USVString} objects.
471      *
472      * @return an iterator of {@code [name, value]} arrays
473      */
474     @JsxFunction
475     @JsxSymbol(symbolName = "iterator")
476     public ES6Iterator entries() {
477         final List<NameValuePair> splitted = splitQuery();
478 
479         return new NativeParamsIterator(getParentScope(),
480                 URL_SEARCH_PARMS_ITERATOR_TAG, NativeParamsIterator.Type.BOTH, splitted.iterator());
481     }
482 
483     /**
484      * Returns an iterator over all keys in this object.
485      *
486      * @return an iterator of key strings
487      */
488     @JsxFunction
489     public ES6Iterator keys() {
490         final List<NameValuePair> splitted = splitQuery();
491 
492         return new NativeParamsIterator(getParentScope(),
493                 URL_SEARCH_PARMS_ITERATOR_TAG, NativeParamsIterator.Type.KEYS, splitted.iterator());
494     }
495 
496     /**
497      * Returns an iterator over all values in this object.
498      *
499      * @return an iterator of value strings
500      */
501     @JsxFunction
502     public ES6Iterator values() {
503         final List<NameValuePair> splitted = splitQuery();
504 
505         return new NativeParamsIterator(getParentScope(),
506                 URL_SEARCH_PARMS_ITERATOR_TAG, NativeParamsIterator.Type.VALUES, splitted.iterator());
507     }
508 
509     /**
510      * Returns the total number of search parameter entries.
511      *
512      * @return the number of entries
513      */
514     @JsxGetter
515     public int getSize() {
516         final List<NameValuePair> splitted = splitQuery();
517         return splitted.size();
518     }
519 
520     /**
521      * Returns the URL-encoded string representation of this search params object.
522      *
523      * @return the encoded query string
524      */
525     @JsxFunction(functionName = "toString")
526     public String jsToString() {
527         final StringBuilder newSearch = new StringBuilder();
528         for (final NameValuePair nameValuePair : splitQuery(url_.getSearch())) {
529             if (newSearch.length() > 0) {
530                 newSearch.append('&');
531             }
532             newSearch
533                 .append(UrlUtils.encodeQueryPart(nameValuePair.getName()))
534                 .append('=')
535                 .append(UrlUtils.encodeQueryPart(nameValuePair.getValue()));
536         }
537 
538         return newSearch.toString();
539     }
540 
541     /**
542      * Returns the default string representation of this object.
543      *
544      * @param hint the type hint
545      * @return the URL-encoded query string
546      * @see org.htmlunit.javascript.HtmlUnitScriptable#getDefaultValue(java.lang.Class)
547      */
548     @Override
549     public Object getDefaultValue(final Class<?> hint) {
550         return jsToString();
551     }
552 
553     /**
554      * Populates the given {@link WebRequest} with the parameters from this object.
555      *
556      * @param webRequest the web request to fill
557      */
558     public void fillRequest(final WebRequest webRequest) {
559         webRequest.setRequestBody(null);
560         webRequest.setEncodingType(FormEncodingType.URL_ENCODED);
561 
562         final List<NameValuePair> splitted = splitQuery();
563         if (!splitted.isEmpty()) {
564             webRequest.setRequestParameters(new ArrayList<>(splitted));
565         }
566     }
567 }