Handle webserver requests from the scouting web page

Right now we don't actually serve a whole lot. It's mostly the
infrastructure. I added a simple data scouting submission as an
example. We can build up from there.

Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
Change-Id: I0572a214039cdb61e5bddf6f7256955a06147099
diff --git a/scouting/webserver/requests/BUILD b/scouting/webserver/requests/BUILD
new file mode 100644
index 0000000..456730e
--- /dev/null
+++ b/scouting/webserver/requests/BUILD
@@ -0,0 +1,30 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+    name = "requests",
+    srcs = ["requests.go"],
+    importpath = "github.com/frc971/971-Robot-Code/scouting/webserver/requests",
+    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_go_fbs",
+        "//scouting/webserver/requests/messages:submit_data_scouting_response_go_fbs",
+        "//scouting/webserver/server",
+        "@com_github_google_flatbuffers//go:go_default_library",
+    ],
+)
+
+go_test(
+    name = "requests_test",
+    srcs = ["requests_test.go"],
+    embed = [":requests"],
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    deps = [
+        "//scouting/webserver/requests/messages:error_response_go_fbs",
+        "//scouting/webserver/requests/messages:submit_data_scouting_go_fbs",
+        "//scouting/webserver/requests/messages:submit_data_scouting_response_go_fbs",
+        "//scouting/webserver/server",
+        "@com_github_google_flatbuffers//go:go_default_library",
+    ],
+)
diff --git a/scouting/webserver/requests/messages/BUILD b/scouting/webserver/requests/messages/BUILD
new file mode 100644
index 0000000..a32ca57
--- /dev/null
+++ b/scouting/webserver/requests/messages/BUILD
@@ -0,0 +1,21 @@
+load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_go_library", "flatbuffer_ts_library")
+
+[(
+    flatbuffer_go_library(
+        name = name + "_go_fbs",
+        srcs = [name + ".fbs"],
+        importpath = "github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/" + name,
+        target_compatible_with = ["@platforms//cpu:x86_64"],
+        visibility = ["//visibility:public"],
+    ),
+    flatbuffer_ts_library(
+        name = name + "_ts_fbs",
+        srcs = [name + ".fbs"],
+        target_compatible_with = ["@platforms//cpu:x86_64"],
+        visibility = ["//visibility:public"],
+    ),
+) for name in (
+    "error_response",
+    "submit_data_scouting",
+    "submit_data_scouting_response",
+)]
diff --git a/scouting/webserver/requests/messages/error_response.fbs b/scouting/webserver/requests/messages/error_response.fbs
new file mode 100644
index 0000000..4371cb5
--- /dev/null
+++ b/scouting/webserver/requests/messages/error_response.fbs
@@ -0,0 +1,7 @@
+namespace scouting.webserver.requests;
+
+table ErrorResponse {
+    error_message:string (id: 0);
+}
+
+root_type ErrorResponse;
diff --git a/scouting/webserver/requests/messages/submit_data_scouting.fbs b/scouting/webserver/requests/messages/submit_data_scouting.fbs
new file mode 100644
index 0000000..63bba7a
--- /dev/null
+++ b/scouting/webserver/requests/messages/submit_data_scouting.fbs
@@ -0,0 +1,11 @@
+namespace scouting.webserver.requests;
+
+table SubmitDataScouting {
+    team:int (id: 0);
+    match:int (id: 1);
+
+    upper_goal_hits:int (id: 2);
+    // TODO: Implement the rest of this.
+}
+
+root_type SubmitDataScouting;
diff --git a/scouting/webserver/requests/messages/submit_data_scouting_response.fbs b/scouting/webserver/requests/messages/submit_data_scouting_response.fbs
new file mode 100644
index 0000000..fb7f765
--- /dev/null
+++ b/scouting/webserver/requests/messages/submit_data_scouting_response.fbs
@@ -0,0 +1,7 @@
+namespace scouting.webserver.requests;
+
+table SubmitDataScoutingResponse {
+    // TODO: Implement this.
+}
+
+root_type SubmitDataScoutingResponse;
diff --git a/scouting/webserver/requests/requests.go b/scouting/webserver/requests/requests.go
new file mode 100644
index 0000000..4839675
--- /dev/null
+++ b/scouting/webserver/requests/requests.go
@@ -0,0 +1,67 @@
+package requests
+
+import (
+	"fmt"
+	"io"
+	"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"
+	_ "github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_data_scouting_response"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
+	flatbuffers "github.com/google/flatbuffers/go"
+)
+
+// Handles unknown requests. Just returns a 404.
+func unknown(w http.ResponseWriter, req *http.Request) {
+	w.WriteHeader(http.StatusNotFound)
+}
+
+func respondWithError(w http.ResponseWriter, statusCode int, errorMessage string) {
+	builder := flatbuffers.NewBuilder(1024)
+	builder.Finish((&error_response.ErrorResponseT{
+		ErrorMessage: errorMessage,
+	}).Pack(builder))
+	w.WriteHeader(statusCode)
+	w.Write(builder.FinishedBytes())
+}
+
+func respondNotImplemented(w http.ResponseWriter) {
+	respondWithError(w, http.StatusNotImplemented, "")
+}
+
+// TODO(phil): Can we turn this into a generic?
+func parseSubmitDataScouting(w http.ResponseWriter, buf []byte) (*submit_data_scouting.SubmitDataScouting, bool) {
+	success := true
+	defer func() {
+		if r := recover(); r != nil {
+			respondWithError(w, http.StatusBadRequest, fmt.Sprintf("Failed to parse SubmitDataScouting: %v", r))
+			success = false
+		}
+	}()
+	result := submit_data_scouting.GetRootAsSubmitDataScouting(buf, 0)
+	return result, success
+}
+
+// Handles a SubmitDataScouting request.
+func submitDataScouting(w http.ResponseWriter, req *http.Request) {
+	requestBytes, err := io.ReadAll(req.Body)
+	if err != nil {
+		respondWithError(w, http.StatusBadRequest, fmt.Sprint("Failed to read request bytes:", err))
+		return
+	}
+
+	_, success := parseSubmitDataScouting(w, requestBytes)
+	if !success {
+		return
+	}
+
+	// TODO(phil): Actually handle the request.
+
+	respondNotImplemented(w)
+}
+
+func HandleRequests(scoutingServer server.ScoutingServer) {
+	scoutingServer.HandleFunc("/requests", unknown)
+	scoutingServer.HandleFunc("/requests/submit/data_scouting", submitDataScouting)
+}
diff --git a/scouting/webserver/requests/requests_test.go b/scouting/webserver/requests/requests_test.go
new file mode 100644
index 0000000..11d728c
--- /dev/null
+++ b/scouting/webserver/requests/requests_test.go
@@ -0,0 +1,81 @@
+package requests
+
+import (
+	"bytes"
+	"io"
+	"net/http"
+	"testing"
+
+	"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"
+	_ "github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_data_scouting_response"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
+	flatbuffers "github.com/google/flatbuffers/go"
+)
+
+// Validates that an unhandled address results in a 404.
+func Test404(t *testing.T) {
+	scoutingServer := server.NewScoutingServer()
+	HandleRequests(scoutingServer)
+	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
+
+	resp, err := http.Get("http://localhost:8080/requests/foo")
+	if err != nil {
+		t.Fatalf("Failed to get data: %v", err)
+	}
+	if resp.StatusCode != http.StatusNotFound {
+		t.Fatalf("Expected error code 404, but got %d instead", resp.Status)
+	}
+}
+
+// Validates that we can submit new data scouting data.
+func TestSubmitDataScoutingError(t *testing.T) {
+	scoutingServer := server.NewScoutingServer()
+	HandleRequests(scoutingServer)
+	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
+
+	resp, err := http.Post("http://localhost:8080/requests/submit/data_scouting", "application/octet-stream", bytes.NewReader([]byte("")))
+	if err != nil {
+		t.Fatalf("Failed to send request: %v", err)
+	}
+	if resp.StatusCode != http.StatusBadRequest {
+		t.Fatal("Unexpected status code. Got", resp.Status)
+	}
+
+	responseBytes, err := io.ReadAll(resp.Body)
+	if err != nil {
+		t.Fatal("Failed to read response bytes:", err)
+	}
+	errorResponse := error_response.GetRootAsErrorResponse(responseBytes, 0)
+
+	errorMessage := string(errorResponse.ErrorMessage())
+	if errorMessage != "Failed to parse SubmitDataScouting: runtime error: index out of range [3] with length 0" {
+		t.Fatal("Got mismatched error message:", errorMessage)
+	}
+}
+
+// Validates that we can submit new data scouting data.
+func TestSubmitDataScouting(t *testing.T) {
+	scoutingServer := server.NewScoutingServer()
+	HandleRequests(scoutingServer)
+	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
+
+	builder := flatbuffers.NewBuilder(1024)
+	builder.Finish((&submit_data_scouting.SubmitDataScoutingT{
+		Team:          971,
+		Match:         1,
+		UpperGoalHits: 9971,
+	}).Pack(builder))
+
+	resp, err := http.Post("http://localhost:8080/requests/submit/data_scouting", "application/octet-stream", bytes.NewReader(builder.FinishedBytes()))
+	if err != nil {
+		t.Fatalf("Failed to send request: %v", err)
+	}
+	if resp.StatusCode != http.StatusNotImplemented {
+		t.Fatal("Unexpected status code. Got", resp.Status)
+	}
+	// TODO(phil): We have nothing to validate yet. Fix that.
+}