Skip to content

Commit

Permalink
add combined http and cli lesson type (#72)
Browse files Browse the repository at this point in the history
* add combined http and cli lesson type

* version 1.15.0
  • Loading branch information
skovranek authored Jan 6, 2025
1 parent 5f57ee6 commit e8f2be2
Show file tree
Hide file tree
Showing 8 changed files with 798 additions and 117 deletions.
145 changes: 145 additions & 0 deletions checks/checks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package checks

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"

api "github.com/bootdotdev/bootdev/client"
"github.com/spf13/cobra"
)

func runCLICommand(command api.CLIStepCLICommand, variables map[string]string) (result api.CLICommandResult) {
finalCommand := InterpolateVariables(command.Command, variables)
result.FinalCommand = finalCommand

cmd := exec.Command("sh", "-c", finalCommand)
cmd.Env = append(os.Environ(), "LANG=en_US.UTF-8")
b, err := cmd.CombinedOutput()
if ee, ok := err.(*exec.ExitError); ok {
result.ExitCode = ee.ExitCode()
} else if err != nil {
result.ExitCode = -2
}
result.Stdout = strings.TrimRight(string(b), " \n\t\r")
result.Variables = variables
return result
}

func runHTTPRequest(
client *http.Client,
baseURL string,
variables map[string]string,
requestStep api.CLIStepHTTPRequest,
) (
result api.HTTPRequestResult,
) {
if baseURL == "" && requestStep.Request.FullURL == "" {
cobra.CheckErr("no base URL or full URL provided")
}

finalBaseURL := strings.TrimSuffix(baseURL, "/")
interpolatedPath := InterpolateVariables(requestStep.Request.Path, variables)
completeURL := fmt.Sprintf("%s%s", finalBaseURL, interpolatedPath)
if requestStep.Request.FullURL != "" {
completeURL = InterpolateVariables(requestStep.Request.FullURL, variables)
}

var req *http.Request
if requestStep.Request.BodyJSON != nil {
dat, err := json.Marshal(requestStep.Request.BodyJSON)
cobra.CheckErr(err)
interpolatedBodyJSONStr := InterpolateVariables(string(dat), variables)
req, err = http.NewRequest(requestStep.Request.Method, completeURL,
bytes.NewBuffer([]byte(interpolatedBodyJSONStr)),
)
if err != nil {
cobra.CheckErr("Failed to create request")
}
req.Header.Add("Content-Type", "application/json")
} else {
var err error
req, err = http.NewRequest(requestStep.Request.Method, completeURL, nil)
if err != nil {
cobra.CheckErr("Failed to create request")
}
}

for k, v := range requestStep.Request.Headers {
req.Header.Add(k, InterpolateVariables(v, variables))
}

if requestStep.Request.BasicAuth != nil {
req.SetBasicAuth(requestStep.Request.BasicAuth.Username, requestStep.Request.BasicAuth.Password)
}

if requestStep.Request.Actions.DelayRequestByMs != nil {
time.Sleep(time.Duration(*requestStep.Request.Actions.DelayRequestByMs) * time.Millisecond)
}

resp, err := client.Do(req)
if err != nil {
result = api.HTTPRequestResult{Err: "Failed to fetch"}
return result
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
result = api.HTTPRequestResult{Err: "Failed to read response body"}
return result
}

headers := make(map[string]string)
for k, v := range resp.Header {
headers[k] = strings.Join(v, ",")
}

parseVariables(body, requestStep.ResponseVariables, variables)

result = api.HTTPRequestResult{
StatusCode: resp.StatusCode,
ResponseHeaders: headers,
BodyString: truncateAndStringifyBody(body),
Variables: variables,
Request: requestStep,
}
return result
}

func CLIChecks(cliData api.CLIData, submitBaseURL *string) (results []api.CLIStepResult) {
client := &http.Client{}
variables := make(map[string]string)
results = make([]api.CLIStepResult, len(cliData.Steps))

// use cli arg url if specified or default lesson data url
baseURL := ""
if submitBaseURL != nil && *submitBaseURL != "" {
baseURL = *submitBaseURL
} else if cliData.BaseURL != nil && *cliData.BaseURL != "" {
baseURL = *cliData.BaseURL
}

for i, step := range cliData.Steps {
switch {
case step.CLICommand != nil:
result := runCLICommand(*step.CLICommand, variables)
results[i].CLICommandResult = &result
case step.HTTPRequest != nil:
result := runHTTPRequest(client, baseURL, variables, *step.HTTPRequest)
results[i].HTTPRequestResult = &result
if result.Variables != nil {
variables = result.Variables
}
default:
cobra.CheckErr("unable to run lesson: missing step")
}
}
return results
}
2 changes: 1 addition & 1 deletion client/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func LoginWithCode(code string) (*LoginResponse, error) {
}

if resp.StatusCode == 403 {
return nil, errors.New("The code you entered was invalid. Try refreshing your browser and trying again.")
return nil, errors.New("invalid login code, please refresh your browser then try again")
}

if resp.StatusCode != 200 {
Expand Down
86 changes: 86 additions & 0 deletions client/lessons.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,50 @@ type LessonDataCLICommand struct {
}
}

type LessonDataCLI struct {
// Readme string
CLIData CLIData
}

type CLIData struct {
// ContainsCompleteDir bool
BaseURL *string
Steps []struct {
CLICommand *CLIStepCLICommand
HTTPRequest *CLIStepHTTPRequest
}
}

type CLIStepCLICommand struct {
Command string
Tests []CLICommandTestCase
}

type CLIStepHTTPRequest struct {
ResponseVariables []ResponseVariable
Tests []HTTPTest
Request struct {
Method string
Path string
FullURL string // overrides BaseURL and Path if set
Headers map[string]string
BodyJSON map[string]interface{}
BasicAuth *struct {
Username string
Password string
}
Actions struct {
DelayRequestByMs *int32
}
}
}

