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