feat: initial public version

This commit is contained in:
Anton
2023-11-25 04:02:45 +05:00
parent ebd1d47fc6
commit 8447197d0f
64 changed files with 3880 additions and 4 deletions

49
server/io.go Normal file
View File

@@ -0,0 +1,49 @@
// Reading requests and writing responses.
package server
import (
"encoding/json"
"errors"
"io"
"net/http"
)
// readJson decodes the request body from JSON.
func readJson[T any](r *http.Request) (T, error) {
var obj T
if r.Header.Get("content-type") != "application/json" {
return obj, errors.New(http.StatusText(http.StatusUnsupportedMediaType))
}
data, err := io.ReadAll(r.Body)
if err != nil {
return obj, err
}
err = json.Unmarshal(data, &obj)
if err != nil {
return obj, err
}
return obj, err
}
// writeJson encodes an object into JSON and writes it to the response.
func writeJson(w http.ResponseWriter, obj any) error {
data, err := json.Marshal(obj)
if err != nil {
return err
}
w.Header().Set("content-type", "application/json")
_, err = w.Write(data)
if err != nil {
return err
}
return nil
}
// writeError encodes an error object into JSON and writes it to the response.
func writeError(w http.ResponseWriter, code int, obj any) {
data, _ := json.Marshal(obj)
w.Header().Set("content-type", "application/json")
w.WriteHeader(code)
w.Write(data) //nolint:errcheck
}

85
server/io_test.go Normal file
View File

@@ -0,0 +1,85 @@
package server
import (
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"github.com/nalgeon/codapi/engine"
)
func Test_readJson(t *testing.T) {
t.Run("success", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/example",
strings.NewReader(`{"sandbox": "python", "command": "run"}`))
req.Header.Set("Content-Type", "application/json")
got, err := readJson[engine.Request](req)
if err != nil {
t.Errorf("expected nil err, got %v", err)
}
want := engine.Request{
Sandbox: "python", Command: "run",
}
if !reflect.DeepEqual(got, want) {
t.Errorf("expected %v, got %v", want, got)
}
})
t.Run("unsupported media type", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/example", nil)
req.Header.Set("Content-Type", "text/plain")
_, err := readJson[engine.Request](req)
if err == nil || err.Error() != "Unsupported Media Type" {
t.Errorf("unexpected error %v", err)
}
})
t.Run("error", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/example", strings.NewReader("hello world"))
req.Header.Set("Content-Type", "application/json")
_, err := readJson[engine.Request](req)
if err == nil {
t.Error("expected unmarshaling error")
}
})
}
func Test_writeJson(t *testing.T) {
w := httptest.NewRecorder()
obj := engine.Request{
ID: "42", Sandbox: "python", Command: "run",
}
err := writeJson(w, obj)
if err != nil {
t.Errorf("expected nil err, got %v", err)
}
body := w.Body.String()
contentType := w.Header().Get("content-type")
if contentType != "application/json" {
t.Errorf("unexpected content-type header %s", contentType)
}
want := `{"id":"42","sandbox":"python","command":"run","files":null}`
if body != want {
t.Errorf("expected %s, got %s", body, want)
}
}
func Test_writeError(t *testing.T) {
w := httptest.NewRecorder()
obj := time.Date(2020, 10, 15, 0, 0, 0, 0, time.UTC)
writeError(w, http.StatusForbidden, obj)
if w.Code != http.StatusForbidden {
t.Errorf("expected status code %d, got %d", http.StatusForbidden, w.Code)
}
if w.Body.String() != `"2020-10-15T00:00:00Z"` {
t.Errorf("unexpected body %s", w.Body.String())
}
}

19
server/middleware.go Normal file
View File

@@ -0,0 +1,19 @@
// HTTP middlewares.
package server
import "net/http"
// enableCORS allows cross-site requests for a given handler.
func enableCORS(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("access-control-allow-origin", "*")
w.Header().Set("access-control-allow-method", "post")
w.Header().Set("access-control-allow-headers", "authorization, content-type")
w.Header().Set("access-control-max-age", "3600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
handler(w, r)
}
}

45
server/middleware_test.go Normal file
View File

