Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 81 additions & 31 deletions cli/runner/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,72 +3,90 @@ package client
import (
"encoding/json"
"fmt"
"github.com/runner-x/runner-x/server/api/v1"
"io"
"net/http"
"os"
"strings"
"time"

coderunner "github.com/runner-x/runner-x/engine/coderunner/v1"
v2 "github.com/runner-x/runner-x/server/api/v2"
)

// TODO: fill in this client package as needed and create, use Client as needed in CLI commands

const (
DEFAULT_URL = "http://localhost:10100"
// DEFAULT_URL = "http://localhost:10100"
DEFAULT_URL = "https://runner.fly.dev"

// Notably, the server still serves under these endpoints
// Despite the migration to v2 on the backend.
LANG_ENDPOINT = "/api/v1/languages"
RUN_ENDPOINT = "/api/v1/run"

TIMEOUT_DEFAULT = time.Second * 5
)

type Requester interface {
Run(r *v1.RunRequest) (*v1.RunResponse, error)
Languages() (*v1.LanguagesResponse, error)
Run(r *v2.RunRequest) (*v2.RunResponse, error)
Languages() (*v2.LanguagesResponse, error)
}

type Client struct {
BaseUrl string // the URL to use for the runner server (i.e. localhost)
HttpClient http.Client // http client to use for GET, POST requests
}

// abstracts the inner requester to allow us to generate mocks
type CliClient struct {
client Requester
}

type Config struct {
BaseUrl string
// add any other configurable values we may want here
Timeout int
}