type Lesson struct {
Lesson struct {
Type string
LessonDataHTTPTests *LessonDataHTTPTests
LessonDataCLICommand *LessonDataCLICommand
LessonDataCLI *LessonDataCLI
}
}

Expand Down Expand Up @@ -150,6 +189,7 @@ type CLICommandResult struct {
ExitCode int
FinalCommand string `json:"-"`
Stdout string
Variables map[string]string
}

func SubmitCLICommandLesson(uuid string, results []CLICommandResult) (*StructuredErrCLICommand, error) {
Expand All @@ -172,3 +212,49 @@ func SubmitCLICommandLesson(uuid string, results []CLICommandResult) (*Structure
}
return &failure, nil
}

type HTTPRequestResult struct {
Err string `json:"-"`
StatusCode int
ResponseHeaders map[string]string
BodyString string
Variables map[string]string
Request CLIStepHTTPRequest
}

type CLIStepResult struct {
CLICommandResult *CLICommandResult
HTTPRequestResult *HTTPRequestResult
}

type lessonSubmissionCLI struct {
CLIResults []CLIStepResult
}

type StructuredErrCLI struct {
ErrorMessage string `json:"Error"`
FailedStepIndex int `json:"FailedStepIndex"`
FailedTestIndex int `json:"FailedTestIndex"`
}

func SubmitCLILesson(uuid string, results []CLIStepResult) (*StructuredErrCLI, error) {
bytes, err := json.Marshal(lessonSubmissionCLI{CLIResults: results})
if err != nil {
return nil, err
}
endpoint := fmt.Sprintf("/v1/lessons/%v/", uuid)
resp, code, err := fetchWithAuthAndPayload("POST", endpoint, bytes)
if err != nil {
return nil, err
}
if code != 200 {
return nil, fmt.Errorf("failed to submit CLI lesson (code: %v): %s", code, string(resp))
}
var failure StructuredErrCLI
err = json.Unmarshal(resp, &failure)
if err != nil || failure.ErrorMessage == "" {
// this is ok - it means we had success
return nil, nil
}
return &failure, nil
}
18 changes: 15 additions & 3 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ func submissionHandler(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
switch lesson.Lesson.Type {
case "type_http_tests":
switch {
case lesson.Lesson.Type == "type_http_tests" && lesson.Lesson.LessonDataHTTPTests != nil:
results, _ := checks.HttpTest(*lesson, &submitBaseURL)
data := *lesson.Lesson.LessonDataHTTPTests
if isSubmit {
Expand All @@ -48,7 +48,7 @@ func submissionHandler(cmd *cobra.Command, args []string) error {
} else {
render.HTTPRun(data, results)
}
case "type_cli_command":
case lesson.Lesson.Type == "type_cli_command" && lesson.Lesson.LessonDataCLICommand != nil:
results := checks.CLICommand(*lesson)
data := *lesson.Lesson.LessonDataCLICommand
if isSubmit {
Expand All @@ -60,6 +60,18 @@ func submissionHandler(cmd *cobra.Command, args []string) error {
} else {
render.CommandRun(data, results)
}
case lesson.Lesson.Type == "type_cli" && lesson.Lesson.LessonDataCLI != nil:
data := lesson.Lesson.LessonDataCLI.CLIData
results := checks.CLIChecks(data, &submitBaseURL)
if isSubmit {
failure, err := api.SubmitCLILesson(lessonUUID, results)
if err != nil {
return err
}
render.RenderSubmission(data, results, failure)
} else {
render.RenderRun(data, results)
}
default:
return errors.New("unsupported lesson type")
}
Expand Down
52 changes: 24 additions & 28 deletions render/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,34 +137,6 @@ func (m cmdRootModel) View() string {
return str
}

func prettyPrintCmd(test api.CLICommandTestCase) string {
if test.ExitCode != nil {
return fmt.Sprintf("Expect exit code %d", *test.ExitCode)
}
if test.StdoutLinesGt != nil {
return fmt.Sprintf("Expect > %d lines on stdout", *test.StdoutLinesGt)
}
if test.StdoutContainsAll != nil {
str := "Expect stdout to contain all of:"
for _, thing := range test.StdoutContainsAll {
str += fmt.Sprintf("\n - '%s'", thing)
}
return str
}
if test.StdoutContainsNone != nil {
str := "Expect stdout to contain none of:"
for _, thing := range test.StdoutContainsNone {
str += fmt.Sprintf("\n - '%s'", thing)
}
return str
}
return ""
}

func pointerToBool(a bool) *bool {
return &a
}

func CommandRun(
data api.LessonDataCLICommand,
results []api.CLICommandResult,
Expand Down Expand Up @@ -260,3 +232,27 @@ func commandRenderer(
}()
wg.Wait()
}

func prettyPrintCmd(test api.CLICommandTestCase) string {
if test.ExitCode != nil {
return fmt.Sprintf("Expect exit code %d", *test.ExitCode)
}
if test.StdoutLinesGt != nil {
return fmt.Sprintf("Expect > %d lines on stdout", *test.StdoutLinesGt)
}
if test.StdoutContainsAll != nil {
str := "Expect stdout to contain all of:"
for _, thing := range test.StdoutContainsAll {
str += fmt.Sprintf("\n - '%s'", thing)
}
return str
}
if test.StdoutContainsNone != nil {
str := "Expect stdout to contain none of:"
for _, thing := range test.StdoutContainsNone {
str += fmt.Sprintf("\n - '%s'", thing)
}
return str
}
return ""
}
Loading

0 comments on commit e8f2be2

Please sign in to comment.