feat: initial public version
This commit is contained in:
49
server/io.go
Normal file
49
server/io.go
Normal 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
85
server/io_test.go
Normal 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
19
server/middleware.go
Normal 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
45
server/middleware_test.go
Normal 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
79
server/router.go
Normal 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
156
server/router_test.go
Normal 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
59
server/server.go
Normal 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
31
server/server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user