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