@@ -0,0 +1,45 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
)
func Test_enableCORS(t *testing.T) {
t.Run("options", func(t *testing.T) {
w := httptest.NewRecorder()
r, _ := http.NewRequest("OPTIONS", "/v1/exec", nil)
handler := func(w http.ResponseWriter, r *http.Request) {}
fn := enableCORS(handler)
fn(w, r)
if w.Header().Get("access-control-allow-origin") != "*" {
t.Errorf("invalid access-control-allow-origin")
}
if w.Code != 200 {
t.Errorf("expected status code 200, got %d", w.Code)
}
})
t.Run("post", func(t *testing.T) {
w := httptest.NewRecorder()
r, _ := http.NewRequest("POST", "/v1/exec", nil)
handler := func(w http.ResponseWriter, r *http.Request) {}
fn := enableCORS(handler)
fn(w, r)
if w.Header().Get("access-control-allow-origin") != "*" {
t.Errorf("invalid access-control-allow-origin")
}
if w.Header().Get("access-control-allow-method") != "post" {
t.Errorf("invalid access-control-allow-method")
}
if w.Header().Get("access-control-allow-headers") != "authorization, content-type" {
t.Errorf("invalid access-control-allow-headers")
}
if w.Header().Get("access-control-max-age") != "3600" {
t.Errorf("access-control-max-age")
}
})
}

79
server/router.go Normal file
View File

@@ -0,0 +1,79 @@
// HTTP routes and handlers.
package server
import (
"errors"
"fmt"
"net/http"
"github.com/nalgeon/codapi/engine"
"github.com/nalgeon/codapi/logx"
"github.com/nalgeon/codapi/sandbox"
"github.com/nalgeon/codapi/stringx"
)
// NewRouter creates HTTP routes and handlers for them.
func NewRouter() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/v1/exec", enableCORS(exec))
return mux
}
// exec runs a sandbox command on the supplied code.
func exec(w http.ResponseWriter, r *http.Request) {
// only POST is allowed
if r.Method != http.MethodPost {
err := fmt.Errorf("unsupported method: %s", r.Method)
writeError(w, http.StatusMethodNotAllowed, engine.Fail("-", err))
return
}
// read the input data - language, command, code
in, err := readJson[engine.Request](r)
if err != nil {
writeError(w, http.StatusBadRequest, engine.Fail("-", err))
return
}
in.GenerateID()
// validate the input data
err = sandbox.Validate(in)
if errors.Is(err, sandbox.ErrUnknownSandbox) || errors.Is(err, sandbox.ErrUnknownCommand) {
writeError(w, http.StatusNotFound, engine.Fail(in.ID, err))
return
}
if err != nil {
writeError(w, http.StatusBadRequest, engine.Fail(in.ID, err))
return
}
// execute the code using the sandbox
out := sandbox.Exec(in)
// fail on application error
if out.Err != nil {
logx.Log("✗ %s: %s", out.ID, out.Err)
if errors.Is(out.Err, engine.ErrBusy) {
writeError(w, http.StatusTooManyRequests, out)
} else {
writeError(w, http.StatusInternalServerError, out)
}
return
}
// log results
if out.OK {
logx.Log("✓ %s: took %d ms", out.ID, out.Duration)
} else {
msg := stringx.Compact(stringx.Shorten(out.Stderr, 80))
logx.Log("✗ %s: %s", out.ID, msg)
}
// write the response
err = writeJson(w, out)
if err != nil {
err = engine.NewExecutionError("write response", err)
writeError(w, http.StatusInternalServerError, engine.Fail(in.ID, err))
return
}
}

156
server/router_test.go Normal file
View File