func NewClient() *Client {
// TODO: implement client with defaults like localhost url (nice to have)
var c Client
c.BaseUrl = DEFAULT_URL
c.HttpClient = http.Client{
Timeout: time.Second * coderunner.TIMEOUT_DEFAULT,
func NewClient() *CliClient {
var client Client
client.BaseUrl = DEFAULT_URL
client.HttpClient = http.Client{
Timeout: TIMEOUT_DEFAULT,
}

return &c
return &CliClient{
client,
}
}

func NewClientFromConfig(c Config) *Client {
// TODO: create client from config
func NewClientFromConfig(c Config) *CliClient {
var client Client
client.BaseUrl = c.BaseUrl
client.HttpClient = http.Client{
Timeout: time.Second * time.Duration(c.Timeout),
}

return &client
return &CliClient{
client,
}
}

func (c *Client) Run(r *v1.RunRequest) (*v1.RunResponse, error) {
// TODO: implement/refactor
func NewClientWithRequester(r Requester) *CliClient {
return &CliClient{
client: r,
}
}

func (c Client) Run(r *v2.RunRequest) (*v2.RunResponse, error) {
source := r.Source

reqBody := v1.RunRequest{
reqBody := v2.RunRequest{
Source: source,
Lang: r.Lang,
}

jsonBody, err := json.Marshal(reqBody)
PanicCheck(err)
if err != nil {
return nil, err
}

body := strings.NewReader(string(jsonBody))

Expand All @@ -91,15 +109,16 @@ func (c *Client) Run(r *v1.RunRequest) (*v1.RunResponse, error) {
return nil, err
}

var ret v1.RunResponse
decodeErr := json.Unmarshal(respReader, &ret)
PanicCheck(decodeErr)
var ret v2.RunResponse
err = json.Unmarshal(respReader, &ret)
if err != nil {
return nil, err
}

return &ret, nil
}

func (c *Client) Languages() (*v1.LanguagesResponse, error) {
// TODO: implement/refactor
func (c Client) Languages() (*v2.LanguagesResponse, error) {
req, err := http.NewRequest("GET", c.BaseUrl+LANG_ENDPOINT, nil)
if err != nil {
return nil, err
Expand All @@ -118,15 +137,46 @@ func (c *Client) Languages() (*v1.LanguagesResponse, error) {
return nil, err
}

var jsonLangs v1.LanguagesResponse
decodeErr := json.Unmarshal(body, &jsonLangs)
PanicCheck(decodeErr)
var jsonLangs v2.LanguagesResponse
err = json.Unmarshal(body, &jsonLangs)
if err != nil {
return nil, err
}

return &jsonLangs, nil
}

func PanicCheck(err error) {
func (cli *CliClient) LanguageRequest() (*v2.LanguagesResponse, error) {
return cli.client.Languages()
}

func (cli *CliClient) RunRequest(language string, filename string) (*v2.RunResponse, error) {
// check if the language is supported
langs, err := cli.client.Languages()
if err != nil {
return nil, err
}

validLanguage := false
for _, lang := range langs.Languages {
if lang == language {
validLanguage = true
break
}
}
if !validLanguage {
return nil, fmt.Errorf("invalid language: %s", language)
}

source, err := os.ReadFile(filename)
if err != nil {
panic(err)
return nil, fmt.Errorf("file not found: %s", filename)
}

req := &v2.RunRequest{
Source: string(source),
Lang: language,
}

return cli.client.Run(req)
}
190 changes: 190 additions & 0 deletions cli/runner/client/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package client_test

import (
"net/http"
"net/http/httptest"
"os"
"testing"

gomock "github.com/golang/mock/gomock"
"github.com/runner-x/runner-x/cli/runner/client"
mock_client "github.com/runner-x/runner-x/cli/runner/client/mocks"
v2 "github.com/runner-x/runner-x/server/api/v2"
)

func TestLanguageRequest(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

expected := []string{
"python3",
"nodejs",
"c++",
"go",
"bash",
"rust",
}
mockLanguageAgent := mock_client.NewMockRequester(ctrl)

langResponse := v2.LanguagesResponse{
Languages: expected,
}

mockLanguageAgent.EXPECT().Languages().Return(&langResponse, nil)
agent := client.NewClientWithRequester(mockLanguageAgent)

resp, err := agent.LanguageRequest()

if err != nil {
t.Errorf("expected no error, got %v", err)
}

for i, lang := range resp.Languages {
if expected[i] != lang {
t.Errorf("language request response mismatch: wanted %v, got %v", expected, resp.Languages)
return
}
}
}

func TestLanguageHttpRequest(t *testing.T) {
testResponseServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
res.WriteHeader(200)
res.Write([]byte("{\"languages\":[\"python3\",\"nodejs\",\"c++\",\"go\",\"bash\",\"rust\"]}"))
}))
defer testResponseServer.Close()

expected := []string{
"python3",
"nodejs",
"c++",
"go",
"bash",
"rust",
}

client := client.NewClientFromConfig(client.Config{
BaseUrl: testResponseServer.URL,
})

resp, err := client.LanguageRequest()

if err != nil {
t.Errorf("expected no error, got %v", err)
}

for i, lang := range resp.Languages {
if expected[i] != lang {
t.Errorf("language request response mismatch: wanted %v, got %v", expected, resp.Languages)
return
}
}
}

func TestHTTPRequestError(t *testing.T) {
client := client.NewClientFromConfig(client.Config{
BaseUrl: "malformed-url",
})
langResp, err := client.LanguageRequest()

if err == nil {
t.Errorf("expected error, got valid language response: %v", langResp)
}

runResp, err := client.RunRequest("c++", "test-files/test.cpp")
if err == nil {
t.Errorf("expected error, got valid run response: %v", runResp)
}
}

func TestBadStatusCode(t *testing.T) {
statusCode := 500
testResponseServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
res.WriteHeader(statusCode)
}))
defer testResponseServer.Close()

client := client.NewClientFromConfig(client.Config{
BaseUrl: testResponseServer.URL,
})

langResp, err := client.LanguageRequest()
if err == nil {
t.Errorf("expected error, got non-nil language response %v", langResp)
}

runResp, err := client.RunRequest("c++", "test-files/test.cpp")
if err == nil {
t.Errorf("expected error, got non-nil run response %v", runResp)
}
}

func TestRunRequest(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockAgent := mock_client.NewMockRequester(ctrl)
contents, err := os.ReadFile("test-files/test.cpp")
if err != nil {
t.Fatalf("unable to read test file: %v", err)
return
}

mockRequest := v2.RunRequest{
Source: string(contents),
Lang: "c++",
}
mockLangs := v2.LanguagesResponse{
Languages: []string{"c++"},
}
mockResponse := v2.RunResponse{
Stdout: "Hello, World!",
Stderr: "",
Error: "",
}

mockAgent.EXPECT().Run(&mockRequest).Return(&mockResponse, nil)
mockAgent.EXPECT().Languages().Return(&mockLangs, nil)
agent := client.NewClientWithRequester(mockAgent)

resp, err := agent.RunRequest("c++", "test-files/test.cpp")

if err != nil {
t.Errorf("expected no error, got %v", err)
}

if *resp != mockResponse {
t.Errorf("response mismatch, wanted %v, got %v", mockResponse, *resp)
}
}

func TestRunInvalidLanguage(t *testing.T) {
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
res.WriteHeader(200)
res.Write([]byte("{\"languages\": [\"invalid-language\"]}"))
}))
defer testServer.Close()

agent := client.NewClientFromConfig(client.Config{
BaseUrl: testServer.URL,
})

resp, err := agent.RunRequest("c++", "test-files/test.cpp")
if err == nil {
t.Errorf("wanted err, got non-nil response %v", resp)
}
}

func TestRunInvalidSource(t *testing.T) {
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
res.WriteHeader(200)
res.Write([]byte("{\"languages\": [\"c++\"]}"))
}))
defer testServer.Close()
agent := client.NewClient()
resp, err := agent.RunRequest("c++", "nonexistent-file")

if err == nil {
t.Errorf("wanted err, got non-nil response %v", resp)
}
}
Loading
Loading