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