scouting: Add a debug CLI for the webserver

This CLI should let us debug the webserver easily. It simulates calls
that the web page can make. Create a JSON version of the flatbuffer
message you want to send and pass it as a file to the correct option
on the `cli` binary. There's nothing really implemented yet because
the webserver doesn't have a whole lot implemented either.

Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
Change-Id: If396dd8dc3b1e24515cb2d5765b3d5f233066cda
diff --git a/scouting/webserver/README.md b/scouting/webserver/README.md
index 1c2383b..ebf6ec0 100644
--- a/scouting/webserver/README.md
+++ b/scouting/webserver/README.md
@@ -24,3 +24,15 @@
 paths used by the other libraries that enhance the server's functionality. E.g.
 if POST requests for match data are serviced at `/requests/xyz`, then don't
 serve any files in a `requests` directory.
+
+`requests/`
+--------------------------------------------------------------------------------
+This directory contains the code that services requests from the web page. The
+web page sends serialized flatbuffers (see the
+`scouting/webserver/requests/messages` directory) and receives serialized
+flatbuffers in response.
+
+### `requests/debug/cli`
+This directory contains a debug application that lets you interact with the
+webserver. It allows you to make call calls that the web page would normally
+make.
diff --git a/scouting/webserver/requests/debug/BUILD b/scouting/webserver/requests/debug/BUILD
new file mode 100644
index 0000000..cfeee2f
--- /dev/null
+++ b/scouting/webserver/requests/debug/BUILD
@@ -0,0 +1,13 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "debug",
+    srcs = ["debug.go"],
+    importpath = "github.com/frc971/971-Robot-Code/scouting/webserver/requests/debug",
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//scouting/webserver/requests/messages:error_response_go_fbs",
+        "//scouting/webserver/requests/messages:submit_data_scouting_response_go_fbs",
+    ],
+)
diff --git a/scouting/webserver/requests/debug/cli/BUILD b/scouting/webserver/requests/debug/cli/BUILD
new file mode 100644
index 0000000..aba9177
--- /dev/null
+++ b/scouting/webserver/requests/debug/cli/BUILD
@@ -0,0 +1,32 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "cli_lib",
+    srcs = ["main.go"],
+    data = [
+        "//scouting/webserver/requests/messages:fbs_files",
+        "@com_github_google_flatbuffers//:flatc",
+    ],
+    importpath = "github.com/frc971/971-Robot-Code/scouting/webserver/requests/debug/cli",
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    visibility = ["//visibility:private"],
+    deps = ["//scouting/webserver/requests/debug"],
+)
+
+go_binary(
+    name = "cli",
+    embed = [":cli_lib"],
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    visibility = ["//visibility:public"],
+)
+
+py_test(
+    name = "cli_test",
+    srcs = [
+        "cli_test.py",
+    ],
+    data = [
+        ":cli",
+        "//scouting/webserver",
+    ],
+)
diff --git a/scouting/webserver/requests/debug/cli/cli_test.py b/scouting/webserver/requests/debug/cli/cli_test.py
new file mode 100644
index 0000000..925cb99
--- /dev/null
+++ b/scouting/webserver/requests/debug/cli/cli_test.py
@@ -0,0 +1,70 @@
+import json
+import os
+from pathlib import Path
+import shutil
+import socket
+import subprocess
+import time
+from typing import Any, Dict, List
+import unittest
+
+def write_json(content: Dict[str, Any]):
+    """Writes a JSON file with the specified dict content."""
+    json_path = Path(os.environ["TEST_TMPDIR"]) / "test.json"
+    with open(json_path, "w") as file:
+        file.write(json.dumps(content))
+    return json_path
+
+def run_debug_cli(args: List[str]):
+    run_result = subprocess.run(
+        ["scouting/webserver/requests/debug/cli/cli_/cli"] + args,
+        check=False,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+    return (
+        run_result.returncode,
+        run_result.stdout.decode("utf-8"),
+        run_result.stderr.decode("utf-8"),
+    )
+
+class TestDebugCli(unittest.TestCase):
+
+    def setUp(self):
+        # Since the webserver creates a database in the current directory,
+        # let's run the test in TEST_TMPDIR where we can do whatever we want.
+        self.webserver_working_dir = Path(os.environ["TEST_TMPDIR"]) / "webserver"
+        os.mkdir(self.webserver_working_dir)
+        webserver_path = os.path.abspath("scouting/webserver/webserver_/webserver")
+        self.webserver = subprocess.Popen([webserver_path], cwd=self.webserver_working_dir)
+
+        # Wait for the server to respond to requests.
+        while True:
+            try:
+                connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+                connection.connect(("localhost", 8080))
+                connection.close()
+                break
+            except ConnectionRefusedError:
+                connection.close()
+                time.sleep(0.01)
+
+    def tearDown(self):
+        self.webserver.terminate()
+        self.webserver.wait()
+        shutil.rmtree(self.webserver_working_dir)
+
+    def test_submit_data_scouting(self):
+        json_path = write_json({
+            "team": 971,
+            "match": 42,
+            "upper_goal_hits": 3,
+        })
+        exit_code, _stdout, stderr = run_debug_cli(["-submitDataScouting", json_path])
+
+        # The SubmitDataScouting message isn't handled yet.
+        self.assertEqual(exit_code, 1)
+        self.assertIn("/requests/submit/data_scouting returned 501 Not Implemented", stderr)
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/scouting/webserver/requests/debug/cli/main.go b/scouting/webserver/requests/debug/cli/main.go
new file mode 100644
index 0000000..ac24b25
--- /dev/null
+++ b/scouting/webserver/requests/debug/cli/main.go
@@ -0,0 +1,87 @@
+// This binary lets users interact with the scouting web server in order to
+// debug it. Run with `--help` to see all the options.
+
+package main
+
+import (
+	"flag"
+	"io/ioutil"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/debug"
+)
+
+// Returns the absolute path of the specified path. This is an unwrapped
+// version of `filepath.Abs`.
+func absPath(path string) string {
+	result, err := filepath.Abs(path)
+	if err != nil {
+		log.Fatal("Failed to determine absolute path for ", path, ": ", err)
+	}
+	return result
+}
+
+// Parses the specified JSON file into a binary version (i.e. serialized
+// flatbuffer). This uses the `flatc` binary and the JSON's corresponding
+// `.fbs` file.
+func parseJson(fbsPath string, jsonPath string) []byte {
+	// Work inside a temporary directory since `flatc` doesn't allow us to
+	// customize the name of the output file.
+	dir, err := ioutil.TempDir("", "webserver_debug_cli")
+	if err != nil {
+		log.Fatal("Failed to create temporary directory: ", err)
+	}
+	defer os.RemoveAll(dir)
+
+	// Turn these paths absolute so that it everything still works from
+	// inside the temporary directory.
+	absFlatcPath := absPath("external/com_github_google_flatbuffers/flatc")
+	absFbsPath := absPath(fbsPath)
+
+	// Create a symlink to the .fbs file so that the output filename that
+	// `flatc` generates is predictable. I.e. `fb.json` gets serialized
+	// into `fb.bin`.
+	jsonSymlink := filepath.Join(dir, "fb.json")
+	os.Symlink(jsonPath, jsonSymlink)
+
+	// Execute the `flatc` command.
+	flatcCommand := exec.Command(absFlatcPath, "--binary", absFbsPath, jsonSymlink)
+	flatcCommand.Dir = dir
+	err = flatcCommand.Run()
+	if err != nil {
+		log.Fatal("Failed to execute flatc: ", err)
+	}
+
+	// Read the serialized flatbuffer and return it.
+	binaryPath := filepath.Join(dir, "fb.bin")
+	binaryFb, err := os.ReadFile(binaryPath)
+	if err != nil {
+		log.Fatal("Failed to read flatc output ", binaryPath, ": ", err)
+	}
+	return binaryFb
+}
+
+func main() {
+	// Parse command line arguments.
+	addressPtr := flag.String("address", "http://localhost:8080",
+		"The end point where the server is listening.")
+	submitDataScoutingPtr := flag.String("submitDataScouting", "",
+		"If specified, parse the file as a SubmitDataScouting JSON request.")
+	flag.Parse()
+
+	// Handle the actual arguments.
+	if *submitDataScoutingPtr != "" {
+		log.Printf("Sending SubmitDataScouting to %s", *addressPtr)
+		binaryRequest := parseJson(
+			"scouting/webserver/requests/messages/submit_data_scouting.fbs",
+			*submitDataScoutingPtr)
+		response, err := debug.SubmitDataScouting(*addressPtr, binaryRequest)
+		if err != nil {
+			log.Fatal("Failed SubmitDataScouting: ", err)
+		}
+		log.Printf("%+v", response)
+	}
+}
diff --git a/scouting/webserver/requests/debug/debug.go b/scouting/webserver/requests/debug/debug.go
new file mode 100644
index 0000000..dd70c1b
--- /dev/null
+++ b/scouting/webserver/requests/debug/debug.go
@@ -0,0 +1,87 @@
+package debug
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/error_response"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_data_scouting_response"
+)
+
+// Use aliases to make the rest of the code more readable.
+type SubmitDataScoutingResponseT = submit_data_scouting_response.SubmitDataScoutingResponseT
+
+// A struct that can be used as an `error`. It contains information about the
+// why the server was unhappy and what the corresponding request was.
+type ResponseError struct {
+	Url           string
+	StatusCode    int
+	ErrorResponse *error_response.ErrorResponse
+}
+
+// Required to implement the `error` interface.
+func (err *ResponseError) Error() string {
+	return fmt.Sprintf(
+		"%s returned %d %s: %s", err.Url, err.StatusCode,
+		http.StatusText(err.StatusCode), err.ErrorResponse.ErrorMessage())
+}
+
+// Parse an `ErrorResponse` message that the server sent back. This happens
+// whenever the status code is something other than 200. If the message is
+// successfully parsed, it's turned into a `ResponseError` which implements the
+// `error` interface.
+func parseErrorResponse(url string, statusCode int, responseBytes []byte) error {
+	getRootErrMessage := ""
+	defer func() {
+		if r := recover(); r != nil {
+			getRootErrMessage = fmt.Sprintf("%v", r)
+		}
+	}()
+	errorMessage := error_response.GetRootAsErrorResponse(responseBytes, 0)
+	if getRootErrMessage != "" {
+		return errors.New(fmt.Sprintf(
+			"Failed to parse response from %s with status %d %s (bytes %v) as ErrorResponse: %s",
+			url, statusCode, http.StatusText(statusCode), responseBytes, getRootErrMessage))
+	}
+
+	return &ResponseError{
+		Url:           url,
+		StatusCode:    statusCode,
+		ErrorResponse: errorMessage,
+	}
+}
+
+// Performs a POST request with the specified payload. The bytes that the
+// server responds with are returned.
+func performPost(url string, requestBytes []byte) ([]byte, error) {
+	resp, err := http.Post(url, "application/octet-stream", bytes.NewReader(requestBytes))
+	if err != nil {
+		log.Printf("Failed to send POST request to %s: %v", url, err)
+		return nil, err
+	}
+	responseBytes, err := io.ReadAll(resp.Body)
+	if err != nil {
+		log.Printf("Failed to parse response bytes from POST to %s: %v", url, err)
+		return nil, err
+	}
+	if resp.StatusCode != http.StatusOK {
+		return nil, parseErrorResponse(url, resp.StatusCode, responseBytes)
+	}
+	return responseBytes, nil
+}
+
+// Sends a `SubmitDataScouting` message to the server and returns the
+// deserialized response.
+func SubmitDataScouting(server string, requestBytes []byte) (*SubmitDataScoutingResponseT, error) {
+	responseBytes, err := performPost(server+"/requests/submit/data_scouting", requestBytes)
+	if err != nil {
+		return nil, err
+	}
+	log.Printf("Parsing SubmitDataScoutingResponse")
+	response := submit_data_scouting_response.GetRootAsSubmitDataScoutingResponse(responseBytes, 0)
+	return response.UnPack(), nil
+}
diff --git a/scouting/webserver/requests/messages/BUILD b/scouting/webserver/requests/messages/BUILD
index a32ca57..1567935 100644
--- a/scouting/webserver/requests/messages/BUILD
+++ b/scouting/webserver/requests/messages/BUILD
@@ -1,5 +1,17 @@
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_go_library", "flatbuffer_ts_library")
 