@@ -0,0 +1,156 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/nalgeon/codapi/config"
"github.com/nalgeon/codapi/engine"
"github.com/nalgeon/codapi/execy"
"github.com/nalgeon/codapi/sandbox"
)
var cfg = &config.Config{
PoolSize: 8,
Boxes: map[string]*config.Box{
"python": {},
},
Commands: map[string]config.SandboxCommands{
"python": map[string]*config.Command{
"run": {
Engine: "docker",
Entry: "main.py",
Steps: []*config.Step{
{Box: "python", Action: "run", NOutput: 4096},
},
},
"test": {Engine: "docker"},
},
},
}
type server struct {
srv *httptest.Server
cli *http.Client
}
func newServer() *server {
router := NewRouter()
srv := httptest.NewServer(router)
return &server{srv, srv.Client()}
}
func (s *server) post(uri string, val any) (*http.Response, error) {
body, _ := json.Marshal(val)
req, _ := http.NewRequest("POST", s.srv.URL+uri, bytes.NewReader(body))
req.Header.Set("content-type", "application/json")
return s.cli.Do(req)
}
func (s *server) close() {
s.srv.Close()
}
func Test_exec(t *testing.T) {
_ = sandbox.ApplyConfig(cfg)
execy.Mock(map[string]execy.CmdOut{
"docker run": {Stdout: "hello"},
})
srv := newServer()
defer srv.close()
t.Run("success", func(t *testing.T) {
in := engine.Request{
Sandbox: "python",
Command: "run",
Files: map[string]string{
"": "print('hello')",
},
}
resp, err := srv.post("/v1/exec", in)
if err != nil {
t.Fatalf("POST /exec: expected nil err, got %v", err)
}
out := decodeResp[engine.Execution](t, resp)
if !out.OK {
t.Error("OK: expected true")
}
if out.Stdout != "hello" {
t.Errorf("Stdout: expected hello, got %s", out.Stdout)
}
if out.Stderr != "" {
t.Errorf("Stderr: expected empty string, got %s", out.Stderr)
}
if out.Err != nil {
t.Errorf("Err: expected nil, got %v", out.Err)
}
})
t.Run("error not found", func(t *testing.T) {
in := engine.Request{
Sandbox: "rust",
Command: "run",
Files: nil,
}
resp, err := srv.post("/v1/exec", in)
if err != nil {
t.Fatalf("POST /exec: expected nil err, got %v", err)
}
if resp.StatusCode != http.StatusNotFound {
t.Errorf("StatusCode: expected 404, got %v", resp.StatusCode)
}
out := decodeResp[engine.Execution](t, resp)
if out.OK {
t.Error("OK: expected false")
}
if out.Stdout != "" {
t.Errorf("Stdout: expected empty string, got %s", out.Stdout)
}
if out.Stderr != "unknown sandbox" {
t.Errorf("Stderr: expected error, got %s", out.Stderr)
}
if out.Err != nil {
t.Errorf("Err: expected nil, got %v", out.Err)
}
})
t.Run("error bad request", func(t *testing.T) {
in := engine.Request{
Sandbox: "python",
Command: "run",
Files: nil,
}
resp, err := srv.post("/v1/exec", in)
if err != nil {
t.Fatalf("POST /exec: expected nil err, got %v", err)
}
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("StatusCode: expected 400, got %v", resp.StatusCode)
}
out := decodeResp[engine.Execution](t, resp)
if out.OK {
t.Error("OK: expected false")
}
if out.Stdout != "" {
t.Errorf("Stdout: expected empty string, got %s", out.Stdout)
}
if out.Stderr != "empty request" {
t.Errorf("Stderr: expected error, got %s", out.Stderr)
}
if out.Err != nil {
t.Errorf("Err: expected nil, got %v", out.Err)
}
})
}
func decodeResp[T any](t *testing.T, resp *http.Response) T {
defer resp.Body.Close()
var val T
err := json.NewDecoder(resp.Body).Decode(&val)
if err != nil {
t.Fatal(err)
}
return val
}

59
server/server.go Normal file
View File

@@ -0,0 +1,59 @@
// Package server provides an HTTP API for running code in a sandbox.
package server
import (
"context"
"fmt"
"net/http"
"sync"
"time"
"github.com/nalgeon/codapi/logx"
)
// The maximum duration of the server graceful shutdown.
const ShutdownTimeout = 3 * time.Second
// A Server is an HTTP sandbox server.
type Server struct {
srv *http.Server
wg *sync.WaitGroup
}
// NewServer creates a new Server.
func NewServer(port int, handler http.Handler) *Server {
addr := fmt.Sprintf(":%d", port)
return &Server{
srv: &http.Server{Addr: addr, Handler: handler},
wg: &sync.WaitGroup{},
}
}
// Start starts the server.
func (s *Server) Start() {
// run the server inside a goroutine so that
// it does not block the main goroutine, and allow it
// to start other processes and listen for signals
s.wg.Add(1)
go func() {
defer s.wg.Done()
err := s.srv.ListenAndServe()
if err != http.ErrServerClosed {
logx.Log(err.Error())
}
}()
}
// Stop stops the server.
func (s *Server) Stop() error {
// perform a graceful shutdown, but not longer
// than the duration of ShutdownTimeout
ctx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout)
defer cancel()
err := s.srv.Shutdown(ctx)
if err != nil {
return err
}
s.wg.Wait()
return nil
}

31
server/server_test.go Normal file
View File

@@ -0,0 +1,31 @@
package server
import (
"net/http"
"testing"
)
func TestServer(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
srv := NewServer(8585, handler)
if srv.srv.Addr != ":8585" {
t.Fatalf("NewServer: expected port :8585 got %s", srv.srv.Addr)
}
srv.Start()
resp, err := http.Get("http://localhost:8585/get")
if err != nil {
t.Fatalf("GET: expected nil err, got %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET: expected status code 200, got %d", resp.StatusCode)
}
err = srv.Stop()
if err != nil {
t.Fatalf("Stop: expected nil err, got %v", err)
}
}