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