001package jmri.jmrit.logixng.actions;
002
003import java.beans.*;
004import java.io.*;
005import java.net.HttpURLConnection;
006import java.net.MalformedURLException;
007import java.net.URL;
008import java.net.URLEncoder;
009import java.nio.charset.Charset;
010import java.util.*;
011
012import javax.net.ssl.HttpsURLConnection;
013
014import jmri.*;
015import jmri.jmrit.logixng.*;
016import jmri.jmrit.logixng.SymbolTable.InitialValueType;
017import jmri.jmrit.logixng.implementation.DefaultSymbolTable;
018import jmri.jmrit.logixng.util.*;
019import jmri.jmrit.logixng.util.parser.ParserException;
020import jmri.util.ThreadingUtil;
021
022/**
023 * This action sends a web request.
024 *
025 * @author Daniel Bergqvist Copyright 2023
026 */
027public class WebRequest extends AbstractDigitalAction
028        implements FemaleSocketListener, PropertyChangeListener, VetoableChangeListener {
029
030    private static final ResourceBundle rbx =
031            ResourceBundle.getBundle("jmri.jmrit.logixng.implementation.ImplementationBundle");
032
033    // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent/Firefox
034    public static final String DEFAULT_USER_AGENT = "Mozilla/5.0";
035
036    // Note that it's valid if the url has parameters as well, like https://www.mysite.org/somepage.php?name=Jim&city=Boston
037    // The parameters are the string after the question mark.
038    private final LogixNG_SelectString _selectUrl =
039            new LogixNG_SelectString(this, this);
040
041    private final LogixNG_SelectCharset _selectCharset =
042            new LogixNG_SelectCharset(this, this);
043
044    private final LogixNG_SelectEnum<RequestMethodType> _selectRequestMethod =
045            new LogixNG_SelectEnum<>(this, RequestMethodType.values(), RequestMethodType.Get, this);
046
047    private final LogixNG_SelectString _selectUserAgent =
048            new LogixNG_SelectString(this, DEFAULT_USER_AGENT, this);
049
050    private final LogixNG_SelectEnum<ReplyType> _selectReplyType =
051            new LogixNG_SelectEnum<>(this, ReplyType.values(), ReplyType.String, this);
052
053    private final LogixNG_SelectEnum<LineEnding> _selectLineEnding =
054            new LogixNG_SelectEnum<>(this, LineEnding.values(), LineEnding.System, this);
055
056    private final List<Parameter> _parameters = new ArrayList<>();
057
058    private String _socketSystemName;
059    private final FemaleDigitalActionSocket _socket;
060    private String _localVariableForResponseCode = "";
061    private String _localVariableForReplyContent = "";
062    private String _localVariableForCookies = "";
063
064    private final InternalFemaleSocket _internalSocket = new InternalFemaleSocket();
065
066
067    public WebRequest(String sys, String user)
068            throws BadUserNameException, BadSystemNameException {
069        super(sys, user);
070        _socket = InstanceManager.getDefault(DigitalActionManager.class)
071                .createFemaleSocket(this, this, Bundle.getMessage("ShowDialog_SocketExecute"));
072    }
073
074    @Override
075    public Base getDeepCopy(Map<String, String> systemNames, Map<String, String> userNames)
076            throws ParserException, JmriException {
077        DigitalActionManager manager = InstanceManager.getDefault(DigitalActionManager.class);
078        String sysName = systemNames.get(getSystemName());
079        String userName = userNames.get(getSystemName());
080        if (sysName == null) sysName = manager.getAutoSystemName();
081        WebRequest copy = new WebRequest(sysName, userName);
082        copy.setComment(getComment());
083        getSelectUrl().copy(copy._selectUrl);
084        getSelectCharset().copy(copy._selectCharset);
085        getSelectRequestMethod().copy(copy._selectRequestMethod);
086        getSelectUserAgent().copy(copy._selectUserAgent);
087        copy._parameters.addAll(_parameters);
088//        getSelectMime().copy(copy._selectMime);
089        copy.setLocalVariableForResponseCode(_localVariableForResponseCode);
090        copy.setLocalVariableForReplyContent(_localVariableForReplyContent);
091        copy.setLocalVariableForCookies(_localVariableForCookies);
092//        copy.setModal(_modal);
093//        copy.setMultiLine(_multiLine);
094//        copy.setFormat(_format);
095//        copy.setFormatType(_formatType);
096//        for (Data data : _dataList) {
097//            copy.getDataList().add(new Data(data));
098//        }
099        return manager.registerAction(copy).deepCopyChildren(this, systemNames, userNames);
100    }
101
102    public LogixNG_SelectString getSelectUrl() {
103        return _selectUrl;
104    }
105
106    public LogixNG_SelectCharset getSelectCharset() {
107        return _selectCharset;
108    }
109
110    public LogixNG_SelectEnum<RequestMethodType> getSelectRequestMethod() {
111        return _selectRequestMethod;
112    }
113
114    public LogixNG_SelectString getSelectUserAgent() {
115        return _selectUserAgent;
116    }
117
118    public LogixNG_SelectEnum<ReplyType> getSelectReplyType() {
119        return _selectReplyType;
120    }
121
122    public LogixNG_SelectEnum<LineEnding> getSelectLineEnding() {
123        return _selectLineEnding;
124    }
125
126    public List<Parameter> getParameters() {
127        return _parameters;
128    }
129
130    public void setLocalVariableForResponseCode(String localVariable) {
131        _localVariableForResponseCode = localVariable;
132    }
133
134    public String getLocalVariableForResponseCode() {
135        return _localVariableForResponseCode;
136    }
137
138    public void setLocalVariableForReplyContent(String localVariable) {
139        _localVariableForReplyContent = localVariable;
140    }
141
142    public String getLocalVariableForReplyContent() {
143        return _localVariableForReplyContent;
144    }
145
146    public void setLocalVariableForCookies(String localVariable) {
147        _localVariableForCookies = localVariable;
148    }
149
150    public String getLocalVariableForCookies() {
151        return _localVariableForCookies;
152    }
153
154    /** {@inheritDoc} */
155    @Override
156    public Category getCategory() {
157        return Category.OTHER;
158    }
159
160    /** {@inheritDoc} */
161    @SuppressWarnings("unchecked")
162    @Override
163    public void execute() throws JmriException {
164
165        final ConditionalNG conditionalNG = getConditionalNG();
166        final DefaultSymbolTable newSymbolTable = new DefaultSymbolTable(conditionalNG.getSymbolTable());
167        final boolean useThread = conditionalNG.getRunDelayed();
168
169        String urlString = _selectUrl.evaluateValue(conditionalNG);
170        Charset charset = _selectCharset.evaluateCharset(conditionalNG);
171        String userAgent = _selectUserAgent.evaluateValue(conditionalNG);
172        RequestMethodType requestMethodType = _selectRequestMethod.evaluateEnum(conditionalNG);
173        ReplyType replyType = _selectReplyType.evaluateEnum(conditionalNG);
174        LineEnding lineEnding = _selectLineEnding.evaluateEnum(conditionalNG);
175
176        URL url;
177        StringBuilder paramString = new StringBuilder();
178
179        try {
180            for (Parameter parameter : _parameters) {
181
182                Object v = SymbolTable.getInitialValue(
183                        SymbolTable.Type.Parameter,
184                        parameter._name,
185                        parameter._type,
186                        parameter._data,
187                        newSymbolTable,
188                        newSymbolTable.getSymbols());
189
190                String value;
191                if (v != null) value = v.toString();
192                else value = "";
193                paramString.append(URLEncoder.encode(parameter._name, charset));
194                paramString.append("=");
195                paramString.append(URLEncoder.encode(value, charset));
196                paramString.append("&");
197            }
198
199            if (paramString.length() > 0) {
200                paramString.deleteCharAt(paramString.length() - 1);
201            }
202
203            if (requestMethodType == RequestMethodType.Get) {
204                if (urlString.contains("?")) {
205                    urlString += "&";
206                } else {
207                    urlString += "?";
208                }
209                urlString +=  paramString.toString();
210//                System.out.format("Param string: \"%s\". URL: \"%s\"%n", paramString, urlString);
211            }
212
213            url = new URL(urlString);
214//            System.out.format("URL: %s, query: %s, userInfo: %s%n", url.toString(), url.getQuery(), url.getUserInfo());
215//            if (!urlString.contains("LogixNG_WebRequest_Test.php") && !urlString.contains("https://www.modulsyd.se/")) return;
216//            if (!urlString.contains("LogixNG_WebRequest_Test.php")) return;
217//            if (!urlString.contains("https://www.modulsyd.se/")) return;
218        } catch (MalformedURLException ex) {
219            throw new JmriException(ex.getMessage(), ex);
220        }
221
222        boolean useHttps = urlString.toLowerCase().startsWith("https://");
223
224        Runnable runnable = () -> {
225//            String https_url = "https://www.google.com/";
226//            String https_url = "https://jmri.bergqvist.se/LogixNG_WebRequest_Test.php";
227            try {
228
229//                long startTime = System.currentTimeMillis();
230
231                HttpURLConnection con;
232                if (useHttps) {
233                    con = (HttpsURLConnection) url.openConnection();
234                } else {
235                    con = (HttpURLConnection) url.openConnection();
236                }
237
238                con.setRequestMethod(requestMethodType._identifier);
239                con.setRequestProperty("User-Agent", userAgent);
240
241
242//                con.setRequestProperty("Cookie", "phpbb3_tm7zs_sid=5b33176e78318082f439a0a302fa4c25; expires=Fri, 29-Mar-2024 18:22:48 GMT; path=/; domain=.modulsyd.se; secure; HttpOnly");
243//                con.setRequestProperty("Cookie", "Daniel=Hej; expires=Fri, 29-Mar-2024 18:22:48 GMT; path=/; domain=.modulsyd.se; secure; HttpOnly");
244//                con.setRequestProperty("Cookie", "DanielAA=Hej; expires=Fri, 29-Mar-2024 18:22:48 GMT; path=/; domain=.modulsyd.se; secure; HttpOnly");
245//                con.setRequestProperty("Cookie", "DanielBB=Hej; expires=Fri, 29-Mar-2024 18:22:48 GMT; path=/; domain=.modulsyd.se; secure; HttpOnly");
246//                con.setRequestProperty("Cookie", "Aaa=Abb; Abb=Add; Acc=Aff");
247
248                Map<String,String> cookiesMap = null;
249
250                if (!_localVariableForCookies.isEmpty()) {
251                    StringBuilder cookies = new StringBuilder();
252
253                    Object cookiesObject = newSymbolTable.getValue(_localVariableForCookies);
254                    if (cookiesObject != null) {
255                        if (!(cookiesObject instanceof Map)) {
256                            throw new IllegalArgumentException(String.format("The value of the local variable '%s' must be a Map", _localVariableForCookies));
257                        }
258                        cookiesMap = (Map<String,String>)cookiesObject;
259//                        System.out.format("Set cookies to connection. Count: %d%n", ((List<Object>)cookiesObject).size());
260                        for (Map.Entry<String,String> entry : cookiesMap.entrySet()) {
261                            if (cookies.length() > 0) {
262                                cookies.append("; ");
263                            }
264                            String[] cookieParts = entry.getValue().split("; ");
265                            cookies.append(cookieParts[0]);
266//                            System.out.format("Set cookie to connection: '%s=%s'%n", entry.getKey(), entry.getValue());
267                        }
268                        if (cookies.length() > 0) {
269//                            System.out.format("Set cookie to connection: '%s'%n", cookies.toString());
270                            con.setRequestProperty("Cookie", cookies.toString());
271                        }
272                    }
273                }
274
275
276
277
278
279
280////DANIEL                con.setRequestProperty("Content-Type", "text/html");
281//                con.setRequestProperty("Content-Type", mime);
282
283//                con.setRequestProperty("Content-Type", "application/json");
284//                con.setRequestProperty("Content-Type", "application/html");
285//                con.setRequestProperty("Content-Type", "text/html");
286//                con.setRequestProperty("Content-Type", "text/plain");
287//                con.setRequestProperty("Content-Type", "text/csv");
288//                con.setRequestProperty("Content-Type", "text/markdown");
289
290                if (requestMethodType == RequestMethodType.Post) {
291                    con.setRequestMethod("POST");
292                    con.setDoOutput(true);
293                    try (DataOutputStream out = new DataOutputStream(con.getOutputStream())) {
294                        out.writeBytes(paramString.toString());
295                        out.flush();
296                    }
297                }
298
299
300
301
302
303
304
305                //dumpl all cert info
306//                print_https_cert(con);
307
308
309//                System.out.println("Response Code: " + con.getResponseCode());
310/*
311                System.out.println("Header fields:");
312                for (var entry : con.getHeaderFields().entrySet()) {
313                    for (String value : entry.getValue()) {
314                        System.out.format("Header: %s, value: %s%n", entry.getKey(), value);
315                    }
316                }
317*/
318                //dump all the content
319//DANIEL                print_content(con);
320
321                Object reply;
322
323                if (replyType == ReplyType.Bytes) {
324                    reply = con.getInputStream().readAllBytes();
325                } else if (replyType == ReplyType.String || replyType == ReplyType.ListOfStrings) {
326                    List<String> list = new ArrayList<>();
327                    try (BufferedReader br = new BufferedReader(new InputStreamReader(con.getInputStream()))) {
328                        String input;
329                        while ((input = br.readLine()) != null) {
330    //                        System.out.println(input);
331                            list.add(input);
332                        }
333    //                } catch (IOException e) {
334    //                    e.printStackTrace();
335                    }
336
337                    if (replyType == ReplyType.String) {
338                        reply = String.join(lineEnding.getLineEnding(), list);
339                    } else {
340                        reply = list;
341                    }
342                } else {
343                    throw new IllegalArgumentException("replyType has unknown value: " + replyType.name());
344                }
345
346
347
348
349                if (cookiesMap == null) {
350                    cookiesMap = new HashMap<>();
351                }
352                for (var entry : con.getHeaderFields().entrySet()) {
353                    if ("Set-Cookie".equals(entry.getKey())) {
354                        for (String value : entry.getValue()) {
355                            String[] parts = value.split("=");
356                            cookiesMap.put(parts[0], value);
357                        }
358                    }
359                }
360
361
362//                long time = System.currentTimeMillis() - startTime;
363
364//                System.out.format("Total time: %d%n", time);
365
366                synchronized (WebRequest.this) {
367                    _internalSocket._conditionalNG = conditionalNG;
368                    _internalSocket._newSymbolTable = newSymbolTable;
369                    _internalSocket._cookies = cookiesMap;
370                    _internalSocket._responseCode = con.getResponseCode();
371                    _internalSocket._reply = reply;
372
373                    if (useThread) {
374                        conditionalNG.execute(_internalSocket);
375                    } else {
376                        _internalSocket.execute();
377                    }
378                }
379
380//            } catch (MalformedURLException e) {
381//                e.printStackTrace();
382//            } catch (IOException e) {
383//                e.printStackTrace();
384//            } catch (JmriException ex) {
385            } catch (IOException | IllegalArgumentException | JmriException ex) {
386                log.error("An exception has occurred: {}", ex, ex);
387            }
388        };
389
390        if (useThread) {
391            ThreadingUtil.newThread(runnable, "LogixNG action WebRequest").start();
392        } else {
393            runnable.run();
394        }
395    }
396/*
397    private void print_content(HttpURLConnection con) {
398        if (con != null) {
399            try (BufferedReader br = new BufferedReader(new InputStreamReader(con.getInputStream()))) {
400
401                System.out.println("****** Content of the URL ********");
402
403                String input;
404                while ((input = br.readLine()) != null) {
405                    System.out.println(input);
406                }
407                br.close();
408
409            } catch (IOException e) {
410                e.printStackTrace();
411            }
412        }
413    }
414*/
415
416    @Override
417    public FemaleSocket getChild(int index) throws IllegalArgumentException, UnsupportedOperationException {
418        switch (index) {
419            case 0:
420                return _socket;
421
422            default:
423                throw new IllegalArgumentException(
424                        String.format("index has invalid value: %d", index));
425        }
426    }
427
428    @Override
429    public int getChildCount() {
430        return 1;
431    }
432
433    @Override
434    public void connected(FemaleSocket socket) {
435        if (socket == _socket) {
436            _socketSystemName = socket.getConnectedSocket().getSystemName();
437        } else {
438            throw new IllegalArgumentException("unkown socket");
439        }
440    }
441
442    @Override
443    public void disconnected(FemaleSocket socket) {
444        if (socket == _socket) {
445            _socketSystemName = null;
446        } else {
447            throw new IllegalArgumentException("unkown socket");
448        }
449    }
450
451    @Override
452    public String getShortDescription(Locale locale) {
453        return Bundle.getMessage(locale, "WebRequest_Short");
454    }
455
456    @Override
457    public String getLongDescription(Locale locale) {
458        return Bundle.getMessage("WebRequest_Long", _selectUrl.getDescription(locale));
459/*
460        String bundleKey;
461        switch (_formatType) {
462            case OnlyText:
463                bundleKey = "ShowDialog_Long_TextOnly";
464                break;
465            case CommaSeparatedList:
466                bundleKey = "ShowDialog_Long_CommaSeparatedList";
467                break;
468            case StringFormat:
469                bundleKey = "ShowDialog_Long_StringFormat";
470                break;
471            default:
472                throw new RuntimeException("_formatType has unknown value: "+_formatType.name());
473        }
474        return Bundle.getMessage(locale, bundleKey, _format);
475*/
476    }
477
478    public FemaleDigitalActionSocket getSocket() {
479        return _socket;
480    }
481
482    public String getSocketSystemName() {
483        return _socketSystemName;
484    }
485
486    public void setSocketSystemName(String systemName) {
487        _socketSystemName = systemName;
488    }
489
490    /** {@inheritDoc} */
491    @Override
492    public void setup() {
493        try {
494            if (!_socket.isConnected()
495                    || !_socket.getConnectedSocket().getSystemName()
496                            .equals(_socketSystemName)) {
497
498                String socketSystemName = _socketSystemName;
499
500                _socket.disconnect();
501
502                if (socketSystemName != null) {
503                    MaleSocket maleSocket =
504                            InstanceManager.getDefault(DigitalActionManager.class)
505                                    .getBySystemName(socketSystemName);
506                    if (maleSocket != null) {
507                        _socket.connect(maleSocket);
508                        maleSocket.setup();
509                    } else {
510                        log.error("cannot load digital action {}", socketSystemName);
511                    }
512                }
513            } else {
514                _socket.getConnectedSocket().setup();
515            }
516        } catch (SocketAlreadyConnectedException ex) {
517            // This shouldn't happen and is a runtime error if it does.
518            throw new RuntimeException("socket is already connected");
519        }
520    }
521
522    /** {@inheritDoc} */
523    @Override
524    public void registerListenersForThisClass() {
525        // Do nothing
526    }
527
528    /** {@inheritDoc} */
529    @Override
530    public void unregisterListenersForThisClass() {
531        // Do nothing
532    }
533
534    /** {@inheritDoc} */
535    @Override
536    public void propertyChange(PropertyChangeEvent evt) {
537        getConditionalNG().execute();
538    }
539
540    /** {@inheritDoc} */
541    @Override
542    public void disposeMe() {
543    }
544
545
546    /** {@inheritDoc} */
547    @Override
548    public void getUsageDetail(int level, NamedBean bean, List<NamedBeanUsageReport> report, NamedBean cdl) {
549/*
550        log.debug("getUsageReport :: ShowDialog: bean = {}, report = {}", cdl, report);
551        for (NamedBeanReference namedBeanReference : _namedBeanReferences.values()) {
552            if (namedBeanReference._handle != null) {
553                if (bean.equals(namedBeanReference._handle.getBean())) {
554                    report.add(new NamedBeanUsageReport("LogixNGAction", cdl, getLongDescription()));
555                }
556            }
557        }
558*/
559    }
560
561
562    public enum RequestMethodType {
563        Get("WebRequest_GetPostType_Get", "GET"),        // "GET" should not be i11n
564        Post("WebRequest_GetPostType_Post", "POST");     // "POST" should not be i11n
565
566        private final String _text;
567        private final String _identifier;
568
569        private RequestMethodType(String text, String identifier) {
570            this._text = Bundle.getMessage(text, identifier);
571            this._identifier = identifier;
572        }
573
574        @Override
575        public String toString() {
576            return _text;
577        }
578
579    }
580
581
582    public enum ReplyType {
583        String(Bundle.getMessage("WebRequest_ReplyType_String")),
584        ListOfStrings(Bundle.getMessage("WebRequest_ReplyType_ListOfStrings")),
585        Bytes(Bundle.getMessage("WebRequest_ReplyType_Bytes"));
586
587        private final String _text;
588
589        private ReplyType(String text) {
590            this._text = text;
591        }
592
593        @Override
594        public String toString() {
595            return _text;
596        }
597
598    }
599
600
601    public static class Parameter {
602
603        public String _name;
604        public InitialValueType _type;
605        public String _data;
606
607        public Parameter(String name, InitialValueType type, String data) {
608            this._name = name;
609            this._type = type;
610            this._data = data;
611        }
612
613        public void setName(String name) { _name = name; }
614        public String getName() { return _name; }
615
616        public void setType(InitialValueType dataType) { _type = dataType; }
617        public InitialValueType getType() { return _type; }
618
619        public void setData(String valueData) { _data = valueData; }
620        public String getData() { return _data; }
621
622    }
623
624
625    private class InternalFemaleSocket extends jmri.jmrit.logixng.implementation.DefaultFemaleDigitalActionSocket {
626
627        private ConditionalNG _conditionalNG;
628        private SymbolTable _newSymbolTable;
629        private int _responseCode;
630        private Map<String,String> _cookies;
631        private Object _reply;
632
633        public InternalFemaleSocket() {
634            super(null, new FemaleSocketListener(){
635                @Override
636                public void connected(FemaleSocket socket) {
637                    // Do nothing
638                }
639
640                @Override
641                public void disconnected(FemaleSocket socket) {
642                    // Do nothing
643                }
644            }, "A");
645        }
646
647        @Override
648        public void execute() throws JmriException {
649            if (_socket != null) {
650                MaleSocket maleSocket = (MaleSocket)WebRequest.this.getParent();
651                try {
652                    SymbolTable oldSymbolTable = _conditionalNG.getSymbolTable();
653                    _conditionalNG.setSymbolTable(_newSymbolTable);
654                    if (!_localVariableForResponseCode.isEmpty()) {
655                        _newSymbolTable.setValue(_localVariableForResponseCode, _responseCode);
656                    }
657                    if (!_localVariableForReplyContent.isEmpty()) {
658                        _newSymbolTable.setValue(_localVariableForReplyContent, _reply);
659                    }
660                    if (!_localVariableForCookies.isEmpty()) {
661//                        System.out.format("Set cookies:%n");
662//                        for (String s : _cookies) {
663//                            System.out.format("Set cookies: '%s'%n", s);
664//                        }
665                        if (!_cookies.isEmpty()) {
666                           _newSymbolTable.setValue(_localVariableForCookies, _cookies);
667                        }
668//                    } else {
669//                        System.out.format("Local variable for cookies is empty!!!%n");
670                    }
671                    _socket.execute();
672                    _conditionalNG.setSymbolTable(oldSymbolTable);
673                } catch (JmriException e) {
674                    if (e.getErrors() != null) {
675                        maleSocket.handleError(WebRequest.this, rbx.getString("ExceptionExecuteMulti"), e.getErrors(), e, log);
676                    } else {
677                        maleSocket.handleError(WebRequest.this, Bundle.formatMessage(rbx.getString("ExceptionExecuteAction"), e.getLocalizedMessage()), e, log);
678                    }
679                } catch (RuntimeException e) {
680                    maleSocket.handleError(WebRequest.this, Bundle.formatMessage(rbx.getString("ExceptionExecuteAction"), e.getLocalizedMessage()), e, log);
681                }
682            }
683        }
684
685    }
686
687
688    private final static org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(WebRequest.class);
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711/*
712
713        https://jmri.bergqvist.se/LogixNG_WebRequest_Test.php
714
715
716        Class HttpURLConnection
717        https://docs.oracle.com/javase/8/docs/api/java/net/HttpURLConnection.html
718
719
720        Class HttpsURLConnection
721        https://docs.oracle.com/javase/8/docs/api/javax/net/ssl/HttpsURLConnection.html
722
723
724        Do a Simple HTTP Request in Java
725        https://www.baeldung.com/java-http-request
726
727
728
729        Java HttpsURLConnection example
730        https://mkyong.com/java/java-https-client-httpsurlconnection-example/
731
732        HttpsURLConnection - Send POST request
733        https://stackoverflow.com/questions/43352000/httpsurlconnection-send-post-request
734
735        HttpsURLConnection
736        https://developer.android.com/reference/javax/net/ssl/HttpsURLConnection
737
738
739
740
741        MIME types (IANA media types)
742        https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
743
744        Why is it `text/html` but `application/json` in media types?
745        https://stackoverflow.com/questions/51191184/why-is-it-text-html-but-application-json-in-media-types
746
747
748        How To Use Java HttpURLConnection for HTTP GET and POST Requests
749        https://www.digitalocean.com/community/tutorials/java-httpurlconnection-example-java-http-request-get-post
750
751
752        Making a JSON POST Request With HttpURLConnection
753        https://www.baeldung.com/httpurlconnection-post
754
755        https://www.jmri.org/JavaDoc/doc/jmri/server/json/JSON.html
756
757
758*/
759
760}