+FILE_NAMES = (
+    "error_response",
+    "submit_data_scouting",
+    "submit_data_scouting_response",
+)
+
+filegroup(
+    name = "fbs_files",
+    srcs = ["%s.fbs" % name for name in FILE_NAMES],
+    visibility = ["//visibility:public"],
+)
+
 [(
     flatbuffer_go_library(
         name = name + "_go_fbs",
@@ -14,8 +26,4 @@
         target_compatible_with = ["@platforms//cpu:x86_64"],
         visibility = ["//visibility:public"],
     ),
-) for name in (
-    "error_response",
-    "submit_data_scouting",
-    "submit_data_scouting_response",
-)]
+) for name in FILE_NAMES]
diff --git a/scouting/webserver/requests/requests_test.go b/scouting/webserver/requests/requests_test.go
index 6f62ce3..4a19eaf 100644
--- a/scouting/webserver/requests/requests_test.go
+++ b/scouting/webserver/requests/requests_test.go
@@ -82,6 +82,7 @@
 		t.Fatal("Unexpected status code. Got", resp.Status)
 	}
 	// TODO(phil): We have nothing to validate yet. Fix that.
+	// TODO(phil): Can we use scouting/webserver/requests/debug here?
 }
 
 // A mocked database we can use for testing. Add functionality to this as