View Javadoc
1   /*
2    * Copyright (c) 2002-2025 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.ScriptableObject;
39  import org.htmlunit.corejs.javascript.SymbolKey;
40  import org.htmlunit.javascript.HtmlUnitScriptable;
41  import org.htmlunit.javascript.JavaScriptEngine;
42  import org.htmlunit.javascript.configuration.JsxClass;
43  import org.htmlunit.javascript.configuration.JsxConstructor;
44  import org.htmlunit.javascript.configuration.JsxFunction;
45  import org.htmlunit.javascript.configuration.JsxGetter;
46  import org.htmlunit.javascript.configuration.JsxSymbol;
47  import org.htmlunit.util.NameValuePair;
48  import org.htmlunit.util.UrlUtils;
49  
50  /**
51   * A JavaScript object for {@code URLSearchParams}.
52   *
53   * @author Ahmed Ashour
54   * @author Ronald Brill
55   * @author Ween Jiann
56   * @author cd alexndr
57   * @author Lai Quang Duong
58   */
59  @JsxClass
60  public class URLSearchParams extends HtmlUnitScriptable {
61  
62      private static final Log LOG = LogFactory.getLog(URLSearchParams.class);
63  
64      /** Constant used to register the prototype in the context. */
65      public static final String URL_SEARCH_PARMS_TAG = "URLSearchParams";
66  
67      private URL url_;
68  
69      /**
70       * {@link ES6Iterator} implementation for js support.
71       */
72      public static final class NativeParamsIterator extends ES6Iterator {
73          enum Type { KEYS, VALUES, BOTH }
74  
75          private final Type type_;
76          private final String className_;
77          private final transient Iterator<NameValuePair> iterator_;
78  
79          /**
80           * Init.
81           * @param scope the scope
82           * @param className the class name
83           */
84          public static void init(final ScriptableObject scope, final String className) {
85              ES6Iterator.init(scope, false, new NativeParamsIterator(className), URL_SEARCH_PARMS_TAG);
86          }
87  
88          /**
89           * Ctor.
90           * @param className the class name
91           */
92          public NativeParamsIterator(final String className) {
93              super();
94              iterator_ = Collections.emptyIterator();
95              type_ = Type.BOTH;
96              className_ = className;
97          }
98  
99          /**
100          * Ctor.
101          * @param scope the scope
102          * @param className the class name
103          * @param type the type
104          * @param iterator the backing iterator
105          */
106         public NativeParamsIterator(final Scriptable scope, final String className, final Type type,
107                                         final Iterator<NameValuePair> iterator) {
108             super(scope, URL_SEARCH_PARMS_TAG);
109             iterator_ = iterator;
110             type_ = type;
111             className_ = className;
112         }
113 
114         @Override
115         public String getClassName() {
116             return className_;
117         }
118 
119         @Override
120         protected boolean isDone(final Context cx, final Scriptable scope) {
121             return !iterator_.hasNext();
122         }
123 
124         @Override
125         protected Object nextValue(final Context cx, final Scriptable scope) {
126             final NameValuePair e = iterator_.next();
127             switch (type_) {
128                 case KEYS:
129                     return e.getName();
130                 case VALUES:
131                     return e.getValue();
132                 case BOTH:
133                     return cx.newArray(scope, new Object[] {e.getName(), e.getValue()});
134                 default:
135                     throw new AssertionError();
136             }
137         }
138     }
139 
140     /**
141      * Constructs a new instance.
142      */
143     public URLSearchParams() {
144         super();
145     }
146 
147     /**
148      * Constructs a new instance for the given js url.
149      * @param url the base url
150      */
151     URLSearchParams(final URL url) {
152         super();
153         url_ = url;
154     }
155 
156     /**
157      * Constructs a new instance.
158      * @param params the params string
159      */
160     @JsxConstructor
161     public void jsConstructor(final Object params) {
162         url_ = new URL();
163         url_.jsConstructor("http://www.htmlunit.org", "");
164 
165         if (params == null || JavaScriptEngine.isUndefined(params)) {
166             return;
167         }
168 
169         try {
170             url_.setSearch(resolveParams(params));
171         }
172         catch (final EcmaError e) {
173             throw JavaScriptEngine.typeError("Failed to construct 'URLSearchParams': " + e.getErrorMessage());
174         }
175         catch (final MalformedURLException e) {
176             LOG.error(e.getMessage(), e);
177         }
178     }
179 
180     /*
181      * Implementation follows https://url.spec.whatwg.org/#urlsearchparams-initialize
182      */
183     private static List<NameValuePair> resolveParams(final Object params) {
184         // if params is a sequence
185         if (params instanceof Scriptable && hasProperty((Scriptable) params, SymbolKey.ITERATOR)) {
186 
187             final Context cx = Context.getCurrentContext();
188             final Scriptable paramsScriptable = (Scriptable) params;
189 
190             final List<NameValuePair> nameValuePairs = new ArrayList<>();
191 
192             try (IteratorLikeIterable itr = buildIteratorLikeIterable(cx, paramsScriptable)) {
193                 for (final Object nameValue : itr) {
194                     if (!(nameValue instanceof Scriptable)) {
195                         throw JavaScriptEngine.typeError("The provided value cannot be converted to a sequence.");
196                     }
197                     if (!hasProperty((Scriptable) nameValue, SymbolKey.ITERATOR)) {
198                         throw JavaScriptEngine.typeError("The object must have a callable @@iterator property.");
199                     }
200 
201                     try (IteratorLikeIterable nameValueItr = buildIteratorLikeIterable(cx, (Scriptable) nameValue)) {
202 
203                         final Iterator<Object> nameValueIterator = nameValueItr.iterator();
204                         final Object name =
205                                 nameValueIterator.hasNext() ? nameValueIterator.next() : NOT_FOUND;
206                         final Object value =
207                                 nameValueIterator.hasNext() ? nameValueIterator.next() : NOT_FOUND;
208 
209                         if (name == NOT_FOUND
210                                 || value == NOT_FOUND
211                                 || nameValueIterator.hasNext()) {
212                             throw JavaScriptEngine.typeError("Sequence initializer must only contain pair elements.");
213                         }
214 
215                         nameValuePairs.add(new NameValuePair(
216                                 JavaScriptEngine.toString(name),
217                                 JavaScriptEngine.toString(value)));
218                     }
219                 }
220             }
221 
222             return nameValuePairs;
223         }
224 
225         // if params is a record
226         if (params instanceof NativeObject) {
227             final List<NameValuePair> nameValuePairs = new ArrayList<>();
228             for (final Map.Entry<Object, Object> keyValuePair : ((NativeObject) params).entrySet()) {
229                 nameValuePairs.add(
230                         new NameValuePair(
231                                 JavaScriptEngine.toString(keyValuePair.getKey()),
232                                 JavaScriptEngine.toString(keyValuePair.getValue())));
233             }
234             return nameValuePairs;
235         }
236 
237         // otherwise handle it as string
238         return splitQuery(JavaScriptEngine.toString(params));
239     }
240 
241     private List<NameValuePair> splitQuery() {
242         return splitQuery(url_.getSearch());
243     }
244 
245     private static List<NameValuePair> splitQuery(String params) {
246         final List<NameValuePair> splitted = new ArrayList<>();
247 
248         params = StringUtils.stripStart(params, "?");
249         if (StringUtils.isEmpty(params)) {
250             return splitted;
251         }
252 
253         final String[] parts = StringUtils.split(params, '&');
254         for (final String part : parts) {
255             final NameValuePair pair = splitQueryParameter(part);
256             splitted.add(new NameValuePair(UrlUtils.decode(pair.getName()), UrlUtils.decode(pair.getValue())));
257         }
258         return splitted;
259     }
260 
261     private static NameValuePair splitQueryParameter(final String singleParam) {
262         final int idx = singleParam.indexOf('=');
263         if (idx > -1) {
264             final String key = singleParam.substring(0, idx);
265             String value = null;
266             if (idx < singleParam.length()) {
267                 value = singleParam.substring(idx + 1);
268             }
269             return new NameValuePair(key, value);
270         }
271         final String value = "";
272         return new NameValuePair(singleParam, value);
273     }
274 
275     private static IteratorLikeIterable buildIteratorLikeIterable(final Context cx, final Scriptable iterable) {
276         final Object iterator = ScriptRuntime.callIterator(iterable, cx, iterable.getParentScope());
277         return new IteratorLikeIterable(cx, iterable.getParentScope(), iterator);
278     }
279 
280     /**
281      * The append() method of the URLSearchParams interface appends a specified
282      * key/value pair as a new search parameter.
283      *
284      * @param name  The name of the parameter to append.
285      * @param value The value of the parameter to append.
286      */
287     @JsxFunction
288     public void append(final String name, final String value) {
289         final String search = url_.getSearch();
290 
291         final List<NameValuePair> pairs;
292         if (search == null || search.isEmpty()) {
293             pairs = new ArrayList<>(1);
294         }
295         else {
296             pairs = splitQuery(search);
297         }
298 
299         pairs.add(new NameValuePair(name, value));
300         try {
301             url_.setSearch(pairs);
302         }
303         catch (final MalformedURLException e) {
304             LOG.error(e.getMessage(), e);
305         }
306     }
307 
308     /**
309      * The delete() method of the URLSearchParams interface deletes the given search
310      * parameter and its associated value, from the list of all search parameters.
311      *
312      * @param name The name of the parameter to be deleted.
313      */
314     @JsxFunction
315     @Override
316     public void delete(final String name) {
317         final List<NameValuePair> splitted = splitQuery();
318         splitted.removeIf(entry -> entry.getName().equals(name));
319 
320         if (splitted.isEmpty()) {
321             try {
322                 url_.setSearch((String) null);
323             }
324             catch (final MalformedURLException e) {
325                 LOG.error(e.getMessage(), e);
326             }
327             return;
328         }
329 
330         try {
331             url_.setSearch(splitted);
332         }
333         catch (final MalformedURLException e) {
334             LOG.error(e.getMessage(), e);
335         }
336     }
337 
338     /**
339      * The get() method of the URLSearchParams interface returns the
340      * first value associated to the given search parameter.
341      *
342      * @param name The name of the parameter to return.
343      * @return An array of USVStrings.
344      */
345     @JsxFunction
346     public String get(final String name) {
347         final List<NameValuePair> splitted = splitQuery();
348         for (final NameValuePair param : splitted) {
349             if (param.getName().equals(name)) {
350                 return param.getValue();
351             }
352         }
353         return null;
354     }
355 
356     /**
357      * The getAll() method of the URLSearchParams interface returns all the values
358      * associated with a given search parameter as an array.
359      *
360      * @param name The name of the parameter to return.
361      * @return An array of USVStrings.
362      */
363     @JsxFunction
364     public Scriptable getAll(final String name) {
365         final List<NameValuePair> splitted = splitQuery();
366         final List<String> result = new ArrayList<>(splitted.size());
367         for (final NameValuePair param : splitted) {
368             if (param.getName().equals(name)) {
369                 result.add(param.getValue());
370             }
371         }
372 
373         return JavaScriptEngine.newArray(getWindow(this), result.toArray());
374     }
375 
376     /**
377      * The set() method of the URLSearchParams interface sets the value associated with a
378      * given search parameter to the given value. If there were several matching values,
379      * this method deletes the others. If the search parameter doesn't exist, this method
380      * creates it.
381      *
382      * @param name  The name of the parameter to set.
383      * @param value The value of the parameter to set.
384      */
385     @JsxFunction
386     public void set(final String name, final String value) {
387         final List<NameValuePair> splitted = splitQuery();
388 
389         boolean change = true;
390         final ListIterator<NameValuePair> iter = splitted.listIterator();
391         while (iter.hasNext()) {
392             final NameValuePair entry = iter.next();
393             if (entry.getName().equals(name)) {
394                 if (change) {
395                     iter.set(new NameValuePair(name, value));
396                     change = false;
397                 }
398                 else {
399                     iter.remove();
400                 }
401             }
402         }
403 
404         if (change) {
405             splitted.add(new NameValuePair(name, value));
406         }
407 
408         try {
409             url_.setSearch(splitted);
410         }
411         catch (final MalformedURLException e) {
412             LOG.error(e.getMessage(), e);
413         }
414     }
415 
416     /**
417      * The has() method of the URLSearchParams interface returns a Boolean that
418      * indicates whether a parameter with the specified name exists.
419      *
420      * @param name The name of the parameter to find.
421      * @return A Boolean.
422      */
423     @JsxFunction
424     public boolean has(final String name) {
425         final List<NameValuePair> splitted = splitQuery();
426 
427         for (final NameValuePair param : splitted) {
428             if (param.getName().equals(name)) {
429                 return true;
430             }
431         }
432         return false;
433     }
434 
435     /**
436      * The URLSearchParams.forEach() method allows iteration through
437      * all key/value pairs contained in this object via a callback function.
438      * @param callback Function to execute on each key/value pairs
439      */
440     @JsxFunction
441     public void forEach(final Object callback) {
442         if (!(callback instanceof Function)) {
443             throw JavaScriptEngine.typeError(
444                     "Foreach callback '" + JavaScriptEngine.toString(callback) + "' is not a function");
445         }
446 
447         final Function fun = (Function) callback;
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                 "URLSearchParams Iterator", 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                 "URLSearchParams Iterator", 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                 "URLSearchParams Iterator", 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             final List<NameValuePair> params = new ArrayList<>();
562             for (final NameValuePair entry : splitted) {
563                 params.add(new NameValuePair(entry.getName(), entry.getValue()));
564             }
565             webRequest.setRequestParameters(params);
566         }
567     }
568 }